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.
- fluidattacks_agent/__init__.py +0 -0
- fluidattacks_agent/batch.py +161 -0
- fluidattacks_agent/deliver.py +178 -0
- fluidattacks_agent/distributions.py +80 -0
- fluidattacks_agent/executions.py +124 -0
- fluidattacks_agent/gate.py +10 -0
- fluidattacks_agent/loads.py +109 -0
- fluidattacks_agent/observer.py +137 -0
- fluidattacks_agent/outbox.py +64 -0
- fluidattacks_agent/patience.py +38 -0
- fluidattacks_agent/post.py +175 -0
- fluidattacks_agent/report.py +286 -0
- fluidattacks_agent/settings.py +119 -0
- fluidattacks_agent/sink.py +163 -0
- fluidattacks_agent/startup.py +439 -0
- fluidattacks_agent/switch.py +22 -0
- fluidattacks_agent-0.1.2.dist-info/METADATA +95 -0
- fluidattacks_agent-0.1.2.dist-info/RECORD +20 -0
- fluidattacks_agent-0.1.2.dist-info/WHEEL +4 -0
- fluidattacks_agent.pth +1 -0
|
@@ -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,,
|
fluidattacks_agent.pth
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import fluidattacks_agent.gate
|