146 lines
6.4 KiB
Python
146 lines
6.4 KiB
Python
#!/usr/bin/env python3
|
|
"""Vendor a pinned HardeningKitty GitHub snapshot on the PACKAGING workstation.
|
|
|
|
Endpoints never download executable code from GitHub. All selected upstream files
|
|
are subsequently included and signed with the WAPT package.
|
|
"""
|
|
import argparse
|
|
import hashlib
|
|
import io
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import sys
|
|
import tempfile
|
|
import urllib.request
|
|
import zipfile
|
|
|
|
UPSTREAM_REPO = '0x6d69636b/windows_hardening'
|
|
UPSTREAM_COMMIT = 'acf9ecee3d044a9337df7aad80e730cbd746e8de'
|
|
UPSTREAM_API = 'https://api.github.com/repos/%s/zipball/%s' % (UPSTREAM_REPO, UPSTREAM_COMMIT)
|
|
MAX_ARCHIVE_BYTES = 30 * 1024 * 1024
|
|
MAX_FILE_BYTES = 3 * 1024 * 1024
|
|
MAX_TOTAL_EXTRACTED_BYTES = 25 * 1024 * 1024
|
|
REQUIRED = {
|
|
'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_11_24h2_machine.csv',
|
|
'lists/finding_list_msft_security_baseline_windows_11_25h2_machine.csv',
|
|
'lists/finding_list_msft_security_baseline_windows_10_22h2_machine.csv',
|
|
}
|
|
|
|
|
|
def sha256(data):
|
|
return hashlib.sha256(data).hexdigest()
|
|
|
|
|
|
def _download_snapshot():
|
|
req = urllib.request.Request(UPSTREAM_API, headers={'User-Agent': 'comitari-wapt-hardeningkitty-vendor',
|
|
'Accept': 'application/vnd.github+json'})
|
|
with urllib.request.urlopen(req, timeout=90) as response:
|
|
if response.url.split('/')[0] != 'https:':
|
|
raise RuntimeError('Refused non-HTTPS archive download.')
|
|
data = response.read(MAX_ARCHIVE_BYTES + 1)
|
|
if len(data) > MAX_ARCHIVE_BYTES:
|
|
raise RuntimeError('GitHub archive exceeds size limit.')
|
|
return data
|
|
|
|
|
|
def _wanted(relative):
|
|
if relative in ('HardeningKitty.psm1', 'HardeningKitty.psd1', 'LICENSE'):
|
|
return True
|
|
if relative.startswith('lists/') and relative.count('/') == 1:
|
|
return (relative.endswith('.csv') or relative in
|
|
('lists/hardeningkitty_lists_manifest.psd1',
|
|
'lists/hardeningkitty_lists_manifest.psd1.p7s'))
|
|
return False
|
|
|
|
|
|
def vendor_from_archive(archive_bytes, destination):
|
|
root = Path(destination)
|
|
root.parent.mkdir(parents=True, exist_ok=True)
|
|
with tempfile.TemporaryDirectory(prefix='vendor-tmp-', dir=str(root.parent)) as tmp:
|
|
files = {}
|
|
uncompressed = 0
|
|
with zipfile.ZipFile(io.BytesIO(archive_bytes)) as archive:
|
|
for entry in archive.infolist():
|
|
if entry.is_dir():
|
|
continue
|
|
segments = entry.filename.split('/')
|
|
if len(segments) < 2:
|
|
continue
|
|
if UPSTREAM_COMMIT[:7].lower() not in segments[0].lower():
|
|
raise RuntimeError('Unexpected GitHub archive revision: %s' % segments[0])
|
|
relative = '/'.join(segments[1:])
|
|
if not _wanted(relative):
|
|
continue
|
|
if (relative.startswith('/') or '..' in segments or '\\' in relative or
|
|
not all(p and p not in ('.', '..') for p in segments[1:])):
|
|
raise RuntimeError('Unsafe archive path: %s' % entry.filename)
|
|
if entry.file_size > MAX_FILE_BYTES or uncompressed + entry.file_size > MAX_TOTAL_EXTRACTED_BYTES:
|
|
raise RuntimeError('Unreasonable uncompressed payload size.')
|
|
if relative in files:
|
|
raise RuntimeError('Duplicate upstream path: %s' % relative)
|
|
with archive.open(entry) as src:
|
|
data = src.read(MAX_FILE_BYTES + 1)
|
|
if len(data) != entry.file_size:
|
|
raise RuntimeError('Corrupted upstream file: %s' % relative)
|
|
uncompressed += len(data)
|
|
target = Path(tmp, *relative.split('/'))
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
target.write_bytes(data)
|
|
files[relative] = sha256(data)
|
|
missing = REQUIRED.difference(files)
|
|
if missing:
|
|
raise RuntimeError('Incomplete upstream archive; missing: %s' % ', '.join(sorted(missing)))
|
|
list_count = sum(name.startswith('lists/') and name.endswith('.csv') for name in files)
|
|
if list_count < 50:
|
|
raise RuntimeError('Insufficient reference lists: %d' % list_count)
|
|
manifest = {
|
|
'upstream_repository': UPSTREAM_REPO,
|
|
'upstream_commit': UPSTREAM_COMMIT,
|
|
'source_archive_sha256': sha256(archive_bytes),
|
|
'source': 'pinned GitHub archive (not a floating branch)',
|
|
'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)
|
|
return manifest
|
|
|
|
|
|
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')
|
|
args = parser.parse_args(argv)
|
|
if args.archive:
|
|
data = Path(args.archive).read_bytes()
|
|
if len(data) > MAX_ARCHIVE_BYTES:
|
|
raise RuntimeError('Offline archive exceeds size limit.')
|
|
if args.archive_sha256 and sha256(data).lower() != args.archive_sha256.lower():
|
|
raise RuntimeError('Offline archive checksum mismatch.')
|
|
else:
|
|
print('Downloading pinned HardeningKitty GitHub archive on packaging workstation...')
|
|
data = _download_snapshot()
|
|
result = vendor_from_archive(data, args.dest)
|
|
print('Vendored %d files, commit %s, archive SHA256 %s' %
|
|
(len(result['files']), result['upstream_commit'], result['source_archive_sha256']))
|
|
print('Now review, sign and build the WAPT package; endpoints need no Internet.')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
try:
|
|
main()
|
|
except Exception as exc:
|
|
print('ERROR: %s' % exc, file=sys.stderr)
|
|
sys.exit(1)
|