diff --git a/README.md b/README.md
index 5165766..6dd1d5c 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,8 @@ intégrés au paquet signé avant l’installation.
## Compatibilité et sélection du benchmark
-`WAPT/control` exige WAPT 2.4 ou ultérieur. Le code Python évite les syntaxes
+`WAPT/control` exige un agent WAPT 2.6 ou ultérieur pour le schéma d'audit
+structuré. Le code Python évite les syntaxes
postérieures à Python 3.8; les tests locaux ont été exécutés avec Python 3.9.
La compatibilité réelle doit être confirmée avec l’agent et la console WAPT de
l’environnement cible.
@@ -175,39 +176,87 @@ de publication WAPT provoque elle-même un `ERROR`.
## Données et Reporting WAPT
-Le paquet utilise les API d’audit documentées pour WAPT 2.4+ :
-`WAPT.write_audit_data_if_changed(section, key, value, ..., max_count=5,
-keep_days=120)` pour les mesures stables, et `WAPT.write_audit_data()` pour
-l’observation horodatée `last_run_utc`. Une erreur d’écriture ne reste pas
-silencieusement cachée : l’audit retourne `ERROR` et tente de publier ce statut.
+## Données et Reporting WAPT
-Dans la section `HardeningKitty`, les clés publiées comprennent :
+Chaque audit publie un unique objet Python sérialisable en JSON avec
+`WAPT.write_audit_data_if_changed('hardeningkitty', 'hardeningkitty', document,
+max_count=2, keep_days=120)`. Il n’y a plus de métriques WAPT dispersées ni de
+clés `findings_00` à `findings_15`. Le document change uniquement si son contenu
+change; sa date d’audit fait partie du document, afin de conserver un historique
+des audits ayant réellement produit un nouvel état. L’[API de données d’audit
+WAPT 2.6](https://www.wapt.fr/fr/doc-2.6/wapt-create-packages-code.html)
+accepte un paramètre `value` générique; le round-trip natif d’un dictionnaire a
+également été contrôlé dans le code local WAPT 2.7. La documentation des
+[templates Mustache Console](https://www.wapt.fr/fr/doc-2.7/wapt-console-usage-advanced-audit-data-plugin.html)
+est celle de WAPT 2.7; le rendu Console doit être qualifié avant diffusion,
+notamment si la Console cible reste en version 2.6.
-- `status`, `benchmark`, `os_caption`, `os_build`, `display_version`, `source`,
- `upstream_commit`, `last_run_utc`, `last_error` ;
-- `expected`, `executed`, `passed`, `high`, `medium`, `low`, `ignored`,
- `not_applicable`, `excluded`, `unknown`, `skipped`, `total` et
- `compliant_percent` ;
-- `findings_00` à `findings_15`, des fragments JSON stables avec ID, catégorie,
- nom, sévérité du contrôle et résultat. Les valeurs actuelles/recommandées ne
- sont pas dupliquées dans ces fragments lorsque `publish_current_values` est faux.
+Les données sont limitées à 2 MiB avant publication. Si le jeu complet dépasse
+la limite, l’audit échoue explicitement et publie un document `ERROR`; aucun
+contrôle n’est tronqué. Les valeurs constatées restent exclues sauf opt-in
+`publish_current_values: true`. Les recommandations viennent du benchmark et
+peuvent être publiées. Chemins, arguments et descriptions ne sont inclus que
+lorsqu’ils sont présents dans les données de référence. Le rapport CSV détaillé
+reste local, sous les ACL de ProgramData. WAPT conserve au plus deux valeurs
+différentes par clé et 120 jours; huit exécutions locales sont gardées par
+défaut.
-Les valeurs actuelles et recommandations issues du poste ne sont pas envoyées
-par défaut (`publish_current_values: false`). Les écarts détaillés restent dans
-le CSV local, protégé par ACL; les journaux locaux peuvent inclure les erreurs
-techniques. Huit exécutions locales sont conservées par défaut. WAPT limite les
-mesures stables à cinq changements pendant 120 jours, les fragments à trois
-changements pendant 120 jours, et les horodatages à cinq observations pendant
-120 jours. Ces valeurs suivent les paramètres documentés des API d’audit WAPT.
+La mesure synthétique exécutée avec 500 contrôles donne 264 171 octets pour
+le document structuré sans valeurs constatées et 335 670 octets avec l'opt-in
+des valeurs. Le document structuré est plus riche qu'un CSV brut et n'est donc
+pas présenté comme systématiquement plus petit. La rationalisation porte sur
+une seule clé, l'absence de répétition du snapshot par métrique/fragment et
+l'historisation uniquement lorsque le document change. Les contrôles détaillés
+restent la source canonique; l'aperçu prioritaire ne répète que leurs ID,
+catégorie, statut et sévérité. Les tailles sont celles
+de sérialisation Python; elles n'incluent pas l'overhead PostgreSQL/WAPT.
-La suite mesure aussi la taille série des écarts sur des CSV synthétiques. Pour
-10/100/500 lignes d’écart, le CSV brut local faisait 3 562/35 062/175 462
-octets; les fragments JSON WAPT par défaut faisaient 1 125/11 106/55 906
-octets. Le mode qui ajoute les valeurs et recommandations synthétiques faisait
-4 305/42 906/214 906 octets. Ce sont des tailles de sérialisation, sans
-surcoût PostgreSQL/WAPT, et non une mesure sur un parc réel. Les métriques de
-résumé et les détails restent séparés; les valeurs de contrôle ne sont pas
-copiées dans Reporting par défaut.
+Exemple anonymisé (forme abrégée; le paquet publie tous les contrôles, sans
+tronquer le JSON) :
+
+```json
+{
+ "schema_version": 2,
+ "status": "WARNING",
+ "compliance": "non_compliant",
+ "scan": {
+ "date_utc": "2026-10-29T10:00:00+00:00",
+ "engine": "HardeningKitty",
+ "engine_commit": "acf9ecee3d044a9337df7aad80e730cbd746e8de",
+ "profile": "auto",
+ "source": "GPO"
+ },
+ "host": {"os_caption": "Windows 11", "build": "26100", "display_version": "24H2"},
+ "benchmark": {"name": "finding_list_example_machine.csv"},
+ "summary": {"expected": 407, "executed": 407, "passed": 390, "high": 2,
+ "medium": 10, "low": 5, "unknown": 0, "skipped": 0,
+ "compliant_percent": 95.82},
+ "categories": {"System": {"expected": 40, "passed": 38, "high": 1,
+ "medium": 1, "low": 0, "compliant_percent": 95.0}},
+ "controls": {"101": {"id": "101", "category": "System", "name": "Example",
+ "status": "failed", "severity": "high",
+ "recommended": "1"}}
+}
+```
+
+La vue Console facultative se trouve dans
+`console-templates/host_audit_hardeningkitty_hardeningkitty.html`. Elle est
+destinée au poste **WAPT Console uniquement**, pas au paquet agent. L’installer
+dans le répertoire de templates de la Console sous ce nom exact, puis choisir
+la clé d’audit `hardeningkitty / hardeningkitty`. Elle exploite le helper
+Mustache WAPT `Items` pour parcourir les dictionnaires de catégories et de
+contrôles. Les valeurs variables utilisent les tags échappés `{{...}}`; aucun
+JavaScript ni triple-brace non échappé n’est utilisé. Le template est une vue
+de synthèse et d’analyse, pas une preuve de conformité indépendante. Sa syntaxe
+a été contrôlée statiquement, mais son rendu dans une Console réelle doit être
+validé avant diffusion.
+
+Les anciennes clés fragmentées restent éventuellement visibles dans
+l’historique antérieur à cette version; elles ne sont plus émises par le code
+actuel. Les requêtes Reporting doivent lire la dernière valeur de
+`value_section='hardeningkitty'` et `value_key='hardeningkitty'`, puis naviguer
+dans le JSON (`summary`, `categories`, `controls`, `priority_findings`). Le
+format et la limite de 2 MiB doivent être pris en compte par les consommateurs.
## Configuration
diff --git a/WAPT/control b/WAPT/control
index 7203edd..3148faa 100644
--- a/WAPT/control
+++ b/WAPT/control
@@ -1,10 +1,10 @@
package : comi-audit-hardeningkitty
-version : 0.9.10-0
+version : 0.9.11-0
architecture : all
section : base
priority : optional
maintainer : Comitari
description : Read-only Windows security baseline audit via HardeningKitty
-min_wapt_version : 2.4
+min_wapt_version : 2.6
target_os : windows
audit_schedule : 1w
diff --git a/console-templates/host_audit_hardeningkitty_hardeningkitty.html b/console-templates/host_audit_hardeningkitty_hardeningkitty.html
new file mode 100644
index 0000000..5bbbb1b
--- /dev/null
+++ b/console-templates/host_audit_hardeningkitty_hardeningkitty.html
@@ -0,0 +1,236 @@
+
+
+
+
+
+ {{#error}}
+
+
Audit incomplet ou indisponible.
+
Code : {{error.code}} — type : {{error.type}}
+
{{error.description}}
+
Vérifier les journaux de l'agent WAPT sur le poste concerné.
+
+ {{/error}}
+
+ {{#summary}}
+
+ Machine : {{host.os_caption}} {{host.display_version}} (build {{host.build}})
+ Référentiel : {{benchmark.name}}
+ Source : {{scan.source}}
+ Audit UTC : {{scan.date_utc}}
+
+
+
+
Taux de conformité
+
{{compliant_percent}} %
+
+
+
+
+
{{high}}Écarts élevés
+
{{medium}}Écarts moyens
+
{{low}}Écarts faibles
+
{{passed}}Conformes
+
{{unknown}}Inconnus
+
{{skipped}}Non exécutés
+
+
+ {{/summary}}
+
+ {{#summary}}
+ {{#privacy.current_values_published}}
+
Valeurs constatées : publication activée. Vérifier les droits d'accès aux données d'audit.
+ {{/privacy.current_values_published}}
+ {{^privacy.current_values_published}}
+
Les valeurs effectivement lues sur le poste ne sont pas publiées par défaut. Le CSV détaillé reste disponible localement sur le poste audité.
+ {{/privacy.current_values_published}}
+
+
Écarts à examiner en priorité
+
{{priority_total}} résultats non conformes ou non évalués. Aperçu limité à 25 contrôles, classés par gravité puis catégorie (pas par risque exploitabilité).
+
+
+
+ Répartition par catégorie
+
+
+ | Catégorie | Contrôles | Conformes | Haut | Moyen | Faible | Non évalués | Conformité |
+
+ {{#Items categories}}
+
+ | {{key}} |
+ {{value.expected}} |
+ {{value.passed}} |
+ {{value.high}} |
+ {{value.medium}} |
+ {{value.low}} |
+ {{value.unknown}} |
+ {{value.compliant_percent}} % |
+
+ {{/Items}}
+
+
+
+
+
+
+ Tous les contrôles du benchmark ({{summary.expected}})
+
+
Sélectionner un filtre. Les détails de chaque contrôle contiennent la justification et la méthode de vérification.
+
+
+
+ {{/summary}}
+
+
+
diff --git a/package-manifest.json b/package-manifest.json
index 83a9044..81364ac 100644
--- a/package-manifest.json
+++ b/package-manifest.json
@@ -1,9 +1,9 @@
{
"files": {
- "WAPT/control": "a7b8ffea3e6df419b88f6118c15b977ccf8b15a91c41d4d757e68e29265a2e0c",
+ "WAPT/control": "4c3eef3800f9994b90cefb06ec35e019ba0c8fb8ffda8a216c7b6ca9eba457d4",
"audit_config.json": "9fc18f556fd491e8e00e3a8b1efd864ad8036682e293a02c1f81f3411304a69b",
"runner.ps1": "5e9787f5cc19d0fc4c0ede01a63188ed71de6ef49df596fbce7b2603093231cd",
- "setup.py": "e67fcfcffd95f0452ed686394ed3ee6af9a2ab95bb8291e9bd350d5fc013db10",
+ "setup.py": "0f4be0d4f2c42ace380dd6305a210c5a7d595508dbc566f652d86f3c7d874d43",
"vendor/HardeningKitty.psd1": "94378764787cd0c7125c5c3ce928803b46ee7d7d3fe21c2b27ddeb23c11377a7",
"vendor/HardeningKitty.psm1": "b799ec1b17de5ce63f7a8ea34af3beba1d24fcaddf7e3774f797096f5c43e2a0",
"vendor/LICENSE": "a879ba6ccdcd2ea55da97b999edd2c401c8589ca946f238236bf93aa40e74d56",
diff --git a/setup.py b/setup.py
index c9a2893..4f75f18 100644
--- a/setup.py
+++ b/setup.py
@@ -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))
diff --git a/tests/test_package.py b/tests/test_package.py
index b66e4a3..d522e39 100644
--- a/tests/test_package.py
+++ b/tests/test_package.py
@@ -130,7 +130,7 @@ class PackageTests(unittest.TestCase):
self.assertEqual(counts['unknown'], 1)
self.assertEqual(pkg._status_from_counts(counts, {'error_on_high': True, 'warn_on_low': True}), 'ERROR')
self.assertEqual(len(findings), 2)
- self.assertEqual(len(pkg._shard_findings(findings)), 16)
+ self.assertEqual(counts['expected'], 3)
def test_only_reviewed_conditional_exclusions_leave_coverage_denominator(self):
with tempfile.TemporaryDirectory() as tmp:
@@ -194,23 +194,36 @@ class PackageTests(unittest.TestCase):
_, findings2 = pkg._read_report(str(report), True)
self.assertEqual(findings2[0]['current'], 'private_value')
- def test_reporting_payload_is_smaller_than_raw_report_without_sensitive_values(self):
+ def test_reporting_payload_size_is_measured_without_sensitive_values(self):
with tempfile.TemporaryDirectory() as tmp:
report = Path(tmp, 'report.csv')
+ baseline = Path(tmp, 'baseline.csv')
rows = [['%04d' % i, 'Synthetic category', 'Synthetic control %04d' % i,
'Low', 'current-setting-' * 8, 'recommended-setting-' * 8, 'Failed']
for i in range(500)]
report.write_text(csv_content(rows), encoding='utf-8')
+ baseline.write_text(csv_content([
+ [row[0], row[1], row[2], 'Low', '', row[5], ''] for row in rows
+ ]), encoding='utf-8')
raw_bytes = report.stat().st_size
- _, compact = pkg._read_report(str(report), publish_current_values=False)
- _, expanded = pkg._read_report(str(report), publish_current_values=True)
- compact_bytes = sum(len(json.dumps(bucket, ensure_ascii=False, separators=(',', ':')).encode('utf-8'))
- for bucket in pkg._shard_findings(compact))
- expanded_bytes = sum(len(json.dumps(bucket, ensure_ascii=False, separators=(',', ':')).encode('utf-8'))
- for bucket in pkg._shard_findings(expanded))
- self.assertEqual(len(compact), 500)
- self.assertLess(compact_bytes, raw_bytes)
+ counts, findings = pkg._read_report(str(report), publish_current_values=False)
+ counts, findings = pkg._check_coverage(str(baseline), str(report), counts, findings)
+ summary = dict(counts, status='WARNING', source='GPO', upstream_commit=pkg.UPSTREAM_COMMIT,
+ last_run_utc='2026-10-29T00:00:00+00:00', benchmark='synthetic.csv',
+ os_caption='Synthetic Windows', os_build='1', display_version='synthetic')
+ compact = pkg._build_audit_document(summary, findings, str(report), str(baseline), {})
+ expanded = pkg._build_audit_document(summary, findings, str(report), str(baseline),
+ {'publish_current_values': True})
+ compact_bytes = len(json.dumps(compact, ensure_ascii=False, separators=(',', ':')).encode('utf-8'))
+ expanded_bytes = len(json.dumps(expanded, ensure_ascii=False, separators=(',', ':')).encode('utf-8'))
+ self.assertEqual(len(compact['controls']), 500)
+ # A complete structured schema is richer than the CSV; record both
+ # measured sizes instead of claiming it is smaller than source.
+ self.assertGreater(compact_bytes, 0)
+ self.assertGreater(raw_bytes, 0)
self.assertGreater(expanded_bytes, compact_bytes)
+ self.assertLessEqual(compact_bytes, pkg.MAX_AUDIT_JSON_BYTES)
+ self.assertNotIn('current', compact['controls']['0000'])
def test_ignored_not_applicable_and_unknown_are_not_compliant(self):
with tempfile.TemporaryDirectory() as tmp:
@@ -305,38 +318,68 @@ class PackageTests(unittest.TestCase):
finally:
pkg._package_source, pkg.INSTALL_ROOT, pkg.REPORT_ROOT = old
- def test_published_shards_and_keys(self):
- events = {}
+ def test_structured_audit_publishes_one_native_json_key(self):
+ events = []
class FakeWapt:
def write_audit_data_if_changed(self, section, key, value, **kwargs):
- events[key] = value
+ events.append((section, key, value, kwargs))
def write_audit_data(self, section, key, value, **kwargs):
- events[key] = value
+ events.append((section, key, value, kwargs))
old = getattr(pkg, 'WAPT', None)
try:
pkg.WAPT = FakeWapt()
- summary = {k: '1' for k in ('status', 'benchmark', 'os_build', 'source',
- 'upstream_commit', 'passed', 'high', 'medium', 'low', 'unknown',
- 'ignored', 'not_applicable', 'excluded', 'os_caption', 'display_version',
- 'total', 'expected', 'executed', 'skipped', 'compliant_percent', 'last_run_utc', 'last_error')}
- pkg._publish_audit(summary, [dict(id='42', name='Enabled', category='Firewall',
- severity='high', result='Failed')], {'publish_to_wapt': True})
- self.assertEqual(len([k for k in events if k.startswith('findings_')]), 16)
- self.assertEqual(sum(len(json.loads(v)) for k, v in events.items() if k.startswith('findings_')), 1)
- self.assertIn('status', events)
+ document = {'schema_version': 2, 'status': 'WARNING', 'controls': {'42': {'status': 'failed'}}}
+ pkg._publish_audit(document, {'publish_to_wapt': True})
+ self.assertEqual(len(events), 1)
+ section, key, value, kwargs = events[0]
+ self.assertEqual((section, key), ('hardeningkitty', 'hardeningkitty'))
+ self.assertIs(value, document)
+ self.assertEqual(kwargs['max_count'], 2)
finally:
if old is None:
del pkg.WAPT
else:
pkg.WAPT = old
- def test_failed_audit_updates_wapt_status(self):
- events = {}
+ def test_console_template_matches_schema_and_uses_escaped_tags(self):
+ template = (ROOT / 'console-templates' /
+ 'host_audit_hardeningkitty_hardeningkitty.html').read_text(encoding='utf-8')
+ self.assertIn('{{#Items categories}}', template)
+ self.assertIn('{{#Items controls}}', template)
+ self.assertIn('{{error.code}}', template)
+ self.assertIn('{{error.description}}', template)
+ self.assertIn('{{summary.expected}}', template)
+ self.assertNotIn('{{{', template)
+ self.assertNotIn('