Files
comi-audit-secu/tools/package_verify.py
T

177 lines
8.5 KiB
Python

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