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