8 changed files with 1046 additions and 118 deletions

No files matched your search

+104 -30
View File
@@ -14,7 +14,8 @@ intégrés au paquet signé avant l’installation.
## Compatibilité et sélection du benchmark ## Compatibilité et sélection du benchmark
`WAPT/control` exige WAPT 2.4 ou ultérieur. Le code Python évite les syntaxes `WAPT/control` exige un agent WAPT 2.6 ou ultérieur pour le schéma d'audit
structuré. Le code Python évite les syntaxes
postérieures à Python 3.8; les tests locaux ont été exécutés avec Python 3.9. postérieures à Python 3.8; les tests locaux ont été exécutés avec Python 3.9.
La compatibilité réelle doit être confirmée avec l’agent et la console WAPT de La compatibilité réelle doit être confirmée avec l’agent et la console WAPT de
l’environnement cible. l’environnement cible.
@@ -175,39 +176,112 @@ 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. 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 ## Configuration
+2 -2
View File
@@ -1,10 +1,10 @@
package : comi-audit-hardeningkitty package : comi-audit-hardeningkitty
version : 0.9.10-0 version : 0.9.11-0
architecture : all architecture : all
section : base section : base
priority : optional priority : optional
maintainer : Comitari maintainer : Comitari
description : Read-only Windows security baseline audit via HardeningKitty description : Read-only Windows security baseline audit via HardeningKitty
min_wapt_version : 2.4 min_wapt_version : 2.6
target_os : windows target_os : windows
audit_schedule : 1w audit_schedule : 1w
@@ -0,0 +1,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}} &bull; {{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 &middot; Synthèse informative uniquement, aucune correction automatique
&middot; Référentiel : <strong>{{value.benchmark.name}}</strong>
&middot; Profil : <strong>{{value.scan.profile}}</strong>
</footer>
</div>
+3 -2
View File
@@ -1,9 +1,10 @@
{ {
"files": { "files": {
"WAPT/control": "a7b8ffea3e6df419b88f6118c15b977ccf8b15a91c41d4d757e68e29265a2e0c", "WAPT/control": "4c3eef3800f9994b90cefb06ec35e019ba0c8fb8ffda8a216c7b6ca9eba457d4",
"audit_config.json": "9fc18f556fd491e8e00e3a8b1efd864ad8036682e293a02c1f81f3411304a69b", "audit_config.json": "9fc18f556fd491e8e00e3a8b1efd864ad8036682e293a02c1f81f3411304a69b",
"console-templates/host_audit_hardeningkitty_hardeningkitty.html": "50d39d6414ee620d05b71539754b8ff7ecb4fab06fb5c3467798478107679df1",
"runner.ps1": "5e9787f5cc19d0fc4c0ede01a63188ed71de6ef49df596fbce7b2603093231cd", "runner.ps1": "5e9787f5cc19d0fc4c0ede01a63188ed71de6ef49df596fbce7b2603093231cd",
"setup.py": "e67fcfcffd95f0452ed686394ed3ee6af9a2ab95bb8291e9bd350d5fc013db10", "setup.py": "d0b4ee0c444d1d2c0c698cc87047b4764d58a623ec647dfcf4c6d186f55b4d26",
"vendor/HardeningKitty.psd1": "94378764787cd0c7125c5c3ce928803b46ee7d7d3fe21c2b27ddeb23c11377a7", "vendor/HardeningKitty.psd1": "94378764787cd0c7125c5c3ce928803b46ee7d7d3fe21c2b27ddeb23c11377a7",
"vendor/HardeningKitty.psm1": "b799ec1b17de5ce63f7a8ea34af3beba1d24fcaddf7e3774f797096f5c43e2a0", "vendor/HardeningKitty.psm1": "b799ec1b17de5ce63f7a8ea34af3beba1d24fcaddf7e3774f797096f5c43e2a0",
"vendor/LICENSE": "a879ba6ccdcd2ea55da97b999edd2c401c8589ca946f238236bf93aa40e74d56", "vendor/LICENSE": "a879ba6ccdcd2ea55da97b999edd2c401c8589ca946f238236bf93aa40e74d56",
+272 -52
View File
@@ -12,7 +12,6 @@ from setuphelpers import *
import collections import collections
import csv import csv
import datetime import datetime
import hashlib # Stable, reproducible distribution of finding IDs to WAPT audit keys.
import io import io
import json import json
import locale import locale
@@ -22,7 +21,9 @@ import uuid
uninstallkey = [] uninstallkey = []
PACKAGE_NAME = 'comi-audit-hardeningkitty' PACKAGE_NAME = 'comi-audit-hardeningkitty'
AUDIT_SECTION = 'HardeningKitty' AUDIT_SECTION = 'hardeningkitty'
AUDIT_KEY = 'hardeningkitty'
MAX_AUDIT_JSON_BYTES = 2 * 1024 * 1024
UPSTREAM_COMMIT = 'acf9ecee3d044a9337df7aad80e730cbd746e8de' UPSTREAM_COMMIT = 'acf9ecee3d044a9337df7aad80e730cbd746e8de'
PROGRAM_DATA = os.environ.get('ProgramData', r'C:\ProgramData') PROGRAM_DATA = os.environ.get('ProgramData', r'C:\ProgramData')
INSTALL_ROOT = makepath(PROGRAM_DATA, 'Comitari', 'Audit-HardeningKitty') INSTALL_ROOT = makepath(PROGRAM_DATA, 'Comitari', 'Audit-HardeningKitty')
@@ -54,6 +55,8 @@ REQUIRED_VENDOR_FILES = (
# A staged/installed directory stores the vendor files at its root (NOT /vendor). # A staged/installed directory stores the vendor files at its root (NOT /vendor).
RUNTIME_FILES = ('runner.ps1', 'audit_config.json') 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. # Old package versions included this file. It is no longer required or trusted.
LEGACY_FILES = ('package-manifest.json',) LEGACY_FILES = ('package-manifest.json',)
@@ -199,6 +202,7 @@ def install():
# A security operation can change ACLs. Verify readability and all # A security operation can change ACLs. Verify readability and all
# checksums AFTER the final ACL change, before replacing a working install. # checksums AFTER the final ACL change, before replacing a working install.
_verify_vendor(staging, runtime=True) _verify_vendor(staging, runtime=True)
_install_console_template(source)
mkdirs(REPORT_ROOT) mkdirs(REPORT_ROOT)
_secure_directory(REPORT_ROOT) _secure_directory(REPORT_ROOT)
if isdir(INSTALL_ROOT): if isdir(INSTALL_ROOT):
@@ -223,6 +227,29 @@ def install():
(manifest['upstream_commit'], len(manifest['files']))) (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(): def uninstall():
if os.path.lexists(INSTALL_ROOT): if os.path.lexists(INSTALL_ROOT):
if not _owned_install(INSTALL_ROOT): if not _owned_install(INSTALL_ROOT):
@@ -389,51 +416,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 +757,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 +778,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') try:
label = 'Collection failed: %s' % type(exc).__name__ _publish_failure(exc)
if wapt is not None: except Exception as publish_error:
try: print('HardeningKitty: failed to publish ERROR JSON (%s).' % type(publish_error).__name__)
wapt.write_audit_data_if_changed(AUDIT_SECTION, 'status', 'ERROR',
max_count=5, keep_days=120)
wapt.write_audit_data_if_changed(AUDIT_SECTION, 'last_error', label,
max_count=5, keep_days=120)
wapt.write_audit_data(AUDIT_SECTION, 'last_run_utc',
datetime.datetime.now(datetime.timezone.utc).isoformat(),
max_count=5, keep_days=120)
except Exception:
pass
try: 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))
+104 -31
View File
@@ -34,6 +34,7 @@ setuphelpers_stub.system32 = lambda: os.path.join(os.environ.get('SystemRoot', '
setuphelpers_stub.run = lambda *args, **kwargs: '' setuphelpers_stub.run = lambda *args, **kwargs: ''
setuphelpers_stub.wget = lambda *args, **kwargs: (_ for _ in ()).throw( setuphelpers_stub.wget = lambda *args, **kwargs: (_ for _ in ()).throw(
AssertionError('unexpected network download in synthetic tests')) AssertionError('unexpected network download in synthetic tests'))
setuphelpers_stub.programfiles32 = '/synthetic/Program Files (x86)'
sys.modules.setdefault('setuphelpers', setuphelpers_stub) sys.modules.setdefault('setuphelpers', setuphelpers_stub)
spec = importlib.util.spec_from_file_location('wapt_hardeningkitty_setup', ROOT / 'setup.py') spec = importlib.util.spec_from_file_location('wapt_hardeningkitty_setup', ROOT / 'setup.py')
pkg = importlib.util.module_from_spec(spec) 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 / 'setup.py').write_text('# synthetic setup\n', encoding='utf-8')
(root / 'runner.ps1').write_text('Invoke-HardeningKitty -Mode Audit\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') (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' asset_root = root / 'vendor'
assets = {x: ('sample ' + x).encode('utf-8') for x in vendor.REQUIRED} assets = {x: ('sample ' + x).encode('utf-8') for x in vendor.REQUIRED}
assets['HardeningKitty.psd1'] = b"@{\n ModuleVersion = '0.9.5'\n}\n" 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(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 +198,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 +322,88 @@ 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')
# 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: 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 +412,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:
@@ -493,7 +564,9 @@ class PackageTests(unittest.TestCase):
verify.verify_source(source) verify.verify_source(source)
archive = Path(tmp, 'synthetic.wapt') archive = Path(tmp, 'synthetic.wapt')
with zipfile.ZipFile(archive, 'w') as zf: 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 / name, name)
zf.write(source / 'WAPT' / 'control', 'WAPT/control') zf.write(source / 'WAPT' / 'control', 'WAPT/control')
for path in (source / 'vendor').rglob('*'): for path in (source / 'vendor').rglob('*'):
+394
View File
@@ -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)
+4 -1
View File
@@ -19,9 +19,12 @@ except ImportError:
UPSTREAM_COMMIT = _vendor_module.UPSTREAM_COMMIT UPSTREAM_COMMIT = _vendor_module.UPSTREAM_COMMIT
REQUIRED = _vendor_module.REQUIRED 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 = ( REQUIRED_WAPT = (
'setup.py', 'WAPT/control', 'runner.ps1', 'audit_config.json', 'package-manifest.json', 'setup.py', 'WAPT/control', 'runner.ps1', 'audit_config.json', 'package-manifest.json',
CONSOLE_TEMPLATE,
'vendor/HardeningKitty.psm1', 'vendor/HardeningKitty.psm1',
'vendor/HardeningKitty.psd1', 'vendor/vendor-manifest.json', 'vendor/HardeningKitty.psd1', 'vendor/vendor-manifest.json',
) )