umer-wasim commited on
Commit
468a5f2
·
verified ·
1 Parent(s): 37205dc

Stage 1 step 1: read-only collectors, evidence schema, catalog and synthetic data

Browse files
.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: yellow
5
- colorTo: red
6
  sdk: static
7
  pinned: false
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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())