fix(audit): harden HardeningKitty packaging and reporting
This commit is contained in:
1 parent
f4a67f3e1b
commit
91eb704215
10 files changed
+1171
-236
No files matched your search
@@ -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
@@ -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.')
|
||||
|
||||
Reference in new issue
Block a user