# -*- coding: utf-8 -*- """Comitari - Offline, read-only HardeningKitty audit package for WAPT. All endpoint operations use WAPT setuphelpers where an appropriate helper exists. WAPT verifies the package signature; vendor-manifest.json additionally checks the pinned HardeningKitty payload, including after it is installed. Build requires update_package.py to prepare vendor/ BEFORE WAPT build/sign/upload. """ from setuphelpers import * import collections import csv import datetime import io import json import locale import os import uuid uninstallkey = [] PACKAGE_NAME = 'comi-audit-hardeningkitty' AUDIT_SECTION = 'hardeningkitty' AUDIT_KEY = 'hardeningkitty' MAX_AUDIT_JSON_BYTES = 2 * 1024 * 1024 UPSTREAM_COMMIT = 'acf9ecee3d044a9337df7aad80e730cbd746e8de' PROGRAM_DATA = os.environ.get('ProgramData', r'C:\ProgramData') INSTALL_ROOT = makepath(PROGRAM_DATA, 'Comitari', 'Audit-HardeningKitty') REPORT_ROOT = makepath(PROGRAM_DATA, 'Comitari', 'Audit-HardeningKitty-Reports') OWNER_MARKER = '.comi-audit-hardeningkitty-owner' # Keep aligned with runner.ps1 supported auto-selected baselines. REQUIRED_VENDOR_FILES = ( '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_10_20h2_21h1_machine.csv', 'lists/finding_list_msft_security_baseline_windows_10_21h2_machine.csv', 'lists/finding_list_msft_security_baseline_windows_10_22h2_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_11_24h2_machine.csv', 'lists/finding_list_msft_security_baseline_windows_11_25h2_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', ) # A staged/installed directory stores the vendor files at its root (NOT /vendor). RUNTIME_FILES = ('runner.ps1', 'audit_config.json') # Old package versions included this file. It is no longer required or trusted. LEGACY_FILES = ('package-manifest.json',) def _package_source(): """Use WAPT's extraction directory, not cwd or the launcher's path.""" root = globals().get('basedir') if not root: root = os.path.dirname(os.path.abspath(__file__)) if globals().get('__file__') else None if not root: raise RuntimeError('WAPT basedir is not available.') root = os.path.abspath(root) if not isfile(makepath(root, 'runner.ps1')) or not isfile(makepath(root, 'audit_config.json')): raise RuntimeError('WAPT package source is missing runner.ps1 or audit_config.json: %s' % root) return root def _load_json(path): return json_load_file(path) def _save_json(path, payload): json_write_file(path, payload, indent=2, sort_keys=True) def _safe_vendor_name(name): return (isinstance(name, str) and bool(name) and '\\' not in name and ':' not in name and not name.startswith('/') and all(part not in ('', '.', '..') for part in name.split('/'))) def _verify_vendor(root, runtime=False): """Verify all publisher payload hashes and reject unexpected files/symlinks.""" manifest_file = makepath(root, 'vendor-manifest.json') if not isfile(manifest_file): raise RuntimeError('HardeningKitty vendor-manifest.json is absent from the signed WAPT package. Run Update Package, then BUILD/SIGN/UPLOAD the new .wapt.') manifest = _load_json(manifest_file) if not isinstance(manifest, dict) or manifest.get('upstream_commit') != UPSTREAM_COMMIT: raise RuntimeError('Unexpected HardeningKitty vendor revision.') files = manifest.get('files') if not isinstance(files, dict) or len(files) < 50: raise RuntimeError('HardeningKitty vendor file inventory is invalid/incomplete.') missing = set(REQUIRED_VENDOR_FILES).difference(files) if missing: raise RuntimeError('HardeningKitty vendor manifest missing: %s' % ', '.join(sorted(missing))) root_real = os.path.realpath(root) expected = {'vendor-manifest.json'} for relative, checksum in files.items(): if not _safe_vendor_name(relative): raise RuntimeError('Unsafe vendor filename: %r' % relative) if not isinstance(checksum, str) or len(checksum) != 64 or any(c not in '0123456789abcdefABCDEF' for c in checksum): raise RuntimeError('Invalid vendor SHA-256 entry: %s' % relative) filename = os.path.realpath(makepath(root, *relative.split('/'))) if not filename.startswith(root_real + os.sep): raise RuntimeError('Vendor filename escaped installation directory: %s' % relative) if not isfile(filename) or get_sha256(filename).lower() != checksum.lower(): raise RuntimeError('Missing/altered vendor asset: %s' % relative) expected.add(relative) if runtime: for filename in RUNTIME_FILES: if not isfile(makepath(root, filename)): raise RuntimeError('Installed WAPT runtime file missing: %s' % filename) expected.update(RUNTIME_FILES) # Older installed versions may contain this obsolete manifest. expected.update(name for name in LEGACY_FILES if isfile(makepath(root, name))) if isfile(makepath(root, OWNER_MARKER)): expected.add(OWNER_MARKER) actual = set() for directory, folders, names in os.walk(root, followlinks=False): for name in folders + names: if os.path.islink(makepath(directory, name)): raise RuntimeError('HardeningKitty directory contains a symlink; refusing it.') for name in names: actual.add(os.path.relpath(makepath(directory, name), root).replace(os.sep, '/')) if actual != expected: raise RuntimeError('Unexpected/missing files in HardeningKitty directory: %s' % ', '.join(sorted(actual.symmetric_difference(expected))[:10])) return manifest def _owned_install(root): """Never replace/delete a directory belonging to someone else.""" if os.path.islink(root) or not isdir(root): return False try: marker = makepath(root, OWNER_MARKER) if isfile(marker): with open(marker, encoding='ascii') as stream: if stream.read().strip() != PACKAGE_NAME: return False # Legacy upgrade without owner marker: require intact original payload. _verify_vendor(root, runtime=True) return True except Exception: return False def _secure_directory(path): """Protect this directory; let its children inherit SYSTEM/Admin rights. Do not use /inheritance:r /T on every file. That removes inherited permissions from each individual file and is unnecessary for a freshly staged directory whose children inherit from its protected parent. """ if os.name != 'nt': return # Grant first: avoid losing the installer's access before the new ACEs exist. # OI/CI make newly created files and subdirectories inherit the grants. run(['icacls.exe', path, '/grant:r', '*S-1-5-18:(OI)(CI)F', '*S-1-5-32-544:(OI)(CI)F'], shell=False, timeout=120, accept_returncodes=[0]) # Only protect the directory itself. Existing descendants keep inheriting # from it; unlike /T, this does not strip every file's inherited ACEs. run(['icacls.exe', path, '/inheritance:r'], shell=False, timeout=120, accept_returncodes=[0]) def install(): """Install files from WAPT basedir, offline; stage then swap atomically.""" source = _package_source() vendor = makepath(source, 'vendor') manifest = _verify_vendor(vendor) parent = os.path.dirname(INSTALL_ROOT) mkdirs(parent) staging = INSTALL_ROOT + '.staging-' + uuid.uuid4().hex previous = INSTALL_ROOT + '.previous-' + uuid.uuid4().hex old_moved = False try: if os.path.lexists(INSTALL_ROOT) and not _owned_install(INSTALL_ROOT): raise RuntimeError('Install directory already exists but is not owned by this WAPT package.') mkdirs(staging) _secure_directory(staging) copytree2(vendor, staging, follow_symlinks=False) for filename in RUNTIME_FILES: filecopyto(makepath(source, filename), makepath(staging, filename)) _verify_vendor(staging, runtime=True) with open(makepath(staging, OWNER_MARKER), 'w', encoding='ascii') as stream: stream.write(PACKAGE_NAME + '\n') _secure_directory(staging) # A security operation can change ACLs. Verify readability and all # checksums AFTER the final ACL change, before replacing a working install. _verify_vendor(staging, runtime=True) mkdirs(REPORT_ROOT) _secure_directory(REPORT_ROOT) if isdir(INSTALL_ROOT): os.rename(INSTALL_ROOT, previous) old_moved = True try: os.rename(staging, INSTALL_ROOT) except Exception: if old_moved and isdir(previous) and not os.path.exists(INSTALL_ROOT): os.rename(previous, INSTALL_ROOT) old_moved = False raise if old_moved and isdir(previous): try: remove_tree(previous) except Exception as exc: print('Previous owned installation preserved for manual cleanup: %s' % type(exc).__name__) finally: if isdir(staging): remove_tree(staging, ignore_errors=True) print('HardeningKitty installed offline: %s (%s verified files)' % (manifest['upstream_commit'], len(manifest['files']))) def uninstall(): if os.path.lexists(INSTALL_ROOT): if not _owned_install(INSTALL_ROOT): raise RuntimeError('Uninstall refused: directory is unverified or not owned by this package.') remove_tree(INSTALL_ROOT) print('HardeningKitty removed. Historical reports kept: %s' % REPORT_ROOT) def _powershell_executable(): """Use native 64-bit PowerShell when WAPT is a 32-bit process.""" system_root = os.environ.get('SystemRoot', r'C:\Windows') sysnative = makepath(system_root, 'Sysnative', 'WindowsPowerShell', 'v1.0', 'powershell.exe') if isfile(sysnative): return sysnative native = makepath(system32(), 'WindowsPowerShell', 'v1.0', 'powershell.exe') if isfile(native): return native raise RuntimeError('Windows PowerShell 5.1 is missing.') def _read_report(path, publish_current_values=False): fieldnames, rows = _read_csv_rows(path) required = {'ID', 'Category', 'Name', 'Severity', 'Result', 'Recommended', 'TestResult', 'SeverityFinding'} if not fieldnames or not required.issubset(fieldnames): raise RuntimeError('Unexpected HardeningKitty CSV headers: %r' % (fieldnames,)) if not rows: raise RuntimeError('HardeningKitty CSV is empty; refuse a false OK.') counts = {'passed': 0, 'high': 0, 'medium': 0, 'low': 0, 'unknown': 0, 'ignored': 0, 'not_applicable': 0, 'excluded': 0, 'total': 0, 'executed': 0} findings = [] seen = set() for item in rows: if None in item: raise RuntimeError('HardeningKitty CSV row has an unexpected number of columns.') control_id = str(item.get('ID') or '').strip() if not control_id or control_id in seen: raise RuntimeError('HardeningKitty CSV contains a blank or duplicate control ID: %r' % (control_id,)) seen.add(control_id) level = (item.get('Severity') or '').strip().lower() outcome = (item.get('TestResult') or '').strip().lower() if outcome == 'passed' and level == 'passed': counts['passed'] += 1 counts['total'] += 1 counts['executed'] += 1 continue if outcome in ('not applicable', 'not_applicable', 'n/a'): counts['not_applicable'] += 1 continue if outcome in ('ignored', 'skipped', 'not evaluated', 'not_evaluated'): counts['ignored'] += 1 counts['total'] += 1 level = 'ignored' elif (outcome in ('failed', 'fail') and level in ('high', 'medium', 'low') and (item.get('SeverityFinding') or '').strip().lower() == level): counts[level] += 1 counts['total'] += 1 counts['executed'] += 1 else: counts['unknown'] += 1 counts['total'] += 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) if counts['total'] else 0.0 return counts, findings def _read_csv_rows(path): """Read PowerShell 5.1 output strictly across UTF encodings and local code pages.""" with open(path, 'rb') as fh: data = fh.read() if data.startswith((b'\xff\xfe', b'\xfe\xff')): text = data.decode('utf-16') elif data.startswith(b'\xef\xbb\xbf'): text = data.decode('utf-8-sig') else: try: text = data.decode('utf-8') except UnicodeDecodeError: encoding = locale.getpreferredencoding(False) try: text = data.decode(encoding) except (UnicodeDecodeError, LookupError) as exc: raise RuntimeError('HardeningKitty CSV encoding is not supported.') from exc reader = csv.DictReader(io.StringIO(text, newline='')) return reader.fieldnames, list(reader) def _check_coverage(baseline_path, report_path, counts, findings, exclusions=None, benchmark=None): """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. """ fieldnames, expected_rows = _read_csv_rows(baseline_path) required = {'ID', 'Category', 'Name'} if not fieldnames or not required.issubset(fieldnames): raise RuntimeError('Selected finding list has unexpected headers: %r' % (fieldnames,)) _, actual_rows = _read_csv_rows(report_path) if not expected_rows: raise RuntimeError('Finding list is empty.') def control_id(row): return str(row.get('ID') or '').strip() # IDs are stable across localizations; labels/categories may be translated by # the installed HardeningKitty language data and are not join keys. actual = collections.Counter(control_id(row) for row in actual_rows) if any(value > 1 for value in actual.values()): raise RuntimeError('HardeningKitty report contains duplicate control IDs.') expected = collections.Counter(control_id(row) for row in expected_rows) if any(not key for key in expected) or any(value > 1 for value in expected.values()): raise RuntimeError('Selected finding list contains duplicate control IDs.') if actual - expected: raise RuntimeError('HardeningKitty report contains controls outside the selected benchmark.') allowed = (exclusions or {}).get(benchmark or '', {}) if (not isinstance(allowed, dict) or any(not isinstance(excluded_id, str) or excluded_id not in set(expected) for excluded_id in allowed) or any(not isinstance(reason, str) or not reason.strip() for reason in allowed.values())): raise RuntimeError('intentional_exclusions must map benchmark IDs to non-empty review reasons.') missing = [] for row in expected_rows: key = control_id(row) if actual[key]: actual[key] -= 1 else: if key in allowed: counts['excluded'] += 1 findings.append({'id': key, 'name': str(row.get('Name') or ''), 'category': str(row.get('Category') or ''), 'severity': 'not_applicable', 'result': str(allowed[key])}) else: missing.append({'id': key, 'name': str(row.get('Name') or ''), 'category': str(row.get('Category') or ''), 'severity': 'unknown', 'result': 'Skipped / no CSV result'}) if missing: findings.extend(missing) counts['unknown'] += len(missing) counts['total'] += len(missing) counts['skipped'] = len(missing) counts['expected'] = len(expected_rows) counts['compliant_percent'] = round(100.0 * counts['passed'] / counts['total'], 2) if counts['total'] else 0.0 return counts, findings def _status_from_counts(counts, config): if counts['total'] == 0 or counts['unknown'] or counts.get('ignored') or counts.get('skipped'): 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 _build_audit_document(summary, findings, report_file, baseline_file, config): """One native JSON object, with all benchmark controls addressable by ID. The detailed CSV stays local. Observed values are excluded by default because some settings can reveal accounts or environment-specific details. """ _, baseline_rows = _read_csv_rows(baseline_file) _, report_rows = _read_csv_rows(report_file) reported = {str(row.get('ID') or '').strip(): row for row in report_rows} problems = {str(row['id']): row for row in findings} category_stats = {} controls = {} source = summary['source'] status_labels = { 'passed': 'Conforme', 'failed': 'Non conforme', 'skipped': 'Non exécuté', 'ignored': 'Ignoré', 'excluded': 'Exclu (justifié)', 'not_applicable': 'Non applicable', 'unknown': 'Inconnu', } severity_labels = { 'high': 'Élevée', 'medium': 'Moyenne', 'low': 'Faible', 'unknown': 'Indéterminée', } explanations = { 'passed': 'Le réglage satisfait au benchmark sélectionné.', 'failed': 'Le réglage ne satisfait pas au benchmark sélectionné. Vérifier la valeur cible et la politique appliquée avant toute correction.', 'skipped': 'HardeningKitty n\'a pas retourné de résultat pour ce contrôle. Il ne peut pas etre considéré conforme.', 'ignored': 'Le contrôle n\'a pas été évalué lors de cet audit.', 'excluded': 'Contrôle exclu intentionnellement. La justification doit être examinée dans la politique d\'audit.', 'not_applicable': 'Contrôle marqué comme non applicable.', 'unknown': 'Résultat insuffisant pour déterminer la conformité.', } for baseline in baseline_rows: control_id = str(baseline.get('ID') or '').strip() recorded = reported.get(control_id) issue = problems.get(control_id) category = str(baseline.get('Category') or '') risk = str(baseline.get('Severity') or '').strip().lower() if risk not in ('high', 'medium', 'low'): risk = 'unknown' if recorded is None: outcome = 'excluded' if issue and issue.get('severity') == 'not_applicable' else 'skipped' elif str(recorded.get('TestResult') or '').strip().lower() in ('not applicable', 'not_applicable', 'n/a'): outcome = 'not_applicable' elif issue is None: outcome = 'passed' elif issue['severity'] in ('high', 'medium', 'low'): outcome = 'failed' elif issue['severity'] in ('ignored', 'not_applicable', 'unknown'): outcome = issue['severity'] else: outcome = 'unknown' control = { 'id': control_id, 'name': str(baseline.get('Name') or ''), 'category': category, 'status': outcome, 'status_label': status_labels.get(outcome, outcome), 'severity': risk, 'severity_label': severity_labels.get(risk, risk), 'explanation': explanations.get(outcome, explanations['unknown']), } # Only copy a description when the selected benchmark actually carries it. description = str(baseline.get('Description') or (recorded.get('Description') if recorded else '') or '').strip() if description: control['description'] = description method = str(baseline.get('Method') or '').strip() if method: control['method'] = method argument = str(baseline.get('MethodArgument') or '').strip() if argument: control['method_argument'] = argument operator_field = ('OperatorIntune' if source == 'Intune' and method.lower() in ('registry', 'registrylist') else 'Operator') operator = str(baseline.get(operator_field) or '').strip() if operator: control['operator'] = operator path_field = ('RegistryPathIntune' if source == 'Intune' and method.lower() in ('registry', 'registrylist') else 'RegistryPath') registry_path = str(baseline.get(path_field) or '').strip() if registry_path: control['registry_path'] = registry_path registry_item = str(baseline.get('RegistryItem') or '').strip() if registry_item: control['registry_item'] = registry_item recommended = '' if recorded is not None: recommended = str(recorded.get('Recommended') or '') if not recommended: # The recommendation is policy data, not an observed host value. field = ('RecommendedValueIntune' if source == 'Intune' and method.lower() in ('registry', 'registrylist') else 'RecommendedValue') recommended = str(baseline.get(field) or '') if recommended: control['recommended'] = recommended if recorded is not None and config.get('publish_current_values', False): control['current'] = str(recorded.get('Result') or '') if outcome in ('excluded', 'skipped', 'ignored', 'unknown'): control['reason'] = (str(issue.get('result') or '') if issue else 'Not evaluated') controls[control_id] = control cat = category_stats.setdefault(category, { 'expected': 0, 'passed': 0, 'high': 0, 'medium': 0, 'low': 0, 'unknown': 0, 'skipped': 0, 'ignored': 0, 'excluded': 0, 'not_applicable': 0, }) cat['expected'] += 1 if outcome == 'failed': actual_risk = str(issue.get('severity') or risk).lower() if actual_risk not in ('high', 'medium', 'low'): actual_risk = 'unknown' cat[actual_risk] += 1 elif outcome == 'passed': cat['passed'] += 1 elif outcome in ('skipped', 'unknown', 'ignored'): cat[outcome] += 1 if outcome == 'skipped': cat['unknown'] += 1 # A skipped test is not a pass. elif outcome == 'excluded': cat['excluded'] += 1 elif outcome == 'not_applicable': cat['not_applicable'] += 1 for cat in category_stats.values(): denominator = cat['expected'] - cat['excluded'] - cat['not_applicable'] cat['compliant_percent'] = (round(100.0 * cat['passed'] / denominator, 2) if denominator else 0.0) evaluated = summary['high'] + summary['medium'] + summary['low'] if summary['unknown'] or summary['ignored'] or summary.get('skipped'): compliance = 'incomplete' elif evaluated: compliance = 'non_compliant' else: compliance = 'compliant' # Keep only a compact index here; the full control object below is canonical. issue_controls = [c for c in controls.values() if c['status'] != 'passed' and c['status'] not in ('excluded', 'not_applicable')] level_sort = {'high': 0, 'medium': 1, 'low': 2, 'unknown': 3} issue_controls.sort(key=lambda c: (0 if c['status'] == 'failed' else 1, level_sort.get(c['severity'], 3), c['category'], c['id'])) priority_fields = ('id', 'category', 'status', 'severity') priority_findings = [ {key: control[key] for key in priority_fields if key in control} for control in issue_controls[:25] ] doc = { 'schema_version': 2, 'status': summary['status'], 'compliance': compliance, 'scan': { 'date_utc': summary['last_run_utc'], 'engine': 'HardeningKitty', 'engine_commit': summary['upstream_commit'], 'profile': config.get('profile', 'auto'), 'source': source, }, 'host': { 'os_caption': summary['os_caption'], 'build': summary['os_build'], 'display_version': summary['display_version'], }, 'benchmark': { 'name': summary['benchmark'], 'expected': summary['expected'], }, 'summary': { 'expected': summary['expected'], 'checked': summary['total'], 'executed': summary['executed'], 'passed': summary['passed'], 'failed': evaluated, 'high': summary['high'], 'medium': summary['medium'], 'low': summary['low'], 'unknown': summary['unknown'], 'skipped': summary['skipped'], 'ignored': summary['ignored'], 'excluded': summary['excluded'], 'not_applicable': summary['not_applicable'], 'compliant_percent': summary['compliant_percent'], }, 'categories': category_stats, 'controls': controls, 'priority_findings': priority_findings, 'priority_total': len(issue_controls), 'privacy': { 'current_values_published': bool(config.get('publish_current_values', False)), }, } if len(controls) != summary['expected']: raise RuntimeError('Structured audit JSON does not cover the complete benchmark.') # This is a size check only: the dict itself is sent to WAPT, not a string. json_size = len(json.dumps(doc, ensure_ascii=False).encode('utf-8')) if json_size > MAX_AUDIT_JSON_BYTES: raise RuntimeError('Structured audit data too large (%s bytes).' % json_size) print('HardeningKitty WAPT JSON: %s controls, %s bytes' % (len(controls), json_size)) return doc def _publish_audit(document, config): """Write exactly one native JSON value, as in WAPT's Wazuh example.""" 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): raise RuntimeError('WAPT.write_audit_data_if_changed is unavailable.') write(AUDIT_SECTION, AUDIT_KEY, document, max_count=2, keep_days=120) def _publish_failure(exc): """Replace a previous successful snapshot with an explicit error snapshot.""" wapt = globals().get('WAPT') write = getattr(wapt, 'write_audit_data_if_changed', None) if not callable(write): return document = { 'schema_version': 2, 'status': 'ERROR', 'compliance': 'incomplete', 'scan': { 'date_utc': datetime.datetime.now(datetime.timezone.utc).isoformat(), 'engine': 'HardeningKitty', }, 'error': { 'code': 'AUDIT_FAILED', 'type': type(exc).__name__, 'description': 'Audit failed; no current compliance assessment is available. Check the local WAPT audit log.', }, } write(AUDIT_SECTION, AUDIT_KEY, document, max_count=2, keep_days=120) def _trim_reports(keep): if not isdir(REPORT_ROOT): return paths = [makepath(REPORT_ROOT, name) for name in os.listdir(REPORT_ROOT) if name.startswith('run-') and isdir(makepath(REPORT_ROOT, name)) and not os.path.islink(makepath(REPORT_ROOT, name))] paths.sort(key=lambda p: os.path.getmtime(p), reverse=True) for path in paths[max(1, int(keep)):]: remove_tree(path) def _audit_impl(): manifest = _verify_vendor(INSTALL_ROOT, runtime=True) config = _load_json(makepath(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')) if not isinstance(config.get('error_on_high'), bool) or not isinstance(config.get('warn_on_low'), bool): raise RuntimeError('Invalid audit threshold settings.') if not isinstance(config.get('publish_to_wapt', True), bool) or not isinstance(config.get('publish_current_values', False), bool): raise RuntimeError('Invalid WAPT publication/privacy settings.') retain = config.get('retain_audit_runs', 8) max_log = config.get('max_findings_in_log', 15) if isinstance(retain, bool) or not isinstance(retain, int) or not 1 <= retain <= 100: raise RuntimeError('retain_audit_runs must be 1..100.') if isinstance(max_log, bool) or not isinstance(max_log, int) or not 0 <= max_log <= 100: raise RuntimeError('max_findings_in_log must be 0..100.') exclusions = config.get('intentional_exclusions', {}) if not isinstance(exclusions, dict): raise RuntimeError('intentional_exclusions must be an object.') timeout = config.get('timeout_seconds', 900) if isinstance(timeout, bool) or not isinstance(timeout, int) or not 30 <= timeout <= 7200: raise RuntimeError('timeout_seconds must be 30..7200.') mkdirs(REPORT_ROOT) _secure_directory(REPORT_ROOT) run_id = datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%d-%H%M%S') + '-' + uuid.uuid4().hex[:8] run_dir = makepath(REPORT_ROOT, 'run-' + run_id) mkdirs(run_dir) _secure_directory(run_dir) csv_file = makepath(run_dir, 'hardeningkitty.csv') log_file = makepath(run_dir, 'powershell.log') metadata_file = makepath(run_dir, 'host.json') # run() is WAPT's process helper and handles timeouts and exit statuses. # No subprocess, shell interpolation, or endpoint Internet access. command = [_powershell_executable(), '-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', makepath(INSTALL_ROOT, 'runner.ps1'), '-InstallRoot', INSTALL_ROOT, '-CsvFile', csv_file, '-MetadataFile', metadata_file, '-Source', config['source'], '-Profile', profile] with open(log_file, 'w', encoding='utf-8') as log: def record(line): text = str(line) log.write(text if text.endswith('\n') else text + '\n') log.flush() try: result = run(command, shell=False, timeout=timeout, accept_returncodes=[0], on_write=record) if log.tell() == 0 and result: record(result) except Exception as exc: log.write('Execution failed: %s\n' % type(exc).__name__) raise RuntimeError('HardeningKitty runner failed (%s). See %s' % (type(exc).__name__, log_file)) from exc if not isfile(csv_file) or not isfile(metadata_file): raise RuntimeError('HardeningKitty runner did not produce CSV and host metadata.') metadata = _load_json(metadata_file) benchmark = str(metadata.get('benchmark', '')) if (not benchmark.startswith('finding_list_') or not benchmark.endswith('.csv') or '/' in benchmark or '\\' in benchmark or '..' in benchmark): raise RuntimeError('Unsafe or invalid benchmark from PowerShell runner.') baseline = makepath(INSTALL_ROOT, 'lists', benchmark) if not isfile(baseline): raise RuntimeError('Runner selected a benchmark absent from vendor assets: %s' % benchmark) counts, findings = _read_report(csv_file, config.get('publish_current_values', False)) counts, findings = _check_coverage(baseline, csv_file, counts, findings, exclusions, benchmark) status = _status_from_counts(counts, config) summary = dict(counts) summary.update({ 'status': status, 'benchmark': benchmark, 'os_build': str(metadata['build']), 'os_caption': str(metadata.get('os_caption', '')), 'display_version': str(metadata.get('display_version', '')), '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, }) _save_json(makepath(run_dir, 'summary.json'), summary) document = _build_audit_document(summary, findings, csv_file, baseline, config) _save_json(makepath(run_dir, 'wapt-audit.json'), document) _publish_audit(document, config) print('HardeningKitty: %s | %s | checked=%s passed=%s high=%s medium=%s low=%s unknown=%s skipped=%s | compliant=%s%%' % (status, benchmark, counts['total'], counts['passed'], counts['high'], counts['medium'], counts['low'], counts['unknown'], counts['skipped'], counts['compliant_percent'])) priority = {'high': 0, 'medium': 1, 'low': 2, 'unknown': 3} for finding in sorted(findings, key=lambda f: (priority.get(f['severity'], 9), f['id']))[:max_log]: print('[%s] %s | %s | %s' % (finding['severity'].upper(), finding['id'], finding['category'], finding['name'])) print('Full CSV report: %s' % csv_file) _trim_reports(retain) return status def audit(): try: return _audit_impl() except Exception as exc: # Log enough detail locally, but publish only a sanitized technical error. print('HardeningKitty audit ERROR: %s' % exc) try: _publish_failure(exc) except Exception as publish_error: print('HardeningKitty: failed to publish ERROR JSON (%s).' % type(publish_error).__name__) try: config = _load_json(makepath(INSTALL_ROOT, 'audit_config.json')) _trim_reports(config.get('retain_audit_runs', 8)) except Exception: pass return 'ERROR'