8 changed files with 1091 additions and 170 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
`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
View File
@@ -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}} &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": {
"WAPT/control": "a7b8ffea3e6df419b88f6118c15b977ccf8b15a91c41d4d757e68e29265a2e0c",
"WAPT/control": "4c3eef3800f9994b90cefb06ec35e019ba0c8fb8ffda8a216c7b6ca9eba457d4",
"audit_config.json": "9fc18f556fd491e8e00e3a8b1efd864ad8036682e293a02c1f81f3411304a69b",
"console-templates/host_audit_hardeningkitty_hardeningkitty.html": "50d39d6414ee620d05b71539754b8ff7ecb4fab06fb5c3467798478107679df1",
"runner.ps1": "5e9787f5cc19d0fc4c0ede01a63188ed71de6ef49df596fbce7b2603093231cd",
"setup.py": "07e77da22bd1e65ce9004d926ca52ee559be4e0b16749b8018bf0684831752ee",
"setup.py": "d0b4ee0c444d1d2c0c698cc87047b4764d58a623ec647dfcf4c6d186f55b4d26",
"vendor/HardeningKitty.psd1": "94378764787cd0c7125c5c3ce928803b46ee7d7d3fe21c2b27ddeb23c11377a7",
"vendor/HardeningKitty.psm1": "b799ec1b17de5ce63f7a8ea34af3beba1d24fcaddf7e3774f797096f5c43e2a0",
"vendor/LICENSE": "a879ba6ccdcd2ea55da97b999edd2c401c8589ca946f238236bf93aa40e74d56",
+297 -99
View File
@@ -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',)
@@ -65,7 +68,7 @@ def _package_source():
root = os.path.dirname(os.path.abspath(__file__)) if globals().get('__file__') else None
if not root:
raise RuntimeError('WAPT basedir is not available.')
root = os.path.relpath(os.path.abspath(root), os.getcwd())
root = os.path.abspath(root)
if not isfile(makepath(root, 'runner.ps1')) or not isfile(makepath(root, 'audit_config.json')):
raise RuntimeError('WAPT package source is missing runner.ps1 or audit_config.json: %s' % root)
return root
@@ -85,7 +88,7 @@ def _safe_vendor_name(name):
all(part not in ('', '.', '..') for part in name.split('/')))
def _verify_vendor(root, runtime=False, allow_extra=False):
def _verify_vendor(root, runtime=False):
"""Verify all publisher payload hashes and reject unexpected files/symlinks."""
manifest_file = makepath(root, 'vendor-manifest.json')
if not isfile(manifest_file):
@@ -131,13 +134,13 @@ def _verify_vendor(root, runtime=False, allow_extra=False):
raise RuntimeError('HardeningKitty directory contains a symlink; refusing it.')
for name in names:
actual.add(os.path.relpath(makepath(directory, name), root).replace(os.sep, '/'))
if not expected.issubset(actual) or (not allow_extra and actual != expected):
if actual != expected:
raise RuntimeError('Unexpected/missing files in HardeningKitty directory: %s' %
', '.join(sorted(actual.symmetric_difference(expected))[:10]))
return manifest
def _owned_install(root, allow_extra=False):
def _owned_install(root):
"""Never replace/delete a directory belonging to someone else."""
if os.path.islink(root) or not isdir(root):
return False
@@ -148,54 +151,29 @@ def _owned_install(root, allow_extra=False):
if stream.read().strip() != PACKAGE_NAME:
return False
# Legacy upgrade without owner marker: require intact original payload.
_verify_vendor(root, runtime=True, allow_extra=allow_extra)
_verify_vendor(root, runtime=True)
return True
except Exception:
return False
def _remove_owned_install_files(root):
"""Remove only verified package files, keeping unrelated operator data."""
manifest = _verify_vendor(root, runtime=True, allow_extra=True)
managed = set(manifest['files'])
managed.update(('vendor-manifest.json',) + RUNTIME_FILES + LEGACY_FILES + (OWNER_MARKER,))
root_real = os.path.realpath(root)
for relative in sorted(managed, key=lambda value: value.count('/'), reverse=True):
if not _safe_vendor_name(relative):
raise RuntimeError('Unsafe installed manifest path; refusing removal.')
path = makepath(root, *relative.split('/'))
resolved = os.path.realpath(path)
if not resolved.startswith(root_real + os.sep):
raise RuntimeError('Installed manifest path escapes package root; refusing removal.')
if os.path.isfile(path) and not os.path.islink(path):
os.unlink(path)
parent = os.path.dirname(path)
while parent.startswith(root_real + os.sep):
try:
os.rmdir(parent)
except OSError:
break
parent = os.path.dirname(parent)
for current, folders, names in os.walk(root, topdown=False, followlinks=False):
for folder in folders:
path = makepath(current, folder)
if not os.path.islink(path):
try:
os.rmdir(path)
except OSError:
pass
try:
os.rmdir(root)
except OSError:
print('Preserved non-package data remaining in %s.' % root)
def _secure_directory(path):
"""Apply SID-based ACLs independent of Windows display language."""
"""Protect this directory; let its children inherit SYSTEM/Admin rights.
Do not use /inheritance:r /T on every file. That removes inherited
permissions from each individual file and is unnecessary for a freshly
staged directory whose children inherit from its protected parent.
"""
if os.name != 'nt':
return
run(['icacls.exe', path, '/inheritance:r', '/grant:r',
'*S-1-5-18:(OI)(CI)F', '*S-1-5-32-544:(OI)(CI)F', '/T', '/Q'],
# Grant first: avoid losing the installer's access before the new ACEs exist.
# OI/CI make newly created files and subdirectories inherit the grants.
run(['icacls.exe', path, '/grant:r',
'*S-1-5-18:(OI)(CI)F', '*S-1-5-32-544:(OI)(CI)F'],
shell=False, timeout=120, accept_returncodes=[0])
# Only protect the directory itself. Existing descendants keep inheriting
# from it; unlike /T, this does not strip every file's inherited ACEs.
run(['icacls.exe', path, '/inheritance:r'],
shell=False, timeout=120, accept_returncodes=[0])
@@ -221,6 +199,10 @@ def install():
with open(makepath(staging, OWNER_MARKER), 'w', encoding='ascii') as stream:
stream.write(PACKAGE_NAME + '\n')
_secure_directory(staging)
# 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):
@@ -245,11 +227,34 @@ 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, allow_extra=True):
if not _owned_install(INSTALL_ROOT):
raise RuntimeError('Uninstall refused: directory is unverified or not owned by this package.')
_remove_owned_install_files(INSTALL_ROOT)
remove_tree(INSTALL_ROOT)
print('HardeningKitty removed. Historical reports kept: %s' % REPORT_ROOT)
@@ -411,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):
@@ -505,7 +710,7 @@ def _audit_impl():
# run() is WAPT's process helper and handles timeouts and exit statuses.
# No subprocess, shell interpolation, or endpoint Internet access.
command = [_powershell_executable(), '-NoLogo', '-NoProfile', '-NonInteractive',
'-File', makepath(INSTALL_ROOT, 'runner.ps1'),
'-ExecutionPolicy', 'Bypass', '-File', makepath(INSTALL_ROOT, 'runner.ps1'),
'-InstallRoot', INSTALL_ROOT, '-CsvFile', csv_file,
'-MetadataFile', metadata_file, '-Source', config['source'], '-Profile', profile]
with open(log_file, 'w', encoding='utf-8') as log:
@@ -552,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']))
@@ -571,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
try:
_publish_failure(exc)
except Exception as publish_error:
print('HardeningKitty: failed to publish ERROR JSON (%s).' % type(publish_error).__name__)
try:
config = _load_json(makepath(INSTALL_ROOT, 'audit_config.json'))
_trim_reports(config.get('retain_audit_runs', 8))
+124 -36
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.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:
@@ -274,9 +291,10 @@ class PackageTests(unittest.TestCase):
self.assertTrue(Path(pkg.REPORT_ROOT).is_dir())
foreign = Path(pkg.INSTALL_ROOT, 'operator-note.txt')
foreign.write_text('keep me', encoding='utf-8')
pkg.uninstall()
with self.assertRaisesRegex(RuntimeError, 'not owned'):
pkg.uninstall()
self.assertTrue(foreign.is_file())
self.assertFalse(Path(pkg.INSTALL_ROOT, 'HardeningKitty.psm1').exists())
self.assertTrue(Path(pkg.INSTALL_ROOT, 'HardeningKitty.psm1').is_file())
self.assertTrue(Path(pkg.INSTALL_ROOT).is_dir())
self.assertTrue(Path(pkg.REPORT_ROOT).is_dir())
finally:
@@ -304,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:
@@ -344,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:
@@ -422,19 +494,33 @@ class PackageTests(unittest.TestCase):
else:
updater.control = old_control
def test_setup_source_directory_is_relative_to_package_root(self):
def test_setup_source_directory_is_absolute_from_package_root(self):
original_cwd = os.getcwd()
try:
os.chdir(ROOT)
self.assertEqual(pkg._package_source(), '.')
self.assertEqual(Path(pkg._package_source()).resolve(), ROOT)
with tempfile.TemporaryDirectory() as tmp:
os.chdir(tmp)
source_root = pkg._package_source()
self.assertFalse(os.path.isabs(source_root))
self.assertTrue(os.path.isabs(source_root))
self.assertTrue(os.path.isfile(os.path.join(source_root, 'WAPT', 'control')))
finally:
os.chdir(original_cwd)
def test_secure_directory_sets_inheritable_acl_without_recursive_rewrite(self):
commands = []
old_run = pkg.run
try:
pkg.run = lambda command, **kwargs: commands.append((command, kwargs))
with mock.patch.object(pkg.os, 'name', 'nt'):
pkg._secure_directory('/synthetic/package')
finally:
pkg.run = old_run
self.assertEqual(len(commands), 2)
self.assertIn('/grant:r', commands[0][0])
self.assertIn('/inheritance:r', commands[1][0])
self.assertTrue(all('/T' not in command for command, _ in commands))
def test_offline_updater_requires_expected_archive_hash(self):
with tempfile.TemporaryDirectory() as tmp:
archive = Path(tmp, 'archive.zip')
@@ -478,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('*'):
+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
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',
)