fluidattacks-agent 0.1.2__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,439 @@
1
+ """Start the probe inside a workload and leave reports where the agent drains."""
2
+
3
+ import _thread
4
+ import atexit
5
+ import contextlib
6
+ import os
7
+ import sys
8
+ import time
9
+ from collections.abc import Callable
10
+ from dataclasses import dataclass, field, replace
11
+ from types import CodeType, ModuleType
12
+ from typing import Final
13
+
14
+ from fluidattacks_agent.distributions import Dist, distribution_map
15
+ from fluidattacks_agent.executions import TOOL, TOOL_NAME, RunObserver, alone, available
16
+ from fluidattacks_agent.loads import LoadObserver
17
+ from fluidattacks_agent.observer import ImportObserver, install
18
+ from fluidattacks_agent.report import Evidence, Record, Window, keyed, render
19
+ from fluidattacks_agent.settings import Delivery, asked, delivery
20
+ from fluidattacks_agent.sink import FileSink, Holding, Sink, Stalled
21
+ from fluidattacks_agent.switch import switched_on
22
+
23
+ # first sightings between flushes. The carrier's clock brings the rest
24
+ FLUSH_EVERY: Final = 128
25
+
26
+ # what a site directory is given so that installing the package starts the
27
+ # probe. The agent writes the same line when it injects one it did not install
28
+ PTH_NAME: Final = "fluidattacks_agent.pth"
29
+ # the gate and not this module: a workload that switched the probe off pays for
30
+ # reading the switch and for nothing above it
31
+ PTH_LINE: Final = "import fluidattacks_agent.gate\n"
32
+
33
+ MS: Final = 1_000_000
34
+ SECOND: Final = 1000
35
+
36
+ START: Final = time.monotonic_ns()
37
+
38
+ # the reader reads u= as milliseconds since the epoch. Taken once and advanced
39
+ # by the monotonic clock, so a stepped clock cannot walk a stamp backwards
40
+ ORIGIN: Final = time.time_ns() // MS
41
+
42
+
43
+ def reached() -> int:
44
+ """Say how far into the probe's own life this moment is, in milliseconds."""
45
+ return (time.monotonic_ns() - START) // MS
46
+
47
+
48
+ @dataclass
49
+ class Probe:
50
+ """An observer whose reports are left behind as the workload runs."""
51
+
52
+ sink: Sink = field(default_factory=FileSink)
53
+ observer: ImportObserver = field(default_factory=ImportObserver)
54
+ loads: LoadObserver = field(default_factory=LoadObserver)
55
+ runs: RunObserver = field(default_factory=RunObserver)
56
+ distributions: Callable[[], dict[str, Dist]] = distribution_map
57
+ clock: Callable[[], int] = reached
58
+ origin: int = ORIGIN
59
+ # what each record was last reported as, so a flush carries the increment
60
+ reported: dict[tuple[str, str], int] = field(default_factory=dict)
61
+ # the same, for the sightings each observer found no room for
62
+ stated: dict[str, int] = field(default_factory=dict)
63
+ first: dict[tuple[str, str], int] = field(default_factory=dict)
64
+ last: dict[tuple[str, str], int] = field(default_factory=dict)
65
+ scanned: dict[str, Dist] | None = None
66
+ pending: int = 0
67
+ flushing: bool = False
68
+ stalled: int = 0
69
+ charged: int = 0
70
+ # owed until a report states them: a flush can end after charging a record
71
+ # and before saying it refused one, and a charged record is never offered again
72
+ refused: int = 0
73
+ undated: int = 0
74
+ monitoring: bool = False
75
+ looking: bool = False
76
+ # sampled when the events were armed, not when the report is written: the
77
+ # later reading governs the next window rather than the one reported
78
+ solitary: bool = False
79
+ # set off the workload's threads, acted on by the next sighting on one of
80
+ # them, since what is held is walked nowhere else
81
+ overdue: bool = False
82
+ # every thread sees overdue at once, so the flush is gated by a lock nothing
83
+ # waits on: a second caller finds it taken and returns. From _thread, which
84
+ # every interpreter start has already paid for, and threading is not
85
+ gate: _thread.LockType = field(default_factory=_thread.allocate_lock, repr=False)
86
+
87
+ def find_spec(
88
+ self,
89
+ fullname: str,
90
+ _path: object = None,
91
+ _target: ModuleType | None = None,
92
+ ) -> None:
93
+ """Note the lookup, and leave a report once enough have piled up."""
94
+ held = self.looking
95
+ self.looking = True
96
+ try:
97
+ with contextlib.suppress(Exception):
98
+ self._looked(fullname)
99
+ finally:
100
+ self.looking = held
101
+
102
+ def _looked(self, fullname: str) -> None:
103
+ if self.flushing:
104
+ return
105
+ module = self.observer.note(fullname)
106
+ self._pile(0 if module is None else self._mark(Evidence.IMPORTED, module))
107
+
108
+ def read(self, path: str) -> None:
109
+ """Note a file the workload opened, and report as those pile up too."""
110
+ # a flush of ours reads and imports on its own account, and none of that
111
+ # is the workload using anything
112
+ if self.flushing:
113
+ return
114
+ package = self.loads.note(path)
115
+ # tested here and not inside: the hook runs on every open the host
116
+ # makes, and all but a few are not ours to stamp
117
+ self._pile(0 if package is None else self._mark(Evidence.READ, package))
118
+
119
+ def ran(self, filename: str, qualname: str) -> None:
120
+ """Note code starting, and report as first sightings pile up too."""
121
+ if self.flushing:
122
+ return
123
+ symbol = self.runs.note(filename, qualname)
124
+ self._pile(0 if symbol is None else self._mark(Evidence.EXECUTED, symbol))
125
+
126
+ def _mark(self, evidence: Evidence, symbol: str) -> int:
127
+ """Stamp a sighting, saying whether it was the first of its symbol."""
128
+ key = keyed(evidence, symbol)
129
+ seen = self.clock()
130
+ self.last[key] = seen
131
+ if key in self.first:
132
+ return 0
133
+ self.first[key] = seen
134
+ return 1
135
+
136
+ def remind(self) -> None:
137
+ """Have the next sighting bring a flush, however few have piled up."""
138
+ # a flag and never a flush: the carrier's clock calls this from a
139
+ # thread of its own, and a flush runs on the workload's alone
140
+ self.overdue = True
141
+
142
+ def _pile(self, added: int) -> None:
143
+ # first sightings only: a flush re-arms every code object, so counting
144
+ # one seen again, or one with no room, made each flush bring the next
145
+ self.pending += added
146
+ if self.overdue or self.pending >= FLUSH_EVERY:
147
+ # cleared before the call, because monitoring reports the entry of
148
+ # flush itself and would otherwise pile up and call it again
149
+ self.pending = 0
150
+ self.flush()
151
+
152
+ def flush(self) -> None:
153
+ """Write what has not been reported, without ever raising at a caller."""
154
+ if not self.gate.acquire(blocking=False):
155
+ return
156
+ self.flushing = True
157
+ # spent here, so a reminder given while this runs brings one more flush
158
+ self.overdue = False
159
+ try:
160
+ with contextlib.suppress(Exception):
161
+ if self._sighted():
162
+ self._write_fresh(self._installed())
163
+ finally:
164
+ self.pending = 0
165
+ self.flushing = False
166
+ self.gate.release()
167
+ self._rearm()
168
+
169
+ def forked(self) -> None:
170
+ """Leave a child able to flush, whatever its parent was doing at the fork."""
171
+ # a lock another thread held at the fork is held in the child forever,
172
+ # and a flag it set stays set: the child has no thread left to clear it
173
+ self.gate = _thread.allocate_lock()
174
+ self.flushing = False
175
+ self.overdue = False
176
+ self.pending = 0
177
+
178
+ def _owed(self) -> bool:
179
+ """Say whether a count is waiting for a report that can state it."""
180
+ return bool(self.refused or self.undated)
181
+
182
+ def _sighted(self) -> bool:
183
+ """Say whether this window holds anything a report could carry."""
184
+ return bool(
185
+ self._owed()
186
+ or self.loads.seen
187
+ or self.runs.seen
188
+ # a lookup that never became a module is dropped when the records are
189
+ # folded, so on its own it is not cause to walk any metadata
190
+ or any(name in sys.modules for name in self.observer.seen)
191
+ or any(self._beyond().values())
192
+ or self.stalled != self.charged,
193
+ )
194
+
195
+ def _rearm(self) -> None:
196
+ if self.monitoring:
197
+ with contextlib.suppress(Exception):
198
+ self.solitary = alone()
199
+ if self.solitary:
200
+ sys.monitoring.restart_events()
201
+
202
+ def _installed(self) -> dict[str, Dist]:
203
+ if self.scanned is not None:
204
+ return self.scanned
205
+ if self.looking:
206
+ # the walk imports, and a lookup of ours runs while the interpreter
207
+ # holds the lock every other import is waiting on. Not remembered,
208
+ # so the next flush outside one still reads it
209
+ return {}
210
+ try:
211
+ scanned = self.distributions()
212
+ except ImportError:
213
+ # what the walk imports goes through whatever the workload put on
214
+ # the import path, and a refusal there can be this once only
215
+ return {}
216
+ except Exception: # noqa: BLE001
217
+ # a scan the workload can make raise is remembered as having found
218
+ # nothing, since suppressing it would leave nothing scanned and buy
219
+ # the workload a full metadata walk on every flush that follows
220
+ self.scanned = {}
221
+ return self.scanned
222
+ self.scanned = scanned
223
+ return scanned
224
+
225
+ def _write_fresh(self, installed: dict[str, Dist]) -> None:
226
+ seen = [
227
+ *self.loads.records(sys.modules, installed, self.reported),
228
+ *self.observer.records(sys.modules, installed, self.reported),
229
+ *self.runs.records(installed, self.reported),
230
+ ]
231
+ fresh = [record for record in map(self._since, seen) if record is not None]
232
+ counted = self._beyond()
233
+ beyond = {tag: n - self.stated.get(tag, 0) for tag, n in counted.items()}
234
+ if not fresh and not any(beyond.values()) and not self._owed():
235
+ return
236
+ # the reader sums what every report states, so this is the increment
237
+ stalled = self.stalled
238
+ window: Window | None = self._window(beyond, stalled - self.charged)
239
+ pending = fresh
240
+ while True:
241
+ report = render(pending, window, self.refused, self.undated)
242
+ self._write(report.text)
243
+ self._charge(pending[: len(pending) - len(report.held)])
244
+ held = list(report.held)
245
+ self.refused = report.dropped if held else 0
246
+ self.undated = report.unstamped if held else 0
247
+ if not held:
248
+ self.stated.update(counted)
249
+ self.charged = stalled
250
+ return
251
+ # a render holding everything back would write files forever, and
252
+ # the suppression above would never let it be seen
253
+ if len(held) >= len(pending):
254
+ return
255
+ pending = held
256
+
257
+ def _charge(self, written: list[Record]) -> None:
258
+ for record in written:
259
+ key = _key(record)
260
+ self.reported[key] = self.reported.get(key, 0) + record.frequency
261
+
262
+ def _beyond(self) -> dict[str, int]:
263
+ return {
264
+ Evidence.READ.value: self.loads.suppressed,
265
+ Evidence.IMPORTED.value: self.observer.suppressed,
266
+ Evidence.EXECUTED.value: self.runs.suppressed,
267
+ }
268
+
269
+ def _window(self, beyond: dict[str, int], backlog: int) -> Window:
270
+ return Window(
271
+ backlog=backlog,
272
+ beyond_read=beyond[Evidence.READ.value],
273
+ beyond_imported=beyond[Evidence.IMPORTED.value],
274
+ beyond_executed=beyond[Evidence.EXECUTED.value],
275
+ monitored=self.monitoring,
276
+ alone=self.monitoring and self.solitary,
277
+ )
278
+
279
+ def _since(self, record: Record) -> Record | None:
280
+ # the reader adds up what every report of a record says, so a report
281
+ # carries what has happened since the last one and never the running
282
+ # total, which would count each sighting once per flush that follows it
283
+ key = _key(record)
284
+ since = record.frequency - self.reported.get(key, 0)
285
+ if since < 1:
286
+ return None
287
+ return replace(
288
+ record,
289
+ frequency=since,
290
+ when=self.first.get(key),
291
+ used=self._used(key),
292
+ )
293
+
294
+ def _used(self, key: tuple[str, str]) -> int | None:
295
+ """Date the last sighting of a symbol as of this report, to the second."""
296
+ seen = self.last.get(key)
297
+ if seen is None:
298
+ return None
299
+ # what drains a report holds its own clock to whole seconds and drops
300
+ # any date past it, so a finer one here is one it can only throw away
301
+ return (self.origin + seen) // SECOND * SECOND
302
+
303
+ def _write(self, text: str) -> None:
304
+ try:
305
+ self.sink.write(text)
306
+ except Stalled:
307
+ # counted here and not in the sink, because what a window missed is
308
+ # the probe's to state and a sink knows nothing of windows
309
+ self.stalled += 1
310
+ raise
311
+
312
+
313
+ def _key(record: Record) -> tuple[str, str]:
314
+ return keyed(record.evidence, record.symbol)
315
+
316
+
317
+ def site_dirs() -> tuple[str, ...]:
318
+ """Name the directories the workload installs its distributions into."""
319
+ found: list[str] = []
320
+ for entry in sys.path:
321
+ if not entry.endswith("site-packages"):
322
+ continue
323
+ for form in (entry, os.path.realpath(entry)):
324
+ if form not in found:
325
+ found.append(form)
326
+ return tuple(found)
327
+
328
+
329
+ def monitor(probe: Probe) -> Callable[[CodeType, int], object]:
330
+ """Build the callback the interpreter calls the first time code starts."""
331
+
332
+ def started(code: CodeType, _offset: int) -> object:
333
+ with contextlib.suppress(Exception):
334
+ probe.ran(code.co_filename, code.co_qualname)
335
+ return sys.monitoring.DISABLE
336
+
337
+ return started
338
+
339
+
340
+ def observe_runs(probe: Probe, tool: int = TOOL) -> bool:
341
+ """Ask the interpreter to report code starting, if it knows how."""
342
+ if not available():
343
+ return False
344
+ try:
345
+ events = sys.monitoring.events
346
+ sys.monitoring.use_tool_id(tool, TOOL_NAME)
347
+ except Exception: # noqa: BLE001
348
+ return False
349
+ try:
350
+ sys.monitoring.register_callback(tool, events.PY_START, monitor(probe))
351
+ sys.monitoring.set_events(tool, events.PY_START)
352
+ except Exception: # noqa: BLE001
353
+ with contextlib.suppress(Exception):
354
+ sys.monitoring.free_tool_id(tool)
355
+ return False
356
+ with contextlib.suppress(Exception):
357
+ probe.solitary = alone(tool)
358
+ return True
359
+
360
+
361
+ def watch(probe: Probe) -> Callable[[str, tuple[object, ...]], None]:
362
+ """Build the audit hook that notes every file the workload opens."""
363
+
364
+ def hook(event: str, args: tuple[object, ...]) -> None:
365
+ if event != "open":
366
+ return
367
+ try:
368
+ target = args[0]
369
+ if isinstance(target, str):
370
+ probe.read(target)
371
+ except Exception: # noqa: BLE001
372
+ return
373
+
374
+ return hook
375
+
376
+
377
+ def running() -> Probe | None:
378
+ """Find the probe already watching this process, if one of ours is."""
379
+ for finder in sys.meta_path:
380
+ if isinstance(finder, Probe):
381
+ return finder
382
+ return None
383
+
384
+
385
+ def start(sink: Sink | None = None) -> Probe | None:
386
+ """Put the probe on the import path, unless one is there or it is unwanted."""
387
+ try:
388
+ return _start(sink)
389
+ except Exception: # noqa: BLE001
390
+ # every interpreter the workload runs reaches this line, and what runs
391
+ # it prints whatever escapes to the host's stderr and carries on
392
+ return None
393
+
394
+
395
+ def chosen(remind: Callable[[], None]) -> Sink:
396
+ """
397
+ Give a workload what it asked for, and something loud if it asked badly.
398
+
399
+ ``remind`` is what a carrier calls, off the workload's threads, once a
400
+ period has gone by with the far end in reach and nothing left to carry.
401
+ """
402
+ if not asked():
403
+ return FileSink()
404
+ told = delivery()
405
+ if told is None:
406
+ # half configured must be loud, not indistinguishable from quiet
407
+ return Holding()
408
+ return carrier(told, remind)
409
+
410
+
411
+ def carrier(told: Delivery, remind: Callable[[], None]) -> Sink:
412
+ """Build the one thing that carries reports out, and wire it to this process."""
413
+ # imported here, so a workload that never asked pays for none of it
414
+ from fluidattacks_agent.deliver import Deliverer # noqa: PLC0415
415
+
416
+ held = Deliverer(told=told, remind=remind)
417
+ # before the flush handler, since exit handlers run in reverse order
418
+ atexit.register(held.parting)
419
+ held.registered()
420
+ return held
421
+
422
+
423
+ def _start(sink: Sink | None) -> Probe | None:
424
+ held = running()
425
+ if held is not None:
426
+ return held
427
+ if not switched_on():
428
+ return None
429
+ sites = site_dirs()
430
+ probe = Probe(loads=LoadObserver(sites=sites), runs=RunObserver(sites=sites))
431
+ # after the probe, because what carries its reports is told what to remind
432
+ probe.sink = chosen(probe.remind) if sink is None else sink
433
+ if hasattr(os, "register_at_fork"):
434
+ os.register_at_fork(after_in_child=probe.forked)
435
+ install(probe)
436
+ sys.addaudithook(watch(probe))
437
+ probe.monitoring = observe_runs(probe)
438
+ atexit.register(probe.flush)
439
+ return probe
@@ -0,0 +1,22 @@
1
+ """Say whether a workload left the probe switched on, before anything is paid."""
2
+
3
+ import os
4
+ from collections.abc import Mapping
5
+
6
+ # no Final: importing typing would cost 1.3 ms of a start nobody asked for
7
+ SWITCH = "FLUIDATTACKS_AGENT"
8
+
9
+ # every spelling of no, and none of the words the agent's own --probe takes for
10
+ # yes: two of its three mean observe there and cannot mean the opposite here
11
+ OFF = frozenset({"0", "off", "false", "no", "disabled", "none"})
12
+
13
+
14
+ def switched_on(environ: Mapping[str, str] | None = None) -> bool:
15
+ """Say whether the probe was left switched on, taking silence for yes."""
16
+ try:
17
+ held = os.environ if environ is None else environ
18
+ value = held.get(SWITCH)
19
+ return not value or value.strip().lower() not in OFF
20
+ except Exception: # noqa: BLE001
21
+ # a switch that cannot be read is not something to read as consent
22
+ return False
@@ -0,0 +1,95 @@
1
+ Metadata-Version: 2.5
2
+ Name: fluidattacks-agent
3
+ Version: 0.1.2
4
+ Summary: In-process probe reporting what a Python workload imports and runs
5
+ Project-URL: Homepage, https://fluidattacks.com
6
+ Project-URL: Source, https://gitlab.com/fluidattacks/universe/-/tree/trunk/watches/agents/python
7
+ Author-email: Development <development@fluidattacks.com>
8
+ License: MPL-2.0
9
+ Keywords: dependencies,observability,reachability,runtime,sbom
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Security
18
+ Classifier: Topic :: Software Development :: Quality Assurance
19
+ Requires-Python: >=3.11
20
+ Description-Content-Type: text/markdown
21
+
22
+ # fluidattacks-agent
23
+
24
+ Reports what a running Python workload actually imports and runs, so that a
25
+ dependency inventory can say which of its findings are reachable at runtime and
26
+ which are not.
27
+
28
+ It is a library, not a service. It observes the interpreter it is installed in,
29
+ writes what it saw, and does nothing else. It takes no dependencies: the
30
+ standard library only, because it is installed into workloads we do not own.
31
+
32
+ ## Installing
33
+
34
+ ```
35
+ pip install fluidattacks-agent
36
+ ```
37
+
38
+ That is the whole setup. A `.pth` file at the root of the wheel starts the probe
39
+ at interpreter start, before the workload's own program runs, so nothing has to
40
+ be imported or called by hand.
41
+
42
+ ## Turning it off
43
+
44
+ ```
45
+ FLUIDATTACKS_AGENT=off
46
+ ```
47
+
48
+ Also `0`, `false`, `no`, `disabled` or `none`. A workload that says no pays for
49
+ reading the setting and for nothing above it. Saying nothing is taken for yes,
50
+ because installing the package is the consent.
51
+
52
+ ## What it observes
53
+
54
+ - distributions whose modules were imported, and which modules
55
+ - functions that were executed, where the interpreter offers that
56
+ - how often, in windows, and when a symbol was first reached
57
+
58
+ Only what an installed distribution owns is attributed. The standard library and
59
+ a workload's own first-party code produce no records.
60
+
61
+ ## Where reports go
62
+
63
+ By default, one file per report under `/tmp/.watches-exec`, for a collector to
64
+ drain. Naming an endpoint sends them instead:
65
+
66
+ | | |
67
+ |---|---|
68
+ | `FLUIDATTACKS_AGENT_ENDPOINT` | where reports are posted, `https://` only |
69
+ | `FLUIDATTACKS_AGENT_TOKEN_FILE` | a file holding the credential, preferred |
70
+ | `FLUIDATTACKS_AGENT_TOKEN` | the credential itself, read only if no file is named |
71
+ | `FLUIDATTACKS_AGENT_GROUP` | what the reports are filed under |
72
+ | `FLUIDATTACKS_AGENT_WORKLOAD` | what this workload is called |
73
+
74
+ A file is preferred over a variable because a file can be mode 400, while an
75
+ environment variable is readable by any process of the same user.
76
+
77
+ An endpoint named without enough beside it to reach is a misconfiguration, not a
78
+ reason to fall back: the probe then holds nothing and counts every report it
79
+ refused, so a half-configured deployment is loud rather than a directory filling
80
+ up where nobody drains it.
81
+
82
+ ## What travels, and what does not
83
+
84
+ Reports are gzipped and signed with a key derived from the credential; the
85
+ credential itself never travels, appears in no record, and is in no exception.
86
+ The far end must prove who it is — certificate chain and hostname both — and no
87
+ redirect is followed.
88
+
89
+ What a report contains is distribution names, versions, module and function
90
+ names, and counts. No arguments, no return values, no file contents, no
91
+ environment.
92
+
93
+ ## Licence
94
+
95
+ MPL-2.0
@@ -0,0 +1,20 @@
1
+ fluidattacks_agent/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ fluidattacks_agent/batch.py,sha256=AE8RyRKfa5qANjQVZKcH3ath1Y2H0Gv9OBmq09P95NE,6143
3
+ fluidattacks_agent/deliver.py,sha256=5Mig_seLUGTmwLtnPbUxNbEMBH6c5qhBuQv2qgQzN-0,7673
4
+ fluidattacks_agent/distributions.py,sha256=ecBT8gk25fuTVghCEsNPjgqpvwBk3cj1_dZI01QP74w,2990
5
+ fluidattacks_agent/executions.py,sha256=L269j15UqiBAU_I6VDR4LAsxlCHzeNE7ypws3JWTHd8,4255
6
+ fluidattacks_agent/gate.py,sha256=lGn4k2GLN6vmAzHFirXRryv8CCaaVfHu4JTKpKvISZw,354
7
+ fluidattacks_agent/loads.py,sha256=0v8QpMyI4VUI84TMoP1bOW4z4pRE4q887R6hTJ-qzhc,3725
8
+ fluidattacks_agent/observer.py,sha256=89LpH37WEfjsdniksl1STZsF6UYLb_9brSoEb0mm6OQ,4880
9
+ fluidattacks_agent/outbox.py,sha256=Wjr1fd5xIi6of33dYS_LRMxSzDh83WhsgHLVjCQ5FDA,2679
10
+ fluidattacks_agent/patience.py,sha256=qMgUJhvyHNDJAn0FgHHRck0h25kLFHoJdTIYQmYXUuE,1229
11
+ fluidattacks_agent/post.py,sha256=hMrzpt1AmN2VLjRyWcz2n1nIZcoMaZ7901GL2BXRgzs,5728
12
+ fluidattacks_agent/report.py,sha256=wdBLaZaEvIcduA3HhxRNtHluXpGTpyGCEr3KFUE3j2k,9532
13
+ fluidattacks_agent/settings.py,sha256=e7yezXwVtcfzpK5zsozhamayIHCF1wfOKipaM6zEJjk,4301
14
+ fluidattacks_agent/sink.py,sha256=19d81osEynUh7iz0XXsIc5kAWkDHsJfaLoAj_0ydfMM,6027
15
+ fluidattacks_agent/startup.py,sha256=yF5BlFsNuH5S14mvaFwzHC2b70WHFbFzCVYSG8MwCUM,17226
16
+ fluidattacks_agent/switch.py,sha256=HQ9ONU7iHEkUlLhSFa5t4NkBQM1Y0rLkbMJdGGJwVMw,900
17
+ fluidattacks_agent.pth,sha256=layRid5SG6BO2eLwjJ33U7d50i4m8BO9HrL4g99bftE,31
18
+ fluidattacks_agent-0.1.2.dist-info/METADATA,sha256=3DvPkwAnchJ0OwuVn-KW0G4jwCRssBZXhTmofXr6-D4,3657
19
+ fluidattacks_agent-0.1.2.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
20
+ fluidattacks_agent-0.1.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
fluidattacks_agent.pth ADDED
@@ -0,0 +1 @@
1
+ import fluidattacks_agent.gate