probolos 1.0.0b1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. probolos-1.0.0b1/CAPABILITIES.md +459 -0
  2. probolos-1.0.0b1/CHANGELOG.md +1248 -0
  3. probolos-1.0.0b1/CITATION.cff +29 -0
  4. probolos-1.0.0b1/LICENSE +674 -0
  5. probolos-1.0.0b1/MANIFEST.in +14 -0
  6. probolos-1.0.0b1/PKG-INFO +416 -0
  7. probolos-1.0.0b1/README.md +385 -0
  8. probolos-1.0.0b1/ROADMAP.md +279 -0
  9. probolos-1.0.0b1/SECURITY.md +590 -0
  10. probolos-1.0.0b1/docs/GITHUB-SETUP.md +188 -0
  11. probolos-1.0.0b1/docs/QA-LOG.md +122 -0
  12. probolos-1.0.0b1/install.sh +222 -0
  13. probolos-1.0.0b1/interrogation_study.py +177 -0
  14. probolos-1.0.0b1/probolos/__init__.py +17 -0
  15. probolos-1.0.0b1/probolos/__main__.py +748 -0
  16. probolos-1.0.0b1/probolos/agent.py +737 -0
  17. probolos-1.0.0b1/probolos/agentlink.py +830 -0
  18. probolos-1.0.0b1/probolos/analyzers.py +327 -0
  19. probolos-1.0.0b1/probolos/atomicio.py +149 -0
  20. probolos-1.0.0b1/probolos/countdown_dialog.py +158 -0
  21. probolos-1.0.0b1/probolos/daemon.py +1770 -0
  22. probolos-1.0.0b1/probolos/deferred_bind.py +388 -0
  23. probolos-1.0.0b1/probolos/descriptors.py +315 -0
  24. probolos-1.0.0b1/probolos/descriptors_safe.py +358 -0
  25. probolos-1.0.0b1/probolos/dialogs.py +480 -0
  26. probolos-1.0.0b1/probolos/gate.py +163 -0
  27. probolos-1.0.0b1/probolos/gate_client.py +227 -0
  28. probolos-1.0.0b1/probolos/gate_server.py +1246 -0
  29. probolos-1.0.0b1/probolos/history.py +205 -0
  30. probolos-1.0.0b1/probolos/instance.py +73 -0
  31. probolos-1.0.0b1/probolos/interrogate.py +277 -0
  32. probolos-1.0.0b1/probolos/ledger.py +742 -0
  33. probolos-1.0.0b1/probolos/mediawatch.py +391 -0
  34. probolos-1.0.0b1/probolos/payload.py +209 -0
  35. probolos-1.0.0b1/probolos/privsep.py +384 -0
  36. probolos-1.0.0b1/probolos/protocol.py +185 -0
  37. probolos-1.0.0b1/probolos/quarantine.py +369 -0
  38. probolos-1.0.0b1/probolos/report.py +269 -0
  39. probolos-1.0.0b1/probolos/rules.py +1032 -0
  40. probolos-1.0.0b1/probolos/safety.py +375 -0
  41. probolos-1.0.0b1/probolos/securefs.py +106 -0
  42. probolos-1.0.0b1/probolos/session.py +252 -0
  43. probolos-1.0.0b1/probolos/storage.py +838 -0
  44. probolos-1.0.0b1/probolos/storage_hardening.py +150 -0
  45. probolos-1.0.0b1/probolos/sysfs.py +869 -0
  46. probolos-1.0.0b1/probolos/textsafe.py +334 -0
  47. probolos-1.0.0b1/probolos/trust.py +543 -0
  48. probolos-1.0.0b1/probolos/usbclass.py +245 -0
  49. probolos-1.0.0b1/probolos.egg-info/PKG-INFO +416 -0
  50. probolos-1.0.0b1/probolos.egg-info/SOURCES.txt +89 -0
  51. probolos-1.0.0b1/probolos.egg-info/dependency_links.txt +1 -0
  52. probolos-1.0.0b1/probolos.egg-info/entry_points.txt +2 -0
  53. probolos-1.0.0b1/probolos.egg-info/requires.txt +10 -0
  54. probolos-1.0.0b1/probolos.egg-info/top_level.txt +1 -0
  55. probolos-1.0.0b1/pyproject.toml +165 -0
  56. probolos-1.0.0b1/setup.cfg +4 -0
  57. probolos-1.0.0b1/systemd/README.md +129 -0
  58. probolos-1.0.0b1/systemd/probolos-agent.service +41 -0
  59. probolos-1.0.0b1/systemd/probolos.service +128 -0
  60. probolos-1.0.0b1/systemd/probolos.sysusers +12 -0
  61. probolos-1.0.0b1/testbed/README.md +119 -0
  62. probolos-1.0.0b1/testbed/__init__.py +0 -0
  63. probolos-1.0.0b1/testbed/emulate.py +189 -0
  64. probolos-1.0.0b1/testbed/hidexp/EXPERIMENT.md +147 -0
  65. probolos-1.0.0b1/testbed/hidexp/hid_attack.py +127 -0
  66. probolos-1.0.0b1/testbed/hidexp/hid_gadget_down.sh +38 -0
  67. probolos-1.0.0b1/testbed/hidexp/hid_gadget_up.sh +79 -0
  68. probolos-1.0.0b1/testbed/rawgadget.py +274 -0
  69. probolos-1.0.0b1/testbed/spawn.py +226 -0
  70. probolos-1.0.0b1/tests/__init__.py +13 -0
  71. probolos-1.0.0b1/tests/_media.py +83 -0
  72. probolos-1.0.0b1/tests/_support.py +332 -0
  73. probolos-1.0.0b1/tests/run_all.py +56 -0
  74. probolos-1.0.0b1/tests/test_agent.py +2381 -0
  75. probolos-1.0.0b1/tests/test_boundary.py +1032 -0
  76. probolos-1.0.0b1/tests/test_cli.py +596 -0
  77. probolos-1.0.0b1/tests/test_daemon.py +2315 -0
  78. probolos-1.0.0b1/tests/test_descriptors.py +532 -0
  79. probolos-1.0.0b1/tests/test_gate.py +513 -0
  80. probolos-1.0.0b1/tests/test_interrogate.py +146 -0
  81. probolos-1.0.0b1/tests/test_ledger.py +937 -0
  82. probolos-1.0.0b1/tests/test_privsep.py +1979 -0
  83. probolos-1.0.0b1/tests/test_properties.py +737 -0
  84. probolos-1.0.0b1/tests/test_quarantine.py +1028 -0
  85. probolos-1.0.0b1/tests/test_rules.py +1017 -0
  86. probolos-1.0.0b1/tests/test_service.py +172 -0
  87. probolos-1.0.0b1/tests/test_storage.py +1256 -0
  88. probolos-1.0.0b1/tests/test_sysfs.py +639 -0
  89. probolos-1.0.0b1/tests/test_testbed.py +74 -0
  90. probolos-1.0.0b1/tests/test_textsafe.py +289 -0
  91. probolos-1.0.0b1/tests/test_trust.py +731 -0
