#!/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)