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:
commit
3468108e10
7 files changed
+1032
-115
No files matched your search
@@ -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
@@ -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 écarts par rapport au référentiel de sécurité</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érifier les journaux de l'agent WAPT sur le poste concerné.</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éfé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é</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>Écarts élevés</span></div>
|
||||||
|
<div class="hk-kpi hk-medium"><strong>{{medium}}</strong><span>Écarts moyens</span></div>
|
||||||
|
<div class="hk-kpi hk-low"><strong>{{low}}</strong><span>É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écutés</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{/summary}}
|
||||||
|
|
||||||
|
{{#summary}}
|
||||||
|
{{#privacy.current_values_published}}
|
||||||
|
<p class="hk-note">Valeurs constatées : publication activée. Vérifier les droits d'accès aux donné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ées par défaut. Le CSV détaillé reste disponible localement sur le poste audité.</p>
|
||||||
|
{{/privacy.current_values_published}}
|
||||||
|
|
||||||
|
<h2>Écarts à examiner en priorité</h2>
|
||||||
|
<p class="hk-muted">{{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é).</p>
|
||||||
|
<div class="hk-section">
|
||||||
|
<div class="hk-scroll">
|
||||||
|
<table class="hk-table">
|
||||||
|
<thead><tr><th>ID</th><th>Catégorie</th><th>Gravité</th><th>É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 écart prioritaire.</td></tr>
|
||||||
|
{{/priority_findings}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details class="hk-section" open>
|
||||||
|
<summary>Répartition par catégorie</summary>
|
||||||
|
<div class="hk-section-content hk-scroll">
|
||||||
|
<table class="hk-table">
|
||||||
|
<thead><tr><th>Catégorie</th><th>Contrôles</th><th>Conformes</th><th>Haut</th><th>Moyen</th><th>Faible</th><th>Non évalués</th><th>Conformité</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ôles du benchmark ({{summary.expected}})</summary>
|
||||||
|
<div class="hk-section-content">
|
||||||
|
<p class="hk-muted">Sélectionner un filtre. Les détails de chaque contrôle contiennent la justification et la méthode de vé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">Écarts / Inconnus</label>
|
||||||
|
<input type="radio" id="hk-filter-high" name="hk-filter"><label for="hk-filter-high">Élevé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ôle et analyse</th><th>Catégorie</th><th>Gravité</th><th>État</th><th>Valeur cible</th>{{#privacy.current_values_published}}<th>Valeur observé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é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 · Synthèse informative uniquement, aucune correction automatique · Référentiel : {{benchmark.name}}</div>
|
||||||
|
</div>
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in new issue
Block a user