oscura 0.3.0__py3-none-any.whl → 0.5.0__py3-none-any.whl
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.
- oscura/__init__.py +1 -7
- oscura/acquisition/__init__.py +147 -0
- oscura/acquisition/file.py +255 -0
- oscura/acquisition/hardware.py +186 -0
- oscura/acquisition/saleae.py +340 -0
- oscura/acquisition/socketcan.py +315 -0
- oscura/acquisition/streaming.py +38 -0
- oscura/acquisition/synthetic.py +229 -0
- oscura/acquisition/visa.py +376 -0
- oscura/analyzers/__init__.py +3 -0
- oscura/analyzers/digital/__init__.py +48 -0
- oscura/analyzers/digital/clock.py +9 -1
- oscura/analyzers/digital/edges.py +1 -1
- oscura/analyzers/digital/extraction.py +195 -0
- oscura/analyzers/digital/ic_database.py +498 -0
- oscura/analyzers/digital/timing.py +41 -11
- oscura/analyzers/digital/timing_paths.py +339 -0
- oscura/analyzers/digital/vintage.py +377 -0
- oscura/analyzers/digital/vintage_result.py +148 -0
- oscura/analyzers/protocols/__init__.py +22 -1
- oscura/analyzers/protocols/parallel_bus.py +449 -0
- oscura/analyzers/side_channel/__init__.py +52 -0
- oscura/analyzers/side_channel/power.py +690 -0
- oscura/analyzers/side_channel/timing.py +369 -0
- oscura/analyzers/signal_integrity/sparams.py +1 -1
- oscura/automotive/__init__.py +4 -2
- oscura/automotive/can/patterns.py +3 -1
- oscura/automotive/can/session.py +277 -78
- oscura/automotive/can/state_machine.py +5 -2
- oscura/builders/__init__.py +9 -11
- oscura/builders/signal_builder.py +99 -191
- oscura/core/exceptions.py +5 -1
- oscura/export/__init__.py +12 -0
- oscura/export/wavedrom.py +430 -0
- oscura/exporters/json_export.py +47 -0
- oscura/exporters/vintage_logic_csv.py +247 -0
- oscura/loaders/__init__.py +1 -0
- oscura/loaders/chipwhisperer.py +393 -0
- oscura/loaders/touchstone.py +1 -1
- oscura/reporting/__init__.py +7 -0
- oscura/reporting/vintage_logic_report.py +523 -0
- oscura/session/session.py +54 -46
- oscura/sessions/__init__.py +70 -0
- oscura/sessions/base.py +323 -0
- oscura/sessions/blackbox.py +640 -0
- oscura/sessions/generic.py +189 -0
- oscura/utils/autodetect.py +5 -1
- oscura/visualization/digital_advanced.py +718 -0
- oscura/visualization/figure_manager.py +156 -0
- {oscura-0.3.0.dist-info → oscura-0.5.0.dist-info}/METADATA +86 -5
- {oscura-0.3.0.dist-info → oscura-0.5.0.dist-info}/RECORD +54 -33
- oscura/automotive/dtc/data.json +0 -2763
- oscura/schemas/bus_configuration.json +0 -322
- oscura/schemas/device_mapping.json +0 -182
- oscura/schemas/packet_format.json +0 -418
- oscura/schemas/protocol_definition.json +0 -363
- {oscura-0.3.0.dist-info → oscura-0.5.0.dist-info}/WHEEL +0 -0
- {oscura-0.3.0.dist-info → oscura-0.5.0.dist-info}/entry_points.txt +0 -0
- {oscura-0.3.0.dist-info → oscura-0.5.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
"""Black-box protocol analysis session.
|
|
2
|
+
|
|
3
|
+
This module provides BlackBoxSession - a specialized analysis session for
|
|
4
|
+
reverse engineering unknown protocols through differential analysis and
|
|
5
|
+
field hypothesis generation.
|
|
6
|
+
|
|
7
|
+
Example:
|
|
8
|
+
>>> from oscura.sessions import BlackBoxSession
|
|
9
|
+
>>> from oscura.acquisition import FileSource
|
|
10
|
+
>>>
|
|
11
|
+
>>> # Create session for black-box protocol analysis
|
|
12
|
+
>>> session = BlackBoxSession(name="IoT Device Protocol Analysis")
|
|
13
|
+
>>>
|
|
14
|
+
>>> # Add recordings from different stimuli
|
|
15
|
+
>>> session.add_recording("baseline", FileSource("idle.bin"))
|
|
16
|
+
>>> session.add_recording("button_press", FileSource("button.bin"))
|
|
17
|
+
>>> session.add_recording("temperature_25C", FileSource("temp25.bin"))
|
|
18
|
+
>>> session.add_recording("temperature_30C", FileSource("temp30.bin"))
|
|
19
|
+
>>>
|
|
20
|
+
>>> # Compare recordings to find differences
|
|
21
|
+
>>> diff = session.compare("baseline", "button_press")
|
|
22
|
+
>>> print(f"Changed bytes: {diff.changed_bytes}")
|
|
23
|
+
>>>
|
|
24
|
+
>>> # Generate protocol specification
|
|
25
|
+
>>> spec = session.generate_protocol_spec()
|
|
26
|
+
>>> print(f"Inferred fields: {len(spec['fields'])}")
|
|
27
|
+
>>>
|
|
28
|
+
>>> # Infer state machine
|
|
29
|
+
>>> sm = session.infer_state_machine()
|
|
30
|
+
>>> print(f"States: {len(sm.states)}")
|
|
31
|
+
>>>
|
|
32
|
+
>>> # Export results
|
|
33
|
+
>>> session.export_results("report", "analysis_report.md")
|
|
34
|
+
>>> session.export_results("dissector", "protocol.lua")
|
|
35
|
+
|
|
36
|
+
Pattern:
|
|
37
|
+
BlackBoxSession extends AnalysisSession and adds:
|
|
38
|
+
- Differential analysis (byte-level comparison)
|
|
39
|
+
- Field hypothesis generation
|
|
40
|
+
- State machine inference
|
|
41
|
+
- CRC/checksum reverse engineering
|
|
42
|
+
- Protocol specification generation
|
|
43
|
+
- Wireshark dissector export
|
|
44
|
+
|
|
45
|
+
Use Cases:
|
|
46
|
+
- IoT device protocol reverse engineering
|
|
47
|
+
- Proprietary protocol understanding
|
|
48
|
+
- Security vulnerability discovery
|
|
49
|
+
- Right-to-repair device replication
|
|
50
|
+
- Commercial intelligence
|
|
51
|
+
|
|
52
|
+
References:
|
|
53
|
+
Architecture Plan Phase 1.1: BlackBoxSession
|
|
54
|
+
docs/architecture/api-patterns.md: When to use Sessions
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
from __future__ import annotations
|
|
58
|
+
|
|
59
|
+
from dataclasses import dataclass, field
|
|
60
|
+
from pathlib import Path
|
|
61
|
+
from typing import TYPE_CHECKING, Any
|
|
62
|
+
|
|
63
|
+
import numpy as np
|
|
64
|
+
from numpy.typing import NDArray
|
|
65
|
+
|
|
66
|
+
from oscura.inference.alignment import align_global
|
|
67
|
+
from oscura.inference.message_format import infer_format
|
|
68
|
+
from oscura.inference.state_machine import infer_rpni
|
|
69
|
+
from oscura.sessions.base import AnalysisSession, ComparisonResult
|
|
70
|
+
|
|
71
|
+
if TYPE_CHECKING:
|
|
72
|
+
from oscura.core.types import Trace
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class FieldHypothesis:
|
|
77
|
+
"""Hypothesis about a field in the protocol.
|
|
78
|
+
|
|
79
|
+
Attributes:
|
|
80
|
+
name: Field name (e.g., "counter", "temperature", "checksum").
|
|
81
|
+
offset: Byte offset in message.
|
|
82
|
+
length: Field length in bytes.
|
|
83
|
+
field_type: Inferred type ("counter", "constant", "checksum", "data", "unknown").
|
|
84
|
+
confidence: Confidence score (0.0 to 1.0).
|
|
85
|
+
evidence: Supporting evidence for this hypothesis.
|
|
86
|
+
|
|
87
|
+
Example:
|
|
88
|
+
>>> field = FieldHypothesis(
|
|
89
|
+
... name="message_counter",
|
|
90
|
+
... offset=2,
|
|
91
|
+
... length=1,
|
|
92
|
+
... field_type="counter",
|
|
93
|
+
... confidence=0.95,
|
|
94
|
+
... evidence={"increments_by_1": True, "wraps_at_256": True}
|
|
95
|
+
... )
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
name: str
|
|
99
|
+
offset: int
|
|
100
|
+
length: int
|
|
101
|
+
field_type: str
|
|
102
|
+
confidence: float
|
|
103
|
+
evidence: dict[str, Any] = field(default_factory=dict)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass
|
|
107
|
+
class ProtocolSpec:
|
|
108
|
+
"""Protocol specification generated from analysis.
|
|
109
|
+
|
|
110
|
+
Attributes:
|
|
111
|
+
name: Protocol name.
|
|
112
|
+
fields: List of inferred fields.
|
|
113
|
+
state_machine: State machine (if inferred).
|
|
114
|
+
crc_info: CRC/checksum information (if found).
|
|
115
|
+
constants: Dictionary of constant values.
|
|
116
|
+
metadata: Additional protocol metadata.
|
|
117
|
+
|
|
118
|
+
Example:
|
|
119
|
+
>>> spec = ProtocolSpec(
|
|
120
|
+
... name="IoT Device Protocol",
|
|
121
|
+
... fields=[field1, field2, field3],
|
|
122
|
+
... state_machine=sm,
|
|
123
|
+
... crc_info={"polynomial": 0x1021, "location": (4, 6)}
|
|
124
|
+
... )
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
name: str
|
|
128
|
+
fields: list[FieldHypothesis] = field(default_factory=list)
|
|
129
|
+
state_machine: Any = None
|
|
130
|
+
crc_info: dict[str, Any] = field(default_factory=dict)
|
|
131
|
+
constants: dict[str, Any] = field(default_factory=dict)
|
|
132
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class BlackBoxSession(AnalysisSession):
|
|
136
|
+
"""Session for black-box protocol reverse engineering.
|
|
137
|
+
|
|
138
|
+
Provides differential analysis, field inference, and protocol
|
|
139
|
+
specification generation for unknown protocols.
|
|
140
|
+
|
|
141
|
+
Features:
|
|
142
|
+
- Byte-level differential analysis
|
|
143
|
+
- Automatic field hypothesis generation
|
|
144
|
+
- State machine inference
|
|
145
|
+
- CRC/checksum reverse engineering
|
|
146
|
+
- Protocol specification export
|
|
147
|
+
- Wireshark dissector generation
|
|
148
|
+
|
|
149
|
+
Example:
|
|
150
|
+
>>> session = BlackBoxSession(name="Device RE")
|
|
151
|
+
>>> session.add_recording("idle", FileSource("idle.bin"))
|
|
152
|
+
>>> session.add_recording("active", FileSource("active.bin"))
|
|
153
|
+
>>> diff = session.compare("idle", "active")
|
|
154
|
+
>>> spec = session.generate_protocol_spec()
|
|
155
|
+
>>> session.export_results("dissector", "protocol.lua")
|
|
156
|
+
|
|
157
|
+
References:
|
|
158
|
+
Architecture Plan Phase 1.1: BlackBoxSession
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
def __init__(self, name: str = "Black-Box Analysis") -> None:
|
|
162
|
+
"""Initialize black-box analysis session.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
name: Session name (default: "Black-Box Analysis").
|
|
166
|
+
|
|
167
|
+
Example:
|
|
168
|
+
>>> session = BlackBoxSession(name="IoT Protocol RE")
|
|
169
|
+
"""
|
|
170
|
+
super().__init__(name)
|
|
171
|
+
self._field_hypotheses: list[FieldHypothesis] = []
|
|
172
|
+
self._protocol_spec: ProtocolSpec | None = None
|
|
173
|
+
|
|
174
|
+
def analyze(self) -> dict[str, Any]:
|
|
175
|
+
"""Perform comprehensive black-box protocol analysis.
|
|
176
|
+
|
|
177
|
+
Analyzes all recordings to:
|
|
178
|
+
- Generate field hypotheses
|
|
179
|
+
- Infer state machine
|
|
180
|
+
- Detect CRC/checksums
|
|
181
|
+
- Build protocol specification
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
Dictionary with analysis results:
|
|
185
|
+
- num_recordings: Number of recordings analyzed
|
|
186
|
+
- field_hypotheses: List of inferred fields
|
|
187
|
+
- state_machine: Inferred state machine (if found)
|
|
188
|
+
- crc_info: CRC/checksum information (if found)
|
|
189
|
+
- protocol_spec: Complete protocol specification
|
|
190
|
+
|
|
191
|
+
Example:
|
|
192
|
+
>>> session = BlackBoxSession()
|
|
193
|
+
>>> session.add_recording("r1", FileSource("data1.bin"))
|
|
194
|
+
>>> session.add_recording("r2", FileSource("data2.bin"))
|
|
195
|
+
>>> results = session.analyze()
|
|
196
|
+
>>> print(f"Found {len(results['field_hypotheses'])} fields")
|
|
197
|
+
|
|
198
|
+
References:
|
|
199
|
+
Architecture Plan Phase 1.1: BlackBoxSession Analysis
|
|
200
|
+
"""
|
|
201
|
+
if not self.recordings:
|
|
202
|
+
return {
|
|
203
|
+
"num_recordings": 0,
|
|
204
|
+
"field_hypotheses": [],
|
|
205
|
+
"state_machine": None,
|
|
206
|
+
"crc_info": {},
|
|
207
|
+
"protocol_spec": None,
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
# Load all recordings
|
|
211
|
+
traces = []
|
|
212
|
+
for name, (source, cached_trace) in self.recordings.items():
|
|
213
|
+
if cached_trace is None:
|
|
214
|
+
cached_trace = source.read()
|
|
215
|
+
self.recordings[name] = (source, cached_trace)
|
|
216
|
+
traces.append(cached_trace)
|
|
217
|
+
|
|
218
|
+
# Generate field hypotheses
|
|
219
|
+
self._field_hypotheses = self._generate_field_hypotheses(traces)
|
|
220
|
+
|
|
221
|
+
# Infer state machine
|
|
222
|
+
state_machine = self._infer_state_machine(traces)
|
|
223
|
+
|
|
224
|
+
# Detect CRC/checksums
|
|
225
|
+
crc_info = self._detect_crc(traces)
|
|
226
|
+
|
|
227
|
+
# Build protocol specification
|
|
228
|
+
self._protocol_spec = ProtocolSpec(
|
|
229
|
+
name=f"{self.name} Protocol",
|
|
230
|
+
fields=self._field_hypotheses,
|
|
231
|
+
state_machine=state_machine,
|
|
232
|
+
crc_info=crc_info,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
"num_recordings": len(self.recordings),
|
|
237
|
+
"field_hypotheses": self._field_hypotheses,
|
|
238
|
+
"state_machine": state_machine,
|
|
239
|
+
"crc_info": crc_info,
|
|
240
|
+
"protocol_spec": self._protocol_spec,
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
def compare(self, name1: str, name2: str) -> ComparisonResult:
|
|
244
|
+
"""Compare two recordings for differential analysis.
|
|
245
|
+
|
|
246
|
+
Performs byte-level comparison to identify:
|
|
247
|
+
- Changed bytes
|
|
248
|
+
- Changed regions
|
|
249
|
+
- Similarity score
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
name1: Name of first recording.
|
|
253
|
+
name2: Name of second recording.
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
ComparisonResult with detailed differences.
|
|
257
|
+
|
|
258
|
+
Example:
|
|
259
|
+
>>> session = BlackBoxSession()
|
|
260
|
+
>>> session.add_recording("baseline", FileSource("idle.bin"))
|
|
261
|
+
>>> session.add_recording("stimulus", FileSource("button.bin"))
|
|
262
|
+
>>> diff = session.compare("baseline", "stimulus")
|
|
263
|
+
>>> print(f"Changed bytes: {diff.changed_bytes}")
|
|
264
|
+
>>> for start, end, desc in diff.changed_regions:
|
|
265
|
+
... print(f" Region {start}-{end}: {desc}")
|
|
266
|
+
|
|
267
|
+
References:
|
|
268
|
+
Architecture Plan Phase 1.1: Differential Analysis
|
|
269
|
+
"""
|
|
270
|
+
# Get recordings
|
|
271
|
+
if name1 not in self.recordings:
|
|
272
|
+
raise KeyError(f"Recording '{name1}' not found")
|
|
273
|
+
if name2 not in self.recordings:
|
|
274
|
+
raise KeyError(f"Recording '{name2}' not found")
|
|
275
|
+
|
|
276
|
+
# Load traces
|
|
277
|
+
source1, trace1 = self.recordings[name1]
|
|
278
|
+
if trace1 is None:
|
|
279
|
+
trace1 = source1.read()
|
|
280
|
+
self.recordings[name1] = (source1, trace1)
|
|
281
|
+
|
|
282
|
+
source2, trace2 = self.recordings[name2]
|
|
283
|
+
if trace2 is None:
|
|
284
|
+
trace2 = source2.read()
|
|
285
|
+
self.recordings[name2] = (source2, trace2)
|
|
286
|
+
|
|
287
|
+
# Convert to byte arrays for comparison
|
|
288
|
+
data1 = self._trace_to_bytes(trace1)
|
|
289
|
+
data2 = self._trace_to_bytes(trace2)
|
|
290
|
+
|
|
291
|
+
# Align sequences if different lengths
|
|
292
|
+
if len(data1) != len(data2):
|
|
293
|
+
alignment = align_global(data1.tolist(), data2.tolist())
|
|
294
|
+
# AlignmentResult uses aligned_a and aligned_b, not seq1_aligned/seq2_aligned
|
|
295
|
+
data1 = np.array(alignment.aligned_a, dtype=np.int32) # -1 for gaps
|
|
296
|
+
data2 = np.array(alignment.aligned_b, dtype=np.int32) # -1 for gaps
|
|
297
|
+
|
|
298
|
+
# Find differences
|
|
299
|
+
min_len = min(len(data1), len(data2))
|
|
300
|
+
diffs = data1[:min_len] != data2[:min_len]
|
|
301
|
+
changed_bytes = int(np.sum(diffs))
|
|
302
|
+
|
|
303
|
+
# Find changed regions
|
|
304
|
+
changed_regions = self._find_changed_regions(diffs)
|
|
305
|
+
|
|
306
|
+
# Calculate similarity
|
|
307
|
+
total_bytes = max(len(data1), len(data2))
|
|
308
|
+
similarity = 1.0 - (changed_bytes / total_bytes) if total_bytes > 0 else 1.0
|
|
309
|
+
|
|
310
|
+
return ComparisonResult(
|
|
311
|
+
recording1=name1,
|
|
312
|
+
recording2=name2,
|
|
313
|
+
changed_bytes=changed_bytes,
|
|
314
|
+
changed_regions=changed_regions,
|
|
315
|
+
similarity_score=similarity,
|
|
316
|
+
details={
|
|
317
|
+
"len1": len(data1),
|
|
318
|
+
"len2": len(data2),
|
|
319
|
+
"alignment_required": len(data1) != len(data2),
|
|
320
|
+
},
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
def generate_protocol_spec(self) -> ProtocolSpec:
|
|
324
|
+
"""Generate complete protocol specification.
|
|
325
|
+
|
|
326
|
+
Runs full analysis and returns protocol specification with:
|
|
327
|
+
- Inferred fields
|
|
328
|
+
- State machine
|
|
329
|
+
- CRC information
|
|
330
|
+
- Constants
|
|
331
|
+
|
|
332
|
+
Returns:
|
|
333
|
+
ProtocolSpec with complete protocol information.
|
|
334
|
+
|
|
335
|
+
Example:
|
|
336
|
+
>>> session = BlackBoxSession()
|
|
337
|
+
>>> # ... add recordings ...
|
|
338
|
+
>>> spec = session.generate_protocol_spec()
|
|
339
|
+
>>> print(f"Protocol: {spec.name}")
|
|
340
|
+
>>> for field in spec.fields:
|
|
341
|
+
... print(f" {field.name} @ offset {field.offset}")
|
|
342
|
+
|
|
343
|
+
References:
|
|
344
|
+
Architecture Plan Phase 1.1: Protocol Specification
|
|
345
|
+
"""
|
|
346
|
+
if self._protocol_spec is None:
|
|
347
|
+
self.analyze()
|
|
348
|
+
|
|
349
|
+
return self._protocol_spec # type: ignore[return-value]
|
|
350
|
+
|
|
351
|
+
def infer_state_machine(self) -> Any:
|
|
352
|
+
"""Infer state machine from recordings.
|
|
353
|
+
|
|
354
|
+
Analyzes message sequences to infer:
|
|
355
|
+
- States
|
|
356
|
+
- Transitions
|
|
357
|
+
- Triggers
|
|
358
|
+
|
|
359
|
+
Returns:
|
|
360
|
+
StateMachine object with inferred states and transitions.
|
|
361
|
+
|
|
362
|
+
Example:
|
|
363
|
+
>>> session = BlackBoxSession()
|
|
364
|
+
>>> # ... add recordings ...
|
|
365
|
+
>>> sm = session.infer_state_machine()
|
|
366
|
+
>>> print(f"States: {len(sm.states)}")
|
|
367
|
+
>>> print(f"Transitions: {len(sm.transitions)}")
|
|
368
|
+
|
|
369
|
+
References:
|
|
370
|
+
Architecture Plan Phase 1.1: State Machine Inference
|
|
371
|
+
"""
|
|
372
|
+
if not self.recordings:
|
|
373
|
+
return None
|
|
374
|
+
|
|
375
|
+
# Load all traces
|
|
376
|
+
traces = []
|
|
377
|
+
for name, (source, cached_trace) in self.recordings.items():
|
|
378
|
+
if cached_trace is None:
|
|
379
|
+
cached_trace = source.read()
|
|
380
|
+
self.recordings[name] = (source, cached_trace)
|
|
381
|
+
traces.append(cached_trace)
|
|
382
|
+
|
|
383
|
+
return self._infer_state_machine(traces)
|
|
384
|
+
|
|
385
|
+
def export_results(self, format: str, path: str | Path) -> None:
|
|
386
|
+
"""Export analysis results to file.
|
|
387
|
+
|
|
388
|
+
Supported formats:
|
|
389
|
+
- "report": Markdown analysis report
|
|
390
|
+
- "dissector": Wireshark Lua dissector
|
|
391
|
+
- "spec": Protocol specification JSON
|
|
392
|
+
- "json": Complete results as JSON
|
|
393
|
+
- "csv": Field hypotheses as CSV
|
|
394
|
+
|
|
395
|
+
Args:
|
|
396
|
+
format: Export format.
|
|
397
|
+
path: Output file path.
|
|
398
|
+
|
|
399
|
+
Example:
|
|
400
|
+
>>> session = BlackBoxSession()
|
|
401
|
+
>>> # ... perform analysis ...
|
|
402
|
+
>>> session.export_results("report", "analysis.md")
|
|
403
|
+
>>> session.export_results("dissector", "protocol.lua")
|
|
404
|
+
>>> session.export_results("spec", "protocol.json")
|
|
405
|
+
|
|
406
|
+
References:
|
|
407
|
+
Architecture Plan Phase 1.1: Result Export
|
|
408
|
+
"""
|
|
409
|
+
path_obj = Path(path)
|
|
410
|
+
path_obj.parent.mkdir(parents=True, exist_ok=True)
|
|
411
|
+
|
|
412
|
+
if format == "report":
|
|
413
|
+
self._export_report(path_obj)
|
|
414
|
+
elif format == "dissector":
|
|
415
|
+
self._export_dissector(path_obj)
|
|
416
|
+
elif format == "spec":
|
|
417
|
+
self._export_spec_json(path_obj)
|
|
418
|
+
elif format == "json":
|
|
419
|
+
self._export_json(path_obj)
|
|
420
|
+
elif format == "csv":
|
|
421
|
+
self._export_csv(path_obj)
|
|
422
|
+
else:
|
|
423
|
+
raise ValueError(f"Unsupported export format: {format}")
|
|
424
|
+
|
|
425
|
+
# Private helper methods
|
|
426
|
+
|
|
427
|
+
def _trace_to_bytes(self, trace: Trace) -> NDArray[np.uint8]:
|
|
428
|
+
"""Convert trace to byte array."""
|
|
429
|
+
from oscura.core.types import IQTrace
|
|
430
|
+
|
|
431
|
+
# Handle IQTrace separately
|
|
432
|
+
if isinstance(trace, IQTrace):
|
|
433
|
+
raise TypeError("IQTrace not supported in blackbox session")
|
|
434
|
+
|
|
435
|
+
# If trace data is already bytes, return as-is
|
|
436
|
+
if trace.data.dtype == np.uint8:
|
|
437
|
+
return trace.data # type: ignore[return-value]
|
|
438
|
+
|
|
439
|
+
# Otherwise, quantize to bytes
|
|
440
|
+
data = trace.data
|
|
441
|
+
if data.dtype in (np.float32, np.float64):
|
|
442
|
+
# Normalize to 0-255
|
|
443
|
+
data_min = np.min(data)
|
|
444
|
+
data_max = np.max(data)
|
|
445
|
+
if float(data_max) > float(data_min):
|
|
446
|
+
normalized = (data - data_min) / (data_max - data_min) * 255 # type: ignore[operator,call-overload]
|
|
447
|
+
else:
|
|
448
|
+
normalized = np.zeros_like(data)
|
|
449
|
+
result: NDArray[np.uint8] = normalized.astype(np.uint8)
|
|
450
|
+
return result
|
|
451
|
+
|
|
452
|
+
# For integer types, just convert
|
|
453
|
+
return data.astype(np.uint8) # type: ignore[return-value]
|
|
454
|
+
|
|
455
|
+
def _find_changed_regions(self, diffs: NDArray[np.bool_]) -> list[tuple[int, int, str]]:
|
|
456
|
+
"""Find contiguous regions of changes."""
|
|
457
|
+
regions = []
|
|
458
|
+
in_region = False
|
|
459
|
+
start = 0
|
|
460
|
+
|
|
461
|
+
for i, changed in enumerate(diffs):
|
|
462
|
+
if bool(changed) and not in_region:
|
|
463
|
+
# Start of new region
|
|
464
|
+
start = i
|
|
465
|
+
in_region = True
|
|
466
|
+
elif not changed and in_region:
|
|
467
|
+
# End of region
|
|
468
|
+
regions.append((start, i - 1, "Changed"))
|
|
469
|
+
in_region = False
|
|
470
|
+
|
|
471
|
+
# Handle region extending to end
|
|
472
|
+
if in_region:
|
|
473
|
+
regions.append((start, len(diffs) - 1, "Changed"))
|
|
474
|
+
|
|
475
|
+
return regions
|
|
476
|
+
|
|
477
|
+
def _generate_field_hypotheses(self, traces: list[Trace]) -> list[FieldHypothesis]:
|
|
478
|
+
"""Generate field hypotheses from traces."""
|
|
479
|
+
if not traces:
|
|
480
|
+
return []
|
|
481
|
+
|
|
482
|
+
# Convert traces to byte arrays
|
|
483
|
+
byte_arrays = [self._trace_to_bytes(t) for t in traces]
|
|
484
|
+
|
|
485
|
+
# Use message format inference
|
|
486
|
+
try:
|
|
487
|
+
schema = infer_format(byte_arrays) # type: ignore[arg-type]
|
|
488
|
+
|
|
489
|
+
# Convert to field hypotheses
|
|
490
|
+
hypotheses = []
|
|
491
|
+
for field in schema.fields:
|
|
492
|
+
hyp = FieldHypothesis(
|
|
493
|
+
name=field.name,
|
|
494
|
+
offset=field.offset,
|
|
495
|
+
length=field.size, # InferredField uses 'size', not 'length'
|
|
496
|
+
field_type=field.field_type,
|
|
497
|
+
confidence=field.confidence,
|
|
498
|
+
evidence={},
|
|
499
|
+
)
|
|
500
|
+
hypotheses.append(hyp)
|
|
501
|
+
|
|
502
|
+
return hypotheses
|
|
503
|
+
except Exception:
|
|
504
|
+
# Fallback: basic field detection
|
|
505
|
+
return []
|
|
506
|
+
|
|
507
|
+
def _infer_state_machine(self, traces: list[Trace]) -> Any:
|
|
508
|
+
"""Infer state machine from traces."""
|
|
509
|
+
if not traces:
|
|
510
|
+
return None
|
|
511
|
+
|
|
512
|
+
try:
|
|
513
|
+
# Convert traces to sequences of strings for RPNI
|
|
514
|
+
# Each trace becomes a sequence
|
|
515
|
+
byte_arrays = [self._trace_to_bytes(t) for t in traces]
|
|
516
|
+
# Convert to lists of strings for RPNI input format
|
|
517
|
+
# infer_rpni expects list[list[str]], not list[tuple]
|
|
518
|
+
sequences = [[str(b) for b in arr.tolist()] for arr in byte_arrays]
|
|
519
|
+
return infer_rpni(sequences)
|
|
520
|
+
except Exception:
|
|
521
|
+
return None
|
|
522
|
+
|
|
523
|
+
def _detect_crc(self, traces: list[Trace]) -> dict[str, Any]:
|
|
524
|
+
"""Detect CRC/checksums in traces."""
|
|
525
|
+
if not traces:
|
|
526
|
+
return {}
|
|
527
|
+
|
|
528
|
+
# For now, return empty dict - CRC reverse engineering is complex
|
|
529
|
+
# The verify_crc function checks CRC but doesn't reverse engineer it
|
|
530
|
+
return {}
|
|
531
|
+
|
|
532
|
+
def _export_report(self, path: Path) -> None:
|
|
533
|
+
"""Export Markdown analysis report."""
|
|
534
|
+
if self._protocol_spec is None:
|
|
535
|
+
self.analyze()
|
|
536
|
+
|
|
537
|
+
report = f"# {self.name} - Analysis Report\n\n"
|
|
538
|
+
report += f"**Generated**: {self.modified_at}\n\n"
|
|
539
|
+
|
|
540
|
+
report += "## Recordings\n\n"
|
|
541
|
+
for name in self.recordings:
|
|
542
|
+
report += f"- {name}\n"
|
|
543
|
+
report += "\n"
|
|
544
|
+
|
|
545
|
+
report += "## Field Hypotheses\n\n"
|
|
546
|
+
if self._field_hypotheses:
|
|
547
|
+
report += "| Field | Offset | Length | Type | Confidence |\n"
|
|
548
|
+
report += "|-------|--------|--------|------|------------|\n"
|
|
549
|
+
for field_hyp in self._field_hypotheses:
|
|
550
|
+
report += f"| {field_hyp.name} | {field_hyp.offset} | {field_hyp.length} | "
|
|
551
|
+
report += f"{field_hyp.field_type} | {field_hyp.confidence:.2f} |\n"
|
|
552
|
+
else:
|
|
553
|
+
report += "No fields inferred.\n"
|
|
554
|
+
report += "\n"
|
|
555
|
+
|
|
556
|
+
path.write_text(report)
|
|
557
|
+
|
|
558
|
+
def _export_dissector(self, path: Path) -> None:
|
|
559
|
+
"""Export Wireshark Lua dissector."""
|
|
560
|
+
if self._protocol_spec is None:
|
|
561
|
+
self.analyze()
|
|
562
|
+
|
|
563
|
+
# Basic Lua dissector template
|
|
564
|
+
dissector = f"-- Wireshark dissector for {self.name}\n"
|
|
565
|
+
dissector += "-- Auto-generated by Oscura BlackBoxSession\n\n"
|
|
566
|
+
dissector += (
|
|
567
|
+
f"local proto = Proto('{self.name.lower().replace(' ', '_')}', '{self.name}')\n\n"
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
# Add fields
|
|
571
|
+
if self._field_hypotheses:
|
|
572
|
+
for field_hyp in self._field_hypotheses:
|
|
573
|
+
dissector += (
|
|
574
|
+
f"-- {field_hyp.name} (offset={field_hyp.offset}, length={field_hyp.length})\n"
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
dissector += "\n-- TODO: Implement dissector logic\n"
|
|
578
|
+
|
|
579
|
+
path.write_text(dissector)
|
|
580
|
+
|
|
581
|
+
def _export_spec_json(self, path: Path) -> None:
|
|
582
|
+
"""Export protocol specification as JSON."""
|
|
583
|
+
import json
|
|
584
|
+
|
|
585
|
+
if self._protocol_spec is None:
|
|
586
|
+
self.analyze()
|
|
587
|
+
|
|
588
|
+
spec_dict = {
|
|
589
|
+
"name": self._protocol_spec.name, # type: ignore[union-attr]
|
|
590
|
+
"fields": [
|
|
591
|
+
{
|
|
592
|
+
"name": f.name,
|
|
593
|
+
"offset": f.offset,
|
|
594
|
+
"length": f.length,
|
|
595
|
+
"type": f.field_type,
|
|
596
|
+
"confidence": f.confidence,
|
|
597
|
+
"evidence": f.evidence,
|
|
598
|
+
}
|
|
599
|
+
for f in self._field_hypotheses
|
|
600
|
+
],
|
|
601
|
+
"crc_info": self._protocol_spec.crc_info, # type: ignore[union-attr]
|
|
602
|
+
"constants": self._protocol_spec.constants, # type: ignore[union-attr]
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
path.write_text(json.dumps(spec_dict, indent=2))
|
|
606
|
+
|
|
607
|
+
def _export_json(self, path: Path) -> None:
|
|
608
|
+
"""Export complete results as JSON."""
|
|
609
|
+
import json
|
|
610
|
+
|
|
611
|
+
results = self.analyze()
|
|
612
|
+
|
|
613
|
+
# Make JSON serializable
|
|
614
|
+
json_results = {
|
|
615
|
+
"num_recordings": results["num_recordings"],
|
|
616
|
+
"field_hypotheses": [
|
|
617
|
+
{
|
|
618
|
+
"name": f.name,
|
|
619
|
+
"offset": f.offset,
|
|
620
|
+
"length": f.length,
|
|
621
|
+
"type": f.field_type,
|
|
622
|
+
"confidence": f.confidence,
|
|
623
|
+
}
|
|
624
|
+
for f in results["field_hypotheses"]
|
|
625
|
+
],
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
path.write_text(json.dumps(json_results, indent=2))
|
|
629
|
+
|
|
630
|
+
def _export_csv(self, path: Path) -> None:
|
|
631
|
+
"""Export field hypotheses as CSV."""
|
|
632
|
+
if self._protocol_spec is None:
|
|
633
|
+
self.analyze()
|
|
634
|
+
|
|
635
|
+
csv = "field_name,offset,length,type,confidence\n"
|
|
636
|
+
for field_hyp in self._field_hypotheses:
|
|
637
|
+
csv += f"{field_hyp.name},{field_hyp.offset},{field_hyp.length},"
|
|
638
|
+
csv += f"{field_hyp.field_type},{field_hyp.confidence:.3f}\n"
|
|
639
|
+
|
|
640
|
+
path.write_text(csv)
|