iotsploit-fuzzer 0.0.9__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 (40) hide show
  1. iotsploit_fuzzer-0.0.9/PKG-INFO +320 -0
  2. iotsploit_fuzzer-0.0.9/README.md +296 -0
  3. iotsploit_fuzzer-0.0.9/pyproject.toml +32 -0
  4. iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/__init__.py +77 -0
  5. iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/analysis/__init__.py +1 -0
  6. iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/analysis/corpus.py +480 -0
  7. iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/analysis/logger.py +54 -0
  8. iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/analysis/outcome.py +273 -0
  9. iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/core/__init__.py +67 -0
  10. iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/core/bit_manipulator.py +405 -0
  11. iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/core/config.py +41 -0
  12. iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/core/fuzzing_engine.py +469 -0
  13. iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/core/orchestrator.py +452 -0
  14. iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/core/parser_campaign.py +380 -0
  15. iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/core/strategies/__init__.py +43 -0
  16. iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/core/strategies/bit_strategies.py +509 -0
  17. iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/core/strategies/field_strategies.py +697 -0
  18. iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/fuzz.py +276 -0
  19. iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/generators/__init__.py +1 -0
  20. iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/generators/base.py +14 -0
  21. iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/generators/corpus_generator.py +219 -0
  22. iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/generators/radamsa_generator.py +96 -0
  23. iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/harnesses/__init__.py +1 -0
  24. iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/harnesses/base.py +27 -0
  25. iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/harnesses/can_harness.py +23 -0
  26. iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/harnesses/parser_harness.py +323 -0
  27. iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/harnesses/parser_targets.py +142 -0
  28. iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/harnesses/parser_worker.py +180 -0
  29. iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/harnesses/spi_harness.py +17 -0
  30. iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/harnesses/uart_harness.py +23 -0
  31. iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/interfaces/__init__.py +1 -0
  32. iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/interfaces/base.py +17 -0
  33. iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/interfaces/can_interface.py +48 -0
  34. iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/interfaces/spi_interface.py +27 -0
  35. iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/interfaces/uart_interface.py +26 -0
  36. iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/monitoring/__init__.py +1 -0
  37. iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/monitoring/boundary_monitor.py +187 -0
  38. iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/monitoring/monitor.py +203 -0
  39. iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/targets/__init__.py +1 -0
  40. iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/targets/iotsploit.py +1004 -0
