feat(audit): publish structured HardeningKitty JSON

This commit is contained in:
PierreCsn committed 2026-10-29 09:03:53 +01:00
1 parent 349b95fbc6
commit f60d102233
7 files changed
+1033 -116

No files matched your search

+246 -52
View File
@@ -12,7 +12,6 @@ from setuphelpers import *
import collections
import csv
import datetime
import hashlib # Stable, reproducible distribution of finding IDs to WAPT audit keys.
import io
import json
import locale
@@ -22,7 +21,9 @@ import uuid
uninstallkey = []
PACKAGE_NAME = 'comi-audit-hardeningkitty'
AUDIT_SECTION = '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')
@@ -389,51 +390,251 @@ def _status_from_counts(counts, config):
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 _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(summary, findings, config):
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; audit data was not published.')
raise RuntimeError('WAPT.write_audit_data_if_changed is unavailable.')
write(AUDIT_SECTION, AUDIT_KEY, document, max_count=2, keep_days=120)
# Per-metric keys are convenient for SQL reporting on HostAuditData.
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:
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:
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 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 _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):
@@ -530,7 +731,9 @@ def _audit_impl():
'report_log': log_file,
})
_save_json(makepath(run_dir, 'summary.json'), summary)
_publish_audit(summary, findings, config)
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']))
@@ -549,19 +752,10 @@ def audit():
except Exception as exc:
# Log enough detail locally, but publish only a sanitized technical error.
print('HardeningKitty audit ERROR: %s' % exc)
wapt = globals().get('WAPT')
label = 'Collection failed: %s' % type(exc).__name__
if wapt is not None:
try:
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:
_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))