umer-wasim commited on
Commit
6ccf48e
·
verified ·
1 Parent(s): 5adde1b

v0.3: phone check (iPhone, Android) as the Space front page, catalog 0.3 (74 checks), shared vulnerability rules, real-CVE training data

Browse files
.gitignore CHANGED
@@ -15,3 +15,5 @@ synthetic/out-v0.2/
15
  training/out-v0.2/
16
  vulndb/cache/
17
  vulndb/out/
 
 
 
15
  training/out-v0.2/
16
  vulndb/cache/
17
  vulndb/out/
18
+ synthetic/out-v0.3/
19
+ training/out-v0.3/
README.md CHANGED
@@ -151,6 +151,20 @@ npm start # run the app
151
 
152
  Offline fine-tuning on CPU: `training/train_lora.py` (LoRA on Qwen2.5-0.5B-Instruct; see `--benchmark` and `--resume`).
153
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
  ## Vulnerability matching (offline)
155
 
156
  `vuln.known_vulnerabilities` is decided by exact version comparison, never by the model:
@@ -165,10 +179,11 @@ Devseis builds the bundle with `python3 vulndb/build_bundle.py` (needs internet;
165
 
166
  | Part | Version | Notes |
167
  |---|---|---|
168
- | Catalog, library, synthetic data, training data | **0.2** | 68 checks: adds signed-in user account type, USB device access, pending security updates, outdated apps, known vulnerabilities, Windows screen lock line; screen lock limit 5 minutes |
169
- | Collectors (Windows, Linux, macOS) | **0.2** | macOS tested on a real Mac; Linux tested on Ubuntu 24.04 and Fedora 42 containers; Windows parse-checked only (needs a real Windows test). Known vulnerabilities: inventory only, matching comes with the offline vulnerability bundle |
170
  | Vulnerability matcher and bundle | 0.1 | Tested: this Mac (Chrome 154 → 58 critical/high, fixed in 155), Ubuntu 24.04 (OpenSSL), Rocky 9 (25 of 25 packages agree with dnf's own security list), Fedora reported as not covered |
171
- | Fine-tuned model | training on 0.1 data | First run in progress on CPU |
 
172
 
173
  ## Test status (collector version 0.1.0)
174
 
 
151
 
152
  Offline fine-tuning on CPU: `training/train_lora.py` (LoRA on Qwen2.5-0.5B-Instruct; see `--benchmark` and `--resume`).
153
 
154
+ ## Phone check (iPhone, iPad, Android)
155
+
156
+ The Space's front page (`index.html`) is a phone self-check that runs entirely in the browser:
157
+
158
+ 1. **Detects** what a browser can see: OS and version (Safari 26+ hides the iOS version in its user agent, so Safari's own version is used; Android uses client hints), device model (Android), browser, whether a device unlock method is set up (WebAuthn), and WebGPU support.
159
+ 2. **Asks** the rest, one question at a time with *where to look* in Settings (`catalog/mobile_questions.json`). Every result is labelled `detected` or `self-reported` in the evidence and the report.
160
+ 3. **Matches** the iOS version against the signed phone subset of the vulnerability bundle (55 KB, signature checked with WebCrypto).
161
+ 4. **Writes** the findings with the same auditor model in WebLLM (about 350 MB download, roughly 1 GB of GPU memory, so newer phones) or with the built-in writer, and builds the same report as the desktop app.
162
+ 5. **Saves or shares** the report as a PDF (share sheet or download). Phones do not allow a silent auto-save.
163
+
164
+ `mobile/mobile-core.js` turns detections and answers into evidence; the synthetic generator calls the same code through `mobile/build-evidence.mjs`, so training data has exactly the page's wording. Organisation answers can be passed in the link: `index.html#org=<base64 JSON {"ORG_ROPA": "yes", ...}>`.
165
+
166
+ Test without a phone: serve the repo (`python3 -m http.server 8765`) and run `app/node_modules/.bin/electron mobile/test/e2e.cjs ios out.json` (or `android`); it answers every question in a phone-sized window with a phone user agent and builds the PDF; then check the evidence with `python3 tools/validate_evidence.py out.json`.
167
+
168
  ## Vulnerability matching (offline)
169
 
170
  `vuln.known_vulnerabilities` is decided by exact version comparison, never by the model:
 
179
 
180
  | Part | Version | Notes |
181
  |---|---|---|
182
+ | Catalog, library, synthetic data, training data | **0.3** | 74 checks: Windows 64, Linux 56, macOS 56, iPhone 29, Android 30. 0.3 adds phones, real CVEs in training data and wording identical to the collectors and the phone page; 39,500 grounded examples |
183
+ | Collectors (Windows, Linux, macOS) | **0.2** (catalog 0.3) | macOS tested on a real Mac; Linux tested on Ubuntu 24.04 and Fedora 42 containers; Windows parse-checked only (needs a real Windows test). Known vulnerabilities: inventory only, matching comes with the offline vulnerability bundle |
184
  | Vulnerability matcher and bundle | 0.1 | Tested: this Mac (Chrome 154 → 58 critical/high, fixed in 155), Ubuntu 24.04 (OpenSSL), Rocky 9 (25 of 25 packages agree with dnf's own security list), Fedora reported as not covered |
185
+ | Phone check (web) | **0.3** | Tested in a phone-sized window as iPhone (Safari 26) and Android (Chrome 141); needs testing on real phones |
186
+ | Fine-tuned model | training on 0.1 data | First run in progress on CPU; next run on 0.3 data |
187
 
188
  ## Test status (collector version 0.1.0)
189
 
about.html ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>Devseis Endpoint Auditor</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">Open source · stage 1</span>
56
+ <h1>Devseis Endpoint Auditor</h1>
57
+ <p class="lead">A read-only audit of Windows, Linux, macOS, iPhone and Android devices against a security baseline, GDPR technical controls, EU AI Act controls and organisational controls, written up by a small AI auditor model that runs on the device. <a href="index.html">Check your phone now</a>.</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 class="card"><b>Offline vulnerability matching</b><span>Installed software is matched against a signed OSV/NVD/CISA KEV bundle by exact version rules.</span></div>
65
+ <div class="card"><b>Phones too</b><span>A browser check for iPhone and Android: what the browser can detect plus guided questions, each result labelled detected or self-reported.</span></div>
66
+ </div>
67
+
68
+ <h2>Sample audit (synthetic)</h2>
69
+ <div class="tabs" role="tablist" id="tabs"></div>
70
+ <div class="counts" id="counts"></div>
71
+ <div class="split">
72
+ <pre id="report" aria-label="Text report">Loading…</pre>
73
+ <div class="table-wrap">
74
+ <table>
75
+ <thead><tr><th>Check</th><th>Status</th><th>Found</th><th>References</th></tr></thead>
76
+ <tbody id="rows"></tbody>
77
+ </table>
78
+ </div>
79
+ </div>
80
+
81
+ <h2>Roadmap</h2>
82
+ <ol>
83
+ <li><b>Collectors, evidence format, catalog (74 checks), synthetic data</b> — done</li>
84
+ <li><b>Control library and fix guidance</b> in Devseis's own wording — done; expert review welcome</li>
85
+ <li><b>Training data</b> v0.3: 39,500 grounded examples for Windows, Linux, macOS, iPhone and Android — done</li>
86
+ <li><b>Offline vulnerability matching</b> (OSV, NVD, CISA KEV, EPSS), signed bundle — done</li>
87
+ <li><b>Desktop app</b> (Electron + WebLLM) and <b>phone check</b> (this Space) — done, with the base model</li>
88
+ <li>Fine-tune Qwen2.5-0.5B-Instruct on this Mac (CPU, LoRA) — in progress</li>
89
+ <li>Evaluate on held-out data, publish the scores and the model</li>
90
+ <li>Installers per OS; tests on real Windows and Linux machines</li>
91
+ </ol>
92
+
93
+ <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>
94
+
95
+ <footer>Open source by Devseis: code Apache-2.0, data CC BY 4.0 · <a href="https://huggingface.co/datasets/Devseis/endpoint-auditor-synthetic">training data</a> · <a href="https://huggingface.co/datasets/Devseis/endpoint-auditor-vulndb">vulnerability data</a> · <a href="CITATION.cff">cite</a></footer>
96
+ </main>
97
+ <script>
98
+ const SAMPLES = Object.fromEntries(['windows', 'linux', 'macos', 'ios', 'android'].map(os => [os, `synthetic/samples/synthetic-${os}-42000126.json`]));
99
+ const LABELS = { windows: 'Windows', linux: 'Linux', macos: 'macOS', ios: 'iPhone', android: 'Android' };
100
+ const LAYOUT = {
101
+ windows: { title: 'WINDOWS ENDPOINT SECURITY BASELINE - AUDIT', results: 'COMPLIANCE RESULTS', end: 'END OF READ-ONLY AUDIT', blank: true, extra: ['Build', 'build'] },
102
+ 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'] },
103
+ macos: { title: 'macOS ENDPOINT SECURITY BASELINE - AUDIT', results: 'COMPLIANCE RESULTS', end: 'END OF AUDIT', blank: false, extra: ['Build', 'build'] },
104
+ ios: { title: 'iPHONE SECURITY CHECK (BROWSER)', results: 'COMPLIANCE RESULTS', end: 'END OF PHONE CHECK', blank: false, extra: ['Browser', 'browser'] },
105
+ android: { title: 'ANDROID SECURITY CHECK (BROWSER)', results: 'COMPLIANCE RESULTS', end: 'END OF PHONE CHECK', blank: false, extra: ['Browser', 'browser'] },
106
+ };
107
+ const SECTIONS = [['baseline', null], ['gdpr', 'GDPR TECHNICAL CONTROLS'], ['aiact', 'EU AI ACT CONTROLS'], ['org', 'ORGANISATIONAL CONTROLS']];
108
+ let catalog = null;
109
+
110
+ function render(rec) {
111
+ const L = LAYOUT[rec.host.os_family], line = '='.repeat(60), pad = s => (s + ' '.repeat(16)).slice(0, 16);
112
+ const out = [line, ' ' + L.title, line,
113
+ pad('Hostname') + ': ' + rec.host.hostname, pad('Operating System') + ': ' + rec.host.os_name,
114
+ pad('OS Version') + ': ' + rec.host.os_version, pad(L.extra[0]) + ': ' + (rec.host[L.extra[1]] || ''),
115
+ pad('Serial Number') + ': ' + (rec.host.serial_number || ''), pad('Execution User') + ': ' + rec.audit.execution_user,
116
+ pad('Audit Date') + ': ' + rec.audit.started_at.replace('T', ' ').slice(0, 19), pad('Audit Mode') + ': ' + rec.audit.mode, line];
117
+ for (const [id, title] of SECTIONS) {
118
+ if (L.blank) out.push('');
119
+ out.push(line, ' ' + (title || L.results), line);
120
+ for (const r of rec.results) if (r.section === id) out.push(r.name, r.status + (r.evidence ? ` (${r.evidence})` : ''));
121
+ }
122
+ out.push(line, ' ' + L.end, line);
123
+ return out.join('\n');
124
+ }
125
+
126
+ function refs(id) {
127
+ const c = catalog && catalog.checks.find(x => x.id === id);
128
+ if (!c) return '';
129
+ 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(' · ');
130
+ }
131
+
132
+ function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
133
+
134
+ async function show(os) {
135
+ document.querySelectorAll('#tabs button').forEach(b => b.setAttribute('aria-selected', String(b.dataset.os === os)));
136
+ try {
137
+ const rec = await (await fetch(SAMPLES[os])).json();
138
+ document.getElementById('report').textContent = render(rec);
139
+ document.getElementById('counts').innerHTML = Object.entries(rec.summary)
140
+ .map(([k, v]) => `<span class="pill s-${k}">${v} ${k}</span>`).join('');
141
+ document.getElementById('rows').innerHTML = rec.results.map(r =>
142
+ `<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('');
143
+ } catch (e) {
144
+ document.getElementById('report').textContent = 'Could not load the sample: ' + e.message;
145
+ }
146
+ }
147
+
148
+ (async () => {
149
+ try { catalog = await (await fetch('catalog/checks.json')).json(); } catch (e) { catalog = null; }
150
+ const tabs = document.getElementById('tabs');
151
+ for (const os of Object.keys(SAMPLES)) {
152
+ const b = document.createElement('button');
153
+ b.textContent = LABELS[os]; b.dataset.os = os; b.setAttribute('role', 'tab');
154
+ b.addEventListener('click', () => show(os));
155
+ tabs.appendChild(b);
156
+ }
157
+ show('windows');
158
+ })();
159
+ </script>
160
+ </body>
161
+ </html>
app/renderer/auditor-core.js CHANGED
@@ -58,6 +58,12 @@ const deviceLine = record => `${record.host.os_name} ${record.host.os_version} (
58
  const sentence = text => { const t = text.trim(); return !t || /[.!?]$/.test(t) ? t : `${t}.`; };
59
 
60
  // --- Task A: finding ----------------------------------------------------------
 
 
 
 
 
 
61
  export function findingPrompt(record, result, lib) {
62
  const osFamily = record.host.os_family;
63
  const baseline = record.baseline;
@@ -72,6 +78,7 @@ export function findingPrompt(record, result, lib) {
72
  `REQUIRED: ${result.expected}`,
73
  ];
74
  if (result.detail) lines.push(`DETAIL: ${result.detail}`);
 
75
  lines.push('REFERENCES:');
76
  for (const [label, title, summary] of lib.references(result.id)) lines.push(`- ${label} ${title}: ${summary}`);
77
  lines.push('GUIDANCE:');
@@ -126,8 +133,10 @@ export function findingTemplate(record, result, lib) {
126
  risk = { level: 'None', reason: 'Not applicable to this device.' };
127
  recommendation = [];
128
  }
 
129
  const answer = { check_id: result.id, status, title, finding: finding.trim(), risk, recommendation, references };
130
  if (verification) answer.verification = verification;
 
131
  return answer;
132
  }
133
 
@@ -142,7 +151,10 @@ export function summaryPrompt(record, lib) {
142
  const { results, summary, host } = record;
143
  const lines = ['TASK: summary', `DEVICE: ${deviceLine(record)}`, `HOSTNAME: ${host.hostname}`,
144
  `AUDIT RUN AS ADMIN: ${record.audit.is_admin ? 'yes' : 'no'}`,
145
- 'COUNTS: ' + Object.entries(summary).map(([k, v]) => `${k}=${v}`).join(', '), 'RESULTS (id | name | status | found):'];
 
 
 
146
  for (const r of results) lines.push(`- ${r.id} | ${r.name} | ${r.status} | ${r.actual}`);
147
  lines.push('RISK LEVELS (from guidance):');
148
  for (const r of results.filter(x => x.status === 'Non-Compliant')) lines.push(`- ${r.id}: ${lib.guide(r.id).risk.level}`);
@@ -172,6 +184,8 @@ export function summaryTemplate(record, lib) {
172
  }
173
  if (summary.Error) text += ` ${summary.Error} checks could not be read` + (record.audit.is_admin ? '.' : ' because the audit was not run as administrator.');
174
  if (summary.Pending) text += ` ${summary.Pending} items are pending.`;
 
 
175
  const nextSteps = [];
176
  if (summary.Error) nextSteps.push('Re-run the audit as administrator/root so that every check can be read.');
177
  for (const r of ranked.slice(0, 3)) {
 
58
  const sentence = text => { const t = text.trim(); return !t || /[.!?]$/.test(t) ? t : `${t}.`; };
59
 
60
  // --- Task A: finding ----------------------------------------------------------
61
+ export const EVIDENCE_TEXT = { 'self-reported': 'self-reported by the user', detected: 'detected by the browser' };
62
+ export const SELF_REPORTED = 'Self-reported by the user, not verified on the device.';
63
+ export function evidenceCounts(results) {
64
+ return [results.filter(r => r.evidence === 'detected').length, results.filter(r => r.evidence === 'self-reported').length];
65
+ }
66
+
67
  export function findingPrompt(record, result, lib) {
68
  const osFamily = record.host.os_family;
69
  const baseline = record.baseline;
 
78
  `REQUIRED: ${result.expected}`,
79
  ];
80
  if (result.detail) lines.push(`DETAIL: ${result.detail}`);
81
+ if (result.evidence) lines.push(`EVIDENCE: ${EVIDENCE_TEXT[result.evidence]}`);
82
  lines.push('REFERENCES:');
83
  for (const [label, title, summary] of lib.references(result.id)) lines.push(`- ${label} ${title}: ${summary}`);
84
  lines.push('GUIDANCE:');
 
133
  risk = { level: 'None', reason: 'Not applicable to this device.' };
134
  recommendation = [];
135
  }
136
+ if (result.evidence === 'self-reported') finding = `${finding.replace('The device reports', 'The user reports')} ${SELF_REPORTED}`;
137
  const answer = { check_id: result.id, status, title, finding: finding.trim(), risk, recommendation, references };
138
  if (verification) answer.verification = verification;
139
+ if (result.evidence) answer.evidence = result.evidence;
140
  return answer;
141
  }
142
 
 
151
  const { results, summary, host } = record;
152
  const lines = ['TASK: summary', `DEVICE: ${deviceLine(record)}`, `HOSTNAME: ${host.hostname}`,
153
  `AUDIT RUN AS ADMIN: ${record.audit.is_admin ? 'yes' : 'no'}`,
154
+ 'COUNTS: ' + Object.entries(summary).map(([k, v]) => `${k}=${v}`).join(', ')];
155
+ const [detected, reported] = evidenceCounts(results);
156
+ if (detected || reported) lines.push(`EVIDENCE: ${detected} detected by the browser, ${reported} self-reported by the user`);
157
+ lines.push('RESULTS (id | name | status | found):');
158
  for (const r of results) lines.push(`- ${r.id} | ${r.name} | ${r.status} | ${r.actual}`);
159
  lines.push('RISK LEVELS (from guidance):');
160
  for (const r of results.filter(x => x.status === 'Non-Compliant')) lines.push(`- ${r.id}: ${lib.guide(r.id).risk.level}`);
 
184
  }
185
  if (summary.Error) text += ` ${summary.Error} checks could not be read` + (record.audit.is_admin ? '.' : ' because the audit was not run as administrator.');
186
  if (summary.Pending) text += ` ${summary.Pending} items are pending.`;
187
+ const reported = evidenceCounts(results)[1];
188
+ if (reported) text += ` ${reported} results are self-reported by the user and were not verified on the device.`;
189
  const nextSteps = [];
190
  if (summary.Error) nextSteps.push('Re-run the audit as administrator/root so that every check can be read.');
191
  for (const r of ranked.slice(0, 3)) {
app/renderer/model-core.js ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Loads the auditor model in WebLLM (runs on the device's GPU through WebGPU), for the desktop app and the phone page.
2
+ // The model is downloaded once and cached by the browser, so later runs work offline.
3
+
4
+ // Until the fine-tuned Devseis model is converted to MLC format and published, the matching base model
5
+ // is used. To switch, set CUSTOM_MODEL to:
6
+ // { model: 'https://huggingface.co/Devseis/<repo>', model_id: 'devseis-auditor-0.5b-q4f16_1', model_lib: '<Qwen2.5-0.5B q4f16_1 WebGPU lib URL from prebuiltAppConfig>' }
7
+ const CUSTOM_MODEL = null;
8
+ // q4f16 needs 16-bit GPU maths (shader-f16); many older and integrated GPUs only support the q4f32 build.
9
+ const BASE_MODEL = { f16: 'Qwen2.5-0.5B-Instruct-q4f16_1-MLC', f32: 'Qwen2.5-0.5B-Instruct-q4f32_1-MLC' };
10
+
11
+ // webllm: the @mlc-ai/web-llm module (bundled in the app, from a CDN on the phone page).
12
+ export async function loadModelWith(webllm, onProgress) {
13
+ const { CreateMLCEngine, prebuiltAppConfig } = webllm;
14
+ if (!('gpu' in navigator)) throw new Error('WebGPU is not available on this device.');
15
+ const adapter = await navigator.gpu.requestAdapter();
16
+ if (!adapter) throw new Error('No WebGPU-capable graphics adapter was found.');
17
+
18
+ let appConfig = prebuiltAppConfig;
19
+ let modelId;
20
+ if (CUSTOM_MODEL) {
21
+ appConfig = { ...prebuiltAppConfig, model_list: [...prebuiltAppConfig.model_list, CUSTOM_MODEL] };
22
+ modelId = CUSTOM_MODEL.model_id;
23
+ } else {
24
+ const wanted = adapter.features.has('shader-f16') ? BASE_MODEL.f16 : BASE_MODEL.f32;
25
+ const entry = prebuiltAppConfig.model_list.find(m => m.model_id === wanted);
26
+ if (!entry) throw new Error('The base model is not in this WebLLM version.');
27
+ modelId = entry.model_id;
28
+ }
29
+
30
+ const engine = await CreateMLCEngine(modelId, {
31
+ appConfig,
32
+ initProgressCallback: report => onProgress({ progress: report.progress, text: report.text }),
33
+ });
34
+ return {
35
+ name: modelId,
36
+ fineTuned: Boolean(CUSTOM_MODEL),
37
+ async generate(system, prompt) {
38
+ const reply = await engine.chat.completions.create({
39
+ messages: [{ role: 'system', content: system }, { role: 'user', content: prompt }],
40
+ temperature: 0,
41
+ max_tokens: 900,
42
+ response_format: { type: 'json_object' },
43
+ });
44
+ return reply.choices[0].message.content;
45
+ },
46
+ };
47
+ }
app/renderer/model.js CHANGED
@@ -1,46 +1,5 @@
1
- // Loads the auditor model in WebLLM (runs on this computer's GPU through WebGPU).
2
- // The model is downloaded once and cached by WebLLM, so later runs work offline.
3
- import { CreateMLCEngine, prebuiltAppConfig } from '../node_modules/@mlc-ai/web-llm/lib/index.js';
4
 
5
- // Until the fine-tuned Devseis model is converted to MLC format and published, the matching base model
6
- // is used. To switch, set CUSTOM_MODEL to:
7
- // { model: 'https://huggingface.co/Devseis/<repo>', model_id: 'devseis-auditor-0.5b-q4f16_1', model_lib: '<Qwen2.5-0.5B q4f16_1 WebGPU lib URL from prebuiltAppConfig>' }
8
- const CUSTOM_MODEL = null;
9
- // q4f16 needs 16-bit GPU maths (shader-f16); many older and integrated GPUs only support the q4f32 build.
10
- const BASE_MODEL = { f16: 'Qwen2.5-0.5B-Instruct-q4f16_1-MLC', f32: 'Qwen2.5-0.5B-Instruct-q4f32_1-MLC' };
11
-
12
- export async function loadModel(onProgress) {
13
- if (!('gpu' in navigator)) throw new Error('WebGPU is not available on this computer.');
14
- const adapter = await navigator.gpu.requestAdapter();
15
- if (!adapter) throw new Error('No WebGPU-capable graphics adapter was found.');
16
-
17
- let appConfig = prebuiltAppConfig;
18
- let modelId;
19
- if (CUSTOM_MODEL) {
20
- appConfig = { ...prebuiltAppConfig, model_list: [...prebuiltAppConfig.model_list, CUSTOM_MODEL] };
21
- modelId = CUSTOM_MODEL.model_id;
22
- } else {
23
- const wanted = adapter.features.has('shader-f16') ? BASE_MODEL.f16 : BASE_MODEL.f32;
24
- const entry = prebuiltAppConfig.model_list.find(m => m.model_id === wanted);
25
- if (!entry) throw new Error('The base model is not in this WebLLM version.');
26
- modelId = entry.model_id;
27
- }
28
-
29
- const engine = await CreateMLCEngine(modelId, {
30
- appConfig,
31
- initProgressCallback: report => onProgress({ progress: report.progress, text: report.text }),
32
- });
33
- return {
34
- name: modelId,
35
- fineTuned: Boolean(CUSTOM_MODEL),
36
- async generate(system, prompt) {
37
- const reply = await engine.chat.completions.create({
38
- messages: [{ role: 'system', content: system }, { role: 'user', content: prompt }],
39
- temperature: 0,
40
- max_tokens: 900,
41
- response_format: { type: 'json_object' },
42
- });
43
- return reply.choices[0].message.content;
44
- },
45
- };
46
- }
 
1
+ // Desktop app: WebLLM from the app's own node_modules (works offline once the model is cached).
2
+ import * as webllm from '../node_modules/@mlc-ai/web-llm/lib/index.js';
3
+ import { loadModelWith } from './model-core.js';
4
 
5
+ export const loadModel = onProgress => loadModelWith(webllm, onProgress);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/renderer/report.js CHANGED
@@ -10,6 +10,8 @@ export function buildReportHtml({ evidence, findings, summary, ai, sha256, model
10
  const host = evidence.host;
11
  const started = evidence.audit.started_at.replace('T', ' ').slice(0, 19);
12
  const counts = evidence.summary;
 
 
13
 
14
  const sectionHtml = ['baseline', 'gdpr', 'aiact', 'org'].map(sectionId => {
15
  const rows = evidence.results.filter(r => r.section === sectionId).map(r => {
@@ -24,7 +26,8 @@ export function buildReportHtml({ evidence, findings, summary, ai, sha256, model
24
  <p class="refs">${f.references.map(esc).join(' · ')}</p>
25
  </td></tr>`;
26
  }
27
- return `<tr><td>${esc(r.name)}</td><td>${badge(r.status, STATUS_COLOR[r.status])}</td><td>${esc(r.actual)}</td><td>${esc(r.expected)}</td></tr>${detail}`;
 
28
  }).join('');
29
  return `<h2>${esc(SECTION_TITLES[sectionId])}</h2>
30
  <table><thead><tr><th style="width:30%">Check</th><th style="width:14%">Status</th><th>Found</th><th style="width:20%">Required</th></tr></thead><tbody>${rows}</tbody></table>`;
@@ -64,9 +67,11 @@ export function buildReportHtml({ evidence, findings, summary, ai, sha256, model
64
  <h1>${esc(host.hostname)}</h1>
65
  <div class="meta">
66
  <b>Operating system</b><span>${esc(host.os_name)} ${esc(host.os_version)}${host.build ? ` (build ${esc(host.build)})` : ''}</span>
67
- <b>Serial number</b><span>${esc(host.serial_number || '')}</span>
 
68
  <b>Audit date</b><span>${esc(started)}</span>
69
- <b>Run as</b><span>${esc(evidence.audit.execution_user)}${evidence.audit.is_admin ? ' (administrator)' : ' (not administrator — some checks could not be read)'}</span>
 
70
  </div>
71
  <div class="score"><div class="big">${summary.score.percent}%</div>
72
  <div>${summary.score.compliant} of ${summary.score.assessed} assessed checks compliant<br>
@@ -82,11 +87,15 @@ ${summary.top_gaps.length ? `<h2>TOP GAPS</h2><ul class="gaps">${summary.top_gap
82
  ${sectionHtml}
83
  ${aiHtml}
84
 
85
- <div class="note">This report records the settings found on this computer at the audit date and compares them with the
 
 
 
 
86
  Devseis baseline. It supports ISO 27001 internal audits, GDPR Article 32 and EU AI Act deployer duties; it is not a
87
- certification and not legal advice. Organisational controls reflect the answers given in organisational.conf.</div>
88
  <div class="foot">Evidence SHA-256: ${esc(sha256)}<br>
89
  Findings written by: ${esc(model || 'built-in report writer')} (${sources.model} by the AI model, ${sources.template} by the built-in writer)<br>
90
- Collector ${esc(evidence.source.collector)} ${esc(evidence.source.collector_version)} · catalog ${esc(evidence.catalog_version)}</div>
91
  </body></html>`;
92
  }
 
10
  const host = evidence.host;
11
  const started = evidence.audit.started_at.replace('T', ' ').slice(0, 19);
12
  const counts = evidence.summary;
13
+ const phone = evidence.source.kind === 'web' || ['ios', 'android'].includes(host.os_family);
14
+ const EVIDENCE_LABEL = { detected: 'detected', 'self-reported': 'self-reported' };
15
 
16
  const sectionHtml = ['baseline', 'gdpr', 'aiact', 'org'].map(sectionId => {
17
  const rows = evidence.results.filter(r => r.section === sectionId).map(r => {
 
26
  <p class="refs">${f.references.map(esc).join(' · ')}</p>
27
  </td></tr>`;
28
  }
29
+ const source = r.evidence ? `<br><span class="muted">${esc(EVIDENCE_LABEL[r.evidence])}</span>` : '';
30
+ return `<tr><td>${esc(r.name)}${source}</td><td>${badge(r.status, STATUS_COLOR[r.status])}</td><td>${esc(r.actual)}</td><td>${esc(r.expected)}</td></tr>${detail}`;
31
  }).join('');
32
  return `<h2>${esc(SECTION_TITLES[sectionId])}</h2>
33
  <table><thead><tr><th style="width:30%">Check</th><th style="width:14%">Status</th><th>Found</th><th style="width:20%">Required</th></tr></thead><tbody>${rows}</tbody></table>`;
 
67
  <h1>${esc(host.hostname)}</h1>
68
  <div class="meta">
69
  <b>Operating system</b><span>${esc(host.os_name)} ${esc(host.os_version)}${host.build ? ` (build ${esc(host.build)})` : ''}</span>
70
+ ${phone ? `<b>Device</b><span>${esc(host.device_model || '')}</span>
71
+ <b>Browser</b><span>${esc(host.browser || '')}</span>` : `<b>Serial number</b><span>${esc(host.serial_number || '')}</span>`}
72
  <b>Audit date</b><span>${esc(started)}</span>
73
+ ${phone ? `<b>Checked by</b><span>Phone self-check in the browser: ${evidence.results.filter(r => r.evidence === 'detected').length} results detected, ${evidence.results.filter(r => r.evidence === 'self-reported').length} self-reported</span>`
74
+ : `<b>Run as</b><span>${esc(evidence.audit.execution_user)}${evidence.audit.is_admin ? ' (administrator)' : ' (not administrator — some checks could not be read)'}</span>`}
75
  </div>
76
  <div class="score"><div class="big">${summary.score.percent}%</div>
77
  <div>${summary.score.compliant} of ${summary.score.assessed} assessed checks compliant<br>
 
87
  ${sectionHtml}
88
  ${aiHtml}
89
 
90
+ <div class="note">${phone
91
+ ? `This report records what the browser detected on this phone and the answers given by its user at the audit date, and compares them with the
92
+ Devseis baseline. Results marked self-reported were not verified on the device; a browser cannot read a phone's security settings.
93
+ It supports ISO 27001 internal audits, GDPR Article 32 and EU AI Act deployer duties; it is not a certification and not legal advice.`
94
+ : `This report records the settings found on this computer at the audit date and compares them with the
95
  Devseis baseline. It supports ISO 27001 internal audits, GDPR Article 32 and EU AI Act deployer duties; it is not a
96
+ certification and not legal advice. Organisational controls reflect the answers given in organisational.conf.`}</div>
97
  <div class="foot">Evidence SHA-256: ${esc(sha256)}<br>
98
  Findings written by: ${esc(model || 'built-in report writer')} (${sources.model} by the AI model, ${sources.template} by the built-in writer)<br>
99
+ ${phone ? 'Phone self-check' : 'Collector'} ${esc(evidence.source.collector)} ${esc(evidence.source.collector_version)} · catalog ${esc(evidence.catalog_version)}</div>
100
  </body></html>`;
101
  }
app/renderer/vuln-core.js ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Vulnerability matching rules shared by the desktop app (vulndb.cjs) and the phone web check.
2
+ // Pure functions only: no file, network or DOM access, so it runs in Node and in browsers.
3
+ // Matching is exact version comparison; the language model never decides whether a CVE applies.
4
+ export const MAX_AGE_DAYS = 14;
5
+ const SEVERE = new Set(['critical', 'high']);
6
+
7
+ // --- Version comparison ----------------------------------------------------------------------
8
+ // Debian policy 5.6.12: [epoch:]upstream[-revision]; letters sort before non-letters, '~' before everything.
9
+ function dpkgOrder(c) {
10
+ if (c === '~') return -1;
11
+ if (/\d/.test(c)) return 0;
12
+ if (!c) return 0;
13
+ if (/[A-Za-z]/.test(c)) return c.charCodeAt(0);
14
+ return c.charCodeAt(0) + 256;
15
+ }
16
+ function dpkgPart(a, b) {
17
+ let i = 0, j = 0;
18
+ while (i < a.length || j < b.length) {
19
+ let diff = 0;
20
+ while ((i < a.length && !/\d/.test(a[i])) || (j < b.length && !/\d/.test(b[j]))) {
21
+ const ac = dpkgOrder(a[i] || ''), bc = dpkgOrder(b[j] || '');
22
+ if (ac !== bc) return ac - bc;
23
+ i++; j++;
24
+ }
25
+ while (a[i] === '0') i++;
26
+ while (b[j] === '0') j++;
27
+ while (/\d/.test(a[i] || '') && /\d/.test(b[j] || '')) {
28
+ if (!diff) diff = a.charCodeAt(i) - b.charCodeAt(j);
29
+ i++; j++;
30
+ }
31
+ if (/\d/.test(a[i] || '')) return 1;
32
+ if (/\d/.test(b[j] || '')) return -1;
33
+ if (diff) return diff;
34
+ }
35
+ return 0;
36
+ }
37
+ function splitDeb(v) {
38
+ let epoch = 0, rest = v;
39
+ const colon = v.indexOf(':');
40
+ if (colon > 0) { epoch = parseInt(v.slice(0, colon), 10) || 0; rest = v.slice(colon + 1); }
41
+ const dash = rest.lastIndexOf('-');
42
+ return dash > 0 ? [epoch, rest.slice(0, dash), rest.slice(dash + 1)] : [epoch, rest, ''];
43
+ }
44
+ export function dpkgCompare(a, b) {
45
+ const [ea, ua, ra] = splitDeb(a), [eb, ub, rb] = splitDeb(b);
46
+ if (ea !== eb) return ea - eb;
47
+ return dpkgPart(ua, ub) || dpkgPart(ra, rb);
48
+ }
49
+
50
+ // rpmvercmp, with epoch:version-release handling.
51
+ function rpmSegment(a, b) {
52
+ if (a === b) return 0;
53
+ const sa = a.match(/~|\^|[A-Za-z]+|\d+/g) || [], sb = b.match(/~|\^|[A-Za-z]+|\d+/g) || [];
54
+ for (let k = 0; k < Math.max(sa.length, sb.length); k++) {
55
+ const x = sa[k], y = sb[k];
56
+ if (x === '~' || y === '~') { if (x !== y) return x === '~' ? -1 : 1; continue; }
57
+ if (x === '^' || y === '^') {
58
+ if (x === undefined) return -1;
59
+ if (y === undefined) return 1;
60
+ if (x !== y) return x === '^' ? 1 : -1;
61
+ continue;
62
+ }
63
+ if (x === undefined) return -1;
64
+ if (y === undefined) return 1;
65
+ const xn = /^\d/.test(x), yn = /^\d/.test(y);
66
+ if (xn !== yn) return xn ? 1 : -1;
67
+ if (xn) {
68
+ const xs = x.replace(/^0+/, ''), ys = y.replace(/^0+/, '');
69
+ if (xs.length !== ys.length) return xs.length - ys.length;
70
+ if (xs !== ys) return xs < ys ? -1 : 1;
71
+ } else if (x !== y) return x < y ? -1 : 1;
72
+ }
73
+ return 0;
74
+ }
75
+ export function splitRpm(v) {
76
+ let epoch = 0, rest = v;
77
+ const colon = v.indexOf(':');
78
+ if (colon > 0) { epoch = parseInt(v.slice(0, colon), 10) || 0; rest = v.slice(colon + 1); }
79
+ const dash = rest.lastIndexOf('-');
80
+ return dash > 0 ? [epoch, rest.slice(0, dash), rest.slice(dash + 1)] : [epoch, rest, ''];
81
+ }
82
+ export function rpmCompare(a, b) {
83
+ const [ea, va, ra] = splitRpm(a), [eb, vb, rb] = splitRpm(b);
84
+ if (ea !== eb) return ea - eb;
85
+ return rpmSegment(va, vb) || rpmSegment(ra, rb);
86
+ }
87
+
88
+ // Desktop application versions: compare the numbers ("6.2.5 (42037)" -> 6.2.5.42037 is avoided by
89
+ // cutting at the first space or parenthesis).
90
+ function appNumbers(v) { return (String(v).split(/[\s(]/)[0].match(/\d+/g) || []).map(Number); }
91
+ export function appCompare(a, b) {
92
+ const x = appNumbers(a), y = appNumbers(b);
93
+ for (let k = 0; k < Math.max(x.length, y.length); k++) {
94
+ const d = (x[k] || 0) - (y[k] || 0);
95
+ if (d) return d;
96
+ }
97
+ return 0;
98
+ }
99
+
100
+ // --- Matching ------------------------------------------------------------------------------------
101
+ // Linux release key in the bundle, from the inventory's os-release fields.
102
+ export function releaseKey(inventory) {
103
+ const id = String(inventory.distro_id || '').toLowerCase();
104
+ const version = String(inventory.version_id || '');
105
+ const major = version.split('.')[0];
106
+ if (id === 'ubuntu') return `ubuntu:${version}`;
107
+ if (id === 'debian') return `debian:${major}`;
108
+ if (id === 'rhel') return `rhel:${major}`;
109
+ if (id === 'almalinux') return `almalinux:${major}`;
110
+ if (id === 'rocky') return `rocky:${major}`;
111
+ return null;
112
+ }
113
+
114
+ export function matchLinux(inventory, bundle) {
115
+ const key = releaseKey(inventory);
116
+ const advisories = key && bundle.releases[key];
117
+ if (!advisories) return { covered: false, reason: `${inventory.pretty_name || inventory.distro_id || 'this distribution'} is not in the vulnerability data (covered: ${Object.keys(bundle.releases).join(', ')})` };
118
+ const deb = inventory.kind === 'deb';
119
+ const compare = deb ? dpkgCompare : rpmCompare;
120
+ // Group installed packages by source package: Debian/Ubuntu advisories name source packages; Red Hat-family
121
+ // advisories name binary packages, sometimes only the main one (openssl) when sub-packages (openssl-libs) are installed.
122
+ const installed = new Map();
123
+ for (const pkg of inventory.packages || []) {
124
+ const group = pkg.source || pkg.name;
125
+ const version = deb ? (pkg.source_version || pkg.version) : pkg.version;
126
+ const names = deb ? [group] : [pkg.name, group];
127
+ const prev = installed.get(group);
128
+ if (!prev) installed.set(group, { version, names: new Set(names) });
129
+ else {
130
+ names.forEach(n => prev.names.add(n));
131
+ if (compare(version, prev.version) > 0) prev.version = version;
132
+ }
133
+ }
134
+ const hits = [];
135
+ for (const [group, { version, names }] of installed) {
136
+ const seen = new Set();
137
+ for (const name of names) {
138
+ for (const [cves, advisory, fixed, severity] of advisories[name] || []) {
139
+ const key = `${cves}|${fixed}`;
140
+ if (seen.has(key)) continue;
141
+ seen.add(key);
142
+ // Red Hat module streams: only compare within the same major version (nodejs 20 fix does not apply to nodejs 22).
143
+ if (!deb && /\.module[+_]/.test(fixed) && splitRpm(fixed)[1].split('.')[0] !== splitRpm(version)[1].split('.')[0]) continue;
144
+ if (compare(version, fixed) < 0) hits.push({ software: group, version, fixed, severity, cves: cves.split('|'), advisory });
145
+ }
146
+ }
147
+ }
148
+ return { covered: true, source: key, checked: installed.size, hits };
149
+ }
150
+
151
+ export function matchApps(inventory, bundle, osFamily) {
152
+ const hits = [];
153
+ const matched = new Set();
154
+ for (const app of inventory.apps || []) {
155
+ if (!app.version) continue;
156
+ for (const product of bundle.apps) {
157
+ const pattern = product[osFamily];
158
+ if (!pattern || !new RegExp(pattern, 'i').test(app.name)) continue;
159
+ matched.add(product.id);
160
+ for (const [cve, severity, si, se, ei, ee, exact] of product.cves) {
161
+ const c = bound => appCompare(app.version, bound);
162
+ const affected = exact ? c(exact) === 0
163
+ : (!si || c(si) >= 0) && (!se || c(se) > 0) && (!ei || c(ei) <= 0) && (!ee || c(ee) < 0);
164
+ if (affected) hits.push({ software: app.name, version: app.version, fixed: ee ? ee : '', severity, cves: [cve], advisory: '' });
165
+ }
166
+ break;
167
+ }
168
+ }
169
+ return { covered: true, source: 'nvd', checked: (inventory.apps || []).length, recognised: matched.size, hits };
170
+ }
171
+
172
+ // Group hits per software and CVE; flag actively exploited CVEs (CISA KEV) and likely ones (EPSS).
173
+ export function summarise(result, bundle) {
174
+ const kev = new Set(bundle.kev);
175
+ const rank = { critical: 0, high: 1, medium: 2, low: 3, unknown: 4 };
176
+ const perCve = new Map();
177
+ for (const hit of result.hits) {
178
+ for (const cve of hit.cves) {
179
+ const id = `${hit.software}|${cve}`;
180
+ const prev = perCve.get(id);
181
+ if (!prev || rank[hit.severity] < rank[prev.severity]) perCve.set(id, { ...hit, cve, exploited: kev.has(cve), epss: bundle.epss[cve] || 0 });
182
+ }
183
+ }
184
+ const all = [...perCve.values()];
185
+ const severe = all.filter(h => SEVERE.has(h.severity) || h.exploited);
186
+ const bySoftware = new Map();
187
+ for (const h of severe) {
188
+ const s = bySoftware.get(h.software) || { software: h.software, version: h.version, fixed: '', cves: [], worst: 'unknown', exploited: false, epss: 0 };
189
+ s.cves.push(h.cve);
190
+ if (rank[h.severity] < rank[s.worst]) s.worst = h.severity;
191
+ s.exploited = s.exploited || h.exploited;
192
+ s.epss = Math.max(s.epss, h.epss);
193
+ if (h.fixed && (!s.fixed || appCompare(h.fixed, s.fixed) > 0 || /[~+]/.test(h.fixed))) s.fixed = h.fixed;
194
+ bySoftware.set(h.software, s);
195
+ }
196
+ const groups = [...bySoftware.values()].sort((a, b) => (b.exploited - a.exploited) || (rank[a.worst] - rank[b.worst]) || (b.cves.length - a.cves.length));
197
+ return { severeCount: new Set(severe.map(h => h.cve)).size, otherCount: new Set(all.filter(h => !severe.includes(h)).map(h => h.cve)).size, groups };
198
+ }
199
+
200
+
201
+ export function ageDays(isoDate, now) { return Math.floor((now - Date.parse(isoDate)) / 86400000); }
202
+
203
+ // Turn a match into the vuln.known_vulnerabilities result fields (same wording on every platform).
204
+ export function knownVulnResult(matched, inventory, loaded, osFamily, maxSevere, now = Date.now()) {
205
+ const { bundle, manifest } = loaded;
206
+ if (!matched.covered) return { status: 'Pending', actual: 'not covered', detail: `${matched.reason}.` };
207
+ const dataDate = manifest.built_at.slice(0, 10);
208
+ const age = ageDays(manifest.built_at, now);
209
+ const ageNote = age > MAX_AGE_DAYS ? ` Warning: the vulnerability data is ${age} days old; newer vulnerabilities are not included.` : '';
210
+ const s = summarise(matched, bundle);
211
+ const scope = osFamily === 'linux'
212
+ ? `${matched.checked} ${inventory.kind === 'deb' ? 'source packages' : 'packages'} checked against ${matched.source} advisories`
213
+ : osFamily === 'ios'
214
+ ? `${(inventory.apps || [])[0] ? inventory.apps[0].name + ' ' + inventory.apps[0].version : 'iOS'} checked against NVD iOS records`
215
+ : `${matched.checked} applications inventoried, ${matched.recognised} recognised (${bundle.apps.filter(a => a[osFamily]).length} common applications covered)`;
216
+ const listed = s.groups.slice(0, 5).map(g => {
217
+ const tags = [`${g.cves.length} CVE${g.cves.length > 1 ? 's' : ''}`, g.worst];
218
+ if (g.exploited) tags.push('actively exploited');
219
+ return `${g.software}${g.software.endsWith(g.version) ? '' : ` ${g.version}`} (${tags.join(', ')}${g.fixed ? `; fixed in ${g.fixed}` : ''}; e.g. ${g.cves.slice(0, 2).join(', ')})`;
220
+ });
221
+ return {
222
+ status: s.severeCount <= maxSevere ? 'Compliant' : 'Non-Compliant',
223
+ actual: `${s.severeCount} critical/high`,
224
+ detail: `Matched against offline OSV/NVD data from ${dataDate}. ${scope}.`
225
+ + (listed.length ? ` ${listed.join('; ')}${s.groups.length > 5 ? `; and ${s.groups.length - 5} more` : ''}.` : '')
226
+ + ` ${s.otherCount} medium/low.${ageNote}`,
227
+ };
228
+ }
app/test/prompt-parity.mjs CHANGED
@@ -9,7 +9,7 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '.
9
  const read = (...p) => JSON.parse(readFileSync(path.join(root, ...p), 'utf8'));
10
  const lib = new Library({ catalog: read('catalog', 'checks.json'), controls: read('library', 'controls.json'),
11
  guidance: read('library', 'check_guidance.json'), aiTools: read('library', 'ai_tools.json') });
12
- const evidence = readFileSync(path.join(root, 'synthetic', process.env.EVIDENCE_DIR || 'out-v0.2', 'evidence.jsonl'), 'utf8').trim().split('\n').slice(0, 30).map(JSON.parse);
13
 
14
  const python = execFileSync('python3', ['-c', `
15
  import json, os, sys
@@ -17,7 +17,7 @@ sys.path.insert(0, "training")
17
  import build_dataset as b
18
  lib = b.Library()
19
  out = []
20
- for line in list(open("synthetic/" + os.environ.get("EVIDENCE_DIR", "out-v0.2") + "/evidence.jsonl"))[:30]:
21
  r = json.loads(line)
22
  item = {"findings": [b.build_finding(r, x, lib, __import__("random").Random(0))[0] for x in r["results"]],
23
  "summary": b.build_summary(r, lib)[0]}
 
9
  const read = (...p) => JSON.parse(readFileSync(path.join(root, ...p), 'utf8'));
10
  const lib = new Library({ catalog: read('catalog', 'checks.json'), controls: read('library', 'controls.json'),
11
  guidance: read('library', 'check_guidance.json'), aiTools: read('library', 'ai_tools.json') });
12
+ const evidence = readFileSync(path.join(root, 'synthetic', process.env.EVIDENCE_DIR || 'out-v0.3', 'evidence.jsonl'), 'utf8').trim().split('\n').slice(0, 60).map(JSON.parse);
13
 
14
  const python = execFileSync('python3', ['-c', `
15
  import json, os, sys
 
17
  import build_dataset as b
18
  lib = b.Library()
19
  out = []
20
+ for line in list(open("synthetic/" + os.environ.get("EVIDENCE_DIR", "out-v0.3") + "/evidence.jsonl"))[:60]:
21
  r = json.loads(line)
22
  item = {"findings": [b.build_finding(r, x, lib, __import__("random").Random(0))[0] for x in r["results"]],
23
  "summary": b.build_summary(r, lib)[0]}
app/vulndb.cjs CHANGED
@@ -10,103 +10,8 @@ const zlib = require('node:zlib');
10
 
11
  // Ed25519 public key that signs bundles (private key stays with Devseis, outside the repository).
12
  const PUBLIC_KEY_FILE = path.join(__dirname, 'vulndb-public-key.pem');
13
- // Where clients download bundles from; null until the bundle is hosted publicly.
14
- const VULNDB_URL = null;
15
- const MAX_AGE_DAYS = 14;
16
- const SEVERE = new Set(['critical', 'high']);
17
-
18
- // --- Version comparison ----------------------------------------------------------------------
19
- // Debian policy 5.6.12: [epoch:]upstream[-revision]; letters sort before non-letters, '~' before everything.
20
- function dpkgOrder(c) {
21
- if (c === '~') return -1;
22
- if (/\d/.test(c)) return 0;
23
- if (!c) return 0;
24
- if (/[A-Za-z]/.test(c)) return c.charCodeAt(0);
25
- return c.charCodeAt(0) + 256;
26
- }
27
- function dpkgPart(a, b) {
28
- let i = 0, j = 0;
29
- while (i < a.length || j < b.length) {
30
- let diff = 0;
31
- while ((i < a.length && !/\d/.test(a[i])) || (j < b.length && !/\d/.test(b[j]))) {
32
- const ac = dpkgOrder(a[i] || ''), bc = dpkgOrder(b[j] || '');
33
- if (ac !== bc) return ac - bc;
34
- i++; j++;
35
- }
36
- while (a[i] === '0') i++;
37
- while (b[j] === '0') j++;
38
- while (/\d/.test(a[i] || '') && /\d/.test(b[j] || '')) {
39
- if (!diff) diff = a.charCodeAt(i) - b.charCodeAt(j);
40
- i++; j++;
41
- }
42
- if (/\d/.test(a[i] || '')) return 1;
43
- if (/\d/.test(b[j] || '')) return -1;
44
- if (diff) return diff;
45
- }
46
- return 0;
47
- }
48
- function splitDeb(v) {
49
- let epoch = 0, rest = v;
50
- const colon = v.indexOf(':');
51
- if (colon > 0) { epoch = parseInt(v.slice(0, colon), 10) || 0; rest = v.slice(colon + 1); }
52
- const dash = rest.lastIndexOf('-');
53
- return dash > 0 ? [epoch, rest.slice(0, dash), rest.slice(dash + 1)] : [epoch, rest, ''];
54
- }
55
- function dpkgCompare(a, b) {
56
- const [ea, ua, ra] = splitDeb(a), [eb, ub, rb] = splitDeb(b);
57
- if (ea !== eb) return ea - eb;
58
- return dpkgPart(ua, ub) || dpkgPart(ra, rb);
59
- }
60
-
61
- // rpmvercmp, with epoch:version-release handling.
62
- function rpmSegment(a, b) {
63
- if (a === b) return 0;
64
- const sa = a.match(/~|\^|[A-Za-z]+|\d+/g) || [], sb = b.match(/~|\^|[A-Za-z]+|\d+/g) || [];
65
- for (let k = 0; k < Math.max(sa.length, sb.length); k++) {
66
- const x = sa[k], y = sb[k];
67
- if (x === '~' || y === '~') { if (x !== y) return x === '~' ? -1 : 1; continue; }
68
- if (x === '^' || y === '^') {
69
- if (x === undefined) return -1;
70
- if (y === undefined) return 1;
71
- if (x !== y) return x === '^' ? 1 : -1;
72
- continue;
73
- }
74
- if (x === undefined) return -1;
75
- if (y === undefined) return 1;
76
- const xn = /^\d/.test(x), yn = /^\d/.test(y);
77
- if (xn !== yn) return xn ? 1 : -1;
78
- if (xn) {
79
- const xs = x.replace(/^0+/, ''), ys = y.replace(/^0+/, '');
80
- if (xs.length !== ys.length) return xs.length - ys.length;
81
- if (xs !== ys) return xs < ys ? -1 : 1;
82
- } else if (x !== y) return x < y ? -1 : 1;
83
- }
84
- return 0;
85
- }
86
- function splitRpm(v) {
87
- let epoch = 0, rest = v;
88
- const colon = v.indexOf(':');
89
- if (colon > 0) { epoch = parseInt(v.slice(0, colon), 10) || 0; rest = v.slice(colon + 1); }
90
- const dash = rest.lastIndexOf('-');
91
- return dash > 0 ? [epoch, rest.slice(0, dash), rest.slice(dash + 1)] : [epoch, rest, ''];
92
- }
93
- function rpmCompare(a, b) {
94
- const [ea, va, ra] = splitRpm(a), [eb, vb, rb] = splitRpm(b);
95
- if (ea !== eb) return ea - eb;
96
- return rpmSegment(va, vb) || rpmSegment(ra, rb);
97
- }
98
-
99
- // Desktop application versions: compare the numbers ("6.2.5 (42037)" -> 6.2.5.42037 is avoided by
100
- // cutting at the first space or parenthesis).
101
- function appNumbers(v) { return (String(v).split(/[\s(]/)[0].match(/\d+/g) || []).map(Number); }
102
- function appCompare(a, b) {
103
- const x = appNumbers(a), y = appNumbers(b);
104
- for (let k = 0; k < Math.max(x.length, y.length); k++) {
105
- const d = (x[k] || 0) - (y[k] || 0);
106
- if (d) return d;
107
- }
108
- return 0;
109
- }
110
 
111
  // --- Bundle loading -------------------------------------------------------------------------------
112
  function verifyBundle(dir) {
@@ -163,107 +68,9 @@ function load(dirs) {
163
  return { error: errors.join('; ') || 'no vulnerability bundle found' };
164
  }
165
 
166
- // --- Matching ------------------------------------------------------------------------------------
167
- // Linux release key in the bundle, from the inventory's os-release fields.
168
- function releaseKey(inventory) {
169
- const id = String(inventory.distro_id || '').toLowerCase();
170
- const version = String(inventory.version_id || '');
171
- const major = version.split('.')[0];
172
- if (id === 'ubuntu') return `ubuntu:${version}`;
173
- if (id === 'debian') return `debian:${major}`;
174
- if (id === 'rhel') return `rhel:${major}`;
175
- if (id === 'almalinux') return `almalinux:${major}`;
176
- if (id === 'rocky') return `rocky:${major}`;
177
- return null;
178
- }
179
-
180
- function matchLinux(inventory, bundle) {
181
- const key = releaseKey(inventory);
182
- const advisories = key && bundle.releases[key];
183
- if (!advisories) return { covered: false, reason: `${inventory.pretty_name || inventory.distro_id || 'this distribution'} is not in the vulnerability data (covered: ${Object.keys(bundle.releases).join(', ')})` };
184
- const deb = inventory.kind === 'deb';
185
- const compare = deb ? dpkgCompare : rpmCompare;
186
- // Group installed packages by source package: Debian/Ubuntu advisories name source packages; Red Hat-family
187
- // advisories name binary packages, sometimes only the main one (openssl) when sub-packages (openssl-libs) are installed.
188
- const installed = new Map();
189
- for (const pkg of inventory.packages || []) {
190
- const group = pkg.source || pkg.name;
191
- const version = deb ? (pkg.source_version || pkg.version) : pkg.version;
192
- const names = deb ? [group] : [pkg.name, group];
193
- const prev = installed.get(group);
194
- if (!prev) installed.set(group, { version, names: new Set(names) });
195
- else {
196
- names.forEach(n => prev.names.add(n));
197
- if (compare(version, prev.version) > 0) prev.version = version;
198
- }
199
- }
200
- const hits = [];
201
- for (const [group, { version, names }] of installed) {
202
- const seen = new Set();
203
- for (const name of names) {
204
- for (const [cves, advisory, fixed, severity] of advisories[name] || []) {
205
- const key = `${cves}|${fixed}`;
206
- if (seen.has(key)) continue;
207
- seen.add(key);
208
- // Red Hat module streams: only compare within the same major version (nodejs 20 fix does not apply to nodejs 22).
209
- if (!deb && /\.module[+_]/.test(fixed) && splitRpm(fixed)[1].split('.')[0] !== splitRpm(version)[1].split('.')[0]) continue;
210
- if (compare(version, fixed) < 0) hits.push({ software: group, version, fixed, severity, cves: cves.split('|'), advisory });
211
- }
212
- }
213
- }
214
- return { covered: true, source: key, checked: installed.size, hits };
215
- }
216
-
217
- function matchApps(inventory, bundle, osFamily) {
218
- const hits = [];
219
- const matched = new Set();
220
- for (const app of inventory.apps || []) {
221
- if (!app.version) continue;
222
- for (const product of bundle.apps) {
223
- const pattern = product[osFamily];
224
- if (!pattern || !new RegExp(pattern, 'i').test(app.name)) continue;
225
- matched.add(product.id);
226
- for (const [cve, severity, si, se, ei, ee, exact] of product.cves) {
227
- const c = bound => appCompare(app.version, bound);
228
- const affected = exact ? c(exact) === 0
229
- : (!si || c(si) >= 0) && (!se || c(se) > 0) && (!ei || c(ei) <= 0) && (!ee || c(ee) < 0);
230
- if (affected) hits.push({ software: app.name, version: app.version, fixed: ee ? ee : '', severity, cves: [cve], advisory: '' });
231
- }
232
- break;
233
- }
234
- }
235
- return { covered: true, source: 'nvd', checked: (inventory.apps || []).length, recognised: matched.size, hits };
236
- }
237
-
238
- // Group hits per software and CVE; flag actively exploited CVEs (CISA KEV) and likely ones (EPSS).
239
- function summarise(result, bundle) {
240
- const kev = new Set(bundle.kev);
241
- const rank = { critical: 0, high: 1, medium: 2, low: 3, unknown: 4 };
242
- const perCve = new Map();
243
- for (const hit of result.hits) {
244
- for (const cve of hit.cves) {
245
- const id = `${hit.software}|${cve}`;
246
- const prev = perCve.get(id);
247
- if (!prev || rank[hit.severity] < rank[prev.severity]) perCve.set(id, { ...hit, cve, exploited: kev.has(cve), epss: bundle.epss[cve] || 0 });
248
- }
249
- }
250
- const all = [...perCve.values()];
251
- const severe = all.filter(h => SEVERE.has(h.severity) || h.exploited);
252
- const bySoftware = new Map();
253
- for (const h of severe) {
254
- const s = bySoftware.get(h.software) || { software: h.software, version: h.version, fixed: '', cves: [], worst: 'unknown', exploited: false, epss: 0 };
255
- s.cves.push(h.cve);
256
- if (rank[h.severity] < rank[s.worst]) s.worst = h.severity;
257
- s.exploited = s.exploited || h.exploited;
258
- s.epss = Math.max(s.epss, h.epss);
259
- if (h.fixed && (!s.fixed || appCompare(h.fixed, s.fixed) > 0 || /[~+]/.test(h.fixed))) s.fixed = h.fixed;
260
- bySoftware.set(h.software, s);
261
- }
262
- const groups = [...bySoftware.values()].sort((a, b) => (b.exploited - a.exploited) || (rank[a.worst] - rank[b.worst]) || (b.cves.length - a.cves.length));
263
- return { severeCount: new Set(severe.map(h => h.cve)).size, otherCount: new Set(all.filter(h => !severe.includes(h)).map(h => h.cve)).size, groups };
264
- }
265
-
266
- function ageDays(isoDate, now) { return Math.floor((now - Date.parse(isoDate)) / 86400000); }
267
 
268
  // Replace the collector's "Pending" vuln.known_vulnerabilities result with the matched one.
269
  function apply(evidence, inventory, loaded, maxSevere, now = Date.now()) {
@@ -274,35 +81,12 @@ function apply(evidence, inventory, loaded, maxSevere, now = Date.now()) {
274
  result.detail = `${result.detail || ''} No vulnerability data available: ${loaded.error}.`.trim();
275
  return evidence;
276
  }
277
- const { bundle, manifest } = loaded;
278
- const dataDate = manifest.built_at.slice(0, 10);
279
- const age = ageDays(manifest.built_at, now);
280
- const ageNote = age > MAX_AGE_DAYS ? ` Warning: the vulnerability data is ${age} days old; newer vulnerabilities are not included.` : '';
281
  const osFamily = evidence.host && evidence.host.os_family;
282
- const matched = osFamily === 'linux' ? matchLinux(inventory, bundle) : matchApps(inventory, bundle, osFamily);
283
- if (!matched.covered) {
284
- result.status = 'Pending';
285
- result.actual = 'not covered';
286
- result.detail = `${matched.reason}.`;
287
- return evidence;
288
- }
289
- const s = summarise(matched, bundle);
290
- const scope = osFamily === 'linux'
291
- ? `${matched.checked} ${inventory.kind === 'deb' ? 'source packages' : 'packages'} checked against ${matched.source} advisories`
292
- : `${matched.checked} applications inventoried, ${matched.recognised} recognised (${bundle.apps.length} common applications covered)`;
293
- const listed = s.groups.slice(0, 5).map(g => {
294
- const tags = [`${g.cves.length} CVE${g.cves.length > 1 ? 's' : ''}`, g.worst];
295
- if (g.exploited) tags.push('actively exploited');
296
- return `${g.software} ${g.version} (${tags.join(', ')}${g.fixed ? `; fixed in ${g.fixed}` : ''}; e.g. ${g.cves.slice(0, 2).join(', ')})`;
297
- });
298
- result.actual = `${s.severeCount} critical/high`;
299
- result.status = s.severeCount <= maxSevere ? 'Compliant' : 'Non-Compliant';
300
- result.detail = `Matched against offline OSV/NVD data from ${dataDate}. ${scope}.`
301
- + (listed.length ? ` ${listed.join('; ')}${s.groups.length > 5 ? `; and ${s.groups.length - 5} more` : ''}.` : '')
302
- + ` ${s.otherCount} medium/low.${ageNote}`;
303
  evidence.summary = { Compliant: 0, 'Non-Compliant': 0, NotApplicable: 0, Pending: 0, Error: 0 };
304
  for (const r of evidence.results) evidence.summary[r.status] += 1;
305
  return evidence;
306
  }
307
 
308
- module.exports = { dpkgCompare, rpmCompare, appCompare, verifyBundle, refresh, load, releaseKey, matchLinux, matchApps, summarise, apply, MAX_AGE_DAYS };
 
10
 
11
  // Ed25519 public key that signs bundles (private key stays with Devseis, outside the repository).
12
  const PUBLIC_KEY_FILE = path.join(__dirname, 'vulndb-public-key.pem');
13
+ // Where clients download bundles from (public Hugging Face dataset).
14
+ const VULNDB_URL = 'https://huggingface.co/datasets/Devseis/endpoint-auditor-vulndb/resolve/main';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
  // --- Bundle loading -------------------------------------------------------------------------------
17
  function verifyBundle(dir) {
 
68
  return { error: errors.join('; ') || 'no vulnerability bundle found' };
69
  }
70
 
71
+ // --- Matching (rules in renderer/vuln-core.js, shared with the phone web check) -------------------
72
+ const core = require('./renderer/vuln-core.js');
73
+ const { dpkgCompare, rpmCompare, appCompare, releaseKey, matchLinux, matchApps, summarise, knownVulnResult } = core;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
 
75
  // Replace the collector's "Pending" vuln.known_vulnerabilities result with the matched one.
76
  function apply(evidence, inventory, loaded, maxSevere, now = Date.now()) {
 
81
  result.detail = `${result.detail || ''} No vulnerability data available: ${loaded.error}.`.trim();
82
  return evidence;
83
  }
 
 
 
 
84
  const osFamily = evidence.host && evidence.host.os_family;
85
+ const matched = osFamily === 'linux' ? matchLinux(inventory, loaded.bundle) : matchApps(inventory, loaded.bundle, osFamily);
86
+ Object.assign(result, knownVulnResult(matched, inventory, loaded, osFamily, maxSevere, now));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  evidence.summary = { Compliant: 0, 'Non-Compliant': 0, NotApplicable: 0, Pending: 0, Error: 0 };
88
  for (const r of evidence.results) evidence.summary[r.status] += 1;
89
  return evidence;
90
  }
91
 
92
+ module.exports = { dpkgCompare, rpmCompare, appCompare, verifyBundle, refresh, load, releaseKey, matchLinux, matchApps, summarise, apply, MAX_AGE_DAYS: core.MAX_AGE_DAYS };
baseline.conf CHANGED
@@ -63,3 +63,9 @@ PERSONAL_DATA_SCAN=0
63
  # --- EU AI Act -------------------------------------------------------------
64
  # AI tools allowed on endpoints, as named in the AI software inventory (e.g. Microsoft Copilot,ChatGPT).
65
  APPROVED_AI_TOOLS=
 
 
 
 
 
 
 
63
  # --- EU AI Act -------------------------------------------------------------
64
  # AI tools allowed on endpoints, as named in the AI software inventory (e.g. Microsoft Copilot,ChatGPT).
65
  APPROVED_AI_TOOLS=
66
+
67
+ # Phones (mobile web check)
68
+ IOS_MIN_MAJOR=18
69
+ ANDROID_MIN_MAJOR=13
70
+ MOBILE_PATCH_MAX_AGE_DAYS=90
71
+ MOBILE_PASSCODE_MIN_DIGITS=6
catalog/checks.json CHANGED
@@ -1,6 +1,12 @@
1
  {
2
- "catalog_version": "0.2.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.",
@@ -9,10 +15,22 @@
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": {
@@ -71,82 +89,1804 @@
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": ["windows", "linux", "macos"], "baseline_keys": [], "rule": "The screen locks when idle (Windows: machine inactivity limit or a password-protected screen saver).", "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 (baseline: 300 = 5 minutes).", "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.device_access", "section": "baseline", "name": "USB storage device access", "os": ["windows", "linux", "macos"], "baseline_keys": ["USB_DEVICE_ACCESS"], "rule": "Whether USB mass storage devices can connect at all equals USB_DEVICE_ACCESS (allow/deny): driver, device-installation policy, USBGuard or MDM restriction.", "controls": { "iso27001": ["A.7.10", "A.8.12"], "gdpr": ["Art. 32(1)(b)"], "ai_act": [] } },
109
- { "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": [] } },
110
- { "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": [] } },
111
-
112
- { "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": [] } },
113
- { "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": [] } },
114
-
115
- { "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": [] } },
116
- { "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": [] } },
117
- { "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": [] } },
118
- { "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": [] } },
119
- { "id": "user.account_type", "section": "baseline", "name": "Signed-in user account type", "os": ["windows", "linux", "macos"], "baseline_keys": ["APPROVED_ADMINS"], "rule": "The everyday signed-in user is a Standard account (not an administrator), unless listed in APPROVED_ADMINS.", "controls": { "iso27001": ["A.8.2", "A.5.15"], "gdpr": ["Art. 32(1)(b)"], "ai_act": [] } },
120
- { "id": "vuln.pending_updates", "section": "baseline", "name": "Pending security updates", "os": ["windows", "linux", "macos"], "baseline_keys": ["MAX_PENDING_SECURITY_UPDATES"], "rule": "Security updates offered by the OS vendor but not yet installed <= MAX_PENDING_SECURITY_UPDATES.", "controls": { "iso27001": ["A.8.8"], "gdpr": ["Art. 32(1)(b)", "Art. 32(1)(d)"], "ai_act": [] } },
121
- { "id": "vuln.outdated_apps", "section": "baseline", "name": "Outdated third-party applications", "os": ["windows", "linux", "macos"], "baseline_keys": ["MAX_OUTDATED_APPS"], "rule": "Installed third-party applications with a newer version available <= MAX_OUTDATED_APPS (Windows: winget; macOS: app versions; Linux: snap/flatpak and packages).", "controls": { "iso27001": ["A.8.8", "A.8.19"], "gdpr": ["Art. 32(1)(b)"], "ai_act": [] } },
122
- { "id": "vuln.known_vulnerabilities", "section": "baseline", "name": "Known vulnerabilities in installed software", "os": ["windows", "linux", "macos"], "baseline_keys": ["MAX_CRITICAL_HIGH_VULNERABILITIES"], "rule": "Installed software versions matched against an offline copy of the OSV/NVD vulnerability data: critical or high vulnerabilities <= MAX_CRITICAL_HIGH_VULNERABILITIES.", "controls": { "iso27001": ["A.8.8"], "gdpr": ["Art. 32(1)(b)"], "ai_act": [] } },
123
-
124
- { "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": [] } },
125
- { "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": [] } },
126
- { "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": [] } },
127
- { "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": [] } },
128
- { "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": [] } },
129
- { "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": [] } },
130
- { "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": [] } },
131
- { "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": [] } },
132
- { "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": [] } },
133
- { "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": [] } },
134
- { "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": [] } },
135
- { "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": [] } },
136
-
137
- { "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"] } },
138
- { "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"] } },
139
- { "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"] } },
140
- { "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"] } },
141
- { "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)"] } },
142
-
143
- { "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": [] } },
144
- { "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": [] } },
145
- { "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": [] } },
146
- { "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": [] } },
147
- { "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"] } },
148
- { "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"] } },
149
- { "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"] } },
150
- { "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"] } }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
  ]
152
  }
 
1
  {
2
+ "catalog_version": "0.3.0",
3
+ "statuses": [
4
+ "Compliant",
5
+ "Non-Compliant",
6
+ "NotApplicable",
7
+ "Pending",
8
+ "Error"
9
+ ],
10
  "status_meaning": {
11
  "Compliant": "The device meets the baseline value.",
12
  "Non-Compliant": "The device does not meet the baseline value.",
 
15
  "Error": "The value could not be read (for example the collector was not run as administrator/root)."
16
  },
17
  "sections": [
18
+ {
19
+ "id": "baseline",
20
+ "title": "COMPLIANCE RESULTS"
21
+ },
22
+ {
23
+ "id": "gdpr",
24
+ "title": "GDPR TECHNICAL CONTROLS"
25
+ },
26
+ {
27
+ "id": "aiact",
28
+ "title": "EU AI ACT CONTROLS"
29
+ },
30
+ {
31
+ "id": "org",
32
+ "title": "ORGANISATIONAL CONTROLS"
33
+ }
34
  ],
35
  "references": {
36
  "iso27001": {
 
89
  }
90
  },
91
  "checks": [
92
+ {
93
+ "id": "password.min_length",
94
+ "section": "baseline",
95
+ "name": "Minimum password length",
96
+ "os": [
97
+ "windows",
98
+ "linux",
99
+ "macos"
100
+ ],
101
+ "baseline_keys": [
102
+ "PASSWORD_MIN_LENGTH"
103
+ ],
104
+ "rule": "Configured minimum length >= PASSWORD_MIN_LENGTH.",
105
+ "controls": {
106
+ "iso27001": [
107
+ "A.5.17",
108
+ "A.8.5"
109
+ ],
110
+ "gdpr": [
111
+ "Art. 32(1)(b)"
112
+ ],
113
+ "ai_act": []
114
+ }
115
+ },
116
+ {
117
+ "id": "password.history",
118
+ "section": "baseline",
119
+ "name": "Password history",
120
+ "os": [
121
+ "windows",
122
+ "linux",
123
+ "macos"
124
+ ],
125
+ "baseline_keys": [
126
+ "PASSWORD_HISTORY"
127
+ ],
128
+ "rule": "Remembered passwords >= PASSWORD_HISTORY.",
129
+ "controls": {
130
+ "iso27001": [
131
+ "A.5.17",
132
+ "A.8.5"
133
+ ],
134
+ "gdpr": [
135
+ "Art. 32(1)(b)"
136
+ ],
137
+ "ai_act": []
138
+ }
139
+ },
140
+ {
141
+ "id": "password.expiration",
142
+ "section": "baseline",
143
+ "name": "Password expiration",
144
+ "os": [
145
+ "windows",
146
+ "linux",
147
+ "macos"
148
+ ],
149
+ "baseline_keys": [
150
+ "PASSWORD_MAX_AGE_DAYS"
151
+ ],
152
+ "rule": "Passwords expire after 1..PASSWORD_MAX_AGE_DAYS days (0 in the baseline = expiry not required).",
153
+ "controls": {
154
+ "iso27001": [
155
+ "A.5.17"
156
+ ],
157
+ "gdpr": [
158
+ "Art. 32(1)(b)"
159
+ ],
160
+ "ai_act": []
161
+ }
162
+ },
163
+ {
164
+ "id": "password.min_age",
165
+ "section": "baseline",
166
+ "name": "Minimum password age",
167
+ "os": [
168
+ "windows",
169
+ "linux",
170
+ "macos"
171
+ ],
172
+ "baseline_keys": [
173
+ "PASSWORD_MIN_AGE_DAYS"
174
+ ],
175
+ "rule": "Minimum age >= PASSWORD_MIN_AGE_DAYS days. macOS has no such setting (NotApplicable).",
176
+ "controls": {
177
+ "iso27001": [
178
+ "A.5.17"
179
+ ],
180
+ "gdpr": [
181
+ "Art. 32(1)(b)"
182
+ ],
183
+ "ai_act": []
184
+ }
185
+ },
186
+ {
187
+ "id": "password.complexity",
188
+ "section": "baseline",
189
+ "name": "Password complexity",
190
+ "os": [
191
+ "windows",
192
+ "linux",
193
+ "macos"
194
+ ],
195
+ "baseline_keys": [
196
+ "PASSWORD_COMPLEXITY"
197
+ ],
198
+ "rule": "Complexity enforced (mixed character classes).",
199
+ "controls": {
200
+ "iso27001": [
201
+ "A.5.17",
202
+ "A.8.5"
203
+ ],
204
+ "gdpr": [
205
+ "Art. 32(1)(b)"
206
+ ],
207
+ "ai_act": []
208
+ }
209
+ },
210
+ {
211
+ "id": "lockout.threshold",
212
+ "section": "baseline",
213
+ "name": "Account lockout threshold",
214
+ "os": [
215
+ "windows",
216
+ "linux",
217
+ "macos"
218
+ ],
219
+ "baseline_keys": [
220
+ "LOCKOUT_THRESHOLD_MAX"
221
+ ],
222
+ "rule": "Lockout after 1..LOCKOUT_THRESHOLD_MAX failed attempts (0 = never locks).",
223
+ "controls": {
224
+ "iso27001": [
225
+ "A.8.5"
226
+ ],
227
+ "gdpr": [
228
+ "Art. 32(1)(b)"
229
+ ],
230
+ "ai_act": []
231
+ }
232
+ },
233
+ {
234
+ "id": "lockout.duration",
235
+ "section": "baseline",
236
+ "name": "Account lockout duration",
237
+ "os": [
238
+ "windows",
239
+ "linux",
240
+ "macos"
241
+ ],
242
+ "baseline_keys": [
243
+ "LOCKOUT_DURATION_MIN_MINUTES"
244
+ ],
245
+ "rule": "Locked for >= LOCKOUT_DURATION_MIN_MINUTES minutes, or until an administrator unlocks.",
246
+ "controls": {
247
+ "iso27001": [
248
+ "A.8.5"
249
+ ],
250
+ "gdpr": [
251
+ "Art. 32(1)(b)"
252
+ ],
253
+ "ai_act": []
254
+ }
255
+ },
256
+ {
257
+ "id": "lockout.observation",
258
+ "section": "baseline",
259
+ "name": "Lockout observation period",
260
+ "os": [
261
+ "windows",
262
+ "linux",
263
+ "macos"
264
+ ],
265
+ "baseline_keys": [
266
+ "LOCKOUT_OBSERVATION_MIN_MINUTES"
267
+ ],
268
+ "rule": "Failed-attempt counter kept for >= LOCKOUT_OBSERVATION_MIN_MINUTES minutes.",
269
+ "controls": {
270
+ "iso27001": [
271
+ "A.8.5"
272
+ ],
273
+ "gdpr": [
274
+ "Art. 32(1)(b)"
275
+ ],
276
+ "ai_act": []
277
+ }
278
+ },
279
+ {
280
+ "id": "malware.antivirus",
281
+ "section": "baseline",
282
+ "name": "Antivirus",
283
+ "os": [
284
+ "windows",
285
+ "linux",
286
+ "macos",
287
+ "android"
288
+ ],
289
+ "baseline_keys": [],
290
+ "rule": "An enabled, up-to-date antivirus product is present. Android: Google Play Protect scanning is on.",
291
+ "controls": {
292
+ "iso27001": [
293
+ "A.8.7"
294
+ ],
295
+ "gdpr": [
296
+ "Art. 32(1)(b)"
297
+ ],
298
+ "ai_act": []
299
+ },
300
+ "names": {
301
+ "android": "Google Play Protect"
302
+ }
303
+ },
304
+ {
305
+ "id": "malware.service",
306
+ "section": "baseline",
307
+ "name": "Malware protection service",
308
+ "names": {
309
+ "windows": "Microsoft Defender Antimalware",
310
+ "linux": "ClamAV malware protection service",
311
+ "macos": "Apple malware protection service"
312
+ },
313
+ "os": [
314
+ "windows",
315
+ "linux",
316
+ "macos"
317
+ ],
318
+ "baseline_keys": [],
319
+ "rule": "The platform malware protection service is running.",
320
+ "controls": {
321
+ "iso27001": [
322
+ "A.8.7"
323
+ ],
324
+ "gdpr": [
325
+ "Art. 32(1)(b)"
326
+ ],
327
+ "ai_act": []
328
+ }
329
+ },
330
+ {
331
+ "id": "malware.realtime",
332
+ "section": "baseline",
333
+ "name": "Real-time protection",
334
+ "os": [
335
+ "windows"
336
+ ],
337
+ "baseline_keys": [],
338
+ "rule": "Defender real-time protection is on.",
339
+ "controls": {
340
+ "iso27001": [
341
+ "A.8.7"
342
+ ],
343
+ "gdpr": [
344
+ "Art. 32(1)(b)"
345
+ ],
346
+ "ai_act": []
347
+ }
348
+ },
349
+ {
350
+ "id": "malware.on_access",
351
+ "section": "baseline",
352
+ "name": "On-access malware protection",
353
+ "names": {
354
+ "windows": "On-access protection"
355
+ },
356
+ "os": [
357
+ "windows",
358
+ "linux",
359
+ "macos"
360
+ ],
361
+ "baseline_keys": [],
362
+ "rule": "Files are scanned when opened. macOS: NotApplicable unless a third-party scanner provides it.",
363
+ "controls": {
364
+ "iso27001": [
365
+ "A.8.7"
366
+ ],
367
+ "gdpr": [
368
+ "Art. 32(1)(b)"
369
+ ],
370
+ "ai_act": []
371
+ }
372
+ },
373
+ {
374
+ "id": "malware.tamper",
375
+ "section": "baseline",
376
+ "name": "Tamper protection",
377
+ "os": [
378
+ "windows"
379
+ ],
380
+ "baseline_keys": [],
381
+ "rule": "Defender tamper protection is on.",
382
+ "controls": {
383
+ "iso27001": [
384
+ "A.8.7"
385
+ ],
386
+ "gdpr": [
387
+ "Art. 32(1)(b)"
388
+ ],
389
+ "ai_act": []
390
+ }
391
+ },
392
+ {
393
+ "id": "malware.signatures",
394
+ "section": "baseline",
395
+ "name": "Malware signatures",
396
+ "names": {
397
+ "windows": "Security intelligence",
398
+ "macos": "Malware protection signatures"
399
+ },
400
+ "os": [
401
+ "windows",
402
+ "linux",
403
+ "macos"
404
+ ],
405
+ "baseline_keys": [
406
+ "SIGNATURE_MAX_AGE_DAYS",
407
+ "MACOS_XPROTECT_MAX_AGE_DAYS"
408
+ ],
409
+ "rule": "Signatures no older than SIGNATURE_MAX_AGE_DAYS (macOS XProtect: MACOS_XPROTECT_MAX_AGE_DAYS).",
410
+ "controls": {
411
+ "iso27001": [
412
+ "A.8.7",
413
+ "A.8.8"
414
+ ],
415
+ "gdpr": [
416
+ "Art. 32(1)(b)"
417
+ ],
418
+ "ai_act": []
419
+ }
420
+ },
421
+ {
422
+ "id": "malware.signature_updates",
423
+ "section": "baseline",
424
+ "name": "Malware signature updates",
425
+ "names": {
426
+ "macos": "Malware security updates"
427
+ },
428
+ "os": [
429
+ "linux",
430
+ "macos"
431
+ ],
432
+ "baseline_keys": [],
433
+ "rule": "Automatic signature/security-data updates are enabled.",
434
+ "controls": {
435
+ "iso27001": [
436
+ "A.8.7",
437
+ "A.8.8"
438
+ ],
439
+ "gdpr": [
440
+ "Art. 32(1)(b)"
441
+ ],
442
+ "ai_act": []
443
+ }
444
+ },
445
+ {
446
+ "id": "firewall.enabled",
447
+ "section": "baseline",
448
+ "name": "Firewall",
449
+ "os": [
450
+ "linux",
451
+ "macos"
452
+ ],
453
+ "baseline_keys": [],
454
+ "rule": "A host firewall is active.",
455
+ "controls": {
456
+ "iso27001": [
457
+ "A.8.20",
458
+ "A.8.1"
459
+ ],
460
+ "gdpr": [
461
+ "Art. 32(1)(b)"
462
+ ],
463
+ "ai_act": []
464
+ }
465
+ },
466
+ {
467
+ "id": "firewall.inbound",
468
+ "section": "baseline",
469
+ "name": "Firewall inbound policy",
470
+ "os": [
471
+ "linux",
472
+ "macos"
473
+ ],
474
+ "baseline_keys": [],
475
+ "rule": "Unsolicited inbound traffic is blocked by default (macOS: firewall on with stealth mode).",
476
+ "controls": {
477
+ "iso27001": [
478
+ "A.8.20"
479
+ ],
480
+ "gdpr": [
481
+ "Art. 32(1)(b)"
482
+ ],
483
+ "ai_act": []
484
+ }
485
+ },
486
+ {
487
+ "id": "firewall.outbound",
488
+ "section": "baseline",
489
+ "name": "Firewall outbound policy",
490
+ "os": [
491
+ "linux",
492
+ "macos"
493
+ ],
494
+ "baseline_keys": [
495
+ "FIREWALL_OUTBOUND"
496
+ ],
497
+ "rule": "Default outbound policy equals FIREWALL_OUTBOUND.",
498
+ "controls": {
499
+ "iso27001": [
500
+ "A.8.20"
501
+ ],
502
+ "gdpr": [
503
+ "Art. 32(1)(b)"
504
+ ],
505
+ "ai_act": []
506
+ }
507
+ },
508
+ {
509
+ "id": "firewall.domain.enabled",
510
+ "section": "baseline",
511
+ "name": "Domain firewall",
512
+ "os": [
513
+ "windows"
514
+ ],
515
+ "baseline_keys": [],
516
+ "rule": "Domain profile enabled.",
517
+ "controls": {
518
+ "iso27001": [
519
+ "A.8.20",
520
+ "A.8.1"
521
+ ],
522
+ "gdpr": [
523
+ "Art. 32(1)(b)"
524
+ ],
525
+ "ai_act": []
526
+ }
527
+ },
528
+ {
529
+ "id": "firewall.domain.inbound",
530
+ "section": "baseline",
531
+ "name": "Domain inbound",
532
+ "os": [
533
+ "windows"
534
+ ],
535
+ "baseline_keys": [],
536
+ "rule": "Domain profile default inbound action is Block.",
537
+ "controls": {
538
+ "iso27001": [
539
+ "A.8.20"
540
+ ],
541
+ "gdpr": [
542
+ "Art. 32(1)(b)"
543
+ ],
544
+ "ai_act": []
545
+ }
546
+ },
547
+ {
548
+ "id": "firewall.domain.outbound",
549
+ "section": "baseline",
550
+ "name": "Domain outbound",
551
+ "os": [
552
+ "windows"
553
+ ],
554
+ "baseline_keys": [
555
+ "FIREWALL_OUTBOUND"
556
+ ],
557
+ "rule": "Domain profile default outbound action equals FIREWALL_OUTBOUND.",
558
+ "controls": {
559
+ "iso27001": [
560
+ "A.8.20"
561
+ ],
562
+ "gdpr": [
563
+ "Art. 32(1)(b)"
564
+ ],
565
+ "ai_act": []
566
+ }
567
+ },
568
+ {
569
+ "id": "firewall.private.enabled",
570
+ "section": "baseline",
571
+ "name": "Private firewall",
572
+ "os": [
573
+ "windows"
574
+ ],
575
+ "baseline_keys": [],
576
+ "rule": "Private profile enabled.",
577
+ "controls": {
578
+ "iso27001": [
579
+ "A.8.20",
580
+ "A.8.1"
581
+ ],
582
+ "gdpr": [
583
+ "Art. 32(1)(b)"
584
+ ],
585
+ "ai_act": []
586
+ }
587
+ },
588
+ {
589
+ "id": "firewall.private.inbound",
590
+ "section": "baseline",
591
+ "name": "Private inbound",
592
+ "os": [
593
+ "windows"
594
+ ],
595
+ "baseline_keys": [],
596
+ "rule": "Private profile default inbound action is Block.",
597
+ "controls": {
598
+ "iso27001": [
599
+ "A.8.20"
600
+ ],
601
+ "gdpr": [
602
+ "Art. 32(1)(b)"
603
+ ],
604
+ "ai_act": []
605
+ }
606
+ },
607
+ {
608
+ "id": "firewall.private.outbound",
609
+ "section": "baseline",
610
+ "name": "Private outbound",
611
+ "os": [
612
+ "windows"
613
+ ],
614
+ "baseline_keys": [
615
+ "FIREWALL_OUTBOUND"
616
+ ],
617
+ "rule": "Private profile default outbound action equals FIREWALL_OUTBOUND.",
618
+ "controls": {
619
+ "iso27001": [
620
+ "A.8.20"
621
+ ],
622
+ "gdpr": [
623
+ "Art. 32(1)(b)"
624
+ ],
625
+ "ai_act": []
626
+ }
627
+ },
628
+ {
629
+ "id": "firewall.public.enabled",
630
+ "section": "baseline",
631
+ "name": "Public firewall",
632
+ "os": [
633
+ "windows"
634
+ ],
635
+ "baseline_keys": [],
636
+ "rule": "Public profile enabled.",
637
+ "controls": {
638
+ "iso27001": [
639
+ "A.8.20",
640
+ "A.8.1"
641
+ ],
642
+ "gdpr": [
643
+ "Art. 32(1)(b)"
644
+ ],
645
+ "ai_act": []
646
+ }
647
+ },
648
+ {
649
+ "id": "firewall.public.inbound",
650
+ "section": "baseline",
651
+ "name": "Public inbound",
652
+ "os": [
653
+ "windows"
654
+ ],
655
+ "baseline_keys": [],
656
+ "rule": "Public profile default inbound action is Block.",
657
+ "controls": {
658
+ "iso27001": [
659
+ "A.8.20"
660
+ ],
661
+ "gdpr": [
662
+ "Art. 32(1)(b)"
663
+ ],
664
+ "ai_act": []
665
+ }
666
+ },
667
+ {
668
+ "id": "firewall.public.outbound",
669
+ "section": "baseline",
670
+ "name": "Public outbound",
671
+ "os": [
672
+ "windows"
673
+ ],
674
+ "baseline_keys": [
675
+ "FIREWALL_OUTBOUND"
676
+ ],
677
+ "rule": "Public profile default outbound action equals FIREWALL_OUTBOUND.",
678
+ "controls": {
679
+ "iso27001": [
680
+ "A.8.20"
681
+ ],
682
+ "gdpr": [
683
+ "Art. 32(1)(b)"
684
+ ],
685
+ "ai_act": []
686
+ }
687
+ },
688
+ {
689
+ "id": "screen_lock.enabled",
690
+ "section": "baseline",
691
+ "name": "Screen lock",
692
+ "os": [
693
+ "windows",
694
+ "linux",
695
+ "macos",
696
+ "ios",
697
+ "android"
698
+ ],
699
+ "baseline_keys": [],
700
+ "rule": "The screen locks when idle (Windows: machine inactivity limit or a password-protected screen saver). Phones: a passcode / PIN / password screen lock is set.",
701
+ "controls": {
702
+ "iso27001": [
703
+ "A.8.1",
704
+ "A.7.7"
705
+ ],
706
+ "gdpr": [
707
+ "Art. 32(1)(b)"
708
+ ],
709
+ "ai_act": []
710
+ },
711
+ "names": {
712
+ "ios": "Passcode",
713
+ "android": "Screen lock"
714
+ }
715
+ },
716
+ {
717
+ "id": "screen_lock.timeout",
718
+ "section": "baseline",
719
+ "name": "Screen lock timeout",
720
+ "os": [
721
+ "windows",
722
+ "linux",
723
+ "macos",
724
+ "ios",
725
+ "android"
726
+ ],
727
+ "baseline_keys": [
728
+ "SCREEN_LOCK_MAX_SECONDS"
729
+ ],
730
+ "rule": "Idle time before lock (including any grace delay) is 1..SCREEN_LOCK_MAX_SECONDS seconds (baseline: 300 = 5 minutes). Phones: Auto-Lock / screen timeout.",
731
+ "controls": {
732
+ "iso27001": [
733
+ "A.8.1",
734
+ "A.7.7"
735
+ ],
736
+ "gdpr": [
737
+ "Art. 32(1)(b)"
738
+ ],
739
+ "ai_act": []
740
+ },
741
+ "names": {
742
+ "ios": "Auto-Lock",
743
+ "android": "Screen timeout"
744
+ }
745
+ },
746
+ {
747
+ "id": "screen_lock.password",
748
+ "section": "baseline",
749
+ "name": "Screen lock password requirement",
750
+ "os": [
751
+ "windows",
752
+ "linux",
753
+ "macos"
754
+ ],
755
+ "baseline_keys": [],
756
+ "rule": "A password is required to unlock (macOS: within 5 seconds of the screen locking).",
757
+ "controls": {
758
+ "iso27001": [
759
+ "A.8.1",
760
+ "A.7.7",
761
+ "A.8.5"
762
+ ],
763
+ "gdpr": [
764
+ "Art. 32(1)(b)"
765
+ ],
766
+ "ai_act": []
767
+ }
768
+ },
769
+ {
770
+ "id": "mobile.passcode_strength",
771
+ "section": "baseline",
772
+ "name": "Passcode strength",
773
+ "os": [
774
+ "ios",
775
+ "android"
776
+ ],
777
+ "names": {
778
+ "ios": "Passcode strength",
779
+ "android": "Screen lock strength"
780
+ },
781
+ "baseline_keys": [
782
+ "MOBILE_PASSCODE_MIN_DIGITS"
783
+ ],
784
+ "rule": "Passcode / PIN has at least MOBILE_PASSCODE_MIN_DIGITS digits, or is alphanumeric. Android pattern or swipe locks do not comply.",
785
+ "controls": {
786
+ "iso27001": [
787
+ "A.5.17",
788
+ "A.8.5"
789
+ ],
790
+ "gdpr": [
791
+ "Art. 32(1)(b)"
792
+ ],
793
+ "ai_act": []
794
+ }
795
+ },
796
+ {
797
+ "id": "mobile.jailbreak_root",
798
+ "section": "baseline",
799
+ "name": "Operating system integrity",
800
+ "os": [
801
+ "ios",
802
+ "android"
803
+ ],
804
+ "names": {
805
+ "ios": "Not jailbroken",
806
+ "android": "Not rooted"
807
+ },
808
+ "baseline_keys": [],
809
+ "rule": "The phone is not jailbroken (iOS) or rooted / bootloader-unlocked (Android).",
810
+ "controls": {
811
+ "iso27001": [
812
+ "A.8.1",
813
+ "A.8.7",
814
+ "A.8.19"
815
+ ],
816
+ "gdpr": [
817
+ "Art. 32(1)(b)"
818
+ ],
819
+ "ai_act": []
820
+ }
821
+ },
822
+ {
823
+ "id": "mobile.app_sources",
824
+ "section": "baseline",
825
+ "name": "App sources restricted",
826
+ "os": [
827
+ "ios",
828
+ "android"
829
+ ],
830
+ "names": {
831
+ "ios": "Apps only from the App Store",
832
+ "android": "Install unknown apps blocked"
833
+ },
834
+ "baseline_keys": [],
835
+ "rule": "Apps are installed only from the official store (no alternative marketplaces, web distribution or unknown sources).",
836
+ "controls": {
837
+ "iso27001": [
838
+ "A.8.1",
839
+ "A.8.7",
840
+ "A.8.19"
841
+ ],
842
+ "gdpr": [
843
+ "Art. 32(1)(b)"
844
+ ],
845
+ "ai_act": []
846
+ }
847
+ },
848
+ {
849
+ "id": "mobile.usb_debugging",
850
+ "section": "baseline",
851
+ "name": "USB debugging off",
852
+ "os": [
853
+ "android"
854
+ ],
855
+ "baseline_keys": [],
856
+ "rule": "Developer options / USB debugging are off.",
857
+ "controls": {
858
+ "iso27001": [
859
+ "A.8.1",
860
+ "A.8.19"
861
+ ],
862
+ "gdpr": [
863
+ "Art. 32(1)(b)"
864
+ ],
865
+ "ai_act": []
866
+ }
867
+ },
868
+ {
869
+ "id": "mobile.find_my_device",
870
+ "section": "baseline",
871
+ "name": "Find and erase a lost device",
872
+ "os": [
873
+ "ios",
874
+ "android"
875
+ ],
876
+ "names": {
877
+ "ios": "Find My iPhone",
878
+ "android": "Find My Device"
879
+ },
880
+ "baseline_keys": [],
881
+ "rule": "Find My iPhone / Find My Device is on, so a lost phone can be located, locked and erased.",
882
+ "controls": {
883
+ "iso27001": [
884
+ "A.5.9",
885
+ "A.8.1"
886
+ ],
887
+ "gdpr": [
888
+ "Art. 32(1)(b)",
889
+ "Art. 32(1)(c)"
890
+ ],
891
+ "ai_act": []
892
+ }
893
+ },
894
+ {
895
+ "id": "usb.device_access",
896
+ "section": "baseline",
897
+ "name": "USB storage device access",
898
+ "os": [
899
+ "windows",
900
+ "linux",
901
+ "macos"
902
+ ],
903
+ "baseline_keys": [
904
+ "USB_DEVICE_ACCESS"
905
+ ],
906
+ "rule": "Whether USB mass storage devices can connect at all equals USB_DEVICE_ACCESS (allow/deny): driver, device-installation policy, USBGuard or MDM restriction.",
907
+ "controls": {
908
+ "iso27001": [
909
+ "A.7.10",
910
+ "A.8.12"
911
+ ],
912
+ "gdpr": [
913
+ "Art. 32(1)(b)"
914
+ ],
915
+ "ai_act": []
916
+ }
917
+ },
918
+ {
919
+ "id": "usb.read",
920
+ "section": "baseline",
921
+ "name": "USB storage read",
922
+ "os": [
923
+ "windows",
924
+ "linux",
925
+ "macos"
926
+ ],
927
+ "baseline_keys": [
928
+ "USB_READ"
929
+ ],
930
+ "rule": "Effective USB storage read access equals USB_READ (allow/deny).",
931
+ "controls": {
932
+ "iso27001": [
933
+ "A.7.10",
934
+ "A.8.12"
935
+ ],
936
+ "gdpr": [
937
+ "Art. 32(1)(b)"
938
+ ],
939
+ "ai_act": []
940
+ }
941
+ },
942
+ {
943
+ "id": "usb.write",
944
+ "section": "baseline",
945
+ "name": "USB storage write",
946
+ "os": [
947
+ "windows",
948
+ "linux",
949
+ "macos"
950
+ ],
951
+ "baseline_keys": [
952
+ "USB_WRITE"
953
+ ],
954
+ "rule": "Effective USB storage write access equals USB_WRITE (allow/deny).",
955
+ "controls": {
956
+ "iso27001": [
957
+ "A.7.10",
958
+ "A.8.12"
959
+ ],
960
+ "gdpr": [
961
+ "Art. 32(1)(b)"
962
+ ],
963
+ "ai_act": []
964
+ }
965
+ },
966
+ {
967
+ "id": "admin.approved",
968
+ "section": "baseline",
969
+ "name": "Approved IT administrator",
970
+ "os": [
971
+ "windows",
972
+ "linux",
973
+ "macos"
974
+ ],
975
+ "baseline_keys": [
976
+ "APPROVED_ADMINS"
977
+ ],
978
+ "rule": "At least one account listed in APPROVED_ADMINS is an enabled administrator.",
979
+ "controls": {
980
+ "iso27001": [
981
+ "A.8.2",
982
+ "A.5.15"
983
+ ],
984
+ "gdpr": [
985
+ "Art. 32(1)(b)"
986
+ ],
987
+ "ai_act": []
988
+ }
989
+ },
990
+ {
991
+ "id": "admin.unauthorized",
992
+ "section": "baseline",
993
+ "name": "Unauthorized employee administrator accounts",
994
+ "names": {
995
+ "windows": "Unauthorized employee local administrator accounts"
996
+ },
997
+ "os": [
998
+ "windows",
999
+ "linux",
1000
+ "macos"
1001
+ ],
1002
+ "baseline_keys": [
1003
+ "APPROVED_ADMINS",
1004
+ "ALLOW_DISABLED_BUILTIN_ADMIN"
1005
+ ],
1006
+ "rule": "No administrator accounts other than APPROVED_ADMINS (and the disabled built-in Administrator on Windows).",
1007
+ "controls": {
1008
+ "iso27001": [
1009
+ "A.8.2",
1010
+ "A.5.18"
1011
+ ],
1012
+ "gdpr": [
1013
+ "Art. 32(1)(b)"
1014
+ ],
1015
+ "ai_act": []
1016
+ }
1017
+ },
1018
+ {
1019
+ "id": "encryption.feature",
1020
+ "section": "baseline",
1021
+ "name": "BitLocker",
1022
+ "os": [
1023
+ "windows"
1024
+ ],
1025
+ "baseline_keys": [],
1026
+ "rule": "BitLocker is turned on for the system drive.",
1027
+ "controls": {
1028
+ "iso27001": [
1029
+ "A.8.24",
1030
+ "A.8.1"
1031
+ ],
1032
+ "gdpr": [
1033
+ "Art. 32(1)(a)"
1034
+ ],
1035
+ "ai_act": []
1036
+ }
1037
+ },
1038
+ {
1039
+ "id": "encryption.enabled",
1040
+ "section": "baseline",
1041
+ "name": "Encryption",
1042
+ "os": [
1043
+ "windows",
1044
+ "linux",
1045
+ "macos",
1046
+ "ios",
1047
+ "android"
1048
+ ],
1049
+ "baseline_keys": [],
1050
+ "rule": "The system disk is fully encrypted (BitLocker / LUKS / FileVault). Phones: iOS data protection is on when a passcode is set; Android 10+ encrypts by default.",
1051
+ "controls": {
1052
+ "iso27001": [
1053
+ "A.8.24",
1054
+ "A.8.1"
1055
+ ],
1056
+ "gdpr": [
1057
+ "Art. 32(1)(a)"
1058
+ ],
1059
+ "ai_act": []
1060
+ },
1061
+ "names": {
1062
+ "ios": "Data protection (encryption)",
1063
+ "android": "Device encryption"
1064
+ }
1065
+ },
1066
+ {
1067
+ "id": "encryption.protection",
1068
+ "section": "baseline",
1069
+ "name": "Disk encryption protection",
1070
+ "names": {
1071
+ "windows": "BitLocker protection"
1072
+ },
1073
+ "os": [
1074
+ "windows",
1075
+ "linux",
1076
+ "macos"
1077
+ ],
1078
+ "baseline_keys": [],
1079
+ "rule": "Encryption protection is active (not suspended or in progress) with a strong cipher.",
1080
+ "controls": {
1081
+ "iso27001": [
1082
+ "A.8.24"
1083
+ ],
1084
+ "gdpr": [
1085
+ "Art. 32(1)(a)"
1086
+ ],
1087
+ "ai_act": []
1088
+ }
1089
+ },
1090
+ {
1091
+ "id": "encryption.recovery",
1092
+ "section": "baseline",
1093
+ "name": "Disk encryption recovery",
1094
+ "names": {
1095
+ "windows": "BitLocker recovery"
1096
+ },
1097
+ "os": [
1098
+ "windows",
1099
+ "linux",
1100
+ "macos"
1101
+ ],
1102
+ "baseline_keys": [],
1103
+ "rule": "A recovery key or second unlock method exists.",
1104
+ "controls": {
1105
+ "iso27001": [
1106
+ "A.8.24",
1107
+ "A.8.13"
1108
+ ],
1109
+ "gdpr": [
1110
+ "Art. 32(1)(a)",
1111
+ "Art. 32(1)(c)"
1112
+ ],
1113
+ "ai_act": []
1114
+ }
1115
+ },
1116
+ {
1117
+ "id": "user.account_type",
1118
+ "section": "baseline",
1119
+ "name": "Signed-in user account type",
1120
+ "os": [
1121
+ "windows",
1122
+ "linux",
1123
+ "macos"
1124
+ ],
1125
+ "baseline_keys": [
1126
+ "APPROVED_ADMINS"
1127
+ ],
1128
+ "rule": "The everyday signed-in user is a Standard account (not an administrator), unless listed in APPROVED_ADMINS.",
1129
+ "controls": {
1130
+ "iso27001": [
1131
+ "A.8.2",
1132
+ "A.5.15"
1133
+ ],
1134
+ "gdpr": [
1135
+ "Art. 32(1)(b)"
1136
+ ],
1137
+ "ai_act": []
1138
+ }
1139
+ },
1140
+ {
1141
+ "id": "vuln.pending_updates",
1142
+ "section": "baseline",
1143
+ "name": "Pending security updates",
1144
+ "os": [
1145
+ "windows",
1146
+ "linux",
1147
+ "macos",
1148
+ "ios",
1149
+ "android"
1150
+ ],
1151
+ "baseline_keys": [
1152
+ "MAX_PENDING_SECURITY_UPDATES",
1153
+ "MOBILE_PATCH_MAX_AGE_DAYS"
1154
+ ],
1155
+ "rule": "Security updates offered by the OS vendor but not yet installed <= MAX_PENDING_SECURITY_UPDATES. Phones: iOS is at the latest security release known to the vulnerability data; Android security patch level is at most MOBILE_PATCH_MAX_AGE_DAYS old.",
1156
+ "controls": {
1157
+ "iso27001": [
1158
+ "A.8.8"
1159
+ ],
1160
+ "gdpr": [
1161
+ "Art. 32(1)(b)",
1162
+ "Art. 32(1)(d)"
1163
+ ],
1164
+ "ai_act": []
1165
+ },
1166
+ "names": {
1167
+ "ios": "iOS security updates",
1168
+ "android": "Android security update"
1169
+ }
1170
+ },
1171
+ {
1172
+ "id": "vuln.outdated_apps",
1173
+ "section": "baseline",
1174
+ "name": "Outdated third-party applications",
1175
+ "os": [
1176
+ "windows",
1177
+ "linux",
1178
+ "macos",
1179
+ "ios",
1180
+ "android"
1181
+ ],
1182
+ "baseline_keys": [
1183
+ "MAX_OUTDATED_APPS"
1184
+ ],
1185
+ "rule": "Installed third-party applications with a newer version available <= MAX_OUTDATED_APPS (Windows: winget; macOS: app versions; Linux: snap/flatpak and packages). Phones: automatic app updates are on.",
1186
+ "controls": {
1187
+ "iso27001": [
1188
+ "A.8.8",
1189
+ "A.8.19"
1190
+ ],
1191
+ "gdpr": [
1192
+ "Art. 32(1)(b)"
1193
+ ],
1194
+ "ai_act": []
1195
+ },
1196
+ "names": {
1197
+ "ios": "App updates",
1198
+ "android": "App updates"
1199
+ }
1200
+ },
1201
+ {
1202
+ "id": "vuln.known_vulnerabilities",
1203
+ "section": "baseline",
1204
+ "name": "Known vulnerabilities in installed software",
1205
+ "os": [
1206
+ "windows",
1207
+ "linux",
1208
+ "macos",
1209
+ "ios"
1210
+ ],
1211
+ "baseline_keys": [
1212
+ "MAX_CRITICAL_HIGH_VULNERABILITIES"
1213
+ ],
1214
+ "rule": "Installed software versions matched against an offline copy of the OSV/NVD vulnerability data: critical or high vulnerabilities <= MAX_CRITICAL_HIGH_VULNERABILITIES. iOS: the iOS version is matched against NVD iOS/iPadOS records.",
1215
+ "controls": {
1216
+ "iso27001": [
1217
+ "A.8.8"
1218
+ ],
1219
+ "gdpr": [
1220
+ "Art. 32(1)(b)"
1221
+ ],
1222
+ "ai_act": []
1223
+ },
1224
+ "names": {
1225
+ "ios": "Known vulnerabilities in iOS"
1226
+ }
1227
+ },
1228
+ {
1229
+ "id": "gdpr.auto_updates",
1230
+ "section": "gdpr",
1231
+ "name": "Automatic security updates",
1232
+ "os": [
1233
+ "windows",
1234
+ "linux",
1235
+ "macos",
1236
+ "ios",
1237
+ "android"
1238
+ ],
1239
+ "baseline_keys": [],
1240
+ "rule": "Security updates are downloaded and installed automatically.",
1241
+ "controls": {
1242
+ "iso27001": [
1243
+ "A.8.8"
1244
+ ],
1245
+ "gdpr": [
1246
+ "Art. 32(1)(b)"
1247
+ ],
1248
+ "ai_act": []
1249
+ },
1250
+ "names": {
1251
+ "ios": "Automatic iOS updates",
1252
+ "android": "Automatic system updates"
1253
+ }
1254
+ },
1255
+ {
1256
+ "id": "gdpr.updates_recent",
1257
+ "section": "gdpr",
1258
+ "name": "Security updates installed recently",
1259
+ "os": [
1260
+ "windows",
1261
+ "linux",
1262
+ "macos"
1263
+ ],
1264
+ "baseline_keys": [
1265
+ "UPDATES_MAX_AGE_DAYS"
1266
+ ],
1267
+ "rule": "Last successful update/package change within UPDATES_MAX_AGE_DAYS days.",
1268
+ "controls": {
1269
+ "iso27001": [
1270
+ "A.8.8"
1271
+ ],
1272
+ "gdpr": [
1273
+ "Art. 32(1)(b)",
1274
+ "Art. 32(1)(d)"
1275
+ ],
1276
+ "ai_act": []
1277
+ }
1278
+ },
1279
+ {
1280
+ "id": "gdpr.os_supported",
1281
+ "section": "gdpr",
1282
+ "name": "Supported operating system version",
1283
+ "os": [
1284
+ "windows",
1285
+ "linux",
1286
+ "macos",
1287
+ "ios",
1288
+ "android"
1289
+ ],
1290
+ "baseline_keys": [
1291
+ "WINDOWS_MIN_BUILD",
1292
+ "MACOS_MIN_MAJOR",
1293
+ "UBUNTU_MIN_VERSION",
1294
+ "DEBIAN_MIN_VERSION",
1295
+ "RHEL_MIN_VERSION",
1296
+ "IOS_MIN_MAJOR",
1297
+ "ANDROID_MIN_MAJOR"
1298
+ ],
1299
+ "rule": "The OS version still receives security updates (minimums in the baseline). Phones: IOS_MIN_MAJOR / ANDROID_MIN_MAJOR.",
1300
+ "controls": {
1301
+ "iso27001": [
1302
+ "A.8.8"
1303
+ ],
1304
+ "gdpr": [
1305
+ "Art. 32(1)(b)"
1306
+ ],
1307
+ "ai_act": []
1308
+ }
1309
+ },
1310
+ {
1311
+ "id": "gdpr.security_logging",
1312
+ "section": "gdpr",
1313
+ "name": "Security event logging",
1314
+ "os": [
1315
+ "windows",
1316
+ "linux",
1317
+ "macos"
1318
+ ],
1319
+ "baseline_keys": [],
1320
+ "rule": "Logon and account-change events are recorded (Windows audit policy, Linux auditd, macOS unified log).",
1321
+ "controls": {
1322
+ "iso27001": [
1323
+ "A.8.15"
1324
+ ],
1325
+ "gdpr": [
1326
+ "Art. 32(1)(d)",
1327
+ "Art. 33"
1328
+ ],
1329
+ "ai_act": []
1330
+ }
1331
+ },
1332
+ {
1333
+ "id": "gdpr.time_sync",
1334
+ "section": "gdpr",
1335
+ "name": "Time synchronisation",
1336
+ "os": [
1337
+ "windows",
1338
+ "linux",
1339
+ "macos",
1340
+ "ios",
1341
+ "android"
1342
+ ],
1343
+ "baseline_keys": [],
1344
+ "rule": "The clock is synchronised from a time server.",
1345
+ "controls": {
1346
+ "iso27001": [
1347
+ "A.8.17"
1348
+ ],
1349
+ "gdpr": [
1350
+ "Art. 33"
1351
+ ],
1352
+ "ai_act": []
1353
+ },
1354
+ "names": {
1355
+ "ios": "Date & time set automatically",
1356
+ "android": "Date & time set automatically"
1357
+ }
1358
+ },
1359
+ {
1360
+ "id": "gdpr.backup_configured",
1361
+ "section": "gdpr",
1362
+ "name": "Backup configured",
1363
+ "os": [
1364
+ "windows",
1365
+ "linux",
1366
+ "macos",
1367
+ "ios",
1368
+ "android"
1369
+ ],
1370
+ "baseline_keys": [],
1371
+ "rule": "A backup solution is configured for user data.",
1372
+ "controls": {
1373
+ "iso27001": [
1374
+ "A.8.13"
1375
+ ],
1376
+ "gdpr": [
1377
+ "Art. 32(1)(c)"
1378
+ ],
1379
+ "ai_act": []
1380
+ },
1381
+ "names": {
1382
+ "ios": "iCloud Backup",
1383
+ "android": "Google backup"
1384
+ }
1385
+ },
1386
+ {
1387
+ "id": "gdpr.backup_recent",
1388
+ "section": "gdpr",
1389
+ "name": "Recent backup",
1390
+ "os": [
1391
+ "windows",
1392
+ "linux",
1393
+ "macos"
1394
+ ],
1395
+ "baseline_keys": [
1396
+ "BACKUP_MAX_AGE_DAYS"
1397
+ ],
1398
+ "rule": "Last backup within BACKUP_MAX_AGE_DAYS days (Pending when the date cannot be read).",
1399
+ "controls": {
1400
+ "iso27001": [
1401
+ "A.8.13"
1402
+ ],
1403
+ "gdpr": [
1404
+ "Art. 32(1)(c)"
1405
+ ],
1406
+ "ai_act": []
1407
+ }
1408
+ },
1409
+ {
1410
+ "id": "gdpr.removable_encryption",
1411
+ "section": "gdpr",
1412
+ "name": "Removable media encryption",
1413
+ "os": [
1414
+ "windows",
1415
+ "linux",
1416
+ "macos"
1417
+ ],
1418
+ "baseline_keys": [],
1419
+ "rule": "Data cannot be written to unencrypted removable drives.",
1420
+ "controls": {
1421
+ "iso27001": [
1422
+ "A.7.10",
1423
+ "A.8.24"
1424
+ ],
1425
+ "gdpr": [
1426
+ "Art. 32(1)(a)",
1427
+ "Art. 34"
1428
+ ],
1429
+ "ai_act": []
1430
+ }
1431
+ },
1432
+ {
1433
+ "id": "gdpr.guest_account",
1434
+ "section": "gdpr",
1435
+ "name": "Guest account disabled",
1436
+ "os": [
1437
+ "windows",
1438
+ "linux",
1439
+ "macos"
1440
+ ],
1441
+ "baseline_keys": [],
1442
+ "rule": "No enabled guest login.",
1443
+ "controls": {
1444
+ "iso27001": [
1445
+ "A.5.16",
1446
+ "A.8.5"
1447
+ ],
1448
+ "gdpr": [
1449
+ "Art. 32(1)(b)"
1450
+ ],
1451
+ "ai_act": []
1452
+ }
1453
+ },
1454
+ {
1455
+ "id": "gdpr.remote_access",
1456
+ "section": "gdpr",
1457
+ "name": "Remote access services restricted",
1458
+ "os": [
1459
+ "windows",
1460
+ "linux",
1461
+ "macos"
1462
+ ],
1463
+ "baseline_keys": [
1464
+ "APPROVED_REMOTE_SERVICES"
1465
+ ],
1466
+ "rule": "Only remote access services listed in APPROVED_REMOTE_SERVICES are enabled.",
1467
+ "controls": {
1468
+ "iso27001": [
1469
+ "A.8.20",
1470
+ "A.6.7"
1471
+ ],
1472
+ "gdpr": [
1473
+ "Art. 32(1)(b)"
1474
+ ],
1475
+ "ai_act": []
1476
+ }
1477
+ },
1478
+ {
1479
+ "id": "gdpr.telemetry",
1480
+ "section": "gdpr",
1481
+ "name": "Diagnostic data minimised",
1482
+ "os": [
1483
+ "windows",
1484
+ "linux",
1485
+ "macos",
1486
+ "ios",
1487
+ "android"
1488
+ ],
1489
+ "baseline_keys": [],
1490
+ "rule": "OS diagnostic/usage data sharing is at the minimum level.",
1491
+ "controls": {
1492
+ "iso27001": [
1493
+ "A.5.34"
1494
+ ],
1495
+ "gdpr": [
1496
+ "Art. 5(1)(c)",
1497
+ "Art. 25"
1498
+ ],
1499
+ "ai_act": []
1500
+ },
1501
+ "names": {
1502
+ "ios": "Analytics sharing minimised",
1503
+ "android": "Usage & diagnostics sharing minimised"
1504
+ }
1505
+ },
1506
+ {
1507
+ "id": "gdpr.lock_screen_previews",
1508
+ "section": "gdpr",
1509
+ "name": "Notification content hidden on the lock screen",
1510
+ "os": [
1511
+ "ios",
1512
+ "android"
1513
+ ],
1514
+ "names": {
1515
+ "ios": "Notification previews hidden when locked",
1516
+ "android": "Sensitive notifications hidden when locked"
1517
+ },
1518
+ "baseline_keys": [],
1519
+ "rule": "Message and email previews are not shown on the locked screen.",
1520
+ "controls": {
1521
+ "iso27001": [
1522
+ "A.7.7",
1523
+ "A.8.1"
1524
+ ],
1525
+ "gdpr": [
1526
+ "Art. 5(1)(f)",
1527
+ "Art. 32(1)(b)"
1528
+ ],
1529
+ "ai_act": []
1530
+ }
1531
+ },
1532
+ {
1533
+ "id": "gdpr.personal_data_discovery",
1534
+ "section": "gdpr",
1535
+ "name": "Personal data discovery",
1536
+ "os": [
1537
+ "windows",
1538
+ "linux",
1539
+ "macos"
1540
+ ],
1541
+ "baseline_keys": [
1542
+ "PERSONAL_DATA_SCAN"
1543
+ ],
1544
+ "rule": "Opt-in scan of user folders for personal data patterns (counts and paths only). Not implemented in stage 1: NotApplicable.",
1545
+ "controls": {
1546
+ "iso27001": [
1547
+ "A.5.34",
1548
+ "A.5.12"
1549
+ ],
1550
+ "gdpr": [
1551
+ "Art. 5(1)(c)",
1552
+ "Art. 5(1)(e)",
1553
+ "Art. 30"
1554
+ ],
1555
+ "ai_act": []
1556
+ }
1557
+ },
1558
+ {
1559
+ "id": "aiact.inventory",
1560
+ "section": "aiact",
1561
+ "name": "AI software inventory",
1562
+ "os": [
1563
+ "windows",
1564
+ "linux",
1565
+ "macos",
1566
+ "ios",
1567
+ "android"
1568
+ ],
1569
+ "baseline_keys": [],
1570
+ "rule": "AI apps, local AI services and local model folders are listed (Compliant = inventory taken).",
1571
+ "controls": {
1572
+ "iso27001": [
1573
+ "A.5.9"
1574
+ ],
1575
+ "gdpr": [
1576
+ "Art. 30"
1577
+ ],
1578
+ "ai_act": [
1579
+ "Art. 26"
1580
+ ]
1581
+ }
1582
+ },
1583
+ {
1584
+ "id": "aiact.unapproved",
1585
+ "section": "aiact",
1586
+ "name": "Unapproved AI tools (shadow AI)",
1587
+ "os": [
1588
+ "windows",
1589
+ "linux",
1590
+ "macos",
1591
+ "ios",
1592
+ "android"
1593
+ ],
1594
+ "baseline_keys": [
1595
+ "APPROVED_AI_TOOLS"
1596
+ ],
1597
+ "rule": "Every AI tool found is listed in APPROVED_AI_TOOLS.",
1598
+ "controls": {
1599
+ "iso27001": [
1600
+ "A.5.9",
1601
+ "A.8.19"
1602
+ ],
1603
+ "gdpr": [
1604
+ "Art. 28",
1605
+ "Art. 44"
1606
+ ],
1607
+ "ai_act": [
1608
+ "Art. 26"
1609
+ ]
1610
+ }
1611
+ },
1612
+ {
1613
+ "id": "aiact.risk_classification",
1614
+ "section": "aiact",
1615
+ "name": "AI system risk classification",
1616
+ "os": [
1617
+ "windows",
1618
+ "linux",
1619
+ "macos",
1620
+ "ios",
1621
+ "android"
1622
+ ],
1623
+ "baseline_keys": [],
1624
+ "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.",
1625
+ "controls": {
1626
+ "iso27001": [
1627
+ "A.5.9"
1628
+ ],
1629
+ "gdpr": [],
1630
+ "ai_act": [
1631
+ "Art. 5",
1632
+ "Art. 6",
1633
+ "Annex III"
1634
+ ]
1635
+ }
1636
+ },
1637
+ {
1638
+ "id": "aiact.os_ai_features",
1639
+ "section": "aiact",
1640
+ "name": "Operating system AI features restricted",
1641
+ "names": {
1642
+ "windows": "Windows Recall / Copilot data analysis disabled",
1643
+ "macos": "Apple Intelligence external integrations restricted",
1644
+ "ios": "Apple Intelligence external integrations restricted",
1645
+ "android": "AI assistant access restricted"
1646
+ },
1647
+ "os": [
1648
+ "windows",
1649
+ "linux",
1650
+ "macos",
1651
+ "ios",
1652
+ "android"
1653
+ ],
1654
+ "baseline_keys": [],
1655
+ "rule": "OS-level AI features that capture or send user data are disabled by policy. NotApplicable where the OS has none. Phones: external AI integrations (ChatGPT in Apple Intelligence, assistant access to screen content) are off unless approved.",
1656
+ "controls": {
1657
+ "iso27001": [
1658
+ "A.5.34"
1659
+ ],
1660
+ "gdpr": [
1661
+ "Art. 5(1)(c)",
1662
+ "Art. 25"
1663
+ ],
1664
+ "ai_act": [
1665
+ "Art. 26"
1666
+ ]
1667
+ }
1668
+ },
1669
+ {
1670
+ "id": "aiact.log_retention",
1671
+ "section": "aiact",
1672
+ "name": "AI usage log retention",
1673
+ "os": [
1674
+ "windows",
1675
+ "linux",
1676
+ "macos",
1677
+ "ios",
1678
+ "android"
1679
+ ],
1680
+ "baseline_keys": [],
1681
+ "rule": "Logs kept >= 6 months if a high-risk AI system is used. Pending until classification; NotApplicable when no AI tool is found.",
1682
+ "controls": {
1683
+ "iso27001": [
1684
+ "A.8.15"
1685
+ ],
1686
+ "gdpr": [],
1687
+ "ai_act": [
1688
+ "Art. 26(6)"
1689
+ ]
1690
+ }
1691
+ },
1692
+ {
1693
+ "id": "org.ropa",
1694
+ "section": "org",
1695
+ "name": "Record of processing activities",
1696
+ "os": [
1697
+ "windows",
1698
+ "linux",
1699
+ "macos",
1700
+ "ios",
1701
+ "android"
1702
+ ],
1703
+ "answer_key": "ORG_ROPA",
1704
+ "question": "Is there a current record of processing activities (RoPA)?",
1705
+ "baseline_keys": [],
1706
+ "rule": "Answer yes = Compliant, no/partial = Non-Compliant, n/a = NotApplicable, unanswered = Pending.",
1707
+ "controls": {
1708
+ "iso27001": [
1709
+ "A.5.34"
1710
+ ],
1711
+ "gdpr": [
1712
+ "Art. 30"
1713
+ ],
1714
+ "ai_act": []
1715
+ }
1716
+ },
1717
+ {
1718
+ "id": "org.dpia",
1719
+ "section": "org",
1720
+ "name": "Data protection impact assessment",
1721
+ "os": [
1722
+ "windows",
1723
+ "linux",
1724
+ "macos",
1725
+ "ios",
1726
+ "android"
1727
+ ],
1728
+ "answer_key": "ORG_DPIA",
1729
+ "question": "Has a DPIA been done for every high-risk processing activity?",
1730
+ "baseline_keys": [],
1731
+ "rule": "As above.",
1732
+ "controls": {
1733
+ "iso27001": [
1734
+ "A.5.34"
1735
+ ],
1736
+ "gdpr": [
1737
+ "Art. 35"
1738
+ ],
1739
+ "ai_act": []
1740
+ }
1741
+ },
1742
+ {
1743
+ "id": "org.breach_procedure",
1744
+ "section": "org",
1745
+ "name": "Breach response procedure",
1746
+ "os": [
1747
+ "windows",
1748
+ "linux",
1749
+ "macos",
1750
+ "ios",
1751
+ "android"
1752
+ ],
1753
+ "answer_key": "ORG_BREACH_PROCEDURE",
1754
+ "question": "Is there a tested procedure to report a personal data breach within 72 hours?",
1755
+ "baseline_keys": [],
1756
+ "rule": "As above.",
1757
+ "controls": {
1758
+ "iso27001": [
1759
+ "A.5.24",
1760
+ "A.5.26"
1761
+ ],
1762
+ "gdpr": [
1763
+ "Art. 33",
1764
+ "Art. 34"
1765
+ ],
1766
+ "ai_act": []
1767
+ }
1768
+ },
1769
+ {
1770
+ "id": "org.processor_agreements",
1771
+ "section": "org",
1772
+ "name": "Data processing agreements",
1773
+ "os": [
1774
+ "windows",
1775
+ "linux",
1776
+ "macos",
1777
+ "ios",
1778
+ "android"
1779
+ ],
1780
+ "answer_key": "ORG_PROCESSOR_AGREEMENTS",
1781
+ "question": "Are data processing agreements signed with all processors, including AI providers?",
1782
+ "baseline_keys": [],
1783
+ "rule": "As above.",
1784
+ "controls": {
1785
+ "iso27001": [
1786
+ "A.5.20"
1787
+ ],
1788
+ "gdpr": [
1789
+ "Art. 28"
1790
+ ],
1791
+ "ai_act": []
1792
+ }
1793
+ },
1794
+ {
1795
+ "id": "org.ai_register",
1796
+ "section": "org",
1797
+ "name": "AI register",
1798
+ "os": [
1799
+ "windows",
1800
+ "linux",
1801
+ "macos",
1802
+ "ios",
1803
+ "android"
1804
+ ],
1805
+ "answer_key": "ORG_AI_REGISTER",
1806
+ "question": "Is there a register of the AI systems the organisation uses?",
1807
+ "baseline_keys": [],
1808
+ "rule": "As above.",
1809
+ "controls": {
1810
+ "iso27001": [
1811
+ "A.5.9"
1812
+ ],
1813
+ "gdpr": [],
1814
+ "ai_act": [
1815
+ "Art. 26"
1816
+ ]
1817
+ }
1818
+ },
1819
+ {
1820
+ "id": "org.ai_literacy",
1821
+ "section": "org",
1822
+ "name": "AI literacy training",
1823
+ "os": [
1824
+ "windows",
1825
+ "linux",
1826
+ "macos",
1827
+ "ios",
1828
+ "android"
1829
+ ],
1830
+ "answer_key": "ORG_AI_LITERACY",
1831
+ "question": "Have staff who use AI received AI literacy training?",
1832
+ "baseline_keys": [],
1833
+ "rule": "As above.",
1834
+ "controls": {
1835
+ "iso27001": [
1836
+ "A.6.3"
1837
+ ],
1838
+ "gdpr": [],
1839
+ "ai_act": [
1840
+ "Art. 4"
1841
+ ]
1842
+ }
1843
+ },
1844
+ {
1845
+ "id": "org.human_oversight",
1846
+ "section": "org",
1847
+ "name": "Human oversight for high-risk AI",
1848
+ "os": [
1849
+ "windows",
1850
+ "linux",
1851
+ "macos",
1852
+ "ios",
1853
+ "android"
1854
+ ],
1855
+ "answer_key": "ORG_HUMAN_OVERSIGHT",
1856
+ "question": "Is a trained person assigned to oversee each high-risk AI system?",
1857
+ "baseline_keys": [],
1858
+ "rule": "As above.",
1859
+ "controls": {
1860
+ "iso27001": [],
1861
+ "gdpr": [],
1862
+ "ai_act": [
1863
+ "Art. 14",
1864
+ "Art. 26"
1865
+ ]
1866
+ }
1867
+ },
1868
+ {
1869
+ "id": "org.ai_transparency",
1870
+ "section": "org",
1871
+ "name": "AI transparency to users",
1872
+ "os": [
1873
+ "windows",
1874
+ "linux",
1875
+ "macos",
1876
+ "ios",
1877
+ "android"
1878
+ ],
1879
+ "answer_key": "ORG_AI_TRANSPARENCY",
1880
+ "question": "Are people told when they interact with AI or receive AI-generated content?",
1881
+ "baseline_keys": [],
1882
+ "rule": "As above.",
1883
+ "controls": {
1884
+ "iso27001": [],
1885
+ "gdpr": [],
1886
+ "ai_act": [
1887
+ "Art. 50"
1888
+ ]
1889
+ }
1890
+ }
1891
  ]
1892
  }
catalog/mobile_questions.json ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_comment": "Phone self-check: what the browser detects and what the user is asked, per check and OS. Read by mobile/mobile-core.js (web page) and, through it, by the synthetic generator, so training data uses exactly this wording. Answer fields: label (shown), actual (written to the evidence), status, detail (optional). Computed answers: seconds (timeout vs SCREEN_LOCK_MAX_SECONDS), digits/alphanumeric/pattern (passcode vs MOBILE_PASSCODE_MIN_DIGITS). Settings paths differ slightly between phone makers; the 'where' text says so.",
3
+ "version": "0.3.0",
4
+ "not_sure": {"label": "I'm not sure / I can't find it", "actual": "not answered", "status": "Pending", "detail": "The user could not find or confirm this setting."},
5
+ "os_version": {
6
+ "ios": {"question": "Which iOS version does this iPhone have?", "where": "Settings › General › About › iOS Version"},
7
+ "android": {"question": "Which Android version does this phone have?", "where": "Settings › About phone › Android version (sometimes under Software information)"}
8
+ },
9
+ "checks": {
10
+ "screen_lock.enabled": {
11
+ "ios": {"question": "Is a passcode set on this iPhone?", "where": "Settings › Face ID & Passcode (or Touch ID & Passcode). If it asks for your passcode before opening, one is set.", "expected": "passcode set",
12
+ "answers": [
13
+ {"label": "Yes, a passcode is set", "actual": "passcode set", "status": "Compliant"},
14
+ {"label": "No passcode", "actual": "no passcode", "status": "Non-Compliant", "detail": "Anyone holding the phone can open it."}]},
15
+ "android": {"question": "Which screen lock is set?", "where": "Settings › Security & privacy › Device unlock › Screen lock (names vary by phone maker)", "expected": "PIN, password or pattern",
16
+ "answers": [
17
+ {"label": "PIN, password or pattern", "actual": "screen lock set", "status": "Compliant"},
18
+ {"label": "Swipe or None", "actual": "no screen lock (Swipe/None)", "status": "Non-Compliant", "detail": "Anyone holding the phone can open it."}]}
19
+ },
20
+ "screen_lock.timeout": {
21
+ "ios": {"question": "What is Auto-Lock set to?", "where": "Settings › Display & Brightness › Auto-Lock", "expected_from": "seconds",
22
+ "answers": [
23
+ {"label": "30 seconds", "seconds": 30}, {"label": "1 minute", "seconds": 60}, {"label": "2 minutes", "seconds": 120},
24
+ {"label": "3 minutes", "seconds": 180}, {"label": "4 minutes", "seconds": 240}, {"label": "5 minutes", "seconds": 300},
25
+ {"label": "Never", "seconds": null}]},
26
+ "android": {"question": "What is Screen timeout set to?", "where": "Settings › Display › Screen timeout. If there is also 'Lock after screen timeout', it should be Immediately.", "expected_from": "seconds",
27
+ "answers": [
28
+ {"label": "15 seconds", "seconds": 15}, {"label": "30 seconds", "seconds": 30}, {"label": "1 minute", "seconds": 60},
29
+ {"label": "2 minutes", "seconds": 120}, {"label": "5 minutes", "seconds": 300}, {"label": "10 minutes", "seconds": 600},
30
+ {"label": "30 minutes", "seconds": 1800}]}
31
+ },
32
+ "mobile.passcode_strength": {
33
+ "ios": {"question": "What kind of passcode do you use?", "where": "The code you type to unlock. Settings › Face ID & Passcode › Change Passcode › Passcode Options shows the choices.", "expected_from": "passcode",
34
+ "answers": [
35
+ {"label": "4-digit code", "digits": 4}, {"label": "6-digit code", "digits": 6},
36
+ {"label": "Custom numeric code (7 or more digits)", "digits": 8}, {"label": "Custom alphanumeric code (letters and numbers)", "alphanumeric": true}]},
37
+ "android": {"question": "What kind of screen lock do you use?", "where": "Settings › Security & privacy › Device unlock › Screen lock", "expected_from": "passcode",
38
+ "answers": [
39
+ {"label": "Pattern", "pattern": true}, {"label": "4-digit PIN", "digits": 4}, {"label": "PIN of 6 or more digits", "digits": 6},
40
+ {"label": "Password (letters and numbers)", "alphanumeric": true}, {"label": "Swipe or None", "none": true}]}
41
+ },
42
+ "mobile.jailbreak_root": {
43
+ "ios": {"question": "Has this iPhone been jailbroken?", "where": "Jailbreaking means deliberately modifying iOS to install apps outside Apple's control. If nobody did this on purpose, answer No.", "expected": "not jailbroken",
44
+ "answers": [
45
+ {"label": "No", "actual": "not jailbroken (user confirmed)", "status": "Compliant"},
46
+ {"label": "Yes", "actual": "jailbroken", "status": "Non-Compliant", "detail": "iOS security protections are bypassed on a jailbroken phone."}]},
47
+ "android": {"question": "Is this phone rooted or is its bootloader unlocked?", "where": "Rooting means deliberately giving apps full system access. If nobody did this on purpose, answer No.", "expected": "not rooted",
48
+ "answers": [
49
+ {"label": "No", "actual": "not rooted (user confirmed)", "status": "Compliant"},
50
+ {"label": "Yes", "actual": "rooted or bootloader unlocked", "status": "Non-Compliant", "detail": "Android security protections are weakened on a rooted phone."}]}
51
+ },
52
+ "mobile.app_sources": {
53
+ "ios": {"question": "Where do the apps on this iPhone come from?", "where": "Settings › General › VPN & Device Management lists developer or enterprise profiles. Alternative app marketplaces appear as separate apps.", "expected": "App Store only",
54
+ "answers": [
55
+ {"label": "Only the App Store", "actual": "App Store only", "status": "Compliant"},
56
+ {"label": "App Store and apps installed by my employer", "actual": "App Store and employer-managed apps", "status": "Compliant"},
57
+ {"label": "Also another marketplace, a website or a developer profile", "actual": "apps from outside the App Store", "status": "Non-Compliant", "detail": "Apps from outside the App Store skip Apple's review."}]},
58
+ "android": {"question": "Is any app allowed to install unknown apps?", "where": "Settings › Apps › Special app access › Install unknown apps. Every app should show 'Not allowed'.", "expected": "all apps: Not allowed",
59
+ "answers": [
60
+ {"label": "All show 'Not allowed'", "actual": "unknown apps blocked for all apps", "status": "Compliant"},
61
+ {"label": "At least one shows 'Allowed'", "actual": "unknown apps allowed for at least one app", "status": "Non-Compliant", "detail": "Apps from outside Google Play skip Play Store checks."}]}
62
+ },
63
+ "mobile.usb_debugging": {
64
+ "android": {"question": "Is USB debugging on?", "where": "Settings › System › Developer options › USB debugging. If you cannot find Developer options, they are off.", "expected": "off",
65
+ "answers": [
66
+ {"label": "Off, or no Developer options", "actual": "off", "status": "Compliant"},
67
+ {"label": "On", "actual": "on", "status": "Non-Compliant", "detail": "A connected computer can read and change data on the phone."}]}
68
+ },
69
+ "mobile.find_my_device": {
70
+ "ios": {"question": "Is Find My iPhone on?", "where": "Settings › [your name] › Find My › Find My iPhone", "expected": "on",
71
+ "answers": [
72
+ {"label": "On", "actual": "on", "status": "Compliant"},
73
+ {"label": "Off", "actual": "off", "status": "Non-Compliant", "detail": "A lost phone cannot be located, locked or erased remotely."}]},
74
+ "android": {"question": "Is Find My Device (Find Hub) on?", "where": "Settings › Google › All services › Find My Device (called Find Hub on newer phones)", "expected": "on",
75
+ "answers": [
76
+ {"label": "On", "actual": "on", "status": "Compliant"},
77
+ {"label": "Off", "actual": "off", "status": "Non-Compliant", "detail": "A lost phone cannot be located, locked or erased remotely."}]}
78
+ },
79
+ "malware.antivirus": {
80
+ "android": {"question": "Is 'Scan apps with Play Protect' on?", "where": "Play Store › your profile picture › Play Protect › Settings (gear icon)", "expected": "on",
81
+ "answers": [
82
+ {"label": "On", "actual": "Play Protect scanning on", "status": "Compliant"},
83
+ {"label": "Off", "actual": "Play Protect scanning off", "status": "Non-Compliant", "detail": "Installed apps are not checked for harmful behaviour."}]}
84
+ },
85
+ "vuln.pending_updates": {
86
+ "android": {"question": "What is the Android security update date?", "where": "Settings › About phone › Android version › Android security update (or Settings › Security & privacy › Updates)", "expected_from": "patch_month", "input": "month"}
87
+ },
88
+ "vuln.outdated_apps": {
89
+ "ios": {"question": "Are automatic App Updates on?", "where": "Settings › App Store › App Updates", "expected": "automatic app updates on",
90
+ "answers": [
91
+ {"label": "On", "actual": "automatic app updates on", "status": "Compliant"},
92
+ {"label": "Off", "actual": "automatic app updates off", "status": "Non-Compliant", "detail": "Apps keep known security bugs until they are updated by hand."}]},
93
+ "android": {"question": "Are apps updated automatically?", "where": "Play Store › your profile picture › Settings › Network preferences › Auto-update apps", "expected": "automatic app updates on",
94
+ "answers": [
95
+ {"label": "Over any network, or over Wi-Fi only", "actual": "automatic app updates on", "status": "Compliant"},
96
+ {"label": "Don't auto-update apps", "actual": "automatic app updates off", "status": "Non-Compliant", "detail": "Apps keep known security bugs until they are updated by hand."}]}
97
+ },
98
+ "gdpr.auto_updates": {
99
+ "ios": {"question": "What do Automatic Updates show?", "where": "Settings › General › Software Update › Automatic Updates", "expected": "download and install on",
100
+ "answers": [
101
+ {"label": "Download and Install are both on", "actual": "download and install on", "status": "Compliant"},
102
+ {"label": "Only Download is on", "actual": "download only", "status": "Non-Compliant", "detail": "Updates wait for the user to install them."},
103
+ {"label": "Both off", "actual": "off", "status": "Non-Compliant", "detail": "Security updates are not installed automatically."}]},
104
+ "android": {"question": "Are system updates downloaded automatically?", "where": "Settings › System › Software updates (names vary: 'Auto download over Wi-Fi', 'Automatic system updates')", "expected": "automatic",
105
+ "answers": [
106
+ {"label": "Yes, automatically", "actual": "automatic", "status": "Compliant"},
107
+ {"label": "No, I install them by hand", "actual": "manual", "status": "Non-Compliant", "detail": "Security updates wait for the user."}]}
108
+ },
109
+ "gdpr.time_sync": {
110
+ "ios": {"question": "Is 'Set Automatically' on?", "where": "Settings › General › Date & Time", "expected": "set automatically",
111
+ "answers": [
112
+ {"label": "On", "actual": "set automatically", "status": "Compliant"},
113
+ {"label": "Off", "actual": "set by hand", "status": "Non-Compliant", "detail": "Wrong time breaks certificate checks and makes logs unreliable."}]},
114
+ "android": {"question": "Is 'Set time automatically' on?", "where": "Settings › System › Date & time", "expected": "set automatically",
115
+ "answers": [
116
+ {"label": "On", "actual": "set automatically", "status": "Compliant"},
117
+ {"label": "Off", "actual": "set by hand", "status": "Non-Compliant", "detail": "Wrong time breaks certificate checks and makes logs unreliable."}]}
118
+ },
119
+ "gdpr.backup_configured": {
120
+ "ios": {"question": "Is iCloud Backup on?", "where": "Settings › [your name] › iCloud › iCloud Backup", "expected": "backup on",
121
+ "answers": [
122
+ {"label": "On", "actual": "iCloud Backup on", "status": "Compliant"},
123
+ {"label": "Off, but I back up to a computer", "actual": "backed up to a computer", "status": "Compliant", "detail": "Manual computer backups depend on the user remembering them."},
124
+ {"label": "Off", "actual": "no backup", "status": "Non-Compliant", "detail": "Data on a lost or broken phone cannot be restored."}]},
125
+ "android": {"question": "Is phone backup on?", "where": "Settings › Google › Backup (or Settings › System › Backup; Samsung: Accounts and backup)", "expected": "backup on",
126
+ "answers": [
127
+ {"label": "Backup by Google One is on", "actual": "Google backup on", "status": "Compliant"},
128
+ {"label": "The phone maker's cloud backup is on", "actual": "phone maker cloud backup on", "status": "Compliant"},
129
+ {"label": "Off", "actual": "no backup", "status": "Non-Compliant", "detail": "Data on a lost or broken phone cannot be restored."}]}
130
+ },
131
+ "gdpr.telemetry": {
132
+ "ios": {"question": "Is 'Share iPhone Analytics' on?", "where": "Settings › Privacy & Security › Analytics & Improvements", "expected": "analytics sharing off",
133
+ "answers": [
134
+ {"label": "Off", "actual": "analytics sharing off", "status": "Compliant"},
135
+ {"label": "On", "actual": "analytics sharing on", "status": "Non-Compliant", "detail": "Usage data from the phone is sent to Apple."}]},
136
+ "android": {"question": "Is 'Usage & diagnostics' on?", "where": "Settings › Google › All services › Usage & diagnostics (also check the phone maker's diagnostics option)", "expected": "usage & diagnostics off",
137
+ "answers": [
138
+ {"label": "Off", "actual": "usage & diagnostics off", "status": "Compliant"},
139
+ {"label": "On", "actual": "usage & diagnostics on", "status": "Non-Compliant", "detail": "Usage data from the phone is sent to Google."}]}
140
+ },
141
+ "gdpr.lock_screen_previews": {
142
+ "ios": {"question": "What is 'Show Previews' set to?", "where": "Settings › Notifications › Show Previews", "expected": "When Unlocked or Never",
143
+ "answers": [
144
+ {"label": "When Unlocked", "actual": "When Unlocked", "status": "Compliant"},
145
+ {"label": "Never", "actual": "Never", "status": "Compliant"},
146
+ {"label": "Always", "actual": "Always", "status": "Non-Compliant", "detail": "Message and email content can be read on the locked screen."}]},
147
+ "android": {"question": "What do notifications show on the lock screen?", "where": "Settings › Notifications › Notifications on lock screen (or Settings › Privacy)", "expected": "sensitive content hidden",
148
+ "answers": [
149
+ {"label": "Don't show notifications", "actual": "no notifications on lock screen", "status": "Compliant"},
150
+ {"label": "Hide sensitive content / show only alerts", "actual": "sensitive content hidden", "status": "Compliant"},
151
+ {"label": "Show all notification content", "actual": "all content shown", "status": "Non-Compliant", "detail": "Message and email content can be read on the locked screen."}]}
152
+ },
153
+ "aiact.os_ai_features": {
154
+ "ios": {"question": "Is the ChatGPT extension in Apple Intelligence on?", "where": "Settings › Apple Intelligence & Siri › ChatGPT (only on iPhones that support Apple Intelligence)", "expected": "external AI off unless approved",
155
+ "answers": [
156
+ {"label": "Off", "actual": "ChatGPT extension off", "status": "Compliant"},
157
+ {"label": "On", "actual": "ChatGPT extension on", "status": "Non-Compliant", "detail": "Requests can be sent to an external AI service."},
158
+ {"label": "Apple Intelligence is not on this iPhone", "actual": "Apple Intelligence not available", "status": "NotApplicable"}]},
159
+ "android": {"question": "Can the AI assistant use what is on your screen?", "where": "Gemini app › your profile picture › Settings › 'Screen context' / 'Use screen context' (other assistants have similar options)", "expected": "screen access off unless approved",
160
+ "answers": [
161
+ {"label": "Off, or no AI assistant", "actual": "assistant screen access off", "status": "Compliant"},
162
+ {"label": "On", "actual": "assistant screen access on", "status": "Non-Compliant", "detail": "Screen content can be sent to an external AI service."}]}
163
+ }
164
+ },
165
+ "ai_apps": {
166
+ "question": "Which AI apps are installed on this phone?",
167
+ "where": "Swipe down on the Home Screen and search, or look in the App Library / app drawer.",
168
+ "options": ["ChatGPT", "Gemini", "Microsoft Copilot", "Claude", "Perplexity", "Meta AI", "Grok", "DeepSeek", "Character.AI", "Replika", "Otter", "Grammarly"]
169
+ }
170
+ }
collectors/linux/audit.sh CHANGED
@@ -11,7 +11,7 @@
11
  set -u
12
 
13
  COLLECTOR_VERSION="0.2.0"
14
- CATALOG_VERSION="0.2.0"
15
 
16
  SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
17
  ROOT_DIR=$(cd "$SCRIPT_DIR/../.." && pwd)
 
11
  set -u
12
 
13
  COLLECTOR_VERSION="0.2.0"
14
+ CATALOG_VERSION="0.3.0"
15
 
16
  SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
17
  ROOT_DIR=$(cd "$SCRIPT_DIR/../.." && pwd)
collectors/macos/audit.sh CHANGED
@@ -13,7 +13,7 @@
13
  set -u
14
 
15
  COLLECTOR_VERSION="0.2.0"
16
- CATALOG_VERSION="0.2.0"
17
 
18
  SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
19
  ROOT_DIR=$(cd "$SCRIPT_DIR/../.." && pwd)
 
13
  set -u
14
 
15
  COLLECTOR_VERSION="0.2.0"
16
+ CATALOG_VERSION="0.3.0"
17
 
18
  SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
19
  ROOT_DIR=$(cd "$SCRIPT_DIR/../.." && pwd)
collectors/windows/audit.ps1 CHANGED
@@ -26,7 +26,7 @@ param(
26
  Set-StrictMode -Version 2.0
27
  $ErrorActionPreference = 'Stop'
28
  $CollectorVersion = '0.2.0'
29
- $CatalogVersion = '0.2.0'
30
 
31
  # ---------------------------------------------------------------------------
32
  # Helpers
 
26
  Set-StrictMode -Version 2.0
27
  $ErrorActionPreference = 'Stop'
28
  $CollectorVersion = '0.2.0'
29
+ $CatalogVersion = '0.3.0'
30
 
31
  # ---------------------------------------------------------------------------
32
  # Helpers
docs/CONTROLS.md CHANGED
@@ -1,96 +1,103 @@
1
  # Audited controls and data sources
2
 
3
- Catalog version 0.2.0 · 68 checks. Generated by `tools/build_controls_doc.py` — do not edit by hand.
4
 
5
  All sources are read-only. Internet is only used by *Pending security updates* on Windows and macOS, and by the app to refresh the vulnerability bundle.
 
6
 
7
- ## COMPLIANCE RESULTS (43)
8
 
9
- | Check | Applies to | References | Rule | Windows source | Linux source | macOS source |
10
- |---|---|---|---|---|---|---|
11
- | Minimum password length | windows, linux, macos | ISO A.5.17; ISO A.8.5; GDPR Art. 32(1)(b) | Configured minimum length >= PASSWORD_MIN_LENGTH. | secedit export: MinimumPasswordLength | PAM pam_pwquality minlen (/etc/security/pwquality.conf, PAM args) | pwpolicy -getaccountpolicies (minLength / regex) |
12
- | Password history | windows, linux, macos | ISO A.5.17; ISO A.8.5; GDPR Art. 32(1)(b) | Remembered passwords >= PASSWORD_HISTORY. | secedit export: PasswordHistorySize | pam_pwhistory remember (PAM args, /etc/security/pwhistory.conf) | pwpolicy: policyAttributePasswordHistoryDepth |
13
- | Password expiration | windows, linux, macos | ISO A.5.17; GDPR Art. 32(1)(b) | Passwords expire after 1..PASSWORD_MAX_AGE_DAYS days (0 in the baseline = expiry not required). | secedit export: MaximumPasswordAge | /etc/login.defs PASS_MAX_DAYS + /etc/shadow per account | pwpolicy: policyAttributeExpiresEveryNDays |
14
- | Minimum password age | windows, linux, macos | ISO A.5.17; GDPR Art. 32(1)(b) | Minimum age >= PASSWORD_MIN_AGE_DAYS days. macOS has no such setting (NotApplicable). | secedit export: MinimumPasswordAge | /etc/login.defs PASS_MIN_DAYS + /etc/shadow per account | not available on macOS (NotApplicable) |
15
- | Password complexity | windows, linux, macos | ISO A.5.17; ISO A.8.5; GDPR Art. 32(1)(b) | Complexity enforced (mixed character classes). | secedit export: PasswordComplexity | pam_pwquality minclass / dcredit-ucredit-lcredit-ocredit | pwpolicy: alpha + numeric requirements |
16
- | Account lockout threshold | windows, linux, macos | ISO A.8.5; GDPR Art. 32(1)(b) | Lockout after 1..LOCKOUT_THRESHOLD_MAX failed attempts (0 = never locks). | secedit export: LockoutBadCount | pam_faillock deny (PAM args, /etc/security/faillock.conf) | pwpolicy: policyAttributeMaximumFailedAuthentications |
17
- | Account lockout duration | windows, linux, macos | ISO A.8.5; GDPR Art. 32(1)(b) | Locked for >= LOCKOUT_DURATION_MIN_MINUTES minutes, or until an administrator unlocks. | secedit export: LockoutDuration | pam_faillock unlock_time | pwpolicy: autoEnableInSeconds |
18
- | Lockout observation period | windows, linux, macos | ISO A.8.5; GDPR Art. 32(1)(b) | Failed-attempt counter kept for >= LOCKOUT_OBSERVATION_MIN_MINUTES minutes. | secedit export: ResetLockoutCount | pam_faillock fail_interval | pwpolicy: autoEnableInSeconds (counter resets with lockout) |
19
- | Antivirus | windows, linux, macos | ISO A.8.7; GDPR Art. 32(1)(b) | An enabled, up-to-date antivirus product is present. | Security Center (root/SecurityCenter2 AntiVirusProduct) | systemd services: clamav-daemon, mdatp, falcon-sensor, sentinelone, sophos-spl, esets | XProtect version (xprotect version) + running third-party agents |
20
- | Malware protection service / Apple malware protection service / ClamAV malware protection service / Microsoft Defender Antimalware | windows, linux, macos | ISO A.8.7; GDPR Art. 32(1)(b) | The platform malware protection service is running. | Get-MpComputerStatus: AMServiceEnabled, AntivirusEnabled | systemctl is-active clamav-daemon / clamd | xprotect status + spctl --status (Gatekeeper) |
21
- | Real-time protection | windows | ISO A.8.7; GDPR Art. 32(1)(b) | Defender real-time protection is on. | Get-MpComputerStatus: RealTimeProtectionEnabled | — | — |
22
- | On-access malware protection / On-access protection | windows, linux, macos | ISO A.8.7; GDPR Art. 32(1)(b) | Files are scanned when opened. macOS: NotApplicable unless a third-party scanner provides it. | Get-MpComputerStatus: OnAccessProtectionEnabled | clamav-clamonacc service / clamonacc process | third-party agent only (XProtect has no on-access scan) |
23
- | Tamper protection | windows | ISO A.8.7; GDPR Art. 32(1)(b) | Defender tamper protection is on. | Get-MpComputerStatus: IsTamperProtected | — | — |
24
- | Malware signatures / Malware protection signatures / Security intelligence | windows, linux, macos | ISO A.8.7; ISO A.8.8; GDPR Art. 32(1)(b) | Signatures no older than SIGNATURE_MAX_AGE_DAYS (macOS XProtect: MACOS_XPROTECT_MAX_AGE_DAYS). | Get-MpComputerStatus: AntivirusSignatureAge | age of /var/lib/clamav/daily.c[lv]d | XProtect install date (xprotect version / XProtect.meta.plist) |
25
- | Malware signature updates / Malware security updates | linux, macos | ISO A.8.7; ISO A.8.8; GDPR Art. 32(1)(b) | Automatic signature/security-data updates are enabled. | — | systemctl is-active clamav-freshclam | com.apple.SoftwareUpdate ConfigDataInstall + CriticalUpdateInstall |
26
- | Firewall | linux, macos | ISO A.8.20; ISO A.8.1; GDPR Art. 32(1)(b) | A host firewall is active. | — | ufw status / firewalld / nft list ruleset / iptables -S | socketfilterfw --getglobalstate |
27
- | Firewall inbound policy | linux, macos | ISO A.8.20; GDPR Art. 32(1)(b) | Unsolicited inbound traffic is blocked by default (macOS: firewall on with stealth mode). | — | ufw default incoming / firewalld zone target / nft input policy | socketfilterfw --getstealthmode |
28
- | Firewall outbound policy | linux, macos | ISO A.8.20; GDPR Art. 32(1)(b) | Default outbound policy equals FIREWALL_OUTBOUND. | — | ufw default outgoing / nft output policy | application firewall is inbound-only |
29
- | Domain firewall | windows | ISO A.8.20; ISO A.8.1; GDPR Art. 32(1)(b) | Domain profile enabled. | Get-NetFirewallProfile -PolicyStore ActiveStore (Domain) | — | — |
30
- | Domain inbound | windows | ISO A.8.20; GDPR Art. 32(1)(b) | Domain profile default inbound action is Block. | Get-NetFirewallProfile: DefaultInboundAction (Domain) | — | — |
31
- | Domain outbound | windows | ISO A.8.20; GDPR Art. 32(1)(b) | Domain profile default outbound action equals FIREWALL_OUTBOUND. | Get-NetFirewallProfile: DefaultOutboundAction (Domain) | — | — |
32
- | Private firewall | windows | ISO A.8.20; ISO A.8.1; GDPR Art. 32(1)(b) | Private profile enabled. | Get-NetFirewallProfile -PolicyStore ActiveStore (Private) | — | — |
33
- | Private inbound | windows | ISO A.8.20; GDPR Art. 32(1)(b) | Private profile default inbound action is Block. | Get-NetFirewallProfile: DefaultInboundAction (Private) | — | — |
34
- | Private outbound | windows | ISO A.8.20; GDPR Art. 32(1)(b) | Private profile default outbound action equals FIREWALL_OUTBOUND. | Get-NetFirewallProfile: DefaultOutboundAction (Private) | — | — |
35
- | Public firewall | windows | ISO A.8.20; ISO A.8.1; GDPR Art. 32(1)(b) | Public profile enabled. | Get-NetFirewallProfile -PolicyStore ActiveStore (Public) | — | — |
36
- | Public inbound | windows | ISO A.8.20; GDPR Art. 32(1)(b) | Public profile default inbound action is Block. | Get-NetFirewallProfile: DefaultInboundAction (Public) | — | — |
37
- | Public outbound | windows | ISO A.8.20; GDPR Art. 32(1)(b) | Public profile default outbound action equals FIREWALL_OUTBOUND. | Get-NetFirewallProfile: DefaultOutboundAction (Public) | — | — |
38
- | Screen lock | windows, linux, macos | ISO A.8.1; ISO A.7.7; GDPR Art. 32(1)(b) | The screen locks when idle (Windows: machine inactivity limit or a password-protected screen saver). | InactivityTimeoutSecs policy or users' ScreenSaveActive/ScreenSaverIsSecure (HKU) | gsettings org.gnome.desktop.screensaver lock-enabled + session idle-delay (per user) | sysadminctl -screenLock status / managed com.apple.screensaver |
39
- | Screen lock timeout | windows, linux, macos | ISO A.8.1; ISO A.7.7; GDPR Art. 32(1)(b) | Idle time before lock (including any grace delay) is 1..SCREEN_LOCK_MAX_SECONDS seconds (baseline: 300 = 5 minutes). | InactivityTimeoutSecs policy or users' ScreenSaveTimeOut (HKU) | gsettings idle-delay + lock-delay (per user) | screen saver idleTime + pmset displaysleep + password delay |
40
- | Screen lock password requirement | windows, linux, macos | ISO A.8.1; ISO A.7.7; ISO A.8.5; GDPR Art. 32(1)(b) | A password is required to unlock (macOS: within 5 seconds of the screen locking). | InactivityTimeoutSecs policy or users' ScreenSaverIsSecure; DisableLockWorkstation | gsettings lock-enabled + lockdown disable-lock-screen | sysadminctl -screenLock delay / managed askForPassword |
41
- | USB storage device access | windows, linux, macos | ISO A.7.10; ISO A.8.12; GDPR Art. 32(1)(b) | Whether USB mass storage devices can connect at all equals USB_DEVICE_ACCESS (allow/deny): driver, device-installation policy, USBGuard or MDM restriction. | RemovableStorageDevices Deny_All policy, USBSTOR service Start | modprobe usb-storage install rule / blacklist / USBGuard | managed com.apple.systemuiserver mount-controls |
42
- | USB storage read | windows, linux, macos | ISO A.7.10; ISO A.8.12; GDPR Art. 32(1)(b) | Effective USB storage read access equals USB_READ (allow/deny). | RemovableStorageDevices policies (Deny_All, Deny_Read), USBSTOR Start | usb-storage module rules, USBGuard | managed mount-controls (deny) |
43
- | USB storage write | windows, linux, macos | ISO A.7.10; ISO A.8.12; GDPR Art. 32(1)(b) | Effective USB storage write access equals USB_WRITE (allow/deny). | RemovableStorageDevices Deny_Write, StorageDevicePolicies WriteProtect | udev read-only rules, usb-storage rules, USBGuard | managed mount-controls (deny / read-only) |
44
- | Approved IT administrator | windows, linux, macos | ISO A.8.2; ISO A.5.15; GDPR Art. 32(1)(b) | At least one account listed in APPROVED_ADMINS is an enabled administrator. | Administrators group by SID S-1-5-32-544 (ADSI) + Get-LocalUser | getent group sudo/admin/wheel, UID 0 accounts, /etc/sudoers(.d) | dscl . -read /Groups/admin GroupMembership |
45
- | Unauthorized employee administrator accounts / Unauthorized employee local administrator accounts | windows, linux, macos | ISO A.8.2; ISO A.5.18; GDPR Art. 32(1)(b) | No administrator accounts other than APPROVED_ADMINS (and the disabled built-in Administrator on Windows). | Administrators group by SID S-1-5-32-544 (ADSI) + Get-LocalUser | getent group sudo/admin/wheel, UID 0 accounts, /etc/sudoers(.d) | dscl . -read /Groups/admin GroupMembership |
46
- | BitLocker | windows | ISO A.8.24; ISO A.8.1; GDPR Art. 32(1)(a) | BitLocker is turned on for the system drive. | Get-BitLockerVolume (system drive): VolumeStatus | — | — |
47
- | Encryption | windows, linux, macos | ISO A.8.24; ISO A.8.1; GDPR Art. 32(1)(a) | The system disk is fully encrypted (BitLocker / LUKS / FileVault). | Get-BitLockerVolume: VolumeStatus, EncryptionPercentage | findmnt / + lsblk (crypt device under /) | fdesetup status |
48
- | Disk encryption protection / BitLocker protection | windows, linux, macos | ISO A.8.24; GDPR Art. 32(1)(a) | Encryption protection is active (not suspended or in progress) with a strong cipher. | Get-BitLockerVolume: ProtectionStatus, EncryptionMethod | cryptsetup status (type, cipher, key size) | fdesetup status (no encryption in progress) |
49
- | Disk encryption recovery / BitLocker recovery | windows, linux, macos | ISO A.8.24; ISO A.8.13; GDPR Art. 32(1)(a); GDPR Art. 32(1)(c) | A recovery key or second unlock method exists. | Get-BitLockerVolume: KeyProtector (RecoveryPassword) | cryptsetup luksDump (key slots, tokens) | fdesetup haspersonalrecoverykey / hasinstitutionalrecoverykey |
50
- | Signed-in user account type | windows, linux, macos | ISO A.8.2; ISO A.5.15; GDPR Art. 32(1)(b) | The everyday signed-in user is a Standard account (not an administrator), unless listed in APPROVED_ADMINS. | signed-in user (Win32_ComputerSystem) vs Administrators group | session users (loginctl) vs sudo/admin/wheel | console user (stat /dev/console) + dsmemberutil checkmembership |
51
- | Pending security updates | windows, linux, macos | ISO A.8.8; GDPR Art. 32(1)(b); GDPR Art. 32(1)(d) | Security updates offered by the OS vendor but not yet installed <= MAX_PENDING_SECURITY_UPDATES. | Windows Update Agent API (Microsoft.Update.Session search) - needs internet | apt-get -s upgrade (security) / dnf updateinfo --security - uses local package lists | softwareupdate -l - needs internet |
52
- | Outdated third-party applications | windows, linux, macos | ISO A.8.8; ISO A.8.19; GDPR Art. 32(1)(b) | Installed third-party applications with a newer version available <= MAX_OUTDATED_APPS (Windows: winget; macOS: app versions; Linux: snap/flatpak and packages). | winget upgrade | apt upgradable, snap refresh --list, flatpak updates | brew outdated (Homebrew; App Store apps not covered) |
53
- | Known vulnerabilities in installed software | windows, linux, macos | ISO A.8.8; GDPR Art. 32(1)(b) | Installed software versions matched against an offline copy of the OSV/NVD vulnerability data: critical or high vulnerabilities <= MAX_CRITICAL_HIGH_VULNERABILITIES. | installed programs (machine and per-user uninstall registry) matched by the app against the signed offline bundle (NVD ranges, CISA KEV, EPSS) | dpkg/rpm package list (with source packages) matched by the app against the signed offline bundle (OSV: Ubuntu, Debian, RHEL, AlmaLinux, Rocky) | app bundle versions (/Applications, ~/Applications) matched by the app against the signed offline bundle (NVD ranges, CISA KEV, EPSS) |
 
 
 
 
 
54
 
55
- ## GDPR TECHNICAL CONTROLS (12)
56
 
57
- | Check | Applies to | References | Rule | Windows source | Linux source | macOS source |
58
- |---|---|---|---|---|---|---|
59
- | Automatic security updates | windows, linux, macos | ISO A.8.8; GDPR Art. 32(1)(b) | Security updates are downloaded and installed automatically. | WindowsUpdate\AU policy (NoAutoUpdate, AUOptions), wuauserv start type | apt-config Unattended-Upgrade / dnf-automatic timer | com.apple.SoftwareUpdate AutomaticCheck/Download/Install keys |
60
- | Security updates installed recently | windows, linux, macos | ISO A.8.8; GDPR Art. 32(1)(b); GDPR Art. 32(1)(d) | Last successful update/package change within UPDATES_MAX_AGE_DAYS days. | Windows Update history (COM) or Get-HotFix | /var/log/apt/history.log / rpm -qa --last | /Library/Receipts/InstallHistory.plist (softwareupdated) |
61
- | Supported operating system version | windows, linux, macos | ISO A.8.8; GDPR Art. 32(1)(b) | The OS version still receives security updates (minimums in the baseline). | Win32_OperatingSystem BuildNumber vs WINDOWS_MIN_BUILD | /etc/os-release VERSION_ID vs baseline minimum | sw_vers -productVersion vs MACOS_MIN_MAJOR |
62
- | Security event logging | windows, linux, macos | ISO A.8.15; GDPR Art. 32(1)(d); GDPR Art. 33 | Logon and account-change events are recorded (Windows audit policy, Linux auditd, macOS unified log). | auditpol /backup (Logon, User Account Management) + legacy audit policy | systemctl is-active auditd, journal persistence | logd running (unified logging) |
63
- | Time synchronisation | windows, linux, macos | ISO A.8.17; GDPR Art. 33 | The clock is synchronised from a time server. | W32Time Parameters Type + service start type | timedatectl NTP / chronyd / ntpd | systemsetup -getusingnetworktime (needs root) |
64
- | Backup configured | windows, linux, macos | ISO A.8.13; GDPR Art. 32(1)(c) | A backup solution is configured for user data. | OneDrive KFM policy, File History config, backup agent services | Timeshift config, Deja Dup (gsettings), restic/borg timers | tmutil destinationinfo, backup agent processes |
65
- | Recent backup | windows, linux, macos | ISO A.8.13; GDPR Art. 32(1)(c) | Last backup within BACKUP_MAX_AGE_DAYS days (Pending when the date cannot be read). | not readable for most tools (Pending) | Timeshift snapshot names, Deja Dup last-backup | tmutil latestbackup |
66
- | Removable media encryption | windows, linux, macos | ISO A.7.10; ISO A.8.24; GDPR Art. 32(1)(a); GDPR Art. 34 | Data cannot be written to unencrypted removable drives. | FVE RDVDenyWriteAccess (BitLocker To Go) or USB write denied | USB write denied | USB write denied |
67
- | Guest account disabled | windows, linux, macos | ISO A.5.16; ISO A.8.5; GDPR Art. 32(1)(b) | No enabled guest login. | Get-LocalUser (SID -501): Enabled | LightDM allow-guest, 'guest' account shell | com.apple.loginwindow GuestEnabled, SMB AllowGuestAccess |
68
- | Remote access services restricted | windows, linux, macos | ISO A.8.20; ISO A.6.7; GDPR Art. 32(1)(b) | Only remote access services listed in APPROVED_REMOTE_SERVICES are enabled. | fDenyTSConnections (RDP), sshd/WinRM/TeamViewer/AnyDesk/chromoting services, non-admin SMB shares | ssh/xrdp/vnc/samba/teamviewer/anydesk/chrome-remote-desktop | launchctl print-disabled (sshd, screensharing, smbd, chromoting), ARDAgent/TeamViewer/AnyDesk |
69
- | Diagnostic data minimised | windows, linux, macos | ISO A.5.34; GDPR Art. 5(1)(c); GDPR Art. 25 | OS diagnostic/usage data sharing is at the minimum level. | DataCollection AllowTelemetry (policy / Settings) | popularity-contest, whoopsie | DiagnosticMessagesHistory AutoSubmit, ThirdPartyDataSubmit |
70
- | Personal data discovery | windows, linux, macos | ISO A.5.34; ISO A.5.12; GDPR Art. 5(1)(c); GDPR Art. 5(1)(e); GDPR Art. 30 | Opt-in scan of user folders for personal data patterns (counts and paths only). Not implemented in stage 1: NotApplicable. | planned opt-in scan | planned opt-in scan | planned opt-in scan |
 
71
 
72
  ## EU AI ACT CONTROLS (5)
73
 
74
- | Check | Applies to | References | Rule | Windows source | Linux source | macOS source |
75
- |---|---|---|---|---|---|---|
76
- | AI software inventory | windows, linux, macos | ISO A.5.9; GDPR Art. 30; AI Act Art. 26 | AI apps, local AI services and local model folders are listed (Compliant = inventory taken). | uninstall registry + Store apps (AI apps), port 11434, model folders, Claude Code CLI | AI CLIs, snaps, flatpaks, /opt apps, port 11434, model folders | /Applications AI apps, AI CLIs, port 11434, model folders |
77
- | Unapproved AI tools (shadow AI) | windows, linux, macos | ISO A.5.9; ISO A.8.19; GDPR Art. 28; GDPR Art. 44; AI Act Art. 26 | Every AI tool found is listed in APPROVED_AI_TOOLS. | AI inventory vs APPROVED_AI_TOOLS | AI inventory vs APPROVED_AI_TOOLS | AI inventory vs APPROVED_AI_TOOLS |
78
- | AI system risk classification | windows, linux, macos | ISO A.5.9; AI Act Art. 5; AI Act Art. 6; AI Act Annex III | 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. | AI inventory, classified by the model | AI inventory, classified by the model | AI inventory, classified by the model |
79
- | Operating system AI features restricted / Apple Intelligence external integrations restricted / Windows Recall / Copilot data analysis disabled | windows, linux, macos | ISO A.5.34; GDPR Art. 5(1)(c); GDPR Art. 25; AI Act Art. 26 | OS-level AI features that capture or send user data are disabled by policy. NotApplicable where the OS has none. | WindowsAI policies (DisableAIDataAnalysis, AllowRecallEnablement), Recall optional feature | none built in (NotApplicable) | managed com.apple.applicationaccess allowExternalIntelligenceIntegrations (Apple silicon) |
80
- | AI usage log retention | windows, linux, macos | ISO A.8.15; AI Act Art. 26(6) | Logs kept >= 6 months if a high-risk AI system is used. Pending until classification; NotApplicable when no AI tool is found. | depends on AI classification | depends on AI classification | depends on AI classification |
81
 
82
  ## ORGANISATIONAL CONTROLS (8)
83
 
84
- | Check | Applies to | References | Rule | Windows source | Linux source | macOS source |
85
- |---|---|---|---|---|---|---|
86
- | Record of processing activities | windows, linux, macos | ISO A.5.34; GDPR Art. 30 | Answer yes = Compliant, no/partial = Non-Compliant, n/a = NotApplicable, unanswered = Pending. | organisational.conf (answered once by the organisation) | organisational.conf (answered once by the organisation) | organisational.conf (answered once by the organisation) |
87
- | Data protection impact assessment | windows, linux, macos | ISO A.5.34; GDPR Art. 35 | As above. | organisational.conf (answered once by the organisation) | organisational.conf (answered once by the organisation) | organisational.conf (answered once by the organisation) |
88
- | Breach response procedure | windows, linux, macos | ISO A.5.24; ISO A.5.26; GDPR Art. 33; GDPR Art. 34 | As above. | organisational.conf (answered once by the organisation) | organisational.conf (answered once by the organisation) | organisational.conf (answered once by the organisation) |
89
- | Data processing agreements | windows, linux, macos | ISO A.5.20; GDPR Art. 28 | As above. | organisational.conf (answered once by the organisation) | organisational.conf (answered once by the organisation) | organisational.conf (answered once by the organisation) |
90
- | AI register | windows, linux, macos | ISO A.5.9; AI Act Art. 26 | As above. | organisational.conf (answered once by the organisation) | organisational.conf (answered once by the organisation) | organisational.conf (answered once by the organisation) |
91
- | AI literacy training | windows, linux, macos | ISO A.6.3; AI Act Art. 4 | As above. | organisational.conf (answered once by the organisation) | organisational.conf (answered once by the organisation) | organisational.conf (answered once by the organisation) |
92
- | Human oversight for high-risk AI | windows, linux, macos | AI Act Art. 14; AI Act Art. 26 | As above. | organisational.conf (answered once by the organisation) | organisational.conf (answered once by the organisation) | organisational.conf (answered once by the organisation) |
93
- | AI transparency to users | windows, linux, macos | AI Act Art. 50 | As above. | organisational.conf (answered once by the organisation) | organisational.conf (answered once by the organisation) | organisational.conf (answered once by the organisation) |
94
 
95
  ## Reference titles
96
 
 
1
  # Audited controls and data sources
2
 
3
+ Catalog version 0.3.0 · 74 checks. Generated by `tools/build_controls_doc.py` — do not edit by hand.
4
 
5
  All sources are read-only. Internet is only used by *Pending security updates* on Windows and macOS, and by the app to refresh the vulnerability bundle.
6
+ Phones are checked in the browser: a web page cannot read a phone's settings, so results are either detected by the browser or answered by the user (self-reported), and labelled as such.
7
 
8
+ ## COMPLIANCE RESULTS (48)
9
 
10
+ | Check | Applies to | References | Rule | Windows source | Linux source | macOS source | iPhone source | Android source |
11
+ |---|---|---|---|---|---|---|---|---|
12
+ | Minimum password length | windows, linux, macos | ISO A.5.17; ISO A.8.5; GDPR Art. 32(1)(b) | Configured minimum length >= PASSWORD_MIN_LENGTH. | secedit export: MinimumPasswordLength | PAM pam_pwquality minlen (/etc/security/pwquality.conf, PAM args) | pwpolicy -getaccountpolicies (minLength / regex) | — | — |
13
+ | Password history | windows, linux, macos | ISO A.5.17; ISO A.8.5; GDPR Art. 32(1)(b) | Remembered passwords >= PASSWORD_HISTORY. | secedit export: PasswordHistorySize | pam_pwhistory remember (PAM args, /etc/security/pwhistory.conf) | pwpolicy: policyAttributePasswordHistoryDepth | — | — |
14
+ | Password expiration | windows, linux, macos | ISO A.5.17; GDPR Art. 32(1)(b) | Passwords expire after 1..PASSWORD_MAX_AGE_DAYS days (0 in the baseline = expiry not required). | secedit export: MaximumPasswordAge | /etc/login.defs PASS_MAX_DAYS + /etc/shadow per account | pwpolicy: policyAttributeExpiresEveryNDays | — | — |
15
+ | Minimum password age | windows, linux, macos | ISO A.5.17; GDPR Art. 32(1)(b) | Minimum age >= PASSWORD_MIN_AGE_DAYS days. macOS has no such setting (NotApplicable). | secedit export: MinimumPasswordAge | /etc/login.defs PASS_MIN_DAYS + /etc/shadow per account | not available on macOS (NotApplicable) | — | — |
16
+ | Password complexity | windows, linux, macos | ISO A.5.17; ISO A.8.5; GDPR Art. 32(1)(b) | Complexity enforced (mixed character classes). | secedit export: PasswordComplexity | pam_pwquality minclass / dcredit-ucredit-lcredit-ocredit | pwpolicy: alpha + numeric requirements | — | — |
17
+ | Account lockout threshold | windows, linux, macos | ISO A.8.5; GDPR Art. 32(1)(b) | Lockout after 1..LOCKOUT_THRESHOLD_MAX failed attempts (0 = never locks). | secedit export: LockoutBadCount | pam_faillock deny (PAM args, /etc/security/faillock.conf) | pwpolicy: policyAttributeMaximumFailedAuthentications | — | — |
18
+ | Account lockout duration | windows, linux, macos | ISO A.8.5; GDPR Art. 32(1)(b) | Locked for >= LOCKOUT_DURATION_MIN_MINUTES minutes, or until an administrator unlocks. | secedit export: LockoutDuration | pam_faillock unlock_time | pwpolicy: autoEnableInSeconds | — | — |
19
+ | Lockout observation period | windows, linux, macos | ISO A.8.5; GDPR Art. 32(1)(b) | Failed-attempt counter kept for >= LOCKOUT_OBSERVATION_MIN_MINUTES minutes. | secedit export: ResetLockoutCount | pam_faillock fail_interval | pwpolicy: autoEnableInSeconds (counter resets with lockout) | — | — |
20
+ | Antivirus / Google Play Protect | windows, linux, macos, android | ISO A.8.7; GDPR Art. 32(1)(b) | An enabled, up-to-date antivirus product is present. Android: Google Play Protect scanning is on. | Security Center (root/SecurityCenter2 AntiVirusProduct) | systemd services: clamav-daemon, mdatp, falcon-sensor, sentinelone, sophos-spl, esets | XProtect version (xprotect version) + running third-party agents | — | asked: Play Store › your profile picture › Play Protect › Settings (gear icon) |
21
+ | Malware protection service / Apple malware protection service / ClamAV malware protection service / Microsoft Defender Antimalware | windows, linux, macos | ISO A.8.7; GDPR Art. 32(1)(b) | The platform malware protection service is running. | Get-MpComputerStatus: AMServiceEnabled, AntivirusEnabled | systemctl is-active clamav-daemon / clamd | xprotect status + spctl --status (Gatekeeper) | — | — |
22
+ | Real-time protection | windows | ISO A.8.7; GDPR Art. 32(1)(b) | Defender real-time protection is on. | Get-MpComputerStatus: RealTimeProtectionEnabled | — | — | — | — |
23
+ | On-access malware protection / On-access protection | windows, linux, macos | ISO A.8.7; GDPR Art. 32(1)(b) | Files are scanned when opened. macOS: NotApplicable unless a third-party scanner provides it. | Get-MpComputerStatus: OnAccessProtectionEnabled | clamav-clamonacc service / clamonacc process | third-party agent only (XProtect has no on-access scan) | — | — |
24
+ | Tamper protection | windows | ISO A.8.7; GDPR Art. 32(1)(b) | Defender tamper protection is on. | Get-MpComputerStatus: IsTamperProtected | — | — | — | — |
25
+ | Malware signatures / Malware protection signatures / Security intelligence | windows, linux, macos | ISO A.8.7; ISO A.8.8; GDPR Art. 32(1)(b) | Signatures no older than SIGNATURE_MAX_AGE_DAYS (macOS XProtect: MACOS_XPROTECT_MAX_AGE_DAYS). | Get-MpComputerStatus: AntivirusSignatureAge | age of /var/lib/clamav/daily.c[lv]d | XProtect install date (xprotect version / XProtect.meta.plist) | — | — |
26
+ | Malware signature updates / Malware security updates | linux, macos | ISO A.8.7; ISO A.8.8; GDPR Art. 32(1)(b) | Automatic signature/security-data updates are enabled. | — | systemctl is-active clamav-freshclam | com.apple.SoftwareUpdate ConfigDataInstall + CriticalUpdateInstall | — | — |
27
+ | Firewall | linux, macos | ISO A.8.20; ISO A.8.1; GDPR Art. 32(1)(b) | A host firewall is active. | — | ufw status / firewalld / nft list ruleset / iptables -S | socketfilterfw --getglobalstate | — | — |
28
+ | Firewall inbound policy | linux, macos | ISO A.8.20; GDPR Art. 32(1)(b) | Unsolicited inbound traffic is blocked by default (macOS: firewall on with stealth mode). | — | ufw default incoming / firewalld zone target / nft input policy | socketfilterfw --getstealthmode | — | — |
29
+ | Firewall outbound policy | linux, macos | ISO A.8.20; GDPR Art. 32(1)(b) | Default outbound policy equals FIREWALL_OUTBOUND. | — | ufw default outgoing / nft output policy | application firewall is inbound-only | — | — |
30
+ | Domain firewall | windows | ISO A.8.20; ISO A.8.1; GDPR Art. 32(1)(b) | Domain profile enabled. | Get-NetFirewallProfile -PolicyStore ActiveStore (Domain) | — | — | — | — |
31
+ | Domain inbound | windows | ISO A.8.20; GDPR Art. 32(1)(b) | Domain profile default inbound action is Block. | Get-NetFirewallProfile: DefaultInboundAction (Domain) | — | — | — | — |
32
+ | Domain outbound | windows | ISO A.8.20; GDPR Art. 32(1)(b) | Domain profile default outbound action equals FIREWALL_OUTBOUND. | Get-NetFirewallProfile: DefaultOutboundAction (Domain) | — | — | — | — |
33
+ | Private firewall | windows | ISO A.8.20; ISO A.8.1; GDPR Art. 32(1)(b) | Private profile enabled. | Get-NetFirewallProfile -PolicyStore ActiveStore (Private) | — | — | — | — |
34
+ | Private inbound | windows | ISO A.8.20; GDPR Art. 32(1)(b) | Private profile default inbound action is Block. | Get-NetFirewallProfile: DefaultInboundAction (Private) | — | — | — | — |
35
+ | Private outbound | windows | ISO A.8.20; GDPR Art. 32(1)(b) | Private profile default outbound action equals FIREWALL_OUTBOUND. | Get-NetFirewallProfile: DefaultOutboundAction (Private) | — | — | — | — |
36
+ | Public firewall | windows | ISO A.8.20; ISO A.8.1; GDPR Art. 32(1)(b) | Public profile enabled. | Get-NetFirewallProfile -PolicyStore ActiveStore (Public) | — | — | — | — |
37
+ | Public inbound | windows | ISO A.8.20; GDPR Art. 32(1)(b) | Public profile default inbound action is Block. | Get-NetFirewallProfile: DefaultInboundAction (Public) | — | — | — | — |
38
+ | Public outbound | windows | ISO A.8.20; GDPR Art. 32(1)(b) | Public profile default outbound action equals FIREWALL_OUTBOUND. | Get-NetFirewallProfile: DefaultOutboundAction (Public) | — | — | — | — |
39
+ | Screen lock / Passcode | windows, linux, macos, ios, android | ISO A.8.1; ISO A.7.7; GDPR Art. 32(1)(b) | The screen locks when idle (Windows: machine inactivity limit or a password-protected screen saver). Phones: a passcode / PIN / password screen lock is set. | InactivityTimeoutSecs policy or users' ScreenSaveActive/ScreenSaverIsSecure (HKU) | gsettings org.gnome.desktop.screensaver lock-enabled + session idle-delay (per user) | sysadminctl -screenLock status / managed com.apple.screensaver | detected: WebAuthn platform authenticator available; otherwise asked; asked: Settings › Face ID & Passcode (or Touch ID & Passcode). If it asks for your passcode before opening, one is set. | detected: WebAuthn platform authenticator available; otherwise asked; asked: Settings › Security & privacy › Device unlock › Screen lock (names vary by phone maker) |
40
+ | Screen lock timeout / Auto-Lock / Screen timeout | windows, linux, macos, ios, android | ISO A.8.1; ISO A.7.7; GDPR Art. 32(1)(b) | Idle time before lock (including any grace delay) is 1..SCREEN_LOCK_MAX_SECONDS seconds (baseline: 300 = 5 minutes). Phones: Auto-Lock / screen timeout. | InactivityTimeoutSecs policy or users' ScreenSaveTimeOut (HKU) | gsettings idle-delay + lock-delay (per user) | screen saver idleTime + pmset displaysleep + password delay | asked: Settings › Display & Brightness › Auto-Lock | asked: Settings › Display › Screen timeout. If there is also 'Lock after screen timeout', it should be Immediately. |
41
+ | Screen lock password requirement | windows, linux, macos | ISO A.8.1; ISO A.7.7; ISO A.8.5; GDPR Art. 32(1)(b) | A password is required to unlock (macOS: within 5 seconds of the screen locking). | InactivityTimeoutSecs policy or users' ScreenSaverIsSecure; DisableLockWorkstation | gsettings lock-enabled + lockdown disable-lock-screen | sysadminctl -screenLock delay / managed askForPassword | — | — |
42
+ | Passcode strength / Screen lock strength | ios, android | ISO A.5.17; ISO A.8.5; GDPR Art. 32(1)(b) | Passcode / PIN has at least MOBILE_PASSCODE_MIN_DIGITS digits, or is alphanumeric. Android pattern or swipe locks do not comply. | — | — | — | asked: The code you type to unlock. Settings › Face ID & Passcode › Change Passcode › Passcode Options shows the choices. | asked: Settings › Security & privacy › Device unlock › Screen lock |
43
+ | Operating system integrity / Not jailbroken / Not rooted | ios, android | ISO A.8.1; ISO A.8.7; ISO A.8.19; GDPR Art. 32(1)(b) | The phone is not jailbroken (iOS) or rooted / bootloader-unlocked (Android). | — | — | — | asked: Jailbreaking means deliberately modifying iOS to install apps outside Apple's control. If nobody did this on purpose, answer No. | asked: Rooting means deliberately giving apps full system access. If nobody did this on purpose, answer No. |
44
+ | App sources restricted / Apps only from the App Store / Install unknown apps blocked | ios, android | ISO A.8.1; ISO A.8.7; ISO A.8.19; GDPR Art. 32(1)(b) | Apps are installed only from the official store (no alternative marketplaces, web distribution or unknown sources). | — | — | — | asked: Settings › General › VPN & Device Management lists developer or enterprise profiles. Alternative app marketplaces appear as separate apps. | asked: Settings › Apps › Special app access › Install unknown apps. Every app should show 'Not allowed'. |
45
+ | USB debugging off | android | ISO A.8.1; ISO A.8.19; GDPR Art. 32(1)(b) | Developer options / USB debugging are off. | — | — | — | — | asked: Settings › System › Developer options › USB debugging. If you cannot find Developer options, they are off. |
46
+ | Find and erase a lost device / Find My Device / Find My iPhone | ios, android | ISO A.5.9; ISO A.8.1; GDPR Art. 32(1)(b); GDPR Art. 32(1)(c) | Find My iPhone / Find My Device is on, so a lost phone can be located, locked and erased. | — | — | — | asked: Settings › [your name] › Find My › Find My iPhone | asked: Settings › Google › All services › Find My Device (called Find Hub on newer phones) |
47
+ | USB storage device access | windows, linux, macos | ISO A.7.10; ISO A.8.12; GDPR Art. 32(1)(b) | Whether USB mass storage devices can connect at all equals USB_DEVICE_ACCESS (allow/deny): driver, device-installation policy, USBGuard or MDM restriction. | RemovableStorageDevices Deny_All policy, USBSTOR service Start | modprobe usb-storage install rule / blacklist / USBGuard | managed com.apple.systemuiserver mount-controls | — | — |
48
+ | USB storage read | windows, linux, macos | ISO A.7.10; ISO A.8.12; GDPR Art. 32(1)(b) | Effective USB storage read access equals USB_READ (allow/deny). | RemovableStorageDevices policies (Deny_All, Deny_Read), USBSTOR Start | usb-storage module rules, USBGuard | managed mount-controls (deny) | — | — |
49
+ | USB storage write | windows, linux, macos | ISO A.7.10; ISO A.8.12; GDPR Art. 32(1)(b) | Effective USB storage write access equals USB_WRITE (allow/deny). | RemovableStorageDevices Deny_Write, StorageDevicePolicies WriteProtect | udev read-only rules, usb-storage rules, USBGuard | managed mount-controls (deny / read-only) | — | — |
50
+ | Approved IT administrator | windows, linux, macos | ISO A.8.2; ISO A.5.15; GDPR Art. 32(1)(b) | At least one account listed in APPROVED_ADMINS is an enabled administrator. | Administrators group by SID S-1-5-32-544 (ADSI) + Get-LocalUser | getent group sudo/admin/wheel, UID 0 accounts, /etc/sudoers(.d) | dscl . -read /Groups/admin GroupMembership | — | — |
51
+ | Unauthorized employee administrator accounts / Unauthorized employee local administrator accounts | windows, linux, macos | ISO A.8.2; ISO A.5.18; GDPR Art. 32(1)(b) | No administrator accounts other than APPROVED_ADMINS (and the disabled built-in Administrator on Windows). | Administrators group by SID S-1-5-32-544 (ADSI) + Get-LocalUser | getent group sudo/admin/wheel, UID 0 accounts, /etc/sudoers(.d) | dscl . -read /Groups/admin GroupMembership | — | — |
52
+ | BitLocker | windows | ISO A.8.24; ISO A.8.1; GDPR Art. 32(1)(a) | BitLocker is turned on for the system drive. | Get-BitLockerVolume (system drive): VolumeStatus | — | — | — | — |
53
+ | Encryption / Data protection (encryption) / Device encryption | windows, linux, macos, ios, android | ISO A.8.24; ISO A.8.1; GDPR Art. 32(1)(a) | The system disk is fully encrypted (BitLocker / LUKS / FileVault). Phones: iOS data protection is on when a passcode is set; Android 10+ encrypts by default. | Get-BitLockerVolume: VolumeStatus, EncryptionPercentage | findmnt / + lsblk (crypt device under /) | fdesetup status | derived: on when a passcode is set (iOS data protection) | detected: Android 10+ encrypts by default |
54
+ | Disk encryption protection / BitLocker protection | windows, linux, macos | ISO A.8.24; GDPR Art. 32(1)(a) | Encryption protection is active (not suspended or in progress) with a strong cipher. | Get-BitLockerVolume: ProtectionStatus, EncryptionMethod | cryptsetup status (type, cipher, key size) | fdesetup status (no encryption in progress) | — | — |
55
+ | Disk encryption recovery / BitLocker recovery | windows, linux, macos | ISO A.8.24; ISO A.8.13; GDPR Art. 32(1)(a); GDPR Art. 32(1)(c) | A recovery key or second unlock method exists. | Get-BitLockerVolume: KeyProtector (RecoveryPassword) | cryptsetup luksDump (key slots, tokens) | fdesetup haspersonalrecoverykey / hasinstitutionalrecoverykey | — | — |
56
+ | Signed-in user account type | windows, linux, macos | ISO A.8.2; ISO A.5.15; GDPR Art. 32(1)(b) | The everyday signed-in user is a Standard account (not an administrator), unless listed in APPROVED_ADMINS. | signed-in user (Win32_ComputerSystem) vs Administrators group | session users (loginctl) vs sudo/admin/wheel | console user (stat /dev/console) + dsmemberutil checkmembership | — | — |
57
+ | Pending security updates / Android security update / iOS security updates | windows, linux, macos, ios, android | ISO A.8.8; GDPR Art. 32(1)(b); GDPR Art. 32(1)(d) | Security updates offered by the OS vendor but not yet installed <= MAX_PENDING_SECURITY_UPDATES. Phones: iOS is at the latest security release known to the vulnerability data; Android security patch level is at most MOBILE_PATCH_MAX_AGE_DAYS old. | Windows Update Agent API (Microsoft.Update.Session search) - needs internet | apt-get -s upgrade (security) / dnf updateinfo --security - uses local package lists | softwareupdate -l - needs internet | detected: iOS version vs newest fixed release in the signed phone bundle | asked: Settings › About phone › Android version › Android security update (or Settings › Security & privacy › Updates) |
58
+ | Outdated third-party applications / App updates | windows, linux, macos, ios, android | ISO A.8.8; ISO A.8.19; GDPR Art. 32(1)(b) | Installed third-party applications with a newer version available <= MAX_OUTDATED_APPS (Windows: winget; macOS: app versions; Linux: snap/flatpak and packages). Phones: automatic app updates are on. | winget upgrade | apt upgradable, snap refresh --list, flatpak updates | brew outdated (Homebrew; App Store apps not covered) | asked: Settings › App Store › App Updates | asked: Play Store › your profile picture › Settings › Network preferences › Auto-update apps |
59
+ | Known vulnerabilities in installed software / Known vulnerabilities in iOS | windows, linux, macos, ios | ISO A.8.8; GDPR Art. 32(1)(b) | Installed software versions matched against an offline copy of the OSV/NVD vulnerability data: critical or high vulnerabilities <= MAX_CRITICAL_HIGH_VULNERABILITIES. iOS: the iOS version is matched against NVD iOS/iPadOS records. | installed programs (machine and per-user uninstall registry) matched by the app against the signed offline bundle (NVD ranges, CISA KEV, EPSS) | dpkg/rpm package list (with source packages) matched by the app against the signed offline bundle (OSV: Ubuntu, Debian, RHEL, AlmaLinux, Rocky) | app bundle versions (/Applications, ~/Applications) matched by the app against the signed offline bundle (NVD ranges, CISA KEV, EPSS) | detected: iOS version matched against NVD iOS/iPadOS records in the signed phone bundle | — |
60
 
61
+ ## GDPR TECHNICAL CONTROLS (13)
62
 
63
+ | Check | Applies to | References | Rule | Windows source | Linux source | macOS source | iPhone source | Android source |
64
+ |---|---|---|---|---|---|---|---|---|
65
+ | Automatic security updates / Automatic iOS updates / Automatic system updates | windows, linux, macos, ios, android | ISO A.8.8; GDPR Art. 32(1)(b) | Security updates are downloaded and installed automatically. | WindowsUpdate\AU policy (NoAutoUpdate, AUOptions), wuauserv start type | apt-config Unattended-Upgrade / dnf-automatic timer | com.apple.SoftwareUpdate AutomaticCheck/Download/Install keys | asked: Settings › General › Software Update › Automatic Updates | asked: Settings › System › Software updates (names vary: 'Auto download over Wi-Fi', 'Automatic system updates') |
66
+ | Security updates installed recently | windows, linux, macos | ISO A.8.8; GDPR Art. 32(1)(b); GDPR Art. 32(1)(d) | Last successful update/package change within UPDATES_MAX_AGE_DAYS days. | Windows Update history (COM) or Get-HotFix | /var/log/apt/history.log / rpm -qa --last | /Library/Receipts/InstallHistory.plist (softwareupdated) | — | — |
67
+ | Supported operating system version | windows, linux, macos, ios, android | ISO A.8.8; GDPR Art. 32(1)(b) | The OS version still receives security updates (minimums in the baseline). Phones: IOS_MIN_MAJOR / ANDROID_MIN_MAJOR. | Win32_OperatingSystem BuildNumber vs WINDOWS_MIN_BUILD | /etc/os-release VERSION_ID vs baseline minimum | sw_vers -productVersion vs MACOS_MIN_MAJOR | detected: OS version (Safari version on iOS 26+, client hints on Android), confirmed by the user | detected: OS version (Safari version on iOS 26+, client hints on Android), confirmed by the user |
68
+ | Security event logging | windows, linux, macos | ISO A.8.15; GDPR Art. 32(1)(d); GDPR Art. 33 | Logon and account-change events are recorded (Windows audit policy, Linux auditd, macOS unified log). | auditpol /backup (Logon, User Account Management) + legacy audit policy | systemctl is-active auditd, journal persistence | logd running (unified logging) | — | — |
69
+ | Time synchronisation / Date & time set automatically | windows, linux, macos, ios, android | ISO A.8.17; GDPR Art. 33 | The clock is synchronised from a time server. | W32Time Parameters Type + service start type | timedatectl NTP / chronyd / ntpd | systemsetup -getusingnetworktime (needs root) | asked: Settings › General › Date & Time | asked: Settings › System › Date & time |
70
+ | Backup configured / Google backup / iCloud Backup | windows, linux, macos, ios, android | ISO A.8.13; GDPR Art. 32(1)(c) | A backup solution is configured for user data. | OneDrive KFM policy, File History config, backup agent services | Timeshift config, Deja Dup (gsettings), restic/borg timers | tmutil destinationinfo, backup agent processes | asked: Settings › [your name] › iCloud › iCloud Backup | asked: Settings › Google › Backup (or Settings › System › Backup; Samsung: Accounts and backup) |
71
+ | Recent backup | windows, linux, macos | ISO A.8.13; GDPR Art. 32(1)(c) | Last backup within BACKUP_MAX_AGE_DAYS days (Pending when the date cannot be read). | not readable for most tools (Pending) | Timeshift snapshot names, Deja Dup last-backup | tmutil latestbackup | — | — |
72
+ | Removable media encryption | windows, linux, macos | ISO A.7.10; ISO A.8.24; GDPR Art. 32(1)(a); GDPR Art. 34 | Data cannot be written to unencrypted removable drives. | FVE RDVDenyWriteAccess (BitLocker To Go) or USB write denied | USB write denied | USB write denied | — | — |
73
+ | Guest account disabled | windows, linux, macos | ISO A.5.16; ISO A.8.5; GDPR Art. 32(1)(b) | No enabled guest login. | Get-LocalUser (SID -501): Enabled | LightDM allow-guest, 'guest' account shell | com.apple.loginwindow GuestEnabled, SMB AllowGuestAccess | — | — |
74
+ | Remote access services restricted | windows, linux, macos | ISO A.8.20; ISO A.6.7; GDPR Art. 32(1)(b) | Only remote access services listed in APPROVED_REMOTE_SERVICES are enabled. | fDenyTSConnections (RDP), sshd/WinRM/TeamViewer/AnyDesk/chromoting services, non-admin SMB shares | ssh/xrdp/vnc/samba/teamviewer/anydesk/chrome-remote-desktop | launchctl print-disabled (sshd, screensharing, smbd, chromoting), ARDAgent/TeamViewer/AnyDesk | — | — |
75
+ | Diagnostic data minimised / Analytics sharing minimised / Usage & diagnostics sharing minimised | windows, linux, macos, ios, android | ISO A.5.34; GDPR Art. 5(1)(c); GDPR Art. 25 | OS diagnostic/usage data sharing is at the minimum level. | DataCollection AllowTelemetry (policy / Settings) | popularity-contest, whoopsie | DiagnosticMessagesHistory AutoSubmit, ThirdPartyDataSubmit | asked: Settings › Privacy & Security › Analytics & Improvements | asked: Settings › Google › All services › Usage & diagnostics (also check the phone maker's diagnostics option) |
76
+ | Notification content hidden on the lock screen / Notification previews hidden when locked / Sensitive notifications hidden when locked | ios, android | ISO A.7.7; ISO A.8.1; GDPR Art. 5(1)(f); GDPR Art. 32(1)(b) | Message and email previews are not shown on the locked screen. | — | — | — | asked: Settings › Notifications › Show Previews | asked: Settings › Notifications › Notifications on lock screen (or Settings › Privacy) |
77
+ | Personal data discovery | windows, linux, macos | ISO A.5.34; ISO A.5.12; GDPR Art. 5(1)(c); GDPR Art. 5(1)(e); GDPR Art. 30 | Opt-in scan of user folders for personal data patterns (counts and paths only). Not implemented in stage 1: NotApplicable. | planned opt-in scan | planned opt-in scan | planned opt-in scan | — | — |
78
 
79
  ## EU AI ACT CONTROLS (5)
80
 
81
+ | Check | Applies to | References | Rule | Windows source | Linux source | macOS source | iPhone source | Android source |
82
+ |---|---|---|---|---|---|---|---|---|
83
+ | AI software inventory | windows, linux, macos, ios, android | ISO A.5.9; GDPR Art. 30; AI Act Art. 26 | AI apps, local AI services and local model folders are listed (Compliant = inventory taken). | uninstall registry + Store apps (AI apps), port 11434, model folders, Claude Code CLI | AI CLIs, snaps, flatpaks, /opt apps, port 11434, model folders | /Applications AI apps, AI CLIs, port 11434, model folders | asked: AI apps installed (checklist); classified by the auditor model | asked: AI apps installed (checklist); classified by the auditor model |
84
+ | Unapproved AI tools (shadow AI) | windows, linux, macos, ios, android | ISO A.5.9; ISO A.8.19; GDPR Art. 28; GDPR Art. 44; AI Act Art. 26 | Every AI tool found is listed in APPROVED_AI_TOOLS. | AI inventory vs APPROVED_AI_TOOLS | AI inventory vs APPROVED_AI_TOOLS | AI inventory vs APPROVED_AI_TOOLS | asked: AI apps installed (checklist); classified by the auditor model | asked: AI apps installed (checklist); classified by the auditor model |
85
+ | AI system risk classification | windows, linux, macos, ios, android | ISO A.5.9; AI Act Art. 5; AI Act Art. 6; AI Act Annex III | 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. | AI inventory, classified by the model | AI inventory, classified by the model | AI inventory, classified by the model | asked: AI apps installed (checklist); classified by the auditor model | asked: AI apps installed (checklist); classified by the auditor model |
86
+ | Operating system AI features restricted / AI assistant access restricted / Apple Intelligence external integrations restricted / Windows Recall / Copilot data analysis disabled | windows, linux, macos, ios, android | ISO A.5.34; GDPR Art. 5(1)(c); GDPR Art. 25; AI Act Art. 26 | OS-level AI features that capture or send user data are disabled by policy. NotApplicable where the OS has none. Phones: external AI integrations (ChatGPT in Apple Intelligence, assistant access to screen content) are off unless approved. | WindowsAI policies (DisableAIDataAnalysis, AllowRecallEnablement), Recall optional feature | none built in (NotApplicable) | managed com.apple.applicationaccess allowExternalIntelligenceIntegrations (Apple silicon) | asked: Settings › Apple Intelligence & Siri › ChatGPT (only on iPhones that support Apple Intelligence) | asked: Gemini app › your profile picture › Settings › 'Screen context' / 'Use screen context' (other assistants have similar options) |
87
+ | AI usage log retention | windows, linux, macos, ios, android | ISO A.8.15; AI Act Art. 26(6) | Logs kept >= 6 months if a high-risk AI system is used. Pending until classification; NotApplicable when no AI tool is found. | depends on AI classification | depends on AI classification | depends on AI classification | asked: AI apps installed (checklist); classified by the auditor model | asked: AI apps installed (checklist); classified by the auditor model |
88
 
89
  ## ORGANISATIONAL CONTROLS (8)
90
 
91
+ | Check | Applies to | References | Rule | Windows source | Linux source | macOS source | iPhone source | Android source |
92
+ |---|---|---|---|---|---|---|---|---|
93
+ | Record of processing activities | windows, linux, macos, ios, android | ISO A.5.34; GDPR Art. 30 | Answer yes = Compliant, no/partial = Non-Compliant, n/a = NotApplicable, unanswered = Pending. | organisational.conf (answered once by the organisation) | organisational.conf (answered once by the organisation) | organisational.conf (answered once by the organisation) | organisational.conf (answered once by the organisation); Pending in a phone check unless passed in the link | organisational.conf (answered once by the organisation); Pending in a phone check unless passed in the link |
94
+ | Data protection impact assessment | windows, linux, macos, ios, android | ISO A.5.34; GDPR Art. 35 | As above. | organisational.conf (answered once by the organisation) | organisational.conf (answered once by the organisation) | organisational.conf (answered once by the organisation) | organisational.conf (answered once by the organisation); Pending in a phone check unless passed in the link | organisational.conf (answered once by the organisation); Pending in a phone check unless passed in the link |
95
+ | Breach response procedure | windows, linux, macos, ios, android | ISO A.5.24; ISO A.5.26; GDPR Art. 33; GDPR Art. 34 | As above. | organisational.conf (answered once by the organisation) | organisational.conf (answered once by the organisation) | organisational.conf (answered once by the organisation) | organisational.conf (answered once by the organisation); Pending in a phone check unless passed in the link | organisational.conf (answered once by the organisation); Pending in a phone check unless passed in the link |
96
+ | Data processing agreements | windows, linux, macos, ios, android | ISO A.5.20; GDPR Art. 28 | As above. | organisational.conf (answered once by the organisation) | organisational.conf (answered once by the organisation) | organisational.conf (answered once by the organisation) | organisational.conf (answered once by the organisation); Pending in a phone check unless passed in the link | organisational.conf (answered once by the organisation); Pending in a phone check unless passed in the link |
97
+ | AI register | windows, linux, macos, ios, android | ISO A.5.9; AI Act Art. 26 | As above. | organisational.conf (answered once by the organisation) | organisational.conf (answered once by the organisation) | organisational.conf (answered once by the organisation) | organisational.conf (answered once by the organisation); Pending in a phone check unless passed in the link | organisational.conf (answered once by the organisation); Pending in a phone check unless passed in the link |
98
+ | AI literacy training | windows, linux, macos, ios, android | ISO A.6.3; AI Act Art. 4 | As above. | organisational.conf (answered once by the organisation) | organisational.conf (answered once by the organisation) | organisational.conf (answered once by the organisation) | organisational.conf (answered once by the organisation); Pending in a phone check unless passed in the link | organisational.conf (answered once by the organisation); Pending in a phone check unless passed in the link |
99
+ | Human oversight for high-risk AI | windows, linux, macos, ios, android | AI Act Art. 14; AI Act Art. 26 | As above. | organisational.conf (answered once by the organisation) | organisational.conf (answered once by the organisation) | organisational.conf (answered once by the organisation) | organisational.conf (answered once by the organisation); Pending in a phone check unless passed in the link | organisational.conf (answered once by the organisation); Pending in a phone check unless passed in the link |
100
+ | AI transparency to users | windows, linux, macos, ios, android | AI Act Art. 50 | As above. | organisational.conf (answered once by the organisation) | organisational.conf (answered once by the organisation) | organisational.conf (answered once by the organisation) | organisational.conf (answered once by the organisation); Pending in a phone check unless passed in the link | organisational.conf (answered once by the organisation); Pending in a phone check unless passed in the link |
101
 
102
  ## Reference titles
103
 
index.html CHANGED
@@ -1,155 +1,91 @@
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 · steps 1–3</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> — done</li>
82
- <li><b>Control library</b> in Devseis's own wording, for retrieval by the model — done</li>
83
- <li><b>Training data</b>: evidence → auditor findings (29,000 synthetic examples) — done; expert review pending</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>
 
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, viewport-fit=cover">
6
+ <meta name="description" content="Free, private security and compliance check for your phone (ISO 27001, GDPR, EU AI Act). Runs in your browser; nothing about your phone is uploaded.">
7
+ <meta name="theme-color" content="#ED2939">
8
+ <title>Devseis Phone Check</title>
9
+ <link rel="stylesheet" href="mobile/styles.css">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  </head>
11
  <body>
12
+ <header class="top">
13
+ <div class="brand"><span class="mark">D</span><span>Devseis <b>Phone Check</b></span></div>
14
+ <div id="model-chip" class="chip hidden"></div>
15
+ </header>
16
 
17
+ <main>
18
+ <!-- 1. Start -->
19
+ <section id="s-start" class="screen">
20
+ <h1>Is this phone ready for work?</h1>
21
+ <p class="lead">A free check against <b>ISO 27001</b>, <b>GDPR</b> and the <b>EU AI Act</b>, written by the Devseis AI auditor <b>inside your browser</b>.</p>
22
+ <div id="device-card" class="card device"><span class="muted">Detecting this device…</span></div>
23
+ <div id="desktop-note" class="card note hidden">
24
+ <p><b>You are on a computer.</b> The Devseis desktop app audits Windows, macOS and Linux automatically (a web page cannot read a computer's settings).</p>
25
+ <p>To try the phone check here, choose a phone type:</p>
26
+ <div class="row"><button class="secondary" data-demo="ios">iPhone</button><button class="secondary" data-demo="android">Android</button></div>
27
+ </div>
28
+ <ul class="facts">
29
+ <li><b>Private:</b> your answers and the report stay on this phone. Only the AI model and vulnerability data are downloaded.</li>
30
+ <li><b>About 5 minutes:</b> the browser detects what it can; you answer the rest, with help on where to look.</li>
31
+ <li><b>Honest:</b> every result says whether it was <i>detected</i> or <i>self-reported</i>.</li>
32
+ </ul>
33
+ <button id="start" class="primary" disabled>Start the check</button>
34
+ <p class="muted center"><a href="about.html">About the project</a></p>
35
+ </section>
36
 
37
+ <!-- 2. Approval -->
38
+ <section id="s-consent" class="screen hidden">
39
+ <h1>Before we start</h1>
40
+ <div class="card">
41
+ <p><b>What will be checked</b> (<span id="check-count"></span> checks):</p>
42
+ <ul id="plan" class="plan"></ul>
43
+ </div>
44
+ <label class="card toggle">
45
+ <input type="checkbox" id="use-ai">
46
+ <span><b>Use the AI auditor</b><br><span id="ai-note" class="muted"></span></span>
47
+ </label>
48
+ <p class="muted">Nothing on the phone is changed. You can stop at any time.</p>
49
+ <div class="row"><button class="secondary" id="consent-back">Back</button><button class="primary" id="consent-ok">I agree, continue</button></div>
50
+ </section>
51
 
52
+ <!-- 3. Questions -->
53
+ <section id="s-question" class="screen hidden">
54
+ <div class="progress"><div id="q-bar" class="bar"></div></div>
55
+ <p class="muted" id="q-count"></p>
56
+ <h2 id="q-check" class="check-name"></h2>
57
+ <h1 id="q-text"></h1>
58
+ <div class="where"><span class="where-label">Where to look</span><span id="q-where"></span></div>
59
+ <div id="q-answers" class="answers"></div>
60
+ <div class="row"><button class="secondary" id="q-back">Back</button></div>
61
+ </section>
62
 
63
+ <!-- 4. Writing the report -->
64
+ <section id="s-work" class="screen hidden">
65
+ <h1 id="w-title">Writing your report</h1>
66
+ <p id="w-step" class="muted"></p>
67
+ <div class="progress big"><div id="w-bar" class="bar"></div></div>
68
+ <p id="w-current" class="muted"></p>
69
+ </section>
70
 
71
+ <!-- 5. Result -->
72
+ <section id="s-done" class="screen hidden">
73
+ <div id="result"></div>
74
+ <div class="actions">
75
+ <button class="primary" id="share-pdf">Save or share PDF</button>
76
+ <button class="secondary" id="view-report">View full report</button>
77
+ <button class="secondary" id="save-json">Download evidence (JSON)</button>
78
+ </div>
79
+ <p id="pdf-note" class="muted"></p>
80
+ <button class="link" id="again">Check again</button>
81
+ </section>
82
+ </main>
83
 
84
+ <footer class="foot">
85
+ Open source by Devseis · <a href="about.html">about</a> ·
86
+ <a href="https://huggingface.co/spaces/Devseis/endpoint-auditor/tree/main">code (Apache-2.0)</a> ·
87
+ <a href="https://huggingface.co/datasets/Devseis/endpoint-auditor-synthetic">data (CC BY 4.0)</a> · Not legal advice or a certification.
88
+ </footer>
89
+ <script type="module" src="mobile/app.js"></script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  </body>
91
  </html>
library/ai_tools.json CHANGED
@@ -1,27 +1,179 @@
1
  {
2
- "library_version": "0.1.0",
3
  "note": "Default classifications assume general office use by a deployer. Under the AI Act the risk class depends on the purpose of use, not on the tool alone. Not legal advice.",
4
  "purpose_rules": {
5
- "prohibited": ["Emotion recognition of employees or students (except for medical or safety reasons)", "Social scoring of people", "Manipulative or exploitative techniques that cause significant harm", "Untargeted scraping of facial images to build face recognition databases", "Biometric categorisation inferring sensitive traits such as political views or sexual orientation"],
6
- "high_risk_examples": ["Screening or ranking job applicants, or making promotion, task allocation or dismissal decisions", "Assessing creditworthiness or credit scores of individuals", "Deciding access to education or grading students", "Assessing eligibility for public benefits or essential services", "Risk assessment and pricing in life and health insurance", "Biometric identification of people"],
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  "limited_risk": "Chatbots and AI-generated content: people must be told they are dealing with AI, and published deepfakes or AI-written public-interest text must be labelled (Art. 50).",
8
  "minimal_risk": "Drafting, summarising, translation, coding help and similar general productivity use."
9
  },
10
  "tools": {
11
- "Microsoft Copilot": { "kind": "Cloud AI assistant", "provider": "Microsoft (US)", "data_location": "Microsoft cloud; enterprise plans keep data under Microsoft's commercial data protection terms", "default_risk": "minimal", "gdpr_notes": "Use the Microsoft 365 work account version covered by the organisation's Microsoft data processing terms; consumer Copilot is not covered." },
12
- "ChatGPT": { "kind": "Cloud AI assistant", "provider": "OpenAI (US)", "data_location": "Provider cloud outside the EEA unless an EU data residency plan is used", "default_risk": "minimal", "gdpr_notes": "Needs a business plan with a data processing agreement and a transfer basis (Art. 28, Art. 44); consumer accounts may use prompts for training unless opted out." },
13
- "Claude": { "kind": "Cloud AI assistant", "provider": "Anthropic (US)", "data_location": "Provider cloud outside the EEA", "default_risk": "minimal", "gdpr_notes": "Needs a commercial plan with a data processing agreement and a transfer basis (Art. 28, Art. 44)." },
14
- "Claude Code": { "kind": "AI coding assistant (command line)", "provider": "Anthropic (US)", "data_location": "Code and prompts sent to the provider's cloud", "default_risk": "minimal", "gdpr_notes": "Source code may contain personal data or secrets; use a commercial plan with a data processing agreement and keep secrets out of repositories." },
15
- "Gemini": { "kind": "Cloud AI assistant", "provider": "Google (US)", "data_location": "Provider cloud", "default_risk": "minimal", "gdpr_notes": "Use the Workspace version under the organisation's Google data processing terms; consumer use is not covered." },
16
- "Perplexity": { "kind": "Cloud AI search assistant", "provider": "Perplexity (US)", "data_location": "Provider cloud outside the EEA", "default_risk": "minimal", "gdpr_notes": "Needs an enterprise agreement and a transfer basis before work or personal data is entered." },
17
- "Cursor": { "kind": "AI code editor", "provider": "Anysphere (US)", "data_location": "Code sent to the provider and its model providers unless privacy mode is enforced", "default_risk": "minimal", "gdpr_notes": "Enforce privacy mode through a business plan and sign a data processing agreement." },
18
- "Windsurf": { "kind": "AI code editor", "provider": "US-based provider", "data_location": "Code sent to the provider's cloud", "default_risk": "minimal", "gdpr_notes": "Use a business plan with a data processing agreement and zero-retention settings." },
19
- "Ollama": { "kind": "Local model runner", "provider": "Open source (runs locally)", "data_location": "On the device; nothing leaves unless a cloud model is selected", "default_risk": "minimal", "gdpr_notes": "No transfer to a provider, but check each model's licence and keep the local API (port 11434) closed to the network." },
20
- "LM Studio": { "kind": "Local model runner", "provider": "Element Labs (runs locally)", "data_location": "On the device", "default_risk": "minimal", "gdpr_notes": "No transfer to a provider; check model licences and whether its local server is exposed." },
21
- "GPT4All": { "kind": "Local model runner", "provider": "Nomic AI (runs locally)", "data_location": "On the device unless optional data sharing is enabled", "default_risk": "minimal", "gdpr_notes": "Keep the optional data lake sharing switched off." },
22
- "Jan": { "kind": "Local model runner", "provider": "Open source (runs locally)", "data_location": "On the device unless a cloud model is connected", "default_risk": "minimal", "gdpr_notes": "Check whether remote API providers are configured; if so, the cloud provider rules apply." },
23
- "Msty": { "kind": "Desktop AI client", "provider": "Desktop app (local and cloud models)", "data_location": "Local, or the cloud provider of any connected model", "default_risk": "minimal", "gdpr_notes": "Treat connected cloud models like the corresponding cloud assistant." },
24
- "Alpaca (Ollama client)": { "kind": "Desktop client for local models", "provider": "Open source (runs locally)", "data_location": "On the device", "default_risk": "minimal", "gdpr_notes": "No transfer to a provider; check model licences." },
25
- "Hugging Face models": { "kind": "Local model files", "provider": "Various model publishers", "data_location": "On the device", "default_risk": "minimal", "gdpr_notes": "Record which models are present and their licences; the risk class depends on what the models are used for." }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  }
27
  }
 
1
  {
2
+ "library_version": "0.3.0",
3
  "note": "Default classifications assume general office use by a deployer. Under the AI Act the risk class depends on the purpose of use, not on the tool alone. Not legal advice.",
4
  "purpose_rules": {
5
+ "prohibited": [
6
+ "Emotion recognition of employees or students (except for medical or safety reasons)",
7
+ "Social scoring of people",
8
+ "Manipulative or exploitative techniques that cause significant harm",
9
+ "Untargeted scraping of facial images to build face recognition databases",
10
+ "Biometric categorisation inferring sensitive traits such as political views or sexual orientation"
11
+ ],
12
+ "high_risk_examples": [
13
+ "Screening or ranking job applicants, or making promotion, task allocation or dismissal decisions",
14
+ "Assessing creditworthiness or credit scores of individuals",
15
+ "Deciding access to education or grading students",
16
+ "Assessing eligibility for public benefits or essential services",
17
+ "Risk assessment and pricing in life and health insurance",
18
+ "Biometric identification of people"
19
+ ],
20
  "limited_risk": "Chatbots and AI-generated content: people must be told they are dealing with AI, and published deepfakes or AI-written public-interest text must be labelled (Art. 50).",
21
  "minimal_risk": "Drafting, summarising, translation, coding help and similar general productivity use."
22
  },
23
  "tools": {
24
+ "Microsoft Copilot": {
25
+ "kind": "Cloud AI assistant",
26
+ "provider": "Microsoft (US)",
27
+ "data_location": "Microsoft cloud; enterprise plans keep data under Microsoft's commercial data protection terms",
28
+ "default_risk": "minimal",
29
+ "gdpr_notes": "Use the Microsoft 365 work account version covered by the organisation's Microsoft data processing terms; consumer Copilot is not covered."
30
+ },
31
+ "ChatGPT": {
32
+ "kind": "Cloud AI assistant",
33
+ "provider": "OpenAI (US)",
34
+ "data_location": "Provider cloud outside the EEA unless an EU data residency plan is used",
35
+ "default_risk": "minimal",
36
+ "gdpr_notes": "Needs a business plan with a data processing agreement and a transfer basis (Art. 28, Art. 44); consumer accounts may use prompts for training unless opted out."
37
+ },
38
+ "Claude": {
39
+ "kind": "Cloud AI assistant",
40
+ "provider": "Anthropic (US)",
41
+ "data_location": "Provider cloud outside the EEA",
42
+ "default_risk": "minimal",
43
+ "gdpr_notes": "Needs a commercial plan with a data processing agreement and a transfer basis (Art. 28, Art. 44)."
44
+ },
45
+ "Claude Code": {
46
+ "kind": "AI coding assistant (command line)",
47
+ "provider": "Anthropic (US)",
48
+ "data_location": "Code and prompts sent to the provider's cloud",
49
+ "default_risk": "minimal",
50
+ "gdpr_notes": "Source code may contain personal data or secrets; use a commercial plan with a data processing agreement and keep secrets out of repositories."
51
+ },
52
+ "Gemini": {
53
+ "kind": "Cloud AI assistant",
54
+ "provider": "Google (US)",
55
+ "data_location": "Provider cloud",
56
+ "default_risk": "minimal",
57
+ "gdpr_notes": "Use the Workspace version under the organisation's Google data processing terms; consumer use is not covered."
58
+ },
59
+ "Perplexity": {
60
+ "kind": "Cloud AI search assistant",
61
+ "provider": "Perplexity (US)",
62
+ "data_location": "Provider cloud outside the EEA",
63
+ "default_risk": "minimal",
64
+ "gdpr_notes": "Needs an enterprise agreement and a transfer basis before work or personal data is entered."
65
+ },
66
+ "Cursor": {
67
+ "kind": "AI code editor",
68
+ "provider": "Anysphere (US)",
69
+ "data_location": "Code sent to the provider and its model providers unless privacy mode is enforced",
70
+ "default_risk": "minimal",
71
+ "gdpr_notes": "Enforce privacy mode through a business plan and sign a data processing agreement."
72
+ },
73
+ "Windsurf": {
74
+ "kind": "AI code editor",
75
+ "provider": "US-based provider",
76
+ "data_location": "Code sent to the provider's cloud",
77
+ "default_risk": "minimal",
78
+ "gdpr_notes": "Use a business plan with a data processing agreement and zero-retention settings."
79
+ },
80
+ "Ollama": {
81
+ "kind": "Local model runner",
82
+ "provider": "Open source (runs locally)",
83
+ "data_location": "On the device; nothing leaves unless a cloud model is selected",
84
+ "default_risk": "minimal",
85
+ "gdpr_notes": "No transfer to a provider, but check each model's licence and keep the local API (port 11434) closed to the network."
86
+ },
87
+ "LM Studio": {
88
+ "kind": "Local model runner",
89
+ "provider": "Element Labs (runs locally)",
90
+ "data_location": "On the device",
91
+ "default_risk": "minimal",
92
+ "gdpr_notes": "No transfer to a provider; check model licences and whether its local server is exposed."
93
+ },
94
+ "GPT4All": {
95
+ "kind": "Local model runner",
96
+ "provider": "Nomic AI (runs locally)",
97
+ "data_location": "On the device unless optional data sharing is enabled",
98
+ "default_risk": "minimal",
99
+ "gdpr_notes": "Keep the optional data lake sharing switched off."
100
+ },
101
+ "Jan": {
102
+ "kind": "Local model runner",
103
+ "provider": "Open source (runs locally)",
104
+ "data_location": "On the device unless a cloud model is connected",
105
+ "default_risk": "minimal",
106
+ "gdpr_notes": "Check whether remote API providers are configured; if so, the cloud provider rules apply."
107
+ },
108
+ "Msty": {
109
+ "kind": "Desktop AI client",
110
+ "provider": "Desktop app (local and cloud models)",
111
+ "data_location": "Local, or the cloud provider of any connected model",
112
+ "default_risk": "minimal",
113
+ "gdpr_notes": "Treat connected cloud models like the corresponding cloud assistant."
114
+ },
115
+ "Alpaca (Ollama client)": {
116
+ "kind": "Desktop client for local models",
117
+ "provider": "Open source (runs locally)",
118
+ "data_location": "On the device",
119
+ "default_risk": "minimal",
120
+ "gdpr_notes": "No transfer to a provider; check model licences."
121
+ },
122
+ "Hugging Face models": {
123
+ "kind": "Local model files",
124
+ "provider": "Various model publishers",
125
+ "data_location": "On the device",
126
+ "default_risk": "minimal",
127
+ "gdpr_notes": "Record which models are present and their licences; the risk class depends on what the models are used for."
128
+ },
129
+ "Meta AI": {
130
+ "kind": "Cloud AI assistant (built into WhatsApp, Instagram and Facebook apps)",
131
+ "provider": "Meta (US)",
132
+ "data_location": "Provider cloud outside the EEA",
133
+ "default_risk": "minimal",
134
+ "gdpr_notes": "Consumer service with no business data processing terms; do not share work or customer data in chats with Meta AI."
135
+ },
136
+ "Grok": {
137
+ "kind": "Cloud AI assistant",
138
+ "provider": "xAI (US)",
139
+ "data_location": "Provider cloud outside the EEA",
140
+ "default_risk": "minimal",
141
+ "gdpr_notes": "Consumer service; needs an enterprise agreement and a transfer basis before work or personal data is entered."
142
+ },
143
+ "DeepSeek": {
144
+ "kind": "Cloud AI assistant",
145
+ "provider": "DeepSeek (China)",
146
+ "data_location": "Provider cloud in the People's Republic of China",
147
+ "default_risk": "minimal",
148
+ "gdpr_notes": "No EU adequacy decision for China and no business data processing terms; EU data protection authorities have restricted or investigated it. Do not use with work or personal data."
149
+ },
150
+ "Character.AI": {
151
+ "kind": "AI companion chatbot",
152
+ "provider": "Character Technologies (US)",
153
+ "data_location": "Provider cloud outside the EEA",
154
+ "default_risk": "limited",
155
+ "gdpr_notes": "Consumer entertainment chatbot; not suitable for work use. Users must be told they are talking to an AI (AI Act Art. 50)."
156
+ },
157
+ "Replika": {
158
+ "kind": "AI companion chatbot",
159
+ "provider": "Luka (US)",
160
+ "data_location": "Provider cloud outside the EEA",
161
+ "default_risk": "limited",
162
+ "gdpr_notes": "Consumer companion app that collects sensitive personal conversations and has been subject to EU data protection enforcement; not suitable for work use."
163
+ },
164
+ "Otter": {
165
+ "kind": "AI meeting transcription",
166
+ "provider": "Otter.ai (US)",
167
+ "data_location": "Provider cloud outside the EEA",
168
+ "default_risk": "minimal",
169
+ "gdpr_notes": "Records and transcribes other people's voices: needs a lawful basis and notice to meeting participants, plus a data processing agreement."
170
+ },
171
+ "Grammarly": {
172
+ "kind": "AI writing assistant (keyboard and browser)",
173
+ "provider": "Grammarly (US)",
174
+ "data_location": "Provider cloud outside the EEA",
175
+ "default_risk": "minimal",
176
+ "gdpr_notes": "Reads everything typed where it is enabled, including personal data; use the business plan with a data processing agreement or disable it in work apps."
177
+ }
178
  }
179
  }
library/check_guidance.json CHANGED
@@ -1,10 +1,16 @@
1
  {
2
- "library_version": "0.2.0",
3
  "note": "Per-check guidance used to write findings. Fix steps are written for administrators; prefer central management (Group Policy/Intune, MDM, configuration management) over per-device changes. Review by a security professional before use with customers.",
4
  "aliases": {
5
- "firewall.domain.enabled": "firewall.enabled", "firewall.private.enabled": "firewall.enabled", "firewall.public.enabled": "firewall.enabled",
6
- "firewall.domain.inbound": "firewall.inbound", "firewall.private.inbound": "firewall.inbound", "firewall.public.inbound": "firewall.inbound",
7
- "firewall.domain.outbound": "firewall.outbound", "firewall.private.outbound": "firewall.outbound", "firewall.public.outbound": "firewall.outbound",
 
 
 
 
 
 
8
  "encryption.feature": "encryption.enabled"
9
  },
10
  "status_notes": {
@@ -15,537 +21,1204 @@
15
  "checks": {
16
  "password.min_length": {
17
  "why": "Short passwords can be guessed or cracked quickly; length is the strongest single factor in password strength.",
18
- "risk": { "level": "Medium", "reason": "Weak passwords make account takeover through guessing or credential stuffing easier." },
 
 
 
19
  "fix": {
20
- "windows": ["Set Group Policy: Computer Configuration > Windows Settings > Security Settings > Account Policies > Password Policy > Minimum password length = {PASSWORD_MIN_LENGTH} (or the Intune equivalent).", "For a stand-alone device: net accounts /minpwlen:{PASSWORD_MIN_LENGTH}"],
21
- "linux": ["Install pam_pwquality (Debian/Ubuntu: apt install libpam-pwquality) and make sure it is listed in the PAM password stack.", "Set minlen = {PASSWORD_MIN_LENGTH} in /etc/security/pwquality.conf."],
22
- "macos": ["Deploy a Passcode configuration profile through your MDM with minLength = {PASSWORD_MIN_LENGTH}."]
 
 
 
 
 
 
 
 
23
  },
24
  "verify": "Re-run the audit; Minimum password length should read {PASSWORD_MIN_LENGTH} or more."
25
  },
26
  "password.history": {
27
  "why": "Without a password history, users can return to old, possibly exposed passwords.",
28
- "risk": { "level": "Low", "reason": "Reused passwords keep compromised credentials valid." },
 
 
 
29
  "fix": {
30
- "windows": ["Set Group Policy: Account Policies > Password Policy > Enforce password history = {PASSWORD_HISTORY}.", "For a stand-alone device: net accounts /uniquepw:{PASSWORD_HISTORY}"],
31
- "linux": ["Add pam_pwhistory to the PAM password stack before pam_unix (e.g. 'password requisite pam_pwhistory.so remember={PASSWORD_HISTORY} use_authtok').", "Or set remember = {PASSWORD_HISTORY} in /etc/security/pwhistory.conf where supported."],
32
- "macos": ["Deploy a Passcode configuration profile with pinHistory = {PASSWORD_HISTORY}."]
 
 
 
 
 
 
 
 
33
  },
34
  "verify": "Re-run the audit; Password history should read {PASSWORD_HISTORY} or more."
35
  },
36
  "password.expiration": {
37
  "why": "The baseline requires passwords to be changed within a set period, limiting how long a stolen password stays useful.",
38
- "risk": { "level": "Low", "reason": "A leaked password can stay valid indefinitely." },
 
 
 
39
  "fix": {
40
- "windows": ["Set Group Policy: Account Policies > Password Policy > Maximum password age = {PASSWORD_MAX_AGE_DAYS} days.", "For a stand-alone device: net accounts /maxpwage:{PASSWORD_MAX_AGE_DAYS}"],
41
- "linux": ["Set PASS_MAX_DAYS {PASSWORD_MAX_AGE_DAYS} in /etc/login.defs (applies to new accounts).", "Apply it to existing accounts: chage --maxdays {PASSWORD_MAX_AGE_DAYS} <user>"],
42
- "macos": ["Deploy a Passcode configuration profile with maxPINAgeInDays = {PASSWORD_MAX_AGE_DAYS}."]
 
 
 
 
 
 
 
 
43
  },
44
  "verify": "Re-run the audit; Password expiration should be between 1 and {PASSWORD_MAX_AGE_DAYS} days.",
45
  "note": "Current NIST guidance (SP 800-63B) advises against forced periodic changes when passwords are long and checked against breach lists. Set PASSWORD_MAX_AGE_DAYS=0 in baseline.conf if your policy follows that approach."
46
  },
47
  "password.min_age": {
48
  "why": "A minimum age stops users cycling through passwords immediately to get back to an old one.",
49
- "risk": { "level": "Low", "reason": "Without it, the password history rule can be bypassed." },
 
 
 
50
  "fix": {
51
- "windows": ["Set Group Policy: Account Policies > Password Policy > Minimum password age = {PASSWORD_MIN_AGE_DAYS} day(s).", "For a stand-alone device: net accounts /minpwage:{PASSWORD_MIN_AGE_DAYS}"],
52
- "linux": ["Set PASS_MIN_DAYS {PASSWORD_MIN_AGE_DAYS} in /etc/login.defs and apply to existing accounts: chage --mindays {PASSWORD_MIN_AGE_DAYS} <user>"],
53
- "macos": ["macOS has no minimum password age setting; rely on password history instead."]
 
 
 
 
 
 
 
54
  },
55
  "verify": "Re-run the audit; Minimum password age should read {PASSWORD_MIN_AGE_DAYS} day(s) or more."
56
  },
57
  "password.complexity": {
58
  "why": "Mixing character types makes passwords harder to guess.",
59
- "risk": { "level": "Medium", "reason": "Simple passwords are cracked quickly by dictionary attacks." },
 
 
 
60
  "fix": {
61
- "windows": ["Set Group Policy: Account Policies > Password Policy > Password must meet complexity requirements = Enabled."],
62
- "linux": ["Enable pam_pwquality and set minclass = 3 (or 4) in /etc/security/pwquality.conf."],
63
- "macos": ["Deploy a Passcode configuration profile with requireAlphanumeric = true (and minComplexChars = 1 if special characters are required)."]
 
 
 
 
 
 
64
  },
65
  "verify": "Re-run the audit; Password complexity should be Compliant."
66
  },
67
  "lockout.threshold": {
68
  "why": "Locking an account after a few wrong passwords stops online guessing.",
69
- "risk": { "level": "Medium", "reason": "Unlimited attempts allow brute-force attacks against local accounts." },
 
 
 
70
  "fix": {
71
- "windows": ["Set Group Policy: Account Policies > Account Lockout Policy > Account lockout threshold = {LOCKOUT_THRESHOLD_MAX} invalid attempts (or fewer, not 0).", "For a stand-alone device: net accounts /lockoutthreshold:{LOCKOUT_THRESHOLD_MAX}"],
72
- "linux": ["Enable pam_faillock in the PAM auth stack (RHEL: authselect enable-feature with-faillock; Debian/Ubuntu: add the preauth/authfail lines described in pam_faillock(8)).", "Set deny = {LOCKOUT_THRESHOLD_MAX} in /etc/security/faillock.conf."],
73
- "macos": ["Deploy a Passcode configuration profile with maxFailedAttempts = {LOCKOUT_THRESHOLD_MAX}."]
 
 
 
 
 
 
 
 
74
  },
75
  "verify": "Re-run the audit; Account lockout threshold should be between 1 and {LOCKOUT_THRESHOLD_MAX}."
76
  },
77
  "lockout.duration": {
78
  "why": "The lockout must last long enough to slow down repeated guessing.",
79
- "risk": { "level": "Low", "reason": "A short lockout lets attackers resume guessing almost at once." },
 
 
 
80
  "fix": {
81
- "windows": ["Set Group Policy: Account Lockout Policy > Account lockout duration = {LOCKOUT_DURATION_MIN_MINUTES} minutes or more.", "For a stand-alone device: net accounts /lockoutduration:{LOCKOUT_DURATION_MIN_MINUTES}"],
82
- "linux": ["Set unlock_time = {LOCKOUT_DURATION_SECONDS} (seconds) in /etc/security/faillock.conf."],
83
- "macos": ["Deploy a Passcode configuration profile with minutesUntilFailedLoginReset = {LOCKOUT_DURATION_MIN_MINUTES}."]
 
 
 
 
 
 
 
84
  },
85
  "verify": "Re-run the audit; Account lockout duration should be {LOCKOUT_DURATION_MIN_MINUTES} minutes or more."
86
  },
87
  "lockout.observation": {
88
  "why": "Failed attempts must be counted over a long enough window to detect slow guessing.",
89
- "risk": { "level": "Low", "reason": "A short window lets attackers pace their guesses to avoid lockout." },
 
 
 
90
  "fix": {
91
- "windows": ["Set Group Policy: Account Lockout Policy > Reset account lockout counter after = {LOCKOUT_OBSERVATION_MIN_MINUTES} minutes or more.", "For a stand-alone device: net accounts /lockoutwindow:{LOCKOUT_OBSERVATION_MIN_MINUTES}"],
92
- "linux": ["Set fail_interval = {LOCKOUT_OBSERVATION_SECONDS} (seconds) in /etc/security/faillock.conf."],
93
- "macos": ["On macOS the counter resets when the lockout ends; set minutesUntilFailedLoginReset = {LOCKOUT_OBSERVATION_MIN_MINUTES} in the Passcode profile."]
 
 
 
 
 
 
 
94
  },
95
  "verify": "Re-run the audit; Lockout observation period should be {LOCKOUT_OBSERVATION_MIN_MINUTES} minutes or more."
96
  },
97
  "malware.antivirus": {
98
  "why": "Anti-malware software detects and blocks known malicious files and behaviour.",
99
- "risk": { "level": "High", "reason": "Without working anti-malware, ransomware and info-stealers can run unchallenged." },
 
 
 
100
  "fix": {
101
- "windows": ["Turn Microsoft Defender Antivirus back on (Windows Security > Virus & threat protection) or make sure the third-party product is installed, enabled and reporting to Security Center."],
102
- "linux": ["Install an endpoint protection product, or ClamAV: apt install clamav clamav-daemon (RHEL: dnf install clamav clamd), then systemctl enable --now clamav-daemon."],
103
- "macos": ["XProtect is built in; if it is missing or damaged, reinstall the latest macOS update. Add a managed endpoint protection product if your policy requires on-access scanning."]
 
 
 
 
 
 
 
 
 
104
  },
105
  "verify": "Re-run the audit; Antivirus should be Compliant."
106
  },
107
  "malware.service": {
108
  "why": "The malware protection service must be running for protection to work.",
109
- "risk": { "level": "High", "reason": "A stopped protection service leaves the device unprotected." },
 
 
 
110
  "fix": {
111
- "windows": ["Re-enable Microsoft Defender: check that no Group Policy sets 'Turn off Microsoft Defender Antivirus', then restart the device."],
112
- "linux": ["systemctl enable --now clamav-daemon (RHEL: clamd@scan)."],
113
- "macos": ["Re-enable Gatekeeper: sudo spctl --master-enable. Keep 'Install Security Responses and system files' on in Software Update."]
 
 
 
 
 
 
114
  },
115
  "verify": "Re-run the audit; the malware protection service should be Compliant."
116
  },
117
  "malware.realtime": {
118
  "why": "Real-time protection scans files and processes as they are used.",
119
- "risk": { "level": "High", "reason": "Without it, malware is only found by scheduled scans, after it has run." },
 
 
 
120
  "fix": {
121
- "windows": ["Set-MpPreference -DisableRealtimeMonitoring $false, and remove any policy that disables real-time protection."]
 
 
122
  },
123
  "verify": "Re-run the audit; Real-time protection should be on."
124
  },
125
  "malware.on_access": {
126
  "why": "On-access scanning checks files when they are opened or written.",
127
- "risk": { "level": "Medium", "reason": "Malicious files can be opened before any scheduled scan finds them." },
 
 
 
128
  "fix": {
129
- "windows": ["Enable real-time protection in Microsoft Defender (on-access protection follows it)."],
130
- "linux": ["Configure OnAccessIncludePath in /etc/clamav/clamd.conf and enable clamonacc (systemctl enable --now clamav-clamonacc), or use an endpoint product with on-access scanning."],
131
- "macos": ["Not provided by macOS itself; deploy a managed endpoint protection product if required."]
 
 
 
 
 
 
132
  },
133
  "verify": "Re-run the audit; On-access malware protection should be Compliant."
134
  },
135
  "malware.tamper": {
136
  "why": "Tamper protection stops malware or users switching off Defender.",
137
- "risk": { "level": "Medium", "reason": "Attackers commonly disable antivirus before deploying ransomware." },
 
 
 
138
  "fix": {
139
- "windows": ["Turn on Tamper Protection in Windows Security > Virus & threat protection settings, or centrally through Microsoft Intune / Defender for Endpoint (it cannot be set with PowerShell)."]
 
 
140
  },
141
  "verify": "Re-run the audit; Tamper protection should be on."
142
  },
143
  "malware.signatures": {
144
  "why": "Anti-malware can only detect threats it knows about; signatures must be current.",
145
- "risk": { "level": "High", "reason": "Outdated signatures miss recent malware." },
 
 
 
146
  "fix": {
147
- "windows": ["Run Update-MpSignature and check that the device can reach Windows Update or your update server."],
148
- "linux": ["systemctl enable --now clamav-freshclam, or run freshclam once to update now."],
149
- "macos": ["Keep 'Install Security Responses and system files' on (Software Update > Automatic Updates); run softwareupdate --background to fetch now."]
 
 
 
 
 
 
150
  },
151
  "verify": "Re-run the audit; signatures should be within the allowed age."
152
  },
153
  "malware.signature_updates": {
154
  "why": "Signature updates must happen automatically so protection stays current without user action.",
155
- "risk": { "level": "Medium", "reason": "Manual updates are forgotten, leaving protection outdated." },
 
 
 
156
  "fix": {
157
- "linux": ["systemctl enable --now clamav-freshclam."],
158
- "macos": ["Turn on 'Install Security Responses and system files' in System Settings > General > Software Update > Automatic Updates, or set ConfigDataInstall and CriticalUpdateInstall to true by configuration profile."]
 
 
 
 
159
  },
160
  "verify": "Re-run the audit; signature updates should be automatic."
161
  },
162
  "firewall.enabled": {
163
  "why": "A host firewall blocks unwanted network connections to the device, especially on public networks.",
164
- "risk": { "level": "High", "reason": "Network services on the device are exposed to anyone on the same network." },
 
 
 
165
  "fix": {
166
- "windows": ["Set-NetFirewallProfile -Profile Domain,Private,Public -Enabled True, or enforce it with Group Policy/Intune."],
167
- "linux": ["Debian/Ubuntu: ufw default deny incoming; ufw default allow outgoing; ufw enable. RHEL: systemctl enable --now firewalld."],
168
- "macos": ["sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setglobalstate on, or enforce the firewall by configuration profile."]
 
 
 
 
 
 
169
  },
170
  "verify": "Re-run the audit; the firewall should be on."
171
  },
172
  "firewall.inbound": {
173
  "why": "Unsolicited inbound connections should be blocked unless a service is explicitly allowed.",
174
- "risk": { "level": "High", "reason": "Open inbound access lets attackers reach vulnerable services directly." },
 
 
 
175
  "fix": {
176
- "windows": ["Set-NetFirewallProfile -Profile Domain,Private,Public -DefaultInboundAction Block."],
177
- "linux": ["ufw default deny incoming (firewalld: keep the default zone target at default/REJECT)."],
178
- "macos": ["Turn on stealth mode: sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setstealthmode on."]
 
 
 
 
 
 
179
  },
180
  "verify": "Re-run the audit; the inbound policy should be block/deny."
181
  },
182
  "firewall.outbound": {
183
  "why": "The baseline fixes the default outbound policy so that it is consistent across devices.",
184
- "risk": { "level": "Low", "reason": "A different outbound policy either breaks applications or deviates from the agreed design." },
 
 
 
185
  "fix": {
186
- "windows": ["Set-NetFirewallProfile -Profile Domain,Private,Public -DefaultOutboundAction {FIREWALL_OUTBOUND_TITLE}."],
187
- "linux": ["ufw default {FIREWALL_OUTBOUND} outgoing (or the equivalent nftables output policy)."],
188
- "macos": ["The macOS application firewall does not filter outbound traffic; use a network filter product if outbound blocking is required."]
 
 
 
 
 
 
189
  },
190
  "verify": "Re-run the audit; the outbound policy should read {FIREWALL_OUTBOUND}."
191
  },
192
  "screen_lock.enabled": {
193
  "why": "An idle device should lock itself so nobody can use it while the owner is away.",
194
- "risk": { "level": "Medium", "reason": "An unlocked, unattended device exposes all the user's data and accounts." },
 
 
 
195
  "fix": {
196
- "windows": ["Set Group Policy: Local Policies > Security Options > Interactive logon: Machine inactivity limit = {SCREEN_LOCK_MAX_SECONDS} seconds, or enforce a password-protected screen saver by Group Policy/Intune."],
197
- "linux": ["For GNOME: gsettings set org.gnome.desktop.screensaver lock-enabled true, and enforce it for all users with a locked dconf setting in /etc/dconf/db/local.d/."],
198
- "macos": ["System Settings > Lock Screen: set the screen saver and display-off times and 'Require password' = Immediately, or enforce with a configuration profile."]
 
 
 
 
 
 
 
 
 
 
 
 
199
  },
200
  "verify": "Re-run the audit; Screen lock should be on."
201
  },
202
  "screen_lock.timeout": {
203
  "why": "The shorter the idle time before locking, the smaller the window for misuse.",
204
- "risk": { "level": "Medium", "reason": "A long or missing timeout leaves unattended devices open." },
 
 
 
205
  "fix": {
206
- "windows": ["Set Group Policy: Computer Configuration > Windows Settings > Security Settings > Local Policies > Security Options > Interactive logon: Machine inactivity limit = {SCREEN_LOCK_MAX_SECONDS} seconds (or less)."],
207
- "linux": ["gsettings set org.gnome.desktop.session idle-delay {SCREEN_LOCK_MAX_SECONDS} and gsettings set org.gnome.desktop.screensaver lock-delay 0; enforce with locked dconf settings."],
208
- "macos": ["Set the screen saver to start within {SCREEN_LOCK_MAX_MINUTES} minutes (idleTime in com.apple.screensaver) and require the password immediately, ideally by configuration profile."]
 
 
 
 
 
 
 
 
 
 
 
 
209
  },
210
  "verify": "Re-run the audit; Screen lock timeout should be {SCREEN_LOCK_MAX_SECONDS} seconds or less."
211
  },
212
  "screen_lock.password": {
213
  "why": "Unlocking the screen must require the user's password.",
214
- "risk": { "level": "Medium", "reason": "A screen lock that needs no password does not protect anything." },
 
 
 
215
  "fix": {
216
- "windows": ["Set the machine inactivity limit by Group Policy (this always requires sign-in), or enable 'Password protect the screen saver' for users by Group Policy."],
217
- "linux": ["Keep org.gnome.desktop.screensaver lock-enabled true and org.gnome.desktop.lockdown disable-lock-screen false."],
218
- "macos": ["System Settings > Lock Screen > 'Require password after screen saver begins or display is turned off' = Immediately (askForPasswordDelay = 0 by profile)."]
 
 
 
 
 
 
219
  },
220
  "verify": "Re-run the audit; the password requirement should be Compliant."
221
  },
222
  "usb.read": {
223
  "why": "The baseline sets whether USB storage may be read; reading lets malware and unapproved data enter.",
224
- "risk": { "level": "Low", "reason": "The setting differs from the organisation's agreed USB policy." },
 
 
 
225
  "fix": {
226
- "windows": ["Set Group Policy: Computer Configuration > Administrative Templates > System > Removable Storage Access > Removable Disks: Deny read access = {USB_READ_POLICY}."],
227
- "linux": ["To block USB storage completely: echo 'install usb-storage /bin/false' > /etc/modprobe.d/usb-storage.conf, or use USBGuard."],
228
- "macos": ["Restrict external storage with your MDM or an endpoint DLP product."]
 
 
 
 
 
 
229
  },
230
  "verify": "Re-run the audit; USB storage read should read {USB_READ}."
231
  },
232
  "usb.write": {
233
  "why": "Writing to USB drives is a common way for data to leave the organisation, often unencrypted.",
234
- "risk": { "level": "Medium", "reason": "Personal or confidential data can be copied to an unmanaged, unencrypted drive and lost." },
 
 
 
235
  "fix": {
236
- "windows": ["Set Group Policy: Administrative Templates > System > Removable Storage Access > Removable Disks: Deny write access = Enabled, or require BitLocker To Go ('Deny write access to removable drives not protected by BitLocker')."],
237
- "linux": ["Make USB storage read-only with a udev rule, or block it with USBGuard or the usb-storage modprobe rule."],
238
- "macos": ["Restrict external storage to read-only or deny it with your MDM or an endpoint DLP product."]
 
 
 
 
 
 
239
  },
240
  "verify": "Re-run the audit; USB storage write should read {USB_WRITE}."
241
  },
242
  "admin.approved": {
243
  "why": "A known, approved IT account should manage the device so that support does not depend on users' own admin rights.",
244
- "risk": { "level": "Medium", "reason": "Without an approved admin account, IT tends to rely on employees' accounts having admin rights." },
 
 
 
245
  "fix": {
246
- "windows": ["Add the approved IT administrator account (listed in APPROVED_ADMINS) to the local Administrators group, ideally managed with Windows LAPS or Intune."],
247
- "linux": ["Add the approved IT account to the sudo (Debian/Ubuntu) or wheel (RHEL) group: usermod -aG sudo <account>."],
248
- "macos": ["Create or promote the approved IT account: sudo dseditgroup -o edit -a <account> -t user admin, ideally managed by your MDM."]
 
 
 
 
 
 
249
  },
250
  "verify": "Re-run the audit; Approved IT administrator should list the approved account."
251
  },
252
  "admin.unauthorized": {
253
  "why": "Everyday user accounts with administrator rights let malware and mistakes change anything on the device.",
254
- "risk": { "level": "High", "reason": "Malware running as an administrator can disable protection, install persistence and steal credentials." },
 
 
 
255
  "fix": {
256
- "windows": ["Remove the listed accounts from the local Administrators group: Remove-LocalGroupMember -SID S-1-5-32-544 -Member <account>; give elevation through an approved process (e.g. Windows LAPS, an endpoint privilege management tool)."],
257
- "linux": ["Remove the listed accounts from the sudo/wheel groups (gpasswd -d <account> sudo) and remove their entries from /etc/sudoers.d."],
258
- "macos": ["Demote the listed accounts: sudo dseditgroup -o edit -d <account> -t user admin."]
 
 
 
 
 
 
259
  },
260
  "verify": "Re-run the audit; Unauthorized administrator accounts should read none."
261
  },
262
  "encryption.enabled": {
263
  "why": "Full-disk encryption keeps data unreadable if the device is lost or stolen.",
264
- "risk": { "level": "High", "reason": "A lost unencrypted laptop is a personal data breach that may have to be reported." },
 
 
 
265
  "fix": {
266
- "windows": ["Turn on BitLocker for the system drive (Enable-BitLocker -MountPoint C: -EncryptionMethod XtsAes256 -TpmProtector), preferably through Intune or Group Policy with key backup."],
267
- "linux": ["Disk encryption (LUKS) is usually chosen at installation; plan a reinstall with encryption, or an offline re-encryption with cryptsetup reencrypt after a full backup."],
268
- "macos": ["Turn on FileVault (System Settings > Privacy & Security > FileVault, or sudo fdesetup enable), ideally enforced and escrowed by your MDM."]
 
 
 
 
 
 
 
 
 
 
 
 
269
  },
270
  "verify": "Re-run the audit; Encryption should be Compliant."
271
  },
272
  "encryption.protection": {
273
  "why": "Encryption only protects the data when it is active, fully applied and uses a strong cipher.",
274
- "risk": { "level": "High", "reason": "Suspended or partial encryption leaves data readable on a lost device." },
 
 
 
275
  "fix": {
276
- "windows": ["Resume protection if it is suspended (Resume-BitLocker -MountPoint C:) and let encryption finish; use an AES-XTS method."],
277
- "linux": ["Use LUKS2 with aes-xts-plain64 and a 512-bit key (AES-256) for new installations; plan migration of weaker setups."],
278
- "macos": ["Let FileVault encryption finish; keep the Mac on power until fdesetup status reports FileVault is On without progress."]
 
 
 
 
 
 
279
  },
280
  "verify": "Re-run the audit; Disk encryption protection should be Compliant."
281
  },
282
  "encryption.recovery": {
283
  "why": "A recovery key lets IT unlock the disk if the user forgets the password or the hardware changes.",
284
- "risk": { "level": "Medium", "reason": "Without a recovery key, an encrypted disk can become permanently unreadable." },
 
 
 
285
  "fix": {
286
- "windows": ["Add a recovery password: Add-BitLockerKeyProtector -MountPoint C: -RecoveryPasswordProtector, and back it up to Entra ID (BackupToAAD-BitLockerKeyProtector) or Active Directory."],
287
- "linux": ["Add a second key slot holding a recovery passphrase: cryptsetup luksAddKey <device>, and store it in your password vault."],
288
- "macos": ["Create or rotate a personal recovery key (sudo fdesetup changerecovery -personal) and escrow it to your MDM."]
 
 
 
 
 
 
289
  },
290
  "verify": "Re-run the audit; Disk encryption recovery should be Compliant."
291
  },
292
  "usb.device_access": {
293
  "why": "Blocking USB mass storage devices completely is the strongest protection against data leaving on, or malware arriving from, removable drives.",
294
- "risk": { "level": "Low", "reason": "The device access setting differs from the organisation's agreed USB policy." },
 
 
 
295
  "fix": {
296
- "windows": ["To block USB storage completely: Group Policy > Administrative Templates > System > Removable Storage Access > All Removable Storage classes: Deny all access = Enabled, or set the USBSTOR service Start value to 4."],
297
- "linux": ["To block USB storage completely: echo 'install usb-storage /bin/false' > /etc/modprobe.d/usb-storage.conf, or enable USBGuard with ImplicitPolicyTarget=block."],
298
- "macos": ["Block or allow external storage with your MDM (Restrictions / device control) or an endpoint DLP product."]
 
 
 
 
 
 
299
  },
300
  "verify": "Re-run the audit; USB storage device access should read {USB_DEVICE_ACCESS}."
301
  },
302
  "user.account_type": {
303
  "why": "Day-to-day work under an administrator account lets any malware or mistake change the whole system.",
304
- "risk": { "level": "High", "reason": "Malware started by the signed-in user runs with full administrator rights." },
 
 
 
305
  "fix": {
306
- "windows": ["Make the signed-in user a Standard user (Settings > Accounts > Other users > Change account type, or Remove-LocalGroupMember -SID S-1-5-32-544 -Member <account>) and use a separate approved admin account or LAPS for elevation."],
307
- "linux": ["Remove the signed-in user from sudo/wheel (gpasswd -d <account> sudo) and use a separate approved admin account."],
308
- "macos": ["Make the signed-in user a Standard user (System Settings > Users & Groups, or sudo dseditgroup -o edit -d <account> -t user admin) and keep a separate approved admin account."]
 
 
 
 
 
 
309
  },
310
  "verify": "Re-run the audit; Signed-in user account type should read Standard."
311
  },
312
  "vuln.pending_updates": {
313
  "why": "Security updates that are offered but not installed leave known, published vulnerabilities open.",
314
- "risk": { "level": "High", "reason": "Attackers target published vulnerabilities quickly after the fix is released." },
 
 
 
315
  "fix": {
316
- "windows": ["Install the pending updates now (Settings > Windows Update > Install all), restart if asked, and check that automatic updates are not paused."],
317
- "linux": ["Install pending security updates: apt update && apt upgrade (RHEL: dnf upgrade --security), then reboot if a new kernel was installed."],
318
- "macos": ["Install the pending updates: System Settings > General > Software Update, or softwareupdate -ia, then restart if asked."]
 
 
 
 
 
 
 
 
 
 
 
 
319
  },
320
  "verify": "Re-run the audit; pending security updates should be {MAX_PENDING_SECURITY_UPDATES} or fewer."
321
  },
322
  "vuln.outdated_apps": {
323
  "why": "Browsers, office suites, PDF readers and meeting apps are common attack targets and need updates like the OS.",
324
- "risk": { "level": "Medium", "reason": "Outdated applications contain known vulnerabilities that attackers exploit through documents and web pages." },
 
 
 
325
  "fix": {
326
- "windows": ["Update the listed applications (winget upgrade --all, or each app's own updater) and manage updates centrally with Intune or a patch management tool."],
327
- "linux": ["Update packages and app stores: apt upgrade, snap refresh, flatpak update."],
328
- "macos": ["Update the listed applications from the App Store or their own updaters (or brew upgrade for Homebrew apps), and manage updates centrally with your MDM."]
 
 
 
 
 
 
 
 
 
 
 
 
329
  },
330
  "verify": "Re-run the audit; outdated applications should be {MAX_OUTDATED_APPS} or fewer."
331
  },
332
  "vuln.known_vulnerabilities": {
333
  "why": "Installed software versions with published critical or high vulnerabilities give attackers a ready-made way in.",
334
- "risk": { "level": "High", "reason": "Critical and high vulnerabilities often allow remote code execution or privilege escalation." },
 
 
 
335
  "fix": {
336
- "all": ["Update or remove each listed software package to a version without the listed vulnerabilities; where no fix exists yet, apply the vendor's workaround or restrict the software's use."],
337
- "windows": ["Use winget upgrade or the vendor's installer to reach a fixed version."],
338
- "linux": ["Use the package manager to reach a fixed version (apt / dnf / snap / flatpak)."],
339
- "macos": ["Use the App Store, the vendor's updater or brew upgrade to reach a fixed version."]
 
 
 
 
 
 
 
 
 
 
 
340
  },
341
  "verify": "Re-run the audit; critical or high vulnerabilities should be {MAX_CRITICAL_HIGH_VULNERABILITIES} or fewer."
342
  },
343
-
344
  "gdpr.auto_updates": {
345
  "why": "Security fixes must be installed automatically so that known vulnerabilities are closed quickly.",
346
- "risk": { "level": "High", "reason": "Unpatched devices are the most common way attackers get in." },
 
 
 
347
  "fix": {
348
- "windows": ["Set Group Policy: Administrative Templates > Windows Components > Windows Update > Configure Automatic Updates = 4 (auto download and schedule the install), or use Windows Update for Business / Intune; make sure the Windows Update service is not disabled."],
349
- "linux": ["Debian/Ubuntu: apt install unattended-upgrades && dpkg-reconfigure -plow unattended-upgrades. RHEL: dnf install dnf-automatic && systemctl enable --now dnf-automatic-install.timer."],
350
- "macos": ["Turn on all options in System Settings > General > Software Update > Automatic Updates, or enforce them with a configuration profile (AutomaticallyInstallMacOSUpdates = true)."]
 
 
 
 
 
 
 
 
 
 
 
 
351
  },
352
  "verify": "Re-run the audit; Automatic security updates should be Compliant."
353
  },
354
  "gdpr.updates_recent": {
355
  "why": "Recent installs show that patching is actually happening, not only configured.",
356
- "risk": { "level": "High", "reason": "A device not updated for a long time is likely missing critical security fixes." },
 
 
 
357
  "fix": {
358
- "windows": ["Run Windows Update now (Settings > Windows Update > Check for updates) and investigate why updates stopped (paused updates, broken update service, metered connection)."],
359
- "linux": ["Run apt update && apt upgrade (RHEL: dnf upgrade) and check the automatic update logs for errors."],
360
- "macos": ["Install pending updates (System Settings > General > Software Update, or softwareupdate -ia)."]
 
 
 
 
 
 
361
  },
362
  "verify": "Re-run the audit; the last update should be within {UPDATES_MAX_AGE_DAYS} days."
363
  },
364
  "gdpr.os_supported": {
365
  "why": "An operating system that no longer receives security updates cannot be kept secure.",
366
- "risk": { "level": "High", "reason": "New vulnerabilities in an unsupported OS are never fixed." },
 
 
 
367
  "fix": {
368
- "windows": ["Upgrade to a supported Windows 11 release (build {WINDOWS_MIN_BUILD} or later), or replace hardware that cannot run it."],
369
- "linux": ["Upgrade to a supported release (e.g. Ubuntu {UBUNTU_MIN_VERSION} or later) or enrol in extended security maintenance."],
370
- "macos": ["Upgrade to macOS {MACOS_MIN_MAJOR} or later, or replace Macs that cannot run a supported version."]
 
 
 
 
 
 
 
 
 
 
 
 
371
  },
372
  "verify": "Re-run the audit; Supported operating system version should be Compliant."
373
  },
374
  "gdpr.security_logging": {
375
  "why": "Logon and account-change events are needed to detect and investigate a breach within the 72-hour GDPR deadline.",
376
- "risk": { "level": "Medium", "reason": "Without these logs, a breach cannot be scoped and the notification decision becomes guesswork." },
 
 
 
377
  "fix": {
378
- "windows": ["Enable auditing: auditpol /set /subcategory:{0CCE9215-69AE-11D9-BED3-505054503030} /success:enable /failure:enable (Logon) and auditpol /set /subcategory:{0CCE9235-69AE-11D9-BED3-505054503030} /success:enable /failure:enable (User Account Management), or set the Advanced Audit Policy by Group Policy."],
379
- "linux": ["Install and start auditd (apt install auditd; systemctl enable --now auditd) and keep the journal persistent (Storage=persistent in /etc/systemd/journald.conf)."],
380
- "macos": ["Unified logging is built in; forward security logs to your log platform if long retention is required."]
 
 
 
 
 
 
381
  },
382
  "verify": "Re-run the audit; Security event logging should be Compliant."
383
  },
384
  "gdpr.time_sync": {
385
  "why": "Accurate clocks are needed to put events in the right order during an investigation.",
386
- "risk": { "level": "Low", "reason": "Wrong timestamps make incident timelines unreliable." },
 
 
 
387
  "fix": {
388
- "windows": ["w32tm /config /syncfromflags:manual /manualpeerlist:\"pool.ntp.org\" /update (domain members sync from the domain automatically); make sure the Windows Time service is not disabled."],
389
- "linux": ["timedatectl set-ntp true (or enable chronyd)."],
390
- "macos": ["sudo systemsetup -setusingnetworktime on."]
 
 
 
 
 
 
 
 
 
 
 
 
391
  },
392
  "verify": "Re-run the audit; Time synchronisation should be Compliant."
393
  },
394
  "gdpr.backup_configured": {
395
  "why": "Backups make it possible to restore personal data after loss, theft, failure or ransomware.",
396
- "risk": { "level": "High", "reason": "Data that exists only on this device is lost if the device fails or is encrypted by ransomware." },
 
 
 
397
  "fix": {
398
- "windows": ["Back up user folders with OneDrive Known Folder Move (Group Policy/Intune), File History, or the organisation's backup agent."],
399
- "linux": ["Set up scheduled backups of /home with Deja Dup, restic or borg (or Timeshift for system snapshots plus a data backup)."],
400
- "macos": ["Configure Time Machine (tmutil setdestination) or the organisation's backup agent."]
 
 
 
 
 
 
 
 
 
 
 
 
401
  },
402
  "verify": "Re-run the audit; Backup configured should be Compliant."
403
  },
404
  "gdpr.backup_recent": {
405
  "why": "A configured backup only helps if it has actually run recently.",
406
- "risk": { "level": "Medium", "reason": "Recent work would be lost if the device failed today." },
 
 
 
407
  "fix": {
408
- "windows": ["Check the backup tool's history and run a backup now; fix whatever stopped it (full destination, signed-out OneDrive, stopped agent)."],
409
- "linux": ["Run the backup job now and check its timer and logs."],
410
- "macos": ["Run tmutil startbackup and check that the destination is reachable."]
 
 
 
 
 
 
411
  },
412
  "verify": "Re-run the audit; the last backup should be within {BACKUP_MAX_AGE_DAYS} days."
413
  },
414
  "gdpr.removable_encryption": {
415
  "why": "Personal data copied to removable media must be encrypted, or copying must be blocked.",
416
- "risk": { "level": "Medium", "reason": "A lost unencrypted USB drive with personal data is a reportable breach." },
 
 
 
417
  "fix": {
418
- "windows": ["Enable 'Deny write access to removable drives not protected by BitLocker' (Group Policy: Windows Components > BitLocker Drive Encryption > Removable Data Drives), or deny USB write access."],
419
- "linux": ["Block USB storage writes (udev read-only rule or USBGuard) and provide approved encrypted drives."],
420
- "macos": ["Restrict external storage writes with your MDM or an endpoint DLP product and provide approved encrypted drives."]
 
 
 
 
 
 
421
  },
422
  "verify": "Re-run the audit; Removable media encryption should be Compliant."
423
  },
424
  "gdpr.guest_account": {
425
  "why": "Guest logins let people use the device without an identifiable account.",
426
- "risk": { "level": "Medium", "reason": "Actions by a guest cannot be traced to a person, and guests may reach shared data." },
 
 
 
427
  "fix": {
428
- "windows": ["Disable the built-in Guest account: Get-LocalUser | Where-Object { $_.SID -like '*-501' } | Disable-LocalUser"],
429
- "linux": ["Set allow-guest=false in the LightDM configuration and remove any 'guest' account with a login shell."],
430
- "macos": ["sudo defaults write /Library/Preferences/com.apple.loginwindow GuestEnabled -bool false, and turn off guest access to shared folders."]
 
 
 
 
 
 
431
  },
432
  "verify": "Re-run the audit; Guest account disabled should be Compliant."
433
  },
434
  "gdpr.remote_access": {
435
  "why": "Remote access services let others reach the device over the network; only approved ones should be on.",
436
- "risk": { "level": "High", "reason": "Unapproved remote access tools are a common entry point for attackers and support scams." },
 
 
 
437
  "fix": {
438
- "windows": ["Turn off the listed services if not approved: disable Remote Desktop (fDenyTSConnections = 1), stop and disable sshd/WinRM, remove unneeded shares, and uninstall unapproved tools such as TeamViewer, AnyDesk or Chrome Remote Desktop."],
439
- "linux": ["Disable unapproved services, e.g. systemctl disable --now ssh xrdp smbd, and remove unapproved remote access tools."],
440
- "macos": ["Turn off Remote Login, Screen Sharing, File Sharing and Remote Management in System Settings > General > Sharing, and uninstall unapproved tools (e.g. the Chrome Remote Desktop host)."]
 
 
 
 
 
 
441
  },
442
  "verify": "Re-run the audit; only approved services ({APPROVED_REMOTE_SERVICES_TEXT}) should remain.",
443
  "note": "If a listed service is needed, add it to APPROVED_REMOTE_SERVICES in baseline.conf and secure it (multi-factor authentication, restricted to the VPN)."
444
  },
445
  "gdpr.telemetry": {
446
  "why": "Operating systems can send optional usage and diagnostic data, which may contain personal data, to the vendor.",
447
- "risk": { "level": "Low", "reason": "More personal data than necessary leaves the organisation." },
 
 
 
448
  "fix": {
449
- "windows": ["Set Group Policy: Administrative Templates > Windows Components > Data Collection and Preview Builds > Allow Diagnostic Data = Send required diagnostic data (AllowTelemetry = 1; 0 on Enterprise/Education)."],
450
- "linux": ["Opt out of popularity-contest (dpkg-reconfigure popularity-contest) and disable whoopsie if present (systemctl disable --now whoopsie)."],
451
- "macos": ["Turn off System Settings > Privacy & Security > Analytics & Improvements > Share Mac Analytics and Share with App Developers, or enforce by profile."]
 
 
 
 
 
 
 
 
 
 
 
 
452
  },
453
  "verify": "Re-run the audit; Diagnostic data minimised should be Compliant."
454
  },
455
  "gdpr.personal_data_discovery": {
456
  "why": "Knowing where personal data is stored on endpoints is needed for the record of processing and for storage limitation.",
457
- "risk": { "level": "Low", "reason": "Not assessed in stage 1." },
458
- "fix": { "all": ["No action needed now: this opt-in scan is planned for a later stage of the tool."] },
 
 
 
 
 
 
 
459
  "verify": "Not applicable in stage 1."
460
  },
461
-
462
  "aiact.inventory": {
463
  "why": "Deployers must know which AI systems they use before they can classify and govern them.",
464
- "risk": { "level": "Low", "reason": "The inventory itself is information; the risk lies in unapproved or unclassified tools." },
465
- "fix": { "all": ["Add the AI tools found on this device to the organisation's AI register, with owner and purpose."] },
 
 
 
 
 
 
 
466
  "verify": "The AI register lists every tool in the inventory."
467
  },
468
  "aiact.unapproved": {
469
  "why": "AI tools used without approval may send work and personal data to providers without contracts or a transfer basis.",
470
- "risk": { "level": "Medium", "reason": "Shadow AI can leak confidential or personal data and creates unassessed AI Act and GDPR obligations." },
 
 
 
471
  "fix": {
472
- "all": ["Decide for each listed tool: approve it (add to APPROVED_AI_TOOLS after a data protection and AI Act review, with a data processing agreement where needed) or remove it.", "Publish an AI acceptable-use policy so staff know which tools are allowed."],
473
- "windows": ["Remove unapproved apps (Settings > Apps) or block them with AppLocker / Intune app control."],
474
- "linux": ["Remove unapproved tools with the package manager and restrict installation rights."],
475
- "macos": ["Remove unapproved apps and restrict installations through your MDM."]
 
 
 
 
 
 
 
 
 
476
  },
477
  "verify": "Re-run the audit; Unapproved AI tools should read none."
478
  },
479
  "aiact.risk_classification": {
480
  "why": "Each AI system must be checked against the banned practices and the high-risk uses in Annex III.",
481
- "risk": { "level": "Medium", "reason": "An unclassified tool could be used for a high-risk or prohibited purpose without the required safeguards." },
482
- "fix": { "all": ["Record the purpose for which each listed tool is used and classify it (prohibited, high-risk, limited-risk transparency, minimal). The auditor model does this from the inventory and the stated purpose."] },
 
 
 
 
 
 
 
483
  "verify": "Every AI tool in the inventory has a classification in the AI register."
484
  },
485
  "aiact.os_ai_features": {
486
  "why": "Built-in OS AI features can capture screen content or send text to external AI services.",
487
- "risk": { "level": "Medium", "reason": "Screen snapshots or prompts can contain personal and confidential data processed without a defined purpose." },
 
 
 
488
  "fix": {
489
- "windows": ["Set Group Policy: Administrative Templates > Windows Components > Windows AI > Turn off saving snapshots for use with Recall = Enabled (DisableAIDataAnalysis = 1), unless Recall has been assessed and approved."],
490
- "macos": ["Deploy a Restrictions profile with allowExternalIntelligenceIntegrations = false (and restrict other Apple Intelligence features your policy does not allow)."],
491
- "linux": ["No action needed: Linux distributions have no built-in AI assistant."]
 
 
 
 
 
 
 
 
 
 
 
 
492
  },
493
  "verify": "Re-run the audit; the OS AI features check should be Compliant or NotApplicable."
494
  },
495
  "aiact.log_retention": {
496
  "why": "Deployers of high-risk AI systems must keep the system's logs for at least six months.",
497
- "risk": { "level": "Medium", "reason": "Missing logs prevent monitoring and investigation of high-risk AI use." },
498
- "fix": { "all": ["If any listed tool is classified high-risk, keep its automatically generated logs under your control for at least six months."] },
 
 
 
 
 
 
 
499
  "verify": "Log retention is documented for each high-risk AI system."
500
  },
501
-
502
  "org.ropa": {
503
  "why": "The record of processing activities is the core GDPR accountability document.",
504
- "risk": { "level": "High", "reason": "A missing or outdated record is a direct infringement and makes every other assessment harder." },
505
- "fix": { "all": ["Create or update the record of processing activities (purposes, categories of data and people, recipients, transfers, retention, security measures).", "Assign an owner and review it at least yearly."] },
 
 
 
 
 
 
 
 
506
  "verify": "Answer ORG_ROPA=yes in organisational.conf once the record is current."
507
  },
508
  "org.dpia": {
509
  "why": "High-risk processing needs a data protection impact assessment before it starts.",
510
- "risk": { "level": "Medium", "reason": "High-risk processing without a DPIA breaches GDPR and leaves risks to people unassessed." },
511
- "fix": { "all": ["List processing that is likely high-risk (e.g. monitoring employees, large-scale sensitive data, new technologies such as AI) and complete a DPIA for each."] },
 
 
 
 
 
 
 
512
  "verify": "Answer ORG_DPIA=yes once DPIAs are done."
513
  },
514
  "org.breach_procedure": {
515
  "why": "Breaches must be reported to the supervisory authority within 72 hours where required.",
516
- "risk": { "level": "High", "reason": "Without a tested procedure, the 72-hour deadline is easily missed." },
517
- "fix": { "all": ["Write a breach procedure (detection, assessment, 72-hour notification to the CNPD or your authority, informing people, breach log) and test it with an exercise."] },
 
 
 
 
 
 
 
518
  "verify": "Answer ORG_BREACH_PROCEDURE=yes once the procedure is written and tested."
519
  },
520
  "org.processor_agreements": {
521
  "why": "Every processor, including cloud and AI providers, needs a contract with the GDPR Article 28 terms.",
522
- "risk": { "level": "High", "reason": "Sharing personal data with a processor without an agreement breaches GDPR." },
523
- "fix": { "all": ["List all processors (including AI services used by staff) and sign a data processing agreement with each; check the transfer basis for non-EEA providers."] },
 
 
 
 
 
 
 
524
  "verify": "Answer ORG_PROCESSOR_AGREEMENTS=yes once all agreements are in place."
525
  },
526
  "org.ai_register": {
527
  "why": "A register of AI systems is the basis for AI Act classification and deployer duties.",
528
- "risk": { "level": "Medium", "reason": "Without a register, high-risk or prohibited AI use can go unnoticed." },
529
- "fix": { "all": ["Create an AI register listing each AI system, owner, purpose, risk class and the data it uses; use the AI software inventory from this audit as input."] },
 
 
 
 
 
 
 
530
  "verify": "Answer ORG_AI_REGISTER=yes once the register exists."
531
  },
532
  "org.ai_literacy": {
533
  "why": "Since 2 February 2025, organisations must ensure staff using AI have sufficient AI literacy.",
534
- "risk": { "level": "Medium", "reason": "Untrained staff misuse AI tools and share data they should not." },
535
- "fix": { "all": ["Provide AI literacy training matched to how staff use AI (capabilities, limits, data protection, the AI acceptable-use policy) and keep records."] },
 
 
 
 
 
 
 
536
  "verify": "Answer ORG_AI_LITERACY=yes once training is delivered and recorded."
537
  },
538
  "org.human_oversight": {
539
  "why": "High-risk AI systems need trained people who can understand, monitor and override them.",
540
- "risk": { "level": "Medium", "reason": "High-risk AI without oversight can harm people without anyone noticing." },
541
- "fix": { "all": ["For each high-risk AI system, name a trained person responsible for oversight; answer n/a if no high-risk AI is used."] },
 
 
 
 
 
 
 
542
  "verify": "Answer ORG_HUMAN_OVERSIGHT=yes (or n/a) in organisational.conf."
543
  },
544
  "org.ai_transparency": {
545
  "why": "People must know when they deal with AI or AI-generated content.",
546
- "risk": { "level": "Low", "reason": "Undisclosed AI use damages trust and breaches the AI Act transparency rules." },
547
- "fix": { "all": ["Set rules for labelling AI chatbots and AI-generated or manipulated content, and include them in the AI acceptable-use policy."] },
 
 
 
 
 
 
 
548
  "verify": "Answer ORG_AI_TRANSPARENCY=yes once the rules are in place."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
549
  }
550
  }
551
  }
 
1
  {
2
+ "library_version": "0.3.0",
3
  "note": "Per-check guidance used to write findings. Fix steps are written for administrators; prefer central management (Group Policy/Intune, MDM, configuration management) over per-device changes. Review by a security professional before use with customers.",
4
  "aliases": {
5
+ "firewall.domain.enabled": "firewall.enabled",
6
+ "firewall.private.enabled": "firewall.enabled",
7
+ "firewall.public.enabled": "firewall.enabled",
8
+ "firewall.domain.inbound": "firewall.inbound",
9
+ "firewall.private.inbound": "firewall.inbound",
10
+ "firewall.public.inbound": "firewall.inbound",
11
+ "firewall.domain.outbound": "firewall.outbound",
12
+ "firewall.private.outbound": "firewall.outbound",
13
+ "firewall.public.outbound": "firewall.outbound",
14
  "encryption.feature": "encryption.enabled"
15
  },
16
  "status_notes": {
 
21
  "checks": {
22
  "password.min_length": {
23
  "why": "Short passwords can be guessed or cracked quickly; length is the strongest single factor in password strength.",
24
+ "risk": {
25
+ "level": "Medium",
26
+ "reason": "Weak passwords make account takeover through guessing or credential stuffing easier."
27
+ },
28
  "fix": {
29
+ "windows": [
30
+ "Set Group Policy: Computer Configuration > Windows Settings > Security Settings > Account Policies > Password Policy > Minimum password length = {PASSWORD_MIN_LENGTH} (or the Intune equivalent).",
31
+ "For a stand-alone device: net accounts /minpwlen:{PASSWORD_MIN_LENGTH}"
32
+ ],
33
+ "linux": [
34
+ "Install pam_pwquality (Debian/Ubuntu: apt install libpam-pwquality) and make sure it is listed in the PAM password stack.",
35
+ "Set minlen = {PASSWORD_MIN_LENGTH} in /etc/security/pwquality.conf."
36
+ ],
37
+ "macos": [
38
+ "Deploy a Passcode configuration profile through your MDM with minLength = {PASSWORD_MIN_LENGTH}."
39
+ ]
40
  },
41
  "verify": "Re-run the audit; Minimum password length should read {PASSWORD_MIN_LENGTH} or more."
42
  },
43
  "password.history": {
44
  "why": "Without a password history, users can return to old, possibly exposed passwords.",
45
+ "risk": {
46
+ "level": "Low",
47
+ "reason": "Reused passwords keep compromised credentials valid."
48
+ },
49
  "fix": {
50
+ "windows": [
51
+ "Set Group Policy: Account Policies > Password Policy > Enforce password history = {PASSWORD_HISTORY}.",
52
+ "For a stand-alone device: net accounts /uniquepw:{PASSWORD_HISTORY}"
53
+ ],
54
+ "linux": [
55
+ "Add pam_pwhistory to the PAM password stack before pam_unix (e.g. 'password requisite pam_pwhistory.so remember={PASSWORD_HISTORY} use_authtok').",
56
+ "Or set remember = {PASSWORD_HISTORY} in /etc/security/pwhistory.conf where supported."
57
+ ],
58
+ "macos": [
59
+ "Deploy a Passcode configuration profile with pinHistory = {PASSWORD_HISTORY}."
60
+ ]
61
  },
62
  "verify": "Re-run the audit; Password history should read {PASSWORD_HISTORY} or more."
63
  },
64
  "password.expiration": {
65
  "why": "The baseline requires passwords to be changed within a set period, limiting how long a stolen password stays useful.",
66
+ "risk": {
67
+ "level": "Low",
68
+ "reason": "A leaked password can stay valid indefinitely."
69
+ },
70
  "fix": {
71
+ "windows": [
72
+ "Set Group Policy: Account Policies > Password Policy > Maximum password age = {PASSWORD_MAX_AGE_DAYS} days.",
73
+ "For a stand-alone device: net accounts /maxpwage:{PASSWORD_MAX_AGE_DAYS}"
74
+ ],
75
+ "linux": [
76
+ "Set PASS_MAX_DAYS {PASSWORD_MAX_AGE_DAYS} in /etc/login.defs (applies to new accounts).",
77
+ "Apply it to existing accounts: chage --maxdays {PASSWORD_MAX_AGE_DAYS} <user>"
78
+ ],
79
+ "macos": [
80
+ "Deploy a Passcode configuration profile with maxPINAgeInDays = {PASSWORD_MAX_AGE_DAYS}."
81
+ ]
82
  },
83
  "verify": "Re-run the audit; Password expiration should be between 1 and {PASSWORD_MAX_AGE_DAYS} days.",
84
  "note": "Current NIST guidance (SP 800-63B) advises against forced periodic changes when passwords are long and checked against breach lists. Set PASSWORD_MAX_AGE_DAYS=0 in baseline.conf if your policy follows that approach."
85
  },
86
  "password.min_age": {
87
  "why": "A minimum age stops users cycling through passwords immediately to get back to an old one.",
88
+ "risk": {
89
+ "level": "Low",
90
+ "reason": "Without it, the password history rule can be bypassed."
91
+ },
92
  "fix": {
93
+ "windows": [
94
+ "Set Group Policy: Account Policies > Password Policy > Minimum password age = {PASSWORD_MIN_AGE_DAYS} day(s).",
95
+ "For a stand-alone device: net accounts /minpwage:{PASSWORD_MIN_AGE_DAYS}"
96
+ ],
97
+ "linux": [
98
+ "Set PASS_MIN_DAYS {PASSWORD_MIN_AGE_DAYS} in /etc/login.defs and apply to existing accounts: chage --mindays {PASSWORD_MIN_AGE_DAYS} <user>"
99
+ ],
100
+ "macos": [
101
+ "macOS has no minimum password age setting; rely on password history instead."
102
+ ]
103
  },
104
  "verify": "Re-run the audit; Minimum password age should read {PASSWORD_MIN_AGE_DAYS} day(s) or more."
105
  },
106
  "password.complexity": {
107
  "why": "Mixing character types makes passwords harder to guess.",
108
+ "risk": {
109
+ "level": "Medium",
110
+ "reason": "Simple passwords are cracked quickly by dictionary attacks."
111
+ },
112
  "fix": {
113
+ "windows": [
114
+ "Set Group Policy: Account Policies > Password Policy > Password must meet complexity requirements = Enabled."
115
+ ],
116
+ "linux": [
117
+ "Enable pam_pwquality and set minclass = 3 (or 4) in /etc/security/pwquality.conf."
118
+ ],
119
+ "macos": [
120
+ "Deploy a Passcode configuration profile with requireAlphanumeric = true (and minComplexChars = 1 if special characters are required)."
121
+ ]
122
  },
123
  "verify": "Re-run the audit; Password complexity should be Compliant."
124
  },
125
  "lockout.threshold": {
126
  "why": "Locking an account after a few wrong passwords stops online guessing.",
127
+ "risk": {
128
+ "level": "Medium",
129
+ "reason": "Unlimited attempts allow brute-force attacks against local accounts."
130
+ },
131
  "fix": {
132
+ "windows": [
133
+ "Set Group Policy: Account Policies > Account Lockout Policy > Account lockout threshold = {LOCKOUT_THRESHOLD_MAX} invalid attempts (or fewer, not 0).",
134
+ "For a stand-alone device: net accounts /lockoutthreshold:{LOCKOUT_THRESHOLD_MAX}"
135
+ ],
136
+ "linux": [
137
+ "Enable pam_faillock in the PAM auth stack (RHEL: authselect enable-feature with-faillock; Debian/Ubuntu: add the preauth/authfail lines described in pam_faillock(8)).",
138
+ "Set deny = {LOCKOUT_THRESHOLD_MAX} in /etc/security/faillock.conf."
139
+ ],
140
+ "macos": [
141
+ "Deploy a Passcode configuration profile with maxFailedAttempts = {LOCKOUT_THRESHOLD_MAX}."
142
+ ]
143
  },
144
  "verify": "Re-run the audit; Account lockout threshold should be between 1 and {LOCKOUT_THRESHOLD_MAX}."
145
  },
146
  "lockout.duration": {
147
  "why": "The lockout must last long enough to slow down repeated guessing.",
148
+ "risk": {
149
+ "level": "Low",
150
+ "reason": "A short lockout lets attackers resume guessing almost at once."
151
+ },
152
  "fix": {
153
+ "windows": [
154
+ "Set Group Policy: Account Lockout Policy > Account lockout duration = {LOCKOUT_DURATION_MIN_MINUTES} minutes or more.",
155
+ "For a stand-alone device: net accounts /lockoutduration:{LOCKOUT_DURATION_MIN_MINUTES}"
156
+ ],
157
+ "linux": [
158
+ "Set unlock_time = {LOCKOUT_DURATION_SECONDS} (seconds) in /etc/security/faillock.conf."
159
+ ],
160
+ "macos": [
161
+ "Deploy a Passcode configuration profile with minutesUntilFailedLoginReset = {LOCKOUT_DURATION_MIN_MINUTES}."
162
+ ]
163
  },
164
  "verify": "Re-run the audit; Account lockout duration should be {LOCKOUT_DURATION_MIN_MINUTES} minutes or more."
165
  },
166
  "lockout.observation": {
167
  "why": "Failed attempts must be counted over a long enough window to detect slow guessing.",
168
+ "risk": {
169
+ "level": "Low",
170
+ "reason": "A short window lets attackers pace their guesses to avoid lockout."
171
+ },
172
  "fix": {
173
+ "windows": [
174
+ "Set Group Policy: Account Lockout Policy > Reset account lockout counter after = {LOCKOUT_OBSERVATION_MIN_MINUTES} minutes or more.",
175
+ "For a stand-alone device: net accounts /lockoutwindow:{LOCKOUT_OBSERVATION_MIN_MINUTES}"
176
+ ],
177
+ "linux": [
178
+ "Set fail_interval = {LOCKOUT_OBSERVATION_SECONDS} (seconds) in /etc/security/faillock.conf."
179
+ ],
180
+ "macos": [
181
+ "On macOS the counter resets when the lockout ends; set minutesUntilFailedLoginReset = {LOCKOUT_OBSERVATION_MIN_MINUTES} in the Passcode profile."
182
+ ]
183
  },
184
  "verify": "Re-run the audit; Lockout observation period should be {LOCKOUT_OBSERVATION_MIN_MINUTES} minutes or more."
185
  },
186
  "malware.antivirus": {
187
  "why": "Anti-malware software detects and blocks known malicious files and behaviour.",
188
+ "risk": {
189
+ "level": "High",
190
+ "reason": "Without working anti-malware, ransomware and info-stealers can run unchallenged."
191
+ },
192
  "fix": {
193
+ "windows": [
194
+ "Turn Microsoft Defender Antivirus back on (Windows Security > Virus & threat protection) or make sure the third-party product is installed, enabled and reporting to Security Center."
195
+ ],
196
+ "linux": [
197
+ "Install an endpoint protection product, or ClamAV: apt install clamav clamav-daemon (RHEL: dnf install clamav clamd), then systemctl enable --now clamav-daemon."
198
+ ],
199
+ "macos": [
200
+ "XProtect is built in; if it is missing or damaged, reinstall the latest macOS update. Add a managed endpoint protection product if your policy requires on-access scanning."
201
+ ],
202
+ "android": [
203
+ "Play Store › profile picture › Play Protect › Settings: turn on 'Scan apps with Play Protect'."
204
+ ]
205
  },
206
  "verify": "Re-run the audit; Antivirus should be Compliant."
207
  },
208
  "malware.service": {
209
  "why": "The malware protection service must be running for protection to work.",
210
+ "risk": {
211
+ "level": "High",
212
+ "reason": "A stopped protection service leaves the device unprotected."
213
+ },
214
  "fix": {
215
+ "windows": [
216
+ "Re-enable Microsoft Defender: check that no Group Policy sets 'Turn off Microsoft Defender Antivirus', then restart the device."
217
+ ],
218
+ "linux": [
219
+ "systemctl enable --now clamav-daemon (RHEL: clamd@scan)."
220
+ ],
221
+ "macos": [
222
+ "Re-enable Gatekeeper: sudo spctl --master-enable. Keep 'Install Security Responses and system files' on in Software Update."
223
+ ]
224
  },
225
  "verify": "Re-run the audit; the malware protection service should be Compliant."
226
  },
227
  "malware.realtime": {
228
  "why": "Real-time protection scans files and processes as they are used.",
229
+ "risk": {
230
+ "level": "High",
231
+ "reason": "Without it, malware is only found by scheduled scans, after it has run."
232
+ },
233
  "fix": {
234
+ "windows": [
235
+ "Set-MpPreference -DisableRealtimeMonitoring $false, and remove any policy that disables real-time protection."
236
+ ]
237
  },
238
  "verify": "Re-run the audit; Real-time protection should be on."
239
  },
240
  "malware.on_access": {
241
  "why": "On-access scanning checks files when they are opened or written.",
242
+ "risk": {
243
+ "level": "Medium",
244
+ "reason": "Malicious files can be opened before any scheduled scan finds them."
245
+ },
246
  "fix": {
247
+ "windows": [
248
+ "Enable real-time protection in Microsoft Defender (on-access protection follows it)."
249
+ ],
250
+ "linux": [
251
+ "Configure OnAccessIncludePath in /etc/clamav/clamd.conf and enable clamonacc (systemctl enable --now clamav-clamonacc), or use an endpoint product with on-access scanning."
252
+ ],
253
+ "macos": [
254
+ "Not provided by macOS itself; deploy a managed endpoint protection product if required."
255
+ ]
256
  },
257
  "verify": "Re-run the audit; On-access malware protection should be Compliant."
258
  },
259
  "malware.tamper": {
260
  "why": "Tamper protection stops malware or users switching off Defender.",
261
+ "risk": {
262
+ "level": "Medium",
263
+ "reason": "Attackers commonly disable antivirus before deploying ransomware."
264
+ },
265
  "fix": {
266
+ "windows": [
267
+ "Turn on Tamper Protection in Windows Security > Virus & threat protection settings, or centrally through Microsoft Intune / Defender for Endpoint (it cannot be set with PowerShell)."
268
+ ]
269
  },
270
  "verify": "Re-run the audit; Tamper protection should be on."
271
  },
272
  "malware.signatures": {
273
  "why": "Anti-malware can only detect threats it knows about; signatures must be current.",
274
+ "risk": {
275
+ "level": "High",
276
+ "reason": "Outdated signatures miss recent malware."
277
+ },
278
  "fix": {
279
+ "windows": [
280
+ "Run Update-MpSignature and check that the device can reach Windows Update or your update server."
281
+ ],
282
+ "linux": [
283
+ "systemctl enable --now clamav-freshclam, or run freshclam once to update now."
284
+ ],
285
+ "macos": [
286
+ "Keep 'Install Security Responses and system files' on (Software Update > Automatic Updates); run softwareupdate --background to fetch now."
287
+ ]
288
  },
289
  "verify": "Re-run the audit; signatures should be within the allowed age."
290
  },
291
  "malware.signature_updates": {
292
  "why": "Signature updates must happen automatically so protection stays current without user action.",
293
+ "risk": {
294
+ "level": "Medium",
295
+ "reason": "Manual updates are forgotten, leaving protection outdated."
296
+ },
297
  "fix": {
298
+ "linux": [
299
+ "systemctl enable --now clamav-freshclam."
300
+ ],
301
+ "macos": [
302
+ "Turn on 'Install Security Responses and system files' in System Settings > General > Software Update > Automatic Updates, or set ConfigDataInstall and CriticalUpdateInstall to true by configuration profile."
303
+ ]
304
  },
305
  "verify": "Re-run the audit; signature updates should be automatic."
306
  },
307
  "firewall.enabled": {
308
  "why": "A host firewall blocks unwanted network connections to the device, especially on public networks.",
309
+ "risk": {
310
+ "level": "High",
311
+ "reason": "Network services on the device are exposed to anyone on the same network."
312
+ },
313
  "fix": {
314
+ "windows": [
315
+ "Set-NetFirewallProfile -Profile Domain,Private,Public -Enabled True, or enforce it with Group Policy/Intune."
316
+ ],
317
+ "linux": [
318
+ "Debian/Ubuntu: ufw default deny incoming; ufw default allow outgoing; ufw enable. RHEL: systemctl enable --now firewalld."
319
+ ],
320
+ "macos": [
321
+ "sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setglobalstate on, or enforce the firewall by configuration profile."
322
+ ]
323
  },
324
  "verify": "Re-run the audit; the firewall should be on."
325
  },
326
  "firewall.inbound": {
327
  "why": "Unsolicited inbound connections should be blocked unless a service is explicitly allowed.",
328
+ "risk": {
329
+ "level": "High",
330
+ "reason": "Open inbound access lets attackers reach vulnerable services directly."
331
+ },
332
  "fix": {
333
+ "windows": [
334
+ "Set-NetFirewallProfile -Profile Domain,Private,Public -DefaultInboundAction Block."
335
+ ],
336
+ "linux": [
337
+ "ufw default deny incoming (firewalld: keep the default zone target at default/REJECT)."
338
+ ],
339
+ "macos": [
340
+ "Turn on stealth mode: sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setstealthmode on."
341
+ ]
342
  },
343
  "verify": "Re-run the audit; the inbound policy should be block/deny."
344
  },
345
  "firewall.outbound": {
346
  "why": "The baseline fixes the default outbound policy so that it is consistent across devices.",
347
+ "risk": {
348
+ "level": "Low",
349
+ "reason": "A different outbound policy either breaks applications or deviates from the agreed design."
350
+ },
351
  "fix": {
352
+ "windows": [
353
+ "Set-NetFirewallProfile -Profile Domain,Private,Public -DefaultOutboundAction {FIREWALL_OUTBOUND_TITLE}."
354
+ ],
355
+ "linux": [
356
+ "ufw default {FIREWALL_OUTBOUND} outgoing (or the equivalent nftables output policy)."
357
+ ],
358
+ "macos": [
359
+ "The macOS application firewall does not filter outbound traffic; use a network filter product if outbound blocking is required."
360
+ ]
361
  },
362
  "verify": "Re-run the audit; the outbound policy should read {FIREWALL_OUTBOUND}."
363
  },
364
  "screen_lock.enabled": {
365
  "why": "An idle device should lock itself so nobody can use it while the owner is away.",
366
+ "risk": {
367
+ "level": "Medium",
368
+ "reason": "An unlocked, unattended device exposes all the user's data and accounts."
369
+ },
370
  "fix": {
371
+ "windows": [
372
+ "Set Group Policy: Local Policies > Security Options > Interactive logon: Machine inactivity limit = {SCREEN_LOCK_MAX_SECONDS} seconds, or enforce a password-protected screen saver by Group Policy/Intune."
373
+ ],
374
+ "linux": [
375
+ "For GNOME: gsettings set org.gnome.desktop.screensaver lock-enabled true, and enforce it for all users with a locked dconf setting in /etc/dconf/db/local.d/."
376
+ ],
377
+ "macos": [
378
+ "System Settings > Lock Screen: set the screen saver and display-off times and 'Require password' = Immediately, or enforce with a configuration profile."
379
+ ],
380
+ "ios": [
381
+ "Settings › Face ID & Passcode › Turn Passcode On; for work phones the employer can require it with a configuration profile."
382
+ ],
383
+ "android": [
384
+ "Settings › Security & privacy › Device unlock › Screen lock: choose PIN or Password."
385
+ ]
386
  },
387
  "verify": "Re-run the audit; Screen lock should be on."
388
  },
389
  "screen_lock.timeout": {
390
  "why": "The shorter the idle time before locking, the smaller the window for misuse.",
391
+ "risk": {
392
+ "level": "Medium",
393
+ "reason": "A long or missing timeout leaves unattended devices open."
394
+ },
395
  "fix": {
396
+ "windows": [
397
+ "Set Group Policy: Computer Configuration > Windows Settings > Security Settings > Local Policies > Security Options > Interactive logon: Machine inactivity limit = {SCREEN_LOCK_MAX_SECONDS} seconds (or less)."
398
+ ],
399
+ "linux": [
400
+ "gsettings set org.gnome.desktop.session idle-delay {SCREEN_LOCK_MAX_SECONDS} and gsettings set org.gnome.desktop.screensaver lock-delay 0; enforce with locked dconf settings."
401
+ ],
402
+ "macos": [
403
+ "Set the screen saver to start within {SCREEN_LOCK_MAX_MINUTES} minutes (idleTime in com.apple.screensaver) and require the password immediately, ideally by configuration profile."
404
+ ],
405
+ "ios": [
406
+ "Settings › Display & Brightness › Auto-Lock: choose {SCREEN_LOCK_MAX_MINUTES} minutes or less (not Never)."
407
+ ],
408
+ "android": [
409
+ "Settings › Display › Screen timeout: choose {SCREEN_LOCK_MAX_MINUTES} minutes or less, and set 'Lock after screen timeout' to Immediately where the phone offers it."
410
+ ]
411
  },
412
  "verify": "Re-run the audit; Screen lock timeout should be {SCREEN_LOCK_MAX_SECONDS} seconds or less."
413
  },
414
  "screen_lock.password": {
415
  "why": "Unlocking the screen must require the user's password.",
416
+ "risk": {
417
+ "level": "Medium",
418
+ "reason": "A screen lock that needs no password does not protect anything."
419
+ },
420
  "fix": {
421
+ "windows": [
422
+ "Set the machine inactivity limit by Group Policy (this always requires sign-in), or enable 'Password protect the screen saver' for users by Group Policy."
423
+ ],
424
+ "linux": [
425
+ "Keep org.gnome.desktop.screensaver lock-enabled true and org.gnome.desktop.lockdown disable-lock-screen false."
426
+ ],
427
+ "macos": [
428
+ "System Settings > Lock Screen > 'Require password after screen saver begins or display is turned off' = Immediately (askForPasswordDelay = 0 by profile)."
429
+ ]
430
  },
431
  "verify": "Re-run the audit; the password requirement should be Compliant."
432
  },
433
  "usb.read": {
434
  "why": "The baseline sets whether USB storage may be read; reading lets malware and unapproved data enter.",
435
+ "risk": {
436
+ "level": "Low",
437
+ "reason": "The setting differs from the organisation's agreed USB policy."
438
+ },
439
  "fix": {
440
+ "windows": [
441
+ "Set Group Policy: Computer Configuration > Administrative Templates > System > Removable Storage Access > Removable Disks: Deny read access = {USB_READ_POLICY}."
442
+ ],
443
+ "linux": [
444
+ "To block USB storage completely: echo 'install usb-storage /bin/false' > /etc/modprobe.d/usb-storage.conf, or use USBGuard."
445
+ ],
446
+ "macos": [
447
+ "Restrict external storage with your MDM or an endpoint DLP product."
448
+ ]
449
  },
450
  "verify": "Re-run the audit; USB storage read should read {USB_READ}."
451
  },
452
  "usb.write": {
453
  "why": "Writing to USB drives is a common way for data to leave the organisation, often unencrypted.",
454
+ "risk": {
455
+ "level": "Medium",
456
+ "reason": "Personal or confidential data can be copied to an unmanaged, unencrypted drive and lost."
457
+ },
458
  "fix": {
459
+ "windows": [
460
+ "Set Group Policy: Administrative Templates > System > Removable Storage Access > Removable Disks: Deny write access = Enabled, or require BitLocker To Go ('Deny write access to removable drives not protected by BitLocker')."
461
+ ],
462
+ "linux": [
463
+ "Make USB storage read-only with a udev rule, or block it with USBGuard or the usb-storage modprobe rule."
464
+ ],
465
+ "macos": [
466
+ "Restrict external storage to read-only or deny it with your MDM or an endpoint DLP product."
467
+ ]
468
  },
469
  "verify": "Re-run the audit; USB storage write should read {USB_WRITE}."
470
  },
471
  "admin.approved": {
472
  "why": "A known, approved IT account should manage the device so that support does not depend on users' own admin rights.",
473
+ "risk": {
474
+ "level": "Medium",
475
+ "reason": "Without an approved admin account, IT tends to rely on employees' accounts having admin rights."
476
+ },
477
  "fix": {
478
+ "windows": [
479
+ "Add the approved IT administrator account (listed in APPROVED_ADMINS) to the local Administrators group, ideally managed with Windows LAPS or Intune."
480
+ ],
481
+ "linux": [
482
+ "Add the approved IT account to the sudo (Debian/Ubuntu) or wheel (RHEL) group: usermod -aG sudo <account>."
483
+ ],
484
+ "macos": [
485
+ "Create or promote the approved IT account: sudo dseditgroup -o edit -a <account> -t user admin, ideally managed by your MDM."
486
+ ]
487
  },
488
  "verify": "Re-run the audit; Approved IT administrator should list the approved account."
489
  },
490
  "admin.unauthorized": {
491
  "why": "Everyday user accounts with administrator rights let malware and mistakes change anything on the device.",
492
+ "risk": {
493
+ "level": "High",
494
+ "reason": "Malware running as an administrator can disable protection, install persistence and steal credentials."
495
+ },
496
  "fix": {
497
+ "windows": [
498
+ "Remove the listed accounts from the local Administrators group: Remove-LocalGroupMember -SID S-1-5-32-544 -Member <account>; give elevation through an approved process (e.g. Windows LAPS, an endpoint privilege management tool)."
499
+ ],
500
+ "linux": [
501
+ "Remove the listed accounts from the sudo/wheel groups (gpasswd -d <account> sudo) and remove their entries from /etc/sudoers.d."
502
+ ],
503
+ "macos": [
504
+ "Demote the listed accounts: sudo dseditgroup -o edit -d <account> -t user admin."
505
+ ]
506
  },
507
  "verify": "Re-run the audit; Unauthorized administrator accounts should read none."
508
  },
509
  "encryption.enabled": {
510
  "why": "Full-disk encryption keeps data unreadable if the device is lost or stolen.",
511
+ "risk": {
512
+ "level": "High",
513
+ "reason": "A lost unencrypted laptop is a personal data breach that may have to be reported."
514
+ },
515
  "fix": {
516
+ "windows": [
517
+ "Turn on BitLocker for the system drive (Enable-BitLocker -MountPoint C: -EncryptionMethod XtsAes256 -TpmProtector), preferably through Intune or Group Policy with key backup."
518
+ ],
519
+ "linux": [
520
+ "Disk encryption (LUKS) is usually chosen at installation; plan a reinstall with encryption, or an offline re-encryption with cryptsetup reencrypt after a full backup."
521
+ ],
522
+ "macos": [
523
+ "Turn on FileVault (System Settings > Privacy & Security > FileVault, or sudo fdesetup enable), ideally enforced and escrowed by your MDM."
524
+ ],
525
+ "ios": [
526
+ "Set a passcode (Settings › Face ID & Passcode): iOS data protection encrypts the phone once a passcode is set."
527
+ ],
528
+ "android": [
529
+ "Update to a supported Android version (10 or later encrypts by default) or replace the phone; set a PIN or password screen lock."
530
+ ]
531
  },
532
  "verify": "Re-run the audit; Encryption should be Compliant."
533
  },
534
  "encryption.protection": {
535
  "why": "Encryption only protects the data when it is active, fully applied and uses a strong cipher.",
536
+ "risk": {
537
+ "level": "High",
538
+ "reason": "Suspended or partial encryption leaves data readable on a lost device."
539
+ },
540
  "fix": {
541
+ "windows": [
542
+ "Resume protection if it is suspended (Resume-BitLocker -MountPoint C:) and let encryption finish; use an AES-XTS method."
543
+ ],
544
+ "linux": [
545
+ "Use LUKS2 with aes-xts-plain64 and a 512-bit key (AES-256) for new installations; plan migration of weaker setups."
546
+ ],
547
+ "macos": [
548
+ "Let FileVault encryption finish; keep the Mac on power until fdesetup status reports FileVault is On without progress."
549
+ ]
550
  },
551
  "verify": "Re-run the audit; Disk encryption protection should be Compliant."
552
  },
553
  "encryption.recovery": {
554
  "why": "A recovery key lets IT unlock the disk if the user forgets the password or the hardware changes.",
555
+ "risk": {
556
+ "level": "Medium",
557
+ "reason": "Without a recovery key, an encrypted disk can become permanently unreadable."
558
+ },
559
  "fix": {
560
+ "windows": [
561
+ "Add a recovery password: Add-BitLockerKeyProtector -MountPoint C: -RecoveryPasswordProtector, and back it up to Entra ID (BackupToAAD-BitLockerKeyProtector) or Active Directory."
562
+ ],
563
+ "linux": [
564
+ "Add a second key slot holding a recovery passphrase: cryptsetup luksAddKey <device>, and store it in your password vault."
565
+ ],
566
+ "macos": [
567
+ "Create or rotate a personal recovery key (sudo fdesetup changerecovery -personal) and escrow it to your MDM."
568
+ ]
569
  },
570
  "verify": "Re-run the audit; Disk encryption recovery should be Compliant."
571
  },
572
  "usb.device_access": {
573
  "why": "Blocking USB mass storage devices completely is the strongest protection against data leaving on, or malware arriving from, removable drives.",
574
+ "risk": {
575
+ "level": "Low",
576
+ "reason": "The device access setting differs from the organisation's agreed USB policy."
577
+ },
578
  "fix": {
579
+ "windows": [
580
+ "To block USB storage completely: Group Policy > Administrative Templates > System > Removable Storage Access > All Removable Storage classes: Deny all access = Enabled, or set the USBSTOR service Start value to 4."
581
+ ],
582
+ "linux": [
583
+ "To block USB storage completely: echo 'install usb-storage /bin/false' > /etc/modprobe.d/usb-storage.conf, or enable USBGuard with ImplicitPolicyTarget=block."
584
+ ],
585
+ "macos": [
586
+ "Block or allow external storage with your MDM (Restrictions / device control) or an endpoint DLP product."
587
+ ]
588
  },
589
  "verify": "Re-run the audit; USB storage device access should read {USB_DEVICE_ACCESS}."
590
  },
591
  "user.account_type": {
592
  "why": "Day-to-day work under an administrator account lets any malware or mistake change the whole system.",
593
+ "risk": {
594
+ "level": "High",
595
+ "reason": "Malware started by the signed-in user runs with full administrator rights."
596
+ },
597
  "fix": {
598
+ "windows": [
599
+ "Make the signed-in user a Standard user (Settings > Accounts > Other users > Change account type, or Remove-LocalGroupMember -SID S-1-5-32-544 -Member <account>) and use a separate approved admin account or LAPS for elevation."
600
+ ],
601
+ "linux": [
602
+ "Remove the signed-in user from sudo/wheel (gpasswd -d <account> sudo) and use a separate approved admin account."
603
+ ],
604
+ "macos": [
605
+ "Make the signed-in user a Standard user (System Settings > Users & Groups, or sudo dseditgroup -o edit -d <account> -t user admin) and keep a separate approved admin account."
606
+ ]
607
  },
608
  "verify": "Re-run the audit; Signed-in user account type should read Standard."
609
  },
610
  "vuln.pending_updates": {
611
  "why": "Security updates that are offered but not installed leave known, published vulnerabilities open.",
612
+ "risk": {
613
+ "level": "High",
614
+ "reason": "Attackers target published vulnerabilities quickly after the fix is released."
615
+ },
616
  "fix": {
617
+ "windows": [
618
+ "Install the pending updates now (Settings > Windows Update > Install all), restart if asked, and check that automatic updates are not paused."
619
+ ],
620
+ "linux": [
621
+ "Install pending security updates: apt update && apt upgrade (RHEL: dnf upgrade --security), then reboot if a new kernel was installed."
622
+ ],
623
+ "macos": [
624
+ "Install the pending updates: System Settings > General > Software Update, or softwareupdate -ia, then restart if asked."
625
+ ],
626
+ "ios": [
627
+ "Settings › General › Software Update: install the latest iOS version offered."
628
+ ],
629
+ "android": [
630
+ "Settings › System › Software updates (and Security & privacy › Updates): install the latest system and Google Play system updates. If the security patch stays older than {MOBILE_PATCH_MAX_AGE_DAYS} days because the maker no longer sends updates, replace the phone."
631
+ ]
632
  },
633
  "verify": "Re-run the audit; pending security updates should be {MAX_PENDING_SECURITY_UPDATES} or fewer."
634
  },
635
  "vuln.outdated_apps": {
636
  "why": "Browsers, office suites, PDF readers and meeting apps are common attack targets and need updates like the OS.",
637
+ "risk": {
638
+ "level": "Medium",
639
+ "reason": "Outdated applications contain known vulnerabilities that attackers exploit through documents and web pages."
640
+ },
641
  "fix": {
642
+ "windows": [
643
+ "Update the listed applications (winget upgrade --all, or each app's own updater) and manage updates centrally with Intune or a patch management tool."
644
+ ],
645
+ "linux": [
646
+ "Update packages and app stores: apt upgrade, snap refresh, flatpak update."
647
+ ],
648
+ "macos": [
649
+ "Update the listed applications from the App Store or their own updaters (or brew upgrade for Homebrew apps), and manage updates centrally with your MDM."
650
+ ],
651
+ "ios": [
652
+ "Settings › App Store: turn on App Updates; then App Store › account picture › update all pending apps."
653
+ ],
654
+ "android": [
655
+ "Play Store › profile picture › Settings › Network preferences › Auto-update apps: Over any network or Over Wi-Fi only; then Manage apps & device › Update all."
656
+ ]
657
  },
658
  "verify": "Re-run the audit; outdated applications should be {MAX_OUTDATED_APPS} or fewer."
659
  },
660
  "vuln.known_vulnerabilities": {
661
  "why": "Installed software versions with published critical or high vulnerabilities give attackers a ready-made way in.",
662
+ "risk": {
663
+ "level": "High",
664
+ "reason": "Critical and high vulnerabilities often allow remote code execution or privilege escalation."
665
+ },
666
  "fix": {
667
+ "all": [
668
+ "Update or remove each listed software package to a version without the listed vulnerabilities; where no fix exists yet, apply the vendor's workaround or restrict the software's use."
669
+ ],
670
+ "windows": [
671
+ "Use winget upgrade or the vendor's installer to reach a fixed version."
672
+ ],
673
+ "linux": [
674
+ "Use the package manager to reach a fixed version (apt / dnf / snap / flatpak)."
675
+ ],
676
+ "macos": [
677
+ "Use the App Store, the vendor's updater or brew upgrade to reach a fixed version."
678
+ ],
679
+ "ios": [
680
+ "Settings › General › Software Update: install the newest iOS version; if the iPhone cannot run a version without the listed vulnerabilities, replace it."
681
+ ]
682
  },
683
  "verify": "Re-run the audit; critical or high vulnerabilities should be {MAX_CRITICAL_HIGH_VULNERABILITIES} or fewer."
684
  },
 
685
  "gdpr.auto_updates": {
686
  "why": "Security fixes must be installed automatically so that known vulnerabilities are closed quickly.",
687
+ "risk": {
688
+ "level": "High",
689
+ "reason": "Unpatched devices are the most common way attackers get in."
690
+ },
691
  "fix": {
692
+ "windows": [
693
+ "Set Group Policy: Administrative Templates > Windows Components > Windows Update > Configure Automatic Updates = 4 (auto download and schedule the install), or use Windows Update for Business / Intune; make sure the Windows Update service is not disabled."
694
+ ],
695
+ "linux": [
696
+ "Debian/Ubuntu: apt install unattended-upgrades && dpkg-reconfigure -plow unattended-upgrades. RHEL: dnf install dnf-automatic && systemctl enable --now dnf-automatic-install.timer."
697
+ ],
698
+ "macos": [
699
+ "Turn on all options in System Settings > General > Software Update > Automatic Updates, or enforce them with a configuration profile (AutomaticallyInstallMacOSUpdates = true)."
700
+ ],
701
+ "ios": [
702
+ "Settings › General › Software Update › Automatic Updates: turn on iOS Updates Download and Install, and Security Responses & System Files."
703
+ ],
704
+ "android": [
705
+ "Settings › System › Software updates: turn on automatic download (names vary by phone maker)."
706
+ ]
707
  },
708
  "verify": "Re-run the audit; Automatic security updates should be Compliant."
709
  },
710
  "gdpr.updates_recent": {
711
  "why": "Recent installs show that patching is actually happening, not only configured.",
712
+ "risk": {
713
+ "level": "High",
714
+ "reason": "A device not updated for a long time is likely missing critical security fixes."
715
+ },
716
  "fix": {
717
+ "windows": [
718
+ "Run Windows Update now (Settings > Windows Update > Check for updates) and investigate why updates stopped (paused updates, broken update service, metered connection)."
719
+ ],
720
+ "linux": [
721
+ "Run apt update && apt upgrade (RHEL: dnf upgrade) and check the automatic update logs for errors."
722
+ ],
723
+ "macos": [
724
+ "Install pending updates (System Settings > General > Software Update, or softwareupdate -ia)."
725
+ ]
726
  },
727
  "verify": "Re-run the audit; the last update should be within {UPDATES_MAX_AGE_DAYS} days."
728
  },
729
  "gdpr.os_supported": {
730
  "why": "An operating system that no longer receives security updates cannot be kept secure.",
731
+ "risk": {
732
+ "level": "High",
733
+ "reason": "New vulnerabilities in an unsupported OS are never fixed."
734
+ },
735
  "fix": {
736
+ "windows": [
737
+ "Upgrade to a supported Windows 11 release (build {WINDOWS_MIN_BUILD} or later), or replace hardware that cannot run it."
738
+ ],
739
+ "linux": [
740
+ "Upgrade to a supported release (e.g. Ubuntu {UBUNTU_MIN_VERSION} or later) or enrol in extended security maintenance."
741
+ ],
742
+ "macos": [
743
+ "Upgrade to macOS {MACOS_MIN_MAJOR} or later, or replace Macs that cannot run a supported version."
744
+ ],
745
+ "ios": [
746
+ "Update to iOS {IOS_MIN_MAJOR} or later in Settings › General › Software Update; if the iPhone cannot run it, replace the iPhone."
747
+ ],
748
+ "android": [
749
+ "Update to Android {ANDROID_MIN_MAJOR} or later in Settings › System › Software updates; if the maker no longer offers it, replace the phone."
750
+ ]
751
  },
752
  "verify": "Re-run the audit; Supported operating system version should be Compliant."
753
  },
754
  "gdpr.security_logging": {
755
  "why": "Logon and account-change events are needed to detect and investigate a breach within the 72-hour GDPR deadline.",
756
+ "risk": {
757
+ "level": "Medium",
758
+ "reason": "Without these logs, a breach cannot be scoped and the notification decision becomes guesswork."
759
+ },
760
  "fix": {
761
+ "windows": [
762
+ "Enable auditing: auditpol /set /subcategory:{0CCE9215-69AE-11D9-BED3-505054503030} /success:enable /failure:enable (Logon) and auditpol /set /subcategory:{0CCE9235-69AE-11D9-BED3-505054503030} /success:enable /failure:enable (User Account Management), or set the Advanced Audit Policy by Group Policy."
763
+ ],
764
+ "linux": [
765
+ "Install and start auditd (apt install auditd; systemctl enable --now auditd) and keep the journal persistent (Storage=persistent in /etc/systemd/journald.conf)."
766
+ ],
767
+ "macos": [
768
+ "Unified logging is built in; forward security logs to your log platform if long retention is required."
769
+ ]
770
  },
771
  "verify": "Re-run the audit; Security event logging should be Compliant."
772
  },
773
  "gdpr.time_sync": {
774
  "why": "Accurate clocks are needed to put events in the right order during an investigation.",
775
+ "risk": {
776
+ "level": "Low",
777
+ "reason": "Wrong timestamps make incident timelines unreliable."
778
+ },
779
  "fix": {
780
+ "windows": [
781
+ "w32tm /config /syncfromflags:manual /manualpeerlist:\"pool.ntp.org\" /update (domain members sync from the domain automatically); make sure the Windows Time service is not disabled."
782
+ ],
783
+ "linux": [
784
+ "timedatectl set-ntp true (or enable chronyd)."
785
+ ],
786
+ "macos": [
787
+ "sudo systemsetup -setusingnetworktime on."
788
+ ],
789
+ "ios": [
790
+ "Settings › General › Date & Time: turn on Set Automatically."
791
+ ],
792
+ "android": [
793
+ "Settings › System › Date & time: turn on Set time automatically."
794
+ ]
795
  },
796
  "verify": "Re-run the audit; Time synchronisation should be Compliant."
797
  },
798
  "gdpr.backup_configured": {
799
  "why": "Backups make it possible to restore personal data after loss, theft, failure or ransomware.",
800
+ "risk": {
801
+ "level": "High",
802
+ "reason": "Data that exists only on this device is lost if the device fails or is encrypted by ransomware."
803
+ },
804
  "fix": {
805
+ "windows": [
806
+ "Back up user folders with OneDrive Known Folder Move (Group Policy/Intune), File History, or the organisation's backup agent."
807
+ ],
808
+ "linux": [
809
+ "Set up scheduled backups of /home with Deja Dup, restic or borg (or Timeshift for system snapshots plus a data backup)."
810
+ ],
811
+ "macos": [
812
+ "Configure Time Machine (tmutil setdestination) or the organisation's backup agent."
813
+ ],
814
+ "ios": [
815
+ "Settings › [your name] › iCloud › iCloud Backup: turn on, or use the employer's approved backup; keep work data only in company-approved apps."
816
+ ],
817
+ "android": [
818
+ "Settings › Google › Backup: turn on Backup by Google One (or the employer's approved backup); keep work data only in company-approved apps."
819
+ ]
820
  },
821
  "verify": "Re-run the audit; Backup configured should be Compliant."
822
  },
823
  "gdpr.backup_recent": {
824
  "why": "A configured backup only helps if it has actually run recently.",
825
+ "risk": {
826
+ "level": "Medium",
827
+ "reason": "Recent work would be lost if the device failed today."
828
+ },
829
  "fix": {
830
+ "windows": [
831
+ "Check the backup tool's history and run a backup now; fix whatever stopped it (full destination, signed-out OneDrive, stopped agent)."
832
+ ],
833
+ "linux": [
834
+ "Run the backup job now and check its timer and logs."
835
+ ],
836
+ "macos": [
837
+ "Run tmutil startbackup and check that the destination is reachable."
838
+ ]
839
  },
840
  "verify": "Re-run the audit; the last backup should be within {BACKUP_MAX_AGE_DAYS} days."
841
  },
842
  "gdpr.removable_encryption": {
843
  "why": "Personal data copied to removable media must be encrypted, or copying must be blocked.",
844
+ "risk": {
845
+ "level": "Medium",
846
+ "reason": "A lost unencrypted USB drive with personal data is a reportable breach."
847
+ },
848
  "fix": {
849
+ "windows": [
850
+ "Enable 'Deny write access to removable drives not protected by BitLocker' (Group Policy: Windows Components > BitLocker Drive Encryption > Removable Data Drives), or deny USB write access."
851
+ ],
852
+ "linux": [
853
+ "Block USB storage writes (udev read-only rule or USBGuard) and provide approved encrypted drives."
854
+ ],
855
+ "macos": [
856
+ "Restrict external storage writes with your MDM or an endpoint DLP product and provide approved encrypted drives."
857
+ ]
858
  },
859
  "verify": "Re-run the audit; Removable media encryption should be Compliant."
860
  },
861
  "gdpr.guest_account": {
862
  "why": "Guest logins let people use the device without an identifiable account.",
863
+ "risk": {
864
+ "level": "Medium",
865
+ "reason": "Actions by a guest cannot be traced to a person, and guests may reach shared data."
866
+ },
867
  "fix": {
868
+ "windows": [
869
+ "Disable the built-in Guest account: Get-LocalUser | Where-Object { $_.SID -like '*-501' } | Disable-LocalUser"
870
+ ],
871
+ "linux": [
872
+ "Set allow-guest=false in the LightDM configuration and remove any 'guest' account with a login shell."
873
+ ],
874
+ "macos": [
875
+ "sudo defaults write /Library/Preferences/com.apple.loginwindow GuestEnabled -bool false, and turn off guest access to shared folders."
876
+ ]
877
  },
878
  "verify": "Re-run the audit; Guest account disabled should be Compliant."
879
  },
880
  "gdpr.remote_access": {
881
  "why": "Remote access services let others reach the device over the network; only approved ones should be on.",
882
+ "risk": {
883
+ "level": "High",
884
+ "reason": "Unapproved remote access tools are a common entry point for attackers and support scams."
885
+ },
886
  "fix": {
887
+ "windows": [
888
+ "Turn off the listed services if not approved: disable Remote Desktop (fDenyTSConnections = 1), stop and disable sshd/WinRM, remove unneeded shares, and uninstall unapproved tools such as TeamViewer, AnyDesk or Chrome Remote Desktop."
889
+ ],
890
+ "linux": [
891
+ "Disable unapproved services, e.g. systemctl disable --now ssh xrdp smbd, and remove unapproved remote access tools."
892
+ ],
893
+ "macos": [
894
+ "Turn off Remote Login, Screen Sharing, File Sharing and Remote Management in System Settings > General > Sharing, and uninstall unapproved tools (e.g. the Chrome Remote Desktop host)."
895
+ ]
896
  },
897
  "verify": "Re-run the audit; only approved services ({APPROVED_REMOTE_SERVICES_TEXT}) should remain.",
898
  "note": "If a listed service is needed, add it to APPROVED_REMOTE_SERVICES in baseline.conf and secure it (multi-factor authentication, restricted to the VPN)."
899
  },
900
  "gdpr.telemetry": {
901
  "why": "Operating systems can send optional usage and diagnostic data, which may contain personal data, to the vendor.",
902
+ "risk": {
903
+ "level": "Low",
904
+ "reason": "More personal data than necessary leaves the organisation."
905
+ },
906
  "fix": {
907
+ "windows": [
908
+ "Set Group Policy: Administrative Templates > Windows Components > Data Collection and Preview Builds > Allow Diagnostic Data = Send required diagnostic data (AllowTelemetry = 1; 0 on Enterprise/Education)."
909
+ ],
910
+ "linux": [
911
+ "Opt out of popularity-contest (dpkg-reconfigure popularity-contest) and disable whoopsie if present (systemctl disable --now whoopsie)."
912
+ ],
913
+ "macos": [
914
+ "Turn off System Settings > Privacy & Security > Analytics & Improvements > Share Mac Analytics and Share with App Developers, or enforce by profile."
915
+ ],
916
+ "ios": [
917
+ "Settings › Privacy & Security › Analytics & Improvements: turn off Share iPhone Analytics."
918
+ ],
919
+ "android": [
920
+ "Settings › Google › All services › Usage & diagnostics: turn off; also turn off the phone maker's diagnostics sharing."
921
+ ]
922
  },
923
  "verify": "Re-run the audit; Diagnostic data minimised should be Compliant."
924
  },
925
  "gdpr.personal_data_discovery": {
926
  "why": "Knowing where personal data is stored on endpoints is needed for the record of processing and for storage limitation.",
927
+ "risk": {
928
+ "level": "Low",
929
+ "reason": "Not assessed in stage 1."
930
+ },
931
+ "fix": {
932
+ "all": [
933
+ "No action needed now: this opt-in scan is planned for a later stage of the tool."
934
+ ]
935
+ },
936
  "verify": "Not applicable in stage 1."
937
  },
 
938
  "aiact.inventory": {
939
  "why": "Deployers must know which AI systems they use before they can classify and govern them.",
940
+ "risk": {
941
+ "level": "Low",
942
+ "reason": "The inventory itself is information; the risk lies in unapproved or unclassified tools."
943
+ },
944
+ "fix": {
945
+ "all": [
946
+ "Add the AI tools found on this device to the organisation's AI register, with owner and purpose."
947
+ ]
948
+ },
949
  "verify": "The AI register lists every tool in the inventory."
950
  },
951
  "aiact.unapproved": {
952
  "why": "AI tools used without approval may send work and personal data to providers without contracts or a transfer basis.",
953
+ "risk": {
954
+ "level": "Medium",
955
+ "reason": "Shadow AI can leak confidential or personal data and creates unassessed AI Act and GDPR obligations."
956
+ },
957
  "fix": {
958
+ "all": [
959
+ "Decide for each listed tool: approve it (add to APPROVED_AI_TOOLS after a data protection and AI Act review, with a data processing agreement where needed) or remove it.",
960
+ "Publish an AI acceptable-use policy so staff know which tools are allowed."
961
+ ],
962
+ "windows": [
963
+ "Remove unapproved apps (Settings > Apps) or block them with AppLocker / Intune app control."
964
+ ],
965
+ "linux": [
966
+ "Remove unapproved tools with the package manager and restrict installation rights."
967
+ ],
968
+ "macos": [
969
+ "Remove unapproved apps and restrict installations through your MDM."
970
+ ]
971
  },
972
  "verify": "Re-run the audit; Unapproved AI tools should read none."
973
  },
974
  "aiact.risk_classification": {
975
  "why": "Each AI system must be checked against the banned practices and the high-risk uses in Annex III.",
976
+ "risk": {
977
+ "level": "Medium",
978
+ "reason": "An unclassified tool could be used for a high-risk or prohibited purpose without the required safeguards."
979
+ },
980
+ "fix": {
981
+ "all": [
982
+ "Record the purpose for which each listed tool is used and classify it (prohibited, high-risk, limited-risk transparency, minimal). The auditor model does this from the inventory and the stated purpose."
983
+ ]
984
+ },
985
  "verify": "Every AI tool in the inventory has a classification in the AI register."
986
  },
987
  "aiact.os_ai_features": {
988
  "why": "Built-in OS AI features can capture screen content or send text to external AI services.",
989
+ "risk": {
990
+ "level": "Medium",
991
+ "reason": "Screen snapshots or prompts can contain personal and confidential data processed without a defined purpose."
992
+ },
993
  "fix": {
994
+ "windows": [
995
+ "Set Group Policy: Administrative Templates > Windows Components > Windows AI > Turn off saving snapshots for use with Recall = Enabled (DisableAIDataAnalysis = 1), unless Recall has been assessed and approved."
996
+ ],
997
+ "macos": [
998
+ "Deploy a Restrictions profile with allowExternalIntelligenceIntegrations = false (and restrict other Apple Intelligence features your policy does not allow)."
999
+ ],
1000
+ "linux": [
1001
+ "No action needed: Linux distributions have no built-in AI assistant."
1002
+ ],
1003
+ "ios": [
1004
+ "Settings › Apple Intelligence & Siri › ChatGPT: turn off unless the organisation has approved it; the employer can restrict it with a configuration profile."
1005
+ ],
1006
+ "android": [
1007
+ "In the Gemini app (or other assistant) settings, turn off screen context / access to app content unless the organisation has approved it."
1008
+ ]
1009
  },
1010
  "verify": "Re-run the audit; the OS AI features check should be Compliant or NotApplicable."
1011
  },
1012
  "aiact.log_retention": {
1013
  "why": "Deployers of high-risk AI systems must keep the system's logs for at least six months.",
1014
+ "risk": {
1015
+ "level": "Medium",
1016
+ "reason": "Missing logs prevent monitoring and investigation of high-risk AI use."
1017
+ },
1018
+ "fix": {
1019
+ "all": [
1020
+ "If any listed tool is classified high-risk, keep its automatically generated logs under your control for at least six months."
1021
+ ]
1022
+ },
1023
  "verify": "Log retention is documented for each high-risk AI system."
1024
  },
 
1025
  "org.ropa": {
1026
  "why": "The record of processing activities is the core GDPR accountability document.",
1027
+ "risk": {
1028
+ "level": "High",
1029
+ "reason": "A missing or outdated record is a direct infringement and makes every other assessment harder."
1030
+ },
1031
+ "fix": {
1032
+ "all": [
1033
+ "Create or update the record of processing activities (purposes, categories of data and people, recipients, transfers, retention, security measures).",
1034
+ "Assign an owner and review it at least yearly."
1035
+ ]
1036
+ },
1037
  "verify": "Answer ORG_ROPA=yes in organisational.conf once the record is current."
1038
  },
1039
  "org.dpia": {
1040
  "why": "High-risk processing needs a data protection impact assessment before it starts.",
1041
+ "risk": {
1042
+ "level": "Medium",
1043
+ "reason": "High-risk processing without a DPIA breaches GDPR and leaves risks to people unassessed."
1044
+ },
1045
+ "fix": {
1046
+ "all": [
1047
+ "List processing that is likely high-risk (e.g. monitoring employees, large-scale sensitive data, new technologies such as AI) and complete a DPIA for each."
1048
+ ]
1049
+ },
1050
  "verify": "Answer ORG_DPIA=yes once DPIAs are done."
1051
  },
1052
  "org.breach_procedure": {
1053
  "why": "Breaches must be reported to the supervisory authority within 72 hours where required.",
1054
+ "risk": {
1055
+ "level": "High",
1056
+ "reason": "Without a tested procedure, the 72-hour deadline is easily missed."
1057
+ },
1058
+ "fix": {
1059
+ "all": [
1060
+ "Write a breach procedure (detection, assessment, 72-hour notification to the CNPD or your authority, informing people, breach log) and test it with an exercise."
1061
+ ]
1062
+ },
1063
  "verify": "Answer ORG_BREACH_PROCEDURE=yes once the procedure is written and tested."
1064
  },
1065
  "org.processor_agreements": {
1066
  "why": "Every processor, including cloud and AI providers, needs a contract with the GDPR Article 28 terms.",
1067
+ "risk": {
1068
+ "level": "High",
1069
+ "reason": "Sharing personal data with a processor without an agreement breaches GDPR."
1070
+ },
1071
+ "fix": {
1072
+ "all": [
1073
+ "List all processors (including AI services used by staff) and sign a data processing agreement with each; check the transfer basis for non-EEA providers."
1074
+ ]
1075
+ },
1076
  "verify": "Answer ORG_PROCESSOR_AGREEMENTS=yes once all agreements are in place."
1077
  },
1078
  "org.ai_register": {
1079
  "why": "A register of AI systems is the basis for AI Act classification and deployer duties.",
1080
+ "risk": {
1081
+ "level": "Medium",
1082
+ "reason": "Without a register, high-risk or prohibited AI use can go unnoticed."
1083
+ },
1084
+ "fix": {
1085
+ "all": [
1086
+ "Create an AI register listing each AI system, owner, purpose, risk class and the data it uses; use the AI software inventory from this audit as input."
1087
+ ]
1088
+ },
1089
  "verify": "Answer ORG_AI_REGISTER=yes once the register exists."
1090
  },
1091
  "org.ai_literacy": {
1092
  "why": "Since 2 February 2025, organisations must ensure staff using AI have sufficient AI literacy.",
1093
+ "risk": {
1094
+ "level": "Medium",
1095
+ "reason": "Untrained staff misuse AI tools and share data they should not."
1096
+ },
1097
+ "fix": {
1098
+ "all": [
1099
+ "Provide AI literacy training matched to how staff use AI (capabilities, limits, data protection, the AI acceptable-use policy) and keep records."
1100
+ ]
1101
+ },
1102
  "verify": "Answer ORG_AI_LITERACY=yes once training is delivered and recorded."
1103
  },
1104
  "org.human_oversight": {
1105
  "why": "High-risk AI systems need trained people who can understand, monitor and override them.",
1106
+ "risk": {
1107
+ "level": "Medium",
1108
+ "reason": "High-risk AI without oversight can harm people without anyone noticing."
1109
+ },
1110
+ "fix": {
1111
+ "all": [
1112
+ "For each high-risk AI system, name a trained person responsible for oversight; answer n/a if no high-risk AI is used."
1113
+ ]
1114
+ },
1115
  "verify": "Answer ORG_HUMAN_OVERSIGHT=yes (or n/a) in organisational.conf."
1116
  },
1117
  "org.ai_transparency": {
1118
  "why": "People must know when they deal with AI or AI-generated content.",
1119
+ "risk": {
1120
+ "level": "Low",
1121
+ "reason": "Undisclosed AI use damages trust and breaches the AI Act transparency rules."
1122
+ },
1123
+ "fix": {
1124
+ "all": [
1125
+ "Set rules for labelling AI chatbots and AI-generated or manipulated content, and include them in the AI acceptable-use policy."
1126
+ ]
1127
+ },
1128
  "verify": "Answer ORG_AI_TRANSPARENCY=yes once the rules are in place."
1129
+ },
1130
+ "mobile.passcode_strength": {
1131
+ "why": "A short numeric code or a pattern can be guessed or seen over the user's shoulder; a longer code protects the data if the phone is lost.",
1132
+ "risk": {
1133
+ "level": "Medium",
1134
+ "reason": "A weak passcode lets someone who has the phone unlock it and read work data."
1135
+ },
1136
+ "fix": {
1137
+ "ios": [
1138
+ "Settings › Face ID & Passcode › Change Passcode › Passcode Options: choose a 6-digit, custom numeric (7+ digits) or alphanumeric code."
1139
+ ],
1140
+ "android": [
1141
+ "Settings › Security & privacy › Device unlock › Screen lock: choose a PIN of at least {MOBILE_PASSCODE_MIN_DIGITS} digits or a password instead of a pattern."
1142
+ ]
1143
+ },
1144
+ "verify": "Re-run the phone check; the passcode answer should be at least {MOBILE_PASSCODE_MIN_DIGITS} digits or alphanumeric."
1145
+ },
1146
+ "mobile.jailbreak_root": {
1147
+ "why": "Jailbreaking or rooting removes the operating system's app isolation and security checks.",
1148
+ "risk": {
1149
+ "level": "High",
1150
+ "reason": "Any app can then read other apps' data, including work email and files."
1151
+ },
1152
+ "fix": {
1153
+ "ios": [
1154
+ "Restore the iPhone with the latest iOS (Settings › General › Transfer or Reset iPhone › Erase All Content and Settings, or restore through a computer), then restore data from a backup."
1155
+ ],
1156
+ "android": [
1157
+ "Remove root and re-lock the bootloader by installing the phone maker's official firmware, or stop using the phone for work."
1158
+ ]
1159
+ },
1160
+ "verify": "Re-run the phone check after restoring; the phone should be reported as not jailbroken / not rooted."
1161
+ },
1162
+ "mobile.app_sources": {
1163
+ "why": "Official app stores review apps for malware; apps from other sources skip those checks.",
1164
+ "risk": {
1165
+ "level": "High",
1166
+ "reason": "Side-loaded apps are a common way malware reaches phones."
1167
+ },
1168
+ "fix": {
1169
+ "ios": [
1170
+ "Remove apps from alternative marketplaces or websites, and remove unknown profiles in Settings › General › VPN & Device Management; install work apps only from the App Store or the employer."
1171
+ ],
1172
+ "android": [
1173
+ "Settings › Apps › Special app access › Install unknown apps: set every app to Not allowed; uninstall apps that did not come from Google Play."
1174
+ ]
1175
+ },
1176
+ "verify": "Re-run the phone check; apps should come only from the official store (or the employer)."
1177
+ },
1178
+ "mobile.usb_debugging": {
1179
+ "why": "USB debugging lets a connected computer install apps and read data without the usual checks.",
1180
+ "risk": {
1181
+ "level": "Medium",
1182
+ "reason": "Anyone who can plug the unlocked phone into a computer can copy data or install software."
1183
+ },
1184
+ "fix": {
1185
+ "android": [
1186
+ "Settings › System › Developer options: turn off USB debugging, or turn off Developer options completely."
1187
+ ]
1188
+ },
1189
+ "verify": "Re-run the phone check; USB debugging should be off."
1190
+ },
1191
+ "mobile.find_my_device": {
1192
+ "why": "Locating, locking and erasing a lost phone limits a personal data breach.",
1193
+ "risk": {
1194
+ "level": "Medium",
1195
+ "reason": "A lost phone cannot be wiped, so its data may have to be reported as a breach (GDPR Art. 33)."
1196
+ },
1197
+ "fix": {
1198
+ "ios": [
1199
+ "Settings › [your name] › Find My › Find My iPhone: turn on, together with Send Last Location."
1200
+ ],
1201
+ "android": [
1202
+ "Settings › Google › All services › Find My Device (Find Hub): turn on, and make sure Location is on."
1203
+ ]
1204
+ },
1205
+ "verify": "Re-run the phone check; Find My should be on."
1206
+ },
1207
+ "gdpr.lock_screen_previews": {
1208
+ "why": "Previews show message and email content, often personal data, to anyone who sees the locked phone.",
1209
+ "risk": {
1210
+ "level": "Low",
1211
+ "reason": "Personal data in notifications can be read without unlocking the phone."
1212
+ },
1213
+ "fix": {
1214
+ "ios": [
1215
+ "Settings › Notifications › Show Previews: choose When Unlocked or Never."
1216
+ ],
1217
+ "android": [
1218
+ "Settings › Notifications › Notifications on lock screen: choose Hide sensitive content or Don't show notifications."
1219
+ ]
1220
+ },
1221
+ "verify": "Re-run the phone check; previews should be hidden when the phone is locked."
1222
  }
1223
  }
1224
  }
mobile/app.js ADDED
@@ -0,0 +1,352 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Devseis Phone Check: browser detection + guided questions -> evidence (mobile-core.js) -> findings written by
2
+ // the auditor model in WebLLM (or the built-in writer) -> report the user can save or share as PDF.
3
+ // Nothing about the phone leaves the browser; only the model and the vulnerability data are downloaded.
4
+ import { Library, findingPrompt, findingTemplate, summaryPrompt, summaryTemplate, aiPrompt, aiTemplate, writeAnswer } from '../app/renderer/auditor-core.js';
5
+ import { buildReportHtml } from '../app/renderer/report.js';
6
+ import { loadModelWith } from '../app/renderer/model-core.js';
7
+ import { buildMobileEvidence, questionsFor } from './mobile-core.js';
8
+ import { detectDevice } from './detect.js';
9
+ import { loadPhoneVulnData } from './vuln-web.js';
10
+
11
+ const WEBLLM_URL = 'https://esm.run/@mlc-ai/web-llm@0.2.85';
12
+ const HTML2PDF_URL = 'https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js';
13
+ const MODEL_SIZE = 'about 350 MB, downloaded once';
14
+
15
+ const $ = id => document.getElementById(id);
16
+ const show = id => { document.querySelectorAll('.screen').forEach(s => s.classList.add('hidden')); $(`s-${id}`).classList.remove('hidden'); window.scrollTo(0, 0); };
17
+ const setBar = (id, fraction) => { $(id).style.width = `${Math.round(Math.max(0, Math.min(1, fraction)) * 100)}%`; };
18
+ const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
19
+
20
+ const state = { device: null, data: null, lib: null, steps: [], index: 0, answers: {}, aiApps: [], version: null, versionEdited: false,
21
+ model: null, modelPromise: null, modelStatus: '', vulnPromise: null, report: null };
22
+
23
+ function readConf(text) {
24
+ const conf = {};
25
+ for (const line of text.split('\n')) {
26
+ if (!line.includes('=') || line.trimStart().startsWith('#')) continue;
27
+ const i = line.indexOf('=');
28
+ conf[line.slice(0, i).trim()] = line.slice(i + 1).trim().replace(/^"|"$/g, '');
29
+ }
30
+ return conf;
31
+ }
32
+
33
+ // Organisation answers can be passed by the employer in the link: #org=<base64 JSON {ORG_ROPA: "yes", ...}>
34
+ function orgFromLink() {
35
+ const m = /[#&]org=([^&]+)/.exec(location.hash);
36
+ if (!m) return null;
37
+ try { return JSON.parse(atob(decodeURIComponent(m[1]).replace(/-/g, '+').replace(/_/g, '/'))); } catch { return null; }
38
+ }
39
+
40
+ async function loadData() {
41
+ const get = async (path, type = 'json') => { const r = await fetch(path); if (!r.ok) throw new Error(`${path}: HTTP ${r.status}`); return type === 'json' ? r.json() : r.text(); };
42
+ const [catalog, questions, controls, guidance, aiTools, baselineText] = await Promise.all([
43
+ get('catalog/checks.json'), get('catalog/mobile_questions.json'), get('library/controls.json'), get('library/check_guidance.json'),
44
+ get('library/ai_tools.json'), get('baseline.conf', 'text')]);
45
+ return { catalog, questions, baseline: readConf(baselineText), library: { catalog, controls, guidance, aiTools } };
46
+ }
47
+
48
+ // --- 1. Start ---------------------------------------------------------------------------------
49
+ function renderDevice() {
50
+ const d = state.device;
51
+ const rows = [['Device', d.device_model || (d.os_family ? 'Phone' : 'Computer')],
52
+ ['System', d.os_name ? `${d.os_name} ${d.os_version || '(version not visible)'}` : 'Not a phone'],
53
+ ['Browser', d.browser],
54
+ ['Screen lock', d.platform_authenticator === true ? 'Unlock method detected' : d.platform_authenticator === false ? 'Not detected (will ask)' : 'Unknown (will ask)'],
55
+ ['AI on this device', d.webgpu ? 'Supported (WebGPU)' : 'Not supported; the built-in writer is used']];
56
+ $('device-card').innerHTML = rows.map(([k, v]) => `<b>${esc(k)}</b><span>${esc(v)}</span>`).join('');
57
+ $('desktop-note').classList.toggle('hidden', !d.desktop);
58
+ $('start').disabled = !d.os_family;
59
+ }
60
+
61
+ function demo(osFamily) {
62
+ Object.assign(state.device, osFamily === 'ios'
63
+ ? { os_family: 'ios', os_name: 'iOS', os_version: null, version_precise: false, device_model: 'iPhone (demo on a computer)', platform_authenticator: null }
64
+ : { os_family: 'android', os_name: 'Android', os_version: null, version_precise: false, device_model: 'Android phone (demo on a computer)', platform_authenticator: null });
65
+ renderDevice();
66
+ }
67
+
68
+ // --- 2. Approval --------------------------------------------------------------------------------
69
+ function showConsent() {
70
+ const { catalog } = state.data;
71
+ const os = state.device.os_family;
72
+ const checks = catalog.checks.filter(c => c.os.includes(os));
73
+ $('check-count').textContent = checks.length;
74
+ $('plan').innerHTML = checks.map(c => `<li>${esc((c.names && c.names[os]) || c.name)}</li>`).join('');
75
+ const ai = $('use-ai');
76
+ ai.disabled = !state.device.webgpu;
77
+ ai.checked = state.device.webgpu && !state.device.cellular;
78
+ $('ai-note').textContent = !state.device.webgpu
79
+ ? 'This browser has no WebGPU, so the built-in report writer is used.'
80
+ : `Runs on this phone (${MODEL_SIZE}; Wi-Fi recommended). Off = built-in report writer.`;
81
+ show('consent');
82
+ }
83
+
84
+ function startBackgroundWork() {
85
+ state.vulnPromise = loadPhoneVulnData();
86
+ if ($('use-ai').checked) {
87
+ const chip = $('model-chip');
88
+ chip.classList.remove('hidden');
89
+ chip.textContent = 'AI auditor: starting download';
90
+ state.modelPromise = import(WEBLLM_URL)
91
+ .then(webllm => loadModelWith(webllm, p => { chip.textContent = `AI auditor: ${Math.round((p.progress || 0) * 100)}%`; }))
92
+ .then(model => { state.model = model; chip.textContent = 'AI auditor ready'; return model; })
93
+ .catch(error => { chip.textContent = 'AI unavailable: built-in writer'; state.modelStatus = error.message; return null; });
94
+ } else {
95
+ state.modelPromise = Promise.resolve(null);
96
+ }
97
+ }
98
+
99
+ // --- 3. Questions -------------------------------------------------------------------------------
100
+ function buildSteps() {
101
+ const { catalog, questions } = state.data;
102
+ const d = state.device;
103
+ const steps = [{ kind: 'version', ...questions.os_version[d.os_family] }];
104
+ for (const q of questionsFor(d.os_family, catalog, questions, { platform_authenticator: d.platform_authenticator })) steps.push({ kind: 'check', ...q });
105
+ steps.push({ kind: 'ai', name: 'AI software inventory', ...questions.ai_apps });
106
+ return steps;
107
+ }
108
+
109
+ function renderStep() {
110
+ const step = state.steps[state.index];
111
+ const total = state.steps.length;
112
+ setBar('q-bar', state.index / total);
113
+ $('q-count').textContent = `Question ${state.index + 1} of ${total}`;
114
+ $('q-check').textContent = step.kind === 'version' ? 'System version' : step.name;
115
+ $('q-text').textContent = step.question;
116
+ $('q-where').textContent = step.where;
117
+ const box = $('q-answers');
118
+ box.innerHTML = '';
119
+ const next = () => { state.index += 1; state.index < total ? renderStep() : finish(); };
120
+
121
+ if (step.kind === 'version') {
122
+ const input = document.createElement('input');
123
+ input.type = 'text'; input.inputMode = 'decimal'; input.placeholder = state.device.os_family === 'ios' ? 'for example 27.0.1' : 'for example 15';
124
+ input.value = state.version || state.device.os_version || '';
125
+ const hint = document.createElement('p');
126
+ hint.className = 'muted';
127
+ hint.textContent = state.device.os_version ? `Detected by the browser: ${state.device.os_version}. Correct it if Settings shows more detail.` : 'The browser could not see the version; please type it.';
128
+ const ok = button('Continue', 'primary', () => {
129
+ const value = input.value.trim();
130
+ if (!/^\d+(\.\d+){0,2}$/.test(value)) { input.focus(); return; }
131
+ state.version = value;
132
+ state.versionEdited = value !== state.device.os_version;
133
+ next();
134
+ });
135
+ box.append(hint, input, ok);
136
+ return;
137
+ }
138
+ if (step.kind === 'ai') {
139
+ for (const app of step.options) {
140
+ const label = document.createElement('label');
141
+ label.className = 'multi';
142
+ label.innerHTML = `<input type="checkbox" value="${esc(app)}" ${state.aiApps.includes(app) ? 'checked' : ''}><span>${esc(app)}</span>`;
143
+ box.append(label);
144
+ }
145
+ box.append(button('Continue', 'primary', () => {
146
+ state.aiApps = [...box.querySelectorAll('input:checked')].map(i => i.value);
147
+ next();
148
+ }));
149
+ return;
150
+ }
151
+ if (step.input === 'month') {
152
+ const input = document.createElement('input');
153
+ input.type = 'month';
154
+ input.value = typeof state.answers[step.id] === 'string' && state.answers[step.id] !== 'not_sure' ? state.answers[step.id] : '';
155
+ box.append(input, button('Continue', 'primary', () => { if (!/^\d{4}-\d{2}$/.test(input.value)) { input.focus(); return; } state.answers[step.id] = input.value; next(); }));
156
+ } else {
157
+ step.answers.forEach((answer, i) => box.append(button(answer.label, 'secondary', () => { state.answers[step.id] = i; next(); })));
158
+ }
159
+ box.append(button(state.data.questions.not_sure.label, 'secondary not-sure', () => { state.answers[step.id] = 'not_sure'; next(); }));
160
+ }
161
+
162
+ function button(text, className, onClick) {
163
+ const b = document.createElement('button');
164
+ b.className = className; b.textContent = text; b.type = 'button';
165
+ b.addEventListener('click', onClick);
166
+ return b;
167
+ }
168
+
169
+ // --- 4. Evidence and report -----------------------------------------------------------------------
170
+ async function finish() {
171
+ show('work');
172
+ const d = state.device;
173
+ $('w-step').textContent = 'Checking the vulnerability data';
174
+ setBar('w-bar', 0.02);
175
+ const vuln = await state.vulnPromise;
176
+ const now = new Date();
177
+ const offset = -now.getTimezoneOffset();
178
+ const pad = n => String(Math.abs(n)).padStart(2, '0');
179
+ const started = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}${offset >= 0 ? '+' : '-'}${pad(Math.floor(Math.abs(offset) / 60))}:${pad(Math.abs(offset) % 60)}`;
180
+ const input = {
181
+ os_family: d.os_family, os_name: d.os_name, os_version: state.version, version_source: state.versionEdited || !d.os_version ? 'user' : 'browser',
182
+ browser: d.browser, device_model: d.device_model || (d.os_family === 'ios' ? 'iPhone' : 'Android phone'), hostname: d.device_model || d.os_name,
183
+ started_at: started, platform_authenticator: d.platform_authenticator, answers: state.answers, ai_apps: state.aiApps, org: orgFromLink(),
184
+ };
185
+ const evidence = buildMobileEvidence(input, { catalog: state.data.catalog, questions: state.data.questions, baseline: state.data.baseline, vuln, now: now.getTime() });
186
+ const evidenceText = JSON.stringify(evidence, null, 2);
187
+ const sha256 = [...new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(evidenceText)))].map(b => b.toString(16).padStart(2, '0')).join('');
188
+
189
+ if (state.modelPromise && !state.model) {
190
+ $('w-title').textContent = 'Getting the AI auditor ready';
191
+ $('w-step').textContent = 'Finishing the model download (only the first time)…';
192
+ await state.modelPromise;
193
+ }
194
+ const model = state.model;
195
+ const lib = state.lib;
196
+ const targets = evidence.results.filter(r => ['Non-Compliant', 'Error', 'Pending'].includes(r.status) && r.section !== 'org');
197
+ const ai = aiPrompt(evidence, lib);
198
+ const total = targets.length + 1 + (ai ? 1 : 0);
199
+ let done = 0;
200
+ const sources = { model: 0, template: 0 };
201
+ const generate = model ? model.generate : null;
202
+ const tick = label => { done += 1; setBar('w-bar', done / total); $('w-current').textContent = label; };
203
+ $('w-title').textContent = 'Writing your report';
204
+ $('w-step').textContent = model ? `The AI auditor is writing ${total} parts on this phone` : 'The built-in writer is writing the findings';
205
+
206
+ const findings = {};
207
+ for (const r of evidence.results.filter(x => x.section === 'org' && x.status !== 'Compliant')) {
208
+ findings[r.id] = findingTemplate(evidence, r, lib); // organisational answers: short, fixed wording
209
+ }
210
+ for (const r of targets) {
211
+ $('w-current').textContent = `Writing: ${r.name}`;
212
+ const out = await writeAnswer('finding', findingPrompt(evidence, r, lib), findingTemplate(evidence, r, lib), generate);
213
+ findings[r.id] = out.answer;
214
+ sources[out.source] += 1;
215
+ tick(`Written: ${r.name}`);
216
+ }
217
+ const summaryBase = summaryTemplate(evidence, lib);
218
+ const summaryOut = await writeAnswer('summary', summaryPrompt(evidence, lib), summaryBase, generate);
219
+ const summary = summaryOut.source === 'model' ? { ...summaryBase, summary: summaryOut.answer.summary, next_steps: summaryOut.answer.next_steps } : summaryBase;
220
+ sources[summaryOut.source] += 1;
221
+ tick('Written: summary');
222
+ let aiResult = null;
223
+ if (ai) {
224
+ const out = await writeAnswer('ai_classification', ai, aiTemplate(evidence, lib), generate);
225
+ aiResult = out.answer;
226
+ sources[out.source] += 1;
227
+ tick('Written: AI app classification');
228
+ }
229
+ const html = buildReportHtml({ evidence, findings, summary, ai: aiResult, sha256, model: model ? model.name : null, sources });
230
+ state.report = { html, evidence, evidenceText, summary, sources };
231
+ showResult();
232
+ }
233
+
234
+ // --- 5. Result, PDF and sharing ----------------------------------------------------------------
235
+ function showResult() {
236
+ const { evidence, summary, sources } = state.report;
237
+ const pills = Object.entries(evidence.summary).filter(([, v]) => v).map(([k, v]) => `<span class="pill s-${k}">${v} ${k}</span>`).join('');
238
+ const gaps = summary.top_gaps.map(g => `<li><b class="s-Non-Compliant">${esc(g.risk)}</b> — ${esc(g.name)}: ${esc(g.found)}</li>`).join('');
239
+ const reported = evidence.results.filter(r => r.evidence === 'self-reported').length;
240
+ const detected = evidence.results.filter(r => r.evidence === 'detected').length;
241
+ $('result').innerHTML = `
242
+ <h1>Your phone check</h1>
243
+ <div class="card"><div class="score">${summary.score.percent}%</div>
244
+ <p class="muted">${summary.score.compliant} of ${summary.score.assessed} assessed checks compliant · ${detected} detected, ${reported} self-reported</p>
245
+ <div class="pills">${pills}</div>
246
+ <p>${esc(summary.summary)}</p>
247
+ ${gaps ? `<b>Top gaps</b><ul class="gaps">${gaps}</ul>` : ''}
248
+ <p class="muted">Written ${sources.model ? `by the AI auditor (${sources.model}) and ` : ''}by the built-in writer (${sources.template}).</p>
249
+ </div>`;
250
+ $('pdf-note').textContent = 'The report stays on this phone until you save or share it.';
251
+ show('done');
252
+ }
253
+
254
+ function loadScript(src) {
255
+ return new Promise((resolve, reject) => {
256
+ if ([...document.scripts].some(s => s.src === src)) return resolve();
257
+ const s = document.createElement('script');
258
+ s.src = src; s.onload = resolve; s.onerror = () => reject(new Error('could not load the PDF tool'));
259
+ document.head.append(s);
260
+ });
261
+ }
262
+
263
+ async function makePdf() {
264
+ await loadScript(HTML2PDF_URL);
265
+ const doc = new DOMParser().parseFromString(state.report.html, 'text/html');
266
+ const holder = document.createElement('div');
267
+ holder.style.cssText = 'position:fixed;left:-10000px;top:0;width:794px;background:#fff;color:#16181d';
268
+ const style = document.createElement('style');
269
+ style.textContent = doc.querySelector('style').textContent.replace(/(^|\n)\s*body\s*\{/g, '$1.devseis-report {');
270
+ const body = document.createElement('div');
271
+ body.className = 'devseis-report';
272
+ body.innerHTML = doc.body.innerHTML;
273
+ holder.append(style, body);
274
+ document.body.append(holder);
275
+ try {
276
+ return await window.html2pdf().set({ margin: 10, image: { type: 'jpeg', quality: 0.92 }, html2canvas: { scale: 1.5, useCORS: true },
277
+ jsPDF: { unit: 'mm', format: 'a4' }, pagebreak: { mode: ['css', 'legacy'] } }).from(body).outputPdf('blob');
278
+ } finally {
279
+ holder.remove();
280
+ }
281
+ }
282
+
283
+ function fileName(ext) {
284
+ const e = state.report.evidence;
285
+ return `devseis-phone-check-${e.host.os_family}-${e.audit.started_at.slice(0, 10)}.${ext}`;
286
+ }
287
+
288
+ function download(blob, name) {
289
+ const a = document.createElement('a');
290
+ a.href = URL.createObjectURL(blob); a.download = name;
291
+ document.body.append(a); a.click(); a.remove();
292
+ setTimeout(() => URL.revokeObjectURL(a.href), 30000);
293
+ }
294
+
295
+ async function sharePdf() {
296
+ $('pdf-note').textContent = 'Preparing the PDF…';
297
+ try {
298
+ const blob = await makePdf();
299
+ const file = new File([blob], fileName('pdf'), { type: 'application/pdf' });
300
+ if (navigator.canShare && navigator.canShare({ files: [file] })) {
301
+ try {
302
+ await navigator.share({ files: [file], title: 'Devseis phone check' });
303
+ $('pdf-note').textContent = 'Shared. Choose "Save to Files" in the share sheet to keep a copy on the phone.';
304
+ return;
305
+ } catch (error) {
306
+ if (error.name === 'AbortError') { $('pdf-note').textContent = 'Sharing cancelled.'; return; }
307
+ }
308
+ }
309
+ download(blob, file.name);
310
+ $('pdf-note').textContent = `Saved as ${file.name} (Downloads).`;
311
+ } catch (error) {
312
+ $('pdf-note').textContent = `The PDF could not be made (${error.message}). Use "View full report" and your browser's Share › Print to save it as PDF.`;
313
+ }
314
+ }
315
+
316
+ function viewReport() {
317
+ const url = URL.createObjectURL(new Blob([state.report.html], { type: 'text/html' }));
318
+ window.open(url, '_blank');
319
+ setTimeout(() => URL.revokeObjectURL(url), 60000);
320
+ }
321
+
322
+ // --- Wiring ----------------------------------------------------------------------------------------
323
+ async function init() {
324
+ try {
325
+ [state.device, state.data] = await Promise.all([detectDevice(), loadData()]);
326
+ } catch (error) {
327
+ $('device-card').innerHTML = `<span class="s-Error">The check could not start: ${esc(error.message)}</span>`;
328
+ return;
329
+ }
330
+ state.lib = new Library(state.data.library);
331
+ renderDevice();
332
+ }
333
+
334
+ $('start').addEventListener('click', showConsent);
335
+ document.querySelectorAll('[data-demo]').forEach(b => b.addEventListener('click', () => demo(b.dataset.demo)));
336
+ $('consent-back').addEventListener('click', () => show('start'));
337
+ $('consent-ok').addEventListener('click', () => {
338
+ startBackgroundWork();
339
+ state.steps = buildSteps();
340
+ state.index = 0;
341
+ show('question');
342
+ renderStep();
343
+ });
344
+ $('q-back').addEventListener('click', () => { if (state.index > 0) { state.index -= 1; renderStep(); } else show('consent'); });
345
+ $('share-pdf').addEventListener('click', sharePdf);
346
+ $('view-report').addEventListener('click', viewReport);
347
+ $('save-json').addEventListener('click', () => download(new Blob([state.report.evidenceText], { type: 'application/json' }), fileName('json')));
348
+ $('again').addEventListener('click', () => { state.answers = {}; state.aiApps = []; state.version = null; show('start'); });
349
+ init();
350
+
351
+ // Automated tests only (?test in the URL): expose state and the PDF maker.
352
+ if (new URLSearchParams(location.search).has('test')) Object.assign(window, { __devseisState: state, __devseisMakePdf: makePdf });
mobile/build-evidence.mjs ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Build phone evidence records from answer sets (JSON lines on stdin -> evidence JSON lines on stdout),
2
+ // using exactly the code the mobile web page uses. Called by synthetic/generate_evidence.py.
3
+ // Usage: node mobile/build-evidence.mjs [--vulndb DIR] [--now ISO-DATE] < answers.jsonl > evidence.jsonl
4
+ import { readFileSync } from 'node:fs';
5
+ import { gunzipSync } from 'node:zlib';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { buildMobileEvidence } from './mobile-core.js';
9
+
10
+ const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
11
+ const arg = name => { const i = process.argv.indexOf(name); return i > 0 ? process.argv[i + 1] : null; };
12
+ const readConf = file => Object.fromEntries(readFileSync(file, 'utf8').split('\n')
13
+ .filter(l => l.includes('=') && !l.trimStart().startsWith('#'))
14
+ .map(l => { const i = l.indexOf('='); return [l.slice(0, i).trim(), l.slice(i + 1).trim().replace(/^"|"$/g, '')]; }));
15
+
16
+ const catalog = JSON.parse(readFileSync(path.join(root, 'catalog', 'checks.json'), 'utf8'));
17
+ const questions = JSON.parse(readFileSync(path.join(root, 'catalog', 'mobile_questions.json'), 'utf8'));
18
+ const baseline = readConf(arg('--baseline') || path.join(root, 'baseline.conf'));
19
+ let vuln = { error: 'no vulnerability data found' };
20
+ try {
21
+ const dir = arg('--vulndb') || path.join(root, 'vulndb', 'out');
22
+ const manifest = JSON.parse(readFileSync(path.join(dir, 'manifest.json'), 'utf8'));
23
+ vuln = { manifest, bundle: JSON.parse(gunzipSync(readFileSync(path.join(dir, manifest.mobile.file))).toString('utf8')) };
24
+ } catch (error) { vuln = { error: error.message }; }
25
+ const now = arg('--now') ? Date.parse(arg('--now')) : Date.now();
26
+
27
+ const lines = readFileSync(0, 'utf8').split('\n').filter(Boolean);
28
+ for (const line of lines) {
29
+ const input = JSON.parse(line);
30
+ const record = buildMobileEvidence(input, { catalog, questions, baseline, vuln, now: input.now ? Date.parse(input.now) : now });
31
+ if (input.synthetic) Object.assign(record.source, { kind: 'synthetic', ...input.synthetic });
32
+ process.stdout.write(JSON.stringify(record) + '\n');
33
+ }
mobile/detect.js ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // What a web page can learn about the phone it runs on. Browsers deliberately hide security settings,
2
+ // so this is limited to the OS and version, device model (Android), browser, whether a device unlock
3
+ // method is set up, and WebGPU support. Everything else is asked.
4
+
5
+ function iosVersion(ua) {
6
+ // Safari 26+ freezes the OS version in the user agent (it reports 18_6), but its own Version/ follows iOS.
7
+ const safari = /Version\/(\d+(?:\.\d+){0,2}).*Safari/.exec(ua);
8
+ const os = /OS (\d+)_(\d+)(?:_(\d+))?/.exec(ua);
9
+ const osVersion = os ? [os[1], os[2], os[3]].filter(Boolean).join('.') : null;
10
+ if (safari && !/CriOS|FxiOS|EdgiOS/.test(ua) && parseInt(safari[1], 10) >= 26) return { version: safari[1], precise: false };
11
+ if (osVersion && osVersion !== '18.6') return { version: osVersion, precise: Boolean(os[3]) };
12
+ if (safari) return { version: safari[1], precise: false };
13
+ return { version: osVersion, precise: false };
14
+ }
15
+
16
+ function browserName(ua, brands) {
17
+ let m;
18
+ if ((m = /SamsungBrowser\/(\d+(?:\.\d+)?)/.exec(ua))) return `Samsung Internet ${m[1]}`;
19
+ if ((m = /EdgA\/(\d+)|EdgiOS\/(\d+)/.exec(ua))) return `Edge ${m[1] || m[2]}`;
20
+ if ((m = /Firefox\/(\d+)|FxiOS\/(\d+)/.exec(ua))) return `Firefox ${m[1] || m[2]}`;
21
+ if ((m = /CriOS\/(\d+)/.exec(ua))) return `Chrome ${m[1]} (iOS)`;
22
+ const chrome = (brands || []).find(b => b.brand === 'Google Chrome');
23
+ if (chrome) return `Chrome ${chrome.version}`;
24
+ if ((m = /Chrome\/(\d+)/.exec(ua))) return `Chrome ${m[1]}`;
25
+ if ((m = /Version\/(\d+(?:\.\d+)?).*Safari/.exec(ua))) return `Safari ${m[1]}`;
26
+ return 'Unknown browser';
27
+ }
28
+
29
+ async function withTimeout(promise, ms, fallback) {
30
+ return Promise.race([promise, new Promise(resolve => setTimeout(() => resolve(fallback), ms))]);
31
+ }
32
+
33
+ export async function detectDevice() {
34
+ const ua = navigator.userAgent;
35
+ const uaData = navigator.userAgentData;
36
+ const out = { os_family: null, os_name: null, os_version: null, version_precise: false, device_model: '', browser: browserName(ua, uaData && uaData.brands),
37
+ platform_authenticator: null, webgpu: 'gpu' in navigator, cellular: Boolean(navigator.connection && navigator.connection.type === 'cellular'), desktop: false };
38
+
39
+ const ipadDesktopMode = /Macintosh/.test(ua) && navigator.maxTouchPoints > 1;
40
+ if (/iPhone|iPod/.test(ua) || /iPad/.test(ua) || ipadDesktopMode) {
41
+ const ipad = /iPad/.test(ua) || ipadDesktopMode;
42
+ const v = iosVersion(ua);
43
+ Object.assign(out, { os_family: 'ios', os_name: ipad ? 'iPadOS' : 'iOS', os_version: v.version, version_precise: v.precise, device_model: ipad ? 'iPad' : 'iPhone' });
44
+ } else if (/Android/.test(ua)) {
45
+ out.os_family = 'android';
46
+ out.os_name = 'Android';
47
+ if (uaData && uaData.getHighEntropyValues) {
48
+ try {
49
+ const hi = await withTimeout(uaData.getHighEntropyValues(['platform', 'platformVersion', 'model']), 1500, {});
50
+ // Only trust the hints when they describe Android (desktop browsers emulating a phone report the host OS).
51
+ if (hi.platform === 'Android') {
52
+ if (hi.platformVersion) out.os_version = String(parseInt(hi.platformVersion, 10));
53
+ if (hi.model) out.device_model = hi.model;
54
+ }
55
+ } catch { /* not allowed */ }
56
+ }
57
+ if (!out.os_version) {
58
+ const m = /Android (\d+)/.exec(ua);
59
+ // Chrome's reduced user agent always says "Android 10; K": not a real version.
60
+ if (m && !/Android 10; K\)/.test(ua)) out.os_version = m[1];
61
+ }
62
+ if (!out.device_model) {
63
+ const m = /Android [\d.]+; ([^;)]+)[;)]/.exec(ua);
64
+ if (m && m[1] !== 'K') out.device_model = m[1].trim();
65
+ }
66
+ } else {
67
+ out.desktop = true;
68
+ }
69
+
70
+ if (window.PublicKeyCredential && PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable) {
71
+ try {
72
+ out.platform_authenticator = await withTimeout(PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(), 2000, null);
73
+ } catch { out.platform_authenticator = null; }
74
+ }
75
+ return out;
76
+ }
mobile/mobile-core.js ADDED
@@ -0,0 +1,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Phone self-check: turns what the browser detected and what the user answered into evidence in the
2
+ // same format as the desktop collectors (schema/evidence.schema.json, source.kind = "web").
3
+ // Used by the mobile web page and, through mobile/build-evidence.mjs, by the synthetic generator, so the
4
+ // training data uses exactly the wording the page produces. Pure functions only (no DOM, no network).
5
+ import { matchApps, knownVulnResult } from '../app/renderer/vuln-core.js';
6
+
7
+ export const MOBILE_VERSION = '0.3.0';
8
+ const MOBILE = new Set(['ios', 'android']);
9
+ const ORG_PENDING = 'Answered once by the organisation (organisational.conf); not part of the phone self-check.';
10
+
11
+ const major = version => parseInt(String(version).split('.')[0], 10) || 0;
12
+ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
13
+
14
+ // Text and status for an answer whose result depends on the baseline.
15
+ function computed(spec, answer, B, os) {
16
+ if (spec.expected_from === 'seconds') {
17
+ const limit = parseInt(B.SCREEN_LOCK_MAX_SECONDS, 10);
18
+ const label = os === 'ios' ? 'Auto-Lock' : 'Screen timeout';
19
+ const ok = answer.seconds != null && answer.seconds > 0 && answer.seconds <= limit;
20
+ return { status: ok ? 'Compliant' : 'Non-Compliant', actual: `${label} ${answer.label}`, expected: `<= ${limit} seconds`,
21
+ detail: ok ? '' : answer.seconds == null ? 'The phone never locks by itself.' : `The phone stays unlocked for ${answer.label} when not in use.` };
22
+ }
23
+ if (spec.expected_from === 'passcode') {
24
+ const min = parseInt(B.MOBILE_PASSCODE_MIN_DIGITS, 10);
25
+ const expected = `>= ${min} digits or alphanumeric`;
26
+ if (answer.alphanumeric) return { status: 'Compliant', actual: 'alphanumeric', expected, detail: '' };
27
+ if (answer.pattern) return { status: 'Non-Compliant', actual: 'pattern', expected, detail: 'Pattern locks are easy to guess from screen smudges or by watching.' };
28
+ if (answer.none) return { status: 'Non-Compliant', actual: 'swipe or none', expected, detail: 'There is no secret needed to unlock the phone.' };
29
+ const ok = answer.digits >= min;
30
+ const shown = answer.digits >= 7 ? '7+ digit numeric code' : `${answer.digits}-digit ${os === 'android' ? 'PIN' : 'code'}`;
31
+ return { status: ok ? 'Compliant' : 'Non-Compliant', actual: shown, expected, detail: ok ? '' : `A ${answer.digits}-digit code has only ${10 ** answer.digits} combinations.` };
32
+ }
33
+ throw new Error(`unknown computed answer type ${spec.expected_from}`);
34
+ }
35
+
36
+ // Android security patch level ("YYYY-MM") against MOBILE_PATCH_MAX_AGE_DAYS.
37
+ function patchResult(value, B, now) {
38
+ const maxDays = parseInt(B.MOBILE_PATCH_MAX_AGE_DAYS, 10);
39
+ const expected = `<= ${maxDays} days old`;
40
+ const m = /^(\d{4})-(\d{2})/.exec(value || '');
41
+ if (!m) return null;
42
+ const days = Math.floor((now - Date.UTC(+m[1], +m[2] - 1, 1)) / 86400000);
43
+ const months = Math.max(0, Math.round(days / 30.4));
44
+ const ok = days <= maxDays;
45
+ return { status: ok ? 'Compliant' : 'Non-Compliant', actual: `security patch ${m[1]}-${m[2]} (${plural(months, 'month')} old)`, expected,
46
+ detail: ok ? '' : `Security fixes published since ${m[1]}-${m[2]} are missing; Google publishes Android fixes every month.` };
47
+ }
48
+
49
+ // Newest fixed iOS version for the same major line, from the NVD ranges in the bundle.
50
+ function latestIosFix(bundle, product, version) {
51
+ const app = bundle.apps.find(a => a.id === product);
52
+ if (!app) return null;
53
+ let best = null;
54
+ for (const row of app.cves) {
55
+ const fixed = row[5];
56
+ if (fixed && major(fixed) === major(version) && (!best || cmp(fixed, best) > 0)) best = fixed;
57
+ }
58
+ return best;
59
+ }
60
+ // Apple fixes some vulnerabilities only in the newest major version.
61
+ function newerMajor(bundle, product, version) {
62
+ const app = bundle.apps.find(a => a.id === product);
63
+ let top = null;
64
+ for (const row of app ? app.cves : []) if (row[5] && major(row[5]) > major(version) && (!top || cmp(row[5], top) > 0)) top = row[5];
65
+ return top ? ` ${major(top)}.x (newest: ${top}) fixes vulnerabilities that are not fixed in ${major(version)}.x.` : '';
66
+ }
67
+ function cmp(a, b) {
68
+ const x = String(a).split('.').map(Number), y = String(b).split('.').map(Number);
69
+ for (let i = 0; i < Math.max(x.length, y.length); i++) { const d = (x[i] || 0) - (y[i] || 0); if (d) return d; }
70
+ return 0;
71
+ }
72
+
73
+ function osSupported(input, B) {
74
+ const ios = input.os_family === 'ios';
75
+ const min = parseInt(ios ? B.IOS_MIN_MAJOR : B.ANDROID_MIN_MAJOR, 10);
76
+ const ok = major(input.os_version) >= min;
77
+ return { status: ok ? 'Compliant' : 'Non-Compliant', actual: `${input.os_name} ${input.os_version}`, expected: `>= ${ios ? 'iOS' : 'Android'} ${min}`,
78
+ detail: (input.version_source === 'user' ? `Version entered by the user from ${ios ? 'Settings › General › About' : 'Settings › About phone'}.` : `Version reported by the browser (${input.browser}).`)
79
+ + (ok ? '' : ' This version no longer receives all security updates.') };
80
+ }
81
+
82
+ function aiResults(id, apps, B) {
83
+ const approved = (B.APPROVED_AI_TOOLS || '').split(',').map(a => a.trim().toLowerCase()).filter(Boolean);
84
+ if (id === 'aiact.inventory') return { status: 'Compliant', actual: apps.join(', ') || 'no AI tools found', expected: 'inventory taken', detail: 'AI apps listed by the user.' };
85
+ if (id === 'aiact.unapproved') {
86
+ const bad = apps.filter(a => !approved.includes(a.toLowerCase()));
87
+ return { status: bad.length ? 'Non-Compliant' : 'Compliant', actual: bad.join(', ') || 'none', expected: `only: ${B.APPROVED_AI_TOOLS || 'none'}`, detail: '' };
88
+ }
89
+ if (id === 'aiact.risk_classification') {
90
+ return apps.length ? { status: 'Pending', actual: apps.join(', '), expected: 'every AI tool classified', detail: 'Classified by the auditor model (Caveat classifier) in a later step.' }
91
+ : { status: 'NotApplicable', actual: 'no AI tools found', expected: 'every AI tool classified', detail: '' };
92
+ }
93
+ return apps.length ? { status: 'Pending', actual: 'depends on classification', expected: '>= 6 months for high-risk AI', detail: 'Required only if a tool is classified high-risk.' }
94
+ : { status: 'NotApplicable', actual: 'no AI tools found', expected: '>= 6 months for high-risk AI', detail: '' };
95
+ }
96
+
97
+ function orgResult(check, answer) {
98
+ if (answer === 'yes') return { status: 'Compliant', actual: 'yes', expected: 'yes', detail: '' };
99
+ if (answer === 'no' || answer === 'partial') return { status: 'Non-Compliant', actual: answer, expected: 'yes', detail: '' };
100
+ if (answer === 'n/a') return { status: 'NotApplicable', actual: 'n/a', expected: 'yes', detail: '' };
101
+ return { status: 'Pending', actual: 'not answered', expected: 'yes', detail: answer === undefined ? ORG_PENDING : `Answer ${check.answer_key} in organisational.conf.` };
102
+ }
103
+
104
+ // The questions the page shows for this OS, in catalog order (detected checks are skipped).
105
+ export function questionsFor(osFamily, catalog, questions, detected = {}) {
106
+ const list = [];
107
+ for (const check of catalog.checks) {
108
+ if (!check.os.includes(osFamily)) continue;
109
+ const spec = (questions.checks[check.id] || {})[osFamily];
110
+ if (!spec) continue;
111
+ if (check.id === 'screen_lock.enabled' && detected.platform_authenticator === true) continue;
112
+ list.push({ id: check.id, name: (check.names && check.names[osFamily]) || check.name, ...spec });
113
+ }
114
+ return list;
115
+ }
116
+
117
+ /**
118
+ * input: { os_family, os_name, os_version, version_source: 'browser'|'user', browser, device_model, hostname, started_at,
119
+ * platform_authenticator: true|false|null, answers: { checkId: answerIndex | 'not_sure' | value }, ai_apps: [...], org: {key: answer}|null }
120
+ * ctx: { catalog, questions, baseline, vuln: {bundle, manifest}|null|{error}, now }
121
+ */
122
+ export function buildMobileEvidence(input, ctx) {
123
+ const { catalog, questions, baseline: B } = ctx;
124
+ const now = ctx.now ?? Date.now();
125
+ const os = input.os_family;
126
+ if (!MOBILE.has(os)) throw new Error(`not a phone OS: ${os}`);
127
+ const answers = input.answers || {};
128
+ const apps = input.ai_apps || [];
129
+ const results = [];
130
+ const lockEvidence = input.platform_authenticator === true ? 'detected' : 'self-reported';
131
+ let lockSet = null;
132
+
133
+ for (const check of catalog.checks) {
134
+ if (!check.os.includes(os)) continue;
135
+ const name = (check.names && check.names[os]) || check.name;
136
+ const spec = (questions.checks[check.id] || {})[os];
137
+ let r, evidence = 'self-reported';
138
+
139
+ if (check.section === 'org') {
140
+ r = orgResult(check, input.org ? input.org[check.answer_key] : undefined);
141
+ evidence = null;
142
+ } else if (check.id === 'screen_lock.enabled' && input.platform_authenticator === true) {
143
+ r = { status: 'Compliant', actual: os === 'ios' ? 'passcode set' : 'screen lock set', expected: spec.expected,
144
+ detail: 'The browser reports that a device unlock method (passcode, PIN, Face ID or fingerprint) is set up.' };
145
+ evidence = 'detected';
146
+ } else if (check.id === 'gdpr.os_supported') {
147
+ r = osSupported(input, B);
148
+ evidence = input.version_source === 'user' ? 'self-reported' : 'detected';
149
+ } else if (check.id === 'encryption.enabled') {
150
+ if (os === 'android') {
151
+ const ok = major(input.os_version) >= 10;
152
+ r = { status: ok ? 'Compliant' : 'Non-Compliant', actual: ok ? `on (Android ${major(input.os_version)} encrypts by default)` : 'not guaranteed (Android older than 10)',
153
+ expected: 'on', detail: ok ? '' : 'Android encrypts by default only from version 10.' };
154
+ evidence = input.version_source === 'user' ? 'self-reported' : 'detected';
155
+ } else {
156
+ r = lockSet === null ? { status: 'Pending', actual: 'unknown', expected: 'on', detail: 'Depends on whether a passcode is set.' }
157
+ : { status: lockSet ? 'Compliant' : 'Non-Compliant', actual: lockSet ? 'on (passcode set)' : 'off (no passcode)', expected: 'on',
158
+ detail: lockSet ? 'iOS encrypts the data on the phone with hardware keys once a passcode is set.' : 'Without a passcode, iOS data protection is not active.' };
159
+ evidence = lockEvidence;
160
+ }
161
+ } else if (os === 'ios' && (check.id === 'vuln.pending_updates' || check.id === 'vuln.known_vulnerabilities')) {
162
+ evidence = input.version_source === 'user' ? 'self-reported' : 'detected';
163
+ const product = input.os_name === 'iPadOS' ? 'ipados' : 'ios';
164
+ if (!ctx.vuln || ctx.vuln.error) {
165
+ r = { status: 'Pending', actual: 'not checked', expected: check.id === 'vuln.pending_updates' ? 'latest iOS security release' : `<= ${B.MAX_CRITICAL_HIGH_VULNERABILITIES} critical/high`,
166
+ detail: `No vulnerability data available${ctx.vuln && ctx.vuln.error ? `: ${ctx.vuln.error}` : ''}.` };
167
+ } else if (check.id === 'vuln.pending_updates') {
168
+ const latest = latestIosFix(ctx.vuln.bundle, product, input.os_version);
169
+ const dataDate = ctx.vuln.manifest.built_at.slice(0, 10);
170
+ r = !latest ? { status: 'Pending', actual: `${input.os_name} ${input.os_version}`, expected: 'latest iOS security release', detail: `No security releases for ${input.os_name} ${major(input.os_version)} in the vulnerability data from ${dataDate}.` }
171
+ : cmp(input.os_version, latest) >= 0 ? { status: 'Compliant', actual: `${input.os_name} ${input.os_version} (up to date)`, expected: 'latest iOS security release', detail: `Newest fixed ${input.os_name} ${major(input.os_version)} release in the vulnerability data from ${dataDate}: ${latest}.${newerMajor(ctx.vuln.bundle, product, input.os_version)}` }
172
+ : { status: 'Non-Compliant', actual: `${input.os_name} ${input.os_version} (update to ${latest} available)`, expected: 'latest iOS security release', detail: `Install ${input.os_name} ${latest} or later in Settings › General › Software Update.` };
173
+ } else {
174
+ const inventory = { apps: [{ name: input.os_name, version: input.os_version }] };
175
+ const matched = matchApps(inventory, ctx.vuln.bundle, 'ios');
176
+ r = { expected: `<= ${B.MAX_CRITICAL_HIGH_VULNERABILITIES} critical/high`, ...knownVulnResult(matched, inventory, ctx.vuln, 'ios', parseInt(B.MAX_CRITICAL_HIGH_VULNERABILITIES, 10) || 0, now) };
177
+ if (input.version_source !== 'user') r.detail += ' Patch-level versions (x.y.z) are not always visible to the browser; confirm the version in Settings › General › About.';
178
+ }
179
+ } else if (check.section === 'aiact' && check.id !== 'aiact.os_ai_features') {
180
+ r = aiResults(check.id, apps, B);
181
+ } else if (spec) {
182
+ const a = answers[check.id];
183
+ if (a === undefined || a === 'not_sure') {
184
+ r = { ...questions.not_sure, expected: spec.expected || (spec.expected_from === 'seconds' ? `<= ${B.SCREEN_LOCK_MAX_SECONDS} seconds` : spec.expected_from === 'passcode' ? `>= ${B.MOBILE_PASSCODE_MIN_DIGITS} digits or alphanumeric` : `<= ${B.MOBILE_PATCH_MAX_AGE_DAYS} days old`) };
185
+ } else if (spec.input === 'month') {
186
+ r = patchResult(a, B, now) || { ...questions.not_sure, expected: `<= ${B.MOBILE_PATCH_MAX_AGE_DAYS} days old` };
187
+ } else {
188
+ const answer = spec.answers[a];
189
+ r = spec.expected_from ? computed(spec, answer, B, os)
190
+ : { status: answer.status, actual: answer.actual, expected: spec.expected, detail: answer.detail || '' };
191
+ }
192
+ } else {
193
+ throw new Error(`no rule for ${check.id} on ${os}`);
194
+ }
195
+ if (check.id === 'screen_lock.enabled') lockSet = r.status === 'Compliant' ? true : r.status === 'Non-Compliant' ? false : null;
196
+ const item = { id: check.id, section: check.section, name, status: r.status, actual: String(r.actual), expected: r.expected };
197
+ if (r.detail) item.detail = r.detail;
198
+ if (evidence) item.evidence = evidence;
199
+ results.push(item);
200
+ }
201
+
202
+ const summary = Object.fromEntries(catalog.statuses.map(s => [s, results.filter(r => r.status === s).length]));
203
+ const host = { hostname: input.hostname, os_family: os, os_name: input.os_name, os_version: String(input.os_version) };
204
+ if (input.device_model) host.device_model = input.device_model;
205
+ if (input.browser) host.browser = input.browser;
206
+ return {
207
+ schema_version: '1.0',
208
+ catalog_version: catalog.catalog_version,
209
+ source: { kind: 'web', collector: os, collector_version: MOBILE_VERSION },
210
+ audit: { mode: 'READ-ONLY', started_at: input.started_at, execution_user: 'phone self-check (browser)', is_admin: false },
211
+ host,
212
+ baseline: { ...B },
213
+ results,
214
+ summary,
215
+ };
216
+ }
mobile/styles.css ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --red: #ED2939; --blue: #00A1DE; --bg: #f6f7fa; --card: #ffffff; --text: #16181d; --muted: #5b6270; --line: #e2e5eb;
3
+ --ok: #1a7f37; --bad: #c4242f; --pending: #9a6700; --na: #6e7781; --err: #8250df;
4
+ }
5
+ @media (prefers-color-scheme: dark) {
6
+ :root:not([data-theme="light"]) { --bg: #101216; --card: #1a1d23; --text: #eef0f4; --muted: #a2a9b6; --line: #2c313a; --ok: #3fb950; --bad: #ff6b6b; --pending: #d29922; --na: #8b949e; --err: #b392f0; }
7
+ }
8
+ :root[data-theme="dark"] { --bg: #101216; --card: #1a1d23; --text: #eef0f4; --muted: #a2a9b6; --line: #2c313a; --ok: #3fb950; --bad: #ff6b6b; --pending: #d29922; --na: #8b949e; --err: #b392f0; }
9
+
10
+ * { box-sizing: border-box; }
11
+ html, body { margin: 0; background: var(--bg); color: var(--text); }
12
+ body { font: 16px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; min-height: 100vh; display: flex; flex-direction: column; }
13
+ main { width: 100%; max-width: 560px; margin: 0 auto; padding: 8px 16px 24px; flex: 1; }
14
+ .hidden { display: none !important; }
15
+ h1 { font-size: 1.5rem; line-height: 1.25; margin: 12px 0 10px; }
16
+ h2 { font-size: 1rem; margin: 0; }
17
+ p { margin: 8px 0; }
18
+ a { color: var(--blue); }
19
+ .muted { color: var(--muted); font-size: .9rem; }
20
+ .lead { font-size: 1.05rem; }
21
+
22
+ .top { display: flex; justify-content: space-between; align-items: center; padding: max(12px, env(safe-area-inset-top)) 16px 10px; border-top: 4px solid var(--red); background: var(--card); border-bottom: 1px solid var(--line); }
23
+ .brand { display: flex; align-items: center; gap: 8px; font-size: .95rem; }
24
+ .mark { width: 26px; height: 26px; border-radius: 6px; background: var(--red); color: #fff; display: grid; place-items: center; font-weight: 800; }
25
+ .chip { font-size: .75rem; color: var(--muted); border: 1px solid var(--line); border-radius: 999px; padding: 2px 10px; max-width: 50%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
26
+
27
+ .card { background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 14px 16px; margin: 12px 0; }
28
+ .device { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; font-size: .95rem; }
29
+ .device b { color: var(--muted); font-weight: 600; }
30
+ .note { border-left: 4px solid var(--blue); }
31
+ .facts { padding-left: 18px; margin: 14px 0; } .facts li { margin: 6px 0; }
32
+ .plan { columns: 2; column-gap: 18px; padding-left: 18px; margin: 6px 0 0; font-size: .85rem; } .plan li { break-inside: avoid; }
33
+ @media (max-width: 380px) { .plan { columns: 1; } }
34
+
35
+ button { font: inherit; border-radius: 12px; padding: 13px 16px; border: 1px solid var(--line); background: var(--card); color: var(--text); cursor: pointer; width: 100%; min-height: 48px; }
36
+ button.primary { background: var(--red); border-color: var(--red); color: #fff; font-weight: 700; }
37
+ button.primary:disabled { opacity: .5; }
38
+ button.secondary { background: var(--card); }
39
+ button.link { background: none; border: none; color: var(--blue); text-decoration: underline; }
40
+ .row { display: flex; gap: 10px; margin-top: 12px; } .row button { flex: 1; }
41
+ .actions { display: grid; gap: 10px; margin-top: 14px; }
42
+
43
+ .toggle { display: flex; gap: 12px; align-items: flex-start; cursor: pointer; }
44
+ .toggle input { width: 22px; height: 22px; margin-top: 2px; accent-color: var(--red); flex: none; }
45
+
46
+ .progress { height: 6px; background: var(--line); border-radius: 99px; overflow: hidden; margin: 12px 0 4px; }
47
+ .progress.big { height: 10px; }
48
+ .bar { height: 100%; width: 0; background: var(--blue); transition: width .25s; }
49
+ .check-name { color: var(--blue); font-size: .85rem; text-transform: uppercase; letter-spacing: .04em; margin-top: 8px; }
50
+ .where { background: var(--card); border: 1px dashed var(--line); border-radius: 12px; padding: 10px 12px; margin: 10px 0 14px; font-size: .92rem; }
51
+ .where-label { display: block; font-size: .75rem; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
52
+ .answers { display: grid; gap: 10px; }
53
+ .answers button { text-align: left; }
54
+ .answers button.not-sure { color: var(--muted); border-style: dashed; }
55
+ .answers input[type="text"], .answers input[type="month"] { font: inherit; width: 100%; padding: 12px; border-radius: 12px; border: 1px solid var(--line); background: var(--card); color: var(--text); min-height: 48px; }
56
+ .answers label.multi { display: flex; gap: 12px; align-items: center; background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; min-height: 48px; }
57
+ .answers label.multi input { width: 22px; height: 22px; accent-color: var(--red); }
58
+
59
+ .score { font-size: 3rem; font-weight: 800; line-height: 1; }
60
+ .pills { display: flex; flex-wrap: wrap; gap: 6px; margin: 10px 0; }
61
+ .pill { font-size: .8rem; font-weight: 700; border: 1px solid; border-radius: 99px; padding: 1px 9px; }
62
+ .s-Compliant { color: var(--ok); } .s-Non-Compliant { color: var(--bad); } .s-Pending { color: var(--pending); } .s-NotApplicable { color: var(--na); } .s-Error { color: var(--err); }
63
+ .gaps { padding-left: 18px; } .gaps li { margin: 6px 0; }
64
+
65
+ .foot { text-align: center; font-size: .75rem; color: var(--muted); padding: 16px 16px max(16px, env(safe-area-inset-bottom)); }
66
+ .center { text-align: center; }
mobile/test/e2e.cjs ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // End-to-end test of the phone check in a phone-sized Electron window with a phone user agent.
2
+ // Usage: (serve the repo root on :8765) npx electron mobile/test/e2e.cjs <ios|android> <out.json>
3
+ const { app, BrowserWindow } = require('electron');
4
+ const fs = require('node:fs');
5
+ const UA = {
6
+ ios: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Mobile/15E148 Safari/604.1',
7
+ android: 'Mozilla/5.0 (Linux; Android 15; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Mobile Safari/537.36',
8
+ };
9
+ const [os, out] = process.argv.slice(-2);
10
+ const PICK = `(() => {
11
+ const box = document.getElementById('q-answers');
12
+ const buttons = [...box.querySelectorAll('button')];
13
+ const cont = buttons.find(b => b.textContent === 'Continue');
14
+ const text = box.querySelector('input[type=text]'), month = box.querySelector('input[type=month]');
15
+ const multi = box.querySelectorAll('input[type=checkbox]');
16
+ if (text) { if (!text.value) text.value = '${os === 'ios' ? '26.0.1' : '15'}'; cont.click(); return 'version'; }
17
+ if (month) { month.value = '2026-06'; cont.click(); return 'month'; }
18
+ if (multi.length) { multi[0].checked = true; multi[7].checked = true; cont.click(); return 'ai'; }
19
+ (buttons.length > 2 ? buttons[1] : buttons[0]).click(); return 'choice';
20
+ })()`;
21
+ app.disableHardwareAcceleration();
22
+ app.whenReady().then(async () => {
23
+ const win = new BrowserWindow({ show: false, width: 390, height: 844 });
24
+ win.webContents.setUserAgent(UA[os]);
25
+ win.webContents.on('console-message', (e, level, message) => { if (level >= 2) console.log('[page]', message); });
26
+ const js = code => win.webContents.executeJavaScript(code);
27
+ const until = async (code, ms = 60000) => { const t = Date.now(); while (!(await js(code))) { if (Date.now() - t > ms) throw new Error(`timeout: ${code}`); await new Promise(r => setTimeout(r, 200)); } };
28
+ try {
29
+ await win.webContents.session.clearCache();
30
+ await win.loadURL('http://127.0.0.1:8765/index.html?test');
31
+ await until(`!document.getElementById('start').disabled`);
32
+ const device = await js(`document.getElementById('device-card').innerText`);
33
+ await js(`document.getElementById('start').click()`);
34
+ await js(`document.getElementById('use-ai').checked = false; document.getElementById('consent-ok').click()`);
35
+ let asked = [];
36
+ for (let i = 0; i < 60; i++) {
37
+ const inQuestions = await js(`!document.getElementById('s-question').classList.contains('hidden')`);
38
+ if (!inQuestions) break;
39
+ const q = await js(`document.getElementById('q-text').textContent`);
40
+ asked.push(q);
41
+ // Pick a mix of answers: the version as detected (or typed), a month, two AI apps, otherwise the 2nd option.
42
+ await js(PICK);
43
+ await new Promise(r => setTimeout(r, 50));
44
+ }
45
+ await until(`!document.getElementById('s-done').classList.contains('hidden')`, 120000);
46
+ const evidence = await js(`window.__devseisState.report.evidenceText`);
47
+ const result = await js(`document.getElementById('result').innerText`);
48
+ const pdfSize = await js(`window.__devseisMakePdf().then(b => b.size)`);
49
+ const html = await js(`window.__devseisState.report.html`);
50
+ fs.writeFileSync(out, evidence);
51
+ fs.writeFileSync(out.replace(/\.json$/, '.html'), html);
52
+ console.log(JSON.stringify({ os, device, questions: asked.length, pdfBytes: pdfSize, result: result.slice(0, 700) }, null, 1));
53
+ } catch (error) {
54
+ console.error('E2E FAILED:', error.message);
55
+ process.exitCode = 1;
56
+ }
57
+ app.quit();
58
+ });
mobile/vuln-web.js ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Downloads the small phone subset of the Devseis vulnerability bundle and checks its SHA-256 and
2
+ // Ed25519 signature before use (WebCrypto). Only data is downloaded; nothing about the phone is sent.
3
+ const VULNDB_URL = 'https://huggingface.co/datasets/Devseis/endpoint-auditor-vulndb/resolve/main';
4
+ const PUBLIC_KEY_URL = 'app/vulndb-public-key.pem';
5
+
6
+ const hex = buffer => [...new Uint8Array(buffer)].map(b => b.toString(16).padStart(2, '0')).join('');
7
+ const fromBase64 = text => Uint8Array.from(atob(text), c => c.charCodeAt(0));
8
+
9
+ async function verifySignature(sha, signature) {
10
+ const pem = await (await fetch(PUBLIC_KEY_URL)).text();
11
+ const der = fromBase64(pem.replace(/-----[^-]+-----/g, '').replace(/\s+/g, ''));
12
+ try {
13
+ const key = await crypto.subtle.importKey('spki', der, { name: 'Ed25519' }, false, ['verify']);
14
+ return await crypto.subtle.verify({ name: 'Ed25519' }, key, fromBase64(signature), new TextEncoder().encode(sha));
15
+ } catch (error) {
16
+ if (error && error.name === 'NotSupportedError') return 'unsupported'; // older browsers: SHA-256 over HTTPS only
17
+ throw error;
18
+ }
19
+ }
20
+
21
+ export async function loadPhoneVulnData() {
22
+ try {
23
+ const manifest = await (await fetch(`${VULNDB_URL}/manifest.json`, { cache: 'no-cache' })).json();
24
+ if (!manifest.mobile) throw new Error('the vulnerability data has no phone subset');
25
+ const data = await (await fetch(`${VULNDB_URL}/${manifest.mobile.file}`)).arrayBuffer();
26
+ const sha = hex(await crypto.subtle.digest('SHA-256', data));
27
+ if (sha !== manifest.mobile.sha256) throw new Error('vulnerability data checksum does not match');
28
+ const signed = await verifySignature(sha, manifest.mobile.signature);
29
+ if (signed === false) throw new Error('vulnerability data signature is not valid');
30
+ const text = await new Response(new Blob([data]).stream().pipeThrough(new DecompressionStream('gzip'))).text();
31
+ return { manifest, bundle: JSON.parse(text), signatureChecked: signed === true };
32
+ } catch (error) {
33
+ return { error: error.message };
34
+ }
35
+ }
schema/evidence.schema.json CHANGED
@@ -14,8 +14,8 @@
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" }
@@ -38,7 +38,9 @@
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" },
@@ -65,7 +67,8 @@
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
  },
 
14
  "required": ["kind", "collector", "collector_version"],
15
  "additionalProperties": false,
16
  "properties": {
17
+ "kind": { "enum": ["collector", "synthetic", "web"] },
18
+ "collector": { "enum": ["windows", "linux", "macos", "ios", "android"] },
19
  "collector_version": { "type": "string" },
20
  "synthetic_seed": { "type": "integer" },
21
  "synthetic_profile": { "type": "string" }
 
38
  "additionalProperties": false,
39
  "properties": {
40
  "hostname": { "type": "string" },
41
+ "os_family": { "enum": ["windows", "linux", "macos", "ios", "android"] },
42
+ "device_model": { "type": "string" },
43
+ "browser": { "type": "string" },
44
  "os_name": { "type": "string" },
45
  "os_version": { "type": "string" },
46
  "build": { "type": "string" },
 
67
  "status": { "enum": ["Compliant", "Non-Compliant", "NotApplicable", "Pending", "Error"] },
68
  "actual": { "type": "string", "description": "What was found on the device" },
69
  "expected": { "type": "string", "description": "What the baseline requires" },
70
+ "detail": { "type": "string", "description": "How the value was read, or why the status was given" },
71
+ "evidence": { "enum": ["detected", "self-reported"], "description": "Phones: detected by the browser, or answered by the user. Absent = read by a collector." }
72
  }
73
  }
74
  },
synthetic/generate_evidence.py CHANGED
@@ -12,14 +12,16 @@ Usage:
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.2.0"
23
 
24
  # Report order per OS, identical to the collectors.
25
  PASSWORD_LOCKOUT = ["password.min_length", "password.history", "password.expiration", "password.min_age",
@@ -83,6 +85,66 @@ AI_TOOLS = {
83
  REMOTE_SERVICES = {"windows": ["rdp", "winrm", "ssh", "smb", "teamviewer", "anydesk", "chromeremotedesktop"],
84
  "linux": ["ssh", "rdp", "vnc", "smb", "teamviewer", "anydesk", "chromeremotedesktop"],
85
  "macos": ["ssh", "screensharing", "filesharing", "remotemanagement", "chromeremotedesktop", "teamviewer", "anydesk"]}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  EMPLOYEE_ACCOUNTS = ["jdoe", "asmith", "mkhan", "lmuller", "pmartin", "sali", "tnguyen", "rschmit", "akaya", "ewagner"]
87
 
88
 
@@ -549,47 +611,116 @@ class Device:
549
  def c_vuln_pending_updates(self):
550
  limit = self.n("MAX_PENDING_SECURITY_UPDATES")
551
  expected = f"<= {limit} pending"
552
- if self.good(-0.05):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
553
  return ("Compliant", "0 pending", expected, "")
554
- names = {"windows": [f"2026-{self.rng.randint(8, 10):02d} Cumulative Update for Windows (KB50{self.rng.randint(41000, 49999)})",
555
- f"Security Update for Microsoft Edge (version {self.rng.randint(128, 131)}.0)", ".NET Framework security update",
556
- "Servicing Stack Update"],
557
- "linux": ["openssl", "libssh-4", "linux-image-generic", "openssh-server", "sudo", "curl", "libxml2", "python3.12"],
558
- "macos": [f"macOS {self.os_version.split('.')[0]} security update", "Background Security Improvement", "Safari security update",
559
- "Command Line Tools security update"]}[self.os]
560
- n = self.rng.randint(1, 12)
561
- listed = self.rng.sample(names, min(len(names), self.pick([1, 2, 3])))
562
- return ("Non-Compliant", f"{n} pending", expected, "Including: " + ", ".join(listed) + ".")
563
 
564
  def c_vuln_outdated_apps(self):
565
  limit = self.n("MAX_OUTDATED_APPS")
566
  expected = f"<= {limit} outdated"
567
- if self.good(-0.15):
568
- return ("Compliant", "0 outdated", expected, "")
569
- catalog = {"windows": ["Google Chrome", "Mozilla Firefox", "Zoom Workplace", "Adobe Acrobat Reader", "7-Zip", "Notepad++", "VLC media player"],
570
- "linux": ["firefox (snap)", "chromium (snap)", "zoom (flatpak)", "libreoffice", "thunderbird", "code (snap)"],
571
- "macos": ["Google Chrome", "Zoom", "Microsoft Word", "Slack", "Adobe Acrobat Reader", "Firefox", "VLC"]}[self.os]
572
- apps = self.rng.sample(catalog, self.pick([1, 2, 3, 4]))
573
- parts = []
574
- for app in apps:
575
- major = self.rng.randint(3, 130)
576
- parts.append(f"{app} {major}.{self.rng.randint(0, 9)} -> {major + self.pick([0, 1, 2])}.{self.rng.randint(0, 9)}")
577
- return ("Non-Compliant", f"{len(apps)} outdated", expected, "; ".join(parts) + ".")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
578
 
579
  def c_vuln_known_vulnerabilities(self):
580
- limit = self.n("MAX_CRITICAL_HIGH_VULNERABILITIES")
581
- expected = f"<= {limit} critical/high"
582
- source = f"Matched against offline OSV/NVD data from 2026-{self.rng.randint(9, 10):02d}-{self.rng.randint(1, 28):02d}."
583
- if self.good(-0.1):
584
- low = self.pick([0, 0, 1, 2, 3])
585
- return ("Compliant", "0 critical/high", expected, f"{source} {low} medium/low.")
586
- software = {"windows": ["Google Chrome", "Adobe Acrobat Reader", "7-Zip", "Zoom Workplace", "Microsoft .NET Runtime", "Notepad++"],
587
- "linux": ["openssl", "openssh-server", "sudo", "curl", "glibc", "libxml2", "firefox"],
588
- "macos": ["Google Chrome", "Zoom", "Adobe Acrobat Reader", "OpenSSL (Homebrew)", "Python 3.11 (Homebrew)", "Firefox"]}[self.os]
589
- hits = self.rng.sample(software, self.pick([1, 1, 2, 3]))
590
- critical = self.rng.randint(0, len(hits))
591
- found = [f"{h} (CVE-2026-{self.rng.randint(10000, 49999)}, {'critical' if i < critical else 'high'})" for i, h in enumerate(hits)]
592
- return ("Non-Compliant", f"{len(hits)} critical/high", expected, f"{source} " + "; ".join(found) + ".")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
593
 
594
  # --- GDPR ----------------------------------------------------------------------------
595
  def c_gdpr_auto_updates(self):
@@ -763,6 +894,98 @@ def org_result(rng, profile, answer_key):
763
  return ("Pending", "not answered", "yes", f"Answer {answer_key} in organisational.conf.")
764
 
765
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
766
  def random_hostname(rng, os_family):
767
  if os_family == "windows":
768
  return rng.choice(["DESKTOP-", "LAPTOP-"]) + "".join(rng.choices(string.ascii_uppercase + string.digits, k=7))
@@ -818,6 +1041,8 @@ def make_record(rng, seed, os_family, profile, baseline, catalog, checks):
818
  "status": status, "actual": actual, "expected": expected}
819
  if detail:
820
  item["detail"] = detail
 
 
821
  results.append(item)
822
 
823
  summary = {s: sum(1 for r in results if r["status"] == s) for s in catalog["statuses"]}
@@ -839,7 +1064,7 @@ def main():
839
  parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
840
  parser.add_argument("--count", type=int, default=100, help="number of audit runs to generate")
841
  parser.add_argument("--seed", type=int, default=1, help="random seed (same seed = same data)")
842
- parser.add_argument("--os", default="windows,linux,macos", help="comma-separated OS families")
843
  parser.add_argument("--baseline", default=os.path.join(ROOT, "baseline.conf"))
844
  parser.add_argument("--out", default=os.path.join(ROOT, "synthetic", "out", "evidence.jsonl"), help="JSONL output file")
845
  parser.add_argument("--files", help="write one pretty JSON file per run into this folder instead of JSONL")
@@ -849,12 +1074,28 @@ def main():
849
  baseline = read_conf(args.baseline)
850
  families = [f.strip() for f in args.os.split(",") if f.strip()]
851
  rng = random.Random(args.seed)
852
- records = []
 
853
  for index in range(args.count):
854
  record_seed = args.seed * 1_000_003 + index
855
  local = random.Random(record_seed)
856
- records.append(make_record(local, record_seed, rng.choice(families), rng.choices(list(PROFILES), weights=[2, 5, 3])[0],
857
- baseline, catalog, checks))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
858
 
859
  if args.files:
860
  os.makedirs(args.files, exist_ok=True)
 
12
  Standard library only.
13
  """
14
  import argparse
15
+ import gzip
16
  import json
17
+ import subprocess
18
  import os
19
  import random
20
  import string
21
  from datetime import datetime, timedelta, timezone
22
 
23
  ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
24
+ GENERATOR_VERSION = "0.3.0"
25
 
26
  # Report order per OS, identical to the collectors.
27
  PASSWORD_LOCKOUT = ["password.min_length", "password.history", "password.expiration", "password.min_age",
 
85
  REMOTE_SERVICES = {"windows": ["rdp", "winrm", "ssh", "smb", "teamviewer", "anydesk", "chromeremotedesktop"],
86
  "linux": ["ssh", "rdp", "vnc", "smb", "teamviewer", "anydesk", "chromeremotedesktop"],
87
  "macos": ["ssh", "screensharing", "filesharing", "remotemanagement", "chromeremotedesktop", "teamviewer", "anydesk"]}
88
+ MAC_CODENAMES = {"26": "Tahoe", "15": "Sequoia", "14": "Sonoma", "13": "Ventura"}
89
+ # Installed-program names as the collectors report them, per vulnerability-bundle product (vulndb/products.json).
90
+ APP_NAMES = {
91
+ "windows": {"chrome": "Google Chrome", "edge": "Microsoft Edge", "firefox": "Mozilla Firefox (x64 en-US)", "acrobat_reader": "Adobe Acrobat Reader (64-bit)",
92
+ "7zip": "7-Zip {v} (x64)", "winrar": "WinRAR {v} (64-bit)", "notepadpp": "Notepad++ (64-bit x64)", "vlc": "VLC media player",
93
+ "zoom": "Zoom Workplace", "teams": "Microsoft Teams", "vscode": "Microsoft Visual Studio Code (User)", "putty": "PuTTY release {v} (64-bit)",
94
+ "winscp": "WinSCP {v}", "filezilla": "FileZilla {v}", "wireshark": "Wireshark {v} x64", "git": "Git", "python": "Python {v} (64-bit)",
95
+ "nodejs": "Node.js", "keepass": "KeePass Password Safe {v}", "teamviewer": "TeamViewer", "anydesk": "AnyDesk", "docker_desktop": "Docker Desktop",
96
+ "libreoffice": "LibreOffice {v}", "gimp": "GIMP {v}", "foxit_reader": "Foxit PDF Reader", "telegram": "Telegram Desktop"},
97
+ "macos": {"chrome": "Google Chrome", "edge": "Microsoft Edge", "firefox": "Firefox", "thunderbird": "Thunderbird", "acrobat_reader": "Adobe Acrobat Reader",
98
+ "vlc": "VLC", "zoom": "zoom.us", "teams": "Microsoft Teams", "vscode": "Visual Studio Code", "filezilla": "FileZilla", "wireshark": "Wireshark",
99
+ "teamviewer": "TeamViewer", "anydesk": "AnyDesk", "docker_desktop": "Docker", "openvpn_connect": "OpenVPN Connect", "libreoffice": "LibreOffice",
100
+ "gimp": "GIMP", "telegram": "Telegram", "safari": "Safari", "iterm2": "iTerm"},
101
+ }
102
+ COMMON_APPS = {"windows": ["chrome", "edge", "zoom", "teams", "acrobat_reader", "7zip", "vscode", "firefox", "notepadpp", "vlc"],
103
+ "macos": ["safari", "chrome", "zoom", "teams", "vscode", "acrobat_reader", "firefox", "vlc", "docker_desktop", "iterm2"]}
104
+ FILLER_APPS = {"windows": ["Microsoft 365 Apps for business - en-us", "Slack", "Notion", "1Password", "Spotify", "Dell SupportAssist",
105
+ "Microsoft Visual C++ 2015-2022 Redistributable (x64)", "NVIDIA Graphics Driver", "WhatsApp", "Logitech Options+", "Intel(R) Graphics Driver"],
106
+ "macos": ["Slack", "Notion", "1Password", "Microsoft Word", "Microsoft Excel", "Spotify", "WhatsApp", "Figma", "Postman", "Raycast", "Keynote", "Pages"]}
107
+ BREW_FORMULAE = ["openssl@3", "python@3.13", "node", "git", "curl", "wget", "ca-certificates", "sqlite", "libpng", "icu4c@77", "jq", "gh", "ffmpeg", "pkgconf", "readline", "xz"]
108
+ WINGET_IDS = {"Google Chrome": "Google.Chrome", "Mozilla Firefox (x64 en-US)": "Mozilla.Firefox", "Zoom Workplace": "Zoom.Zoom", "7-Zip 24.08 (x64)": "7zip.7zip",
109
+ "Notepad++ (64-bit x64)": "Notepad++.Notepad++", "VLC media player": "VideoLAN.VLC", "Microsoft Visual Studio Code (User)": "Microsoft.VisualStudioCode",
110
+ "Adobe Acrobat Reader (64-bit)": "Adobe.Acrobat.Reader.64-bit", "Git": "Git.Git", "Slack": "SlackTechnologies.Slack"}
111
+ _BUNDLE = None
112
+
113
+
114
+ def vuln_bundle():
115
+ """Real offline vulnerability bundle (vulndb/out), used to draw realistic installed versions."""
116
+ global _BUNDLE
117
+ if _BUNDLE is None:
118
+ out = os.path.join(ROOT, "vulndb", "out")
119
+ manifest = json.load(open(os.path.join(out, "manifest.json")))
120
+ with gzip.open(os.path.join(out, manifest["file"])) as handle:
121
+ _BUNDLE = json.load(handle)
122
+ _BUNDLE["_manifest"] = manifest
123
+ return _BUNDLE
124
+
125
+
126
+ def vkey(v):
127
+ return [int(x) if x.isdigit() else 0 for x in v.replace("-", ".").split(".")]
128
+
129
+
130
+ def fixed_versions(product):
131
+ """Real release numbers from the NVD ranges (versions that fixed something), oldest first."""
132
+ return sorted({row[5] for row in product["cves"] if row[5]}, key=vkey)
133
+
134
+
135
+ def current_version(product):
136
+ """A version newer than every affected one in NVD: what a fully updated device runs."""
137
+ bounds = sorted({v for row in product["cves"] for v in (row[4], row[5], row[6]) if v}, key=vkey)
138
+ top = bounds[-1]
139
+ if any(top in (row[4], row[6]) for row in product["cves"]): # still affected at that version: one patch newer
140
+ parts = top.split(".")
141
+ parts[-1] = str(int(parts[-1]) + 1) if parts[-1].isdigit() else parts[-1] + ".1"
142
+ top = ".".join(parts)
143
+ return top
144
+
145
+
146
+ VULN_JOBS = [] # (result item, job) filled by Device.c_vuln_known_vulnerabilities, resolved by the real matcher in main()
147
+
148
  EMPLOYEE_ACCOUNTS = ["jdoe", "asmith", "mkhan", "lmuller", "pmartin", "sali", "tnguyen", "rschmit", "akaya", "ewagner"]
149
 
150
 
 
611
  def c_vuln_pending_updates(self):
612
  limit = self.n("MAX_PENDING_SECURITY_UPDATES")
613
  expected = f"<= {limit} pending"
614
+ good = self.good(-0.05)
615
+ if self.os == "macos":
616
+ if self.rng.random() < 0.04:
617
+ return ("Error", "not read", expected, "Apple's update service could not be reached.")
618
+ if good:
619
+ return ("Compliant", "0 pending", expected, "")
620
+ major = self.os_version.split(".")[0]
621
+ parts = self.os_version.split(".") + ["0", "0"]
622
+ newer = f"{major}.{int(parts[1]) + self.pick([0, 1, 2])}.{int(parts[2]) + 1}"
623
+ labels = [f"macOS {MAC_CODENAMES.get(major, '')} {newer}-{self.pick(['25G', '24H', '23J', '22H'])}{self.rng.randint(100, 899)}".replace(" ", " ")]
624
+ if self.rng.random() < 0.3:
625
+ labels.append(f"Command Line Tools for Xcode-{self.pick(['16.4', '26.0', '26.1'])}")
626
+ if self.rng.random() < 0.2:
627
+ labels.append(f"Safari{self.pick(['26.0.1', '26.1', '18.6'])}{MAC_CODENAMES.get(major, '')}-{self.pick(['26.0.1', '26.1', '18.6'])}")
628
+ return ("Non-Compliant", f"{len(labels)} pending", expected, f"Including: {', '.join(labels)}.")
629
+ if self.os == "linux":
630
+ note = (f" Package lists are {self.pick([0, 0, 1, 2, 3, 7, 14, 30])} days old." if self.distro in ("ubuntu", "debian") else " Uses the cached repository data.")
631
+ if good:
632
+ return ("Compliant", "0 pending", expected, note.strip())
633
+ names = (["libssl3t64", "openssl", "libc6", "libc-bin", "linux-image-generic", "openssh-server", "sudo", "curl", "libcurl4t64", "libxml2", "python3.12", "systemd", "libsystemd0", "tzdata"]
634
+ if self.distro != "rhel" else ["openssl-libs", "krb5-libs", "glibc", "glibc-common", "kernel", "curl-minimal", "libxml2", "sudo", "systemd-libs", "python3-libs", "vim-minimal"])
635
+ n = self.rng.randint(1, 40)
636
+ listed = self.rng.sample(names, min(n, 3))
637
+ return ("Non-Compliant", f"{n} pending", expected, f"Including: {', '.join(listed)}.{note}")
638
+ if self.rng.random() < 0.04:
639
+ return ("Error", "not read", expected, "No answer within 300 seconds.")
640
+ if good:
641
  return ("Compliant", "0 pending", expected, "")
642
+ version = {"26200": "25H2", "26100": "24H2", "22631": "23H2", "22621": "22H2", "19045": "22H2"}.get(self.build, "24H2")
643
+ product = "Windows 10" if self.build == "19045" else "Windows 11"
644
+ titles = [f"2026-{self.pick(['09', '10'])} Cumulative Update for {product} Version {version} for x64-based Systems (KB50{self.rng.randint(41000, 49999)})"]
645
+ for extra in ("2026-{m} Cumulative Update for .NET Framework 3.5 and 4.8.1 for {p} Version {v} for x64 (KB50{k})",
646
+ "Security Intelligence Update for Microsoft Defender Antivirus - KB2267602 (Version 1.4{k3}.0)"):
647
+ if self.rng.random() < 0.35:
648
+ titles.append(extra.format(m=self.pick(["09", "10"]), p=product, v=version, k=self.rng.randint(41000, 49999), k3=self.rng.randint(10, 99)))
649
+ return ("Non-Compliant", f"{len(titles)} pending", expected, f"Including: {', '.join(titles[:3])}.")
 
650
 
651
  def c_vuln_outdated_apps(self):
652
  limit = self.n("MAX_OUTDATED_APPS")
653
  expected = f"<= {limit} outdated"
654
+ good = self.good(-0.15)
655
+ if self.os == "macos":
656
+ if self.rng.random() < 0.45:
657
+ return ("NotApplicable", "not checked", expected, "Homebrew is not installed, so there is no local source of current application versions.")
658
+ if good:
659
+ return ("Compliant", "0 outdated", expected, "Homebrew packages checked; App Store apps are not covered.")
660
+ n = self.rng.randint(1, 70)
661
+ rows = []
662
+ for formula in self.rng.sample(BREW_FORMULAE, min(n, 5)):
663
+ a, b = self.rng.randint(1, 30), self.rng.randint(0, 20)
664
+ rows.append(f"{formula} ({a}.{b}.{self.rng.randint(0, 9)}) < {a}.{b + self.pick([0, 1, 2])}.{self.rng.randint(10, 19)}")
665
+ return ("Non-Compliant", f"{n} outdated", expected, "; ".join(rows) + ".")
666
+ if self.os == "linux":
667
+ packages = 0 if good else self.rng.randint(0, 60)
668
+ parts, total = [f"packages: {packages}"], packages
669
+ if not good and self.distro == "ubuntu" and self.rng.random() < 0.5:
670
+ snaps = self.rng.sample(["firefox", "snap-store", "thunderbird", "code", "chromium", "slack", "zoom-client"], self.pick([1, 2, 3]))
671
+ parts.append("snaps: " + ", ".join(snaps)); total += len(snaps)
672
+ if not good and self.rng.random() < 0.25:
673
+ flats = self.rng.sample(["org.mozilla.firefox", "com.slack.Slack", "us.zoom.Zoom", "org.libreoffice.LibreOffice", "org.gimp.GIMP"], self.pick([1, 2]))
674
+ parts.append("flatpaks: " + ", ".join(flats)); total += len(flats)
675
+ return self.result(total <= limit, f"{total} outdated", expected, "; ".join(parts))
676
+ if self.rng.random() < 0.15:
677
+ return ("NotApplicable", "not checked", expected, "winget (App Installer) is not available for this account, so there is no local source of current application versions.")
678
+ if good:
679
+ return ("Compliant", "0 outdated", expected, self.pick(["winget found no upgrades for packages it can match.", ""]))
680
+ rows = []
681
+ for name in self.rng.sample(list(WINGET_IDS), self.pick([1, 2, 3, 4, 6])):
682
+ a = self.rng.randint(1, 130)
683
+ rows.append(f"{name} {WINGET_IDS[name]} {a}.{self.rng.randint(0, 9)}.{self.rng.randint(0, 999)} {a + self.pick([0, 1])}.{self.rng.randint(0, 9)}.{self.rng.randint(0, 999)} winget")
684
+ return ("Non-Compliant", f"{len(rows)} outdated", expected, "; ".join(rows[:5]) + ".")
685
 
686
  def c_vuln_known_vulnerabilities(self):
687
+ """Builds a realistic software inventory; the real matcher fills in the result (see VULN_JOBS)."""
688
+ expected = f"<= {self.n('MAX_CRITICAL_HIGH_VULNERABILITIES')} critical/high"
689
+ bundle = vuln_bundle()
690
+ stale = {"neglected": 0.9, "typical": 0.5, "hardened": 0.04}[self.profile]
691
+ if self.os == "linux":
692
+ key = {"ubuntu": f"ubuntu:{self.os_version}", "debian": f"debian:{self.os_version}", "rhel": f"rhel:{self.os_version.split('.')[0]}"}[self.distro]
693
+ kind = "rpm" if self.distro == "rhel" else "deb"
694
+ packages = []
695
+ advisories = bundle["releases"].get(key, {})
696
+ # Cloud/OEM kernel flavours (linux-aws, linux-ibm, ...) are not on ordinary endpoints.
697
+ names = sorted(n for n in advisories if not (n.startswith("linux-") and not n.startswith(("linux-firmware", "linux-signed"))))
698
+ per_package = {"neglected": 0.12, "typical": 0.03, "hardened": 0.002}[self.profile]
699
+ for name in self.rng.sample(names, min(len(names), self.rng.randint(300, 900))):
700
+ entries = advisories[name]
701
+ if self.rng.random() < per_package:
702
+ version = self.pick(entries)[2] # an older build: the matcher decides what still applies
703
+ else:
704
+ version = "99:99" if kind == "deb" else "99:99-1" # newer than every fix (never shown in findings)
705
+ packages.append({"name": name, "version": version, "source": "", "source_version": ""})
706
+ inventory = {"format": 1, "os_family": "linux", "distro_id": self.distro, "version_id": self.os_version, "pretty_name": self.os_name, "kind": kind, "packages": packages}
707
+ else:
708
+ apps = []
709
+ products = {p["id"]: p for p in bundle["apps"]}
710
+ names = APP_NAMES[self.os]
711
+ chosen = [pid for pid in names if pid in products and (pid in COMMON_APPS[self.os] or self.rng.random() < 0.15)]
712
+ for pid in chosen:
713
+ versions = fixed_versions(products[pid])
714
+ if not versions:
715
+ continue
716
+ version = self.pick(versions[-12:-1]) if (self.rng.random() < stale * 0.5 and len(versions) > 2) else current_version(products[pid])
717
+ apps.append({"name": names[pid].format(v=version), "version": version})
718
+ for filler in self.rng.sample(FILLER_APPS[self.os], self.rng.randint(3, len(FILLER_APPS[self.os]))):
719
+ apps.append({"name": filler, "version": f"{self.rng.randint(1, 30)}.{self.rng.randint(0, 9)}.{self.rng.randint(0, 99)}"})
720
+ inventory = {"format": 1, "os_family": self.os, "apps": apps}
721
+ self.vuln_job = {"os_family": self.os, "inventory": inventory, "max": self.n("MAX_CRITICAL_HIGH_VULNERABILITIES"),
722
+ "now": self.started.isoformat(timespec="seconds")}
723
+ return ("Pending", "matching", expected, "")
724
 
725
  # --- GDPR ----------------------------------------------------------------------------
726
  def c_gdpr_auto_updates(self):
 
894
  return ("Pending", "not answered", "yes", f"Answer {answer_key} in organisational.conf.")
895
 
896
 
897
+ # --- Phones: answer sets for the mobile web check (mobile/mobile-core.js builds the evidence) ---------------
898
+ ANDROID_MODELS = ["Pixel 9", "Pixel 8", "Pixel 7a", "Galaxy S25", "Galaxy S24", "Galaxy A55", "Galaxy A35", "Galaxy Tab S9", "Xiaomi 14", "OnePlus 12", "Motorola edge 50"]
899
+ MOBILE_AI_WEIGHTS = {"ChatGPT": 10, "Gemini": 7, "Microsoft Copilot": 5, "Claude": 3, "Perplexity": 3, "Meta AI": 3, "Grok": 2, "DeepSeek": 2,
900
+ "Character.AI": 1, "Replika": 1, "Otter": 2, "Grammarly": 2}
901
+
902
+
903
+ def mobile_questions():
904
+ with open(os.path.join(ROOT, "catalog", "mobile_questions.json"), encoding="utf-8") as handle:
905
+ return json.load(handle)
906
+
907
+
908
+ def answer_ok(spec, answer, baseline):
909
+ if spec.get("expected_from") == "seconds":
910
+ return answer["seconds"] is not None and 0 < answer["seconds"] <= int(baseline["SCREEN_LOCK_MAX_SECONDS"])
911
+ if spec.get("expected_from") == "passcode":
912
+ return bool(answer.get("alphanumeric")) or (answer.get("digits") or 0) >= int(baseline["MOBILE_PASSCODE_MIN_DIGITS"])
913
+ return answer.get("status") in ("Compliant", "NotApplicable")
914
+
915
+
916
+ def make_mobile_input(rng, seed, os_family, profile, baseline, questions):
917
+ p_good = PROFILES[profile]
918
+ good = lambda bias=0.0: rng.random() < min(max(p_good + bias, 0.02), 0.98)
919
+ offset = timezone(timedelta(hours=rng.choice([1, 2, 5])))
920
+ started = datetime(2026, 10, 1, tzinfo=offset) + timedelta(minutes=rng.randint(0, 60 * 24 * 6))
921
+ team = rng.choice(["fin", "hr", "dev", "ops", "sales", "legal", "mkt"])
922
+ bundle = vuln_bundle()
923
+ if os_family == "ios":
924
+ ipad = rng.random() < 0.15
925
+ os_name = "iPadOS" if ipad else "iOS"
926
+ product = next(a for a in bundle["apps"] if a["id"] == ("ipados" if ipad else "ios"))
927
+ versions = fixed_versions(product)
928
+ lines = {m: [v for v in versions if v.split(".")[0] == m] for m in ("27", "26", "18", "17")}
929
+ if good(0.05): # up to date: newest release, or the newest of the previous line (older device)
930
+ version = current_version(product) if rng.random() < 0.7 else rng.choice([lines["26"][-1], lines["18"][-1]])
931
+ else:
932
+ version = rng.choice(lines["26"][-8:-1] + lines["18"][-6:-1] + lines["17"][-3:])
933
+ version_source = "browser" if rng.random() < 0.6 else "user"
934
+ shown = ".".join(version.split(".")[:2]) if version_source == "browser" else version
935
+ browser = f"Safari {'.'.join(version.split('.')[:2])}" if rng.random() < 0.88 else f"Chrome {rng.randint(138, 142)} (iOS)"
936
+ model = "iPad" if ipad else "iPhone"
937
+ hostname = f"{team.upper()}-{model}-{rng.randint(1, 60):02d}"
938
+ else:
939
+ os_name = "Android"
940
+ version = str(rng.choice([16, 16, 15, 15, 14]) if good() else rng.choice([15, 14, 13, 12, 11, 10]))
941
+ version_source, shown = ("browser" if rng.random() < 0.85 else "user"), version
942
+ browser = rng.choice([f"Chrome {rng.randint(138, 142)}", f"Chrome {rng.randint(138, 142)}", "Samsung Internet 28.0", "Firefox 143"])
943
+ model = rng.choice(ANDROID_MODELS)
944
+ hostname = f"{team}-{model.split()[0].lower()}-{rng.randint(1, 60):02d}"
945
+
946
+ lock_set = good(0.25)
947
+ platform_authenticator = (True if rng.random() < 0.8 else rng.choice([None, False])) if lock_set else False
948
+ answers = {}
949
+ for check_id, per_os in questions["checks"].items():
950
+ spec = per_os.get(os_family)
951
+ if not spec:
952
+ continue
953
+ if check_id == "screen_lock.enabled":
954
+ if platform_authenticator is True:
955
+ continue
956
+ answers[check_id] = 0 if lock_set else 1
957
+ continue
958
+ if rng.random() < 0.04:
959
+ answers[check_id] = "not_sure"
960
+ continue
961
+ if spec.get("input") == "month":
962
+ months_back = rng.randint(0, 2) if good() else rng.randint(3, 20)
963
+ month = (started.year * 12 + started.month - 1) - months_back
964
+ answers[check_id] = f"{month // 12:04d}-{month % 12 + 1:02d}"
965
+ continue
966
+ ok_list = [i for i, a in enumerate(spec["answers"]) if answer_ok(spec, a, baseline)]
967
+ bad_list = [i for i, a in enumerate(spec["answers"]) if not answer_ok(spec, a, baseline)]
968
+ bias = 0.25 if check_id in ("mobile.jailbreak_root", "mobile.usb_debugging", "gdpr.time_sync") else 0.0
969
+ pool = ok_list if (good(bias) or not bad_list) else bad_list
970
+ answers[check_id] = rng.choice(pool)
971
+ ai_apps = [] if rng.random() < 0.4 else sorted(set(rng.choices(list(MOBILE_AI_WEIGHTS), weights=list(MOBILE_AI_WEIGHTS.values()), k=rng.choice([1, 1, 2, 3]))))
972
+ org = None
973
+ if rng.random() < 0.4:
974
+ choices, weights = zip(*ORG_ANSWERS[profile])
975
+ org = {key: rng.choices(choices, weights=weights)[0] for key in ("ORG_ROPA", "ORG_DPIA", "ORG_BREACH_PROCEDURE", "ORG_PROCESSOR_AGREEMENTS",
976
+ "ORG_AI_REGISTER", "ORG_AI_LITERACY", "ORG_HUMAN_OVERSIGHT", "ORG_AI_TRANSPARENCY")}
977
+ return {"os_family": os_family, "os_name": os_name, "os_version": shown, "version_source": version_source, "browser": browser,
978
+ "device_model": model, "hostname": hostname, "started_at": started.isoformat(timespec="seconds"), "now": started.isoformat(timespec="seconds"),
979
+ "platform_authenticator": platform_authenticator, "answers": answers, "ai_apps": ai_apps, "org": org,
980
+ "synthetic": {"synthetic_seed": seed, "synthetic_profile": profile, "collector_version": GENERATOR_VERSION}}
981
+
982
+
983
+ def run_node(script, lines, *args):
984
+ proc = subprocess.run(["node", os.path.join(ROOT, script), *args], input="".join(json.dumps(l) + "\n" for l in lines),
985
+ capture_output=True, text=True, check=True)
986
+ return [json.loads(l) for l in proc.stdout.splitlines() if l.strip()]
987
+
988
+
989
  def random_hostname(rng, os_family):
990
  if os_family == "windows":
991
  return rng.choice(["DESKTOP-", "LAPTOP-"]) + "".join(rng.choices(string.ascii_uppercase + string.digits, k=7))
 
1041
  "status": status, "actual": actual, "expected": expected}
1042
  if detail:
1043
  item["detail"] = detail
1044
+ if check_id == "vuln.known_vulnerabilities" and status == "Pending" and actual == "matching":
1045
+ VULN_JOBS.append((item, device.vuln_job))
1046
  results.append(item)
1047
 
1048
  summary = {s: sum(1 for r in results if r["status"] == s) for s in catalog["statuses"]}
 
1064
  parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
1065
  parser.add_argument("--count", type=int, default=100, help="number of audit runs to generate")
1066
  parser.add_argument("--seed", type=int, default=1, help="random seed (same seed = same data)")
1067
+ parser.add_argument("--os", default="windows,linux,macos,ios,android", help="comma-separated OS families")
1068
  parser.add_argument("--baseline", default=os.path.join(ROOT, "baseline.conf"))
1069
  parser.add_argument("--out", default=os.path.join(ROOT, "synthetic", "out", "evidence.jsonl"), help="JSONL output file")
1070
  parser.add_argument("--files", help="write one pretty JSON file per run into this folder instead of JSONL")
 
1074
  baseline = read_conf(args.baseline)
1075
  families = [f.strip() for f in args.os.split(",") if f.strip()]
1076
  rng = random.Random(args.seed)
1077
+ records, mobile_inputs = [], []
1078
+ questions = mobile_questions()
1079
  for index in range(args.count):
1080
  record_seed = args.seed * 1_000_003 + index
1081
  local = random.Random(record_seed)
1082
+ family, profile = rng.choice(families), rng.choices(list(PROFILES), weights=[2, 5, 3])[0]
1083
+ if family in ("ios", "android"):
1084
+ mobile_inputs.append((index, make_mobile_input(local, record_seed, family, profile, baseline, questions)))
1085
+ records.append(None)
1086
+ else:
1087
+ records.append(make_record(local, record_seed, family, profile, baseline, catalog, checks))
1088
+ # Phones: the same code as the mobile web page builds the evidence.
1089
+ if mobile_inputs:
1090
+ built = run_node("mobile/build-evidence.mjs", [m for _, m in mobile_inputs])
1091
+ for (index, _), record in zip(mobile_inputs, built):
1092
+ records[index] = record
1093
+ # Desktop known vulnerabilities: real matcher, real bundle.
1094
+ if VULN_JOBS:
1095
+ for (item, _), r in zip(VULN_JOBS, run_node("synthetic/vuln-helper.mjs", [job for _, job in VULN_JOBS])):
1096
+ item.update(status=r["status"], actual=r["actual"], detail=r["detail"])
1097
+ for record in records:
1098
+ record["summary"] = {s: sum(1 for r in record["results"] if r["status"] == s) for s in catalog["statuses"]}
1099
 
1100
  if args.files:
1101
  os.makedirs(args.files, exist_ok=True)
synthetic/samples/synthetic-android-42000126.json ADDED
@@ -0,0 +1,346 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "schema_version": "1.0",
3
+ "catalog_version": "0.3.0",
4
+ "source": {
5
+ "kind": "synthetic",
6
+ "collector": "android",
7
+ "collector_version": "0.3.0",
8
+ "synthetic_seed": 42000126,
9
+ "synthetic_profile": "hardened"
10
+ },
11
+ "audit": {
12
+ "mode": "READ-ONLY",
13
+ "started_at": "2026-10-05T06:12:00+05:00",
14
+ "execution_user": "phone self-check (browser)",
15
+ "is_admin": false
16
+ },
17
+ "host": {
18
+ "hostname": "sales-oneplus-32",
19
+ "os_family": "android",
20
+ "os_name": "Android",
21
+ "os_version": "14",
22
+ "device_model": "OnePlus 12",
23
+ "browser": "Chrome 142"
24
+ },
25
+ "baseline": {
26
+ "PASSWORD_MIN_LENGTH": "14",
27
+ "PASSWORD_HISTORY": "24",
28
+ "PASSWORD_MAX_AGE_DAYS": "365",
29
+ "PASSWORD_MIN_AGE_DAYS": "1",
30
+ "PASSWORD_COMPLEXITY": "1",
31
+ "LOCKOUT_THRESHOLD_MAX": "5",
32
+ "LOCKOUT_DURATION_MIN_MINUTES": "15",
33
+ "LOCKOUT_OBSERVATION_MIN_MINUTES": "15",
34
+ "SIGNATURE_MAX_AGE_DAYS": "3",
35
+ "MACOS_XPROTECT_MAX_AGE_DAYS": "30",
36
+ "FIREWALL_OUTBOUND": "allow",
37
+ "SCREEN_LOCK_MAX_SECONDS": "300",
38
+ "USB_DEVICE_ACCESS": "allow",
39
+ "USB_READ": "allow",
40
+ "USB_WRITE": "deny",
41
+ "APPROVED_ADMINS": "itadmin",
42
+ "ALLOW_DISABLED_BUILTIN_ADMIN": "1",
43
+ "MAX_PENDING_SECURITY_UPDATES": "0",
44
+ "MAX_OUTDATED_APPS": "0",
45
+ "MAX_CRITICAL_HIGH_VULNERABILITIES": "0",
46
+ "UPDATES_MAX_AGE_DAYS": "30",
47
+ "BACKUP_MAX_AGE_DAYS": "7",
48
+ "APPROVED_REMOTE_SERVICES": "",
49
+ "WINDOWS_MIN_BUILD": "22631",
50
+ "MACOS_MIN_MAJOR": "14",
51
+ "UBUNTU_MIN_VERSION": "22.04",
52
+ "DEBIAN_MIN_VERSION": "12",
53
+ "RHEL_MIN_VERSION": "9",
54
+ "PERSONAL_DATA_SCAN": "0",
55
+ "APPROVED_AI_TOOLS": "",
56
+ "IOS_MIN_MAJOR": "18",
57
+ "ANDROID_MIN_MAJOR": "13",
58
+ "MOBILE_PATCH_MAX_AGE_DAYS": "90",
59
+ "MOBILE_PASSCODE_MIN_DIGITS": "6"
60
+ },
61
+ "results": [
62
+ {
63
+ "id": "malware.antivirus",
64
+ "section": "baseline",
65
+ "name": "Google Play Protect",
66
+ "status": "Compliant",
67
+ "actual": "Play Protect scanning on",
68
+ "expected": "on",
69
+ "evidence": "self-reported"
70
+ },
71
+ {
72
+ "id": "screen_lock.enabled",
73
+ "section": "baseline",
74
+ "name": "Screen lock",
75
+ "status": "Compliant",
76
+ "actual": "screen lock set",
77
+ "expected": "PIN, password or pattern",
78
+ "detail": "The browser reports that a device unlock method (passcode, PIN, Face ID or fingerprint) is set up.",
79
+ "evidence": "detected"
80
+ },
81
+ {
82
+ "id": "screen_lock.timeout",
83
+ "section": "baseline",
84
+ "name": "Screen timeout",
85
+ "status": "Compliant",
86
+ "actual": "Screen timeout 2 minutes",
87
+ "expected": "<= 300 seconds",
88
+ "evidence": "self-reported"
89
+ },
90
+ {
91
+ "id": "mobile.passcode_strength",
92
+ "section": "baseline",
93
+ "name": "Screen lock strength",
94
+ "status": "Compliant",
95
+ "actual": "6-digit PIN",
96
+ "expected": ">= 6 digits or alphanumeric",
97
+ "evidence": "self-reported"
98
+ },
99
+ {
100
+ "id": "mobile.jailbreak_root",
101
+ "section": "baseline",
102
+ "name": "Not rooted",
103
+ "status": "Compliant",
104
+ "actual": "not rooted (user confirmed)",
105
+ "expected": "not rooted",
106
+ "evidence": "self-reported"
107
+ },
108
+ {
109
+ "id": "mobile.app_sources",
110
+ "section": "baseline",
111
+ "name": "Install unknown apps blocked",
112
+ "status": "Compliant",
113
+ "actual": "unknown apps blocked for all apps",
114
+ "expected": "all apps: Not allowed",
115
+ "evidence": "self-reported"
116
+ },
117
+ {
118
+ "id": "mobile.usb_debugging",
119
+ "section": "baseline",
120
+ "name": "USB debugging off",
121
+ "status": "Compliant",
122
+ "actual": "off",
123
+ "expected": "off",
124
+ "evidence": "self-reported"
125
+ },
126
+ {
127
+ "id": "mobile.find_my_device",
128
+ "section": "baseline",
129
+ "name": "Find My Device",
130
+ "status": "Compliant",
131
+ "actual": "on",
132
+ "expected": "on",
133
+ "evidence": "self-reported"
134
+ },
135
+ {
136
+ "id": "encryption.enabled",
137
+ "section": "baseline",
138
+ "name": "Device encryption",
139
+ "status": "Compliant",
140
+ "actual": "on (Android 14 encrypts by default)",
141
+ "expected": "on",
142
+ "evidence": "detected"
143
+ },
144
+ {
145
+ "id": "vuln.pending_updates",
146
+ "section": "baseline",
147
+ "name": "Android security update",
148
+ "status": "Compliant",
149
+ "actual": "security patch 2026-10 (0 months old)",
150
+ "expected": "<= 90 days old",
151
+ "evidence": "self-reported"
152
+ },
153
+ {
154
+ "id": "vuln.outdated_apps",
155
+ "section": "baseline",
156
+ "name": "App updates",
157
+ "status": "Compliant",
158
+ "actual": "automatic app updates on",
159
+ "expected": "automatic app updates on",
160
+ "evidence": "self-reported"
161
+ },
162
+ {
163
+ "id": "gdpr.auto_updates",
164
+ "section": "gdpr",
165
+ "name": "Automatic system updates",
166
+ "status": "Compliant",
167
+ "actual": "automatic",
168
+ "expected": "automatic",
169
+ "evidence": "self-reported"
170
+ },
171
+ {
172
+ "id": "gdpr.os_supported",
173
+ "section": "gdpr",
174
+ "name": "Supported operating system version",
175
+ "status": "Compliant",
176
+ "actual": "Android 14",
177
+ "expected": ">= Android 13",
178
+ "detail": "Version reported by the browser (Chrome 142).",
179
+ "evidence": "detected"
180
+ },
181
+ {
182
+ "id": "gdpr.time_sync",
183
+ "section": "gdpr",
184
+ "name": "Date & time set automatically",
185
+ "status": "Compliant",
186
+ "actual": "set automatically",
187
+ "expected": "set automatically",
188
+ "evidence": "self-reported"
189
+ },
190
+ {
191
+ "id": "gdpr.backup_configured",
192
+ "section": "gdpr",
193
+ "name": "Google backup",
194
+ "status": "Compliant",
195
+ "actual": "phone maker cloud backup on",
196
+ "expected": "backup on",
197
+ "evidence": "self-reported"
198
+ },
199
+ {
200
+ "id": "gdpr.telemetry",
201
+ "section": "gdpr",
202
+ "name": "Usage & diagnostics sharing minimised",
203
+ "status": "Compliant",
204
+ "actual": "usage & diagnostics off",
205
+ "expected": "usage & diagnostics off",
206
+ "evidence": "self-reported"
207
+ },
208
+ {
209
+ "id": "gdpr.lock_screen_previews",
210
+ "section": "gdpr",
211
+ "name": "Sensitive notifications hidden when locked",
212
+ "status": "Compliant",
213
+ "actual": "no notifications on lock screen",
214
+ "expected": "sensitive content hidden",
215
+ "evidence": "self-reported"
216
+ },
217
+ {
218
+ "id": "aiact.inventory",
219
+ "section": "aiact",
220
+ "name": "AI software inventory",
221
+ "status": "Compliant",
222
+ "actual": "ChatGPT, Gemini, Perplexity",
223
+ "expected": "inventory taken",
224
+ "detail": "AI apps listed by the user.",
225
+ "evidence": "self-reported"
226
+ },
227
+ {
228
+ "id": "aiact.unapproved",
229
+ "section": "aiact",
230
+ "name": "Unapproved AI tools (shadow AI)",
231
+ "status": "Non-Compliant",
232
+ "actual": "ChatGPT, Gemini, Perplexity",
233
+ "expected": "only: none",
234
+ "evidence": "self-reported"
235
+ },
236
+ {
237
+ "id": "aiact.risk_classification",
238
+ "section": "aiact",
239
+ "name": "AI system risk classification",
240
+ "status": "Pending",
241
+ "actual": "ChatGPT, Gemini, Perplexity",
242
+ "expected": "every AI tool classified",
243
+ "detail": "Classified by the auditor model (Caveat classifier) in a later step.",
244
+ "evidence": "self-reported"
245
+ },
246
+ {
247
+ "id": "aiact.os_ai_features",
248
+ "section": "aiact",
249
+ "name": "AI assistant access restricted",
250
+ "status": "Pending",
251
+ "actual": "not answered",
252
+ "expected": "screen access off unless approved",
253
+ "detail": "The user could not find or confirm this setting.",
254
+ "evidence": "self-reported"
255
+ },
256
+ {
257
+ "id": "aiact.log_retention",
258
+ "section": "aiact",
259
+ "name": "AI usage log retention",
260
+ "status": "Pending",
261
+ "actual": "depends on classification",
262
+ "expected": ">= 6 months for high-risk AI",
263
+ "detail": "Required only if a tool is classified high-risk.",
264
+ "evidence": "self-reported"
265
+ },
266
+ {
267
+ "id": "org.ropa",
268
+ "section": "org",
269
+ "name": "Record of processing activities",
270
+ "status": "Pending",
271
+ "actual": "not answered",
272
+ "expected": "yes",
273
+ "detail": "Answered once by the organisation (organisational.conf); not part of the phone self-check."
274
+ },
275
+ {
276
+ "id": "org.dpia",
277
+ "section": "org",
278
+ "name": "Data protection impact assessment",
279
+ "status": "Pending",
280
+ "actual": "not answered",
281
+ "expected": "yes",
282
+ "detail": "Answered once by the organisation (organisational.conf); not part of the phone self-check."
283
+ },
284
+ {
285
+ "id": "org.breach_procedure",
286
+ "section": "org",
287
+ "name": "Breach response procedure",
288
+ "status": "Pending",
289
+ "actual": "not answered",
290
+ "expected": "yes",
291
+ "detail": "Answered once by the organisation (organisational.conf); not part of the phone self-check."
292
+ },
293
+ {
294
+ "id": "org.processor_agreements",
295
+ "section": "org",
296
+ "name": "Data processing agreements",
297
+ "status": "Pending",
298
+ "actual": "not answered",
299
+ "expected": "yes",
300
+ "detail": "Answered once by the organisation (organisational.conf); not part of the phone self-check."
301
+ },
302
+ {
303
+ "id": "org.ai_register",
304
+ "section": "org",
305
+ "name": "AI register",
306
+ "status": "Pending",
307
+ "actual": "not answered",
308
+ "expected": "yes",
309
+ "detail": "Answered once by the organisation (organisational.conf); not part of the phone self-check."
310
+ },
311
+ {
312
+ "id": "org.ai_literacy",
313
+ "section": "org",
314
+ "name": "AI literacy training",
315
+ "status": "Pending",
316
+ "actual": "not answered",
317
+ "expected": "yes",
318
+ "detail": "Answered once by the organisation (organisational.conf); not part of the phone self-check."
319
+ },
320
+ {
321
+ "id": "org.human_oversight",
322
+ "section": "org",
323
+ "name": "Human oversight for high-risk AI",
324
+ "status": "Pending",
325
+ "actual": "not answered",
326
+ "expected": "yes",
327
+ "detail": "Answered once by the organisation (organisational.conf); not part of the phone self-check."
328
+ },
329
+ {
330
+ "id": "org.ai_transparency",
331
+ "section": "org",
332
+ "name": "AI transparency to users",
333
+ "status": "Pending",
334
+ "actual": "not answered",
335
+ "expected": "yes",
336
+ "detail": "Answered once by the organisation (organisational.conf); not part of the phone self-check."
337
+ }
338
+ ],
339
+ "summary": {
340
+ "Compliant": 18,
341
+ "Non-Compliant": 1,
342
+ "NotApplicable": 0,
343
+ "Pending": 11,
344
+ "Error": 0
345
+ }
346
+ }
synthetic/samples/synthetic-ios-42000126.json ADDED
@@ -0,0 +1,331 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "schema_version": "1.0",
3
+ "catalog_version": "0.3.0",
4
+ "source": {
5
+ "kind": "synthetic",
6
+ "collector": "ios",
7
+ "collector_version": "0.3.0",
8
+ "synthetic_seed": 42000126,
9
+ "synthetic_profile": "hardened"
10
+ },
11
+ "audit": {
12
+ "mode": "READ-ONLY",
13
+ "started_at": "2026-10-05T06:12:00+05:00",
14
+ "execution_user": "phone self-check (browser)",
15
+ "is_admin": false
16
+ },
17
+ "host": {
18
+ "hostname": "SALES-iPhone-39",
19
+ "os_family": "ios",
20
+ "os_name": "iOS",
21
+ "os_version": "26.7",
22
+ "device_model": "iPhone",
23
+ "browser": "Safari 26.7"
24
+ },
25
+ "baseline": {
26
+ "PASSWORD_MIN_LENGTH": "14",
27
+ "PASSWORD_HISTORY": "24",
28
+ "PASSWORD_MAX_AGE_DAYS": "365",
29
+ "PASSWORD_MIN_AGE_DAYS": "1",
30
+ "PASSWORD_COMPLEXITY": "1",
31
+ "LOCKOUT_THRESHOLD_MAX": "5",
32
+ "LOCKOUT_DURATION_MIN_MINUTES": "15",
33
+ "LOCKOUT_OBSERVATION_MIN_MINUTES": "15",
34
+ "SIGNATURE_MAX_AGE_DAYS": "3",
35
+ "MACOS_XPROTECT_MAX_AGE_DAYS": "30",
36
+ "FIREWALL_OUTBOUND": "allow",
37
+ "SCREEN_LOCK_MAX_SECONDS": "300",
38
+ "USB_DEVICE_ACCESS": "allow",
39
+ "USB_READ": "allow",
40
+ "USB_WRITE": "deny",
41
+ "APPROVED_ADMINS": "itadmin",
42
+ "ALLOW_DISABLED_BUILTIN_ADMIN": "1",
43
+ "MAX_PENDING_SECURITY_UPDATES": "0",
44
+ "MAX_OUTDATED_APPS": "0",
45
+ "MAX_CRITICAL_HIGH_VULNERABILITIES": "0",
46
+ "UPDATES_MAX_AGE_DAYS": "30",
47
+ "BACKUP_MAX_AGE_DAYS": "7",
48
+ "APPROVED_REMOTE_SERVICES": "",
49
+ "WINDOWS_MIN_BUILD": "22631",
50
+ "MACOS_MIN_MAJOR": "14",
51
+ "UBUNTU_MIN_VERSION": "22.04",
52
+ "DEBIAN_MIN_VERSION": "12",
53
+ "RHEL_MIN_VERSION": "9",
54
+ "PERSONAL_DATA_SCAN": "0",
55
+ "APPROVED_AI_TOOLS": "",
56
+ "IOS_MIN_MAJOR": "18",
57
+ "ANDROID_MIN_MAJOR": "13",
58
+ "MOBILE_PATCH_MAX_AGE_DAYS": "90",
59
+ "MOBILE_PASSCODE_MIN_DIGITS": "6"
60
+ },
61
+ "results": [
62
+ {
63
+ "id": "screen_lock.enabled",
64
+ "section": "baseline",
65
+ "name": "Passcode",
66
+ "status": "Compliant",
67
+ "actual": "passcode set",
68
+ "expected": "passcode set",
69
+ "evidence": "self-reported"
70
+ },
71
+ {
72
+ "id": "screen_lock.timeout",
73
+ "section": "baseline",
74
+ "name": "Auto-Lock",
75
+ "status": "Compliant",
76
+ "actual": "Auto-Lock 5 minutes",
77
+ "expected": "<= 300 seconds",
78
+ "evidence": "self-reported"
79
+ },
80
+ {
81
+ "id": "mobile.passcode_strength",
82
+ "section": "baseline",
83
+ "name": "Passcode strength",
84
+ "status": "Compliant",
85
+ "actual": "alphanumeric",
86
+ "expected": ">= 6 digits or alphanumeric",
87
+ "evidence": "self-reported"
88
+ },
89
+ {
90
+ "id": "mobile.jailbreak_root",
91
+ "section": "baseline",
92
+ "name": "Not jailbroken",
93
+ "status": "Compliant",
94
+ "actual": "not jailbroken (user confirmed)",
95
+ "expected": "not jailbroken",
96
+ "evidence": "self-reported"
97
+ },
98
+ {
99
+ "id": "mobile.app_sources",
100
+ "section": "baseline",
101
+ "name": "Apps only from the App Store",
102
+ "status": "Compliant",
103
+ "actual": "App Store only",
104
+ "expected": "App Store only",
105
+ "evidence": "self-reported"
106
+ },
107
+ {
108
+ "id": "mobile.find_my_device",
109
+ "section": "baseline",
110
+ "name": "Find My iPhone",
111
+ "status": "Compliant",
112
+ "actual": "on",
113
+ "expected": "on",
114
+ "evidence": "self-reported"
115
+ },
116
+ {
117
+ "id": "encryption.enabled",
118
+ "section": "baseline",
119
+ "name": "Data protection (encryption)",
120
+ "status": "Compliant",
121
+ "actual": "on (passcode set)",
122
+ "expected": "on",
123
+ "detail": "iOS encrypts the data on the phone with hardware keys once a passcode is set.",
124
+ "evidence": "self-reported"
125
+ },
126
+ {
127
+ "id": "vuln.pending_updates",
128
+ "section": "baseline",
129
+ "name": "iOS security updates",
130
+ "status": "Non-Compliant",
131
+ "actual": "iOS 26.7 (update to 26.7.1 available)",
132
+ "expected": "latest iOS security release",
133
+ "detail": "Install iOS 26.7.1 or later in Settings › General › Software Update.",
134
+ "evidence": "detected"
135
+ },
136
+ {
137
+ "id": "vuln.outdated_apps",
138
+ "section": "baseline",
139
+ "name": "App updates",
140
+ "status": "Compliant",
141
+ "actual": "automatic app updates on",
142
+ "expected": "automatic app updates on",
143
+ "evidence": "self-reported"
144
+ },
145
+ {
146
+ "id": "vuln.known_vulnerabilities",
147
+ "section": "baseline",
148
+ "name": "Known vulnerabilities in iOS",
149
+ "status": "Non-Compliant",
150
+ "actual": "15 critical/high",
151
+ "expected": "<= 0 critical/high",
152
+ "detail": "Matched against offline OSV/NVD data from 2026-10-08. iOS 26.7 checked against NVD iOS records. iOS 26.7 (15 CVEs, critical, actively exploited; fixed in 27.0; e.g. CVE-2026-20683, CVE-2026-43688). 32 medium/low. Patch-level versions (x.y.z) are not always visible to the browser; confirm the version in Settings › General › About.",
153
+ "evidence": "detected"
154
+ },
155
+ {
156
+ "id": "gdpr.auto_updates",
157
+ "section": "gdpr",
158
+ "name": "Automatic iOS updates",
159
+ "status": "Compliant",
160
+ "actual": "download and install on",
161
+ "expected": "download and install on",
162
+ "evidence": "self-reported"
163
+ },
164
+ {
165
+ "id": "gdpr.os_supported",
166
+ "section": "gdpr",
167
+ "name": "Supported operating system version",
168
+ "status": "Compliant",
169
+ "actual": "iOS 26.7",
170
+ "expected": ">= iOS 18",
171
+ "detail": "Version reported by the browser (Safari 26.7).",
172
+ "evidence": "detected"
173
+ },
174
+ {
175
+ "id": "gdpr.time_sync",
176
+ "section": "gdpr",
177
+ "name": "Date & time set automatically",
178
+ "status": "Compliant",
179
+ "actual": "set automatically",
180
+ "expected": "set automatically",
181
+ "evidence": "self-reported"
182
+ },
183
+ {
184
+ "id": "gdpr.backup_configured",
185
+ "section": "gdpr",
186
+ "name": "iCloud Backup",
187
+ "status": "Compliant",
188
+ "actual": "backed up to a computer",
189
+ "expected": "backup on",
190
+ "detail": "Manual computer backups depend on the user remembering them.",
191
+ "evidence": "self-reported"
192
+ },
193
+ {
194
+ "id": "gdpr.telemetry",
195
+ "section": "gdpr",
196
+ "name": "Analytics sharing minimised",
197
+ "status": "Compliant",
198
+ "actual": "analytics sharing off",
199
+ "expected": "analytics sharing off",
200
+ "evidence": "self-reported"
201
+ },
202
+ {
203
+ "id": "gdpr.lock_screen_previews",
204
+ "section": "gdpr",
205
+ "name": "Notification previews hidden when locked",
206
+ "status": "Compliant",
207
+ "actual": "Never",
208
+ "expected": "When Unlocked or Never",
209
+ "evidence": "self-reported"
210
+ },
211
+ {
212
+ "id": "aiact.inventory",
213
+ "section": "aiact",
214
+ "name": "AI software inventory",
215
+ "status": "Compliant",
216
+ "actual": "ChatGPT, Microsoft Copilot",
217
+ "expected": "inventory taken",
218
+ "detail": "AI apps listed by the user.",
219
+ "evidence": "self-reported"
220
+ },
221
+ {
222
+ "id": "aiact.unapproved",
223
+ "section": "aiact",
224
+ "name": "Unapproved AI tools (shadow AI)",
225
+ "status": "Non-Compliant",
226
+ "actual": "ChatGPT, Microsoft Copilot",
227
+ "expected": "only: none",
228
+ "evidence": "self-reported"
229
+ },
230
+ {
231
+ "id": "aiact.risk_classification",
232
+ "section": "aiact",
233
+ "name": "AI system risk classification",
234
+ "status": "Pending",
235
+ "actual": "ChatGPT, Microsoft Copilot",
236
+ "expected": "every AI tool classified",
237
+ "detail": "Classified by the auditor model (Caveat classifier) in a later step.",
238
+ "evidence": "self-reported"
239
+ },
240
+ {
241
+ "id": "aiact.os_ai_features",
242
+ "section": "aiact",
243
+ "name": "Apple Intelligence external integrations restricted",
244
+ "status": "NotApplicable",
245
+ "actual": "Apple Intelligence not available",
246
+ "expected": "external AI off unless approved",
247
+ "evidence": "self-reported"
248
+ },
249
+ {
250
+ "id": "aiact.log_retention",
251
+ "section": "aiact",
252
+ "name": "AI usage log retention",
253
+ "status": "Pending",
254
+ "actual": "depends on classification",
255
+ "expected": ">= 6 months for high-risk AI",
256
+ "detail": "Required only if a tool is classified high-risk.",
257
+ "evidence": "self-reported"
258
+ },
259
+ {
260
+ "id": "org.ropa",
261
+ "section": "org",
262
+ "name": "Record of processing activities",
263
+ "status": "Compliant",
264
+ "actual": "yes",
265
+ "expected": "yes"
266
+ },
267
+ {
268
+ "id": "org.dpia",
269
+ "section": "org",
270
+ "name": "Data protection impact assessment",
271
+ "status": "Compliant",
272
+ "actual": "yes",
273
+ "expected": "yes"
274
+ },
275
+ {
276
+ "id": "org.breach_procedure",
277
+ "section": "org",
278
+ "name": "Breach response procedure",
279
+ "status": "Compliant",
280
+ "actual": "yes",
281
+ "expected": "yes"
282
+ },
283
+ {
284
+ "id": "org.processor_agreements",
285
+ "section": "org",
286
+ "name": "Data processing agreements",
287
+ "status": "Compliant",
288
+ "actual": "yes",
289
+ "expected": "yes"
290
+ },
291
+ {
292
+ "id": "org.ai_register",
293
+ "section": "org",
294
+ "name": "AI register",
295
+ "status": "Non-Compliant",
296
+ "actual": "partial",
297
+ "expected": "yes"
298
+ },
299
+ {
300
+ "id": "org.ai_literacy",
301
+ "section": "org",
302
+ "name": "AI literacy training",
303
+ "status": "Compliant",
304
+ "actual": "yes",
305
+ "expected": "yes"
306
+ },
307
+ {
308
+ "id": "org.human_oversight",
309
+ "section": "org",
310
+ "name": "Human oversight for high-risk AI",
311
+ "status": "Compliant",
312
+ "actual": "yes",
313
+ "expected": "yes"
314
+ },
315
+ {
316
+ "id": "org.ai_transparency",
317
+ "section": "org",
318
+ "name": "AI transparency to users",
319
+ "status": "Compliant",
320
+ "actual": "yes",
321
+ "expected": "yes"
322
+ }
323
+ ],
324
+ "summary": {
325
+ "Compliant": 22,
326
+ "Non-Compliant": 4,
327
+ "NotApplicable": 1,
328
+ "Pending": 2,
329
+ "Error": 0
330
+ }
331
+ }
synthetic/samples/synthetic-linux-42000126.json CHANGED
@@ -1,10 +1,10 @@
1
  {
2
  "schema_version": "1.0",
3
- "catalog_version": "0.2.0",
4
  "source": {
5
  "kind": "synthetic",
6
  "collector": "linux",
7
- "collector_version": "0.2.0",
8
  "synthetic_seed": 42000126,
9
  "synthetic_profile": "hardened"
10
  },
@@ -53,7 +53,11 @@
53
  "DEBIAN_MIN_VERSION": "12",
54
  "RHEL_MIN_VERSION": "9",
55
  "PERSONAL_DATA_SCAN": "0",
56
- "APPROVED_AI_TOOLS": ""
 
 
 
 
57
  },
58
  "results": [
59
  {
@@ -290,18 +294,18 @@
290
  "section": "baseline",
291
  "name": "Pending security updates",
292
  "status": "Non-Compliant",
293
- "actual": "6 pending",
294
  "expected": "<= 0 pending",
295
- "detail": "Including: libxml2, curl."
296
  },
297
  {
298
  "id": "vuln.outdated_apps",
299
  "section": "baseline",
300
  "name": "Outdated third-party applications",
301
- "status": "Non-Compliant",
302
- "actual": "4 outdated",
303
  "expected": "<= 0 outdated",
304
- "detail": "chromium (snap) 124.4 -> 124.4; libreoffice 61.1 -> 63.7; zoom (flatpak) 115.3 -> 115.6; thunderbird 122.7 -> 124.5."
305
  },
306
  {
307
  "id": "vuln.known_vulnerabilities",
@@ -310,7 +314,7 @@
310
  "status": "Compliant",
311
  "actual": "0 critical/high",
312
  "expected": "<= 0 critical/high",
313
- "detail": "Matched against offline OSV/NVD data from 2026-09-09. 0 medium/low."
314
  },
315
  {
316
  "id": "gdpr.auto_updates",
@@ -325,9 +329,9 @@
325
  "section": "gdpr",
326
  "name": "Security updates installed recently",
327
  "status": "Compliant",
328
- "actual": "21 days ago",
329
  "expected": "<= 30 days ago",
330
- "detail": "Last package upgrade: 2026-09-14 03:19:46"
331
  },
332
  {
333
  "id": "gdpr.os_supported",
@@ -358,17 +362,16 @@
358
  "section": "gdpr",
359
  "name": "Backup configured",
360
  "status": "Compliant",
361
- "actual": "restic-backup.timer",
362
  "expected": "backup configured"
363
  },
364
  {
365
  "id": "gdpr.backup_recent",
366
  "section": "gdpr",
367
  "name": "Recent backup",
368
- "status": "Pending",
369
- "actual": "date not readable",
370
- "expected": "<= 7 days ago",
371
- "detail": "Confirm the last backup date in: restic-backup.timer."
372
  },
373
  {
374
  "id": "gdpr.removable_encryption",
@@ -416,26 +419,24 @@
416
  "section": "aiact",
417
  "name": "AI software inventory",
418
  "status": "Compliant",
419
- "actual": "Hugging Face models",
420
- "expected": "inventory taken",
421
- "detail": "Local models: /home/jdoe/.cache/huggingface/hub (17G)."
422
  },
423
  {
424
  "id": "aiact.unapproved",
425
  "section": "aiact",
426
  "name": "Unapproved AI tools (shadow AI)",
427
- "status": "Non-Compliant",
428
- "actual": "Hugging Face models",
429
  "expected": "only: none"
430
  },
431
  {
432
  "id": "aiact.risk_classification",
433
  "section": "aiact",
434
  "name": "AI system risk classification",
435
- "status": "Pending",
436
- "actual": "Hugging Face models",
437
- "expected": "every AI tool classified",
438
- "detail": "Classified by the auditor model (Caveat classifier) in a later step."
439
  },
440
  {
441
  "id": "aiact.os_ai_features",
@@ -450,10 +451,9 @@
450
  "id": "aiact.log_retention",
451
  "section": "aiact",
452
  "name": "AI usage log retention",
453
- "status": "Pending",
454
- "actual": "depends on classification",
455
- "expected": ">= 6 months for high-risk AI",
456
- "detail": "Required only if a tool is classified high-risk."
457
  },
458
  {
459
  "id": "org.ropa",
@@ -491,8 +491,8 @@
491
  "id": "org.ai_register",
492
  "section": "org",
493
  "name": "AI register",
494
- "status": "Compliant",
495
- "actual": "yes",
496
  "expected": "yes"
497
  },
498
  {
@@ -515,16 +515,16 @@
515
  "id": "org.ai_transparency",
516
  "section": "org",
517
  "name": "AI transparency to users",
518
- "status": "Non-Compliant",
519
- "actual": "partial",
520
  "expected": "yes"
521
  }
522
  ],
523
  "summary": {
524
- "Compliant": 38,
525
- "Non-Compliant": 13,
526
- "NotApplicable": 2,
527
- "Pending": 3,
528
  "Error": 0
529
  }
530
  }
 
1
  {
2
  "schema_version": "1.0",
3
+ "catalog_version": "0.3.0",
4
  "source": {
5
  "kind": "synthetic",
6
  "collector": "linux",
7
+ "collector_version": "0.3.0",
8
  "synthetic_seed": 42000126,
9
  "synthetic_profile": "hardened"
10
  },
 
53
  "DEBIAN_MIN_VERSION": "12",
54
  "RHEL_MIN_VERSION": "9",
55
  "PERSONAL_DATA_SCAN": "0",
56
+ "APPROVED_AI_TOOLS": "",
57
+ "IOS_MIN_MAJOR": "18",
58
+ "ANDROID_MIN_MAJOR": "13",
59
+ "MOBILE_PATCH_MAX_AGE_DAYS": "90",
60
+ "MOBILE_PASSCODE_MIN_DIGITS": "6"
61
  },
62
  "results": [
63
  {
 
294
  "section": "baseline",
295
  "name": "Pending security updates",
296
  "status": "Non-Compliant",
297
+ "actual": "15 pending",
298
  "expected": "<= 0 pending",
299
+ "detail": "Including: curl, openssh-server, libsystemd0. Package lists are 1 days old."
300
  },
301
  {
302
  "id": "vuln.outdated_apps",
303
  "section": "baseline",
304
  "name": "Outdated third-party applications",
305
+ "status": "Compliant",
306
+ "actual": "0 outdated",
307
  "expected": "<= 0 outdated",
308
+ "detail": "packages: 0"
309
  },
310
  {
311
  "id": "vuln.known_vulnerabilities",
 
314
  "status": "Compliant",
315
  "actual": "0 critical/high",
316
  "expected": "<= 0 critical/high",
317
+ "detail": "Matched against offline OSV/NVD data from 2026-10-08. 609 source packages checked against ubuntu:22.04 advisories. 0 medium/low."
318
  },
319
  {
320
  "id": "gdpr.auto_updates",
 
329
  "section": "gdpr",
330
  "name": "Security updates installed recently",
331
  "status": "Compliant",
332
+ "actual": "6 days ago",
333
  "expected": "<= 30 days ago",
334
+ "detail": "Last package upgrade: 2026-09-29 05:06:17"
335
  },
336
  {
337
  "id": "gdpr.os_supported",
 
362
  "section": "gdpr",
363
  "name": "Backup configured",
364
  "status": "Compliant",
365
+ "actual": "Timeshift",
366
  "expected": "backup configured"
367
  },
368
  {
369
  "id": "gdpr.backup_recent",
370
  "section": "gdpr",
371
  "name": "Recent backup",
372
+ "status": "Compliant",
373
+ "actual": "7 days ago",
374
+ "expected": "<= 7 days ago"
 
375
  },
376
  {
377
  "id": "gdpr.removable_encryption",
 
419
  "section": "aiact",
420
  "name": "AI software inventory",
421
  "status": "Compliant",
422
+ "actual": "no AI tools found",
423
+ "expected": "inventory taken"
 
424
  },
425
  {
426
  "id": "aiact.unapproved",
427
  "section": "aiact",
428
  "name": "Unapproved AI tools (shadow AI)",
429
+ "status": "Compliant",
430
+ "actual": "none",
431
  "expected": "only: none"
432
  },
433
  {
434
  "id": "aiact.risk_classification",
435
  "section": "aiact",
436
  "name": "AI system risk classification",
437
+ "status": "NotApplicable",
438
+ "actual": "no AI tools found",
439
+ "expected": "every AI tool classified"
 
440
  },
441
  {
442
  "id": "aiact.os_ai_features",
 
451
  "id": "aiact.log_retention",
452
  "section": "aiact",
453
  "name": "AI usage log retention",
454
+ "status": "NotApplicable",
455
+ "actual": "no AI tools found",
456
+ "expected": ">= 6 months for high-risk AI"
 
457
  },
458
  {
459
  "id": "org.ropa",
 
491
  "id": "org.ai_register",
492
  "section": "org",
493
  "name": "AI register",
494
+ "status": "Non-Compliant",
495
+ "actual": "partial",
496
  "expected": "yes"
497
  },
498
  {
 
515
  "id": "org.ai_transparency",
516
  "section": "org",
517
  "name": "AI transparency to users",
518
+ "status": "Compliant",
519
+ "actual": "yes",
520
  "expected": "yes"
521
  }
522
  ],
523
  "summary": {
524
+ "Compliant": 41,
525
+ "Non-Compliant": 11,
526
+ "NotApplicable": 4,
527
+ "Pending": 0,
528
  "Error": 0
529
  }
530
  }
synthetic/samples/synthetic-macos-42000126.json CHANGED
@@ -1,10 +1,10 @@
1
  {
2
  "schema_version": "1.0",
3
- "catalog_version": "0.2.0",
4
  "source": {
5
  "kind": "synthetic",
6
  "collector": "macos",
7
- "collector_version": "0.2.0",
8
  "synthetic_seed": 42000126,
9
  "synthetic_profile": "hardened"
10
  },
@@ -53,7 +53,11 @@
53
  "DEBIAN_MIN_VERSION": "12",
54
  "RHEL_MIN_VERSION": "9",
55
  "PERSONAL_DATA_SCAN": "0",
56
- "APPROVED_AI_TOOLS": ""
 
 
 
 
57
  },
58
  "results": [
59
  {
@@ -292,17 +296,18 @@
292
  "section": "baseline",
293
  "name": "Pending security updates",
294
  "status": "Non-Compliant",
295
- "actual": "9 pending",
296
  "expected": "<= 0 pending",
297
- "detail": "Including: Background Security Improvement, macOS 26 security update."
298
  },
299
  {
300
  "id": "vuln.outdated_apps",
301
  "section": "baseline",
302
  "name": "Outdated third-party applications",
303
- "status": "Compliant",
304
- "actual": "0 outdated",
305
- "expected": "<= 0 outdated"
 
306
  },
307
  {
308
  "id": "vuln.known_vulnerabilities",
@@ -311,7 +316,7 @@
311
  "status": "Compliant",
312
  "actual": "0 critical/high",
313
  "expected": "<= 0 critical/high",
314
- "detail": "Matched against offline OSV/NVD data from 2026-10-08. 2 medium/low."
315
  },
316
  {
317
  "id": "gdpr.auto_updates",
@@ -326,9 +331,9 @@
326
  "section": "gdpr",
327
  "name": "Security updates installed recently",
328
  "status": "Compliant",
329
- "actual": "15 days ago",
330
  "expected": "<= 30 days ago",
331
- "detail": "Last software update: 2026-09-20 07:47:00 +0000"
332
  },
333
  {
334
  "id": "gdpr.os_supported",
@@ -360,17 +365,16 @@
360
  "section": "gdpr",
361
  "name": "Backup configured",
362
  "status": "Compliant",
363
- "actual": "bzserv",
364
  "expected": "backup configured"
365
  },
366
  {
367
  "id": "gdpr.backup_recent",
368
  "section": "gdpr",
369
  "name": "Recent backup",
370
- "status": "Pending",
371
- "actual": "date not readable",
372
- "expected": "<= 7 days ago",
373
- "detail": "Confirm the last backup date in bzserv."
374
  },
375
  {
376
  "id": "gdpr.removable_encryption",
@@ -400,8 +404,8 @@
400
  "id": "gdpr.telemetry",
401
  "section": "gdpr",
402
  "name": "Diagnostic data minimised",
403
- "status": "Non-Compliant",
404
- "actual": "share Mac analytics=1, share with app developers=0",
405
  "expected": "analytics sharing off"
406
  },
407
  {
@@ -418,25 +422,24 @@
418
  "section": "aiact",
419
  "name": "AI software inventory",
420
  "status": "Compliant",
421
- "actual": "Claude",
422
  "expected": "inventory taken"
423
  },
424
  {
425
  "id": "aiact.unapproved",
426
  "section": "aiact",
427
  "name": "Unapproved AI tools (shadow AI)",
428
- "status": "Non-Compliant",
429
- "actual": "Claude",
430
  "expected": "only: none"
431
  },
432
  {
433
  "id": "aiact.risk_classification",
434
  "section": "aiact",
435
  "name": "AI system risk classification",
436
- "status": "Pending",
437
- "actual": "Claude",
438
- "expected": "every AI tool classified",
439
- "detail": "Classified by the auditor model (Caveat classifier) in a later step."
440
  },
441
  {
442
  "id": "aiact.os_ai_features",
@@ -450,10 +453,9 @@
450
  "id": "aiact.log_retention",
451
  "section": "aiact",
452
  "name": "AI usage log retention",
453
- "status": "Pending",
454
- "actual": "depends on classification",
455
- "expected": ">= 6 months for high-risk AI",
456
- "detail": "Required only if a tool is classified high-risk."
457
  },
458
  {
459
  "id": "org.ropa",
@@ -475,8 +477,8 @@
475
  "id": "org.breach_procedure",
476
  "section": "org",
477
  "name": "Breach response procedure",
478
- "status": "Non-Compliant",
479
- "actual": "partial",
480
  "expected": "yes"
481
  },
482
  {
@@ -521,10 +523,10 @@
521
  }
522
  ],
523
  "summary": {
524
- "Compliant": 38,
525
- "Non-Compliant": 12,
526
- "NotApplicable": 3,
527
- "Pending": 3,
528
  "Error": 0
529
  }
530
  }
 
1
  {
2
  "schema_version": "1.0",
3
+ "catalog_version": "0.3.0",
4
  "source": {
5
  "kind": "synthetic",
6
  "collector": "macos",
7
+ "collector_version": "0.3.0",
8
  "synthetic_seed": 42000126,
9
  "synthetic_profile": "hardened"
10
  },
 
53
  "DEBIAN_MIN_VERSION": "12",
54
  "RHEL_MIN_VERSION": "9",
55
  "PERSONAL_DATA_SCAN": "0",
56
+ "APPROVED_AI_TOOLS": "",
57
+ "IOS_MIN_MAJOR": "18",
58
+ "ANDROID_MIN_MAJOR": "13",
59
+ "MOBILE_PATCH_MAX_AGE_DAYS": "90",
60
+ "MOBILE_PASSCODE_MIN_DIGITS": "6"
61
  },
62
  "results": [
63
  {
 
296
  "section": "baseline",
297
  "name": "Pending security updates",
298
  "status": "Non-Compliant",
299
+ "actual": "1 pending",
300
  "expected": "<= 0 pending",
301
+ "detail": "Including: macOS Tahoe 26.1.2-22H250."
302
  },
303
  {
304
  "id": "vuln.outdated_apps",
305
  "section": "baseline",
306
  "name": "Outdated third-party applications",
307
+ "status": "NotApplicable",
308
+ "actual": "not checked",
309
+ "expected": "<= 0 outdated",
310
+ "detail": "Homebrew is not installed, so there is no local source of current application versions."
311
  },
312
  {
313
  "id": "vuln.known_vulnerabilities",
 
316
  "status": "Compliant",
317
  "actual": "0 critical/high",
318
  "expected": "<= 0 critical/high",
319
+ "detail": "Matched against offline OSV/NVD data from 2026-10-08. 17 applications inventoried, 10 recognised (23 common applications covered). 0 medium/low."
320
  },
321
  {
322
  "id": "gdpr.auto_updates",
 
331
  "section": "gdpr",
332
  "name": "Security updates installed recently",
333
  "status": "Compliant",
334
+ "actual": "22 days ago",
335
  "expected": "<= 30 days ago",
336
+ "detail": "Last software update: 2026-09-13 00:41:00 +0000"
337
  },
338
  {
339
  "id": "gdpr.os_supported",
 
365
  "section": "gdpr",
366
  "name": "Backup configured",
367
  "status": "Compliant",
368
+ "actual": "Time Machine",
369
  "expected": "backup configured"
370
  },
371
  {
372
  "id": "gdpr.backup_recent",
373
  "section": "gdpr",
374
  "name": "Recent backup",
375
+ "status": "Compliant",
376
+ "actual": "1 days ago",
377
+ "expected": "<= 7 days ago"
 
378
  },
379
  {
380
  "id": "gdpr.removable_encryption",
 
404
  "id": "gdpr.telemetry",
405
  "section": "gdpr",
406
  "name": "Diagnostic data minimised",
407
+ "status": "Compliant",
408
+ "actual": "analytics sharing off",
409
  "expected": "analytics sharing off"
410
  },
411
  {
 
422
  "section": "aiact",
423
  "name": "AI software inventory",
424
  "status": "Compliant",
425
+ "actual": "no AI tools found",
426
  "expected": "inventory taken"
427
  },
428
  {
429
  "id": "aiact.unapproved",
430
  "section": "aiact",
431
  "name": "Unapproved AI tools (shadow AI)",
432
+ "status": "Compliant",
433
+ "actual": "none",
434
  "expected": "only: none"
435
  },
436
  {
437
  "id": "aiact.risk_classification",
438
  "section": "aiact",
439
  "name": "AI system risk classification",
440
+ "status": "NotApplicable",
441
+ "actual": "no AI tools found",
442
+ "expected": "every AI tool classified"
 
443
  },
444
  {
445
  "id": "aiact.os_ai_features",
 
453
  "id": "aiact.log_retention",
454
  "section": "aiact",
455
  "name": "AI usage log retention",
456
+ "status": "NotApplicable",
457
+ "actual": "no AI tools found",
458
+ "expected": ">= 6 months for high-risk AI"
 
459
  },
460
  {
461
  "id": "org.ropa",
 
477
  "id": "org.breach_procedure",
478
  "section": "org",
479
  "name": "Breach response procedure",
480
+ "status": "Compliant",
481
+ "actual": "yes",
482
  "expected": "yes"
483
  },
484
  {
 
523
  }
524
  ],
525
  "summary": {
526
+ "Compliant": 41,
527
+ "Non-Compliant": 9,
528
+ "NotApplicable": 6,
529
+ "Pending": 0,
530
  "Error": 0
531
  }
532
  }
synthetic/samples/synthetic-windows-42000126.json CHANGED
@@ -1,10 +1,10 @@
1
  {
2
  "schema_version": "1.0",
3
- "catalog_version": "0.2.0",
4
  "source": {
5
  "kind": "synthetic",
6
  "collector": "windows",
7
- "collector_version": "0.2.0",
8
  "synthetic_seed": 42000126,
9
  "synthetic_profile": "hardened"
10
  },
@@ -53,7 +53,11 @@
53
  "DEBIAN_MIN_VERSION": "12",
54
  "RHEL_MIN_VERSION": "9",
55
  "PERSONAL_DATA_SCAN": "0",
56
- "APPROVED_AI_TOOLS": ""
 
 
 
 
57
  },
58
  "results": [
59
  {
@@ -361,9 +365,10 @@
361
  "id": "vuln.outdated_apps",
362
  "section": "baseline",
363
  "name": "Outdated third-party applications",
364
- "status": "Compliant",
365
- "actual": "0 outdated",
366
- "expected": "<= 0 outdated"
 
367
  },
368
  {
369
  "id": "vuln.known_vulnerabilities",
@@ -372,14 +377,14 @@
372
  "status": "Compliant",
373
  "actual": "0 critical/high",
374
  "expected": "<= 0 critical/high",
375
- "detail": "Matched against offline OSV/NVD data from 2026-09-10. 1 medium/low."
376
  },
377
  {
378
  "id": "gdpr.auto_updates",
379
  "section": "gdpr",
380
  "name": "Automatic security updates",
381
  "status": "Compliant",
382
- "actual": "AUOptions=4",
383
  "expected": "automatic"
384
  },
385
  {
@@ -387,9 +392,9 @@
387
  "section": "gdpr",
388
  "name": "Security updates installed recently",
389
  "status": "Compliant",
390
- "actual": "28 days ago",
391
  "expected": "<= 30 days ago",
392
- "detail": "Last update: 2026-02 Cumulative Update for Windows 11 (2026-09-07)"
393
  },
394
  {
395
  "id": "gdpr.os_supported",
@@ -412,7 +417,7 @@
412
  "section": "gdpr",
413
  "name": "Time synchronisation",
414
  "status": "Compliant",
415
- "actual": "type NTP, service Running/Manual",
416
  "expected": "NTP or domain time"
417
  },
418
  {
@@ -452,8 +457,8 @@
452
  "id": "gdpr.remote_access",
453
  "section": "gdpr",
454
  "name": "Remote access services restricted",
455
- "status": "Non-Compliant",
456
- "actual": "smb",
457
  "expected": "only: none"
458
  },
459
  {
@@ -461,7 +466,7 @@
461
  "section": "gdpr",
462
  "name": "Diagnostic data minimised",
463
  "status": "Compliant",
464
- "actual": "AllowTelemetry=0 (Settings)",
465
  "expected": "required diagnostic data only (0-1)"
466
  },
467
  {
@@ -478,16 +483,15 @@
478
  "section": "aiact",
479
  "name": "AI software inventory",
480
  "status": "Compliant",
481
- "actual": "Cursor, Claude, Ollama",
482
- "expected": "inventory taken",
483
- "detail": "Local AI service listening on port 11434. Local models: C:\\Users\\jdoe\\.ollama\\models (51 GB)."
484
  },
485
  {
486
  "id": "aiact.unapproved",
487
  "section": "aiact",
488
  "name": "Unapproved AI tools (shadow AI)",
489
  "status": "Non-Compliant",
490
- "actual": "Cursor, Claude, Ollama",
491
  "expected": "only: none"
492
  },
493
  {
@@ -495,7 +499,7 @@
495
  "section": "aiact",
496
  "name": "AI system risk classification",
497
  "status": "Pending",
498
- "actual": "Cursor, Claude, Ollama",
499
  "expected": "every AI tool classified",
500
  "detail": "Classified by the auditor model (Caveat classifier) in a later step."
501
  },
@@ -503,10 +507,10 @@
503
  "id": "aiact.os_ai_features",
504
  "section": "aiact",
505
  "name": "Windows Recall / Copilot data analysis disabled",
506
- "status": "NotApplicable",
507
- "actual": "Recall not available on this device",
508
  "expected": "disabled by policy",
509
- "detail": "DisableAIDataAnalysis=; AllowRecallEnablement=; TurnOffWindowsCopilot="
510
  },
511
  {
512
  "id": "aiact.log_retention",
@@ -521,8 +525,8 @@
521
  "id": "org.ropa",
522
  "section": "org",
523
  "name": "Record of processing activities",
524
- "status": "Compliant",
525
- "actual": "yes",
526
  "expected": "yes"
527
  },
528
  {
@@ -577,17 +581,16 @@
577
  "id": "org.ai_transparency",
578
  "section": "org",
579
  "name": "AI transparency to users",
580
- "status": "Pending",
581
- "actual": "not answered",
582
- "expected": "yes",
583
- "detail": "Answer ORG_AI_TRANSPARENCY in organisational.conf."
584
  }
585
  ],
586
  "summary": {
587
- "Compliant": 55,
588
  "Non-Compliant": 3,
589
  "NotApplicable": 2,
590
- "Pending": 4,
591
  "Error": 0
592
  }
593
  }
 
1
  {
2
  "schema_version": "1.0",
3
+ "catalog_version": "0.3.0",
4
  "source": {
5
  "kind": "synthetic",
6
  "collector": "windows",
7
+ "collector_version": "0.3.0",
8
  "synthetic_seed": 42000126,
9
  "synthetic_profile": "hardened"
10
  },
 
53
  "DEBIAN_MIN_VERSION": "12",
54
  "RHEL_MIN_VERSION": "9",
55
  "PERSONAL_DATA_SCAN": "0",
56
+ "APPROVED_AI_TOOLS": "",
57
+ "IOS_MIN_MAJOR": "18",
58
+ "ANDROID_MIN_MAJOR": "13",
59
+ "MOBILE_PATCH_MAX_AGE_DAYS": "90",
60
+ "MOBILE_PASSCODE_MIN_DIGITS": "6"
61
  },
62
  "results": [
63
  {
 
365
  "id": "vuln.outdated_apps",
366
  "section": "baseline",
367
  "name": "Outdated third-party applications",
368
+ "status": "NotApplicable",
369
+ "actual": "not checked",
370
+ "expected": "<= 0 outdated",
371
+ "detail": "winget (App Installer) is not available for this account, so there is no local source of current application versions."
372
  },
373
  {
374
  "id": "vuln.known_vulnerabilities",
 
377
  "status": "Compliant",
378
  "actual": "0 critical/high",
379
  "expected": "<= 0 critical/high",
380
+ "detail": "Matched against offline OSV/NVD data from 2026-10-08. 14 applications inventoried, 11 recognised (31 common applications covered). 0 medium/low."
381
  },
382
  {
383
  "id": "gdpr.auto_updates",
384
  "section": "gdpr",
385
  "name": "Automatic security updates",
386
  "status": "Compliant",
387
+ "actual": "Windows default (automatic)",
388
  "expected": "automatic"
389
  },
390
  {
 
392
  "section": "gdpr",
393
  "name": "Security updates installed recently",
394
  "status": "Compliant",
395
+ "actual": "18 days ago",
396
  "expected": "<= 30 days ago",
397
+ "detail": "Last update: 2026-07 Cumulative Update for Windows 11 (2026-09-17)"
398
  },
399
  {
400
  "id": "gdpr.os_supported",
 
417
  "section": "gdpr",
418
  "name": "Time synchronisation",
419
  "status": "Compliant",
420
+ "actual": "type NT5DS, service Running/Manual",
421
  "expected": "NTP or domain time"
422
  },
423
  {
 
457
  "id": "gdpr.remote_access",
458
  "section": "gdpr",
459
  "name": "Remote access services restricted",
460
+ "status": "Compliant",
461
+ "actual": "none enabled",
462
  "expected": "only: none"
463
  },
464
  {
 
466
  "section": "gdpr",
467
  "name": "Diagnostic data minimised",
468
  "status": "Compliant",
469
+ "actual": "AllowTelemetry=1 (Settings)",
470
  "expected": "required diagnostic data only (0-1)"
471
  },
472
  {
 
483
  "section": "aiact",
484
  "name": "AI software inventory",
485
  "status": "Compliant",
486
+ "actual": "Claude Code",
487
+ "expected": "inventory taken"
 
488
  },
489
  {
490
  "id": "aiact.unapproved",
491
  "section": "aiact",
492
  "name": "Unapproved AI tools (shadow AI)",
493
  "status": "Non-Compliant",
494
+ "actual": "Claude Code",
495
  "expected": "only: none"
496
  },
497
  {
 
499
  "section": "aiact",
500
  "name": "AI system risk classification",
501
  "status": "Pending",
502
+ "actual": "Claude Code",
503
  "expected": "every AI tool classified",
504
  "detail": "Classified by the auditor model (Caveat classifier) in a later step."
505
  },
 
507
  "id": "aiact.os_ai_features",
508
  "section": "aiact",
509
  "name": "Windows Recall / Copilot data analysis disabled",
510
+ "status": "Compliant",
511
+ "actual": "Recall disabled by policy",
512
  "expected": "disabled by policy",
513
+ "detail": "DisableAIDataAnalysis=1; AllowRecallEnablement=0; TurnOffWindowsCopilot="
514
  },
515
  {
516
  "id": "aiact.log_retention",
 
525
  "id": "org.ropa",
526
  "section": "org",
527
  "name": "Record of processing activities",
528
+ "status": "Non-Compliant",
529
+ "actual": "partial",
530
  "expected": "yes"
531
  },
532
  {
 
581
  "id": "org.ai_transparency",
582
  "section": "org",
583
  "name": "AI transparency to users",
584
+ "status": "Compliant",
585
+ "actual": "yes",
586
+ "expected": "yes"
 
587
  }
588
  ],
589
  "summary": {
590
+ "Compliant": 56,
591
  "Non-Compliant": 3,
592
  "NotApplicable": 2,
593
+ "Pending": 3,
594
  "Error": 0
595
  }
596
  }
synthetic/vuln-helper.mjs ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Synthetic data helper: run generated software inventories through the real matcher (app/renderer/vuln-core.js)
2
+ // and the real offline bundle, so training data has real CVEs in exactly the app's wording.
3
+ // stdin: JSON lines {os_family, inventory, max, now} -> stdout: JSON lines {status, actual, detail}
4
+ import { readFileSync } from 'node:fs';
5
+ import { gunzipSync } from 'node:zlib';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { matchLinux, matchApps, knownVulnResult } from '../app/renderer/vuln-core.js';
9
+
10
+ const dir = process.argv[2] || path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'vulndb', 'out');
11
+ const manifest = JSON.parse(readFileSync(path.join(dir, 'manifest.json'), 'utf8'));
12
+ const bundle = JSON.parse(gunzipSync(readFileSync(path.join(dir, manifest.file))).toString('utf8'));
13
+ for (const line of readFileSync(0, 'utf8').split('\n').filter(Boolean)) {
14
+ const job = JSON.parse(line);
15
+ const matched = job.os_family === 'linux' ? matchLinux(job.inventory, bundle) : matchApps(job.inventory, bundle, job.os_family);
16
+ process.stdout.write(JSON.stringify(knownVulnResult(matched, job.inventory, { bundle, manifest }, job.os_family, job.max, Date.parse(job.now))) + '\n');
17
+ }
tools/build_controls_doc.py CHANGED
@@ -73,11 +73,37 @@ SOURCES = {
73
  "aiact.log_retention": {"windows": "depends on AI classification", "linux": "depends on AI classification", "macos": "depends on AI classification"},
74
  }
75
  ORG_SOURCE = "organisational.conf (answered once by the organisation)"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
 
77
 
78
  def main():
79
  with open(os.path.join(ROOT, "catalog", "checks.json"), encoding="utf-8") as handle:
80
  catalog = json.load(handle)
 
 
81
  titles = catalog["references"]
82
  lines = [
83
  "# Audited controls and data sources",
@@ -85,26 +111,30 @@ def main():
85
  f"Catalog version {catalog['catalog_version']} · {len(catalog['checks'])} checks. Generated by `tools/build_controls_doc.py` — do not edit by hand.",
86
  "",
87
  "All sources are read-only. Internet is only used by *Pending security updates* on Windows and macOS, and by the app to refresh the vulnerability bundle.",
 
88
  "",
89
  ]
90
  for section in catalog["sections"]:
91
  checks = [c for c in catalog["checks"] if c["section"] == section["id"]]
92
  lines += [f"## {section['title']} ({len(checks)})", "",
93
- "| Check | Applies to | References | Rule | Windows source | Linux source | macOS source |",
94
- "|---|---|---|---|---|---|---|"]
95
  for c in checks:
96
  refs = [f"ISO {r}" for r in c["controls"]["iso27001"]] + [f"GDPR {r}" for r in c["controls"]["gdpr"]] + [f"AI Act {r}" for r in c["controls"]["ai_act"]]
97
  src = SOURCES.get(c["id"], {})
98
  cells = []
99
- for os_family in ("windows", "linux", "macos"):
100
  if os_family not in c["os"]:
101
  cells.append("—")
102
  elif section["id"] == "org":
103
- cells.append(ORG_SOURCE)
 
 
104
  else:
105
  cells.append(src.get(os_family, "**missing**"))
106
  names = c.get("names", {})
107
- name = c["name"] + (" / " + " / ".join(sorted(set(names.values()))) if names else "")
 
108
  lines.append(f"| {name} | {', '.join(c['os'])} | {'; '.join(refs) or '—'} | {c['rule']} | " + " | ".join(cells) + " |")
109
  lines.append("")
110
  lines += ["## Reference titles", ""]
@@ -115,7 +145,8 @@ def main():
115
  out = os.path.join(ROOT, "docs", "CONTROLS.md")
116
  with open(out, "w", encoding="utf-8") as handle:
117
  handle.write("\n".join(lines))
118
- missing = [c["id"] for c in catalog["checks"] if c["section"] != "org" and any(o not in SOURCES.get(c["id"], {}) for o in c["os"])]
 
119
  print(f"Wrote {out}; checks without a documented source: {missing or 'none'}")
120
 
121
 
 
73
  "aiact.log_retention": {"windows": "depends on AI classification", "linux": "depends on AI classification", "macos": "depends on AI classification"},
74
  }
75
  ORG_SOURCE = "organisational.conf (answered once by the organisation)"
76
+ # Phones: what the browser detects; everything else comes from catalog/mobile_questions.json (asked, self-reported).
77
+ PHONE_DETECTED = {
78
+ "gdpr.os_supported": "detected: OS version (Safari version on iOS 26+, client hints on Android), confirmed by the user",
79
+ "vuln.known_vulnerabilities": "detected: iOS version matched against NVD iOS/iPadOS records in the signed phone bundle",
80
+ "encryption.enabled": {"ios": "derived: on when a passcode is set (iOS data protection)", "android": "detected: Android 10+ encrypts by default"},
81
+ "screen_lock.enabled": "detected: WebAuthn platform authenticator available; otherwise asked",
82
+ }
83
+ PHONE_AI = "asked: AI apps installed (checklist); classified by the auditor model"
84
+
85
+
86
+ def phone_source(check_id, os_family, questions):
87
+ det = PHONE_DETECTED.get(check_id)
88
+ if isinstance(det, dict):
89
+ det = det.get(os_family)
90
+ if check_id == "vuln.pending_updates" and os_family == "ios":
91
+ return "detected: iOS version vs newest fixed release in the signed phone bundle"
92
+ if det and check_id != "screen_lock.enabled":
93
+ return det
94
+ spec = questions["checks"].get(check_id, {}).get(os_family)
95
+ if spec:
96
+ return (det + "; " if det else "") + f"asked: {spec['where']}"
97
+ if check_id.startswith("aiact."):
98
+ return PHONE_AI
99
+ return None
100
 
101
 
102
  def main():
103
  with open(os.path.join(ROOT, "catalog", "checks.json"), encoding="utf-8") as handle:
104
  catalog = json.load(handle)
105
+ with open(os.path.join(ROOT, "catalog", "mobile_questions.json"), encoding="utf-8") as handle:
106
+ questions = json.load(handle)
107
  titles = catalog["references"]
108
  lines = [
109
  "# Audited controls and data sources",
 
111
  f"Catalog version {catalog['catalog_version']} · {len(catalog['checks'])} checks. Generated by `tools/build_controls_doc.py` — do not edit by hand.",
112
  "",
113
  "All sources are read-only. Internet is only used by *Pending security updates* on Windows and macOS, and by the app to refresh the vulnerability bundle.",
114
+ "Phones are checked in the browser: a web page cannot read a phone's settings, so results are either detected by the browser or answered by the user (self-reported), and labelled as such.",
115
  "",
116
  ]
117
  for section in catalog["sections"]:
118
  checks = [c for c in catalog["checks"] if c["section"] == section["id"]]
119
  lines += [f"## {section['title']} ({len(checks)})", "",
120
+ "| Check | Applies to | References | Rule | Windows source | Linux source | macOS source | iPhone source | Android source |",
121
+ "|---|---|---|---|---|---|---|---|---|"]
122
  for c in checks:
123
  refs = [f"ISO {r}" for r in c["controls"]["iso27001"]] + [f"GDPR {r}" for r in c["controls"]["gdpr"]] + [f"AI Act {r}" for r in c["controls"]["ai_act"]]
124
  src = SOURCES.get(c["id"], {})
125
  cells = []
126
+ for os_family in ("windows", "linux", "macos", "ios", "android"):
127
  if os_family not in c["os"]:
128
  cells.append("—")
129
  elif section["id"] == "org":
130
+ cells.append(ORG_SOURCE if os_family not in ("ios", "android") else ORG_SOURCE + "; Pending in a phone check unless passed in the link")
131
+ elif os_family in ("ios", "android"):
132
+ cells.append(phone_source(c["id"], os_family, questions) or "**missing**")
133
  else:
134
  cells.append(src.get(os_family, "**missing**"))
135
  names = c.get("names", {})
136
+ others = sorted(set(names.values()) - {c["name"]})
137
+ name = c["name"] + (" / " + " / ".join(others) if others else "")
138
  lines.append(f"| {name} | {', '.join(c['os'])} | {'; '.join(refs) or '—'} | {c['rule']} | " + " | ".join(cells) + " |")
139
  lines.append("")
140
  lines += ["## Reference titles", ""]
 
145
  out = os.path.join(ROOT, "docs", "CONTROLS.md")
146
  with open(out, "w", encoding="utf-8") as handle:
147
  handle.write("\n".join(lines))
148
+ missing = [f"{c['id']} ({o})" for c in catalog["checks"] if c["section"] != "org" for o in c["os"]
149
+ if (o in ("ios", "android") and not phone_source(c["id"], o, questions)) or (o not in ("ios", "android") and o not in SOURCES.get(c["id"], {}))]
150
  print(f"Wrote {out}; checks without a documented source: {missing or 'none'}")
151
 
152
 
tools/validate_evidence.py CHANGED
@@ -15,7 +15,7 @@ import sys
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():
@@ -38,10 +38,10 @@ def validate(record, catalog, checks):
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:
@@ -82,6 +82,12 @@ def validate(record, catalog, checks):
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")
 
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", "evidence"}
19
 
20
 
21
  def load_catalog():
 
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", "web"):
42
+ errors.append("source.kind must be collector, synthetic or web")
43
  os_family = record["host"].get("os_family")
44
+ if os_family not in ("windows", "linux", "macos", "ios", "android"):
45
  errors.append(f"host.os_family invalid: {os_family!r}")
46
  return errors
47
  if record["source"].get("collector") != os_family:
 
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
+ if "evidence" in result and result["evidence"] not in ("detected", "self-reported"):
86
+ errors.append(f"{where}: {check_id} evidence must be detected or self-reported")
87
+ if os_family in ("ios", "android") and check["section"] != "org" and "evidence" not in result:
88
+ errors.append(f"{where}: {check_id} needs an evidence source on phones")
89
+ if os_family not in ("ios", "android") and "evidence" in result:
90
+ errors.append(f"{where}: {check_id} evidence source is only used on phones")
91
  for key in ("actual", "expected") + (("detail",) if "detail" in result else ()):
92
  if not isinstance(result[key], str):
93
  errors.append(f"{where}: {check_id}.{key} must be a string")
training/build_dataset.py CHANGED
@@ -24,7 +24,7 @@ import random
24
  import re
25
 
26
  ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
27
- DATASET_VERSION = "0.2.0"
28
  SYSTEM_PROMPT = ("You are the Devseis endpoint auditor. You write audit findings from collected evidence for "
29
  "ISO 27001, GDPR and the EU AI Act. Use only the evidence and reference material in the request. "
30
  "Never invent values, dates, versions, account names or tools. Reply with JSON only.")
@@ -115,6 +115,16 @@ FINDING = {
115
  }
116
 
117
 
 
 
 
 
 
 
 
 
 
 
118
  def sentence(text):
119
  text = text.strip()
120
  return text if not text or text.endswith((".", "!", "?")) else text + "."
@@ -139,6 +149,8 @@ def build_finding(record, result, lib, rng):
139
  ]
140
  if detail:
141
  lines.append(f"DETAIL: {detail}")
 
 
142
  lines.append("REFERENCES:")
143
  lines += [f"- {label} {title}: {summary}" for label, title, summary in refs]
144
  lines.append("GUIDANCE:")
@@ -158,6 +170,8 @@ def build_finding(record, result, lib, rng):
158
  "detail": sentence(detail) if detail else "no detail", "reason": sentence(detail or result["actual"]),
159
  "pending": sentence(detail) if detail else lib.status_notes["Pending"]}
160
  finding = rng.choice(FINDING[status]).format(**fields)
 
 
161
  references = [label for label, _, _ in refs]
162
 
163
  if status == "Compliant":
@@ -185,6 +199,8 @@ def build_finding(record, result, lib, rng):
185
  "finding": finding.strip(), "risk": risk, "recommendation": recommendation, "references": references}
186
  if verification:
187
  answer["verification"] = verification
 
 
188
  return "\n".join(lines), answer
189
 
190
 
@@ -196,7 +212,11 @@ def build_summary(record, lib):
196
  host = record["host"]
197
  lines = ["TASK: summary", f"DEVICE: {device_line(record)}", f"HOSTNAME: {host['hostname']}",
198
  f"AUDIT RUN AS ADMIN: {'yes' if record['audit']['is_admin'] else 'no'}",
199
- "COUNTS: " + ", ".join(f"{k}={v}" for k, v in summary.items()), "RESULTS (id | name | status | found):"]
 
 
 
 
200
  lines += [f"- {r['id']} | {r['name']} | {r['status']} | {r['actual']}" for r in results]
201
  lines.append("RISK LEVELS (from guidance):")
202
  gaps = [r for r in results if r["status"] == "Non-Compliant"]
@@ -227,6 +247,8 @@ def build_summary(record, lib):
227
  text += f" {summary['Error']} checks could not be read" + (" because the audit was not run as administrator." if not record["audit"]["is_admin"] else ".")
228
  if summary["Pending"]:
229
  text += f" {summary['Pending']} items are pending."
 
 
230
 
231
  next_steps = []
232
  if summary["Error"]:
 
24
  import re
25
 
26
  ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
27
+ DATASET_VERSION = "0.3.0"
28
  SYSTEM_PROMPT = ("You are the Devseis endpoint auditor. You write audit findings from collected evidence for "
29
  "ISO 27001, GDPR and the EU AI Act. Use only the evidence and reference material in the request. "
30
  "Never invent values, dates, versions, account names or tools. Reply with JSON only.")
 
115
  }
116
 
117
 
118
+ EVIDENCE_TEXT = {"self-reported": "self-reported by the user", "detected": "detected by the browser"}
119
+ SELF_REPORTED = "Self-reported by the user, not verified on the device."
120
+
121
+
122
+ def evidence_counts(results):
123
+ detected = sum(1 for r in results if r.get("evidence") == "detected")
124
+ reported = sum(1 for r in results if r.get("evidence") == "self-reported")
125
+ return detected, reported
126
+
127
+
128
  def sentence(text):
129
  text = text.strip()
130
  return text if not text or text.endswith((".", "!", "?")) else text + "."
 
149
  ]
150
  if detail:
151
  lines.append(f"DETAIL: {detail}")
152
+ if result.get("evidence"):
153
+ lines.append(f"EVIDENCE: {EVIDENCE_TEXT[result['evidence']]}")
154
  lines.append("REFERENCES:")
155
  lines += [f"- {label} {title}: {summary}" for label, title, summary in refs]
156
  lines.append("GUIDANCE:")
 
170
  "detail": sentence(detail) if detail else "no detail", "reason": sentence(detail or result["actual"]),
171
  "pending": sentence(detail) if detail else lib.status_notes["Pending"]}
172
  finding = rng.choice(FINDING[status]).format(**fields)
173
+ if result.get("evidence") == "self-reported":
174
+ finding = finding.replace("The device reports", "The user reports") + " " + SELF_REPORTED
175
  references = [label for label, _, _ in refs]
176
 
177
  if status == "Compliant":
 
199
  "finding": finding.strip(), "risk": risk, "recommendation": recommendation, "references": references}
200
  if verification:
201
  answer["verification"] = verification
202
+ if result.get("evidence"):
203
+ answer["evidence"] = result["evidence"]
204
  return "\n".join(lines), answer
205
 
206
 
 
212
  host = record["host"]
213
  lines = ["TASK: summary", f"DEVICE: {device_line(record)}", f"HOSTNAME: {host['hostname']}",
214
  f"AUDIT RUN AS ADMIN: {'yes' if record['audit']['is_admin'] else 'no'}",
215
+ "COUNTS: " + ", ".join(f"{k}={v}" for k, v in summary.items())]
216
+ detected, reported = evidence_counts(results)
217
+ if detected or reported:
218
+ lines.append(f"EVIDENCE: {detected} detected by the browser, {reported} self-reported by the user")
219
+ lines.append("RESULTS (id | name | status | found):")
220
  lines += [f"- {r['id']} | {r['name']} | {r['status']} | {r['actual']}" for r in results]
221
  lines.append("RISK LEVELS (from guidance):")
222
  gaps = [r for r in results if r["status"] == "Non-Compliant"]
 
247
  text += f" {summary['Error']} checks could not be read" + (" because the audit was not run as administrator." if not record["audit"]["is_admin"] else ".")
248
  if summary["Pending"]:
249
  text += f" {summary['Pending']} items are pending."
250
+ if reported:
251
+ text += f" {reported} results are self-reported by the user and were not verified on the device."
252
 
253
  next_steps = []
254
  if summary["Error"]:
vulndb/build_bundle.py CHANGED
@@ -215,7 +215,7 @@ def build_osv(stats):
215
  # --- NVD (desktop applications) ----------------------------------------------------------------
216
  def nvd_query(cpe):
217
  """All CVEs for vendor:product, cached as raw pages."""
218
- vendor, product = cpe.split(":", 1)
219
  path = os.path.join(CACHE, "nvd", re.sub(r"[^A-Za-z0-9_.-]", "_", cpe) + ".json")
220
  if os.path.exists(path):
221
  with open(path, encoding="utf-8") as handle:
@@ -223,7 +223,7 @@ def nvd_query(cpe):
223
  os.makedirs(os.path.dirname(path), exist_ok=True)
224
  items, start = [], 0
225
  while True:
226
- params = urllib.parse.urlencode({"virtualMatchString": f"cpe:2.3:a:{vendor}:{product}", "startIndex": start, "noRejected": ""})
227
  url = f"{NVD_URL}?{params}".replace("noRejected=", "noRejected")
228
  for attempt in range(5):
229
  try:
@@ -273,7 +273,7 @@ def build_nvd(products, stats):
273
  for product in products:
274
  ranges = {}
275
  for cpe in product["cpe"]:
276
- vendor, name = cpe.split(":", 1)
277
  for item in nvd_query(cpe):
278
  cve = item["cve"]
279
  severity = nvd_severity(cve)
@@ -288,10 +288,10 @@ def build_nvd(products, stats):
288
  exact = "" if version in ("*", "-") else version.replace("\\", "")
289
  row = (cve["id"], severity, match.get("versionStartIncluding", ""), match.get("versionStartExcluding", ""),
290
  match.get("versionEndIncluding", ""), match.get("versionEndExcluding", ""), exact)
291
- if version == "-" or (not exact and not any(row[2:6])):
292
- continue # "all versions" with no bound: cannot tell fixed from unfixed
293
  ranges[row] = True
294
- apps.append({"id": product["id"], "name": product["name"], "windows": product.get("windows", ""), "macos": product.get("macos", ""),
295
  "cves": sorted(list(r) for r in ranges)})
296
  stats["nvd"][product["id"]] = len({r[0] for r in ranges})
297
  if not ranges:
@@ -384,6 +384,20 @@ def main():
384
  "counts": {"releases": stats["osv"], "apps": stats["nvd"], "kev": len(bundle["kev"]), "epss": len(bundle["epss"])},
385
  "signature": None,
386
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
387
  with open(os.path.join(OUT, "manifest.json"), "w", encoding="utf-8") as handle:
388
  json.dump(manifest, handle, indent=2)
389
  log(f"\nWrote vulndb/out/{name}: {len(data) / 1048576:.1f} MB compressed ({len(raw) / 1048576:.1f} MB raw)")
 
215
  # --- NVD (desktop applications) ----------------------------------------------------------------
216
  def nvd_query(cpe):
217
  """All CVEs for vendor:product, cached as raw pages."""
218
+ part, vendor, product = cpe.split(":", 2) if cpe.count(":") >= 2 and cpe[1] == ":" else ("a", *cpe.split(":", 1))
219
  path = os.path.join(CACHE, "nvd", re.sub(r"[^A-Za-z0-9_.-]", "_", cpe) + ".json")
220
  if os.path.exists(path):
221
  with open(path, encoding="utf-8") as handle:
 
223
  os.makedirs(os.path.dirname(path), exist_ok=True)
224
  items, start = [], 0
225
  while True:
226
+ params = urllib.parse.urlencode({"virtualMatchString": f"cpe:2.3:{part}:{vendor}:{product}", "startIndex": start, "noRejected": ""})
227
  url = f"{NVD_URL}?{params}".replace("noRejected=", "noRejected")
228
  for attempt in range(5):
229
  try:
 
273
  for product in products:
274
  ranges = {}
275
  for cpe in product["cpe"]:
276
+ vendor, name = cpe.split(":")[-2:]
277
  for item in nvd_query(cpe):
278
  cve = item["cve"]
279
  severity = nvd_severity(cve)
 
288
  exact = "" if version in ("*", "-") else version.replace("\\", "")
289
  row = (cve["id"], severity, match.get("versionStartIncluding", ""), match.get("versionStartExcluding", ""),
290
  match.get("versionEndIncluding", ""), match.get("versionEndExcluding", ""), exact)
291
+ if version == "-" or (not exact and not row[4] and not row[5]):
292
+ continue # no upper bound ("all versions" or "from version X on"): cannot tell fixed from unfixed
293
  ranges[row] = True
294
+ apps.append({"id": product["id"], "name": product["name"], "windows": product.get("windows", ""), "macos": product.get("macos", ""), "ios": product.get("ios", ""),
295
  "cves": sorted(list(r) for r in ranges)})
296
  stats["nvd"][product["id"]] = len({r[0] for r in ranges})
297
  if not ranges:
 
384
  "counts": {"releases": stats["osv"], "apps": stats["nvd"], "kev": len(bundle["kev"]), "epss": len(bundle["epss"])},
385
  "signature": None,
386
  }
387
+ # Small bundle for the phone web check: mobile OS records only.
388
+ mobile_apps = [a for a in apps if a.get("ios")]
389
+ mobile_cves = {row[0] for a in mobile_apps for row in a["cves"]}
390
+ mobile = {**{k: bundle[k] for k in ("format", "built_at", "sources")}, "notes": "Phone web check subset: iOS and iPadOS NVD records.",
391
+ "releases": {}, "apps": mobile_apps, "kev": sorted(mobile_cves & kev),
392
+ "epss": {cve: score for cve, score in bundle["epss"].items() if cve in mobile_cves}}
393
+ mobile_name = f"devseis-vulndb-mobile-{today}.json.gz"
394
+ mobile_data = gzip.compress(json.dumps(mobile, separators=(",", ":")).encode("utf-8"), 9, mtime=0)
395
+ with open(os.path.join(OUT, mobile_name), "wb") as handle:
396
+ handle.write(mobile_data)
397
+ manifest["mobile"] = {"file": mobile_name, "size": len(mobile_data), "sha256": hashlib.sha256(mobile_data).hexdigest(), "signature": None}
398
+ for old in os.listdir(OUT):
399
+ if old.startswith("devseis-vulndb-") and old not in (name, mobile_name):
400
+ os.remove(os.path.join(OUT, old))
401
  with open(os.path.join(OUT, "manifest.json"), "w", encoding="utf-8") as handle:
402
  json.dump(manifest, handle, indent=2)
403
  log(f"\nWrote vulndb/out/{name}: {len(data) / 1048576:.1f} MB compressed ({len(raw) / 1048576:.1f} MB raw)")
vulndb/products.json CHANGED
@@ -1,5 +1,5 @@
1
  {
2
- "_comment": "Desktop applications matched against NVD. match: case-insensitive regex on the Windows program name (windows) or the macOS app bundle name without .app (macos). cpe: NVD vendor:product. Keep regexes tight: a wrong match produces false findings.",
3
  "version": "0.1.0",
4
  "products": [
5
  {"id": "chrome", "name": "Google Chrome", "cpe": ["google:chrome"], "windows": "^Google Chrome$", "macos": "^Google Chrome$"},
@@ -34,6 +34,8 @@
34
  {"id": "tightvnc", "name": "TightVNC", "cpe": ["tightvnc:tightvnc", "glavsoft:tightvnc"], "windows": "^TightVNC$"},
35
  {"id": "telegram", "name": "Telegram Desktop", "cpe": ["telegram:telegram_desktop"], "windows": "^Telegram Desktop", "macos": "^Telegram$"},
36
  {"id": "safari", "name": "Safari", "cpe": ["apple:safari"], "macos": "^Safari$"},
 
 
37
  {"id": "iterm2", "name": "iTerm2", "cpe": ["iterm2:iterm2"], "macos": "^iTerm$"}
38
  ]
39
  }
 
1
  {
2
+ "_comment": "Applications (and mobile OSes) matched against NVD. Regex per OS family on the program name (windows), app bundle name without .app (macos), or \"iOS\"/\"iPadOS\" (ios, from the mobile web check). cpe: NVD [part:]vendor:product, part defaults to a. Keep regexes tight: a wrong match produces false findings.",
3
  "version": "0.1.0",
4
  "products": [
5
  {"id": "chrome", "name": "Google Chrome", "cpe": ["google:chrome"], "windows": "^Google Chrome$", "macos": "^Google Chrome$"},
 
34
  {"id": "tightvnc", "name": "TightVNC", "cpe": ["tightvnc:tightvnc", "glavsoft:tightvnc"], "windows": "^TightVNC$"},
35
  {"id": "telegram", "name": "Telegram Desktop", "cpe": ["telegram:telegram_desktop"], "windows": "^Telegram Desktop", "macos": "^Telegram$"},
36
  {"id": "safari", "name": "Safari", "cpe": ["apple:safari"], "macos": "^Safari$"},
37
+ {"id": "ios", "name": "iOS", "cpe": ["o:apple:iphone_os"], "ios": "^iOS$"},
38
+ {"id": "ipados", "name": "iPadOS", "cpe": ["o:apple:ipados"], "ios": "^iPadOS$"},
39
  {"id": "iterm2", "name": "iTerm2", "cpe": ["iterm2:iterm2"], "macos": "^iTerm$"}
40
  ]
41
  }
vulndb/sign-bundle.mjs CHANGED
@@ -33,6 +33,11 @@ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
33
  const sha = createHash('sha256').update(readFileSync(path.join(out, manifest.file))).digest('hex');
34
  if (sha !== manifest.sha256) throw new Error(`${manifest.file} does not match manifest.json; rebuild the bundle`);
35
  manifest.signature = sign(null, Buffer.from(sha), privateKey).toString('base64');
 
 
 
 
 
36
  manifest.signed_at = new Date().toISOString().replace(/\.\d+Z$/, 'Z');
37
  writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
38
  console.log(`Signed ${manifest.file} (sha256 ${sha.slice(0, 16)}...)`);
 
33
  const sha = createHash('sha256').update(readFileSync(path.join(out, manifest.file))).digest('hex');
34
  if (sha !== manifest.sha256) throw new Error(`${manifest.file} does not match manifest.json; rebuild the bundle`);
35
  manifest.signature = sign(null, Buffer.from(sha), privateKey).toString('base64');
36
+ if (manifest.mobile) {
37
+ const mobileSha = createHash('sha256').update(readFileSync(path.join(out, manifest.mobile.file))).digest('hex');
38
+ if (mobileSha !== manifest.mobile.sha256) throw new Error(`${manifest.mobile.file} does not match manifest.json; rebuild the bundle`);
39
+ manifest.mobile.signature = sign(null, Buffer.from(mobileSha), privateKey).toString('base64');
40
+ }
41
  manifest.signed_at = new Date().toISOString().replace(/\.\d+Z$/, 'Z');
42
  writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
43
  console.log(`Signed ${manifest.file} (sha256 ${sha.slice(0, 16)}...)`);