iotsploit-protocols 0.0.9__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.
@@ -0,0 +1,355 @@
1
+ """SOME/IP Service Discovery: what a segment announces, and what a host admits to.
2
+
3
+ Two ways round, because one of them does not always work:
4
+
5
+ ``listen()`` is passive. ECUs announce OfferService on the SD multicast group by
6
+ themselves, so it enumerates a bus without putting anything on it. It is the
7
+ right tool on a bench cabled to the segment.
8
+
9
+ ``find()`` is active: it sends a FindService and reads the OfferService replies.
10
+ It exists because multicast does not always reach you. Across a router or a VPN
11
+ the group is usually not forwarded, so a passive listen sits silent and reports
12
+ "no services" about a bus full of them -- a wrong answer rather than an error.
13
+ Sent to one host, ``find()`` needs no multicast at all.
14
+
15
+ No root is needed either way. Joining a multicast group is an unprivileged
16
+ socket option; it is *raw* sockets that need privileges, and this uses none.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import ipaddress
22
+ import logging
23
+ import socket
24
+ import struct
25
+ import time
26
+ from dataclasses import dataclass, field
27
+ from typing import List, Optional, Tuple
28
+
29
+ from iotsploit_protocols.errors import NotConfigured
30
+ from iotsploit_protocols.someip.codec import application_payload
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ #: The SD port assigned by the SOME/IP specification. A standard number, not a
35
+ #: description of any particular vehicle, so it may carry a default.
36
+ DEFAULT_SD_PORT = 30490
37
+
38
+ #: The multicast group the specification names for service discovery. Also a
39
+ #: constant of the protocol rather than of a vehicle -- and unlike a host
40
+ #: address, a wrong group discovers nothing rather than probing a stranger.
41
+ DEFAULT_SD_GROUP = "224.244.224.245"
42
+
43
+ #: SD entry type for OfferService. FindService (0x00) shares the same layout.
44
+ ENTRY_TYPE_OFFER = 0x01
45
+
46
+ #: SD option types carrying a reachable endpoint.
47
+ OPTION_IP4_ENDPOINT = 0x04
48
+
49
+ L4_PROTO_NAMES = {6: "tcp", 17: "udp"}
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class SdConfig:
54
+ """Which segment to listen on.
55
+
56
+ ``group`` and ``interface`` describe an Ethernet segment rather than a
57
+ component -- every ECU on the segment shares them -- so they are supplied by
58
+ the caller rather than read from any one component's facet.
59
+
60
+ The two fields are easy to confuse, and the kernel is no help: joining a
61
+ group at a unicast address fails with a bare ``EINVAL``. So the confusion is
62
+ diagnosed here, where the two values are side by side and the message can
63
+ say which one was probably meant.
64
+ """
65
+
66
+ group: str
67
+ interface: Optional[str] = None
68
+ port: int = DEFAULT_SD_PORT
69
+
70
+ def __post_init__(self) -> None:
71
+ if not self.group:
72
+ raise NotConfigured("SD multicast group is required; there is no default")
73
+ if not 0 < self.port < 65536:
74
+ raise NotConfigured(f"SD port {self.port!r} is out of range")
75
+
76
+ try:
77
+ address = ipaddress.ip_address(self.group)
78
+ except ValueError:
79
+ raise NotConfigured(
80
+ f"SD group {self.group!r} is not an IP address. Service discovery "
81
+ f"listens on an IPv4 multicast group such as {DEFAULT_SD_GROUP}."
82
+ ) from None
83
+ if not isinstance(address, ipaddress.IPv4Address):
84
+ raise NotConfigured(
85
+ f"SD group {self.group!r} is IPv6; this listener is IPv4 only."
86
+ )
87
+ if not address.is_multicast:
88
+ raise NotConfigured(
89
+ f"SD group {self.group!r} is a unicast address, and a multicast "
90
+ f"group must be in 224.0.0.0/4 (the SOME/IP default is "
91
+ f"{DEFAULT_SD_GROUP}). If {self.group!r} is the local address of "
92
+ f"the interface you want to listen on, pass it as 'interface' "
93
+ f"instead of 'sd_group'."
94
+ )
95
+
96
+
97
+ @dataclass(frozen=True)
98
+ class ServiceOffer:
99
+ """One announced service instance.
100
+
101
+ ``endpoints`` are plain tuples of ``(ip, port, "tcp"|"udp")`` so a caller
102
+ never has to import scapy to read a result.
103
+ """
104
+
105
+ service_id: int
106
+ instance_id: int
107
+ major_version: int
108
+ minor_version: int
109
+ ttl: int
110
+ endpoints: List[Tuple[str, int, str]] = field(default_factory=list)
111
+ source: Optional[str] = None
112
+
113
+ @property
114
+ def canonical_id(self) -> str:
115
+ from iotsploit_protocols.someip.facet import canonical_service_id
116
+
117
+ return canonical_service_id(self.service_id, self.instance_id)
118
+
119
+
120
+ class ServiceDiscovery:
121
+ """Listens on the SD multicast group and reports what it heard."""
122
+
123
+ def __init__(self, config: SdConfig) -> None:
124
+ self.config = config
125
+
126
+ def listen(self, timeout: float = 10.0) -> List[ServiceOffer]:
127
+ """Collect offers for ``timeout`` seconds, announced by anyone.
128
+
129
+ Returns one entry per distinct (service, instance), latest wins: an ECU
130
+ re-announcing the same service during the window is one service, not
131
+ several. Deduplicating here rather than in the caller keeps the "what is
132
+ on this segment" answer stable regardless of how long you listened.
133
+ """
134
+ with self._socket(join_group=True) as sock:
135
+ offers = self._collect(sock, timeout)
136
+ logger.info("SD heard %d service instance(s) on %s", len(offers), self.config.group)
137
+ return offers
138
+
139
+ def find(
140
+ self,
141
+ host: Optional[str] = None,
142
+ timeout: float = 3.0,
143
+ service_id: int = 0xFFFF,
144
+ instance_id: int = 0xFFFF,
145
+ ) -> List[ServiceOffer]:
146
+ """Ask, rather than wait. Returns the OfferService replies.
147
+
148
+ ``host`` unset sends the FindService to the multicast group; set, it goes
149
+ to that one host and no multicast is involved at any point, which is what
150
+ makes this work across a router or a VPN.
151
+
152
+ The defaults are the specification's wildcards -- any service, any
153
+ instance -- so the bare call is "tell me everything you offer".
154
+ """
155
+ destination = host or self.config.group
156
+ with self._socket(join_group=host is None) as sock:
157
+ datagram = self._find_datagram(service_id, instance_id, ttl=int(timeout) + 1)
158
+ logger.debug("SD FindService -> %s:%d", destination, self.config.port)
159
+ try:
160
+ sock.sendto(datagram, (destination, self.config.port))
161
+ except OSError as exc:
162
+ raise NotConfigured(
163
+ f"cannot send FindService to {destination}:{self.config.port}: "
164
+ f"{exc.strerror or exc}"
165
+ ) from None
166
+ offers = self._collect(sock, timeout)
167
+ logger.info("SD FindService to %s answered with %d instance(s)", destination, len(offers))
168
+ return offers
169
+
170
+ # ── internals ─────────────────────────────────────────────────────────
171
+
172
+ def _collect(self, sock: socket.socket, timeout: float) -> List[ServiceOffer]:
173
+ """Read offers until ``timeout`` expires, deduplicated by (service, instance)."""
174
+ deadline = time.monotonic() + timeout
175
+ offers: dict[tuple[int, int], ServiceOffer] = {}
176
+ while True:
177
+ remaining = deadline - time.monotonic()
178
+ if remaining <= 0:
179
+ break
180
+ sock.settimeout(remaining)
181
+ try:
182
+ datagram, sender = sock.recvfrom(65535)
183
+ except (TimeoutError, socket.timeout):
184
+ break
185
+ except OSError:
186
+ logger.debug("SD receive failed", exc_info=True)
187
+ break
188
+ for offer in self._parse(datagram, sender[0]):
189
+ offers[(offer.service_id, offer.instance_id)] = offer
190
+ return list(offers.values())
191
+
192
+ @staticmethod
193
+ def _find_datagram(service_id: int, instance_id: int, ttl: int) -> bytes:
194
+ from scapy.contrib.automotive.someip import SD, SDEntry_Service, SOMEIP
195
+
196
+ entry = SDEntry_Service(
197
+ type=0x00, # FindService
198
+ srv_id=service_id,
199
+ inst_id=instance_id,
200
+ # 0xFF / 0xFFFFFFFF are the spec's "any version" wildcards.
201
+ major_ver=0xFF,
202
+ minor_ver=0xFFFFFFFF,
203
+ ttl=ttl,
204
+ n_opt_1=0,
205
+ n_opt_2=0,
206
+ )
207
+ # UNICAST says we accept unicast replies, which is the entire point when
208
+ # the request did not go to a group.
209
+ return bytes(SOMEIP() / SD(flags="UNICAST", entry_array=[entry]))
210
+
211
+ def _socket(self, join_group: bool) -> socket.socket:
212
+ """A UDP socket bound to the SD port, optionally joined to the group.
213
+
214
+ Binding to the SD port matters even for a unicast find: the ECU replies
215
+ to the port it was asked from, so an ephemeral source port would send
216
+ the answer somewhere nobody is listening.
217
+ """
218
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
219
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
220
+ sock.bind(("", self.config.port))
221
+ if not join_group:
222
+ return sock
223
+ interface = self._interface_address()
224
+ try:
225
+ # Egress selection for anything we send to the group. Without it the
226
+ # kernel picks by route, which on a multi-NIC host is a different
227
+ # NIC from the one we joined on.
228
+ sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_IF, interface)
229
+ sock.setsockopt(
230
+ socket.IPPROTO_IP,
231
+ socket.IP_ADD_MEMBERSHIP,
232
+ socket.inet_aton(self.config.group) + interface,
233
+ )
234
+ except OSError as exc:
235
+ sock.close()
236
+ # ENODEV/EADDRNOTAVAIL here mean the interface is not one of this
237
+ # host's own. The errno alone sends people looking at the ECU, so
238
+ # say what was wrong and what this host actually has.
239
+ raise NotConfigured(
240
+ f"cannot join {self.config.group} on interface "
241
+ f"{self.config.interface!r}: {exc.strerror or exc}. "
242
+ f"'interface' must name a NIC on this host, or carry that NIC's "
243
+ f"own address. Available here: {_local_interfaces() or 'unknown'}"
244
+ ) from None
245
+ return sock
246
+
247
+ def _interface_address(self) -> bytes:
248
+ """Which local interface joins the group.
249
+
250
+ INADDR_ANY lets the kernel choose, which is wrong on a bench with
251
+ several NICs -- the join silently lands on the wrong one and the listen
252
+ returns nothing, looking exactly like "no services on this bus".
253
+ """
254
+ if not self.config.interface:
255
+ logger.warning(
256
+ "SD joining %s on the default interface. On a host with several "
257
+ "NICs the kernel's choice is often the wrong segment, and the "
258
+ "result is an empty sweep that reads as 'no services'. Pass an "
259
+ "interface name or local IP to be sure.",
260
+ self.config.group,
261
+ )
262
+ return struct.pack("=I", socket.INADDR_ANY)
263
+ try:
264
+ return socket.inet_aton(socket.gethostbyname(self.config.interface))
265
+ except OSError:
266
+ pass
267
+ address = _interface_ipv4(self.config.interface)
268
+ if address is None:
269
+ raise NotConfigured(
270
+ f"interface {self.config.interface!r} has no IPv4 address to join the group with"
271
+ )
272
+ return socket.inet_aton(address)
273
+
274
+ @staticmethod
275
+ def _parse(datagram: bytes, sender: str) -> List[ServiceOffer]:
276
+ from scapy.contrib.automotive.someip import SD, SOMEIP
277
+
278
+ try:
279
+ packet = SOMEIP(datagram)
280
+ payload = application_payload(packet)
281
+ if not payload:
282
+ return []
283
+ sd = SD(payload)
284
+ except Exception:
285
+ logger.debug("SD datagram from %s did not parse", sender, exc_info=True)
286
+ return []
287
+
288
+ options = list(getattr(sd, "option_array", []) or [])
289
+ offers = []
290
+ for entry in getattr(sd, "entry_array", []) or []:
291
+ if int(getattr(entry, "type", -1)) != ENTRY_TYPE_OFFER:
292
+ continue
293
+ ttl = int(getattr(entry, "ttl", 0))
294
+ if ttl == 0:
295
+ # TTL 0 is StopOffer: the service is going away, not appearing.
296
+ continue
297
+ offers.append(
298
+ ServiceOffer(
299
+ service_id=int(entry.srv_id),
300
+ instance_id=int(entry.inst_id),
301
+ major_version=int(getattr(entry, "major_ver", 0)),
302
+ minor_version=int(getattr(entry, "minor_ver", 0)),
303
+ ttl=ttl,
304
+ endpoints=_entry_endpoints(entry, options),
305
+ source=sender,
306
+ )
307
+ )
308
+ return offers
309
+
310
+
311
+ def _entry_endpoints(entry, options) -> List[Tuple[str, int, str]]:
312
+ """The endpoint options an entry points at.
313
+
314
+ An SD entry does not carry its endpoint; it carries two (index, count) runs
315
+ into the message-wide option array. Resolving that here is why callers get
316
+ an address instead of a pair of integers.
317
+ """
318
+ endpoints = []
319
+ runs = (
320
+ (int(getattr(entry, "index_1", 0)), int(getattr(entry, "n_opt_1", 0))),
321
+ (int(getattr(entry, "index_2", 0)), int(getattr(entry, "n_opt_2", 0))),
322
+ )
323
+ for index, count in runs:
324
+ for option in options[index : index + count]:
325
+ if int(getattr(option, "type", -1)) != OPTION_IP4_ENDPOINT:
326
+ continue
327
+ proto = L4_PROTO_NAMES.get(int(getattr(option, "l4_proto", 0)), "unknown")
328
+ endpoints.append((str(option.addr), int(option.port), proto))
329
+ return endpoints
330
+
331
+
332
+ def _local_interfaces() -> str:
333
+ """``name=address`` for every IPv4 NIC on this host, for error messages."""
334
+ import psutil
335
+
336
+ entries = []
337
+ for name, addresses in psutil.net_if_addrs().items():
338
+ for address in addresses:
339
+ if address.family == socket.AF_INET:
340
+ entries.append(f"{name}={address.address}")
341
+ return ", ".join(entries)
342
+
343
+
344
+ def _interface_ipv4(name: str) -> Optional[str]:
345
+ """The IPv4 address of a named NIC, or None.
346
+
347
+ Uses psutil so interface enumeration remains portable.
348
+ """
349
+ import psutil
350
+
351
+ addresses = psutil.net_if_addrs().get(name, [])
352
+ for address in addresses:
353
+ if address.family == socket.AF_INET:
354
+ return address.address
355
+ return None
@@ -0,0 +1,118 @@
1
+ Metadata-Version: 2.4
2
+ Name: iotsploit-protocols
3
+ Version: 0.0.9
4
+ Summary: Automotive protocol clients and description parsers for IoTSploit
5
+ License: GPL-3.0-or-later
6
+ Keywords: someip,doip,uds,automotive,security,testing
7
+ Author: IoTSploit Team
8
+ Author-email: support@iotsploit.org
9
+ Requires-Python: >=3.10,<4.0
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Intended Audience :: Information Technology
13
+ Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Programming Language :: Python :: 3.15
21
+ Classifier: Topic :: Security
22
+ Requires-Dist: cantools (==42.0.3)
23
+ Requires-Dist: iotsploit-core
24
+ Requires-Dist: psutil (>=5.9)
25
+ Requires-Dist: pydantic (>=2.0)
26
+ Requires-Dist: python-can (>=4.5)
27
+ Requires-Dist: scapy (>=2.6,<3)
28
+ Project-URL: Homepage, https://www.iotsploit.org/
29
+ Project-URL: Repository, https://github.com/TKXB/iotsploit
30
+ Description-Content-Type: text/markdown
31
+
32
+ # iotsploit-protocols
33
+
34
+ Automotive protocol clients and description parsers for IoTSploit: SOME/IP,
35
+ DoIP/UDS, and AUTOSAR ARXML.
36
+
37
+ Nothing in this package reads the IoTSploit database, runtime configuration,
38
+ environment variables, or the current target, and nothing runs `sudo` or
39
+ prompts a human. Clients receive explicit config objects and description
40
+ parsers receive caller-selected files. That is what lets them run under
41
+ Celery, under MCP, in a test, or from a plugin — and what keeps one lab bench's
42
+ IP addresses out of a library.
43
+
44
+ scapy is used as a **codec, not as a socket**: packets are built and parsed with
45
+ `scapy.contrib.automotive`, but I/O happens on ordinary sockets, so calling a
46
+ method needs no root.
47
+
48
+ ## SOME/IP
49
+
50
+ Call a method:
51
+
52
+ ```python
53
+ from iotsploit_protocols.someip import SomeIpClient, SomeIpConfig
54
+
55
+ with SomeIpClient(SomeIpConfig(host="198.18.34.10", port=30509)) as client:
56
+ response = client.call(service=0x1234, instance=0x0001, method=0x0002, payload=b"\x01")
57
+ if response.ok:
58
+ print(response.payload.hex())
59
+ else:
60
+ print("refused:", response.return_code_name)
61
+ ```
62
+
63
+ Fire-and-forget:
64
+
65
+ ```python
66
+ client.notify(service=0x1234, instance=0x0001, method=0x0003, payload=b"\x07")
67
+ ```
68
+
69
+ Hear what a segment announces — passive, no traffic generated, no root:
70
+
71
+ ```python
72
+ from iotsploit_protocols.someip import ServiceDiscovery, SdConfig
73
+
74
+ sd = ServiceDiscovery(SdConfig(group="224.244.224.245", interface="eth0"))
75
+ for offer in sd.listen(timeout=10.0):
76
+ print(offer.canonical_id, offer.endpoints) # "1234:0001" [('198.18.34.10', 30509, 'tcp')]
77
+ ```
78
+
79
+ `canonical_id` is the form observations store as `subject_id`, so a discovered
80
+ service joins against the reference catalog without any string munging at the
81
+ call site.
82
+
83
+ ## Configuration from a target
84
+
85
+ Inside the IoTSploit app, don't build the config by hand — the Django binding
86
+ layer resolves it from the current target's `someip` facet and component
87
+ address:
88
+
89
+ ```python
90
+ from iotsploit_django.adapters.django.protocol_binding import someip_client_for
91
+
92
+ with someip_client_for("TCAM") as client:
93
+ ...
94
+ ```
95
+
96
+ An unconfigured target raises `NotConfigured` rather than falling back to a
97
+ built-in address, because a helper that guesses a host probes whatever is at
98
+ that address and reports its silence as a finding.
99
+
100
+ ## Errors
101
+
102
+ | exception | meaning |
103
+ |---|---|
104
+ | `NotConfigured` | settings are missing — fix the target, not the code |
105
+ | `NegativeResponse` | the peer answered and said no |
106
+ | `ProtocolError` | malformed or unmatchable traffic |
107
+
108
+ Transport failures are left as stdlib `ConnectionError` / `TimeoutError` rather
109
+ than re-spelled in a new namespace.
110
+
111
+ ## Tests
112
+
113
+ ```bash
114
+ poetry run pytest iotsploit-protocols/tests
115
+ ```
116
+
117
+ No hardware and no network: loopback servers stand in for ECUs.
118
+
@@ -0,0 +1,25 @@
1
+ iotsploit_protocols/__init__.py,sha256=KToyVubjOQC36bJf2KRQKhJqlX6lGI_zOQeo10o14ZI,872
2
+ iotsploit_protocols/autosar/__init__.py,sha256=LoVoEQ_B-WQa8ygtgJeMSRA9pR5_5KggQNsoWnnws1g,300
3
+ iotsploit_protocols/autosar/arxml.py,sha256=gSuRDCb0uephE-8s0arnTpAPKf1K0uolFgaL6-SdP1Q,30191
4
+ iotsploit_protocols/canbus/__init__.py,sha256=cDAkv9tn2MOlq3dIsK-1g3xi-s340fmXvlkJkQCCMZE,1804
5
+ iotsploit_protocols/canbus/bus_match.py,sha256=P8QO1_2dzJc01Gp9TtiEjithW0BpphxsiZM57-hjDfU,4119
6
+ iotsploit_protocols/canbus/catalog.py,sha256=UcXes-Hv1E0p--dwwKcpcpmNqKdWLYL0wllA87xgnGw,16540
7
+ iotsploit_protocols/canbus/codec.py,sha256=l2DmZKJ3cj5sKNXkqESz0s3iFGYDIsLFHPQaUXa_lMc,17946
8
+ iotsploit_protocols/canbus/definitions.py,sha256=PEA_LQySQeOfq_uqgaa2lTXK2JuWbF9FAr6M77KZjYs,9232
9
+ iotsploit_protocols/canbus/errorframes.py,sha256=3lI8mOJyoQN7-9Cbfawn1ml-dwfMwY4FfJQXjD6Ll3M,6019
10
+ iotsploit_protocols/canbus/errors.py,sha256=sT4RumLub70llBiNQiKm0GzpqYhxah2q2D7ZgOM9Vjo,1966
11
+ iotsploit_protocols/canbus/logfile.py,sha256=mBAPv0g9E58jzQAX3sfjljxpfbkhSC0e_F9taKI77kc,28640
12
+ iotsploit_protocols/canbus/socketcan.py,sha256=ltABu9eoDb-YAdAOsH8JIPoNN3nMxVXe25B318gx0EM,14596
13
+ iotsploit_protocols/doip/__init__.py,sha256=QT1q0pA4sxmF9bO69sNxOE2EyY6g0GI1muKbSQi8Qg4,504
14
+ iotsploit_protocols/doip/client.py,sha256=5ednrJXsQgiwguU2AR5f-SeSqCLoumVzc-FF0A5cBqM,10711
15
+ iotsploit_protocols/doip/facet.py,sha256=RFk5W7hQhaaw8HY0QQwtzeuI0lNiNNoMrnTb8MpZF4I,2080
16
+ iotsploit_protocols/doip/uds.py,sha256=hVJHWhb9XEoQ5xAL2DAitsZObEopGj367buKdVtn4hk,9752
17
+ iotsploit_protocols/errors.py,sha256=Sx2bcMSITm9W_iwxcoMoi3KRTXxNDRPqIXPL4PgKPUI,1638
18
+ iotsploit_protocols/someip/__init__.py,sha256=IN7fj26H7fpPicaoEwtO1w6viMT9NCbOW4EN2TNh5Vw,572
19
+ iotsploit_protocols/someip/client.py,sha256=XgiI_68GuuI_kM2aKqhj9Famgoz4TLaqpwEEYr1czK0,11903
20
+ iotsploit_protocols/someip/codec.py,sha256=bP9nwJ7p6KwjT5Che1rk0GnM-Xx1mpYwMRrRM0toYDM,407
21
+ iotsploit_protocols/someip/facet.py,sha256=_15kqOqD0FwL5wCxr3bGKr_yNhf-3OANxVMXAj0lR5g,3061
22
+ iotsploit_protocols/someip/sd.py,sha256=-p9kFHUYZSwjDuBX0m8zeCHn4flpUOkud__uLDW6rfk,14424
23
+ iotsploit_protocols-0.0.9.dist-info/METADATA,sha256=qKt0SBrevaBCbcMK8lhwW6Yc4Sj9Yw_fQZYA0uYeXyI,4057
24
+ iotsploit_protocols-0.0.9.dist-info/WHEEL,sha256=L-WvLdSBvJWHXv5zrQxDvNRnIgoBzLUpcbuiw19KCuQ,88
25
+ iotsploit_protocols-0.0.9.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.5.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any