@@ -0,0 +1,320 @@
1
+ Metadata-Version: 2.4
2
+ Name: iotsploit-fuzzer
3
+ Version: 0.0.9
4
+ Summary: IoTSploit fuzzer library (ports + core + adapters), extracted from zeekr_sat_main
5
+ Author: IoTSploit Team
6
+ Author-email: devnull@example.com
7
+ Requires-Python: >=3.10,<4.0
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.10
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Classifier: Programming Language :: Python :: 3.15
15
+ Provides-Extra: all
16
+ Provides-Extra: can
17
+ Provides-Extra: spi
18
+ Provides-Extra: uart
19
+ Requires-Dist: pyserial ; extra == "uart" or extra == "all"
20
+ Requires-Dist: python-can ; extra == "can" or extra == "all"
21
+ Requires-Dist: spidev ; extra == "spi" or extra == "all"
22
+ Description-Content-Type: text/markdown
23
+
24
+ # iotsploit-fuzzer
25
+
26
+ Standalone fuzzer library extracted from `zeekr_sat_main`.
27
+
28
+ - **Package name (PyPI)**: `iotsploit-fuzzer`
29
+ - **Import name (Python)**: `iotsploit_fuzzer`
30
+
31
+ ## Install (editable)
32
+
33
+ ```bash
34
+ pip install -e iotsploit-fuzzer
35
+ ```
36
+
37
+ ## Notes
38
+
39
+ This package is being introduced to replace (and later deprecate) the in-repo `iot_protocol_fuzzer` module.
40
+
41
+
42
+
43
+ ## Two things this package fuzzes
44
+
45
+ They are not the same job, and conflating them is how a fuzzing effort ends up
46
+ measuring the wrong thing.
47
+
48
+ **Outbound — the device under test.** CAN/UART/SPI payloads sent through a
49
+ wire harness at whatever is on the other end. Needs the rig. This is what
50
+ `CANHarness`, `UARTHarness` and `SPIHarness` do, driven from the Django UI.
51
+
52
+ **Inbound — our own parsers.** The same `Orchestrator`, pointed at a function
53
+ instead of an interface. Needs nothing but CPU, so it runs in CI and in the
54
+ commit gate. That is what follows.
55
+
56
+ ## The parser loop
57
+
58
+ A campaign that is run once, reported and forgotten finds its last bug in week
59
+ two. What keeps producing information is the **error boundary** -- the line
60
+ between the inputs a parser accepts and the ones it rejects -- because every
61
+ refactor moves it and almost every move is unintended. Nothing in the test
62
+ suite asserts which ARXML files import or which ASC lines parse. The ledger is
63
+ that assertion, and it writes itself.
64
+
65
+ ```bash
66
+ # What is in the registry
67
+ poetry run python -m iotsploit_fuzzer.core.parser_campaign --list
68
+
69
+ # One target, one campaign
70
+ poetry run python -m iotsploit_fuzzer.core.parser_campaign \
71
+ --target canbus.scan_log --iterations 2000
72
+
73
+ # Everything, as a nightly run. Exits non-zero on a violation.
74
+ poetry run python -m iotsploit_fuzzer.core.parser_campaign --iterations 2000
75
+
76
+ # Replay the retained corpus and generate nothing. This is what the commit
77
+ # gate runs, via tests/test_parser_corpus_replay.py.
78
+ poetry run python -m iotsploit_fuzzer.core.parser_campaign --replay
79
+ ```
80
+
81
+ A campaign reports exactly three things:
82
+
83
+ | Event | Means | Nightly |
84
+ |-------|-------|---------|
85
+ | `VIOLATION` | A contract broke: an undeclared exception, a hang, a resource limit, a broken round trip | Fails |
86
+ | `BOUNDARY_MOVED` | A payload the ledger knows now does something else. **Not necessarily a bug** -- a behaviour change somebody should confirm was intended | Reports |
87
+ | `NEW_REGION` | A signature this target has never produced. The corpus grew | Informational |
88
+
89
+ ### Each parse runs in a process we are willing to lose
90
+
91
+ `ParserHarness` is a controller; the parse happens in a subprocess under
92
+ wall-clock, memory, output-size and payload-size limits. This is not
93
+ defensiveness. A parser that allocates until the host swaps cannot be
94
+ recovered from in-process -- `except MemoryError` runs only after the
95
+ allocation happened -- and a signal handler only runs at a bytecode boundary
96
+ the interpreter may never reach again. Worker death *is* the result.
97
+
98
+ Workers are batched and recycled rather than forked per payload, because a
99
+ spawn costs more than the parsing does. Anything that is not a plain accept or
100
+ reject is re-run alone in a fresh worker, three times, before it is believed:
101
+ an outcome that does not reproduce is reported as flaky and never enters the
102
+ corpus.
103
+
104
+ ### The ledger is a contract, and it is versioned
105
+
106
+ `corpus/<target>/` holds two files: `payloads.zip` and `ledger.json`. One
107
+ archive per target rather than one file per payload -- a thousand inputs of a
108
+ hundred bytes each, stored loose, was 74% of the repository's tracked file
109
+ count for 1% of its bytes, and a 4 KB block apiece turned 1.1 MB into 9.4 MB
110
+ on disk. An existing loose corpus migrates itself on the next save.
111
+
112
+ The ledger is tracked in git on purpose -- it is the loop's
113
+ memory, the gate's regression corpus, and a boundary movement arrives as a
114
+ JSON diff in the pull request that caused it. Each entry carries the payload's
115
+ content hash, its normalised outcome signature, and the campaign that first
116
+ saw it.
117
+
118
+ Signatures never contain a raw exception message: messages carry offsets, hex,
119
+ paths and quoted input, and comparing them would report thousands of movements
120
+ nobody caused. They also never contain a line number, for the same reason.
121
+
122
+ Changing a target's declared exception set, its adapter, or the signature
123
+ format changes its **fingerprint**, and a ledger recorded under a different
124
+ fingerprint is refused rather than diffed:
125
+
126
+ ```bash
127
+ poetry run python -m iotsploit_fuzzer.core.parser_campaign \
128
+ --target canbus.scan_log --rebaseline
129
+ ```
130
+
131
+ Re-baselining keeps the payloads -- they are the expensive part -- and drops
132
+ only the claim about what they do, which the next campaign re-derives.
133
+
134
+ ### Two mutators
135
+
136
+ The default is eight byte-level operations on a seeded PRNG. It needs nothing
137
+ installed, which is the point: a campaign has to be able to run on a machine
138
+ nobody has prepared -- CI, a Pi, the Windows target.
139
+
140
+ `--radamsa` selects [radamsa](https://gitlab.com/akihe/radamsa) instead, which
141
+ reads the *shape* of its input, so a mutated JSON document is usually still
142
+ JSON and far more mutants survive the adapter:
143
+
144
+ ```bash
145
+ git clone --depth 1 https://gitlab.com/akihe/radamsa
146
+ cd radamsa && make && make install PREFIX=$HOME/.local
147
+
148
+ poetry run python -m iotsploit_fuzzer.core.parser_campaign --radamsa --iterations 2000
149
+ ```
150
+
151
+ Measured on this registry, fresh corpus, same seed, all targets:
152
+
153
+ | | signatures | time | per second |
154
+ |---|---:|---:|---:|
155
+ | built-in, 2,000 inputs | 304 | 41 s | **7.4** |
156
+ | radamsa, 2,000 inputs | **331** | 848 s | 0.4 |
157
+ | built-in, 32,000 inputs | **443** | 614 s | 0.7 |
158
+
159
+ Per input radamsa wins by 9%; per second the built-in wins by 19x. Given equal
160
+ wall clock the built-in found 34% more signatures and two defects radamsa did
161
+ not -- and five of the six product defects the loop has found came from it.
162
+
163
+ Keep radamsa for what it reaches rather than for volume: it builds the deep
164
+ nesting and long repetitions a byte mutator hits only by accident, which is
165
+ how the `RecursionError` in the frame composer was found.
166
+
167
+ Both modes are seeded and reproducible, both record which payload a mutant
168
+ came from, and the manifest says which one ran (`builtin/<seed>` or
169
+ `radamsa/<seed>`). Neither the commit gate nor `--replay` uses a mutator at
170
+ all.
171
+
172
+ ### The nightly run
173
+
174
+ `tools/testing/nightly-parser-fuzz.sh` runs one campaign against every target
175
+ and exits non-zero on a violation. Driven by cron rather than by the platform,
176
+ so that a night when Django or Redis is down is still a night the loop runs:
177
+
178
+ ```cron
179
+ 17 3 * * * /path/to/repo/tools/testing/nightly-parser-fuzz.sh
180
+ ```
181
+
182
+ The seed is the day of the year, so each night explores a different corner and
183
+ any night can be reproduced exactly. Logs land in
184
+ `artifacts/parser-fuzz-logs/`, which is git-ignored; the corpus it grows is
185
+ not, and committing that change is what carries the night's learning to
186
+ everyone else and puts it in the commit gate.
187
+
188
+ `--iterations` is the mutation budget. The retained corpus is replayed on top
189
+ of it rather than out of it -- a boundary movement is defined on a payload the
190
+ ledger already holds, so a corpus larger than the budget would otherwise stop
191
+ the campaign mutating at all.
192
+
193
+ Triage, when it fails: the log names the target, the payload hash and the
194
+ source line. `corpus/<target>/payloads/<hash>.bin` is the input. Fix the owner,
195
+ then `--replay` that target to confirm; the payload stays in the corpus, so
196
+ every commit from then on checks it.
197
+
198
+ ### Fuzzing one function
199
+
200
+ ```bash
201
+ tools/testing/fuzz mypkg.parser:parse_config --raises ValueError --seed '{"port": 80}'
202
+ tools/testing/fuzz ./newfile.py:parse_range --raises ValueError --seed 'bytes=0-1023'
203
+ tools/testing/fuzz mypkg.log:scan --raises LogError --seed @capture.asc
204
+ ```
205
+
206
+ That is the whole thing. It reads the function's signature to work out whether
207
+ to hand it bytes, text, JSON or a path; it finds `ValueError` in builtins and
208
+ `LogError` beside the function itself; and the corpus goes to a temporary
209
+ directory unless you pass `--keep DIR`.
210
+
211
+ **`--raises` is the experiment.** It is the contract you are holding the
212
+ function to -- the exceptions it says it can raise. Anything else escaping is
213
+ the finding. Empty means "this never raises", which is right for a decoder
214
+ that returns a failure object and wrong for a validator.
215
+
216
+ Get it wrong and the run stops before it starts:
217
+
218
+ ```
219
+ Stopping: the seeds already break the contract you gave.
220
+ Every one of them raised something --raises does not cover:
221
+
222
+ AttributeError
223
+
224
+ ... Re-run with:
225
+
226
+ --raises AttributeError
227
+ ```
228
+
229
+ Without that check a wrong contract does not fail, it just never finishes:
230
+ every input becomes a violation and every violation is replayed three times in
231
+ a fresh process to confirm it.
232
+
233
+ **Seeds decide how deep it gets.** One real input is worth more than any
234
+ number of iterations. On the same function, same budget:
235
+
236
+ | Seed | Result |
237
+ |------|--------|
238
+ | none | 1 signature, corpus 3 -- never got past the first check |
239
+ | `bytes=0-1023` | 16 signatures, corpus 36 |
240
+
241
+ **Reading the result.** `violations` is a broken contract, and the payload is
242
+ in the corpus directory. `moved` is a payload that used to do something else
243
+ -- not necessarily a bug. `new` is behaviour never seen before, and should
244
+ fall towards zero as the corpus fills. Zero violations is the normal outcome;
245
+ the signature count is the map of what your function does.
246
+
247
+ ### Fuzzing another application
248
+
249
+ The engine knows nothing about IoTSploit. Targets live in a **pack** -- an
250
+ ordinary module that calls `register()` -- and IoTSploit's is just the one
251
+ that ships here:
252
+
253
+ ```python
254
+ # myapp_fuzz.py, anywhere on PYTHONPATH
255
+ from iotsploit_fuzzer.harnesses.parser_targets import ParseTarget, json_object, register
256
+
257
+ def parse_config(payload: bytes):
258
+ raw = json_object(payload, "a config object")
259
+ ...
260
+
261
+ register(ParseTarget(
262
+ name="myapp.config",
263
+ adapter="myapp_fuzz:parse_config",
264
+ declared=("builtins:ValueError",),
265
+ seeds=(b'{"port": 8080}',),
266
+ ))
267
+ ```
268
+
269
+ ```bash
270
+ python -m iotsploit_fuzzer.core.parser_campaign \
271
+ --targets myapp_fuzz --root ~/myapp-corpus --iterations 3000
272
+ ```
273
+
274
+ `--targets` replaces the default pack entirely, so none of IoTSploit's load.
275
+ `--root` keeps the corpus with your own source, where the gate that replays it
276
+ lives. A pack imports `ParseTarget`, `register`, and whichever adapter helpers
277
+ it wants -- `temp_file`, `json_input`, `json_object`, `text_input` -- each of
278
+ which raises `Skip` rather than letting a malformed payload look like a defect
279
+ in your parser.
280
+
281
+ Everything else -- worker isolation, outcome signatures, the corpus, the
282
+ ledger and its fingerprint, the boundary diff, the gate replay -- works the
283
+ same whatever the pack contains.
284
+
285
+ ### Making it permanent
286
+
287
+ `tools/testing/fuzz` throws its corpus away, which is right for a question you
288
+ are asking once. When a target earns a permanent slot, move it into a pack --
289
+ `targets/iotsploit.py` for this codebase -- as an adapter next to the others
290
+ and an entry in the registry list:
291
+
292
+ ```python
293
+ ParseTarget(
294
+ name="drivers.logic_capture",
295
+ adapter=f"{_HERE}:logic_capture",
296
+ declared=("builtins:ValueError", "builtins:TypeError"),
297
+ seeds=(_CAPTURE_SEED,),
298
+ budget_seconds=5.0,
299
+ ),
300
+ ```
301
+
302
+ `tests/test_parser_targets.py` then checks it on every commit: that the adapter
303
+ and every declared name resolve, and that at least one seed actually reaches
304
+ the target instead of being skipped. Once it has a corpus, the gate replays it.
305
+
306
+ ### Known limits
307
+
308
+ - **`--radamsa` mutants inflate.** Left alone they grow until they hit
309
+ `payload_max_bytes`, and radamsa's cost scales with input size, so a long
310
+ radamsa campaign gets slower as it goes.
311
+ - **Mutation is byte-level by default.** For the JSON-shaped targets (`canbus.from_target`,
312
+ `canbus.decode_frame`) roughly 90% of mutants are not valid JSON and are
313
+ skipped. Structure-aware mutation would fix it and has not been written.
314
+ - **`someip.sd_parse` has almost no observable boundary** from random bytes: it
315
+ catches everything and returns a list, so nearly every input looks the same.
316
+ It needs seeds that are valid SD datagrams to say anything.
317
+ - **Novelty by outcome is a weak fitness signal** next to coverage guidance. It
318
+ plateaus. Real coverage feedback needs `sys.monitoring` (3.12+); on 3.10 it
319
+ would cost a 10-30x slowdown.
320
+
@@ -0,0 +1,296 @@
1
+ # iotsploit-fuzzer
2
+
3
+ Standalone fuzzer library extracted from `zeekr_sat_main`.
4
+
5
+ - **Package name (PyPI)**: `iotsploit-fuzzer`
6
+ - **Import name (Python)**: `iotsploit_fuzzer`
7
+
8
+ ## Install (editable)
9
+
10
+ ```bash
11
+ pip install -e iotsploit-fuzzer
12
+ ```
13
+
14
+ ## Notes
15
+
16
+ This package is being introduced to replace (and later deprecate) the in-repo `iot_protocol_fuzzer` module.
17
+
18
+
19
+
20
+ ## Two things this package fuzzes
21
+
22
+ They are not the same job, and conflating them is how a fuzzing effort ends up
23
+ measuring the wrong thing.
24
+
25
+ **Outbound — the device under test.** CAN/UART/SPI payloads sent through a
26
+ wire harness at whatever is on the other end. Needs the rig. This is what
27
+ `CANHarness`, `UARTHarness` and `SPIHarness` do, driven from the Django UI.
28
+
29
+ **Inbound — our own parsers.** The same `Orchestrator`, pointed at a function
30
+ instead of an interface. Needs nothing but CPU, so it runs in CI and in the
31
+ commit gate. That is what follows.
32
+
33
+ ## The parser loop
34
+
35
+ A campaign that is run once, reported and forgotten finds its last bug in week
36
+ two. What keeps producing information is the **error boundary** -- the line
37
+ between the inputs a parser accepts and the ones it rejects -- because every
38
+ refactor moves it and almost every move is unintended. Nothing in the test
39
+ suite asserts which ARXML files import or which ASC lines parse. The ledger is
40
+ that assertion, and it writes itself.
41
+
42
+ ```bash
43
+ # What is in the registry
44
+ poetry run python -m iotsploit_fuzzer.core.parser_campaign --list
45
+
46
+ # One target, one campaign
47
+ poetry run python -m iotsploit_fuzzer.core.parser_campaign \
48
+ --target canbus.scan_log --iterations 2000
49
+
50
+ # Everything, as a nightly run. Exits non-zero on a violation.
51
+ poetry run python -m iotsploit_fuzzer.core.parser_campaign --iterations 2000
52
+
53
+ # Replay the retained corpus and generate nothing. This is what the commit
54
+ # gate runs, via tests/test_parser_corpus_replay.py.
55
+ poetry run python -m iotsploit_fuzzer.core.parser_campaign --replay
56
+ ```
57
+
58
+ A campaign reports exactly three things:
59
+
60
+ | Event | Means | Nightly |
61
+ |-------|-------|---------|
62
+ | `VIOLATION` | A contract broke: an undeclared exception, a hang, a resource limit, a broken round trip | Fails |
63
+ | `BOUNDARY_MOVED` | A payload the ledger knows now does something else. **Not necessarily a bug** -- a behaviour change somebody should confirm was intended | Reports |
64
+ | `NEW_REGION` | A signature this target has never produced. The corpus grew | Informational |
65
+
66
+ ### Each parse runs in a process we are willing to lose
67
+
68
+ `ParserHarness` is a controller; the parse happens in a subprocess under
69
+ wall-clock, memory, output-size and payload-size limits. This is not
70
+ defensiveness. A parser that allocates until the host swaps cannot be
71
+ recovered from in-process -- `except MemoryError` runs only after the
72
+ allocation happened -- and a signal handler only runs at a bytecode boundary
73
+ the interpreter may never reach again. Worker death *is* the result.
74
+
75
+ Workers are batched and recycled rather than forked per payload, because a
76
+ spawn costs more than the parsing does. Anything that is not a plain accept or
77
+ reject is re-run alone in a fresh worker, three times, before it is believed:
78
+ an outcome that does not reproduce is reported as flaky and never enters the
79
+ corpus.
80
+
81
+ ### The ledger is a contract, and it is versioned
82
+
83
+ `corpus/<target>/` holds two files: `payloads.zip` and `ledger.json`. One
84
+ archive per target rather than one file per payload -- a thousand inputs of a
85
+ hundred bytes each, stored loose, was 74% of the repository's tracked file
86
+ count for 1% of its bytes, and a 4 KB block apiece turned 1.1 MB into 9.4 MB
87
+ on disk. An existing loose corpus migrates itself on the next save.
88
+
89
+ The ledger is tracked in git on purpose -- it is the loop's
90
+ memory, the gate's regression corpus, and a boundary movement arrives as a
91
+ JSON diff in the pull request that caused it. Each entry carries the payload's
92
+ content hash, its normalised outcome signature, and the campaign that first
93
+ saw it.
94
+
95
+ Signatures never contain a raw exception message: messages carry offsets, hex,
96
+ paths and quoted input, and comparing them would report thousands of movements
97
+ nobody caused. They also never contain a line number, for the same reason.
98
+
99
+ Changing a target's declared exception set, its adapter, or the signature
100
+ format changes its **fingerprint**, and a ledger recorded under a different
101
+ fingerprint is refused rather than diffed:
102
+
103
+ ```bash
104
+ poetry run python -m iotsploit_fuzzer.core.parser_campaign \
105
+ --target canbus.scan_log --rebaseline
106
+ ```
107
+
108
+ Re-baselining keeps the payloads -- they are the expensive part -- and drops
109
+ only the claim about what they do, which the next campaign re-derives.
110
+
111
+ ### Two mutators
112
+
113
+ The default is eight byte-level operations on a seeded PRNG. It needs nothing
114
+ installed, which is the point: a campaign has to be able to run on a machine
115
+ nobody has prepared -- CI, a Pi, the Windows target.
116
+
117
+ `--radamsa` selects [radamsa](https://gitlab.com/akihe/radamsa) instead, which
118
+ reads the *shape* of its input, so a mutated JSON document is usually still
119
+ JSON and far more mutants survive the adapter:
120
+
121
+ ```bash
122
+ git clone --depth 1 https://gitlab.com/akihe/radamsa
123
+ cd radamsa && make && make install PREFIX=$HOME/.local
124
+
125
+ poetry run python -m iotsploit_fuzzer.core.parser_campaign --radamsa --iterations 2000
126
+ ```
127
+
128
+ Measured on this registry, fresh corpus, same seed, all targets:
129
+
130
+ | | signatures | time | per second |
131
+ |---|---:|---:|---:|
132
+ | built-in, 2,000 inputs | 304 | 41 s | **7.4** |
133
+ | radamsa, 2,000 inputs | **331** | 848 s | 0.4 |
134
+ | built-in, 32,000 inputs | **443** | 614 s | 0.7 |
135
+
136
+ Per input radamsa wins by 9%; per second the built-in wins by 19x. Given equal
137
+ wall clock the built-in found 34% more signatures and two defects radamsa did
138
+ not -- and five of the six product defects the loop has found came from it.
139
+
140
+ Keep radamsa for what it reaches rather than for volume: it builds the deep
141
+ nesting and long repetitions a byte mutator hits only by accident, which is
142
+ how the `RecursionError` in the frame composer was found.
143
+
144
+ Both modes are seeded and reproducible, both record which payload a mutant
145
+ came from, and the manifest says which one ran (`builtin/<seed>` or
146
+ `radamsa/<seed>`). Neither the commit gate nor `--replay` uses a mutator at
147
+ all.
148
+
149
+ ### The nightly run
150
+
151
+ `tools/testing/nightly-parser-fuzz.sh` runs one campaign against every target
152
+ and exits non-zero on a violation. Driven by cron rather than by the platform,
153
+ so that a night when Django or Redis is down is still a night the loop runs:
154
+
155
+ ```cron
156
+ 17 3 * * * /path/to/repo/tools/testing/nightly-parser-fuzz.sh
157
+ ```
158
+
159
+ The seed is the day of the year, so each night explores a different corner and
160
+ any night can be reproduced exactly. Logs land in
161
+ `artifacts/parser-fuzz-logs/`, which is git-ignored; the corpus it grows is
162
+ not, and committing that change is what carries the night's learning to
163
+ everyone else and puts it in the commit gate.
164
+
165
+ `--iterations` is the mutation budget. The retained corpus is replayed on top
166
+ of it rather than out of it -- a boundary movement is defined on a payload the
167
+ ledger already holds, so a corpus larger than the budget would otherwise stop
168
+ the campaign mutating at all.
169
+
170
+ Triage, when it fails: the log names the target, the payload hash and the
171
+ source line. `corpus/<target>/payloads/<hash>.bin` is the input. Fix the owner,
172
+ then `--replay` that target to confirm; the payload stays in the corpus, so
173
+ every commit from then on checks it.
174
+
175
+ ### Fuzzing one function
176
+
177
+ ```bash
178
+ tools/testing/fuzz mypkg.parser:parse_config --raises ValueError --seed '{"port": 80}'
179
+ tools/testing/fuzz ./newfile.py:parse_range --raises ValueError --seed 'bytes=0-1023'
180
+ tools/testing/fuzz mypkg.log:scan --raises LogError --seed @capture.asc
181
+ ```
182
+
183
+ That is the whole thing. It reads the function's signature to work out whether
184
+ to hand it bytes, text, JSON or a path; it finds `ValueError` in builtins and
185
+ `LogError` beside the function itself; and the corpus goes to a temporary
186
+ directory unless you pass `--keep DIR`.
187
+
188
+ **`--raises` is the experiment.** It is the contract you are holding the
189
+ function to -- the exceptions it says it can raise. Anything else escaping is
190
+ the finding. Empty means "this never raises", which is right for a decoder
191
+ that returns a failure object and wrong for a validator.
192
+
193
+ Get it wrong and the run stops before it starts:
194
+
195
+ ```
196
+ Stopping: the seeds already break the contract you gave.
197
+ Every one of them raised something --raises does not cover:
198
+
199
+ AttributeError
200
+
201
+ ... Re-run with:
202
+
203
+ --raises AttributeError
204
+ ```
205
+
206
+ Without that check a wrong contract does not fail, it just never finishes:
207
+ every input becomes a violation and every violation is replayed three times in
208
+ a fresh process to confirm it.
209
+
210
+ **Seeds decide how deep it gets.** One real input is worth more than any
211
+ number of iterations. On the same function, same budget:
212
+
213
+ | Seed | Result |
214
+ |------|--------|
215
+ | none | 1 signature, corpus 3 -- never got past the first check |
216
+ | `bytes=0-1023` | 16 signatures, corpus 36 |
217
+
218
+ **Reading the result.** `violations` is a broken contract, and the payload is
219
+ in the corpus directory. `moved` is a payload that used to do something else
220
+ -- not necessarily a bug. `new` is behaviour never seen before, and should
221
+ fall towards zero as the corpus fills. Zero violations is the normal outcome;
222
+ the signature count is the map of what your function does.
223
+
224
+ ### Fuzzing another application
225
+
226
+ The engine knows nothing about IoTSploit. Targets live in a **pack** -- an
227
+ ordinary module that calls `register()` -- and IoTSploit's is just the one
228
+ that ships here:
229
+
230
+ ```python
231
+ # myapp_fuzz.py, anywhere on PYTHONPATH
232
+ from iotsploit_fuzzer.harnesses.parser_targets import ParseTarget, json_object, register
233
+
234
+ def parse_config(payload: bytes):
235
+ raw = json_object(payload, "a config object")
236
+ ...
237
+
238
+ register(ParseTarget(
239
+ name="myapp.config",
240
+ adapter="myapp_fuzz:parse_config",
241
+ declared=("builtins:ValueError",),
242
+ seeds=(b'{"port": 8080}',),
243
+ ))
244
+ ```
245
+
246
+ ```bash
247
+ python -m iotsploit_fuzzer.core.parser_campaign \
248
+ --targets myapp_fuzz --root ~/myapp-corpus --iterations 3000
249
+ ```
250
+
251
+ `--targets` replaces the default pack entirely, so none of IoTSploit's load.
252
+ `--root` keeps the corpus with your own source, where the gate that replays it
253
+ lives. A pack imports `ParseTarget`, `register`, and whichever adapter helpers
254
+ it wants -- `temp_file`, `json_input`, `json_object`, `text_input` -- each of
255
+ which raises `Skip` rather than letting a malformed payload look like a defect
256
+ in your parser.
257
+
258
+ Everything else -- worker isolation, outcome signatures, the corpus, the
259
+ ledger and its fingerprint, the boundary diff, the gate replay -- works the
260
+ same whatever the pack contains.
261
+
262
+ ### Making it permanent
263
+
264
+ `tools/testing/fuzz` throws its corpus away, which is right for a question you
265
+ are asking once. When a target earns a permanent slot, move it into a pack --
266
+ `targets/iotsploit.py` for this codebase -- as an adapter next to the others
267
+ and an entry in the registry list:
268
+
269
+ ```python
270
+ ParseTarget(
271
+ name="drivers.logic_capture",
272
+ adapter=f"{_HERE}:logic_capture",
273
+ declared=("builtins:ValueError", "builtins:TypeError"),
274
+ seeds=(_CAPTURE_SEED,),
275
+ budget_seconds=5.0,
276
+ ),
277
+ ```
278
+
279
+ `tests/test_parser_targets.py` then checks it on every commit: that the adapter
280
+ and every declared name resolve, and that at least one seed actually reaches
281
+ the target instead of being skipped. Once it has a corpus, the gate replays it.
282
+
283
+ ### Known limits
284
+
285
+ - **`--radamsa` mutants inflate.** Left alone they grow until they hit
286
+ `payload_max_bytes`, and radamsa's cost scales with input size, so a long
287
+ radamsa campaign gets slower as it goes.
288
+ - **Mutation is byte-level by default.** For the JSON-shaped targets (`canbus.from_target`,
289
+ `canbus.decode_frame`) roughly 90% of mutants are not valid JSON and are
290
+ skipped. Structure-aware mutation would fix it and has not been written.
291
+ - **`someip.sd_parse` has almost no observable boundary** from random bytes: it
292
+ catches everything and returns a list, so nearly every input looks the same.
293
+ It needs seeds that are valid SD datagrams to say anything.
294
+ - **Novelty by outcome is a weak fitness signal** next to coverage guidance. It
295
+ plateaus. Real coverage feedback needs `sys.monitoring` (3.12+); on 3.10 it
296
+ would cost a 10-30x slowdown.
@@ -0,0 +1,32 @@
1
+ [tool.poetry]
2
+ name = "iotsploit-fuzzer"
3
+ version = "0.0.9"
4
+ description = "IoTSploit fuzzer library (ports + core + adapters), extracted from zeekr_sat_main"
5
+ authors = ["IoTSploit Team <devnull@example.com>"]
6
+ readme = "README.md"
7
+ packages = [{ include = "iotsploit_fuzzer", from = "src" }]
8
+
9
+ [tool.poetry.dependencies]
10
+ python = ">=3.10,<4.0"
11
+
12
+ # Keep core deps minimal; optional hardware/protocol deps are in extras.
13
+ python-can = { version = "*", optional = true }
14
+ pyserial = { version = "*", optional = true }
15
+ spidev = { version = "*", optional = true }
16
+
17
+ [tool.poetry.group.dev.dependencies]
18
+ pytest = "^7.4.0"
19
+
20
+ [tool.poetry.extras]
21
+ can = ["python-can"]
22
+ uart = ["pyserial"]
23
+ spi = ["spidev"]
24
+ all = ["python-can", "pyserial", "spidev"]
25
+
26
+ [build-system]
27
+ requires = ["poetry-core>=1.8.0"]
28
+ build-backend = "poetry.core.masonry.api"
29
+
30
+ [tool.pytest.ini_options]
31
+ testpaths = ["tests"]
32
+ addopts = ["-q"]