Compare commits

..
2 Commits
4 changed files with 340 additions and 83 deletions

No files matched your search

+12 -4
View File
@@ -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. SOURCE ONLY: must build and sign with the organization's WAPT tooling. Not yet Windows/WAPT-tested.
## Scope ## 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. 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 ## 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. - Reinstallation idempotent; uninstall conditional: restores only values still equal to package target, leaving externally modified values unchanged.
- No reboot forced. Audit implemented. - No reboot forced. Audit implemented.
- State persists after uninstall intentionally, so repeated uninstall does not overwrite third-party edits. - 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 ## 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. 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. 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. 4. Deploy to 1 test PC, verify WAPT agent/check-in, LapsByWAPT password rotation and recovery, business apps, USB workflows.
-66
View File
@@ -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.'
}
+165 -13
View File
@@ -1,33 +1,185 @@
# -*- coding: utf-8 -*- # -*- 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 * from setuphelpers import *
import os import os
import subprocess
uninstallkey = [] uninstallkey = []
STATE_DIRECTORY = ('Comitari', 'HardeningSafe')
STATE_FILENAME = 'original-values.json'
def _run(action): # Intentionally limited machine-wide controls. Keep state-file paths in the
script = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'hardening.ps1') # legacy PowerShell format so existing installations can roll back safely.
result = subprocess.run(['powershell.exe', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', script, '-Action', action], capture_output=True, text=True) SETTINGS = (
print(result.stdout) {'Path': r'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\Explorer',
if result.stderr: 'Name': 'NoAutorun', 'Value': 1},
print(result.stderr) {'Path': r'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\Explorer',
if result.returncode != 0: 'Name': 'NoDriveTypeAutoRun', 'Value': 255},
raise Exception('Hardening %s failed with code %s' % (action, result.returncode)) {'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(): 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(): def audit():
"""Read-only compliance check for the configured package settings."""
try: 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' return 'OK'
except Exception as exc: except Exception as exc:
print(exc) print('Hardening audit failed: %s' % type(exc).__name__)
return 'WARNING' return 'WARNING'
def uninstall(): 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.')
+163
View File
@@ -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()