From 3a046ef57dfc8cd575cd3c9500dd8cece54d6689 Mon Sep 17 00:00:00 2001 From: PierreCsn Date: Fri, 30 Oct 2026 11:26:40 +0100 Subject: [PATCH] refactor: inline Windows hardening with WAPT helpers --- README.md | 16 +++- hardening.ps1 | 66 ---------------- setup.py | 178 ++++++++++++++++++++++++++++++++++++++++---- tests/test_setup.py | 163 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 340 insertions(+), 83 deletions(-) delete mode 100644 hardening.ps1 create mode 100644 tests/test_setup.py diff --git a/README.md b/README.md index ab7407f..14f5a72 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,27 @@ -# Com itari Windows hardening safe v1 (WAPT source) +# Comitari Windows hardening safe v1 (WAPT source) SOURCE ONLY: must build and sign with the organization's WAPT tooling. Not yet Windows/WAPT-tested. ## Scope Five HKLM registry settings: disable AutoRun and AutoPlay (including non-volume devices), disable lock-screen camera and slideshows. No account / password / LAPS change. No WAPT, firewall, network, SMB, BitLocker, Microsoft Defender, antivirus, remote desktop, printing or power settings change. +## Implementation +- `setup.py` now contains the complete install, audit and conditional rollback logic. +- Registry reads and writes use WAPT 2.6 `setuphelpers` (`reg_key_exists`, `reg_value_exists`, `reg_openkey_noredir`, `reg_getvalue`, `registry_set`, and `reg_delvalue`). No PowerShell process or external command is launched. +- The approved setting table is centralized in `SETTINGS`. A future `update_package.py` can validate operator-selected settings and serialize them as package data; this refactor keeps the current five-setting policy fixed. +- If a future package configuration changes a setting’s target, its update flow must migrate the saved uninstall target while preserving the original pre-install value. That migration is not part of this refactor. +- The WAPT package source currently has no `WAPT/control` or `update_package.py`; it is code-only and is not yet a complete buildable WAPT package. + ## Safeguards -- Initial values saved once in `%ProgramData%\Comitari\HardeningSafe\original-values.json` before any modifications. +- Initial values are saved once in `%ProgramData%\Comitari\HardeningSafe\original-values.json` before any modifications. The JSON layout remains compatible with the prior PowerShell package. +- If the baseline is absent, malformed or incomplete, uninstall refuses to make registry changes. - Reinstallation idempotent; uninstall conditional: restores only values still equal to package target, leaving externally modified values unchanged. - No reboot forced. Audit implemented. - State persists after uninstall intentionally, so repeated uninstall does not overwrite third-party edits. -- Policy owner can override package; audit then reports WARNING. +- Audit is read-only. A non-compliant or unreadable setting returns `WARNING`; successful collection with all five values at target returns `OK`. ## Deployment -1. Inspect `setup.py` and `hardening.ps1` in an isolated preproduction repository. +1. Inspect `setup.py` and the five `SETTINGS` entries in an isolated preproduction repository. 2. Validate registry settings and resulting behavior on a Windows 11 25H2 representative workstation, especially AutoPlay workflow. 3. Build/sign WAPT package with organization's key. Do not distribute unsigned sources as a ready-to-deploy binary. 4. Deploy to 1 test PC, verify WAPT agent/check-in, LapsByWAPT password rotation and recovery, business apps, USB workflows. diff --git a/hardening.ps1 b/hardening.ps1 deleted file mode 100644 index 54c8cd1..0000000 --- a/hardening.ps1 +++ /dev/null @@ -1,66 +0,0 @@ -param([ValidateSet('install','audit','uninstall')][string]$Action = 'audit') -$ErrorActionPreference = 'Stop' -$stateDir = Join-Path $env:ProgramData 'Comitari\HardeningSafe' -$stateFile = Join-Path $stateDir 'original-values.json' -# Only low-impact, machine-wide controls. Deliberately excludes Windows LAPS, accounts, -# services, remote management, networking, firewall policy, BitLocker and Defender. -$settings = @( - @{ Path='HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\Explorer'; Name='NoAutorun'; Value=1 }, - @{ Path='HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\Explorer'; Name='NoDriveTypeAutoRun'; Value=255 }, - @{ Path='HKLM:\SOFTWARE\Policies\Microsoft\Windows\Explorer'; Name='NoAutoplayfornonVolume'; Value=1 }, - @{ Path='HKLM:\SOFTWARE\Policies\Microsoft\Windows\Personalization'; Name='NoLockScreenCamera'; Value=1 }, - @{ Path='HKLM:\SOFTWARE\Policies\Microsoft\Windows\Personalization'; Name='NoLockScreenSlideshow'; Value=1 } -) -function Read-Value($path, $name) { - if (-not (Test-Path -LiteralPath $path)) { return @{ Exists=$false; Value=$null } } - $key = Get-Item -LiteralPath $path - if ($key.GetValueNames() -contains $name) { - return @{ Exists=$true; Value=[int]$key.GetValue($name) } - } - return @{ Exists=$false; Value=$null } -} -function Write-Value($path, $name, [int]$value) { - if (-not (Test-Path -LiteralPath $path)) { New-Item -Path $path -Force | Out-Null } - New-ItemProperty -LiteralPath $path -Name $name -Value $value -PropertyType DWord -Force | Out-Null -} -if ($Action -eq 'install') { - if (-not (Test-Path -LiteralPath $stateDir)) { New-Item -Path $stateDir -ItemType Directory -Force | Out-Null } - if (-not (Test-Path -LiteralPath $stateFile)) { - $original = @() - foreach ($setting in $settings) { - $old = Read-Value $setting.Path $setting.Name - $original += [pscustomobject]@{ Path=$setting.Path; Name=$setting.Name; Exists=$old.Exists; Value=$old.Value; Target=$setting.Value } - } - $json = ConvertTo-Json -InputObject @($original) -Depth 5 - $temp = "$stateFile.tmp" - [System.IO.File]::WriteAllText($temp, $json, [System.Text.UTF8Encoding]::new($false)) - Move-Item -LiteralPath $temp -Destination $stateFile -Force - } - foreach ($setting in $settings) { Write-Value $setting.Path $setting.Name $setting.Value } - Write-Output 'Installed conservative hardening; LapsByWAPT untouched.' -} -elseif ($Action -eq 'audit') { - $failed = @() - foreach ($setting in $settings) { - $actual = Read-Value $setting.Path $setting.Name - if (-not $actual.Exists -or $actual.Value -ne $setting.Value) { $failed += "$($setting.Name) != $($setting.Value)" } - } - if ($failed.Count -gt 0) { $failed | ForEach-Object { Write-Output $_ }; exit 1 } - Write-Output 'All 5 settings compliant.' -} -else { - if (-not (Test-Path -LiteralPath $stateFile)) { throw 'No saved baseline; refusing blind rollback.' } - $original = @(Get-Content -LiteralPath $stateFile -Raw | ConvertFrom-Json) - foreach ($item in $original) { - $current = Read-Value $item.Path $item.Name - # Do not override later changes made by GPO or another administrator. - if (-not $current.Exists -or $current.Value -ne [int]$item.Target) { - Write-Output "Skipped externally changed setting: $($item.Name)" - continue - } - if ($item.Exists) { Write-Value $item.Path $item.Name ([int]$item.Value) } - else { Remove-ItemProperty -LiteralPath $item.Path -Name $item.Name -ErrorAction Stop } - } - # Keep baseline for forensic trace; uninstall is idempotent only for settings still matching targets. - Write-Output 'Conditional rollback completed; LapsByWAPT untouched.' -} diff --git a/setup.py b/setup.py index b4d8145..b072f17 100644 --- a/setup.py +++ b/setup.py @@ -1,33 +1,185 @@ # -*- coding: utf-8 -*- +"""Comitari conservative Windows hardening package. + +Registry operations use WAPT setuphelpers only. The supported settings are +kept as data in SETTINGS so a later update_package.py can validate operator +choices and serialize an approved configuration without generating code. +""" + from setuphelpers import * import os -import subprocess uninstallkey = [] +STATE_DIRECTORY = ('Comitari', 'HardeningSafe') +STATE_FILENAME = 'original-values.json' -def _run(action): - script = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'hardening.ps1') - result = subprocess.run(['powershell.exe', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', script, '-Action', action], capture_output=True, text=True) - print(result.stdout) - if result.stderr: - print(result.stderr) - if result.returncode != 0: - raise Exception('Hardening %s failed with code %s' % (action, result.returncode)) +# Intentionally limited machine-wide controls. Keep state-file paths in the +# legacy PowerShell format so existing installations can roll back safely. +SETTINGS = ( + {'Path': r'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\Explorer', + 'Name': 'NoAutorun', 'Value': 1}, + {'Path': r'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\Explorer', + 'Name': 'NoDriveTypeAutoRun', 'Value': 255}, + {'Path': r'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Explorer', + 'Name': 'NoAutoplayfornonVolume', 'Value': 1}, + {'Path': r'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Personalization', + 'Name': 'NoLockScreenCamera', 'Value': 1}, + {'Path': r'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Personalization', + 'Name': 'NoLockScreenSlideshow', 'Value': 1}, +) + + +def _state_path(): + return makepath(programdata(), *STATE_DIRECTORY, STATE_FILENAME) + + +def _registry_path(path): + prefix = 'HKLM:\\' + if not isinstance(path, str) or not path.startswith(prefix): + raise RuntimeError('Refusing an unsupported registry root in package state.') + return path[len(prefix):] + + +def _read_value(path, name): + """Read one HKLM value without hiding access or registry errors.""" + subkey = _registry_path(path) + if not reg_key_exists(HKEY_LOCAL_MACHINE, subkey): + return {'Exists': False, 'Value': None} + if not reg_value_exists(HKEY_LOCAL_MACHINE, subkey, name): + return {'Exists': False, 'Value': None} + with reg_openkey_noredir(HKEY_LOCAL_MACHINE, subkey) as key: + value = reg_getvalue(key, name) + if value is None: + raise RuntimeError('Registry value disappeared while it was being read: %s' % name) + try: + return {'Exists': True, 'Value': int(value)} + except (TypeError, ValueError): + raise RuntimeError('Registry value is not an integer DWORD: %s' % name) + + +def _write_value(path, name, value): + registry_set(HKEY_LOCAL_MACHINE, _registry_path(path), name, + int(value), type=REG_DWORD) + + +def _delete_value(path, name): + subkey = _registry_path(path) + if not reg_key_exists(HKEY_LOCAL_MACHINE, subkey): + return + with reg_openkey_noredir(HKEY_LOCAL_MACHINE, subkey, + sam=KEY_ALL_ACCESS) as key: + reg_delvalue(key, name) + + +def _capture_original_values(): + original = [] + for setting in SETTINGS: + current = _read_value(setting['Path'], setting['Name']) + original.append({ + 'Path': setting['Path'], + 'Name': setting['Name'], + 'Exists': current['Exists'], + 'Value': current['Value'], + 'Target': setting['Value'], + }) + return original + + +def _write_state_once(state_file, state): + """Persist the rollback baseline before changing any registry value.""" + state_dir = os.path.dirname(state_file) + mkdirs(state_dir) + temporary = state_file + '.tmp' + try: + json_write_file(temporary, state, indent=2, sort_keys=True) + # Same-volume replace keeps a prior baseline intact until the new file + # is complete. WAPT serializes package installations on each endpoint. + os.replace(temporary, state_file) + finally: + if isfile(temporary): + remove_file(temporary) + + +def _load_state(state_file): + if not isfile(state_file): + raise RuntimeError('No saved baseline; refusing blind rollback.') + try: + state = json_load_file(state_file) + except Exception as exc: + raise RuntimeError('Saved baseline is unreadable; refusing registry changes.') from exc + if not isinstance(state, list) or len(state) != len(SETTINGS): + raise RuntimeError('Saved baseline is incomplete; refusing registry changes.') + + expected = {(item['Path'], item['Name']): item for item in SETTINGS} + seen = set() + for item in state: + if not isinstance(item, dict): + raise RuntimeError('Saved baseline has an invalid entry; refusing registry changes.') + key = (item.get('Path'), item.get('Name')) + setting = expected.get(key) + if setting is None or key in seen: + raise RuntimeError('Saved baseline contains an unsupported setting; refusing registry changes.') + seen.add(key) + target = item.get('Target') + if isinstance(target, bool) or not isinstance(target, int) or target != setting['Value']: + raise RuntimeError('Saved baseline target differs from this package; refusing registry changes.') + exists = item.get('Exists') + value = item.get('Value') + if not isinstance(exists, bool): + raise RuntimeError('Saved baseline has an invalid existence flag; refusing registry changes.') + if exists and (isinstance(value, bool) or not isinstance(value, int)): + raise RuntimeError('Saved baseline has an invalid original DWORD; refusing registry changes.') + if not exists and value is not None: + raise RuntimeError('Saved baseline has an inconsistent absent value; refusing registry changes.') + if seen != set(expected): + raise RuntimeError('Saved baseline is missing settings; refusing registry changes.') + return state def install(): - _run('install') + """Save the pre-package state once, then apply the five approved DWORDs.""" + state_file = _state_path() + if not isfile(state_file): + original = _capture_original_values() + _write_state_once(state_file, original) + + for setting in SETTINGS: + _write_value(setting['Path'], setting['Name'], setting['Value']) + print('Installed conservative hardening; LapsByWAPT untouched.') def audit(): + """Read-only compliance check for the configured package settings.""" try: - _run('audit') + failed = [] + for setting in SETTINGS: + actual = _read_value(setting['Path'], setting['Name']) + if not actual['Exists'] or actual['Value'] != setting['Value']: + failed.append('%s != %s' % (setting['Name'], setting['Value'])) + if failed: + for item in failed: + print(item) + return 'WARNING' + print('All %s settings compliant.' % len(SETTINGS)) return 'OK' except Exception as exc: - print(exc) + print('Hardening audit failed: %s' % type(exc).__name__) return 'WARNING' def uninstall(): - _run('uninstall') + """Conditionally restore only values still owned by this package.""" + state = _load_state(_state_path()) + for item in state: + current = _read_value(item['Path'], item['Name']) + if not current['Exists'] or current['Value'] != int(item['Target']): + print('Skipped externally changed setting: %s' % item['Name']) + continue + if item['Exists']: + _write_value(item['Path'], item['Name'], item['Value']) + else: + _delete_value(item['Path'], item['Name']) + # Preserve the baseline as a forensic record, matching the previous + # PowerShell implementation and allowing repeated uninstall safely. + print('Conditional rollback completed; LapsByWAPT untouched.') diff --git a/tests/test_setup.py b/tests/test_setup.py new file mode 100644 index 0000000..08f7f69 --- /dev/null +++ b/tests/test_setup.py @@ -0,0 +1,163 @@ +"""Synthetic tests for the WAPT package; no Windows registry is accessed.""" +import importlib.util +import json +import os +from pathlib import Path +import sys +import tempfile +import types +import unittest +from unittest import mock + + +ROOT = Path(__file__).resolve().parents[1] +REGISTRY = {} +WRITES = [] +DELETES = [] +HKLM = 'synthetic-HKLM' + + +class RegistryHandle: + def __init__(self, root, path): + self.address = (root, path) + + def __enter__(self): + return self.address + + def __exit__(self, *args): + return False + + +def registry_set(root, path, name, value, type=None): + REGISTRY.setdefault((root, path), {})[name] = value + WRITES.append((root, path, name, value, type)) + + +def _load_setup_module(): + helpers = types.ModuleType('setuphelpers') + helpers.HKEY_LOCAL_MACHINE = HKLM + helpers.KEY_ALL_ACCESS = 0xF003F + helpers.REG_DWORD = 4 + helpers.makepath = lambda *parts: os.path.join(*(str(part) for part in parts)) + helpers.programdata = lambda: tempfile.gettempdir() + helpers.mkdirs = lambda path: os.makedirs(path, exist_ok=True) + helpers.isfile = os.path.isfile + helpers.remove_file = lambda path: os.remove(path) if os.path.exists(path) else None + helpers.json_write_file = lambda path, data, **kwargs: Path(path).write_text( + json.dumps(data, **kwargs), encoding='utf-8') + helpers.json_load_file = lambda path: json.loads(Path(path).read_text(encoding='utf-8')) + helpers.reg_key_exists = lambda root, path: (root, path) in REGISTRY + helpers.reg_value_exists = lambda root, path, name: name in REGISTRY.get((root, path), {}) + helpers.reg_openkey_noredir = lambda root, path, **kwargs: RegistryHandle(root, path) + helpers.reg_getvalue = lambda key, name, default=None: REGISTRY.get(key, {}).get(name, default) + helpers.registry_set = registry_set + + def delete_value(key, name): + DELETES.append((key[0], key[1], name)) + REGISTRY.get(key, {}).pop(name, None) + helpers.reg_delvalue = delete_value + + with mock.patch.dict(sys.modules, {'setuphelpers': helpers}): + spec = importlib.util.spec_from_file_location('hardening_setup', ROOT / 'setup.py') + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +setup = _load_setup_module() + + +class HardeningSetupTests(unittest.TestCase): + def setUp(self): + REGISTRY.clear() + WRITES.clear() + DELETES.clear() + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.state_dir = Path(self.temp.name) / 'Comitari' / 'HardeningSafe' + self.state_file = self.state_dir / 'original-values.json' + self.programdata_patch = mock.patch.object(setup, 'programdata', lambda: self.temp.name) + self.programdata_patch.start() + self.addCleanup(self.programdata_patch.stop) + + def _seed(self, setting, value): + address = (HKLM, setup._registry_path(setting['Path'])) + REGISTRY.setdefault(address, {})[setting['Name']] = value + + def test_install_saves_originals_once_before_applying_dwords(self): + setting = setup.SETTINGS[0] + self._seed(setting, 0) + setup.install() + state = json.loads(self.state_file.read_text(encoding='utf-8')) + self.assertEqual(len(state), 5) + self.assertEqual(state[0]['Value'], 0) + self.assertTrue(state[0]['Exists']) + self.assertEqual(state[0]['Target'], 1) + self.assertEqual(len(WRITES), 5) + self.assertTrue(all(write[4] == setup.REG_DWORD for write in WRITES)) + + self._seed(setting, 7) + setup.install() + state_again = json.loads(self.state_file.read_text(encoding='utf-8')) + self.assertEqual(state_again[0]['Value'], 0) + + def test_audit_is_read_only_and_reports_noncompliance(self): + setup.install() + self.assertEqual(setup.audit(), 'OK') + before = list(WRITES) + self._seed(setup.SETTINGS[1], 0) + self.assertEqual(setup.audit(), 'WARNING') + self.assertEqual(WRITES, before) + + def test_audit_read_error_is_not_reported_as_healthy(self): + with mock.patch.object(setup, 'reg_key_exists', side_effect=PermissionError): + self.assertEqual(setup.audit(), 'WARNING') + + def test_uninstall_restores_owned_values_and_preserves_external_changes(self): + existing = setup.SETTINGS[0] + absent = setup.SETTINGS[1] + externally_changed = setup.SETTINGS[2] + self._seed(existing, 0) + setup.install() + self._seed(externally_changed, 77) + + setup.uninstall() + + self.assertEqual(REGISTRY[(HKLM, setup._registry_path(existing['Path']))][existing['Name']], 0) + self.assertNotIn(absent['Name'], REGISTRY[(HKLM, setup._registry_path(absent['Path']))]) + self.assertEqual(REGISTRY[(HKLM, setup._registry_path(externally_changed['Path']))] + [externally_changed['Name']], 77) + self.assertIn((HKLM, setup._registry_path(absent['Path']), absent['Name']), DELETES) + self.assertTrue(self.state_file.is_file()) + + def test_uninstall_without_baseline_refuses_changes(self): + with self.assertRaisesRegex(RuntimeError, 'No saved baseline'): + setup.uninstall() + self.assertEqual(WRITES, []) + self.assertEqual(DELETES, []) + + def test_invalid_baseline_refuses_all_registry_changes(self): + self.state_dir.mkdir(parents=True) + self.state_file.write_text(json.dumps([{'Path': 'HKLM:\\SOFTWARE\\Elsewhere', + 'Name': 'Synthetic', 'Exists': False, + 'Value': None, 'Target': 1}]), encoding='utf-8') + with self.assertRaisesRegex(RuntimeError, 'incomplete'): + setup.uninstall() + self.assertEqual(WRITES, []) + self.assertEqual(DELETES, []) + + def test_failure_to_persist_baseline_prevents_registry_modification(self): + with mock.patch.object(setup, 'json_write_file', side_effect=OSError('synthetic disk error')): + with self.assertRaises(OSError): + setup.install() + self.assertEqual(WRITES, []) + + def test_no_powershell_or_child_process_dependency_remains(self): + source = (ROOT / 'setup.py').read_text(encoding='utf-8').lower() + self.assertNotIn('subprocess', source) + self.assertNotIn('powershell.exe', source) + self.assertNotIn('hardening.ps1', source) + + +if __name__ == '__main__': + unittest.main()