Stage 1 step 1: read-only collectors, evidence schema, catalog and synthetic data
Browse files- .gitignore +7 -0
- README.md +120 -4
- baseline.conf +56 -0
- catalog/checks.json +147 -0
- collectors/linux/audit.sh +688 -0
- collectors/macos/audit.sh +713 -0
- collectors/windows/audit.ps1 +574 -0
- index.html +153 -17
- organisational.conf +20 -0
- schema/evidence.schema.json +85 -0
- synthetic/generate_evidence.py +801 -0
- synthetic/samples/synthetic-linux-42000126.json +482 -0
- synthetic/samples/synthetic-macos-42000126.json +482 -0
- synthetic/samples/synthetic-windows-42000126.json +538 -0
- tools/render_report.py +80 -0
- tools/validate_evidence.py +129 -0
.gitignore
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Generated synthetic data (regenerate with synthetic/generate_evidence.py)
|
| 2 |
+
synthetic/out/
|
| 3 |
+
# Real audit output is personal data: never commit it
|
| 4 |
+
audit-*.json
|
| 5 |
+
audit-*.txt
|
| 6 |
+
__pycache__/
|
| 7 |
+
.DS_Store
|
README.md
CHANGED
|
@@ -1,10 +1,126 @@
|
|
| 1 |
---
|
| 2 |
title: Endpoint Auditor
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: Endpoint Auditor
|
| 3 |
+
emoji: 🛡️
|
| 4 |
+
colorFrom: red
|
| 5 |
+
colorTo: indigo
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
| 8 |
+
short_description: Read-only endpoint audit for ISO 27001, GDPR, EU AI Act
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# Devseis Endpoint Auditor — stage 1, step 1
|
| 12 |
+
|
| 13 |
+
A **read-only** audit of a Windows, Linux or macOS endpoint against a security baseline, GDPR technical
|
| 14 |
+
controls, EU AI Act controls and organisational controls. It produces:
|
| 15 |
+
|
| 16 |
+
- a **text report** in the Devseis format (one line per check, then its status), and
|
| 17 |
+
- a **JSON evidence file** in one shared format, the input for the Devseis auditor model (later steps).
|
| 18 |
+
|
| 19 |
+
This repository is **step 1** of the MVP: the collectors, the evidence format, the check catalog and a
|
| 20 |
+
**synthetic evidence generator**, so the model can be built before real customer data exists.
|
| 21 |
+
|
| 22 |
+
> Not a certification. ISO 27001 certification needs an accredited auditor, and much of GDPR and AI Act
|
| 23 |
+
> compliance is organisational. The tool collects evidence and flags gaps.
|
| 24 |
+
|
| 25 |
+
## What it checks
|
| 26 |
+
|
| 27 |
+
| Section | Checks | Examples |
|
| 28 |
+
|---|---|---|
|
| 29 |
+
| `COMPLIANCE RESULTS` | Security baseline (33 on Windows, 26 on Linux/macOS) | Password and lockout policy, antivirus, firewall, screen lock, USB storage, administrators, disk encryption |
|
| 30 |
+
| `GDPR TECHNICAL CONTROLS` | 12 | Automatic updates, supported OS, security logging, time sync, backup, removable media encryption, guest account, remote access, diagnostic data, personal data discovery (opt-in, later stage) |
|
| 31 |
+
| `EU AI ACT CONTROLS` | 5 | AI software inventory, unapproved AI tools (shadow AI), risk classification (by the model), Windows Recall / Apple Intelligence, AI log retention |
|
| 32 |
+
| `ORGANISATIONAL CONTROLS` | 8 | RoPA, DPIA, breach procedure, processor agreements, AI register, AI literacy, human oversight, AI transparency |
|
| 33 |
+
|
| 34 |
+
Every check, with its ISO 27001:2022 Annex A, GDPR and AI Act references, is in
|
| 35 |
+
[`catalog/checks.json`](catalog/checks.json). Control titles are referenced; the ISO standard text is not
|
| 36 |
+
reproduced (it is copyrighted).
|
| 37 |
+
|
| 38 |
+
Statuses: `Compliant`, `Non-Compliant`, `NotApplicable`, `Pending` (needs the model or an organisational
|
| 39 |
+
answer) and `Error` (value not readable, usually because the collector was not run as administrator/root).
|
| 40 |
+
|
| 41 |
+
## Repository layout
|
| 42 |
+
|
| 43 |
+
```
|
| 44 |
+
baseline.conf required values (password length, lockout, USB, approved admins, AI tools…)
|
| 45 |
+
organisational.conf answers to the organisational questions (yes / no / partial / n/a)
|
| 46 |
+
catalog/checks.json every check: id, section, name per OS, rule, control references
|
| 47 |
+
schema/evidence.schema.json JSON Schema of the evidence file
|
| 48 |
+
collectors/windows/audit.ps1 Windows collector (Windows PowerShell 5.1 or PowerShell 7)
|
| 49 |
+
collectors/linux/audit.sh Linux collector (Ubuntu/Debian, RHEL family)
|
| 50 |
+
collectors/macos/audit.sh macOS collector (bash 3.2 as shipped with macOS)
|
| 51 |
+
synthetic/generate_evidence.py synthetic evidence in the same format, using the same rules
|
| 52 |
+
synthetic/samples/ three example synthetic records (one per OS)
|
| 53 |
+
tools/validate_evidence.py checks evidence against the schema rules and the catalog
|
| 54 |
+
tools/render_report.py turns evidence back into the text report
|
| 55 |
+
```
|
| 56 |
+
|
| 57 |
+
## Running an audit
|
| 58 |
+
|
| 59 |
+
The collectors only **read** settings. The only files they write are the report and evidence
|
| 60 |
+
(plus short-lived exports from `secedit`/`auditpol` in TEMP on Windows, deleted straight away).
|
| 61 |
+
|
| 62 |
+
```powershell
|
| 63 |
+
# Windows (elevated PowerShell). Output: C:\ProgramData\Devseis\EndpointAudit\
|
| 64 |
+
powershell -ExecutionPolicy Bypass -File .\collectors\windows\audit.ps1
|
| 65 |
+
```
|
| 66 |
+
|
| 67 |
+
```bash
|
| 68 |
+
# Linux. Output: /var/log/devseis-endpoint-audit/
|
| 69 |
+
sudo ./collectors/linux/audit.sh
|
| 70 |
+
|
| 71 |
+
# macOS. Output: /Library/Logs/Devseis/EndpointAudit/
|
| 72 |
+
sudo ./collectors/macos/audit.sh
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
Options: `--baseline FILE`, `--org FILE`, `--output DIR` (Windows: `-Baseline`, `-Organisational`, `-Output`).
|
| 76 |
+
Exit code: `0` all compliant, `1` at least one Non-Compliant, `2` could not run or write output.
|
| 77 |
+
|
| 78 |
+
Without administrator/root rights the audit still runs; checks that need those rights report `Error`.
|
| 79 |
+
|
| 80 |
+
## Configuring the baseline
|
| 81 |
+
|
| 82 |
+
Edit [`baseline.conf`](baseline.conf). Important values to set per organisation:
|
| 83 |
+
|
| 84 |
+
- `APPROVED_ADMINS` — the IT administrator accounts allowed to be administrators.
|
| 85 |
+
- `APPROVED_AI_TOOLS` — AI tools allowed on endpoints, as named in the AI inventory.
|
| 86 |
+
- `APPROVED_REMOTE_SERVICES` — remote access allowed (e.g. `ssh`, `rdp`).
|
| 87 |
+
- `USB_READ` / `USB_WRITE` — `allow` or `deny`.
|
| 88 |
+
|
| 89 |
+
Answer the eight questions in [`organisational.conf`](organisational.conf) once; they appear in every report.
|
| 90 |
+
|
| 91 |
+
## Synthetic data
|
| 92 |
+
|
| 93 |
+
```bash
|
| 94 |
+
python3 synthetic/generate_evidence.py --count 600 --seed 7 --out synthetic/out/evidence.jsonl
|
| 95 |
+
python3 tools/validate_evidence.py synthetic/out/evidence.jsonl
|
| 96 |
+
python3 tools/render_report.py synthetic/out/evidence.jsonl --index 0
|
| 97 |
+
```
|
| 98 |
+
|
| 99 |
+
Records follow the collectors' check order, names and pass/fail rules, across three device profiles
|
| 100 |
+
(`hardened`, `typical`, `neglected`), current and unsupported OS versions, runs without admin rights,
|
| 101 |
+
third-party antivirus, and AI tool inventories. Host names, serial numbers and accounts are invented.
|
| 102 |
+
The same seed always gives the same data.
|
| 103 |
+
|
| 104 |
+
## Test status (collector version 0.1.0)
|
| 105 |
+
|
| 106 |
+
| Collector | How it was tested |
|
| 107 |
+
|---|---|
|
| 108 |
+
| macOS | Run on a real Mac (macOS 26, Intel) without root: evidence valid; report identical to the rendered evidence apart from the time-zone label. |
|
| 109 |
+
| Linux | Run in an Ubuntu 24.04 container as root and as a normal user: evidence valid, results checked by hand. Still to test on a full desktop install (GNOME, LUKS, ufw). |
|
| 110 |
+
| Windows | Parsed with PowerShell 7 (0 errors) and checked with PSScriptAnalyzer (no errors). **Still to run on a real Windows 11 machine.** |
|
| 111 |
+
| Synthetic | 600 records, all valid against the schema and catalog; reproducible from the seed. |
|
| 112 |
+
|
| 113 |
+
## Privacy
|
| 114 |
+
|
| 115 |
+
Real evidence contains the host name, serial number, user account names and file paths: it is personal
|
| 116 |
+
data under GDPR. Keep it on the device or in the organisation's own storage. Only synthetic data belongs
|
| 117 |
+
in this repository.
|
| 118 |
+
|
| 119 |
+
## Roadmap
|
| 120 |
+
|
| 121 |
+
1. **Collectors, evidence format, catalog, synthetic data** ← this repository
|
| 122 |
+
2. Control library in Devseis's own wording, for retrieval by the model
|
| 123 |
+
3. Training data: evidence → auditor findings (explanation, risk, recommendation, references), reviewed by an ISO 27001 / GDPR expert
|
| 124 |
+
4. Fine-tune a small open-weight model (LoRA/QLoRA) to run locally
|
| 125 |
+
5. Evaluate on held-out data (correct references, no invented values) and publish the scores
|
| 126 |
+
6. Package: one installer per OS that detects the OS, runs the collector and writes the report locally
|
baseline.conf
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Devseis endpoint audit baseline
|
| 2 |
+
# KEY=VALUE lines only. The collectors parse this file; they never execute it.
|
| 3 |
+
# Lists are comma-separated. Name matching ignores case; "*" works as a wildcard.
|
| 4 |
+
|
| 5 |
+
# --- Passwords -------------------------------------------------------------
|
| 6 |
+
PASSWORD_MIN_LENGTH=14
|
| 7 |
+
PASSWORD_HISTORY=24
|
| 8 |
+
# Passwords must expire within this many days. 0 = expiry not required (NIST SP 800-63B style).
|
| 9 |
+
PASSWORD_MAX_AGE_DAYS=365
|
| 10 |
+
PASSWORD_MIN_AGE_DAYS=1
|
| 11 |
+
PASSWORD_COMPLEXITY=1
|
| 12 |
+
|
| 13 |
+
# --- Account lockout -------------------------------------------------------
|
| 14 |
+
LOCKOUT_THRESHOLD_MAX=5
|
| 15 |
+
LOCKOUT_DURATION_MIN_MINUTES=15
|
| 16 |
+
LOCKOUT_OBSERVATION_MIN_MINUTES=15
|
| 17 |
+
|
| 18 |
+
# --- Malware protection ----------------------------------------------------
|
| 19 |
+
SIGNATURE_MAX_AGE_DAYS=3
|
| 20 |
+
# Apple ships XProtect data less often than daily signature feeds.
|
| 21 |
+
MACOS_XPROTECT_MAX_AGE_DAYS=30
|
| 22 |
+
|
| 23 |
+
# --- Firewall --------------------------------------------------------------
|
| 24 |
+
# Expected default outbound policy: allow | deny
|
| 25 |
+
FIREWALL_OUTBOUND=allow
|
| 26 |
+
|
| 27 |
+
# --- Screen lock -----------------------------------------------------------
|
| 28 |
+
SCREEN_LOCK_MAX_SECONDS=900
|
| 29 |
+
|
| 30 |
+
# --- USB storage (allow | deny) --------------------------------------------
|
| 31 |
+
USB_READ=allow
|
| 32 |
+
USB_WRITE=deny
|
| 33 |
+
|
| 34 |
+
# --- Administrators --------------------------------------------------------
|
| 35 |
+
# Accounts allowed to be administrators: user names, DOMAIN\name, or Windows SIDs.
|
| 36 |
+
APPROVED_ADMINS=itadmin
|
| 37 |
+
# Windows: a disabled built-in Administrator (RID 500) is not counted as unauthorized.
|
| 38 |
+
ALLOW_DISABLED_BUILTIN_ADMIN=1
|
| 39 |
+
|
| 40 |
+
# --- GDPR technical controls ----------------------------------------------
|
| 41 |
+
UPDATES_MAX_AGE_DAYS=30
|
| 42 |
+
BACKUP_MAX_AGE_DAYS=7
|
| 43 |
+
# Remote access services that may be enabled: rdp, ssh, smb, winrm, vnc, screensharing, filesharing, remotemanagement
|
| 44 |
+
APPROVED_REMOTE_SERVICES=
|
| 45 |
+
# Lowest OS versions that still receive security updates.
|
| 46 |
+
WINDOWS_MIN_BUILD=22631
|
| 47 |
+
MACOS_MIN_MAJOR=14
|
| 48 |
+
UBUNTU_MIN_VERSION=22.04
|
| 49 |
+
DEBIAN_MIN_VERSION=12
|
| 50 |
+
RHEL_MIN_VERSION=9
|
| 51 |
+
# Opt-in personal data discovery scan (0 = off). Not implemented in stage 1.
|
| 52 |
+
PERSONAL_DATA_SCAN=0
|
| 53 |
+
|
| 54 |
+
# --- EU AI Act -------------------------------------------------------------
|
| 55 |
+
# AI tools allowed on endpoints, as named in the AI software inventory (e.g. Microsoft Copilot,ChatGPT).
|
| 56 |
+
APPROVED_AI_TOOLS=
|
catalog/checks.json
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"catalog_version": "0.1.0",
|
| 3 |
+
"statuses": ["Compliant", "Non-Compliant", "NotApplicable", "Pending", "Error"],
|
| 4 |
+
"status_meaning": {
|
| 5 |
+
"Compliant": "The device meets the baseline value.",
|
| 6 |
+
"Non-Compliant": "The device does not meet the baseline value.",
|
| 7 |
+
"NotApplicable": "The check does not apply to this device or OS, or an opt-in check is switched off.",
|
| 8 |
+
"Pending": "Needs a later step: classification by the auditor model, or an unanswered organisational question.",
|
| 9 |
+
"Error": "The value could not be read (for example the collector was not run as administrator/root)."
|
| 10 |
+
},
|
| 11 |
+
"sections": [
|
| 12 |
+
{ "id": "baseline", "title": "COMPLIANCE RESULTS" },
|
| 13 |
+
{ "id": "gdpr", "title": "GDPR TECHNICAL CONTROLS" },
|
| 14 |
+
{ "id": "aiact", "title": "EU AI ACT CONTROLS" },
|
| 15 |
+
{ "id": "org", "title": "ORGANISATIONAL CONTROLS" }
|
| 16 |
+
],
|
| 17 |
+
"references": {
|
| 18 |
+
"iso27001": {
|
| 19 |
+
"A.5.9": "Inventory of information and other associated assets",
|
| 20 |
+
"A.5.12": "Classification of information",
|
| 21 |
+
"A.5.15": "Access control",
|
| 22 |
+
"A.5.16": "Identity management",
|
| 23 |
+
"A.5.17": "Authentication information",
|
| 24 |
+
"A.5.18": "Access rights",
|
| 25 |
+
"A.5.20": "Addressing information security within supplier agreements",
|
| 26 |
+
"A.5.24": "Information security incident management planning and preparation",
|
| 27 |
+
"A.5.26": "Response to information security incidents",
|
| 28 |
+
"A.5.34": "Privacy and protection of PII",
|
| 29 |
+
"A.6.3": "Information security awareness, education and training",
|
| 30 |
+
"A.6.7": "Remote working",
|
| 31 |
+
"A.7.7": "Clear desk and clear screen",
|
| 32 |
+
"A.7.10": "Storage media",
|
| 33 |
+
"A.8.1": "User endpoint devices",
|
| 34 |
+
"A.8.2": "Privileged access rights",
|
| 35 |
+
"A.8.5": "Secure authentication",
|
| 36 |
+
"A.8.7": "Protection against malware",
|
| 37 |
+
"A.8.8": "Management of technical vulnerabilities",
|
| 38 |
+
"A.8.12": "Data leakage prevention",
|
| 39 |
+
"A.8.13": "Information backup",
|
| 40 |
+
"A.8.15": "Logging",
|
| 41 |
+
"A.8.17": "Clock synchronization",
|
| 42 |
+
"A.8.19": "Installation of software on operational systems",
|
| 43 |
+
"A.8.20": "Networks security",
|
| 44 |
+
"A.8.24": "Use of cryptography"
|
| 45 |
+
},
|
| 46 |
+
"gdpr": {
|
| 47 |
+
"Art. 5(1)(c)": "Data minimisation",
|
| 48 |
+
"Art. 5(1)(e)": "Storage limitation",
|
| 49 |
+
"Art. 5(1)(f)": "Integrity and confidentiality",
|
| 50 |
+
"Art. 25": "Data protection by design and by default",
|
| 51 |
+
"Art. 28": "Processor",
|
| 52 |
+
"Art. 30": "Records of processing activities",
|
| 53 |
+
"Art. 32(1)(a)": "Security of processing: pseudonymisation and encryption",
|
| 54 |
+
"Art. 32(1)(b)": "Security of processing: confidentiality, integrity, availability and resilience",
|
| 55 |
+
"Art. 32(1)(c)": "Security of processing: restore availability and access after an incident",
|
| 56 |
+
"Art. 32(1)(d)": "Security of processing: regular testing and evaluation",
|
| 57 |
+
"Art. 33": "Notification of a personal data breach to the supervisory authority",
|
| 58 |
+
"Art. 34": "Communication of a personal data breach to the data subject",
|
| 59 |
+
"Art. 35": "Data protection impact assessment",
|
| 60 |
+
"Art. 44": "General principle for transfers"
|
| 61 |
+
},
|
| 62 |
+
"ai_act": {
|
| 63 |
+
"Art. 4": "AI literacy",
|
| 64 |
+
"Art. 5": "Prohibited AI practices",
|
| 65 |
+
"Art. 6": "Classification rules for high-risk AI systems",
|
| 66 |
+
"Art. 14": "Human oversight",
|
| 67 |
+
"Art. 26": "Obligations of deployers of high-risk AI systems",
|
| 68 |
+
"Art. 26(6)": "Deployers: keep automatically generated logs for at least six months",
|
| 69 |
+
"Art. 50": "Transparency obligations for providers and deployers of certain AI systems",
|
| 70 |
+
"Annex III": "High-risk AI systems referred to in Article 6(2)"
|
| 71 |
+
}
|
| 72 |
+
},
|
| 73 |
+
"checks": [
|
| 74 |
+
{ "id": "password.min_length", "section": "baseline", "name": "Minimum password length", "os": ["windows", "linux", "macos"], "baseline_keys": ["PASSWORD_MIN_LENGTH"], "rule": "Configured minimum length >= PASSWORD_MIN_LENGTH.", "controls": { "iso27001": ["A.5.17", "A.8.5"], "gdpr": ["Art. 32(1)(b)"], "ai_act": [] } },
|
| 75 |
+
{ "id": "password.history", "section": "baseline", "name": "Password history", "os": ["windows", "linux", "macos"], "baseline_keys": ["PASSWORD_HISTORY"], "rule": "Remembered passwords >= PASSWORD_HISTORY.", "controls": { "iso27001": ["A.5.17", "A.8.5"], "gdpr": ["Art. 32(1)(b)"], "ai_act": [] } },
|
| 76 |
+
{ "id": "password.expiration", "section": "baseline", "name": "Password expiration", "os": ["windows", "linux", "macos"], "baseline_keys": ["PASSWORD_MAX_AGE_DAYS"], "rule": "Passwords expire after 1..PASSWORD_MAX_AGE_DAYS days (0 in the baseline = expiry not required).", "controls": { "iso27001": ["A.5.17"], "gdpr": ["Art. 32(1)(b)"], "ai_act": [] } },
|
| 77 |
+
{ "id": "password.min_age", "section": "baseline", "name": "Minimum password age", "os": ["windows", "linux", "macos"], "baseline_keys": ["PASSWORD_MIN_AGE_DAYS"], "rule": "Minimum age >= PASSWORD_MIN_AGE_DAYS days. macOS has no such setting (NotApplicable).", "controls": { "iso27001": ["A.5.17"], "gdpr": ["Art. 32(1)(b)"], "ai_act": [] } },
|
| 78 |
+
{ "id": "password.complexity", "section": "baseline", "name": "Password complexity", "os": ["windows", "linux", "macos"], "baseline_keys": ["PASSWORD_COMPLEXITY"], "rule": "Complexity enforced (mixed character classes).", "controls": { "iso27001": ["A.5.17", "A.8.5"], "gdpr": ["Art. 32(1)(b)"], "ai_act": [] } },
|
| 79 |
+
{ "id": "lockout.threshold", "section": "baseline", "name": "Account lockout threshold", "os": ["windows", "linux", "macos"], "baseline_keys": ["LOCKOUT_THRESHOLD_MAX"], "rule": "Lockout after 1..LOCKOUT_THRESHOLD_MAX failed attempts (0 = never locks).", "controls": { "iso27001": ["A.8.5"], "gdpr": ["Art. 32(1)(b)"], "ai_act": [] } },
|
| 80 |
+
{ "id": "lockout.duration", "section": "baseline", "name": "Account lockout duration", "os": ["windows", "linux", "macos"], "baseline_keys": ["LOCKOUT_DURATION_MIN_MINUTES"], "rule": "Locked for >= LOCKOUT_DURATION_MIN_MINUTES minutes, or until an administrator unlocks.", "controls": { "iso27001": ["A.8.5"], "gdpr": ["Art. 32(1)(b)"], "ai_act": [] } },
|
| 81 |
+
{ "id": "lockout.observation", "section": "baseline", "name": "Lockout observation period", "os": ["windows", "linux", "macos"], "baseline_keys": ["LOCKOUT_OBSERVATION_MIN_MINUTES"], "rule": "Failed-attempt counter kept for >= LOCKOUT_OBSERVATION_MIN_MINUTES minutes.", "controls": { "iso27001": ["A.8.5"], "gdpr": ["Art. 32(1)(b)"], "ai_act": [] } },
|
| 82 |
+
|
| 83 |
+
{ "id": "malware.antivirus", "section": "baseline", "name": "Antivirus", "os": ["windows", "linux", "macos"], "baseline_keys": [], "rule": "An enabled, up-to-date antivirus product is present.", "controls": { "iso27001": ["A.8.7"], "gdpr": ["Art. 32(1)(b)"], "ai_act": [] } },
|
| 84 |
+
{ "id": "malware.service", "section": "baseline", "name": "Malware protection service", "names": { "windows": "Microsoft Defender Antimalware", "linux": "ClamAV malware protection service", "macos": "Apple malware protection service" }, "os": ["windows", "linux", "macos"], "baseline_keys": [], "rule": "The platform malware protection service is running.", "controls": { "iso27001": ["A.8.7"], "gdpr": ["Art. 32(1)(b)"], "ai_act": [] } },
|
| 85 |
+
{ "id": "malware.realtime", "section": "baseline", "name": "Real-time protection", "os": ["windows"], "baseline_keys": [], "rule": "Defender real-time protection is on.", "controls": { "iso27001": ["A.8.7"], "gdpr": ["Art. 32(1)(b)"], "ai_act": [] } },
|
| 86 |
+
{ "id": "malware.on_access", "section": "baseline", "name": "On-access malware protection", "names": { "windows": "On-access protection" }, "os": ["windows", "linux", "macos"], "baseline_keys": [], "rule": "Files are scanned when opened. macOS: NotApplicable unless a third-party scanner provides it.", "controls": { "iso27001": ["A.8.7"], "gdpr": ["Art. 32(1)(b)"], "ai_act": [] } },
|
| 87 |
+
{ "id": "malware.tamper", "section": "baseline", "name": "Tamper protection", "os": ["windows"], "baseline_keys": [], "rule": "Defender tamper protection is on.", "controls": { "iso27001": ["A.8.7"], "gdpr": ["Art. 32(1)(b)"], "ai_act": [] } },
|
| 88 |
+
{ "id": "malware.signatures", "section": "baseline", "name": "Malware signatures", "names": { "windows": "Security intelligence", "macos": "Malware protection signatures" }, "os": ["windows", "linux", "macos"], "baseline_keys": ["SIGNATURE_MAX_AGE_DAYS", "MACOS_XPROTECT_MAX_AGE_DAYS"], "rule": "Signatures no older than SIGNATURE_MAX_AGE_DAYS (macOS XProtect: MACOS_XPROTECT_MAX_AGE_DAYS).", "controls": { "iso27001": ["A.8.7", "A.8.8"], "gdpr": ["Art. 32(1)(b)"], "ai_act": [] } },
|
| 89 |
+
{ "id": "malware.signature_updates", "section": "baseline", "name": "Malware signature updates", "names": { "macos": "Malware security updates" }, "os": ["linux", "macos"], "baseline_keys": [], "rule": "Automatic signature/security-data updates are enabled.", "controls": { "iso27001": ["A.8.7", "A.8.8"], "gdpr": ["Art. 32(1)(b)"], "ai_act": [] } },
|
| 90 |
+
|
| 91 |
+
{ "id": "firewall.enabled", "section": "baseline", "name": "Firewall", "os": ["linux", "macos"], "baseline_keys": [], "rule": "A host firewall is active.", "controls": { "iso27001": ["A.8.20", "A.8.1"], "gdpr": ["Art. 32(1)(b)"], "ai_act": [] } },
|
| 92 |
+
{ "id": "firewall.inbound", "section": "baseline", "name": "Firewall inbound policy", "os": ["linux", "macos"], "baseline_keys": [], "rule": "Unsolicited inbound traffic is blocked by default (macOS: firewall on with stealth mode).", "controls": { "iso27001": ["A.8.20"], "gdpr": ["Art. 32(1)(b)"], "ai_act": [] } },
|
| 93 |
+
{ "id": "firewall.outbound", "section": "baseline", "name": "Firewall outbound policy", "os": ["linux", "macos"], "baseline_keys": ["FIREWALL_OUTBOUND"], "rule": "Default outbound policy equals FIREWALL_OUTBOUND.", "controls": { "iso27001": ["A.8.20"], "gdpr": ["Art. 32(1)(b)"], "ai_act": [] } },
|
| 94 |
+
{ "id": "firewall.domain.enabled", "section": "baseline", "name": "Domain firewall", "os": ["windows"], "baseline_keys": [], "rule": "Domain profile enabled.", "controls": { "iso27001": ["A.8.20", "A.8.1"], "gdpr": ["Art. 32(1)(b)"], "ai_act": [] } },
|
| 95 |
+
{ "id": "firewall.domain.inbound", "section": "baseline", "name": "Domain inbound", "os": ["windows"], "baseline_keys": [], "rule": "Domain profile default inbound action is Block.", "controls": { "iso27001": ["A.8.20"], "gdpr": ["Art. 32(1)(b)"], "ai_act": [] } },
|
| 96 |
+
{ "id": "firewall.domain.outbound", "section": "baseline", "name": "Domain outbound", "os": ["windows"], "baseline_keys": ["FIREWALL_OUTBOUND"], "rule": "Domain profile default outbound action equals FIREWALL_OUTBOUND.", "controls": { "iso27001": ["A.8.20"], "gdpr": ["Art. 32(1)(b)"], "ai_act": [] } },
|
| 97 |
+
{ "id": "firewall.private.enabled", "section": "baseline", "name": "Private firewall", "os": ["windows"], "baseline_keys": [], "rule": "Private profile enabled.", "controls": { "iso27001": ["A.8.20", "A.8.1"], "gdpr": ["Art. 32(1)(b)"], "ai_act": [] } },
|
| 98 |
+
{ "id": "firewall.private.inbound", "section": "baseline", "name": "Private inbound", "os": ["windows"], "baseline_keys": [], "rule": "Private profile default inbound action is Block.", "controls": { "iso27001": ["A.8.20"], "gdpr": ["Art. 32(1)(b)"], "ai_act": [] } },
|
| 99 |
+
{ "id": "firewall.private.outbound", "section": "baseline", "name": "Private outbound", "os": ["windows"], "baseline_keys": ["FIREWALL_OUTBOUND"], "rule": "Private profile default outbound action equals FIREWALL_OUTBOUND.", "controls": { "iso27001": ["A.8.20"], "gdpr": ["Art. 32(1)(b)"], "ai_act": [] } },
|
| 100 |
+
{ "id": "firewall.public.enabled", "section": "baseline", "name": "Public firewall", "os": ["windows"], "baseline_keys": [], "rule": "Public profile enabled.", "controls": { "iso27001": ["A.8.20", "A.8.1"], "gdpr": ["Art. 32(1)(b)"], "ai_act": [] } },
|
| 101 |
+
{ "id": "firewall.public.inbound", "section": "baseline", "name": "Public inbound", "os": ["windows"], "baseline_keys": [], "rule": "Public profile default inbound action is Block.", "controls": { "iso27001": ["A.8.20"], "gdpr": ["Art. 32(1)(b)"], "ai_act": [] } },
|
| 102 |
+
{ "id": "firewall.public.outbound", "section": "baseline", "name": "Public outbound", "os": ["windows"], "baseline_keys": ["FIREWALL_OUTBOUND"], "rule": "Public profile default outbound action equals FIREWALL_OUTBOUND.", "controls": { "iso27001": ["A.8.20"], "gdpr": ["Art. 32(1)(b)"], "ai_act": [] } },
|
| 103 |
+
|
| 104 |
+
{ "id": "screen_lock.enabled", "section": "baseline", "name": "Screen lock", "os": ["linux", "macos"], "baseline_keys": [], "rule": "The screen locks when idle.", "controls": { "iso27001": ["A.8.1", "A.7.7"], "gdpr": ["Art. 32(1)(b)"], "ai_act": [] } },
|
| 105 |
+
{ "id": "screen_lock.timeout", "section": "baseline", "name": "Screen lock timeout", "os": ["windows", "linux", "macos"], "baseline_keys": ["SCREEN_LOCK_MAX_SECONDS"], "rule": "Idle time before lock (including any grace delay) is 1..SCREEN_LOCK_MAX_SECONDS seconds.", "controls": { "iso27001": ["A.8.1", "A.7.7"], "gdpr": ["Art. 32(1)(b)"], "ai_act": [] } },
|
| 106 |
+
{ "id": "screen_lock.password", "section": "baseline", "name": "Screen lock password requirement", "os": ["windows", "linux", "macos"], "baseline_keys": [], "rule": "A password is required to unlock (macOS: within 5 seconds of the screen locking).", "controls": { "iso27001": ["A.8.1", "A.7.7", "A.8.5"], "gdpr": ["Art. 32(1)(b)"], "ai_act": [] } },
|
| 107 |
+
|
| 108 |
+
{ "id": "usb.read", "section": "baseline", "name": "USB storage read", "os": ["windows", "linux", "macos"], "baseline_keys": ["USB_READ"], "rule": "Effective USB storage read access equals USB_READ (allow/deny).", "controls": { "iso27001": ["A.7.10", "A.8.12"], "gdpr": ["Art. 32(1)(b)"], "ai_act": [] } },
|
| 109 |
+
{ "id": "usb.write", "section": "baseline", "name": "USB storage write", "os": ["windows", "linux", "macos"], "baseline_keys": ["USB_WRITE"], "rule": "Effective USB storage write access equals USB_WRITE (allow/deny).", "controls": { "iso27001": ["A.7.10", "A.8.12"], "gdpr": ["Art. 32(1)(b)"], "ai_act": [] } },
|
| 110 |
+
|
| 111 |
+
{ "id": "admin.approved", "section": "baseline", "name": "Approved IT administrator", "os": ["windows", "linux", "macos"], "baseline_keys": ["APPROVED_ADMINS"], "rule": "At least one account listed in APPROVED_ADMINS is an enabled administrator.", "controls": { "iso27001": ["A.8.2", "A.5.15"], "gdpr": ["Art. 32(1)(b)"], "ai_act": [] } },
|
| 112 |
+
{ "id": "admin.unauthorized", "section": "baseline", "name": "Unauthorized employee administrator accounts", "names": { "windows": "Unauthorized employee local administrator accounts" }, "os": ["windows", "linux", "macos"], "baseline_keys": ["APPROVED_ADMINS", "ALLOW_DISABLED_BUILTIN_ADMIN"], "rule": "No administrator accounts other than APPROVED_ADMINS (and the disabled built-in Administrator on Windows).", "controls": { "iso27001": ["A.8.2", "A.5.18"], "gdpr": ["Art. 32(1)(b)"], "ai_act": [] } },
|
| 113 |
+
|
| 114 |
+
{ "id": "encryption.feature", "section": "baseline", "name": "BitLocker", "os": ["windows"], "baseline_keys": [], "rule": "BitLocker is turned on for the system drive.", "controls": { "iso27001": ["A.8.24", "A.8.1"], "gdpr": ["Art. 32(1)(a)"], "ai_act": [] } },
|
| 115 |
+
{ "id": "encryption.enabled", "section": "baseline", "name": "Encryption", "os": ["windows", "linux", "macos"], "baseline_keys": [], "rule": "The system disk is fully encrypted (BitLocker / LUKS / FileVault).", "controls": { "iso27001": ["A.8.24", "A.8.1"], "gdpr": ["Art. 32(1)(a)"], "ai_act": [] } },
|
| 116 |
+
{ "id": "encryption.protection", "section": "baseline", "name": "Disk encryption protection", "names": { "windows": "BitLocker protection" }, "os": ["windows", "linux", "macos"], "baseline_keys": [], "rule": "Encryption protection is active (not suspended or in progress) with a strong cipher.", "controls": { "iso27001": ["A.8.24"], "gdpr": ["Art. 32(1)(a)"], "ai_act": [] } },
|
| 117 |
+
{ "id": "encryption.recovery", "section": "baseline", "name": "Disk encryption recovery", "names": { "windows": "BitLocker recovery" }, "os": ["windows", "linux", "macos"], "baseline_keys": [], "rule": "A recovery key or second unlock method exists.", "controls": { "iso27001": ["A.8.24", "A.8.13"], "gdpr": ["Art. 32(1)(a)", "Art. 32(1)(c)"], "ai_act": [] } },
|
| 118 |
+
|
| 119 |
+
{ "id": "gdpr.auto_updates", "section": "gdpr", "name": "Automatic security updates", "os": ["windows", "linux", "macos"], "baseline_keys": [], "rule": "Security updates are downloaded and installed automatically.", "controls": { "iso27001": ["A.8.8"], "gdpr": ["Art. 32(1)(b)"], "ai_act": [] } },
|
| 120 |
+
{ "id": "gdpr.updates_recent", "section": "gdpr", "name": "Security updates installed recently", "os": ["windows", "linux", "macos"], "baseline_keys": ["UPDATES_MAX_AGE_DAYS"], "rule": "Last successful update/package change within UPDATES_MAX_AGE_DAYS days.", "controls": { "iso27001": ["A.8.8"], "gdpr": ["Art. 32(1)(b)", "Art. 32(1)(d)"], "ai_act": [] } },
|
| 121 |
+
{ "id": "gdpr.os_supported", "section": "gdpr", "name": "Supported operating system version", "os": ["windows", "linux", "macos"], "baseline_keys": ["WINDOWS_MIN_BUILD", "MACOS_MIN_MAJOR", "UBUNTU_MIN_VERSION", "DEBIAN_MIN_VERSION", "RHEL_MIN_VERSION"], "rule": "The OS version still receives security updates (minimums in the baseline).", "controls": { "iso27001": ["A.8.8"], "gdpr": ["Art. 32(1)(b)"], "ai_act": [] } },
|
| 122 |
+
{ "id": "gdpr.security_logging", "section": "gdpr", "name": "Security event logging", "os": ["windows", "linux", "macos"], "baseline_keys": [], "rule": "Logon and account-change events are recorded (Windows audit policy, Linux auditd, macOS unified log).", "controls": { "iso27001": ["A.8.15"], "gdpr": ["Art. 32(1)(d)", "Art. 33"], "ai_act": [] } },
|
| 123 |
+
{ "id": "gdpr.time_sync", "section": "gdpr", "name": "Time synchronisation", "os": ["windows", "linux", "macos"], "baseline_keys": [], "rule": "The clock is synchronised from a time server.", "controls": { "iso27001": ["A.8.17"], "gdpr": ["Art. 33"], "ai_act": [] } },
|
| 124 |
+
{ "id": "gdpr.backup_configured", "section": "gdpr", "name": "Backup configured", "os": ["windows", "linux", "macos"], "baseline_keys": [], "rule": "A backup solution is configured for user data.", "controls": { "iso27001": ["A.8.13"], "gdpr": ["Art. 32(1)(c)"], "ai_act": [] } },
|
| 125 |
+
{ "id": "gdpr.backup_recent", "section": "gdpr", "name": "Recent backup", "os": ["windows", "linux", "macos"], "baseline_keys": ["BACKUP_MAX_AGE_DAYS"], "rule": "Last backup within BACKUP_MAX_AGE_DAYS days (Pending when the date cannot be read).", "controls": { "iso27001": ["A.8.13"], "gdpr": ["Art. 32(1)(c)"], "ai_act": [] } },
|
| 126 |
+
{ "id": "gdpr.removable_encryption", "section": "gdpr", "name": "Removable media encryption", "os": ["windows", "linux", "macos"], "baseline_keys": [], "rule": "Data cannot be written to unencrypted removable drives.", "controls": { "iso27001": ["A.7.10", "A.8.24"], "gdpr": ["Art. 32(1)(a)", "Art. 34"], "ai_act": [] } },
|
| 127 |
+
{ "id": "gdpr.guest_account", "section": "gdpr", "name": "Guest account disabled", "os": ["windows", "linux", "macos"], "baseline_keys": [], "rule": "No enabled guest login.", "controls": { "iso27001": ["A.5.16", "A.8.5"], "gdpr": ["Art. 32(1)(b)"], "ai_act": [] } },
|
| 128 |
+
{ "id": "gdpr.remote_access", "section": "gdpr", "name": "Remote access services restricted", "os": ["windows", "linux", "macos"], "baseline_keys": ["APPROVED_REMOTE_SERVICES"], "rule": "Only remote access services listed in APPROVED_REMOTE_SERVICES are enabled.", "controls": { "iso27001": ["A.8.20", "A.6.7"], "gdpr": ["Art. 32(1)(b)"], "ai_act": [] } },
|
| 129 |
+
{ "id": "gdpr.telemetry", "section": "gdpr", "name": "Diagnostic data minimised", "os": ["windows", "linux", "macos"], "baseline_keys": [], "rule": "OS diagnostic/usage data sharing is at the minimum level.", "controls": { "iso27001": ["A.5.34"], "gdpr": ["Art. 5(1)(c)", "Art. 25"], "ai_act": [] } },
|
| 130 |
+
{ "id": "gdpr.personal_data_discovery", "section": "gdpr", "name": "Personal data discovery", "os": ["windows", "linux", "macos"], "baseline_keys": ["PERSONAL_DATA_SCAN"], "rule": "Opt-in scan of user folders for personal data patterns (counts and paths only). Not implemented in stage 1: NotApplicable.", "controls": { "iso27001": ["A.5.34", "A.5.12"], "gdpr": ["Art. 5(1)(c)", "Art. 5(1)(e)", "Art. 30"], "ai_act": [] } },
|
| 131 |
+
|
| 132 |
+
{ "id": "aiact.inventory", "section": "aiact", "name": "AI software inventory", "os": ["windows", "linux", "macos"], "baseline_keys": [], "rule": "AI apps, local AI services and local model folders are listed (Compliant = inventory taken).", "controls": { "iso27001": ["A.5.9"], "gdpr": ["Art. 30"], "ai_act": ["Art. 26"] } },
|
| 133 |
+
{ "id": "aiact.unapproved", "section": "aiact", "name": "Unapproved AI tools (shadow AI)", "os": ["windows", "linux", "macos"], "baseline_keys": ["APPROVED_AI_TOOLS"], "rule": "Every AI tool found is listed in APPROVED_AI_TOOLS.", "controls": { "iso27001": ["A.5.9", "A.8.19"], "gdpr": ["Art. 28", "Art. 44"], "ai_act": ["Art. 26"] } },
|
| 134 |
+
{ "id": "aiact.risk_classification", "section": "aiact", "name": "AI system risk classification", "os": ["windows", "linux", "macos"], "baseline_keys": [], "rule": "Each AI tool found is classified (prohibited / high-risk / limited / minimal). Done by the auditor model: Pending in the collector output, NotApplicable when no AI tool is found.", "controls": { "iso27001": ["A.5.9"], "gdpr": [], "ai_act": ["Art. 5", "Art. 6", "Annex III"] } },
|
| 135 |
+
{ "id": "aiact.os_ai_features", "section": "aiact", "name": "Operating system AI features restricted", "names": { "windows": "Windows Recall / Copilot data analysis disabled", "macos": "Apple Intelligence external integrations restricted" }, "os": ["windows", "linux", "macos"], "baseline_keys": [], "rule": "OS-level AI features that capture or send user data are disabled by policy. NotApplicable where the OS has none.", "controls": { "iso27001": ["A.5.34"], "gdpr": ["Art. 5(1)(c)", "Art. 25"], "ai_act": ["Art. 26"] } },
|
| 136 |
+
{ "id": "aiact.log_retention", "section": "aiact", "name": "AI usage log retention", "os": ["windows", "linux", "macos"], "baseline_keys": [], "rule": "Logs kept >= 6 months if a high-risk AI system is used. Pending until classification; NotApplicable when no AI tool is found.", "controls": { "iso27001": ["A.8.15"], "gdpr": [], "ai_act": ["Art. 26(6)"] } },
|
| 137 |
+
|
| 138 |
+
{ "id": "org.ropa", "section": "org", "name": "Record of processing activities", "os": ["windows", "linux", "macos"], "answer_key": "ORG_ROPA", "question": "Is there a current record of processing activities (RoPA)?", "baseline_keys": [], "rule": "Answer yes = Compliant, no/partial = Non-Compliant, n/a = NotApplicable, unanswered = Pending.", "controls": { "iso27001": ["A.5.34"], "gdpr": ["Art. 30"], "ai_act": [] } },
|
| 139 |
+
{ "id": "org.dpia", "section": "org", "name": "Data protection impact assessment", "os": ["windows", "linux", "macos"], "answer_key": "ORG_DPIA", "question": "Has a DPIA been done for every high-risk processing activity?", "baseline_keys": [], "rule": "As above.", "controls": { "iso27001": ["A.5.34"], "gdpr": ["Art. 35"], "ai_act": [] } },
|
| 140 |
+
{ "id": "org.breach_procedure", "section": "org", "name": "Breach response procedure", "os": ["windows", "linux", "macos"], "answer_key": "ORG_BREACH_PROCEDURE", "question": "Is there a tested procedure to report a personal data breach within 72 hours?", "baseline_keys": [], "rule": "As above.", "controls": { "iso27001": ["A.5.24", "A.5.26"], "gdpr": ["Art. 33", "Art. 34"], "ai_act": [] } },
|
| 141 |
+
{ "id": "org.processor_agreements", "section": "org", "name": "Data processing agreements", "os": ["windows", "linux", "macos"], "answer_key": "ORG_PROCESSOR_AGREEMENTS", "question": "Are data processing agreements signed with all processors, including AI providers?", "baseline_keys": [], "rule": "As above.", "controls": { "iso27001": ["A.5.20"], "gdpr": ["Art. 28"], "ai_act": [] } },
|
| 142 |
+
{ "id": "org.ai_register", "section": "org", "name": "AI register", "os": ["windows", "linux", "macos"], "answer_key": "ORG_AI_REGISTER", "question": "Is there a register of the AI systems the organisation uses?", "baseline_keys": [], "rule": "As above.", "controls": { "iso27001": ["A.5.9"], "gdpr": [], "ai_act": ["Art. 26"] } },
|
| 143 |
+
{ "id": "org.ai_literacy", "section": "org", "name": "AI literacy training", "os": ["windows", "linux", "macos"], "answer_key": "ORG_AI_LITERACY", "question": "Have staff who use AI received AI literacy training?", "baseline_keys": [], "rule": "As above.", "controls": { "iso27001": ["A.6.3"], "gdpr": [], "ai_act": ["Art. 4"] } },
|
| 144 |
+
{ "id": "org.human_oversight", "section": "org", "name": "Human oversight for high-risk AI", "os": ["windows", "linux", "macos"], "answer_key": "ORG_HUMAN_OVERSIGHT", "question": "Is a trained person assigned to oversee each high-risk AI system?", "baseline_keys": [], "rule": "As above.", "controls": { "iso27001": [], "gdpr": [], "ai_act": ["Art. 14", "Art. 26"] } },
|
| 145 |
+
{ "id": "org.ai_transparency", "section": "org", "name": "AI transparency to users", "os": ["windows", "linux", "macos"], "answer_key": "ORG_AI_TRANSPARENCY", "question": "Are people told when they interact with AI or receive AI-generated content?", "baseline_keys": [], "rule": "As above.", "controls": { "iso27001": [], "gdpr": [], "ai_act": ["Art. 50"] } }
|
| 146 |
+
]
|
| 147 |
+
}
|
collectors/linux/audit.sh
ADDED
|
@@ -0,0 +1,688 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
# Devseis endpoint audit - Linux collector (Ubuntu/Debian and RHEL family)
|
| 3 |
+
#
|
| 4 |
+
# READ-ONLY: this script only reads settings. It never changes the system.
|
| 5 |
+
# The only files it writes are the report (.txt) and evidence (.json) in the output folder.
|
| 6 |
+
#
|
| 7 |
+
# Run as root for complete results: sudo ./audit.sh
|
| 8 |
+
# Options: --baseline FILE --org FILE --output DIR --help
|
| 9 |
+
|
| 10 |
+
set -u
|
| 11 |
+
|
| 12 |
+
COLLECTOR_VERSION="0.1.0"
|
| 13 |
+
CATALOG_VERSION="0.1.0"
|
| 14 |
+
|
| 15 |
+
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
| 16 |
+
ROOT_DIR=$(cd "$SCRIPT_DIR/../.." && pwd)
|
| 17 |
+
BASELINE_FILE="$ROOT_DIR/baseline.conf"
|
| 18 |
+
ORG_FILE="$ROOT_DIR/organisational.conf"
|
| 19 |
+
OUTPUT_DIR="/var/log/devseis-endpoint-audit"
|
| 20 |
+
|
| 21 |
+
while [ $# -gt 0 ]; do
|
| 22 |
+
case "$1" in
|
| 23 |
+
--baseline) BASELINE_FILE="$2"; shift 2 ;;
|
| 24 |
+
--org) ORG_FILE="$2"; shift 2 ;;
|
| 25 |
+
--output) OUTPUT_DIR="$2"; shift 2 ;;
|
| 26 |
+
-h|--help) sed -n '2,9p' "$0"; exit 0 ;;
|
| 27 |
+
*) echo "Unknown option: $1" >&2; exit 2 ;;
|
| 28 |
+
esac
|
| 29 |
+
done
|
| 30 |
+
|
| 31 |
+
# ---------------------------------------------------------------------------
|
| 32 |
+
# Helpers
|
| 33 |
+
# ---------------------------------------------------------------------------
|
| 34 |
+
|
| 35 |
+
# Read KEY=VALUE from a config file without executing it. Usage: conf_get FILE KEY DEFAULT
|
| 36 |
+
conf_get() {
|
| 37 |
+
local value=""
|
| 38 |
+
if [ -r "$1" ]; then
|
| 39 |
+
value=$(awk -v key="$2" '
|
| 40 |
+
/^[[:space:]]*#/ { next }
|
| 41 |
+
index($0, "=") { k = substr($0, 1, index($0, "=") - 1); gsub(/[[:space:]]/, "", k)
|
| 42 |
+
if (k == key) { v = substr($0, index($0, "=") + 1) } }
|
| 43 |
+
END { print v }' "$1")
|
| 44 |
+
value=$(printf '%s' "$value" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e 's/^"\(.*\)"$/\1/')
|
| 45 |
+
fi
|
| 46 |
+
if [ -n "$value" ]; then printf '%s' "$value"; else printf '%s' "$3"; fi
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
BASELINE_KEYS="PASSWORD_MIN_LENGTH PASSWORD_HISTORY PASSWORD_MAX_AGE_DAYS PASSWORD_MIN_AGE_DAYS PASSWORD_COMPLEXITY LOCKOUT_THRESHOLD_MAX LOCKOUT_DURATION_MIN_MINUTES LOCKOUT_OBSERVATION_MIN_MINUTES SIGNATURE_MAX_AGE_DAYS MACOS_XPROTECT_MAX_AGE_DAYS FIREWALL_OUTBOUND SCREEN_LOCK_MAX_SECONDS USB_READ USB_WRITE APPROVED_ADMINS ALLOW_DISABLED_BUILTIN_ADMIN UPDATES_MAX_AGE_DAYS BACKUP_MAX_AGE_DAYS APPROVED_REMOTE_SERVICES WINDOWS_MIN_BUILD MACOS_MIN_MAJOR UBUNTU_MIN_VERSION DEBIAN_MIN_VERSION RHEL_MIN_VERSION PERSONAL_DATA_SCAN APPROVED_AI_TOOLS"
|
| 50 |
+
declare -A B
|
| 51 |
+
B[PASSWORD_MIN_LENGTH]=14; B[PASSWORD_HISTORY]=24; B[PASSWORD_MAX_AGE_DAYS]=365; B[PASSWORD_MIN_AGE_DAYS]=1
|
| 52 |
+
B[PASSWORD_COMPLEXITY]=1; B[LOCKOUT_THRESHOLD_MAX]=5; B[LOCKOUT_DURATION_MIN_MINUTES]=15; B[LOCKOUT_OBSERVATION_MIN_MINUTES]=15
|
| 53 |
+
B[SIGNATURE_MAX_AGE_DAYS]=3; B[MACOS_XPROTECT_MAX_AGE_DAYS]=30; B[FIREWALL_OUTBOUND]=allow; B[SCREEN_LOCK_MAX_SECONDS]=900
|
| 54 |
+
B[USB_READ]=allow; B[USB_WRITE]=deny; B[APPROVED_ADMINS]=""; B[ALLOW_DISABLED_BUILTIN_ADMIN]=1
|
| 55 |
+
B[UPDATES_MAX_AGE_DAYS]=30; B[BACKUP_MAX_AGE_DAYS]=7; B[APPROVED_REMOTE_SERVICES]=""; B[WINDOWS_MIN_BUILD]=22631
|
| 56 |
+
B[MACOS_MIN_MAJOR]=14; B[UBUNTU_MIN_VERSION]=22.04; B[DEBIAN_MIN_VERSION]=12; B[RHEL_MIN_VERSION]=9
|
| 57 |
+
B[PERSONAL_DATA_SCAN]=0; B[APPROVED_AI_TOOLS]=""
|
| 58 |
+
for key in $BASELINE_KEYS; do B[$key]=$(conf_get "$BASELINE_FILE" "$key" "${B[$key]}"); done
|
| 59 |
+
|
| 60 |
+
is_root() { [ "$(id -u)" -eq 0 ]; }
|
| 61 |
+
is_int() { case "$1" in ''|*[!0-9-]*) return 1 ;; *) return 0 ;; esac; }
|
| 62 |
+
lower() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]'; }
|
| 63 |
+
have() { command -v "$1" >/dev/null 2>&1; }
|
| 64 |
+
days_since() { echo $(( ( $(date +%s) - $1 ) / 86400 )); }
|
| 65 |
+
active() { have systemctl && systemctl is-active --quiet "$1" 2>/dev/null; }
|
| 66 |
+
enabled() { have systemctl && systemctl is-enabled --quiet "$1" 2>/dev/null; }
|
| 67 |
+
version_ge() { [ "$(printf '%s\n%s\n' "$2" "$1" | sort -V | head -1)" = "$2" ]; }
|
| 68 |
+
|
| 69 |
+
# Is VALUE in a comma-separated LIST? Case-insensitive, "*" wildcards allowed.
|
| 70 |
+
in_list() {
|
| 71 |
+
local value item found=1
|
| 72 |
+
value=$(lower "$1")
|
| 73 |
+
set -f
|
| 74 |
+
local IFS=,
|
| 75 |
+
for item in $2; do
|
| 76 |
+
item=$(lower "$(printf '%s' "$item" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')")
|
| 77 |
+
[ -z "$item" ] && continue
|
| 78 |
+
# shellcheck disable=SC2254
|
| 79 |
+
case "$value" in $item) found=0; break ;; esac
|
| 80 |
+
done
|
| 81 |
+
set +f
|
| 82 |
+
return $found
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
json_escape() {
|
| 86 |
+
printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/\t/\\t/g' | awk 'NR > 1 { printf "\\n" } { printf "%s", $0 }' | tr -d '\000-\010\013\014\016-\037'
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
R_ID=(); R_SECTION=(); R_NAME=(); R_STATUS=(); R_ACTUAL=(); R_EXPECTED=(); R_DETAIL=()
|
| 90 |
+
add() { # id section name status actual expected [detail]
|
| 91 |
+
R_ID+=("$1"); R_SECTION+=("$2"); R_NAME+=("$3"); R_STATUS+=("$4")
|
| 92 |
+
R_ACTUAL+=("$5"); R_EXPECTED+=("$6"); R_DETAIL+=("${7:-}")
|
| 93 |
+
}
|
| 94 |
+
needs_root() { add "$1" "$2" "$3" "Error" "not read" "$4" "Requires root: run the collector with sudo."; }
|
| 95 |
+
pass_fail() { # condition-result(0/1) id section name actual expected [detail]
|
| 96 |
+
local ok=$1; shift
|
| 97 |
+
if [ "$ok" -eq 0 ]; then add "$1" "$2" "$3" Compliant "$4" "$5" "${6:-}"; else add "$1" "$2" "$3" Non-Compliant "$4" "$5" "${6:-}"; fi
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
# Human accounts: UID >= UID_MIN, a real login shell and a home directory.
|
| 101 |
+
UID_MIN=$(awk '$1 == "UID_MIN" { print $2 }' /etc/login.defs 2>/dev/null); UID_MIN=${UID_MIN:-1000}
|
| 102 |
+
HUMAN_USERS=$(awk -F: -v min="$UID_MIN" '$3 >= min && $3 < 60000 && $7 !~ /(nologin|false)$/ { print $1 ":" $3 ":" $6 }' /etc/passwd)
|
| 103 |
+
|
| 104 |
+
# Run a read-only command as another user (for per-user desktop settings).
|
| 105 |
+
as_user() { # user uid home command...
|
| 106 |
+
local user=$1 uid=$2 home=$3; shift 3
|
| 107 |
+
if is_root; then
|
| 108 |
+
runuser -u "$user" -- env HOME="$home" XDG_RUNTIME_DIR="/run/user/$uid" DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$uid/bus" "$@" 2>/dev/null
|
| 109 |
+
elif [ "$(id -un)" = "$user" ]; then
|
| 110 |
+
"$@" 2>/dev/null
|
| 111 |
+
fi
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
# ---------------------------------------------------------------------------
|
| 115 |
+
# Host information
|
| 116 |
+
# ---------------------------------------------------------------------------
|
| 117 |
+
# shellcheck disable=SC1091
|
| 118 |
+
OS_ID=$(. /etc/os-release 2>/dev/null; echo "${ID:-linux}")
|
| 119 |
+
OS_LIKE=$(. /etc/os-release 2>/dev/null; echo "${ID_LIKE:-}")
|
| 120 |
+
OS_PRETTY=$(. /etc/os-release 2>/dev/null; echo "${PRETTY_NAME:-Linux}")
|
| 121 |
+
OS_VERSION=$(. /etc/os-release 2>/dev/null; echo "${VERSION_ID:-unknown}")
|
| 122 |
+
KERNEL=$(uname -r)
|
| 123 |
+
ARCH=$(uname -m)
|
| 124 |
+
HOSTNAME_VALUE=$(hostname 2>/dev/null || cat /etc/hostname)
|
| 125 |
+
SERIAL=$(cat /sys/class/dmi/id/product_serial 2>/dev/null); SERIAL=${SERIAL:-unknown}
|
| 126 |
+
EXEC_USER=$(id -un)
|
| 127 |
+
AUDIT_DATE=$(date '+%Y-%m-%d %H:%M:%S %Z')
|
| 128 |
+
STARTED_AT=$(date '+%Y-%m-%dT%H:%M:%S%:z')
|
| 129 |
+
IS_ADMIN=false; is_root && IS_ADMIN=true
|
| 130 |
+
|
| 131 |
+
case " $OS_ID $OS_LIKE " in
|
| 132 |
+
*" debian "*|*" ubuntu "*) FAMILY=debian; PAM_PASSWORD="/etc/pam.d/common-password"; PAM_AUTH="/etc/pam.d/common-auth" ;;
|
| 133 |
+
*" rhel "*|*" fedora "*|*" centos "*) FAMILY=rhel; PAM_PASSWORD="/etc/pam.d/system-auth /etc/pam.d/password-auth"; PAM_AUTH="/etc/pam.d/system-auth /etc/pam.d/password-auth" ;;
|
| 134 |
+
*) FAMILY=other; PAM_PASSWORD="/etc/pam.d/common-password /etc/pam.d/system-auth"; PAM_AUTH="/etc/pam.d/common-auth /etc/pam.d/system-auth" ;;
|
| 135 |
+
esac
|
| 136 |
+
|
| 137 |
+
# ---------------------------------------------------------------------------
|
| 138 |
+
# 1. Security baseline
|
| 139 |
+
# ---------------------------------------------------------------------------
|
| 140 |
+
S=baseline
|
| 141 |
+
|
| 142 |
+
# PAM module line (non-comment) for a module in the given files.
|
| 143 |
+
pam_line() { # module files...
|
| 144 |
+
local module=$1; shift
|
| 145 |
+
# shellcheck disable=SC2086
|
| 146 |
+
grep -hE "^[[:space:]]*[a-z-]+[[:space:]].*$module" $@ 2>/dev/null | head -1
|
| 147 |
+
}
|
| 148 |
+
pam_arg() { printf '%s' "$1" | grep -oE "(^|[[:space:]])$2=[^[:space:]]+" | head -1 | cut -d= -f2; }
|
| 149 |
+
conf_value() { # key files... -> last "key = value" in the files
|
| 150 |
+
local key=$1; shift
|
| 151 |
+
# shellcheck disable=SC2068
|
| 152 |
+
cat $@ 2>/dev/null | awk -v k="$key" '/^[[:space:]]*#/ { next } { line = $0; gsub(/[[:space:]]/, "", line) } index(line, k "=") == 1 { v = substr(line, length(k) + 2) } END { print v }'
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
PWQ_LINE=$(pam_line pam_pwquality.so $PAM_PASSWORD)
|
| 156 |
+
PWQ_CONF="/etc/security/pwquality.conf $(ls /etc/security/pwquality.conf.d/*.conf 2>/dev/null | tr '\n' ' ')"
|
| 157 |
+
pwq() { # key default -> value from the PAM arguments, else pwquality.conf, else default
|
| 158 |
+
local v
|
| 159 |
+
v=$(pam_arg "$PWQ_LINE" "$1")
|
| 160 |
+
# shellcheck disable=SC2086
|
| 161 |
+
[ -z "$v" ] && v=$(conf_value "$1" $PWQ_CONF)
|
| 162 |
+
printf '%s' "${v:-$2}"
|
| 163 |
+
}
|
| 164 |
+
UNIX_LINE=$(pam_line pam_unix.so $PAM_PASSWORD)
|
| 165 |
+
|
| 166 |
+
if [ -n "$PWQ_LINE" ]; then
|
| 167 |
+
min_len=$(pwq minlen 8)
|
| 168 |
+
detail="pam_pwquality"
|
| 169 |
+
else
|
| 170 |
+
min_len=$(pam_arg "$UNIX_LINE" minlen); min_len=${min_len:-6}
|
| 171 |
+
detail="pam_pwquality not enabled; pam_unix minimum"
|
| 172 |
+
fi
|
| 173 |
+
[ "$min_len" -ge "${B[PASSWORD_MIN_LENGTH]}" ] 2>/dev/null; pass_fail $? password.min_length $S "Minimum password length" "$min_len" ">= ${B[PASSWORD_MIN_LENGTH]}" "$detail"
|
| 174 |
+
|
| 175 |
+
PWH_LINE=$(pam_line pam_pwhistory.so $PAM_PASSWORD)
|
| 176 |
+
if [ -n "$PWH_LINE" ]; then
|
| 177 |
+
remember=$(pam_arg "$PWH_LINE" remember); [ -z "$remember" ] && remember=$(conf_value remember /etc/security/pwhistory.conf)
|
| 178 |
+
remember=${remember:-10}
|
| 179 |
+
else
|
| 180 |
+
remember=$(pam_arg "$UNIX_LINE" remember); remember=${remember:-0}
|
| 181 |
+
fi
|
| 182 |
+
[ "$remember" -ge "${B[PASSWORD_HISTORY]}" ] 2>/dev/null; pass_fail $? password.history $S "Password history" "$remember" ">= ${B[PASSWORD_HISTORY]}"
|
| 183 |
+
|
| 184 |
+
defs() { awk -v k="$1" '$1 == k { print $2 }' /etc/login.defs 2>/dev/null | tail -1; }
|
| 185 |
+
max_days=$(defs PASS_MAX_DAYS); min_days=$(defs PASS_MIN_DAYS)
|
| 186 |
+
# Existing human accounts with a password: their own max/min age (needs /etc/shadow).
|
| 187 |
+
shadow_out=""; shadow_min_out=""
|
| 188 |
+
if is_root; then
|
| 189 |
+
for entry in $HUMAN_USERS; do
|
| 190 |
+
u=${entry%%:*}
|
| 191 |
+
line=$(grep "^$u:" /etc/shadow)
|
| 192 |
+
hash=$(echo "$line" | cut -d: -f2); umin=$(echo "$line" | cut -d: -f4); umax=$(echo "$line" | cut -d: -f5)
|
| 193 |
+
case "$hash" in '!'*|'*'*|'') continue ;; esac
|
| 194 |
+
if [ "${B[PASSWORD_MAX_AGE_DAYS]}" -gt 0 ] && { ! is_int "$umax" || [ "$umax" -lt 1 ] || [ "$umax" -gt "${B[PASSWORD_MAX_AGE_DAYS]}" ]; }; then
|
| 195 |
+
shadow_out="${shadow_out:+$shadow_out, }$u (${umax:-never})"
|
| 196 |
+
fi
|
| 197 |
+
if ! is_int "$umin" || [ "$umin" -lt "${B[PASSWORD_MIN_AGE_DAYS]}" ]; then
|
| 198 |
+
shadow_min_out="${shadow_min_out:+$shadow_min_out, }$u (${umin:-0})"
|
| 199 |
+
fi
|
| 200 |
+
done
|
| 201 |
+
fi
|
| 202 |
+
if [ "${B[PASSWORD_MAX_AGE_DAYS]}" -eq 0 ]; then
|
| 203 |
+
add password.expiration $S "Password expiration" Compliant "PASS_MAX_DAYS ${max_days:-unset}" "not required" "Baseline does not require expiry."
|
| 204 |
+
elif is_int "$max_days" && [ "$max_days" -ge 1 ] && [ "$max_days" -le "${B[PASSWORD_MAX_AGE_DAYS]}" ] && [ -z "$shadow_out" ]; then
|
| 205 |
+
add password.expiration $S "Password expiration" Compliant "$max_days days" "1-${B[PASSWORD_MAX_AGE_DAYS]} days" "$(is_root || echo 'Existing accounts not checked (needs root).')"
|
| 206 |
+
else
|
| 207 |
+
add password.expiration $S "Password expiration" Non-Compliant "PASS_MAX_DAYS ${max_days:-unset}${shadow_out:+; accounts: $shadow_out}" "1-${B[PASSWORD_MAX_AGE_DAYS]} days"
|
| 208 |
+
fi
|
| 209 |
+
if is_int "$min_days" && [ "$min_days" -ge "${B[PASSWORD_MIN_AGE_DAYS]}" ] && [ -z "$shadow_min_out" ]; then
|
| 210 |
+
add password.min_age $S "Minimum password age" Compliant "$min_days days" ">= ${B[PASSWORD_MIN_AGE_DAYS]} days"
|
| 211 |
+
else
|
| 212 |
+
add password.min_age $S "Minimum password age" Non-Compliant "PASS_MIN_DAYS ${min_days:-unset}${shadow_min_out:+; accounts: $shadow_min_out}" ">= ${B[PASSWORD_MIN_AGE_DAYS]} days"
|
| 213 |
+
fi
|
| 214 |
+
|
| 215 |
+
if [ -n "$PWQ_LINE" ]; then
|
| 216 |
+
minclass=$(pwq minclass 0); negatives=0
|
| 217 |
+
for credit in dcredit ucredit lcredit ocredit; do
|
| 218 |
+
c=$(pwq "$credit" 0); [ "${c:-0}" -lt 0 ] 2>/dev/null && negatives=$((negatives + 1))
|
| 219 |
+
done
|
| 220 |
+
{ [ "$minclass" -ge 3 ] 2>/dev/null || [ "$negatives" -ge 3 ]; }
|
| 221 |
+
pass_fail $? password.complexity $S "Password complexity" "minclass=$minclass, required classes=$negatives" "3 or more character classes"
|
| 222 |
+
else
|
| 223 |
+
add password.complexity $S "Password complexity" Non-Compliant "pam_pwquality not enabled" "3 or more character classes"
|
| 224 |
+
fi
|
| 225 |
+
|
| 226 |
+
FL_LINE=$(pam_line pam_faillock.so $PAM_AUTH)
|
| 227 |
+
fl() { local v; v=$(pam_arg "$FL_LINE" "$1"); [ -z "$v" ] && v=$(conf_value "$1" /etc/security/faillock.conf); printf '%s' "${v:-$2}"; }
|
| 228 |
+
if [ -n "$FL_LINE" ]; then
|
| 229 |
+
deny=$(fl deny 3); unlock=$(fl unlock_time 600); interval=$(fl fail_interval 900)
|
| 230 |
+
{ [ "$deny" -ge 1 ] && [ "$deny" -le "${B[LOCKOUT_THRESHOLD_MAX]}" ]; } 2>/dev/null
|
| 231 |
+
pass_fail $? lockout.threshold $S "Account lockout threshold" "$deny" "1-${B[LOCKOUT_THRESHOLD_MAX]}" "pam_faillock"
|
| 232 |
+
if [ "$unlock" = "0" ] || [ "$unlock" = "never" ]; then
|
| 233 |
+
add lockout.duration $S "Account lockout duration" Compliant "until an administrator unlocks" ">= ${B[LOCKOUT_DURATION_MIN_MINUTES]} minutes"
|
| 234 |
+
else
|
| 235 |
+
[ "$unlock" -ge $(( B[LOCKOUT_DURATION_MIN_MINUTES] * 60 )) ] 2>/dev/null
|
| 236 |
+
pass_fail $? lockout.duration $S "Account lockout duration" "$(( unlock / 60 )) minutes" ">= ${B[LOCKOUT_DURATION_MIN_MINUTES]} minutes"
|
| 237 |
+
fi
|
| 238 |
+
[ "$interval" -ge $(( B[LOCKOUT_OBSERVATION_MIN_MINUTES] * 60 )) ] 2>/dev/null
|
| 239 |
+
pass_fail $? lockout.observation $S "Lockout observation period" "$(( interval / 60 )) minutes" ">= ${B[LOCKOUT_OBSERVATION_MIN_MINUTES]} minutes"
|
| 240 |
+
else
|
| 241 |
+
add lockout.threshold $S "Account lockout threshold" Non-Compliant "pam_faillock not enabled" "1-${B[LOCKOUT_THRESHOLD_MAX]}"
|
| 242 |
+
add lockout.duration $S "Account lockout duration" Non-Compliant "no lockout" ">= ${B[LOCKOUT_DURATION_MIN_MINUTES]} minutes"
|
| 243 |
+
add lockout.observation $S "Lockout observation period" Non-Compliant "no lockout" ">= ${B[LOCKOUT_OBSERVATION_MIN_MINUTES]} minutes"
|
| 244 |
+
fi
|
| 245 |
+
|
| 246 |
+
# Malware protection: ClamAV, or another endpoint protection product.
|
| 247 |
+
other_av=""
|
| 248 |
+
active mdatp && other_av="${other_av:+$other_av, }Microsoft Defender for Endpoint"
|
| 249 |
+
active falcon-sensor && other_av="${other_av:+$other_av, }CrowdStrike Falcon"
|
| 250 |
+
active sentinelone && other_av="${other_av:+$other_av, }SentinelOne"
|
| 251 |
+
active sophos-spl && other_av="${other_av:+$other_av, }Sophos"
|
| 252 |
+
active esets && other_av="${other_av:+$other_av, }ESET"
|
| 253 |
+
clamd=""; for svc in clamav-daemon clamd@scan clamd; do active "$svc" && clamd=$svc && break; done
|
| 254 |
+
freshclam=""; for svc in clamav-freshclam freshclam clamav-freshclam.timer; do active "$svc" && freshclam=$svc && break; done
|
| 255 |
+
clamonacc=0; { active clamav-clamonacc || pgrep -x clamonacc >/dev/null 2>&1; } && clamonacc=1
|
| 256 |
+
|
| 257 |
+
if [ -n "$clamd" ] || [ -n "$other_av" ]; then
|
| 258 |
+
add malware.antivirus $S "Antivirus" Compliant "${clamd:+ClamAV ($clamd)}${clamd:+${other_av:+, }}$other_av" "antivirus running"
|
| 259 |
+
else
|
| 260 |
+
add malware.antivirus $S "Antivirus" Non-Compliant "none running" "antivirus running"
|
| 261 |
+
fi
|
| 262 |
+
if [ -n "$other_av" ] && [ -z "$clamd" ]; then
|
| 263 |
+
na="Another endpoint protection product is used: $other_av"
|
| 264 |
+
add malware.service $S "ClamAV malware protection service" NotApplicable "not used" "running" "$na"
|
| 265 |
+
add malware.signature_updates $S "Malware signature updates" NotApplicable "not used" "automatic" "$na"
|
| 266 |
+
add malware.signatures $S "Malware signatures" NotApplicable "not used" "<= ${B[SIGNATURE_MAX_AGE_DAYS]} days old" "$na"
|
| 267 |
+
add malware.on_access $S "On-access malware protection" Compliant "$other_av" "on-access scanning" "Provided by $other_av."
|
| 268 |
+
else
|
| 269 |
+
[ -n "$clamd" ]; pass_fail $? malware.service $S "ClamAV malware protection service" "${clamd:-not running}" "running"
|
| 270 |
+
[ -n "$freshclam" ]; pass_fail $? malware.signature_updates $S "Malware signature updates" "${freshclam:-freshclam not running}" "automatic"
|
| 271 |
+
db=$(ls -t /var/lib/clamav/daily.c[lv]d /var/lib/clamav/daily.cld /var/lib/clamav/daily.cvd 2>/dev/null | head -1)
|
| 272 |
+
if [ -n "$db" ]; then
|
| 273 |
+
age=$(days_since "$(stat -c %Y "$db")")
|
| 274 |
+
[ "$age" -le "${B[SIGNATURE_MAX_AGE_DAYS]}" ]; pass_fail $? malware.signatures $S "Malware signatures" "$age days old" "<= ${B[SIGNATURE_MAX_AGE_DAYS]} days old" "$db"
|
| 275 |
+
else
|
| 276 |
+
add malware.signatures $S "Malware signatures" Non-Compliant "no signature database" "<= ${B[SIGNATURE_MAX_AGE_DAYS]} days old"
|
| 277 |
+
fi
|
| 278 |
+
[ "$clamonacc" -eq 1 ]; pass_fail $? malware.on_access $S "On-access malware protection" "$( [ "$clamonacc" -eq 1 ] && echo "clamonacc running" || echo "not running")" "on-access scanning"
|
| 279 |
+
fi
|
| 280 |
+
|
| 281 |
+
# Firewall: ufw, firewalld, nftables or iptables.
|
| 282 |
+
fw_name=""; fw_in=""; fw_out=""; fw_detail=""
|
| 283 |
+
if have ufw && is_root; then
|
| 284 |
+
ufw_out=$(ufw status verbose 2>/dev/null)
|
| 285 |
+
if printf '%s' "$ufw_out" | grep -q "Status: active"; then
|
| 286 |
+
fw_name=ufw
|
| 287 |
+
defaults=$(printf '%s' "$ufw_out" | sed -n 's/^Default: //p')
|
| 288 |
+
fw_in=$(printf '%s' "$defaults" | grep -oE '(deny|reject|allow) \(incoming\)' | cut -d' ' -f1)
|
| 289 |
+
fw_out=$(printf '%s' "$defaults" | grep -oE '(deny|reject|allow) \(outgoing\)' | cut -d' ' -f1)
|
| 290 |
+
fi
|
| 291 |
+
fi
|
| 292 |
+
if [ -z "$fw_name" ] && active firewalld; then
|
| 293 |
+
fw_name=firewalld
|
| 294 |
+
zone=$(firewall-cmd --get-default-zone 2>/dev/null)
|
| 295 |
+
target=$(firewall-cmd --permanent --zone="$zone" --get-target 2>/dev/null)
|
| 296 |
+
case "$target" in ACCEPT) fw_in=allow ;; *) fw_in=deny ;; esac
|
| 297 |
+
fw_out=allow; fw_detail="default zone $zone, target ${target:-default}"
|
| 298 |
+
fi
|
| 299 |
+
if [ -z "$fw_name" ] && have nft && is_root; then
|
| 300 |
+
ruleset=$(nft list ruleset 2>/dev/null)
|
| 301 |
+
if printf '%s' "$ruleset" | grep -q "hook input"; then
|
| 302 |
+
fw_name=nftables
|
| 303 |
+
printf '%s' "$ruleset" | grep -E "hook input.*policy drop" >/dev/null && fw_in=deny || fw_in=allow
|
| 304 |
+
printf '%s' "$ruleset" | grep -E "hook output.*policy drop" >/dev/null && fw_out=deny || fw_out=allow
|
| 305 |
+
fi
|
| 306 |
+
fi
|
| 307 |
+
if [ -z "$fw_name" ] && have iptables && is_root; then
|
| 308 |
+
if iptables -S INPUT 2>/dev/null | grep -q "^-P INPUT DROP"; then
|
| 309 |
+
fw_name=iptables; fw_in=deny
|
| 310 |
+
iptables -S OUTPUT 2>/dev/null | grep -q "^-P OUTPUT DROP" && fw_out=deny || fw_out=allow
|
| 311 |
+
fi
|
| 312 |
+
fi
|
| 313 |
+
if [ -z "$fw_name" ] && ! is_root; then
|
| 314 |
+
needs_root firewall.enabled $S "Firewall" "active"
|
| 315 |
+
needs_root firewall.inbound $S "Firewall inbound policy" "deny"
|
| 316 |
+
needs_root firewall.outbound $S "Firewall outbound policy" "${B[FIREWALL_OUTBOUND]}"
|
| 317 |
+
elif [ -z "$fw_name" ]; then
|
| 318 |
+
add firewall.enabled $S "Firewall" Non-Compliant "no active firewall" "active"
|
| 319 |
+
add firewall.inbound $S "Firewall inbound policy" Non-Compliant "allow (no firewall)" "deny"
|
| 320 |
+
add firewall.outbound $S "Firewall outbound policy" Non-Compliant "no firewall" "${B[FIREWALL_OUTBOUND]}"
|
| 321 |
+
else
|
| 322 |
+
add firewall.enabled $S "Firewall" Compliant "$fw_name active" "active" "$fw_detail"
|
| 323 |
+
case "$fw_in" in deny|reject) pass_fail 0 firewall.inbound $S "Firewall inbound policy" "$fw_in" "deny" ;; *) pass_fail 1 firewall.inbound $S "Firewall inbound policy" "${fw_in:-unknown}" "deny" ;; esac
|
| 324 |
+
out_expected=$(lower "${B[FIREWALL_OUTBOUND]}")
|
| 325 |
+
case "$fw_out" in reject) fw_out_norm=deny ;; *) fw_out_norm=$fw_out ;; esac
|
| 326 |
+
[ "$fw_out_norm" = "$out_expected" ]; pass_fail $? firewall.outbound $S "Firewall outbound policy" "${fw_out:-unknown}" "$out_expected"
|
| 327 |
+
fi
|
| 328 |
+
|
| 329 |
+
# Screen lock (GNOME settings, per user).
|
| 330 |
+
if ! have gsettings || ! gsettings list-schemas 2>/dev/null | grep -q '^org.gnome.desktop.screensaver$'; then
|
| 331 |
+
for pair in "screen_lock.enabled:Screen lock:on" "screen_lock.timeout:Screen lock timeout:<= ${B[SCREEN_LOCK_MAX_SECONDS]} seconds" "screen_lock.password:Screen lock password requirement:password required"; do
|
| 332 |
+
IFS=: read -r cid cname cexp <<< "$pair"
|
| 333 |
+
add "$cid" $S "$cname" NotApplicable "no GNOME desktop" "$cexp" "No graphical desktop settings found."
|
| 334 |
+
done
|
| 335 |
+
else
|
| 336 |
+
lock_bad=""; timeout_bad=""; pass_bad=""; checked=""
|
| 337 |
+
for entry in $HUMAN_USERS; do
|
| 338 |
+
IFS=: read -r u uid home <<< "$entry"
|
| 339 |
+
enabled_v=$(as_user "$u" "$uid" "$home" gsettings get org.gnome.desktop.screensaver lock-enabled)
|
| 340 |
+
[ -z "$enabled_v" ] && continue
|
| 341 |
+
checked="${checked:+$checked, }$u"
|
| 342 |
+
idle=$(as_user "$u" "$uid" "$home" gsettings get org.gnome.desktop.session idle-delay | awk '{ print $NF }')
|
| 343 |
+
delay=$(as_user "$u" "$uid" "$home" gsettings get org.gnome.desktop.screensaver lock-delay | awk '{ print $NF }')
|
| 344 |
+
no_lock=$(as_user "$u" "$uid" "$home" gsettings get org.gnome.desktop.lockdown disable-lock-screen)
|
| 345 |
+
{ [ "$enabled_v" = "true" ] && [ "${idle:-0}" -gt 0 ]; } || lock_bad="${lock_bad:+$lock_bad, }$u"
|
| 346 |
+
{ [ "${idle:-0}" -gt 0 ] && [ $(( idle + ${delay:-0} )) -le "${B[SCREEN_LOCK_MAX_SECONDS]}" ]; } || timeout_bad="${timeout_bad:+$timeout_bad, }$u ($(( ${idle:-0} + ${delay:-0} ))s)"
|
| 347 |
+
{ [ "$enabled_v" = "true" ] && [ "$no_lock" != "true" ]; } || pass_bad="${pass_bad:+$pass_bad, }$u"
|
| 348 |
+
done
|
| 349 |
+
if [ -z "$checked" ]; then
|
| 350 |
+
for pair in "screen_lock.enabled:Screen lock:on" "screen_lock.timeout:Screen lock timeout:<= ${B[SCREEN_LOCK_MAX_SECONDS]} seconds" "screen_lock.password:Screen lock password requirement:password required"; do
|
| 351 |
+
IFS=: read -r cid cname cexp <<< "$pair"
|
| 352 |
+
if is_root; then add "$cid" $S "$cname" NotApplicable "no desktop users" "$cexp"; else needs_root "$cid" $S "$cname" "$cexp"; fi
|
| 353 |
+
done
|
| 354 |
+
else
|
| 355 |
+
[ -z "$lock_bad" ]; pass_fail $? screen_lock.enabled $S "Screen lock" "${lock_bad:+off for: }${lock_bad:-on}" "on" "Users checked: $checked"
|
| 356 |
+
[ -z "$timeout_bad" ]; pass_fail $? screen_lock.timeout $S "Screen lock timeout" "${timeout_bad:+too long for: }${timeout_bad:-within limit}" "<= ${B[SCREEN_LOCK_MAX_SECONDS]} seconds" "Users checked: $checked"
|
| 357 |
+
[ -z "$pass_bad" ]; pass_fail $? screen_lock.password $S "Screen lock password requirement" "${pass_bad:+not required for: }${pass_bad:-required}" "password required" "Users checked: $checked"
|
| 358 |
+
fi
|
| 359 |
+
fi
|
| 360 |
+
|
| 361 |
+
# USB storage
|
| 362 |
+
usb_read=allow; usb_write=allow; usb_detail=""
|
| 363 |
+
if modprobe -n -v usb-storage 2>/dev/null | grep -qE "install[[:space:]]+/bin/(true|false)"; then
|
| 364 |
+
usb_read=deny; usb_write=deny; usb_detail="usb-storage module disabled"
|
| 365 |
+
elif grep -rqsE '^[[:space:]]*blacklist[[:space:]]+usb[-_]storage' /etc/modprobe.d && ! lsmod 2>/dev/null | grep -q '^usb_storage'; then
|
| 366 |
+
usb_read=deny; usb_write=deny; usb_detail="usb-storage module blacklisted"
|
| 367 |
+
elif active usbguard && grep -qsE '^ImplicitPolicyTarget=(block|reject)' /etc/usbguard/usbguard-daemon.conf; then
|
| 368 |
+
usb_read=deny; usb_write=deny; usb_detail="USBGuard blocks unknown devices"
|
| 369 |
+
elif grep -rqsE 'usb.*(ATTR\{ro\}="1"|blockdev --setro)' /etc/udev/rules.d; then
|
| 370 |
+
usb_write=deny; usb_detail="udev rule makes USB storage read-only"
|
| 371 |
+
fi
|
| 372 |
+
[ "$usb_read" = "$(lower "${B[USB_READ]}")" ]; pass_fail $? usb.read $S "USB storage read" "$usb_read" "${B[USB_READ]}" "$usb_detail"
|
| 373 |
+
[ "$usb_write" = "$(lower "${B[USB_WRITE]}")" ]; pass_fail $? usb.write $S "USB storage write" "$usb_write" "${B[USB_WRITE]}" "$usb_detail"
|
| 374 |
+
|
| 375 |
+
# Administrators: sudo/admin/wheel groups, extra UID 0 accounts, sudoers entries.
|
| 376 |
+
admins=""
|
| 377 |
+
for group in sudo admin wheel; do
|
| 378 |
+
members=$(getent group "$group" 2>/dev/null | cut -d: -f4 | tr ',' ' ')
|
| 379 |
+
for m in $members; do admins="$admins $m"; done
|
| 380 |
+
done
|
| 381 |
+
for m in $(awk -F: '$3 == 0 && $1 != "root" { print $1 }' /etc/passwd); do admins="$admins $m"; done
|
| 382 |
+
if is_root; then
|
| 383 |
+
for m in $(grep -hsE '^[a-zA-Z0-9_.-]+[[:space:]]+ALL[[:space:]]*=' /etc/sudoers /etc/sudoers.d/* | awk '{ print $1 }'); do
|
| 384 |
+
[ "$m" = "root" ] || admins="$admins $m"
|
| 385 |
+
done
|
| 386 |
+
fi
|
| 387 |
+
admins=$(printf '%s\n' $admins | sort -u | tr '\n' ' ')
|
| 388 |
+
approved_found=""; unauthorized=""
|
| 389 |
+
for user in $admins; do
|
| 390 |
+
if in_list "$user" "${B[APPROVED_ADMINS]}"; then approved_found="${approved_found:+$approved_found, }$user"
|
| 391 |
+
else unauthorized="${unauthorized:+$unauthorized, }$user"; fi
|
| 392 |
+
done
|
| 393 |
+
[ -n "$approved_found" ]; pass_fail $? admin.approved $S "Approved IT administrator" "${approved_found:-none of the approved accounts is an administrator}" "${B[APPROVED_ADMINS]:-none configured}"
|
| 394 |
+
[ -z "$unauthorized" ]; pass_fail $? admin.unauthorized $S "Unauthorized employee administrator accounts" "${unauthorized:-none}" "none" "$(is_root || echo 'sudoers files not read (needs root).')"
|
| 395 |
+
|
| 396 |
+
# Disk encryption (LUKS under the root filesystem)
|
| 397 |
+
root_src=$(findmnt -no SOURCE / 2>/dev/null)
|
| 398 |
+
# Btrfs subvolumes appear as /dev/mapper/x[/@]: keep the device part only.
|
| 399 |
+
root_src=${root_src%%[*}
|
| 400 |
+
crypt_name=$(lsblk -nslo NAME,TYPE "$root_src" 2>/dev/null | awk '$2 == "crypt" { print $1; exit }')
|
| 401 |
+
if [ -n "$crypt_name" ]; then
|
| 402 |
+
add encryption.enabled $S "Encryption" Compliant "root filesystem on LUKS ($crypt_name)" "system disk encrypted"
|
| 403 |
+
if is_root; then
|
| 404 |
+
status=$(cryptsetup status "$crypt_name" 2>/dev/null)
|
| 405 |
+
ctype=$(printf '%s' "$status" | awk '/type:/ { print $2 }'); cipher=$(printf '%s' "$status" | awk '/cipher:/ { print $2 }')
|
| 406 |
+
keysize=$(printf '%s' "$status" | awk '/keysize:/ { print $2 }')
|
| 407 |
+
{ printf '%s' "$status" | grep -q "is active" && ! printf '%s' "$status" | grep -qi suspended && [ "${keysize:-0}" -ge 256 ]; }
|
| 408 |
+
pass_fail $? encryption.protection $S "Disk encryption protection" "$ctype $cipher ${keysize:-?}-bit key" "active, strong cipher"
|
| 409 |
+
parent=$(lsblk -no PKNAME "/dev/mapper/$crypt_name" 2>/dev/null | head -1)
|
| 410 |
+
dump=$(cryptsetup luksDump "/dev/$parent" 2>/dev/null)
|
| 411 |
+
slots=$(printf '%s' "$dump" | awk '/^Keyslots:/ { k = 1; next } /^[A-Za-z]/ { k = 0 } k && /^[[:space:]]+[0-9]+: luks2/ { n++ } /^Key Slot [0-9]+: ENABLED/ { n++ } END { print n + 0 }')
|
| 412 |
+
tokens=$(printf '%s' "$dump" | grep -cE 'systemd-(tpm2|fido2|recovery)')
|
| 413 |
+
{ [ "$slots" -ge 2 ] || [ "$tokens" -ge 1 ]; }
|
| 414 |
+
pass_fail $? encryption.recovery $S "Disk encryption recovery" "$slots key slots, $tokens unlock tokens" "recovery key or second unlock method"
|
| 415 |
+
else
|
| 416 |
+
needs_root encryption.protection $S "Disk encryption protection" "active, strong cipher"
|
| 417 |
+
needs_root encryption.recovery $S "Disk encryption recovery" "recovery key or second unlock method"
|
| 418 |
+
fi
|
| 419 |
+
else
|
| 420 |
+
add encryption.enabled $S "Encryption" Non-Compliant "root filesystem not encrypted" "system disk encrypted"
|
| 421 |
+
add encryption.protection $S "Disk encryption protection" Non-Compliant "not encrypted" "active, strong cipher"
|
| 422 |
+
add encryption.recovery $S "Disk encryption recovery" Non-Compliant "not encrypted" "recovery key or second unlock method"
|
| 423 |
+
fi
|
| 424 |
+
|
| 425 |
+
# ---------------------------------------------------------------------------
|
| 426 |
+
# 2. GDPR technical controls
|
| 427 |
+
# ---------------------------------------------------------------------------
|
| 428 |
+
S=gdpr
|
| 429 |
+
|
| 430 |
+
if [ "$FAMILY" = "debian" ]; then
|
| 431 |
+
uu=$(apt-config dump 2>/dev/null | awk -F'"' '/^APT::Periodic::Unattended-Upgrade /{ print $2 }')
|
| 432 |
+
dpkg-query -W -f='${Status}' unattended-upgrades 2>/dev/null | grep -q "install ok installed" && uu_pkg=1 || uu_pkg=0
|
| 433 |
+
{ [ "$uu" = "1" ] && [ "$uu_pkg" -eq 1 ]; }
|
| 434 |
+
pass_fail $? gdpr.auto_updates $S "Automatic security updates" "unattended-upgrades installed=$uu_pkg, enabled=${uu:-0}" "automatic"
|
| 435 |
+
elif [ "$FAMILY" = "rhel" ]; then
|
| 436 |
+
apply=$(conf_value apply_updates /etc/dnf/automatic.conf)
|
| 437 |
+
{ { enabled dnf-automatic-install.timer || { enabled dnf-automatic.timer && [ "$apply" = "yes" ]; }; }; }
|
| 438 |
+
pass_fail $? gdpr.auto_updates $S "Automatic security updates" "dnf-automatic timer $(enabled dnf-automatic.timer && echo on || echo off), apply_updates=${apply:-no}" "automatic"
|
| 439 |
+
else
|
| 440 |
+
add gdpr.auto_updates $S "Automatic security updates" Pending "unknown distribution" "automatic" "Check the vendor's update mechanism."
|
| 441 |
+
fi
|
| 442 |
+
|
| 443 |
+
last_update=""
|
| 444 |
+
if [ "$FAMILY" = "debian" ]; then
|
| 445 |
+
last_update=$(zcat -f /var/log/apt/history.log* 2>/dev/null | awk '/^Start-Date:/ { d = $2 " " $3 } /^Upgrade:/ { if (d > last) last = d } END { print last }')
|
| 446 |
+
[ -z "$last_update" ] && [ -f /var/lib/dpkg/status ] && last_update=$(date -d "@$(stat -c %Y /var/lib/dpkg/status)" '+%Y-%m-%d %H:%M:%S')
|
| 447 |
+
elif have rpm; then
|
| 448 |
+
last_update=$(rpm -qa --last 2>/dev/null | head -1 | sed -E 's/^[^ ]+ +//')
|
| 449 |
+
fi
|
| 450 |
+
if [ -n "$last_update" ] && lu_epoch=$(date -d "$last_update" +%s 2>/dev/null); then
|
| 451 |
+
age=$(days_since "$lu_epoch")
|
| 452 |
+
[ "$age" -le "${B[UPDATES_MAX_AGE_DAYS]}" ]; pass_fail $? gdpr.updates_recent $S "Security updates installed recently" "$age days ago" "<= ${B[UPDATES_MAX_AGE_DAYS]} days ago" "Last package upgrade: $last_update"
|
| 453 |
+
else
|
| 454 |
+
add gdpr.updates_recent $S "Security updates installed recently" Error "not read" "<= ${B[UPDATES_MAX_AGE_DAYS]} days ago" "No package upgrade history found."
|
| 455 |
+
fi
|
| 456 |
+
|
| 457 |
+
case "$OS_ID" in
|
| 458 |
+
ubuntu) version_ge "$OS_VERSION" "${B[UBUNTU_MIN_VERSION]}"; pass_fail $? gdpr.os_supported $S "Supported operating system version" "$OS_PRETTY" "Ubuntu ${B[UBUNTU_MIN_VERSION]} or later" ;;
|
| 459 |
+
debian) version_ge "${OS_VERSION%%.*}" "${B[DEBIAN_MIN_VERSION]}"; pass_fail $? gdpr.os_supported $S "Supported operating system version" "$OS_PRETTY" "Debian ${B[DEBIAN_MIN_VERSION]} or later" ;;
|
| 460 |
+
rhel|rocky|almalinux|centos) version_ge "${OS_VERSION%%.*}" "${B[RHEL_MIN_VERSION]}"; pass_fail $? gdpr.os_supported $S "Supported operating system version" "$OS_PRETTY" "RHEL ${B[RHEL_MIN_VERSION]} or later" ;;
|
| 461 |
+
*) add gdpr.os_supported $S "Supported operating system version" Pending "$OS_PRETTY" "vendor-supported release" "Unknown distribution: check the vendor's support dates." ;;
|
| 462 |
+
esac
|
| 463 |
+
|
| 464 |
+
journal="volatile"; [ -d /var/log/journal ] && journal="persistent"
|
| 465 |
+
active auditd; pass_fail $? gdpr.security_logging $S "Security event logging" "auditd $(active auditd && echo running || echo 'not running'), journal $journal" "auditd running"
|
| 466 |
+
|
| 467 |
+
ntp_sync=$(timedatectl show -p NTPSynchronized --value 2>/dev/null); ntp_on=$(timedatectl show -p NTP --value 2>/dev/null)
|
| 468 |
+
{ [ "$ntp_on" = "yes" ] || active chronyd || active chrony || active ntpd || active ntp; }
|
| 469 |
+
pass_fail $? gdpr.time_sync $S "Time synchronisation" "NTP service ${ntp_on:-unknown}, synchronised ${ntp_sync:-unknown}" "time synchronised"
|
| 470 |
+
|
| 471 |
+
backup_tool=""; backup_date=""
|
| 472 |
+
if [ -f /etc/timeshift/timeshift.json ] && grep -qE '"schedule_[a-z]+"[[:space:]]*:[[:space:]]*"true"' /etc/timeshift/timeshift.json; then
|
| 473 |
+
backup_tool="Timeshift"
|
| 474 |
+
snap=$(ls -1d /timeshift/snapshots/* /run/timeshift/*/backup/timeshift*/snapshots/* 2>/dev/null | grep -oE '[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}-[0-9]{2}-[0-9]{2}' | sort | tail -1)
|
| 475 |
+
[ -n "$snap" ] && backup_date=$(echo "$snap" | sed -E 's/_/ /; s/-([0-9]{2})-([0-9]{2})$/:\1:\2/')
|
| 476 |
+
fi
|
| 477 |
+
if have gsettings; then
|
| 478 |
+
for entry in $HUMAN_USERS; do
|
| 479 |
+
IFS=: read -r u uid home <<< "$entry"
|
| 480 |
+
if [ "$(as_user "$u" "$uid" "$home" gsettings get org.gnome.DejaDup periodic)" = "true" ]; then
|
| 481 |
+
backup_tool="${backup_tool:+$backup_tool, }Deja Dup ($u)"
|
| 482 |
+
last=$(as_user "$u" "$uid" "$home" gsettings get org.gnome.DejaDup last-backup | tr -d "'")
|
| 483 |
+
[ -n "$last" ] && { [ -z "$backup_date" ] || [[ "$last" > "$backup_date" ]]; } && backup_date=$last
|
| 484 |
+
fi
|
| 485 |
+
done
|
| 486 |
+
fi
|
| 487 |
+
timers=$(systemctl list-timers --all --no-legend 2>/dev/null | grep -oiE '[a-z0-9@_.-]*(restic|borg|backup)[a-z0-9@_.-]*\.timer' | sort -u | tr '\n' ' ')
|
| 488 |
+
[ -n "$timers" ] && backup_tool="${backup_tool:+$backup_tool, }$timers"
|
| 489 |
+
if [ -n "$backup_tool" ]; then
|
| 490 |
+
add gdpr.backup_configured $S "Backup configured" Compliant "$backup_tool" "backup configured"
|
| 491 |
+
if [ -n "$backup_date" ] && b_epoch=$(date -d "$backup_date" +%s 2>/dev/null); then
|
| 492 |
+
age=$(days_since "$b_epoch")
|
| 493 |
+
[ "$age" -le "${B[BACKUP_MAX_AGE_DAYS]}" ]; pass_fail $? gdpr.backup_recent $S "Recent backup" "$age days ago" "<= ${B[BACKUP_MAX_AGE_DAYS]} days ago"
|
| 494 |
+
else
|
| 495 |
+
add gdpr.backup_recent $S "Recent backup" Pending "date not readable" "<= ${B[BACKUP_MAX_AGE_DAYS]} days ago" "Confirm the last backup date in: $backup_tool"
|
| 496 |
+
fi
|
| 497 |
+
else
|
| 498 |
+
add gdpr.backup_configured $S "Backup configured" Non-Compliant "none" "backup configured"
|
| 499 |
+
add gdpr.backup_recent $S "Recent backup" Non-Compliant "no backup" "<= ${B[BACKUP_MAX_AGE_DAYS]} days ago"
|
| 500 |
+
fi
|
| 501 |
+
|
| 502 |
+
[ "$usb_write" = "deny" ]; pass_fail $? gdpr.removable_encryption $S "Removable media encryption" "$( [ "$usb_write" = "deny" ] && echo "writing to USB storage blocked" || echo "USB storage writable without enforced encryption")" "no unencrypted writes"
|
| 503 |
+
|
| 504 |
+
guest_issue=""
|
| 505 |
+
grep -rqsiE '^[[:space:]]*allow-guest[[:space:]]*=[[:space:]]*true' /etc/lightdm && guest_issue="LightDM guest session allowed"
|
| 506 |
+
awk -F: '$1 == "guest" && $7 !~ /(nologin|false)$/' /etc/passwd | grep -q . && guest_issue="${guest_issue:+$guest_issue; }guest account with login shell"
|
| 507 |
+
[ -z "$guest_issue" ]; pass_fail $? gdpr.guest_account $S "Guest account disabled" "${guest_issue:-no guest login}" "no guest login"
|
| 508 |
+
|
| 509 |
+
remote=""
|
| 510 |
+
{ active ssh || active sshd || active ssh.socket; } && remote="${remote:+$remote, }ssh"
|
| 511 |
+
active xrdp && remote="${remote:+$remote, }rdp"
|
| 512 |
+
{ active gnome-remote-desktop || pgrep -x gnome-remote-de >/dev/null 2>&1; } && remote="${remote:+$remote, }rdp"
|
| 513 |
+
pgrep -x -f '.*(x11vnc|Xvnc|vncserver).*' >/dev/null 2>&1 && remote="${remote:+$remote, }vnc"
|
| 514 |
+
{ active smbd || active smb; } && remote="${remote:+$remote, }smb"
|
| 515 |
+
active teamviewerd && remote="${remote:+$remote, }teamviewer"
|
| 516 |
+
active anydesk && remote="${remote:+$remote, }anydesk"
|
| 517 |
+
pgrep -f chrome-remote-desktop >/dev/null 2>&1 && remote="${remote:+$remote, }chromeremotedesktop"
|
| 518 |
+
remote=$(printf '%s' "$remote" | tr ',' '\n' | sed 's/^ *//' | sort -u | paste -sd, - | sed 's/,/, /g')
|
| 519 |
+
unapproved_remote=""
|
| 520 |
+
IFS=, read -ra svcs <<< "$remote"
|
| 521 |
+
for svc in "${svcs[@]}"; do
|
| 522 |
+
svc=$(printf '%s' "$svc" | sed 's/^ *//'); [ -z "$svc" ] && continue
|
| 523 |
+
in_list "$svc" "${B[APPROVED_REMOTE_SERVICES]}" || unapproved_remote="${unapproved_remote:+$unapproved_remote, }$svc"
|
| 524 |
+
done
|
| 525 |
+
[ -z "$unapproved_remote" ]; pass_fail $? gdpr.remote_access $S "Remote access services restricted" "${unapproved_remote:-${remote:-none enabled}}" "only: ${B[APPROVED_REMOTE_SERVICES]:-none}"
|
| 526 |
+
|
| 527 |
+
telemetry=""
|
| 528 |
+
grep -qsE '^PARTICIPATE="?yes' /etc/popularity-contest.conf && telemetry="popularity-contest on"
|
| 529 |
+
active whoopsie && telemetry="${telemetry:+$telemetry, }whoopsie error reporting on"
|
| 530 |
+
[ -z "$telemetry" ]; pass_fail $? gdpr.telemetry $S "Diagnostic data minimised" "${telemetry:-usage reporting off}" "usage reporting off"
|
| 531 |
+
|
| 532 |
+
add gdpr.personal_data_discovery $S "Personal data discovery" NotApplicable "not run" "opt-in scan" "Opt-in scan (PERSONAL_DATA_SCAN=${B[PERSONAL_DATA_SCAN]}) is planned for a later stage."
|
| 533 |
+
|
| 534 |
+
# ---------------------------------------------------------------------------
|
| 535 |
+
# 3. EU AI Act controls
|
| 536 |
+
# ---------------------------------------------------------------------------
|
| 537 |
+
S=aiact
|
| 538 |
+
|
| 539 |
+
ai_tools=""; ai_detail=""
|
| 540 |
+
add_tool() { case ",$ai_tools," in *",$1,"*) ;; *) ai_tools="${ai_tools:+$ai_tools,}$1" ;; esac; }
|
| 541 |
+
homes=$(printf '%s\n' "$HUMAN_USERS" | cut -d: -f3)
|
| 542 |
+
for cli in "ollama:Ollama" "lms:LM Studio" "gpt4all:GPT4All" "jan:Jan" "claude:Claude Code"; do
|
| 543 |
+
name=${cli%%:*}
|
| 544 |
+
found=0; have "$name" && found=1
|
| 545 |
+
for h in $homes; do [ -x "$h/.local/bin/$name" ] && found=1; done
|
| 546 |
+
[ "$found" -eq 1 ] && add_tool "${cli##*:}"
|
| 547 |
+
done
|
| 548 |
+
if have snap; then
|
| 549 |
+
for s in $(snap list 2>/dev/null | awk 'NR > 1 { print $1 }'); do
|
| 550 |
+
case "$s" in *ollama*) add_tool Ollama ;; *gpt4all*) add_tool GPT4All ;; *chatgpt*) add_tool ChatGPT ;; *claude*) add_tool Claude ;; *lm-studio*|*lmstudio*) add_tool "LM Studio" ;; esac
|
| 551 |
+
done
|
| 552 |
+
fi
|
| 553 |
+
if have flatpak; then
|
| 554 |
+
for f in $(flatpak list --app --columns=application 2>/dev/null); do
|
| 555 |
+
case "$(lower "$f")" in *gpt4all*) add_tool GPT4All ;; *jan*) add_tool Jan ;; *alpaca*) add_tool "Alpaca (Ollama client)" ;; *chatgpt*) add_tool ChatGPT ;; esac
|
| 556 |
+
done
|
| 557 |
+
fi
|
| 558 |
+
for d in /opt/LM-Studio /opt/lm-studio /opt/Jan /opt/gpt4all; do [ -d "$d" ] && add_tool "$(basename "$d")"; done
|
| 559 |
+
if ss -ltn 2>/dev/null | grep -q ':11434 '; then add_tool Ollama; ai_detail="${ai_detail}Local AI service listening on port 11434. "; fi
|
| 560 |
+
for h in $homes /usr/share/ollama; do
|
| 561 |
+
for pair in ".ollama/models:Ollama" ".cache/lm-studio/models:LM Studio" ".lmstudio/models:LM Studio" ".local/share/nomic.ai/GPT4All:GPT4All" ".cache/huggingface/hub:Hugging Face models"; do
|
| 562 |
+
path="$h/${pair%%:*}"
|
| 563 |
+
if [ -d "$path" ] && [ -n "$(ls -A "$path" 2>/dev/null)" ]; then
|
| 564 |
+
add_tool "${pair##*:}"; ai_detail="${ai_detail}Local models: $path ($(du -sh "$path" 2>/dev/null | awk '{ print $1 }')). "
|
| 565 |
+
fi
|
| 566 |
+
done
|
| 567 |
+
done
|
| 568 |
+
ai_list=$(printf '%s' "$ai_tools" | sed 's/,/, /g')
|
| 569 |
+
add aiact.inventory $S "AI software inventory" Compliant "${ai_list:-no AI tools found}" "inventory taken" "$ai_detail"
|
| 570 |
+
|
| 571 |
+
unapproved_ai=""
|
| 572 |
+
IFS=, read -ra tools <<< "$ai_tools"
|
| 573 |
+
for tool in "${tools[@]}"; do
|
| 574 |
+
[ -z "$tool" ] && continue
|
| 575 |
+
in_list "$tool" "${B[APPROVED_AI_TOOLS]}" || unapproved_ai="${unapproved_ai:+$unapproved_ai, }$tool"
|
| 576 |
+
done
|
| 577 |
+
[ -z "$unapproved_ai" ]; pass_fail $? aiact.unapproved $S "Unapproved AI tools (shadow AI)" "${unapproved_ai:-none}" "only: ${B[APPROVED_AI_TOOLS]:-none}"
|
| 578 |
+
|
| 579 |
+
if [ -n "$ai_tools" ]; then
|
| 580 |
+
add aiact.risk_classification $S "AI system risk classification" Pending "$ai_list" "every AI tool classified" "Classified by the auditor model (Caveat classifier) in a later step."
|
| 581 |
+
add aiact.os_ai_features $S "Operating system AI features restricted" NotApplicable "none built in" "restricted by policy" "Linux distributions ship no built-in AI assistant."
|
| 582 |
+
add aiact.log_retention $S "AI usage log retention" Pending "depends on classification" ">= 6 months for high-risk AI" "Required only if a tool is classified high-risk."
|
| 583 |
+
else
|
| 584 |
+
add aiact.risk_classification $S "AI system risk classification" NotApplicable "no AI tools found" "every AI tool classified"
|
| 585 |
+
add aiact.os_ai_features $S "Operating system AI features restricted" NotApplicable "none built in" "restricted by policy" "Linux distributions ship no built-in AI assistant."
|
| 586 |
+
add aiact.log_retention $S "AI usage log retention" NotApplicable "no AI tools found" ">= 6 months for high-risk AI"
|
| 587 |
+
fi
|
| 588 |
+
|
| 589 |
+
# ---------------------------------------------------------------------------
|
| 590 |
+
# 4. Organisational controls (answers from organisational.conf)
|
| 591 |
+
# ---------------------------------------------------------------------------
|
| 592 |
+
S=org
|
| 593 |
+
org() { # id key name
|
| 594 |
+
local answer
|
| 595 |
+
answer=$(lower "$(conf_get "$ORG_FILE" "$2" "")")
|
| 596 |
+
case "$answer" in
|
| 597 |
+
yes) add "$1" $S "$3" Compliant "yes" "yes" ;;
|
| 598 |
+
no) add "$1" $S "$3" Non-Compliant "no" "yes" ;;
|
| 599 |
+
partial) add "$1" $S "$3" Non-Compliant "partial" "yes" ;;
|
| 600 |
+
n/a|na) add "$1" $S "$3" NotApplicable "n/a" "yes" ;;
|
| 601 |
+
*) add "$1" $S "$3" Pending "not answered" "yes" "Answer $2 in organisational.conf." ;;
|
| 602 |
+
esac
|
| 603 |
+
}
|
| 604 |
+
org org.ropa ORG_ROPA "Record of processing activities"
|
| 605 |
+
org org.dpia ORG_DPIA "Data protection impact assessment"
|
| 606 |
+
org org.breach_procedure ORG_BREACH_PROCEDURE "Breach response procedure"
|
| 607 |
+
org org.processor_agreements ORG_PROCESSOR_AGREEMENTS "Data processing agreements"
|
| 608 |
+
org org.ai_register ORG_AI_REGISTER "AI register"
|
| 609 |
+
org org.ai_literacy ORG_AI_LITERACY "AI literacy training"
|
| 610 |
+
org org.human_oversight ORG_HUMAN_OVERSIGHT "Human oversight for high-risk AI"
|
| 611 |
+
org org.ai_transparency ORG_AI_TRANSPARENCY "AI transparency to users"
|
| 612 |
+
|
| 613 |
+
# ---------------------------------------------------------------------------
|
| 614 |
+
# Report
|
| 615 |
+
# ---------------------------------------------------------------------------
|
| 616 |
+
LINE="============================================================"
|
| 617 |
+
print_section() { # section-id title
|
| 618 |
+
local i
|
| 619 |
+
echo "$LINE"; echo " $2"; echo "$LINE"
|
| 620 |
+
for i in "${!R_ID[@]}"; do
|
| 621 |
+
if [ "${R_SECTION[$i]}" = "$1" ]; then echo "${R_NAME[$i]}"; echo "${R_STATUS[$i]}"; fi
|
| 622 |
+
done
|
| 623 |
+
}
|
| 624 |
+
report() {
|
| 625 |
+
echo "$LINE"
|
| 626 |
+
echo " LINUX ENDPOINT SECURITY BASELINE - AUDIT"
|
| 627 |
+
echo "$LINE"
|
| 628 |
+
echo "Hostname : $HOSTNAME_VALUE"
|
| 629 |
+
echo "Operating System: $OS_PRETTY"
|
| 630 |
+
echo "OS Version : $OS_VERSION"
|
| 631 |
+
echo "Kernel : $KERNEL"
|
| 632 |
+
echo "Serial Number : $SERIAL"
|
| 633 |
+
echo "Execution User : $EXEC_USER"
|
| 634 |
+
echo "Audit Date : $AUDIT_DATE"
|
| 635 |
+
echo "Audit Mode : READ-ONLY"
|
| 636 |
+
echo "$LINE"
|
| 637 |
+
print_section baseline "LINUX ENDPOINT SECURITY BASELINE - RESULTS"
|
| 638 |
+
print_section gdpr "GDPR TECHNICAL CONTROLS"
|
| 639 |
+
print_section aiact "EU AI ACT CONTROLS"
|
| 640 |
+
print_section org "ORGANISATIONAL CONTROLS"
|
| 641 |
+
echo "$LINE"
|
| 642 |
+
echo " END OF LINUX ENDPOINT SECURITY BASELINE AUDIT"
|
| 643 |
+
echo "$LINE"
|
| 644 |
+
}
|
| 645 |
+
|
| 646 |
+
count() { local i n=0; for i in "${!R_STATUS[@]}"; do [ "${R_STATUS[$i]}" = "$1" ] && n=$((n + 1)); done; echo $n; }
|
| 647 |
+
|
| 648 |
+
evidence_json() {
|
| 649 |
+
local i key first=1
|
| 650 |
+
printf '{\n "schema_version": "1.0",\n "catalog_version": "%s",\n' "$CATALOG_VERSION"
|
| 651 |
+
printf ' "source": { "kind": "collector", "collector": "linux", "collector_version": "%s" },\n' "$COLLECTOR_VERSION"
|
| 652 |
+
printf ' "audit": { "mode": "READ-ONLY", "started_at": "%s", "execution_user": "%s", "is_admin": %s },\n' "$STARTED_AT" "$(json_escape "$EXEC_USER")" "$IS_ADMIN"
|
| 653 |
+
printf ' "host": { "hostname": "%s", "os_family": "linux", "os_name": "%s", "os_version": "%s", "kernel": "%s", "architecture": "%s", "serial_number": "%s" },\n' \
|
| 654 |
+
"$(json_escape "$HOSTNAME_VALUE")" "$(json_escape "$OS_PRETTY")" "$(json_escape "$OS_VERSION")" "$(json_escape "$KERNEL")" "$ARCH" "$(json_escape "$SERIAL")"
|
| 655 |
+
printf ' "baseline": {'
|
| 656 |
+
for key in $BASELINE_KEYS; do
|
| 657 |
+
[ $first -eq 1 ] && first=0 || printf ','
|
| 658 |
+
printf ' "%s": "%s"' "$key" "$(json_escape "${B[$key]}")"
|
| 659 |
+
done
|
| 660 |
+
printf ' },\n "results": [\n'
|
| 661 |
+
for i in "${!R_ID[@]}"; do
|
| 662 |
+
[ "$i" -gt 0 ] && printf ',\n'
|
| 663 |
+
printf ' { "id": "%s", "section": "%s", "name": "%s", "status": "%s", "actual": "%s", "expected": "%s"' \
|
| 664 |
+
"${R_ID[$i]}" "${R_SECTION[$i]}" "$(json_escape "${R_NAME[$i]}")" "${R_STATUS[$i]}" "$(json_escape "${R_ACTUAL[$i]}")" "$(json_escape "${R_EXPECTED[$i]}")"
|
| 665 |
+
[ -n "${R_DETAIL[$i]}" ] && printf ', "detail": "%s"' "$(json_escape "${R_DETAIL[$i]}")"
|
| 666 |
+
printf ' }'
|
| 667 |
+
done
|
| 668 |
+
printf '\n ],\n "summary": { "Compliant": %s, "Non-Compliant": %s, "NotApplicable": %s, "Pending": %s, "Error": %s }\n}\n' \
|
| 669 |
+
"$(count Compliant)" "$(count Non-Compliant)" "$(count NotApplicable)" "$(count Pending)" "$(count Error)"
|
| 670 |
+
}
|
| 671 |
+
|
| 672 |
+
is_root || echo "Warning: not running as root. Some checks will report Error. Run: sudo $0" >&2
|
| 673 |
+
|
| 674 |
+
STAMP=$(date '+%Y%m%d-%H%M%S')
|
| 675 |
+
BASENAME="audit-linux-$HOSTNAME_VALUE-$STAMP"
|
| 676 |
+
if mkdir -p "$OUTPUT_DIR" 2>/dev/null && [ -w "$OUTPUT_DIR" ]; then
|
| 677 |
+
report | tee "$OUTPUT_DIR/$BASENAME.txt"
|
| 678 |
+
evidence_json > "$OUTPUT_DIR/$BASENAME.json"
|
| 679 |
+
echo "Report: $OUTPUT_DIR/$BASENAME.txt" >&2
|
| 680 |
+
echo "Evidence: $OUTPUT_DIR/$BASENAME.json" >&2
|
| 681 |
+
else
|
| 682 |
+
report
|
| 683 |
+
echo "Could not write to $OUTPUT_DIR (use --output DIR). Evidence not saved." >&2
|
| 684 |
+
exit 2
|
| 685 |
+
fi
|
| 686 |
+
|
| 687 |
+
[ "$(count Non-Compliant)" -gt 0 ] && exit 1
|
| 688 |
+
exit 0
|
collectors/macos/audit.sh
ADDED
|
@@ -0,0 +1,713 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
# Devseis endpoint audit - macOS collector
|
| 3 |
+
#
|
| 4 |
+
# READ-ONLY: this script only reads settings. It never changes the system.
|
| 5 |
+
# The only files it writes are the report (.txt) and evidence (.json) in the output folder.
|
| 6 |
+
#
|
| 7 |
+
# Run as root for complete results: sudo ./audit.sh
|
| 8 |
+
# Options: --baseline FILE --org FILE --output DIR --help
|
| 9 |
+
#
|
| 10 |
+
# Compatible with the bash 3.2 that ships with macOS.
|
| 11 |
+
|
| 12 |
+
set -u
|
| 13 |
+
|
| 14 |
+
COLLECTOR_VERSION="0.1.0"
|
| 15 |
+
CATALOG_VERSION="0.1.0"
|
| 16 |
+
|
| 17 |
+
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
| 18 |
+
ROOT_DIR=$(cd "$SCRIPT_DIR/../.." && pwd)
|
| 19 |
+
BASELINE_FILE="$ROOT_DIR/baseline.conf"
|
| 20 |
+
ORG_FILE="$ROOT_DIR/organisational.conf"
|
| 21 |
+
OUTPUT_DIR="/Library/Logs/Devseis/EndpointAudit"
|
| 22 |
+
|
| 23 |
+
while [ $# -gt 0 ]; do
|
| 24 |
+
case "$1" in
|
| 25 |
+
--baseline) BASELINE_FILE="$2"; shift 2 ;;
|
| 26 |
+
--org) ORG_FILE="$2"; shift 2 ;;
|
| 27 |
+
--output) OUTPUT_DIR="$2"; shift 2 ;;
|
| 28 |
+
-h|--help) sed -n '2,12p' "$0"; exit 0 ;;
|
| 29 |
+
*) echo "Unknown option: $1" >&2; exit 2 ;;
|
| 30 |
+
esac
|
| 31 |
+
done
|
| 32 |
+
|
| 33 |
+
# ---------------------------------------------------------------------------
|
| 34 |
+
# Helpers
|
| 35 |
+
# ---------------------------------------------------------------------------
|
| 36 |
+
|
| 37 |
+
# Read KEY=VALUE from a config file without executing it. Usage: conf_get FILE KEY DEFAULT
|
| 38 |
+
conf_get() {
|
| 39 |
+
local value=""
|
| 40 |
+
if [ -r "$1" ]; then
|
| 41 |
+
value=$(awk -v key="$2" '
|
| 42 |
+
/^[[:space:]]*#/ { next }
|
| 43 |
+
index($0, "=") { k = substr($0, 1, index($0, "=") - 1); gsub(/[[:space:]]/, "", k)
|
| 44 |
+
if (k == key) { v = substr($0, index($0, "=") + 1) } }
|
| 45 |
+
END { print v }' "$1")
|
| 46 |
+
value=$(printf '%s' "$value" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e 's/^"\(.*\)"$/\1/')
|
| 47 |
+
fi
|
| 48 |
+
if [ -n "$value" ]; then printf '%s' "$value"; else printf '%s' "$3"; fi
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
BASELINE_KEYS="PASSWORD_MIN_LENGTH PASSWORD_HISTORY PASSWORD_MAX_AGE_DAYS PASSWORD_MIN_AGE_DAYS PASSWORD_COMPLEXITY LOCKOUT_THRESHOLD_MAX LOCKOUT_DURATION_MIN_MINUTES LOCKOUT_OBSERVATION_MIN_MINUTES SIGNATURE_MAX_AGE_DAYS MACOS_XPROTECT_MAX_AGE_DAYS FIREWALL_OUTBOUND SCREEN_LOCK_MAX_SECONDS USB_READ USB_WRITE APPROVED_ADMINS ALLOW_DISABLED_BUILTIN_ADMIN UPDATES_MAX_AGE_DAYS BACKUP_MAX_AGE_DAYS APPROVED_REMOTE_SERVICES WINDOWS_MIN_BUILD MACOS_MIN_MAJOR UBUNTU_MIN_VERSION DEBIAN_MIN_VERSION RHEL_MIN_VERSION PERSONAL_DATA_SCAN APPROVED_AI_TOOLS"
|
| 52 |
+
|
| 53 |
+
B_PASSWORD_MIN_LENGTH=$(conf_get "$BASELINE_FILE" PASSWORD_MIN_LENGTH 14)
|
| 54 |
+
B_PASSWORD_HISTORY=$(conf_get "$BASELINE_FILE" PASSWORD_HISTORY 24)
|
| 55 |
+
B_PASSWORD_MAX_AGE_DAYS=$(conf_get "$BASELINE_FILE" PASSWORD_MAX_AGE_DAYS 365)
|
| 56 |
+
B_PASSWORD_MIN_AGE_DAYS=$(conf_get "$BASELINE_FILE" PASSWORD_MIN_AGE_DAYS 1)
|
| 57 |
+
B_PASSWORD_COMPLEXITY=$(conf_get "$BASELINE_FILE" PASSWORD_COMPLEXITY 1)
|
| 58 |
+
B_LOCKOUT_THRESHOLD_MAX=$(conf_get "$BASELINE_FILE" LOCKOUT_THRESHOLD_MAX 5)
|
| 59 |
+
B_LOCKOUT_DURATION_MIN_MINUTES=$(conf_get "$BASELINE_FILE" LOCKOUT_DURATION_MIN_MINUTES 15)
|
| 60 |
+
B_LOCKOUT_OBSERVATION_MIN_MINUTES=$(conf_get "$BASELINE_FILE" LOCKOUT_OBSERVATION_MIN_MINUTES 15)
|
| 61 |
+
B_SIGNATURE_MAX_AGE_DAYS=$(conf_get "$BASELINE_FILE" SIGNATURE_MAX_AGE_DAYS 3)
|
| 62 |
+
B_MACOS_XPROTECT_MAX_AGE_DAYS=$(conf_get "$BASELINE_FILE" MACOS_XPROTECT_MAX_AGE_DAYS 30)
|
| 63 |
+
B_FIREWALL_OUTBOUND=$(conf_get "$BASELINE_FILE" FIREWALL_OUTBOUND allow)
|
| 64 |
+
B_SCREEN_LOCK_MAX_SECONDS=$(conf_get "$BASELINE_FILE" SCREEN_LOCK_MAX_SECONDS 900)
|
| 65 |
+
B_USB_READ=$(conf_get "$BASELINE_FILE" USB_READ allow)
|
| 66 |
+
B_USB_WRITE=$(conf_get "$BASELINE_FILE" USB_WRITE deny)
|
| 67 |
+
B_APPROVED_ADMINS=$(conf_get "$BASELINE_FILE" APPROVED_ADMINS "")
|
| 68 |
+
B_ALLOW_DISABLED_BUILTIN_ADMIN=$(conf_get "$BASELINE_FILE" ALLOW_DISABLED_BUILTIN_ADMIN 1)
|
| 69 |
+
B_UPDATES_MAX_AGE_DAYS=$(conf_get "$BASELINE_FILE" UPDATES_MAX_AGE_DAYS 30)
|
| 70 |
+
B_BACKUP_MAX_AGE_DAYS=$(conf_get "$BASELINE_FILE" BACKUP_MAX_AGE_DAYS 7)
|
| 71 |
+
B_APPROVED_REMOTE_SERVICES=$(conf_get "$BASELINE_FILE" APPROVED_REMOTE_SERVICES "")
|
| 72 |
+
B_WINDOWS_MIN_BUILD=$(conf_get "$BASELINE_FILE" WINDOWS_MIN_BUILD 22631)
|
| 73 |
+
B_MACOS_MIN_MAJOR=$(conf_get "$BASELINE_FILE" MACOS_MIN_MAJOR 14)
|
| 74 |
+
B_UBUNTU_MIN_VERSION=$(conf_get "$BASELINE_FILE" UBUNTU_MIN_VERSION 22.04)
|
| 75 |
+
B_DEBIAN_MIN_VERSION=$(conf_get "$BASELINE_FILE" DEBIAN_MIN_VERSION 12)
|
| 76 |
+
B_RHEL_MIN_VERSION=$(conf_get "$BASELINE_FILE" RHEL_MIN_VERSION 9)
|
| 77 |
+
B_PERSONAL_DATA_SCAN=$(conf_get "$BASELINE_FILE" PERSONAL_DATA_SCAN 0)
|
| 78 |
+
B_APPROVED_AI_TOOLS=$(conf_get "$BASELINE_FILE" APPROVED_AI_TOOLS "")
|
| 79 |
+
|
| 80 |
+
is_root() { [ "$(id -u)" -eq 0 ]; }
|
| 81 |
+
is_int() { case "$1" in ''|*[!0-9-]*) return 1 ;; *) return 0 ;; esac; }
|
| 82 |
+
lower() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]'; }
|
| 83 |
+
now_epoch() { date +%s; }
|
| 84 |
+
days_since() { echo $(( ( $(now_epoch) - $1 ) / 86400 )); }
|
| 85 |
+
|
| 86 |
+
# Is VALUE in a comma-separated LIST? Case-insensitive, "*" wildcards allowed.
|
| 87 |
+
in_list() {
|
| 88 |
+
local value item old_ifs="$IFS" found=1
|
| 89 |
+
value=$(lower "$1")
|
| 90 |
+
set -f
|
| 91 |
+
IFS=,
|
| 92 |
+
for item in $2; do
|
| 93 |
+
item=$(lower "$(printf '%s' "$item" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')")
|
| 94 |
+
[ -z "$item" ] && continue
|
| 95 |
+
# shellcheck disable=SC2254
|
| 96 |
+
case "$value" in $item) found=0; break ;; esac
|
| 97 |
+
done
|
| 98 |
+
IFS="$old_ifs"
|
| 99 |
+
set +f
|
| 100 |
+
return $found
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
json_escape() {
|
| 104 |
+
printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/ /\\t/g' | awk 'NR > 1 { printf "\\n" } { printf "%s", $0 }' | tr -d '\000-\010\013\014\016-\037'
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
# Results are kept in parallel arrays (bash 3.2 has no associative arrays).
|
| 108 |
+
R_ID=(); R_SECTION=(); R_NAME=(); R_STATUS=(); R_ACTUAL=(); R_EXPECTED=(); R_DETAIL=()
|
| 109 |
+
add() { # id section name status actual expected [detail]
|
| 110 |
+
R_ID+=("$1"); R_SECTION+=("$2"); R_NAME+=("$3"); R_STATUS+=("$4")
|
| 111 |
+
R_ACTUAL+=("$5"); R_EXPECTED+=("$6"); R_DETAIL+=("${7:-}")
|
| 112 |
+
}
|
| 113 |
+
needs_root() { # id section name expected
|
| 114 |
+
add "$1" "$2" "$3" "Error" "not read" "$4" "Requires root: run the collector with sudo."
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
CONSOLE_USER=$(stat -f %Su /dev/console 2>/dev/null)
|
| 118 |
+
[ "$CONSOLE_USER" = "root" ] && CONSOLE_USER=""
|
| 119 |
+
# Run a read-only command as the signed-in user (their preferences live in their own domain).
|
| 120 |
+
as_console_user() {
|
| 121 |
+
if is_root && [ -n "$CONSOLE_USER" ]; then sudo -u "$CONSOLE_USER" "$@"; else "$@"; fi
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
MANAGED="/Library/Managed Preferences"
|
| 125 |
+
managed_read() { # domain key -> value from a configuration profile, if any
|
| 126 |
+
[ -f "$MANAGED/$1.plist" ] && defaults read "$MANAGED/$1" "$2" 2>/dev/null
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
# ---------------------------------------------------------------------------
|
| 130 |
+
# Host information
|
| 131 |
+
# ---------------------------------------------------------------------------
|
| 132 |
+
HOSTNAME_VALUE=$(scutil --get LocalHostName 2>/dev/null || hostname -s)
|
| 133 |
+
OS_NAME=$(sw_vers -productName 2>/dev/null)
|
| 134 |
+
OS_VERSION=$(sw_vers -productVersion 2>/dev/null)
|
| 135 |
+
OS_BUILD=$(sw_vers -buildVersion 2>/dev/null)
|
| 136 |
+
ARCH=$(uname -m)
|
| 137 |
+
SERIAL=$(ioreg -rd1 -c IOPlatformExpertDevice 2>/dev/null | awk -F'"' '/IOPlatformSerialNumber/ { print $4 }')
|
| 138 |
+
EXEC_USER=$(id -un)
|
| 139 |
+
AUDIT_DATE=$(date '+%Y-%m-%d %H:%M:%S %Z')
|
| 140 |
+
STARTED_AT=$(date '+%Y-%m-%dT%H:%M:%S%z' | sed -E 's/([0-9]{2})([0-9]{2})$/\1:\2/')
|
| 141 |
+
IS_ADMIN=false; is_root && IS_ADMIN=true
|
| 142 |
+
|
| 143 |
+
# ---------------------------------------------------------------------------
|
| 144 |
+
# 1. Security baseline
|
| 145 |
+
# ---------------------------------------------------------------------------
|
| 146 |
+
S=baseline
|
| 147 |
+
|
| 148 |
+
# Global account policy (pwpolicy works without root).
|
| 149 |
+
PW_XML=$(pwpolicy -getaccountpolicies 2>/dev/null | sed '1{/^Getting/d;}')
|
| 150 |
+
pw_int() { # first <integer> directly after <key>NAME</key>, for each NAME given
|
| 151 |
+
local key value
|
| 152 |
+
for key in "$@"; do
|
| 153 |
+
value=$(printf '%s\n' "$PW_XML" | awk -v k="<key>$key</key>" '
|
| 154 |
+
found { if ($0 ~ /<integer>/) { gsub(/.*<integer>|<\/integer>.*/, ""); print } exit }
|
| 155 |
+
index($0, k) { found = 1 }')
|
| 156 |
+
if [ -n "$value" ]; then printf '%s' "$value"; return; fi
|
| 157 |
+
done
|
| 158 |
+
}
|
| 159 |
+
pw_has() { printf '%s' "$PW_XML" | grep -qiE "$1"; }
|
| 160 |
+
|
| 161 |
+
min_len=$(pw_int minimumLength minLength policyAttributeMinimumLength)
|
| 162 |
+
[ -z "$min_len" ] && min_len=$(printf '%s' "$PW_XML" | grep -oE '\.\{[0-9]+,' | head -1 | tr -dc '0-9')
|
| 163 |
+
if is_int "$min_len" && [ "$min_len" -ge "$B_PASSWORD_MIN_LENGTH" ]; then
|
| 164 |
+
add password.min_length $S "Minimum password length" Compliant "$min_len" ">= $B_PASSWORD_MIN_LENGTH" "pwpolicy global account policy"
|
| 165 |
+
else
|
| 166 |
+
add password.min_length $S "Minimum password length" Non-Compliant "${min_len:-not set}" ">= $B_PASSWORD_MIN_LENGTH" "pwpolicy global account policy"
|
| 167 |
+
fi
|
| 168 |
+
|
| 169 |
+
history=$(pw_int policyAttributePasswordHistoryDepth pinHistory)
|
| 170 |
+
if is_int "$history" && [ "$history" -ge "$B_PASSWORD_HISTORY" ]; then
|
| 171 |
+
add password.history $S "Password history" Compliant "$history" ">= $B_PASSWORD_HISTORY"
|
| 172 |
+
else
|
| 173 |
+
add password.history $S "Password history" Non-Compliant "${history:-not set}" ">= $B_PASSWORD_HISTORY"
|
| 174 |
+
fi
|
| 175 |
+
|
| 176 |
+
max_age=$(pw_int policyAttributeExpiresEveryNDays maxPINAgeInDays policyAttributeDaysUntilExpiration)
|
| 177 |
+
if [ "$B_PASSWORD_MAX_AGE_DAYS" -eq 0 ]; then
|
| 178 |
+
add password.expiration $S "Password expiration" Compliant "${max_age:-never}" "not required" "Baseline does not require expiry."
|
| 179 |
+
elif is_int "$max_age" && [ "$max_age" -ge 1 ] && [ "$max_age" -le "$B_PASSWORD_MAX_AGE_DAYS" ]; then
|
| 180 |
+
add password.expiration $S "Password expiration" Compliant "$max_age days" "1-$B_PASSWORD_MAX_AGE_DAYS days"
|
| 181 |
+
else
|
| 182 |
+
add password.expiration $S "Password expiration" Non-Compliant "${max_age:-never}" "1-$B_PASSWORD_MAX_AGE_DAYS days"
|
| 183 |
+
fi
|
| 184 |
+
|
| 185 |
+
add password.min_age $S "Minimum password age" NotApplicable "no such setting" ">= $B_PASSWORD_MIN_AGE_DAYS days" "macOS account policies have no minimum password age."
|
| 186 |
+
|
| 187 |
+
if pw_has 'requiresAlpha|requireAlphanumeric|\[a-z|\[A-Z|policyAttributeAlpha' && pw_has 'requiresNumeric|requireAlphanumeric|\[0-9\]|policyAttributeNumeric'; then
|
| 188 |
+
add password.complexity $S "Password complexity" Compliant "letters and numbers required" "enforced"
|
| 189 |
+
else
|
| 190 |
+
add password.complexity $S "Password complexity" Non-Compliant "not enforced" "enforced"
|
| 191 |
+
fi
|
| 192 |
+
|
| 193 |
+
threshold=$(pw_int policyAttributeMaximumFailedAuthentications maxFailedAttempts)
|
| 194 |
+
if is_int "$threshold" && [ "$threshold" -ge 1 ] && [ "$threshold" -le "$B_LOCKOUT_THRESHOLD_MAX" ]; then
|
| 195 |
+
add lockout.threshold $S "Account lockout threshold" Compliant "$threshold" "1-$B_LOCKOUT_THRESHOLD_MAX"
|
| 196 |
+
else
|
| 197 |
+
add lockout.threshold $S "Account lockout threshold" Non-Compliant "${threshold:-not set}" "1-$B_LOCKOUT_THRESHOLD_MAX"
|
| 198 |
+
fi
|
| 199 |
+
|
| 200 |
+
lock_seconds=$(pw_int autoEnableInSeconds)
|
| 201 |
+
if [ -z "$lock_seconds" ]; then
|
| 202 |
+
lock_minutes=$(pw_int minutesUntilFailedLoginReset)
|
| 203 |
+
is_int "$lock_minutes" && lock_seconds=$(( lock_minutes * 60 ))
|
| 204 |
+
fi
|
| 205 |
+
lock_text="not set"; is_int "$lock_seconds" && lock_text="$(( lock_seconds / 60 )) minutes"
|
| 206 |
+
min_lock=$(( B_LOCKOUT_DURATION_MIN_MINUTES * 60 ))
|
| 207 |
+
if is_int "$lock_seconds" && [ "$lock_seconds" -ge "$min_lock" ]; then
|
| 208 |
+
add lockout.duration $S "Account lockout duration" Compliant "$(( lock_seconds / 60 )) minutes" ">= $B_LOCKOUT_DURATION_MIN_MINUTES minutes"
|
| 209 |
+
else
|
| 210 |
+
add lockout.duration $S "Account lockout duration" Non-Compliant "$lock_text" ">= $B_LOCKOUT_DURATION_MIN_MINUTES minutes"
|
| 211 |
+
fi
|
| 212 |
+
min_obs=$(( B_LOCKOUT_OBSERVATION_MIN_MINUTES * 60 ))
|
| 213 |
+
if is_int "$lock_seconds" && [ "$lock_seconds" -ge "$min_obs" ]; then
|
| 214 |
+
add lockout.observation $S "Lockout observation period" Compliant "$(( lock_seconds / 60 )) minutes" ">= $B_LOCKOUT_OBSERVATION_MIN_MINUTES minutes" "macOS resets the failed-attempt count when the lockout ends."
|
| 215 |
+
else
|
| 216 |
+
add lockout.observation $S "Lockout observation period" Non-Compliant "$lock_text" ">= $B_LOCKOUT_OBSERVATION_MIN_MINUTES minutes" "macOS resets the failed-attempt count when the lockout ends."
|
| 217 |
+
fi
|
| 218 |
+
|
| 219 |
+
# Malware protection
|
| 220 |
+
XPROTECT_BUNDLE="/Library/Apple/System/Library/CoreServices/XProtect.bundle"
|
| 221 |
+
xp_version=$(xprotect version 2>/dev/null | sed -n 's/^Version: \([0-9]*\).*/\1/p')
|
| 222 |
+
[ -z "$xp_version" ] && xp_version=$(defaults read "$XPROTECT_BUNDLE/Contents/Info" CFBundleShortVersionString 2>/dev/null)
|
| 223 |
+
third_party_av=""
|
| 224 |
+
for proc in wdavdaemon falcond SentinelAgent SophosScanD esets_daemon BDLDaemon RTProtectionDaemon; do
|
| 225 |
+
pgrep -x "$proc" >/dev/null 2>&1 && third_party_av="${third_party_av:+$third_party_av, }$proc"
|
| 226 |
+
done
|
| 227 |
+
if [ -n "$xp_version" ] || [ -n "$third_party_av" ]; then
|
| 228 |
+
add malware.antivirus $S "Antivirus" Compliant "XProtect ${xp_version:-not found}${third_party_av:+; third-party: $third_party_av}" "antivirus present"
|
| 229 |
+
else
|
| 230 |
+
add malware.antivirus $S "Antivirus" Non-Compliant "none found" "antivirus present"
|
| 231 |
+
fi
|
| 232 |
+
|
| 233 |
+
xp_status=$(xprotect status 2>/dev/null)
|
| 234 |
+
gatekeeper=$(spctl --status 2>/dev/null)
|
| 235 |
+
if [ -n "$xp_status" ]; then
|
| 236 |
+
xp_on=1; printf '%s' "$xp_status" | grep -qi "launch scans: enabled" || xp_on=0
|
| 237 |
+
else
|
| 238 |
+
xp_on=0; launchctl print system/com.apple.XProtect.daemon.scan >/dev/null 2>&1 && xp_on=1
|
| 239 |
+
fi
|
| 240 |
+
if [ "$xp_on" -eq 1 ] && printf '%s' "$gatekeeper" | grep -q "assessments enabled"; then
|
| 241 |
+
add malware.service $S "Apple malware protection service" Compliant "XProtect scanning on; Gatekeeper on" "XProtect and Gatekeeper on"
|
| 242 |
+
else
|
| 243 |
+
add malware.service $S "Apple malware protection service" Non-Compliant "XProtect scanning $( [ "$xp_on" -eq 1 ] && echo on || echo off ); Gatekeeper ${gatekeeper:-unknown}" "XProtect and Gatekeeper on"
|
| 244 |
+
fi
|
| 245 |
+
|
| 246 |
+
xp_installed=$(xprotect version 2>/dev/null | sed -n 's/.*Installed: \([0-9-]* [0-9:]* [+-][0-9]*\).*/\1/p')
|
| 247 |
+
if [ -n "$xp_installed" ]; then
|
| 248 |
+
xp_epoch=$(date -j -f "%Y-%m-%d %H:%M:%S %z" "$xp_installed" +%s 2>/dev/null)
|
| 249 |
+
else
|
| 250 |
+
xp_epoch=$(stat -f %m "$XPROTECT_BUNDLE/Contents/Resources/XProtect.meta.plist" 2>/dev/null)
|
| 251 |
+
fi
|
| 252 |
+
if is_int "$xp_epoch"; then
|
| 253 |
+
xp_age=$(days_since "$xp_epoch")
|
| 254 |
+
if [ "$xp_age" -le "$B_MACOS_XPROTECT_MAX_AGE_DAYS" ]; then
|
| 255 |
+
add malware.signatures $S "Malware protection signatures" Compliant "XProtect $xp_version, $xp_age days old" "<= $B_MACOS_XPROTECT_MAX_AGE_DAYS days old"
|
| 256 |
+
else
|
| 257 |
+
add malware.signatures $S "Malware protection signatures" Non-Compliant "XProtect $xp_version, $xp_age days old" "<= $B_MACOS_XPROTECT_MAX_AGE_DAYS days old"
|
| 258 |
+
fi
|
| 259 |
+
else
|
| 260 |
+
add malware.signatures $S "Malware protection signatures" Error "not read" "<= $B_MACOS_XPROTECT_MAX_AGE_DAYS days old" "XProtect install date not found."
|
| 261 |
+
fi
|
| 262 |
+
|
| 263 |
+
sw_pref() { # key default -> managed profile value, else system preference, else default
|
| 264 |
+
local v
|
| 265 |
+
v=$(managed_read com.apple.SoftwareUpdate "$1")
|
| 266 |
+
[ -z "$v" ] && v=$(defaults read /Library/Preferences/com.apple.SoftwareUpdate "$1" 2>/dev/null)
|
| 267 |
+
printf '%s' "${v:-$2}"
|
| 268 |
+
}
|
| 269 |
+
config_data=$(sw_pref ConfigDataInstall 1)
|
| 270 |
+
critical=$(sw_pref CriticalUpdateInstall 1)
|
| 271 |
+
if [ "$config_data" = "1" ] && [ "$critical" = "1" ]; then
|
| 272 |
+
add malware.signature_updates $S "Malware security updates" Compliant "security responses and system data files install automatically" "automatic"
|
| 273 |
+
else
|
| 274 |
+
add malware.signature_updates $S "Malware security updates" Non-Compliant "ConfigDataInstall=$config_data, CriticalUpdateInstall=$critical" "automatic"
|
| 275 |
+
fi
|
| 276 |
+
|
| 277 |
+
if [ -n "$third_party_av" ]; then
|
| 278 |
+
add malware.on_access $S "On-access malware protection" Compliant "third-party scanner: $third_party_av" "on-access scanning"
|
| 279 |
+
else
|
| 280 |
+
add malware.on_access $S "On-access malware protection" NotApplicable "XProtect only" "on-access scanning" "XProtect scans at launch and download; macOS has no built-in on-access scanning."
|
| 281 |
+
fi
|
| 282 |
+
|
| 283 |
+
# Firewall (application firewall)
|
| 284 |
+
FW=/usr/libexec/ApplicationFirewall/socketfilterfw
|
| 285 |
+
fw_state=$("$FW" --getglobalstate 2>/dev/null)
|
| 286 |
+
fw_stealth=$("$FW" --getstealthmode 2>/dev/null)
|
| 287 |
+
fw_on=0; printf '%s' "$fw_state" | grep -qiE "enabled|State = [12]" && fw_on=1
|
| 288 |
+
stealth_on=0; printf '%s' "$fw_stealth" | grep -qiE "stealth mode is on|stealth mode enabled" && stealth_on=1
|
| 289 |
+
if [ "$fw_on" -eq 1 ]; then
|
| 290 |
+
add firewall.enabled $S "Firewall" Compliant "on" "on"
|
| 291 |
+
else
|
| 292 |
+
add firewall.enabled $S "Firewall" Non-Compliant "off" "on"
|
| 293 |
+
fi
|
| 294 |
+
if [ "$fw_on" -eq 1 ] && [ "$stealth_on" -eq 1 ]; then
|
| 295 |
+
add firewall.inbound $S "Firewall inbound policy" Compliant "block (firewall on, stealth mode on)" "block"
|
| 296 |
+
else
|
| 297 |
+
add firewall.inbound $S "Firewall inbound policy" Non-Compliant "firewall $( [ "$fw_on" -eq 1 ] && echo on || echo off ), stealth mode $( [ "$stealth_on" -eq 1 ] && echo on || echo off )" "block"
|
| 298 |
+
fi
|
| 299 |
+
if [ "$(lower "$B_FIREWALL_OUTBOUND")" = "allow" ]; then
|
| 300 |
+
add firewall.outbound $S "Firewall outbound policy" Compliant "allow" "allow" "The macOS application firewall filters inbound connections only."
|
| 301 |
+
else
|
| 302 |
+
add firewall.outbound $S "Firewall outbound policy" Non-Compliant "allow" "$B_FIREWALL_OUTBOUND" "The macOS application firewall cannot block outbound traffic."
|
| 303 |
+
fi
|
| 304 |
+
|
| 305 |
+
# Screen lock
|
| 306 |
+
idle=$(managed_read com.apple.screensaver idleTime)
|
| 307 |
+
[ -z "$idle" ] && idle=$(as_console_user defaults -currentHost read com.apple.screensaver idleTime 2>/dev/null)
|
| 308 |
+
display_min=$(pmset -g 2>/dev/null | awk '$1 == "displaysleep" { print $2 }')
|
| 309 |
+
effective_idle=""
|
| 310 |
+
is_int "$idle" && [ "$idle" -gt 0 ] && effective_idle=$idle
|
| 311 |
+
if is_int "$display_min" && [ "$display_min" -gt 0 ]; then
|
| 312 |
+
d=$(( display_min * 60 ))
|
| 313 |
+
if [ -z "$effective_idle" ] || [ "$d" -lt "$effective_idle" ]; then effective_idle=$d; fi
|
| 314 |
+
fi
|
| 315 |
+
ask=$(managed_read com.apple.screensaver askForPassword)
|
| 316 |
+
ask_delay=$(managed_read com.apple.screensaver askForPasswordDelay)
|
| 317 |
+
if [ -n "$ask" ]; then
|
| 318 |
+
if [ "$ask" = "1" ]; then lock_delay=${ask_delay:-0}; else lock_delay="off"; fi
|
| 319 |
+
else
|
| 320 |
+
lock_status=$(as_console_user sysadminctl -screenLock status 2>&1)
|
| 321 |
+
case "$lock_status" in
|
| 322 |
+
*"is off"*) lock_delay="off" ;;
|
| 323 |
+
*immediate*) lock_delay=0 ;;
|
| 324 |
+
*"delay is"*) lock_delay=$(printf '%s' "$lock_status" | sed -n 's/.*delay is \([0-9]*\) seconds.*/\1/p') ;;
|
| 325 |
+
*) lock_delay="" ;;
|
| 326 |
+
esac
|
| 327 |
+
fi
|
| 328 |
+
|
| 329 |
+
if [ -z "$lock_delay" ]; then
|
| 330 |
+
add screen_lock.timeout $S "Screen lock timeout" Error "not read" "<= $B_SCREEN_LOCK_MAX_SECONDS seconds" "Could not read the screen lock setting."
|
| 331 |
+
add screen_lock.enabled $S "Screen lock" Error "not read" "on"
|
| 332 |
+
add screen_lock.password $S "Screen lock password requirement" Error "not read" "password within 5 seconds"
|
| 333 |
+
else
|
| 334 |
+
if [ "$lock_delay" != "off" ] && [ -n "$effective_idle" ] && [ $(( effective_idle + lock_delay )) -le "$B_SCREEN_LOCK_MAX_SECONDS" ]; then
|
| 335 |
+
add screen_lock.timeout $S "Screen lock timeout" Compliant "$(( effective_idle + lock_delay )) seconds" "<= $B_SCREEN_LOCK_MAX_SECONDS seconds" "Idle ${effective_idle}s (screen saver/display sleep) + ${lock_delay}s password delay."
|
| 336 |
+
else
|
| 337 |
+
add screen_lock.timeout $S "Screen lock timeout" Non-Compliant "${effective_idle:-never} idle, password delay $lock_delay" "<= $B_SCREEN_LOCK_MAX_SECONDS seconds"
|
| 338 |
+
fi
|
| 339 |
+
if [ "$lock_delay" != "off" ] && [ -n "$effective_idle" ]; then
|
| 340 |
+
add screen_lock.enabled $S "Screen lock" Compliant "on" "on"
|
| 341 |
+
else
|
| 342 |
+
add screen_lock.enabled $S "Screen lock" Non-Compliant "off" "on"
|
| 343 |
+
fi
|
| 344 |
+
if [ "$lock_delay" != "off" ] && [ "$lock_delay" -le 5 ]; then
|
| 345 |
+
add screen_lock.password $S "Screen lock password requirement" Compliant "password after $lock_delay seconds" "password within 5 seconds"
|
| 346 |
+
else
|
| 347 |
+
add screen_lock.password $S "Screen lock password requirement" Non-Compliant "password after $lock_delay seconds" "password within 5 seconds"
|
| 348 |
+
fi
|
| 349 |
+
fi
|
| 350 |
+
|
| 351 |
+
# USB storage (configuration profile mount-controls)
|
| 352 |
+
usb_rule=""
|
| 353 |
+
if [ -f "$MANAGED/com.apple.systemuiserver.plist" ]; then
|
| 354 |
+
usb_rule=$(plutil -extract "mount-controls.harddisk-external" json -o - "$MANAGED/com.apple.systemuiserver.plist" 2>/dev/null)
|
| 355 |
+
fi
|
| 356 |
+
usb_read=allow; usb_write=allow
|
| 357 |
+
case "$usb_rule" in *deny*) usb_read=deny; usb_write=deny ;; *read-only*) usb_write=deny ;; esac
|
| 358 |
+
if [ "$usb_read" = "$(lower "$B_USB_READ")" ]; then
|
| 359 |
+
add usb.read $S "USB storage read" Compliant "$usb_read" "$B_USB_READ"
|
| 360 |
+
else
|
| 361 |
+
add usb.read $S "USB storage read" Non-Compliant "$usb_read" "$B_USB_READ"
|
| 362 |
+
fi
|
| 363 |
+
if [ "$usb_write" = "$(lower "$B_USB_WRITE")" ]; then
|
| 364 |
+
add usb.write $S "USB storage write" Compliant "$usb_write" "$B_USB_WRITE"
|
| 365 |
+
else
|
| 366 |
+
add usb.write $S "USB storage write" Non-Compliant "$usb_write" "$B_USB_WRITE" "No configuration profile restricts external drives."
|
| 367 |
+
fi
|
| 368 |
+
|
| 369 |
+
# Administrators
|
| 370 |
+
admins=$(dscl . -read /Groups/admin GroupMembership 2>/dev/null | sed 's/^GroupMembership://')
|
| 371 |
+
approved_found=""; unauthorized=""
|
| 372 |
+
for user in $admins; do
|
| 373 |
+
[ "$user" = "root" ] && continue
|
| 374 |
+
if in_list "$user" "$B_APPROVED_ADMINS"; then
|
| 375 |
+
approved_found="${approved_found:+$approved_found, }$user"
|
| 376 |
+
else
|
| 377 |
+
unauthorized="${unauthorized:+$unauthorized, }$user"
|
| 378 |
+
fi
|
| 379 |
+
done
|
| 380 |
+
if [ -n "$approved_found" ]; then
|
| 381 |
+
add admin.approved $S "Approved IT administrator" Compliant "$approved_found" "${B_APPROVED_ADMINS:-none configured}"
|
| 382 |
+
else
|
| 383 |
+
add admin.approved $S "Approved IT administrator" Non-Compliant "none of the approved accounts is an administrator" "${B_APPROVED_ADMINS:-none configured}"
|
| 384 |
+
fi
|
| 385 |
+
if [ -z "$unauthorized" ]; then
|
| 386 |
+
add admin.unauthorized $S "Unauthorized employee administrator accounts" Compliant "none" "none"
|
| 387 |
+
else
|
| 388 |
+
add admin.unauthorized $S "Unauthorized employee administrator accounts" Non-Compliant "$unauthorized" "none"
|
| 389 |
+
fi
|
| 390 |
+
|
| 391 |
+
# FileVault
|
| 392 |
+
fv=$(fdesetup status 2>/dev/null)
|
| 393 |
+
if printf '%s' "$fv" | grep -q "FileVault is On"; then
|
| 394 |
+
add encryption.enabled $S "Encryption" Compliant "FileVault on" "FileVault on"
|
| 395 |
+
if printf '%s' "$fv" | grep -qi "in progress"; then
|
| 396 |
+
add encryption.protection $S "Disk encryption protection" Non-Compliant "$(printf '%s' "$fv" | tr '\n' ' ')" "fully encrypted"
|
| 397 |
+
else
|
| 398 |
+
add encryption.protection $S "Disk encryption protection" Compliant "fully encrypted" "fully encrypted"
|
| 399 |
+
fi
|
| 400 |
+
if is_root; then
|
| 401 |
+
prk=$(fdesetup haspersonalrecoverykey 2>/dev/null); irk=$(fdesetup hasinstitutionalrecoverykey 2>/dev/null)
|
| 402 |
+
if [ "$prk" = "true" ] || [ "$irk" = "true" ]; then
|
| 403 |
+
add encryption.recovery $S "Disk encryption recovery" Compliant "recovery key present (personal: $prk, institutional: $irk)" "recovery key present"
|
| 404 |
+
else
|
| 405 |
+
add encryption.recovery $S "Disk encryption recovery" Non-Compliant "no recovery key" "recovery key present"
|
| 406 |
+
fi
|
| 407 |
+
else
|
| 408 |
+
needs_root encryption.recovery $S "Disk encryption recovery" "recovery key present"
|
| 409 |
+
fi
|
| 410 |
+
else
|
| 411 |
+
add encryption.enabled $S "Encryption" Non-Compliant "FileVault off" "FileVault on"
|
| 412 |
+
add encryption.protection $S "Disk encryption protection" Non-Compliant "not encrypted" "fully encrypted"
|
| 413 |
+
add encryption.recovery $S "Disk encryption recovery" Non-Compliant "no FileVault" "recovery key present"
|
| 414 |
+
fi
|
| 415 |
+
|
| 416 |
+
# ---------------------------------------------------------------------------
|
| 417 |
+
# 2. GDPR technical controls
|
| 418 |
+
# ---------------------------------------------------------------------------
|
| 419 |
+
S=gdpr
|
| 420 |
+
|
| 421 |
+
auto_check=$(sw_pref AutomaticCheckEnabled 1)
|
| 422 |
+
auto_download=$(sw_pref AutomaticDownload 1)
|
| 423 |
+
auto_macos=$(sw_pref AutomaticallyInstallMacOSUpdates 0)
|
| 424 |
+
if [ "$auto_check" = "1" ] && [ "$auto_download" = "1" ] && [ "$critical" = "1" ] && [ "$auto_macos" = "1" ]; then
|
| 425 |
+
add gdpr.auto_updates $S "Automatic security updates" Compliant "check, download and install automatic" "automatic"
|
| 426 |
+
else
|
| 427 |
+
add gdpr.auto_updates $S "Automatic security updates" Non-Compliant "check=$auto_check download=$auto_download security=$critical macOS-install=$auto_macos" "automatic"
|
| 428 |
+
fi
|
| 429 |
+
|
| 430 |
+
# Last macOS/software update from the install history (config-data such as XProtect is ignored).
|
| 431 |
+
last_update=$(plutil -p /Library/Receipts/InstallHistory.plist 2>/dev/null | awk '
|
| 432 |
+
/"contentType" =>/ { ctype = $0 }
|
| 433 |
+
/"date" =>/ { d = $3 " " $4 " " $5 }
|
| 434 |
+
/"processName" =>/ { if ($0 ~ /softwareupdated/ && ctype !~ /config-data/ && d > last) last = d; ctype = "" }
|
| 435 |
+
END { print last }')
|
| 436 |
+
if [ -n "$last_update" ]; then
|
| 437 |
+
lu_epoch=$(date -j -f "%Y-%m-%d %H:%M:%S %z" "$last_update" +%s 2>/dev/null)
|
| 438 |
+
lu_age=$(days_since "$lu_epoch")
|
| 439 |
+
if [ "$lu_age" -le "$B_UPDATES_MAX_AGE_DAYS" ]; then
|
| 440 |
+
add gdpr.updates_recent $S "Security updates installed recently" Compliant "$lu_age days ago" "<= $B_UPDATES_MAX_AGE_DAYS days ago" "Last software update: $last_update"
|
| 441 |
+
else
|
| 442 |
+
add gdpr.updates_recent $S "Security updates installed recently" Non-Compliant "$lu_age days ago" "<= $B_UPDATES_MAX_AGE_DAYS days ago" "Last software update: $last_update"
|
| 443 |
+
fi
|
| 444 |
+
else
|
| 445 |
+
add gdpr.updates_recent $S "Security updates installed recently" Error "not read" "<= $B_UPDATES_MAX_AGE_DAYS days ago" "No software update found in the install history."
|
| 446 |
+
fi
|
| 447 |
+
|
| 448 |
+
major=${OS_VERSION%%.*}
|
| 449 |
+
if is_int "$major" && [ "$major" -ge "$B_MACOS_MIN_MAJOR" ]; then
|
| 450 |
+
add gdpr.os_supported $S "Supported operating system version" Compliant "macOS $OS_VERSION" "macOS $B_MACOS_MIN_MAJOR or later"
|
| 451 |
+
else
|
| 452 |
+
add gdpr.os_supported $S "Supported operating system version" Non-Compliant "macOS $OS_VERSION" "macOS $B_MACOS_MIN_MAJOR or later"
|
| 453 |
+
fi
|
| 454 |
+
|
| 455 |
+
if launchctl print system/com.apple.logd >/dev/null 2>&1; then
|
| 456 |
+
add gdpr.security_logging $S "Security event logging" Compliant "unified logging active" "security events logged" "macOS unified log records authentication events; retention is managed by the OS."
|
| 457 |
+
else
|
| 458 |
+
add gdpr.security_logging $S "Security event logging" Non-Compliant "logd not running" "security events logged"
|
| 459 |
+
fi
|
| 460 |
+
|
| 461 |
+
if is_root; then
|
| 462 |
+
nt=$(/usr/sbin/systemsetup -getusingnetworktime 2>/dev/null)
|
| 463 |
+
if printf '%s' "$nt" | grep -qi ": On"; then
|
| 464 |
+
add gdpr.time_sync $S "Time synchronisation" Compliant "network time on" "network time on"
|
| 465 |
+
else
|
| 466 |
+
add gdpr.time_sync $S "Time synchronisation" Non-Compliant "${nt:-unknown}" "network time on"
|
| 467 |
+
fi
|
| 468 |
+
else
|
| 469 |
+
needs_root gdpr.time_sync $S "Time synchronisation" "network time on"
|
| 470 |
+
fi
|
| 471 |
+
|
| 472 |
+
tm_dest=$(tmutil destinationinfo 2>&1)
|
| 473 |
+
backup_tool=""
|
| 474 |
+
printf '%s' "$tm_dest" | grep -q "No destinations" || backup_tool="Time Machine"
|
| 475 |
+
for proc in bzserv CrashPlanService ArqAgent; do
|
| 476 |
+
pgrep -x "$proc" >/dev/null 2>&1 && backup_tool="${backup_tool:+$backup_tool, }$proc"
|
| 477 |
+
done
|
| 478 |
+
if [ -n "$backup_tool" ]; then
|
| 479 |
+
add gdpr.backup_configured $S "Backup configured" Compliant "$backup_tool" "backup configured"
|
| 480 |
+
tm_latest=$(tmutil latestbackup 2>/dev/null | grep -oE '[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{6}' | tail -1)
|
| 481 |
+
if [ -n "$tm_latest" ]; then
|
| 482 |
+
tm_epoch=$(date -j -f "%Y-%m-%d-%H%M%S" "$tm_latest" +%s 2>/dev/null)
|
| 483 |
+
tm_age=$(days_since "$tm_epoch")
|
| 484 |
+
if [ "$tm_age" -le "$B_BACKUP_MAX_AGE_DAYS" ]; then
|
| 485 |
+
add gdpr.backup_recent $S "Recent backup" Compliant "$tm_age days ago" "<= $B_BACKUP_MAX_AGE_DAYS days ago"
|
| 486 |
+
else
|
| 487 |
+
add gdpr.backup_recent $S "Recent backup" Non-Compliant "$tm_age days ago" "<= $B_BACKUP_MAX_AGE_DAYS days ago"
|
| 488 |
+
fi
|
| 489 |
+
else
|
| 490 |
+
add gdpr.backup_recent $S "Recent backup" Pending "date not readable" "<= $B_BACKUP_MAX_AGE_DAYS days ago" "Confirm the last backup date in $backup_tool."
|
| 491 |
+
fi
|
| 492 |
+
else
|
| 493 |
+
add gdpr.backup_configured $S "Backup configured" Non-Compliant "none" "backup configured"
|
| 494 |
+
add gdpr.backup_recent $S "Recent backup" Non-Compliant "no backup" "<= $B_BACKUP_MAX_AGE_DAYS days ago"
|
| 495 |
+
fi
|
| 496 |
+
|
| 497 |
+
if [ "$usb_write" = "deny" ]; then
|
| 498 |
+
add gdpr.removable_encryption $S "Removable media encryption" Compliant "writing to external drives blocked" "no unencrypted writes"
|
| 499 |
+
else
|
| 500 |
+
add gdpr.removable_encryption $S "Removable media encryption" Non-Compliant "external drives writable without enforced encryption" "no unencrypted writes"
|
| 501 |
+
fi
|
| 502 |
+
|
| 503 |
+
guest=$(defaults read /Library/Preferences/com.apple.loginwindow GuestEnabled 2>/dev/null)
|
| 504 |
+
smb_guest=$(defaults read /Library/Preferences/SystemConfiguration/com.apple.smb.server AllowGuestAccess 2>/dev/null)
|
| 505 |
+
if [ "${guest:-0}" != "1" ] && [ "${smb_guest:-0}" != "1" ]; then
|
| 506 |
+
add gdpr.guest_account $S "Guest account disabled" Compliant "guest login off" "guest login off"
|
| 507 |
+
else
|
| 508 |
+
add gdpr.guest_account $S "Guest account disabled" Non-Compliant "guest login ${guest:-0}, guest file sharing ${smb_guest:-0}" "guest login off"
|
| 509 |
+
fi
|
| 510 |
+
|
| 511 |
+
disabled_list=$(launchctl print-disabled system 2>/dev/null)
|
| 512 |
+
svc_enabled() { printf '%s' "$disabled_list" | grep -qE "\"$1[^\"]*\" => (enabled|false)"; }
|
| 513 |
+
remote=""
|
| 514 |
+
svc_enabled com.openssh.sshd && remote="${remote:+$remote, }ssh"
|
| 515 |
+
svc_enabled com.apple.screensharing && remote="${remote:+$remote, }screensharing"
|
| 516 |
+
svc_enabled com.apple.smbd && remote="${remote:+$remote, }filesharing"
|
| 517 |
+
pgrep -x ARDAgent >/dev/null 2>&1 && remote="${remote:+$remote, }remotemanagement"
|
| 518 |
+
svc_enabled org.chromium.chromoting && remote="${remote:+$remote, }chromeremotedesktop"
|
| 519 |
+
pgrep -f "TeamViewer" >/dev/null 2>&1 && remote="${remote:+$remote, }teamviewer"
|
| 520 |
+
pgrep -x AnyDesk >/dev/null 2>&1 && remote="${remote:+$remote, }anydesk"
|
| 521 |
+
unapproved_remote=""
|
| 522 |
+
old_ifs="$IFS"; IFS=,
|
| 523 |
+
for svc in $remote; do
|
| 524 |
+
svc=$(printf '%s' "$svc" | sed 's/^ *//')
|
| 525 |
+
in_list "$svc" "$B_APPROVED_REMOTE_SERVICES" || unapproved_remote="${unapproved_remote:+$unapproved_remote, }$svc"
|
| 526 |
+
done
|
| 527 |
+
IFS="$old_ifs"
|
| 528 |
+
if [ -z "$unapproved_remote" ]; then
|
| 529 |
+
add gdpr.remote_access $S "Remote access services restricted" Compliant "${remote:-none enabled}" "only: ${B_APPROVED_REMOTE_SERVICES:-none}"
|
| 530 |
+
else
|
| 531 |
+
add gdpr.remote_access $S "Remote access services restricted" Non-Compliant "$unapproved_remote" "only: ${B_APPROVED_REMOTE_SERVICES:-none}"
|
| 532 |
+
fi
|
| 533 |
+
|
| 534 |
+
DIAG="/Library/Application Support/CrashReporter/DiagnosticMessagesHistory.plist"
|
| 535 |
+
auto_submit=$(defaults read "${DIAG%.plist}" AutoSubmit 2>/dev/null)
|
| 536 |
+
third_party=$(defaults read "${DIAG%.plist}" ThirdPartyDataSubmit 2>/dev/null)
|
| 537 |
+
if [ "${auto_submit:-0}" != "1" ] && [ "${third_party:-0}" != "1" ]; then
|
| 538 |
+
add gdpr.telemetry $S "Diagnostic data minimised" Compliant "analytics sharing off" "analytics sharing off"
|
| 539 |
+
else
|
| 540 |
+
add gdpr.telemetry $S "Diagnostic data minimised" Non-Compliant "share Mac analytics=${auto_submit:-0}, share with app developers=${third_party:-0}" "analytics sharing off"
|
| 541 |
+
fi
|
| 542 |
+
|
| 543 |
+
add gdpr.personal_data_discovery $S "Personal data discovery" NotApplicable "not run" "opt-in scan" "Opt-in scan (PERSONAL_DATA_SCAN=$B_PERSONAL_DATA_SCAN) is planned for a later stage."
|
| 544 |
+
|
| 545 |
+
# ---------------------------------------------------------------------------
|
| 546 |
+
# 3. EU AI Act controls
|
| 547 |
+
# ---------------------------------------------------------------------------
|
| 548 |
+
S=aiact
|
| 549 |
+
|
| 550 |
+
ai_tools=""
|
| 551 |
+
add_tool() { case ",$ai_tools," in *",$1,"*) ;; *) ai_tools="${ai_tools:+$ai_tools,}$1" ;; esac; }
|
| 552 |
+
ai_detail=""
|
| 553 |
+
for app in "ChatGPT" "Claude" "Microsoft Copilot" "Copilot" "Gemini" "Ollama" "LM Studio" "GPT4All" "Jan" "Perplexity" "Msty" "Cursor" "Windsurf"; do
|
| 554 |
+
for dir in /Applications /Users/*/Applications; do
|
| 555 |
+
[ -d "$dir/$app.app" ] && add_tool "$app"
|
| 556 |
+
done
|
| 557 |
+
done
|
| 558 |
+
for cli in ollama claude; do
|
| 559 |
+
for dir in /usr/local/bin /opt/homebrew/bin /Users/*/.local/bin; do
|
| 560 |
+
if [ -x "$dir/$cli" ]; then
|
| 561 |
+
case "$cli" in ollama) add_tool "Ollama" ;; claude) add_tool "Claude Code" ;; esac
|
| 562 |
+
fi
|
| 563 |
+
done
|
| 564 |
+
done
|
| 565 |
+
lsof -nP -iTCP:11434 -sTCP:LISTEN >/dev/null 2>&1 && { add_tool "Ollama"; ai_detail="${ai_detail}Local AI service listening on port 11434. "; }
|
| 566 |
+
for home in /Users/*; do
|
| 567 |
+
[ -d "$home" ] || continue
|
| 568 |
+
for pair in ".ollama/models:Ollama" ".cache/lm-studio/models:LM Studio" ".lmstudio/models:LM Studio" "Library/Application Support/nomic.ai/GPT4All:GPT4All" ".cache/huggingface/hub:Hugging Face models"; do
|
| 569 |
+
path="$home/${pair%%:*}"
|
| 570 |
+
if [ -d "$path" ] && [ -n "$(ls -A "$path" 2>/dev/null)" ]; then
|
| 571 |
+
add_tool "${pair##*:}"
|
| 572 |
+
ai_detail="${ai_detail}Local models: $path ($(du -sh "$path" 2>/dev/null | awk '{ print $1 }')). "
|
| 573 |
+
fi
|
| 574 |
+
done
|
| 575 |
+
done
|
| 576 |
+
ai_list=$(printf '%s' "$ai_tools" | sed 's/,/, /g')
|
| 577 |
+
add aiact.inventory $S "AI software inventory" Compliant "${ai_list:-no AI tools found}" "inventory taken" "$ai_detail"
|
| 578 |
+
|
| 579 |
+
unapproved_ai=""
|
| 580 |
+
old_ifs="$IFS"; IFS=,
|
| 581 |
+
for tool in $ai_tools; do
|
| 582 |
+
in_list "$tool" "$B_APPROVED_AI_TOOLS" || unapproved_ai="${unapproved_ai:+$unapproved_ai, }$tool"
|
| 583 |
+
done
|
| 584 |
+
IFS="$old_ifs"
|
| 585 |
+
if [ -z "$unapproved_ai" ]; then
|
| 586 |
+
add aiact.unapproved $S "Unapproved AI tools (shadow AI)" Compliant "none" "only: ${B_APPROVED_AI_TOOLS:-none}"
|
| 587 |
+
else
|
| 588 |
+
add aiact.unapproved $S "Unapproved AI tools (shadow AI)" Non-Compliant "$unapproved_ai" "only: ${B_APPROVED_AI_TOOLS:-none}"
|
| 589 |
+
fi
|
| 590 |
+
|
| 591 |
+
if [ -n "$ai_tools" ]; then
|
| 592 |
+
add aiact.risk_classification $S "AI system risk classification" Pending "$ai_list" "every AI tool classified" "Classified by the auditor model (Caveat classifier) in a later step."
|
| 593 |
+
else
|
| 594 |
+
add aiact.risk_classification $S "AI system risk classification" NotApplicable "no AI tools found" "every AI tool classified"
|
| 595 |
+
fi
|
| 596 |
+
|
| 597 |
+
if [ "$ARCH" != "arm64" ]; then
|
| 598 |
+
add aiact.os_ai_features $S "Apple Intelligence external integrations restricted" NotApplicable "Intel Mac" "restricted by profile" "Apple Intelligence needs Apple silicon."
|
| 599 |
+
else
|
| 600 |
+
ext_ai=$(managed_read com.apple.applicationaccess allowExternalIntelligenceIntegrations)
|
| 601 |
+
if [ "$ext_ai" = "0" ]; then
|
| 602 |
+
add aiact.os_ai_features $S "Apple Intelligence external integrations restricted" Compliant "restricted by profile" "restricted by profile"
|
| 603 |
+
else
|
| 604 |
+
add aiact.os_ai_features $S "Apple Intelligence external integrations restricted" Non-Compliant "not restricted" "restricted by profile" "No profile sets allowExternalIntelligenceIntegrations to false."
|
| 605 |
+
fi
|
| 606 |
+
fi
|
| 607 |
+
|
| 608 |
+
if [ -n "$ai_tools" ]; then
|
| 609 |
+
add aiact.log_retention $S "AI usage log retention" Pending "depends on classification" ">= 6 months for high-risk AI" "Required only if a tool is classified high-risk."
|
| 610 |
+
else
|
| 611 |
+
add aiact.log_retention $S "AI usage log retention" NotApplicable "no AI tools found" ">= 6 months for high-risk AI"
|
| 612 |
+
fi
|
| 613 |
+
|
| 614 |
+
# ---------------------------------------------------------------------------
|
| 615 |
+
# 4. Organisational controls (answers from organisational.conf)
|
| 616 |
+
# ---------------------------------------------------------------------------
|
| 617 |
+
S=org
|
| 618 |
+
org() { # id key name
|
| 619 |
+
local answer
|
| 620 |
+
answer=$(lower "$(conf_get "$ORG_FILE" "$2" "")")
|
| 621 |
+
case "$answer" in
|
| 622 |
+
yes) add "$1" $S "$3" Compliant "yes" "yes" ;;
|
| 623 |
+
no) add "$1" $S "$3" Non-Compliant "no" "yes" ;;
|
| 624 |
+
partial) add "$1" $S "$3" Non-Compliant "partial" "yes" ;;
|
| 625 |
+
n/a|na) add "$1" $S "$3" NotApplicable "n/a" "yes" ;;
|
| 626 |
+
*) add "$1" $S "$3" Pending "not answered" "yes" "Answer $2 in organisational.conf." ;;
|
| 627 |
+
esac
|
| 628 |
+
}
|
| 629 |
+
org org.ropa ORG_ROPA "Record of processing activities"
|
| 630 |
+
org org.dpia ORG_DPIA "Data protection impact assessment"
|
| 631 |
+
org org.breach_procedure ORG_BREACH_PROCEDURE "Breach response procedure"
|
| 632 |
+
org org.processor_agreements ORG_PROCESSOR_AGREEMENTS "Data processing agreements"
|
| 633 |
+
org org.ai_register ORG_AI_REGISTER "AI register"
|
| 634 |
+
org org.ai_literacy ORG_AI_LITERACY "AI literacy training"
|
| 635 |
+
org org.human_oversight ORG_HUMAN_OVERSIGHT "Human oversight for high-risk AI"
|
| 636 |
+
org org.ai_transparency ORG_AI_TRANSPARENCY "AI transparency to users"
|
| 637 |
+
|
| 638 |
+
# ---------------------------------------------------------------------------
|
| 639 |
+
# Report
|
| 640 |
+
# ---------------------------------------------------------------------------
|
| 641 |
+
LINE="============================================================"
|
| 642 |
+
print_section() { # section-id title
|
| 643 |
+
local i
|
| 644 |
+
echo "$LINE"; echo " $2"; echo "$LINE"
|
| 645 |
+
for i in "${!R_ID[@]}"; do
|
| 646 |
+
if [ "${R_SECTION[$i]}" = "$1" ]; then echo "${R_NAME[$i]}"; echo "${R_STATUS[$i]}"; fi
|
| 647 |
+
done
|
| 648 |
+
}
|
| 649 |
+
report() {
|
| 650 |
+
echo "$LINE"
|
| 651 |
+
echo " macOS ENDPOINT SECURITY BASELINE - AUDIT"
|
| 652 |
+
echo "$LINE"
|
| 653 |
+
echo "Hostname : $HOSTNAME_VALUE"
|
| 654 |
+
echo "Operating System: $OS_NAME"
|
| 655 |
+
echo "OS Version : $OS_VERSION"
|
| 656 |
+
echo "Build : $OS_BUILD"
|
| 657 |
+
echo "Serial Number : $SERIAL"
|
| 658 |
+
echo "Execution User : $EXEC_USER"
|
| 659 |
+
echo "Audit Date : $AUDIT_DATE"
|
| 660 |
+
echo "Audit Mode : READ-ONLY"
|
| 661 |
+
echo "$LINE"
|
| 662 |
+
print_section baseline "COMPLIANCE RESULTS"
|
| 663 |
+
print_section gdpr "GDPR TECHNICAL CONTROLS"
|
| 664 |
+
print_section aiact "EU AI ACT CONTROLS"
|
| 665 |
+
print_section org "ORGANISATIONAL CONTROLS"
|
| 666 |
+
echo "$LINE"
|
| 667 |
+
echo " END OF AUDIT"
|
| 668 |
+
echo "$LINE"
|
| 669 |
+
}
|
| 670 |
+
|
| 671 |
+
count() { local i n=0; for i in "${!R_STATUS[@]}"; do [ "${R_STATUS[$i]}" = "$1" ] && n=$((n + 1)); done; echo $n; }
|
| 672 |
+
|
| 673 |
+
evidence_json() {
|
| 674 |
+
local i key first=1
|
| 675 |
+
printf '{\n "schema_version": "1.0",\n "catalog_version": "%s",\n' "$CATALOG_VERSION"
|
| 676 |
+
printf ' "source": { "kind": "collector", "collector": "macos", "collector_version": "%s" },\n' "$COLLECTOR_VERSION"
|
| 677 |
+
printf ' "audit": { "mode": "READ-ONLY", "started_at": "%s", "execution_user": "%s", "is_admin": %s },\n' "$STARTED_AT" "$(json_escape "$EXEC_USER")" "$IS_ADMIN"
|
| 678 |
+
printf ' "host": { "hostname": "%s", "os_family": "macos", "os_name": "%s", "os_version": "%s", "build": "%s", "architecture": "%s", "serial_number": "%s" },\n' \
|
| 679 |
+
"$(json_escape "$HOSTNAME_VALUE")" "$(json_escape "$OS_NAME")" "$(json_escape "$OS_VERSION")" "$(json_escape "$OS_BUILD")" "$ARCH" "$(json_escape "$SERIAL")"
|
| 680 |
+
printf ' "baseline": {'
|
| 681 |
+
for key in $BASELINE_KEYS; do
|
| 682 |
+
[ $first -eq 1 ] && first=0 || printf ','
|
| 683 |
+
eval "printf ' \"%s\": \"%s\"' \"\$key\" \"\$(json_escape \"\$B_$key\")\""
|
| 684 |
+
done
|
| 685 |
+
printf ' },\n "results": [\n'
|
| 686 |
+
for i in "${!R_ID[@]}"; do
|
| 687 |
+
[ "$i" -gt 0 ] && printf ',\n'
|
| 688 |
+
printf ' { "id": "%s", "section": "%s", "name": "%s", "status": "%s", "actual": "%s", "expected": "%s"' \
|
| 689 |
+
"${R_ID[$i]}" "${R_SECTION[$i]}" "$(json_escape "${R_NAME[$i]}")" "${R_STATUS[$i]}" "$(json_escape "${R_ACTUAL[$i]}")" "$(json_escape "${R_EXPECTED[$i]}")"
|
| 690 |
+
[ -n "${R_DETAIL[$i]}" ] && printf ', "detail": "%s"' "$(json_escape "${R_DETAIL[$i]}")"
|
| 691 |
+
printf ' }'
|
| 692 |
+
done
|
| 693 |
+
printf '\n ],\n "summary": { "Compliant": %s, "Non-Compliant": %s, "NotApplicable": %s, "Pending": %s, "Error": %s }\n}\n' \
|
| 694 |
+
"$(count Compliant)" "$(count Non-Compliant)" "$(count NotApplicable)" "$(count Pending)" "$(count Error)"
|
| 695 |
+
}
|
| 696 |
+
|
| 697 |
+
is_root || echo "Warning: not running as root. Some checks will report Error. Run: sudo $0" >&2
|
| 698 |
+
|
| 699 |
+
STAMP=$(date '+%Y%m%d-%H%M%S')
|
| 700 |
+
BASENAME="audit-macos-$HOSTNAME_VALUE-$STAMP"
|
| 701 |
+
if mkdir -p "$OUTPUT_DIR" 2>/dev/null && [ -w "$OUTPUT_DIR" ]; then
|
| 702 |
+
report | tee "$OUTPUT_DIR/$BASENAME.txt"
|
| 703 |
+
evidence_json > "$OUTPUT_DIR/$BASENAME.json"
|
| 704 |
+
echo "Report: $OUTPUT_DIR/$BASENAME.txt" >&2
|
| 705 |
+
echo "Evidence: $OUTPUT_DIR/$BASENAME.json" >&2
|
| 706 |
+
else
|
| 707 |
+
report
|
| 708 |
+
echo "Could not write to $OUTPUT_DIR (use --output DIR). Evidence not saved." >&2
|
| 709 |
+
exit 2
|
| 710 |
+
fi
|
| 711 |
+
|
| 712 |
+
[ "$(count Non-Compliant)" -gt 0 ] && exit 1
|
| 713 |
+
exit 0
|
collectors/windows/audit.ps1
ADDED
|
@@ -0,0 +1,574 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<#
|
| 2 |
+
.SYNOPSIS
|
| 3 |
+
Devseis endpoint audit - Windows collector.
|
| 4 |
+
|
| 5 |
+
.DESCRIPTION
|
| 6 |
+
READ-ONLY: this script only reads settings. It never changes the system.
|
| 7 |
+
The only files it writes are the report (.txt) and evidence (.json) in the output folder,
|
| 8 |
+
plus short-lived exports from secedit/auditpol in the TEMP folder (deleted straight away).
|
| 9 |
+
|
| 10 |
+
Run from an elevated PowerShell (or as SYSTEM) for complete results.
|
| 11 |
+
Works with Windows PowerShell 5.1 (built into Windows 10/11) and PowerShell 7.
|
| 12 |
+
|
| 13 |
+
.EXAMPLE
|
| 14 |
+
powershell -ExecutionPolicy Bypass -File .\audit.ps1
|
| 15 |
+
powershell -ExecutionPolicy Bypass -File .\audit.ps1 -Output C:\Audit
|
| 16 |
+
#>
|
| 17 |
+
[CmdletBinding()]
|
| 18 |
+
param(
|
| 19 |
+
[string]$Baseline = (Join-Path $PSScriptRoot '..\..\baseline.conf'),
|
| 20 |
+
[string]$Organisational = (Join-Path $PSScriptRoot '..\..\organisational.conf'),
|
| 21 |
+
[string]$Output = (Join-Path $env:ProgramData 'Devseis\EndpointAudit')
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
Set-StrictMode -Version 2.0
|
| 25 |
+
$ErrorActionPreference = 'Stop'
|
| 26 |
+
$CollectorVersion = '0.1.0'
|
| 27 |
+
$CatalogVersion = '0.1.0'
|
| 28 |
+
|
| 29 |
+
# ---------------------------------------------------------------------------
|
| 30 |
+
# Helpers
|
| 31 |
+
# ---------------------------------------------------------------------------
|
| 32 |
+
|
| 33 |
+
# Read KEY=VALUE lines without executing anything.
|
| 34 |
+
function Read-Conf([string]$Path) {
|
| 35 |
+
$conf = @{}
|
| 36 |
+
if (Test-Path -LiteralPath $Path) {
|
| 37 |
+
foreach ($line in Get-Content -LiteralPath $Path) {
|
| 38 |
+
if ($line -match '^\s*#' -or $line -notmatch '=') { continue }
|
| 39 |
+
$parts = $line -split '=', 2
|
| 40 |
+
$conf[$parts[0].Trim()] = $parts[1].Trim().Trim('"')
|
| 41 |
+
}
|
| 42 |
+
}
|
| 43 |
+
return $conf
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
$Defaults = [ordered]@{
|
| 47 |
+
PASSWORD_MIN_LENGTH = '14'; PASSWORD_HISTORY = '24'; PASSWORD_MAX_AGE_DAYS = '365'; PASSWORD_MIN_AGE_DAYS = '1'
|
| 48 |
+
PASSWORD_COMPLEXITY = '1'; LOCKOUT_THRESHOLD_MAX = '5'; LOCKOUT_DURATION_MIN_MINUTES = '15'; LOCKOUT_OBSERVATION_MIN_MINUTES = '15'
|
| 49 |
+
SIGNATURE_MAX_AGE_DAYS = '3'; MACOS_XPROTECT_MAX_AGE_DAYS = '30'; FIREWALL_OUTBOUND = 'allow'; SCREEN_LOCK_MAX_SECONDS = '900'
|
| 50 |
+
USB_READ = 'allow'; USB_WRITE = 'deny'; APPROVED_ADMINS = ''; ALLOW_DISABLED_BUILTIN_ADMIN = '1'
|
| 51 |
+
UPDATES_MAX_AGE_DAYS = '30'; BACKUP_MAX_AGE_DAYS = '7'; APPROVED_REMOTE_SERVICES = ''; WINDOWS_MIN_BUILD = '22631'
|
| 52 |
+
MACOS_MIN_MAJOR = '14'; UBUNTU_MIN_VERSION = '22.04'; DEBIAN_MIN_VERSION = '12'; RHEL_MIN_VERSION = '9'
|
| 53 |
+
PERSONAL_DATA_SCAN = '0'; APPROVED_AI_TOOLS = ''
|
| 54 |
+
}
|
| 55 |
+
$conf = Read-Conf $Baseline
|
| 56 |
+
$B = [ordered]@{}
|
| 57 |
+
foreach ($key in $Defaults.Keys) {
|
| 58 |
+
if ($conf.ContainsKey($key) -and $conf[$key] -ne '') { $B[$key] = $conf[$key] } else { $B[$key] = $Defaults[$key] }
|
| 59 |
+
}
|
| 60 |
+
$OrgAnswers = Read-Conf $Organisational
|
| 61 |
+
|
| 62 |
+
function Split-List([string]$List) { @($List -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' }) }
|
| 63 |
+
# Case-insensitive match against a comma-separated list; "*" wildcards allowed.
|
| 64 |
+
function Test-InList([string[]]$Values, [string]$List) {
|
| 65 |
+
foreach ($pattern in (Split-List $List)) {
|
| 66 |
+
foreach ($value in $Values) { if ($value -and $value -like $pattern) { return $true } }
|
| 67 |
+
}
|
| 68 |
+
return $false
|
| 69 |
+
}
|
| 70 |
+
function Get-RegValue([string]$Path, [string]$Name) {
|
| 71 |
+
try { return (Get-ItemProperty -LiteralPath $Path -Name $Name -ErrorAction Stop).$Name } catch { return $null }
|
| 72 |
+
}
|
| 73 |
+
function Get-Prop($Object, [string]$Name) {
|
| 74 |
+
if ($null -ne $Object -and $Object.PSObject.Properties[$Name]) { return $Object.$Name } else { return $null }
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
$Results = New-Object System.Collections.Generic.List[object]
|
| 78 |
+
function Add-Result($Id, $Section, $Name, $Status, $Actual, $Expected, $Detail) {
|
| 79 |
+
$item = [ordered]@{ id = $Id; section = $Section; name = $Name; status = $Status; actual = "$Actual"; expected = "$Expected" }
|
| 80 |
+
if ($Detail) { $item['detail'] = "$Detail" }
|
| 81 |
+
$Results.Add($item)
|
| 82 |
+
}
|
| 83 |
+
function Pass([bool]$Ok, $Actual, $Detail) {
|
| 84 |
+
$status = 'Non-Compliant'; if ($Ok) { $status = 'Compliant' }
|
| 85 |
+
return @{ Status = $status; Actual = $Actual; Detail = $Detail }
|
| 86 |
+
}
|
| 87 |
+
function State([string]$Status, $Actual, $Detail) { return @{ Status = $Status; Actual = $Actual; Detail = $Detail } }
|
| 88 |
+
# Run one check; any failure to read becomes an Error result instead of stopping the audit.
|
| 89 |
+
function Check($Id, $Section, $Name, $Expected, [scriptblock]$Test) {
|
| 90 |
+
try {
|
| 91 |
+
$r = & $Test
|
| 92 |
+
Add-Result $Id $Section $Name $r.Status $r.Actual $Expected $r.Detail
|
| 93 |
+
} catch {
|
| 94 |
+
Add-Result $Id $Section $Name 'Error' 'not read' $Expected $_.Exception.Message
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
$Identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
| 99 |
+
$IsAdmin = (New-Object Security.Principal.WindowsPrincipal($Identity)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
| 100 |
+
function Assert-Admin { if (-not $IsAdmin) { throw 'Requires an elevated (administrator) PowerShell.' } }
|
| 101 |
+
|
| 102 |
+
# ---------------------------------------------------------------------------
|
| 103 |
+
# Host information
|
| 104 |
+
# ---------------------------------------------------------------------------
|
| 105 |
+
$OsInfo = Get-CimInstance Win32_OperatingSystem
|
| 106 |
+
$Serial = (Get-CimInstance Win32_BIOS).SerialNumber
|
| 107 |
+
$AuditDate = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
|
| 108 |
+
$StartedAt = (Get-Date).ToString('yyyy-MM-ddTHH:mm:sszzz')
|
| 109 |
+
if (-not $IsAdmin) { Write-Warning 'Not running elevated: several checks will report Error. Run PowerShell as administrator.' }
|
| 110 |
+
|
| 111 |
+
# Local security policy (password, lockout, legacy audit policy) via secedit export.
|
| 112 |
+
function Get-SecPolicy {
|
| 113 |
+
$tmp = Join-Path $env:TEMP ("devseis_secpol_{0}.inf" -f [guid]::NewGuid())
|
| 114 |
+
try {
|
| 115 |
+
$null = & secedit.exe /export /cfg $tmp /areas SECURITYPOLICY /quiet
|
| 116 |
+
if (-not (Test-Path -LiteralPath $tmp)) { throw 'secedit export failed' }
|
| 117 |
+
$policy = @{}
|
| 118 |
+
foreach ($line in Get-Content -LiteralPath $tmp) {
|
| 119 |
+
if ($line -match '^\s*([A-Za-z]+)\s*=\s*(.+?)\s*$') { $policy[$Matches[1]] = $Matches[2] }
|
| 120 |
+
}
|
| 121 |
+
return $policy
|
| 122 |
+
} finally { Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue }
|
| 123 |
+
}
|
| 124 |
+
$Sec = $null
|
| 125 |
+
if ($IsAdmin) { try { $Sec = Get-SecPolicy } catch { $Sec = $null } }
|
| 126 |
+
function SecInt([string]$Key) {
|
| 127 |
+
Assert-Admin
|
| 128 |
+
if ($null -eq $Sec) { throw 'Local security policy could not be exported.' }
|
| 129 |
+
if ($Sec.ContainsKey($Key)) { return [int]$Sec[$Key] } else { return $null }
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
# ---------------------------------------------------------------------------
|
| 133 |
+
# 1. Security baseline
|
| 134 |
+
# ---------------------------------------------------------------------------
|
| 135 |
+
$S = 'baseline'
|
| 136 |
+
$minLength = [int]$B.PASSWORD_MIN_LENGTH; $history = [int]$B.PASSWORD_HISTORY; $maxAge = [int]$B.PASSWORD_MAX_AGE_DAYS
|
| 137 |
+
$minAge = [int]$B.PASSWORD_MIN_AGE_DAYS; $lockMax = [int]$B.LOCKOUT_THRESHOLD_MAX
|
| 138 |
+
$lockDur = [int]$B.LOCKOUT_DURATION_MIN_MINUTES; $lockObs = [int]$B.LOCKOUT_OBSERVATION_MIN_MINUTES
|
| 139 |
+
|
| 140 |
+
Check 'password.min_length' $S 'Minimum password length' ">= $minLength" { $v = SecInt 'MinimumPasswordLength'; Pass ($v -ge $minLength) $v }
|
| 141 |
+
Check 'password.history' $S 'Password history' ">= $history" { $v = SecInt 'PasswordHistorySize'; Pass ($v -ge $history) $v }
|
| 142 |
+
Check 'password.expiration' $S 'Password expiration' "1-$maxAge days" {
|
| 143 |
+
$v = SecInt 'MaximumPasswordAge'
|
| 144 |
+
$text = "$v days"; if ($null -eq $v -or $v -le 0) { $text = 'never' }
|
| 145 |
+
if ($maxAge -eq 0) { State 'Compliant' $text 'Baseline does not require expiry.' }
|
| 146 |
+
else { Pass ($null -ne $v -and $v -ge 1 -and $v -le $maxAge) $text }
|
| 147 |
+
}
|
| 148 |
+
Check 'password.min_age' $S 'Minimum password age' ">= $minAge days" { $v = SecInt 'MinimumPasswordAge'; Pass ($v -ge $minAge) "$v days" }
|
| 149 |
+
Check 'password.complexity' $S 'Password complexity' 'enabled' { $v = SecInt 'PasswordComplexity'; Pass ($v -eq 1) $(if ($v -eq 1) { 'enabled' } else { 'disabled' }) }
|
| 150 |
+
Check 'lockout.threshold' $S 'Account lockout threshold' "1-$lockMax" { $v = SecInt 'LockoutBadCount'; Pass ($v -ge 1 -and $v -le $lockMax) $v }
|
| 151 |
+
Check 'lockout.duration' $S 'Account lockout duration' ">= $lockDur minutes" {
|
| 152 |
+
$v = SecInt 'LockoutDuration'
|
| 153 |
+
if ($null -eq $v) { Pass $false 'no lockout configured' }
|
| 154 |
+
elseif ($v -eq -1) { Pass $true 'until an administrator unlocks' }
|
| 155 |
+
else { Pass ($v -ge $lockDur) "$v minutes" }
|
| 156 |
+
}
|
| 157 |
+
Check 'lockout.observation' $S 'Lockout observation period' ">= $lockObs minutes" {
|
| 158 |
+
$v = SecInt 'ResetLockoutCount'
|
| 159 |
+
if ($null -eq $v) { Pass $false 'no lockout configured' } else { Pass ($v -ge $lockObs) "$v minutes" }
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
# Antivirus (Security Center) and Microsoft Defender
|
| 163 |
+
$AvProducts = @()
|
| 164 |
+
try { $AvProducts = @(Get-CimInstance -Namespace root/SecurityCenter2 -ClassName AntiVirusProduct -ErrorAction Stop) } catch { $AvProducts = @() }
|
| 165 |
+
$AvGood = @($AvProducts | Where-Object { (([int]$_.productState -band 0x1000) -ne 0) -and (([int]$_.productState -band 0x10) -eq 0) })
|
| 166 |
+
$ThirdPartyAv = @($AvGood | Where-Object { $_.displayName -notmatch 'Defender' } | ForEach-Object { $_.displayName })
|
| 167 |
+
Check 'malware.antivirus' $S 'Antivirus' 'enabled and up to date' {
|
| 168 |
+
$names = ($AvProducts | ForEach-Object {
|
| 169 |
+
$s = [int]$_.productState
|
| 170 |
+
"{0} ({1}, {2})" -f $_.displayName, $(if (($s -band 0x1000) -ne 0) { 'on' } else { 'off' }), $(if (($s -band 0x10) -eq 0) { 'up to date' } else { 'out of date' })
|
| 171 |
+
}) -join '; '
|
| 172 |
+
if (-not $names) { $names = 'none registered' }
|
| 173 |
+
Pass ($AvGood.Count -gt 0) $names
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
$Mp = $null; $MpError = $null
|
| 177 |
+
try { $Mp = Get-MpComputerStatus -ErrorAction Stop } catch { $MpError = $_.Exception.Message }
|
| 178 |
+
$runningMode = Get-Prop $Mp 'AMRunningMode'
|
| 179 |
+
$DefenderPassive = ($null -ne $Mp -and $runningMode -and $runningMode -ne 'Normal') -or ($null -eq $Mp -and $ThirdPartyAv.Count -gt 0)
|
| 180 |
+
function DefenderCheck($Id, $Name, $Expected, [scriptblock]$Test) {
|
| 181 |
+
if ($DefenderPassive) {
|
| 182 |
+
Add-Result $Id $S $Name 'NotApplicable' "Defender in $(if ($runningMode) { $runningMode } else { 'passive/off' })" $Expected ("Another antivirus is primary: " + ($ThirdPartyAv -join ', '))
|
| 183 |
+
} elseif ($null -eq $Mp) {
|
| 184 |
+
Add-Result $Id $S $Name 'Error' 'not read' $Expected $MpError
|
| 185 |
+
} else { Check $Id $S $Name $Expected $Test }
|
| 186 |
+
}
|
| 187 |
+
DefenderCheck 'malware.service' 'Microsoft Defender Antimalware' 'running' { Pass ($Mp.AMServiceEnabled -and $Mp.AntivirusEnabled) ("service {0}, antivirus {1}" -f $Mp.AMServiceEnabled, $Mp.AntivirusEnabled) }
|
| 188 |
+
DefenderCheck 'malware.realtime' 'Real-time protection' 'on' { Pass ([bool]$Mp.RealTimeProtectionEnabled) $(if ($Mp.RealTimeProtectionEnabled) { 'on' } else { 'off' }) }
|
| 189 |
+
DefenderCheck 'malware.on_access' 'On-access protection' 'on' { Pass ([bool]$Mp.OnAccessProtectionEnabled) $(if ($Mp.OnAccessProtectionEnabled) { 'on' } else { 'off' }) }
|
| 190 |
+
DefenderCheck 'malware.tamper' 'Tamper protection' 'on' { Pass ([bool]$Mp.IsTamperProtected) $(if ($Mp.IsTamperProtected) { 'on' } else { 'off' }) }
|
| 191 |
+
$sigMax = [int]$B.SIGNATURE_MAX_AGE_DAYS
|
| 192 |
+
DefenderCheck 'malware.signatures' 'Security intelligence' "<= $sigMax days old" {
|
| 193 |
+
Pass ([int]$Mp.AntivirusSignatureAge -le $sigMax) ("{0} days old (version {1})" -f $Mp.AntivirusSignatureAge, $Mp.AntivirusSignatureVersion) ("Last updated {0}" -f $Mp.AntivirusSignatureLastUpdated)
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
# Firewall profiles (effective policy)
|
| 197 |
+
$Profiles = @{}
|
| 198 |
+
try { Get-NetFirewallProfile -PolicyStore ActiveStore -ErrorAction Stop | ForEach-Object { $Profiles[$_.Name] = $_ } } catch { }
|
| 199 |
+
$outExpected = $B.FIREWALL_OUTBOUND.ToLower()
|
| 200 |
+
foreach ($profileName in 'Domain', 'Private', 'Public') {
|
| 201 |
+
$p = $null; if ($Profiles.ContainsKey($profileName)) { $p = $Profiles[$profileName] }
|
| 202 |
+
$id = "firewall.$($profileName.ToLower())"
|
| 203 |
+
Check "$id.enabled" $S "$profileName firewall" 'on' {
|
| 204 |
+
if ($null -eq $p) { throw 'Firewall profile not readable.' }
|
| 205 |
+
Pass ("$($p.Enabled)" -eq 'True') $(if ("$($p.Enabled)" -eq 'True') { 'on' } else { 'off' })
|
| 206 |
+
}
|
| 207 |
+
Check "$id.inbound" $S "$profileName inbound" 'block' {
|
| 208 |
+
if ($null -eq $p) { throw 'Firewall profile not readable.' }
|
| 209 |
+
$action = "$($p.DefaultInboundAction)"; $detail = $null
|
| 210 |
+
if ($action -eq 'NotConfigured') { $action = 'Block'; $detail = 'Not configured: Windows default (Block) applies.' }
|
| 211 |
+
Pass ($action -eq 'Block' -and "$($p.Enabled)" -eq 'True') $action.ToLower() $detail
|
| 212 |
+
}
|
| 213 |
+
Check "$id.outbound" $S "$profileName outbound" $outExpected {
|
| 214 |
+
if ($null -eq $p) { throw 'Firewall profile not readable.' }
|
| 215 |
+
$action = "$($p.DefaultOutboundAction)"; $detail = $null
|
| 216 |
+
if ($action -eq 'NotConfigured') { $action = 'Allow'; $detail = 'Not configured: Windows default (Allow) applies.' }
|
| 217 |
+
Pass ($action.ToLower() -eq $outExpected -and "$($p.Enabled)" -eq 'True') $action.ToLower() $detail
|
| 218 |
+
}
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
# Screen lock: machine inactivity limit, else every loaded user profile's screen saver settings.
|
| 222 |
+
$lockMaxSec = [int]$B.SCREEN_LOCK_MAX_SECONDS
|
| 223 |
+
$MachineInactivity = Get-RegValue 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' 'InactivityTimeoutSecs'
|
| 224 |
+
$LockDisabled = Get-RegValue 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' 'DisableLockWorkstation'
|
| 225 |
+
$UserScreens = @()
|
| 226 |
+
foreach ($hive in Get-ChildItem -Path 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue) {
|
| 227 |
+
if ($hive.PSChildName -notmatch '^S-1-(5-21|12-1)-[\d-]+$') { continue }
|
| 228 |
+
$setting = @{}
|
| 229 |
+
foreach ($key in "Registry::HKEY_USERS\$($hive.PSChildName)\Software\Policies\Microsoft\Windows\Control Panel\Desktop", "Registry::HKEY_USERS\$($hive.PSChildName)\Control Panel\Desktop") {
|
| 230 |
+
foreach ($name in 'ScreenSaveActive', 'ScreenSaveTimeOut', 'ScreenSaverIsSecure') {
|
| 231 |
+
if (-not $setting.ContainsKey($name)) { $v = Get-RegValue $key $name; if ($null -ne $v) { $setting[$name] = "$v" } }
|
| 232 |
+
}
|
| 233 |
+
}
|
| 234 |
+
$account = $hive.PSChildName
|
| 235 |
+
try { $account = (New-Object Security.Principal.SecurityIdentifier($hive.PSChildName)).Translate([Security.Principal.NTAccount]).Value } catch { }
|
| 236 |
+
$UserScreens += [pscustomobject]@{ User = $account; Active = $setting['ScreenSaveActive']; Timeout = $setting['ScreenSaveTimeOut']; Secure = $setting['ScreenSaverIsSecure'] }
|
| 237 |
+
}
|
| 238 |
+
Check 'screen_lock.timeout' $S 'Screen lock timeout' "<= $lockMaxSec seconds" {
|
| 239 |
+
if ($null -ne $MachineInactivity -and [int]$MachineInactivity -gt 0) {
|
| 240 |
+
return Pass ([int]$MachineInactivity -le $lockMaxSec) "$MachineInactivity seconds (machine inactivity limit)"
|
| 241 |
+
}
|
| 242 |
+
if ($UserScreens.Count -eq 0) { return Pass $false 'no machine inactivity limit; no signed-in user to check' }
|
| 243 |
+
$bad = @($UserScreens | Where-Object { $_.Active -ne '1' -or -not $_.Timeout -or [int]$_.Timeout -le 0 -or [int]$_.Timeout -gt $lockMaxSec })
|
| 244 |
+
if ($bad.Count -eq 0) { Pass $true ('screen saver within limit for: ' + (($UserScreens | ForEach-Object { $_.User }) -join ', ')) }
|
| 245 |
+
else { Pass $false ('no machine inactivity limit; too long or off for: ' + (($bad | ForEach-Object { "$($_.User) ($($_.Timeout)s, active=$($_.Active))" }) -join ', ')) }
|
| 246 |
+
}
|
| 247 |
+
Check 'screen_lock.password' $S 'Screen lock password requirement' 'sign-in required to unlock' {
|
| 248 |
+
if ($LockDisabled -eq 1) { return Pass $false 'locking the workstation is disabled by policy' }
|
| 249 |
+
if ($null -ne $MachineInactivity -and [int]$MachineInactivity -gt 0) { return Pass $true 'machine inactivity limit locks the session' }
|
| 250 |
+
if ($UserScreens.Count -eq 0) { return Pass $false 'no machine policy; no signed-in user to check' }
|
| 251 |
+
$bad = @($UserScreens | Where-Object { $_.Secure -ne '1' })
|
| 252 |
+
if ($bad.Count -eq 0) { Pass $true 'screen saver requires sign-in for all signed-in users' }
|
| 253 |
+
else { Pass $false ('screen saver does not require sign-in for: ' + (($bad | ForEach-Object { $_.User }) -join ', ')) }
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
# USB storage
|
| 257 |
+
$RS = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\RemovableStorageDevices'
|
| 258 |
+
$RemovableDisk = "$RS\{53f5630d-b6bf-11d0-94f2-00a0c91efb8b}"
|
| 259 |
+
$UsbStorStart = Get-RegValue 'HKLM:\SYSTEM\CurrentControlSet\Services\USBSTOR' 'Start'
|
| 260 |
+
$UsbRead = 'allow'; $UsbWrite = 'allow'; $UsbDetail = @()
|
| 261 |
+
if ((Get-RegValue $RS 'Deny_All') -eq 1) { $UsbRead = 'deny'; $UsbWrite = 'deny'; $UsbDetail += 'all removable storage denied by policy' }
|
| 262 |
+
if ((Get-RegValue $RemovableDisk 'Deny_Read') -eq 1) { $UsbRead = 'deny'; $UsbDetail += 'removable disk read denied by policy' }
|
| 263 |
+
if ((Get-RegValue $RemovableDisk 'Deny_Write') -eq 1) { $UsbWrite = 'deny'; $UsbDetail += 'removable disk write denied by policy' }
|
| 264 |
+
if ($UsbStorStart -eq 4) { $UsbRead = 'deny'; $UsbWrite = 'deny'; $UsbDetail += 'USB mass storage driver disabled' }
|
| 265 |
+
if ((Get-RegValue 'HKLM:\SYSTEM\CurrentControlSet\Control\StorageDevicePolicies' 'WriteProtect') -eq 1) { $UsbWrite = 'deny'; $UsbDetail += 'storage write protection on' }
|
| 266 |
+
Check 'usb.read' $S 'USB storage read' $B.USB_READ { Pass ($UsbRead -eq $B.USB_READ.ToLower()) $UsbRead ($UsbDetail -join '; ') }
|
| 267 |
+
Check 'usb.write' $S 'USB storage write' $B.USB_WRITE { Pass ($UsbWrite -eq $B.USB_WRITE.ToLower()) $UsbWrite ($UsbDetail -join '; ') }
|
| 268 |
+
|
| 269 |
+
# Local Administrators group (found by its well-known SID, so it works in any language)
|
| 270 |
+
function Get-AdminMembers {
|
| 271 |
+
$groupName = (New-Object Security.Principal.SecurityIdentifier('S-1-5-32-544')).Translate([Security.Principal.NTAccount]).Value.Split('\')[-1]
|
| 272 |
+
$group = [ADSI]"WinNT://$env:COMPUTERNAME/$groupName,group"
|
| 273 |
+
$machineSid = $null
|
| 274 |
+
try { $machineSid = (Get-LocalUser -ErrorAction Stop | Select-Object -First 1).SID.AccountDomainSid.Value } catch { }
|
| 275 |
+
foreach ($member in @($group.Invoke('Members'))) {
|
| 276 |
+
$sidBytes = $member.GetType().InvokeMember('objectSid', 'GetProperty', $null, $member, $null)
|
| 277 |
+
$sid = (New-Object Security.Principal.SecurityIdentifier($sidBytes, 0)).Value
|
| 278 |
+
$name = $sid
|
| 279 |
+
try { $name = (New-Object Security.Principal.SecurityIdentifier($sid)).Translate([Security.Principal.NTAccount]).Value } catch { }
|
| 280 |
+
$enabled = $true
|
| 281 |
+
if ($machineSid -and $sid.StartsWith($machineSid)) {
|
| 282 |
+
try { $enabled = (Get-LocalUser -SID $sid -ErrorAction Stop).Enabled } catch { }
|
| 283 |
+
}
|
| 284 |
+
[pscustomobject]@{ Name = $name; Short = $name.Split('\')[-1]; Sid = $sid; Enabled = $enabled; BuiltIn = $sid -match '-500$' }
|
| 285 |
+
}
|
| 286 |
+
}
|
| 287 |
+
$Admins = $null; $AdminError = $null
|
| 288 |
+
try { $Admins = @(Get-AdminMembers) } catch { $AdminError = $_.Exception.Message }
|
| 289 |
+
Check 'admin.approved' $S 'Approved IT administrator' $(if ($B.APPROVED_ADMINS) { $B.APPROVED_ADMINS } else { 'none configured' }) {
|
| 290 |
+
if ($null -eq $Admins) { throw $AdminError }
|
| 291 |
+
$ok = @($Admins | Where-Object { $_.Enabled -and (Test-InList @($_.Name, $_.Short, $_.Sid) $B.APPROVED_ADMINS) })
|
| 292 |
+
if ($ok.Count -gt 0) { Pass $true (($ok | ForEach-Object { $_.Name }) -join ', ') }
|
| 293 |
+
else { Pass $false 'none of the approved accounts is an enabled administrator' }
|
| 294 |
+
}
|
| 295 |
+
Check 'admin.unauthorized' $S 'Unauthorized employee local administrator accounts' 'none' {
|
| 296 |
+
if ($null -eq $Admins) { throw $AdminError }
|
| 297 |
+
$bad = @($Admins | Where-Object {
|
| 298 |
+
-not (Test-InList @($_.Name, $_.Short, $_.Sid) $B.APPROVED_ADMINS) -and
|
| 299 |
+
-not ($_.BuiltIn -and -not $_.Enabled -and $B.ALLOW_DISABLED_BUILTIN_ADMIN -eq '1')
|
| 300 |
+
})
|
| 301 |
+
if ($bad.Count -eq 0) { Pass $true 'none' }
|
| 302 |
+
else { Pass $false (($bad | ForEach-Object { $_.Name + $(if (-not $_.Enabled) { ' (disabled)' } else { '' }) }) -join ', ') }
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
# BitLocker on the system drive
|
| 306 |
+
$Bl = $null; $BlError = $null
|
| 307 |
+
try { Assert-Admin; $Bl = Get-BitLockerVolume -MountPoint $env:SystemDrive -ErrorAction Stop } catch { $BlError = $_.Exception.Message }
|
| 308 |
+
function BitLockerCheck($Id, $Name, $Expected, [scriptblock]$Test) {
|
| 309 |
+
if ($null -eq $Bl) { Add-Result $Id $S $Name 'Error' 'not read' $Expected $BlError } else { Check $Id $S $Name $Expected $Test }
|
| 310 |
+
}
|
| 311 |
+
BitLockerCheck 'encryption.feature' 'BitLocker' 'on' { Pass ("$($Bl.VolumeStatus)" -ne 'FullyDecrypted') $(if ("$($Bl.VolumeStatus)" -ne 'FullyDecrypted') { 'on' } else { 'off' }) }
|
| 312 |
+
BitLockerCheck 'encryption.enabled' 'Encryption' 'fully encrypted' { Pass ("$($Bl.VolumeStatus)" -eq 'FullyEncrypted') ("{0} ({1}%)" -f $Bl.VolumeStatus, $Bl.EncryptionPercentage) }
|
| 313 |
+
BitLockerCheck 'encryption.protection' 'BitLocker protection' 'protection on, AES' {
|
| 314 |
+
$method = "$($Bl.EncryptionMethod)"
|
| 315 |
+
Pass ("$($Bl.ProtectionStatus)" -eq 'On' -and $method -match 'Aes') ("protection {0}, {1}" -f $Bl.ProtectionStatus, $method)
|
| 316 |
+
}
|
| 317 |
+
BitLockerCheck 'encryption.recovery' 'BitLocker recovery' 'recovery password present' {
|
| 318 |
+
$types = @($Bl.KeyProtector | ForEach-Object { "$($_.KeyProtectorType)" })
|
| 319 |
+
Pass ($types -contains 'RecoveryPassword') ('key protectors: ' + ($types -join ', '))
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
# ---------------------------------------------------------------------------
|
| 323 |
+
# 2. GDPR technical controls
|
| 324 |
+
# ---------------------------------------------------------------------------
|
| 325 |
+
$S = 'gdpr'
|
| 326 |
+
|
| 327 |
+
Check 'gdpr.auto_updates' $S 'Automatic security updates' 'automatic' {
|
| 328 |
+
$au = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU'
|
| 329 |
+
$noAuto = Get-RegValue $au 'NoAutoUpdate'; $option = Get-RegValue $au 'AUOptions'
|
| 330 |
+
$svc = Get-Service -Name wuauserv -ErrorAction SilentlyContinue
|
| 331 |
+
if ($svc -and "$($svc.StartType)" -eq 'Disabled') { return Pass $false 'Windows Update service disabled' }
|
| 332 |
+
if ($noAuto -eq 1) { return Pass $false 'automatic updates turned off by policy' }
|
| 333 |
+
if ($null -ne $option) { return Pass ([int]$option -ge 3) "AUOptions=$option" 'AUOptions 3-5 download and install automatically; 2 only notifies.' }
|
| 334 |
+
Pass $true 'Windows default (automatic)'
|
| 335 |
+
}
|
| 336 |
+
$updMax = [int]$B.UPDATES_MAX_AGE_DAYS
|
| 337 |
+
Check 'gdpr.updates_recent' $S 'Security updates installed recently' "<= $updMax days ago" {
|
| 338 |
+
$last = $null
|
| 339 |
+
try {
|
| 340 |
+
$searcher = (New-Object -ComObject Microsoft.Update.Session).CreateUpdateSearcher()
|
| 341 |
+
$count = $searcher.GetTotalHistoryCount()
|
| 342 |
+
if ($count -gt 0) {
|
| 343 |
+
$last = @($searcher.QueryHistory(0, [Math]::Min($count, 300)) | Where-Object {
|
| 344 |
+
$_.Operation -eq 1 -and $_.ResultCode -eq 2 -and $_.Title -notmatch 'Security Intelligence|Definition Update|KB2267602'
|
| 345 |
+
} | Sort-Object Date -Descending | Select-Object -First 1)
|
| 346 |
+
}
|
| 347 |
+
} catch { }
|
| 348 |
+
if ($last -and $last.Count -gt 0) {
|
| 349 |
+
$age = [int]((Get-Date) - $last[0].Date.ToLocalTime()).TotalDays
|
| 350 |
+
return Pass ($age -le $updMax) "$age days ago" ("Last update: {0} ({1:yyyy-MM-dd})" -f $last[0].Title, $last[0].Date)
|
| 351 |
+
}
|
| 352 |
+
$hotfix = Get-HotFix -ErrorAction Stop | Where-Object { $_.InstalledOn } | Sort-Object InstalledOn -Descending | Select-Object -First 1
|
| 353 |
+
if (-not $hotfix) { throw 'No update history found.' }
|
| 354 |
+
$age = [int]((Get-Date) - $hotfix.InstalledOn).TotalDays
|
| 355 |
+
Pass ($age -le $updMax) "$age days ago" ("Last hotfix: {0}" -f $hotfix.HotFixID)
|
| 356 |
+
}
|
| 357 |
+
Check 'gdpr.os_supported' $S 'Supported operating system version' "build $($B.WINDOWS_MIN_BUILD) or later" {
|
| 358 |
+
Pass ([int]$OsInfo.BuildNumber -ge [int]$B.WINDOWS_MIN_BUILD) ("{0} build {1}" -f $OsInfo.Caption, $OsInfo.BuildNumber)
|
| 359 |
+
}
|
| 360 |
+
Check 'gdpr.security_logging' $S 'Security event logging' 'logon success+failure and account changes audited' {
|
| 361 |
+
Assert-Admin
|
| 362 |
+
$tmp = Join-Path $env:TEMP ("devseis_audit_{0}.csv" -f [guid]::NewGuid())
|
| 363 |
+
try {
|
| 364 |
+
$null = & auditpol.exe /backup /file:$tmp
|
| 365 |
+
$rows = @(Import-Csv -LiteralPath $tmp)
|
| 366 |
+
} finally { Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue }
|
| 367 |
+
$value = { param($guid) $r = $rows | Where-Object { $_.'Subcategory GUID' -eq $guid } | Select-Object -First 1; if ($r) { [int]$r.'Setting Value' } else { 0 } }
|
| 368 |
+
$logon = & $value '{0CCE9215-69AE-11D9-BED3-505054503030}'
|
| 369 |
+
$account = & $value '{0CCE9235-69AE-11D9-BED3-505054503030}'
|
| 370 |
+
# Fall back to the legacy audit policy when advanced subcategories are not set.
|
| 371 |
+
if ($logon -eq 0 -and $Sec -and $Sec.ContainsKey('AuditLogonEvents')) { $logon = [int]$Sec['AuditLogonEvents'] }
|
| 372 |
+
if ($account -eq 0 -and $Sec -and $Sec.ContainsKey('AuditAccountManage')) { $account = [int]$Sec['AuditAccountManage'] }
|
| 373 |
+
$names = @{ 0 = 'none'; 1 = 'success'; 2 = 'failure'; 3 = 'success+failure' }
|
| 374 |
+
Pass ($logon -eq 3 -and ($account -band 1) -eq 1) ("logon: {0}; account management: {1}" -f $names[$logon], $names[$account])
|
| 375 |
+
}
|
| 376 |
+
Check 'gdpr.time_sync' $S 'Time synchronisation' 'NTP or domain time' {
|
| 377 |
+
$type = Get-RegValue 'HKLM:\SYSTEM\CurrentControlSet\Services\W32Time\Parameters' 'Type'
|
| 378 |
+
$svc = Get-Service -Name W32Time -ErrorAction SilentlyContinue
|
| 379 |
+
Pass ($type -and $type -ne 'NoSync' -and $svc -and "$($svc.StartType)" -ne 'Disabled') ("type {0}, service {1}" -f $type, $(if ($svc) { "$($svc.Status)/$($svc.StartType)" } else { 'missing' }))
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
$BackupTools = @()
|
| 383 |
+
if (Get-RegValue 'HKLM:\SOFTWARE\Policies\Microsoft\OneDrive' 'KFMSilentOptIn') { $BackupTools += 'OneDrive folder backup (policy)' }
|
| 384 |
+
foreach ($profileDir in Get-ChildItem -Path (Join-Path $env:SystemDrive 'Users') -Directory -ErrorAction SilentlyContinue) {
|
| 385 |
+
if (Test-Path -LiteralPath (Join-Path $profileDir.FullName 'AppData\Local\Microsoft\Windows\FileHistory\Configuration\Config1.xml')) { $BackupTools += "File History ($($profileDir.Name))" }
|
| 386 |
+
}
|
| 387 |
+
foreach ($svcName in 'VeeamEndpointBackupSvc', 'AcronisCyberProtectionService', 'MMS', 'CrashPlanService', 'bzserv') {
|
| 388 |
+
$svc = Get-Service -Name $svcName -ErrorAction SilentlyContinue
|
| 389 |
+
if ($svc -and "$($svc.Status)" -eq 'Running') { $BackupTools += $svc.DisplayName }
|
| 390 |
+
}
|
| 391 |
+
Check 'gdpr.backup_configured' $S 'Backup configured' 'backup configured' {
|
| 392 |
+
Pass ($BackupTools.Count -gt 0) $(if ($BackupTools.Count) { $BackupTools -join ', ' } else { 'none found' })
|
| 393 |
+
}
|
| 394 |
+
Check 'gdpr.backup_recent' $S 'Recent backup' "<= $($B.BACKUP_MAX_AGE_DAYS) days ago" {
|
| 395 |
+
if ($BackupTools.Count -eq 0) { return Pass $false 'no backup' }
|
| 396 |
+
State 'Pending' 'date not readable' ("Confirm the last backup date in: " + ($BackupTools -join ', '))
|
| 397 |
+
}
|
| 398 |
+
Check 'gdpr.removable_encryption' $S 'Removable media encryption' 'no unencrypted writes' {
|
| 399 |
+
$btg = Get-RegValue 'HKLM:\SYSTEM\CurrentControlSet\Policies\Microsoft\FVE' 'RDVDenyWriteAccess'
|
| 400 |
+
if ($btg -eq 1) { return Pass $true 'BitLocker To Go required before writing' }
|
| 401 |
+
if ($UsbWrite -eq 'deny') { return Pass $true 'writing to USB storage blocked' }
|
| 402 |
+
Pass $false 'USB drives writable without encryption'
|
| 403 |
+
}
|
| 404 |
+
Check 'gdpr.guest_account' $S 'Guest account disabled' 'disabled' {
|
| 405 |
+
$guest = Get-LocalUser -ErrorAction Stop | Where-Object { $_.SID.Value -match '-501$' } | Select-Object -First 1
|
| 406 |
+
if (-not $guest) { return Pass $true 'no guest account' }
|
| 407 |
+
Pass (-not $guest.Enabled) $(if ($guest.Enabled) { 'enabled' } else { 'disabled' })
|
| 408 |
+
}
|
| 409 |
+
Check 'gdpr.remote_access' $S 'Remote access services restricted' $(if ($B.APPROVED_REMOTE_SERVICES) { "only: $($B.APPROVED_REMOTE_SERVICES)" } else { 'only: none' }) {
|
| 410 |
+
$on = @()
|
| 411 |
+
if ((Get-RegValue 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server' 'fDenyTSConnections') -eq 0) { $on += 'rdp' }
|
| 412 |
+
foreach ($pair in @(@('sshd', 'ssh'), @('WinRM', 'winrm'), @('TeamViewer', 'teamviewer'), @('AnyDesk', 'anydesk'), @('chromoting', 'chromeremotedesktop'))) {
|
| 413 |
+
$svc = Get-Service -Name $pair[0] -ErrorAction SilentlyContinue
|
| 414 |
+
if ($svc -and "$($svc.Status)" -eq 'Running') { $on += $pair[1] }
|
| 415 |
+
}
|
| 416 |
+
try { if (@(Get-SmbShare -ErrorAction Stop | Where-Object { -not $_.Special }).Count -gt 0) { $on += 'smb' } } catch { }
|
| 417 |
+
$bad = @($on | Where-Object { -not (Test-InList @($_) $B.APPROVED_REMOTE_SERVICES) })
|
| 418 |
+
if ($bad.Count -eq 0) { Pass $true $(if ($on.Count) { $on -join ', ' } else { 'none enabled' }) } else { Pass $false ($bad -join ', ') }
|
| 419 |
+
}
|
| 420 |
+
Check 'gdpr.telemetry' $S 'Diagnostic data minimised' 'required diagnostic data only (0-1)' {
|
| 421 |
+
$policy = Get-RegValue 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\DataCollection' 'AllowTelemetry'
|
| 422 |
+
$setting = Get-RegValue 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\DataCollection' 'AllowTelemetry'
|
| 423 |
+
$level = $policy; $source = 'policy'
|
| 424 |
+
if ($null -eq $level) { $level = $setting; $source = 'Settings' }
|
| 425 |
+
if ($null -eq $level) { return Pass $false 'not configured' 'Windows may send optional diagnostic data.' }
|
| 426 |
+
Pass ([int]$level -le 1) "AllowTelemetry=$level ($source)"
|
| 427 |
+
}
|
| 428 |
+
Add-Result 'gdpr.personal_data_discovery' $S 'Personal data discovery' 'NotApplicable' 'not run' 'opt-in scan' "Opt-in scan (PERSONAL_DATA_SCAN=$($B.PERSONAL_DATA_SCAN)) is planned for a later stage."
|
| 429 |
+
|
| 430 |
+
# ---------------------------------------------------------------------------
|
| 431 |
+
# 3. EU AI Act controls
|
| 432 |
+
# ---------------------------------------------------------------------------
|
| 433 |
+
$S = 'aiact'
|
| 434 |
+
$AiTools = New-Object System.Collections.Generic.List[string]
|
| 435 |
+
$AiDetail = @()
|
| 436 |
+
function Add-Tool([string]$Name) { if (-not $AiTools.Contains($Name)) { $AiTools.Add($Name) } }
|
| 437 |
+
$KnownApps = [ordered]@{ '^Ollama' = 'Ollama'; '^LM Studio' = 'LM Studio'; '^GPT4All' = 'GPT4All'; '^Jan( |$)' = 'Jan'; 'ChatGPT' = 'ChatGPT'
|
| 438 |
+
'^Claude' = 'Claude'; '^Cursor' = 'Cursor'; '^Windsurf' = 'Windsurf'; '^Perplexity' = 'Perplexity'; '^Msty' = 'Msty' }
|
| 439 |
+
$uninstallKeys = @('HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*', 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*')
|
| 440 |
+
foreach ($hive in Get-ChildItem -Path 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue) {
|
| 441 |
+
if ($hive.PSChildName -match '^S-1-(5-21|12-1)-[\d-]+$') { $uninstallKeys += "Registry::HKEY_USERS\$($hive.PSChildName)\Software\Microsoft\Windows\CurrentVersion\Uninstall\*" }
|
| 442 |
+
}
|
| 443 |
+
foreach ($key in $uninstallKeys) {
|
| 444 |
+
foreach ($app in @(Get-ItemProperty -Path $key -ErrorAction SilentlyContinue)) {
|
| 445 |
+
$display = Get-Prop $app 'DisplayName'
|
| 446 |
+
if (-not $display) { continue }
|
| 447 |
+
foreach ($pattern in $KnownApps.Keys) { if ($display -match $pattern) { Add-Tool $KnownApps[$pattern] } }
|
| 448 |
+
}
|
| 449 |
+
}
|
| 450 |
+
try {
|
| 451 |
+
$appx = @(Get-AppxPackage -AllUsers -ErrorAction Stop)
|
| 452 |
+
} catch { $appx = @(Get-AppxPackage -ErrorAction SilentlyContinue) }
|
| 453 |
+
foreach ($pkg in $appx) {
|
| 454 |
+
if ($pkg.Name -eq 'Microsoft.Copilot' -or $pkg.Name -eq 'Microsoft.Windows.Ai.Copilot.Provider') { Add-Tool 'Microsoft Copilot' }
|
| 455 |
+
elseif ($pkg.Name -match 'ChatGPT') { Add-Tool 'ChatGPT' }
|
| 456 |
+
elseif ($pkg.Name -match 'Claude') { Add-Tool 'Claude' }
|
| 457 |
+
}
|
| 458 |
+
try { if (Get-NetTCPConnection -LocalPort 11434 -State Listen -ErrorAction Stop) { Add-Tool 'Ollama'; $AiDetail += 'Local AI service listening on port 11434.' } } catch { }
|
| 459 |
+
foreach ($profileDir in Get-ChildItem -Path (Join-Path $env:SystemDrive 'Users') -Directory -ErrorAction SilentlyContinue) {
|
| 460 |
+
foreach ($cli in '.local\bin\claude.exe', 'AppData\Roaming\npm\claude.cmd') {
|
| 461 |
+
if (Test-Path -LiteralPath (Join-Path $profileDir.FullName $cli)) { Add-Tool 'Claude Code' }
|
| 462 |
+
}
|
| 463 |
+
foreach ($pair in @(@('.ollama\models', 'Ollama'), @('.cache\lm-studio\models', 'LM Studio'), @('.lmstudio\models', 'LM Studio'),
|
| 464 |
+
@('AppData\Local\nomic.ai\GPT4All', 'GPT4All'), @('.cache\huggingface\hub', 'Hugging Face models'))) {
|
| 465 |
+
$path = Join-Path $profileDir.FullName $pair[0]
|
| 466 |
+
if ((Test-Path -LiteralPath $path) -and (Get-ChildItem -LiteralPath $path -ErrorAction SilentlyContinue | Select-Object -First 1)) {
|
| 467 |
+
Add-Tool $pair[1]
|
| 468 |
+
$bytes = (Get-ChildItem -LiteralPath $path -Recurse -File -ErrorAction SilentlyContinue | Measure-Object -Property Length -Sum).Sum
|
| 469 |
+
$AiDetail += ("Local models: {0} ({1:N1} GB)." -f $path, ($bytes / 1GB))
|
| 470 |
+
}
|
| 471 |
+
}
|
| 472 |
+
}
|
| 473 |
+
$AiList = $AiTools -join ', '
|
| 474 |
+
Add-Result 'aiact.inventory' $S 'AI software inventory' 'Compliant' $(if ($AiList) { $AiList } else { 'no AI tools found' }) 'inventory taken' ($AiDetail -join ' ')
|
| 475 |
+
Check 'aiact.unapproved' $S 'Unapproved AI tools (shadow AI)' $(if ($B.APPROVED_AI_TOOLS) { "only: $($B.APPROVED_AI_TOOLS)" } else { 'only: none' }) {
|
| 476 |
+
$bad = @($AiTools | Where-Object { -not (Test-InList @($_) $B.APPROVED_AI_TOOLS) })
|
| 477 |
+
if ($bad.Count -eq 0) { Pass $true 'none' } else { Pass $false ($bad -join ', ') }
|
| 478 |
+
}
|
| 479 |
+
if ($AiTools.Count -gt 0) {
|
| 480 |
+
Add-Result 'aiact.risk_classification' $S 'AI system risk classification' 'Pending' $AiList 'every AI tool classified' 'Classified by the auditor model (Caveat classifier) in a later step.'
|
| 481 |
+
} else {
|
| 482 |
+
Add-Result 'aiact.risk_classification' $S 'AI system risk classification' 'NotApplicable' 'no AI tools found' 'every AI tool classified' $null
|
| 483 |
+
}
|
| 484 |
+
Check 'aiact.os_ai_features' $S 'Windows Recall / Copilot data analysis disabled' 'disabled by policy' {
|
| 485 |
+
$ai = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsAI'
|
| 486 |
+
$disableAnalysis = Get-RegValue $ai 'DisableAIDataAnalysis'; $allowRecall = Get-RegValue $ai 'AllowRecallEnablement'
|
| 487 |
+
$copilotOff = Get-RegValue 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsCopilot' 'TurnOffWindowsCopilot'
|
| 488 |
+
$detail = "DisableAIDataAnalysis=$disableAnalysis; AllowRecallEnablement=$allowRecall; TurnOffWindowsCopilot=$copilotOff"
|
| 489 |
+
if ($disableAnalysis -eq 1 -or $allowRecall -eq 0) { return Pass $true 'Recall disabled by policy' $detail }
|
| 490 |
+
$recall = $null
|
| 491 |
+
try { Assert-Admin; $recall = Get-WindowsOptionalFeature -Online -FeatureName Recall -ErrorAction Stop } catch { }
|
| 492 |
+
if ($null -eq $recall) { return State 'NotApplicable' 'Recall not available on this device' $detail }
|
| 493 |
+
Pass $false ("Recall feature {0}, not disabled by policy" -f $recall.State) $detail
|
| 494 |
+
}
|
| 495 |
+
if ($AiTools.Count -gt 0) {
|
| 496 |
+
Add-Result 'aiact.log_retention' $S 'AI usage log retention' 'Pending' 'depends on classification' '>= 6 months for high-risk AI' 'Required only if a tool is classified high-risk.'
|
| 497 |
+
} else {
|
| 498 |
+
Add-Result 'aiact.log_retention' $S 'AI usage log retention' 'NotApplicable' 'no AI tools found' '>= 6 months for high-risk AI' $null
|
| 499 |
+
}
|
| 500 |
+
|
| 501 |
+
# ---------------------------------------------------------------------------
|
| 502 |
+
# 4. Organisational controls (answers from organisational.conf)
|
| 503 |
+
# ---------------------------------------------------------------------------
|
| 504 |
+
$S = 'org'
|
| 505 |
+
$OrgItems = [ordered]@{
|
| 506 |
+
'org.ropa' = @('ORG_ROPA', 'Record of processing activities'); 'org.dpia' = @('ORG_DPIA', 'Data protection impact assessment')
|
| 507 |
+
'org.breach_procedure' = @('ORG_BREACH_PROCEDURE', 'Breach response procedure'); 'org.processor_agreements' = @('ORG_PROCESSOR_AGREEMENTS', 'Data processing agreements')
|
| 508 |
+
'org.ai_register' = @('ORG_AI_REGISTER', 'AI register'); 'org.ai_literacy' = @('ORG_AI_LITERACY', 'AI literacy training')
|
| 509 |
+
'org.human_oversight' = @('ORG_HUMAN_OVERSIGHT', 'Human oversight for high-risk AI'); 'org.ai_transparency' = @('ORG_AI_TRANSPARENCY', 'AI transparency to users')
|
| 510 |
+
}
|
| 511 |
+
foreach ($id in $OrgItems.Keys) {
|
| 512 |
+
$key = $OrgItems[$id][0]; $name = $OrgItems[$id][1]
|
| 513 |
+
$answer = ''; if ($OrgAnswers.ContainsKey($key)) { $answer = $OrgAnswers[$key].ToLower() }
|
| 514 |
+
switch ($answer) {
|
| 515 |
+
'yes' { Add-Result $id $S $name 'Compliant' 'yes' 'yes' $null }
|
| 516 |
+
'no' { Add-Result $id $S $name 'Non-Compliant' 'no' 'yes' $null }
|
| 517 |
+
'partial' { Add-Result $id $S $name 'Non-Compliant' 'partial' 'yes' $null }
|
| 518 |
+
{ $_ -in 'n/a', 'na' } { Add-Result $id $S $name 'NotApplicable' 'n/a' 'yes' $null }
|
| 519 |
+
default { Add-Result $id $S $name 'Pending' 'not answered' 'yes' "Answer $key in organisational.conf." }
|
| 520 |
+
}
|
| 521 |
+
}
|
| 522 |
+
|
| 523 |
+
# ---------------------------------------------------------------------------
|
| 524 |
+
# Report and evidence
|
| 525 |
+
# ---------------------------------------------------------------------------
|
| 526 |
+
$Line = '=' * 60
|
| 527 |
+
$sb = New-Object System.Text.StringBuilder
|
| 528 |
+
function W([string]$Text) { [void]$sb.AppendLine($Text) }
|
| 529 |
+
W $Line; W ' WINDOWS ENDPOINT SECURITY BASELINE - AUDIT'; W $Line
|
| 530 |
+
W "Hostname : $env:COMPUTERNAME"
|
| 531 |
+
W "Operating System: $($OsInfo.Caption)"
|
| 532 |
+
W "OS Version : $($OsInfo.Version)"
|
| 533 |
+
W "Build : $($OsInfo.BuildNumber)"
|
| 534 |
+
W "Serial Number : $Serial"
|
| 535 |
+
W "Execution User : $($Identity.Name)"
|
| 536 |
+
W "Audit Date : $AuditDate"
|
| 537 |
+
W 'Audit Mode : READ-ONLY'
|
| 538 |
+
W $Line
|
| 539 |
+
foreach ($section in @(@('baseline', 'COMPLIANCE RESULTS'), @('gdpr', 'GDPR TECHNICAL CONTROLS'), @('aiact', 'EU AI ACT CONTROLS'), @('org', 'ORGANISATIONAL CONTROLS'))) {
|
| 540 |
+
W ''; W $Line; W " $($section[1])"; W $Line
|
| 541 |
+
foreach ($r in $Results) { if ($r.section -eq $section[0]) { W $r.name; W $r.status } }
|
| 542 |
+
}
|
| 543 |
+
W $Line; W ' END OF READ-ONLY AUDIT'; W $Line
|
| 544 |
+
$ReportText = $sb.ToString()
|
| 545 |
+
|
| 546 |
+
$Summary = [ordered]@{}
|
| 547 |
+
foreach ($status in 'Compliant', 'Non-Compliant', 'NotApplicable', 'Pending', 'Error') { $Summary[$status] = @($Results | Where-Object { $_.status -eq $status }).Count }
|
| 548 |
+
$Evidence = [ordered]@{
|
| 549 |
+
schema_version = '1.0'
|
| 550 |
+
catalog_version = $CatalogVersion
|
| 551 |
+
source = [ordered]@{ kind = 'collector'; collector = 'windows'; collector_version = $CollectorVersion }
|
| 552 |
+
audit = [ordered]@{ mode = 'READ-ONLY'; started_at = $StartedAt; execution_user = $Identity.Name; is_admin = [bool]$IsAdmin }
|
| 553 |
+
host = [ordered]@{ hostname = $env:COMPUTERNAME; os_family = 'windows'; os_name = "$($OsInfo.Caption)"; os_version = "$($OsInfo.Version)"
|
| 554 |
+
build = "$($OsInfo.BuildNumber)"; architecture = "$env:PROCESSOR_ARCHITECTURE"; serial_number = "$Serial" }
|
| 555 |
+
baseline = $B
|
| 556 |
+
results = $Results
|
| 557 |
+
summary = $Summary
|
| 558 |
+
}
|
| 559 |
+
|
| 560 |
+
Write-Output $ReportText
|
| 561 |
+
try {
|
| 562 |
+
New-Item -ItemType Directory -Path $Output -Force | Out-Null
|
| 563 |
+
$base = Join-Path $Output ("audit-windows-{0}-{1:yyyyMMdd-HHmmss}" -f $env:COMPUTERNAME, (Get-Date))
|
| 564 |
+
$utf8 = New-Object System.Text.UTF8Encoding($false)
|
| 565 |
+
[IO.File]::WriteAllText("$base.txt", $ReportText, $utf8)
|
| 566 |
+
[IO.File]::WriteAllText("$base.json", ($Evidence | ConvertTo-Json -Depth 6), $utf8)
|
| 567 |
+
Write-Host "Report: $base.txt"
|
| 568 |
+
Write-Host "Evidence: $base.json"
|
| 569 |
+
} catch {
|
| 570 |
+
Write-Error "Could not write to $Output (use -Output DIR): $($_.Exception.Message)" -ErrorAction Continue
|
| 571 |
+
exit 2
|
| 572 |
+
}
|
| 573 |
+
if ($Summary['Non-Compliant'] -gt 0) { exit 1 }
|
| 574 |
+
exit 0
|
index.html
CHANGED
|
@@ -1,19 +1,155 @@
|
|
| 1 |
<!doctype html>
|
| 2 |
-
<html>
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
</html>
|
|
|
|
| 1 |
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
+
<title>Endpoint Auditor — Devseis</title>
|
| 7 |
+
<meta name="description" content="Read-only endpoint audit for ISO 27001, GDPR and the EU AI Act. Stage 1, step 1: collectors, evidence format and synthetic data.">
|
| 8 |
+
<style>
|
| 9 |
+
:root {
|
| 10 |
+
--bg: #f7f8fb; --card: #ffffff; --text: #16181d; --muted: #5b6270; --line: #e3e6ec;
|
| 11 |
+
--red: #ED2939; --blue: #00A1DE; --ok: #1a7f37; --bad: #c4242f; --na: #6e7781; --pending: #9a6700; --err: #8250df;
|
| 12 |
+
--code-bg: #0f1218; --code-text: #e6edf3;
|
| 13 |
+
}
|
| 14 |
+
@media (prefers-color-scheme: dark) {
|
| 15 |
+
:root:not([data-theme="light"]) {
|
| 16 |
+
--bg: #0d1117; --card: #161b22; --text: #e6edf3; --muted: #9da7b3; --line: #2b313a;
|
| 17 |
+
--ok: #3fb950; --bad: #ff6b6b; --na: #8b949e; --pending: #d29922; --err: #b392f0; --code-bg: #0a0d12;
|
| 18 |
+
}
|
| 19 |
+
}
|
| 20 |
+
* { box-sizing: border-box; }
|
| 21 |
+
body { margin: 0; background: var(--bg); color: var(--text); font: 16px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
|
| 22 |
+
main { max-width: 1040px; margin: 0 auto; padding: 32px 16px 64px; }
|
| 23 |
+
.tag { display: inline-block; font-size: 12px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--red); border: 1px solid var(--red); border-radius: 999px; padding: 2px 10px; }
|
| 24 |
+
h1 { font-size: clamp(28px, 5vw, 40px); line-height: 1.15; margin: 14px 0 8px; }
|
| 25 |
+
h2 { font-size: 20px; margin: 36px 0 12px; }
|
| 26 |
+
p.lead { color: var(--muted); font-size: 18px; max-width: 760px; margin: 0; }
|
| 27 |
+
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; margin-top: 20px; }
|
| 28 |
+
.card { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 16px; }
|
| 29 |
+
.card b { display: block; font-size: 15px; margin-bottom: 4px; }
|
| 30 |
+
.card span { color: var(--muted); font-size: 14px; }
|
| 31 |
+
.tabs { display: flex; gap: 8px; flex-wrap: wrap; margin: 12px 0; }
|
| 32 |
+
.tabs button { font: inherit; font-size: 14px; padding: 6px 14px; border-radius: 999px; border: 1px solid var(--line); background: var(--card); color: var(--text); cursor: pointer; }
|
| 33 |
+
.tabs button[aria-selected="true"] { background: var(--blue); border-color: var(--blue); color: #fff; }
|
| 34 |
+
.counts { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; }
|
| 35 |
+
.pill { font-size: 13px; font-weight: 600; padding: 3px 10px; border-radius: 999px; background: var(--card); border: 1px solid var(--line); }
|
| 36 |
+
.s-Compliant { color: var(--ok); } .s-Non-Compliant { color: var(--bad); } .s-NotApplicable { color: var(--na); }
|
| 37 |
+
.s-Pending { color: var(--pending); } .s-Error { color: var(--err); }
|
| 38 |
+
.split { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.25fr); gap: 12px; }
|
| 39 |
+
@media (max-width: 760px) { .split { grid-template-columns: 1fr; } }
|
| 40 |
+
pre { margin: 0; background: var(--code-bg); color: var(--code-text); border-radius: 12px; padding: 14px; font: 12.5px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow: auto; max-height: 560px; }
|
| 41 |
+
table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
|
| 42 |
+
.table-wrap { background: var(--card); border: 1px solid var(--line); border-radius: 12px; overflow: auto; max-height: 560px; }
|
| 43 |
+
th, td { text-align: left; padding: 7px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
|
| 44 |
+
th { position: sticky; top: 0; background: var(--card); font-size: 12px; text-transform: uppercase; letter-spacing: .03em; color: var(--muted); }
|
| 45 |
+
td.ref { color: var(--muted); font-size: 12.5px; }
|
| 46 |
+
code { font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; background: var(--card); border: 1px solid var(--line); border-radius: 6px; padding: 1px 6px; }
|
| 47 |
+
.note { border-left: 4px solid var(--red); background: var(--card); padding: 12px 16px; border-radius: 0 12px 12px 0; color: var(--muted); }
|
| 48 |
+
ol li { margin: 4px 0; }
|
| 49 |
+
a { color: var(--blue); }
|
| 50 |
+
footer { margin-top: 48px; color: var(--muted); font-size: 14px; }
|
| 51 |
+
</style>
|
| 52 |
+
</head>
|
| 53 |
+
<body>
|
| 54 |
+
<main>
|
| 55 |
+
<span class="tag">Experimental · stage 1 · step 1</span>
|
| 56 |
+
<h1>Devseis Endpoint Auditor</h1>
|
| 57 |
+
<p class="lead">A read-only audit of Windows, Linux and macOS endpoints against a security baseline, GDPR technical controls, EU AI Act controls and organisational controls — producing a plain report and JSON evidence for a local auditor model.</p>
|
| 58 |
+
|
| 59 |
+
<div class="grid">
|
| 60 |
+
<div class="card"><b>Read-only collectors</b><span>PowerShell, Linux shell and macOS shell scripts. They read settings and never change the system.</span></div>
|
| 61 |
+
<div class="card"><b>One evidence format</b><span>Same JSON for every OS, joined to a catalog with ISO 27001, GDPR and AI Act references.</span></div>
|
| 62 |
+
<div class="card"><b>Synthetic data</b><span>Realistic evidence with the same rules, so the auditor model can be built before real data exists.</span></div>
|
| 63 |
+
<div class="card"><b>Runs locally</b><span>Evidence stays on the device. Nothing is sent anywhere.</span></div>
|
| 64 |
+
</div>
|
| 65 |
+
|
| 66 |
+
<h2>Sample audit (synthetic)</h2>
|
| 67 |
+
<div class="tabs" role="tablist" id="tabs"></div>
|
| 68 |
+
<div class="counts" id="counts"></div>
|
| 69 |
+
<div class="split">
|
| 70 |
+
<pre id="report" aria-label="Text report">Loading…</pre>
|
| 71 |
+
<div class="table-wrap">
|
| 72 |
+
<table>
|
| 73 |
+
<thead><tr><th>Check</th><th>Status</th><th>Found</th><th>References</th></tr></thead>
|
| 74 |
+
<tbody id="rows"></tbody>
|
| 75 |
+
</table>
|
| 76 |
+
</div>
|
| 77 |
+
</div>
|
| 78 |
+
|
| 79 |
+
<h2>Roadmap</h2>
|
| 80 |
+
<ol>
|
| 81 |
+
<li><b>Collectors, evidence format, catalog, synthetic data</b> — this Space</li>
|
| 82 |
+
<li>Control library in Devseis's own wording, for retrieval by the model</li>
|
| 83 |
+
<li>Training data: evidence → auditor findings, reviewed by an ISO 27001 / GDPR expert</li>
|
| 84 |
+
<li>Fine-tune a small open-weight model to run locally</li>
|
| 85 |
+
<li>Evaluate on held-out data and publish the scores</li>
|
| 86 |
+
<li>One installer per OS: detect the OS, run the audit, write the report locally</li>
|
| 87 |
+
</ol>
|
| 88 |
+
|
| 89 |
+
<p class="note">Not a certification. ISO 27001 certification needs an accredited auditor, and much of GDPR and AI Act compliance is organisational. The tool collects evidence and flags gaps. See the <a href="README.md">README</a> for how to run the collectors.</p>
|
| 90 |
+
|
| 91 |
+
<footer>Devseis · <a href="https://huggingface.co/Devseis">huggingface.co/Devseis</a></footer>
|
| 92 |
+
</main>
|
| 93 |
+
<script>
|
| 94 |
+
const SAMPLES = { windows: 'synthetic/samples/synthetic-windows-42000126.json', linux: 'synthetic/samples/synthetic-linux-42000126.json', macos: 'synthetic/samples/synthetic-macos-42000126.json' };
|
| 95 |
+
const LABELS = { windows: 'Windows', linux: 'Linux', macos: 'macOS' };
|
| 96 |
+
const LAYOUT = {
|
| 97 |
+
windows: { title: 'WINDOWS ENDPOINT SECURITY BASELINE - AUDIT', results: 'COMPLIANCE RESULTS', end: 'END OF READ-ONLY AUDIT', blank: true, extra: ['Build', 'build'] },
|
| 98 |
+
linux: { title: 'LINUX ENDPOINT SECURITY BASELINE - AUDIT', results: 'LINUX ENDPOINT SECURITY BASELINE - RESULTS', end: 'END OF LINUX ENDPOINT SECURITY BASELINE AUDIT', blank: false, extra: ['Kernel', 'kernel'] },
|
| 99 |
+
macos: { title: 'macOS ENDPOINT SECURITY BASELINE - AUDIT', results: 'COMPLIANCE RESULTS', end: 'END OF AUDIT', blank: false, extra: ['Build', 'build'] },
|
| 100 |
+
};
|
| 101 |
+
const SECTIONS = [['baseline', null], ['gdpr', 'GDPR TECHNICAL CONTROLS'], ['aiact', 'EU AI ACT CONTROLS'], ['org', 'ORGANISATIONAL CONTROLS']];
|
| 102 |
+
let catalog = null;
|
| 103 |
+
|
| 104 |
+
function render(rec) {
|
| 105 |
+
const L = LAYOUT[rec.host.os_family], line = '='.repeat(60), pad = s => (s + ' '.repeat(16)).slice(0, 16);
|
| 106 |
+
const out = [line, ' ' + L.title, line,
|
| 107 |
+
pad('Hostname') + ': ' + rec.host.hostname, pad('Operating System') + ': ' + rec.host.os_name,
|
| 108 |
+
pad('OS Version') + ': ' + rec.host.os_version, pad(L.extra[0]) + ': ' + (rec.host[L.extra[1]] || ''),
|
| 109 |
+
pad('Serial Number') + ': ' + (rec.host.serial_number || ''), pad('Execution User') + ': ' + rec.audit.execution_user,
|
| 110 |
+
pad('Audit Date') + ': ' + rec.audit.started_at.replace('T', ' ').slice(0, 19), pad('Audit Mode') + ': ' + rec.audit.mode, line];
|
| 111 |
+
for (const [id, title] of SECTIONS) {
|
| 112 |
+
if (L.blank) out.push('');
|
| 113 |
+
out.push(line, ' ' + (title || L.results), line);
|
| 114 |
+
for (const r of rec.results) if (r.section === id) out.push(r.name, r.status);
|
| 115 |
+
}
|
| 116 |
+
out.push(line, ' ' + L.end, line);
|
| 117 |
+
return out.join('\n');
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
function refs(id) {
|
| 121 |
+
const c = catalog && catalog.checks.find(x => x.id === id);
|
| 122 |
+
if (!c) return '';
|
| 123 |
+
return [...c.controls.iso27001.map(x => 'ISO ' + x), ...c.controls.gdpr.map(x => 'GDPR ' + x), ...c.controls.ai_act.map(x => 'AI Act ' + x)].join(' · ');
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
| 127 |
+
|
| 128 |
+
async function show(os) {
|
| 129 |
+
document.querySelectorAll('#tabs button').forEach(b => b.setAttribute('aria-selected', String(b.dataset.os === os)));
|
| 130 |
+
try {
|
| 131 |
+
const rec = await (await fetch(SAMPLES[os])).json();
|
| 132 |
+
document.getElementById('report').textContent = render(rec);
|
| 133 |
+
document.getElementById('counts').innerHTML = Object.entries(rec.summary)
|
| 134 |
+
.map(([k, v]) => `<span class="pill s-${k}">${v} ${k}</span>`).join('');
|
| 135 |
+
document.getElementById('rows').innerHTML = rec.results.map(r =>
|
| 136 |
+
`<tr><td>${esc(r.name)}</td><td class="s-${r.status}"><b>${r.status}</b></td><td>${esc(r.actual)}</td><td class="ref">${esc(refs(r.id))}</td></tr>`).join('');
|
| 137 |
+
} catch (e) {
|
| 138 |
+
document.getElementById('report').textContent = 'Could not load the sample: ' + e.message;
|
| 139 |
+
}
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
(async () => {
|
| 143 |
+
try { catalog = await (await fetch('catalog/checks.json')).json(); } catch (e) { catalog = null; }
|
| 144 |
+
const tabs = document.getElementById('tabs');
|
| 145 |
+
for (const os of Object.keys(SAMPLES)) {
|
| 146 |
+
const b = document.createElement('button');
|
| 147 |
+
b.textContent = LABELS[os]; b.dataset.os = os; b.setAttribute('role', 'tab');
|
| 148 |
+
b.addEventListener('click', () => show(os));
|
| 149 |
+
tabs.appendChild(b);
|
| 150 |
+
}
|
| 151 |
+
show('windows');
|
| 152 |
+
})();
|
| 153 |
+
</script>
|
| 154 |
+
</body>
|
| 155 |
</html>
|
organisational.conf
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Organisational controls: answered once by the organisation, included in every audit report.
|
| 2 |
+
# Answer each with: yes | no | partial | n/a (leave empty = Pending)
|
| 3 |
+
# yes = Compliant, no/partial = Non-Compliant, n/a = NotApplicable.
|
| 4 |
+
|
| 5 |
+
# GDPR Art. 30: Is there a current record of processing activities (RoPA)?
|
| 6 |
+
ORG_ROPA=
|
| 7 |
+
# GDPR Art. 35: Has a DPIA been done for every high-risk processing activity?
|
| 8 |
+
ORG_DPIA=
|
| 9 |
+
# GDPR Art. 33-34: Is there a tested procedure to report a personal data breach within 72 hours?
|
| 10 |
+
ORG_BREACH_PROCEDURE=
|
| 11 |
+
# GDPR Art. 28: Are data processing agreements signed with all processors, including AI providers?
|
| 12 |
+
ORG_PROCESSOR_AGREEMENTS=
|
| 13 |
+
# AI Act Art. 26: Is there a register of the AI systems the organisation uses?
|
| 14 |
+
ORG_AI_REGISTER=
|
| 15 |
+
# AI Act Art. 4: Have staff who use AI received AI literacy training?
|
| 16 |
+
ORG_AI_LITERACY=
|
| 17 |
+
# AI Act Art. 14, 26: Is a trained person assigned to oversee each high-risk AI system?
|
| 18 |
+
ORG_HUMAN_OVERSIGHT=
|
| 19 |
+
# AI Act Art. 50: Are people told when they interact with AI or receive AI-generated content?
|
| 20 |
+
ORG_AI_TRANSPARENCY=
|
schema/evidence.schema.json
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
| 3 |
+
"$id": "https://huggingface.co/spaces/Devseis/endpoint-auditor/schema/evidence.schema.json",
|
| 4 |
+
"title": "Devseis endpoint audit evidence",
|
| 5 |
+
"description": "Output of one read-only audit run (real collector or synthetic generator). Control references are not repeated here: join results[].id with catalog/checks.json.",
|
| 6 |
+
"type": "object",
|
| 7 |
+
"required": ["schema_version", "catalog_version", "source", "audit", "host", "baseline", "results", "summary"],
|
| 8 |
+
"additionalProperties": false,
|
| 9 |
+
"properties": {
|
| 10 |
+
"schema_version": { "const": "1.0" },
|
| 11 |
+
"catalog_version": { "type": "string" },
|
| 12 |
+
"source": {
|
| 13 |
+
"type": "object",
|
| 14 |
+
"required": ["kind", "collector", "collector_version"],
|
| 15 |
+
"additionalProperties": false,
|
| 16 |
+
"properties": {
|
| 17 |
+
"kind": { "enum": ["collector", "synthetic"] },
|
| 18 |
+
"collector": { "enum": ["windows", "linux", "macos"] },
|
| 19 |
+
"collector_version": { "type": "string" },
|
| 20 |
+
"synthetic_seed": { "type": "integer" },
|
| 21 |
+
"synthetic_profile": { "type": "string" }
|
| 22 |
+
}
|
| 23 |
+
},
|
| 24 |
+
"audit": {
|
| 25 |
+
"type": "object",
|
| 26 |
+
"required": ["mode", "started_at", "execution_user", "is_admin"],
|
| 27 |
+
"additionalProperties": false,
|
| 28 |
+
"properties": {
|
| 29 |
+
"mode": { "const": "READ-ONLY" },
|
| 30 |
+
"started_at": { "type": "string", "description": "ISO 8601 with offset, e.g. 2026-10-06T17:09:54+05:00" },
|
| 31 |
+
"execution_user": { "type": "string" },
|
| 32 |
+
"is_admin": { "type": "boolean" }
|
| 33 |
+
}
|
| 34 |
+
},
|
| 35 |
+
"host": {
|
| 36 |
+
"type": "object",
|
| 37 |
+
"required": ["hostname", "os_family", "os_name", "os_version"],
|
| 38 |
+
"additionalProperties": false,
|
| 39 |
+
"properties": {
|
| 40 |
+
"hostname": { "type": "string" },
|
| 41 |
+
"os_family": { "enum": ["windows", "linux", "macos"] },
|
| 42 |
+
"os_name": { "type": "string" },
|
| 43 |
+
"os_version": { "type": "string" },
|
| 44 |
+
"build": { "type": "string" },
|
| 45 |
+
"kernel": { "type": "string" },
|
| 46 |
+
"architecture": { "type": "string" },
|
| 47 |
+
"serial_number": { "type": "string" }
|
| 48 |
+
}
|
| 49 |
+
},
|
| 50 |
+
"baseline": {
|
| 51 |
+
"type": "object",
|
| 52 |
+
"description": "The baseline.conf values used for this run (all values as strings).",
|
| 53 |
+
"additionalProperties": { "type": "string" }
|
| 54 |
+
},
|
| 55 |
+
"results": {
|
| 56 |
+
"type": "array",
|
| 57 |
+
"items": {
|
| 58 |
+
"type": "object",
|
| 59 |
+
"required": ["id", "section", "name", "status", "actual", "expected"],
|
| 60 |
+
"additionalProperties": false,
|
| 61 |
+
"properties": {
|
| 62 |
+
"id": { "type": "string", "description": "Check id from catalog/checks.json" },
|
| 63 |
+
"section": { "enum": ["baseline", "gdpr", "aiact", "org"] },
|
| 64 |
+
"name": { "type": "string", "description": "Report line, as printed for this OS" },
|
| 65 |
+
"status": { "enum": ["Compliant", "Non-Compliant", "NotApplicable", "Pending", "Error"] },
|
| 66 |
+
"actual": { "type": "string", "description": "What was found on the device" },
|
| 67 |
+
"expected": { "type": "string", "description": "What the baseline requires" },
|
| 68 |
+
"detail": { "type": "string", "description": "How the value was read, or why the status was given" }
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
},
|
| 72 |
+
"summary": {
|
| 73 |
+
"type": "object",
|
| 74 |
+
"required": ["Compliant", "Non-Compliant", "NotApplicable", "Pending", "Error"],
|
| 75 |
+
"additionalProperties": false,
|
| 76 |
+
"properties": {
|
| 77 |
+
"Compliant": { "type": "integer" },
|
| 78 |
+
"Non-Compliant": { "type": "integer" },
|
| 79 |
+
"NotApplicable": { "type": "integer" },
|
| 80 |
+
"Pending": { "type": "integer" },
|
| 81 |
+
"Error": { "type": "integer" }
|
| 82 |
+
}
|
| 83 |
+
}
|
| 84 |
+
}
|
| 85 |
+
}
|
synthetic/generate_evidence.py
ADDED
|
@@ -0,0 +1,801 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Generate synthetic endpoint audit evidence for the MVP.
|
| 3 |
+
|
| 4 |
+
Every record has exactly the format of a real collector run (schema/evidence.schema.json):
|
| 5 |
+
same check ids, names, order and pass/fail rules as collectors/*/audit.* with baseline.conf.
|
| 6 |
+
Host names and serial numbers are invented, so the data holds no personal data.
|
| 7 |
+
|
| 8 |
+
Usage:
|
| 9 |
+
python3 synthetic/generate_evidence.py --count 500 --seed 7 --out synthetic/out/evidence.jsonl
|
| 10 |
+
python3 synthetic/generate_evidence.py --count 3 --os macos --files synthetic/out/samples
|
| 11 |
+
|
| 12 |
+
Standard library only.
|
| 13 |
+
"""
|
| 14 |
+
import argparse
|
| 15 |
+
import json
|
| 16 |
+
import os
|
| 17 |
+
import random
|
| 18 |
+
import string
|
| 19 |
+
from datetime import datetime, timedelta, timezone
|
| 20 |
+
|
| 21 |
+
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 22 |
+
GENERATOR_VERSION = "0.1.0"
|
| 23 |
+
|
| 24 |
+
# Report order per OS, identical to the collectors.
|
| 25 |
+
PASSWORD_LOCKOUT = ["password.min_length", "password.history", "password.expiration", "password.min_age",
|
| 26 |
+
"password.complexity", "lockout.threshold", "lockout.duration", "lockout.observation"]
|
| 27 |
+
GDPR = ["gdpr.auto_updates", "gdpr.updates_recent", "gdpr.os_supported", "gdpr.security_logging", "gdpr.time_sync",
|
| 28 |
+
"gdpr.backup_configured", "gdpr.backup_recent", "gdpr.removable_encryption", "gdpr.guest_account",
|
| 29 |
+
"gdpr.remote_access", "gdpr.telemetry", "gdpr.personal_data_discovery"]
|
| 30 |
+
AIACT = ["aiact.inventory", "aiact.unapproved", "aiact.risk_classification", "aiact.os_ai_features", "aiact.log_retention"]
|
| 31 |
+
ORG = ["org.ropa", "org.dpia", "org.breach_procedure", "org.processor_agreements", "org.ai_register",
|
| 32 |
+
"org.ai_literacy", "org.human_oversight", "org.ai_transparency"]
|
| 33 |
+
ORDER = {
|
| 34 |
+
"windows": PASSWORD_LOCKOUT + [
|
| 35 |
+
"malware.antivirus", "malware.service", "malware.realtime", "malware.on_access", "malware.tamper", "malware.signatures",
|
| 36 |
+
"firewall.domain.enabled", "firewall.domain.inbound", "firewall.domain.outbound",
|
| 37 |
+
"firewall.private.enabled", "firewall.private.inbound", "firewall.private.outbound",
|
| 38 |
+
"firewall.public.enabled", "firewall.public.inbound", "firewall.public.outbound",
|
| 39 |
+
"screen_lock.timeout", "screen_lock.password", "usb.read", "usb.write", "admin.approved", "admin.unauthorized",
|
| 40 |
+
"encryption.feature", "encryption.enabled", "encryption.protection", "encryption.recovery"] + GDPR + AIACT + ORG,
|
| 41 |
+
"linux": PASSWORD_LOCKOUT + [
|
| 42 |
+
"malware.antivirus", "malware.service", "malware.signature_updates", "malware.signatures", "malware.on_access",
|
| 43 |
+
"firewall.enabled", "firewall.inbound", "firewall.outbound",
|
| 44 |
+
"screen_lock.enabled", "screen_lock.timeout", "screen_lock.password", "usb.read", "usb.write",
|
| 45 |
+
"admin.approved", "admin.unauthorized", "encryption.enabled", "encryption.protection", "encryption.recovery"] + GDPR + AIACT + ORG,
|
| 46 |
+
"macos": PASSWORD_LOCKOUT + [
|
| 47 |
+
"malware.antivirus", "malware.service", "malware.signatures", "malware.signature_updates", "malware.on_access",
|
| 48 |
+
"firewall.enabled", "firewall.inbound", "firewall.outbound",
|
| 49 |
+
"screen_lock.timeout", "screen_lock.enabled", "screen_lock.password", "usb.read", "usb.write",
|
| 50 |
+
"admin.approved", "admin.unauthorized", "encryption.enabled", "encryption.protection", "encryption.recovery"] + GDPR + AIACT + ORG,
|
| 51 |
+
}
|
| 52 |
+
# Checks that report Error when the collector is not run as administrator/root.
|
| 53 |
+
NEEDS_ADMIN = {
|
| 54 |
+
"windows": set(PASSWORD_LOCKOUT) | {"encryption.feature", "encryption.enabled", "encryption.protection", "encryption.recovery", "gdpr.security_logging"},
|
| 55 |
+
"linux": {"firewall.enabled", "firewall.inbound", "firewall.outbound", "encryption.protection", "encryption.recovery"},
|
| 56 |
+
"macos": {"encryption.recovery", "gdpr.time_sync"},
|
| 57 |
+
}
|
| 58 |
+
# Probability that a check meets the baseline, per device profile.
|
| 59 |
+
PROFILES = {"hardened": 0.92, "typical": 0.65, "neglected": 0.3}
|
| 60 |
+
ORG_ANSWERS = {"hardened": [("yes", 8), ("partial", 1), ("", 1)], "typical": [("yes", 4), ("no", 2), ("partial", 2), ("", 3)],
|
| 61 |
+
"neglected": [("no", 4), ("", 5), ("partial", 1)]}
|
| 62 |
+
|
| 63 |
+
OS_VERSIONS = {
|
| 64 |
+
"windows": [("Microsoft Windows 11 Pro", "10.0.26200", "26200"), ("Microsoft Windows 11 Pro", "10.0.26100", "26100"),
|
| 65 |
+
("Microsoft Windows 11 Enterprise", "10.0.26100", "26100"), ("Microsoft Windows 11 Pro", "10.0.22631", "22631"),
|
| 66 |
+
("Microsoft Windows 11 Pro", "10.0.22621", "22621"), ("Microsoft Windows 10 Pro", "10.0.19045", "19045")],
|
| 67 |
+
"linux": [("Ubuntu 26.04.1 LTS", "26.04", "ubuntu"), ("Ubuntu 24.04.3 LTS", "24.04", "ubuntu"), ("Ubuntu 22.04.5 LTS", "22.04", "ubuntu"),
|
| 68 |
+
("Ubuntu 20.04.6 LTS", "20.04", "ubuntu"), ("Debian GNU/Linux 13 (trixie)", "13", "debian"),
|
| 69 |
+
("Debian GNU/Linux 12 (bookworm)", "12", "debian"), ("Red Hat Enterprise Linux 9.6 (Plow)", "9.6", "rhel"),
|
| 70 |
+
("Red Hat Enterprise Linux 8.10 (Ootpa)", "8.10", "rhel")],
|
| 71 |
+
"macos": [("26.5.2", "25F84"), ("26.0.1", "25A362"), ("15.7.2", "24G325"), ("15.6", "24G84"), ("14.7.8", "23H730"), ("13.7.8", "22H730")],
|
| 72 |
+
}
|
| 73 |
+
KERNELS = {"ubuntu": ["7.0.0-34-generic", "6.8.0-85-generic", "5.15.0-151-generic"], "debian": ["6.12.48+deb13-amd64", "6.1.0-40-amd64"],
|
| 74 |
+
"rhel": ["5.14.0-570.42.1.el9_6.x86_64", "4.18.0-553.el8_10.x86_64"]}
|
| 75 |
+
AI_TOOLS = {
|
| 76 |
+
"windows": ["Microsoft Copilot", "ChatGPT", "Claude", "Ollama", "LM Studio", "GPT4All", "Cursor", "Claude Code", "Hugging Face models"],
|
| 77 |
+
"linux": ["Ollama", "LM Studio", "GPT4All", "Jan", "Claude Code", "Hugging Face models", "Alpaca (Ollama client)"],
|
| 78 |
+
"macos": ["ChatGPT", "Claude", "Ollama", "LM Studio", "Jan", "Cursor", "Claude Code", "Hugging Face models", "Perplexity"],
|
| 79 |
+
}
|
| 80 |
+
REMOTE_SERVICES = {"windows": ["rdp", "winrm", "ssh", "smb", "teamviewer", "anydesk", "chromeremotedesktop"],
|
| 81 |
+
"linux": ["ssh", "rdp", "vnc", "smb", "teamviewer", "anydesk", "chromeremotedesktop"],
|
| 82 |
+
"macos": ["ssh", "screensharing", "filesharing", "remotemanagement", "chromeremotedesktop", "teamviewer", "anydesk"]}
|
| 83 |
+
EMPLOYEE_ACCOUNTS = ["jdoe", "asmith", "mkhan", "lmuller", "pmartin", "sali", "tnguyen", "rschmit", "akaya", "ewagner"]
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def read_conf(path):
|
| 87 |
+
conf = {}
|
| 88 |
+
if os.path.exists(path):
|
| 89 |
+
with open(path, encoding="utf-8") as handle:
|
| 90 |
+
for line in handle:
|
| 91 |
+
if line.lstrip().startswith("#") or "=" not in line:
|
| 92 |
+
continue
|
| 93 |
+
key, value = line.split("=", 1)
|
| 94 |
+
conf[key.strip()] = value.strip().strip('"')
|
| 95 |
+
return conf
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def load_catalog():
|
| 99 |
+
with open(os.path.join(ROOT, "catalog", "checks.json"), encoding="utf-8") as handle:
|
| 100 |
+
catalog = json.load(handle)
|
| 101 |
+
return catalog, {c["id"]: c for c in catalog["checks"]}
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def check_name(check, os_family):
|
| 105 |
+
return check.get("names", {}).get(os_family, check["name"])
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
class Device:
|
| 109 |
+
"""One synthetic endpoint: draws a value for each check and applies the baseline rule."""
|
| 110 |
+
|
| 111 |
+
def __init__(self, rng, os_family, profile, baseline):
|
| 112 |
+
self.rng, self.os, self.profile, self.B = rng, os_family, profile, baseline
|
| 113 |
+
self.p_good = PROFILES[profile]
|
| 114 |
+
self.is_admin = rng.random() > 0.06
|
| 115 |
+
self.third_party_av = rng.random() < (0.25 if os_family == "windows" else 0.12)
|
| 116 |
+
self.av_name = rng.choice(["CrowdStrike Falcon", "SentinelOne", "Sophos Endpoint", "ESET Endpoint Security", "Bitdefender Endpoint Security"])
|
| 117 |
+
self.usb_write = None
|
| 118 |
+
self.ai_tools = []
|
| 119 |
+
self.backup_tool = None
|
| 120 |
+
|
| 121 |
+
def good(self, bias=0.0):
|
| 122 |
+
return self.rng.random() < min(max(self.p_good + bias, 0.02), 0.98)
|
| 123 |
+
|
| 124 |
+
def b(self, key):
|
| 125 |
+
return self.B[key]
|
| 126 |
+
|
| 127 |
+
def n(self, key):
|
| 128 |
+
return int(self.B[key])
|
| 129 |
+
|
| 130 |
+
def pick(self, choices):
|
| 131 |
+
return self.rng.choice(choices)
|
| 132 |
+
|
| 133 |
+
# Each method returns (status, actual, expected, detail).
|
| 134 |
+
def value(self, check_id):
|
| 135 |
+
method = getattr(self, "c_" + check_id.replace(".", "_"), None)
|
| 136 |
+
if method is None:
|
| 137 |
+
raise KeyError(check_id)
|
| 138 |
+
return method()
|
| 139 |
+
|
| 140 |
+
@staticmethod
|
| 141 |
+
def result(ok, actual, expected, detail=""):
|
| 142 |
+
return ("Compliant" if ok else "Non-Compliant", str(actual), expected, detail)
|
| 143 |
+
|
| 144 |
+
# --- passwords and lockout ---------------------------------------------
|
| 145 |
+
def c_password_min_length(self):
|
| 146 |
+
need = self.n("PASSWORD_MIN_LENGTH")
|
| 147 |
+
if self.good():
|
| 148 |
+
return self.result(True, self.pick([need, need, need + 2, 16]), f">= {need}", self._pw_source())
|
| 149 |
+
if self.os == "macos":
|
| 150 |
+
return self.result(False, self.pick(["not set", 8, 10, 12]), f">= {need}", "pwpolicy global account policy")
|
| 151 |
+
if self.os == "linux" and self.rng.random() < 0.5:
|
| 152 |
+
return self.result(False, 6, f">= {need}", "pam_pwquality not enabled; pam_unix minimum")
|
| 153 |
+
return self.result(False, self.pick([0, 6, 8, 10, 12]), f">= {need}", self._pw_source())
|
| 154 |
+
|
| 155 |
+
def _pw_source(self):
|
| 156 |
+
return {"windows": "", "linux": "pam_pwquality", "macos": "pwpolicy global account policy"}[self.os]
|
| 157 |
+
|
| 158 |
+
def c_password_history(self):
|
| 159 |
+
need = self.n("PASSWORD_HISTORY")
|
| 160 |
+
if self.good():
|
| 161 |
+
return self.result(True, self.pick([need, need, 24]), f">= {need}")
|
| 162 |
+
return self.result(False, self.pick(["not set", 0, 5, 10]) if self.os == "macos" else self.pick([0, 5, 10, 12]), f">= {need}")
|
| 163 |
+
|
| 164 |
+
def c_password_expiration(self):
|
| 165 |
+
limit = self.n("PASSWORD_MAX_AGE_DAYS")
|
| 166 |
+
if limit == 0:
|
| 167 |
+
return ("Compliant", "never", "not required", "Baseline does not require expiry.")
|
| 168 |
+
if self.good():
|
| 169 |
+
days = self.pick([60, 90, 180, 365])
|
| 170 |
+
return self.result(True, f"{days} days" if self.os != "linux" else f"{days} days", f"1-{limit} days")
|
| 171 |
+
if self.os == "linux":
|
| 172 |
+
return self.result(False, self.pick(["PASS_MAX_DAYS 99999", "PASS_MAX_DAYS 90; accounts: jdoe (99999)"]), f"1-{limit} days")
|
| 173 |
+
return self.result(False, "never", f"1-{limit} days")
|
| 174 |
+
|
| 175 |
+
def c_password_min_age(self):
|
| 176 |
+
need = self.n("PASSWORD_MIN_AGE_DAYS")
|
| 177 |
+
if self.os == "macos":
|
| 178 |
+
return ("NotApplicable", "no such setting", f">= {need} days", "macOS account policies have no minimum password age.")
|
| 179 |
+
if self.good():
|
| 180 |
+
return self.result(True, f"{self.pick([1, 1, 2])} days", f">= {need} days")
|
| 181 |
+
return self.result(False, "0 days" if self.os == "windows" else "PASS_MIN_DAYS 0", f">= {need} days")
|
| 182 |
+
|
| 183 |
+
def c_password_complexity(self):
|
| 184 |
+
if self.os == "windows":
|
| 185 |
+
ok = self.good()
|
| 186 |
+
return self.result(ok, "enabled" if ok else "disabled", "enabled")
|
| 187 |
+
if self.os == "linux":
|
| 188 |
+
if self.good():
|
| 189 |
+
return self.result(True, self.pick(["minclass=4, required classes=0", "minclass=3, required classes=0", "minclass=0, required classes=4"]), "3 or more character classes")
|
| 190 |
+
return self.result(False, self.pick(["pam_pwquality not enabled", "minclass=0, required classes=0", "minclass=2, required classes=1"]), "3 or more character classes")
|
| 191 |
+
ok = self.good()
|
| 192 |
+
return self.result(ok, "letters and numbers required" if ok else "not enforced", "enforced")
|
| 193 |
+
|
| 194 |
+
def c_lockout_threshold(self):
|
| 195 |
+
limit = self.n("LOCKOUT_THRESHOLD_MAX")
|
| 196 |
+
if self.good():
|
| 197 |
+
return self.result(True, self.pick(list(range(3, limit + 1))), f"1-{limit}", "pam_faillock" if self.os == "linux" else "")
|
| 198 |
+
if self.os == "linux":
|
| 199 |
+
return self.result(False, self.pick(["pam_faillock not enabled", 10]), f"1-{limit}")
|
| 200 |
+
return self.result(False, self.pick([0, 10, 20]) if self.os == "windows" else self.pick(["not set", 10]), f"1-{limit}")
|
| 201 |
+
|
| 202 |
+
def c_lockout_duration(self):
|
| 203 |
+
need = self.n("LOCKOUT_DURATION_MIN_MINUTES")
|
| 204 |
+
if self.good():
|
| 205 |
+
actual = self.pick([f"{need} minutes", "30 minutes", "until an administrator unlocks"])
|
| 206 |
+
return self.result(True, actual, f">= {need} minutes")
|
| 207 |
+
return self.result(False, self.pick(["no lockout configured", "5 minutes", "10 minutes"]) if self.os == "windows"
|
| 208 |
+
else self.pick(["no lockout", "10 minutes", "not set"]), f">= {need} minutes")
|
| 209 |
+
|
| 210 |
+
def c_lockout_observation(self):
|
| 211 |
+
need = self.n("LOCKOUT_OBSERVATION_MIN_MINUTES")
|
| 212 |
+
detail = "macOS resets the failed-attempt count when the lockout ends." if self.os == "macos" else ""
|
| 213 |
+
if self.good():
|
| 214 |
+
return self.result(True, self.pick([f"{need} minutes", "30 minutes"]), f">= {need} minutes", detail)
|
| 215 |
+
return self.result(False, self.pick(["no lockout configured", "5 minutes"]) if self.os == "windows"
|
| 216 |
+
else self.pick(["no lockout", "10 minutes", "not set"]), f">= {need} minutes", detail)
|
| 217 |
+
|
| 218 |
+
# --- malware protection -------------------------------------------------
|
| 219 |
+
def c_malware_antivirus(self):
|
| 220 |
+
if self.os == "windows":
|
| 221 |
+
if self.third_party_av:
|
| 222 |
+
return self.result(True, f"{self.av_name} (on, up to date); Windows Defender (off, up to date)", "enabled and up to date")
|
| 223 |
+
ok = self.good(0.2)
|
| 224 |
+
return self.result(ok, "Windows Defender (on, up to date)" if ok else "Windows Defender (off, out of date)", "enabled and up to date")
|
| 225 |
+
if self.os == "linux":
|
| 226 |
+
if self.third_party_av:
|
| 227 |
+
return self.result(True, self.av_name, "antivirus running")
|
| 228 |
+
ok = self.good(-0.15)
|
| 229 |
+
return self.result(ok, "ClamAV (clamav-daemon)" if ok else "none running", "antivirus running")
|
| 230 |
+
if self.third_party_av:
|
| 231 |
+
return self.result(True, f"XProtect {self.rng.randint(5200, 5400)}; third-party: {self.av_name}", "antivirus present")
|
| 232 |
+
return self.result(True, f"XProtect {self.rng.randint(5200, 5400)}", "antivirus present")
|
| 233 |
+
|
| 234 |
+
def _defender_na(self, expected):
|
| 235 |
+
return ("NotApplicable", "Defender in Passive Mode", expected, f"Another antivirus is primary: {self.av_name}")
|
| 236 |
+
|
| 237 |
+
def _linux_other_av_na(self, expected):
|
| 238 |
+
return ("NotApplicable", "not used", expected, f"Another endpoint protection product is used: {self.av_name}")
|
| 239 |
+
|
| 240 |
+
def c_malware_service(self):
|
| 241 |
+
if self.os == "windows":
|
| 242 |
+
if self.third_party_av:
|
| 243 |
+
return self._defender_na("running")
|
| 244 |
+
ok = self.good(0.2)
|
| 245 |
+
return self.result(ok, "service True, antivirus True" if ok else "service True, antivirus False", "running")
|
| 246 |
+
if self.os == "linux":
|
| 247 |
+
if self.third_party_av:
|
| 248 |
+
return self._linux_other_av_na("running")
|
| 249 |
+
ok = self.good(-0.15)
|
| 250 |
+
return self.result(ok, "clamav-daemon" if ok else "not running", "running")
|
| 251 |
+
ok = self.good(0.25)
|
| 252 |
+
return self.result(ok, "XProtect scanning on; Gatekeeper on" if ok else "XProtect scanning on; Gatekeeper assessments disabled", "XProtect and Gatekeeper on")
|
| 253 |
+
|
| 254 |
+
def c_malware_realtime(self):
|
| 255 |
+
if self.third_party_av:
|
| 256 |
+
return self._defender_na("on")
|
| 257 |
+
ok = self.good(0.15)
|
| 258 |
+
return self.result(ok, "on" if ok else "off", "on")
|
| 259 |
+
|
| 260 |
+
def c_malware_on_access(self):
|
| 261 |
+
if self.os == "windows":
|
| 262 |
+
if self.third_party_av:
|
| 263 |
+
return self._defender_na("on")
|
| 264 |
+
ok = self.good(0.15)
|
| 265 |
+
return self.result(ok, "on" if ok else "off", "on")
|
| 266 |
+
if self.os == "linux":
|
| 267 |
+
if self.third_party_av:
|
| 268 |
+
return ("Compliant", self.av_name, "on-access scanning", f"Provided by {self.av_name}.")
|
| 269 |
+
ok = self.good(-0.3)
|
| 270 |
+
return self.result(ok, "clamonacc running" if ok else "not running", "on-access scanning")
|
| 271 |
+
if self.third_party_av:
|
| 272 |
+
return ("Compliant", f"third-party scanner: {self.av_name}", "on-access scanning", "")
|
| 273 |
+
return ("NotApplicable", "XProtect only", "on-access scanning", "XProtect scans at launch and download; macOS has no built-in on-access scanning.")
|
| 274 |
+
|
| 275 |
+
def c_malware_tamper(self):
|
| 276 |
+
if self.third_party_av:
|
| 277 |
+
return self._defender_na("on")
|
| 278 |
+
ok = self.good()
|
| 279 |
+
return self.result(ok, "on" if ok else "off", "on")
|
| 280 |
+
|
| 281 |
+
def c_malware_signatures(self):
|
| 282 |
+
if self.os == "windows":
|
| 283 |
+
limit = self.n("SIGNATURE_MAX_AGE_DAYS")
|
| 284 |
+
if self.third_party_av:
|
| 285 |
+
return self._defender_na(f"<= {limit} days old")
|
| 286 |
+
age = self.rng.randint(0, limit) if self.good(0.1) else self.rng.randint(limit + 1, 60)
|
| 287 |
+
version = f"1.{self.rng.randint(430, 450)}.{self.rng.randint(0, 999)}.0"
|
| 288 |
+
return self.result(age <= limit, f"{age} days old (version {version})", f"<= {limit} days old")
|
| 289 |
+
if self.os == "linux":
|
| 290 |
+
limit = self.n("SIGNATURE_MAX_AGE_DAYS")
|
| 291 |
+
if self.third_party_av:
|
| 292 |
+
return self._linux_other_av_na(f"<= {limit} days old")
|
| 293 |
+
if not self.good(-0.15):
|
| 294 |
+
if self.rng.random() < 0.5:
|
| 295 |
+
return self.result(False, "no signature database", f"<= {limit} days old")
|
| 296 |
+
age = self.rng.randint(limit + 1, 90)
|
| 297 |
+
else:
|
| 298 |
+
age = self.rng.randint(0, limit)
|
| 299 |
+
return self.result(age <= limit, f"{age} days old", f"<= {limit} days old", "/var/lib/clamav/daily.cld")
|
| 300 |
+
limit = self.n("MACOS_XPROTECT_MAX_AGE_DAYS")
|
| 301 |
+
age = self.rng.randint(0, limit) if self.good(0.2) else self.rng.randint(limit + 1, 120)
|
| 302 |
+
return self.result(age <= limit, f"XProtect {self.rng.randint(5200, 5400)}, {age} days old", f"<= {limit} days old")
|
| 303 |
+
|
| 304 |
+
def c_malware_signature_updates(self):
|
| 305 |
+
if self.os == "linux":
|
| 306 |
+
if self.third_party_av:
|
| 307 |
+
return self._linux_other_av_na("automatic")
|
| 308 |
+
ok = self.good(-0.15)
|
| 309 |
+
return self.result(ok, "clamav-freshclam" if ok else "freshclam not running", "automatic")
|
| 310 |
+
ok = self.good(0.2)
|
| 311 |
+
return self.result(ok, "security responses and system data files install automatically" if ok
|
| 312 |
+
else "ConfigDataInstall=0, CriticalUpdateInstall=1", "automatic")
|
| 313 |
+
|
| 314 |
+
# --- firewall -------------------------------------------------------------
|
| 315 |
+
def _fw_profile(self, kind):
|
| 316 |
+
expected_out = self.b("FIREWALL_OUTBOUND").lower()
|
| 317 |
+
if kind == "enabled":
|
| 318 |
+
ok = self.good(0.15)
|
| 319 |
+
self._fw_on = ok
|
| 320 |
+
return self.result(ok, "on" if ok else "off", "on")
|
| 321 |
+
if kind == "inbound":
|
| 322 |
+
ok = self._fw_on and self.good(0.2)
|
| 323 |
+
action = "block" if ok else "allow"
|
| 324 |
+
detail = "Not configured: Windows default (Block) applies." if ok and self.rng.random() < 0.5 else ""
|
| 325 |
+
return self.result(ok and self._fw_on, action, "block", detail)
|
| 326 |
+
action = expected_out if self.good(0.2) else ("block" if expected_out == "allow" else "allow")
|
| 327 |
+
detail = "Not configured: Windows default (Allow) applies." if action == "allow" and self.rng.random() < 0.5 else ""
|
| 328 |
+
return self.result(action == expected_out and self._fw_on, action, expected_out, detail)
|
| 329 |
+
|
| 330 |
+
def c_firewall_domain_enabled(self): return self._fw_profile("enabled")
|
| 331 |
+
def c_firewall_domain_inbound(self): return self._fw_profile("inbound")
|
| 332 |
+
def c_firewall_domain_outbound(self): return self._fw_profile("outbound")
|
| 333 |
+
def c_firewall_private_enabled(self): return self._fw_profile("enabled")
|
| 334 |
+
def c_firewall_private_inbound(self): return self._fw_profile("inbound")
|
| 335 |
+
def c_firewall_private_outbound(self): return self._fw_profile("outbound")
|
| 336 |
+
def c_firewall_public_enabled(self): return self._fw_profile("enabled")
|
| 337 |
+
def c_firewall_public_inbound(self): return self._fw_profile("inbound")
|
| 338 |
+
def c_firewall_public_outbound(self): return self._fw_profile("outbound")
|
| 339 |
+
|
| 340 |
+
def c_firewall_enabled(self):
|
| 341 |
+
if self.os == "linux":
|
| 342 |
+
self._fw_name = self.pick(["ufw", "ufw", "firewalld", "nftables"]) if self.good(-0.1) else None
|
| 343 |
+
if self._fw_name:
|
| 344 |
+
return ("Compliant", f"{self._fw_name} active", "active", "")
|
| 345 |
+
return ("Non-Compliant", "no active firewall", "active", "")
|
| 346 |
+
self._fw_name = "application firewall" if self.good() else None
|
| 347 |
+
return self.result(bool(self._fw_name), "on" if self._fw_name else "off", "on")
|
| 348 |
+
|
| 349 |
+
def c_firewall_inbound(self):
|
| 350 |
+
if self.os == "linux":
|
| 351 |
+
if not self._fw_name:
|
| 352 |
+
return ("Non-Compliant", "allow (no firewall)", "deny", "")
|
| 353 |
+
ok = self.good(0.25)
|
| 354 |
+
return self.result(ok, "deny" if ok else "allow", "deny")
|
| 355 |
+
stealth = self.good(-0.1)
|
| 356 |
+
if self._fw_name and stealth:
|
| 357 |
+
return ("Compliant", "block (firewall on, stealth mode on)", "block", "")
|
| 358 |
+
return ("Non-Compliant", f"firewall {'on' if self._fw_name else 'off'}, stealth mode {'on' if stealth else 'off'}", "block", "")
|
| 359 |
+
|
| 360 |
+
def c_firewall_outbound(self):
|
| 361 |
+
expected = self.b("FIREWALL_OUTBOUND").lower()
|
| 362 |
+
if self.os == "macos":
|
| 363 |
+
ok = expected == "allow"
|
| 364 |
+
return self.result(ok, "allow", expected, "The macOS application firewall filters inbound connections only.")
|
| 365 |
+
if not self._fw_name:
|
| 366 |
+
return ("Non-Compliant", "no firewall", expected, "")
|
| 367 |
+
actual = expected if self.good(0.25) else ("deny" if expected == "allow" else "allow")
|
| 368 |
+
return self.result(actual == expected, actual, expected)
|
| 369 |
+
|
| 370 |
+
# --- screen lock ------------------------------------------------------------
|
| 371 |
+
def c_screen_lock_enabled(self):
|
| 372 |
+
if self.os == "linux" and self.rng.random() < 0.12:
|
| 373 |
+
return ("NotApplicable", "no GNOME desktop", "on", "No graphical desktop settings found.")
|
| 374 |
+
self._lock_on = self.good(0.15)
|
| 375 |
+
users = self._desktop_users()
|
| 376 |
+
if self.os == "linux":
|
| 377 |
+
return self.result(self._lock_on, "on" if self._lock_on else f"off for: {users}", "on", f"Users checked: {users}")
|
| 378 |
+
return self.result(self._lock_on, "on" if self._lock_on else "off", "on")
|
| 379 |
+
|
| 380 |
+
def _desktop_users(self):
|
| 381 |
+
if not hasattr(self, "_users"):
|
| 382 |
+
self._users = ", ".join(self.rng.sample(EMPLOYEE_ACCOUNTS, self.rng.choice([1, 1, 2])))
|
| 383 |
+
return self._users
|
| 384 |
+
|
| 385 |
+
def c_screen_lock_timeout(self):
|
| 386 |
+
limit = self.n("SCREEN_LOCK_MAX_SECONDS")
|
| 387 |
+
expected = f"<= {limit} seconds"
|
| 388 |
+
if self.os == "linux" and not hasattr(self, "_lock_on"):
|
| 389 |
+
return ("NotApplicable", "no GNOME desktop", expected, "No graphical desktop settings found.")
|
| 390 |
+
if self.os == "windows":
|
| 391 |
+
if self.good():
|
| 392 |
+
if self.rng.random() < 0.5:
|
| 393 |
+
secs = self.pick([300, 600, 900])
|
| 394 |
+
return self.result(True, f"{secs} seconds (machine inactivity limit)", expected)
|
| 395 |
+
return self.result(True, f"screen saver within limit for: {self._win_user()}", expected)
|
| 396 |
+
return self.result(False, self.pick(["no machine inactivity limit; no signed-in user to check",
|
| 397 |
+
f"no machine inactivity limit; too long or off for: {self._win_user()} (1200s, active=1)",
|
| 398 |
+
f"no machine inactivity limit; too long or off for: {self._win_user()} (600s, active=0)"]), expected)
|
| 399 |
+
if self.os == "linux":
|
| 400 |
+
users = self._desktop_users()
|
| 401 |
+
ok = self.good()
|
| 402 |
+
return self.result(ok, "within limit" if ok else f"too long for: {users.split(', ')[0]} ({self.pick([1800, 3600, 0])}s)", expected, f"Users checked: {users}")
|
| 403 |
+
idle = self.pick([120, 300, 600]) if self.good() else self.pick([1200, 1800, 3600])
|
| 404 |
+
delay = self.pick([0, 5, 60, 300])
|
| 405 |
+
if not getattr(self, "_lock_on", True):
|
| 406 |
+
return ("Non-Compliant", f"{idle} idle, password delay off", expected, "")
|
| 407 |
+
total = idle + delay
|
| 408 |
+
if total <= limit:
|
| 409 |
+
return ("Compliant", f"{total} seconds", expected, f"Idle {idle}s (screen saver/display sleep) + {delay}s password delay.")
|
| 410 |
+
return ("Non-Compliant", f"{idle} idle, password delay {delay}", expected, "")
|
| 411 |
+
|
| 412 |
+
def _win_user(self):
|
| 413 |
+
return f"{self._domain()}\\{self._desktop_users().split(', ')[0]}"
|
| 414 |
+
|
| 415 |
+
def _domain(self):
|
| 416 |
+
if not hasattr(self, "_dom"):
|
| 417 |
+
self._dom = self.pick(["CONTOSO", "DEVSEIS", "FABRIKAM", self.hostname])
|
| 418 |
+
return self._dom
|
| 419 |
+
|
| 420 |
+
def c_screen_lock_password(self):
|
| 421 |
+
if self.os == "linux" and not hasattr(self, "_lock_on"):
|
| 422 |
+
return ("NotApplicable", "no GNOME desktop", "password required", "No graphical desktop settings found.")
|
| 423 |
+
if self.os == "windows":
|
| 424 |
+
ok = self.good(0.1)
|
| 425 |
+
return self.result(ok, self.pick(["machine inactivity limit locks the session", "screen saver requires sign-in for all signed-in users"]) if ok
|
| 426 |
+
else f"screen saver does not require sign-in for: {self._win_user()}", "sign-in required to unlock")
|
| 427 |
+
if self.os == "linux":
|
| 428 |
+
ok = getattr(self, "_lock_on", False) and self.good(0.2)
|
| 429 |
+
users = self._desktop_users()
|
| 430 |
+
return self.result(ok, "required" if ok else f"not required for: {users}", "password required", f"Users checked: {users}")
|
| 431 |
+
delay = self.pick([0, 5]) if self.good(-0.1) else self.pick([60, 300, 900])
|
| 432 |
+
if not getattr(self, "_lock_on", True):
|
| 433 |
+
return ("Non-Compliant", "password after off seconds", "password within 5 seconds", "")
|
| 434 |
+
return self.result(delay <= 5, f"password after {delay} seconds", "password within 5 seconds")
|
| 435 |
+
|
| 436 |
+
# --- USB ------------------------------------------------------------------------
|
| 437 |
+
def c_usb_read(self):
|
| 438 |
+
expected = self.b("USB_READ").lower()
|
| 439 |
+
self._usb_blocked_all = self.rng.random() < 0.08
|
| 440 |
+
actual = "deny" if self._usb_blocked_all else "allow"
|
| 441 |
+
detail = {"windows": "all removable storage denied by policy", "linux": "usb-storage module disabled",
|
| 442 |
+
"macos": ""}[self.os] if self._usb_blocked_all else ""
|
| 443 |
+
return self.result(actual == expected, actual, self.b("USB_READ"), detail)
|
| 444 |
+
|
| 445 |
+
def c_usb_write(self):
|
| 446 |
+
expected = self.b("USB_WRITE").lower()
|
| 447 |
+
if self._usb_blocked_all:
|
| 448 |
+
self.usb_write = "deny"
|
| 449 |
+
else:
|
| 450 |
+
self.usb_write = "deny" if self.good(-0.25) else "allow"
|
| 451 |
+
detail = ""
|
| 452 |
+
if self.usb_write == "deny" and not self._usb_blocked_all:
|
| 453 |
+
detail = {"windows": "removable disk write denied by policy", "linux": "udev rule makes USB storage read-only", "macos": ""}[self.os]
|
| 454 |
+
elif self.os == "macos" and self.usb_write == "allow":
|
| 455 |
+
detail = "No configuration profile restricts external drives."
|
| 456 |
+
return self.result(self.usb_write == expected, self.usb_write, self.b("USB_WRITE"), detail)
|
| 457 |
+
|
| 458 |
+
# --- administrators -------------------------------------------------------------
|
| 459 |
+
def c_admin_approved(self):
|
| 460 |
+
approved = self.b("APPROVED_ADMINS")
|
| 461 |
+
expected = approved or "none configured"
|
| 462 |
+
if approved and self.good(0.05):
|
| 463 |
+
name = approved.split(",")[0].strip()
|
| 464 |
+
return self.result(True, f"{self._domain()}\\{name}" if self.os == "windows" else name, expected)
|
| 465 |
+
return self.result(False, "none of the approved accounts is an enabled administrator" if self.os == "windows"
|
| 466 |
+
else "none of the approved accounts is an administrator", expected)
|
| 467 |
+
|
| 468 |
+
def c_admin_unauthorized(self):
|
| 469 |
+
if self.good(-0.1):
|
| 470 |
+
return ("Compliant", "none", "none", "")
|
| 471 |
+
extra = self.rng.sample(EMPLOYEE_ACCOUNTS, self.pick([1, 1, 2]))
|
| 472 |
+
if self.os == "windows":
|
| 473 |
+
names = [f"{self.hostname}\\{u}" for u in extra]
|
| 474 |
+
if self.rng.random() < 0.3:
|
| 475 |
+
names.append(f"{self.hostname}\\Administrator")
|
| 476 |
+
return ("Non-Compliant", ", ".join(names), "none", "")
|
| 477 |
+
return ("Non-Compliant", ", ".join(extra), "none", "")
|
| 478 |
+
|
| 479 |
+
# --- encryption -------------------------------------------------------------------
|
| 480 |
+
def c_encryption_feature(self):
|
| 481 |
+
self._enc = self.good(0.1)
|
| 482 |
+
return self.result(self._enc, "on" if self._enc else "off", "on")
|
| 483 |
+
|
| 484 |
+
def c_encryption_enabled(self):
|
| 485 |
+
if self.os != "windows":
|
| 486 |
+
self._enc = self.good(0.05)
|
| 487 |
+
if self.os == "windows":
|
| 488 |
+
return self.result(self._enc, "FullyEncrypted (100%)" if self._enc else self.pick(["FullyDecrypted (0%)", "EncryptionInProgress (42%)"]), "fully encrypted")
|
| 489 |
+
if self.os == "linux":
|
| 490 |
+
return self.result(self._enc, "root filesystem on LUKS (dm_crypt-0)" if self._enc else "root filesystem not encrypted", "system disk encrypted")
|
| 491 |
+
return self.result(self._enc, "FileVault on" if self._enc else "FileVault off", "FileVault on")
|
| 492 |
+
|
| 493 |
+
def c_encryption_protection(self):
|
| 494 |
+
if self.os == "windows":
|
| 495 |
+
ok = self._enc and self.good(0.15)
|
| 496 |
+
return self.result(ok, "protection On, XtsAes128" if ok else f"protection {'Off' if self._enc else 'Off'}, {'XtsAes128' if self._enc else 'None'}", "protection on, AES")
|
| 497 |
+
if self.os == "linux":
|
| 498 |
+
if not self._enc:
|
| 499 |
+
return ("Non-Compliant", "not encrypted", "active, strong cipher", "")
|
| 500 |
+
ok = self.good(0.25)
|
| 501 |
+
return self.result(ok, "LUKS2 aes-xts-plain64 512-bit key" if ok else "LUKS1 aes-cbc-essiv:sha256 128-bit key", "active, strong cipher")
|
| 502 |
+
if not self._enc:
|
| 503 |
+
return ("Non-Compliant", "not encrypted", "fully encrypted", "")
|
| 504 |
+
return ("Compliant", "fully encrypted", "fully encrypted", "")
|
| 505 |
+
|
| 506 |
+
def c_encryption_recovery(self):
|
| 507 |
+
if self.os == "windows":
|
| 508 |
+
ok = self._enc and self.good(0.1)
|
| 509 |
+
return self.result(ok, "key protectors: Tpm, RecoveryPassword" if ok else "key protectors: Tpm", "recovery password present")
|
| 510 |
+
if self.os == "linux":
|
| 511 |
+
if not self._enc:
|
| 512 |
+
return ("Non-Compliant", "not encrypted", "recovery key or second unlock method", "")
|
| 513 |
+
ok = self.good()
|
| 514 |
+
return self.result(ok, f"{self.pick([2, 3])} key slots, 0 unlock tokens" if ok else "1 key slots, 0 unlock tokens", "recovery key or second unlock method")
|
| 515 |
+
if not self._enc:
|
| 516 |
+
return ("Non-Compliant", "no FileVault", "recovery key present", "")
|
| 517 |
+
ok = self.good(0.1)
|
| 518 |
+
return self.result(ok, f"recovery key present (personal: {'true' if ok else 'false'}, institutional: false)" if ok else "no recovery key", "recovery key present")
|
| 519 |
+
|
| 520 |
+
# --- GDPR ----------------------------------------------------------------------------
|
| 521 |
+
def c_gdpr_auto_updates(self):
|
| 522 |
+
ok = self.good(0.1)
|
| 523 |
+
if self.os == "windows":
|
| 524 |
+
return self.result(ok, self.pick(["Windows default (automatic)", "AUOptions=4"]) if ok else self.pick(["automatic updates turned off by policy", "AUOptions=2", "Windows Update service disabled"]), "automatic")
|
| 525 |
+
if self.os == "linux":
|
| 526 |
+
if self.distro == "rhel":
|
| 527 |
+
return self.result(ok, "dnf-automatic timer on, apply_updates=yes" if ok else "dnf-automatic timer off, apply_updates=no", "automatic")
|
| 528 |
+
return self.result(ok, "unattended-upgrades installed=1, enabled=1" if ok else self.pick(["unattended-upgrades installed=0, enabled=0", "unattended-upgrades installed=1, enabled=0"]), "automatic")
|
| 529 |
+
return self.result(ok, "check, download and install automatic" if ok else "check=1 download=1 security=1 macOS-install=0", "automatic")
|
| 530 |
+
|
| 531 |
+
def c_gdpr_updates_recent(self):
|
| 532 |
+
limit = self.n("UPDATES_MAX_AGE_DAYS")
|
| 533 |
+
age = self.rng.randint(0, limit) if self.good() else self.rng.randint(limit + 1, 240)
|
| 534 |
+
when = (self.started - timedelta(days=age)).strftime("%Y-%m-%d")
|
| 535 |
+
detail = {"windows": f"Last update: 2026-{self.rng.randint(1, 9):02d} Cumulative Update for Windows 11 ({when})",
|
| 536 |
+
"linux": f"Last package upgrade: {when} {self.rng.randint(0, 23):02d}:{self.rng.randint(0, 59):02d}:{self.rng.randint(0, 59):02d}",
|
| 537 |
+
"macos": f"Last software update: {when} {self.rng.randint(0, 23):02d}:{self.rng.randint(0, 59):02d}:00 +0000"}[self.os]
|
| 538 |
+
return self.result(age <= limit, f"{age} days ago", f"<= {limit} days ago", detail)
|
| 539 |
+
|
| 540 |
+
def c_gdpr_os_supported(self):
|
| 541 |
+
if self.os == "windows":
|
| 542 |
+
ok = int(self.build) >= self.n("WINDOWS_MIN_BUILD")
|
| 543 |
+
return self.result(ok, f"{self.os_name} build {self.build}", f"build {self.b('WINDOWS_MIN_BUILD')} or later")
|
| 544 |
+
if self.os == "linux":
|
| 545 |
+
minimum = {"ubuntu": ("UBUNTU_MIN_VERSION", "Ubuntu"), "debian": ("DEBIAN_MIN_VERSION", "Debian"), "rhel": ("RHEL_MIN_VERSION", "RHEL")}[self.distro]
|
| 546 |
+
need = self.b(minimum[0])
|
| 547 |
+
version = self.os_version if self.distro == "ubuntu" else self.os_version.split(".")[0]
|
| 548 |
+
ok = tuple(int(x) for x in version.split(".")) >= tuple(int(x) for x in need.split("."))
|
| 549 |
+
return self.result(ok, self.os_name, f"{minimum[1]} {need} or later")
|
| 550 |
+
ok = int(self.os_version.split(".")[0]) >= self.n("MACOS_MIN_MAJOR")
|
| 551 |
+
return self.result(ok, f"macOS {self.os_version}", f"macOS {self.b('MACOS_MIN_MAJOR')} or later")
|
| 552 |
+
|
| 553 |
+
def c_gdpr_security_logging(self):
|
| 554 |
+
ok = self.good()
|
| 555 |
+
if self.os == "windows":
|
| 556 |
+
return self.result(ok, "logon: success+failure; account management: success+failure" if ok
|
| 557 |
+
else self.pick(["logon: success; account management: none", "logon: none; account management: none"]),
|
| 558 |
+
"logon success+failure and account changes audited")
|
| 559 |
+
if self.os == "linux":
|
| 560 |
+
return self.result(ok, f"auditd running, journal {self.pick(['persistent', 'volatile'])}" if ok else f"auditd not running, journal {self.pick(['persistent', 'volatile'])}", "auditd running")
|
| 561 |
+
return ("Compliant", "unified logging active", "security events logged", "macOS unified log records authentication events; retention is managed by the OS.")
|
| 562 |
+
|
| 563 |
+
def c_gdpr_time_sync(self):
|
| 564 |
+
ok = self.good(0.25)
|
| 565 |
+
if self.os == "windows":
|
| 566 |
+
return self.result(ok, f"type {self.pick(['NTP', 'NT5DS'])}, service Running/Manual" if ok else "type NoSync, service Stopped/Manual", "NTP or domain time")
|
| 567 |
+
if self.os == "linux":
|
| 568 |
+
return self.result(ok, "NTP service yes, synchronised yes" if ok else "NTP service no, synchronised no", "time synchronised")
|
| 569 |
+
return self.result(ok, "network time on" if ok else "Network Time: Off", "network time on")
|
| 570 |
+
|
| 571 |
+
def c_gdpr_backup_configured(self):
|
| 572 |
+
options = {"windows": ["OneDrive folder backup (policy)", "File History (jdoe)", "Veeam Agent for Microsoft Windows"],
|
| 573 |
+
"linux": ["Timeshift", "Deja Dup (jdoe)", "restic-backup.timer"],
|
| 574 |
+
"macos": ["Time Machine", "bzserv", "ArqAgent"]}[self.os]
|
| 575 |
+
self.backup_tool = self.pick(options) if self.good(-0.15) else None
|
| 576 |
+
if self.backup_tool:
|
| 577 |
+
return ("Compliant", self.backup_tool, "backup configured", "")
|
| 578 |
+
return ("Non-Compliant", "none found" if self.os == "windows" else "none", "backup configured", "")
|
| 579 |
+
|
| 580 |
+
def c_gdpr_backup_recent(self):
|
| 581 |
+
limit = self.n("BACKUP_MAX_AGE_DAYS")
|
| 582 |
+
expected = f"<= {limit} days ago"
|
| 583 |
+
if not self.backup_tool:
|
| 584 |
+
return ("Non-Compliant", "no backup", expected, "")
|
| 585 |
+
readable = {"windows": False, "linux": self.backup_tool == "Timeshift" or self.backup_tool.startswith("Deja"), "macos": self.backup_tool == "Time Machine"}[self.os]
|
| 586 |
+
if not readable:
|
| 587 |
+
return ("Pending", "date not readable", expected, f"Confirm the last backup date in{':' if self.os != 'macos' else ''} {self.backup_tool}.")
|
| 588 |
+
age = self.rng.randint(0, limit) if self.good() else self.rng.randint(limit + 1, 120)
|
| 589 |
+
return self.result(age <= limit, f"{age} days ago", expected)
|
| 590 |
+
|
| 591 |
+
def c_gdpr_removable_encryption(self):
|
| 592 |
+
if self.os == "windows" and self.usb_write != "deny" and self.good(-0.35):
|
| 593 |
+
return ("Compliant", "BitLocker To Go required before writing", "no unencrypted writes", "")
|
| 594 |
+
if self.usb_write == "deny":
|
| 595 |
+
return ("Compliant", "writing to USB storage blocked" if self.os != "macos" else "writing to external drives blocked", "no unencrypted writes", "")
|
| 596 |
+
text = {"windows": "USB drives writable without encryption", "linux": "USB storage writable without enforced encryption",
|
| 597 |
+
"macos": "external drives writable without enforced encryption"}[self.os]
|
| 598 |
+
return ("Non-Compliant", text, "no unencrypted writes", "")
|
| 599 |
+
|
| 600 |
+
def c_gdpr_guest_account(self):
|
| 601 |
+
ok = self.good(0.3)
|
| 602 |
+
if self.os == "windows":
|
| 603 |
+
return self.result(ok, "disabled" if ok else "enabled", "disabled")
|
| 604 |
+
if self.os == "linux":
|
| 605 |
+
return self.result(ok, "no guest login" if ok else "LightDM guest session allowed", "no guest login")
|
| 606 |
+
return self.result(ok, "guest login off" if ok else "guest login 1, guest file sharing 0", "guest login off")
|
| 607 |
+
|
| 608 |
+
def c_gdpr_remote_access(self):
|
| 609 |
+
approved = [s.strip() for s in self.b("APPROVED_REMOTE_SERVICES").split(",") if s.strip()]
|
| 610 |
+
expected = f"only: {self.b('APPROVED_REMOTE_SERVICES') or 'none'}"
|
| 611 |
+
enabled = [] if self.good(-0.1) else self.rng.sample(REMOTE_SERVICES[self.os], self.pick([1, 1, 2]))
|
| 612 |
+
unapproved = [s for s in enabled if s not in approved]
|
| 613 |
+
if not unapproved:
|
| 614 |
+
return ("Compliant", ", ".join(enabled) or "none enabled", expected, "")
|
| 615 |
+
return ("Non-Compliant", ", ".join(unapproved), expected, "")
|
| 616 |
+
|
| 617 |
+
def c_gdpr_telemetry(self):
|
| 618 |
+
ok = self.good(-0.1)
|
| 619 |
+
if self.os == "windows":
|
| 620 |
+
return self.result(ok, f"AllowTelemetry={self.pick([0, 1])} ({self.pick(['policy', 'Settings'])})" if ok
|
| 621 |
+
else self.pick(["AllowTelemetry=3 (Settings)", "not configured"]), "required diagnostic data only (0-1)",
|
| 622 |
+
"" if ok else "Windows may send optional diagnostic data.")
|
| 623 |
+
if self.os == "linux":
|
| 624 |
+
return self.result(ok, "usage reporting off" if ok else self.pick(["popularity-contest on", "whoopsie error reporting on"]), "usage reporting off")
|
| 625 |
+
return self.result(ok, "analytics sharing off" if ok else "share Mac analytics=1, share with app developers=0", "analytics sharing off")
|
| 626 |
+
|
| 627 |
+
def c_gdpr_personal_data_discovery(self):
|
| 628 |
+
return ("NotApplicable", "not run", "opt-in scan", f"Opt-in scan (PERSONAL_DATA_SCAN={self.b('PERSONAL_DATA_SCAN')}) is planned for a later stage.")
|
| 629 |
+
|
| 630 |
+
# --- EU AI Act -----------------------------------------------------------------------
|
| 631 |
+
def c_aiact_inventory(self):
|
| 632 |
+
if self.rng.random() < 0.35:
|
| 633 |
+
self.ai_tools = []
|
| 634 |
+
else:
|
| 635 |
+
self.ai_tools = self.rng.sample(AI_TOOLS[self.os], self.pick([1, 1, 2, 3]))
|
| 636 |
+
detail = []
|
| 637 |
+
if "Ollama" in self.ai_tools and self.rng.random() < 0.5:
|
| 638 |
+
detail.append("Local AI service listening on port 11434.")
|
| 639 |
+
for tool, path in (("Ollama", ".ollama/models"), ("Hugging Face models", ".cache/huggingface/hub")):
|
| 640 |
+
if tool in self.ai_tools:
|
| 641 |
+
size = self.rng.randint(2, 60)
|
| 642 |
+
home = {"windows": "C:\\Users\\jdoe\\", "linux": "/home/jdoe/", "macos": "/Users/jdoe/"}[self.os]
|
| 643 |
+
shown = home + (path.replace("/", "\\") if self.os == "windows" else path)
|
| 644 |
+
detail.append(f"Local models: {shown} ({size} GB)." if self.os == "windows" else f"Local models: {shown} ({size}G).")
|
| 645 |
+
return ("Compliant", ", ".join(self.ai_tools) or "no AI tools found", "inventory taken", " ".join(detail))
|
| 646 |
+
|
| 647 |
+
def c_aiact_unapproved(self):
|
| 648 |
+
approved = self.b("APPROVED_AI_TOOLS")
|
| 649 |
+
approved_list = [a.strip().lower() for a in approved.split(",") if a.strip()]
|
| 650 |
+
expected = f"only: {approved or 'none'}"
|
| 651 |
+
unapproved = [t for t in self.ai_tools if t.lower() not in approved_list]
|
| 652 |
+
if not unapproved:
|
| 653 |
+
return ("Compliant", "none", expected, "")
|
| 654 |
+
return ("Non-Compliant", ", ".join(unapproved), expected, "")
|
| 655 |
+
|
| 656 |
+
def c_aiact_risk_classification(self):
|
| 657 |
+
if self.ai_tools:
|
| 658 |
+
return ("Pending", ", ".join(self.ai_tools), "every AI tool classified", "Classified by the auditor model (Caveat classifier) in a later step.")
|
| 659 |
+
return ("NotApplicable", "no AI tools found", "every AI tool classified", "")
|
| 660 |
+
|
| 661 |
+
def c_aiact_os_ai_features(self):
|
| 662 |
+
if self.os == "linux":
|
| 663 |
+
return ("NotApplicable", "none built in", "restricted by policy", "Linux distributions ship no built-in AI assistant.")
|
| 664 |
+
if self.os == "macos":
|
| 665 |
+
if self.architecture != "arm64":
|
| 666 |
+
return ("NotApplicable", "Intel Mac", "restricted by profile", "Apple Intelligence needs Apple silicon.")
|
| 667 |
+
ok = self.good(-0.35)
|
| 668 |
+
return self.result(ok, "restricted by profile" if ok else "not restricted", "restricted by profile",
|
| 669 |
+
"" if ok else "No profile sets allowExternalIntelligenceIntegrations to false.")
|
| 670 |
+
if self.good(-0.3):
|
| 671 |
+
return ("Compliant", "Recall disabled by policy", "disabled by policy", "DisableAIDataAnalysis=1; AllowRecallEnablement=0; TurnOffWindowsCopilot=")
|
| 672 |
+
if self.rng.random() < 0.5:
|
| 673 |
+
return ("NotApplicable", "Recall not available on this device", "disabled by policy", "DisableAIDataAnalysis=; AllowRecallEnablement=; TurnOffWindowsCopilot=")
|
| 674 |
+
return ("Non-Compliant", f"Recall feature {self.pick(['Enabled', 'Disabled'])}, not disabled by policy", "disabled by policy", "DisableAIDataAnalysis=; AllowRecallEnablement=; TurnOffWindowsCopilot=")
|
| 675 |
+
|
| 676 |
+
def c_aiact_log_retention(self):
|
| 677 |
+
if self.ai_tools:
|
| 678 |
+
return ("Pending", "depends on classification", ">= 6 months for high-risk AI", "Required only if a tool is classified high-risk.")
|
| 679 |
+
return ("NotApplicable", "no AI tools found", ">= 6 months for high-risk AI", "")
|
| 680 |
+
|
| 681 |
+
|
| 682 |
+
def org_result(rng, profile, answer_key):
|
| 683 |
+
choices, weights = zip(*ORG_ANSWERS[profile])
|
| 684 |
+
answer = rng.choices(choices, weights=weights)[0]
|
| 685 |
+
if answer == "yes":
|
| 686 |
+
return ("Compliant", "yes", "yes", "")
|
| 687 |
+
if answer in ("no", "partial"):
|
| 688 |
+
return ("Non-Compliant", answer, "yes", "")
|
| 689 |
+
return ("Pending", "not answered", "yes", f"Answer {answer_key} in organisational.conf.")
|
| 690 |
+
|
| 691 |
+
|
| 692 |
+
def random_hostname(rng, os_family):
|
| 693 |
+
if os_family == "windows":
|
| 694 |
+
return rng.choice(["DESKTOP-", "LAPTOP-"]) + "".join(rng.choices(string.ascii_uppercase + string.digits, k=7))
|
| 695 |
+
team = rng.choice(["fin", "hr", "dev", "ops", "sales", "legal", "mkt"])
|
| 696 |
+
if os_family == "linux":
|
| 697 |
+
return f"{team}-{rng.choice(['ws', 'lt', 'ubuntu', 'dev'])}-{rng.randint(1, 99):02d}"
|
| 698 |
+
return f"{team.upper()}-MacBook-{rng.randint(1, 60):02d}"
|
| 699 |
+
|
| 700 |
+
|
| 701 |
+
def make_record(rng, seed, os_family, profile, baseline, catalog, checks):
|
| 702 |
+
device = Device(rng, os_family, profile, baseline)
|
| 703 |
+
offset = timezone(timedelta(hours=rng.choice([1, 2, 5])))
|
| 704 |
+
started = datetime(2026, 10, 1, tzinfo=offset) + timedelta(minutes=rng.randint(0, 60 * 24 * 6))
|
| 705 |
+
device.started = started
|
| 706 |
+
device.hostname = random_hostname(rng, os_family)
|
| 707 |
+
host = {"hostname": device.hostname, "os_family": os_family}
|
| 708 |
+
if os_family == "windows":
|
| 709 |
+
name, version, build = rng.choice(OS_VERSIONS["windows"])
|
| 710 |
+
device.os_name, device.os_version, device.build = name, version, build
|
| 711 |
+
device.architecture = "AMD64"
|
| 712 |
+
host.update(os_name=name, os_version=version, build=build, architecture="AMD64",
|
| 713 |
+
serial_number="".join(rng.choices(string.ascii_uppercase + string.digits, k=7)))
|
| 714 |
+
user = "NT AUTHORITY\\SYSTEM" if device.is_admin and rng.random() < 0.7 else f"{device.hostname}\\{rng.choice(EMPLOYEE_ACCOUNTS)}"
|
| 715 |
+
elif os_family == "linux":
|
| 716 |
+
name, version, distro = rng.choice(OS_VERSIONS["linux"])
|
| 717 |
+
device.os_name, device.os_version, device.distro = name, version, distro
|
| 718 |
+
device.architecture = "x86_64"
|
| 719 |
+
host.update(os_name=name, os_version=version, kernel=rng.choice(KERNELS[distro]), architecture="x86_64",
|
| 720 |
+
serial_number="".join(rng.choices(string.ascii_uppercase + string.digits, k=7)))
|
| 721 |
+
user = "root" if device.is_admin else rng.choice(EMPLOYEE_ACCOUNTS)
|
| 722 |
+
else:
|
| 723 |
+
version, build = rng.choice(OS_VERSIONS["macos"])
|
| 724 |
+
device.os_name, device.os_version = "macOS", version
|
| 725 |
+
device.architecture = rng.choice(["arm64", "arm64", "arm64", "x86_64"])
|
| 726 |
+
host.update(os_name="macOS", os_version=version, build=build, architecture=device.architecture,
|
| 727 |
+
serial_number="C02" + "".join(rng.choices(string.ascii_uppercase + string.digits, k=9)))
|
| 728 |
+
user = "root" if device.is_admin else rng.choice(EMPLOYEE_ACCOUNTS)
|
| 729 |
+
|
| 730 |
+
results = []
|
| 731 |
+
for check_id in ORDER[os_family]:
|
| 732 |
+
check = checks[check_id]
|
| 733 |
+
if check["section"] == "org":
|
| 734 |
+
status, actual, expected, detail = org_result(rng, profile, check["answer_key"])
|
| 735 |
+
else:
|
| 736 |
+
status, actual, expected, detail = device.value(check_id)
|
| 737 |
+
if not device.is_admin and check_id in NEEDS_ADMIN[os_family]:
|
| 738 |
+
detail = ("Requires an elevated (administrator) PowerShell." if os_family == "windows"
|
| 739 |
+
else "Requires root: run the collector with sudo.")
|
| 740 |
+
status, actual = "Error", "not read"
|
| 741 |
+
elif status in ("Compliant", "Non-Compliant") and rng.random() < 0.01:
|
| 742 |
+
status, actual, detail = "Error", "not read", "Value could not be read."
|
| 743 |
+
item = {"id": check_id, "section": check["section"], "name": check_name(check, os_family),
|
| 744 |
+
"status": status, "actual": actual, "expected": expected}
|
| 745 |
+
if detail:
|
| 746 |
+
item["detail"] = detail
|
| 747 |
+
results.append(item)
|
| 748 |
+
|
| 749 |
+
summary = {s: sum(1 for r in results if r["status"] == s) for s in catalog["statuses"]}
|
| 750 |
+
return {
|
| 751 |
+
"schema_version": "1.0",
|
| 752 |
+
"catalog_version": catalog["catalog_version"],
|
| 753 |
+
"source": {"kind": "synthetic", "collector": os_family, "collector_version": GENERATOR_VERSION,
|
| 754 |
+
"synthetic_seed": seed, "synthetic_profile": profile},
|
| 755 |
+
"audit": {"mode": "READ-ONLY", "started_at": started.isoformat(timespec="seconds"),
|
| 756 |
+
"execution_user": user, "is_admin": device.is_admin},
|
| 757 |
+
"host": host,
|
| 758 |
+
"baseline": dict(baseline),
|
| 759 |
+
"results": results,
|
| 760 |
+
"summary": summary,
|
| 761 |
+
}
|
| 762 |
+
|
| 763 |
+
|
| 764 |
+
def main():
|
| 765 |
+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
| 766 |
+
parser.add_argument("--count", type=int, default=100, help="number of audit runs to generate")
|
| 767 |
+
parser.add_argument("--seed", type=int, default=1, help="random seed (same seed = same data)")
|
| 768 |
+
parser.add_argument("--os", default="windows,linux,macos", help="comma-separated OS families")
|
| 769 |
+
parser.add_argument("--baseline", default=os.path.join(ROOT, "baseline.conf"))
|
| 770 |
+
parser.add_argument("--out", default=os.path.join(ROOT, "synthetic", "out", "evidence.jsonl"), help="JSONL output file")
|
| 771 |
+
parser.add_argument("--files", help="write one pretty JSON file per run into this folder instead of JSONL")
|
| 772 |
+
args = parser.parse_args()
|
| 773 |
+
|
| 774 |
+
catalog, checks = load_catalog()
|
| 775 |
+
baseline = read_conf(args.baseline)
|
| 776 |
+
families = [f.strip() for f in args.os.split(",") if f.strip()]
|
| 777 |
+
rng = random.Random(args.seed)
|
| 778 |
+
records = []
|
| 779 |
+
for index in range(args.count):
|
| 780 |
+
record_seed = args.seed * 1_000_003 + index
|
| 781 |
+
local = random.Random(record_seed)
|
| 782 |
+
records.append(make_record(local, record_seed, rng.choice(families), rng.choices(list(PROFILES), weights=[2, 5, 3])[0],
|
| 783 |
+
baseline, catalog, checks))
|
| 784 |
+
|
| 785 |
+
if args.files:
|
| 786 |
+
os.makedirs(args.files, exist_ok=True)
|
| 787 |
+
for record in records:
|
| 788 |
+
path = os.path.join(args.files, f"synthetic-{record['host']['os_family']}-{record['source']['synthetic_seed']}.json")
|
| 789 |
+
with open(path, "w", encoding="utf-8") as handle:
|
| 790 |
+
json.dump(record, handle, indent=2, ensure_ascii=False)
|
| 791 |
+
print(f"Wrote {len(records)} files to {args.files}")
|
| 792 |
+
else:
|
| 793 |
+
os.makedirs(os.path.dirname(os.path.abspath(args.out)), exist_ok=True)
|
| 794 |
+
with open(args.out, "w", encoding="utf-8") as handle:
|
| 795 |
+
for record in records:
|
| 796 |
+
handle.write(json.dumps(record, ensure_ascii=False) + "\n")
|
| 797 |
+
print(f"Wrote {len(records)} records to {args.out}")
|
| 798 |
+
|
| 799 |
+
|
| 800 |
+
if __name__ == "__main__":
|
| 801 |
+
main()
|
synthetic/samples/synthetic-linux-42000126.json
ADDED
|
@@ -0,0 +1,482 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"schema_version": "1.0",
|
| 3 |
+
"catalog_version": "0.1.0",
|
| 4 |
+
"source": {
|
| 5 |
+
"kind": "synthetic",
|
| 6 |
+
"collector": "linux",
|
| 7 |
+
"collector_version": "0.1.0",
|
| 8 |
+
"synthetic_seed": 42000126,
|
| 9 |
+
"synthetic_profile": "hardened"
|
| 10 |
+
},
|
| 11 |
+
"audit": {
|
| 12 |
+
"mode": "READ-ONLY",
|
| 13 |
+
"started_at": "2026-10-05T19:36:00+05:00",
|
| 14 |
+
"execution_user": "root",
|
| 15 |
+
"is_admin": true
|
| 16 |
+
},
|
| 17 |
+
"host": {
|
| 18 |
+
"hostname": "legal-lt-70",
|
| 19 |
+
"os_family": "linux",
|
| 20 |
+
"os_name": "Ubuntu 22.04.5 LTS",
|
| 21 |
+
"os_version": "22.04",
|
| 22 |
+
"kernel": "5.15.0-151-generic",
|
| 23 |
+
"architecture": "x86_64",
|
| 24 |
+
"serial_number": "R9DP33X"
|
| 25 |
+
},
|
| 26 |
+
"baseline": {
|
| 27 |
+
"PASSWORD_MIN_LENGTH": "14",
|
| 28 |
+
"PASSWORD_HISTORY": "24",
|
| 29 |
+
"PASSWORD_MAX_AGE_DAYS": "365",
|
| 30 |
+
"PASSWORD_MIN_AGE_DAYS": "1",
|
| 31 |
+
"PASSWORD_COMPLEXITY": "1",
|
| 32 |
+
"LOCKOUT_THRESHOLD_MAX": "5",
|
| 33 |
+
"LOCKOUT_DURATION_MIN_MINUTES": "15",
|
| 34 |
+
"LOCKOUT_OBSERVATION_MIN_MINUTES": "15",
|
| 35 |
+
"SIGNATURE_MAX_AGE_DAYS": "3",
|
| 36 |
+
"MACOS_XPROTECT_MAX_AGE_DAYS": "30",
|
| 37 |
+
"FIREWALL_OUTBOUND": "allow",
|
| 38 |
+
"SCREEN_LOCK_MAX_SECONDS": "900",
|
| 39 |
+
"USB_READ": "allow",
|
| 40 |
+
"USB_WRITE": "deny",
|
| 41 |
+
"APPROVED_ADMINS": "itadmin",
|
| 42 |
+
"ALLOW_DISABLED_BUILTIN_ADMIN": "1",
|
| 43 |
+
"UPDATES_MAX_AGE_DAYS": "30",
|
| 44 |
+
"BACKUP_MAX_AGE_DAYS": "7",
|
| 45 |
+
"APPROVED_REMOTE_SERVICES": "",
|
| 46 |
+
"WINDOWS_MIN_BUILD": "22631",
|
| 47 |
+
"MACOS_MIN_MAJOR": "14",
|
| 48 |
+
"UBUNTU_MIN_VERSION": "22.04",
|
| 49 |
+
"DEBIAN_MIN_VERSION": "12",
|
| 50 |
+
"RHEL_MIN_VERSION": "9",
|
| 51 |
+
"PERSONAL_DATA_SCAN": "0",
|
| 52 |
+
"APPROVED_AI_TOOLS": ""
|
| 53 |
+
},
|
| 54 |
+
"results": [
|
| 55 |
+
{
|
| 56 |
+
"id": "password.min_length",
|
| 57 |
+
"section": "baseline",
|
| 58 |
+
"name": "Minimum password length",
|
| 59 |
+
"status": "Compliant",
|
| 60 |
+
"actual": "14",
|
| 61 |
+
"expected": ">= 14",
|
| 62 |
+
"detail": "pam_pwquality"
|
| 63 |
+
},
|
| 64 |
+
{
|
| 65 |
+
"id": "password.history",
|
| 66 |
+
"section": "baseline",
|
| 67 |
+
"name": "Password history",
|
| 68 |
+
"status": "Compliant",
|
| 69 |
+
"actual": "24",
|
| 70 |
+
"expected": ">= 24"
|
| 71 |
+
},
|
| 72 |
+
{
|
| 73 |
+
"id": "password.expiration",
|
| 74 |
+
"section": "baseline",
|
| 75 |
+
"name": "Password expiration",
|
| 76 |
+
"status": "Compliant",
|
| 77 |
+
"actual": "90 days",
|
| 78 |
+
"expected": "1-365 days"
|
| 79 |
+
},
|
| 80 |
+
{
|
| 81 |
+
"id": "password.min_age",
|
| 82 |
+
"section": "baseline",
|
| 83 |
+
"name": "Minimum password age",
|
| 84 |
+
"status": "Compliant",
|
| 85 |
+
"actual": "1 days",
|
| 86 |
+
"expected": ">= 1 days"
|
| 87 |
+
},
|
| 88 |
+
{
|
| 89 |
+
"id": "password.complexity",
|
| 90 |
+
"section": "baseline",
|
| 91 |
+
"name": "Password complexity",
|
| 92 |
+
"status": "Compliant",
|
| 93 |
+
"actual": "minclass=0, required classes=4",
|
| 94 |
+
"expected": "3 or more character classes"
|
| 95 |
+
},
|
| 96 |
+
{
|
| 97 |
+
"id": "lockout.threshold",
|
| 98 |
+
"section": "baseline",
|
| 99 |
+
"name": "Account lockout threshold",
|
| 100 |
+
"status": "Compliant",
|
| 101 |
+
"actual": "5",
|
| 102 |
+
"expected": "1-5",
|
| 103 |
+
"detail": "pam_faillock"
|
| 104 |
+
},
|
| 105 |
+
{
|
| 106 |
+
"id": "lockout.duration",
|
| 107 |
+
"section": "baseline",
|
| 108 |
+
"name": "Account lockout duration",
|
| 109 |
+
"status": "Compliant",
|
| 110 |
+
"actual": "until an administrator unlocks",
|
| 111 |
+
"expected": ">= 15 minutes"
|
| 112 |
+
},
|
| 113 |
+
{
|
| 114 |
+
"id": "lockout.observation",
|
| 115 |
+
"section": "baseline",
|
| 116 |
+
"name": "Lockout observation period",
|
| 117 |
+
"status": "Compliant",
|
| 118 |
+
"actual": "30 minutes",
|
| 119 |
+
"expected": ">= 15 minutes"
|
| 120 |
+
},
|
| 121 |
+
{
|
| 122 |
+
"id": "malware.antivirus",
|
| 123 |
+
"section": "baseline",
|
| 124 |
+
"name": "Antivirus",
|
| 125 |
+
"status": "Compliant",
|
| 126 |
+
"actual": "ClamAV (clamav-daemon)",
|
| 127 |
+
"expected": "antivirus running"
|
| 128 |
+
},
|
| 129 |
+
{
|
| 130 |
+
"id": "malware.service",
|
| 131 |
+
"section": "baseline",
|
| 132 |
+
"name": "ClamAV malware protection service",
|
| 133 |
+
"status": "Compliant",
|
| 134 |
+
"actual": "clamav-daemon",
|
| 135 |
+
"expected": "running"
|
| 136 |
+
},
|
| 137 |
+
{
|
| 138 |
+
"id": "malware.signature_updates",
|
| 139 |
+
"section": "baseline",
|
| 140 |
+
"name": "Malware signature updates",
|
| 141 |
+
"status": "Compliant",
|
| 142 |
+
"actual": "clamav-freshclam",
|
| 143 |
+
"expected": "automatic"
|
| 144 |
+
},
|
| 145 |
+
{
|
| 146 |
+
"id": "malware.signatures",
|
| 147 |
+
"section": "baseline",
|
| 148 |
+
"name": "Malware signatures",
|
| 149 |
+
"status": "Non-Compliant",
|
| 150 |
+
"actual": "no signature database",
|
| 151 |
+
"expected": "<= 3 days old"
|
| 152 |
+
},
|
| 153 |
+
{
|
| 154 |
+
"id": "malware.on_access",
|
| 155 |
+
"section": "baseline",
|
| 156 |
+
"name": "On-access malware protection",
|
| 157 |
+
"status": "Non-Compliant",
|
| 158 |
+
"actual": "not running",
|
| 159 |
+
"expected": "on-access scanning"
|
| 160 |
+
},
|
| 161 |
+
{
|
| 162 |
+
"id": "firewall.enabled",
|
| 163 |
+
"section": "baseline",
|
| 164 |
+
"name": "Firewall",
|
| 165 |
+
"status": "Compliant",
|
| 166 |
+
"actual": "firewalld active",
|
| 167 |
+
"expected": "active"
|
| 168 |
+
},
|
| 169 |
+
{
|
| 170 |
+
"id": "firewall.inbound",
|
| 171 |
+
"section": "baseline",
|
| 172 |
+
"name": "Firewall inbound policy",
|
| 173 |
+
"status": "Compliant",
|
| 174 |
+
"actual": "deny",
|
| 175 |
+
"expected": "deny"
|
| 176 |
+
},
|
| 177 |
+
{
|
| 178 |
+
"id": "firewall.outbound",
|
| 179 |
+
"section": "baseline",
|
| 180 |
+
"name": "Firewall outbound policy",
|
| 181 |
+
"status": "Compliant",
|
| 182 |
+
"actual": "allow",
|
| 183 |
+
"expected": "allow"
|
| 184 |
+
},
|
| 185 |
+
{
|
| 186 |
+
"id": "screen_lock.enabled",
|
| 187 |
+
"section": "baseline",
|
| 188 |
+
"name": "Screen lock",
|
| 189 |
+
"status": "Compliant",
|
| 190 |
+
"actual": "on",
|
| 191 |
+
"expected": "on",
|
| 192 |
+
"detail": "Users checked: akaya, jdoe"
|
| 193 |
+
},
|
| 194 |
+
{
|
| 195 |
+
"id": "screen_lock.timeout",
|
| 196 |
+
"section": "baseline",
|
| 197 |
+
"name": "Screen lock timeout",
|
| 198 |
+
"status": "Compliant",
|
| 199 |
+
"actual": "within limit",
|
| 200 |
+
"expected": "<= 900 seconds",
|
| 201 |
+
"detail": "Users checked: akaya, jdoe"
|
| 202 |
+
},
|
| 203 |
+
{
|
| 204 |
+
"id": "screen_lock.password",
|
| 205 |
+
"section": "baseline",
|
| 206 |
+
"name": "Screen lock password requirement",
|
| 207 |
+
"status": "Compliant",
|
| 208 |
+
"actual": "required",
|
| 209 |
+
"expected": "password required",
|
| 210 |
+
"detail": "Users checked: akaya, jdoe"
|
| 211 |
+
},
|
| 212 |
+
{
|
| 213 |
+
"id": "usb.read",
|
| 214 |
+
"section": "baseline",
|
| 215 |
+
"name": "USB storage read",
|
| 216 |
+
"status": "Compliant",
|
| 217 |
+
"actual": "allow",
|
| 218 |
+
"expected": "allow"
|
| 219 |
+
},
|
| 220 |
+
{
|
| 221 |
+
"id": "usb.write",
|
| 222 |
+
"section": "baseline",
|
| 223 |
+
"name": "USB storage write",
|
| 224 |
+
"status": "Non-Compliant",
|
| 225 |
+
"actual": "allow",
|
| 226 |
+
"expected": "deny"
|
| 227 |
+
},
|
| 228 |
+
{
|
| 229 |
+
"id": "admin.approved",
|
| 230 |
+
"section": "baseline",
|
| 231 |
+
"name": "Approved IT administrator",
|
| 232 |
+
"status": "Compliant",
|
| 233 |
+
"actual": "itadmin",
|
| 234 |
+
"expected": "itadmin"
|
| 235 |
+
},
|
| 236 |
+
{
|
| 237 |
+
"id": "admin.unauthorized",
|
| 238 |
+
"section": "baseline",
|
| 239 |
+
"name": "Unauthorized employee administrator accounts",
|
| 240 |
+
"status": "Non-Compliant",
|
| 241 |
+
"actual": "akaya",
|
| 242 |
+
"expected": "none"
|
| 243 |
+
},
|
| 244 |
+
{
|
| 245 |
+
"id": "encryption.enabled",
|
| 246 |
+
"section": "baseline",
|
| 247 |
+
"name": "Encryption",
|
| 248 |
+
"status": "Compliant",
|
| 249 |
+
"actual": "root filesystem on LUKS (dm_crypt-0)",
|
| 250 |
+
"expected": "system disk encrypted"
|
| 251 |
+
},
|
| 252 |
+
{
|
| 253 |
+
"id": "encryption.protection",
|
| 254 |
+
"section": "baseline",
|
| 255 |
+
"name": "Disk encryption protection",
|
| 256 |
+
"status": "Compliant",
|
| 257 |
+
"actual": "LUKS2 aes-xts-plain64 512-bit key",
|
| 258 |
+
"expected": "active, strong cipher"
|
| 259 |
+
},
|
| 260 |
+
{
|
| 261 |
+
"id": "encryption.recovery",
|
| 262 |
+
"section": "baseline",
|
| 263 |
+
"name": "Disk encryption recovery",
|
| 264 |
+
"status": "Compliant",
|
| 265 |
+
"actual": "2 key slots, 0 unlock tokens",
|
| 266 |
+
"expected": "recovery key or second unlock method"
|
| 267 |
+
},
|
| 268 |
+
{
|
| 269 |
+
"id": "gdpr.auto_updates",
|
| 270 |
+
"section": "gdpr",
|
| 271 |
+
"name": "Automatic security updates",
|
| 272 |
+
"status": "Compliant",
|
| 273 |
+
"actual": "unattended-upgrades installed=1, enabled=1",
|
| 274 |
+
"expected": "automatic"
|
| 275 |
+
},
|
| 276 |
+
{
|
| 277 |
+
"id": "gdpr.updates_recent",
|
| 278 |
+
"section": "gdpr",
|
| 279 |
+
"name": "Security updates installed recently",
|
| 280 |
+
"status": "Non-Compliant",
|
| 281 |
+
"actual": "156 days ago",
|
| 282 |
+
"expected": "<= 30 days ago",
|
| 283 |
+
"detail": "Last package upgrade: 2026-05-02 11:51:26"
|
| 284 |
+
},
|
| 285 |
+
{
|
| 286 |
+
"id": "gdpr.os_supported",
|
| 287 |
+
"section": "gdpr",
|
| 288 |
+
"name": "Supported operating system version",
|
| 289 |
+
"status": "Compliant",
|
| 290 |
+
"actual": "Ubuntu 22.04.5 LTS",
|
| 291 |
+
"expected": "Ubuntu 22.04 or later"
|
| 292 |
+
},
|
| 293 |
+
{
|
| 294 |
+
"id": "gdpr.security_logging",
|
| 295 |
+
"section": "gdpr",
|
| 296 |
+
"name": "Security event logging",
|
| 297 |
+
"status": "Compliant",
|
| 298 |
+
"actual": "auditd running, journal persistent",
|
| 299 |
+
"expected": "auditd running"
|
| 300 |
+
},
|
| 301 |
+
{
|
| 302 |
+
"id": "gdpr.time_sync",
|
| 303 |
+
"section": "gdpr",
|
| 304 |
+
"name": "Time synchronisation",
|
| 305 |
+
"status": "Compliant",
|
| 306 |
+
"actual": "NTP service yes, synchronised yes",
|
| 307 |
+
"expected": "time synchronised"
|
| 308 |
+
},
|
| 309 |
+
{
|
| 310 |
+
"id": "gdpr.backup_configured",
|
| 311 |
+
"section": "gdpr",
|
| 312 |
+
"name": "Backup configured",
|
| 313 |
+
"status": "Compliant",
|
| 314 |
+
"actual": "Deja Dup (jdoe)",
|
| 315 |
+
"expected": "backup configured"
|
| 316 |
+
},
|
| 317 |
+
{
|
| 318 |
+
"id": "gdpr.backup_recent",
|
| 319 |
+
"section": "gdpr",
|
| 320 |
+
"name": "Recent backup",
|
| 321 |
+
"status": "Compliant",
|
| 322 |
+
"actual": "1 days ago",
|
| 323 |
+
"expected": "<= 7 days ago"
|
| 324 |
+
},
|
| 325 |
+
{
|
| 326 |
+
"id": "gdpr.removable_encryption",
|
| 327 |
+
"section": "gdpr",
|
| 328 |
+
"name": "Removable media encryption",
|
| 329 |
+
"status": "Non-Compliant",
|
| 330 |
+
"actual": "USB storage writable without enforced encryption",
|
| 331 |
+
"expected": "no unencrypted writes"
|
| 332 |
+
},
|
| 333 |
+
{
|
| 334 |
+
"id": "gdpr.guest_account",
|
| 335 |
+
"section": "gdpr",
|
| 336 |
+
"name": "Guest account disabled",
|
| 337 |
+
"status": "Compliant",
|
| 338 |
+
"actual": "no guest login",
|
| 339 |
+
"expected": "no guest login"
|
| 340 |
+
},
|
| 341 |
+
{
|
| 342 |
+
"id": "gdpr.remote_access",
|
| 343 |
+
"section": "gdpr",
|
| 344 |
+
"name": "Remote access services restricted",
|
| 345 |
+
"status": "Compliant",
|
| 346 |
+
"actual": "none enabled",
|
| 347 |
+
"expected": "only: none"
|
| 348 |
+
},
|
| 349 |
+
{
|
| 350 |
+
"id": "gdpr.telemetry",
|
| 351 |
+
"section": "gdpr",
|
| 352 |
+
"name": "Diagnostic data minimised",
|
| 353 |
+
"status": "Compliant",
|
| 354 |
+
"actual": "usage reporting off",
|
| 355 |
+
"expected": "usage reporting off"
|
| 356 |
+
},
|
| 357 |
+
{
|
| 358 |
+
"id": "gdpr.personal_data_discovery",
|
| 359 |
+
"section": "gdpr",
|
| 360 |
+
"name": "Personal data discovery",
|
| 361 |
+
"status": "NotApplicable",
|
| 362 |
+
"actual": "not run",
|
| 363 |
+
"expected": "opt-in scan",
|
| 364 |
+
"detail": "Opt-in scan (PERSONAL_DATA_SCAN=0) is planned for a later stage."
|
| 365 |
+
},
|
| 366 |
+
{
|
| 367 |
+
"id": "aiact.inventory",
|
| 368 |
+
"section": "aiact",
|
| 369 |
+
"name": "AI software inventory",
|
| 370 |
+
"status": "Compliant",
|
| 371 |
+
"actual": "LM Studio, Alpaca (Ollama client)",
|
| 372 |
+
"expected": "inventory taken"
|
| 373 |
+
},
|
| 374 |
+
{
|
| 375 |
+
"id": "aiact.unapproved",
|
| 376 |
+
"section": "aiact",
|
| 377 |
+
"name": "Unapproved AI tools (shadow AI)",
|
| 378 |
+
"status": "Non-Compliant",
|
| 379 |
+
"actual": "LM Studio, Alpaca (Ollama client)",
|
| 380 |
+
"expected": "only: none"
|
| 381 |
+
},
|
| 382 |
+
{
|
| 383 |
+
"id": "aiact.risk_classification",
|
| 384 |
+
"section": "aiact",
|
| 385 |
+
"name": "AI system risk classification",
|
| 386 |
+
"status": "Pending",
|
| 387 |
+
"actual": "LM Studio, Alpaca (Ollama client)",
|
| 388 |
+
"expected": "every AI tool classified",
|
| 389 |
+
"detail": "Classified by the auditor model (Caveat classifier) in a later step."
|
| 390 |
+
},
|
| 391 |
+
{
|
| 392 |
+
"id": "aiact.os_ai_features",
|
| 393 |
+
"section": "aiact",
|
| 394 |
+
"name": "Operating system AI features restricted",
|
| 395 |
+
"status": "NotApplicable",
|
| 396 |
+
"actual": "none built in",
|
| 397 |
+
"expected": "restricted by policy",
|
| 398 |
+
"detail": "Linux distributions ship no built-in AI assistant."
|
| 399 |
+
},
|
| 400 |
+
{
|
| 401 |
+
"id": "aiact.log_retention",
|
| 402 |
+
"section": "aiact",
|
| 403 |
+
"name": "AI usage log retention",
|
| 404 |
+
"status": "Pending",
|
| 405 |
+
"actual": "depends on classification",
|
| 406 |
+
"expected": ">= 6 months for high-risk AI",
|
| 407 |
+
"detail": "Required only if a tool is classified high-risk."
|
| 408 |
+
},
|
| 409 |
+
{
|
| 410 |
+
"id": "org.ropa",
|
| 411 |
+
"section": "org",
|
| 412 |
+
"name": "Record of processing activities",
|
| 413 |
+
"status": "Non-Compliant",
|
| 414 |
+
"actual": "partial",
|
| 415 |
+
"expected": "yes"
|
| 416 |
+
},
|
| 417 |
+
{
|
| 418 |
+
"id": "org.dpia",
|
| 419 |
+
"section": "org",
|
| 420 |
+
"name": "Data protection impact assessment",
|
| 421 |
+
"status": "Pending",
|
| 422 |
+
"actual": "not answered",
|
| 423 |
+
"expected": "yes",
|
| 424 |
+
"detail": "Answer ORG_DPIA in organisational.conf."
|
| 425 |
+
},
|
| 426 |
+
{
|
| 427 |
+
"id": "org.breach_procedure",
|
| 428 |
+
"section": "org",
|
| 429 |
+
"name": "Breach response procedure",
|
| 430 |
+
"status": "Compliant",
|
| 431 |
+
"actual": "yes",
|
| 432 |
+
"expected": "yes"
|
| 433 |
+
},
|
| 434 |
+
{
|
| 435 |
+
"id": "org.processor_agreements",
|
| 436 |
+
"section": "org",
|
| 437 |
+
"name": "Data processing agreements",
|
| 438 |
+
"status": "Compliant",
|
| 439 |
+
"actual": "yes",
|
| 440 |
+
"expected": "yes"
|
| 441 |
+
},
|
| 442 |
+
{
|
| 443 |
+
"id": "org.ai_register",
|
| 444 |
+
"section": "org",
|
| 445 |
+
"name": "AI register",
|
| 446 |
+
"status": "Compliant",
|
| 447 |
+
"actual": "yes",
|
| 448 |
+
"expected": "yes"
|
| 449 |
+
},
|
| 450 |
+
{
|
| 451 |
+
"id": "org.ai_literacy",
|
| 452 |
+
"section": "org",
|
| 453 |
+
"name": "AI literacy training",
|
| 454 |
+
"status": "Compliant",
|
| 455 |
+
"actual": "yes",
|
| 456 |
+
"expected": "yes"
|
| 457 |
+
},
|
| 458 |
+
{
|
| 459 |
+
"id": "org.human_oversight",
|
| 460 |
+
"section": "org",
|
| 461 |
+
"name": "Human oversight for high-risk AI",
|
| 462 |
+
"status": "Compliant",
|
| 463 |
+
"actual": "yes",
|
| 464 |
+
"expected": "yes"
|
| 465 |
+
},
|
| 466 |
+
{
|
| 467 |
+
"id": "org.ai_transparency",
|
| 468 |
+
"section": "org",
|
| 469 |
+
"name": "AI transparency to users",
|
| 470 |
+
"status": "Compliant",
|
| 471 |
+
"actual": "yes",
|
| 472 |
+
"expected": "yes"
|
| 473 |
+
}
|
| 474 |
+
],
|
| 475 |
+
"summary": {
|
| 476 |
+
"Compliant": 38,
|
| 477 |
+
"Non-Compliant": 8,
|
| 478 |
+
"NotApplicable": 2,
|
| 479 |
+
"Pending": 3,
|
| 480 |
+
"Error": 0
|
| 481 |
+
}
|
| 482 |
+
}
|
synthetic/samples/synthetic-macos-42000126.json
ADDED
|
@@ -0,0 +1,482 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"schema_version": "1.0",
|
| 3 |
+
"catalog_version": "0.1.0",
|
| 4 |
+
"source": {
|
| 5 |
+
"kind": "synthetic",
|
| 6 |
+
"collector": "macos",
|
| 7 |
+
"collector_version": "0.1.0",
|
| 8 |
+
"synthetic_seed": 42000126,
|
| 9 |
+
"synthetic_profile": "hardened"
|
| 10 |
+
},
|
| 11 |
+
"audit": {
|
| 12 |
+
"mode": "READ-ONLY",
|
| 13 |
+
"started_at": "2026-10-05T19:36:00+05:00",
|
| 14 |
+
"execution_user": "root",
|
| 15 |
+
"is_admin": true
|
| 16 |
+
},
|
| 17 |
+
"host": {
|
| 18 |
+
"hostname": "LEGAL-MacBook-34",
|
| 19 |
+
"os_family": "macos",
|
| 20 |
+
"os_name": "macOS",
|
| 21 |
+
"os_version": "26.0.1",
|
| 22 |
+
"build": "25A362",
|
| 23 |
+
"architecture": "arm64",
|
| 24 |
+
"serial_number": "C02VWA6H79QN"
|
| 25 |
+
},
|
| 26 |
+
"baseline": {
|
| 27 |
+
"PASSWORD_MIN_LENGTH": "14",
|
| 28 |
+
"PASSWORD_HISTORY": "24",
|
| 29 |
+
"PASSWORD_MAX_AGE_DAYS": "365",
|
| 30 |
+
"PASSWORD_MIN_AGE_DAYS": "1",
|
| 31 |
+
"PASSWORD_COMPLEXITY": "1",
|
| 32 |
+
"LOCKOUT_THRESHOLD_MAX": "5",
|
| 33 |
+
"LOCKOUT_DURATION_MIN_MINUTES": "15",
|
| 34 |
+
"LOCKOUT_OBSERVATION_MIN_MINUTES": "15",
|
| 35 |
+
"SIGNATURE_MAX_AGE_DAYS": "3",
|
| 36 |
+
"MACOS_XPROTECT_MAX_AGE_DAYS": "30",
|
| 37 |
+
"FIREWALL_OUTBOUND": "allow",
|
| 38 |
+
"SCREEN_LOCK_MAX_SECONDS": "900",
|
| 39 |
+
"USB_READ": "allow",
|
| 40 |
+
"USB_WRITE": "deny",
|
| 41 |
+
"APPROVED_ADMINS": "itadmin",
|
| 42 |
+
"ALLOW_DISABLED_BUILTIN_ADMIN": "1",
|
| 43 |
+
"UPDATES_MAX_AGE_DAYS": "30",
|
| 44 |
+
"BACKUP_MAX_AGE_DAYS": "7",
|
| 45 |
+
"APPROVED_REMOTE_SERVICES": "",
|
| 46 |
+
"WINDOWS_MIN_BUILD": "22631",
|
| 47 |
+
"MACOS_MIN_MAJOR": "14",
|
| 48 |
+
"UBUNTU_MIN_VERSION": "22.04",
|
| 49 |
+
"DEBIAN_MIN_VERSION": "12",
|
| 50 |
+
"RHEL_MIN_VERSION": "9",
|
| 51 |
+
"PERSONAL_DATA_SCAN": "0",
|
| 52 |
+
"APPROVED_AI_TOOLS": ""
|
| 53 |
+
},
|
| 54 |
+
"results": [
|
| 55 |
+
{
|
| 56 |
+
"id": "password.min_length",
|
| 57 |
+
"section": "baseline",
|
| 58 |
+
"name": "Minimum password length",
|
| 59 |
+
"status": "Non-Compliant",
|
| 60 |
+
"actual": "not set",
|
| 61 |
+
"expected": ">= 14",
|
| 62 |
+
"detail": "pwpolicy global account policy"
|
| 63 |
+
},
|
| 64 |
+
{
|
| 65 |
+
"id": "password.history",
|
| 66 |
+
"section": "baseline",
|
| 67 |
+
"name": "Password history",
|
| 68 |
+
"status": "Compliant",
|
| 69 |
+
"actual": "24",
|
| 70 |
+
"expected": ">= 24"
|
| 71 |
+
},
|
| 72 |
+
{
|
| 73 |
+
"id": "password.expiration",
|
| 74 |
+
"section": "baseline",
|
| 75 |
+
"name": "Password expiration",
|
| 76 |
+
"status": "Compliant",
|
| 77 |
+
"actual": "90 days",
|
| 78 |
+
"expected": "1-365 days"
|
| 79 |
+
},
|
| 80 |
+
{
|
| 81 |
+
"id": "password.min_age",
|
| 82 |
+
"section": "baseline",
|
| 83 |
+
"name": "Minimum password age",
|
| 84 |
+
"status": "NotApplicable",
|
| 85 |
+
"actual": "no such setting",
|
| 86 |
+
"expected": ">= 1 days",
|
| 87 |
+
"detail": "macOS account policies have no minimum password age."
|
| 88 |
+
},
|
| 89 |
+
{
|
| 90 |
+
"id": "password.complexity",
|
| 91 |
+
"section": "baseline",
|
| 92 |
+
"name": "Password complexity",
|
| 93 |
+
"status": "Compliant",
|
| 94 |
+
"actual": "letters and numbers required",
|
| 95 |
+
"expected": "enforced"
|
| 96 |
+
},
|
| 97 |
+
{
|
| 98 |
+
"id": "lockout.threshold",
|
| 99 |
+
"section": "baseline",
|
| 100 |
+
"name": "Account lockout threshold",
|
| 101 |
+
"status": "Non-Compliant",
|
| 102 |
+
"actual": "not set",
|
| 103 |
+
"expected": "1-5"
|
| 104 |
+
},
|
| 105 |
+
{
|
| 106 |
+
"id": "lockout.duration",
|
| 107 |
+
"section": "baseline",
|
| 108 |
+
"name": "Account lockout duration",
|
| 109 |
+
"status": "Compliant",
|
| 110 |
+
"actual": "15 minutes",
|
| 111 |
+
"expected": ">= 15 minutes"
|
| 112 |
+
},
|
| 113 |
+
{
|
| 114 |
+
"id": "lockout.observation",
|
| 115 |
+
"section": "baseline",
|
| 116 |
+
"name": "Lockout observation period",
|
| 117 |
+
"status": "Compliant",
|
| 118 |
+
"actual": "15 minutes",
|
| 119 |
+
"expected": ">= 15 minutes",
|
| 120 |
+
"detail": "macOS resets the failed-attempt count when the lockout ends."
|
| 121 |
+
},
|
| 122 |
+
{
|
| 123 |
+
"id": "malware.antivirus",
|
| 124 |
+
"section": "baseline",
|
| 125 |
+
"name": "Antivirus",
|
| 126 |
+
"status": "Compliant",
|
| 127 |
+
"actual": "XProtect 5283",
|
| 128 |
+
"expected": "antivirus present"
|
| 129 |
+
},
|
| 130 |
+
{
|
| 131 |
+
"id": "malware.service",
|
| 132 |
+
"section": "baseline",
|
| 133 |
+
"name": "Apple malware protection service",
|
| 134 |
+
"status": "Compliant",
|
| 135 |
+
"actual": "XProtect scanning on; Gatekeeper on",
|
| 136 |
+
"expected": "XProtect and Gatekeeper on"
|
| 137 |
+
},
|
| 138 |
+
{
|
| 139 |
+
"id": "malware.signatures",
|
| 140 |
+
"section": "baseline",
|
| 141 |
+
"name": "Malware protection signatures",
|
| 142 |
+
"status": "Compliant",
|
| 143 |
+
"actual": "XProtect 5270, 12 days old",
|
| 144 |
+
"expected": "<= 30 days old"
|
| 145 |
+
},
|
| 146 |
+
{
|
| 147 |
+
"id": "malware.signature_updates",
|
| 148 |
+
"section": "baseline",
|
| 149 |
+
"name": "Malware security updates",
|
| 150 |
+
"status": "Compliant",
|
| 151 |
+
"actual": "security responses and system data files install automatically",
|
| 152 |
+
"expected": "automatic"
|
| 153 |
+
},
|
| 154 |
+
{
|
| 155 |
+
"id": "malware.on_access",
|
| 156 |
+
"section": "baseline",
|
| 157 |
+
"name": "On-access malware protection",
|
| 158 |
+
"status": "NotApplicable",
|
| 159 |
+
"actual": "XProtect only",
|
| 160 |
+
"expected": "on-access scanning",
|
| 161 |
+
"detail": "XProtect scans at launch and download; macOS has no built-in on-access scanning."
|
| 162 |
+
},
|
| 163 |
+
{
|
| 164 |
+
"id": "firewall.enabled",
|
| 165 |
+
"section": "baseline",
|
| 166 |
+
"name": "Firewall",
|
| 167 |
+
"status": "Compliant",
|
| 168 |
+
"actual": "on",
|
| 169 |
+
"expected": "on"
|
| 170 |
+
},
|
| 171 |
+
{
|
| 172 |
+
"id": "firewall.inbound",
|
| 173 |
+
"section": "baseline",
|
| 174 |
+
"name": "Firewall inbound policy",
|
| 175 |
+
"status": "Compliant",
|
| 176 |
+
"actual": "block (firewall on, stealth mode on)",
|
| 177 |
+
"expected": "block"
|
| 178 |
+
},
|
| 179 |
+
{
|
| 180 |
+
"id": "firewall.outbound",
|
| 181 |
+
"section": "baseline",
|
| 182 |
+
"name": "Firewall outbound policy",
|
| 183 |
+
"status": "Compliant",
|
| 184 |
+
"actual": "allow",
|
| 185 |
+
"expected": "allow",
|
| 186 |
+
"detail": "The macOS application firewall filters inbound connections only."
|
| 187 |
+
},
|
| 188 |
+
{
|
| 189 |
+
"id": "screen_lock.timeout",
|
| 190 |
+
"section": "baseline",
|
| 191 |
+
"name": "Screen lock timeout",
|
| 192 |
+
"status": "Compliant",
|
| 193 |
+
"actual": "420 seconds",
|
| 194 |
+
"expected": "<= 900 seconds",
|
| 195 |
+
"detail": "Idle 120s (screen saver/display sleep) + 300s password delay."
|
| 196 |
+
},
|
| 197 |
+
{
|
| 198 |
+
"id": "screen_lock.enabled",
|
| 199 |
+
"section": "baseline",
|
| 200 |
+
"name": "Screen lock",
|
| 201 |
+
"status": "Compliant",
|
| 202 |
+
"actual": "on",
|
| 203 |
+
"expected": "on"
|
| 204 |
+
},
|
| 205 |
+
{
|
| 206 |
+
"id": "screen_lock.password",
|
| 207 |
+
"section": "baseline",
|
| 208 |
+
"name": "Screen lock password requirement",
|
| 209 |
+
"status": "Compliant",
|
| 210 |
+
"actual": "password after 0 seconds",
|
| 211 |
+
"expected": "password within 5 seconds"
|
| 212 |
+
},
|
| 213 |
+
{
|
| 214 |
+
"id": "usb.read",
|
| 215 |
+
"section": "baseline",
|
| 216 |
+
"name": "USB storage read",
|
| 217 |
+
"status": "Non-Compliant",
|
| 218 |
+
"actual": "deny",
|
| 219 |
+
"expected": "allow"
|
| 220 |
+
},
|
| 221 |
+
{
|
| 222 |
+
"id": "usb.write",
|
| 223 |
+
"section": "baseline",
|
| 224 |
+
"name": "USB storage write",
|
| 225 |
+
"status": "Compliant",
|
| 226 |
+
"actual": "deny",
|
| 227 |
+
"expected": "deny"
|
| 228 |
+
},
|
| 229 |
+
{
|
| 230 |
+
"id": "admin.approved",
|
| 231 |
+
"section": "baseline",
|
| 232 |
+
"name": "Approved IT administrator",
|
| 233 |
+
"status": "Compliant",
|
| 234 |
+
"actual": "itadmin",
|
| 235 |
+
"expected": "itadmin"
|
| 236 |
+
},
|
| 237 |
+
{
|
| 238 |
+
"id": "admin.unauthorized",
|
| 239 |
+
"section": "baseline",
|
| 240 |
+
"name": "Unauthorized employee administrator accounts",
|
| 241 |
+
"status": "Compliant",
|
| 242 |
+
"actual": "none",
|
| 243 |
+
"expected": "none"
|
| 244 |
+
},
|
| 245 |
+
{
|
| 246 |
+
"id": "encryption.enabled",
|
| 247 |
+
"section": "baseline",
|
| 248 |
+
"name": "Encryption",
|
| 249 |
+
"status": "Compliant",
|
| 250 |
+
"actual": "FileVault on",
|
| 251 |
+
"expected": "FileVault on"
|
| 252 |
+
},
|
| 253 |
+
{
|
| 254 |
+
"id": "encryption.protection",
|
| 255 |
+
"section": "baseline",
|
| 256 |
+
"name": "Disk encryption protection",
|
| 257 |
+
"status": "Compliant",
|
| 258 |
+
"actual": "fully encrypted",
|
| 259 |
+
"expected": "fully encrypted"
|
| 260 |
+
},
|
| 261 |
+
{
|
| 262 |
+
"id": "encryption.recovery",
|
| 263 |
+
"section": "baseline",
|
| 264 |
+
"name": "Disk encryption recovery",
|
| 265 |
+
"status": "Compliant",
|
| 266 |
+
"actual": "recovery key present (personal: true, institutional: false)",
|
| 267 |
+
"expected": "recovery key present"
|
| 268 |
+
},
|
| 269 |
+
{
|
| 270 |
+
"id": "gdpr.auto_updates",
|
| 271 |
+
"section": "gdpr",
|
| 272 |
+
"name": "Automatic security updates",
|
| 273 |
+
"status": "Compliant",
|
| 274 |
+
"actual": "check, download and install automatic",
|
| 275 |
+
"expected": "automatic"
|
| 276 |
+
},
|
| 277 |
+
{
|
| 278 |
+
"id": "gdpr.updates_recent",
|
| 279 |
+
"section": "gdpr",
|
| 280 |
+
"name": "Security updates installed recently",
|
| 281 |
+
"status": "Compliant",
|
| 282 |
+
"actual": "5 days ago",
|
| 283 |
+
"expected": "<= 30 days ago",
|
| 284 |
+
"detail": "Last software update: 2026-09-30 06:07:00 +0000"
|
| 285 |
+
},
|
| 286 |
+
{
|
| 287 |
+
"id": "gdpr.os_supported",
|
| 288 |
+
"section": "gdpr",
|
| 289 |
+
"name": "Supported operating system version",
|
| 290 |
+
"status": "Compliant",
|
| 291 |
+
"actual": "macOS 26.0.1",
|
| 292 |
+
"expected": "macOS 14 or later"
|
| 293 |
+
},
|
| 294 |
+
{
|
| 295 |
+
"id": "gdpr.security_logging",
|
| 296 |
+
"section": "gdpr",
|
| 297 |
+
"name": "Security event logging",
|
| 298 |
+
"status": "Compliant",
|
| 299 |
+
"actual": "unified logging active",
|
| 300 |
+
"expected": "security events logged",
|
| 301 |
+
"detail": "macOS unified log records authentication events; retention is managed by the OS."
|
| 302 |
+
},
|
| 303 |
+
{
|
| 304 |
+
"id": "gdpr.time_sync",
|
| 305 |
+
"section": "gdpr",
|
| 306 |
+
"name": "Time synchronisation",
|
| 307 |
+
"status": "Compliant",
|
| 308 |
+
"actual": "network time on",
|
| 309 |
+
"expected": "network time on"
|
| 310 |
+
},
|
| 311 |
+
{
|
| 312 |
+
"id": "gdpr.backup_configured",
|
| 313 |
+
"section": "gdpr",
|
| 314 |
+
"name": "Backup configured",
|
| 315 |
+
"status": "Compliant",
|
| 316 |
+
"actual": "Time Machine",
|
| 317 |
+
"expected": "backup configured"
|
| 318 |
+
},
|
| 319 |
+
{
|
| 320 |
+
"id": "gdpr.backup_recent",
|
| 321 |
+
"section": "gdpr",
|
| 322 |
+
"name": "Recent backup",
|
| 323 |
+
"status": "Compliant",
|
| 324 |
+
"actual": "7 days ago",
|
| 325 |
+
"expected": "<= 7 days ago"
|
| 326 |
+
},
|
| 327 |
+
{
|
| 328 |
+
"id": "gdpr.removable_encryption",
|
| 329 |
+
"section": "gdpr",
|
| 330 |
+
"name": "Removable media encryption",
|
| 331 |
+
"status": "Compliant",
|
| 332 |
+
"actual": "writing to external drives blocked",
|
| 333 |
+
"expected": "no unencrypted writes"
|
| 334 |
+
},
|
| 335 |
+
{
|
| 336 |
+
"id": "gdpr.guest_account",
|
| 337 |
+
"section": "gdpr",
|
| 338 |
+
"name": "Guest account disabled",
|
| 339 |
+
"status": "Compliant",
|
| 340 |
+
"actual": "guest login off",
|
| 341 |
+
"expected": "guest login off"
|
| 342 |
+
},
|
| 343 |
+
{
|
| 344 |
+
"id": "gdpr.remote_access",
|
| 345 |
+
"section": "gdpr",
|
| 346 |
+
"name": "Remote access services restricted",
|
| 347 |
+
"status": "Compliant",
|
| 348 |
+
"actual": "none enabled",
|
| 349 |
+
"expected": "only: none"
|
| 350 |
+
},
|
| 351 |
+
{
|
| 352 |
+
"id": "gdpr.telemetry",
|
| 353 |
+
"section": "gdpr",
|
| 354 |
+
"name": "Diagnostic data minimised",
|
| 355 |
+
"status": "Non-Compliant",
|
| 356 |
+
"actual": "share Mac analytics=1, share with app developers=0",
|
| 357 |
+
"expected": "analytics sharing off"
|
| 358 |
+
},
|
| 359 |
+
{
|
| 360 |
+
"id": "gdpr.personal_data_discovery",
|
| 361 |
+
"section": "gdpr",
|
| 362 |
+
"name": "Personal data discovery",
|
| 363 |
+
"status": "NotApplicable",
|
| 364 |
+
"actual": "not run",
|
| 365 |
+
"expected": "opt-in scan",
|
| 366 |
+
"detail": "Opt-in scan (PERSONAL_DATA_SCAN=0) is planned for a later stage."
|
| 367 |
+
},
|
| 368 |
+
{
|
| 369 |
+
"id": "aiact.inventory",
|
| 370 |
+
"section": "aiact",
|
| 371 |
+
"name": "AI software inventory",
|
| 372 |
+
"status": "Compliant",
|
| 373 |
+
"actual": "no AI tools found",
|
| 374 |
+
"expected": "inventory taken"
|
| 375 |
+
},
|
| 376 |
+
{
|
| 377 |
+
"id": "aiact.unapproved",
|
| 378 |
+
"section": "aiact",
|
| 379 |
+
"name": "Unapproved AI tools (shadow AI)",
|
| 380 |
+
"status": "Compliant",
|
| 381 |
+
"actual": "none",
|
| 382 |
+
"expected": "only: none"
|
| 383 |
+
},
|
| 384 |
+
{
|
| 385 |
+
"id": "aiact.risk_classification",
|
| 386 |
+
"section": "aiact",
|
| 387 |
+
"name": "AI system risk classification",
|
| 388 |
+
"status": "NotApplicable",
|
| 389 |
+
"actual": "no AI tools found",
|
| 390 |
+
"expected": "every AI tool classified"
|
| 391 |
+
},
|
| 392 |
+
{
|
| 393 |
+
"id": "aiact.os_ai_features",
|
| 394 |
+
"section": "aiact",
|
| 395 |
+
"name": "Apple Intelligence external integrations restricted",
|
| 396 |
+
"status": "Non-Compliant",
|
| 397 |
+
"actual": "not restricted",
|
| 398 |
+
"expected": "restricted by profile",
|
| 399 |
+
"detail": "No profile sets allowExternalIntelligenceIntegrations to false."
|
| 400 |
+
},
|
| 401 |
+
{
|
| 402 |
+
"id": "aiact.log_retention",
|
| 403 |
+
"section": "aiact",
|
| 404 |
+
"name": "AI usage log retention",
|
| 405 |
+
"status": "NotApplicable",
|
| 406 |
+
"actual": "no AI tools found",
|
| 407 |
+
"expected": ">= 6 months for high-risk AI"
|
| 408 |
+
},
|
| 409 |
+
{
|
| 410 |
+
"id": "org.ropa",
|
| 411 |
+
"section": "org",
|
| 412 |
+
"name": "Record of processing activities",
|
| 413 |
+
"status": "Compliant",
|
| 414 |
+
"actual": "yes",
|
| 415 |
+
"expected": "yes"
|
| 416 |
+
},
|
| 417 |
+
{
|
| 418 |
+
"id": "org.dpia",
|
| 419 |
+
"section": "org",
|
| 420 |
+
"name": "Data protection impact assessment",
|
| 421 |
+
"status": "Compliant",
|
| 422 |
+
"actual": "yes",
|
| 423 |
+
"expected": "yes"
|
| 424 |
+
},
|
| 425 |
+
{
|
| 426 |
+
"id": "org.breach_procedure",
|
| 427 |
+
"section": "org",
|
| 428 |
+
"name": "Breach response procedure",
|
| 429 |
+
"status": "Compliant",
|
| 430 |
+
"actual": "yes",
|
| 431 |
+
"expected": "yes"
|
| 432 |
+
},
|
| 433 |
+
{
|
| 434 |
+
"id": "org.processor_agreements",
|
| 435 |
+
"section": "org",
|
| 436 |
+
"name": "Data processing agreements",
|
| 437 |
+
"status": "Pending",
|
| 438 |
+
"actual": "not answered",
|
| 439 |
+
"expected": "yes",
|
| 440 |
+
"detail": "Answer ORG_PROCESSOR_AGREEMENTS in organisational.conf."
|
| 441 |
+
},
|
| 442 |
+
{
|
| 443 |
+
"id": "org.ai_register",
|
| 444 |
+
"section": "org",
|
| 445 |
+
"name": "AI register",
|
| 446 |
+
"status": "Compliant",
|
| 447 |
+
"actual": "yes",
|
| 448 |
+
"expected": "yes"
|
| 449 |
+
},
|
| 450 |
+
{
|
| 451 |
+
"id": "org.ai_literacy",
|
| 452 |
+
"section": "org",
|
| 453 |
+
"name": "AI literacy training",
|
| 454 |
+
"status": "Compliant",
|
| 455 |
+
"actual": "yes",
|
| 456 |
+
"expected": "yes"
|
| 457 |
+
},
|
| 458 |
+
{
|
| 459 |
+
"id": "org.human_oversight",
|
| 460 |
+
"section": "org",
|
| 461 |
+
"name": "Human oversight for high-risk AI",
|
| 462 |
+
"status": "Compliant",
|
| 463 |
+
"actual": "yes",
|
| 464 |
+
"expected": "yes"
|
| 465 |
+
},
|
| 466 |
+
{
|
| 467 |
+
"id": "org.ai_transparency",
|
| 468 |
+
"section": "org",
|
| 469 |
+
"name": "AI transparency to users",
|
| 470 |
+
"status": "Compliant",
|
| 471 |
+
"actual": "yes",
|
| 472 |
+
"expected": "yes"
|
| 473 |
+
}
|
| 474 |
+
],
|
| 475 |
+
"summary": {
|
| 476 |
+
"Compliant": 40,
|
| 477 |
+
"Non-Compliant": 5,
|
| 478 |
+
"NotApplicable": 5,
|
| 479 |
+
"Pending": 1,
|
| 480 |
+
"Error": 0
|
| 481 |
+
}
|
| 482 |
+
}
|
synthetic/samples/synthetic-windows-42000126.json
ADDED
|
@@ -0,0 +1,538 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"schema_version": "1.0",
|
| 3 |
+
"catalog_version": "0.1.0",
|
| 4 |
+
"source": {
|
| 5 |
+
"kind": "synthetic",
|
| 6 |
+
"collector": "windows",
|
| 7 |
+
"collector_version": "0.1.0",
|
| 8 |
+
"synthetic_seed": 42000126,
|
| 9 |
+
"synthetic_profile": "hardened"
|
| 10 |
+
},
|
| 11 |
+
"audit": {
|
| 12 |
+
"mode": "READ-ONLY",
|
| 13 |
+
"started_at": "2026-10-05T19:36:00+05:00",
|
| 14 |
+
"execution_user": "NT AUTHORITY\\SYSTEM",
|
| 15 |
+
"is_admin": true
|
| 16 |
+
},
|
| 17 |
+
"host": {
|
| 18 |
+
"hostname": "DESKTOP-TYVWA6H",
|
| 19 |
+
"os_family": "windows",
|
| 20 |
+
"os_name": "Microsoft Windows 10 Pro",
|
| 21 |
+
"os_version": "10.0.19045",
|
| 22 |
+
"build": "19045",
|
| 23 |
+
"architecture": "AMD64",
|
| 24 |
+
"serial_number": "QN9DCV4"
|
| 25 |
+
},
|
| 26 |
+
"baseline": {
|
| 27 |
+
"PASSWORD_MIN_LENGTH": "14",
|
| 28 |
+
"PASSWORD_HISTORY": "24",
|
| 29 |
+
"PASSWORD_MAX_AGE_DAYS": "365",
|
| 30 |
+
"PASSWORD_MIN_AGE_DAYS": "1",
|
| 31 |
+
"PASSWORD_COMPLEXITY": "1",
|
| 32 |
+
"LOCKOUT_THRESHOLD_MAX": "5",
|
| 33 |
+
"LOCKOUT_DURATION_MIN_MINUTES": "15",
|
| 34 |
+
"LOCKOUT_OBSERVATION_MIN_MINUTES": "15",
|
| 35 |
+
"SIGNATURE_MAX_AGE_DAYS": "3",
|
| 36 |
+
"MACOS_XPROTECT_MAX_AGE_DAYS": "30",
|
| 37 |
+
"FIREWALL_OUTBOUND": "allow",
|
| 38 |
+
"SCREEN_LOCK_MAX_SECONDS": "900",
|
| 39 |
+
"USB_READ": "allow",
|
| 40 |
+
"USB_WRITE": "deny",
|
| 41 |
+
"APPROVED_ADMINS": "itadmin",
|
| 42 |
+
"ALLOW_DISABLED_BUILTIN_ADMIN": "1",
|
| 43 |
+
"UPDATES_MAX_AGE_DAYS": "30",
|
| 44 |
+
"BACKUP_MAX_AGE_DAYS": "7",
|
| 45 |
+
"APPROVED_REMOTE_SERVICES": "",
|
| 46 |
+
"WINDOWS_MIN_BUILD": "22631",
|
| 47 |
+
"MACOS_MIN_MAJOR": "14",
|
| 48 |
+
"UBUNTU_MIN_VERSION": "22.04",
|
| 49 |
+
"DEBIAN_MIN_VERSION": "12",
|
| 50 |
+
"RHEL_MIN_VERSION": "9",
|
| 51 |
+
"PERSONAL_DATA_SCAN": "0",
|
| 52 |
+
"APPROVED_AI_TOOLS": ""
|
| 53 |
+
},
|
| 54 |
+
"results": [
|
| 55 |
+
{
|
| 56 |
+
"id": "password.min_length",
|
| 57 |
+
"section": "baseline",
|
| 58 |
+
"name": "Minimum password length",
|
| 59 |
+
"status": "Compliant",
|
| 60 |
+
"actual": "16",
|
| 61 |
+
"expected": ">= 14"
|
| 62 |
+
},
|
| 63 |
+
{
|
| 64 |
+
"id": "password.history",
|
| 65 |
+
"section": "baseline",
|
| 66 |
+
"name": "Password history",
|
| 67 |
+
"status": "Compliant",
|
| 68 |
+
"actual": "24",
|
| 69 |
+
"expected": ">= 24"
|
| 70 |
+
},
|
| 71 |
+
{
|
| 72 |
+
"id": "password.expiration",
|
| 73 |
+
"section": "baseline",
|
| 74 |
+
"name": "Password expiration",
|
| 75 |
+
"status": "Compliant",
|
| 76 |
+
"actual": "90 days",
|
| 77 |
+
"expected": "1-365 days"
|
| 78 |
+
},
|
| 79 |
+
{
|
| 80 |
+
"id": "password.min_age",
|
| 81 |
+
"section": "baseline",
|
| 82 |
+
"name": "Minimum password age",
|
| 83 |
+
"status": "Compliant",
|
| 84 |
+
"actual": "1 days",
|
| 85 |
+
"expected": ">= 1 days"
|
| 86 |
+
},
|
| 87 |
+
{
|
| 88 |
+
"id": "password.complexity",
|
| 89 |
+
"section": "baseline",
|
| 90 |
+
"name": "Password complexity",
|
| 91 |
+
"status": "Compliant",
|
| 92 |
+
"actual": "enabled",
|
| 93 |
+
"expected": "enabled"
|
| 94 |
+
},
|
| 95 |
+
{
|
| 96 |
+
"id": "lockout.threshold",
|
| 97 |
+
"section": "baseline",
|
| 98 |
+
"name": "Account lockout threshold",
|
| 99 |
+
"status": "Compliant",
|
| 100 |
+
"actual": "3",
|
| 101 |
+
"expected": "1-5"
|
| 102 |
+
},
|
| 103 |
+
{
|
| 104 |
+
"id": "lockout.duration",
|
| 105 |
+
"section": "baseline",
|
| 106 |
+
"name": "Account lockout duration",
|
| 107 |
+
"status": "Compliant",
|
| 108 |
+
"actual": "15 minutes",
|
| 109 |
+
"expected": ">= 15 minutes"
|
| 110 |
+
},
|
| 111 |
+
{
|
| 112 |
+
"id": "lockout.observation",
|
| 113 |
+
"section": "baseline",
|
| 114 |
+
"name": "Lockout observation period",
|
| 115 |
+
"status": "Compliant",
|
| 116 |
+
"actual": "30 minutes",
|
| 117 |
+
"expected": ">= 15 minutes"
|
| 118 |
+
},
|
| 119 |
+
{
|
| 120 |
+
"id": "malware.antivirus",
|
| 121 |
+
"section": "baseline",
|
| 122 |
+
"name": "Antivirus",
|
| 123 |
+
"status": "Compliant",
|
| 124 |
+
"actual": "Windows Defender (on, up to date)",
|
| 125 |
+
"expected": "enabled and up to date"
|
| 126 |
+
},
|
| 127 |
+
{
|
| 128 |
+
"id": "malware.service",
|
| 129 |
+
"section": "baseline",
|
| 130 |
+
"name": "Microsoft Defender Antimalware",
|
| 131 |
+
"status": "Compliant",
|
| 132 |
+
"actual": "service True, antivirus True",
|
| 133 |
+
"expected": "running"
|
| 134 |
+
},
|
| 135 |
+
{
|
| 136 |
+
"id": "malware.realtime",
|
| 137 |
+
"section": "baseline",
|
| 138 |
+
"name": "Real-time protection",
|
| 139 |
+
"status": "Compliant",
|
| 140 |
+
"actual": "on",
|
| 141 |
+
"expected": "on"
|
| 142 |
+
},
|
| 143 |
+
{
|
| 144 |
+
"id": "malware.on_access",
|
| 145 |
+
"section": "baseline",
|
| 146 |
+
"name": "On-access protection",
|
| 147 |
+
"status": "Compliant",
|
| 148 |
+
"actual": "on",
|
| 149 |
+
"expected": "on"
|
| 150 |
+
},
|
| 151 |
+
{
|
| 152 |
+
"id": "malware.tamper",
|
| 153 |
+
"section": "baseline",
|
| 154 |
+
"name": "Tamper protection",
|
| 155 |
+
"status": "Compliant",
|
| 156 |
+
"actual": "on",
|
| 157 |
+
"expected": "on"
|
| 158 |
+
},
|
| 159 |
+
{
|
| 160 |
+
"id": "malware.signatures",
|
| 161 |
+
"section": "baseline",
|
| 162 |
+
"name": "Security intelligence",
|
| 163 |
+
"status": "Compliant",
|
| 164 |
+
"actual": "3 days old (version 1.439.890.0)",
|
| 165 |
+
"expected": "<= 3 days old"
|
| 166 |
+
},
|
| 167 |
+
{
|
| 168 |
+
"id": "firewall.domain.enabled",
|
| 169 |
+
"section": "baseline",
|
| 170 |
+
"name": "Domain firewall",
|
| 171 |
+
"status": "Compliant",
|
| 172 |
+
"actual": "on",
|
| 173 |
+
"expected": "on"
|
| 174 |
+
},
|
| 175 |
+
{
|
| 176 |
+
"id": "firewall.domain.inbound",
|
| 177 |
+
"section": "baseline",
|
| 178 |
+
"name": "Domain inbound",
|
| 179 |
+
"status": "Compliant",
|
| 180 |
+
"actual": "block",
|
| 181 |
+
"expected": "block",
|
| 182 |
+
"detail": "Not configured: Windows default (Block) applies."
|
| 183 |
+
},
|
| 184 |
+
{
|
| 185 |
+
"id": "firewall.domain.outbound",
|
| 186 |
+
"section": "baseline",
|
| 187 |
+
"name": "Domain outbound",
|
| 188 |
+
"status": "Compliant",
|
| 189 |
+
"actual": "allow",
|
| 190 |
+
"expected": "allow"
|
| 191 |
+
},
|
| 192 |
+
{
|
| 193 |
+
"id": "firewall.private.enabled",
|
| 194 |
+
"section": "baseline",
|
| 195 |
+
"name": "Private firewall",
|
| 196 |
+
"status": "Compliant",
|
| 197 |
+
"actual": "on",
|
| 198 |
+
"expected": "on"
|
| 199 |
+
},
|
| 200 |
+
{
|
| 201 |
+
"id": "firewall.private.inbound",
|
| 202 |
+
"section": "baseline",
|
| 203 |
+
"name": "Private inbound",
|
| 204 |
+
"status": "Compliant",
|
| 205 |
+
"actual": "block",
|
| 206 |
+
"expected": "block",
|
| 207 |
+
"detail": "Not configured: Windows default (Block) applies."
|
| 208 |
+
},
|
| 209 |
+
{
|
| 210 |
+
"id": "firewall.private.outbound",
|
| 211 |
+
"section": "baseline",
|
| 212 |
+
"name": "Private outbound",
|
| 213 |
+
"status": "Compliant",
|
| 214 |
+
"actual": "allow",
|
| 215 |
+
"expected": "allow"
|
| 216 |
+
},
|
| 217 |
+
{
|
| 218 |
+
"id": "firewall.public.enabled",
|
| 219 |
+
"section": "baseline",
|
| 220 |
+
"name": "Public firewall",
|
| 221 |
+
"status": "Compliant",
|
| 222 |
+
"actual": "on",
|
| 223 |
+
"expected": "on"
|
| 224 |
+
},
|
| 225 |
+
{
|
| 226 |
+
"id": "firewall.public.inbound",
|
| 227 |
+
"section": "baseline",
|
| 228 |
+
"name": "Public inbound",
|
| 229 |
+
"status": "Compliant",
|
| 230 |
+
"actual": "block",
|
| 231 |
+
"expected": "block",
|
| 232 |
+
"detail": "Not configured: Windows default (Block) applies."
|
| 233 |
+
},
|
| 234 |
+
{
|
| 235 |
+
"id": "firewall.public.outbound",
|
| 236 |
+
"section": "baseline",
|
| 237 |
+
"name": "Public outbound",
|
| 238 |
+
"status": "Compliant",
|
| 239 |
+
"actual": "allow",
|
| 240 |
+
"expected": "allow",
|
| 241 |
+
"detail": "Not configured: Windows default (Allow) applies."
|
| 242 |
+
},
|
| 243 |
+
{
|
| 244 |
+
"id": "screen_lock.timeout",
|
| 245 |
+
"section": "baseline",
|
| 246 |
+
"name": "Screen lock timeout",
|
| 247 |
+
"status": "Compliant",
|
| 248 |
+
"actual": "300 seconds (machine inactivity limit)",
|
| 249 |
+
"expected": "<= 900 seconds"
|
| 250 |
+
},
|
| 251 |
+
{
|
| 252 |
+
"id": "screen_lock.password",
|
| 253 |
+
"section": "baseline",
|
| 254 |
+
"name": "Screen lock password requirement",
|
| 255 |
+
"status": "Compliant",
|
| 256 |
+
"actual": "screen saver requires sign-in for all signed-in users",
|
| 257 |
+
"expected": "sign-in required to unlock"
|
| 258 |
+
},
|
| 259 |
+
{
|
| 260 |
+
"id": "usb.read",
|
| 261 |
+
"section": "baseline",
|
| 262 |
+
"name": "USB storage read",
|
| 263 |
+
"status": "Compliant",
|
| 264 |
+
"actual": "allow",
|
| 265 |
+
"expected": "allow"
|
| 266 |
+
},
|
| 267 |
+
{
|
| 268 |
+
"id": "usb.write",
|
| 269 |
+
"section": "baseline",
|
| 270 |
+
"name": "USB storage write",
|
| 271 |
+
"status": "Non-Compliant",
|
| 272 |
+
"actual": "allow",
|
| 273 |
+
"expected": "deny"
|
| 274 |
+
},
|
| 275 |
+
{
|
| 276 |
+
"id": "admin.approved",
|
| 277 |
+
"section": "baseline",
|
| 278 |
+
"name": "Approved IT administrator",
|
| 279 |
+
"status": "Compliant",
|
| 280 |
+
"actual": "FABRIKAM\\itadmin",
|
| 281 |
+
"expected": "itadmin"
|
| 282 |
+
},
|
| 283 |
+
{
|
| 284 |
+
"id": "admin.unauthorized",
|
| 285 |
+
"section": "baseline",
|
| 286 |
+
"name": "Unauthorized employee local administrator accounts",
|
| 287 |
+
"status": "Compliant",
|
| 288 |
+
"actual": "none",
|
| 289 |
+
"expected": "none"
|
| 290 |
+
},
|
| 291 |
+
{
|
| 292 |
+
"id": "encryption.feature",
|
| 293 |
+
"section": "baseline",
|
| 294 |
+
"name": "BitLocker",
|
| 295 |
+
"status": "Compliant",
|
| 296 |
+
"actual": "on",
|
| 297 |
+
"expected": "on"
|
| 298 |
+
},
|
| 299 |
+
{
|
| 300 |
+
"id": "encryption.enabled",
|
| 301 |
+
"section": "baseline",
|
| 302 |
+
"name": "Encryption",
|
| 303 |
+
"status": "Compliant",
|
| 304 |
+
"actual": "FullyEncrypted (100%)",
|
| 305 |
+
"expected": "fully encrypted"
|
| 306 |
+
},
|
| 307 |
+
{
|
| 308 |
+
"id": "encryption.protection",
|
| 309 |
+
"section": "baseline",
|
| 310 |
+
"name": "BitLocker protection",
|
| 311 |
+
"status": "Compliant",
|
| 312 |
+
"actual": "protection On, XtsAes128",
|
| 313 |
+
"expected": "protection on, AES"
|
| 314 |
+
},
|
| 315 |
+
{
|
| 316 |
+
"id": "encryption.recovery",
|
| 317 |
+
"section": "baseline",
|
| 318 |
+
"name": "BitLocker recovery",
|
| 319 |
+
"status": "Compliant",
|
| 320 |
+
"actual": "key protectors: Tpm, RecoveryPassword",
|
| 321 |
+
"expected": "recovery password present"
|
| 322 |
+
},
|
| 323 |
+
{
|
| 324 |
+
"id": "gdpr.auto_updates",
|
| 325 |
+
"section": "gdpr",
|
| 326 |
+
"name": "Automatic security updates",
|
| 327 |
+
"status": "Compliant",
|
| 328 |
+
"actual": "AUOptions=4",
|
| 329 |
+
"expected": "automatic"
|
| 330 |
+
},
|
| 331 |
+
{
|
| 332 |
+
"id": "gdpr.updates_recent",
|
| 333 |
+
"section": "gdpr",
|
| 334 |
+
"name": "Security updates installed recently",
|
| 335 |
+
"status": "Non-Compliant",
|
| 336 |
+
"actual": "63 days ago",
|
| 337 |
+
"expected": "<= 30 days ago",
|
| 338 |
+
"detail": "Last update: 2026-05 Cumulative Update for Windows 11 (2026-08-03)"
|
| 339 |
+
},
|
| 340 |
+
{
|
| 341 |
+
"id": "gdpr.os_supported",
|
| 342 |
+
"section": "gdpr",
|
| 343 |
+
"name": "Supported operating system version",
|
| 344 |
+
"status": "Non-Compliant",
|
| 345 |
+
"actual": "Microsoft Windows 10 Pro build 19045",
|
| 346 |
+
"expected": "build 22631 or later"
|
| 347 |
+
},
|
| 348 |
+
{
|
| 349 |
+
"id": "gdpr.security_logging",
|
| 350 |
+
"section": "gdpr",
|
| 351 |
+
"name": "Security event logging",
|
| 352 |
+
"status": "Compliant",
|
| 353 |
+
"actual": "logon: success+failure; account management: success+failure",
|
| 354 |
+
"expected": "logon success+failure and account changes audited"
|
| 355 |
+
},
|
| 356 |
+
{
|
| 357 |
+
"id": "gdpr.time_sync",
|
| 358 |
+
"section": "gdpr",
|
| 359 |
+
"name": "Time synchronisation",
|
| 360 |
+
"status": "Compliant",
|
| 361 |
+
"actual": "type NTP, service Running/Manual",
|
| 362 |
+
"expected": "NTP or domain time"
|
| 363 |
+
},
|
| 364 |
+
{
|
| 365 |
+
"id": "gdpr.backup_configured",
|
| 366 |
+
"section": "gdpr",
|
| 367 |
+
"name": "Backup configured",
|
| 368 |
+
"status": "Compliant",
|
| 369 |
+
"actual": "OneDrive folder backup (policy)",
|
| 370 |
+
"expected": "backup configured"
|
| 371 |
+
},
|
| 372 |
+
{
|
| 373 |
+
"id": "gdpr.backup_recent",
|
| 374 |
+
"section": "gdpr",
|
| 375 |
+
"name": "Recent backup",
|
| 376 |
+
"status": "Pending",
|
| 377 |
+
"actual": "date not readable",
|
| 378 |
+
"expected": "<= 7 days ago",
|
| 379 |
+
"detail": "Confirm the last backup date in: OneDrive folder backup (policy)."
|
| 380 |
+
},
|
| 381 |
+
{
|
| 382 |
+
"id": "gdpr.removable_encryption",
|
| 383 |
+
"section": "gdpr",
|
| 384 |
+
"name": "Removable media encryption",
|
| 385 |
+
"status": "Compliant",
|
| 386 |
+
"actual": "BitLocker To Go required before writing",
|
| 387 |
+
"expected": "no unencrypted writes"
|
| 388 |
+
},
|
| 389 |
+
{
|
| 390 |
+
"id": "gdpr.guest_account",
|
| 391 |
+
"section": "gdpr",
|
| 392 |
+
"name": "Guest account disabled",
|
| 393 |
+
"status": "Compliant",
|
| 394 |
+
"actual": "disabled",
|
| 395 |
+
"expected": "disabled"
|
| 396 |
+
},
|
| 397 |
+
{
|
| 398 |
+
"id": "gdpr.remote_access",
|
| 399 |
+
"section": "gdpr",
|
| 400 |
+
"name": "Remote access services restricted",
|
| 401 |
+
"status": "Non-Compliant",
|
| 402 |
+
"actual": "ssh",
|
| 403 |
+
"expected": "only: none"
|
| 404 |
+
},
|
| 405 |
+
{
|
| 406 |
+
"id": "gdpr.telemetry",
|
| 407 |
+
"section": "gdpr",
|
| 408 |
+
"name": "Diagnostic data minimised",
|
| 409 |
+
"status": "Compliant",
|
| 410 |
+
"actual": "AllowTelemetry=0 (policy)",
|
| 411 |
+
"expected": "required diagnostic data only (0-1)"
|
| 412 |
+
},
|
| 413 |
+
{
|
| 414 |
+
"id": "gdpr.personal_data_discovery",
|
| 415 |
+
"section": "gdpr",
|
| 416 |
+
"name": "Personal data discovery",
|
| 417 |
+
"status": "NotApplicable",
|
| 418 |
+
"actual": "not run",
|
| 419 |
+
"expected": "opt-in scan",
|
| 420 |
+
"detail": "Opt-in scan (PERSONAL_DATA_SCAN=0) is planned for a later stage."
|
| 421 |
+
},
|
| 422 |
+
{
|
| 423 |
+
"id": "aiact.inventory",
|
| 424 |
+
"section": "aiact",
|
| 425 |
+
"name": "AI software inventory",
|
| 426 |
+
"status": "Compliant",
|
| 427 |
+
"actual": "Ollama",
|
| 428 |
+
"expected": "inventory taken",
|
| 429 |
+
"detail": "Local models: C:\\Users\\jdoe\\.ollama\\models (2 GB)."
|
| 430 |
+
},
|
| 431 |
+
{
|
| 432 |
+
"id": "aiact.unapproved",
|
| 433 |
+
"section": "aiact",
|
| 434 |
+
"name": "Unapproved AI tools (shadow AI)",
|
| 435 |
+
"status": "Non-Compliant",
|
| 436 |
+
"actual": "Ollama",
|
| 437 |
+
"expected": "only: none"
|
| 438 |
+
},
|
| 439 |
+
{
|
| 440 |
+
"id": "aiact.risk_classification",
|
| 441 |
+
"section": "aiact",
|
| 442 |
+
"name": "AI system risk classification",
|
| 443 |
+
"status": "Pending",
|
| 444 |
+
"actual": "Ollama",
|
| 445 |
+
"expected": "every AI tool classified",
|
| 446 |
+
"detail": "Classified by the auditor model (Caveat classifier) in a later step."
|
| 447 |
+
},
|
| 448 |
+
{
|
| 449 |
+
"id": "aiact.os_ai_features",
|
| 450 |
+
"section": "aiact",
|
| 451 |
+
"name": "Windows Recall / Copilot data analysis disabled",
|
| 452 |
+
"status": "Compliant",
|
| 453 |
+
"actual": "Recall disabled by policy",
|
| 454 |
+
"expected": "disabled by policy",
|
| 455 |
+
"detail": "DisableAIDataAnalysis=1; AllowRecallEnablement=0; TurnOffWindowsCopilot="
|
| 456 |
+
},
|
| 457 |
+
{
|
| 458 |
+
"id": "aiact.log_retention",
|
| 459 |
+
"section": "aiact",
|
| 460 |
+
"name": "AI usage log retention",
|
| 461 |
+
"status": "Pending",
|
| 462 |
+
"actual": "depends on classification",
|
| 463 |
+
"expected": ">= 6 months for high-risk AI",
|
| 464 |
+
"detail": "Required only if a tool is classified high-risk."
|
| 465 |
+
},
|
| 466 |
+
{
|
| 467 |
+
"id": "org.ropa",
|
| 468 |
+
"section": "org",
|
| 469 |
+
"name": "Record of processing activities",
|
| 470 |
+
"status": "Non-Compliant",
|
| 471 |
+
"actual": "partial",
|
| 472 |
+
"expected": "yes"
|
| 473 |
+
},
|
| 474 |
+
{
|
| 475 |
+
"id": "org.dpia",
|
| 476 |
+
"section": "org",
|
| 477 |
+
"name": "Data protection impact assessment",
|
| 478 |
+
"status": "Compliant",
|
| 479 |
+
"actual": "yes",
|
| 480 |
+
"expected": "yes"
|
| 481 |
+
},
|
| 482 |
+
{
|
| 483 |
+
"id": "org.breach_procedure",
|
| 484 |
+
"section": "org",
|
| 485 |
+
"name": "Breach response procedure",
|
| 486 |
+
"status": "Compliant",
|
| 487 |
+
"actual": "yes",
|
| 488 |
+
"expected": "yes"
|
| 489 |
+
},
|
| 490 |
+
{
|
| 491 |
+
"id": "org.processor_agreements",
|
| 492 |
+
"section": "org",
|
| 493 |
+
"name": "Data processing agreements",
|
| 494 |
+
"status": "Compliant",
|
| 495 |
+
"actual": "yes",
|
| 496 |
+
"expected": "yes"
|
| 497 |
+
},
|
| 498 |
+
{
|
| 499 |
+
"id": "org.ai_register",
|
| 500 |
+
"section": "org",
|
| 501 |
+
"name": "AI register",
|
| 502 |
+
"status": "Compliant",
|
| 503 |
+
"actual": "yes",
|
| 504 |
+
"expected": "yes"
|
| 505 |
+
},
|
| 506 |
+
{
|
| 507 |
+
"id": "org.ai_literacy",
|
| 508 |
+
"section": "org",
|
| 509 |
+
"name": "AI literacy training",
|
| 510 |
+
"status": "Compliant",
|
| 511 |
+
"actual": "yes",
|
| 512 |
+
"expected": "yes"
|
| 513 |
+
},
|
| 514 |
+
{
|
| 515 |
+
"id": "org.human_oversight",
|
| 516 |
+
"section": "org",
|
| 517 |
+
"name": "Human oversight for high-risk AI",
|
| 518 |
+
"status": "Non-Compliant",
|
| 519 |
+
"actual": "partial",
|
| 520 |
+
"expected": "yes"
|
| 521 |
+
},
|
| 522 |
+
{
|
| 523 |
+
"id": "org.ai_transparency",
|
| 524 |
+
"section": "org",
|
| 525 |
+
"name": "AI transparency to users",
|
| 526 |
+
"status": "Compliant",
|
| 527 |
+
"actual": "yes",
|
| 528 |
+
"expected": "yes"
|
| 529 |
+
}
|
| 530 |
+
],
|
| 531 |
+
"summary": {
|
| 532 |
+
"Compliant": 47,
|
| 533 |
+
"Non-Compliant": 7,
|
| 534 |
+
"NotApplicable": 1,
|
| 535 |
+
"Pending": 3,
|
| 536 |
+
"Error": 0
|
| 537 |
+
}
|
| 538 |
+
}
|
tools/render_report.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Render audit evidence (JSON) as the text report printed by the collectors.
|
| 3 |
+
|
| 4 |
+
Usage:
|
| 5 |
+
python3 tools/render_report.py audit-macos-host-20261007.json
|
| 6 |
+
python3 tools/render_report.py synthetic/out/evidence.jsonl --index 3
|
| 7 |
+
|
| 8 |
+
Useful for synthetic records (which have no printed report) and for checking that
|
| 9 |
+
collector output and evidence stay in sync. Standard library only.
|
| 10 |
+
"""
|
| 11 |
+
import argparse
|
| 12 |
+
import json
|
| 13 |
+
from datetime import datetime
|
| 14 |
+
|
| 15 |
+
LINE = "=" * 60
|
| 16 |
+
SECTIONS = [("baseline", None), ("gdpr", "GDPR TECHNICAL CONTROLS"), ("aiact", "EU AI ACT CONTROLS"), ("org", "ORGANISATIONAL CONTROLS")]
|
| 17 |
+
LAYOUT = {
|
| 18 |
+
"windows": {"title": "WINDOWS ENDPOINT SECURITY BASELINE - AUDIT", "results": "COMPLIANCE RESULTS",
|
| 19 |
+
"end": "END OF READ-ONLY AUDIT", "blank_before_sections": True,
|
| 20 |
+
"fields": [("Hostname", "hostname"), ("Operating System", "os_name"), ("OS Version", "os_version"), ("Build", "build"),
|
| 21 |
+
("Serial Number", "serial_number"), ("Execution User", None), ("Audit Date", None), ("Audit Mode", None)]},
|
| 22 |
+
"linux": {"title": "LINUX ENDPOINT SECURITY BASELINE - AUDIT", "results": "LINUX ENDPOINT SECURITY BASELINE - RESULTS",
|
| 23 |
+
"end": "END OF LINUX ENDPOINT SECURITY BASELINE AUDIT", "blank_before_sections": False,
|
| 24 |
+
"fields": [("Hostname", "hostname"), ("Operating System", "os_name"), ("OS Version", "os_version"), ("Kernel", "kernel"),
|
| 25 |
+
("Serial Number", "serial_number"), ("Execution User", None), ("Audit Date", None), ("Audit Mode", None)]},
|
| 26 |
+
"macos": {"title": "macOS ENDPOINT SECURITY BASELINE - AUDIT", "results": "COMPLIANCE RESULTS",
|
| 27 |
+
"end": "END OF AUDIT", "blank_before_sections": False,
|
| 28 |
+
"fields": [("Hostname", "hostname"), ("Operating System", "os_name"), ("OS Version", "os_version"), ("Build", "build"),
|
| 29 |
+
("Serial Number", "serial_number"), ("Execution User", None), ("Audit Date", None), ("Audit Mode", None)]},
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def audit_date(record, os_family):
|
| 34 |
+
started = datetime.fromisoformat(record["audit"]["started_at"])
|
| 35 |
+
text = started.strftime("%Y-%m-%d %H:%M:%S")
|
| 36 |
+
# Linux/macOS collectors print the time zone abbreviation, which the ISO offset cannot recover exactly.
|
| 37 |
+
return text if os_family == "windows" else f"{text} {record.get('_tz', started.strftime('%z'))}"
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def render(record):
|
| 41 |
+
os_family = record["host"]["os_family"]
|
| 42 |
+
layout = LAYOUT[os_family]
|
| 43 |
+
out = [LINE, f" {layout['title']}", LINE]
|
| 44 |
+
for label, key in layout["fields"]:
|
| 45 |
+
if key:
|
| 46 |
+
value = record["host"].get(key, "")
|
| 47 |
+
elif label == "Execution User":
|
| 48 |
+
value = record["audit"]["execution_user"]
|
| 49 |
+
elif label == "Audit Date":
|
| 50 |
+
value = audit_date(record, os_family)
|
| 51 |
+
else:
|
| 52 |
+
value = record["audit"]["mode"]
|
| 53 |
+
out.append(f"{label:<16}: {value}")
|
| 54 |
+
out.append(LINE)
|
| 55 |
+
for section, title in SECTIONS:
|
| 56 |
+
if layout["blank_before_sections"]:
|
| 57 |
+
out.append("")
|
| 58 |
+
out += [LINE, f" {title or layout['results']}", LINE]
|
| 59 |
+
for result in record["results"]:
|
| 60 |
+
if result["section"] == section:
|
| 61 |
+
out += [result["name"], result["status"]]
|
| 62 |
+
out += [LINE, f" {layout['end']}", LINE]
|
| 63 |
+
return "\n".join(out)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def main():
|
| 67 |
+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
| 68 |
+
parser.add_argument("path")
|
| 69 |
+
parser.add_argument("--index", type=int, default=0, help="record number in a .jsonl file (0-based)")
|
| 70 |
+
args = parser.parse_args()
|
| 71 |
+
with open(args.path, encoding="utf-8-sig") as handle:
|
| 72 |
+
if args.path.endswith(".jsonl"):
|
| 73 |
+
record = json.loads(handle.readlines()[args.index])
|
| 74 |
+
else:
|
| 75 |
+
record = json.load(handle)
|
| 76 |
+
print(render(record))
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
if __name__ == "__main__":
|
| 80 |
+
main()
|
tools/validate_evidence.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Validate audit evidence (collector or synthetic) against the schema rules and the check catalog.
|
| 3 |
+
|
| 4 |
+
Usage:
|
| 5 |
+
python3 tools/validate_evidence.py audit-macos-host-20261007.json
|
| 6 |
+
python3 tools/validate_evidence.py synthetic/out/evidence.jsonl
|
| 7 |
+
|
| 8 |
+
Accepts .json (one record) or .jsonl (one record per line). Exit code 1 if any record is invalid.
|
| 9 |
+
Standard library only.
|
| 10 |
+
"""
|
| 11 |
+
import json
|
| 12 |
+
import os
|
| 13 |
+
import sys
|
| 14 |
+
|
| 15 |
+
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 16 |
+
REQUIRED = {"schema_version", "catalog_version", "source", "audit", "host", "baseline", "results", "summary"}
|
| 17 |
+
RESULT_KEYS = {"id", "section", "name", "status", "actual", "expected"}
|
| 18 |
+
OPTIONAL_RESULT_KEYS = {"detail"}
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def load_catalog():
|
| 22 |
+
with open(os.path.join(ROOT, "catalog", "checks.json"), encoding="utf-8") as handle:
|
| 23 |
+
catalog = json.load(handle)
|
| 24 |
+
return catalog, {c["id"]: c for c in catalog["checks"]}
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def validate(record, catalog, checks):
|
| 28 |
+
errors = []
|
| 29 |
+
missing = REQUIRED - record.keys()
|
| 30 |
+
extra = record.keys() - REQUIRED
|
| 31 |
+
if missing:
|
| 32 |
+
errors.append(f"missing top-level keys: {sorted(missing)}")
|
| 33 |
+
if extra:
|
| 34 |
+
errors.append(f"unexpected top-level keys: {sorted(extra)}")
|
| 35 |
+
if errors:
|
| 36 |
+
return errors
|
| 37 |
+
if record["schema_version"] != "1.0":
|
| 38 |
+
errors.append(f"schema_version {record['schema_version']!r} != '1.0'")
|
| 39 |
+
if record["catalog_version"] != catalog["catalog_version"]:
|
| 40 |
+
errors.append(f"catalog_version {record['catalog_version']} != {catalog['catalog_version']}")
|
| 41 |
+
if record["source"].get("kind") not in ("collector", "synthetic"):
|
| 42 |
+
errors.append("source.kind must be collector or synthetic")
|
| 43 |
+
os_family = record["host"].get("os_family")
|
| 44 |
+
if os_family not in ("windows", "linux", "macos"):
|
| 45 |
+
errors.append(f"host.os_family invalid: {os_family!r}")
|
| 46 |
+
return errors
|
| 47 |
+
if record["source"].get("collector") != os_family:
|
| 48 |
+
errors.append("source.collector does not match host.os_family")
|
| 49 |
+
if record["audit"].get("mode") != "READ-ONLY":
|
| 50 |
+
errors.append("audit.mode must be READ-ONLY")
|
| 51 |
+
if not isinstance(record["audit"].get("is_admin"), bool):
|
| 52 |
+
errors.append("audit.is_admin must be true/false")
|
| 53 |
+
for key in ("hostname", "os_name", "os_version"):
|
| 54 |
+
if not record["host"].get(key):
|
| 55 |
+
errors.append(f"host.{key} is empty")
|
| 56 |
+
if not all(isinstance(v, str) for v in record["baseline"].values()):
|
| 57 |
+
errors.append("baseline values must all be strings")
|
| 58 |
+
|
| 59 |
+
seen = set()
|
| 60 |
+
for index, result in enumerate(record["results"]):
|
| 61 |
+
where = f"results[{index}]"
|
| 62 |
+
keys = set(result.keys())
|
| 63 |
+
if not RESULT_KEYS <= keys:
|
| 64 |
+
errors.append(f"{where}: missing {sorted(RESULT_KEYS - keys)}")
|
| 65 |
+
continue
|
| 66 |
+
if keys - RESULT_KEYS - OPTIONAL_RESULT_KEYS:
|
| 67 |
+
errors.append(f"{where}: unexpected keys {sorted(keys - RESULT_KEYS - OPTIONAL_RESULT_KEYS)}")
|
| 68 |
+
check_id = result["id"]
|
| 69 |
+
if check_id in seen:
|
| 70 |
+
errors.append(f"{where}: duplicate id {check_id}")
|
| 71 |
+
seen.add(check_id)
|
| 72 |
+
check = checks.get(check_id)
|
| 73 |
+
if not check:
|
| 74 |
+
errors.append(f"{where}: unknown check id {check_id}")
|
| 75 |
+
continue
|
| 76 |
+
if os_family not in check["os"]:
|
| 77 |
+
errors.append(f"{where}: {check_id} does not apply to {os_family}")
|
| 78 |
+
if result["section"] != check["section"]:
|
| 79 |
+
errors.append(f"{where}: {check_id} section {result['section']} != {check['section']}")
|
| 80 |
+
expected_name = check.get("names", {}).get(os_family, check["name"])
|
| 81 |
+
if result["name"] != expected_name:
|
| 82 |
+
errors.append(f"{where}: {check_id} name {result['name']!r} != catalog {expected_name!r}")
|
| 83 |
+
if result["status"] not in catalog["statuses"]:
|
| 84 |
+
errors.append(f"{where}: {check_id} invalid status {result['status']!r}")
|
| 85 |
+
for key in ("actual", "expected") + (("detail",) if "detail" in result else ()):
|
| 86 |
+
if not isinstance(result[key], str):
|
| 87 |
+
errors.append(f"{where}: {check_id}.{key} must be a string")
|
| 88 |
+
|
| 89 |
+
expected_ids = {c["id"] for c in catalog["checks"] if os_family in c["os"]}
|
| 90 |
+
if seen != expected_ids:
|
| 91 |
+
if expected_ids - seen:
|
| 92 |
+
errors.append(f"checks missing for {os_family}: {sorted(expected_ids - seen)}")
|
| 93 |
+
if seen - expected_ids:
|
| 94 |
+
errors.append(f"checks not expected for {os_family}: {sorted(seen - expected_ids)}")
|
| 95 |
+
|
| 96 |
+
counts = {s: sum(1 for r in record["results"] if r.get("status") == s) for s in catalog["statuses"]}
|
| 97 |
+
if record["summary"] != counts:
|
| 98 |
+
errors.append(f"summary {record['summary']} != counted {counts}")
|
| 99 |
+
return errors
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def read_records(path):
|
| 103 |
+
with open(path, encoding="utf-8-sig") as handle:
|
| 104 |
+
if path.endswith(".jsonl"):
|
| 105 |
+
return [json.loads(line) for line in handle if line.strip()]
|
| 106 |
+
return [json.load(handle)]
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def main():
|
| 110 |
+
if len(sys.argv) < 2:
|
| 111 |
+
print(__doc__)
|
| 112 |
+
return 2
|
| 113 |
+
catalog, checks = load_catalog()
|
| 114 |
+
total = bad = 0
|
| 115 |
+
for path in sys.argv[1:]:
|
| 116 |
+
for number, record in enumerate(read_records(path), 1):
|
| 117 |
+
total += 1
|
| 118 |
+
errors = validate(record, catalog, checks)
|
| 119 |
+
if errors:
|
| 120 |
+
bad += 1
|
| 121 |
+
print(f"INVALID {path} record {number}:")
|
| 122 |
+
for error in errors[:20]:
|
| 123 |
+
print(f" - {error}")
|
| 124 |
+
print(f"{total - bad}/{total} records valid")
|
| 125 |
+
return 1 if bad else 0
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
if __name__ == "__main__":
|
| 129 |
+
sys.exit(main())
|