From b1532c9c1fafbc0a7246dbed3cd6c217f4cc40ed Mon Sep 17 00:00:00 2001 From: "G.Reijn" <26114636+Gijsreyn@users.noreply.github.com> Date: Fri, 16 Oct 2026 09:19:40 +0200 Subject: [PATCH 1/2] (GH-1755) Add two new DSC resources to manage AuditPolicy --- .../code-review/security.instructions.md | 2 +- .../code-review/windows.instructions.md | 2 +- CHANGELOG.md | 14 + Cargo.lock | 12 + Cargo.toml | 7 +- data.build.json | 2 + .../examples/auditpolicy.config.dsc.yaml | 20 + .../examples/configure-audit-policy.md | 256 +++++++++ .../AuditPolicy/examples/get-audit-policy.md | 113 ++++ .../Microsoft/Windows/AuditPolicy/index.md | 319 +++++++++++ .../auditpolicyoption.config.dsc.yaml | 13 + .../examples/configure-audit-policy-option.md | 152 +++++ .../Windows/AuditPolicyOption/index.md | 230 ++++++++ docs/reference/resources/overview.md | 4 + .../windows_audit_policy/.project.data.json | 14 + resources/windows_audit_policy/Cargo.toml | 19 + resources/windows_audit_policy/build.rs | 7 + .../windows_audit_policy/locales/en-us.toml | 34 ++ resources/windows_audit_policy/src/audit.rs | 538 ++++++++++++++++++ resources/windows_audit_policy/src/main.rs | 216 +++++++ resources/windows_audit_policy/src/options.rs | 178 ++++++ .../windows_audit_policy/src/subcategories.rs | 302 ++++++++++ resources/windows_audit_policy/src/types.rs | 251 ++++++++ .../windows_audit_policy_export.tests.ps1 | 92 +++ .../tests/windows_audit_policy_get.tests.ps1 | 121 ++++ .../windows_audit_policy_option.tests.ps1 | 151 +++++ .../tests/windows_audit_policy_set.tests.ps1 | 159 ++++++ .../windows_audit_policy_whatif.tests.ps1 | 139 +++++ .../windows_audit_policy.dsc.manifests.json | 216 +++++++ 29 files changed, 3580 insertions(+), 3 deletions(-) create mode 100644 docs/reference/resources/Microsoft/Windows/AuditPolicy/examples/auditpolicy.config.dsc.yaml create mode 100644 docs/reference/resources/Microsoft/Windows/AuditPolicy/examples/configure-audit-policy.md create mode 100644 docs/reference/resources/Microsoft/Windows/AuditPolicy/examples/get-audit-policy.md create mode 100644 docs/reference/resources/Microsoft/Windows/AuditPolicy/index.md create mode 100644 docs/reference/resources/Microsoft/Windows/AuditPolicyOption/examples/auditpolicyoption.config.dsc.yaml create mode 100644 docs/reference/resources/Microsoft/Windows/AuditPolicyOption/examples/configure-audit-policy-option.md create mode 100644 docs/reference/resources/Microsoft/Windows/AuditPolicyOption/index.md create mode 100644 resources/windows_audit_policy/.project.data.json create mode 100644 resources/windows_audit_policy/Cargo.toml create mode 100644 resources/windows_audit_policy/build.rs create mode 100644 resources/windows_audit_policy/locales/en-us.toml create mode 100644 resources/windows_audit_policy/src/audit.rs create mode 100644 resources/windows_audit_policy/src/main.rs create mode 100644 resources/windows_audit_policy/src/options.rs create mode 100644 resources/windows_audit_policy/src/subcategories.rs create mode 100644 resources/windows_audit_policy/src/types.rs create mode 100644 resources/windows_audit_policy/tests/windows_audit_policy_export.tests.ps1 create mode 100644 resources/windows_audit_policy/tests/windows_audit_policy_get.tests.ps1 create mode 100644 resources/windows_audit_policy/tests/windows_audit_policy_option.tests.ps1 create mode 100644 resources/windows_audit_policy/tests/windows_audit_policy_set.tests.ps1 create mode 100644 resources/windows_audit_policy/tests/windows_audit_policy_whatif.tests.ps1 create mode 100644 resources/windows_audit_policy/windows_audit_policy.dsc.manifests.json diff --git a/.github/skills/code-review/security.instructions.md b/.github/skills/code-review/security.instructions.md index 42de16d5c..43a5eadaf 100644 --- a/.github/skills/code-review/security.instructions.md +++ b/.github/skills/code-review/security.instructions.md @@ -1,5 +1,5 @@ --- -applyTo: 'lib/dsc-lib/src/util.rs,lib/dsc-lib-security_context/**,lib/dsc-lib-registry/**,resources/registry/**,resources/windows_firewall/**,resources/windows_service/**,resources/dism_dsc/**' +applyTo: 'lib/dsc-lib/src/util.rs,lib/dsc-lib-security_context/**,lib/dsc-lib-registry/**,resources/registry/**,resources/windows_firewall/**,resources/windows_service/**,resources/windows_audit_policy/**,resources/dism_dsc/**' description: 'Code review guidance for security-sensitive code (ACLs, policy, credentials, elevated resources)' --- diff --git a/.github/skills/code-review/windows.instructions.md b/.github/skills/code-review/windows.instructions.md index 2a6d5e545..c0883ec1d 100644 --- a/.github/skills/code-review/windows.instructions.md +++ b/.github/skills/code-review/windows.instructions.md @@ -1,5 +1,5 @@ --- -applyTo: 'resources/windows_firewall/**,resources/windows_service/**,resources/WindowsUpdate/**,resources/windows_personalization/**,resources/dism_dsc/**,resources/registry/**,lib/dsc-lib-registry/**,lib/dsc-lib-pal/**' +applyTo: 'resources/windows_firewall/**,resources/windows_service/**,resources/windows_audit_policy/**,resources/WindowsUpdate/**,resources/windows_personalization/**,resources/dism_dsc/**,resources/registry/**,lib/dsc-lib-registry/**,lib/dsc-lib-pal/**' description: 'Code review guidance for Windows-specific code (FFI, COM, registry, DISM, services)' --- diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ae6e6f41..46b4c68e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,19 @@ changes since the last release, see the [diff on GitHub][unreleased]. ### Added +- Added the `Microsoft.Windows/AuditPolicy` and `Microsoft.Windows/AuditPolicyOption` built-in + resources for managing the Windows advanced audit policy natively, as a replacement for the + `AuditPolicyDsc` PowerShell module. `AuditPolicy` manages success and failure auditing per + subcategory (by English name or GUID) through the LSA audit APIs, and `AuditPolicyOption` + manages the `CrashOnAuditFail`, `FullPrivilegeAuditing`, `AuditBaseObjects`, and + `AuditBaseDirectories` options. Both resources support `get`, `set`, `export`, and `--what-if`. + +
Related work items + + - Issues: [#1755][#1755] + +
+ - Added what-if support to the MCP server tools. The `invoke_dsc_config` tool now accepts a `what_if` option for the `set` operation, and the `invoke_dsc_resource` tool accepts `what_if` for the `set` and `delete` operations. This mirrors the `--what-if` flag on the `dsc config set`, @@ -1982,4 +1995,5 @@ Version `3.0.0` is the first generally available release of DSC. [#1557]: https://github.com/PowerShell/DSC/issues/1557 [#1558]: https://github.com/PowerShell/DSC/issues/1558 [#1562]: https://github.com/PowerShell/DSC/issues/1562 +[#1755]: https://github.com/PowerShell/DSC/issues/1755 diff --git a/Cargo.lock b/Cargo.lock index 31ec37535..e1d4bd905 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4485,6 +4485,18 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_audit_policy" +version = "0.1.0" +dependencies = [ + "dsc-lib-registry", + "dsc-lib-security_context", + "rust-i18n", + "serde", + "serde_json", + "windows 0.62.2", +] + [[package]] name = "windows_firewall" version = "0.3.0" diff --git a/Cargo.toml b/Cargo.toml index d371a5b4d..b4d98e20e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ members = [ "resources/windows_service", "resources/WindowsUpdate", "resources/environment_variable", + "resources/windows_audit_policy", "adapters/group_policy_template", "tools/dsctest", "tools/test_group_resource", @@ -62,6 +63,7 @@ default-members = [ "resources/windows_service", "resources/WindowsUpdate", "resources/environment_variable", + "resources/windows_audit_policy", "adapters/group_policy_template", "tools/dsctest", "tools/test_group_resource", @@ -98,6 +100,7 @@ Windows = [ "resources/windows_service", "resources/WindowsUpdate", "resources/environment_variable", + "resources/windows_audit_policy", "adapters/group_policy_template", "tools/dsctest", "tools/test_group_resource", @@ -290,17 +293,19 @@ urlencoding = { version = "2.1" } which = { version = "8.0.6" } # dsc-lib ipnetwork = { version = "0.21" } -# WindowsUpdate, windows_service, dsc-lib (ACL checks), dsc-lib-registry +# WindowsUpdate, windows_service, windows_audit_policy, dsc-lib (ACL checks), dsc-lib-registry windows = { version = "0.62", features = [ "Win32_Foundation", "Win32_NetworkManagement_WindowsFirewall", "Win32_Security", + "Win32_Security_Authentication_Identity", "Win32_Security_Authorization", "Win32_System_Com", "Win32_System_Memory", "Win32_System_Ole", "Win32_System_Registry", "Win32_System_Services", + "Win32_System_Threading", "Win32_System_Variant", "Win32_System_UpdateAgent", "Win32_Globalization" diff --git a/data.build.json b/data.build.json index a91dc0240..6531773fe 100644 --- a/data.build.json +++ b/data.build.json @@ -117,6 +117,8 @@ "windows_firewall.exe", "windows_service.exe", "windows_service.dsc.resource.json", + "windows_audit_policy.exe", + "windows_audit_policy.dsc.manifests.json", "windows_feature.dsc.resource.json", "wmi.dsc.resource.json", "wmi.resource.ps1", diff --git a/docs/reference/resources/Microsoft/Windows/AuditPolicy/examples/auditpolicy.config.dsc.yaml b/docs/reference/resources/Microsoft/Windows/AuditPolicy/examples/auditpolicy.config.dsc.yaml new file mode 100644 index 000000000..05568e05e --- /dev/null +++ b/docs/reference/resources/Microsoft/Windows/AuditPolicy/examples/auditpolicy.config.dsc.yaml @@ -0,0 +1,20 @@ +# yaml-language-server: $schema=https://aka.ms/dsc/schemas/v3/bundled/config/document.vscode.json +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: + - name: Audit successful and failed logons + type: Microsoft.Windows/AuditPolicy + properties: + subcategory: Logon + success: true + failure: true + - name: Audit failed credential validation + type: Microsoft.Windows/AuditPolicy + properties: + subcategory: Credential Validation + failure: true + - name: Disable auditing of other privilege use events + type: Microsoft.Windows/AuditPolicy + properties: + subcategory: Other Privilege Use Events + success: false + failure: false diff --git a/docs/reference/resources/Microsoft/Windows/AuditPolicy/examples/configure-audit-policy.md b/docs/reference/resources/Microsoft/Windows/AuditPolicy/examples/configure-audit-policy.md new file mode 100644 index 000000000..3438405c6 --- /dev/null +++ b/docs/reference/resources/Microsoft/Windows/AuditPolicy/examples/configure-audit-policy.md @@ -0,0 +1,256 @@ +--- +description: > + Example showing how to use the Microsoft.Windows/AuditPolicy resource in a DSC configuration + document to enforce the auditing of audit policy subcategories. +ms.date: 10/16/2026 +ms.topic: reference +title: Configure audit policy +--- + +# Configure audit policy + +This example shows how you can use the `Microsoft.Windows/AuditPolicy` resource in a DSC +configuration document to enforce the auditing of several audit policy subcategories, as a +hardening baseline typically requires. + +> [!IMPORTANT] +> All operations of this resource require an elevated (administrator) process context. Run your +> terminal or PowerShell session as Administrator before using `dsc config set`. + +## Definition + +The configuration document for this example defines three instances of the `AuditPolicy` +resource. + +The first instance ensures that both successful and failed logon events are audited. The second +instance ensures that failed credential validation is audited and leaves success auditing as it +is, because the `success` property is omitted. The third instance disables auditing of the +`Other Privilege Use Events` subcategory entirely. + +:::code language="yaml" source="auditpolicy.config.dsc.yaml"::: + +Copy the configuration document and save it as `auditpolicy.config.dsc.yaml`. + +## Setup + +The output in this example assumes that the system audits successful logons only, doesn't audit +credential validation, and audits successful privilege use events. You can set the system to this +starting state with the following commands: + +```powershell +auditpol /set /subcategory:"Logon" /success:enable /failure:disable +auditpol /set /subcategory:"Credential Validation" /success:disable /failure:disable +auditpol /set /subcategory:"Other Privilege Use Events" /success:enable /failure:disable +``` + +## Test the configuration + +To see whether the system is already in the desired state, use the [dsc config test][01] command. + +```powershell +dsc config test --file ./auditpolicy.config.dsc.yaml +``` + +```yaml +executionInformation: + # Elided for brevity +metadata: + # Elided for brevity +results: +- executionInformation: + duration: PT0.0411372S + metadata: + Microsoft.DSC: + duration: PT0.0411372S + name: Audit successful and failed logons + type: Microsoft.Windows/AuditPolicy + result: + desiredState: + subcategory: Logon + success: true + failure: true + actualState: + subcategory: Logon + success: true + failure: false + guid: '{0CCE9215-69AE-11D9-BED3-505054503030}' + category: Logon/Logoff + inDesiredState: false + differingProperties: + - failure +- executionInformation: + duration: PT0.0398114S + metadata: + Microsoft.DSC: + duration: PT0.0398114S + name: Audit failed credential validation + type: Microsoft.Windows/AuditPolicy + result: + desiredState: + subcategory: Credential Validation + failure: true + actualState: + subcategory: Credential Validation + success: false + failure: false + guid: '{0CCE923F-69AE-11D9-BED3-505054503030}' + category: Account Logon + inDesiredState: false + differingProperties: + - failure +- executionInformation: + duration: PT0.0402517S + metadata: + Microsoft.DSC: + duration: PT0.0402517S + name: Disable auditing of other privilege use events + type: Microsoft.Windows/AuditPolicy + result: + desiredState: + subcategory: Other Privilege Use Events + success: false + failure: false + actualState: + subcategory: Other Privilege Use Events + success: true + failure: false + guid: '{0CCE922A-69AE-11D9-BED3-505054503030}' + category: Privilege Use + inDesiredState: false + differingProperties: + - success +messages: [] +hadErrors: false +``` + +Each instance reports `inDesiredState: false` and lists only the flags that differ. The second +instance doesn't list `success` because the configuration document doesn't specify it. + +## Set the configuration + +To enforce the desired state, use the [dsc config set][02] command. + +```powershell +dsc config set --file ./auditpolicy.config.dsc.yaml +``` + +```yaml +executionInformation: + # Elided for brevity +metadata: + # Elided for brevity +results: +- executionInformation: + duration: PT0.0824631S + metadata: + Microsoft.DSC: + duration: PT0.0824631S + name: Audit successful and failed logons + type: Microsoft.Windows/AuditPolicy + result: + beforeState: + subcategory: Logon + success: true + failure: false + guid: '{0CCE9215-69AE-11D9-BED3-505054503030}' + category: Logon/Logoff + afterState: + subcategory: Logon + success: true + failure: true + guid: '{0CCE9215-69AE-11D9-BED3-505054503030}' + category: Logon/Logoff + changedProperties: + - failure +- executionInformation: + duration: PT0.0811207S + metadata: + Microsoft.DSC: + duration: PT0.0811207S + name: Audit failed credential validation + type: Microsoft.Windows/AuditPolicy + result: + beforeState: + subcategory: Credential Validation + success: false + failure: false + guid: '{0CCE923F-69AE-11D9-BED3-505054503030}' + category: Account Logon + afterState: + subcategory: Credential Validation + success: false + failure: true + guid: '{0CCE923F-69AE-11D9-BED3-505054503030}' + category: Account Logon + changedProperties: + - failure +- executionInformation: + duration: PT0.0807443S + metadata: + Microsoft.DSC: + duration: PT0.0807443S + name: Disable auditing of other privilege use events + type: Microsoft.Windows/AuditPolicy + result: + beforeState: + subcategory: Other Privilege Use Events + success: true + failure: false + guid: '{0CCE922A-69AE-11D9-BED3-505054503030}' + category: Privilege Use + afterState: + subcategory: Other Privilege Use Events + success: false + failure: false + guid: '{0CCE922A-69AE-11D9-BED3-505054503030}' + category: Privilege Use + changedProperties: + - success +messages: [] +hadErrors: false +``` + +The second instance only changed `failure`; its `success` flag kept the current value because the +configuration document doesn't specify it. Running `dsc config set` again makes no further +changes because every instance is already in the desired state. + +## Preview the changes + +To see which flags would change without applying them, add the `--what-if` option to the +`dsc config set` command. The resource reports the projected state and describes each change +under `_metadata.whatIf`. + +```powershell +dsc config set --file ./auditpolicy.config.dsc.yaml --what-if +``` + +```yaml +# Elided for brevity +results: +- name: Audit successful and failed logons + type: Microsoft.Windows/AuditPolicy + result: + beforeState: + subcategory: Logon + success: true + failure: false + guid: '{0CCE9215-69AE-11D9-BED3-505054503030}' + category: Logon/Logoff + afterState: + subcategory: Logon + success: true + failure: true + guid: '{0CCE9215-69AE-11D9-BED3-505054503030}' + category: Logon/Logoff + _metadata: + whatIf: + - Would change failure auditing for subcategory 'Logon' from false to true + changedProperties: + - failure + - _metadata +# Elided for brevity +``` + + +[01]: ../../../../../../cli/config/test.md +[02]: ../../../../../../cli/config/set.md diff --git a/docs/reference/resources/Microsoft/Windows/AuditPolicy/examples/get-audit-policy.md b/docs/reference/resources/Microsoft/Windows/AuditPolicy/examples/get-audit-policy.md new file mode 100644 index 000000000..d87443cbb --- /dev/null +++ b/docs/reference/resources/Microsoft/Windows/AuditPolicy/examples/get-audit-policy.md @@ -0,0 +1,113 @@ +--- +description: > + Example showing how to use the Microsoft.Windows/AuditPolicy resource with the dsc resource + commands to retrieve and export the audit policy. +ms.date: 10/16/2026 +ms.topic: reference +title: Get audit policy +--- + +# Get audit policy + +This example shows how you can use the `Microsoft.Windows/AuditPolicy` resource to retrieve the +auditing of one subcategory and to export the auditing of every subcategory. + +> [!IMPORTANT] +> All operations of this resource require an elevated (administrator) process context. Run your +> terminal or PowerShell session as Administrator before using the `dsc resource` commands. + +## Get the auditing of a subcategory + +To retrieve the current auditing of a subcategory, use the [dsc resource get][01] command. The +only required property is `subcategory`. You can specify the English name of the subcategory or +its GUID. + +```powershell +dsc resource get --resource Microsoft.Windows/AuditPolicy --input '{"subcategory":"Logon"}' +``` + +```yaml +actualState: + subcategory: Logon + success: true + failure: true + guid: '{0CCE9215-69AE-11D9-BED3-505054503030}' + category: Logon/Logoff +``` + +The resource returns the `subcategory` exactly as you specified it, together with the read-only +`guid` and `category` properties. The following command retrieves the same subcategory by GUID: + +```powershell +dsc resource get --resource Microsoft.Windows/AuditPolicy --input '{"subcategory":"{0CCE9215-69AE-11D9-BED3-505054503030}"}' +``` + +```yaml +actualState: + subcategory: '{0CCE9215-69AE-11D9-BED3-505054503030}' + success: true + failure: true + guid: '{0CCE9215-69AE-11D9-BED3-505054503030}' + category: Logon/Logoff +``` + +## Test the auditing of a subcategory + +To check whether a subcategory has the auditing you expect, use the [dsc resource test][02] +command. Only the flags you specify are compared. + +```powershell +dsc resource test --resource Microsoft.Windows/AuditPolicy --input '{"subcategory":"Credential Validation","failure":true}' +``` + +```yaml +desiredState: + subcategory: Credential Validation + failure: true +actualState: + subcategory: Credential Validation + success: true + failure: false + guid: '{0CCE923F-69AE-11D9-BED3-505054503030}' + category: Account Logon +inDesiredState: false +differingProperties: +- failure +``` + +## Export the full audit policy + +To export the auditing of every subcategory, use the [dsc resource export][03] command. The +output is a configuration document with one instance per subcategory, grouped by category, that +you can use as the starting point for a baseline. + +```powershell +dsc resource export --resource Microsoft.Windows/AuditPolicy +``` + +```yaml +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: Microsoft.Windows/AuditPolicy-0 + type: Microsoft.Windows/AuditPolicy + properties: + subcategory: Security State Change + success: true + failure: false + guid: '{0CCE9210-69AE-11D9-BED3-505054503030}' + category: System +- name: Microsoft.Windows/AuditPolicy-1 + type: Microsoft.Windows/AuditPolicy + properties: + subcategory: Security System Extension + success: false + failure: false + guid: '{0CCE9211-69AE-11D9-BED3-505054503030}' + category: System +# Elided for brevity +``` + + +[01]: ../../../../../../cli/resource/get.md +[02]: ../../../../../../cli/resource/test.md +[03]: ../../../../../../cli/resource/export.md diff --git a/docs/reference/resources/Microsoft/Windows/AuditPolicy/index.md b/docs/reference/resources/Microsoft/Windows/AuditPolicy/index.md new file mode 100644 index 000000000..5897f051f --- /dev/null +++ b/docs/reference/resources/Microsoft/Windows/AuditPolicy/index.md @@ -0,0 +1,319 @@ +--- +description: Microsoft.Windows/AuditPolicy resource reference documentation +ms.date: 10/16/2026 +ms.topic: reference +title: Microsoft.Windows/AuditPolicy +--- + +# Microsoft.Windows/AuditPolicy + +## Synopsis + +Manage success and failure auditing for Windows advanced audit policy subcategories. + +## Metadata + +```yaml +Version : 0.1.0 +Kind : resource +Tags : [Windows, Security, Audit] +Author : Microsoft +``` + +## Instance definition syntax + +```yaml +resources: + - name: + type: Microsoft.Windows/AuditPolicy + properties: + # Key properties + subcategory: string + # Instance properties + success: bool + failure: bool +``` + +## Description + +The `Microsoft.Windows/AuditPolicy` resource enables you to idempotently manage the advanced +audit policy of a Windows system. These are the same settings that `auditpol.exe` and the +**Advanced Audit Policy Configuration** node of the Local Security Policy console manage. Each +instance of the resource represents one audit subcategory, such as `Logon` or +`Credential Validation`, and controls whether successful and failed events in that subcategory +are written to the Security event log. + +The resource can: + +- Retrieve the current success and failure auditing of a subcategory. +- Enable or disable success auditing, failure auditing, or both for a subcategory. A flag that + you omit from the desired state is left unchanged. +- Export the auditing of every subcategory on the system. + +You can identify a subcategory by its English name, regardless of the system language, or by its +GUID. The resource always reports the English names, so configuration documents and exported +state are portable between systems with different display languages. + +Windows defines the following categories and subcategories: + +| Category | Subcategories | +|:-------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| System | Security State Change, Security System Extension, System Integrity, IPsec Driver, Other System Events | +| Logon/Logoff | Logon, Logoff, Account Lockout, IPsec Main Mode, IPsec Quick Mode, IPsec Extended Mode, Special Logon, Other Logon/Logoff Events, Network Policy Server, User / Device Claims, Group Membership, Access Rights | +| Object Access | File System, Registry, Kernel Object, SAM, Certification Services, Application Generated, Handle Manipulation, File Share, Filtering Platform Packet Drop, Filtering Platform Connection, Other Object Access Events, Detailed File Share, Removable Storage, Central Policy Staging | +| Privilege Use | Sensitive Privilege Use, Non Sensitive Privilege Use, Other Privilege Use Events | +| Detailed Tracking | Process Creation, Process Termination, DPAPI Activity, RPC Events, Plug and Play Events, Token Right Adjusted Events | +| Policy Change | Audit Policy Change, Authentication Policy Change, Authorization Policy Change, MPSSVC Rule-Level Policy Change, Filtering Platform Policy Change, Other Policy Change Events | +| Account Management | User Account Management, Computer Account Management, Security Group Management, Distribution Group Management, Application Group Management, Other Account Management Events | +| DS Access | Directory Service Access, Directory Service Changes, Directory Service Replication, Detailed Directory Service Replication | +| Account Logon | Credential Validation, Kerberos Service Ticket Operations, Other Account Logon Events, Kerberos Authentication Service | + +To list the subcategories and GUIDs that your system supports, run `auditpol /list /subcategory:* /v` +or use the **Export** operation of this resource. + +> [!NOTE] +> This resource is installed with DSC itself on Windows systems. +> +> You can update this resource by updating DSC. When you update DSC, the updated version of this +> resource is automatically available. + +## Requirements + +- The resource is only usable on a Windows system. +- All operations, including **Get** and **Export**, require an elevated (administrator) process + context because reading the audit policy requires the `SeSecurityPrivilege` privilege. DSC + refuses to invoke the resource from a non-elevated process. +- When the audit policy is managed by Group Policy, the domain or local Group Policy setting + overwrites the local setting at the next policy refresh. Manage the setting through Group + Policy instead, or exclude the subcategory from Group Policy. + +## Capabilities + +The resource has the following capabilities: + +- `get` - You can use the resource to retrieve the actual state of a subcategory. +- `set` - You can use the resource to enforce the desired auditing of a subcategory. +- `export` - You can use the resource to export the auditing of every subcategory on the system. +- `whatIf` - You can use the resource with the `--what-if` option of the `dsc config set` and + `dsc resource set` commands to see which flags would change without applying them. + +This resource uses the synthetic test functionality of DSC to determine whether an instance is in +the desired state. For more information about resource capabilities, see +[DSC resource capabilities][01]. + +## Examples + +1. [Get audit policy][02] - Shows how to retrieve and export the audit policy with the + `dsc resource` commands. +1. [Configure audit policy][03] - Shows how to enforce the desired auditing of subcategories using + a DSC configuration document. + +## Properties + +The following list describes the properties for the resource. + +- **Key properties:** The following properties uniquely identify an + instance. If two instances of a resource have the same values for their key properties, the + instances are conflicting. For more information about key properties, see the "Key resource + properties" section in [DSC resource properties][04]. + + - [subcategory](#subcategory) - The subcategory to manage, as an English name or a GUID. + +- **Instance properties:** The following properties are optional. + They define the desired state for an instance of the resource. + + - [success](#success) - Whether successful events in the subcategory are audited. + - [failure](#failure) - Whether failed events in the subcategory are audited. + +- **Read-only properties:** The resource returns the following + properties, but they aren't configurable. For more information about read-only properties, see + the "Read-only resource properties" section in [DSC resource properties][05]. + + - [guid](#guid) - The GUID of the subcategory. + - [category](#category) - The English name of the category that contains the subcategory. + +### subcategory + +
Expand for subcategory property metadata + +```yaml +Type : string +IsRequired : true +IsKey : true +IsReadOnly : false +IsWriteOnly : false +``` + +
+ +The subcategory to manage. Specify either the English name of the subcategory, such as `Logon` +or `Credential Validation`, or its GUID, such as `{0CCE9215-69AE-11D9-BED3-505054503030}`. Names +are matched case-insensitively and the braces around a GUID are optional. The resource returns the +value exactly as you specified it. **Export** returns the English name. + +If the subcategory isn't a known English name or GUID, the resource falls back to the names that +the local system reports in its display language, so localized names are also accepted. + +### success + +
Expand for success property metadata + +```yaml +Type : boolean +IsRequired : false +IsKey : false +IsReadOnly : false +IsWriteOnly : false +``` + +
+ +Defines whether successful events in the subcategory are audited. Set this property to `true` to +enable success auditing or `false` to disable it. When you omit this property from a **Set** +operation, the current success auditing is left unchanged. + +### failure + +
Expand for failure property metadata + +```yaml +Type : boolean +IsRequired : false +IsKey : false +IsReadOnly : false +IsWriteOnly : false +``` + +
+ +Defines whether failed events in the subcategory are audited. Set this property to `true` to +enable failure auditing or `false` to disable it. When you omit this property from a **Set** +operation, the current failure auditing is left unchanged. + +### guid + +
Expand for guid property metadata + +```yaml +Type : string +IsRequired : false +IsKey : false +IsReadOnly : true +IsWriteOnly : false +``` + +
+ +The GUID of the subcategory, in braces and upper-case, for example +`{0CCE9215-69AE-11D9-BED3-505054503030}`. The resource returns this property and you can't set +it. + +### category + +
Expand for category property metadata + +```yaml +Type : string +IsRequired : false +IsKey : false +IsReadOnly : true +IsWriteOnly : false +``` + +
+ +The English name of the audit policy category that contains the subcategory, for example +`Logon/Logoff`. The resource returns this property and you can't set it. + +## Instance validating schema + +The following snippet contains the JSON Schema that validates an instance of the resource. + +```json +{ + "type": "object", + "required": ["subcategory"], + "additionalProperties": false, + "properties": { + "subcategory": { + "type": "string", + "minLength": 1 + }, + "success": { + "type": "boolean" + }, + "failure": { + "type": "boolean" + }, + "guid": { + "type": "string", + "readOnly": true + }, + "category": { + "type": "string", + "readOnly": true + }, + "_metadata": { + "type": "object", + "readOnly": true, + "properties": { + "whatIf": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } +} +``` + +## Exit codes + +The resource returns the following exit codes from operations: + +- [0](#exit-code-0) - Success +- [1](#exit-code-1) - Invalid arguments +- [2](#exit-code-2) - Invalid input +- [3](#exit-code-3) - Audit policy error +- [4](#exit-code-4) - Elevation required + +### Exit code 0 + +Indicates the resource operation completed without errors. + +### Exit code 1 + +Indicates the resource operation failed because required arguments were missing or the operation +name was not recognized. + +### Exit code 2 + +Indicates the resource operation failed because the JSON input could not be deserialized into a +valid instance, or because the `subcategory` isn't a known subcategory name or GUID. + +### Exit code 3 + +Indicates the resource operation failed due to an error raised by the Windows audit policy APIs, +or the result could not be serialized. + +### Exit code 4 + +Indicates the resource operation failed because the process isn't elevated. Reading or changing +the audit policy requires an elevated process. + +## See also + +- [Microsoft.Windows/AuditPolicyOption resource][06] +- [DSC resource capabilities][01] +- [DSC resource properties][07] + + +[01]: ../../../../../concepts/resources/capabilities.md +[02]: ./examples/get-audit-policy.md +[03]: ./examples/configure-audit-policy.md +[04]: ../../../../../concepts/resources/properties.md#key-resource-properties +[05]: ../../../../../concepts/resources/properties.md#read-only-resource-properties +[06]: ../AuditPolicyOption/index.md +[07]: ../../../../../concepts/resources/properties.md diff --git a/docs/reference/resources/Microsoft/Windows/AuditPolicyOption/examples/auditpolicyoption.config.dsc.yaml b/docs/reference/resources/Microsoft/Windows/AuditPolicyOption/examples/auditpolicyoption.config.dsc.yaml new file mode 100644 index 000000000..aff0c2f37 --- /dev/null +++ b/docs/reference/resources/Microsoft/Windows/AuditPolicyOption/examples/auditpolicyoption.config.dsc.yaml @@ -0,0 +1,13 @@ +# yaml-language-server: $schema=https://aka.ms/dsc/schemas/v3/bundled/config/document.vscode.json +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: + - name: Audit the use of Backup and Restore privileges + type: Microsoft.Windows/AuditPolicyOption + properties: + name: FullPrivilegeAuditing + enabled: true + - name: Keep running when audits cannot be logged + type: Microsoft.Windows/AuditPolicyOption + properties: + name: CrashOnAuditFail + enabled: false diff --git a/docs/reference/resources/Microsoft/Windows/AuditPolicyOption/examples/configure-audit-policy-option.md b/docs/reference/resources/Microsoft/Windows/AuditPolicyOption/examples/configure-audit-policy-option.md new file mode 100644 index 000000000..2f43dbd17 --- /dev/null +++ b/docs/reference/resources/Microsoft/Windows/AuditPolicyOption/examples/configure-audit-policy-option.md @@ -0,0 +1,152 @@ +--- +description: > + Example showing how to use the Microsoft.Windows/AuditPolicyOption resource in a DSC + configuration document to enforce the desired state of audit policy options. +ms.date: 10/16/2026 +ms.topic: reference +title: Configure audit policy options +--- + +# Configure audit policy options + +This example shows how you can use the `Microsoft.Windows/AuditPolicyOption` resource in a DSC +configuration document to enforce the state of audit policy options. + +> [!IMPORTANT] +> **Set** operations for this resource require an elevated (administrator) process context. Run +> your terminal or PowerShell session as Administrator before using `dsc config set`. +> +> A change to an audit policy option takes effect after the next restart. + +## Definition + +The configuration document for this example defines two instances of the `AuditPolicyOption` +resource. The first instance ensures that the use of the Backup and Restore privileges is +audited. The second instance ensures that the system doesn't halt when it can't write audit +records. + +:::code language="yaml" source="auditpolicyoption.config.dsc.yaml"::: + +Copy the configuration document and save it as `auditpolicyoption.config.dsc.yaml`. + +## Setup + +The output in this example assumes that both options are disabled. You can set the system to this +starting state with the following commands: + +```powershell +auditpol /set /option:FullPrivilegeAuditing /value:disable +auditpol /set /option:CrashOnAuditFail /value:disable +``` + +## Test the configuration + +To see whether the system is already in the desired state, use the [dsc config test][01] command. + +```powershell +dsc config test --file ./auditpolicyoption.config.dsc.yaml +``` + +```yaml +executionInformation: + # Elided for brevity +metadata: + # Elided for brevity +results: +- executionInformation: + duration: PT0.0312458S + metadata: + Microsoft.DSC: + duration: PT0.0312458S + name: Audit the use of Backup and Restore privileges + type: Microsoft.Windows/AuditPolicyOption + result: + desiredState: + name: FullPrivilegeAuditing + enabled: true + actualState: + name: FullPrivilegeAuditing + enabled: false + inDesiredState: false + differingProperties: + - enabled +- executionInformation: + duration: PT0.0298731S + metadata: + Microsoft.DSC: + duration: PT0.0298731S + name: Keep running when audits cannot be logged + type: Microsoft.Windows/AuditPolicyOption + result: + desiredState: + name: CrashOnAuditFail + enabled: false + actualState: + name: CrashOnAuditFail + enabled: false + inDesiredState: true + differingProperties: [] +messages: [] +hadErrors: false +``` + +## Set the configuration + +To enforce the desired state, use the [dsc config set][02] command. + +```powershell +dsc config set --file ./auditpolicyoption.config.dsc.yaml +``` + +```yaml +executionInformation: + # Elided for brevity +metadata: + # Elided for brevity +results: +- executionInformation: + duration: PT0.0587219S + metadata: + Microsoft.DSC: + duration: PT0.0587219S + name: Audit the use of Backup and Restore privileges + type: Microsoft.Windows/AuditPolicyOption + result: + beforeState: + name: FullPrivilegeAuditing + enabled: false + afterState: + name: FullPrivilegeAuditing + enabled: true + changedProperties: + - enabled +- executionInformation: + duration: PT0.0276594S + metadata: + Microsoft.DSC: + duration: PT0.0276594S + name: Keep running when audits cannot be logged + type: Microsoft.Windows/AuditPolicyOption + result: + beforeState: + name: CrashOnAuditFail + enabled: false + afterState: + name: CrashOnAuditFail + enabled: false + changedProperties: null +messages: +- name: Audit the use of Backup and Restore privileges + type: Microsoft.Windows/AuditPolicyOption + message: A restart is required for the change to audit policy option 'FullPrivilegeAuditing' to take effect + level: warning +hadErrors: false +``` + +The first instance changed `enabled` and the resource emitted a warning that the change takes +effect after a restart. The second instance was already in the desired state, so DSC didn't invoke +the **Set** operation for it. + + +[01]: ../../../../../../cli/config/test.md +[02]: ../../../../../../cli/config/set.md diff --git a/docs/reference/resources/Microsoft/Windows/AuditPolicyOption/index.md b/docs/reference/resources/Microsoft/Windows/AuditPolicyOption/index.md new file mode 100644 index 000000000..e2aaa4ebf --- /dev/null +++ b/docs/reference/resources/Microsoft/Windows/AuditPolicyOption/index.md @@ -0,0 +1,230 @@ +--- +description: Microsoft.Windows/AuditPolicyOption resource reference documentation +ms.date: 10/16/2026 +ms.topic: reference +title: Microsoft.Windows/AuditPolicyOption +--- + +# Microsoft.Windows/AuditPolicyOption + +## Synopsis + +Manage Windows audit policy options stored under the LSA registry key. + +## Metadata + +```yaml +Version : 0.1.0 +Kind : resource +Tags : [Windows, Security, Audit] +Author : Microsoft +``` + +## Instance definition syntax + +```yaml +resources: + - name: + type: Microsoft.Windows/AuditPolicyOption + properties: + # Key properties + name: string + # Instance properties + enabled: bool +``` + +## Description + +The `Microsoft.Windows/AuditPolicyOption` resource enables you to idempotently manage the four +audit policy options that `auditpol.exe` exposes through its `/option` parameter. Windows stores +these options as values under the `HKLM\SYSTEM\CurrentControlSet\Control\Lsa` registry key, which +is also where the corresponding **Security Options** of the Local Security Policy console and +`secedit` security templates store them. + +| Option | Security Options policy | Registry value type | +|:------------------------|:----------------------------------------------------------------------------|:--------------------| +| `CrashOnAuditFail` | Audit: Shut down system immediately if unable to log security audits | `REG_DWORD` | +| `FullPrivilegeAuditing` | Audit: Audit the use of Backup and Restore privilege | `REG_BINARY` | +| `AuditBaseObjects` | Audit: Audit the access of global system objects | `REG_DWORD` | +| `AuditBaseDirectories` | Audit the access of global system directories (no Security Options policy) | `REG_DWORD` | + +The resource reports an option as enabled when its registry value is present and non-zero, and as +disabled when the value is absent or zero. When the resource sets an option, it preserves the +existing registry value type. + +> [!NOTE] +> This resource is installed with DSC itself on Windows systems. +> +> You can update this resource by updating DSC. When you update DSC, the updated version of this +> resource is automatically available. + +## Requirements + +- The resource is only usable on a Windows system. +- **Set** operations require an elevated (administrator) process context. +- The Local Security Authority reads these values when the system starts. A change made with the + **Set** operation takes effect after the next restart, and the resource writes a warning to say + so. + +> [!WARNING] +> Enabling `CrashOnAuditFail` causes Windows to halt with a `STOP` error whenever it can't write +> an audit record, for example when the Security event log is full. After such a halt, Windows +> sets the registry value to `2` and only administrators can sign in until the value is reset. +> The resource reports a value of `2` as `enabled: true`. + +## Capabilities + +The resource has the following capabilities: + +- `get` - You can use the resource to retrieve the actual state of an option. +- `set` - You can use the resource to enable or disable an option. +- `export` - You can use the resource to export the state of all four options. +- `whatIf` - You can use the resource with the `--what-if` option of the `dsc config set` and + `dsc resource set` commands to see whether an option would change without applying it. + +This resource uses the synthetic test functionality of DSC to determine whether an instance is in +the desired state. For more information about resource capabilities, see +[DSC resource capabilities][01]. + +## Examples + +1. [Configure audit policy options][02] - Shows how to enforce the desired state of audit policy + options using a DSC configuration document. + +## Properties + +The following list describes the properties for the resource. + +- **Key properties:** The following properties uniquely identify an + instance. If two instances of a resource have the same values for their key properties, the + instances are conflicting. For more information about key properties, see the "Key resource + properties" section in [DSC resource properties][03]. + + - [name](#name) - The option to manage. + +- **Instance properties:** The following properties are optional. + They define the desired state for an instance of the resource. + + - [enabled](#enabled) - Whether the option is enabled. + +### name + +
Expand for name property metadata + +```yaml +Type : string +IsRequired : true +IsKey : true +IsReadOnly : false +IsWriteOnly : false +Enum : [CrashOnAuditFail, FullPrivilegeAuditing, AuditBaseObjects, AuditBaseDirectories] +``` + +
+ +The audit policy option to manage. The value is the name of the registry value that stores the +option and matches the name that `auditpol /get /option:` uses. + +### enabled + +
Expand for enabled property metadata + +```yaml +Type : boolean +IsRequired : false +IsKey : false +IsReadOnly : false +IsWriteOnly : false +``` + +
+ +Defines whether the option is enabled. This property is optional for the **Get** operation and +required for the **Set** operation. A change takes effect after the next restart. + +## Instance validating schema + +The following snippet contains the JSON Schema that validates an instance of the resource. + +```json +{ + "type": "object", + "required": ["name"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "enum": [ + "CrashOnAuditFail", + "FullPrivilegeAuditing", + "AuditBaseObjects", + "AuditBaseDirectories" + ] + }, + "enabled": { + "type": "boolean" + }, + "_metadata": { + "type": "object", + "readOnly": true, + "properties": { + "whatIf": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } +} +``` + +## Exit codes + +The resource returns the following exit codes from operations: + +- [0](#exit-code-0) - Success +- [1](#exit-code-1) - Invalid arguments +- [2](#exit-code-2) - Invalid input +- [3](#exit-code-3) - Audit policy error +- [4](#exit-code-4) - Elevation required + +### Exit code 0 + +Indicates the resource operation completed without errors. + +### Exit code 1 + +Indicates the resource operation failed because required arguments were missing or the operation +name was not recognized. + +### Exit code 2 + +Indicates the resource operation failed because the JSON input could not be deserialized into a +valid instance, or because `enabled` was omitted from a **Set** operation. + +### Exit code 3 + +Indicates the resource operation failed because the registry value could not be read or written, +has an unexpected type, or the result could not be serialized. + +### Exit code 4 + +Indicates the resource operation failed because the process isn't elevated. Changing an audit +policy option requires an elevated process. + +## See also + +- [Microsoft.Windows/AuditPolicy resource][04] +- [Microsoft.Windows/Registry resource][05] +- [DSC resource capabilities][01] +- [DSC resource properties][06] + + +[01]: ../../../../../concepts/resources/capabilities.md +[02]: ./examples/configure-audit-policy-option.md +[03]: ../../../../../concepts/resources/properties.md#key-resource-properties +[04]: ../AuditPolicy/index.md +[05]: ../Registry/index.md +[06]: ../../../../../concepts/resources/properties.md diff --git a/docs/reference/resources/overview.md b/docs/reference/resources/overview.md index c6324dfa9..d0b34b92f 100644 --- a/docs/reference/resources/overview.md +++ b/docs/reference/resources/overview.md @@ -18,6 +18,8 @@ This document lists the available resources and links to the reference documenta - [Microsoft.DSC.Transitional/PowerShellScript](./Microsoft/DSC/Transitional/PowerShellScript/index.md) - [Microsoft.DSC.Transitional/RunCommandOnSet](./Microsoft/DSC/Transitional/RunCommandOnSet/index.md) - [Microsoft.DSC.Transitional/WindowsPowerShellScript](./Microsoft/DSC/Transitional/WindowsPowerShellScript/index.md) +- [Microsoft.Windows/AuditPolicy](./Microsoft/Windows/AuditPolicy/index.md) +- [Microsoft.Windows/AuditPolicyOption](./Microsoft/Windows/AuditPolicyOption/index.md) - [Microsoft.Windows/FeatureOnDemandList](./Microsoft/Windows/FeatureOnDemandList/index.md) - [Microsoft.Windows/FirewallRuleList](./Microsoft/Windows/FirewallRuleList/index.md) - [Microsoft.Windows/OptionalFeatureList](./Microsoft/Windows/OptionalFeatureList/index.md) @@ -61,6 +63,8 @@ The following built-in resources change the state of a machine directly: - [Microsoft.DSC.Transitional/PowerShellScript](./Microsoft/DSC/Transitional/PowerShellScript/index.md) - [Microsoft.DSC.Transitional/RunCommandOnSet](./Microsoft/DSC/Transitional/RunCommandOnSet/index.md) - [Microsoft.DSC.Transitional/WindowsPowerShellScript](./Microsoft/DSC/Transitional/WindowsPowerShellScript/index.md) +- [Microsoft.Windows/AuditPolicy](./Microsoft/Windows/AuditPolicy/index.md) +- [Microsoft.Windows/AuditPolicyOption](./Microsoft/Windows/AuditPolicyOption/index.md) - [Microsoft.Windows/FeatureOnDemandList](./Microsoft/Windows/FeatureOnDemandList/index.md) - [Microsoft.Windows/FirewallRuleList](./Microsoft/Windows/FirewallRuleList/index.md) - [Microsoft.Windows/OptionalFeatureList](./Microsoft/Windows/OptionalFeatureList/index.md) diff --git a/resources/windows_audit_policy/.project.data.json b/resources/windows_audit_policy/.project.data.json new file mode 100644 index 000000000..498c35d56 --- /dev/null +++ b/resources/windows_audit_policy/.project.data.json @@ -0,0 +1,14 @@ +{ + "Name": "windows_audit_policy", + "Kind": "Resource", + "IsRust": true, + "SupportedPlatformOS": "Windows", + "Binaries": [ + "windows_audit_policy" + ], + "CopyFiles": { + "Windows": [ + "windows_audit_policy.dsc.manifests.json" + ] + } +} diff --git a/resources/windows_audit_policy/Cargo.toml b/resources/windows_audit_policy/Cargo.toml new file mode 100644 index 000000000..8a6d6c5a0 --- /dev/null +++ b/resources/windows_audit_policy/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "windows_audit_policy" +version = "0.1.0" +edition = "2024" + +[package.metadata.i18n] +available-locales = ["en-us"] +default-locale = "en-us" +load-path = "locales" + +[dependencies] +rust-i18n = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } + +[target.'cfg(windows)'.dependencies] +windows = { workspace = true } +dsc-lib-registry = { workspace = true } +dsc-lib-security_context = { workspace = true } diff --git a/resources/windows_audit_policy/build.rs b/resources/windows_audit_policy/build.rs new file mode 100644 index 000000000..315b846a1 --- /dev/null +++ b/resources/windows_audit_policy/build.rs @@ -0,0 +1,7 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +fn main() { + // Always rebuild if translations are updated. + println!("cargo::rerun-if-changed=locales"); +} diff --git a/resources/windows_audit_policy/locales/en-us.toml b/resources/windows_audit_policy/locales/en-us.toml new file mode 100644 index 000000000..c810c0cdb --- /dev/null +++ b/resources/windows_audit_policy/locales/en-us.toml @@ -0,0 +1,34 @@ +_version = 1 + +[main] +missingOperation = "Missing operation. Usage: windows_audit_policy [--input ] [--what-if]" +unknownOperation = "Unknown operation: '%{operation}'. Expected: get, set, or export" +missingTarget = "Missing resource target. Expected: subcategory or option" +unknownTarget = "Unknown resource target: '%{target}'. Expected: subcategory or option" +missingInput = "Missing --input argument" +missingInputValue = "Missing value for --input argument" +invalidJson = "Invalid JSON input: %{error}" +serializeFailed = "Failed to serialize output: %{error}" +windowsOnly = "This resource is only supported on Windows" +elevationRequired = "Managing the Windows audit policy requires elevation. Run DSC from an elevated process." + +[audit] +privilegeFailed = "Failed to enable SeSecurityPrivilege: %{error}" +unknownSubcategory = "Unknown audit policy subcategory '%{subcategory}'. Specify the English subcategory name or its GUID." +enumerateCategoriesFailed = "Failed to enumerate audit policy categories: %{error}" +enumerateSubcategoriesFailed = "Failed to enumerate audit policy subcategories: %{error}" +lookupNameFailed = "Failed to look up the name for audit policy GUID '%{guid}': %{error}" +queryFailed = "Failed to query the audit policy for subcategory '%{subcategory}': %{error}" +queryCategoryFailed = "Failed to query the audit policy for category '%{category}': %{error}" +queryEmpty = "no policy information was returned" +setFailed = "Failed to set the audit policy for subcategory '%{subcategory}': %{error}" +whatIfChangeSuccess = "Would change success auditing for subcategory '%{subcategory}' from %{current} to %{desired}" +whatIfChangeFailure = "Would change failure auditing for subcategory '%{subcategory}' from %{current} to %{desired}" + +[option] +enabledRequired = "'enabled' is required for the set operation" +readFailed = "Failed to read audit policy option '%{name}' from the registry: %{error}" +writeFailed = "Failed to write audit policy option '%{name}' to the registry: %{error}" +unsupportedType = "Audit policy option '%{name}' has an unsupported registry value type; expected REG_DWORD or REG_BINARY" +whatIfChangeEnabled = "Would change audit policy option '%{name}' from enabled %{current} to %{desired}" +restartRequired = "A restart is required for the change to audit policy option '%{name}' to take effect" diff --git a/resources/windows_audit_policy/src/audit.rs b/resources/windows_audit_policy/src/audit.rs new file mode 100644 index 000000000..f3aac50b7 --- /dev/null +++ b/resources/windows_audit_policy/src/audit.rs @@ -0,0 +1,538 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Reads and writes the system audit policy through the LSA audit APIs. +//! +//! Every pointer handed out by an `Audit*` API is owned by the LSA and must be released with +//! `AuditFree`; the RAII guards below do that on drop so early returns cannot leak. + +use crate::subcategories::{self, format_guid, parse_guid}; +use crate::types::{AuditError, AuditFlags, AuditPolicy, Metadata}; +use dsc_lib_security_context::{SecurityContext, get_security_context}; +use rust_i18n::t; +use std::ptr; +use windows::Win32::Foundation::{CloseHandle, ERROR_NOT_ALL_ASSIGNED, GetLastError, HANDLE, LUID}; +use windows::Win32::Security::Authentication::Identity::{ + AUDIT_POLICY_INFORMATION, AuditEnumerateCategories, AuditEnumerateSubCategories, AuditFree, + AuditLookupCategoryNameW, AuditLookupSubCategoryNameW, AuditQuerySystemPolicy, AuditSetSystemPolicy, +}; +use windows::Win32::Security::{ + AdjustTokenPrivileges, LUID_AND_ATTRIBUTES, LookupPrivilegeValueW, SE_PRIVILEGE_ENABLED, SE_SECURITY_NAME, + TOKEN_ADJUST_PRIVILEGES, TOKEN_PRIVILEGES, TOKEN_QUERY, +}; +use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; +use windows::core::{GUID, PCWSTR, PWSTR}; + +impl From for AuditError { + fn from(error: windows::core::Error) -> Self { + Self::Audit(error.to_string()) + } +} + +/// The error for the most recent failed Win32 call on this thread. +fn last_error() -> windows::core::Error { + windows::core::Error::from_thread() +} + +/// An array allocated by an `Audit*` API and released with `AuditFree`. +struct AuditBuffer { + ptr: *mut T, + count: usize, +} + +impl AuditBuffer { + fn as_slice(&self) -> &[T] { + if self.ptr.is_null() || self.count == 0 { + &[] + } else { + // SAFETY: the LSA allocated `count` contiguous items at `ptr`; they stay valid until + // `AuditFree` runs in `drop`. + unsafe { std::slice::from_raw_parts(self.ptr, self.count) } + } + } +} + +impl Drop for AuditBuffer { + fn drop(&mut self) { + if !self.ptr.is_null() { + // SAFETY: `ptr` came from an `Audit*` API and is released exactly once. + unsafe { AuditFree(self.ptr.cast()) }; + } + } +} + +/// A string allocated by an `AuditLookup*NameW` API and released with `AuditFree`. +struct AuditString(PWSTR); + +impl AuditString { + fn to_string_lossy(&self) -> String { + if self.0.is_null() { + String::new() + } else { + // SAFETY: the LSA returns a null-terminated UTF-16 string that stays valid until + // `AuditFree` runs in `drop`. + unsafe { String::from_utf16_lossy(self.0.as_wide()) } + } + } +} + +impl Drop for AuditString { + fn drop(&mut self) { + if !self.0.is_null() { + // SAFETY: the string came from an `AuditLookup*NameW` API and is released exactly once. + unsafe { AuditFree(self.0.as_ptr().cast()) }; + } + } +} + +/// An open access token handle, closed on drop. +struct TokenHandle(HANDLE); + +impl Drop for TokenHandle { + fn drop(&mut self) { + // SAFETY: the handle was returned by `OpenProcessToken` and is closed exactly once. + unsafe { + let _ = CloseHandle(self.0); + } + } +} + +/// A subcategory resolved from user input. +struct Resolved { + guid: GUID, + /// English name when known, otherwise the name the LSA reports. + name: String, + /// English category name when known from the static table. + category: Option, +} + +/// Fail unless the process is elevated. `dsc` enforces this through the manifest; this check +/// covers direct invocation of the binary. +pub(crate) fn require_elevated() -> Result<(), AuditError> { + if get_security_context() == SecurityContext::Admin { + Ok(()) + } else { + Err(AuditError::ElevationRequired) + } +} + +/// Enable `SeSecurityPrivilege` on the process token. The audit APIs need the privilege, and an +/// elevated token holds it but does not enable it by default. +fn enable_security_privilege() -> Result<(), AuditError> { + let privilege_error = + |error: windows::core::Error| AuditError::Audit(t!("audit.privilegeFailed", error = error.to_string()).to_string()); + + let mut raw_token = HANDLE::default(); + // SAFETY: the pseudo handle from `GetCurrentProcess` is always valid; `raw_token` receives the + // opened token and is owned by `TokenHandle` from here on. + unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, &mut raw_token) } + .map_err(privilege_error)?; + let token = TokenHandle(raw_token); + + let mut luid = LUID::default(); + // SAFETY: `SE_SECURITY_NAME` is a valid null-terminated constant and `luid` is a valid out pointer. + unsafe { LookupPrivilegeValueW(PCWSTR::null(), SE_SECURITY_NAME, &mut luid) }.map_err(privilege_error)?; + + let privileges = TOKEN_PRIVILEGES { + PrivilegeCount: 1, + Privileges: [LUID_AND_ATTRIBUTES { + Luid: luid, + Attributes: SE_PRIVILEGE_ENABLED, + }], + }; + // SAFETY: `token` is open with `TOKEN_ADJUST_PRIVILEGES` and `privileges` outlives the call. + unsafe { AdjustTokenPrivileges(token.0, false, Some(ptr::from_ref(&privileges)), 0, None, None) } + .map_err(privilege_error)?; + // AdjustTokenPrivileges succeeds even when the token does not hold the privilege at all; + // only the last error reveals that. + // SAFETY: reading the calling thread's last error has no preconditions. + if unsafe { GetLastError() } == ERROR_NOT_ALL_ASSIGNED { + return Err(AuditError::ElevationRequired); + } + Ok(()) +} + +fn enumerate_categories() -> Result, AuditError> { + let mut ptr: *mut GUID = ptr::null_mut(); + let mut count: u32 = 0; + // SAFETY: both out pointers are valid for the duration of the call. + if !unsafe { AuditEnumerateCategories(&mut ptr, &mut count) } { + return Err(AuditError::Audit( + t!("audit.enumerateCategoriesFailed", error = last_error().to_string()).to_string(), + )); + } + Ok(AuditBuffer { ptr, count: count as usize }) +} + +/// Enumerate the subcategories of one category, or of every category when `category` is `None`. +fn enumerate_subcategories(category: Option<&GUID>) -> Result, AuditError> { + let mut ptr: *mut GUID = ptr::null_mut(); + let mut count: u32 = 0; + // The "retrieve all" flag makes the LSA ignore the category GUID, so it must only be set + // when no category is given. + let retrieve_all = category.is_none(); + // SAFETY: the optional category pointer and both out pointers are valid for the call. + if !unsafe { AuditEnumerateSubCategories(category.map(ptr::from_ref), retrieve_all, &mut ptr, &mut count) } { + return Err(AuditError::Audit( + t!("audit.enumerateSubcategoriesFailed", error = last_error().to_string()).to_string(), + )); + } + Ok(AuditBuffer { ptr, count: count as usize }) +} + +fn lookup_error(guid: &GUID) -> AuditError { + AuditError::Audit( + t!("audit.lookupNameFailed", guid = format_guid(guid.to_u128()), error = last_error().to_string()).to_string(), + ) +} + +/// The localized subcategory name reported by the LSA. +fn lookup_subcategory_name(guid: &GUID) -> Result { + let mut name = PWSTR::null(); + // SAFETY: `guid` and `name` are valid pointers for the duration of the call. + if !unsafe { AuditLookupSubCategoryNameW(ptr::from_ref(guid), &mut name) } { + return Err(lookup_error(guid)); + } + Ok(AuditString(name).to_string_lossy()) +} + +/// The localized category name reported by the LSA. +fn lookup_category_name(guid: &GUID) -> Result { + let mut name = PWSTR::null(); + // SAFETY: `guid` and `name` are valid pointers for the duration of the call. + if !unsafe { AuditLookupCategoryNameW(ptr::from_ref(guid), &mut name) } { + return Err(lookup_error(guid)); + } + Ok(AuditString(name).to_string_lossy()) +} + +/// English subcategory name from the static table, falling back to the LSA's localized name. +fn subcategory_display_name(guid: &GUID) -> Result { + match subcategories::find_by_guid(guid.to_u128()) { + Some(entry) => Ok(entry.name.to_string()), + None => lookup_subcategory_name(guid), + } +} + +/// English category name from the static table, falling back to the LSA's localized name. +fn category_display_name(guid: &GUID) -> Result { + match subcategories::category_name(guid.to_u128()) { + Some(name) => Ok(name.to_string()), + None => lookup_category_name(guid), + } +} + +/// Resolve the `subcategory` input (English name or GUID) to a subcategory GUID. +fn resolve_subcategory(input: &str) -> Result { + let unknown = || AuditError::InvalidInput(t!("audit.unknownSubcategory", subcategory = input).to_string()); + + if let Some(guid) = parse_guid(input) { + if let Some(entry) = subcategories::find_by_guid(guid) { + return Ok(Resolved { + guid: GUID::from_u128(guid), + name: entry.name.to_string(), + category: Some(entry.category.name.to_string()), + }); + } + // Not in the table: let the LSA decide whether the GUID is a real subcategory. + let guid = GUID::from_u128(guid); + let name = lookup_subcategory_name(&guid).map_err(|_| unknown())?; + return Ok(Resolved { guid, name, category: None }); + } + + if let Some(entry) = subcategories::find_by_name(input) { + return Ok(Resolved { + guid: GUID::from_u128(entry.guid), + name: entry.name.to_string(), + category: Some(entry.category.name.to_string()), + }); + } + + // Fall back to the localized names the LSA reports, so localized input and subcategories + // newer than the table still resolve. + let wanted = input.trim(); + let all = enumerate_subcategories(None)?; + for guid in all.as_slice() { + let name = lookup_subcategory_name(guid)?; + if name.eq_ignore_ascii_case(wanted) { + return Ok(Resolved { guid: *guid, name, category: None }); + } + } + Err(unknown()) +} + +/// Query the effective policy for a set of subcategories. +fn query_policy(guids: &[GUID]) -> Result, windows::core::Error> { + let mut ptr: *mut AUDIT_POLICY_INFORMATION = ptr::null_mut(); + if guids.is_empty() { + // The API rejects an empty query. + return Ok(AuditBuffer { ptr, count: 0 }); + } + // SAFETY: `guids` is a valid slice and `ptr` a valid out pointer for the call. + if !unsafe { AuditQuerySystemPolicy(guids, &mut ptr) } { + return Err(last_error()); + } + Ok(AuditBuffer { ptr, count: guids.len() }) +} + +/// Build the resource state from queried policy information. +fn policy_from_info(info: &AUDIT_POLICY_INFORMATION, subcategory: String, category: Option<&str>) -> Result { + let flags = AuditFlags::from_bits(info.AuditingInformation); + let category = match category { + Some(name) => name.to_string(), + None => category_display_name(&info.AuditCategoryGuid)?, + }; + Ok(AuditPolicy { + subcategory, + success: Some(flags.success), + failure: Some(flags.failure), + guid: Some(format_guid(info.AuditSubCategoryGuid.to_u128())), + category: Some(category), + metadata: None, + }) +} + +/// Query one resolved subcategory, echoing `echo` as the `subcategory` value. +fn query_one(resolved: &Resolved, echo: &str) -> Result<(AUDIT_POLICY_INFORMATION, AuditPolicy), AuditError> { + let query_error = + |error: String| AuditError::Audit(t!("audit.queryFailed", subcategory = resolved.name, error = error).to_string()); + let buffer = query_policy(&[resolved.guid]).map_err(|error| query_error(error.to_string()))?; + let info = buffer + .as_slice() + .first() + .copied() + .ok_or_else(|| query_error(t!("audit.queryEmpty").to_string()))?; + let policy = policy_from_info(&info, echo.to_string(), resolved.category.as_deref())?; + Ok((info, policy)) +} + +/// Get the current auditing of one subcategory. +/// +/// # Errors +/// +/// Returns an error when the process is not elevated, the subcategory is unknown, or the LSA +/// query fails. +pub fn get_audit_policy(input: &AuditPolicy) -> Result { + require_elevated()?; + enable_security_privilege()?; + let resolved = resolve_subcategory(&input.subcategory)?; + let (_, policy) = query_one(&resolved, &input.subcategory)?; + Ok(policy) +} + +/// Set the auditing of one subcategory. Flags omitted from the input keep their current value. +/// In what-if mode nothing is changed and the projected state carries `_metadata.whatIf`. +/// +/// # Errors +/// +/// Returns an error when the process is not elevated, the subcategory is unknown, or an LSA +/// call fails. +pub fn set_audit_policy(input: &AuditPolicy, what_if: bool) -> Result { + require_elevated()?; + enable_security_privilege()?; + let resolved = resolve_subcategory(&input.subcategory)?; + + let (info, current) = match query_one(&resolved, &input.subcategory) { + Ok(result) => result, + Err(error) if what_if => { + return Ok(AuditPolicy { + subcategory: input.subcategory.clone(), + success: input.success, + failure: input.failure, + metadata: Metadata::from_messages(vec![error.to_string()]), + ..AuditPolicy::default() + }); + } + Err(error) => return Err(error), + }; + + let current_flags = AuditFlags::from_bits(info.AuditingInformation); + let desired = AuditFlags { + success: input.success.unwrap_or(current_flags.success), + failure: input.failure.unwrap_or(current_flags.failure), + }; + + let mut messages = Vec::new(); + if desired.success != current_flags.success { + messages.push( + t!( + "audit.whatIfChangeSuccess", + subcategory = resolved.name, + current = current_flags.success.to_string(), + desired = desired.success.to_string() + ) + .to_string(), + ); + } + if desired.failure != current_flags.failure { + messages.push( + t!( + "audit.whatIfChangeFailure", + subcategory = resolved.name, + current = current_flags.failure.to_string(), + desired = desired.failure.to_string() + ) + .to_string(), + ); + } + + if messages.is_empty() { + return Ok(current); + } + if what_if { + return Ok(AuditPolicy { + success: Some(desired.success), + failure: Some(desired.failure), + metadata: Metadata::from_messages(messages), + ..current + }); + } + + let policy = AUDIT_POLICY_INFORMATION { + AuditSubCategoryGuid: info.AuditSubCategoryGuid, + AuditingInformation: desired.to_bits(), + AuditCategoryGuid: info.AuditCategoryGuid, + }; + // SAFETY: the slice holds one fully initialised policy entry for the duration of the call. + if !unsafe { AuditSetSystemPolicy(&[policy]) } { + return Err(AuditError::Audit( + t!("audit.setFailed", subcategory = resolved.name, error = last_error().to_string()).to_string(), + )); + } + + let (_, updated) = query_one(&resolved, &input.subcategory)?; + Ok(updated) +} + +/// Export the auditing of every subcategory, grouped by category. +/// +/// # Errors +/// +/// Returns an error when the process is not elevated or an LSA call fails. +pub fn export_audit_policies() -> Result, AuditError> { + require_elevated()?; + enable_security_privilege()?; + + let categories = enumerate_categories()?; + let mut policies = Vec::with_capacity(subcategories::SUBCATEGORIES.len()); + for category in categories.as_slice() { + let category_name = category_display_name(category)?; + let members = enumerate_subcategories(Some(category))?; + let buffer = query_policy(members.as_slice()).map_err(|error| { + AuditError::Audit( + t!("audit.queryCategoryFailed", category = category_name.clone(), error = error.to_string()).to_string(), + ) + })?; + for info in buffer.as_slice() { + let name = subcategory_display_name(&info.AuditSubCategoryGuid)?; + policies.push(policy_from_info(info, name, Some(&category_name))?); + } + } + Ok(policies) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::subcategories::{CATEGORIES, LOGON_LOGOFF, SUBCATEGORIES}; + + const LOGON_GUID: u128 = 0x0CCE_9215_69AE_11D9_BED3_5050_5450_3030; + const BOGUS_GUID: u128 = 0x1111_1111_2222_3333_4444_5555_5555_5555; + + fn is_elevated() -> bool { + get_security_context() == SecurityContext::Admin + } + + #[test] + fn table_values_map_to_the_expected_guid_fields() { + let guid = GUID::from_u128(LOGON_GUID); + assert_eq!(guid.data1, 0x0CCE_9215); + assert_eq!(guid.data2, 0x69AE); + assert_eq!(guid.data3, 0x11D9); + assert_eq!(guid.data4, [0xBE, 0xD3, 0x50, 0x50, 0x54, 0x50, 0x30, 0x30]); + assert_eq!(guid.to_u128(), LOGON_GUID); + assert_eq!(format_guid(guid.to_u128()), "{0CCE9215-69AE-11D9-BED3-505054503030}"); + } + + // Enumeration and name lookup do not need elevation, so these run in any context. + #[test] + fn enumerates_categories_and_subcategories() { + let categories = enumerate_categories().unwrap(); + assert_eq!(categories.as_slice().len(), CATEGORIES.len()); + for category in categories.as_slice() { + assert!( + subcategories::category_name(category.to_u128()).is_some(), + "unknown category {}", + format_guid(category.to_u128()) + ); + } + + let all = enumerate_subcategories(None).unwrap(); + let known = all + .as_slice() + .iter() + .filter(|guid| subcategories::find_by_guid(guid.to_u128()).is_some()) + .count(); + assert!(known >= 50, "only {known} of {} reported subcategories are in the table", all.as_slice().len()); + + let logon_logoff = enumerate_subcategories(Some(&GUID::from_u128(LOGON_LOGOFF.guid))).unwrap(); + assert!(logon_logoff.as_slice().iter().any(|guid| guid.to_u128() == LOGON_GUID)); + assert!(logon_logoff.as_slice().len() < all.as_slice().len()); + assert!(logon_logoff.as_slice().len() <= SUBCATEGORIES.len()); + } + + #[test] + fn looks_up_names_for_known_guids_only() { + assert!(!lookup_subcategory_name(&GUID::from_u128(LOGON_GUID)).unwrap().is_empty()); + assert!(!lookup_category_name(&GUID::from_u128(LOGON_LOGOFF.guid)).unwrap().is_empty()); + assert!(lookup_subcategory_name(&GUID::from_u128(BOGUS_GUID)).is_err()); + assert!(lookup_category_name(&GUID::from_u128(BOGUS_GUID)).is_err()); + } + + #[test] + fn resolves_names_and_guids() { + let by_name = resolve_subcategory(" lOgOn ").unwrap(); + assert_eq!(by_name.guid.to_u128(), LOGON_GUID); + assert_eq!(by_name.name, "Logon"); + assert_eq!(by_name.category.as_deref(), Some("Logon/Logoff")); + + let by_guid = resolve_subcategory("{0cce9215-69ae-11d9-bed3-505054503030}").unwrap(); + assert_eq!(by_guid.guid.to_u128(), LOGON_GUID); + assert_eq!(by_guid.name, "Logon"); + + let bare = resolve_subcategory("0CCE9215-69AE-11D9-BED3-505054503030").unwrap(); + assert_eq!(bare.guid.to_u128(), LOGON_GUID); + + assert!(matches!(resolve_subcategory("Bogus Subcategory"), Err(AuditError::InvalidInput(_)))); + assert!(matches!(resolve_subcategory(&format_guid(BOGUS_GUID)), Err(AuditError::InvalidInput(_)))); + } + + #[test] + fn privilege_and_query_follow_the_security_context() { + let privilege = enable_security_privilege(); + let query = query_policy(&[GUID::from_u128(LOGON_GUID)]); + let input = AuditPolicy { + subcategory: "Logon".to_string(), + ..AuditPolicy::default() + }; + if is_elevated() { + privilege.unwrap(); + assert_eq!(query.unwrap().as_slice().len(), 1); + let policy = get_audit_policy(&input).unwrap(); + assert_eq!(policy.guid.as_deref(), Some("{0CCE9215-69AE-11D9-BED3-505054503030}")); + assert_eq!(policy.category.as_deref(), Some("Logon/Logoff")); + assert!(policy.success.is_some() && policy.failure.is_some()); + } else { + assert!(matches!(privilege, Err(AuditError::ElevationRequired))); + assert!(query.is_err()); + assert!(matches!(get_audit_policy(&input), Err(AuditError::ElevationRequired))); + assert!(matches!(set_audit_policy(&input, true), Err(AuditError::ElevationRequired))); + assert!(matches!(export_audit_policies(), Err(AuditError::ElevationRequired))); + } + } + + #[test] + fn empty_query_returns_an_empty_buffer() { + assert!(query_policy(&[]).unwrap().as_slice().is_empty()); + } +} diff --git a/resources/windows_audit_policy/src/main.rs b/resources/windows_audit_policy/src/main.rs new file mode 100644 index 000000000..82c01adde --- /dev/null +++ b/resources/windows_audit_policy/src/main.rs @@ -0,0 +1,216 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#[cfg_attr(not(windows), allow(dead_code))] +mod subcategories; +#[cfg_attr(not(windows), allow(dead_code))] +mod types; + +#[cfg(windows)] +mod audit; +#[cfg(windows)] +mod options; + +use rust_i18n::t; +use std::process::ExitCode; + +rust_i18n::i18n!("locales", fallback = "en-us"); + +const EXIT_SUCCESS: u8 = 0; +const EXIT_INVALID_ARGS: u8 = 1; +const EXIT_INVALID_INPUT: u8 = 2; +const EXIT_AUDIT_ERROR: u8 = 3; +const EXIT_ELEVATION_REQUIRED: u8 = 4; + +/// Write a JSON error object to stderr: `{"error":""}` +fn write_error(message: &str) { + eprintln!("{}", serde_json::json!({"error": message})); +} + +#[cfg(not(windows))] +fn main() -> ExitCode { + write_error(&t!("main.windowsOnly")); + ExitCode::from(EXIT_AUDIT_ERROR) +} + +#[cfg(windows)] +fn main() -> ExitCode { + use types::{AuditPolicy, AuditPolicyOption}; + + let args: Vec = std::env::args().collect(); + + if args.len() < 2 { + write_error(&t!("main.missingOperation")); + return ExitCode::from(EXIT_INVALID_ARGS); + } + + let operation = args[1].as_str(); + if !matches!(operation, "get" | "set" | "export") { + write_error(&t!("main.unknownOperation", operation = operation)); + return ExitCode::from(EXIT_INVALID_ARGS); + } + + let Some(target) = args.get(2) else { + write_error(&t!("main.missingTarget")); + return ExitCode::from(EXIT_INVALID_ARGS); + }; + + let input_json = match parse_input_arg(&args) { + Ok(input) => input, + Err(code) => return code, + }; + let what_if = parse_what_if_flag(&args); + + match (operation, target.as_str()) { + ("get", "subcategory") => match require_input::(input_json) { + Ok(input) => finish(audit::get_audit_policy(&input)), + Err(code) => code, + }, + ("set", "subcategory") => match require_input::(input_json) { + Ok(input) => finish(audit::set_audit_policy(&input, what_if)), + Err(code) => code, + }, + ("export", "subcategory") => finish_many(audit::export_audit_policies()), + ("get", "option") => match require_input::(input_json) { + Ok(input) => finish(options::get_option(&input)), + Err(code) => code, + }, + ("set", "option") => match require_input::(input_json) { + Ok(input) => finish(options::set_option(&input, what_if)), + Err(code) => code, + }, + ("export", "option") => finish_many(options::export_options()), + (_, target) => { + write_error(&t!("main.unknownTarget", target = target)); + ExitCode::from(EXIT_INVALID_ARGS) + } + } +} + +/// Deserialize the required JSON input, or return the exit code to use. +#[cfg(windows)] +fn require_input(input_json: Option) -> Result { + let Some(json) = input_json else { + write_error(&t!("main.missingInput")); + return Err(ExitCode::from(EXIT_INVALID_ARGS)); + }; + serde_json::from_str(&json).map_err(|error| { + write_error(&t!("main.invalidJson", error = error.to_string())); + ExitCode::from(EXIT_INVALID_INPUT) + }) +} + +/// Serialize a value to JSON and print it to stdout, or return the exit code to use. +#[cfg(windows)] +fn print_json(value: &impl serde::Serialize) -> Result<(), ExitCode> { + match serde_json::to_string(value) { + Ok(json) => { + println!("{json}"); + Ok(()) + } + Err(error) => { + write_error(&t!("main.serializeFailed", error = error.to_string())); + Err(ExitCode::from(EXIT_AUDIT_ERROR)) + } + } +} + +/// Map a resource error to its documented exit code. +#[cfg(windows)] +fn exit_code_for(error: &types::AuditError) -> ExitCode { + use types::AuditError; + + ExitCode::from(match error { + AuditError::ElevationRequired => EXIT_ELEVATION_REQUIRED, + AuditError::InvalidInput(_) => EXIT_INVALID_INPUT, + AuditError::Audit(_) => EXIT_AUDIT_ERROR, + }) +} + +/// Print a single result as JSON, or write the error and return its exit code. +#[cfg(windows)] +fn finish(result: Result) -> ExitCode { + match result { + Ok(value) => match print_json(&value) { + Ok(()) => ExitCode::from(EXIT_SUCCESS), + Err(code) => code, + }, + Err(error) => { + write_error(&error.to_string()); + exit_code_for(&error) + } + } +} + +/// Print each exported instance on its own line, or write the error and return its exit code. +#[cfg(windows)] +fn finish_many(result: Result, types::AuditError>) -> ExitCode { + match result { + Ok(values) => { + for value in &values { + if let Err(code) = print_json(value) { + return code; + } + } + ExitCode::from(EXIT_SUCCESS) + } + Err(error) => { + write_error(&error.to_string()); + exit_code_for(&error) + } + } +} + +/// Parse the `--input ` argument. Arguments 1 and 2 are the operation and the target. +#[cfg(windows)] +fn parse_input_arg(args: &[String]) -> Result, ExitCode> { + let mut index = 3; + while index < args.len() { + if args[index] == "--input" || args[index] == "-i" { + if index + 1 < args.len() { + return Ok(Some(args[index + 1].clone())); + } + write_error(&t!("main.missingInputValue")); + return Err(ExitCode::from(EXIT_INVALID_ARGS)); + } + index += 1; + } + Ok(None) +} + +/// Parse the `--what-if` / `-w` flag. Arguments 1 and 2 are the operation and the target. +#[cfg(windows)] +fn parse_what_if_flag(args: &[String]) -> bool { + args.iter().skip(3).any(|arg| arg == "--what-if" || arg == "-w") +} + +#[cfg(all(test, windows))] +mod tests { + use super::{parse_input_arg, parse_what_if_flag}; + + fn args(list: &[&str]) -> Vec { + list.iter().map(ToString::to_string).collect() + } + + #[test] + fn finds_long_and_short_input_arguments_after_the_target() { + assert_eq!( + parse_input_arg(&args(&["exe", "get", "subcategory", "--input", "{}"])), + Ok(Some("{}".to_string())) + ); + assert_eq!( + parse_input_arg(&args(&["exe", "set", "option", "-w", "-i", "[]"])), + Ok(Some("[]".to_string())) + ); + assert_eq!(parse_input_arg(&args(&["exe", "export", "subcategory"])), Ok(None)); + assert!(parse_input_arg(&args(&["exe", "get", "subcategory", "--input"])).is_err()); + } + + #[test] + fn finds_what_if_flag_after_the_target() { + assert!(parse_what_if_flag(&args(&["exe", "set", "subcategory", "--what-if", "--input", "{}"]))); + assert!(parse_what_if_flag(&args(&["exe", "set", "option", "--input", "{}", "-w"]))); + assert!(!parse_what_if_flag(&args(&["exe", "set", "subcategory", "--input", "{}"]))); + assert!(!parse_what_if_flag(&args(&["exe", "-w", "subcategory"]))); + } +} diff --git a/resources/windows_audit_policy/src/options.rs b/resources/windows_audit_policy/src/options.rs new file mode 100644 index 000000000..f6b69d174 --- /dev/null +++ b/resources/windows_audit_policy/src/options.rs @@ -0,0 +1,178 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Reads and writes the audit policy options stored as values under the LSA registry key. +//! These are the same values that `secedit` and Group Policy write; the LSA reads them at +//! startup, so a change takes effect after a restart. + +use crate::audit::require_elevated; +use crate::types::{AuditError, AuditOptionName, AuditPolicyOption, Metadata}; +use dsc_lib_registry::{RegistryHelper, config::RegistryValueData}; +use rust_i18n::t; + +const LSA_KEY: &str = r"HKLM\SYSTEM\CurrentControlSet\Control\Lsa"; + +fn helper(name: AuditOptionName, data: Option) -> Result { + RegistryHelper::new(LSA_KEY, Some(name.value_name().to_string()), data).map_err(|error| { + AuditError::Audit(t!("option.readFailed", name = name.to_string(), error = error.to_string()).to_string()) + }) +} + +/// Interpret a registry value as an option state. A missing value means disabled, and any +/// non-zero value (including `CrashOnAuditFail = 2`, the "already crashed" marker) means enabled. +/// +/// # Errors +/// +/// Returns an error when the value has a type other than `REG_DWORD`, `REG_QWORD` or `REG_BINARY`. +pub fn enabled_from_data(name: AuditOptionName, data: Option<&RegistryValueData>) -> Result { + match data { + None | Some(RegistryValueData::None) => Ok(false), + Some(RegistryValueData::DWord(value)) => Ok(*value != 0), + Some(RegistryValueData::QWord(value)) => Ok(*value != 0), + Some(RegistryValueData::Binary(bytes)) => Ok(bytes.first().is_some_and(|byte| *byte != 0)), + Some(_) => Err(AuditError::Audit(t!("option.unsupportedType", name = name.to_string()).to_string())), + } +} + +/// The registry data to write for an option. The existing value type is preserved; otherwise the +/// canonical type for the option is used. +#[must_use] +pub fn data_for(name: AuditOptionName, enabled: bool, current: Option<&RegistryValueData>) -> RegistryValueData { + match current { + Some(RegistryValueData::Binary(_)) => RegistryValueData::Binary(vec![u8::from(enabled)]), + Some(RegistryValueData::DWord(_)) => RegistryValueData::DWord(u32::from(enabled)), + _ if name.is_binary() => RegistryValueData::Binary(vec![u8::from(enabled)]), + _ => RegistryValueData::DWord(u32::from(enabled)), + } +} + +fn read_current(name: AuditOptionName) -> Result, AuditError> { + let state = helper(name, None)?.get().map_err(|error| { + AuditError::Audit(t!("option.readFailed", name = name.to_string(), error = error.to_string()).to_string()) + })?; + Ok(state.value_data) +} + +fn state(name: AuditOptionName, enabled: bool, metadata: Option) -> AuditPolicyOption { + AuditPolicyOption { + name, + enabled: Some(enabled), + metadata, + } +} + +/// Get the current state of one option. +/// +/// # Errors +/// +/// Returns an error when the registry value cannot be read or has an unexpected type. +pub fn get_option(input: &AuditPolicyOption) -> Result { + let current = read_current(input.name)?; + Ok(state(input.name, enabled_from_data(input.name, current.as_ref())?, None)) +} + +/// Set one option. In what-if mode nothing is changed and the projected state carries +/// `_metadata.whatIf`. +/// +/// # Errors +/// +/// Returns an error when `enabled` is missing, the process is not elevated, or the registry +/// value cannot be read or written. +pub fn set_option(input: &AuditPolicyOption, what_if: bool) -> Result { + let desired = input + .enabled + .ok_or_else(|| AuditError::InvalidInput(t!("option.enabledRequired").to_string()))?; + require_elevated()?; + + let current_data = read_current(input.name)?; + let current = enabled_from_data(input.name, current_data.as_ref())?; + if current == desired { + return Ok(state(input.name, current, None)); + } + + let message = t!( + "option.whatIfChangeEnabled", + name = input.name.to_string(), + current = current.to_string(), + desired = desired.to_string() + ) + .to_string(); + if what_if { + return Ok(state(input.name, desired, Metadata::from_messages(vec![message]))); + } + + helper(input.name, Some(data_for(input.name, desired, current_data.as_ref())))? + .set() + .map_err(|error| { + AuditError::Audit(t!("option.writeFailed", name = input.name.to_string(), error = error.to_string()).to_string()) + })?; + eprintln!( + "{}", + serde_json::json!({ "warn": t!("option.restartRequired", name = input.name.to_string()).to_string() }) + ); + get_option(input) +} + +/// Export the state of every option. +/// +/// # Errors +/// +/// Returns an error when any registry value cannot be read or has an unexpected type. +pub fn export_options() -> Result, AuditError> { + AuditOptionName::ALL + .iter() + .map(|name| { + get_option(&AuditPolicyOption { + name: *name, + enabled: None, + metadata: None, + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn enabled_follows_non_zero_values() { + let name = AuditOptionName::AuditBaseObjects; + assert!(!enabled_from_data(name, None).unwrap()); + assert!(!enabled_from_data(name, Some(&RegistryValueData::None)).unwrap()); + assert!(!enabled_from_data(name, Some(&RegistryValueData::DWord(0))).unwrap()); + assert!(enabled_from_data(name, Some(&RegistryValueData::DWord(1))).unwrap()); + assert!(enabled_from_data(AuditOptionName::CrashOnAuditFail, Some(&RegistryValueData::DWord(2))).unwrap()); + assert!(enabled_from_data(name, Some(&RegistryValueData::QWord(1))).unwrap()); + assert!(!enabled_from_data(name, Some(&RegistryValueData::Binary(Vec::new()))).unwrap()); + assert!(!enabled_from_data(name, Some(&RegistryValueData::Binary(vec![0]))).unwrap()); + assert!(enabled_from_data(name, Some(&RegistryValueData::Binary(vec![1]))).unwrap()); + assert!(enabled_from_data(name, Some(&RegistryValueData::String("1".to_string()))).is_err()); + } + + #[test] + fn data_preserves_existing_type_or_uses_the_canonical_one() { + let dword = RegistryValueData::DWord(0); + let binary = RegistryValueData::Binary(vec![0]); + assert_eq!( + data_for(AuditOptionName::AuditBaseObjects, true, Some(&dword)), + RegistryValueData::DWord(1) + ); + assert_eq!( + data_for(AuditOptionName::AuditBaseObjects, false, Some(&binary)), + RegistryValueData::Binary(vec![0]) + ); + assert_eq!( + data_for(AuditOptionName::AuditBaseObjects, true, None), + RegistryValueData::DWord(1) + ); + assert_eq!( + data_for(AuditOptionName::FullPrivilegeAuditing, true, None), + RegistryValueData::Binary(vec![1]) + ); + assert_eq!( + data_for(AuditOptionName::FullPrivilegeAuditing, false, Some(&dword)), + RegistryValueData::DWord(0) + ); + } +} diff --git a/resources/windows_audit_policy/src/subcategories.rs b/resources/windows_audit_policy/src/subcategories.rs new file mode 100644 index 000000000..45bd9f9a1 --- /dev/null +++ b/resources/windows_audit_policy/src/subcategories.rs @@ -0,0 +1,302 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Static table of the Windows advanced audit policy categories and subcategories. +//! +//! The Win32 audit APIs identify categories and subcategories by GUID and only return +//! localized display names. This table keeps the resource locale-independent: input is +//! matched against the English names (or a GUID) and output always uses the English names. +//! GUIDs are stored as `u128` in the byte order that `windows::core::GUID::from_u128` expects, +//! so `{0CCE9215-69AE-11D9-BED3-505054503030}` is `0x0CCE9215_69AE_11D9_BED3_505054503030`. + +use std::fmt::Write as _; + +/// An audit policy category. +#[derive(Debug, PartialEq, Eq)] +pub struct Category { + /// English category name as shown by `auditpol /list /category`. + pub name: &'static str, + /// Category GUID. + pub guid: u128, +} + +/// An audit policy subcategory. +#[derive(Debug, PartialEq, Eq)] +pub struct Subcategory { + /// English subcategory name as shown by `auditpol /list /subcategory:*`. + pub name: &'static str, + /// Subcategory GUID. + pub guid: u128, + /// The category that contains this subcategory. + pub category: &'static Category, +} + +/// Low 96 bits shared by every subcategory GUID (`-69AE-11D9-BED3-505054503030`). +const SUBCATEGORY_SUFFIX: u128 = 0x69AE_11D9_BED3_5050_5450_3030; +/// Low 96 bits shared by every category GUID (`-797A-11D9-BED3-505054503030`). +const CATEGORY_SUFFIX: u128 = 0x797A_11D9_BED3_5050_5450_3030; + +/// Build a subcategory GUID from its distinguishing first 32 bits. +#[allow(clippy::cast_lossless)] // `u128::from` cannot be used in a const fn +const fn subcategory_guid(prefix: u32) -> u128 { + ((prefix as u128) << 96) | SUBCATEGORY_SUFFIX +} + +/// Build a category GUID from its distinguishing first 32 bits. +#[allow(clippy::cast_lossless)] // `u128::from` cannot be used in a const fn +const fn category_guid(prefix: u32) -> u128 { + ((prefix as u128) << 96) | CATEGORY_SUFFIX +} + +pub static SYSTEM: Category = Category { name: "System", guid: category_guid(0x6997_9848) }; +pub static LOGON_LOGOFF: Category = Category { name: "Logon/Logoff", guid: category_guid(0x6997_9849) }; +pub static OBJECT_ACCESS: Category = Category { name: "Object Access", guid: category_guid(0x6997_984A) }; +pub static PRIVILEGE_USE: Category = Category { name: "Privilege Use", guid: category_guid(0x6997_984B) }; +pub static DETAILED_TRACKING: Category = Category { name: "Detailed Tracking", guid: category_guid(0x6997_984C) }; +pub static POLICY_CHANGE: Category = Category { name: "Policy Change", guid: category_guid(0x6997_984D) }; +pub static ACCOUNT_MANAGEMENT: Category = Category { name: "Account Management", guid: category_guid(0x6997_984E) }; +pub static DS_ACCESS: Category = Category { name: "DS Access", guid: category_guid(0x6997_984F) }; +pub static ACCOUNT_LOGON: Category = Category { name: "Account Logon", guid: category_guid(0x6997_9850) }; + +/// All known categories in the order `auditpol /list /subcategory:* /v` reports them. +pub static CATEGORIES: [&Category; 9] = [ + &SYSTEM, + &LOGON_LOGOFF, + &OBJECT_ACCESS, + &PRIVILEGE_USE, + &DETAILED_TRACKING, + &POLICY_CHANGE, + &ACCOUNT_MANAGEMENT, + &DS_ACCESS, + &ACCOUNT_LOGON, +]; + +macro_rules! subcategory { + ($name:literal, $prefix:literal, $category:ident) => { + Subcategory { + name: $name, + guid: subcategory_guid($prefix), + category: &$category, + } + }; +} + +/// All known subcategories, grouped by category. +pub static SUBCATEGORIES: [Subcategory; 60] = [ + // System + subcategory!("Security State Change", 0x0CCE_9210, SYSTEM), + subcategory!("Security System Extension", 0x0CCE_9211, SYSTEM), + subcategory!("System Integrity", 0x0CCE_9212, SYSTEM), + subcategory!("IPsec Driver", 0x0CCE_9213, SYSTEM), + subcategory!("Other System Events", 0x0CCE_9214, SYSTEM), + // Logon/Logoff + subcategory!("Logon", 0x0CCE_9215, LOGON_LOGOFF), + subcategory!("Logoff", 0x0CCE_9216, LOGON_LOGOFF), + subcategory!("Account Lockout", 0x0CCE_9217, LOGON_LOGOFF), + subcategory!("IPsec Main Mode", 0x0CCE_9218, LOGON_LOGOFF), + subcategory!("IPsec Quick Mode", 0x0CCE_9219, LOGON_LOGOFF), + subcategory!("IPsec Extended Mode", 0x0CCE_921A, LOGON_LOGOFF), + subcategory!("Special Logon", 0x0CCE_921B, LOGON_LOGOFF), + subcategory!("Other Logon/Logoff Events", 0x0CCE_921C, LOGON_LOGOFF), + subcategory!("Network Policy Server", 0x0CCE_9243, LOGON_LOGOFF), + subcategory!("User / Device Claims", 0x0CCE_9247, LOGON_LOGOFF), + subcategory!("Group Membership", 0x0CCE_9249, LOGON_LOGOFF), + subcategory!("Access Rights", 0x0CCE_924B, LOGON_LOGOFF), + // Object Access + subcategory!("File System", 0x0CCE_921D, OBJECT_ACCESS), + subcategory!("Registry", 0x0CCE_921E, OBJECT_ACCESS), + subcategory!("Kernel Object", 0x0CCE_921F, OBJECT_ACCESS), + subcategory!("SAM", 0x0CCE_9220, OBJECT_ACCESS), + subcategory!("Certification Services", 0x0CCE_9221, OBJECT_ACCESS), + subcategory!("Application Generated", 0x0CCE_9222, OBJECT_ACCESS), + subcategory!("Handle Manipulation", 0x0CCE_9223, OBJECT_ACCESS), + subcategory!("File Share", 0x0CCE_9224, OBJECT_ACCESS), + subcategory!("Filtering Platform Packet Drop", 0x0CCE_9225, OBJECT_ACCESS), + subcategory!("Filtering Platform Connection", 0x0CCE_9226, OBJECT_ACCESS), + subcategory!("Other Object Access Events", 0x0CCE_9227, OBJECT_ACCESS), + subcategory!("Detailed File Share", 0x0CCE_9244, OBJECT_ACCESS), + subcategory!("Removable Storage", 0x0CCE_9245, OBJECT_ACCESS), + subcategory!("Central Policy Staging", 0x0CCE_9246, OBJECT_ACCESS), + // Privilege Use + subcategory!("Sensitive Privilege Use", 0x0CCE_9228, PRIVILEGE_USE), + subcategory!("Non Sensitive Privilege Use", 0x0CCE_9229, PRIVILEGE_USE), + subcategory!("Other Privilege Use Events", 0x0CCE_922A, PRIVILEGE_USE), + // Detailed Tracking + subcategory!("Process Creation", 0x0CCE_922B, DETAILED_TRACKING), + subcategory!("Process Termination", 0x0CCE_922C, DETAILED_TRACKING), + subcategory!("DPAPI Activity", 0x0CCE_922D, DETAILED_TRACKING), + subcategory!("RPC Events", 0x0CCE_922E, DETAILED_TRACKING), + subcategory!("Plug and Play Events", 0x0CCE_9248, DETAILED_TRACKING), + subcategory!("Token Right Adjusted Events", 0x0CCE_924A, DETAILED_TRACKING), + // Policy Change + subcategory!("Audit Policy Change", 0x0CCE_922F, POLICY_CHANGE), + subcategory!("Authentication Policy Change", 0x0CCE_9230, POLICY_CHANGE), + subcategory!("Authorization Policy Change", 0x0CCE_9231, POLICY_CHANGE), + subcategory!("MPSSVC Rule-Level Policy Change", 0x0CCE_9232, POLICY_CHANGE), + subcategory!("Filtering Platform Policy Change", 0x0CCE_9233, POLICY_CHANGE), + subcategory!("Other Policy Change Events", 0x0CCE_9234, POLICY_CHANGE), + // Account Management + subcategory!("User Account Management", 0x0CCE_9235, ACCOUNT_MANAGEMENT), + subcategory!("Computer Account Management", 0x0CCE_9236, ACCOUNT_MANAGEMENT), + subcategory!("Security Group Management", 0x0CCE_9237, ACCOUNT_MANAGEMENT), + subcategory!("Distribution Group Management", 0x0CCE_9238, ACCOUNT_MANAGEMENT), + subcategory!("Application Group Management", 0x0CCE_9239, ACCOUNT_MANAGEMENT), + subcategory!("Other Account Management Events", 0x0CCE_923A, ACCOUNT_MANAGEMENT), + // DS Access + subcategory!("Directory Service Access", 0x0CCE_923B, DS_ACCESS), + subcategory!("Directory Service Changes", 0x0CCE_923C, DS_ACCESS), + subcategory!("Directory Service Replication", 0x0CCE_923D, DS_ACCESS), + subcategory!("Detailed Directory Service Replication", 0x0CCE_923E, DS_ACCESS), + // Account Logon + subcategory!("Credential Validation", 0x0CCE_923F, ACCOUNT_LOGON), + subcategory!("Kerberos Service Ticket Operations", 0x0CCE_9240, ACCOUNT_LOGON), + subcategory!("Other Account Logon Events", 0x0CCE_9241, ACCOUNT_LOGON), + subcategory!("Kerberos Authentication Service", 0x0CCE_9242, ACCOUNT_LOGON), +]; + +/// Find a subcategory by its English name, ignoring case and surrounding whitespace. +#[must_use] +pub fn find_by_name(name: &str) -> Option<&'static Subcategory> { + let name = name.trim(); + SUBCATEGORIES.iter().find(|entry| entry.name.eq_ignore_ascii_case(name)) +} + +/// Find a subcategory by its GUID. +#[must_use] +pub fn find_by_guid(guid: u128) -> Option<&'static Subcategory> { + SUBCATEGORIES.iter().find(|entry| entry.guid == guid) +} + +/// Find the English name of a category by its GUID. +#[must_use] +pub fn category_name(guid: u128) -> Option<&'static str> { + CATEGORIES.iter().find(|entry| entry.guid == guid).map(|entry| entry.name) +} + +/// Parse a GUID written as `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`, with or without braces, +/// in either case. Returns `None` for anything that is not a well-formed GUID. +#[must_use] +pub fn parse_guid(text: &str) -> Option { + let trimmed = text.trim(); + let inner = match (trimmed.strip_prefix('{'), trimmed.strip_suffix('}')) { + (Some(_), Some(_)) => &trimmed[1..trimmed.len() - 1], + (None, None) => trimmed, + _ => return None, + }; + let parts: Vec<&str> = inner.split('-').collect(); + if parts.len() != 5 { + return None; + } + const GROUP_LENGTHS: [usize; 5] = [8, 4, 4, 4, 12]; + for (part, expected) in parts.iter().zip(GROUP_LENGTHS) { + if part.len() != expected || !part.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return None; + } + } + u128::from_str_radix(&parts.concat(), 16).ok() +} + +/// Format a GUID as `{XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX}` (braces, upper-case). +#[must_use] +pub fn format_guid(guid: u128) -> String { + let bytes = guid.to_be_bytes(); + let mut text = String::with_capacity(38); + text.push('{'); + for (index, byte) in bytes.iter().enumerate() { + if matches!(index, 4 | 6 | 8 | 10) { + text.push('-'); + } + // Writing to a String cannot fail. + let _ = write!(text, "{byte:02X}"); + } + text.push('}'); + text +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + const LOGON_GUID: u128 = 0x0CCE_9215_69AE_11D9_BED3_5050_5450_3030; + const LOGON_TEXT: &str = "{0CCE9215-69AE-11D9-BED3-505054503030}"; + + #[test] + fn table_has_sixty_unique_entries() { + assert_eq!(SUBCATEGORIES.len(), 60); + let names: HashSet = SUBCATEGORIES.iter().map(|s| s.name.to_ascii_lowercase()).collect(); + assert_eq!(names.len(), 60, "subcategory names must be unique"); + let guids: HashSet = SUBCATEGORIES.iter().map(|s| s.guid).collect(); + assert_eq!(guids.len(), 60, "subcategory GUIDs must be unique"); + } + + #[test] + fn guids_share_the_expected_suffixes() { + const LOW_96_BITS: u128 = (1 << 96) - 1; + for entry in &SUBCATEGORIES { + assert_eq!(entry.guid & LOW_96_BITS, SUBCATEGORY_SUFFIX, "{}", entry.name); + } + for category in CATEGORIES { + assert_eq!(category.guid & LOW_96_BITS, CATEGORY_SUFFIX, "{}", category.name); + } + assert_eq!(CATEGORIES.len(), 9); + } + + #[test] + fn every_category_has_subcategories() { + for category in CATEGORIES { + assert!( + SUBCATEGORIES.iter().any(|s| std::ptr::eq(s.category, category)), + "{} has no subcategories", + category.name + ); + } + } + + #[test] + fn find_by_name_ignores_case_and_whitespace() { + assert_eq!(find_by_name("Logon").map(|s| s.guid), Some(LOGON_GUID)); + assert_eq!(find_by_name(" lOgOn ").map(|s| s.guid), Some(LOGON_GUID)); + assert_eq!(find_by_name("Logon ").map(|s| s.category.name), Some("Logon/Logoff")); + assert!(find_by_name("Logons").is_none()); + assert!(find_by_name("").is_none()); + } + + #[test] + fn find_by_guid_and_category_name() { + assert_eq!(find_by_guid(LOGON_GUID).map(|s| s.name), Some("Logon")); + assert!(find_by_guid(0).is_none()); + assert_eq!(category_name(LOGON_LOGOFF.guid), Some("Logon/Logoff")); + assert!(category_name(LOGON_GUID).is_none()); + } + + #[test] + fn parse_guid_accepts_braced_bare_and_lowercase() { + assert_eq!(parse_guid(LOGON_TEXT), Some(LOGON_GUID)); + assert_eq!(parse_guid("0CCE9215-69AE-11D9-BED3-505054503030"), Some(LOGON_GUID)); + assert_eq!(parse_guid("{0cce9215-69ae-11d9-bed3-505054503030}"), Some(LOGON_GUID)); + assert_eq!(parse_guid(" {0cce9215-69ae-11d9-bed3-505054503030} "), Some(LOGON_GUID)); + } + + #[test] + fn parse_guid_rejects_malformed_input() { + assert!(parse_guid("Logon").is_none()); + assert!(parse_guid("").is_none()); + assert!(parse_guid("{}").is_none()); + assert!(parse_guid("{0CCE9215-69AE-11D9-BED3-505054503030").is_none()); + assert!(parse_guid("0CCE9215-69AE-11D9-BED3-505054503030}").is_none()); + assert!(parse_guid("0CCE921569AE11D9BED3505054503030").is_none()); + assert!(parse_guid("0CCE9215-69AE-11D9-BED3-50505450303").is_none()); + assert!(parse_guid("0CCE9215-69AE-11D9-BED3-50505450303G").is_none()); + assert!(parse_guid("0CCE9215-69AE-11D9-BED3-505054503030-00").is_none()); + } + + #[test] + fn format_guid_round_trips() { + assert_eq!(format_guid(LOGON_GUID), LOGON_TEXT); + for entry in &SUBCATEGORIES { + assert_eq!(parse_guid(&format_guid(entry.guid)), Some(entry.guid)); + } + assert_eq!(format_guid(0), "{00000000-0000-0000-0000-000000000000}"); + } +} diff --git a/resources/windows_audit_policy/src/types.rs b/resources/windows_audit_policy/src/types.rs new file mode 100644 index 000000000..de64b9e63 --- /dev/null +++ b/resources/windows_audit_policy/src/types.rs @@ -0,0 +1,251 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use rust_i18n::t; +use serde::{Deserialize, Serialize}; + +/// Desired or actual state of one audit policy subcategory (`Microsoft.Windows/AuditPolicy`). +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AuditPolicy { + /// The subcategory, as its English name (case-insensitive) or its GUID. Echoed back as given. + pub subcategory: String, + + /// Whether successful events in the subcategory are audited. + #[serde(skip_serializing_if = "Option::is_none")] + pub success: Option, + + /// Whether failed events in the subcategory are audited. + #[serde(skip_serializing_if = "Option::is_none")] + pub failure: Option, + + /// Read-only: the subcategory GUID, e.g. `{0CCE9215-69AE-11D9-BED3-505054503030}`. + #[serde(skip_serializing_if = "Option::is_none")] + pub guid: Option, + + /// Read-only: the English name of the category that contains the subcategory. + #[serde(skip_serializing_if = "Option::is_none")] + pub category: Option, + + /// Metadata returned by what-if operations describing the changes that would be applied. + #[serde(rename = "_metadata", skip_serializing_if = "Option::is_none")] + pub metadata: Option, +} + +/// The audit policy options stored as values under `HKLM\SYSTEM\CurrentControlSet\Control\Lsa`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum AuditOptionName { + /// Shut down the system immediately if it is unable to log security audits (`REG_DWORD`). + CrashOnAuditFail, + /// Audit the use of the Backup and Restore privileges (`REG_BINARY`, one byte). + FullPrivilegeAuditing, + /// Audit the access of global system objects (`REG_DWORD`). + AuditBaseObjects, + /// Audit the access of global system directories (`REG_DWORD`). + AuditBaseDirectories, +} + +impl AuditOptionName { + /// Every option, in the order `export` reports them. + pub const ALL: [Self; 4] = [ + Self::CrashOnAuditFail, + Self::FullPrivilegeAuditing, + Self::AuditBaseObjects, + Self::AuditBaseDirectories, + ]; + + /// The registry value name backing the option. + #[must_use] + pub fn value_name(self) -> &'static str { + match self { + Self::CrashOnAuditFail => "CrashOnAuditFail", + Self::FullPrivilegeAuditing => "FullPrivilegeAuditing", + Self::AuditBaseObjects => "AuditBaseObjects", + Self::AuditBaseDirectories => "AuditBaseDirectories", + } + } + + /// Whether Windows stores the option as a one-byte `REG_BINARY` value instead of a `REG_DWORD`. + #[must_use] + pub fn is_binary(self) -> bool { + matches!(self, Self::FullPrivilegeAuditing) + } +} + +impl std::fmt::Display for AuditOptionName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.value_name()) + } +} + +/// Desired or actual state of one audit policy option (`Microsoft.Windows/AuditPolicyOption`). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AuditPolicyOption { + /// The option to manage. + pub name: AuditOptionName, + + /// Whether the option is enabled. + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, + + /// Metadata returned by what-if operations describing the changes that would be applied. + #[serde(rename = "_metadata", skip_serializing_if = "Option::is_none")] + pub metadata: Option, +} + +/// Metadata returned by what-if operations. +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Metadata { + /// Human-readable descriptions of the changes that would be applied. + #[serde(skip_serializing_if = "Option::is_none")] + pub what_if: Option>, +} + +impl Metadata { + /// Wrap what-if messages, returning `None` when there is nothing to report. + #[must_use] + pub fn from_messages(messages: Vec) -> Option { + if messages.is_empty() { + None + } else { + Some(Self { what_if: Some(messages) }) + } + } +} + +/// `AuditingInformation` bit for success auditing (`POLICY_AUDIT_EVENT_SUCCESS`). +pub const AUDIT_SUCCESS: u32 = 0x1; +/// `AuditingInformation` bit for failure auditing (`POLICY_AUDIT_EVENT_FAILURE`). +pub const AUDIT_FAILURE: u32 = 0x2; +/// `AuditingInformation` value that explicitly disables auditing (`POLICY_AUDIT_EVENT_NONE`). +pub const AUDIT_NONE: u32 = 0x4; + +/// The success and failure auditing flags of a subcategory. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AuditFlags { + pub success: bool, + pub failure: bool, +} + +impl AuditFlags { + /// Decode an `AuditingInformation` value. Only the success and failure bits are inspected, + /// so both `POLICY_AUDIT_EVENT_UNCHANGED` (0) and `POLICY_AUDIT_EVENT_NONE` (4) decode as + /// "no auditing". + #[must_use] + pub fn from_bits(bits: u32) -> Self { + Self { + success: bits & AUDIT_SUCCESS != 0, + failure: bits & AUDIT_FAILURE != 0, + } + } + + /// Encode as an `AuditingInformation` value for `AuditSetSystemPolicy`. When neither flag is + /// set this returns `POLICY_AUDIT_EVENT_NONE`, never 0, because 0 means "leave unchanged". + #[must_use] + pub fn to_bits(self) -> u32 { + let bits = (u32::from(self.success) * AUDIT_SUCCESS) | (u32::from(self.failure) * AUDIT_FAILURE); + if bits == 0 { AUDIT_NONE } else { bits } + } +} + +/// Errors from the audit policy resource. +#[derive(Debug)] +pub enum AuditError { + /// The operation needs an elevated process. + ElevationRequired, + /// The input is well-formed JSON but not usable (unknown subcategory, missing property). + InvalidInput(String), + /// A Win32, LSA or registry operation failed. + Audit(String), +} + +impl std::fmt::Display for AuditError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::ElevationRequired => f.write_str(&t!("main.elevationRequired")), + Self::InvalidInput(message) | Self::Audit(message) => f.write_str(message), + } + } +} + +impl std::error::Error for AuditError {} + +impl From for AuditError { + fn from(message: String) -> Self { + Self::Audit(message) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn flags_decode_only_the_success_and_failure_bits() { + let off = AuditFlags { success: false, failure: false }; + assert_eq!(AuditFlags::from_bits(0), off); + assert_eq!(AuditFlags::from_bits(AUDIT_NONE), off); + assert_eq!(AuditFlags::from_bits(1), AuditFlags { success: true, failure: false }); + assert_eq!(AuditFlags::from_bits(2), AuditFlags { success: false, failure: true }); + assert_eq!(AuditFlags::from_bits(3), AuditFlags { success: true, failure: true }); + assert_eq!(AuditFlags::from_bits(5), AuditFlags { success: true, failure: false }); + } + + #[test] + fn flags_encode_none_when_both_are_off() { + assert_eq!(AuditFlags { success: false, failure: false }.to_bits(), AUDIT_NONE); + assert_eq!(AuditFlags { success: true, failure: false }.to_bits(), 1); + assert_eq!(AuditFlags { success: false, failure: true }.to_bits(), 2); + assert_eq!(AuditFlags { success: true, failure: true }.to_bits(), 3); + } + + #[test] + fn flags_round_trip() { + for bits in 1..=3 { + assert_eq!(AuditFlags::from_bits(bits).to_bits(), bits); + } + } + + #[test] + fn option_name_serializes_as_its_registry_value_name() { + for name in AuditOptionName::ALL { + let json = serde_json::to_string(&name).unwrap(); + assert_eq!(json, format!("\"{}\"", name.value_name())); + let parsed: AuditOptionName = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, name); + } + assert!(serde_json::from_str::("\"NotAnOption\"").is_err()); + assert!(AuditOptionName::FullPrivilegeAuditing.is_binary()); + assert!(!AuditOptionName::CrashOnAuditFail.is_binary()); + } + + #[test] + fn audit_policy_rejects_unknown_fields_and_requires_subcategory() { + assert!(serde_json::from_str::(r#"{"subcategory":"Logon","bogus":1}"#).is_err()); + assert!(serde_json::from_str::(r#"{"success":true}"#).is_err()); + let policy: AuditPolicy = serde_json::from_str(r#"{"subcategory":"Logon","success":true}"#).unwrap(); + assert_eq!(policy.subcategory, "Logon"); + assert_eq!(policy.success, Some(true)); + assert_eq!(policy.failure, None); + assert_eq!(serde_json::to_string(&policy).unwrap(), r#"{"subcategory":"Logon","success":true}"#); + } + + #[test] + fn option_rejects_unknown_fields_and_requires_name() { + assert!(serde_json::from_str::(r#"{"enabled":true}"#).is_err()); + assert!(serde_json::from_str::(r#"{"name":"AuditBaseObjects","bogus":1}"#).is_err()); + let option: AuditPolicyOption = + serde_json::from_str(r#"{"name":"AuditBaseObjects","enabled":false}"#).unwrap(); + assert_eq!(option.name, AuditOptionName::AuditBaseObjects); + assert_eq!(option.enabled, Some(false)); + } + + #[test] + fn metadata_is_none_without_messages() { + assert!(Metadata::from_messages(Vec::new()).is_none()); + let metadata = Metadata::from_messages(vec!["Would change".to_string()]).unwrap(); + assert_eq!(metadata.what_if.unwrap().len(), 1); + } +} diff --git a/resources/windows_audit_policy/tests/windows_audit_policy_export.tests.ps1 b/resources/windows_audit_policy/tests/windows_audit_policy_export.tests.ps1 new file mode 100644 index 000000000..d0ebc14eb --- /dev/null +++ b/resources/windows_audit_policy/tests/windows_audit_policy_export.tests.ps1 @@ -0,0 +1,92 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Windows audit policy export tests' -Skip:(!$IsWindows) { + BeforeDiscovery { + $isAdmin = if ($IsWindows) { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]$identity + $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + } + else { + $false + } + } + + BeforeAll { + $resourceType = 'Microsoft.Windows/AuditPolicy' + $expectedCategories = @( + 'System' + 'Logon/Logoff' + 'Object Access' + 'Privilege Use' + 'Detailed Tracking' + 'Policy Change' + 'Account Management' + 'DS Access' + 'Account Logon' + ) + } + + Context 'Export all subcategories' -Skip:(!$isAdmin) { + BeforeAll { + $raw = dsc resource export -r $resourceType 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $script:export = $raw | ConvertFrom-Json + } + + It 'Returns every subcategory' { + $script:export.resources.Count | Should -BeGreaterOrEqual 50 + } + + It 'Returns complete, well-formed entries with the correct type' { + foreach ($resource in $script:export.resources) { + $resource.type | Should -BeExactly $resourceType + $resource.properties.subcategory | Should -Not -BeNullOrEmpty + $resource.properties.guid | Should -Match '^\{[0-9A-F]{8}(-[0-9A-F]{4}){3}-[0-9A-F]{12}\}$' + $resource.properties.category | Should -Not -BeNullOrEmpty + $resource.properties.success | Should -BeOfType [bool] + $resource.properties.failure | Should -BeOfType [bool] + } + } + + It 'Uses the canonical English subcategory names' { + $names = $script:export.resources.properties.subcategory + $names | Should -Contain 'Logon' + $names | Should -Contain 'Credential Validation' + $names | Should -Contain 'Security State Change' + $logon = $script:export.resources | Where-Object { $_.properties.subcategory -eq 'Logon' } + $logon.properties.guid | Should -BeExactly '{0CCE9215-69AE-11D9-BED3-505054503030}' + $logon.properties.category | Should -BeExactly 'Logon/Logoff' + } + + It 'Covers every category and groups entries by category' { + $categories = @($script:export.resources.properties.category) + $unique = @($categories | Select-Object -Unique) + $unique.Count | Should -Be $expectedCategories.Count + foreach ($expected in $expectedCategories) { + $unique | Should -Contain $expected + } + # Each category must appear as one contiguous block. + $blocks = 1 + for ($i = 1; $i -lt $categories.Count; $i++) { + if ($categories[$i] -ne $categories[$i - 1]) { + $blocks++ + } + } + $blocks | Should -Be $unique.Count + } + + It 'Exported entries round-trip through get' { + foreach ($resource in $script:export.resources | Select-Object -First 3) { + $json = @{ subcategory = $resource.properties.guid } | ConvertTo-Json -Compress + $out = $json | dsc resource get -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $actual = ($out | ConvertFrom-Json).actualState + $actual.success | Should -Be $resource.properties.success + $actual.failure | Should -Be $resource.properties.failure + $actual.category | Should -BeExactly $resource.properties.category + } + } + } +} diff --git a/resources/windows_audit_policy/tests/windows_audit_policy_get.tests.ps1 b/resources/windows_audit_policy/tests/windows_audit_policy_get.tests.ps1 new file mode 100644 index 000000000..1c7fc8ef2 --- /dev/null +++ b/resources/windows_audit_policy/tests/windows_audit_policy_get.tests.ps1 @@ -0,0 +1,121 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Windows audit policy get tests' -Skip:(!$IsWindows) { + BeforeDiscovery { + $isAdmin = if ($IsWindows) { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]$identity + $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + } + else { + $false + } + $isEnglish = (Get-Culture).Name -like 'en-*' + } + + BeforeAll { + $resourceType = 'Microsoft.Windows/AuditPolicy' + $logonGuid = '{0CCE9215-69AE-11D9-BED3-505054503030}' + + function Get-AuditPolicyState { + param([string]$Subcategory) + $json = @{ subcategory = $Subcategory } | ConvertTo-Json -Compress + $out = $json | dsc resource get -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + return ($out | ConvertFrom-Json).actualState + } + } + + Context 'Get by name' -Skip:(!$isAdmin) { + It 'Returns the policy for a subcategory by English name' { + $result = Get-AuditPolicyState -Subcategory 'Logon' + $result.subcategory | Should -BeExactly 'Logon' + $result.guid | Should -BeExactly $logonGuid + $result.category | Should -BeExactly 'Logon/Logoff' + $result.success | Should -BeOfType [bool] + $result.failure | Should -BeOfType [bool] + } + + It 'Matches the name case-insensitively and echoes the input' { + $result = Get-AuditPolicyState -Subcategory 'lOgOn' + $result.subcategory | Should -BeExactly 'lOgOn' + $result.guid | Should -BeExactly $logonGuid + } + + It 'Resolves to category ' -TestCases @( + @{ subcategory = 'Security State Change'; category = 'System' } + @{ subcategory = 'Logoff'; category = 'Logon/Logoff' } + @{ subcategory = 'File System'; category = 'Object Access' } + @{ subcategory = 'Sensitive Privilege Use'; category = 'Privilege Use' } + @{ subcategory = 'Process Creation'; category = 'Detailed Tracking' } + @{ subcategory = 'Audit Policy Change'; category = 'Policy Change' } + @{ subcategory = 'User Account Management'; category = 'Account Management' } + @{ subcategory = 'Directory Service Access'; category = 'DS Access' } + @{ subcategory = 'Credential Validation'; category = 'Account Logon' } + ) { + $result = Get-AuditPolicyState -Subcategory $subcategory + $result.subcategory | Should -BeExactly $subcategory + $result.category | Should -BeExactly $category + $result.guid | Should -Match '^\{[0-9A-F]{8}(-[0-9A-F]{4}){3}-[0-9A-F]{12}\}$' + } + + It 'Agrees with auditpol' -Skip:(!$isEnglish) { + $result = Get-AuditPolicyState -Subcategory 'Logon' + $row = auditpol /get /subcategory:"Logon" /r | Where-Object { $_ -match ',' } | ConvertFrom-Csv + $setting = $row.'Inclusion Setting' + $result.success | Should -Be ($setting -match 'Success') + $result.failure | Should -Be ($setting -match 'Failure') + } + } + + Context 'Get by GUID' -Skip:(!$isAdmin) { + It 'Accepts a braced GUID in any case and echoes the input' { + $guidInput = '{0cce9215-69ae-11d9-bed3-505054503030}' + $result = Get-AuditPolicyState -Subcategory $guidInput + $result.subcategory | Should -BeExactly $guidInput + $result.guid | Should -BeExactly $logonGuid + $result.category | Should -BeExactly 'Logon/Logoff' + } + + It 'Accepts a bare GUID' { + $result = Get-AuditPolicyState -Subcategory '0CCE9215-69AE-11D9-BED3-505054503030' + $result.guid | Should -BeExactly $logonGuid + $result.category | Should -BeExactly 'Logon/Logoff' + } + } + + Context 'Input validation' -Skip:(!$isAdmin) { + It 'Fails for an unknown subcategory name' { + $json = @{ subcategory = 'Not A Subcategory' } | ConvertTo-Json -Compress + $null = $json | dsc resource get -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Not -Be 0 + Get-Content -Raw $testdrive/error.log | Should -Match 'Unknown audit policy subcategory' + } + + It 'Fails for a GUID that is not a subcategory' { + $json = @{ subcategory = '{11111111-2222-3333-4444-555555555555}' } | ConvertTo-Json -Compress + $null = $json | dsc resource get -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Not -Be 0 + } + + It 'Fails when subcategory is missing' { + $json = @{ success = $true } | ConvertTo-Json -Compress + $null = $json | dsc resource get -r $resourceType -f - 2>&1 + $LASTEXITCODE | Should -Not -Be 0 + } + + It 'Fails when input JSON is invalid' { + $null = 'not-json' | dsc resource get -r $resourceType -f - 2>&1 + $LASTEXITCODE | Should -Not -Be 0 + } + } + + Context 'Without elevation' -Skip:($isAdmin) { + It 'Fails because the resource requires an elevated process' { + $json = @{ subcategory = 'Logon' } | ConvertTo-Json -Compress + $null = $json | dsc resource get -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Not -Be 0 + } + } +} diff --git a/resources/windows_audit_policy/tests/windows_audit_policy_option.tests.ps1 b/resources/windows_audit_policy/tests/windows_audit_policy_option.tests.ps1 new file mode 100644 index 000000000..791f2a4b6 --- /dev/null +++ b/resources/windows_audit_policy/tests/windows_audit_policy_option.tests.ps1 @@ -0,0 +1,151 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Windows audit policy option tests' -Skip:(!$IsWindows) { + BeforeDiscovery { + $isAdmin = if ($IsWindows) { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]$identity + $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + } + else { + $false + } + } + + BeforeAll { + $resourceType = 'Microsoft.Windows/AuditPolicyOption' + $lsaKey = 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' + + function Get-RawOptionValue { + param([string]$Name) + (Get-ItemProperty -Path $lsaKey -Name $Name -ErrorAction SilentlyContinue).$Name + } + + function ConvertTo-Enabled { + param($Raw) + if ($null -eq $Raw) { + return $false + } + if ($Raw -is [byte[]]) { + return ($Raw.Length -gt 0 -and $Raw[0] -ne 0) + } + return ($Raw -ne 0) + } + + function Restore-RawOptionValue { + param([string]$Name, $Raw) + if ($null -eq $Raw) { + Remove-ItemProperty -Path $lsaKey -Name $Name -ErrorAction SilentlyContinue + } + else { + Set-ItemProperty -Path $lsaKey -Name $Name -Value $Raw + } + } + + function Get-OptionState { + param([string]$Name) + $json = @{ name = $Name } | ConvertTo-Json -Compress + $out = $json | dsc resource get -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + return ($out | ConvertFrom-Json).actualState + } + + function Set-OptionState { + param([string]$Name, [bool]$Enabled) + $json = @{ name = $Name; enabled = $Enabled } | ConvertTo-Json -Compress + $out = $json | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + return ($out | ConvertFrom-Json) + } + } + + Context 'Get' { + It 'Returns the option state from the registry' -TestCases @( + @{ name = 'CrashOnAuditFail' } + @{ name = 'FullPrivilegeAuditing' } + @{ name = 'AuditBaseObjects' } + @{ name = 'AuditBaseDirectories' } + ) { + $result = Get-OptionState -Name $name + $result.name | Should -BeExactly $name + $result.enabled | Should -BeOfType [bool] + $result.enabled | Should -Be (ConvertTo-Enabled -Raw (Get-RawOptionValue -Name $name)) + } + + It 'Fails for an unknown option name' { + $json = @{ name = 'NotAnOption' } | ConvertTo-Json -Compress + $null = $json | dsc resource get -r $resourceType -f - 2>&1 + $LASTEXITCODE | Should -Not -Be 0 + } + + It 'Fails when name is missing' { + $json = @{ enabled = $true } | ConvertTo-Json -Compress + $null = $json | dsc resource get -r $resourceType -f - 2>&1 + $LASTEXITCODE | Should -Not -Be 0 + } + } + + Context 'Export' { + It 'Returns all four options' { + $raw = dsc resource export -r $resourceType 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $result = $raw | ConvertFrom-Json + $result.resources.Count | Should -Be 4 + foreach ($expected in 'CrashOnAuditFail', 'FullPrivilegeAuditing', 'AuditBaseObjects', 'AuditBaseDirectories') { + $result.resources.properties.name | Should -Contain $expected + } + foreach ($resource in $result.resources) { + $resource.type | Should -BeExactly $resourceType + $resource.properties.enabled | Should -BeOfType [bool] + } + } + } + + Context 'Set' -Skip:(!$isAdmin) { + BeforeAll { + $script:originalAuditBaseObjects = Get-RawOptionValue -Name 'AuditBaseObjects' + $script:originalFullPrivilegeAuditing = Get-RawOptionValue -Name 'FullPrivilegeAuditing' + } + + AfterAll { + Restore-RawOptionValue -Name 'AuditBaseObjects' -Raw $script:originalAuditBaseObjects + Restore-RawOptionValue -Name 'FullPrivilegeAuditing' -Raw $script:originalFullPrivilegeAuditing + } + + It 'Enables AuditBaseObjects' { + $result = (Set-OptionState -Name 'AuditBaseObjects' -Enabled $true).afterState + $result.name | Should -BeExactly 'AuditBaseObjects' + $result.enabled | Should -BeTrue + Get-RawOptionValue -Name 'AuditBaseObjects' | Should -Be 1 + } + + It 'Disables AuditBaseObjects' { + $result = (Set-OptionState -Name 'AuditBaseObjects' -Enabled $false).afterState + $result.enabled | Should -BeFalse + Get-RawOptionValue -Name 'AuditBaseObjects' | Should -Be 0 + } + + It 'Is idempotent when already in the desired state' { + $output = Set-OptionState -Name 'AuditBaseObjects' -Enabled $false + $output.afterState.enabled | Should -BeFalse + $output.changedProperties | Should -BeNullOrEmpty + } + + It 'Enables FullPrivilegeAuditing as a REG_BINARY value' { + $result = (Set-OptionState -Name 'FullPrivilegeAuditing' -Enabled $true).afterState + $result.enabled | Should -BeTrue + $raw = Get-RawOptionValue -Name 'FullPrivilegeAuditing' + $raw | Should -Not -BeNullOrEmpty + $raw[0] | Should -Be 1 + (Get-Item -Path $lsaKey).GetValueKind('FullPrivilegeAuditing') | Should -Be 'Binary' + } + + It 'Fails when enabled is missing' { + # Called directly: through dsc, the synthetic test would treat the key-only input as + # already in the desired state and skip set. + $null = windows_audit_policy set option --input '{"name":"AuditBaseObjects"}' 2>$null + $LASTEXITCODE | Should -Be 2 + } + } +} diff --git a/resources/windows_audit_policy/tests/windows_audit_policy_set.tests.ps1 b/resources/windows_audit_policy/tests/windows_audit_policy_set.tests.ps1 new file mode 100644 index 000000000..b340db16f --- /dev/null +++ b/resources/windows_audit_policy/tests/windows_audit_policy_set.tests.ps1 @@ -0,0 +1,159 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Windows audit policy set tests' -Skip:(!$IsWindows) { + BeforeDiscovery { + $isAdmin = if ($IsWindows) { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]$identity + $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + } + else { + $false + } + $isEnglish = (Get-Culture).Name -like 'en-*' + } + + BeforeAll { + $resourceType = 'Microsoft.Windows/AuditPolicy' + # 'Other Privilege Use Events' is documented by Microsoft as not currently in use, so + # toggling its auditing generates no events and is safe to change briefly. + $testSubcategory = 'Other Privilege Use Events' + $testGuid = '{0CCE922A-69AE-11D9-BED3-505054503030}' + + function Get-AuditPolicyState { + param([string]$Subcategory = $testSubcategory) + $json = @{ subcategory = $Subcategory } | ConvertTo-Json -Compress + $out = $json | dsc resource get -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + return ($out | ConvertFrom-Json).actualState + } + + function Set-AuditPolicyState { + param([hashtable]$Properties) + $json = $Properties | ConvertTo-Json -Compress + $out = $json | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + return ($out | ConvertFrom-Json) + } + + function Restore-AuditPolicyState { + param($Original) + if ($Original) { + $json = @{ + subcategory = $testSubcategory + success = $Original.success + failure = $Original.failure + } | ConvertTo-Json -Compress + $json | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + } + } + } + + Context 'Set success and failure flags' -Skip:(!$isAdmin) { + BeforeAll { + $script:originalState = Get-AuditPolicyState + } + + AfterAll { + Restore-AuditPolicyState -Original $script:originalState + } + + It 'Enables success auditing and leaves failure unchanged' { + $before = Get-AuditPolicyState + $result = (Set-AuditPolicyState -Properties @{ subcategory = $testSubcategory; success = $true }).afterState + $result.subcategory | Should -BeExactly $testSubcategory + $result.success | Should -BeTrue + $result.failure | Should -Be $before.failure + $result.guid | Should -BeExactly $testGuid + $result.category | Should -BeExactly 'Privilege Use' + } + + It 'Enables failure auditing and leaves success unchanged' { + $result = (Set-AuditPolicyState -Properties @{ subcategory = $testSubcategory; failure = $true }).afterState + $result.success | Should -BeTrue + $result.failure | Should -BeTrue + } + + It 'Disables both flags' { + $result = (Set-AuditPolicyState -Properties @{ subcategory = $testSubcategory; success = $false; failure = $false }).afterState + $result.success | Should -BeFalse + $result.failure | Should -BeFalse + } + + It 'Reports no auditing to auditpol after disabling both flags' -Skip:(!$isEnglish) { + $row = auditpol /get /subcategory:"$testSubcategory" /r | Where-Object { $_ -match ',' } | ConvertFrom-Csv + $row.'Inclusion Setting' | Should -BeExactly 'No Auditing' + } + + It 'Is idempotent when already in the desired state' { + $output = Set-AuditPolicyState -Properties @{ subcategory = $testSubcategory; success = $false; failure = $false } + $output.afterState.success | Should -BeFalse + $output.afterState.failure | Should -BeFalse + $output.changedProperties | Should -BeNullOrEmpty + } + + It 'Can set by GUID and echoes the GUID' { + $result = (Set-AuditPolicyState -Properties @{ subcategory = $testGuid; success = $true }).afterState + $result.subcategory | Should -BeExactly $testGuid + $result.success | Should -BeTrue + (Get-AuditPolicyState).success | Should -BeTrue + } + + It 'Changes nothing when only the key is provided' { + $before = Get-AuditPolicyState + $result = (Set-AuditPolicyState -Properties @{ subcategory = $testSubcategory }).afterState + $result.success | Should -Be $before.success + $result.failure | Should -Be $before.failure + } + } + + Context 'Input validation' -Skip:(!$isAdmin) { + It 'Fails when input JSON is invalid' { + $null = 'not-json' | dsc resource set -r $resourceType -f - 2>&1 + $LASTEXITCODE | Should -Not -Be 0 + } + + It 'Fails for an unknown subcategory' { + $json = @{ subcategory = 'Not A Subcategory'; success = $true } | ConvertTo-Json -Compress + $null = $json | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Not -Be 0 + Get-Content -Raw $testdrive/error.log | Should -Match 'Unknown audit policy subcategory' + } + } + + Context 'Configuration document' -Skip:(!$isAdmin) { + BeforeAll { + $script:originalState = Get-AuditPolicyState + $yaml = @" +`$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: + - name: Audit other privilege use events + type: Microsoft.Windows/AuditPolicy + properties: + subcategory: $testSubcategory + success: true + failure: true +"@ + $script:configPath = Join-Path $testdrive 'auditpolicy.config.dsc.yaml' + Set-Content -Path $script:configPath -Value $yaml + } + + AfterAll { + Restore-AuditPolicyState -Original $script:originalState + } + + It 'Applies the configuration and then reports it in the desired state' { + $out = dsc config set -f $script:configPath 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $result = ($out | ConvertFrom-Json).results[0].result.afterState + $result.subcategory | Should -BeExactly $testSubcategory + $result.success | Should -BeTrue + $result.failure | Should -BeTrue + + $out = dsc config test -f $script:configPath 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + ($out | ConvertFrom-Json).results[0].result.inDesiredState | Should -BeTrue + } + } +} diff --git a/resources/windows_audit_policy/tests/windows_audit_policy_whatif.tests.ps1 b/resources/windows_audit_policy/tests/windows_audit_policy_whatif.tests.ps1 new file mode 100644 index 000000000..09133b07c --- /dev/null +++ b/resources/windows_audit_policy/tests/windows_audit_policy_whatif.tests.ps1 @@ -0,0 +1,139 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Windows audit policy whatif tests' -Skip:(!$IsWindows) { + BeforeDiscovery { + $isAdmin = if ($IsWindows) { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]$identity + $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + } + else { + $false + } + } + + BeforeAll { + # 'Other Privilege Use Events' is documented by Microsoft as not currently in use. + $testSubcategory = 'Other Privilege Use Events' + $lsaKey = 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' + + function Get-AuditPolicyState { + $json = @{ subcategory = $testSubcategory } | ConvertTo-Json -Compress + $out = windows_audit_policy get subcategory --input $json 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + return ($out | ConvertFrom-Json) + } + + function Get-RawOptionValue { + param([string]$Name) + (Get-ItemProperty -Path $lsaKey -Name $Name -ErrorAction SilentlyContinue).$Name + } + } + + Context 'Subcategory what-if' -Skip:(!$isAdmin) { + It 'Projects a success change without applying it' { + $before = Get-AuditPolicyState + $desired = -not $before.success + $json = @{ subcategory = $testSubcategory; success = $desired } | ConvertTo-Json -Compress + + $result = windows_audit_policy set subcategory -w --input $json 2>$null | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + + $result.subcategory | Should -BeExactly $testSubcategory + $result.success | Should -Be $desired + $result.failure | Should -Be $before.failure + $result.guid | Should -BeExactly $before.guid + $result.category | Should -BeExactly 'Privilege Use' + $result._metadata.whatIf | Should -Not -BeNullOrEmpty + $expected = "Would change success auditing for subcategory '$testSubcategory' from $($before.success.ToString().ToLower()) to $($desired.ToString().ToLower())" + $result._metadata.whatIf | Should -Contain $expected + + # Assert no mutation happened + $after = Get-AuditPolicyState + $after.success | Should -Be $before.success + $after.failure | Should -Be $before.failure + } + + It 'Projects changes to both flags' { + $before = Get-AuditPolicyState + $json = @{ + subcategory = $testSubcategory + success = (-not $before.success) + failure = (-not $before.failure) + } | ConvertTo-Json -Compress + + $result = windows_audit_policy set subcategory -w --input $json 2>$null | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + + $result._metadata.whatIf.Count | Should -Be 2 + $result._metadata.whatIf[0] | Should -Match '^Would change success auditing' + $result._metadata.whatIf[1] | Should -Match '^Would change failure auditing' + + $after = Get-AuditPolicyState + $after.success | Should -Be $before.success + $after.failure | Should -Be $before.failure + } + + It 'Returns no whatIf messages when nothing would change' { + $before = Get-AuditPolicyState + $json = @{ + subcategory = $testSubcategory + success = $before.success + failure = $before.failure + } | ConvertTo-Json -Compress + + $result = windows_audit_policy set subcategory -w --input $json 2>$null | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $result._metadata | Should -BeNullOrEmpty + } + + It 'Fails for an unknown subcategory' { + $json = @{ subcategory = 'Bogus Subcategory'; success = $true } | ConvertTo-Json -Compress + $null = windows_audit_policy set subcategory -w --input $json 2>$null + $LASTEXITCODE | Should -Be 2 + } + + It 'Works through dsc resource set --what-if' { + $before = Get-AuditPolicyState + $desired = -not $before.failure + $json = @{ subcategory = $testSubcategory; failure = $desired } | ConvertTo-Json -Compress + + $out = $json | dsc resource set -r Microsoft.Windows/AuditPolicy -w -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $result = $out | ConvertFrom-Json + $result.afterState.failure | Should -Be $desired + $result.afterState._metadata.whatIf | Should -Not -BeNullOrEmpty + + (Get-AuditPolicyState).failure | Should -Be $before.failure + } + } + + Context 'Option what-if' -Skip:(!$isAdmin) { + It 'Projects an option change without touching the registry' { + $rawBefore = Get-RawOptionValue -Name 'AuditBaseObjects' + $current = windows_audit_policy get option --input '{"name":"AuditBaseObjects"}' 2>$null | ConvertFrom-Json + $desired = -not $current.enabled + $json = @{ name = 'AuditBaseObjects'; enabled = $desired } | ConvertTo-Json -Compress + + $result = windows_audit_policy set option -w --input $json 2>$null | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + + $result.name | Should -BeExactly 'AuditBaseObjects' + $result.enabled | Should -Be $desired + $result._metadata.whatIf | Should -Match "Would change audit policy option 'AuditBaseObjects'" + + Get-RawOptionValue -Name 'AuditBaseObjects' | Should -Be $rawBefore + } + + It 'Projects enabling CrashOnAuditFail without applying it' { + $rawBefore = Get-RawOptionValue -Name 'CrashOnAuditFail' + + $result = windows_audit_policy set option -w --input '{"name":"CrashOnAuditFail","enabled":true}' 2>$null | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + + $result.enabled | Should -BeTrue + Get-RawOptionValue -Name 'CrashOnAuditFail' | Should -Be $rawBefore + } + } +} diff --git a/resources/windows_audit_policy/windows_audit_policy.dsc.manifests.json b/resources/windows_audit_policy/windows_audit_policy.dsc.manifests.json new file mode 100644 index 000000000..29aca903e --- /dev/null +++ b/resources/windows_audit_policy/windows_audit_policy.dsc.manifests.json @@ -0,0 +1,216 @@ +{ + "resources": [ + { + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", + "type": "Microsoft.Windows/AuditPolicy", + "description": "Manage success and failure auditing for a Windows advanced audit policy subcategory.", + "tags": [ + "Windows", + "Security", + "Audit" + ], + "version": "0.1.0", + "get": { + "executable": "windows_audit_policy", + "args": [ + "get", + "subcategory", + { + "jsonInputArg": "--input", + "mandatory": true + } + ], + "requireSecurityContext": "elevated" + }, + "set": { + "executable": "windows_audit_policy", + "args": [ + "set", + "subcategory", + { + "jsonInputArg": "--input", + "mandatory": true + }, + { + "whatIfArg": "--what-if" + } + ], + "implementsPretest": false, + "return": "state", + "requireSecurityContext": "elevated", + "whatIfReturns": "state" + }, + "export": { + "executable": "windows_audit_policy", + "args": [ + "export", + "subcategory" + ], + "supportsFiltering": false, + "requireSecurityContext": "elevated" + }, + "exitCodes": { + "0": "Success", + "1": "Invalid arguments", + "2": "Invalid input", + "3": "Audit policy error", + "4": "Elevation required: Reading or changing the audit policy requires an elevated process" + }, + "schema": { + "embedded": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Windows Audit Policy Subcategory", + "description": "Manage success and failure auditing for a Windows advanced audit policy subcategory.", + "type": "object", + "required": [ + "subcategory" + ], + "additionalProperties": false, + "properties": { + "subcategory": { + "type": "string", + "title": "Subcategory", + "description": "The audit subcategory, identified by its English name (case-insensitive, for example 'Logon') or by its GUID (for example '{0CCE9215-69AE-11D9-BED3-505054503030}').", + "minLength": 1 + }, + "success": { + "type": "boolean", + "title": "Audit success", + "description": "Whether successful events in the subcategory are audited. When omitted from a set operation, the current setting is left unchanged." + }, + "failure": { + "type": "boolean", + "title": "Audit failure", + "description": "Whether failed events in the subcategory are audited. When omitted from a set operation, the current setting is left unchanged." + }, + "guid": { + "type": "string", + "title": "Subcategory GUID", + "description": "The GUID of the subcategory, in braces and upper-case.", + "readOnly": true + }, + "category": { + "type": "string", + "title": "Category", + "description": "The English name of the audit policy category that contains the subcategory.", + "readOnly": true + }, + "_metadata": { + "type": "object", + "title": "Metadata", + "description": "Metadata returned by what-if operations describing the changes that would be applied.", + "readOnly": true, + "properties": { + "whatIf": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + } + }, + { + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", + "type": "Microsoft.Windows/AuditPolicyOption", + "description": "Manage Windows audit policy options stored under the LSA registry key.", + "tags": [ + "Windows", + "Security", + "Audit" + ], + "version": "0.1.0", + "get": { + "executable": "windows_audit_policy", + "args": [ + "get", + "option", + { + "jsonInputArg": "--input", + "mandatory": true + } + ] + }, + "set": { + "executable": "windows_audit_policy", + "args": [ + "set", + "option", + { + "jsonInputArg": "--input", + "mandatory": true + }, + { + "whatIfArg": "--what-if" + } + ], + "implementsPretest": false, + "return": "state", + "requireSecurityContext": "elevated", + "whatIfReturns": "state" + }, + "export": { + "executable": "windows_audit_policy", + "args": [ + "export", + "option" + ], + "supportsFiltering": false + }, + "exitCodes": { + "0": "Success", + "1": "Invalid arguments", + "2": "Invalid input", + "3": "Audit policy error", + "4": "Elevation required: Changing audit policy options requires an elevated process" + }, + "schema": { + "embedded": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Windows Audit Policy Option", + "description": "Manage a Windows audit policy option stored under HKLM\\SYSTEM\\CurrentControlSet\\Control\\Lsa.", + "type": "object", + "required": [ + "name" + ], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "title": "Option name", + "description": "The audit policy option to manage.", + "enum": [ + "CrashOnAuditFail", + "FullPrivilegeAuditing", + "AuditBaseObjects", + "AuditBaseDirectories" + ] + }, + "enabled": { + "type": "boolean", + "title": "Enabled", + "description": "Whether the option is enabled. Required for set. Changes take effect after a restart." + }, + "_metadata": { + "type": "object", + "title": "Metadata", + "description": "Metadata returned by what-if operations describing the changes that would be applied.", + "readOnly": true, + "properties": { + "whatIf": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + } + } + ] +} From a5e4fa909827a05dd0158723a69fc22c0b2aa525 Mon Sep 17 00:00:00 2001 From: "G.Reijn" <26114636+Gijsreyn@users.noreply.github.com> Date: Sat, 17 Oct 2026 02:05:14 +0200 Subject: [PATCH 2/2] Resolve Copilot remarks --- .../Windows/AuditPolicyOption/index.md | 5 +- .../windows_audit_policy/locales/en-us.toml | 3 +- resources/windows_audit_policy/src/audit.rs | 16 +-- resources/windows_audit_policy/src/main.rs | 108 +++++++++++++----- resources/windows_audit_policy/src/options.rs | 15 ++- .../tests/windows_audit_policy_get.tests.ps1 | 3 +- .../windows_audit_policy_option.tests.ps1 | 16 ++- .../tests/windows_audit_policy_set.tests.ps1 | 9 +- .../windows_audit_policy_whatif.tests.ps1 | 29 +++++ 9 files changed, 151 insertions(+), 53 deletions(-) diff --git a/docs/reference/resources/Microsoft/Windows/AuditPolicyOption/index.md b/docs/reference/resources/Microsoft/Windows/AuditPolicyOption/index.md index e2aaa4ebf..95d635521 100644 --- a/docs/reference/resources/Microsoft/Windows/AuditPolicyOption/index.md +++ b/docs/reference/resources/Microsoft/Windows/AuditPolicyOption/index.md @@ -49,8 +49,9 @@ is also where the corresponding **Security Options** of the Local Security Polic | `AuditBaseDirectories` | Audit the access of global system directories (no Security Options policy) | `REG_DWORD` | The resource reports an option as enabled when its registry value is present and non-zero, and as -disabled when the value is absent or zero. When the resource sets an option, it preserves the -existing registry value type. +disabled when the value is absent or zero. The value may be stored as `REG_DWORD`, `REG_QWORD`, or +`REG_BINARY`. When the resource sets an option, it preserves the existing registry value type and +only uses the type from the table when the value doesn't exist yet. > [!NOTE] > This resource is installed with DSC itself on Windows systems. diff --git a/resources/windows_audit_policy/locales/en-us.toml b/resources/windows_audit_policy/locales/en-us.toml index c810c0cdb..f8e546e0a 100644 --- a/resources/windows_audit_policy/locales/en-us.toml +++ b/resources/windows_audit_policy/locales/en-us.toml @@ -7,6 +7,7 @@ missingTarget = "Missing resource target. Expected: subcategory or option" unknownTarget = "Unknown resource target: '%{target}'. Expected: subcategory or option" missingInput = "Missing --input argument" missingInputValue = "Missing value for --input argument" +unknownArgument = "Unknown argument: '%{argument}'. Expected: --input and/or --what-if" invalidJson = "Invalid JSON input: %{error}" serializeFailed = "Failed to serialize output: %{error}" windowsOnly = "This resource is only supported on Windows" @@ -29,6 +30,6 @@ whatIfChangeFailure = "Would change failure auditing for subcategory '%{subcateg enabledRequired = "'enabled' is required for the set operation" readFailed = "Failed to read audit policy option '%{name}' from the registry: %{error}" writeFailed = "Failed to write audit policy option '%{name}' to the registry: %{error}" -unsupportedType = "Audit policy option '%{name}' has an unsupported registry value type; expected REG_DWORD or REG_BINARY" +unsupportedType = "Audit policy option '%{name}' has an unsupported registry value type; expected REG_DWORD, REG_QWORD or REG_BINARY" whatIfChangeEnabled = "Would change audit policy option '%{name}' from enabled %{current} to %{desired}" restartRequired = "A restart is required for the change to audit policy option '%{name}' to take effect" diff --git a/resources/windows_audit_policy/src/audit.rs b/resources/windows_audit_policy/src/audit.rs index f3aac50b7..5d7753bac 100644 --- a/resources/windows_audit_policy/src/audit.rs +++ b/resources/windows_audit_policy/src/audit.rs @@ -332,19 +332,9 @@ pub fn set_audit_policy(input: &AuditPolicy, what_if: bool) -> Result result, - Err(error) if what_if => { - return Ok(AuditPolicy { - subcategory: input.subcategory.clone(), - success: input.success, - failure: input.failure, - metadata: Metadata::from_messages(vec![error.to_string()]), - ..AuditPolicy::default() - }); - } - Err(error) => return Err(error), - }; + // A failed query is an error in what-if mode too: set cannot create a subcategory, so there + // is no verified current state to project a change from. + let (info, current) = query_one(&resolved, &input.subcategory)?; let current_flags = AuditFlags::from_bits(info.AuditingInformation); let desired = AuditFlags { diff --git a/resources/windows_audit_policy/src/main.rs b/resources/windows_audit_policy/src/main.rs index 82c01adde..8627ca6a9 100644 --- a/resources/windows_audit_policy/src/main.rs +++ b/resources/windows_audit_policy/src/main.rs @@ -55,11 +55,10 @@ fn main() -> ExitCode { return ExitCode::from(EXIT_INVALID_ARGS); }; - let input_json = match parse_input_arg(&args) { - Ok(input) => input, + let Arguments { input: input_json, what_if } = match parse_arguments(&args) { + Ok(arguments) => arguments, Err(code) => return code, }; - let what_if = parse_what_if_flag(&args); match (operation, target.as_str()) { ("get", "subcategory") => match require_input::(input_json) { @@ -161,56 +160,107 @@ fn finish_many(result: Result, types::AuditError>) - } } -/// Parse the `--input ` argument. Arguments 1 and 2 are the operation and the target. +/// The options that may follow the operation and the target. #[cfg(windows)] -fn parse_input_arg(args: &[String]) -> Result, ExitCode> { +#[derive(Debug, Default, PartialEq, Eq)] +struct Arguments { + /// The JSON given with `--input` / `-i`. + input: Option, + /// Whether `--what-if` / `-w` was given. + what_if: bool, +} + +/// Parse every argument after the operation and the target (arguments 1 and 2). Only +/// `--input ` and `--what-if` are accepted; anything else is rejected before any operation +/// is dispatched, so a misspelled preview flag can never turn into a real change. +/// +/// # Errors +/// +/// Returns the exit code to use when `--input` has no value or an argument is unknown. +#[cfg(windows)] +fn parse_arguments(args: &[String]) -> Result { + let mut arguments = Arguments::default(); let mut index = 3; while index < args.len() { - if args[index] == "--input" || args[index] == "-i" { - if index + 1 < args.len() { - return Ok(Some(args[index + 1].clone())); + match args[index].as_str() { + "--input" | "-i" => { + let Some(value) = args.get(index + 1) else { + write_error(&t!("main.missingInputValue")); + return Err(ExitCode::from(EXIT_INVALID_ARGS)); + }; + arguments.input = Some(value.clone()); + index += 2; + } + "--what-if" | "-w" => { + arguments.what_if = true; + index += 1; + } + unknown => { + write_error(&t!("main.unknownArgument", argument = unknown)); + return Err(ExitCode::from(EXIT_INVALID_ARGS)); } - write_error(&t!("main.missingInputValue")); - return Err(ExitCode::from(EXIT_INVALID_ARGS)); } - index += 1; } - Ok(None) -} - -/// Parse the `--what-if` / `-w` flag. Arguments 1 and 2 are the operation and the target. -#[cfg(windows)] -fn parse_what_if_flag(args: &[String]) -> bool { - args.iter().skip(3).any(|arg| arg == "--what-if" || arg == "-w") + Ok(arguments) } #[cfg(all(test, windows))] mod tests { - use super::{parse_input_arg, parse_what_if_flag}; + use super::{Arguments, EXIT_INVALID_ARGS, parse_arguments}; + use std::process::ExitCode; fn args(list: &[&str]) -> Vec { list.iter().map(ToString::to_string).collect() } + fn parsed(input: Option<&str>, what_if: bool) -> Arguments { + Arguments { + input: input.map(ToString::to_string), + what_if, + } + } + #[test] fn finds_long_and_short_input_arguments_after_the_target() { assert_eq!( - parse_input_arg(&args(&["exe", "get", "subcategory", "--input", "{}"])), - Ok(Some("{}".to_string())) + parse_arguments(&args(&["exe", "get", "subcategory", "--input", "{}"])), + Ok(parsed(Some("{}"), false)) ); assert_eq!( - parse_input_arg(&args(&["exe", "set", "option", "-w", "-i", "[]"])), - Ok(Some("[]".to_string())) + parse_arguments(&args(&["exe", "set", "option", "-w", "-i", "[]"])), + Ok(parsed(Some("[]"), true)) + ); + assert_eq!(parse_arguments(&args(&["exe", "export", "subcategory"])), Ok(parsed(None, false))); + assert_eq!( + parse_arguments(&args(&["exe", "get", "subcategory", "--input"])), + Err(ExitCode::from(EXIT_INVALID_ARGS)) ); - assert_eq!(parse_input_arg(&args(&["exe", "export", "subcategory"])), Ok(None)); - assert!(parse_input_arg(&args(&["exe", "get", "subcategory", "--input"])).is_err()); } #[test] fn finds_what_if_flag_after_the_target() { - assert!(parse_what_if_flag(&args(&["exe", "set", "subcategory", "--what-if", "--input", "{}"]))); - assert!(parse_what_if_flag(&args(&["exe", "set", "option", "--input", "{}", "-w"]))); - assert!(!parse_what_if_flag(&args(&["exe", "set", "subcategory", "--input", "{}"]))); - assert!(!parse_what_if_flag(&args(&["exe", "-w", "subcategory"]))); + assert_eq!( + parse_arguments(&args(&["exe", "set", "subcategory", "--what-if", "--input", "{}"])), + Ok(parsed(Some("{}"), true)) + ); + assert_eq!( + parse_arguments(&args(&["exe", "set", "option", "--input", "{}", "-w"])), + Ok(parsed(Some("{}"), true)) + ); + assert_eq!( + parse_arguments(&args(&["exe", "set", "subcategory", "--input", "{}"])), + Ok(parsed(Some("{}"), false)) + ); + // The operation and target positions are never read as flags. + assert_eq!(parse_arguments(&args(&["exe", "-w", "subcategory"])), Ok(parsed(None, false))); + } + + #[test] + fn rejects_unknown_arguments_before_and_after_input() { + let invalid = Err(ExitCode::from(EXIT_INVALID_ARGS)); + assert_eq!(parse_arguments(&args(&["exe", "set", "option", "--whatif", "--input", "{}"])), invalid); + assert_eq!(parse_arguments(&args(&["exe", "set", "option", "--input", "{}", "--whatif"])), invalid); + assert_eq!(parse_arguments(&args(&["exe", "set", "subcategory", "--input", "{}", "extra"])), invalid); + assert_eq!(parse_arguments(&args(&["exe", "get", "subcategory", "-x"])), invalid); } } diff --git a/resources/windows_audit_policy/src/options.rs b/resources/windows_audit_policy/src/options.rs index f6b69d174..18b0bf91b 100644 --- a/resources/windows_audit_policy/src/options.rs +++ b/resources/windows_audit_policy/src/options.rs @@ -34,13 +34,15 @@ pub fn enabled_from_data(name: AuditOptionName, data: Option<&RegistryValueData> } } -/// The registry data to write for an option. The existing value type is preserved; otherwise the -/// canonical type for the option is used. +/// The registry data to write for an option. An existing `REG_DWORD`, `REG_QWORD` or `REG_BINARY` +/// value keeps its type (every type `enabled_from_data` accepts); otherwise the canonical type for +/// the option is used. #[must_use] pub fn data_for(name: AuditOptionName, enabled: bool, current: Option<&RegistryValueData>) -> RegistryValueData { match current { Some(RegistryValueData::Binary(_)) => RegistryValueData::Binary(vec![u8::from(enabled)]), Some(RegistryValueData::DWord(_)) => RegistryValueData::DWord(u32::from(enabled)), + Some(RegistryValueData::QWord(_)) => RegistryValueData::QWord(u64::from(enabled)), _ if name.is_binary() => RegistryValueData::Binary(vec![u8::from(enabled)]), _ => RegistryValueData::DWord(u32::from(enabled)), } @@ -153,11 +155,20 @@ mod tests { #[test] fn data_preserves_existing_type_or_uses_the_canonical_one() { let dword = RegistryValueData::DWord(0); + let qword = RegistryValueData::QWord(0); let binary = RegistryValueData::Binary(vec![0]); assert_eq!( data_for(AuditOptionName::AuditBaseObjects, true, Some(&dword)), RegistryValueData::DWord(1) ); + assert_eq!( + data_for(AuditOptionName::AuditBaseObjects, true, Some(&qword)), + RegistryValueData::QWord(1) + ); + assert_eq!( + data_for(AuditOptionName::FullPrivilegeAuditing, false, Some(&qword)), + RegistryValueData::QWord(0) + ); assert_eq!( data_for(AuditOptionName::AuditBaseObjects, false, Some(&binary)), RegistryValueData::Binary(vec![0]) diff --git a/resources/windows_audit_policy/tests/windows_audit_policy_get.tests.ps1 b/resources/windows_audit_policy/tests/windows_audit_policy_get.tests.ps1 index 1c7fc8ef2..64f4d5c7b 100644 --- a/resources/windows_audit_policy/tests/windows_audit_policy_get.tests.ps1 +++ b/resources/windows_audit_policy/tests/windows_audit_policy_get.tests.ps1 @@ -11,7 +11,8 @@ Describe 'Windows audit policy get tests' -Skip:(!$IsWindows) { else { $false } - $isEnglish = (Get-Culture).Name -like 'en-*' + # auditpol localizes its output with the display (UI) language, not the regional format. + $isEnglish = (Get-UICulture).Name -like 'en-*' } BeforeAll { diff --git a/resources/windows_audit_policy/tests/windows_audit_policy_option.tests.ps1 b/resources/windows_audit_policy/tests/windows_audit_policy_option.tests.ps1 index 791f2a4b6..f3fda0207 100644 --- a/resources/windows_audit_policy/tests/windows_audit_policy_option.tests.ps1 +++ b/resources/windows_audit_policy/tests/windows_audit_policy_option.tests.ps1 @@ -106,6 +106,14 @@ Describe 'Windows audit policy option tests' -Skip:(!$IsWindows) { BeforeAll { $script:originalAuditBaseObjects = Get-RawOptionValue -Name 'AuditBaseObjects' $script:originalFullPrivilegeAuditing = Get-RawOptionValue -Name 'FullPrivilegeAuditing' + # The resource preserves an existing value type and only creates a missing value as + # REG_BINARY, so the expected kind depends on the starting state. + $script:expectedFullPrivilegeAuditingKind = if ($null -eq $script:originalFullPrivilegeAuditing) { + [Microsoft.Win32.RegistryValueKind]::Binary + } + else { + (Get-Item -Path $lsaKey).GetValueKind('FullPrivilegeAuditing') + } } AfterAll { @@ -127,18 +135,20 @@ Describe 'Windows audit policy option tests' -Skip:(!$IsWindows) { } It 'Is idempotent when already in the desired state' { + # Establish the desired state first so the asserted call is the one that changes nothing. + $null = Set-OptionState -Name 'AuditBaseObjects' -Enabled $false $output = Set-OptionState -Name 'AuditBaseObjects' -Enabled $false $output.afterState.enabled | Should -BeFalse $output.changedProperties | Should -BeNullOrEmpty } - It 'Enables FullPrivilegeAuditing as a REG_BINARY value' { + It 'Enables FullPrivilegeAuditing and preserves the registry value type' { $result = (Set-OptionState -Name 'FullPrivilegeAuditing' -Enabled $true).afterState $result.enabled | Should -BeTrue $raw = Get-RawOptionValue -Name 'FullPrivilegeAuditing' $raw | Should -Not -BeNullOrEmpty - $raw[0] | Should -Be 1 - (Get-Item -Path $lsaKey).GetValueKind('FullPrivilegeAuditing') | Should -Be 'Binary' + ConvertTo-Enabled -Raw $raw | Should -BeTrue + (Get-Item -Path $lsaKey).GetValueKind('FullPrivilegeAuditing') | Should -Be $script:expectedFullPrivilegeAuditingKind } It 'Fails when enabled is missing' { diff --git a/resources/windows_audit_policy/tests/windows_audit_policy_set.tests.ps1 b/resources/windows_audit_policy/tests/windows_audit_policy_set.tests.ps1 index b340db16f..3e172c25c 100644 --- a/resources/windows_audit_policy/tests/windows_audit_policy_set.tests.ps1 +++ b/resources/windows_audit_policy/tests/windows_audit_policy_set.tests.ps1 @@ -11,7 +11,8 @@ Describe 'Windows audit policy set tests' -Skip:(!$IsWindows) { else { $false } - $isEnglish = (Get-Culture).Name -like 'en-*' + # auditpol localizes its output with the display (UI) language, not the regional format. + $isEnglish = (Get-UICulture).Name -like 'en-*' } BeforeAll { @@ -70,8 +71,9 @@ Describe 'Windows audit policy set tests' -Skip:(!$IsWindows) { } It 'Enables failure auditing and leaves success unchanged' { + $before = Get-AuditPolicyState $result = (Set-AuditPolicyState -Properties @{ subcategory = $testSubcategory; failure = $true }).afterState - $result.success | Should -BeTrue + $result.success | Should -Be $before.success $result.failure | Should -BeTrue } @@ -82,11 +84,14 @@ Describe 'Windows audit policy set tests' -Skip:(!$IsWindows) { } It 'Reports no auditing to auditpol after disabling both flags' -Skip:(!$isEnglish) { + $null = Set-AuditPolicyState -Properties @{ subcategory = $testSubcategory; success = $false; failure = $false } $row = auditpol /get /subcategory:"$testSubcategory" /r | Where-Object { $_ -match ',' } | ConvertFrom-Csv $row.'Inclusion Setting' | Should -BeExactly 'No Auditing' } It 'Is idempotent when already in the desired state' { + # Establish the desired state first so the asserted call is the one that changes nothing. + $null = Set-AuditPolicyState -Properties @{ subcategory = $testSubcategory; success = $false; failure = $false } $output = Set-AuditPolicyState -Properties @{ subcategory = $testSubcategory; success = $false; failure = $false } $output.afterState.success | Should -BeFalse $output.afterState.failure | Should -BeFalse diff --git a/resources/windows_audit_policy/tests/windows_audit_policy_whatif.tests.ps1 b/resources/windows_audit_policy/tests/windows_audit_policy_whatif.tests.ps1 index 09133b07c..618efad20 100644 --- a/resources/windows_audit_policy/tests/windows_audit_policy_whatif.tests.ps1 +++ b/resources/windows_audit_policy/tests/windows_audit_policy_whatif.tests.ps1 @@ -109,6 +109,35 @@ Describe 'Windows audit policy whatif tests' -Skip:(!$IsWindows) { } } + Context 'Argument validation' { + BeforeAll { + $optionJson = '{"name":"CrashOnAuditFail","enabled":true}' + } + + It 'Rejects an unknown switch before --input without touching the registry' { + $rawBefore = Get-RawOptionValue -Name 'CrashOnAuditFail' + $null = windows_audit_policy set option --whatif --input $optionJson 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 1 + Get-Content -Raw $testdrive/error.log | Should -Match "Unknown argument: '--whatif'" + Get-RawOptionValue -Name 'CrashOnAuditFail' | Should -Be $rawBefore + } + + It 'Rejects an unknown switch after --input without touching the registry' { + $rawBefore = Get-RawOptionValue -Name 'CrashOnAuditFail' + $null = windows_audit_policy set option --input $optionJson --whatif 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 1 + Get-Content -Raw $testdrive/error.log | Should -Match "Unknown argument: '--whatif'" + Get-RawOptionValue -Name 'CrashOnAuditFail' | Should -Be $rawBefore + } + + It 'Rejects an unknown switch on the subcategory set' { + $json = @{ subcategory = $testSubcategory; success = $true } | ConvertTo-Json -Compress + $null = windows_audit_policy set subcategory --input $json --what-if --verbose 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 1 + Get-Content -Raw $testdrive/error.log | Should -Match "Unknown argument: '--verbose'" + } + } + Context 'Option what-if' -Skip:(!$isAdmin) { It 'Projects an option change without touching the registry' { $rawBefore = Get-RawOptionValue -Name 'AuditBaseObjects'