fix(package): integrate WAPT ZIP correction

This commit is contained in:
PierreCsn committed 2026-10-28 17:49:49 +01:00
1 parent 1afb3a2b73
commit f817d41189
6 files changed
+609 -401

No files matched your search

+240 -295
View File
@@ -1,34 +1,39 @@
# -*- coding: utf-8 -*-
"""WAPT package: offline, read-only HardeningKitty machine baseline audit.
"""Comitari - Offline, read-only HardeningKitty audit package for WAPT.
This code runs under WAPT Agent's Python runtime on Windows.
Vendor assets are fetched on the packaging workstation, not on endpoints.
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 csv
import collections
import csv
import datetime
import hashlib
import hashlib # Stable, reproducible distribution of finding IDs to WAPT audit keys.
import io
import json
import locale
import os
import shutil
import subprocess
import sys
import uuid
uninstallkey = []
PACKAGE_NAME = 'comi-audit-hardeningkitty'
AUDIT_SECTION = 'HardeningKitty'
UPSTREAM_COMMIT = 'acf9ecee3d044a9337df7aad80e730cbd746e8de'
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')
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/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',
@@ -47,172 +52,118 @@ REQUIRED_VENDOR_FILES = (
'lists/finding_list_msft_security_baseline_windows_server_2025_24h2_member_machine.csv',
)
def _source_directory():
"""Find package root and return it relative to the current working directory."""
candidates = []
if globals().get('__file__'):
candidates.append(os.path.dirname(os.path.abspath(__file__)))
for name in ('basedir', 'package_dir'):
if globals().get(name):
candidates.append(os.path.abspath(globals()[name]))
current_directory = os.getcwd()
candidates.append(current_directory)
for candidate in candidates:
relative_candidate = os.path.relpath(os.path.abspath(candidate), current_directory)
if os.path.isfile(os.path.join(relative_candidate, 'WAPT', 'control')):
return relative_candidate
raise RuntimeError('Cannot locate WAPT source directory (WAPT/control not found).')
# 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 _read_json(path):
with open(path, 'r', encoding='utf-8-sig') as fh:
return json.load(fh)
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.relpath(os.path.abspath(root), os.getcwd())
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 _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 _load_json(path):
return json_load_file(path)
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 _save_json(path, payload):
json_write_file(path, payload, indent=2, sort_keys=True)
def _verify_vendor(root, allow_runtime=False):
manifest_path = os.path.join(root, 'vendor-manifest.json')
if not os.path.isfile(manifest_path):
raise RuntimeError('Vendor assets missing from WAPT package (vendor/vendor-manifest.json). Rebuild from prepared sources and verify the resulting .wapt before installation.')
manifest = _read_json(manifest_path)
if manifest.get('upstream_commit') != 'acf9ecee3d044a9337df7aad80e730cbd746e8de':
raise RuntimeError('Vendor manifest does not use the approved pinned revision.')
files = manifest.get('files', {})
if not isinstance(files, dict) or len(files) < 5:
raise RuntimeError('Invalid vendor manifest; refuse incomplete audit assets.')
for item in REQUIRED_VENDOR_FILES:
if item not in files:
raise RuntimeError('Missing required vendor asset: %s' % item)
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, allow_extra=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_files = set(files)
expected_files.add('vendor-manifest.json')
if allow_runtime:
for runtime_name in ('runner.ps1', 'audit_config.json', 'package-manifest.json', OWNER_MARKER):
if os.path.isfile(os.path.join(root, runtime_name)):
expected_files.add(runtime_name)
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)
actual_files = set()
for current, dirs, names in os.walk(root, followlinks=False):
if any(os.path.islink(os.path.join(current, name)) for name in dirs + names):
raise RuntimeError('Vendor assets must not contain symlinks.')
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_files.add(os.path.relpath(os.path.join(current, name), root).replace(os.sep, '/'))
if actual_files != expected_files:
raise RuntimeError('Vendor directory contains missing or unmanifested files.')
actual.add(os.path.relpath(makepath(directory, name), root).replace(os.sep, '/'))
if not expected.issubset(actual) or (not allow_extra and actual != expected):
raise RuntimeError('Unexpected/missing files in HardeningKitty directory: %s' %
', '.join(sorted(actual.symmetric_difference(expected))[:10]))
return manifest
def _verify_package_source(source):
if not os.path.isfile(os.path.join(source, 'WAPT', 'control')):
raise RuntimeError('WAPT/control is missing from package sources.')
manifest = _read_json(os.path.join(source, 'package-manifest.json'))
files = manifest.get('files')
if manifest.get('schema_version') != 1 or manifest.get('upstream_commit') != 'acf9ecee3d044a9337df7aad80e730cbd746e8de' or not isinstance(files, dict):
raise RuntimeError('Package source manifest is invalid.')
_verify_vendor(os.path.join(source, 'vendor'))
root_real = os.path.realpath(source)
actual = {}
for name in ('setup.py', 'WAPT/control', 'runner.ps1', 'audit_config.json', 'vendor/vendor-manifest.json'):
if name not in files:
raise RuntimeError('Package manifest is missing required source file: %s' % name)
for name, expected in files.items():
if not isinstance(name, str) or '\\' in name or name.startswith('/') or '..' in name.split('/'):
raise RuntimeError('Unsafe package manifest path: %r' % name)
path = os.path.realpath(os.path.join(source, *name.split('/')))
if not path.startswith(root_real + os.sep) or not os.path.isfile(path):
raise RuntimeError('Package source file missing or outside source root: %s' % name)
actual[name] = _checksum(path)
if actual[name].lower() != str(expected).lower():
raise RuntimeError('Package source file differs from package manifest: %s' % name)
required = set('vendor/' + item for item in REQUIRED_VENDOR_FILES)
if not required.issubset(files):
raise RuntimeError('Package manifest omits runner-required benchmark assets.')
return manifest
def _verify_runtime_assets(root):
manifest = _read_json(os.path.join(root, 'package-manifest.json'))
files = manifest.get('files')
if not isinstance(files, dict):
raise RuntimeError('Runtime package manifest is invalid.')
for name in ('runner.ps1', 'audit_config.json', 'vendor/vendor-manifest.json'):
if name not in files:
raise RuntimeError('Runtime manifest is missing %s.' % name)
for name, digest in files.items():
if name in ('setup.py', 'WAPT/control'):
continue
runtime_name = name[len('vendor/'):] if name.startswith('vendor/') else name
path = os.path.join(root, *runtime_name.split('/'))
if not os.path.isfile(path) or _checksum(path).lower() != str(digest).lower():
raise RuntimeError('Runtime asset missing or altered: %s' % name)
_verify_vendor(root, allow_runtime=True)
def _is_owned_install(root):
if os.path.islink(root) or not os.path.isdir(root):
def _owned_install(root, allow_extra=False):
"""Never replace/delete a directory belonging to someone else."""
if os.path.islink(root) or not isdir(root):
return False
marker = os.path.join(root, OWNER_MARKER)
try:
if os.path.isfile(marker):
with open(marker, 'r', encoding='utf-8') as fh:
if fh.read().strip() != PACKAGE_NAME:
return False
else:
# Upgrade path for versions predating the ownership marker.
_verify_vendor(root, allow_runtime=True)
if not os.path.isfile(os.path.join(root, 'runner.ps1')) or not os.path.isfile(os.path.join(root, 'audit_config.json')):
return False
_verify_vendor(root, allow_runtime=True)
manifest = _read_json(os.path.join(root, 'vendor-manifest.json'))
allowed = set(manifest['files'])
allowed.update(('vendor-manifest.json', 'runner.ps1', 'audit_config.json',
'package-manifest.json', OWNER_MARKER))
for current, dirs, files in os.walk(root, followlinks=False):
for directory in dirs:
if os.path.islink(os.path.join(current, directory)):
return False
for filename in files:
path = os.path.join(current, filename)
if os.path.islink(path):
return False
relative = os.path.relpath(path, root).replace(os.sep, '/')
if relative not in allowed:
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, allow_extra=allow_extra)
return True
except Exception:
return False
def _remove_owned_install_files(root):
manifest = _read_json(os.path.join(root, 'vendor-manifest.json'))
managed = set(manifest.get('files', {}))
managed.update(('vendor-manifest.json', 'runner.ps1', 'audit_config.json',
'package-manifest.json', OWNER_MARKER))
"""Remove only verified package files, keeping unrelated operator data."""
manifest = _verify_vendor(root, runtime=True, allow_extra=True)
managed = set(manifest['files'])
managed.update(('vendor-manifest.json',) + RUNTIME_FILES + LEGACY_FILES + (OWNER_MARKER,))
root_real = os.path.realpath(root)
for name in sorted(managed, key=lambda value: value.count('/'), reverse=True):
if not isinstance(name, str) or '\\' in name or name.startswith('/') or '..' in name.split('/'):
for relative in sorted(managed, key=lambda value: value.count('/'), reverse=True):
if not _safe_vendor_name(relative):
raise RuntimeError('Unsafe installed manifest path; refusing removal.')
path = os.path.join(root, *name.split('/'))
path = makepath(root, *relative.split('/'))
resolved = os.path.realpath(path)
if not resolved.startswith(root_real + os.sep):
raise RuntimeError('Installed manifest path escapes package root; refusing removal.')
@@ -225,9 +176,9 @@ def _remove_owned_install_files(root):
except OSError:
break
parent = os.path.dirname(parent)
for current, dirs, files in os.walk(root, topdown=False, followlinks=False):
for directory in dirs:
path = os.path.join(current, directory)
for current, folders, names in os.walk(root, topdown=False, followlinks=False):
for folder in folders:
path = makepath(current, folder)
if not os.path.islink(path):
try:
os.rmdir(path)
@@ -240,97 +191,78 @@ def _remove_owned_install_files(root):
def _secure_directory(path):
"""Only LocalSystem and local Administrators can access audit assets."""
"""Apply SID-based ACLs independent of Windows display language."""
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'])
run(['icacls.exe', path, '/inheritance:r', '/grant:r',
'*S-1-5-18:(OI)(CI)F', '*S-1-5-32-544:(OI)(CI)F', '/T', '/Q'],
shell=False, timeout=120, accept_returncodes=[0])
def install():
source = _source_directory()
_verify_package_source(source)
vendor = os.path.join(source, 'vendor')
"""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)
os.makedirs(parent, exist_ok=True)
mkdirs(parent)
staging = INSTALL_ROOT + '.staging-' + uuid.uuid4().hex
previous = INSTALL_ROOT + '.previous-' + uuid.uuid4().hex
renamed_old = False
old_moved = False
try:
if os.path.exists(INSTALL_ROOT):
if not _is_owned_install(INSTALL_ROOT):
raise RuntimeError('Existing install root is not identified as this package; refusing replacement.')
os.makedirs(staging)
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)
for current, dirs, files in os.walk(vendor):
relative = os.path.relpath(current, vendor)
target_dir = staging if relative == '.' else os.path.join(staging, relative)
os.makedirs(target_dir, exist_ok=True)
for directory in dirs:
os.makedirs(os.path.join(target_dir, directory), exist_ok=True)
for filename in files:
shutil.copy2(os.path.join(current, filename), os.path.join(target_dir, filename))
for name in ('runner.ps1', 'audit_config.json', 'package-manifest.json'):
shutil.copy2(os.path.join(source, name), os.path.join(staging, name))
_verify_runtime_assets(staging)
with open(os.path.join(staging, OWNER_MARKER), 'w', encoding='ascii') as marker:
marker.write(PACKAGE_NAME + '\n')
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)
os.makedirs(REPORT_ROOT, exist_ok=True)
mkdirs(REPORT_ROOT)
_secure_directory(REPORT_ROOT)
if os.path.isdir(INSTALL_ROOT):
if isdir(INSTALL_ROOT):
os.rename(INSTALL_ROOT, previous)
renamed_old = True
os.rename(staging, INSTALL_ROOT)
if renamed_old and os.path.isdir(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:
shutil.rmtree(previous)
except OSError as exc:
print('Previous package-owned engine retained at %s: %s' % (previous, type(exc).__name__))
except Exception:
if os.path.isdir(staging):
try:
shutil.rmtree(staging)
except OSError:
pass
if renamed_old and not os.path.exists(INSTALL_ROOT):
os.rename(previous, INSTALL_ROOT)
raise
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.')
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.isdir(INSTALL_ROOT):
if os.path.islink(INSTALL_ROOT):
raise RuntimeError('Refusing to remove a symlink install root.')
marker = os.path.join(INSTALL_ROOT, OWNER_MARKER)
if os.path.isfile(marker):
with open(marker, 'r', encoding='utf-8') as fh:
if fh.read().strip() != PACKAGE_NAME:
raise RuntimeError('Install ownership marker is invalid; refusing removal.')
else:
# Older package releases predate the marker; require verified assets.
_verify_vendor(INSTALL_ROOT, allow_runtime=True)
if not os.path.isfile(os.path.join(INSTALL_ROOT, 'vendor-manifest.json')):
raise RuntimeError('Install ownership marker missing; refusing to remove foreign data.')
if os.path.lexists(INSTALL_ROOT):
if not _owned_install(INSTALL_ROOT, allow_extra=True):
raise RuntimeError('Uninstall refused: directory is unverified or not owned by this package.')
_remove_owned_install_files(INSTALL_ROOT)
print('Removed HardeningKitty audit engine. Historical reports kept in %s' % REPORT_ROOT)
print('HardeningKitty removed. Historical reports kept: %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.')
"""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)
@@ -527,69 +459,88 @@ def _publish_audit(summary, findings, config):
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))
and not os.path.islink(os.path.join(REPORT_ROOT, name))]
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)):]:
shutil.rmtree(path)
remove_tree(path)
def _audit_impl():
_verify_runtime_assets(INSTALL_ROOT)
manifest = _verify_vendor(INSTALL_ROOT, allow_runtime=True)
config = _read_json(os.path.join(INSTALL_ROOT, 'audit_config.json'))
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')
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.')
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('WAPT publication/privacy settings must be JSON booleans.')
raise RuntimeError('Invalid WAPT publication/privacy settings.')
retain = config.get('retain_audit_runs', 8)
max_log_findings = config.get('max_findings_in_log', 15)
if isinstance(retain, bool) or not isinstance(retain, int) or retain < 1 or retain > 100:
raise RuntimeError('retain_audit_runs must be an integer from 1 to 100.')
if isinstance(max_log_findings, bool) or not isinstance(max_log_findings, int) or max_log_findings < 0 or max_log_findings > 100:
raise RuntimeError('max_findings_in_log must be an integer from 0 to 100.')
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 a benchmark-to-ID mapping.')
timeout = int(config.get('timeout_seconds', 900))
if timeout < 30 or timeout > 7200:
raise RuntimeError('timeout_seconds must be between 30 and 7200.')
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.')
os.makedirs(REPORT_ROOT, exist_ok=True)
mkdirs(REPORT_ROOT)
_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)
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 = 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')
csv_file = makepath(run_dir, 'hardeningkitty.csv')
log_file = makepath(run_dir, 'powershell.log')
metadata_file = makepath(run_dir, 'host.json')
cmd = [_powershell_executable(), '-NoLogo', '-NoProfile', '-NonInteractive',
'-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,
exclusions, metadata['benchmark'])
# 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',
'-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': metadata['benchmark'],
'benchmark': benchmark,
'os_build': str(metadata['build']),
'os_caption': str(metadata.get('os_caption', '')),
'display_version': str(metadata.get('display_version', '')),
@@ -600,16 +551,17 @@ def _audit_impl():
'report_csv': csv_file,
'report_log': log_file,
})
_write_json(os.path.join(run_dir, 'summary.json'), summary)
_save_json(makepath(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'],
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']))
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))
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
@@ -617,31 +569,24 @@ def audit():
try:
return _audit_impl()
except Exception as exc:
# Do not send paths, command lines or host-specific details to Reporting.
message = 'Collection failed: %s' % type(exc).__name__
print('HardeningKitty audit ERROR (execution/integrity failure): %s' % message)
# Log enough detail locally, but publish only a sanitized technical error.
print('HardeningKitty audit ERROR: %s' % exc)
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):
label = 'Collection failed: %s' % type(exc).__name__
if wapt is not None:
try:
write_run(AUDIT_SECTION, 'last_run_utc',
datetime.datetime.now(datetime.timezone.utc).isoformat(),
max_count=5, keep_days=120)
wapt.write_audit_data_if_changed(AUDIT_SECTION, 'status', 'ERROR',
max_count=5, keep_days=120)
wapt.write_audit_data_if_changed(AUDIT_SECTION, 'last_error', label,
max_count=5, keep_days=120)
wapt.write_audit_data(AUDIT_SECTION, 'last_run_utc',
datetime.datetime.now(datetime.timezone.utc).isoformat(),
max_count=5, keep_days=120)
except Exception:
pass
try:
config = _read_json(os.path.join(INSTALL_ROOT, 'audit_config.json'))
config = _load_json(makepath(INSTALL_ROOT, 'audit_config.json'))
_trim_reports(config.get('retain_audit_runs', 8))
except Exception:
pass
return 'ERROR'