Compare commits
4
Commits
349b95fbc6
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbef8829b9 | ||
|
|
eb4ca133d1 | ||
|
|
3468108e10 | ||
|
|
f60d102233 |
No files matched your search
@@ -14,7 +14,8 @@ intégrés au paquet signé avant l’installation.
|
||||
|
||||
## Compatibilité et sélection du benchmark
|
||||
|
||||
`WAPT/control` exige WAPT 2.4 ou ultérieur. Le code Python évite les syntaxes
|
||||
`WAPT/control` exige un agent WAPT 2.6 ou ultérieur pour le schéma d'audit
|
||||
structuré. Le code Python évite les syntaxes
|
||||
postérieures à Python 3.8; les tests locaux ont été exécutés avec Python 3.9.
|
||||
La compatibilité réelle doit être confirmée avec l’agent et la console WAPT de
|
||||
l’environnement cible.
|
||||
@@ -175,39 +176,112 @@ de publication WAPT provoque elle-même un `ERROR`.
|
||||
|
||||
## Données et Reporting WAPT
|
||||
|
||||
Le paquet utilise les API d’audit documentées pour WAPT 2.4+ :
|
||||
`WAPT.write_audit_data_if_changed(section, key, value, ..., max_count=5,
|
||||
keep_days=120)` pour les mesures stables, et `WAPT.write_audit_data()` pour
|
||||
l’observation horodatée `last_run_utc`. Une erreur d’écriture ne reste pas
|
||||
silencieusement cachée : l’audit retourne `ERROR` et tente de publier ce statut.
|
||||
## Données et Reporting WAPT
|
||||
|
||||
Dans la section `HardeningKitty`, les clés publiées comprennent :
|
||||
Chaque audit publie un unique objet Python sérialisable en JSON avec
|
||||
`WAPT.write_audit_data_if_changed('hardeningkitty', 'hardeningkitty', document,
|
||||
max_count=2, keep_days=120)`. Il n’y a plus de métriques WAPT dispersées ni de
|
||||
clés `findings_00` à `findings_15`. Le document change uniquement si son contenu
|
||||
change; sa date d’audit fait partie du document, afin de conserver un historique
|
||||
des audits ayant réellement produit un nouvel état. L’[API de données d’audit
|
||||
WAPT 2.6](https://www.wapt.fr/fr/doc-2.6/wapt-create-packages-code.html)
|
||||
accepte un paramètre `value` générique; le round-trip natif d’un dictionnaire a
|
||||
également été contrôlé dans le code local WAPT 2.7. La documentation des
|
||||
[templates Mustache Console](https://www.wapt.fr/fr/doc-2.7/wapt-console-usage-advanced-audit-data-plugin.html)
|
||||
est celle de WAPT 2.7; le rendu Console doit être qualifié avant diffusion,
|
||||
notamment si la Console cible reste en version 2.6.
|
||||
|
||||
- `status`, `benchmark`, `os_caption`, `os_build`, `display_version`, `source`,
|
||||
`upstream_commit`, `last_run_utc`, `last_error` ;
|
||||
- `expected`, `executed`, `passed`, `high`, `medium`, `low`, `ignored`,
|
||||
`not_applicable`, `excluded`, `unknown`, `skipped`, `total` et
|
||||
`compliant_percent` ;
|
||||
- `findings_00` à `findings_15`, des fragments JSON stables avec ID, catégorie,
|
||||
nom, sévérité du contrôle et résultat. Les valeurs actuelles/recommandées ne
|
||||
sont pas dupliquées dans ces fragments lorsque `publish_current_values` est faux.
|
||||
Les données sont limitées à 2 MiB avant publication. Si le jeu complet dépasse
|
||||
la limite, l’audit échoue explicitement et publie un document `ERROR`; aucun
|
||||
contrôle n’est tronqué. Les valeurs constatées restent exclues sauf opt-in
|
||||
`publish_current_values: true`. Les recommandations viennent du benchmark et
|
||||
peuvent être publiées. Chemins, arguments et descriptions ne sont inclus que
|
||||
lorsqu’ils sont présents dans les données de référence. Le rapport CSV détaillé
|
||||
reste local, sous les ACL de ProgramData. WAPT conserve au plus deux valeurs
|
||||
différentes par clé et 120 jours; huit exécutions locales sont gardées par
|
||||
défaut.
|
||||
|
||||
Les valeurs actuelles et recommandations issues du poste ne sont pas envoyées
|
||||
par défaut (`publish_current_values: false`). Les écarts détaillés restent dans
|
||||
le CSV local, protégé par ACL; les journaux locaux peuvent inclure les erreurs
|
||||
techniques. Huit exécutions locales sont conservées par défaut. WAPT limite les
|
||||
mesures stables à cinq changements pendant 120 jours, les fragments à trois
|
||||
changements pendant 120 jours, et les horodatages à cinq observations pendant
|
||||
120 jours. Ces valeurs suivent les paramètres documentés des API d’audit WAPT.
|
||||
La mesure synthétique exécutée avec 500 contrôles donne 264 171 octets pour
|
||||
le document structuré sans valeurs constatées et 335 670 octets avec l'opt-in
|
||||
des valeurs. Le document structuré est plus riche qu'un CSV brut et n'est donc
|
||||
pas présenté comme systématiquement plus petit. La rationalisation porte sur
|
||||
une seule clé, l'absence de répétition du snapshot par métrique/fragment et
|
||||
l'historisation uniquement lorsque le document change. Les contrôles détaillés
|
||||
restent la source canonique; l'aperçu prioritaire ne répète que leurs ID,
|
||||
catégorie, statut et sévérité. Les tailles sont celles
|
||||
de sérialisation Python; elles n'incluent pas l'overhead PostgreSQL/WAPT.
|
||||
|
||||
La suite mesure aussi la taille série des écarts sur des CSV synthétiques. Pour
|
||||
10/100/500 lignes d’écart, le CSV brut local faisait 3 562/35 062/175 462
|
||||
octets; les fragments JSON WAPT par défaut faisaient 1 125/11 106/55 906
|
||||
octets. Le mode qui ajoute les valeurs et recommandations synthétiques faisait
|
||||
4 305/42 906/214 906 octets. Ce sont des tailles de sérialisation, sans
|
||||
surcoût PostgreSQL/WAPT, et non une mesure sur un parc réel. Les métriques de
|
||||
résumé et les détails restent séparés; les valeurs de contrôle ne sont pas
|
||||
copiées dans Reporting par défaut.
|
||||
Exemple anonymisé (forme abrégée; le paquet publie tous les contrôles, sans
|
||||
tronquer le JSON) :
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 2,
|
||||
"status": "WARNING",
|
||||
"compliance": "non_compliant",
|
||||
"scan": {
|
||||
"date_utc": "2026-10-29T10:00:00+00:00",
|
||||
"engine": "HardeningKitty",
|
||||
"engine_commit": "acf9ecee3d044a9337df7aad80e730cbd746e8de",
|
||||
"profile": "auto",
|
||||
"source": "GPO"
|
||||
},
|
||||
"host": {"os_caption": "Windows 11", "build": "26100", "display_version": "24H2"},
|
||||
"benchmark": {"name": "finding_list_example_machine.csv"},
|
||||
"summary": {"expected": 407, "executed": 407, "passed": 390, "high": 2,
|
||||
"medium": 10, "low": 5, "unknown": 0, "skipped": 0,
|
||||
"compliant_percent": 95.82},
|
||||
"categories": {"System": {"expected": 40, "passed": 38, "high": 1,
|
||||
"medium": 1, "low": 0, "compliant_percent": 95.0}},
|
||||
"controls": {"101": {"id": "101", "category": "System", "name": "Example",
|
||||
"status": "failed", "severity": "high",
|
||||
"recommended": "1"}}
|
||||
}
|
||||
```
|
||||
|
||||
La vue Console facultative se trouve dans
|
||||
`console-templates/host_audit_hardeningkitty_hardeningkitty.html`. Elle est
|
||||
destinée au poste **WAPT Console uniquement**, pas au paquet agent. L’installer
|
||||
dans le répertoire de templates de la Console sous ce nom exact, puis choisir
|
||||
la clé d’audit `hardeningkitty / hardeningkitty`. Elle exploite le helper
|
||||
Mustache WAPT `Items` pour parcourir les dictionnaires de catégories et de
|
||||
contrôles. WAPT expose le document JSON sous `value`; les informations de
|
||||
machine, comme `computer_fqdn`, restent à la racine du contexte Mustache. Le
|
||||
template suit cette structure et les collections sont parcourues avec
|
||||
`Items value.categories` et `Items value.controls`.
|
||||
|
||||
Lors de l’installation du paquet sur Windows, `install()` copie automatiquement
|
||||
le template avec les helpers WAPT vers
|
||||
`makepath(programfiles32, "wapt", "templates")`, soit le répertoire standard
|
||||
de templates de l’agent/console WAPT 32 bits. Le fichier porte le nom exact
|
||||
`host_audit_hardeningkitty_hardeningkitty.html`.
|
||||
|
||||
Sur une console macOS/Linux dont le répertoire de templates est
|
||||
`/opt/wapt/templates`, le paquet Windows ne peut pas effectuer cette copie.
|
||||
Installer ou mettre à jour le fichier depuis la racine du paquet :
|
||||
|
||||
```sh
|
||||
sudo install -m 0644 \
|
||||
console-templates/host_audit_hardeningkitty_hardeningkitty.html \
|
||||
/opt/wapt/templates/host_audit_hardeningkitty_hardeningkitty.html
|
||||
```
|
||||
|
||||
Après copie, ouvrir le rapport d’audit et sélectionner la clé
|
||||
`hardeningkitty / hardeningkitty`. Le template est une vue console; il ne
|
||||
modifie ni la configuration Defender ni les données du rapport.
|
||||
|
||||
Les valeurs variables utilisent les tags Mustache é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. Son rendu a été testé
|
||||
avec le moteur Mustache WAPT sur des données synthétiques, mais doit être
|
||||
contrôlé dans la version réelle de WAPT Console 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
|
||||
|
||||
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
package : comi-audit-hardeningkitty
|
||||
version : 0.9.10-0
|
||||
version : 0.9.11-0
|
||||
architecture : all
|
||||
section : base
|
||||
priority : optional
|
||||
maintainer : Comitari
|
||||
description : Read-only Windows security baseline audit via HardeningKitty
|
||||
min_wapt_version : 2.4
|
||||
min_wapt_version : 2.6
|
||||
target_os : windows
|
||||
audit_schedule : 1w
|
||||
@@ -0,0 +1,163 @@
|
||||
<!--
|
||||
Comitari - Audit de durcissement Windows / HardeningKitty
|
||||
WAPT: section=hardeningkitty, key=hardeningkitty
|
||||
WAPT audit rendering context: audit JSON is under value; host metadata is at root.
|
||||
No JavaScript, external stylesheets or external resources.
|
||||
-->
|
||||
<style>
|
||||
.comi-hk { color:#23364f; background:#fff; font-family:Arial,Helvetica,sans-serif; font-size:13px; line-height:1.48; max-width:100%; padding:22px 22px 26px; box-sizing:border-box; }
|
||||
.comi-hk * { box-sizing:border-box; }
|
||||
.comi-hk h1,.comi-hk h2,.comi-hk h3,.comi-hk p { margin:0; }
|
||||
.comi-hk .eyebrow { color:#3d6490; font-size:10px; font-weight:700; letter-spacing:0.065em; text-transform:uppercase; }
|
||||
.comi-hk h1 { color:#173354; font-size:23px; line-height:1.22; margin:3px 0 4px; }
|
||||
.comi-hk .sub { color:#5b708b; font-size:12px; }
|
||||
.comi-hk h2 { color:#193656; font-size:16px; margin:0 0 12px; }
|
||||
.comi-hk header { padding-bottom:16px; border-bottom:1px solid #e4ebf3; }
|
||||
.comi-hk .meta { color:#5c7088; padding:11px 0 15px; font-size:11px; }
|
||||
.comi-hk .meta span { margin-right:12px; display:inline-block; padding-bottom:3px; }
|
||||
.comi-hk .meta strong { color:#2d4667; }
|
||||
.comi-hk .section { margin-top:22px; }
|
||||
.comi-hk .cards { display:flex; flex-wrap:wrap; gap:10px; }
|
||||
.comi-hk .card { flex:1 1 124px; min-width:108px; border:1px solid #e2eaf3; background:#f8fbfe; border-radius:8px; padding:12px 13px; }
|
||||
.comi-hk .card .label { font-size:11px; color:#637a94; line-height:1.3; }
|
||||
.comi-hk .card .num { font-size:26px; font-weight:700; line-height:1.2; color:#193656; margin-top:5px; }
|
||||
.comi-hk .card.bad .num { color:#b63838; }
|
||||
.comi-hk .card.good .num { color:#287c60; }
|
||||
.comi-hk .card.medium .num { color:#b86a27; }
|
||||
.comi-hk .progress { height:10px; background:#e9eff6; border-radius:20px; overflow:hidden; margin-top:12px; }
|
||||
.comi-hk .progress-fill { background:#2d8b75; height:10px; border-radius:20px; }
|
||||
.comi-hk .progress-legend { display:flex; justify-content:space-between; gap:12px; color:#60738e; font-size:11px; margin-top:5px; }
|
||||
.comi-hk .callout { background:#f2f6fc; border-left:3px solid #3c719d; border-radius:4px; padding:10px 12px; color:#435d78; font-size:12px; margin-top:12px; }
|
||||
.comi-hk .callout-warn { background:#fff8ed; border-left-color:#d18a30; color:#684e2c; }
|
||||
.comi-hk .table-wrap { max-width:100%; overflow-x:auto; border:1px solid #e3eaf2; border-radius:7px; }
|
||||
.comi-hk table { border-collapse:collapse; width:100%; font-size:12px; }
|
||||
.comi-hk thead th { background:#f2f6fb; border-bottom:1px solid #dfe7f1; padding:9px 10px; text-align:left; font-size:11px; font-weight:700; color:#3d5473; }
|
||||
.comi-hk tbody td { border-bottom:1px solid #edf1f6; padding:9px 10px; vertical-align:top; }
|
||||
.comi-hk tbody tr:last-child td { border-bottom:0; }
|
||||
.comi-hk tbody tr:nth-child(even) { background:#fbfcfe; }
|
||||
.comi-hk .n { text-align:right; white-space:nowrap; }
|
||||
.comi-hk .name { font-weight:600; color:#203a5a; }
|
||||
.comi-hk .muted { color:#75869a; font-size:11px; margin-top:4px; overflow-wrap:anywhere; }
|
||||
.comi-hk .small { color:#5b708a; font-size:11px; margin-top:4px; overflow-wrap:anywhere; }
|
||||
.comi-hk .id { font-family:Consolas,"Courier New",monospace; color:#60718b; font-size:11px; white-space:nowrap; }
|
||||
.comi-hk .pill { display:inline-block; padding:3px 7px; font-size:11px; font-weight:700; border-radius:99px; background:#edf2f7; color:#49617d; white-space:nowrap; }
|
||||
.comi-hk .pill.severity-high { background:#fde8e8; color:#aa2424; }
|
||||
.comi-hk .pill.severity-medium { background:#fff1df; color:#985c20; }
|
||||
.comi-hk .pill.severity-low { background:#eaf2ff; color:#3b6ca9; }
|
||||
.comi-hk .pill.status-failed { background:#fee9e8; color:#b12f2a; }
|
||||
.comi-hk .pill.status-passed { background:#e0f5ec; color:#207956; }
|
||||
.comi-hk .pill.status-ignored,.comi-hk .pill.status-skipped,.comi-hk .pill.status-not_applicable { background:#eff2f5; color:#687988; }
|
||||
.comi-hk .note { color:#657c93; font-size:11px; margin:10px 0; }
|
||||
.comi-hk footer { color:#647b96; font-size:11px; margin-top:24px; border-top:1px solid #e5ebf2; padding-top:12px; }
|
||||
.comi-hk .mono { font-family:Consolas,"Courier New",monospace; overflow-wrap:anywhere; }
|
||||
.comi-hk .notfound { border:1px solid #e9d09e; background:#fff9ef; border-radius:6px; padding:16px; margin-top:20px; color:#805822; }
|
||||
</style>
|
||||
<div class="comi-hk">
|
||||
<header>
|
||||
<p class="eyebrow">COMITARI / HARDENINGKITTY</p>
|
||||
<h1>Audit de durcissement Windows</h1>
|
||||
<p class="sub">Analyse des écarts par rapport au référentiel de sécurité</p>
|
||||
</header>
|
||||
|
||||
{{#value.summary}}
|
||||
<div class="meta">
|
||||
<span><strong>Poste :</strong> {{computer_fqdn}}</span>
|
||||
<span><strong>Système :</strong> {{value.host.os_caption}} {{value.host.display_version}} (build {{value.host.build}})</span>
|
||||
<span><strong>Exécuté le :</strong> {{value.scan.date_utc}}</span>
|
||||
<span><strong>Source :</strong> {{value.scan.source}}</span>
|
||||
<span><strong>Statut :</strong> {{value.status}}</span>
|
||||
</div>
|
||||
|
||||
<section class="section">
|
||||
<h2>Vue d'ensemble</h2>
|
||||
<div class="cards">
|
||||
<div class="card"><div class="label">Contrôles vérifiés</div><div class="num">{{checked}}</div></div>
|
||||
<div class="card good"><div class="label">Conformes</div><div class="num">{{passed}}</div></div>
|
||||
<div class="card bad"><div class="label">Non conformes</div><div class="num">{{failed}}</div></div>
|
||||
<div class="card"><div class="label">Conformité</div><div class="num">{{compliant_percent}} %</div></div>
|
||||
<div class="card bad"><div class="label">Criticité élevée</div><div class="num">{{high}}</div></div>
|
||||
<div class="card medium"><div class="label">Criticité moyenne</div><div class="num">{{medium}}</div></div>
|
||||
<div class="card"><div class="label">Criticité faible</div><div class="num">{{low}}</div></div>
|
||||
</div>
|
||||
<div class="progress"><div class="progress-fill" style="width:{{compliant_percent}}%;"></div></div>
|
||||
<div class="progress-legend"><span>Score de conformité observé</span><span>{{passed}} / {{checked}} contrôles conformes</span></div>
|
||||
<div class="callout callout-warn">Ce rapport est informatif : aucune correction automatique n'a été appliquée. Les écarts sont évalués par rapport au profil sélectionné, pas nécessairement contre une configuration adaptée à tous les environnements.</div>
|
||||
</section>
|
||||
{{/value.summary}}
|
||||
|
||||
{{#value.error}}
|
||||
<div class="notfound"><strong>Audit en erreur : {{code}}</strong><br>{{description}}<span class="small">Type technique : {{type}}</span></div>
|
||||
{{/value.error}}
|
||||
|
||||
{{^value.summary}}
|
||||
{{^value.error}}
|
||||
<div class="notfound"><strong>Données d'audit absentes.</strong> La valeur reçue ne contient ni synthèse ni erreur structurée.</div>
|
||||
{{/value.error}}
|
||||
{{/value.summary}}
|
||||
|
||||
{{#value.categories}}
|
||||
<section class="section">
|
||||
<h2>Répartition par domaine</h2>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Domaine</th><th class="n">Contrôles</th><th class="n">Conformes</th><th class="n">Élevée</th><th class="n">Moyenne</th><th class="n">Faible</th><th class="n">Conformité</th></tr></thead>
|
||||
<tbody>
|
||||
{{#Items value.categories}}
|
||||
<tr>
|
||||
<td class="name">{{key}}</td>
|
||||
<td class="n">{{value.expected}}</td>
|
||||
<td class="n">{{value.passed}}</td>
|
||||
<td class="n">{{value.high}}</td>
|
||||
<td class="n">{{value.medium}}</td>
|
||||
<td class="n">{{value.low}}</td>
|
||||
<td class="n">{{value.compliant_percent}} %</td>
|
||||
</tr>
|
||||
{{/Items}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{{/value.categories}}
|
||||
|
||||
{{#value.controls}}
|
||||
<section class="section">
|
||||
<h2>Détail des contrôles ({{ Count value.controls }})</h2>
|
||||
<p class="note">Les contrôles sont classés par identifiant HardeningKitty. Les valeurs observées ne sont affichées que lorsque leur publication est activée dans audit_config.json.</p>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>ID</th><th style="min-width:260px">Contrôle et diagnostic</th><th>Criticité</th><th>État</th><th>Valeur cible</th>{{#value.privacy.current_values_published}}<th>Valeur observée</th>{{/value.privacy.current_values_published}}</tr></thead>
|
||||
<tbody>
|
||||
{{#Items value.controls}}
|
||||
<tr>
|
||||
<td class="id">{{value.id}}</td>
|
||||
<td>
|
||||
<div class="name">{{value.name}}</div>
|
||||
<div class="muted">{{value.category}} • {{value.method}}</div>
|
||||
{{#value.explanation}}<div class="small">{{value.explanation}}</div>{{/value.explanation}}
|
||||
{{#value.registry_path}}<div class="muted">Registre : <span class="mono">{{value.registry_path}} {{value.registry_item}}</span></div>{{/value.registry_path}}
|
||||
{{#value.method_argument}}<div class="muted">Paramètre : <span class="mono">{{value.method_argument}}</span></div>{{/value.method_argument}}
|
||||
</td>
|
||||
<td><span class="pill severity-{{value.severity}}">{{value.severity_label}}</span></td>
|
||||
<td><span class="pill status-{{value.status}}">{{value.status_label}}</span></td>
|
||||
<td class="mono">{{value.recommended}}</td>
|
||||
{{#value.privacy.current_values_published}}<td class="mono">{{value.current}}</td>{{/value.privacy.current_values_published}}
|
||||
</tr>
|
||||
{{/Items}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{{/value.controls}}
|
||||
|
||||
{{#value.summary}}
|
||||
{{^value.controls}}
|
||||
<p class="notfound">Aucun contrôle HardeningKitty disponible dans le contexte de rendu.</p>
|
||||
{{/value.controls}}
|
||||
{{/value.summary}}
|
||||
|
||||
<footer>
|
||||
HardeningKitty / Comitari · Synthèse informative uniquement, aucune correction automatique
|
||||
· Référentiel : <strong>{{value.benchmark.name}}</strong>
|
||||
· Profil : <strong>{{value.scan.profile}}</strong>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -1,9 +1,10 @@
|
||||
{
|
||||
"files": {
|
||||
"WAPT/control": "a7b8ffea3e6df419b88f6118c15b977ccf8b15a91c41d4d757e68e29265a2e0c",
|
||||
"WAPT/control": "4c3eef3800f9994b90cefb06ec35e019ba0c8fb8ffda8a216c7b6ca9eba457d4",
|
||||
"audit_config.json": "9fc18f556fd491e8e00e3a8b1efd864ad8036682e293a02c1f81f3411304a69b",
|
||||
"console-templates/host_audit_hardeningkitty_hardeningkitty.html": "50d39d6414ee620d05b71539754b8ff7ecb4fab06fb5c3467798478107679df1",
|
||||
"runner.ps1": "5e9787f5cc19d0fc4c0ede01a63188ed71de6ef49df596fbce7b2603093231cd",
|
||||
"setup.py": "e67fcfcffd95f0452ed686394ed3ee6af9a2ab95bb8291e9bd350d5fc013db10",
|
||||
"setup.py": "d0b4ee0c444d1d2c0c698cc87047b4764d58a623ec647dfcf4c6d186f55b4d26",
|
||||
"vendor/HardeningKitty.psd1": "94378764787cd0c7125c5c3ce928803b46ee7d7d3fe21c2b27ddeb23c11377a7",
|
||||
"vendor/HardeningKitty.psm1": "b799ec1b17de5ce63f7a8ea34af3beba1d24fcaddf7e3774f797096f5c43e2a0",
|
||||
"vendor/LICENSE": "a879ba6ccdcd2ea55da97b999edd2c401c8589ca946f238236bf93aa40e74d56",
|
||||
|
||||
@@ -12,7 +12,6 @@ from setuphelpers import *
|
||||
import collections
|
||||
import csv
|
||||
import datetime
|
||||
import hashlib # Stable, reproducible distribution of finding IDs to WAPT audit keys.
|
||||
import io
|
||||
import json
|
||||
import locale
|
||||
@@ -22,7 +21,9 @@ import uuid
|
||||
uninstallkey = []
|
||||
|
||||
PACKAGE_NAME = 'comi-audit-hardeningkitty'
|
||||
AUDIT_SECTION = 'HardeningKitty'
|
||||
AUDIT_SECTION = 'hardeningkitty'
|
||||
AUDIT_KEY = 'hardeningkitty'
|
||||
MAX_AUDIT_JSON_BYTES = 2 * 1024 * 1024
|
||||
UPSTREAM_COMMIT = 'acf9ecee3d044a9337df7aad80e730cbd746e8de'
|
||||
PROGRAM_DATA = os.environ.get('ProgramData', r'C:\ProgramData')
|
||||
INSTALL_ROOT = makepath(PROGRAM_DATA, 'Comitari', 'Audit-HardeningKitty')
|
||||
@@ -54,6 +55,8 @@ REQUIRED_VENDOR_FILES = (
|
||||
|
||||
# A staged/installed directory stores the vendor files at its root (NOT /vendor).
|
||||
RUNTIME_FILES = ('runner.ps1', 'audit_config.json')
|
||||
CONSOLE_TEMPLATE = 'console-templates/host_audit_hardeningkitty_hardeningkitty.html'
|
||||
_IS_WINDOWS = os.name == 'nt'
|
||||
# Old package versions included this file. It is no longer required or trusted.
|
||||
LEGACY_FILES = ('package-manifest.json',)
|
||||
|
||||
@@ -199,6 +202,7 @@ def install():
|
||||
# A security operation can change ACLs. Verify readability and all
|
||||
# checksums AFTER the final ACL change, before replacing a working install.
|
||||
_verify_vendor(staging, runtime=True)
|
||||
_install_console_template(source)
|
||||
mkdirs(REPORT_ROOT)
|
||||
_secure_directory(REPORT_ROOT)
|
||||
if isdir(INSTALL_ROOT):
|
||||
@@ -223,6 +227,29 @@ def install():
|
||||
(manifest['upstream_commit'], len(manifest['files'])))
|
||||
|
||||
|
||||
def _install_console_template(source):
|
||||
"""Install the WAPT Console view on Windows using WAPT setuphelpers paths."""
|
||||
if not _IS_WINDOWS:
|
||||
return
|
||||
template_source = makepath(source, *CONSOLE_TEMPLATE.split('/'))
|
||||
if not isfile(template_source):
|
||||
raise RuntimeError('WAPT Console audit template is missing from the package.')
|
||||
# WAPT setuphelpers exposes programfiles32 as the x86 Program Files path.
|
||||
template_dir = makepath(programfiles32, 'wapt', 'templates')
|
||||
mkdirs(template_dir)
|
||||
target = makepath(template_dir, os.path.basename(CONSOLE_TEMPLATE))
|
||||
staged = target + '.new-' + uuid.uuid4().hex
|
||||
try:
|
||||
# setuphelpers filecopyto stages on the destination volume; os.replace
|
||||
# swaps the template atomically and leaves an existing file intact if
|
||||
# copying fails.
|
||||
filecopyto(template_source, staged)
|
||||
os.replace(staged, target)
|
||||
finally:
|
||||
if isfile(staged):
|
||||
remove_file(staged)
|
||||
|
||||
|
||||
def uninstall():
|
||||
if os.path.lexists(INSTALL_ROOT):
|
||||
if not _owned_install(INSTALL_ROOT):
|
||||
@@ -389,51 +416,251 @@ def _status_from_counts(counts, config):
|
||||
return 'OK'
|
||||
|
||||
|
||||
def _shard_findings(findings, bucket_count=16):
|
||||
buckets = [[] for i in range(bucket_count)]
|
||||
for item in findings:
|
||||
# Stable shards, including empty ones: no obsolete audit keys on a later run.
|
||||
key = ('%s:%s' % (item['id'], item['name'])).encode('utf-8')
|
||||
bucket = int(hashlib.sha256(key).hexdigest()[:8], 16) % bucket_count
|
||||
buckets[bucket].append(item)
|
||||
return buckets
|
||||
def _build_audit_document(summary, findings, report_file, baseline_file, config):
|
||||
"""One native JSON object, with all benchmark controls addressable by ID.
|
||||
|
||||
The detailed CSV stays local. Observed values are excluded by default
|
||||
because some settings can reveal accounts or environment-specific details.
|
||||
"""
|
||||
_, baseline_rows = _read_csv_rows(baseline_file)
|
||||
_, report_rows = _read_csv_rows(report_file)
|
||||
reported = {str(row.get('ID') or '').strip(): row for row in report_rows}
|
||||
problems = {str(row['id']): row for row in findings}
|
||||
category_stats = {}
|
||||
controls = {}
|
||||
source = summary['source']
|
||||
status_labels = {
|
||||
'passed': 'Conforme',
|
||||
'failed': 'Non conforme',
|
||||
'skipped': 'Non exécuté',
|
||||
'ignored': 'Ignoré',
|
||||
'excluded': 'Exclu (justifié)',
|
||||
'not_applicable': 'Non applicable',
|
||||
'unknown': 'Inconnu',
|
||||
}
|
||||
severity_labels = {
|
||||
'high': 'Élevée',
|
||||
'medium': 'Moyenne',
|
||||
'low': 'Faible',
|
||||
'unknown': 'Indéterminée',
|
||||
}
|
||||
explanations = {
|
||||
'passed': 'Le réglage satisfait au benchmark sélectionné.',
|
||||
'failed': 'Le réglage ne satisfait pas au benchmark sélectionné. Vérifier la valeur cible et la politique appliquée avant toute correction.',
|
||||
'skipped': 'HardeningKitty n\'a pas retourné de résultat pour ce contrôle. Il ne peut pas etre considéré conforme.',
|
||||
'ignored': 'Le contrôle n\'a pas été évalué lors de cet audit.',
|
||||
'excluded': 'Contrôle exclu intentionnellement. La justification doit être examinée dans la politique d\'audit.',
|
||||
'not_applicable': 'Contrôle marqué comme non applicable.',
|
||||
'unknown': 'Résultat insuffisant pour déterminer la conformité.',
|
||||
}
|
||||
|
||||
for baseline in baseline_rows:
|
||||
control_id = str(baseline.get('ID') or '').strip()
|
||||
recorded = reported.get(control_id)
|
||||
issue = problems.get(control_id)
|
||||
category = str(baseline.get('Category') or '')
|
||||
risk = str(baseline.get('Severity') or '').strip().lower()
|
||||
if risk not in ('high', 'medium', 'low'):
|
||||
risk = 'unknown'
|
||||
|
||||
if recorded is None:
|
||||
outcome = 'excluded' if issue and issue.get('severity') == 'not_applicable' else 'skipped'
|
||||
elif str(recorded.get('TestResult') or '').strip().lower() in ('not applicable', 'not_applicable', 'n/a'):
|
||||
outcome = 'not_applicable'
|
||||
elif issue is None:
|
||||
outcome = 'passed'
|
||||
elif issue['severity'] in ('high', 'medium', 'low'):
|
||||
outcome = 'failed'
|
||||
elif issue['severity'] in ('ignored', 'not_applicable', 'unknown'):
|
||||
outcome = issue['severity']
|
||||
else:
|
||||
outcome = 'unknown'
|
||||
|
||||
control = {
|
||||
'id': control_id,
|
||||
'name': str(baseline.get('Name') or ''),
|
||||
'category': category,
|
||||
'status': outcome,
|
||||
'status_label': status_labels.get(outcome, outcome),
|
||||
'severity': risk,
|
||||
'severity_label': severity_labels.get(risk, risk),
|
||||
'explanation': explanations.get(outcome, explanations['unknown']),
|
||||
}
|
||||
# Only copy a description when the selected benchmark actually carries it.
|
||||
description = str(baseline.get('Description') or
|
||||
(recorded.get('Description') if recorded else '') or '').strip()
|
||||
if description:
|
||||
control['description'] = description
|
||||
method = str(baseline.get('Method') or '').strip()
|
||||
if method:
|
||||
control['method'] = method
|
||||
argument = str(baseline.get('MethodArgument') or '').strip()
|
||||
if argument:
|
||||
control['method_argument'] = argument
|
||||
operator_field = ('OperatorIntune' if source == 'Intune' and
|
||||
method.lower() in ('registry', 'registrylist') else 'Operator')
|
||||
operator = str(baseline.get(operator_field) or '').strip()
|
||||
if operator:
|
||||
control['operator'] = operator
|
||||
path_field = ('RegistryPathIntune' if source == 'Intune' and
|
||||
method.lower() in ('registry', 'registrylist') else 'RegistryPath')
|
||||
registry_path = str(baseline.get(path_field) or '').strip()
|
||||
if registry_path:
|
||||
control['registry_path'] = registry_path
|
||||
registry_item = str(baseline.get('RegistryItem') or '').strip()
|
||||
if registry_item:
|
||||
control['registry_item'] = registry_item
|
||||
|
||||
recommended = ''
|
||||
if recorded is not None:
|
||||
recommended = str(recorded.get('Recommended') or '')
|
||||
if not recommended:
|
||||
# The recommendation is policy data, not an observed host value.
|
||||
field = ('RecommendedValueIntune' if source == 'Intune' and
|
||||
method.lower() in ('registry', 'registrylist') else 'RecommendedValue')
|
||||
recommended = str(baseline.get(field) or '')
|
||||
if recommended:
|
||||
control['recommended'] = recommended
|
||||
if recorded is not None and config.get('publish_current_values', False):
|
||||
control['current'] = str(recorded.get('Result') or '')
|
||||
if outcome in ('excluded', 'skipped', 'ignored', 'unknown'):
|
||||
control['reason'] = (str(issue.get('result') or '') if issue else 'Not evaluated')
|
||||
|
||||
controls[control_id] = control
|
||||
cat = category_stats.setdefault(category, {
|
||||
'expected': 0, 'passed': 0, 'high': 0, 'medium': 0, 'low': 0,
|
||||
'unknown': 0, 'skipped': 0, 'ignored': 0, 'excluded': 0,
|
||||
'not_applicable': 0,
|
||||
})
|
||||
cat['expected'] += 1
|
||||
if outcome == 'failed':
|
||||
actual_risk = str(issue.get('severity') or risk).lower()
|
||||
if actual_risk not in ('high', 'medium', 'low'):
|
||||
actual_risk = 'unknown'
|
||||
cat[actual_risk] += 1
|
||||
elif outcome == 'passed':
|
||||
cat['passed'] += 1
|
||||
elif outcome in ('skipped', 'unknown', 'ignored'):
|
||||
cat[outcome] += 1
|
||||
if outcome == 'skipped':
|
||||
cat['unknown'] += 1 # A skipped test is not a pass.
|
||||
elif outcome == 'excluded':
|
||||
cat['excluded'] += 1
|
||||
elif outcome == 'not_applicable':
|
||||
cat['not_applicable'] += 1
|
||||
|
||||
for cat in category_stats.values():
|
||||
denominator = cat['expected'] - cat['excluded'] - cat['not_applicable']
|
||||
cat['compliant_percent'] = (round(100.0 * cat['passed'] / denominator, 2)
|
||||
if denominator else 0.0)
|
||||
|
||||
evaluated = summary['high'] + summary['medium'] + summary['low']
|
||||
if summary['unknown'] or summary['ignored'] or summary.get('skipped'):
|
||||
compliance = 'incomplete'
|
||||
elif evaluated:
|
||||
compliance = 'non_compliant'
|
||||
else:
|
||||
compliance = 'compliant'
|
||||
|
||||
# Keep only a compact index here; the full control object below is canonical.
|
||||
issue_controls = [c for c in controls.values() if c['status'] != 'passed'
|
||||
and c['status'] not in ('excluded', 'not_applicable')]
|
||||
level_sort = {'high': 0, 'medium': 1, 'low': 2, 'unknown': 3}
|
||||
issue_controls.sort(key=lambda c: (0 if c['status'] == 'failed' else 1,
|
||||
level_sort.get(c['severity'], 3),
|
||||
c['category'], c['id']))
|
||||
priority_fields = ('id', 'category', 'status', 'severity')
|
||||
priority_findings = [
|
||||
{key: control[key] for key in priority_fields if key in control}
|
||||
for control in issue_controls[:25]
|
||||
]
|
||||
|
||||
doc = {
|
||||
'schema_version': 2,
|
||||
'status': summary['status'],
|
||||
'compliance': compliance,
|
||||
'scan': {
|
||||
'date_utc': summary['last_run_utc'],
|
||||
'engine': 'HardeningKitty',
|
||||
'engine_commit': summary['upstream_commit'],
|
||||
'profile': config.get('profile', 'auto'),
|
||||
'source': source,
|
||||
},
|
||||
'host': {
|
||||
'os_caption': summary['os_caption'],
|
||||
'build': summary['os_build'],
|
||||
'display_version': summary['display_version'],
|
||||
},
|
||||
'benchmark': {
|
||||
'name': summary['benchmark'],
|
||||
'expected': summary['expected'],
|
||||
},
|
||||
'summary': {
|
||||
'expected': summary['expected'],
|
||||
'checked': summary['total'],
|
||||
'executed': summary['executed'],
|
||||
'passed': summary['passed'],
|
||||
'failed': evaluated,
|
||||
'high': summary['high'],
|
||||
'medium': summary['medium'],
|
||||
'low': summary['low'],
|
||||
'unknown': summary['unknown'],
|
||||
'skipped': summary['skipped'],
|
||||
'ignored': summary['ignored'],
|
||||
'excluded': summary['excluded'],
|
||||
'not_applicable': summary['not_applicable'],
|
||||
'compliant_percent': summary['compliant_percent'],
|
||||
},
|
||||
'categories': category_stats,
|
||||
'controls': controls,
|
||||
'priority_findings': priority_findings,
|
||||
'priority_total': len(issue_controls),
|
||||
'privacy': {
|
||||
'current_values_published': bool(config.get('publish_current_values', False)),
|
||||
},
|
||||
}
|
||||
if len(controls) != summary['expected']:
|
||||
raise RuntimeError('Structured audit JSON does not cover the complete benchmark.')
|
||||
# This is a size check only: the dict itself is sent to WAPT, not a string.
|
||||
json_size = len(json.dumps(doc, ensure_ascii=False).encode('utf-8'))
|
||||
if json_size > MAX_AUDIT_JSON_BYTES:
|
||||
raise RuntimeError('Structured audit data too large (%s bytes).' % json_size)
|
||||
print('HardeningKitty WAPT JSON: %s controls, %s bytes' % (len(controls), json_size))
|
||||
return doc
|
||||
|
||||
|
||||
def _publish_audit(summary, findings, config):
|
||||
def _publish_audit(document, config):
|
||||
"""Write exactly one native JSON value, as in WAPT's Wazuh example."""
|
||||
if not config.get('publish_to_wapt', True):
|
||||
return
|
||||
wapt = globals().get('WAPT')
|
||||
write = getattr(wapt, 'write_audit_data_if_changed', None)
|
||||
if not callable(write):
|
||||
raise RuntimeError('WAPT.write_audit_data_if_changed is unavailable; audit data was not published.')
|
||||
raise RuntimeError('WAPT.write_audit_data_if_changed is unavailable.')
|
||||
write(AUDIT_SECTION, AUDIT_KEY, document, max_count=2, keep_days=120)
|
||||
|
||||
# Per-metric keys are convenient for SQL reporting on HostAuditData.
|
||||
keys = ('status', 'benchmark', 'os_build', 'os_caption', 'display_version', 'source',
|
||||
'upstream_commit', 'last_error', 'passed', 'high', 'medium', 'low', 'unknown',
|
||||
'ignored', 'not_applicable', 'excluded', 'total', 'expected', 'executed', 'skipped',
|
||||
'compliant_percent')
|
||||
for key in keys:
|
||||
try:
|
||||
write(AUDIT_SECTION, key, str(summary[key]), max_count=5, keep_days=120)
|
||||
except Exception as exc:
|
||||
raise RuntimeError('WAPT audit metric publication failed for %s.' % key) from exc
|
||||
|
||||
for index, group in enumerate(_shard_findings(findings)):
|
||||
try:
|
||||
payload = json.dumps(group, ensure_ascii=False, separators=(',', ':'))
|
||||
write(AUDIT_SECTION, 'findings_%02d' % index, payload, max_count=3, keep_days=120)
|
||||
except Exception as exc:
|
||||
raise RuntimeError('WAPT finding publication failed for shard %02d.' % index) from exc
|
||||
|
||||
# Unlike the stable metrics above, store one timestamped observation each run.
|
||||
write_current = getattr(wapt, 'write_audit_data', None)
|
||||
if not callable(write_current):
|
||||
raise RuntimeError('WAPT.write_audit_data is unavailable; audit timestamp was not published.')
|
||||
try:
|
||||
write_current(AUDIT_SECTION, 'last_run_utc', summary['last_run_utc'],
|
||||
max_count=5, keep_days=120)
|
||||
except Exception as exc:
|
||||
raise RuntimeError('WAPT audit timestamp publication failed.') from exc
|
||||
def _publish_failure(exc):
|
||||
"""Replace a previous successful snapshot with an explicit error snapshot."""
|
||||
wapt = globals().get('WAPT')
|
||||
write = getattr(wapt, 'write_audit_data_if_changed', None)
|
||||
if not callable(write):
|
||||
return
|
||||
document = {
|
||||
'schema_version': 2,
|
||||
'status': 'ERROR',
|
||||
'compliance': 'incomplete',
|
||||
'scan': {
|
||||
'date_utc': datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
'engine': 'HardeningKitty',
|
||||
},
|
||||
'error': {
|
||||
'code': 'AUDIT_FAILED',
|
||||
'type': type(exc).__name__,
|
||||
'description': 'Audit failed; no current compliance assessment is available. Check the local WAPT audit log.',
|
||||
},
|
||||
}
|
||||
write(AUDIT_SECTION, AUDIT_KEY, document, max_count=2, keep_days=120)
|
||||
|
||||
|
||||
def _trim_reports(keep):
|
||||
@@ -530,7 +757,9 @@ def _audit_impl():
|
||||
'report_log': log_file,
|
||||
})
|
||||
_save_json(makepath(run_dir, 'summary.json'), summary)
|
||||
_publish_audit(summary, findings, config)
|
||||
document = _build_audit_document(summary, findings, csv_file, baseline, config)
|
||||
_save_json(makepath(run_dir, 'wapt-audit.json'), document)
|
||||
_publish_audit(document, config)
|
||||
print('HardeningKitty: %s | %s | checked=%s passed=%s high=%s medium=%s low=%s unknown=%s skipped=%s | compliant=%s%%' %
|
||||
(status, benchmark, counts['total'], counts['passed'], counts['high'],
|
||||
counts['medium'], counts['low'], counts['unknown'], counts['skipped'], counts['compliant_percent']))
|
||||
@@ -549,19 +778,10 @@ def audit():
|
||||
except Exception as exc:
|
||||
# Log enough detail locally, but publish only a sanitized technical error.
|
||||
print('HardeningKitty audit ERROR: %s' % exc)
|
||||
wapt = globals().get('WAPT')
|
||||
label = 'Collection failed: %s' % type(exc).__name__
|
||||
if wapt is not None:
|
||||
try:
|
||||
wapt.write_audit_data_if_changed(AUDIT_SECTION, 'status', 'ERROR',
|
||||
max_count=5, keep_days=120)
|
||||
wapt.write_audit_data_if_changed(AUDIT_SECTION, 'last_error', label,
|
||||
max_count=5, keep_days=120)
|
||||
wapt.write_audit_data(AUDIT_SECTION, 'last_run_utc',
|
||||
datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
max_count=5, keep_days=120)
|
||||
except Exception:
|
||||
pass
|
||||
_publish_failure(exc)
|
||||
except Exception as publish_error:
|
||||
print('HardeningKitty: failed to publish ERROR JSON (%s).' % type(publish_error).__name__)
|
||||
try:
|
||||
config = _load_json(makepath(INSTALL_ROOT, 'audit_config.json'))
|
||||
_trim_reports(config.get('retain_audit_runs', 8))
|
||||
|
||||
+104
-31
@@ -34,6 +34,7 @@ setuphelpers_stub.system32 = lambda: os.path.join(os.environ.get('SystemRoot', '
|
||||
setuphelpers_stub.run = lambda *args, **kwargs: ''
|
||||
setuphelpers_stub.wget = lambda *args, **kwargs: (_ for _ in ()).throw(
|
||||
AssertionError('unexpected network download in synthetic tests'))
|
||||
setuphelpers_stub.programfiles32 = '/synthetic/Program Files (x86)'
|
||||
sys.modules.setdefault('setuphelpers', setuphelpers_stub)
|
||||
spec = importlib.util.spec_from_file_location('wapt_hardeningkitty_setup', ROOT / 'setup.py')
|
||||
pkg = importlib.util.module_from_spec(spec)
|
||||
@@ -75,6 +76,9 @@ def make_source(root):
|
||||
(root / 'setup.py').write_text('# synthetic setup\n', encoding='utf-8')
|
||||
(root / 'runner.ps1').write_text('Invoke-HardeningKitty -Mode Audit\n', encoding='utf-8')
|
||||
(root / 'audit_config.json').write_text('{}\n', encoding='utf-8')
|
||||
template = root / pkg.CONSOLE_TEMPLATE
|
||||
template.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(ROOT / pkg.CONSOLE_TEMPLATE, template)
|
||||
asset_root = root / 'vendor'
|
||||
assets = {x: ('sample ' + x).encode('utf-8') for x in vendor.REQUIRED}
|
||||
assets['HardeningKitty.psd1'] = b"@{\n ModuleVersion = '0.9.5'\n}\n"
|
||||
@@ -130,7 +134,7 @@ class PackageTests(unittest.TestCase):
|
||||
self.assertEqual(counts['unknown'], 1)
|
||||
self.assertEqual(pkg._status_from_counts(counts, {'error_on_high': True, 'warn_on_low': True}), 'ERROR')
|
||||
self.assertEqual(len(findings), 2)
|
||||
self.assertEqual(len(pkg._shard_findings(findings)), 16)
|
||||
self.assertEqual(counts['expected'], 3)
|
||||
|
||||
def test_only_reviewed_conditional_exclusions_leave_coverage_denominator(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
@@ -194,23 +198,36 @@ class PackageTests(unittest.TestCase):
|
||||
_, findings2 = pkg._read_report(str(report), True)
|
||||
self.assertEqual(findings2[0]['current'], 'private_value')
|
||||
|
||||
def test_reporting_payload_is_smaller_than_raw_report_without_sensitive_values(self):
|
||||
def test_reporting_payload_size_is_measured_without_sensitive_values(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
report = Path(tmp, 'report.csv')
|
||||
baseline = Path(tmp, 'baseline.csv')
|
||||
rows = [['%04d' % i, 'Synthetic category', 'Synthetic control %04d' % i,
|
||||
'Low', 'current-setting-' * 8, 'recommended-setting-' * 8, 'Failed']
|
||||
for i in range(500)]
|
||||
report.write_text(csv_content(rows), encoding='utf-8')
|
||||
baseline.write_text(csv_content([
|
||||
[row[0], row[1], row[2], 'Low', '', row[5], ''] for row in rows
|
||||
]), encoding='utf-8')
|
||||
raw_bytes = report.stat().st_size
|
||||
_, compact = pkg._read_report(str(report), publish_current_values=False)
|
||||
_, expanded = pkg._read_report(str(report), publish_current_values=True)
|
||||
compact_bytes = sum(len(json.dumps(bucket, ensure_ascii=False, separators=(',', ':')).encode('utf-8'))
|
||||
for bucket in pkg._shard_findings(compact))
|
||||
expanded_bytes = sum(len(json.dumps(bucket, ensure_ascii=False, separators=(',', ':')).encode('utf-8'))
|
||||
for bucket in pkg._shard_findings(expanded))
|
||||
self.assertEqual(len(compact), 500)
|
||||
self.assertLess(compact_bytes, raw_bytes)
|
||||
counts, findings = pkg._read_report(str(report), publish_current_values=False)
|
||||
counts, findings = pkg._check_coverage(str(baseline), str(report), counts, findings)
|
||||
summary = dict(counts, status='WARNING', source='GPO', upstream_commit=pkg.UPSTREAM_COMMIT,
|
||||
last_run_utc='2026-10-29T00:00:00+00:00', benchmark='synthetic.csv',
|
||||
os_caption='Synthetic Windows', os_build='1', display_version='synthetic')
|
||||
compact = pkg._build_audit_document(summary, findings, str(report), str(baseline), {})
|
||||
expanded = pkg._build_audit_document(summary, findings, str(report), str(baseline),
|
||||
{'publish_current_values': True})
|
||||
compact_bytes = len(json.dumps(compact, ensure_ascii=False, separators=(',', ':')).encode('utf-8'))
|
||||
expanded_bytes = len(json.dumps(expanded, ensure_ascii=False, separators=(',', ':')).encode('utf-8'))
|
||||
self.assertEqual(len(compact['controls']), 500)
|
||||
# A complete structured schema is richer than the CSV; record both
|
||||
# measured sizes instead of claiming it is smaller than source.
|
||||
self.assertGreater(compact_bytes, 0)
|
||||
self.assertGreater(raw_bytes, 0)
|
||||
self.assertGreater(expanded_bytes, compact_bytes)
|
||||
self.assertLessEqual(compact_bytes, pkg.MAX_AUDIT_JSON_BYTES)
|
||||
self.assertNotIn('current', compact['controls']['0000'])
|
||||
|
||||
def test_ignored_not_applicable_and_unknown_are_not_compliant(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
@@ -305,38 +322,88 @@ class PackageTests(unittest.TestCase):
|
||||
finally:
|
||||
pkg._package_source, pkg.INSTALL_ROOT, pkg.REPORT_ROOT = old
|
||||
|
||||
def test_published_shards_and_keys(self):
|
||||
events = {}
|
||||
def test_structured_audit_publishes_one_native_json_key(self):
|
||||
events = []
|
||||
class FakeWapt:
|
||||
def write_audit_data_if_changed(self, section, key, value, **kwargs):
|
||||
events[key] = value
|
||||
events.append((section, key, value, kwargs))
|
||||
def write_audit_data(self, section, key, value, **kwargs):
|
||||
events[key] = value
|
||||
events.append((section, key, value, kwargs))
|
||||
old = getattr(pkg, 'WAPT', None)
|
||||
try:
|
||||
pkg.WAPT = FakeWapt()
|
||||
summary = {k: '1' for k in ('status', 'benchmark', 'os_build', 'source',
|
||||
'upstream_commit', 'passed', 'high', 'medium', 'low', 'unknown',
|
||||
'ignored', 'not_applicable', 'excluded', 'os_caption', 'display_version',
|
||||
'total', 'expected', 'executed', 'skipped', 'compliant_percent', 'last_run_utc', 'last_error')}
|
||||
pkg._publish_audit(summary, [dict(id='42', name='Enabled', category='Firewall',
|
||||
severity='high', result='Failed')], {'publish_to_wapt': True})
|
||||
self.assertEqual(len([k for k in events if k.startswith('findings_')]), 16)
|
||||
self.assertEqual(sum(len(json.loads(v)) for k, v in events.items() if k.startswith('findings_')), 1)
|
||||
self.assertIn('status', events)
|
||||
document = {'schema_version': 2, 'status': 'WARNING', 'controls': {'42': {'status': 'failed'}}}
|
||||
pkg._publish_audit(document, {'publish_to_wapt': True})
|
||||
self.assertEqual(len(events), 1)
|
||||
section, key, value, kwargs = events[0]
|
||||
self.assertEqual((section, key), ('hardeningkitty', 'hardeningkitty'))
|
||||
self.assertIs(value, document)
|
||||
self.assertEqual(kwargs['max_count'], 2)
|
||||
finally:
|
||||
if old is None:
|
||||
del pkg.WAPT
|
||||
else:
|
||||
pkg.WAPT = old
|
||||
|
||||
def test_failed_audit_updates_wapt_status(self):
|
||||
events = {}
|
||||
def test_console_template_matches_schema_and_uses_escaped_tags(self):
|
||||
template = (ROOT / 'console-templates' /
|
||||
'host_audit_hardeningkitty_hardeningkitty.html').read_text(encoding='utf-8')
|
||||
# WAPT wraps the audit JSON under `value`; host metadata stays at root.
|
||||
self.assertIn('{{#value.summary}}', template)
|
||||
self.assertIn('{{#Items value.categories}}', template)
|
||||
self.assertIn('{{#Items value.controls}}', template)
|
||||
self.assertIn('{{#value.error}}', template)
|
||||
self.assertIn('{{code}}', template)
|
||||
self.assertIn('{{description}}', template)
|
||||
self.assertIn('{{checked}}', template)
|
||||
self.assertIn('{{computer_fqdn}}', template)
|
||||
self.assertNotIn('{{{', template)
|
||||
self.assertNotIn('<script', template.lower())
|
||||
|
||||
def test_windows_install_copies_console_template_using_wapt_path(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
source = make_source(root / 'package')
|
||||
prior = (pkg._IS_WINDOWS, pkg.programfiles32)
|
||||
try:
|
||||
pkg._IS_WINDOWS = True
|
||||
pkg.programfiles32 = str(root / 'Program Files (x86)')
|
||||
pkg._install_console_template(str(source))
|
||||
installed = (root / 'Program Files (x86)' / 'wapt' / 'templates' /
|
||||
'host_audit_hardeningkitty_hardeningkitty.html')
|
||||
self.assertEqual(installed.read_bytes(),
|
||||
(source / pkg.CONSOLE_TEMPLATE).read_bytes())
|
||||
finally:
|
||||
pkg._IS_WINDOWS, pkg.programfiles32 = prior
|
||||
|
||||
def test_structured_error_does_not_publish_exception_details(self):
|
||||
events = []
|
||||
class FakeWapt:
|
||||
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):
|
||||
events[key] = value
|
||||
events.append((section, key, value, kwargs))
|
||||
old_wapt = getattr(pkg, 'WAPT', None)
|
||||
old_audit = pkg._audit_impl
|
||||
try:
|
||||
@@ -345,9 +412,13 @@ class PackageTests(unittest.TestCase):
|
||||
raise RuntimeError('Missing PowerShell')
|
||||
pkg._audit_impl = fails
|
||||
self.assertEqual(pkg.audit(), 'ERROR')
|
||||
self.assertEqual(events['status'], 'ERROR')
|
||||
self.assertEqual(events['last_error'], 'Collection failed: RuntimeError')
|
||||
self.assertIn('last_run_utc', events)
|
||||
self.assertEqual(len(events), 1)
|
||||
section, key, payload, _ = events[0]
|
||||
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:
|
||||
pkg._audit_impl = old_audit
|
||||
if old_wapt is None:
|
||||
@@ -493,7 +564,9 @@ class PackageTests(unittest.TestCase):
|
||||
verify.verify_source(source)
|
||||
archive = Path(tmp, 'synthetic.wapt')
|
||||
with zipfile.ZipFile(archive, 'w') as zf:
|
||||
for name in ('setup.py', 'runner.ps1', 'audit_config.json', 'package-manifest.json'):
|
||||
for name in ('setup.py', 'runner.ps1', 'audit_config.json',
|
||||
'console-templates/host_audit_hardeningkitty_hardeningkitty.html',
|
||||
'package-manifest.json'):
|
||||
zf.write(source / name, name)
|
||||
zf.write(source / 'WAPT' / 'control', 'WAPT/control')
|
||||
for path in (source / 'vendor').rglob('*'):
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
# -*- 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,
|
||||
}))
|
||||
template = self.source/'console-templates'/'host_audit_hardeningkitty_hardeningkitty.html'
|
||||
template.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(WORK/'console-templates'/'host_audit_hardeningkitty_hardeningkitty.html', template)
|
||||
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)
|
||||
@@ -19,9 +19,12 @@ except ImportError:
|
||||
UPSTREAM_COMMIT = _vendor_module.UPSTREAM_COMMIT
|
||||
REQUIRED = _vendor_module.REQUIRED
|
||||
|
||||
PACKAGE_FILES = ('setup.py', 'WAPT/control', 'runner.ps1', 'audit_config.json', 'vendor/vendor-manifest.json')
|
||||
CONSOLE_TEMPLATE = 'console-templates/host_audit_hardeningkitty_hardeningkitty.html'
|
||||
PACKAGE_FILES = ('setup.py', 'WAPT/control', 'runner.ps1', 'audit_config.json',
|
||||
CONSOLE_TEMPLATE, 'vendor/vendor-manifest.json')
|
||||
REQUIRED_WAPT = (
|
||||
'setup.py', 'WAPT/control', 'runner.ps1', 'audit_config.json', 'package-manifest.json',
|
||||
CONSOLE_TEMPLATE,
|
||||
'vendor/HardeningKitty.psm1',
|
||||
'vendor/HardeningKitty.psd1', 'vendor/vendor-manifest.json',
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user