fix(package): integrate WAPT ZIP correction

This commit is contained in:
PierreCsn committed 2026-10-28 17:49:49 +01:00
1 parent 1afb3a2b73
commit f817d41189
6 files changed
+596 -388

No files matched your search

+4 -3
View File
@@ -57,8 +57,9 @@ Dans WAPT Console / VS Code, lancer **Update Package**. Le point d’entrée uni
est `update_package.py`; il localise le paquet à partir de son propre chemin, est `update_package.py`; il localise le paquet à partir de son propre chemin,
quel que soit le répertoire courant. Il récupère l’archive HTTPS du commit quel que soit le répertoire courant. Il récupère l’archive HTTPS du commit
fixé, vérifie la structure et prépare `vendor/`, ses empreintes SHA-256 et fixé, vérifie la structure et prépare `vendor/`, ses empreintes SHA-256 et
`package-manifest.json`. Il n’incrémente pas la version, ne construit pas le `package-manifest.json`. Il incrémente le build WAPT uniquement si le contenu
`.wapt`, ne le signe pas et ne le publie pas. vendor change. Il ne construit pas le `.wapt`, ne le signe pas et ne le publie
pas.
Pour une préparation hors ligne, fournissez une archive ZIP exacte du commit et Pour une préparation hors ligne, fournissez une archive ZIP exacte du commit et
son empreinte obtenue par un canal de confiance distinct : son empreinte obtenue par un canal de confiance distinct :
@@ -105,7 +106,7 @@ utiliser **Build and upload** avant la vérification du fichier construit.
Inspecter le `.wapt` réellement généré : Inspecter le `.wapt` réellement généré :
```powershell ```powershell
waptpython tools\package_verify.py --wapt .\comi-audit-hardeningkitty_0.9.9-0_all.wapt waptpython tools\package_verify.py --wapt .\comi-audit-hardeningkitty_0.9.10-0_all.wapt
``` ```
Cette vérification ouvre l’archive ZIP `.wapt` et contrôle les scripts, Cette vérification ouvre l’archive ZIP `.wapt` et contrôle les scripts,
+1 -1
View File
@@ -1,5 +1,5 @@
package : comi-audit-hardeningkitty package : comi-audit-hardeningkitty
version : 0.9.9-0 version : 0.9.10-0
architecture : all architecture : all
section : base section : base
priority : optional priority : optional
+2 -2
View File
@@ -1,9 +1,9 @@
{ {
"files": { "files": {
"WAPT/control": "2d42c593fb406bf1ce99efdfa61ccfc053eb2cf125a181d12a0725494a11e566", "WAPT/control": "a7b8ffea3e6df419b88f6118c15b977ccf8b15a91c41d4d757e68e29265a2e0c",
"audit_config.json": "9fc18f556fd491e8e00e3a8b1efd864ad8036682e293a02c1f81f3411304a69b", "audit_config.json": "9fc18f556fd491e8e00e3a8b1efd864ad8036682e293a02c1f81f3411304a69b",
"runner.ps1": "5e9787f5cc19d0fc4c0ede01a63188ed71de6ef49df596fbce7b2603093231cd", "runner.ps1": "5e9787f5cc19d0fc4c0ede01a63188ed71de6ef49df596fbce7b2603093231cd",
"setup.py": "a7fb150a1c4f3ccb64f68b83fb7670c011b4434634dd7baa62b67d3050d4959e", "setup.py": "07e77da22bd1e65ce9004d926ca52ee559be4e0b16749b8018bf0684831752ee",
"vendor/HardeningKitty.psd1": "94378764787cd0c7125c5c3ce928803b46ee7d7d3fe21c2b27ddeb23c11377a7", "vendor/HardeningKitty.psd1": "94378764787cd0c7125c5c3ce928803b46ee7d7d3fe21c2b27ddeb23c11377a7",
"vendor/HardeningKitty.psm1": "b799ec1b17de5ce63f7a8ea34af3beba1d24fcaddf7e3774f797096f5c43e2a0", "vendor/HardeningKitty.psm1": "b799ec1b17de5ce63f7a8ea34af3beba1d24fcaddf7e3774f797096f5c43e2a0",
"vendor/LICENSE": "a879ba6ccdcd2ea55da97b999edd2c401c8589ca946f238236bf93aa40e74d56", "vendor/LICENSE": "a879ba6ccdcd2ea55da97b999edd2c401c8589ca946f238236bf93aa40e74d56",
+233 -288
View File
@@ -1,34 +1,39 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""WAPT package: offline, read-only HardeningKitty machine baseline audit. """Comitari - Offline, read-only HardeningKitty audit package for WAPT.
This code runs under WAPT Agent's Python runtime on Windows. All endpoint operations use WAPT setuphelpers where an appropriate helper exists.
Vendor assets are fetched on the packaging workstation, not on endpoints. WAPT verifies the package signature; vendor-manifest.json additionally checks the
pinned HardeningKitty payload, including after it is installed.
Build requires update_package.py to prepare vendor/ BEFORE WAPT build/sign/upload.
""" """
from setuphelpers import * from setuphelpers import *
import csv
import collections import collections
import csv
import datetime import datetime
import hashlib import hashlib # Stable, reproducible distribution of finding IDs to WAPT audit keys.
import io import io
import json import json
import locale import locale
import os import os
import shutil
import subprocess
import sys
import uuid import uuid
uninstallkey = [] uninstallkey = []
PACKAGE_NAME = 'comi-audit-hardeningkitty' PACKAGE_NAME = 'comi-audit-hardeningkitty'
AUDIT_SECTION = 'HardeningKitty' AUDIT_SECTION = 'HardeningKitty'
UPSTREAM_COMMIT = 'acf9ecee3d044a9337df7aad80e730cbd746e8de'
PROGRAM_DATA = os.environ.get('ProgramData', r'C:\ProgramData') PROGRAM_DATA = os.environ.get('ProgramData', r'C:\ProgramData')
INSTALL_ROOT = os.path.join(PROGRAM_DATA, 'Comitari', 'Audit-HardeningKitty') INSTALL_ROOT = makepath(PROGRAM_DATA, 'Comitari', 'Audit-HardeningKitty')
REPORT_ROOT = os.path.join(PROGRAM_DATA, 'Comitari', 'Audit-HardeningKitty-Reports') REPORT_ROOT = makepath(PROGRAM_DATA, 'Comitari', 'Audit-HardeningKitty-Reports')
OWNER_MARKER = '.comi-audit-hardeningkitty-owner' OWNER_MARKER = '.comi-audit-hardeningkitty-owner'
# Keep aligned with runner.ps1 supported auto-selected baselines.
REQUIRED_VENDOR_FILES = ( REQUIRED_VENDOR_FILES = (
'HardeningKitty.psm1', 'HardeningKitty.psd1', 'LICENSE', 'HardeningKitty.psm1', 'HardeningKitty.psd1', 'LICENSE',
'lists/hardeningkitty_lists_manifest.psd1', 'lists/hardeningkitty_lists_manifest.psd1.p7s', 'lists/hardeningkitty_lists_manifest.psd1',
'lists/hardeningkitty_lists_manifest.psd1.p7s',
'lists/finding_list_0x6d69636b_machine.csv', 'lists/finding_list_0x6d69636b_machine.csv',
'lists/finding_list_msft_security_baseline_windows_10_20h2_21h1_machine.csv', 'lists/finding_list_msft_security_baseline_windows_10_20h2_21h1_machine.csv',
'lists/finding_list_msft_security_baseline_windows_10_21h2_machine.csv', 'lists/finding_list_msft_security_baseline_windows_10_21h2_machine.csv',
@@ -47,172 +52,118 @@ REQUIRED_VENDOR_FILES = (
'lists/finding_list_msft_security_baseline_windows_server_2025_24h2_member_machine.csv', 'lists/finding_list_msft_security_baseline_windows_server_2025_24h2_member_machine.csv',
) )
# A staged/installed directory stores the vendor files at its root (NOT /vendor).
def _source_directory(): RUNTIME_FILES = ('runner.ps1', 'audit_config.json')
"""Find package root and return it relative to the current working directory.""" # Old package versions included this file. It is no longer required or trusted.
candidates = [] LEGACY_FILES = ('package-manifest.json',)
if globals().get('__file__'):
candidates.append(os.path.dirname(os.path.abspath(__file__)))
for name in ('basedir', 'package_dir'):
if globals().get(name):
candidates.append(os.path.abspath(globals()[name]))
current_directory = os.getcwd()
candidates.append(current_directory)
for candidate in candidates:
relative_candidate = os.path.relpath(os.path.abspath(candidate), current_directory)
if os.path.isfile(os.path.join(relative_candidate, 'WAPT', 'control')):
return relative_candidate
raise RuntimeError('Cannot locate WAPT source directory (WAPT/control not found).')
def _read_json(path): def _package_source():
with open(path, 'r', encoding='utf-8-sig') as fh: """Use WAPT's extraction directory, not cwd or the launcher's path."""
return json.load(fh) root = globals().get('basedir')
if not root:
root = os.path.dirname(os.path.abspath(__file__)) if globals().get('__file__') else None
if not root:
raise RuntimeError('WAPT basedir is not available.')
root = os.path.relpath(os.path.abspath(root), os.getcwd())
if not isfile(makepath(root, 'runner.ps1')) or not isfile(makepath(root, 'audit_config.json')):
raise RuntimeError('WAPT package source is missing runner.ps1 or audit_config.json: %s' % root)
return root
def _write_json(path, data): def _load_json(path):
with open(path, 'w', encoding='utf-8') as fh: return json_load_file(path)
json.dump(data, fh, ensure_ascii=False, indent=2, sort_keys=True)
def _checksum(path): def _save_json(path, payload):
sha = hashlib.sha256() json_write_file(path, payload, indent=2, sort_keys=True)
with open(path, 'rb') as fh:
for block in iter(lambda: fh.read(1024 * 1024), b''):
sha.update(block)
return sha.hexdigest()
def _verify_vendor(root, allow_runtime=False): def _safe_vendor_name(name):
manifest_path = os.path.join(root, 'vendor-manifest.json') return (isinstance(name, str) and bool(name) and '\\' not in name and
if not os.path.isfile(manifest_path): ':' not in name and not name.startswith('/') and
raise RuntimeError('Vendor assets missing from WAPT package (vendor/vendor-manifest.json). Rebuild from prepared sources and verify the resulting .wapt before installation.') all(part not in ('', '.', '..') for part in name.split('/')))
manifest = _read_json(manifest_path)
if manifest.get('upstream_commit') != 'acf9ecee3d044a9337df7aad80e730cbd746e8de':
raise RuntimeError('Vendor manifest does not use the approved pinned revision.') def _verify_vendor(root, runtime=False, allow_extra=False):
files = manifest.get('files', {}) """Verify all publisher payload hashes and reject unexpected files/symlinks."""
if not isinstance(files, dict) or len(files) < 5: manifest_file = makepath(root, 'vendor-manifest.json')
raise RuntimeError('Invalid vendor manifest; refuse incomplete audit assets.') if not isfile(manifest_file):
for item in REQUIRED_VENDOR_FILES: raise RuntimeError('HardeningKitty vendor-manifest.json is absent from the signed WAPT package. Run Update Package, then BUILD/SIGN/UPLOAD the new .wapt.')
if item not in files: manifest = _load_json(manifest_file)
raise RuntimeError('Missing required vendor asset: %s' % item) if not isinstance(manifest, dict) or manifest.get('upstream_commit') != UPSTREAM_COMMIT:
raise RuntimeError('Unexpected HardeningKitty vendor revision.')
files = manifest.get('files')
if not isinstance(files, dict) or len(files) < 50:
raise RuntimeError('HardeningKitty vendor file inventory is invalid/incomplete.')
missing = set(REQUIRED_VENDOR_FILES).difference(files)
if missing:
raise RuntimeError('HardeningKitty vendor manifest missing: %s' % ', '.join(sorted(missing)))
root_real = os.path.realpath(root) root_real = os.path.realpath(root)
expected_files = set(files) expected = {'vendor-manifest.json'}
expected_files.add('vendor-manifest.json') for relative, checksum in files.items():
if allow_runtime: if not _safe_vendor_name(relative):
for runtime_name in ('runner.ps1', 'audit_config.json', 'package-manifest.json', OWNER_MARKER): raise RuntimeError('Unsafe vendor filename: %r' % relative)
if os.path.isfile(os.path.join(root, runtime_name)): if not isinstance(checksum, str) or len(checksum) != 64 or any(c not in '0123456789abcdefABCDEF' for c in checksum):
expected_files.add(runtime_name) raise RuntimeError('Invalid vendor SHA-256 entry: %s' % relative)
for name, digest in files.items(): filename = os.path.realpath(makepath(root, *relative.split('/')))
if not isinstance(name, str) or '\\' in name or name.startswith('/') or '..' in name.split('/'): if not filename.startswith(root_real + os.sep):
raise RuntimeError('Unsafe vendor path: %r' % name) raise RuntimeError('Vendor filename escaped installation directory: %s' % relative)
dest = os.path.realpath(os.path.join(root, *name.split('/'))) if not isfile(filename) or get_sha256(filename).lower() != checksum.lower():
if not dest.startswith(root_real + os.sep): raise RuntimeError('Missing/altered vendor asset: %s' % relative)
raise RuntimeError('Vendor asset escapes install directory: %r' % name) expected.add(relative)
if not os.path.isfile(dest) or _checksum(dest).lower() != str(digest).lower():
raise RuntimeError('Missing or altered vendor asset: %s' % name) if runtime:
actual_files = set() for filename in RUNTIME_FILES:
for current, dirs, names in os.walk(root, followlinks=False): if not isfile(makepath(root, filename)):
if any(os.path.islink(os.path.join(current, name)) for name in dirs + names): raise RuntimeError('Installed WAPT runtime file missing: %s' % filename)
raise RuntimeError('Vendor assets must not contain symlinks.') expected.update(RUNTIME_FILES)
# Older installed versions may contain this obsolete manifest.
expected.update(name for name in LEGACY_FILES if isfile(makepath(root, name)))
if isfile(makepath(root, OWNER_MARKER)):
expected.add(OWNER_MARKER)
actual = set()
for directory, folders, names in os.walk(root, followlinks=False):
for name in folders + names:
if os.path.islink(makepath(directory, name)):
raise RuntimeError('HardeningKitty directory contains a symlink; refusing it.')
for name in names: for name in names:
actual_files.add(os.path.relpath(os.path.join(current, name), root).replace(os.sep, '/')) actual.add(os.path.relpath(makepath(directory, name), root).replace(os.sep, '/'))
if actual_files != expected_files: if not expected.issubset(actual) or (not allow_extra and actual != expected):
raise RuntimeError('Vendor directory contains missing or unmanifested files.') raise RuntimeError('Unexpected/missing files in HardeningKitty directory: %s' %
', '.join(sorted(actual.symmetric_difference(expected))[:10]))
return manifest return manifest
def _verify_package_source(source): def _owned_install(root, allow_extra=False):
if not os.path.isfile(os.path.join(source, 'WAPT', 'control')): """Never replace/delete a directory belonging to someone else."""
raise RuntimeError('WAPT/control is missing from package sources.') if os.path.islink(root) or not isdir(root):
manifest = _read_json(os.path.join(source, 'package-manifest.json'))
files = manifest.get('files')
if manifest.get('schema_version') != 1 or manifest.get('upstream_commit') != 'acf9ecee3d044a9337df7aad80e730cbd746e8de' or not isinstance(files, dict):
raise RuntimeError('Package source manifest is invalid.')
_verify_vendor(os.path.join(source, 'vendor'))
root_real = os.path.realpath(source)
actual = {}
for name in ('setup.py', 'WAPT/control', 'runner.ps1', 'audit_config.json', 'vendor/vendor-manifest.json'):
if name not in files:
raise RuntimeError('Package manifest is missing required source file: %s' % name)
for name, expected in files.items():
if not isinstance(name, str) or '\\' in name or name.startswith('/') or '..' in name.split('/'):
raise RuntimeError('Unsafe package manifest path: %r' % name)
path = os.path.realpath(os.path.join(source, *name.split('/')))
if not path.startswith(root_real + os.sep) or not os.path.isfile(path):
raise RuntimeError('Package source file missing or outside source root: %s' % name)
actual[name] = _checksum(path)
if actual[name].lower() != str(expected).lower():
raise RuntimeError('Package source file differs from package manifest: %s' % name)
required = set('vendor/' + item for item in REQUIRED_VENDOR_FILES)
if not required.issubset(files):
raise RuntimeError('Package manifest omits runner-required benchmark assets.')
return manifest
def _verify_runtime_assets(root):
manifest = _read_json(os.path.join(root, 'package-manifest.json'))
files = manifest.get('files')
if not isinstance(files, dict):
raise RuntimeError('Runtime package manifest is invalid.')
for name in ('runner.ps1', 'audit_config.json', 'vendor/vendor-manifest.json'):
if name not in files:
raise RuntimeError('Runtime manifest is missing %s.' % name)
for name, digest in files.items():
if name in ('setup.py', 'WAPT/control'):
continue
runtime_name = name[len('vendor/'):] if name.startswith('vendor/') else name
path = os.path.join(root, *runtime_name.split('/'))
if not os.path.isfile(path) or _checksum(path).lower() != str(digest).lower():
raise RuntimeError('Runtime asset missing or altered: %s' % name)
_verify_vendor(root, allow_runtime=True)
def _is_owned_install(root):
if os.path.islink(root) or not os.path.isdir(root):
return False return False
marker = os.path.join(root, OWNER_MARKER)
try: try:
if os.path.isfile(marker): marker = makepath(root, OWNER_MARKER)
with open(marker, 'r', encoding='utf-8') as fh: if isfile(marker):
if fh.read().strip() != PACKAGE_NAME: with open(marker, encoding='ascii') as stream:
return False if stream.read().strip() != PACKAGE_NAME:
else:
# Upgrade path for versions predating the ownership marker.
_verify_vendor(root, allow_runtime=True)
if not os.path.isfile(os.path.join(root, 'runner.ps1')) or not os.path.isfile(os.path.join(root, 'audit_config.json')):
return False
_verify_vendor(root, allow_runtime=True)
manifest = _read_json(os.path.join(root, 'vendor-manifest.json'))
allowed = set(manifest['files'])
allowed.update(('vendor-manifest.json', 'runner.ps1', 'audit_config.json',
'package-manifest.json', OWNER_MARKER))
for current, dirs, files in os.walk(root, followlinks=False):
for directory in dirs:
if os.path.islink(os.path.join(current, directory)):
return False
for filename in files:
path = os.path.join(current, filename)
if os.path.islink(path):
return False
relative = os.path.relpath(path, root).replace(os.sep, '/')
if relative not in allowed:
return False return False
# Legacy upgrade without owner marker: require intact original payload.
_verify_vendor(root, runtime=True, allow_extra=allow_extra)
return True return True
except Exception: except Exception:
return False return False
def _remove_owned_install_files(root): def _remove_owned_install_files(root):
manifest = _read_json(os.path.join(root, 'vendor-manifest.json')) """Remove only verified package files, keeping unrelated operator data."""
managed = set(manifest.get('files', {})) manifest = _verify_vendor(root, runtime=True, allow_extra=True)
managed.update(('vendor-manifest.json', 'runner.ps1', 'audit_config.json', managed = set(manifest['files'])
'package-manifest.json', OWNER_MARKER)) managed.update(('vendor-manifest.json',) + RUNTIME_FILES + LEGACY_FILES + (OWNER_MARKER,))
root_real = os.path.realpath(root) root_real = os.path.realpath(root)
for name in sorted(managed, key=lambda value: value.count('/'), reverse=True): for relative in sorted(managed, key=lambda value: value.count('/'), reverse=True):
if not isinstance(name, str) or '\\' in name or name.startswith('/') or '..' in name.split('/'): if not _safe_vendor_name(relative):
raise RuntimeError('Unsafe installed manifest path; refusing removal.') raise RuntimeError('Unsafe installed manifest path; refusing removal.')
path = os.path.join(root, *name.split('/')) path = makepath(root, *relative.split('/'))
resolved = os.path.realpath(path) resolved = os.path.realpath(path)
if not resolved.startswith(root_real + os.sep): if not resolved.startswith(root_real + os.sep):
raise RuntimeError('Installed manifest path escapes package root; refusing removal.') raise RuntimeError('Installed manifest path escapes package root; refusing removal.')
@@ -225,9 +176,9 @@ def _remove_owned_install_files(root):
except OSError: except OSError:
break break
parent = os.path.dirname(parent) parent = os.path.dirname(parent)
for current, dirs, files in os.walk(root, topdown=False, followlinks=False): for current, folders, names in os.walk(root, topdown=False, followlinks=False):
for directory in dirs: for folder in folders:
path = os.path.join(current, directory) path = makepath(current, folder)
if not os.path.islink(path): if not os.path.islink(path):
try: try:
os.rmdir(path) os.rmdir(path)
@@ -240,97 +191,78 @@ def _remove_owned_install_files(root):
def _secure_directory(path): def _secure_directory(path):
"""Only LocalSystem and local Administrators can access audit assets.""" """Apply SID-based ACLs independent of Windows display language."""
if os.name != 'nt': if os.name != 'nt':
return return
# Language-independent SIDs; do not rely on localized group names. run(['icacls.exe', path, '/inheritance:r', '/grant:r',
subprocess.check_call(['icacls.exe', path, '/inheritance:r', '/grant:r', '*S-1-5-18:(OI)(CI)F', '*S-1-5-32-544:(OI)(CI)F', '/T', '/Q'],
'*S-1-5-18:(OI)(CI)F', '*S-1-5-32-544:(OI)(CI)F', shell=False, timeout=120, accept_returncodes=[0])
'/T', '/Q'])
def install(): def install():
source = _source_directory() """Install files from WAPT basedir, offline; stage then swap atomically."""
_verify_package_source(source) source = _package_source()
vendor = os.path.join(source, 'vendor') vendor = makepath(source, 'vendor')
manifest = _verify_vendor(vendor) manifest = _verify_vendor(vendor)
parent = os.path.dirname(INSTALL_ROOT) parent = os.path.dirname(INSTALL_ROOT)
os.makedirs(parent, exist_ok=True) mkdirs(parent)
staging = INSTALL_ROOT + '.staging-' + uuid.uuid4().hex staging = INSTALL_ROOT + '.staging-' + uuid.uuid4().hex
previous = INSTALL_ROOT + '.previous-' + uuid.uuid4().hex previous = INSTALL_ROOT + '.previous-' + uuid.uuid4().hex
renamed_old = False old_moved = False
try: try:
if os.path.exists(INSTALL_ROOT): if os.path.lexists(INSTALL_ROOT) and not _owned_install(INSTALL_ROOT):
if not _is_owned_install(INSTALL_ROOT): raise RuntimeError('Install directory already exists but is not owned by this WAPT package.')
raise RuntimeError('Existing install root is not identified as this package; refusing replacement.') mkdirs(staging)
os.makedirs(staging)
_secure_directory(staging) _secure_directory(staging)
for current, dirs, files in os.walk(vendor): copytree2(vendor, staging, follow_symlinks=False)
relative = os.path.relpath(current, vendor) for filename in RUNTIME_FILES:
target_dir = staging if relative == '.' else os.path.join(staging, relative) filecopyto(makepath(source, filename), makepath(staging, filename))
os.makedirs(target_dir, exist_ok=True) _verify_vendor(staging, runtime=True)
for directory in dirs: with open(makepath(staging, OWNER_MARKER), 'w', encoding='ascii') as stream:
os.makedirs(os.path.join(target_dir, directory), exist_ok=True) stream.write(PACKAGE_NAME + '\n')
for filename in files:
shutil.copy2(os.path.join(current, filename), os.path.join(target_dir, filename))
for name in ('runner.ps1', 'audit_config.json', 'package-manifest.json'):
shutil.copy2(os.path.join(source, name), os.path.join(staging, name))
_verify_runtime_assets(staging)
with open(os.path.join(staging, OWNER_MARKER), 'w', encoding='ascii') as marker:
marker.write(PACKAGE_NAME + '\n')
_secure_directory(staging) _secure_directory(staging)
os.makedirs(REPORT_ROOT, exist_ok=True) mkdirs(REPORT_ROOT)
_secure_directory(REPORT_ROOT) _secure_directory(REPORT_ROOT)
if os.path.isdir(INSTALL_ROOT): if isdir(INSTALL_ROOT):
os.rename(INSTALL_ROOT, previous) os.rename(INSTALL_ROOT, previous)
renamed_old = True old_moved = True
try:
os.rename(staging, INSTALL_ROOT) os.rename(staging, INSTALL_ROOT)
if renamed_old and os.path.isdir(previous):
try:
shutil.rmtree(previous)
except OSError as exc:
print('Previous package-owned engine retained at %s: %s' % (previous, type(exc).__name__))
except Exception: except Exception:
if os.path.isdir(staging): if old_moved and isdir(previous) and not os.path.exists(INSTALL_ROOT):
try:
shutil.rmtree(staging)
except OSError:
pass
if renamed_old and not os.path.exists(INSTALL_ROOT):
os.rename(previous, INSTALL_ROOT) os.rename(previous, INSTALL_ROOT)
old_moved = False
raise raise
print('Installed offline HardeningKitty audit assets, commit %s (%s files).' % if old_moved and isdir(previous):
(manifest.get('upstream_commit', 'unknown'), len(manifest['files']))) try:
print('No Windows security setting has been modified.') remove_tree(previous)
except Exception as exc:
print('Previous owned installation preserved for manual cleanup: %s' % type(exc).__name__)
finally:
if isdir(staging):
remove_tree(staging, ignore_errors=True)
print('HardeningKitty installed offline: %s (%s verified files)' %
(manifest['upstream_commit'], len(manifest['files'])))
def uninstall(): def uninstall():
if os.path.isdir(INSTALL_ROOT): if os.path.lexists(INSTALL_ROOT):
if os.path.islink(INSTALL_ROOT): if not _owned_install(INSTALL_ROOT, allow_extra=True):
raise RuntimeError('Refusing to remove a symlink install root.') raise RuntimeError('Uninstall refused: directory is unverified or not owned by this package.')
marker = os.path.join(INSTALL_ROOT, OWNER_MARKER)
if os.path.isfile(marker):
with open(marker, 'r', encoding='utf-8') as fh:
if fh.read().strip() != PACKAGE_NAME:
raise RuntimeError('Install ownership marker is invalid; refusing removal.')
else:
# Older package releases predate the marker; require verified assets.
_verify_vendor(INSTALL_ROOT, allow_runtime=True)
if not os.path.isfile(os.path.join(INSTALL_ROOT, 'vendor-manifest.json')):
raise RuntimeError('Install ownership marker missing; refusing to remove foreign data.')
_remove_owned_install_files(INSTALL_ROOT) _remove_owned_install_files(INSTALL_ROOT)
print('Removed HardeningKitty audit engine. Historical reports kept in %s' % REPORT_ROOT) print('HardeningKitty removed. Historical reports kept: %s' % REPORT_ROOT)
def _powershell_executable(): def _powershell_executable():
windir = os.environ.get('SystemRoot', r'C:\Windows') """Use native 64-bit PowerShell when WAPT is a 32-bit process."""
for relative in ((r'Sysnative', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), system_root = os.environ.get('SystemRoot', r'C:\Windows')
(r'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')): sysnative = makepath(system_root, 'Sysnative', 'WindowsPowerShell', 'v1.0', 'powershell.exe')
candidate = os.path.join(windir, *relative) if isfile(sysnative):
if os.path.isfile(candidate): return sysnative
return candidate native = makepath(system32(), 'WindowsPowerShell', 'v1.0', 'powershell.exe')
raise RuntimeError('Windows PowerShell 5.1 64-bit not found.') if isfile(native):
return native
raise RuntimeError('Windows PowerShell 5.1 is missing.')
def _read_report(path, publish_current_values=False): def _read_report(path, publish_current_values=False):
fieldnames, rows = _read_csv_rows(path) fieldnames, rows = _read_csv_rows(path)
@@ -527,69 +459,88 @@ def _publish_audit(summary, findings, config):
def _trim_reports(keep): def _trim_reports(keep):
paths = [os.path.join(REPORT_ROOT, name) for name in os.listdir(REPORT_ROOT) if not isdir(REPORT_ROOT):
if name.startswith('run-') and os.path.isdir(os.path.join(REPORT_ROOT, name)) return
and not os.path.islink(os.path.join(REPORT_ROOT, name))] paths = [makepath(REPORT_ROOT, name) for name in os.listdir(REPORT_ROOT)
if name.startswith('run-') and isdir(makepath(REPORT_ROOT, name))
and not os.path.islink(makepath(REPORT_ROOT, name))]
paths.sort(key=lambda p: os.path.getmtime(p), reverse=True) paths.sort(key=lambda p: os.path.getmtime(p), reverse=True)
for path in paths[max(1, int(keep)):]: for path in paths[max(1, int(keep)):]:
shutil.rmtree(path) remove_tree(path)
def _audit_impl(): def _audit_impl():
_verify_runtime_assets(INSTALL_ROOT) manifest = _verify_vendor(INSTALL_ROOT, runtime=True)
manifest = _verify_vendor(INSTALL_ROOT, allow_runtime=True) config = _load_json(makepath(INSTALL_ROOT, 'audit_config.json'))
config = _read_json(os.path.join(INSTALL_ROOT, 'audit_config.json'))
if config.get('source') not in ('GPO', 'Intune'): if config.get('source') not in ('GPO', 'Intune'):
raise RuntimeError('audit_config.json source must be GPO or Intune') raise RuntimeError('audit_config.json: source must be GPO or Intune.')
profile = str(config.get('profile', 'auto')) profile = str(config.get('profile', 'auto'))
# Runner validates the override independently to prevent command injection/traversal.
if not isinstance(config.get('error_on_high'), bool) or not isinstance(config.get('warn_on_low'), bool): if not isinstance(config.get('error_on_high'), bool) or not isinstance(config.get('warn_on_low'), bool):
raise RuntimeError('Invalid compliance threshold settings.') raise RuntimeError('Invalid audit threshold settings.')
if not isinstance(config.get('publish_to_wapt', True), bool) or not isinstance(config.get('publish_current_values', False), bool): if not isinstance(config.get('publish_to_wapt', True), bool) or not isinstance(config.get('publish_current_values', False), bool):
raise RuntimeError('WAPT publication/privacy settings must be JSON booleans.') raise RuntimeError('Invalid WAPT publication/privacy settings.')
retain = config.get('retain_audit_runs', 8) retain = config.get('retain_audit_runs', 8)
max_log_findings = config.get('max_findings_in_log', 15) max_log = config.get('max_findings_in_log', 15)
if isinstance(retain, bool) or not isinstance(retain, int) or retain < 1 or retain > 100: if isinstance(retain, bool) or not isinstance(retain, int) or not 1 <= retain <= 100:
raise RuntimeError('retain_audit_runs must be an integer from 1 to 100.') raise RuntimeError('retain_audit_runs must be 1..100.')
if isinstance(max_log_findings, bool) or not isinstance(max_log_findings, int) or max_log_findings < 0 or max_log_findings > 100: if isinstance(max_log, bool) or not isinstance(max_log, int) or not 0 <= max_log <= 100:
raise RuntimeError('max_findings_in_log must be an integer from 0 to 100.') raise RuntimeError('max_findings_in_log must be 0..100.')
exclusions = config.get('intentional_exclusions', {}) exclusions = config.get('intentional_exclusions', {})
if not isinstance(exclusions, dict): if not isinstance(exclusions, dict):
raise RuntimeError('intentional_exclusions must be a benchmark-to-ID mapping.') raise RuntimeError('intentional_exclusions must be an object.')
timeout = int(config.get('timeout_seconds', 900)) timeout = config.get('timeout_seconds', 900)
if timeout < 30 or timeout > 7200: if isinstance(timeout, bool) or not isinstance(timeout, int) or not 30 <= timeout <= 7200:
raise RuntimeError('timeout_seconds must be between 30 and 7200.') raise RuntimeError('timeout_seconds must be 30..7200.')
os.makedirs(REPORT_ROOT, exist_ok=True) mkdirs(REPORT_ROOT)
_secure_directory(REPORT_ROOT) _secure_directory(REPORT_ROOT)
run_dir = os.path.join(REPORT_ROOT, 'run-' + datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%d-%H%M%S') + '-' + uuid.uuid4().hex[:8]) run_id = datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%d-%H%M%S') + '-' + uuid.uuid4().hex[:8]
os.makedirs(run_dir, exist_ok=False) run_dir = makepath(REPORT_ROOT, 'run-' + run_id)
mkdirs(run_dir)
_secure_directory(run_dir) _secure_directory(run_dir)
csv_file = os.path.join(run_dir, 'hardeningkitty.csv') csv_file = makepath(run_dir, 'hardeningkitty.csv')
log_file = os.path.join(run_dir, 'powershell.log') log_file = makepath(run_dir, 'powershell.log')
metadata_file = os.path.join(run_dir, 'host.json') metadata_file = makepath(run_dir, 'host.json')
cmd = [_powershell_executable(), '-NoLogo', '-NoProfile', '-NonInteractive', # run() is WAPT's process helper and handles timeouts and exit statuses.
'-File', os.path.join(INSTALL_ROOT, 'runner.ps1'), # No subprocess, shell interpolation, or endpoint Internet access.
command = [_powershell_executable(), '-NoLogo', '-NoProfile', '-NonInteractive',
'-File', makepath(INSTALL_ROOT, 'runner.ps1'),
'-InstallRoot', INSTALL_ROOT, '-CsvFile', csv_file, '-InstallRoot', INSTALL_ROOT, '-CsvFile', csv_file,
'-MetadataFile', metadata_file, '-Source', config['source'], '-Profile', profile] '-MetadataFile', metadata_file, '-Source', config['source'], '-Profile', profile]
with open(log_file, 'wb') as log: with open(log_file, 'w', encoding='utf-8') as log:
completed = subprocess.run(cmd, stdout=log, stderr=subprocess.STDOUT, timeout=timeout, check=False) def record(line):
if completed.returncode != 0: text = str(line)
raise RuntimeError('HardeningKitty PowerShell runner exited %s; see %s' % log.write(text if text.endswith('\n') else text + '\n')
(completed.returncode, log_file)) log.flush()
if not os.path.isfile(csv_file) or not os.path.isfile(metadata_file): try:
raise RuntimeError('Audit runner did not create all required reports.') result = run(command, shell=False, timeout=timeout,
metadata = _read_json(metadata_file) accept_returncodes=[0], on_write=record)
counts, findings = _read_report(csv_file, bool(config.get('publish_current_values', False))) if log.tell() == 0 and result:
baseline_file = os.path.join(INSTALL_ROOT, 'lists', metadata['benchmark']) record(result)
counts, findings = _check_coverage(baseline_file, csv_file, counts, findings, except Exception as exc:
exclusions, metadata['benchmark']) log.write('Execution failed: %s\n' % type(exc).__name__)
raise RuntimeError('HardeningKitty runner failed (%s). See %s' %
(type(exc).__name__, log_file)) from exc
if not isfile(csv_file) or not isfile(metadata_file):
raise RuntimeError('HardeningKitty runner did not produce CSV and host metadata.')
metadata = _load_json(metadata_file)
benchmark = str(metadata.get('benchmark', ''))
if (not benchmark.startswith('finding_list_') or not benchmark.endswith('.csv') or
'/' in benchmark or '\\' in benchmark or '..' in benchmark):
raise RuntimeError('Unsafe or invalid benchmark from PowerShell runner.')
baseline = makepath(INSTALL_ROOT, 'lists', benchmark)
if not isfile(baseline):
raise RuntimeError('Runner selected a benchmark absent from vendor assets: %s' % benchmark)
counts, findings = _read_report(csv_file, config.get('publish_current_values', False))
counts, findings = _check_coverage(baseline, csv_file, counts, findings, exclusions, benchmark)
status = _status_from_counts(counts, config) status = _status_from_counts(counts, config)
summary = dict(counts) summary = dict(counts)
summary.update({ summary.update({
'status': status, 'status': status,
'benchmark': metadata['benchmark'], 'benchmark': benchmark,
'os_build': str(metadata['build']), 'os_build': str(metadata['build']),
'os_caption': str(metadata.get('os_caption', '')), 'os_caption': str(metadata.get('os_caption', '')),
'display_version': str(metadata.get('display_version', '')), 'display_version': str(metadata.get('display_version', '')),
@@ -600,16 +551,17 @@ def _audit_impl():
'report_csv': csv_file, 'report_csv': csv_file,
'report_log': log_file, 'report_log': log_file,
}) })
_write_json(os.path.join(run_dir, 'summary.json'), summary) _save_json(makepath(run_dir, 'summary.json'), summary)
_publish_audit(summary, findings, config) _publish_audit(summary, findings, config)
print('HardeningKitty: %s | %s | total=%s passed=%s high=%s medium=%s low=%s unknown=%s skipped=%s | compliant=%s%%' % print('HardeningKitty: %s | %s | checked=%s passed=%s high=%s medium=%s low=%s unknown=%s skipped=%s | compliant=%s%%' %
(status, summary['benchmark'], counts['total'], counts['passed'], counts['high'], (status, benchmark, counts['total'], counts['passed'], counts['high'],
counts['medium'], counts['low'], counts['unknown'], counts['skipped'], counts['compliant_percent'])) counts['medium'], counts['low'], counts['unknown'], counts['skipped'], counts['compliant_percent']))
sort_priority = {'high': 0, 'medium': 1, 'low': 2, 'unknown': 3} priority = {'high': 0, 'medium': 1, 'low': 2, 'unknown': 3}
for entry in sorted(findings, key=lambda f: (sort_priority.get(f['severity'], 9), f['id']))[:int(config.get('max_findings_in_log', 15))]: for finding in sorted(findings, key=lambda f: (priority.get(f['severity'], 9), f['id']))[:max_log]:
print('[%s] %s | %s | %s' % (entry['severity'].upper(), entry['id'], entry['category'], entry['name'])) print('[%s] %s | %s | %s' %
print('Local full report: %s' % csv_file) (finding['severity'].upper(), finding['id'], finding['category'], finding['name']))
_trim_reports(config.get('retain_audit_runs', 8)) print('Full CSV report: %s' % csv_file)
_trim_reports(retain)
return status return status
@@ -617,31 +569,24 @@ def audit():
try: try:
return _audit_impl() return _audit_impl()
except Exception as exc: except Exception as exc:
# Do not send paths, command lines or host-specific details to Reporting. # Log enough detail locally, but publish only a sanitized technical error.
message = 'Collection failed: %s' % type(exc).__name__ print('HardeningKitty audit ERROR: %s' % exc)
print('HardeningKitty audit ERROR (execution/integrity failure): %s' % message)
wapt = globals().get('WAPT') wapt = globals().get('WAPT')
write = getattr(wapt, 'write_audit_data_if_changed', None) label = 'Collection failed: %s' % type(exc).__name__
if callable(write): if wapt is not None:
for key, value in [('status', 'ERROR'), ('last_error', message)]:
try: try:
write(AUDIT_SECTION, key, value, max_count=5, keep_days=120) wapt.write_audit_data_if_changed(AUDIT_SECTION, 'status', 'ERROR',
except Exception: max_count=5, keep_days=120)
pass wapt.write_audit_data_if_changed(AUDIT_SECTION, 'last_error', label,
write_run = getattr(wapt, 'write_audit_data', None) max_count=5, keep_days=120)
if callable(write_run): wapt.write_audit_data(AUDIT_SECTION, 'last_run_utc',
try:
write_run(AUDIT_SECTION, 'last_run_utc',
datetime.datetime.now(datetime.timezone.utc).isoformat(), datetime.datetime.now(datetime.timezone.utc).isoformat(),
max_count=5, keep_days=120) max_count=5, keep_days=120)
except Exception: except Exception:
pass pass
try: try:
config = _read_json(os.path.join(INSTALL_ROOT, 'audit_config.json')) config = _load_json(makepath(INSTALL_ROOT, 'audit_config.json'))
_trim_reports(config.get('retain_audit_runs', 8)) _trim_reports(config.get('retain_audit_runs', 8))
except Exception: except Exception:
pass pass
return 'ERROR' return 'ERROR'
+79 -53
View File
@@ -14,7 +14,27 @@ import shutil
from unittest import mock from unittest import mock
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
sys.modules.setdefault('setuphelpers', types.ModuleType('setuphelpers')) setuphelpers_stub = types.ModuleType('setuphelpers')
setuphelpers_stub.makepath = lambda *parts: os.path.join(*(str(part) for part in parts))
setuphelpers_stub.isfile = os.path.isfile
setuphelpers_stub.isdir = os.path.isdir
setuphelpers_stub.mkdirs = lambda path: os.makedirs(path, exist_ok=True)
setuphelpers_stub.ensure_dir = lambda path: os.makedirs(os.path.dirname(path), exist_ok=True)
setuphelpers_stub.filecopyto = lambda source, target: shutil.copy2(source, target)
setuphelpers_stub.copytree2 = lambda source, target, follow_symlinks=False: shutil.copytree(
source, target, dirs_exist_ok=True, symlinks=not follow_symlinks)
setuphelpers_stub.remove_tree = lambda path, ignore_errors=False: shutil.rmtree(
path, ignore_errors=ignore_errors)
setuphelpers_stub.remove_file = lambda path: os.remove(path) if os.path.exists(path) else None
setuphelpers_stub.get_sha256 = lambda path: hashlib.sha256(Path(path).read_bytes()).hexdigest()
setuphelpers_stub.json_load_file = lambda path: json.loads(Path(path).read_text(encoding='utf-8'))
setuphelpers_stub.json_write_file = lambda path, data, **kwargs: Path(path).write_text(
json.dumps(data, **kwargs) + '\n', encoding='utf-8')
setuphelpers_stub.system32 = lambda: os.path.join(os.environ.get('SystemRoot', '/tmp'), 'System32')
setuphelpers_stub.run = lambda *args, **kwargs: ''
setuphelpers_stub.wget = lambda *args, **kwargs: (_ for _ in ()).throw(
AssertionError('unexpected network download in synthetic tests'))
sys.modules.setdefault('setuphelpers', setuphelpers_stub)
spec = importlib.util.spec_from_file_location('wapt_hardeningkitty_setup', ROOT / 'setup.py') spec = importlib.util.spec_from_file_location('wapt_hardeningkitty_setup', ROOT / 'setup.py')
pkg = importlib.util.module_from_spec(spec) pkg = importlib.util.module_from_spec(spec)
spec.loader.exec_module(pkg) spec.loader.exec_module(pkg)
@@ -57,6 +77,10 @@ def make_source(root):
(root / 'audit_config.json').write_text('{}\n', encoding='utf-8') (root / 'audit_config.json').write_text('{}\n', encoding='utf-8')
asset_root = root / 'vendor' asset_root = root / 'vendor'
assets = {x: ('sample ' + x).encode('utf-8') for x in vendor.REQUIRED} assets = {x: ('sample ' + x).encode('utf-8') for x in vendor.REQUIRED}
assets['HardeningKitty.psd1'] = b"@{\n ModuleVersion = '0.9.5'\n}\n"
csv_count = sum(name.startswith('lists/') and name.endswith('.csv') for name in assets)
assets.update({'lists/synthetic_machine_%02d.csv' % i: ('synthetic list %d' % i).encode('utf-8')
for i in range(max(0, 50 - csv_count))})
for name, data in assets.items(): for name, data in assets.items():
path = asset_root.joinpath(*name.split('/')) path = asset_root.joinpath(*name.split('/'))
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
@@ -218,6 +242,8 @@ class PackageTests(unittest.TestCase):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp) root = Path(tmp)
assets = {x: ('sample ' + x).encode('utf-8') for x in vendor.REQUIRED} assets = {x: ('sample ' + x).encode('utf-8') for x in vendor.REQUIRED}
assets.update({'lists/synthetic_machine_%02d.csv' % i: ('synthetic list %d' % i).encode('utf-8')
for i in range(max(0, 50 - len(assets)))})
for name, data in assets.items(): for name, data in assets.items():
path = root.joinpath(*name.split('/')) path = root.joinpath(*name.split('/'))
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
@@ -238,22 +264,23 @@ class PackageTests(unittest.TestCase):
shutil.copy2(ROOT / 'tools' / 'vendor_assets.py', package_source / 'tools' / 'vendor_assets.py') shutil.copy2(ROOT / 'tools' / 'vendor_assets.py', package_source / 'tools' / 'vendor_assets.py')
shutil.copy2(ROOT / 'tools' / 'package_verify.py', package_source / 'tools' / 'package_verify.py') shutil.copy2(ROOT / 'tools' / 'package_verify.py', package_source / 'tools' / 'package_verify.py')
verify.write_source_manifest(package_source) verify.write_source_manifest(package_source)
prior = (pkg._source_directory, pkg.INSTALL_ROOT, pkg.REPORT_ROOT) prior = (pkg._package_source, pkg.INSTALL_ROOT, pkg.REPORT_ROOT)
try: try:
pkg._source_directory = lambda: str(package_source) pkg._package_source = lambda: str(package_source)
pkg.INSTALL_ROOT = str(root / 'programdata' / 'engine') pkg.INSTALL_ROOT = str(root / 'programdata' / 'engine')
pkg.REPORT_ROOT = str(root / 'programdata' / 'reports') pkg.REPORT_ROOT = str(root / 'programdata' / 'reports')
pkg.install() pkg.install()
self.assertEqual(pkg._verify_vendor(pkg.INSTALL_ROOT, allow_runtime=True)['upstream_commit'], vendor.UPSTREAM_COMMIT) self.assertEqual(pkg._verify_vendor(pkg.INSTALL_ROOT, runtime=True)['upstream_commit'], vendor.UPSTREAM_COMMIT)
self.assertTrue(Path(pkg.REPORT_ROOT).is_dir()) self.assertTrue(Path(pkg.REPORT_ROOT).is_dir())
foreign = Path(pkg.INSTALL_ROOT, 'operator-note.txt') foreign = Path(pkg.INSTALL_ROOT, 'operator-note.txt')
foreign.write_text('keep me', encoding='utf-8') foreign.write_text('keep me', encoding='utf-8')
pkg.uninstall() pkg.uninstall()
self.assertTrue(foreign.is_file()) self.assertTrue(foreign.is_file())
self.assertFalse(Path(pkg.INSTALL_ROOT, 'HardeningKitty.psm1').exists()) self.assertFalse(Path(pkg.INSTALL_ROOT, 'HardeningKitty.psm1').exists())
self.assertTrue(Path(pkg.INSTALL_ROOT).is_dir())
self.assertTrue(Path(pkg.REPORT_ROOT).is_dir()) self.assertTrue(Path(pkg.REPORT_ROOT).is_dir())
finally: finally:
pkg._source_directory, pkg.INSTALL_ROOT, pkg.REPORT_ROOT = prior pkg._package_source, pkg.INSTALL_ROOT, pkg.REPORT_ROOT = prior
def test_installer_refuses_to_replace_foreign_existing_directory(self): def test_installer_refuses_to_replace_foreign_existing_directory(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
@@ -263,19 +290,19 @@ class PackageTests(unittest.TestCase):
shutil.copy2(ROOT / 'tools' / 'vendor_assets.py', source / 'tools' / 'vendor_assets.py') shutil.copy2(ROOT / 'tools' / 'vendor_assets.py', source / 'tools' / 'vendor_assets.py')
shutil.copy2(ROOT / 'tools' / 'package_verify.py', source / 'tools' / 'package_verify.py') shutil.copy2(ROOT / 'tools' / 'package_verify.py', source / 'tools' / 'package_verify.py')
verify.write_source_manifest(source) verify.write_source_manifest(source)
old = (pkg._source_directory, pkg.INSTALL_ROOT, pkg.REPORT_ROOT) old = (pkg._package_source, pkg.INSTALL_ROOT, pkg.REPORT_ROOT)
try: try:
pkg._source_directory = lambda: str(source) pkg._package_source = lambda: str(source)
pkg.INSTALL_ROOT = str(root / 'programdata' / 'engine') pkg.INSTALL_ROOT = str(root / 'programdata' / 'engine')
pkg.REPORT_ROOT = str(root / 'programdata' / 'reports') pkg.REPORT_ROOT = str(root / 'programdata' / 'reports')
Path(pkg.INSTALL_ROOT).mkdir(parents=True) Path(pkg.INSTALL_ROOT).mkdir(parents=True)
foreign = Path(pkg.INSTALL_ROOT, 'foreign.txt') foreign = Path(pkg.INSTALL_ROOT, 'foreign.txt')
foreign.write_text('do not delete', encoding='utf-8') foreign.write_text('do not delete', encoding='utf-8')
with self.assertRaisesRegex(RuntimeError, 'not identified'): with self.assertRaisesRegex(RuntimeError, 'not owned'):
pkg.install() pkg.install()
self.assertEqual(foreign.read_text(encoding='utf-8'), 'do not delete') self.assertEqual(foreign.read_text(encoding='utf-8'), 'do not delete')
finally: finally:
pkg._source_directory, pkg.INSTALL_ROOT, pkg.REPORT_ROOT = old pkg._package_source, pkg.INSTALL_ROOT, pkg.REPORT_ROOT = old
def test_published_shards_and_keys(self): def test_published_shards_and_keys(self):
events = {} events = {}
@@ -356,48 +383,53 @@ class PackageTests(unittest.TestCase):
original_cwd = os.getcwd() original_cwd = os.getcwd()
try: try:
os.chdir(tmp) os.chdir(tmp)
source_root = updater._source_directory() source_root = updater._source_dir()
self.assertFalse(os.path.isabs(source_root)) self.assertFalse(os.path.isabs(source_root))
self.assertTrue((Path(source_root) / 'WAPT' / 'control').is_file()) self.assertTrue((Path(source_root) / 'WAPT' / 'control').is_file())
self.assertEqual(Path(source_root).resolve(), ROOT) self.assertEqual(Path(source_root).resolve(), ROOT)
os.chdir(ROOT) os.chdir(ROOT)
self.assertEqual(updater._source_directory(), Path('.')) self.assertEqual(updater._source_dir(), '.')
finally: finally:
os.chdir(original_cwd) os.chdir(original_cwd)
calls = [] with tempfile.TemporaryDirectory() as tmp:
class FakeVendor: package_source = make_source(Path(tmp, 'package'))
MAX_ARCHIVE_BYTES = vendor.MAX_ARCHIVE_BYTES (package_source / 'tools').mkdir(exist_ok=True)
@staticmethod shutil.copy2(ROOT / 'tools' / 'vendor_assets.py', package_source / 'tools' / 'vendor_assets.py')
def _download_snapshot(): shutil.copy2(ROOT / 'tools' / 'package_verify.py', package_source / 'tools' / 'package_verify.py')
return b'synthetic-pinned-archive' verify.write_source_manifest(package_source)
@staticmethod
def vendor_from_archive(data, dest):
calls.append(('vendor', data, Path(dest)))
class FakeVerifier:
@staticmethod
def write_source_manifest(root):
calls.append(('manifest', Path(root)))
@staticmethod
def verify_source(root):
calls.append(('verify', Path(root)))
old_loader = updater._load_tool
try: try:
updater._load_tool = lambda root, name: FakeVendor if name == 'vendor_assets' else FakeVerifier updater._verify_vendor(str(package_source / 'vendor'))
except Exception as exc:
self.fail('synthetic vendor did not satisfy updater: %s' % exc)
old_source = updater._source_dir
old_control = getattr(updater, 'control', None)
class FakeControl:
version = '0.9.9-0'
def inc_build(self):
raise AssertionError('idempotent update must not increment the build')
def save_control_to_wapt(self):
raise AssertionError('idempotent update must not write control')
try:
updater._source_dir = lambda: str(package_source)
updater.control = FakeControl()
updater.update_package() updater.update_package()
updater.update_package() updater.update_package()
finally: finally:
updater._load_tool = old_loader updater._source_dir = old_source
self.assertEqual([call[0] for call in calls], ['vendor', 'manifest', 'verify'] * 2) if old_control is None:
del updater.control
else:
updater.control = old_control
def test_setup_source_directory_is_relative_to_package_root(self): def test_setup_source_directory_is_relative_to_package_root(self):
original_cwd = os.getcwd() original_cwd = os.getcwd()
try: try:
os.chdir(ROOT) os.chdir(ROOT)
self.assertEqual(pkg._source_directory(), '.') self.assertEqual(pkg._package_source(), '.')
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
os.chdir(tmp) os.chdir(tmp)
source_root = pkg._source_directory() source_root = pkg._package_source()
self.assertFalse(os.path.isabs(source_root)) self.assertFalse(os.path.isabs(source_root))
self.assertTrue(os.path.isfile(os.path.join(source_root, 'WAPT', 'control'))) self.assertTrue(os.path.isfile(os.path.join(source_root, 'WAPT', 'control')))
finally: finally:
@@ -408,34 +440,28 @@ class PackageTests(unittest.TestCase):
archive = Path(tmp, 'archive.zip') archive = Path(tmp, 'archive.zip')
archive.write_bytes(b'synthetic') archive.write_bytes(b'synthetic')
expected = hashlib.sha256(b'synthetic').hexdigest() expected = hashlib.sha256(b'synthetic').hexdigest()
old_source = updater._source_dir
old_isfile = updater.isfile
old_isdir = updater.isdir
old_env = dict(os.environ) old_env = dict(os.environ)
old_loader = updater._load_tool
old_source = updater._source_directory
class FakeVendor:
MAX_ARCHIVE_BYTES = 1024
@staticmethod
def sha256(data): return hashlib.sha256(data).hexdigest()
@staticmethod
def vendor_from_archive(data, dest): pass
class FakeVerifier:
@staticmethod
def write_source_manifest(root): pass
@staticmethod
def verify_source(root): pass
try: try:
updater._source_dir = lambda: str(ROOT)
updater.isfile = os.path.isfile
updater.isdir = os.path.isdir
with self.assertRaisesRegex(RuntimeError, 'SHA-256 is required'):
updater.update_package(snapshot_zip=str(archive))
with self.assertRaisesRegex(RuntimeError, 'does not match'):
updater.update_package(snapshot_zip=str(archive), snapshot_sha256='0' * 64)
os.environ['HARDENINGKITTY_ARCHIVE'] = str(archive) os.environ['HARDENINGKITTY_ARCHIVE'] = str(archive)
os.environ.pop('HARDENINGKITTY_ARCHIVE_SHA256', None)
updater._source_directory = lambda: ROOT
updater._load_tool = lambda root, name: FakeVendor if name == 'vendor_assets' else FakeVerifier
with self.assertRaisesRegex(RuntimeError, 'required for offline'):
updater.update_package()
os.environ['HARDENINGKITTY_ARCHIVE_SHA256'] = expected os.environ['HARDENINGKITTY_ARCHIVE_SHA256'] = expected
with self.assertRaises(zipfile.BadZipFile):
updater.update_package() updater.update_package()
finally: finally:
updater._source_dir = old_source
updater.isfile = old_isfile
updater.isdir = old_isdir
os.environ.clear() os.environ.clear()
os.environ.update(old_env) os.environ.update(old_env)
updater._load_tool = old_loader
updater._source_directory = old_source
def test_prebuild_verifier_rejects_stale_package_manifest(self): def test_prebuild_verifier_rejects_stale_package_manifest(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
+277 -41
View File
@@ -1,56 +1,292 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""WAPT-native source preparation entry point (no build/sign/upload).""" """WAPT Update Package hook for Comitari HardeningKitty audit.
from pathlib import Path
import importlib.util Runs on the WAPT packaging workstation ONLY, never on endpoints.
Uses WAPT helpers: wget, unzip, filecopyto, mkdirs, remove_tree,
get_sha256, json_{load,write}_file and PackageEntry.inc_build().
The package must be BUILT, SIGNED and UPLOADED after Update Package.
"""
from setuphelpers import *
import os import os
import sys import re
import uuid
import zipfile # Only used for ZIP safety preflight; WAPT unzip() extracts it.
import importlib.util
UPSTREAM_REPOSITORY = '0x6d69636b/windows_hardening'
UPSTREAM_COMMIT = 'acf9ecee3d044a9337df7aad80e730cbd746e8de'
UPSTREAM_URL = 'https://codeload.github.com/%s/zip/%s' % (UPSTREAM_REPOSITORY, UPSTREAM_COMMIT)
MODULE_VERSION = '0.9.5'
MAX_ARCHIVE_BYTES = 40 * 1024 * 1024
MAX_EXTRACTED_BYTES = 100 * 1024 * 1024
MAX_SINGLE_FILE_BYTES = 8 * 1024 * 1024
MIN_LISTS = 50
REQUIRED_FILES = (
'HardeningKitty.psm1', 'HardeningKitty.psd1', 'LICENSE',
'lists/hardeningkitty_lists_manifest.psd1',
'lists/hardeningkitty_lists_manifest.psd1.p7s',
'lists/finding_list_0x6d69636b_machine.csv',
'lists/finding_list_msft_security_baseline_windows_10_20h2_21h1_machine.csv',
'lists/finding_list_msft_security_baseline_windows_10_21h2_machine.csv',
'lists/finding_list_msft_security_baseline_windows_10_22h2_machine.csv',
'lists/finding_list_msft_security_baseline_windows_11_21h2_machine.csv',
'lists/finding_list_msft_security_baseline_windows_11_22h2_machine.csv',
'lists/finding_list_msft_security_baseline_windows_11_23h2_machine.csv',
'lists/finding_list_msft_security_baseline_windows_11_24h2_machine.csv',
'lists/finding_list_msft_security_baseline_windows_11_25h2_machine.csv',
'lists/finding_list_cis_microsoft_windows_server_2012_r2_3.0.0_machine.csv',
'lists/finding_list_cis_microsoft_windows_server_2016_1607_3.0.0_machine.csv',
'lists/finding_list_cis_microsoft_windows_server_2019_1809_3.0.0_machine.csv',
'lists/finding_list_msft_security_baseline_windows_server_2022_21h2_dc_machine.csv',
'lists/finding_list_msft_security_baseline_windows_server_2022_21h2_member_machine.csv',
'lists/finding_list_msft_security_baseline_windows_server_2025_24h2_dc_machine.csv',
'lists/finding_list_msft_security_baseline_windows_server_2025_24h2_member_machine.csv',
)
def _source_directory(): def _source_dir():
"""Find package root and return its path relative to the current directory.""" """Use WAPT's injected basedir; __file__ is a development fallback."""
source = Path(os.path.relpath(Path(__file__).resolve().parent, Path.cwd())) root = globals().get('basedir')
if not (source / 'WAPT' / 'control').is_file(): if not root:
raise RuntimeError('update_package.py must be inside a WAPT package source directory.') root = os.path.dirname(os.path.abspath(__file__)) if globals().get('__file__') else None
return source if not root:
raise RuntimeError('WAPT did not provide basedir.')
root = os.path.relpath(os.path.abspath(root), os.getcwd())
if not isfile(makepath(root, 'WAPT', 'control')):
raise RuntimeError('Not a WAPT package source folder: %s' % root)
return root
def _load_tool(source, name): def _safe_name(relative):
path = source / 'tools' / (name + '.py') return (isinstance(relative, str) and bool(relative) and
spec = importlib.util.spec_from_file_location('comi_audit_secu_' + name, str(path)) '\\' not in relative and ':' not in relative and not relative.startswith('/') and
if spec is None or spec.loader is None: all(piece not in ('', '.', '..') for piece in relative.split('/')))
raise RuntimeError('Cannot load package tool: %s' % path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def update_package(): def _wanted(relative):
"""Prepare and verify pinned assets; WAPT Console still builds and signs.""" if relative in ('HardeningKitty.psm1', 'HardeningKitty.psd1', 'LICENSE'):
source = _source_directory() return True
vendor_tool = _load_tool(source, 'vendor_assets') return (relative.startswith('lists/') and relative.count('/') == 1 and
archive = os.environ.get('HARDENINGKITTY_ARCHIVE') (relative.endswith('.csv') or relative in (
if archive: 'lists/hardeningkitty_lists_manifest.psd1',
archive_path = Path(archive) 'lists/hardeningkitty_lists_manifest.psd1.p7s')))
data = archive_path.read_bytes()
if len(data) > vendor_tool.MAX_ARCHIVE_BYTES:
raise RuntimeError('Offline HardeningKitty archive exceeds size limit.') def _check_module_version(root):
expected = os.environ.get('HARDENINGKITTY_ARCHIVE_SHA256') with open(makepath(root, 'HardeningKitty.psd1'), 'r', encoding='utf-8-sig') as stream:
if not expected: text = stream.read()
raise RuntimeError('HARDENINGKITTY_ARCHIVE_SHA256 is required for offline preparation.') match = re.search(r'''(?im)^\s*ModuleVersion\s*=\s*['\"]([^'\"]+)['\"]''', text)
if vendor_tool.sha256(data).lower() != expected.lower(): if not match or match.group(1) != MODULE_VERSION:
raise RuntimeError('Offline HardeningKitty archive checksum mismatch.') raise RuntimeError('Unexpected HardeningKitty module version: %s' %
(match.group(1) if match else 'missing'))
def _verify_vendor(root):
manifest_file = makepath(root, 'vendor-manifest.json')
if not isfile(manifest_file):
raise RuntimeError('vendor/vendor-manifest.json is missing.')
manifest = json_load_file(manifest_file)
if manifest.get('upstream_commit') != UPSTREAM_COMMIT:
raise RuntimeError('Vendor revision does not match approved pinned commit.')
files = manifest.get('files')
if not isinstance(files, dict) or not set(REQUIRED_FILES).issubset(files):
raise RuntimeError('Vendor file manifest is missing required assets.')
if sum(name.startswith('lists/') and name.endswith('.csv') for name in files) < MIN_LISTS:
raise RuntimeError('Vendor contains too few benchmark lists.')
expected = {'vendor-manifest.json'}
root_real = os.path.realpath(root)
for name, digest in files.items():
if not _safe_name(name):
raise RuntimeError('Unsafe vendor file name: %r' % name)
if not isinstance(digest, str) or len(digest) != 64 or any(c not in '0123456789abcdefABCDEF' for c in digest):
raise RuntimeError('Invalid SHA-256 in vendor manifest: %s' % name)
filename = os.path.realpath(makepath(root, *name.split('/')))
if not filename.startswith(root_real + os.sep) or not isfile(filename):
raise RuntimeError('Missing/escaped vendor file: %s' % name)
if get_sha256(filename).lower() != digest.lower():
raise RuntimeError('Vendor checksum mismatch: %s' % name)
expected.add(name)
actual = set()
for current, folders, names in os.walk(root, followlinks=False):
for member in folders + names:
if os.path.islink(makepath(current, member)):
raise RuntimeError('Vendor contains an untrusted symlink.')
for name in names:
actual.add(os.path.relpath(makepath(current, name), root).replace(os.sep, '/'))
if actual != expected:
raise RuntimeError('Vendor has extra or missing files: %s' %
', '.join(sorted(actual.symmetric_difference(expected))[:8]))
_check_module_version(root)
return manifest
def _check_snapshot_archive(filename):
"""Validate filenames, symlinks and sizes BEFORE WAPT unzip()."""
if os.path.getsize(filename) > MAX_ARCHIVE_BYTES:
raise RuntimeError('HardeningKitty upstream ZIP exceeds size limit.')
seen = set()
root = None
size_total = 0
selected = []
with zipfile.ZipFile(filename, 'r') as archive:
for entry in archive.infolist():
name = entry.filename
if '\\' in name or name.startswith('/') or ':' in name:
raise RuntimeError('Unsafe entry in source ZIP: %r' % name)
parts = name.rstrip('/').split('/')
if not parts or any(part in ('', '.', '..') for part in parts):
raise RuntimeError('Unsafe ZIP path: %r' % name)
if root is None:
root = parts[0]
if parts[0] != root or UPSTREAM_COMMIT[:7].lower() not in root.lower():
raise RuntimeError('Unexpected upstream ZIP root: %s' % parts[0])
if name in seen:
raise RuntimeError('Duplicate ZIP entry: %s' % name)
seen.add(name)
if (entry.external_attr >> 16) & 0o170000 == 0o120000:
raise RuntimeError('Symlink in upstream ZIP: %s' % name)
if entry.file_size > MAX_SINGLE_FILE_BYTES:
raise RuntimeError('Oversized ZIP entry: %s' % name)
size_total += entry.file_size
if size_total > MAX_EXTRACTED_BYTES:
raise RuntimeError('Uncompressed ZIP exceeds size limit.')
if not entry.is_dir() and len(parts) >= 2:
relative = '/'.join(parts[1:])
if _wanted(relative):
selected.append(relative)
if not root or not set(REQUIRED_FILES).issubset(selected):
raise RuntimeError('ZIP snapshot does not contain all required HardeningKitty files.')
if len([name for name in selected if name.startswith('lists/') and name.endswith('.csv')]) < MIN_LISTS:
raise RuntimeError('ZIP snapshot does not contain enough benchmark lists.')
return root, selected
def _prepare_from_archive(source, snapshot):
"""Stage and verify files before atomically replacing vendor/."""
work = makepath(source, '.hk-update-' + uuid.uuid4().hex)
extraction = makepath(work, 'extracted')
stage = makepath(work, 'vendor-new')
backup = makepath(work, 'vendor-old')
vendor = makepath(source, 'vendor')
old_moved = False
mkdirs(extraction)
mkdirs(stage)
try:
root, selected = _check_snapshot_archive(snapshot)
unzip(snapshot, target=extraction)
extracted_root = makepath(extraction, root)
if not isdir(extracted_root):
raise RuntimeError('WAPT unzip did not produce the expected upstream folder.')
files = {}
for relative in selected:
origin = makepath(extracted_root, *relative.split('/'))
target = makepath(stage, *relative.split('/'))
if not isfile(origin) or os.path.islink(origin):
raise RuntimeError('Missing/unsafe ZIP output: %s' % relative)
ensure_dir(target)
filecopyto(origin, target)
files[relative] = get_sha256(target).lower()
manifest = {
'upstream_repository': UPSTREAM_REPOSITORY,
'upstream_commit': UPSTREAM_COMMIT,
'source_archive_sha256': get_sha256(snapshot).lower(),
'files': dict(sorted(files.items())),
}
json_write_file(makepath(stage, 'vendor-manifest.json'), manifest,
indent=2, sort_keys=True)
_verify_vendor(stage)
if os.path.lexists(vendor):
if os.path.islink(vendor) or not isdir(vendor):
raise RuntimeError('vendor path is not a safe directory.')
# Preserve an existing vendor if anything fails during replacement.
os.rename(vendor, backup)
old_moved = True
try:
os.rename(stage, vendor)
except Exception:
if old_moved and not os.path.exists(vendor):
os.rename(backup, vendor)
old_moved = False
raise
if old_moved and isdir(backup):
try:
remove_tree(backup)
except Exception as exc:
print('Old vendor retained for cleanup: %s' % type(exc).__name__)
return _verify_vendor(vendor)
finally:
if isdir(work):
remove_tree(work, ignore_errors=True)
def update_package(snapshot_zip=None, snapshot_sha256=None):
"""WAPT Console / VS Code Update Package entry point."""
if snapshot_zip is None:
snapshot_zip = os.environ.get('HARDENINGKITTY_ARCHIVE')
snapshot_sha256 = snapshot_sha256 or os.environ.get('HARDENINGKITTY_ARCHIVE_SHA256')
source = _source_dir()
vendor = makepath(source, 'vendor')
print('WAPT source: %s' % source)
print('Pinned HardeningKitty revision: %s' % UPSTREAM_COMMIT)
existing = None
vendor_changed = False
if isdir(vendor):
try:
existing = _verify_vendor(vendor)
except Exception as exc:
print('Existing vendor invalid, rebuilding (%s).' % type(exc).__name__)
if existing and snapshot_zip is None:
manifest = existing
print('Verified vendor/ already prepared; no network access needed.')
else: else:
data = vendor_tool._download_snapshot() work = makepath(source, '.hk-download-' + uuid.uuid4().hex)
vendor_tool.vendor_from_archive(data, source / 'vendor') mkdirs(work)
verifier = _load_tool(source, 'package_verify') try:
if snapshot_zip is not None:
snapshot = os.path.abspath(snapshot_zip)
if not isfile(snapshot):
raise RuntimeError('Local snapshot ZIP does not exist: %s' % snapshot)
if not isinstance(snapshot_sha256, str) or not re.fullmatch(r'[0-9a-fA-F]{64}', snapshot_sha256):
raise RuntimeError('A valid SHA-256 is required for a local snapshot ZIP.')
if get_sha256(snapshot).lower() != snapshot_sha256.lower():
raise RuntimeError('Local snapshot ZIP SHA-256 does not match the supplied digest.')
else:
snapshot = makepath(work, 'hardeningkitty.zip')
print('Downloading fixed HardeningKitty revision with WAPT wget()...')
wget(UPSTREAM_URL, target=snapshot, verify_cert=True,
connect_timeout=20, download_timeout=120)
manifest = _prepare_from_archive(source, snapshot)
finally:
if isdir(work):
remove_tree(work, ignore_errors=True)
vendor_changed = (existing is None or
existing.get('source_archive_sha256') != manifest.get('source_archive_sha256'))
package_control = globals().get('control')
if package_control is None:
from waptpackage import PackageEntry
package_control = PackageEntry(waptfile=source)
if vendor_changed:
package_control.inc_build()
package_control.save_control_to_wapt()
verifier_path = makepath(source, 'tools', 'package_verify.py')
spec = importlib.util.spec_from_file_location('comi_audit_package_verify', verifier_path)
if spec is None or spec.loader is None:
raise RuntimeError('Could not load package source verifier.')
verifier = importlib.util.module_from_spec(spec)
spec.loader.exec_module(verifier)
verifier.write_source_manifest(source) verifier.write_source_manifest(source)
verifier.verify_source(source) verifier.verify_source(source)
print('HardeningKitty sources are prepared and verified. No WAPT build, signature or upload was performed.') print('Vendor ready: %d verified upstream files.' % len(manifest['files']))
print('WAPT/control version: %s' % package_control.version)
print('Now BUILD + SIGN + UPLOAD a new WAPT archive. Update Package does not publish.')
return manifest
if __name__ == '__main__': if __name__ == '__main__':
try:
update_package() update_package()
except Exception as exc:
print('ERROR: %s' % exc, file=sys.stderr)
raise