fix(audit): harden HardeningKitty packaging and reporting

This commit is contained in:
PierreCsn committed 2026-10-28 13:28:48 +01:00
1 parent f4a67f3e1b
commit 91eb704215
10 files changed
+1171 -236

No files matched your search

+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env python3
"""Fail-closed source and built-WAPT payload verifier."""
import argparse
import hashlib
import json
from pathlib import Path, PurePosixPath
import sys
import stat
import zipfile
try:
from vendor_assets import UPSTREAM_COMMIT, REQUIRED
except ImportError:
import importlib.util
_vendor_spec = importlib.util.spec_from_file_location(
'comi_audit_secu_vendor_assets', Path(__file__).resolve().parent / 'vendor_assets.py')
_vendor_module = importlib.util.module_from_spec(_vendor_spec)
_vendor_spec.loader.exec_module(_vendor_module)
UPSTREAM_COMMIT = _vendor_module.UPSTREAM_COMMIT
REQUIRED = _vendor_module.REQUIRED
PACKAGE_FILES = ('setup.py', 'WAPT/control', 'runner.ps1', 'audit_config.json', 'vendor/vendor-manifest.json')
REQUIRED_WAPT = (
'setup.py', 'WAPT/control', 'runner.ps1', 'audit_config.json', 'package-manifest.json',
'vendor/HardeningKitty.psm1',
'vendor/HardeningKitty.psd1', 'vendor/vendor-manifest.json',
)
def digest_bytes(data):
return hashlib.sha256(data).hexdigest()
def _safe_name(name):
path = PurePosixPath(name)
return (not path.is_absolute() and '\\' not in name and
all(part not in ('', '.', '..') for part in name.split('/')))
def _verify_vendor(root):
vendor = root / 'vendor'
manifest_path = vendor / 'vendor-manifest.json'
if not manifest_path.is_file():
raise RuntimeError('vendor/vendor-manifest.json is missing.')
manifest = json.loads(manifest_path.read_text(encoding='utf-8'))
if manifest.get('upstream_commit') != UPSTREAM_COMMIT:
raise RuntimeError('Vendor manifest does not pin the approved HardeningKitty commit.')
files = manifest.get('files')
if not isinstance(files, dict) or not REQUIRED.issubset(files):
raise RuntimeError('Vendor manifest is incomplete for the runner benchmark mappings.')
expected_files = set(files) | {'vendor-manifest.json'}
for name, expected in files.items():
if not _safe_name(name):
raise RuntimeError('Unsafe path in vendor manifest: %r' % name)
path = vendor.joinpath(*name.split('/'))
if not path.is_file() or digest_bytes(path.read_bytes()).lower() != str(expected).lower():
raise RuntimeError('Missing or altered vendor asset: %s' % name)
actual_files = set()
for path in vendor.rglob('*'):
if path.is_symlink():
raise RuntimeError('Vendor assets must not contain symlinks.')
if path.is_file():
actual_files.add(path.relative_to(vendor).as_posix())
if actual_files != expected_files:
raise RuntimeError('Vendor directory contains missing or unmanifested files.')
csv_files = [name for name in files if name.startswith('lists/') and name.endswith('.csv')]
if not csv_files or not any(name.endswith('_machine.csv') for name in csv_files):
raise RuntimeError('Vendor manifest contains no machine benchmark lists.')
return manifest
def _manifest_files(root):
names = list(PACKAGE_FILES)
names.extend('vendor/' + name for name in sorted(_verify_vendor(root)['files']))
return {name: digest_bytes((root / name).read_bytes()) for name in sorted(set(names))}
def write_source_manifest(root):
root = Path(root).resolve()
files = _manifest_files(root)
document = {'schema_version': 1, 'upstream_commit': UPSTREAM_COMMIT, 'files': files}
(root / 'package-manifest.json').write_text(
json.dumps(document, sort_keys=True, indent=2) + '\n', encoding='utf-8')
return document
def verify_source(root):
root = Path(root).resolve()
if not (root / 'WAPT' / 'control').is_file():
raise RuntimeError('WAPT/control is missing; not a package source root.')
_verify_vendor(root)
path = root / 'package-manifest.json'
if not path.is_file():
raise RuntimeError('package-manifest.json is missing; run update-package preparation before building.')
manifest = json.loads(path.read_text(encoding='utf-8'))
if manifest.get('schema_version') != 1 or manifest.get('upstream_commit') != UPSTREAM_COMMIT:
raise RuntimeError('Invalid package manifest schema or upstream revision.')
expected = manifest.get('files')
if not isinstance(expected, dict) or not expected:
raise RuntimeError('Package file manifest is empty or malformed.')
actual = _manifest_files(root)
if expected != actual:
changed = sorted(set(expected) | set(actual))
changed = [name for name in changed if expected.get(name) != actual.get(name)]
raise RuntimeError('Package sources differ from package-manifest.json: %s' % ', '.join(changed))
return manifest
def verify_wapt(archive_path):
"""Inspect the actual built .wapt ZIP, including every manifested digest."""
with zipfile.ZipFile(str(archive_path), 'r') as package:
members = package.infolist()
member_names = [info.filename for info in members]
if len(member_names) != len(set(member_names)):
raise RuntimeError('Built WAPT contains duplicate ZIP member names.')
if any(stat.S_ISLNK(info.external_attr >> 16) for info in members):
raise RuntimeError('Built WAPT contains a symlink member.')
names = set(member_names)
missing = sorted(set(REQUIRED_WAPT) - names)
if missing:
raise RuntimeError('Built WAPT is missing required files: %s' % ', '.join(missing))
manifest = json.loads(package.read('package-manifest.json').decode('utf-8'))
vendor_manifest = json.loads(package.read('vendor/vendor-manifest.json').decode('utf-8'))
if manifest.get('schema_version') != 1 or manifest.get('upstream_commit') != UPSTREAM_COMMIT:
raise RuntimeError('Built WAPT package manifest is invalid.')
expected = manifest.get('files')
if not isinstance(expected, dict):
raise RuntimeError('Built WAPT package manifest has no file map.')
for name, digest in expected.items():
if not _safe_name(name) or name not in names:
raise RuntimeError('Built WAPT is missing manifested file: %s' % name)
if digest_bytes(package.read(name)).lower() != str(digest).lower():
raise RuntimeError('Built WAPT file hash mismatch: %s' % name)
vendor_files = vendor_manifest.get('files', {})
if not isinstance(vendor_files, dict) or not REQUIRED.issubset(vendor_files):
raise RuntimeError('Built WAPT vendor manifest is incomplete.')
for name, digest in vendor_files.items():
entry = 'vendor/' + name
if entry not in names or digest_bytes(package.read(entry)).lower() != str(digest).lower():
raise RuntimeError('Built WAPT vendor file missing or altered: %s' % entry)
list_entries = [name for name in names if name.startswith('vendor/lists/') and name.endswith('.csv')]
if not list_entries:
raise RuntimeError('Built WAPT has no vendor/lists/*.csv benchmark files.')
actual_vendor_files = {name[len('vendor/'):] for name in names
if name.startswith('vendor/') and not name.endswith('/')}
if actual_vendor_files != set(vendor_files) | {'vendor-manifest.json'}:
raise RuntimeError('Built WAPT vendor directory contains missing or unmanifested files.')
return {'files_verified': len(expected) + len(vendor_files), 'csv_lists': len(list_entries),
'upstream_commit': UPSTREAM_COMMIT}
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument('--source', metavar='DIR', help='Verify package source before build.')
group.add_argument('--write-source-manifest', metavar='DIR',
help='Refresh package-manifest.json after intentional source changes.')
group.add_argument('--wapt', metavar='FILE', help='Verify the actual built WAPT archive.')
args = parser.parse_args(argv)
if args.source:
result = verify_source(Path(args.source))
print('Source verification passed (%d manifested files).' % len(result['files']))
elif args.write_source_manifest:
result = write_source_manifest(Path(args.write_source_manifest))
print('Wrote source manifest (%d files).' % len(result['files']))
else:
result = verify_wapt(args.wapt)
print('Built WAPT verification passed: %s' % result)
if __name__ == '__main__':
try:
main()
except Exception as exc:
print('ERROR: %s' % exc, file=sys.stderr)
sys.exit(1)
+54 -5
View File
@@ -13,6 +13,7 @@ from pathlib import Path
import shutil
import sys
import tempfile
import uuid
import urllib.request
import zipfile
@@ -31,7 +32,19 @@ REQUIRED = {
'lists/finding_list_0x6d69636b_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_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_10_22h2_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_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',
}
@@ -63,6 +76,8 @@ def _wanted(relative):
def vendor_from_archive(archive_bytes, destination):
root = Path(destination)
if root.is_symlink() or (root.exists() and not root.is_dir()):
raise RuntimeError('Vendor destination must be a real directory, not a symlink or file.')
root.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix='vendor-tmp-', dir=str(root.parent)) as tmp:
files = {}
@@ -109,10 +124,42 @@ def vendor_from_archive(archive_bytes, destination):
'files': dict(sorted(files.items())),
}
Path(tmp, 'vendor-manifest.json').write_text(json.dumps(manifest, indent=2, sort_keys=True) + '\n', encoding='utf-8')
# Preserve a previous verified vendor folder when the download fails.
if root.exists():
shutil.rmtree(root)
shutil.move(tmp, root)
if root.is_dir():
try:
current = json.loads((root / 'vendor-manifest.json').read_text(encoding='utf-8'))
current_files = {path.relative_to(root).as_posix() for path in root.rglob('*') if path.is_file()}
intact = (current == manifest and
current_files == set(manifest['files']) | {'vendor-manifest.json'} and all(
(root.joinpath(*name.split('/')).is_file() and
sha256(root.joinpath(*name.split('/')).read_bytes()) == digest)
for name, digest in manifest['files'].items()))
if intact:
return manifest
except (OSError, ValueError):
pass
# Validate the complete candidate before replacing the current assets.
candidate = Path(tmp)
candidate_manifest = json.loads((candidate / 'vendor-manifest.json').read_text(encoding='utf-8'))
for name, expected in candidate_manifest['files'].items():
if sha256(candidate.joinpath(*name.split('/')).read_bytes()) != expected:
raise RuntimeError('Candidate vendor verification failed: %s' % name)
backup = root.with_name(root.name + '.previous-' + uuid.uuid4().hex)
moved_old = False
try:
if root.exists():
root.rename(backup)
moved_old = True
candidate.rename(root)
except Exception:
if moved_old and not root.exists() and backup.exists():
backup.rename(root)
raise
else:
if backup.exists():
try:
shutil.rmtree(backup)
except OSError as exc:
print('Previous vendor backup retained at %s: %s' % (backup, type(exc).__name__), file=sys.stderr)
return manifest
@@ -120,9 +167,11 @@ def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--dest', default='vendor', help='Directory to populate in the WAPT package sources')
parser.add_argument('--archive', help='Use a locally downloaded GitHub archive for offline preparation')
parser.add_argument('--archive-sha256', help='Optional expected hash of the local ZIP for offline verification')
parser.add_argument('--archive-sha256', help='Required independently obtained hash of the local ZIP')
args = parser.parse_args(argv)
if args.archive:
if not args.archive_sha256:
raise RuntimeError('--archive-sha256 is required for offline archive preparation.')
data = Path(args.archive).read_bytes()
if len(data) > MAX_ARCHIVE_BYTES:
raise RuntimeError('Offline archive exceeds size limit.')