Compare commits
5
Commits
11f99a588f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26aed33925 | ||
|
|
ca21b0fb3a | ||
|
|
86224e8840 | ||
|
|
79628520b7 | ||
|
|
3a046ef57d |
No files matched your search
@@ -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`. Each dictionary documents `Path`, `Name`, `Value`, `Purpose` and `Rationale`, so maintainers can see both the exact registry operation and why the policy is included. A future `update_package.py` can validate operator-selected settings and serialize them as package data; the current five-setting policy remains 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.
|
||||||
|
|||||||
@@ -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.'
|
|
||||||
}
|
|
||||||
@@ -1,33 +1,215 @@
|
|||||||
# -*- 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 = []
|
||||||
|
|
||||||
|
# Relative subdirectories under ProgramData used for this package's rollback state.
|
||||||
|
STATE_DIRECTORY = ('Comitari', 'HardeningSafe')
|
||||||
|
# Stores original registry values plus their package target for conditional rollback.
|
||||||
|
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)
|
# Purpose explains what the policy does; Rationale records why this package
|
||||||
print(result.stdout)
|
# applies it. These fields are descriptive and do not affect registry writes.
|
||||||
if result.stderr:
|
SETTINGS = (
|
||||||
print(result.stderr)
|
{
|
||||||
if result.returncode != 0:
|
'Path': r'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\Explorer',
|
||||||
raise Exception('Hardening %s failed with code %s' % (action, result.returncode))
|
'Name': 'NoAutorun',
|
||||||
|
'Value': 1,
|
||||||
|
'Purpose': 'Disable AutoRun commands for the computer.',
|
||||||
|
'Rationale': 'AutoRun can launch commands associated with removable media. Disabling it reduces the chance that connecting media starts a command without an explicit user action.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'Path': r'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\Explorer',
|
||||||
|
'Name': 'NoDriveTypeAutoRun',
|
||||||
|
'Value': 255,
|
||||||
|
'Purpose': 'Disable AutoPlay for all drive types covered by the Windows policy mask.',
|
||||||
|
'Rationale': 'The 0xFF mask applies the policy to every drive type represented by this setting, reducing automatic handling of newly connected or mounted media.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'Path': r'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Explorer',
|
||||||
|
'Name': 'NoAutoplayfornonVolume',
|
||||||
|
'Value': 1,
|
||||||
|
'Purpose': 'Disable AutoPlay for devices that do not expose a volume.',
|
||||||
|
'Rationale': 'The drive-type policy does not cover every device category. This additional policy closes that gap for non-volume devices.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'Path': r'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Personalization',
|
||||||
|
'Name': 'NoLockScreenCamera',
|
||||||
|
'Value': 1,
|
||||||
|
'Purpose': 'Prevent camera use from the Windows lock screen.',
|
||||||
|
'Rationale': 'The camera remains unavailable from the lock screen, limiting access to this device feature before a user signs in.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'Path': r'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Personalization',
|
||||||
|
'Name': 'NoLockScreenSlideshow',
|
||||||
|
'Value': 1,
|
||||||
|
'Purpose': 'Prevent a slideshow from running on the Windows lock screen.',
|
||||||
|
'Rationale': 'This prevents lock-screen slideshow configuration, avoiding display of rotating images while the computer is locked.',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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}
|
||||||
|
missing = object()
|
||||||
|
with reg_openkey_noredir(HKEY_LOCAL_MACHINE, subkey) as key:
|
||||||
|
value = reg_getvalue(key, name, default=missing)
|
||||||
|
if value is missing:
|
||||||
|
return {'Exists': False, 'Value': None}
|
||||||
|
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.')
|
||||||
@@ -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()
|
||||||
Reference in new issue
Block a user