feat(audit): publish structured HardeningKitty JSON
This commit is contained in:
1 parent
349b95fbc6
commit
f60d102233
7 files changed
+1033
-116
No files matched your search
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user