scrubbr 0.2.0__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.
- scrubbr-0.2.0/LICENSE +21 -0
- scrubbr-0.2.0/PKG-INFO +255 -0
- scrubbr-0.2.0/README.md +241 -0
- scrubbr-0.2.0/pyproject.toml +61 -0
- scrubbr-0.2.0/pyproject.toml.orig +54 -0
- scrubbr-0.2.0/scrubbr/__init__.py +6 -0
- scrubbr-0.2.0/scrubbr/alias.py +38 -0
- scrubbr-0.2.0/scrubbr/cli.py +168 -0
- scrubbr-0.2.0/scrubbr/detect.py +230 -0
- scrubbr-0.2.0/scrubbr/identity.py +59 -0
- scrubbr-0.2.0/scrubbr/kinds.py +41 -0
- scrubbr-0.2.0/scrubbr/report.py +108 -0
- scrubbr-0.2.0/scrubbr/residual.py +103 -0
- scrubbr-0.2.0/scrubbr/review.py +90 -0
- scrubbr-0.2.0/scrubbr/scrub.py +218 -0
- scrubbr-0.2.0/scrubbr/shapes.py +239 -0
scrubbr-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 afk
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
scrubbr-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: scrubbr
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Sanitize Linux diagnostics before pasting them into an LLM
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Dist: pydantic>=2.9
|
|
8
|
+
Requires-Dist: structlog>=24.4
|
|
9
|
+
Requires-Python: >=3.13
|
|
10
|
+
Project-URL: Homepage, https://github.com/greenseeing/scrubbr
|
|
11
|
+
Project-URL: Repository, https://github.com/greenseeing/scrubbr
|
|
12
|
+
Project-URL: Changelog, https://github.com/greenseeing/scrubbr/blob/main/CHANGELOG.md
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# scrubbr
|
|
16
|
+
|
|
17
|
+
Sanitize Linux diagnostics before pasting them into an LLM chat.
|
|
18
|
+
|
|
19
|
+
When you ask an AI assistant for help with a system problem, you usually paste in logs —
|
|
20
|
+
and logs are full of things that identify you: your username, your hostname, your Wi-Fi
|
|
21
|
+
network name, hardware addresses, sometimes even keys and passwords. scrubbr replaces all
|
|
22
|
+
of that with harmless random look-alikes, shows you exactly what it changed, and only then
|
|
23
|
+
hands the text over.
|
|
24
|
+
|
|
25
|
+
The same value always gets the same replacement, so the log still makes sense: if your
|
|
26
|
+
laptop appears forty times, it becomes `host-a` all forty times, and the assistant can
|
|
27
|
+
still follow what happened.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
You need Python 3.13 or newer. Then, with either [uv](https://docs.astral.sh/uv/) or
|
|
32
|
+
[pipx](https://pipx.pypa.io/):
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
uv tool install scrubbr
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
pipx install scrubbr
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
After that, the `scrubbr` command is available in your terminal, and `scrubbr --version`
|
|
43
|
+
tells you which release you have.
|
|
44
|
+
|
|
45
|
+
To run unreleased changes, install from the repository instead:
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
uv tool install git+https://github.com/greenseeing/scrubbr
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Updating
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
uv tool upgrade scrubbr
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
or `pipx upgrade scrubbr`. [CHANGELOG.md](CHANGELOG.md) lists what changed in each release.
|
|
58
|
+
|
|
59
|
+
A git install is a moving branch rather than a version, so there is nothing for `upgrade` to
|
|
60
|
+
compare against; reinstall it instead:
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
uv tool install --force git+https://github.com/greenseeing/scrubbr
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## How to use it
|
|
67
|
+
|
|
68
|
+
### Clean up a log file
|
|
69
|
+
|
|
70
|
+
Say a program wrote a log you want to share. Point scrubbr at the file and tell it where
|
|
71
|
+
to save the cleaned copy with `-o`:
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
scrubbr boot-log.txt -o boot-log.clean.txt
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
scrubbr shows you a review of every change it wants to make (see below). Once you approve,
|
|
78
|
+
the safe copy lands in `boot-log.clean.txt`, ready to attach or paste. Your original file
|
|
79
|
+
is never modified.
|
|
80
|
+
|
|
81
|
+
### Clean up a command's output
|
|
82
|
+
|
|
83
|
+
You can also feed scrubbr the output of another command directly, using a pipe (`|`):
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
dmesg | scrubbr -o dmesg.clean.txt
|
|
87
|
+
journalctl -u NetworkManager -n 200 | scrubbr -o nm.clean.txt
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The pipe sends whatever the first command prints straight into scrubbr, so nothing
|
|
91
|
+
sensitive ever touches your disk unscrubbed.
|
|
92
|
+
|
|
93
|
+
### Straight to the clipboard
|
|
94
|
+
|
|
95
|
+
If you'd rather skip the file entirely, pipe scrubbr's output into your clipboard tool and
|
|
96
|
+
paste it into the chat:
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
journalctl -u NetworkManager -n 200 | scrubbr | wl-copy
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
(`wl-copy` is for Wayland desktops; on X11 use `xclip -selection clipboard`.)
|
|
103
|
+
|
|
104
|
+
Without `-o`, scrubbr prints the cleaned text to standard output — the terminal, or
|
|
105
|
+
whatever you pipe it into. The report of what was changed always goes to the screen
|
|
106
|
+
separately (stderr), so it never mixes into the cleaned text. If you redirect stderr too
|
|
107
|
+
(so it isn't a terminal), the report switches to JSON lines — one event per line — so
|
|
108
|
+
scripts can parse it.
|
|
109
|
+
|
|
110
|
+
### The review step
|
|
111
|
+
|
|
112
|
+
Before emitting anything, scrubbr shows a diff on your terminal: every line it changed,
|
|
113
|
+
original next to replacement, plus a warning list of anything that *looks* sensitive but
|
|
114
|
+
that it didn't recognise well enough to rewrite. It then asks:
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
emit scrubbed text? [y/N]
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Type `y` to approve. Anything else (including just pressing Enter) discards the output —
|
|
121
|
+
nothing is printed and no file is written. If you trust a run and want to skip the
|
|
122
|
+
question, pass `-y`.
|
|
123
|
+
|
|
124
|
+
### Options
|
|
125
|
+
|
|
126
|
+
| Option | What it does |
|
|
127
|
+
|---|---|
|
|
128
|
+
| `-o FILE`, `--output FILE` | save the cleaned text to `FILE` instead of printing it |
|
|
129
|
+
| `-y`, `--no-review` | skip the interactive review |
|
|
130
|
+
| `-v`, `--verbose` | list every replaced value: what it was, how often it occurred, what it became |
|
|
131
|
+
| `--strict` | refuse to emit anything while suspicious strings remain unscrubbed |
|
|
132
|
+
| `--also TEXT` | also scrub this exact string; repeat the flag for several. IPs, long hex, UUIDs and emails keep their shape (IPs are replaced even when private or loopback); names become `[REDACTED]` |
|
|
133
|
+
| `--no-identity` | don't seed the scanner with this machine's hostname, user and machine-id |
|
|
134
|
+
| `--version` | print the installed version and exit |
|
|
135
|
+
|
|
136
|
+
The report normally shows only counts per category. With `-v` it also prints a table with
|
|
137
|
+
one row per distinct value, so you can see exactly what maps to what:
|
|
138
|
+
|
|
139
|
+
```
|
|
140
|
+
kind count text alias
|
|
141
|
+
mac 3 aa:bb:cc:dd:ee:ff ea:82:db:a4:68:68
|
|
142
|
+
email 1 alice@corp.example person-a@example.invalid
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
The table is sized to your window; values too long for their column are shortened in the
|
|
146
|
+
middle (`9f2a1c7b…4c6b`). Be aware this prints the original sensitive values to your
|
|
147
|
+
screen (stderr), so don't share that part.
|
|
148
|
+
|
|
149
|
+
`--also` is for values only you know are sensitive — an internal server name, a project
|
|
150
|
+
codename, an address inside your own network:
|
|
151
|
+
|
|
152
|
+
```
|
|
153
|
+
scrubbr app.log -o app.clean.txt --also prod-db-07 --also 10.1.2.7
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Each value's type is detected from its shape. An IP address, hex of 32+ characters, a UUID
|
|
157
|
+
or an email is replaced the same way scrubbr replaces ones it finds on its own — same
|
|
158
|
+
shape, same alias pool — and a declared IP is replaced even if it is private or loopback,
|
|
159
|
+
which incidental matches are not. A value with no recognisable shape (a name) becomes
|
|
160
|
+
`[REDACTED]`; note that every such name renders identically, so two declared names cannot
|
|
161
|
+
be told apart in the output.
|
|
162
|
+
|
|
163
|
+
## What it replaces
|
|
164
|
+
|
|
165
|
+
| | |
|
|
166
|
+
|---|---|
|
|
167
|
+
| Hardware | MAC and Bluetooth addresses, disk WWN / `by-id` paths |
|
|
168
|
+
| Network | public IPv4 and IPv6, SSIDs, BSSIDs |
|
|
169
|
+
| Identity | this machine's hostname, your username, `/home/<user>`, machine-id, emails |
|
|
170
|
+
| Secrets | PEM blocks, `openssl passwd` hashes, JWTs, `psk=`/`password=` values, hex ≥32 chars |
|
|
171
|
+
| Other | UUIDs, certificate fingerprints |
|
|
172
|
+
|
|
173
|
+
Replacements keep the original's shape: `AA-BB-CC-DD-EE-FF` stays hyphenated and uppercase,
|
|
174
|
+
a v4 UUID stays a valid v4 UUID, public IPs become RFC 5737 documentation addresses. Names
|
|
175
|
+
become readable aliases (`host-a`, `user-a`, `network-a`) because `host-a can't reach host-b`
|
|
176
|
+
is diagnosable and two random hex blobs are not.
|
|
177
|
+
|
|
178
|
+
Generated MACs are always locally-administered unicast (second hex digit `2`, `6`, `a`, `e`),
|
|
179
|
+
which is what macchanger, systemd-networkd and Android all produce — and which makes it
|
|
180
|
+
impossible to accidentally mint a broadcast or reserved multicast address.
|
|
181
|
+
|
|
182
|
+
## What it deliberately leaves alone
|
|
183
|
+
|
|
184
|
+
`127.0.0.1`, `::1`, `0.0.0.0`, RFC1918 private ranges, reserved MACs (broadcast, IPv4/IPv6
|
|
185
|
+
multicast, STP, VRRP), standard system paths, and system usernames like `root`. These
|
|
186
|
+
identify nobody and carry diagnostic meaning; scrubbing them would hand the LLM a log it
|
|
187
|
+
cannot reason about.
|
|
188
|
+
|
|
189
|
+
## Trust
|
|
190
|
+
|
|
191
|
+
Regex scrubbing has false negatives, and the failure mode is silent. Two mitigations:
|
|
192
|
+
|
|
193
|
+
- Anything left over that looks sensitive is **reported, not rewritten**, with a line
|
|
194
|
+
number: high entropy, a known credential prefix like `ghp_` or `AKIA`, or colon-hex that
|
|
195
|
+
is recognisably structured but is neither a MAC, a fingerprint nor a valid address. The
|
|
196
|
+
tool tells you where it is unsure rather than implying it caught everything.
|
|
197
|
+
- Every run shows an interactive diff on your terminal before emitting. Skip it with `-y`.
|
|
198
|
+
|
|
199
|
+
`--strict` refuses to emit at all while anything suspicious remains unscrubbed.
|
|
200
|
+
|
|
201
|
+
## Known limits
|
|
202
|
+
|
|
203
|
+
- Hostnames are found by asking *this* system for its own name. A log from another machine
|
|
204
|
+
carries a hostname scrubbr cannot guess — pass it explicitly with `--also other-host`
|
|
205
|
+
(it will appear as `[REDACTED]`).
|
|
206
|
+
- `--also` matches the exact spelling you give it. For IPv6 that means `--also fe80::1`
|
|
207
|
+
also covers `FE80::1` but not the longhand `fe80:0:0:0:0:0:0:1` — declare each spelling
|
|
208
|
+
the log uses.
|
|
209
|
+
- Hex of 32+ characters is replaced with no exceptions, so checksums and 40-character git
|
|
210
|
+
SHAs get scrambled too. That is deliberate: no special cases means nothing slips through.
|
|
211
|
+
- Replacements are per-run. Sanitizing the same file twice gives different output; sanitize
|
|
212
|
+
once and keep the result if you need two pastes to line up.
|
|
213
|
+
|
|
214
|
+
Also worth knowing: don't use `journalctl -x` for output you intend to share. The
|
|
215
|
+
explanatory text it adds widens what gets exposed, and no scrubber can help with data you
|
|
216
|
+
chose to include.
|
|
217
|
+
|
|
218
|
+
## Exit codes
|
|
219
|
+
|
|
220
|
+
For scripts and the curious:
|
|
221
|
+
|
|
222
|
+
| Exit | Meaning |
|
|
223
|
+
|---|---|
|
|
224
|
+
| 0 | scrubbed text emitted |
|
|
225
|
+
| 1 | you declined at the review |
|
|
226
|
+
| 2 | refused under `--strict` because suspicious strings remained |
|
|
227
|
+
| 3 | refused because there was no terminal to review on — pass `-y` to skip review |
|
|
228
|
+
| 4 | the output file given with `-o` could not be written |
|
|
229
|
+
|
|
230
|
+
For the review, scrubbr opens the controlling terminal (`/dev/tty`) so pipes stay free;
|
|
231
|
+
where that device doesn't exist it falls back to your terminal's own input and output, as
|
|
232
|
+
long as both are genuinely interactive. Exit 3 only happens when neither is available —
|
|
233
|
+
and it exists because the alternative is worse: emitting unreviewed text exactly when the
|
|
234
|
+
safety gate could not run. Skipping the review should be your decision, not a fallback.
|
|
235
|
+
On any refusal, `-o` writes nothing — the output file is only created after you approve.
|
|
236
|
+
|
|
237
|
+
## Releasing
|
|
238
|
+
|
|
239
|
+
For maintainers. Bump the version:
|
|
240
|
+
|
|
241
|
+
```
|
|
242
|
+
uv version --bump patch
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
Move the entries under `## [Unreleased]` in `CHANGELOG.md` into a section for the new
|
|
246
|
+
version, commit both files, then tag:
|
|
247
|
+
|
|
248
|
+
```
|
|
249
|
+
git tag v0.2.1 && git push origin main --tags
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
The tag triggers `.github/workflows/release.yml`, which builds the wheel and source archive,
|
|
253
|
+
smoke-tests both in an isolated environment, and publishes to PyPI using trusted publishing —
|
|
254
|
+
no API token is stored anywhere. It refuses to publish if the tag and the version in
|
|
255
|
+
`pyproject.toml` disagree, because a PyPI version can never be replaced once uploaded.
|
scrubbr-0.2.0/README.md
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# scrubbr
|
|
2
|
+
|
|
3
|
+
Sanitize Linux diagnostics before pasting them into an LLM chat.
|
|
4
|
+
|
|
5
|
+
When you ask an AI assistant for help with a system problem, you usually paste in logs —
|
|
6
|
+
and logs are full of things that identify you: your username, your hostname, your Wi-Fi
|
|
7
|
+
network name, hardware addresses, sometimes even keys and passwords. scrubbr replaces all
|
|
8
|
+
of that with harmless random look-alikes, shows you exactly what it changed, and only then
|
|
9
|
+
hands the text over.
|
|
10
|
+
|
|
11
|
+
The same value always gets the same replacement, so the log still makes sense: if your
|
|
12
|
+
laptop appears forty times, it becomes `host-a` all forty times, and the assistant can
|
|
13
|
+
still follow what happened.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
You need Python 3.13 or newer. Then, with either [uv](https://docs.astral.sh/uv/) or
|
|
18
|
+
[pipx](https://pipx.pypa.io/):
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
uv tool install scrubbr
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
pipx install scrubbr
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
After that, the `scrubbr` command is available in your terminal, and `scrubbr --version`
|
|
29
|
+
tells you which release you have.
|
|
30
|
+
|
|
31
|
+
To run unreleased changes, install from the repository instead:
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
uv tool install git+https://github.com/greenseeing/scrubbr
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Updating
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
uv tool upgrade scrubbr
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
or `pipx upgrade scrubbr`. [CHANGELOG.md](CHANGELOG.md) lists what changed in each release.
|
|
44
|
+
|
|
45
|
+
A git install is a moving branch rather than a version, so there is nothing for `upgrade` to
|
|
46
|
+
compare against; reinstall it instead:
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
uv tool install --force git+https://github.com/greenseeing/scrubbr
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## How to use it
|
|
53
|
+
|
|
54
|
+
### Clean up a log file
|
|
55
|
+
|
|
56
|
+
Say a program wrote a log you want to share. Point scrubbr at the file and tell it where
|
|
57
|
+
to save the cleaned copy with `-o`:
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
scrubbr boot-log.txt -o boot-log.clean.txt
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
scrubbr shows you a review of every change it wants to make (see below). Once you approve,
|
|
64
|
+
the safe copy lands in `boot-log.clean.txt`, ready to attach or paste. Your original file
|
|
65
|
+
is never modified.
|
|
66
|
+
|
|
67
|
+
### Clean up a command's output
|
|
68
|
+
|
|
69
|
+
You can also feed scrubbr the output of another command directly, using a pipe (`|`):
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
dmesg | scrubbr -o dmesg.clean.txt
|
|
73
|
+
journalctl -u NetworkManager -n 200 | scrubbr -o nm.clean.txt
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The pipe sends whatever the first command prints straight into scrubbr, so nothing
|
|
77
|
+
sensitive ever touches your disk unscrubbed.
|
|
78
|
+
|
|
79
|
+
### Straight to the clipboard
|
|
80
|
+
|
|
81
|
+
If you'd rather skip the file entirely, pipe scrubbr's output into your clipboard tool and
|
|
82
|
+
paste it into the chat:
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
journalctl -u NetworkManager -n 200 | scrubbr | wl-copy
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
(`wl-copy` is for Wayland desktops; on X11 use `xclip -selection clipboard`.)
|
|
89
|
+
|
|
90
|
+
Without `-o`, scrubbr prints the cleaned text to standard output — the terminal, or
|
|
91
|
+
whatever you pipe it into. The report of what was changed always goes to the screen
|
|
92
|
+
separately (stderr), so it never mixes into the cleaned text. If you redirect stderr too
|
|
93
|
+
(so it isn't a terminal), the report switches to JSON lines — one event per line — so
|
|
94
|
+
scripts can parse it.
|
|
95
|
+
|
|
96
|
+
### The review step
|
|
97
|
+
|
|
98
|
+
Before emitting anything, scrubbr shows a diff on your terminal: every line it changed,
|
|
99
|
+
original next to replacement, plus a warning list of anything that *looks* sensitive but
|
|
100
|
+
that it didn't recognise well enough to rewrite. It then asks:
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
emit scrubbed text? [y/N]
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Type `y` to approve. Anything else (including just pressing Enter) discards the output —
|
|
107
|
+
nothing is printed and no file is written. If you trust a run and want to skip the
|
|
108
|
+
question, pass `-y`.
|
|
109
|
+
|
|
110
|
+
### Options
|
|
111
|
+
|
|
112
|
+
| Option | What it does |
|
|
113
|
+
|---|---|
|
|
114
|
+
| `-o FILE`, `--output FILE` | save the cleaned text to `FILE` instead of printing it |
|
|
115
|
+
| `-y`, `--no-review` | skip the interactive review |
|
|
116
|
+
| `-v`, `--verbose` | list every replaced value: what it was, how often it occurred, what it became |
|
|
117
|
+
| `--strict` | refuse to emit anything while suspicious strings remain unscrubbed |
|
|
118
|
+
| `--also TEXT` | also scrub this exact string; repeat the flag for several. IPs, long hex, UUIDs and emails keep their shape (IPs are replaced even when private or loopback); names become `[REDACTED]` |
|
|
119
|
+
| `--no-identity` | don't seed the scanner with this machine's hostname, user and machine-id |
|
|
120
|
+
| `--version` | print the installed version and exit |
|
|
121
|
+
|
|
122
|
+
The report normally shows only counts per category. With `-v` it also prints a table with
|
|
123
|
+
one row per distinct value, so you can see exactly what maps to what:
|
|
124
|
+
|
|
125
|
+
```
|
|
126
|
+
kind count text alias
|
|
127
|
+
mac 3 aa:bb:cc:dd:ee:ff ea:82:db:a4:68:68
|
|
128
|
+
email 1 alice@corp.example person-a@example.invalid
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
The table is sized to your window; values too long for their column are shortened in the
|
|
132
|
+
middle (`9f2a1c7b…4c6b`). Be aware this prints the original sensitive values to your
|
|
133
|
+
screen (stderr), so don't share that part.
|
|
134
|
+
|
|
135
|
+
`--also` is for values only you know are sensitive — an internal server name, a project
|
|
136
|
+
codename, an address inside your own network:
|
|
137
|
+
|
|
138
|
+
```
|
|
139
|
+
scrubbr app.log -o app.clean.txt --also prod-db-07 --also 10.1.2.7
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Each value's type is detected from its shape. An IP address, hex of 32+ characters, a UUID
|
|
143
|
+
or an email is replaced the same way scrubbr replaces ones it finds on its own — same
|
|
144
|
+
shape, same alias pool — and a declared IP is replaced even if it is private or loopback,
|
|
145
|
+
which incidental matches are not. A value with no recognisable shape (a name) becomes
|
|
146
|
+
`[REDACTED]`; note that every such name renders identically, so two declared names cannot
|
|
147
|
+
be told apart in the output.
|
|
148
|
+
|
|
149
|
+
## What it replaces
|
|
150
|
+
|
|
151
|
+
| | |
|
|
152
|
+
|---|---|
|
|
153
|
+
| Hardware | MAC and Bluetooth addresses, disk WWN / `by-id` paths |
|
|
154
|
+
| Network | public IPv4 and IPv6, SSIDs, BSSIDs |
|
|
155
|
+
| Identity | this machine's hostname, your username, `/home/<user>`, machine-id, emails |
|
|
156
|
+
| Secrets | PEM blocks, `openssl passwd` hashes, JWTs, `psk=`/`password=` values, hex ≥32 chars |
|
|
157
|
+
| Other | UUIDs, certificate fingerprints |
|
|
158
|
+
|
|
159
|
+
Replacements keep the original's shape: `AA-BB-CC-DD-EE-FF` stays hyphenated and uppercase,
|
|
160
|
+
a v4 UUID stays a valid v4 UUID, public IPs become RFC 5737 documentation addresses. Names
|
|
161
|
+
become readable aliases (`host-a`, `user-a`, `network-a`) because `host-a can't reach host-b`
|
|
162
|
+
is diagnosable and two random hex blobs are not.
|
|
163
|
+
|
|
164
|
+
Generated MACs are always locally-administered unicast (second hex digit `2`, `6`, `a`, `e`),
|
|
165
|
+
which is what macchanger, systemd-networkd and Android all produce — and which makes it
|
|
166
|
+
impossible to accidentally mint a broadcast or reserved multicast address.
|
|
167
|
+
|
|
168
|
+
## What it deliberately leaves alone
|
|
169
|
+
|
|
170
|
+
`127.0.0.1`, `::1`, `0.0.0.0`, RFC1918 private ranges, reserved MACs (broadcast, IPv4/IPv6
|
|
171
|
+
multicast, STP, VRRP), standard system paths, and system usernames like `root`. These
|
|
172
|
+
identify nobody and carry diagnostic meaning; scrubbing them would hand the LLM a log it
|
|
173
|
+
cannot reason about.
|
|
174
|
+
|
|
175
|
+
## Trust
|
|
176
|
+
|
|
177
|
+
Regex scrubbing has false negatives, and the failure mode is silent. Two mitigations:
|
|
178
|
+
|
|
179
|
+
- Anything left over that looks sensitive is **reported, not rewritten**, with a line
|
|
180
|
+
number: high entropy, a known credential prefix like `ghp_` or `AKIA`, or colon-hex that
|
|
181
|
+
is recognisably structured but is neither a MAC, a fingerprint nor a valid address. The
|
|
182
|
+
tool tells you where it is unsure rather than implying it caught everything.
|
|
183
|
+
- Every run shows an interactive diff on your terminal before emitting. Skip it with `-y`.
|
|
184
|
+
|
|
185
|
+
`--strict` refuses to emit at all while anything suspicious remains unscrubbed.
|
|
186
|
+
|
|
187
|
+
## Known limits
|
|
188
|
+
|
|
189
|
+
- Hostnames are found by asking *this* system for its own name. A log from another machine
|
|
190
|
+
carries a hostname scrubbr cannot guess — pass it explicitly with `--also other-host`
|
|
191
|
+
(it will appear as `[REDACTED]`).
|
|
192
|
+
- `--also` matches the exact spelling you give it. For IPv6 that means `--also fe80::1`
|
|
193
|
+
also covers `FE80::1` but not the longhand `fe80:0:0:0:0:0:0:1` — declare each spelling
|
|
194
|
+
the log uses.
|
|
195
|
+
- Hex of 32+ characters is replaced with no exceptions, so checksums and 40-character git
|
|
196
|
+
SHAs get scrambled too. That is deliberate: no special cases means nothing slips through.
|
|
197
|
+
- Replacements are per-run. Sanitizing the same file twice gives different output; sanitize
|
|
198
|
+
once and keep the result if you need two pastes to line up.
|
|
199
|
+
|
|
200
|
+
Also worth knowing: don't use `journalctl -x` for output you intend to share. The
|
|
201
|
+
explanatory text it adds widens what gets exposed, and no scrubber can help with data you
|
|
202
|
+
chose to include.
|
|
203
|
+
|
|
204
|
+
## Exit codes
|
|
205
|
+
|
|
206
|
+
For scripts and the curious:
|
|
207
|
+
|
|
208
|
+
| Exit | Meaning |
|
|
209
|
+
|---|---|
|
|
210
|
+
| 0 | scrubbed text emitted |
|
|
211
|
+
| 1 | you declined at the review |
|
|
212
|
+
| 2 | refused under `--strict` because suspicious strings remained |
|
|
213
|
+
| 3 | refused because there was no terminal to review on — pass `-y` to skip review |
|
|
214
|
+
| 4 | the output file given with `-o` could not be written |
|
|
215
|
+
|
|
216
|
+
For the review, scrubbr opens the controlling terminal (`/dev/tty`) so pipes stay free;
|
|
217
|
+
where that device doesn't exist it falls back to your terminal's own input and output, as
|
|
218
|
+
long as both are genuinely interactive. Exit 3 only happens when neither is available —
|
|
219
|
+
and it exists because the alternative is worse: emitting unreviewed text exactly when the
|
|
220
|
+
safety gate could not run. Skipping the review should be your decision, not a fallback.
|
|
221
|
+
On any refusal, `-o` writes nothing — the output file is only created after you approve.
|
|
222
|
+
|
|
223
|
+
## Releasing
|
|
224
|
+
|
|
225
|
+
For maintainers. Bump the version:
|
|
226
|
+
|
|
227
|
+
```
|
|
228
|
+
uv version --bump patch
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Move the entries under `## [Unreleased]` in `CHANGELOG.md` into a section for the new
|
|
232
|
+
version, commit both files, then tag:
|
|
233
|
+
|
|
234
|
+
```
|
|
235
|
+
git tag v0.2.1 && git push origin main --tags
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
The tag triggers `.github/workflows/release.yml`, which builds the wheel and source archive,
|
|
239
|
+
smoke-tests both in an isolated environment, and publishes to PyPI using trusted publishing —
|
|
240
|
+
no API token is stored anywhere. It refuses to publish if the tag and the version in
|
|
241
|
+
`pyproject.toml` disagree, because a PyPI version can never be replaced once uploaded.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "scrubbr"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "Sanitize Linux diagnostics before pasting them into an LLM"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.13"
|
|
9
|
+
dependencies = [
|
|
10
|
+
"pydantic>=2.9",
|
|
11
|
+
"structlog>=24.4",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.scripts]
|
|
15
|
+
scrubbr = "scrubbr.cli:main"
|
|
16
|
+
|
|
17
|
+
[project.urls]
|
|
18
|
+
Homepage = "https://github.com/greenseeing/scrubbr"
|
|
19
|
+
Repository = "https://github.com/greenseeing/scrubbr"
|
|
20
|
+
Changelog = "https://github.com/greenseeing/scrubbr/blob/main/CHANGELOG.md"
|
|
21
|
+
|
|
22
|
+
[dependency-groups]
|
|
23
|
+
dev = [
|
|
24
|
+
"pytest>=8.3",
|
|
25
|
+
"ruff>=0.7",
|
|
26
|
+
"mypy>=1.13",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[build-system]
|
|
30
|
+
requires = ["uv_build>=0.9,<0.12"]
|
|
31
|
+
build-backend = "uv_build"
|
|
32
|
+
|
|
33
|
+
[tool.uv.build-backend]
|
|
34
|
+
module-name = "scrubbr"
|
|
35
|
+
module-root = ""
|
|
36
|
+
|
|
37
|
+
[tool.ruff]
|
|
38
|
+
line-length = 100
|
|
39
|
+
target-version = "py313"
|
|
40
|
+
|
|
41
|
+
[tool.ruff.lint]
|
|
42
|
+
select = [
|
|
43
|
+
"E",
|
|
44
|
+
"F",
|
|
45
|
+
"I",
|
|
46
|
+
"UP",
|
|
47
|
+
"B",
|
|
48
|
+
"SIM",
|
|
49
|
+
"RUF",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
[tool.ruff.lint.per-file-ignores]
|
|
53
|
+
"tests/*" = ["E501"]
|
|
54
|
+
|
|
55
|
+
[tool.mypy]
|
|
56
|
+
python_version = "3.13"
|
|
57
|
+
strict = true
|
|
58
|
+
warn_return_any = true
|
|
59
|
+
|
|
60
|
+
[tool.pytest.ini_options]
|
|
61
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "scrubbr"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "Sanitize Linux diagnostics before pasting them into an LLM"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.13"
|
|
9
|
+
dependencies = [
|
|
10
|
+
"pydantic>=2.9",
|
|
11
|
+
"structlog>=24.4",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.scripts]
|
|
15
|
+
scrubbr = "scrubbr.cli:main"
|
|
16
|
+
|
|
17
|
+
[project.urls]
|
|
18
|
+
Homepage = "https://github.com/greenseeing/scrubbr"
|
|
19
|
+
Repository = "https://github.com/greenseeing/scrubbr"
|
|
20
|
+
Changelog = "https://github.com/greenseeing/scrubbr/blob/main/CHANGELOG.md"
|
|
21
|
+
|
|
22
|
+
[dependency-groups]
|
|
23
|
+
dev = [
|
|
24
|
+
"pytest>=8.3",
|
|
25
|
+
"ruff>=0.7",
|
|
26
|
+
"mypy>=1.13",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[build-system]
|
|
30
|
+
requires = ["uv_build>=0.9,<0.12"]
|
|
31
|
+
build-backend = "uv_build"
|
|
32
|
+
|
|
33
|
+
[tool.uv.build-backend]
|
|
34
|
+
module-name = "scrubbr"
|
|
35
|
+
module-root = ""
|
|
36
|
+
|
|
37
|
+
[tool.ruff]
|
|
38
|
+
line-length = 100
|
|
39
|
+
target-version = "py313"
|
|
40
|
+
|
|
41
|
+
[tool.ruff.lint]
|
|
42
|
+
select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
|
|
43
|
+
|
|
44
|
+
[tool.ruff.lint.per-file-ignores]
|
|
45
|
+
# Log fixtures are real lines; reflowing them would change what is under test.
|
|
46
|
+
"tests/*" = ["E501"]
|
|
47
|
+
|
|
48
|
+
[tool.mypy]
|
|
49
|
+
python_version = "3.13"
|
|
50
|
+
strict = true
|
|
51
|
+
warn_return_any = true
|
|
52
|
+
|
|
53
|
+
[tool.pytest.ini_options]
|
|
54
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import random
|
|
2
|
+
|
|
3
|
+
from scrubbr.kinds import Kind
|
|
4
|
+
from scrubbr.shapes import mint, normalize, render
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AliasBook:
|
|
8
|
+
"""The only thing that may mint an alias.
|
|
9
|
+
|
|
10
|
+
Callers ask for the alias of a value and cannot obtain an inconsistent answer, which
|
|
11
|
+
is what makes "one value, one replacement" a structural guarantee rather than a
|
|
12
|
+
convention every call site has to remember.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, rng: random.Random | None = None) -> None:
|
|
16
|
+
# SystemRandom is the CSPRNG secrets is built on; a seeded Random makes the
|
|
17
|
+
# output reproducible, which tests and correlated multi-document runs need.
|
|
18
|
+
self._rng = rng if rng is not None else random.SystemRandom()
|
|
19
|
+
self._canonical: dict[tuple[Kind, str], str] = {}
|
|
20
|
+
self._issued: dict[Kind, int] = {}
|
|
21
|
+
|
|
22
|
+
def alias_for(self, kind: Kind, text: str) -> str:
|
|
23
|
+
canonical = self.canonical_alias(kind, normalize(kind, text))
|
|
24
|
+
return render(kind, canonical, text)
|
|
25
|
+
|
|
26
|
+
def canonical_alias(self, kind: Kind, normalized: str) -> str:
|
|
27
|
+
key = (kind, normalized)
|
|
28
|
+
existing = self._canonical.get(key)
|
|
29
|
+
if existing is not None:
|
|
30
|
+
return existing
|
|
31
|
+
minted = mint(kind, normalized, self._rng, self._next(kind))
|
|
32
|
+
self._canonical[key] = minted
|
|
33
|
+
return minted
|
|
34
|
+
|
|
35
|
+
def _next(self, kind: Kind) -> int:
|
|
36
|
+
index = self._issued.get(kind, 0)
|
|
37
|
+
self._issued[kind] = index + 1
|
|
38
|
+
return index
|