Files
comi-audit-secu/update_package.py
T

293 lines
14 KiB
Python

# -*- coding: utf-8 -*-
"""WAPT Update Package hook for Comitari HardeningKitty audit.
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 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_dir():
"""Use WAPT's injected basedir; __file__ is a development fallback."""
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 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 _safe_name(relative):
return (isinstance(relative, str) and bool(relative) and
'\\' not in relative and ':' not in relative and not relative.startswith('/') and
all(piece not in ('', '.', '..') for piece in relative.split('/')))
def _wanted(relative):
if relative in ('HardeningKitty.psm1', 'HardeningKitty.psd1', 'LICENSE'):
return True
return (relative.startswith('lists/') and relative.count('/') == 1 and
(relative.endswith('.csv') or relative in (
'lists/hardeningkitty_lists_manifest.psd1',
'lists/hardeningkitty_lists_manifest.psd1.p7s')))
def _check_module_version(root):
with open(makepath(root, 'HardeningKitty.psd1'), 'r', encoding='utf-8-sig') as stream:
text = stream.read()
match = re.search(r'''(?im)^\s*ModuleVersion\s*=\s*['\"]([^'\"]+)['\"]''', text)
if not match or match.group(1) != MODULE_VERSION:
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:
work = makepath(source, '.hk-download-' + uuid.uuid4().hex)
mkdirs(work)
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.verify_source(source)
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__':
update_package()