fix(audit): harden HardeningKitty packaging and reporting
This commit is contained in:
1 parent
f4a67f3e1b
commit
91eb704215
10 files changed
+1171
-236
No files matched your search
@@ -9,7 +9,9 @@ import csv
|
||||
import collections
|
||||
import datetime
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import locale
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -23,15 +25,41 @@ 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')
|
||||
OWNER_MARKER = '.comi-audit-hardeningkitty-owner'
|
||||
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',
|
||||
)
|
||||
|
||||
|
||||
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__))
|
||||
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]))
|
||||
candidates.append(os.getcwd())
|
||||
for candidate in candidates:
|
||||
if os.path.isfile(os.path.join(candidate, 'WAPT', 'control')):
|
||||
return candidate
|
||||
return os.getcwd()
|
||||
raise RuntimeError('Cannot locate WAPT source directory (WAPT/control not found).')
|
||||
|
||||
|
||||
def _read_json(path):
|
||||
@@ -52,22 +80,26 @@ def _checksum(path):
|
||||
return sha.hexdigest()
|
||||
|
||||
|
||||
def _verify_vendor(root):
|
||||
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. Run python tools/vendor_assets.py before building the WAPT package.')
|
||||
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.')
|
||||
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:
|
||||
for item in REQUIRED_VENDOR_FILES:
|
||||
if item not in files:
|
||||
raise RuntimeError('Missing required vendor asset: %s' % item)
|
||||
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)
|
||||
@@ -76,9 +108,134 @@ def _verify_vendor(root):
|
||||
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.')
|
||||
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.')
|
||||
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):
|
||||
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:
|
||||
return False
|
||||
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))
|
||||
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('/'):
|
||||
raise RuntimeError('Unsafe installed manifest path; refusing removal.')
|
||||
path = os.path.join(root, *name.split('/'))
|
||||
resolved = os.path.realpath(path)
|
||||
if not resolved.startswith(root_real + os.sep):
|
||||
raise RuntimeError('Installed manifest path escapes package root; refusing removal.')
|
||||
if os.path.isfile(path) and not os.path.islink(path):
|
||||
os.unlink(path)
|
||||
parent = os.path.dirname(path)
|
||||
while parent.startswith(root_real + os.sep):
|
||||
try:
|
||||
os.rmdir(parent)
|
||||
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)
|
||||
if not os.path.islink(path):
|
||||
try:
|
||||
os.rmdir(path)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.rmdir(root)
|
||||
except OSError:
|
||||
print('Preserved non-package data remaining in %s.' % root)
|
||||
|
||||
|
||||
def _secure_directory(path):
|
||||
"""Only LocalSystem and local Administrators can access audit assets."""
|
||||
if os.name != 'nt':
|
||||
@@ -91,38 +248,54 @@ def _secure_directory(path):
|
||||
|
||||
def install():
|
||||
source = _source_directory()
|
||||
_verify_package_source(source)
|
||||
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)
|
||||
staging = INSTALL_ROOT + '.staging-' + uuid.uuid4().hex
|
||||
previous = INSTALL_ROOT + '.previous-' + uuid.uuid4().hex
|
||||
renamed_old = 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)
|
||||
_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')
|
||||
_secure_directory(staging)
|
||||
os.makedirs(REPORT_ROOT, exist_ok=True)
|
||||
_secure_directory(REPORT_ROOT)
|
||||
if os.path.isdir(INSTALL_ROOT):
|
||||
os.rename(INSTALL_ROOT, previous)
|
||||
renamed_old = True
|
||||
os.rename(staging, INSTALL_ROOT)
|
||||
if renamed_old and os.path.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
|
||||
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.')
|
||||
@@ -130,7 +303,19 @@ def install():
|
||||
|
||||
def uninstall():
|
||||
if os.path.isdir(INSTALL_ROOT):
|
||||
shutil.rmtree(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.')
|
||||
_remove_owned_install_files(INSTALL_ROOT)
|
||||
print('Removed HardeningKitty audit engine. Historical reports kept in %s' % REPORT_ROOT)
|
||||
|
||||
|
||||
@@ -145,27 +330,46 @@ def _powershell_executable():
|
||||
|
||||
|
||||
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)
|
||||
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, 'total': len(rows)}
|
||||
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 != 'passed' and level in ('high', 'medium', 'low'):
|
||||
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 ''),
|
||||
@@ -179,53 +383,91 @@ def _read_report(path, publish_current_values=False):
|
||||
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)
|
||||
counts['compliant_percent'] = round(100.0 * counts['passed'] / counts['total'], 2) if counts['total'] else 0.0
|
||||
return counts, findings
|
||||
|
||||
|
||||
def _check_coverage(baseline_path, report_path, 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.
|
||||
"""
|
||||
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))
|
||||
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 row_key(row):
|
||||
return (str(row.get('ID') or '').strip(),
|
||||
str(row.get('Name') or '').strip(),
|
||||
str(row.get('Category') or '').strip())
|
||||
def control_id(row):
|
||||
return str(row.get('ID') or '').strip()
|
||||
|
||||
actual = collections.Counter(row_key(row) for row in actual_rows)
|
||||
# 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 = row_key(row)
|
||||
key = control_id(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 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['executed'] = len(actual_rows)
|
||||
counts['skipped'] = len(missing)
|
||||
counts['expected'] = len(expected_rows)
|
||||
counts['compliant_percent'] = round(100.0 * counts['passed'] / counts['total'], 2)
|
||||
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']:
|
||||
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'
|
||||
@@ -250,44 +492,49 @@ def _publish_audit(summary, findings, config):
|
||||
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
|
||||
raise RuntimeError('WAPT.write_audit_data_if_changed is unavailable; audit data was not published.')
|
||||
|
||||
# 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'):
|
||||
keys = ('status', 'benchmark', 'os_build', 'os_caption', 'display_version', 'source',
|
||||
'upstream_commit', 'last_error', 'passed', 'high', 'medium', 'low', 'unknown',
|
||||
'ignored', 'not_applicable', 'excluded', 'total', 'expected', 'executed', 'skipped',
|
||||
'compliant_percent')
|
||||
for key in keys:
|
||||
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))
|
||||
raise RuntimeError('WAPT audit metric publication failed for %s.' % key) from 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))
|
||||
raise RuntimeError('WAPT finding publication failed for shard %02d.' % index) from 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)
|
||||
if not callable(write_current):
|
||||
raise RuntimeError('WAPT.write_audit_data is unavailable; audit timestamp was not published.')
|
||||
try:
|
||||
write_current(AUDIT_SECTION, 'last_run_utc', summary['last_run_utc'],
|
||||
max_count=5, keep_days=120)
|
||||
except Exception as exc:
|
||||
raise RuntimeError('WAPT audit timestamp publication failed.') from 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))]
|
||||
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))]
|
||||
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)
|
||||
_verify_runtime_assets(INSTALL_ROOT)
|
||||
manifest = _verify_vendor(INSTALL_ROOT, allow_runtime=True)
|
||||
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')
|
||||
@@ -295,6 +542,17 @@ def _audit_impl():
|
||||
# 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.')
|
||||
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.')
|
||||
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.')
|
||||
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.')
|
||||
@@ -309,7 +567,7 @@ def _audit_impl():
|
||||
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'),
|
||||
'-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:
|
||||
@@ -322,13 +580,16 @@ def _audit_impl():
|
||||
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)
|
||||
counts, findings = _check_coverage(baseline_file, csv_file, counts, findings,
|
||||
exclusions, metadata['benchmark'])
|
||||
status = _status_from_counts(counts, config)
|
||||
summary = dict(counts)
|
||||
summary.update({
|
||||
'status': status,
|
||||
'benchmark': metadata['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': '',
|
||||
@@ -353,7 +614,8 @@ def audit():
|
||||
try:
|
||||
return _audit_impl()
|
||||
except Exception as exc:
|
||||
message = str(exc)[:1000]
|
||||
# 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)
|
||||
wapt = globals().get('WAPT')
|
||||
write = getattr(wapt, 'write_audit_data_if_changed', None)
|
||||
@@ -371,6 +633,11 @@ def audit():
|
||||
max_count=5, keep_days=120)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
config = _read_json(os.path.join(INSTALL_ROOT, 'audit_config.json'))
|
||||
_trim_reports(config.get('retain_audit_runs', 8))
|
||||
except Exception:
|
||||
pass
|
||||
return 'ERROR'
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user