Merge pull request 'feat(audit): publish structured HardeningKitty JSON' (#2) from feature/defender-structured-audit-json into main

This commit was merged in pull request #2.
This commit is contained in:
pcosson committed 2026-10-29 09:11:33 +00:00
commit 3468108e10
7 files changed
+1032 -115

No files matched your search

+79 -30
View File
@@ -14,7 +14,8 @@ intégrés au paquet signé avant l’installation.
## Compatibilité et sélection du benchmark ## 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. 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 La compatibilité réelle doit être confirmée avec l’agent et la console WAPT de
l’environnement cible. l’environnement cible.
@@ -175,39 +176,87 @@ de publication WAPT provoque elle-même un `ERROR`.
## Données et Reporting WAPT ## Données et Reporting WAPT
Le paquet utilise les API d’audit documentées pour WAPT 2.4+ : ## Données et Reporting WAPT
`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.
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`, Les données sont limitées à 2 MiB avant publication. Si le jeu complet dépasse
`upstream_commit`, `last_run_utc`, `last_error` ; la limite, l’audit échoue explicitement et publie un document `ERROR`; aucun
- `expected`, `executed`, `passed`, `high`, `medium`, `low`, `ignored`, contrôle n’est tronqué. Les valeurs constatées restent exclues sauf opt-in
`not_applicable`, `excluded`, `unknown`, `skipped`, `total` et `publish_current_values: true`. Les recommandations viennent du benchmark et
`compliant_percent` ; peuvent être publiées. Chemins, arguments et descriptions ne sont inclus que
- `findings_00` à `findings_15`, des fragments JSON stables avec ID, catégorie, lorsqu’ils sont présents dans les données de référence. Le rapport CSV détaillé
nom, sévérité du contrôle et résultat. Les valeurs actuelles/recommandées ne reste local, sous les ACL de ProgramData. WAPT conserve au plus deux valeurs
sont pas dupliquées dans ces fragments lorsque `publish_current_values` est faux. 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 La mesure synthétique exécutée avec 500 contrôles donne 264 171 octets pour
par défaut (`publish_current_values: false`). Les écarts détaillés restent dans le document structuré sans valeurs constatées et 335 670 octets avec l'opt-in
le CSV local, protégé par ACL; les journaux locaux peuvent inclure les erreurs des valeurs. Le document structuré est plus riche qu'un CSV brut et n'est donc
techniques. Huit exécutions locales sont conservées par défaut. WAPT limite les pas présenté comme systématiquement plus petit. La rationalisation porte sur
mesures stables à cinq changements pendant 120 jours, les fragments à trois une seule clé, l'absence de répétition du snapshot par métrique/fragment et
changements pendant 120 jours, et les horodatages à cinq observations pendant l'historisation uniquement lorsque le document change. Les contrôles détaillés
120 jours. Ces valeurs suivent les paramètres documentés des API d’audit WAPT. 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 Exemple anonymisé (forme abrégée; le paquet publie tous les contrôles, sans
10/100/500 lignes d’écart, le CSV brut local faisait 3 562/35 062/175 462 tronquer le JSON) :
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 ```json
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 "schema_version": 2,
résumé et les détails restent séparés; les valeurs de contrôle ne sont pas "status": "WARNING",
copiées dans Reporting par défaut. "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 ## Configuration
+2 -2
View File
@@ -1,10 +1,10 @@
package : comi-audit-hardeningkitty package : comi-audit-hardeningkitty
version : 0.9.10-0 version : 0.9.11-0
architecture : all architecture : all
section : base section : base
priority : optional priority : optional
maintainer : Comitari maintainer : Comitari
description : Read-only Windows security baseline audit via HardeningKitty description : Read-only Windows security baseline audit via HardeningKitty
min_wapt_version : 2.4 min_wapt_version : 2.6
target_os : windows target_os : windows
audit_schedule : 1w audit_schedule : 1w
@@ -0,0 +1,236 @@
<!-- Comitari / HardeningKitty - WAPT Console Mustache audit view.
Section=hardeningkitty; key=hardeningkitty.
Keep this file on WAPT CONSOLE machines, not on audited endpoints.
All dynamic values use HTML-escaped Mustache tags. No JavaScript. -->
<style>
.hk-report {
box-sizing: border-box; background: #fff; color: #202b3b;
font-family: "Segoe UI", Arial, sans-serif; font-size: 13px;
line-height: 1.45; padding: 20px; max-width: 1500px; margin: 0 auto;
}
.hk-report *, .hk-report *:before, .hk-report *:after { box-sizing: border-box; }
.hk-report .hk-header { display:flex; align-items:center; justify-content:space-between; gap:14px; padding-bottom:18px; border-bottom:1px solid #e3e8ee; }
.hk-report .hk-brand { font-size:11px; letter-spacing:2px; font-weight:800; color:#4c6b8b; text-transform:uppercase; }
.hk-report h1 { font-size:23px; color:#132a45; margin:3px 0 2px; line-height:1.2; }
.hk-report h2 { font-size:16px; color:#132a45; margin:24px 0 11px; }
.hk-report p { margin:4px 0 8px; }
.hk-report .hk-muted { color:#65758a; font-size:12px; }
.hk-report .hk-overall { display:inline-block; padding:6px 13px; border-radius:5px; font-size:12px; font-weight:800; letter-spacing:.3px; background:#edf2f7; color:#31465d; }
.hk-report .hk-overall-ERROR { background:#fde8e7; color:#a52a23; }
.hk-report .hk-overall-WARNING { background:#fff1db; color:#8d5400; }
.hk-report .hk-overall-OK { background:#dff5e9; color:#096b3f; }
.hk-report .hk-meta { margin:15px 0 18px; display:flex; flex-wrap:wrap; gap:8px 24px; color:#5d7188; font-size:12px; }
.hk-report .hk-meta strong { color:#243c58; }
.hk-report .hk-score-layout { display:flex; gap:15px; align-items:stretch; flex-wrap:wrap; }
.hk-report .hk-score { background:#f4f7fb; border:1px solid #e5ebf1; border-radius:9px; flex:1 1 270px; padding:17px 18px; min-width:220px; }
.hk-report .hk-score-label { font-size:12px; color:#586a7c; margin:0; }
.hk-report .hk-score-number { font-size:38px; line-height:1.15; font-weight:750; color:#17395e; margin:4px 0 11px; }
.hk-report .hk-score-number span { font-size:19px; font-weight:500; color:#728197; }
.hk-report .hk-track { height:9px; background:#d8e3ee; border-radius:20px; overflow:hidden; }
.hk-report .hk-fill { height:100%; background:#278e65; max-width:100%; }
.hk-report .hk-score-foot { display:flex; justify-content:space-between; gap:12px; margin-top:9px; color:#66798b; font-size:11px; }
.hk-report .hk-kpis { flex:3 1 460px; display:grid; grid-template-columns:repeat(3,minmax(110px,1fr)); gap:10px; }
.hk-report .hk-kpi { border:1px solid #e5ebf1; border-radius:9px; background:#fff; padding:12px 14px; }
.hk-report .hk-kpi strong { display:block; font-size:25px; line-height:1.15; color:#203752; }
.hk-report .hk-kpi span { display:block; margin-top:3px; font-size:11px; color:#687b90; }
.hk-report .hk-kpi.hk-high strong { color:#c23b34; }
.hk-report .hk-kpi.hk-medium strong { color:#b77910; }
.hk-report .hk-kpi.hk-low strong { color:#9d8a22; }
.hk-report .hk-kpi.hk-passed strong { color:#178256; }
.hk-report .hk-banner { padding:12px 15px; border:1px solid #f3d9a2; background:#fff9eb; color:#74531f; border-radius:7px; margin:16px 0; }
.hk-report .hk-banner-error { border-color:#f0bebc; background:#fff1f0; color:#882b26; }
.hk-report .hk-section { border:1px solid #e3e9f0; border-radius:8px; overflow:hidden; margin:14px 0; }
.hk-report .hk-section > summary { cursor:pointer; padding:12px 15px; background:#f7f9fc; color:#243e58; font-size:14px; font-weight:700; }
.hk-report .hk-section > summary:hover { background:#eff5fc; }
.hk-report .hk-section-content { padding:4px 13px 14px; }
.hk-report .hk-scroll { overflow-x:auto; max-width:100%; }
.hk-report .hk-table { width:100%; border-collapse:collapse; border-spacing:0; min-width:690px; text-align:left; }
.hk-report .hk-table th { padding:9px 9px; color:#62758b; background:#f5f8fb; font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.35px; border-bottom:1px solid #dfe7ef; }
.hk-report .hk-table td { padding:10px 9px; vertical-align:top; border-bottom:1px solid #edf0f4; overflow-wrap:anywhere; }
.hk-report .hk-table tbody tr:hover { background:#f7fafe; }
.hk-report .hk-table tbody tr:last-child td { border-bottom:0; }
.hk-report .hk-table .hk-id { white-space:nowrap; font-family:Consolas,monospace; color:#54708a; }
.hk-report .hk-table .hk-control-title { font-weight:600; color:#223b54; }
.hk-report .hk-badge { display:inline-block; white-space:nowrap; border-radius:5px; padding:2px 7px; font-size:11px; font-weight:700; background:#edf2f7; color:#54677c; }
.hk-report .hk-sev-high { background:#fce6e6; color:#a52b28; }
.hk-report .hk-sev-medium { background:#fff0d4; color:#9d5e03; }
.hk-report .hk-sev-low { background:#fff9db; color:#947d17; }
.hk-report .hk-sev-unknown { background:#ebeff4; color:#58677c; }
.hk-report .hk-state-passed { color:#118159; font-weight:650; }
.hk-report .hk-state-failed { color:#b04335; font-weight:650; }
.hk-report .hk-state-skipped, .hk-report .hk-state-unknown, .hk-report .hk-state-ignored { color:#8c5810; font-weight:650; }
.hk-report .hk-value { font-family:Consolas,"Courier New",monospace; font-size:11px; color:#344c66; word-break:break-word; }
.hk-report .hk-small { color:#64748b; font-size:11px; }
.hk-report .hk-detail { margin:5px 0 0; }
.hk-report .hk-detail summary { cursor:pointer; display:inline; color:#245b94; font-weight:600; }
.hk-report .hk-detail-content { margin:8px 0 4px; padding:8px 10px; background:#f6f9fc; border-left:3px solid #b7ccde; color:#465c74; font-size:11px; }
.hk-report .hk-detail-content p { margin:4px 0; }
.hk-report .hk-filters { padding:12px 0 8px; }
.hk-report .hk-filters input[type=radio] { position:absolute; opacity:0; height:1px; width:1px; }
.hk-report .hk-filters label { display:inline-block; margin:0 5px 8px 0; padding:5px 12px; border-radius:6px; border:1px solid #dbe4ed; cursor:pointer; color:#556e85; font-weight:600; font-size:12px; }
.hk-report .hk-filters label:hover { background:#f1f5f9; }
.hk-report #hk-filter-all:checked + label,
.hk-report #hk-filter-issues:checked + label,
.hk-report #hk-filter-high:checked + label,
.hk-report #hk-filter-passed:checked + label { background:#275d93; color:white; border-color:#275d93; }
.hk-report #hk-filter-issues:checked ~ .hk-scroll .hk-row-passed,
.hk-report #hk-filter-issues:checked ~ .hk-scroll .hk-row-excluded,
.hk-report #hk-filter-issues:checked ~ .hk-scroll .hk-row-not_applicable { display:none; }
.hk-report #hk-filter-high:checked ~ .hk-scroll .hk-row:not(.hk-row-failed),
.hk-report #hk-filter-high:checked ~ .hk-scroll .hk-row:not(.hk-risk-high) { display:none; }
.hk-report #hk-filter-passed:checked ~ .hk-scroll .hk-row:not(.hk-row-passed) { display:none; }
.hk-report .hk-note { border-left:3px solid #b9c9db; padding:8px 10px; margin:14px 0; font-size:12px; color:#5b6f84; background:#f6f9fc; }
.hk-report .hk-footer { padding-top:11px; margin-top:22px; border-top:1px solid #e6eaf0; color:#73859a; font-size:11px; }
.hk-report .hk-no-data { margin:18px 0; padding:15px; border:1px solid #e8dfba; border-radius:6px; background:#fffaf0; }
@media (max-width:750px) {
.hk-report { padding:10px; }
.hk-report .hk-kpis { grid-template-columns:repeat(2,minmax(110px,1fr)); }
.hk-report h1 { font-size:18px; }
}
</style>
<div class="hk-report">
<div class="hk-header">
<div>
<div class="hk-brand">COMITARI / HARDENINGKITTY</div>
<h1>Audit de durcissement Windows</h1>
<div class="hk-muted">Analyse des &eacute;carts par rapport au r&eacute;f&eacute;rentiel de s&eacute;curit&eacute;</div>
</div>
<span class="hk-overall hk-overall-{{status}}">{{status}}</span>
</div>
{{#error}}
<div class="hk-banner hk-banner-error">
<strong>Audit incomplet ou indisponible.</strong>
<p>Code : {{error.code}} — type : {{error.type}}</p>
<p>{{error.description}}</p>
<p>V&eacute;rifier les journaux de l'agent WAPT sur le poste concern&eacute;.</p>
</div>
{{/error}}
{{#summary}}
<div class="hk-meta">
<span><strong>Machine :</strong> {{host.os_caption}} {{host.display_version}} (build {{host.build}})</span>
<span><strong>R&eacute;f&eacute;rentiel :</strong> {{benchmark.name}}</span>
<span><strong>Source :</strong> {{scan.source}}</span>
<span><strong>Audit UTC :</strong> {{scan.date_utc}}</span>
</div>
<div class="hk-score-layout">
<div class="hk-score">
<p class="hk-score-label">Taux de conformit&eacute;</p>
<div class="hk-score-number">{{compliant_percent}} <span>%</span></div>
<div class="hk-track"><div class="hk-fill" style="width:{{compliant_percent}}%;"></div></div>
<div class="hk-score-foot"><span>{{passed}} conformes</span><span>{{expected}} attendus</span></div>
</div>
<div class="hk-kpis">
<div class="hk-kpi hk-high"><strong>{{high}}</strong><span>&Eacute;carts &eacute;lev&eacute;s</span></div>
<div class="hk-kpi hk-medium"><strong>{{medium}}</strong><span>&Eacute;carts moyens</span></div>
<div class="hk-kpi hk-low"><strong>{{low}}</strong><span>&Eacute;carts faibles</span></div>
<div class="hk-kpi hk-passed"><strong>{{passed}}</strong><span>Conformes</span></div>
<div class="hk-kpi"><strong>{{unknown}}</strong><span>Inconnus</span></div>
<div class="hk-kpi"><strong>{{skipped}}</strong><span>Non ex&eacute;cut&eacute;s</span></div>
</div>
</div>
{{/summary}}
{{#summary}}
{{#privacy.current_values_published}}
<p class="hk-note">Valeurs constat&eacute;es : publication activ&eacute;e. V&eacute;rifier les droits d'acc&egrave;s aux donn&eacute;es d'audit.</p>
{{/privacy.current_values_published}}
{{^privacy.current_values_published}}
<p class="hk-note">Les valeurs effectivement lues sur le poste ne sont pas publi&eacute;es par d&eacute;faut. Le CSV d&eacute;taill&eacute; reste disponible localement sur le poste audit&eacute;.</p>
{{/privacy.current_values_published}}
<h2>&Eacute;carts &agrave; examiner en priorit&eacute;</h2>
<p class="hk-muted">{{priority_total}} r&eacute;sultats non conformes ou non &eacute;valu&eacute;s. Aper&ccedil;u limit&eacute; &agrave; 25 contr&ocirc;les, class&eacute;s par gravit&eacute; puis cat&eacute;gorie (pas par risque exploitabilit&eacute;).</p>
<div class="hk-section">
<div class="hk-scroll">
<table class="hk-table">
<thead><tr><th>ID</th><th>Cat&eacute;gorie</th><th>Gravit&eacute;</th><th>&Eacute;tat</th></tr></thead>
<tbody>
{{#priority_findings}}
<tr>
<td class="hk-id">{{id}}</td>
<td>{{category}}</td>
<td><span class="hk-badge hk-sev-{{severity}}">{{severity}}</span></td>
<td><span class="hk-state-{{status}}">{{status}}</span></td>
</tr>
{{/priority_findings}}
{{^priority_findings}}
<tr><td colspan="4" class="hk-muted">Aucun &eacute;cart prioritaire.</td></tr>
{{/priority_findings}}
</tbody>
</table>
</div>
</div>
<details class="hk-section" open>
<summary>R&eacute;partition par cat&eacute;gorie</summary>
<div class="hk-section-content hk-scroll">
<table class="hk-table">
<thead><tr><th>Cat&eacute;gorie</th><th>Contr&ocirc;les</th><th>Conformes</th><th>Haut</th><th>Moyen</th><th>Faible</th><th>Non &eacute;valu&eacute;s</th><th>Conformit&eacute;</th></tr></thead>
<tbody>
{{#Items categories}}
<tr>
<td class="hk-control-title">{{key}}</td>
<td>{{value.expected}}</td>
<td>{{value.passed}}</td>
<td>{{value.high}}</td>
<td>{{value.medium}}</td>
<td>{{value.low}}</td>
<td>{{value.unknown}}</td>
<td><strong>{{value.compliant_percent}} %</strong></td>
</tr>
{{/Items}}
</tbody>
</table>
</div>
</details>
<details class="hk-section">
<summary>Tous les contr&ocirc;les du benchmark ({{summary.expected}})</summary>
<div class="hk-section-content">
<p class="hk-muted">S&eacute;lectionner un filtre. Les d&eacute;tails de chaque contr&ocirc;le contiennent la justification et la m&eacute;thode de v&eacute;rification.</p>
<div class="hk-filters">
<input type="radio" id="hk-filter-all" name="hk-filter" checked><label for="hk-filter-all">Tous</label>
<input type="radio" id="hk-filter-issues" name="hk-filter"><label for="hk-filter-issues">&Eacute;carts / Inconnus</label>
<input type="radio" id="hk-filter-high" name="hk-filter"><label for="hk-filter-high">&Eacute;lev&eacute;s uniquement</label>
<input type="radio" id="hk-filter-passed" name="hk-filter"><label for="hk-filter-passed">Conformes</label>
<div class="hk-scroll">
<table class="hk-table">
<thead><tr><th>ID</th><th>Contr&ocirc;le et analyse</th><th>Cat&eacute;gorie</th><th>Gravit&eacute;</th><th>&Eacute;tat</th><th>Valeur cible</th>{{#privacy.current_values_published}}<th>Valeur observ&eacute;e</th>{{/privacy.current_values_published}}</tr></thead>
<tbody>
{{#Items controls}}
<tr class="hk-row hk-row-{{value.status}} hk-risk-{{value.severity}}">
<td class="hk-id">{{key}}</td>
<td>
<div class="hk-control-title">{{value.name}}</div>
<details class="hk-detail">
<summary>Voir l'analyse</summary>
<div class="hk-detail-content">
<p>{{value.explanation}}</p>
{{#value.reason}}<p><strong>Justification :</strong> {{value.reason}}</p>{{/value.reason}}
{{#value.method}}<p><strong>M&eacute;thode :</strong> {{value.method}}</p>{{/value.method}}
{{#value.method_argument}}<p><strong>Argument :</strong> <span class="hk-value">{{value.method_argument}}</span></p>{{/value.method_argument}}
{{#value.registry_path}}<p><strong>Registre :</strong> <span class="hk-value">{{value.registry_path}}</span></p>{{/value.registry_path}}
{{#value.registry_item}}<p><strong>Valeur registre :</strong> <span class="hk-value">{{value.registry_item}}</span></p>{{/value.registry_item}}
{{#value.operator}}<p><strong>Comparaison :</strong> {{value.operator}}</p>{{/value.operator}}
</div>
</details>
</td>
<td>{{value.category}}</td>
<td><span class="hk-badge hk-sev-{{value.severity}}">{{value.severity_label}}{{^value.severity_label}}{{value.severity}}{{/value.severity_label}}</span></td>
<td><span class="hk-state-{{value.status}}">{{value.status_label}}{{^value.status_label}}{{value.status}}{{/value.status_label}}</span></td>
<td class="hk-value">{{value.recommended}}</td>
{{#privacy.current_values_published}}<td class="hk-value">{{value.current}}</td>{{/privacy.current_values_published}}
</tr>
{{/Items}}
</tbody>
</table>
</div>
</div>
</div>
</details>
{{/summary}}
<div class="hk-footer">HardeningKitty / Comitari &middot; Synth&egrave;se informative uniquement, aucune correction automatique &middot; R&eacute;f&eacute;rentiel : {{benchmark.name}}</div>
</div>
+2 -2
View File
@@ -1,9 +1,9 @@
{ {
"files": { "files": {
"WAPT/control": "a7b8ffea3e6df419b88f6118c15b977ccf8b15a91c41d4d757e68e29265a2e0c", "WAPT/control": "4c3eef3800f9994b90cefb06ec35e019ba0c8fb8ffda8a216c7b6ca9eba457d4",
"audit_config.json": "9fc18f556fd491e8e00e3a8b1efd864ad8036682e293a02c1f81f3411304a69b", "audit_config.json": "9fc18f556fd491e8e00e3a8b1efd864ad8036682e293a02c1f81f3411304a69b",
"runner.ps1": "5e9787f5cc19d0fc4c0ede01a63188ed71de6ef49df596fbce7b2603093231cd", "runner.ps1": "5e9787f5cc19d0fc4c0ede01a63188ed71de6ef49df596fbce7b2603093231cd",
"setup.py": "e67fcfcffd95f0452ed686394ed3ee6af9a2ab95bb8291e9bd350d5fc013db10", "setup.py": "0f4be0d4f2c42ace380dd6305a210c5a7d595508dbc566f652d86f3c7d874d43",
"vendor/HardeningKitty.psd1": "94378764787cd0c7125c5c3ce928803b46ee7d7d3fe21c2b27ddeb23c11377a7", "vendor/HardeningKitty.psd1": "94378764787cd0c7125c5c3ce928803b46ee7d7d3fe21c2b27ddeb23c11377a7",
"vendor/HardeningKitty.psm1": "b799ec1b17de5ce63f7a8ea34af3beba1d24fcaddf7e3774f797096f5c43e2a0", "vendor/HardeningKitty.psm1": "b799ec1b17de5ce63f7a8ea34af3beba1d24fcaddf7e3774f797096f5c43e2a0",
"vendor/LICENSE": "a879ba6ccdcd2ea55da97b999edd2c401c8589ca946f238236bf93aa40e74d56", "vendor/LICENSE": "a879ba6ccdcd2ea55da97b999edd2c401c8589ca946f238236bf93aa40e74d56",
+245 -51
View File
@@ -12,7 +12,6 @@ from setuphelpers import *
import collections import collections
import csv import csv
import datetime import datetime
import hashlib # Stable, reproducible distribution of finding IDs to WAPT audit keys.
import io import io
import json import json
import locale import locale
@@ -22,7 +21,9 @@ import uuid
uninstallkey = [] uninstallkey = []
PACKAGE_NAME = 'comi-audit-hardeningkitty' PACKAGE_NAME = 'comi-audit-hardeningkitty'
AUDIT_SECTION = 'HardeningKitty' AUDIT_SECTION = 'hardeningkitty'
AUDIT_KEY = 'hardeningkitty'
MAX_AUDIT_JSON_BYTES = 2 * 1024 * 1024
UPSTREAM_COMMIT = 'acf9ecee3d044a9337df7aad80e730cbd746e8de' UPSTREAM_COMMIT = 'acf9ecee3d044a9337df7aad80e730cbd746e8de'
PROGRAM_DATA = os.environ.get('ProgramData', r'C:\ProgramData') PROGRAM_DATA = os.environ.get('ProgramData', r'C:\ProgramData')
INSTALL_ROOT = makepath(PROGRAM_DATA, 'Comitari', 'Audit-HardeningKitty') INSTALL_ROOT = makepath(PROGRAM_DATA, 'Comitari', 'Audit-HardeningKitty')
@@ -389,51 +390,251 @@ def _status_from_counts(counts, config):
return 'OK' return 'OK'
def _shard_findings(findings, bucket_count=16): def _build_audit_document(summary, findings, report_file, baseline_file, config):
buckets = [[] for i in range(bucket_count)] """One native JSON object, with all benchmark controls addressable by ID.
for item in findings:
# Stable shards, including empty ones: no obsolete audit keys on a later run. The detailed CSV stays local. Observed values are excluded by default
key = ('%s:%s' % (item['id'], item['name'])).encode('utf-8') because some settings can reveal accounts or environment-specific details.
bucket = int(hashlib.sha256(key).hexdigest()[:8], 16) % bucket_count """
buckets[bucket].append(item) _, baseline_rows = _read_csv_rows(baseline_file)
return buckets _, 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): if not config.get('publish_to_wapt', True):
return return
wapt = globals().get('WAPT') wapt = globals().get('WAPT')
write = getattr(wapt, 'write_audit_data_if_changed', None) write = getattr(wapt, 'write_audit_data_if_changed', None)
if not callable(write): 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)): def _publish_failure(exc):
try: """Replace a previous successful snapshot with an explicit error snapshot."""
payload = json.dumps(group, ensure_ascii=False, separators=(',', ':')) wapt = globals().get('WAPT')
write(AUDIT_SECTION, 'findings_%02d' % index, payload, max_count=3, keep_days=120) write = getattr(wapt, 'write_audit_data_if_changed', None)
except Exception as exc: if not callable(write):
raise RuntimeError('WAPT finding publication failed for shard %02d.' % index) from exc return
document = {
# Unlike the stable metrics above, store one timestamped observation each run. 'schema_version': 2,
write_current = getattr(wapt, 'write_audit_data', None) 'status': 'ERROR',
if not callable(write_current): 'compliance': 'incomplete',
raise RuntimeError('WAPT.write_audit_data is unavailable; audit timestamp was not published.') 'scan': {
try: 'date_utc': datetime.datetime.now(datetime.timezone.utc).isoformat(),
write_current(AUDIT_SECTION, 'last_run_utc', summary['last_run_utc'], 'engine': 'HardeningKitty',
max_count=5, keep_days=120) },
except Exception as exc: 'error': {
raise RuntimeError('WAPT audit timestamp publication failed.') from exc '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): def _trim_reports(keep):
@@ -530,7 +731,9 @@ def _audit_impl():
'report_log': log_file, 'report_log': log_file,
}) })
_save_json(makepath(run_dir, 'summary.json'), summary) _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%%' % 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'], (status, benchmark, counts['total'], counts['passed'], counts['high'],
counts['medium'], counts['low'], counts['unknown'], counts['skipped'], counts['compliant_percent'])) counts['medium'], counts['low'], counts['unknown'], counts['skipped'], counts['compliant_percent']))
@@ -549,19 +752,10 @@ def audit():
except Exception as exc: except Exception as exc:
# Log enough detail locally, but publish only a sanitized technical error. # Log enough detail locally, but publish only a sanitized technical error.
print('HardeningKitty audit ERROR: %s' % exc) print('HardeningKitty audit ERROR: %s' % exc)
wapt = globals().get('WAPT')
label = 'Collection failed: %s' % type(exc).__name__
if wapt is not None:
try: try:
wapt.write_audit_data_if_changed(AUDIT_SECTION, 'status', 'ERROR', _publish_failure(exc)
max_count=5, keep_days=120) except Exception as publish_error:
wapt.write_audit_data_if_changed(AUDIT_SECTION, 'last_error', label, print('HardeningKitty: failed to publish ERROR JSON (%s).' % type(publish_error).__name__)
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: try:
config = _load_json(makepath(INSTALL_ROOT, 'audit_config.json')) config = _load_json(makepath(INSTALL_ROOT, 'audit_config.json'))
_trim_reports(config.get('retain_audit_runs', 8)) _trim_reports(config.get('retain_audit_runs', 8))
+77 -30
View File
@@ -130,7 +130,7 @@ class PackageTests(unittest.TestCase):
self.assertEqual(counts['unknown'], 1) self.assertEqual(counts['unknown'], 1)
self.assertEqual(pkg._status_from_counts(counts, {'error_on_high': True, 'warn_on_low': True}), 'ERROR') self.assertEqual(pkg._status_from_counts(counts, {'error_on_high': True, 'warn_on_low': True}), 'ERROR')
self.assertEqual(len(findings), 2) 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): def test_only_reviewed_conditional_exclusions_leave_coverage_denominator(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
@@ -194,23 +194,36 @@ class PackageTests(unittest.TestCase):
_, findings2 = pkg._read_report(str(report), True) _, findings2 = pkg._read_report(str(report), True)
self.assertEqual(findings2[0]['current'], 'private_value') 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: with tempfile.TemporaryDirectory() as tmp:
report = Path(tmp, 'report.csv') report = Path(tmp, 'report.csv')
baseline = Path(tmp, 'baseline.csv')
rows = [['%04d' % i, 'Synthetic category', 'Synthetic control %04d' % i, rows = [['%04d' % i, 'Synthetic category', 'Synthetic control %04d' % i,
'Low', 'current-setting-' * 8, 'recommended-setting-' * 8, 'Failed'] 'Low', 'current-setting-' * 8, 'recommended-setting-' * 8, 'Failed']
for i in range(500)] for i in range(500)]
report.write_text(csv_content(rows), encoding='utf-8') 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 raw_bytes = report.stat().st_size
_, compact = pkg._read_report(str(report), publish_current_values=False) counts, findings = pkg._read_report(str(report), publish_current_values=False)
_, expanded = pkg._read_report(str(report), publish_current_values=True) counts, findings = pkg._check_coverage(str(baseline), str(report), counts, findings)
compact_bytes = sum(len(json.dumps(bucket, ensure_ascii=False, separators=(',', ':')).encode('utf-8')) summary = dict(counts, status='WARNING', source='GPO', upstream_commit=pkg.UPSTREAM_COMMIT,
for bucket in pkg._shard_findings(compact)) last_run_utc='2026-10-29T00:00:00+00:00', benchmark='synthetic.csv',
expanded_bytes = sum(len(json.dumps(bucket, ensure_ascii=False, separators=(',', ':')).encode('utf-8')) os_caption='Synthetic Windows', os_build='1', display_version='synthetic')
for bucket in pkg._shard_findings(expanded)) compact = pkg._build_audit_document(summary, findings, str(report), str(baseline), {})
self.assertEqual(len(compact), 500) expanded = pkg._build_audit_document(summary, findings, str(report), str(baseline),
self.assertLess(compact_bytes, raw_bytes) {'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.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): def test_ignored_not_applicable_and_unknown_are_not_compliant(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
@@ -305,38 +318,68 @@ class PackageTests(unittest.TestCase):
finally: finally:
pkg._package_source, pkg.INSTALL_ROOT, pkg.REPORT_ROOT = old pkg._package_source, pkg.INSTALL_ROOT, pkg.REPORT_ROOT = old
def test_published_shards_and_keys(self): def test_structured_audit_publishes_one_native_json_key(self):
events = {} events = []
class FakeWapt: class FakeWapt:
def write_audit_data_if_changed(self, section, key, value, **kwargs): 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): def write_audit_data(self, section, key, value, **kwargs):
events[key] = value events.append((section, key, value, kwargs))
old = getattr(pkg, 'WAPT', None) old = getattr(pkg, 'WAPT', None)
try: try:
pkg.WAPT = FakeWapt() pkg.WAPT = FakeWapt()
summary = {k: '1' for k in ('status', 'benchmark', 'os_build', 'source', document = {'schema_version': 2, 'status': 'WARNING', 'controls': {'42': {'status': 'failed'}}}
'upstream_commit', 'passed', 'high', 'medium', 'low', 'unknown', pkg._publish_audit(document, {'publish_to_wapt': True})
'ignored', 'not_applicable', 'excluded', 'os_caption', 'display_version', self.assertEqual(len(events), 1)
'total', 'expected', 'executed', 'skipped', 'compliant_percent', 'last_run_utc', 'last_error')} section, key, value, kwargs = events[0]
pkg._publish_audit(summary, [dict(id='42', name='Enabled', category='Firewall', self.assertEqual((section, key), ('hardeningkitty', 'hardeningkitty'))
severity='high', result='Failed')], {'publish_to_wapt': True}) self.assertIs(value, document)
self.assertEqual(len([k for k in events if k.startswith('findings_')]), 16) self.assertEqual(kwargs['max_count'], 2)
self.assertEqual(sum(len(json.loads(v)) for k, v in events.items() if k.startswith('findings_')), 1)
self.assertIn('status', events)
finally: finally:
if old is None: if old is None:
del pkg.WAPT del pkg.WAPT
else: else:
pkg.WAPT = old pkg.WAPT = old
def test_failed_audit_updates_wapt_status(self): def test_console_template_matches_schema_and_uses_escaped_tags(self):
events = {} 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('<script', template.lower())
def test_structured_error_does_not_publish_exception_details(self):
events = []
class FakeWapt: class FakeWapt:
def write_audit_data_if_changed(self, section, key, value, **kwargs): def write_audit_data_if_changed(self, section, key, value, **kwargs):
events[key] = value events.append((section, key, value))
old_wapt = getattr(pkg, 'WAPT', None)
try:
pkg.WAPT = FakeWapt()
pkg._publish_failure(RuntimeError('private path or setting value'))
self.assertEqual(len(events), 1)
section, key, payload = events[0]
self.assertEqual((section, key), ('hardeningkitty', 'hardeningkitty'))
self.assertEqual(payload['error']['code'], 'AUDIT_FAILED')
self.assertNotIn('private path', json.dumps(payload))
self.assertNotIn('setting value', json.dumps(payload))
finally:
if old_wapt is None:
del pkg.WAPT
else:
pkg.WAPT = old_wapt
def test_failed_audit_updates_wapt_status(self):
events = []
class FakeWapt:
def write_audit_data_if_changed(self, section, key, value, **kwargs):
events.append((section, key, value, kwargs))
def write_audit_data(self, section, key, value, **kwargs): def write_audit_data(self, section, key, value, **kwargs):
events[key] = value events.append((section, key, value, kwargs))
old_wapt = getattr(pkg, 'WAPT', None) old_wapt = getattr(pkg, 'WAPT', None)
old_audit = pkg._audit_impl old_audit = pkg._audit_impl
try: try:
@@ -345,9 +388,13 @@ class PackageTests(unittest.TestCase):
raise RuntimeError('Missing PowerShell') raise RuntimeError('Missing PowerShell')
pkg._audit_impl = fails pkg._audit_impl = fails
self.assertEqual(pkg.audit(), 'ERROR') self.assertEqual(pkg.audit(), 'ERROR')
self.assertEqual(events['status'], 'ERROR') self.assertEqual(len(events), 1)
self.assertEqual(events['last_error'], 'Collection failed: RuntimeError') section, key, payload, _ = events[0]
self.assertIn('last_run_utc', events) self.assertEqual((section, key), ('hardeningkitty', 'hardeningkitty'))
self.assertEqual(payload['status'], 'ERROR')
self.assertEqual(payload['error']['code'], 'AUDIT_FAILED')
self.assertIn('no current compliance assessment', payload['error']['description'])
self.assertNotIn('findings_00', [event[1] for event in events])
finally: finally:
pkg._audit_impl = old_audit pkg._audit_impl = old_audit
if old_wapt is None: if old_wapt is None:
+391
View File
@@ -0,0 +1,391 @@
# -*- coding: utf-8 -*-
"""Offline smoke tests: no WAPT installation or network required."""
import csv
import hashlib
import importlib.util
import json
import os
from pathlib import Path
import shutil
import sys
import tempfile
import types
import unittest
from unittest.mock import patch
import zipfile
WORK = Path(__file__).resolve().parents[1]
PIN = 'acf9ecee3d044a9337df7aad80e730cbd746e8de'
def makepath(*pieces):
return os.path.join(*(str(p) for p in pieces))
class Control:
def __init__(self, source):
self.source = source
self.version = '0.9.5-4'
self.package = 'comi-audit-hardeningkitty'
def get_software_version(self):
return self.version.split('-')[0]
def inc_build(self):
a, b = self.version.rsplit('-', 1)
self.version = '%s-%d' % (a, int(b)+1)
def save_control_to_wapt(self):
(self.source / 'WAPT' / 'control').write_text('package: %s\nversion: %s\n' % (self.package, self.version))
class StubSetuphelpers(types.ModuleType):
def __init__(self):
super().__init__('setuphelpers')
self.wget_snapshot = None
self.simulate_audit = False
self.audits = []
self.__all__ = ['makepath', 'isfile', 'isdir', 'mkdirs', 'ensure_dir', 'copytree2', 'filecopyto',
'remove_tree', 'remove_file', 'get_sha256', 'json_load_file', 'json_write_file',
'wget', 'unzip', 'run', 'system32']
for name in self.__all__:
setattr(self, name, getattr(self, name) if hasattr(self, name) else globals()[name])
def isfile(self, p): return os.path.isfile(p)
def isdir(self, p): return os.path.isdir(p)
def mkdirs(self, p): os.makedirs(p, exist_ok=True)
def ensure_dir(self, p): os.makedirs(os.path.dirname(p), exist_ok=True)
def get_sha256(self, p): return hashlib.sha256(Path(p).read_bytes()).hexdigest()
def filecopyto(self, source, target):
if os.path.isdir(target): target = os.path.join(target, os.path.basename(source))
os.makedirs(os.path.dirname(target), exist_ok=True)
shutil.copy2(source,target)
def copytree2(self, source, target, **kw): shutil.copytree(source,target,dirs_exist_ok=True)
def remove_tree(self, p, **kw): shutil.rmtree(p, ignore_errors=kw.get('ignore_errors',False))
def remove_file(self, p): os.unlink(p)
def json_load_file(self, p): return json.loads(Path(p).read_text(encoding='utf-8'))
def json_write_file(self, p, obj, **kw): Path(p).write_text(json.dumps(obj,indent=2, sort_keys=True),encoding='utf-8')
def wget(self, url, target, **kw):
assert url.startswith('https://codeload.github.com/') and kw['verify_cert'] is True
shutil.copy2(self.wget_snapshot,target)
def unzip(self, p, target):
with zipfile.ZipFile(p) as z: z.extractall(target)
def system32(self): return '/fake/system32'
def run(self, cmd, **kw):
if cmd[0] == 'icacls.exe': return ''
if not self.simulate_audit: return ''
def arg(name): return cmd[cmd.index(name)+1]
csv_path = arg('-CsvFile')
metadata_path = arg('-MetadataFile')
rows=[
{'ID':'101','Category':'System','Name':'Setting A','Severity':'Passed','Result':'1',
'Recommended':'1','TestResult':'Passed','SeverityFinding':'High'},
{'ID':'102','Category':'System','Name':'Setting B','Severity':'Medium','Result':'0',
'Recommended':'1','TestResult':'Failed','SeverityFinding':'Medium'},
]
with open(csv_path,'w',newline='',encoding='utf-8') as f:
writer=csv.DictWriter(f,fieldnames=list(rows[0]))
writer.writeheader(); writer.writerows(rows)
Path(metadata_path).write_text(json.dumps({
'benchmark':'finding_list_msft_security_baseline_windows_11_25h2_machine.csv',
'build':'26100','os_caption':'Windows 11','display_version':'25H2','source':'GPO',
}))
if kw.get('on_write'):kw['on_write']('Audit completed')
return 'Audit completed'
def load_python(name, path, stub, basedir, control=None):
sys.modules['setuphelpers'] = stub
spec = importlib.util.spec_from_file_location(name,str(path))
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
mod.basedir = str(basedir)
if control is not None: mod.control = control
return mod
class AuditWaptTest(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.source = self.root/'source'
(self.source/'WAPT').mkdir(parents=True)
(self.source/'WAPT'/'control').write_text('package: comi-audit-hardeningkitty\nversion: 0.9.5-4\n')
(self.source/'runner.ps1').write_text('# audit only\n')
(self.source/'audit_config.json').write_text(json.dumps({
'profile':'auto','source':'GPO','error_on_high':True,'warn_on_low':True,
'publish_to_wapt':True,'retain_audit_runs':3,'timeout_seconds':120,
'max_findings_in_log':10,
}))
self.source.joinpath('package-manifest.json').write_text('{"stale":true}')
(self.source/'setup.py').write_text('# synthetic setup\n')
(self.source/'tools').mkdir()
shutil.copy2(WORK/'tools'/'vendor_assets.py', self.source/'tools'/'vendor_assets.py')
shutil.copy2(WORK/'tools'/'package_verify.py', self.source/'tools'/'package_verify.py')
self.ctrl = Control(self.source)
self.stub = StubSetuphelpers()
self.upd = load_python('wapt_update',WORK/'update_package.py',self.stub,self.source,self.ctrl)
self.setup = load_python('wapt_setup',WORK/'setup.py',self.stub,self.source)
self.setup.INSTALL_ROOT = str(self.root/'installed')
self.setup.REPORT_ROOT = str(self.root/'reports')
snapshot = self.root/'snapshot.zip'
with zipfile.ZipFile(snapshot,'w') as z:
items = set(self.upd.REQUIRED_FILES)
for i in range(55): items.add('lists/example_%03d.csv'%i)
for name in items:
if name == 'HardeningKitty.psd1':
data = "@{\nModuleVersion = '0.9.5'\n}\n"
elif name == 'lists/finding_list_msft_security_baseline_windows_11_25h2_machine.csv':
data = 'ID,Category,Name\n101,System,Setting A\n102,System,Setting B\n'
else: data = 'id,name\n42,dummy\n'
z.writestr('windows_hardening-'+PIN+'/'+name,data)
self.stub.wget_snapshot = snapshot
def prepare(self):
self.upd.update_package()
def test_update_creates_verified_vendor_and_uses_native_control(self):
self.prepare()
self.assertEqual(self.ctrl.version,'0.9.5-5')
self.assertTrue((self.source/'vendor'/'HardeningKitty.psm1').is_file())
self.assertTrue((self.source/'package-manifest.json').is_file())
manifest=json.loads((self.source/'vendor'/'vendor-manifest.json').read_text())
self.assertGreater(len(manifest['files']), 55)
self.assertEqual(manifest['upstream_commit'], PIN)
self.upd.update_package()
self.assertEqual(self.ctrl.version,'0.9.5-5')
def test_acl_hardening_does_not_strip_inheritance_recursively(self):
self.prepare()
self.stub.acl_calls = []
original = self.stub.run
def recorder(cmd, **kw):
if cmd[0] == 'icacls.exe':
self.stub.acl_calls.append(list(cmd))
return original(cmd, **kw)
self.setup.run = recorder
# Force the Windows-only helper branch in this isolated unit test.
with patch.object(os, 'name', 'nt'):
self.setup._secure_directory(str(self.root / 'target'))
self.assertEqual(len(self.stub.acl_calls), 2)
self.assertIn('/grant:r', self.stub.acl_calls[0])
self.assertIn('/inheritance:r', self.stub.acl_calls[1])
self.assertNotIn('/T', self.stub.acl_calls[0])
self.assertNotIn('/T', self.stub.acl_calls[1])
def test_install_verifies_integrity_after_final_acl_operation(self):
self.prepare()
seen = []
def simulate_acl(path):
seen.append(path)
if len(seen) == 2:
# Mimic a post-hardening permission/integrity failure.
Path(path, 'vendor-manifest.json').write_text('{}')
self.setup._secure_directory = simulate_acl
with self.assertRaisesRegex(RuntimeError, 'Unexpected HardeningKitty vendor revision'):
self.setup.install()
self.assertFalse(Path(self.setup.INSTALL_ROOT).exists())
def test_install_from_different_cwd(self):
self.prepare()
old=os.getcwd()
try:
os.chdir(self.root)
self.setup.install()
finally:os.chdir(old)
self.assertTrue((self.root/'installed'/'runner.ps1').is_file())
self.assertTrue((self.root/'installed'/'lists'/'finding_list_0x6d69636b_machine.csv').is_file())
self.assertFalse((self.root/'installed'/'package-manifest.json').exists())
def test_missing_assets_fail_before_deploy(self):
with self.assertRaisesRegex(RuntimeError,'vendor-manifest'):
self.setup.install()
self.assertFalse((self.root/'installed').exists())
def test_corrupt_vendor_blocks_install(self):
self.prepare()
(self.source/'vendor'/'HardeningKitty.psm1').write_text('corrupted')
with self.assertRaisesRegex(RuntimeError,'altered'):
self.setup.install()
self.assertFalse((self.root/'installed').exists())
def test_audit_warning_single_json(self):
self.prepare(); self.setup.install()
self.stub.simulate_audit=True
self.setup._powershell_executable = lambda: '/fake/powershell.exe'
class W:
def __init__(self): self.data=[]
def write_audit_data_if_changed(self,*args,**kwargs): self.data.append((args,kwargs))
def write_audit_data(self,*args,**kwargs): self.data.append((args,kwargs))
w = W()
self.setup.WAPT=w
self.assertEqual(self.setup.audit(),'WARNING')
self.assertEqual(len(w.data), 1)
args, kwargs = w.data[0]
self.assertEqual(args[:2], ('hardeningkitty', 'hardeningkitty'))
self.assertIsInstance(args[2], dict)
payload = args[2]
self.assertEqual(payload['status'], 'WARNING')
self.assertEqual(payload['summary']['passed'], 1)
self.assertEqual(payload['summary']['medium'], 1)
self.assertEqual(payload['summary']['expected'], 2)
self.assertEqual(payload['controls']['101']['status'], 'passed')
self.assertEqual(payload['controls']['102']['status'], 'failed')
self.assertEqual(payload['controls']['102']['recommended'], '1')
self.assertNotIn('current', payload['controls']['102'])
self.assertEqual(payload['categories']['System']['compliant_percent'], 50.0)
self.assertEqual(payload['schema_version'], 2)
self.assertEqual(payload['controls']['102']['status_label'], 'Non conforme')
self.assertIn('benchmark', payload['controls']['102']['explanation'])
self.assertEqual(payload['priority_total'], 1)
self.assertEqual(payload['priority_findings'][0]['id'], '102')
self.assertFalse(payload['privacy']['current_values_published'])
json.dumps(payload)
self.assertEqual(kwargs['max_count'], 2)
self.assertTrue(list((self.root/'reports').rglob('wapt-audit.json')))
def test_uninstall_does_not_delete_reports(self):
self.prepare(); self.setup.install()
(self.root/'reports'/'keep.txt').write_text('historical')
self.setup.uninstall()
self.assertFalse((self.root/'installed').exists())
self.assertTrue((self.root/'reports'/'keep.txt').exists())
def test_install_upgrade_replaces_owned_files(self):
self.prepare()
self.setup.install()
self.setup.install()
self.assertTrue((self.root/'installed'/'vendor-manifest.json').is_file())
self.assertTrue((self.root/'installed'/self.setup.OWNER_MARKER).is_file())
self.assertEqual(len(list(self.root.glob('installed.previous-*'))), 0)
def test_install_never_replaces_foreign_directory(self):
self.prepare()
foreign = self.root/'installed'
foreign.mkdir()
(foreign/'keep.txt').write_text('not mine')
with self.assertRaisesRegex(RuntimeError,'not owned'):
self.setup.install()
self.assertEqual((foreign/'keep.txt').read_text(),'not mine')
def test_update_rejects_zip_traversal_without_modifying_vendor(self):
self.prepare()
good = (self.source/'vendor'/'HardeningKitty.psm1').read_bytes()
malicious = self.root/'malicious.zip'
with zipfile.ZipFile(malicious,'w') as z:
z.writestr('windows_hardening-'+PIN+'/../escape.txt','payload')
with self.assertRaisesRegex(RuntimeError,'Unsafe ZIP path'):
self.upd.update_package(snapshot_zip=str(malicious),
snapshot_sha256=hashlib.sha256(malicious.read_bytes()).hexdigest())
self.assertEqual((self.source/'vendor'/'HardeningKitty.psm1').read_bytes(),good)
def test_audit_failure_sets_error_not_false_ok(self):
self.prepare()
self.setup.install()
(self.root/'installed'/'HardeningKitty.psm1').write_text('tampered')
class W:
def __init__(self): self.data=[]
def write_audit_data_if_changed(self,*args,**kwargs): self.data.append(args)
def write_audit_data(self,*args,**kwargs): self.data.append(args)
w = W()
self.setup.WAPT = w
self.assertEqual(self.setup.audit(),'ERROR')
self.assertEqual(len(w.data), 1)
section, key, payload = w.data[0]
self.assertEqual((section, key), ('hardeningkitty', 'hardeningkitty'))
self.assertEqual(payload['status'], 'ERROR')
self.assertEqual(payload['compliance'], 'incomplete')
self.assertNotIn('controls', payload)
def test_json_current_values_are_opt_in(self):
self.prepare(); self.setup.install()
self.stub.simulate_audit = True
self.setup._powershell_executable = lambda: '/fake/powershell.exe'
config_file = self.root/'installed'/'audit_config.json'
config = json.loads(config_file.read_text())
config['publish_current_values'] = True
config_file.write_text(json.dumps(config))
class W:
data = []
def write_audit_data_if_changed(self, *args, **kw): self.data.append(args)
self.setup.WAPT = W()
self.assertEqual(self.setup.audit(), 'WARNING')
payload = self.setup.WAPT.data[-1][2]
self.assertEqual(payload['controls']['102']['current'], '0')
self.assertEqual(payload['controls']['101']['current'], '1')
def test_json_includes_missing_control_as_not_evaluated(self):
self.prepare(); self.setup.install()
self.stub.simulate_audit = True
self.setup._powershell_executable = lambda: '/fake/powershell.exe'
self.setup.run = self.stub.run
baseline = self.root/'installed'/'lists'/'finding_list_msft_security_baseline_windows_11_25h2_machine.csv'
# No modification to the installed vendor file: simulate absence from report
# by calling the pure payload builder with temporary CSVs.
scan = self.root/'scan.csv'
scan.write_text('ID,Category,Name,Severity,Result,Recommended,TestResult,SeverityFinding\n101,System,Setting A,Passed,1,1,Passed,High\n')
bench = self.root/'bench.csv'
bench.write_text('ID,Category,Name,Severity,Method,RecommendedValue\n101,System,Setting A,High,Registry,1\n102,Other,Setting B,Medium,secedit,1\n')
counts, findings = self.setup._read_report(str(scan))
counts, findings = self.setup._check_coverage(str(bench), str(scan), counts, findings)
summary = dict(counts, status='ERROR', source='GPO', upstream_commit=PIN,
last_run_utc='2026-10-28T00:00:00+00:00', benchmark='bench.csv',
os_caption='Windows', os_build='26100', display_version='25H2')
payload = self.setup._build_audit_document(summary, findings, str(scan), str(bench), {})
self.assertEqual(payload['controls']['102']['status'], 'skipped')
self.assertEqual(payload['controls']['102']['status'], 'skipped')
self.assertEqual(payload['compliance'], 'incomplete')
self.assertEqual(payload['summary']['unknown'], 1)
self.assertEqual(payload['categories']['Other']['unknown'], 1)
def test_json_not_applicable_is_not_marked_passed(self):
scan = self.root/'scan.csv'
scan.write_text('ID,Category,Name,Severity,Result,Recommended,TestResult,SeverityFinding\n101,System,Setting A,Medium,,,Not Applicable,Medium\n')
bench = self.root/'bench.csv'
bench.write_text('ID,Category,Name,Severity,RecommendedValue\n101,System,Setting A,Medium,1\n')
counts, findings = self.setup._read_report(str(scan))
counts, findings = self.setup._check_coverage(str(bench), str(scan), counts, findings)
summary = dict(counts, status='ERROR', source='GPO', upstream_commit=PIN,
last_run_utc='2026-10-28T00:00:00+00:00', benchmark='bench.csv',
os_caption='Windows', os_build='26100', display_version='25H2')
payload = self.setup._build_audit_document(summary, findings, str(scan), str(bench), {})
self.assertEqual(payload['controls']['101']['status'], 'not_applicable')
self.assertEqual(payload['summary']['not_applicable'], 1)
self.assertEqual(payload['categories']['System']['not_applicable'], 1)
def test_json_407_controls_single_key(self):
baseline = self.root/'bench.csv'
report = self.root/'scan.csv'
baseline_rows = []
report_rows = []
for i in range(407):
control_id = str(10000+i)
severity = 'Medium' if i >= 85 and i < 380 else ('Low' if i >= 380 else 'Medium')
baseline_rows.append({'ID':control_id,'Category':'Category %d'%(i%8),
'Name':'Check %d'%i,'Severity':severity,'Method':'Registry',
'RecommendedValue':'1'})
report_rows.append({'ID':control_id,'Category':'Category %d'%(i%8),
'Name':'Check %d'%i,'Severity':'Passed' if i<85 else severity,
'Result':'1' if i<85 else '0','Recommended':'1',
'TestResult':'Passed' if i<85 else 'Failed',
'SeverityFinding':severity})
with baseline.open('w',newline='') as f:
writer=csv.DictWriter(f,fieldnames=list(baseline_rows[0]));writer.writeheader();writer.writerows(baseline_rows)
with report.open('w',newline='') as f:
writer=csv.DictWriter(f,fieldnames=list(report_rows[0]));writer.writeheader();writer.writerows(report_rows)
counts, findings = self.setup._read_report(str(report))
counts, findings = self.setup._check_coverage(str(baseline), str(report), counts, findings)
summary = dict(counts, status='WARNING', source='GPO', upstream_commit=PIN,
last_run_utc='2026-10-28T00:00:00+00:00', benchmark='bench.csv',
os_caption='Windows', os_build='22631', display_version='23H2')
payload = self.setup._build_audit_document(summary, findings, str(report), str(baseline), {})
self.assertEqual(len(payload['controls']), 407)
self.assertEqual(payload['summary']['passed'], 85)
self.assertEqual(payload['summary']['medium'], 295)
self.assertEqual(payload['summary']['low'], 27)
self.assertEqual(payload['summary']['compliant_percent'], 20.88)
self.assertEqual(sum(x['expected'] for x in payload['categories'].values()), 407)
self.assertNotIn('description', payload['controls']['10000'])
self.assertLess(len(json.dumps(payload).encode('utf-8')), self.setup.MAX_AUDIT_JSON_BYTES)
if __name__=='__main__': unittest.main(verbosity=2)