@@ -0,0 +1,459 @@
1
+ # Capabilities and scope
2
+
3
+ > **Feature freeze.** §1 below, as of the `v0.11.0` tag, is the feature
4
+ > contract for 1.0.0. Until 1.0.0 nothing new goes in: every change is a fix,
5
+ > a security fix, testing, documentation or release work (`ROADMAP.md`). A
6
+ > change that makes documented behaviour true is a fix; one that adds
7
+ > behaviour goes on the post-1.0 list (§3.0).
8
+
9
+ This document is the authoritative answer to "what does Probolos actually do?".
10
+ It exists because a security tool that is vague about its own boundary is worse
11
+ than one with a narrow but honest one: a user who overestimates the tool makes
12
+ decisions the tool cannot support.
13
+
14
+ Three lists follow: **what is implemented and active**, **what is deliberately
15
+ or currently not done**, and **what is under consideration**. Every entry in the
16
+ first list corresponds to code on an execution path that actually runs. Anything
17
+ that exists in the tree but is not reachable is in the second list, not the
18
+ first — that distinction is the point of this document.
19
+
20
+ Status: **beta**. Validated largely under software emulation
21
+ (`dummy_hcd`/`raw_gadget`); real-hardware coverage is limited.
22
+
23
+ ---
24
+
25
+ ## 1. What Probolos does
26
+
27
+ ### 1.1 Admission control
28
+
29
+ | Capability | Mechanism |
30
+ |---|---|
31
+ | Deny-by-default for newly attached USB devices | `authorized_default=0` written per USB bus (`gate.py`) |
32
+ | Per-device hold and release | `/sys/bus/usb/devices/<dev>/authorized` |
33
+ | Final admission policy | terminal/agent decision, remembered trust, or safety exemption; inspection can activate earlier |
34
+ | Original bus state restored on exit, crash, or signal | `atexit` + signal handlers in `gate.py`; `--release` recovers manually |
35
+ | Devices already attached at start are left alone | the gate governs *new* attachments only |
36
+
37
+ The prompt is served on an already-trusted terminal or an already-authenticated
38
+ agent socket. A device that has just been plugged in is still unauthorized, so
39
+ it cannot press its own "yes". This is the core invariant.
40
+
41
+ The desktop agent asks with as much friction as the device earns
42
+ (`daemon._prompt_steps`): **one dialog** for a device that can neither type
43
+ nor carry traffic and showed nothing suspicious; **two** (a second "switch it
44
+ on?") for keyboards/HID, network, radio, vendor-specific or unknown functions,
45
+ or any warning; and for a CRITICAL finding the **countdown**: "Allow anyway"
46
+ stays disabled for 10 s, "always" is never offered, and the daemon itself
47
+ refuses an approval that arrives sooner. A dialog that closes without a
48
+ button (killed, timed out, no display) is no decision, never a refusal. On a
49
+ terminal a CRITICAL device needs the typed word `authorize`.
50
+
51
+ ### 1.2 Stage 1 — Identity
52
+
53
+ Descriptor parsing from sysfs and the raw `descriptors` blob (`descriptors.py`,
54
+ `sysfs.py`): device / configuration / interface descriptors, string descriptors,
55
+ speed, and per-configuration power.
56
+
57
+ `bMaxPower` is scaled by the correct unit — 2 mA below USB 3.0, 8 mA at
58
+ SuperSpeed — selected from `bcdUSB`, and the bus limit is 500 mA / 900 mA
59
+ accordingly.
60
+
61
+ ### 1.3 Stage 2 — Consistency rules
62
+
63
+ Static rules over the declared identity (`rules.evaluate`). Severities are
64
+ defaults and are overridable per rule in a YAML config (`--rules`).
65
+
66
+ **CRITICAL**
67
+ - `storage-with-keyboard` — a mass-storage device that also declares HID input
68
+ - `network-with-keyboard` — a network interface that also declares HID input
69
+ - `crafted-strings-hid` — text-manipulation characters in the strings of an input device
70
+ - `storage-with-undeclared-hid` — a mass-storage device that also declares an
71
+ input interface whose shape the descriptors do not show (HID subclass 0, or a
72
+ declared mouse whose report descriptor was not read)
73
+ - `unreadable-descriptors`, `descriptor-chain-truncated`, `configurations-missing`
74
+ — part of what the device declared was never examined (parse failure, a chain
75
+ cut short, or fewer configurations read than declared), so no rule above can
76
+ vouch for it
77
+
78
+ **WARNING**
79
+ - `keyboard-with-unrelated-function`
80
+ - `self-contradictory-identity`
81
+ - `interface-count-mismatch` — declared `bNumInterfaces` vs. interfaces present
82
+ - `crafted-strings`
83
+ - `network-with-undeclared-hid` — a network interface beside an input interface
84
+ of unclear shape (as `storage-with-undeclared-hid`)
85
+ - `stacked-combining-marks` — more combining marks on one character than any
86
+ writing system uses, which can draw over the report
87
+ - `power-exceeds-bus-limit` — declares more than the specification permits
88
+
89
+ **NOTICE**
90
+ - `multiple-distinct-functions`
91
+ - `no-interfaces`
92
+ - `keyboard-at-high-speed`
93
+ - `invisible-string-characters`
94
+ - `storage-declares-negligible-power`
95
+ - `power-varies-across-configurations`
96
+ - `descriptor-length-overstated` — `wTotalLength` claims more configuration data
97
+ than was delivered
98
+
99
+ A check that raises is reported, never skipped: `analyzer-failed:<check>` is
100
+ CRITICAL when that check is the one that decides its stage, and NOTICE
101
+ otherwise.
102
+
103
+ ### 1.4 Stage 3 — Pre-authorization quarantine
104
+
105
+ The device is temporarily activated and evdev nodes are grabbed as they appear.
106
+ It is re-blocked before those grabs are released, including on exceptions.
107
+ Input can escape before a grab succeeds. Late nodes are checked throughout the
108
+ window, and failed grabs stop it. Event buffers are bounded; timing comes from
109
+ kernel input-event timestamps rather than Python's batch-read times.
110
+
111
+ Behavioural rules (`rules.behaviour_findings`):
112
+
113
+ - `machine-generated-keystrokes` (CRITICAL) — inter-keystroke coefficient of variation below human range
114
+ - `unprompted-typing` (CRITICAL) — input while the user was told not to touch it
115
+ - `unexpected-keystrokes` (WARNING)
116
+ - `immediate-activity` (WARNING)
117
+ - `incomplete-isolation` (WARNING) — a node could not be grabbed
118
+ - `quarantine-unavailable` (NOTICE)
119
+ - `quarantine-not-restored` (CRITICAL) — `authorized=0` could not be written
120
+ back after observation, so the device may still be live
121
+
122
+ Payload rule (`analyzers.PayloadAnalyzer`, only with `--capture-payload`):
123
+
124
+ - `payload-captured` (CRITICAL) — the device typed at least one key during
125
+ quarantine; its keystrokes are rebuilt as a DuckyScript-style transcript,
126
+ assuming a US QWERTY layout and capped at about 4096 characters (keystrokes
127
+ past the cap are counted, not transcribed)
128
+
129
+ Authorization-to-first-grab latency is reported. It is not a measurement of
130
+ all escaped input, and `--close-race-window` does not eliminate the race.
131
+ Optional raw keystroke capture (`--capture-payload`, off by default) feeds a
132
+ payload analyzer.
133
+
134
+ ### 1.5 Stage 4 — Storage inspection without mounting
135
+
136
+ Read-only inspection of the first sectors of a USB block device (`storage.py`):
137
+ MBR entries and GPT header/protective-MBR detection, with limited filesystem
138
+ signatures. ISO 9660 and UDF are reported only when their volume structure
139
+ checks out (ISO 9660: descriptor set, PVD both-byte-order fields, root
140
+ directory record, size; UDF: the recognition sequence), not on the magic
141
+ alone; the other signatures are still magic-only. GPT entries are read only
142
+ for their type GUID and attributes, only from the standard location inside
143
+ the header read (LBA 2, 128-byte entries), and only the media-change rules
144
+ (§1.13) use them; the geometry rules still judge the protective MBR. Probolos does not mount or write the
145
+ medium; other services can still mount it during activation.
146
+
147
+ Recognised filesystems are a fixed internal set (NTFS, exFAT, FAT12/16/32,
148
+ ext2/3/4, btrfs, ISO 9660, UDF); detection does not delegate to libblkid.
149
+ f2fs, minix and squashfs, among others, read as "no known filesystem" — the
150
+ same string a medium with no structure at all produces. The stage 4 result is
151
+ **contextual, not a gate criterion**: a device can also force the
152
+ identity-only path by delaying its block node past the poll window. Admission
153
+ rests on stages 1–2.
154
+
155
+ Every inspection failure (switch-on refused, no block node, node never ready,
156
+ unreadable or stalled read, removal mid-scan, re-block refused) is reported the
157
+ same way: `not inspected: <reason>` in the MEDIUM block plus the
158
+ `storage-unreadable` notice. The reason shown is from a fixed vocabulary; the
159
+ raw error text and paths go to the JSON audit log (`medium.detail`) only.
160
+
161
+ - `partition-beyond-end-of-device` (WARNING)
162
+ - `overlapping-partitions` (WARNING)
163
+ - `impossible-partition-geometry` (WARNING) — entries that cannot exist on this
164
+ medium (`storage_hardening`); they are not read
165
+ - `filesystem-type-mismatch` (NOTICE)
166
+ - `filesystem-signature-without-structure` (NOTICE)
167
+ - `large-unallocated-gap` (NOTICE)
168
+ - `gpt-without-protective-mbr` (NOTICE)
169
+
170
+ The read/parse phase runs in a forked worker with a timeout. Authorization,
171
+ gate-provided descriptor acquisition and cleanup are outside that deadline. Storage inspection is **refused** on a
172
+ composite storage+input device, because authorizing it to look would switch the
173
+ input half on without a grab, and on any other composite (storage+network,
174
+ serial or vendor), because it would switch those functions on before a
175
+ decision. This guard includes every parsed configuration and alternate setting. Incomplete descriptors disable early activation entirely.
176
+
177
+ ### 1.6 Memory across sessions
178
+
179
+ - **Ledger** (`ledger.py`) — bounded per-identity history, with a
180
+ **normalized** SHA-256 fingerprint of the parsed descriptor set: VID/PID,
181
+ `bcdDevice`, top-level class triple, and per-interface
182
+ class/subclass/protocol. Bus-negotiated fields (`bcdUSB`, `bMaxPower`,
183
+ endpoint descriptors, SuperSpeed companion descriptors) are excluded, so
184
+ the same physical stick on a USB 2 vs. a USB 3 controller does not produce
185
+ a spurious drift alarm. The raw-byte hash is stored beside it (`raw_hash`)
186
+ for forensics but never fed to the CRITICAL rule.
187
+ - **Trust store** (`trust.py`) — devices pinned by identity *and* descriptor
188
+ hash. Trust never overrides a CRITICAL finding. Both files are written
189
+ atomically. The ledger is `0600`. The trust store is `0600` unless
190
+ `--privsep` is used: there the launcher makes an existing store `0644` and
191
+ the gate creates a new one `0644`, so the analyzer can read it back, and any
192
+ later rewrite keeps the read bits the file already has. An unreadable trust
193
+ store fails **closed** (nothing trusted, everything asked).
194
+
195
+ History rules (`analyzers.LedgerAnalyzer`):
196
+
197
+ - `descriptor-drift` (CRITICAL) — the normalized descriptor set differs from
198
+ this identity's baseline: the first readable set recorded for it, whatever
199
+ that sighting's decision, or the set most recently approved
200
+ - `previously-rejected` (CRITICAL) — this identity was refused before; the
201
+ prompt is the countdown
202
+ - `ledger-unavailable` (NOTICE) — the history could not be read, so the device
203
+ is judged without it
204
+
205
+ ### 1.7 Session awareness
206
+
207
+ Screen-lock handling (`session.py`): devices arriving while the session is
208
+ locked are queued or denied per `--lock-policy`, and a queued device is re-asked
209
+ on unlock. Deferral never becomes silent approval.
210
+
211
+ With no one to ask at all -- the service has no terminal, and no desktop agent
212
+ is connected (login screen, logout, agent restart) -- a device is **held**
213
+ blocked before stages 3 and 4, and asked about when an agent connects. A
214
+ replayed udev "add" for a held device is ignored. A device whose question was
215
+ lost to a departing agent three times is refused as unanswered. A question
216
+ that was shown and not answered is recorded as "no answer", not re-queued,
217
+ and the agent shows a display-only "still blocked, replug it" notice
218
+ (critical urgency for a CRITICAL device).
219
+
220
+ ### 1.8 Privilege separation (`--privsep`)
221
+
222
+ A separate root gate (`gate_server.py`) performs the only privileged operations —
223
+ writing `authorized`, opening input and block nodes read-only — and passes file
224
+ descriptors over a `SEQPACKET` socket with `SCM_RIGHTS` to an analyzer running
225
+ as `nobody`, or the account `--privsep-user` names (the systemd service: its
226
+ own `probolos` system account). The privilege drop is verified, including
227
+ that `setuid(0)` fails afterwards.
228
+
229
+ The gate distinguishes temporary activation from final admission. Read scope
230
+ follows the USB device ancestor, skipping interface nodes, and requires blocked
231
+ state or an unexpired temporary permission for the same device instance. Final
232
+ admission gives no continued read/deauthorization permission. Whole-device
233
+ requests cannot target interfaces. See `SECURITY.md` for the limits of this
234
+ boundary; the analyzer still controls admission policy.
235
+
236
+ "Always" under `--privsep` is written by the gate (`REQ_TRUST`), never by the
237
+ analyzer: only for a device the gate admitted on this connection, within
238
+ 60 s, once, and only under the fingerprint the gate took itself before first
239
+ switching the device on. The trust file's path comes from the root side's
240
+ command line.
241
+
242
+ ### 1.9 Lockout safety
243
+
244
+ Watchdog with configurable timeout, panic file, `--release` recovery,
245
+ `--allow-port` to exempt known ports, `--dry-run`, and best-effort restoration of bus
246
+ state, including partial startup failures and analyzer disconnects. On the reference laptop the built-in keyboard is on the
247
+ i8042 PS/2 controller and is unaffected by USB authorization entirely.
248
+
249
+ ### 1.10 Hostile-text handling
250
+
251
+ All device-supplied strings pass through `textsafe.py` before display:
252
+ bidirectional overrides, control characters, and invisible characters are
253
+ neutralized and reported as findings. A device cannot forge or reorder the
254
+ report about itself.
255
+
256
+ ### 1.11 Non-product tooling
257
+
258
+ - `testbed/` — `dummy_hcd` / `raw_gadget` software emulation and a HID attack fixture
259
+ - `interrogation_study.py` + `interrogate.py` — **research instrument only.**
260
+ Active control-transfer fingerprinting for data collection. It classifies
261
+ nothing and is not wired into the daemon.
262
+
263
+ ### 1.12 Notes on what changed
264
+
265
+ Several capabilities in the lists above were, until recently, code that
266
+ existed without being reachable or that did not do what it said. They are
267
+ called out because the failure mode is worth recognising rather than just
268
+ repairing:
269
+
270
+ | Was | Now |
271
+ |---|---|
272
+ | `deferred_bind` decided it was supported by counting interface directories on a device held at `authorized=0`, where none exist. It returned `False` on every device and the daemon silently took the racy path. | Uses bus-wide `drivers_autoprobe` to defer binding, opt-in via the legacy `--close-race-window` flag. The input race remains. |
273
+ | `descriptors_safe` was imported by nothing, so its bounds on descriptor count and its `bLength` checks protected nothing. | `descriptors.parse()` walks through it. Truncation is now recorded and surfaced instead of discarded. |
274
+ | `storage_hardening` had one of four functions called. A partition with a legal start and an absurd length was read anyway. | All three bounds are wired, and `MediumReport.suspicious` — previously written and read by nobody — is now a finding. |
275
+ | `TrustStore.load()` parsed an admission list without checking who owned it or who could write it. | Ownership, mode, and symlink checks before the JSON is believed. Fails closed. |
276
+ | `_write_attr_pinned()` opened the pinned directory with `O_DIRECTORY \| O_NOFOLLOW` on the path as given, but `/sys/bus/usb/devices/<name>` entries are symlinks — so the direct backend failed with ENOTDIR on every write except `admit()`, which lacked the flag. The gate could not close in the default (non-privsep) mode. | `realpath()` before the open, symlink refusal preserved on the attribute. `admit()` uses the same discipline. Measured on real hardware: five root hubs, all closed cleanly. |
277
+ | `SafetyPolicy.is_protected()` accepted `removable=fixed` on any device. For a device behind an EXTERNAL hub that value comes from the hub's own `DeviceRemovable` bitmap, so one hostile hub silently disabled every check on everything behind it. | The chain from the device to the controller is walked; every ancestor must itself be `fixed` and the walk must reach a root hub before the exemption is granted. |
278
+ | `Ledger.record()` overwrote `descriptor_hash` on every decision, and `LedgerAnalyzer` compared against that field. A refusal, a timeout-denial, or merely queueing a device via `_hold_until_unlocked` adopted the attacker's blob as the reference. | A dedicated `baseline_hash` moved only by an explicit approval (`Ledger.record(..., approved=True)`). Old ledgers migrate via `known_hashes[0]`. |
279
+ | `descriptor_fingerprint()` hashed the raw descriptor blob, so the same physical stick on a USB 2 vs. a USB 3 controller produced a different digest — measured false positive on a Kingston DataTraveler. | Normalized fingerprint over the parsed device/interface identity, dropping bus-negotiated fields. The raw hash is kept beside it in `raw_hash` for forensics. Ledgers written by earlier versions clear their baseline on load and re-learn from the next sighting. |
280
+ | `report._line()` padded with Python character count, so a long ASCII name, a CJK product name, or a product string containing box-drawing characters broke the report box. `textsafe.pad`/`fit`/`display_width` were written for exactly this and had no callers. | The box is gone: the report is indented text, `_wrap()` measures in terminal columns (`textsafe.display_width`, `split_width`), and `_quote()` quotes device-supplied strings so they read as testimony, not as verdict text. `pad`/`fit` lost their callers with the box (§2.2). |
281
+ | The shipped systemd unit ran `--privsep --agent --timeout 0` with no `--agent-user`. At boot there is no graphical session, so `_resolve_agent_identity()` called `sys.exit()` and `Restart=on-failure` looped forever with the gate never closing. | Auto-detection failure logs and continues without the agent (explicit unknown `--agent-user` still exits). The unit gained `Environment=PROBOLOS_AGENT_USER` and a drop-in note. |
282
+
283
+ Each row has regression tests under `tests/`. Hardware claims are not
284
+ inferred from mock tests.
285
+
286
+ ### 1.13 Media changes in admitted card readers (`--watch-media`, off by default)
287
+
288
+ **A separate detection layer, not part of the admission gate.** A card is a
289
+ SCSI medium inside a reader, not a USB device: inserting one is a unit
290
+ attention in the bound `usb-storage`/`uas` driver, with no re-enumeration and
291
+ no `authorized` decision point. The pre-authorization quarantine therefore
292
+ does not extend to cards, and nothing here claims it does.
293
+
294
+ | Piece | Mechanism |
295
+ |---|---|
296
+ | Which hosts | storage-only devices admitted by this run (prompt, trust, safety exemption) and storage-only devices present at startup (`mediawatch.MediaWatch.register`) |
297
+ | Event | udev `block`/`disk` `change` (and `add`) on a disk whose sysfs path is under a watched host, same kernel directory instance (`daemon._dispatch`) |
298
+ | Read | stage 4's own `storage.inspect_safely` on `/dev/sdX`: raw, read-only, never mounted, bounded, watchdog paused |
299
+ | Drift | ledger `media` section keyed on reader identity + SCSI LUN; the first layout is the baseline and never moves |
300
+ | Privsep | the gate, started with the same flag, records admitted/baseline storage-only hosts and allows whole-disk read-only opens and switch-**off** for them (`gate_server._media_host`) |
301
+ | Enforcement | `--media-policy log` (default) or `deauthorize`: drop the **whole reader** on a CRITICAL finding; nothing finer exists |
302
+
303
+ Rules (`rules.media_findings`), added to the stage 4 structural findings:
304
+
305
+ - `media-efi-system-partition` (CRITICAL) — MBR type `0xEF` or the GPT ESP GUID
306
+ - `media-hidden-partition` (CRITICAL) — hidden MBR types, or GPT attribute bit 62
307
+ - `media-inserted-while-locked` (WARNING)
308
+ - `media-layout-drift` (WARNING for a layout never seen in the slot, NOTICE for one seen before)
309
+
310
+ Every report states whether automount was inhibited for the disk
311
+ (`UDISKS_IGNORE`/`UDISKS_AUTO` from udev) or the medium was already mounted
312
+ when read, in which case it is post-hoc alerting. Latency follows the kernel's
313
+ disk-event polling (typically 1–2 s); a slot whose `events` does not include
314
+ `media_change`, or that nobody polls, is flagged when first seen.
315
+
316
+ Not covered: SD/MMC readers that are not USB mass storage (`mmcblk`, e.g.
317
+ SDHCI or `rtsx`), composite readers, and filesystem-parser exploits (§2.1).
318
+
319
+ ### 1.14 The background service
320
+
321
+ `sudo ./install.sh` installs the code root-owned in `/opt/probolos` and enables
322
+ two systemd units (`systemd/`): `probolos.service`, the gate as a system
323
+ service with `--privsep --agent` and a sandboxed unit, and
324
+ `probolos-agent.service`, the desktop agent as a user service. Without an
325
+ agent the service holds devices (§1.7); with one, it asks through the desktop
326
+ prompt (§1.1). The units are checked in CI (`systemd-analyze verify`); their
327
+ behaviour on real hardware is part of the hardware matrix (ROADMAP 2.2).
328
+
329
+ ---
330
+
331
+ ## 2. What Probolos does not do
332
+
333
+ ### 2.1 Out of scope by design
334
+
335
+ These are not deficiencies; they are boundaries. Each belongs to a different
336
+ subsystem or threat model.
337
+
338
+ - **Vulnerabilities in the kernel USB stack.** The gate acts *after* the kernel
339
+ has parsed descriptors. Memory corruption during enumeration is reached before
340
+ Probolos sees anything. Only a sacrificial host (`usbip`) removes this.
341
+ - **Thunderbolt / PCIe DMA.** A different subsystem entirely — that is what the
342
+ IOMMU and `boltctl` are for.
343
+ - **USB Power Delivery.** PD negotiation happens in the Type-C port manager, not
344
+ in USB device authorization.
345
+ - **Wireless gateways.** A Bluetooth adapter passes every check and then admits
346
+ a keyboard over the air, with no USB event at all.
347
+ - **A patient attacker.** A device that behaves for a week and attacks afterwards
348
+ passes everything here.
349
+ - **Descriptor forgery.** An O.MG cable declares exactly what a real cable
350
+ declares. No identity-based check can separate them — which is precisely why
351
+ stages 3 and 4 exist.
352
+ - **Malicious hub or controller silicon.** Trust in the bus topology is assumed.
353
+ - **Post-authorization monitoring.** Once a device is approved, Probolos stops
354
+ watching it. The one exception is `--watch-media` (§1.13), which watches
355
+ the *media* of admitted storage hosts and still does not gate them.
356
+ - **Admission control for removable media.** A card has no `authorized`
357
+ switch of its own. `--watch-media` observes and alerts; at most it drops the
358
+ whole reader.
359
+ - **Filesystem-parser exploits.** A crafted exFAT/NTFS/FAT that attacks the
360
+ kernel's filesystem driver on mount looks like an ordinary filesystem in a
361
+ partition table. Kernel fs-driver bugs are the same class as kernel USB-stack
362
+ bugs above.
363
+ - **Content scanning.** No file inspection, no signatures, no anti-malware. The
364
+ storage stage reads partition metadata, nothing else.
365
+ - **Non-Linux platforms.** The entire mechanism is Linux sysfs USB authorization.
366
+
367
+ ### 2.2 Present in the tree but not on any execution path
368
+
369
+ Listed separately because reading the source could suggest otherwise. The four
370
+ entries that used to head this list — an inert `deferred_bind`, an orphaned
371
+ `descriptors_safe`, a half-wired `storage_hardening`, and an unverified trust
372
+ store — have been fixed; see §1.12.
373
+
374
+ - **HID report descriptor analysis.** `descriptors_safe.walk_hid_items()`
375
+ exists, is hardened, and is tested — but the sysfs `descriptors` blob does not
376
+ contain report descriptors, only the HID descriptor (`0x21`) that announces
377
+ their length. Reaching the report descriptor itself requires the device to be
378
+ authorized and `usbhid` bound, i.e. the quarantine stage. Not yet wired to
379
+ anything; see §3.2.
380
+ - **`textsafe.pad()` and `textsafe.fit()`.** Tested, and correct for
381
+ double-width and combining characters, but the boxed report they were
382
+ written for is gone (§1.12): nothing outside the tests calls them. They stay
383
+ until after 1.0.
384
+ - **Deferred binding remains experimental** (`--close-race-window`). It does
385
+ not guarantee a zero exposure window, it is not available under
386
+ `--privsep`, and it is outside the 1.0 guarantees (§3.0).
387
+
388
+ ---
389
+
390
+ ## 3. What may be built
391
+
392
+ ### 3.0 Deferred past 1.0
393
+
394
+ Decided at the feature freeze (`v0.11.0`), so the absence reads as a decision.
395
+ None of these is in 1.0.0; each is a post-1.0 candidate.
396
+
397
+ | Deferred | Note |
398
+ |---|---|
399
+ | A GUI for history and trust | `--history`, `--trusted` and `--remove-*` stay command-line |
400
+ | `--remove-history N\|PATTERN`, numbered `--history` | |
401
+ | A configurable dialog timeout | 60 s stays the default |
402
+ | HID report-descriptor analysis | §3.2 |
403
+ | `--close-race-window` (deferred binding) | Stays in the tree, **experimental, outside the 1.0 guarantees** (§2.2) |
404
+
405
+ ### 3.1 Validate the existing scope before adding features
406
+
407
+ For a thesis, prioritize hardware experiments: escaped keystrokes at the trusted
408
+ host, detection/miss rates, false positives on ordinary devices, stage latency,
409
+ and recovery after failures. Measure actual effects, not just discovery-to-grab
410
+ time. A clear, limited contribution with reproducible evidence is sufficient;
411
+ new detectors, kernel components or a second machine are separate research work.
412
+
413
+ ### 3.2 Reach the HID report descriptors
414
+
415
+ `walk_hid_items()` is written, hardened against push/pop imbalance, collection
416
+ depth, and absurd report sizes — and has nothing to read. The report descriptor
417
+ is not in the sysfs blob. Getting to it means either reading
418
+ `/sys/bus/hid/devices/*/report_descriptor` during the quarantine window, once
419
+ `usbhid` has bound, or fetching it over a control transfer with the device
420
+ authorized but no driver attached — which `--close-race-window` now makes
421
+ possible for the first time.
422
+
423
+ The rule it unlocks is a strong one with no equivalent in the current set: a
424
+ device whose interface descriptor says *mouse* while its report descriptor
425
+ claims the keyboard usage page. Identity-level checks cannot see that at all.
426
+
427
+ ### 3.3 Other connected work
428
+
429
+ - An HMAC over the trust store, keyed by a root-only file, would extend §1.6
430
+ from "nobody else could write this" to "nobody else did write this".
431
+ - Validate the systemd service (§1.14) on real hardware (§3.1, ROADMAP 2.2).
432
+
433
+ ### 3.4 Research directions
434
+
435
+ - **Population-scale false-positive measurement of two specification-derived rules**
436
+ (`descriptor-chain-truncated`, `descriptor-length-overstated`). Both are
437
+ reasoned from the specification rather than observed in the wild, which is
438
+ the profile of a rule that turns out to fire on some ordinary vendor's
439
+ firmware.
440
+ - **Active interrogation as a detector** — promote `interrogate.py` from study
441
+ to rule, *if and only if* the collected distributions actually separate
442
+ consumer USB stacks from general-purpose microcontrollers.
443
+ - **Post-authorization monitoring via HID-BPF** — addresses §2.1's blind spot
444
+ after approval. Requires C and clang.
445
+ - **Port and topology watch** — a device appearing on a port that is physically
446
+ internal is worth a finding on its own.
447
+ - **Differential analysis across visits** — beyond descriptor hash equality, to
448
+ *what* changed and whether the change is plausible.
449
+ - **Vendor plausibility** — a VID assigned to one company whose strings name
450
+ another.
451
+ - **Population-scale false-positive measurement** — the rule set is only as
452
+ good as its false-positive rate across ordinary hardware, which is currently
453
+ unmeasured.
454
+
455
+ ### 3.5 Explicitly not planned
456
+
457
+ Thunderbolt/DMA, USB-PD, wireless attack surfaces, content scanning, and
458
+ non-Linux platforms. They are listed here so their absence reads as a decision
459
+ rather than an omission.