# -*- coding: utf-8 -*- """WAPT package: offline, read-only HardeningKitty machine baseline audit. This code runs under WAPT Agent's Python runtime on Windows. Vendor assets are fetched on the packaging workstation, not on endpoints. """ from setuphelpers import * import csv import collections import datetime import hashlib import json import os import shutil import subprocess import sys import uuid uninstallkey = [] PACKAGE_NAME = 'comi-audit-hardeningkitty' AUDIT_SECTION = 'HardeningKitty' PROGRAM_DATA = os.environ.get('ProgramData', r'C:\ProgramData') INSTALL_ROOT = os.path.join(PROGRAM_DATA, 'Comitari', 'Audit-HardeningKitty') REPORT_ROOT = os.path.join(PROGRAM_DATA, 'Comitari', 'Audit-HardeningKitty-Reports') def _source_directory(): # WAPT IDE can execute setup.py with a wrapper: verify __file__ before use. if '__file__' in globals(): candidate = os.path.dirname(os.path.abspath(__file__)) if os.path.isfile(os.path.join(candidate, 'WAPT', 'control')): return candidate return os.getcwd() def _read_json(path): with open(path, 'r', encoding='utf-8-sig') as fh: return json.load(fh) def _write_json(path, data): with open(path, 'w', encoding='utf-8') as fh: json.dump(data, fh, ensure_ascii=False, indent=2, sort_keys=True) def _checksum(path): sha = hashlib.sha256() with open(path, 'rb') as fh: for block in iter(lambda: fh.read(1024 * 1024), b''): sha.update(block) return sha.hexdigest() def _verify_vendor(root): manifest_path = os.path.join(root, 'vendor-manifest.json') if not os.path.isfile(manifest_path): raise RuntimeError('Vendor assets missing. Run python tools/vendor_assets.py before building the WAPT package.') manifest = _read_json(manifest_path) files = manifest.get('files', {}) if not isinstance(files, dict) or len(files) < 5: raise RuntimeError('Invalid vendor manifest; refuse incomplete audit assets.') required = ('HardeningKitty.psm1', 'HardeningKitty.psd1', 'LICENSE', 'lists/hardeningkitty_lists_manifest.psd1', 'lists/hardeningkitty_lists_manifest.psd1.p7s', 'lists/finding_list_0x6d69636b_machine.csv') for item in required: if item not in files: raise RuntimeError('Missing required vendor asset: %s' % item) root_real = os.path.realpath(root) for name, digest in files.items(): if not isinstance(name, str) or '\\' in name or name.startswith('/') or '..' in name.split('/'): raise RuntimeError('Unsafe vendor path: %r' % name) dest = os.path.realpath(os.path.join(root, *name.split('/'))) if not dest.startswith(root_real + os.sep): raise RuntimeError('Vendor asset escapes install directory: %r' % name) if not os.path.isfile(dest) or _checksum(dest).lower() != str(digest).lower(): raise RuntimeError('Missing or altered vendor asset: %s' % name) return manifest def _secure_directory(path): """Only LocalSystem and local Administrators can access audit assets.""" if os.name != 'nt': return # Language-independent SIDs; do not rely on localized group names. subprocess.check_call(['icacls.exe', path, '/inheritance:r', '/grant:r', '*S-1-5-18:(OI)(CI)F', '*S-1-5-32-544:(OI)(CI)F', '/T', '/Q']) def install(): source = _source_directory() vendor = os.path.join(source, 'vendor') manifest = _verify_vendor(vendor) parent = os.path.dirname(INSTALL_ROOT) os.makedirs(parent, exist_ok=True) staging = INSTALL_ROOT + '.staging' previous = INSTALL_ROOT + '.previous' if os.path.isdir(staging): shutil.rmtree(staging) if os.path.isdir(previous): shutil.rmtree(previous) shutil.copytree(vendor, staging) shutil.copy2(os.path.join(source, 'runner.ps1'), os.path.join(staging, 'runner.ps1')) shutil.copy2(os.path.join(source, 'audit_config.json'), os.path.join(staging, 'audit_config.json')) _verify_vendor(staging) _secure_directory(staging) renamed_old = False try: if os.path.isdir(INSTALL_ROOT): os.rename(INSTALL_ROOT, previous) renamed_old = True os.rename(staging, INSTALL_ROOT) except Exception: if renamed_old and not os.path.exists(INSTALL_ROOT): os.rename(previous, INSTALL_ROOT) raise finally: if os.path.isdir(staging): shutil.rmtree(staging) if os.path.isdir(previous): shutil.rmtree(previous) os.makedirs(REPORT_ROOT, exist_ok=True) _secure_directory(REPORT_ROOT) print('Installed offline HardeningKitty audit assets, commit %s (%s files).' % (manifest.get('upstream_commit', 'unknown'), len(manifest['files']))) print('No Windows security setting has been modified.') def uninstall(): if os.path.isdir(INSTALL_ROOT): shutil.rmtree(INSTALL_ROOT) print('Removed HardeningKitty audit engine. Historical reports kept in %s' % REPORT_ROOT) def _powershell_executable(): windir = os.environ.get('SystemRoot', r'C:\Windows') for relative in ((r'Sysnative', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), (r'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')): candidate = os.path.join(windir, *relative) if os.path.isfile(candidate): return candidate raise RuntimeError('Windows PowerShell 5.1 64-bit not found.') def _read_report(path, publish_current_values=False): with open(path, 'r', encoding='utf-8-sig', newline='') as fh: reader = csv.DictReader(fh) required = {'ID', 'Category', 'Name', 'Severity', 'Result', 'Recommended', 'TestResult'} if not reader.fieldnames or not required.issubset(reader.fieldnames): raise RuntimeError('Unexpected HardeningKitty CSV headers: %r' % reader.fieldnames) rows = list(reader) if not rows: raise RuntimeError('HardeningKitty CSV is empty; refuse a false OK.') counts = {'passed': 0, 'high': 0, 'medium': 0, 'low': 0, 'unknown': 0, 'total': len(rows)} findings = [] for item in rows: level = (item.get('Severity') or '').strip().lower() outcome = (item.get('TestResult') or '').strip().lower() if outcome == 'passed' and level == 'passed': counts['passed'] += 1 continue if outcome != 'passed' and level in ('high', 'medium', 'low'): counts[level] += 1 else: counts['unknown'] += 1 level = 'unknown' finding = { 'id': str(item.get('ID') or ''), 'category': str(item.get('Category') or ''), 'name': str(item.get('Name') or ''), 'severity': level, 'result': str(item.get('TestResult') or ''), } # Avoid uploading potentially sensitive local configuration by default. if publish_current_values: finding['current'] = str(item.get('Result') or '') finding['recommended'] = str(item.get('Recommended') or '') findings.append(finding) counts['compliant_percent'] = round(100.0 * counts['passed'] / counts['total'], 2) return counts, findings def _check_coverage(baseline_path, report_path, counts, findings): """Mark tests skipped by HardeningKitty as unknown, never as Passed. HardeningKitty can skip a check (e.g. a tool is missing). Such an item may be absent from the output CSV. Counting only present rows could give a misleading green result, so compare to the original finding list. """ with open(baseline_path, 'r', encoding='utf-8-sig', newline='') as fh: expected_rows = list(csv.DictReader(fh)) with open(report_path, 'r', encoding='utf-8-sig', newline='') as fh: actual_rows = list(csv.DictReader(fh)) if not expected_rows: raise RuntimeError('Finding list is empty.') def row_key(row): return (str(row.get('ID') or '').strip(), str(row.get('Name') or '').strip(), str(row.get('Category') or '').strip()) actual = collections.Counter(row_key(row) for row in actual_rows) missing = [] for row in expected_rows: key = row_key(row) if actual[key]: actual[key] -= 1 else: missing.append({ 'id': key[0], 'name': key[1], 'category': key[2], 'severity': 'unknown', 'result': 'Skipped / no CSV result', }) if missing: findings.extend(missing) counts['unknown'] += len(missing) counts['total'] += len(missing) counts['executed'] = len(actual_rows) counts['skipped'] = len(missing) counts['expected'] = len(expected_rows) counts['compliant_percent'] = round(100.0 * counts['passed'] / counts['total'], 2) return counts, findings def _status_from_counts(counts, config): if counts['total'] == 0 or counts['unknown']: return 'ERROR' if counts['high']: return 'ERROR' if config['error_on_high'] else 'WARNING' if counts['medium'] or (counts['low'] and config['warn_on_low']): return 'WARNING' return 'OK' def _shard_findings(findings, bucket_count=16): buckets = [[] for i in range(bucket_count)] for item in findings: # Stable shards, including empty ones: no obsolete audit keys on a later run. key = ('%s:%s' % (item['id'], item['name'])).encode('utf-8') bucket = int(hashlib.sha256(key).hexdigest()[:8], 16) % bucket_count buckets[bucket].append(item) return buckets def _publish_audit(summary, findings, config): if not config.get('publish_to_wapt', True): return wapt = globals().get('WAPT') write = getattr(wapt, 'write_audit_data_if_changed', None) if not callable(write): print('WAPT audit_data unavailable; only audit status and local CSV are published.') return # Per-metric keys are convenient for SQL reporting on HostAuditData. for key in ('status', 'benchmark', 'os_build', 'source', 'upstream_commit', 'last_error', 'passed', 'high', 'medium', 'low', 'unknown', 'total', 'expected', 'executed', 'skipped', 'compliant_percent'): try: write(AUDIT_SECTION, key, str(summary[key]), max_count=5, keep_days=120) except Exception as exc: print('Could not publish WAPT audit metric %s: %s' % (key, exc)) for index, group in enumerate(_shard_findings(findings)): try: payload = json.dumps(group, ensure_ascii=False, separators=(',', ':')) write(AUDIT_SECTION, 'findings_%02d' % index, payload, max_count=3, keep_days=120) except Exception as exc: print('Could not publish WAPT audit shard %02d: %s' % (index, exc)) # Unlike the stable metrics above, store one timestamped observation each run. write_current = getattr(wapt, 'write_audit_data', None) if callable(write_current): try: write_current(AUDIT_SECTION, 'last_run_utc', summary['last_run_utc'], max_count=5, keep_days=120) except Exception as exc: print('Could not publish WAPT audit timestamp: %s' % exc) def _trim_reports(keep): paths = [os.path.join(REPORT_ROOT, name) for name in os.listdir(REPORT_ROOT) if name.startswith('run-') and os.path.isdir(os.path.join(REPORT_ROOT, name))] paths.sort(key=lambda p: os.path.getmtime(p), reverse=True) for path in paths[max(1, int(keep)):]: shutil.rmtree(path) def _audit_impl(): manifest = _verify_vendor(INSTALL_ROOT) config = _read_json(os.path.join(INSTALL_ROOT, 'audit_config.json')) if config.get('source') not in ('GPO', 'Intune'): raise RuntimeError('audit_config.json source must be GPO or Intune') profile = str(config.get('profile', 'auto')) # Runner validates the override independently to prevent command injection/traversal. if not isinstance(config.get('error_on_high'), bool) or not isinstance(config.get('warn_on_low'), bool): raise RuntimeError('Invalid compliance threshold settings.') timeout = int(config.get('timeout_seconds', 900)) if timeout < 30 or timeout > 7200: raise RuntimeError('timeout_seconds must be between 30 and 7200.') os.makedirs(REPORT_ROOT, exist_ok=True) _secure_directory(REPORT_ROOT) run_dir = os.path.join(REPORT_ROOT, 'run-' + datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%d-%H%M%S') + '-' + uuid.uuid4().hex[:8]) os.makedirs(run_dir, exist_ok=False) _secure_directory(run_dir) csv_file = os.path.join(run_dir, 'hardeningkitty.csv') log_file = os.path.join(run_dir, 'powershell.log') metadata_file = os.path.join(run_dir, 'host.json') cmd = [_powershell_executable(), '-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', os.path.join(INSTALL_ROOT, 'runner.ps1'), '-InstallRoot', INSTALL_ROOT, '-CsvFile', csv_file, '-MetadataFile', metadata_file, '-Source', config['source'], '-Profile', profile] with open(log_file, 'wb') as log: completed = subprocess.run(cmd, stdout=log, stderr=subprocess.STDOUT, timeout=timeout, check=False) if completed.returncode != 0: raise RuntimeError('HardeningKitty PowerShell runner exited %s; see %s' % (completed.returncode, log_file)) if not os.path.isfile(csv_file) or not os.path.isfile(metadata_file): raise RuntimeError('Audit runner did not create all required reports.') metadata = _read_json(metadata_file) counts, findings = _read_report(csv_file, bool(config.get('publish_current_values', False))) baseline_file = os.path.join(INSTALL_ROOT, 'lists', metadata['benchmark']) counts, findings = _check_coverage(baseline_file, csv_file, counts, findings) status = _status_from_counts(counts, config) summary = dict(counts) summary.update({ 'status': status, 'benchmark': metadata['benchmark'], 'os_build': str(metadata['build']), 'source': metadata['source'], 'upstream_commit': manifest['upstream_commit'], 'last_error': '', 'last_run_utc': datetime.datetime.now(datetime.timezone.utc).isoformat(), 'report_csv': csv_file, 'report_log': log_file, }) _write_json(os.path.join(run_dir, 'summary.json'), summary) _publish_audit(summary, findings, config) print('HardeningKitty: %s | %s | total=%s passed=%s high=%s medium=%s low=%s unknown=%s skipped=%s | compliant=%s%%' % (status, summary['benchmark'], counts['total'], counts['passed'], counts['high'], counts['medium'], counts['low'], counts['unknown'], counts['skipped'], counts['compliant_percent'])) sort_priority = {'high': 0, 'medium': 1, 'low': 2, 'unknown': 3} for entry in sorted(findings, key=lambda f: (sort_priority.get(f['severity'], 9), f['id']))[:int(config.get('max_findings_in_log', 15))]: print('[%s] %s | %s | %s' % (entry['severity'].upper(), entry['id'], entry['category'], entry['name'])) print('Local full report: %s' % csv_file) _trim_reports(config.get('retain_audit_runs', 8)) return status def audit(): try: return _audit_impl() except Exception as exc: message = str(exc)[:1000] print('HardeningKitty audit ERROR (execution/integrity failure): %s' % message) wapt = globals().get('WAPT') write = getattr(wapt, 'write_audit_data_if_changed', None) if callable(write): for key, value in [('status', 'ERROR'), ('last_error', message)]: try: write(AUDIT_SECTION, key, value, max_count=5, keep_days=120) except Exception: pass write_run = getattr(wapt, 'write_audit_data', None) if callable(write_run): try: write_run(AUDIT_SECTION, 'last_run_utc', datetime.datetime.now(datetime.timezone.utc).isoformat(), max_count=5, keep_days=120) except Exception: pass return 'ERROR' def update_package(): """Populate local offline vendor assets before building/signing WAPT.""" source = _source_directory() script = os.path.join(source, 'tools', 'vendor_assets.py') subprocess.check_call([sys.executable, script, '--dest', os.path.join(source, 'vendor')]) print('Vendor ready. Review the diff and build/sign/upload via WAPT Console.')