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
|
File without changes
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Pack a report into a batch the centre can order, verify and decompress."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import threading
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Final
|
|
8
|
+
|
|
9
|
+
from fluidattacks_agent.settings import Delivery, Secret
|
|
10
|
+
|
|
11
|
+
BATCH_TAG: Final = "watches-batch/1"
|
|
12
|
+
|
|
13
|
+
# what the centre keys on. The whitepaper's unit is a machine that outlives a
|
|
14
|
+
# reboot; ours is an interpreter that may live for seconds, so what names one is
|
|
15
|
+
# minted per start and never reused
|
|
16
|
+
INSTANCE_BYTES: Final = 16
|
|
17
|
+
|
|
18
|
+
# a probe's own health is not a workload's evidence and must not be filed as it
|
|
19
|
+
EXEC: Final = "exec"
|
|
20
|
+
|
|
21
|
+
# what orders one instance's batches. Wrapping would land on a key already
|
|
22
|
+
# ingested and be discarded as a duplicate, so the counter refuses instead
|
|
23
|
+
MAX_SEQ: Final = 1 << 53
|
|
24
|
+
|
|
25
|
+
# a flush runs inside a host callback, so this is the workload's own latency:
|
|
26
|
+
# measured on a mebibyte of real report text, level one costs 4.5 ms for 3.1x
|
|
27
|
+
# and level six costs 18.6 ms for 3.7x. Fourteen milliseconds of somebody
|
|
28
|
+
# else's request is not worth sixty kibibytes
|
|
29
|
+
COMPRESSION: Final = 1
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def packed(text: str) -> bytes:
|
|
33
|
+
"""Compress a report, the same way every time it holds the same thing."""
|
|
34
|
+
# here and not above: a flush is what needs this, and two milliseconds of
|
|
35
|
+
# interpreter start is not owed by a workload that never reaches one
|
|
36
|
+
import gzip # noqa: PLC0415
|
|
37
|
+
|
|
38
|
+
# mtime zero because a gzip header otherwise carries a clock, and a probe
|
|
39
|
+
# that asserts no phase of its own should not assert one here either
|
|
40
|
+
return gzip.compress(text.encode(), compresslevel=COMPRESSION, mtime=0)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class Batch:
|
|
45
|
+
"""One delivery: what names it, what orders it, and what proves it."""
|
|
46
|
+
|
|
47
|
+
instance: str
|
|
48
|
+
seq: int
|
|
49
|
+
stream: str
|
|
50
|
+
workload: str
|
|
51
|
+
group: str
|
|
52
|
+
body: bytes
|
|
53
|
+
signature: str
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def minted() -> str:
|
|
57
|
+
"""Name this interpreter, once, out of the kernel's own entropy."""
|
|
58
|
+
# not secrets.token_hex: the same source at the same 0.8 us a call, but
|
|
59
|
+
# importing it costs 3.5 ms of a start this package spent months shortening
|
|
60
|
+
return os.urandom(INSTANCE_BYTES).hex()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _stated(*parts: str) -> bytes:
|
|
64
|
+
# length-prefixed over the encoded bytes, so that no two different batches
|
|
65
|
+
# can spell alike whatever the values hold: being unambiguous is a property
|
|
66
|
+
# of this form rather than a promise made somewhere else
|
|
67
|
+
return b"\n".join(b"%d:%s" % (len(spelt), spelt) for spelt in map(str.encode, parts))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def derived(token: Secret) -> bytes:
|
|
71
|
+
"""Make a key that signs, and is not the credential that authorises."""
|
|
72
|
+
import hmac # noqa: PLC0415
|
|
73
|
+
|
|
74
|
+
# so a signature cannot be replayed as a credential, and a credential
|
|
75
|
+
# cannot be read back out of a signature
|
|
76
|
+
return hmac.digest(token.held.encode(), BATCH_TAG.encode(), "sha256")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def batched(
|
|
80
|
+
delivery: Delivery,
|
|
81
|
+
instance: str,
|
|
82
|
+
seq: int,
|
|
83
|
+
stream: str,
|
|
84
|
+
text: str,
|
|
85
|
+
) -> Batch | None:
|
|
86
|
+
"""Name a report, order it, pack it, and prove where it came from."""
|
|
87
|
+
if not 0 < seq < MAX_SEQ:
|
|
88
|
+
return None
|
|
89
|
+
import hmac # noqa: PLC0415
|
|
90
|
+
|
|
91
|
+
body = packed(text)
|
|
92
|
+
workload = delivery.workload or ""
|
|
93
|
+
group = delivery.group or ""
|
|
94
|
+
proof = hmac.new(derived(delivery.token), digestmod="sha256")
|
|
95
|
+
# what travels beside the body is signed with it, because a proxy that can
|
|
96
|
+
# rewrite which instance or which tenant sent this misfiles it without
|
|
97
|
+
# touching a byte of the evidence
|
|
98
|
+
proof.update(_stated(BATCH_TAG, group, workload, instance, str(seq), stream))
|
|
99
|
+
proof.update(body)
|
|
100
|
+
return Batch(
|
|
101
|
+
instance=instance,
|
|
102
|
+
seq=seq,
|
|
103
|
+
stream=stream,
|
|
104
|
+
workload=workload,
|
|
105
|
+
group=group,
|
|
106
|
+
body=body,
|
|
107
|
+
signature=proof.hexdigest(),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass
|
|
112
|
+
class Batcher:
|
|
113
|
+
"""One instance's batches, in the order that instance made them."""
|
|
114
|
+
|
|
115
|
+
instance: str = ""
|
|
116
|
+
seq: int = 0
|
|
117
|
+
# two host callbacks can be inside a flush at the same time, because what
|
|
118
|
+
# keeps a second one out is a plain attribute and not a mutex: measured,
|
|
119
|
+
# eight threads asking for a place got the same one in all three hundred
|
|
120
|
+
# rounds, and two batches sharing a place have one of them discarded
|
|
121
|
+
guard: threading.Lock = field(default_factory=threading.Lock, repr=False)
|
|
122
|
+
|
|
123
|
+
def named(self) -> str:
|
|
124
|
+
"""Say what names this interpreter, minting it the first time asked."""
|
|
125
|
+
if not self.instance:
|
|
126
|
+
self.instance = minted()
|
|
127
|
+
return self.instance
|
|
128
|
+
|
|
129
|
+
def next(
|
|
130
|
+
self,
|
|
131
|
+
delivery: Delivery,
|
|
132
|
+
text: str,
|
|
133
|
+
taken: Callable[[Batch], bool],
|
|
134
|
+
stream: str = EXEC,
|
|
135
|
+
) -> Batch | None:
|
|
136
|
+
"""Build the next batch, and spend its place only once it is held."""
|
|
137
|
+
# taking the place, building against it and offering it are one step, so
|
|
138
|
+
# that no two batches can be built against the same place and no place
|
|
139
|
+
# is spent on a batch that was turned away. Whoever takes it is asked
|
|
140
|
+
# from in here for that reason, and never asks anything back of this
|
|
141
|
+
with self.guard:
|
|
142
|
+
held = self.seq + 1
|
|
143
|
+
batch = batched(delivery, self.named(), held, stream, text)
|
|
144
|
+
if batch is None or not taken(batch):
|
|
145
|
+
return None
|
|
146
|
+
# a place spent on a batch nothing is holding would leave a hole,
|
|
147
|
+
# and a hole is how evidence lost in flight is meant to look
|
|
148
|
+
self.seq = held
|
|
149
|
+
return batch
|
|
150
|
+
|
|
151
|
+
def forked(self) -> None:
|
|
152
|
+
"""Forget what named this instance, because a child of it is another."""
|
|
153
|
+
# a lock held by another thread at the moment of the fork is held in the
|
|
154
|
+
# child forever, and a fresh one cannot be: the child has no second
|
|
155
|
+
# thread, so there is nothing for this one to be held against
|
|
156
|
+
self.guard = threading.Lock()
|
|
157
|
+
# a child inherits the name and the count and goes on emitting the keys
|
|
158
|
+
# its parent is emitting: measured across a fork, every key after it
|
|
159
|
+
# collided, and ingest discards a collision without saying so
|
|
160
|
+
self.instance = ""
|
|
161
|
+
self.seq = 0
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Carry what a workload reported out of the process it was reported in."""
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import errno
|
|
5
|
+
import os
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Final
|
|
11
|
+
|
|
12
|
+
from fluidattacks_agent.batch import Batch, Batcher
|
|
13
|
+
from fluidattacks_agent.outbox import Outbox
|
|
14
|
+
from fluidattacks_agent.patience import TOPMOST, fraction, splay, standoff
|
|
15
|
+
from fluidattacks_agent.post import TIMEOUT, Verdict, posted
|
|
16
|
+
from fluidattacks_agent.settings import Delivery
|
|
17
|
+
from fluidattacks_agent.sink import Stalled
|
|
18
|
+
|
|
19
|
+
# how old the centre's picture may be while nothing new is being found
|
|
20
|
+
INTERVAL: Final = 15.0
|
|
21
|
+
|
|
22
|
+
# what a workload's own exit may be delayed by while what is held goes out
|
|
23
|
+
PARTING: Final = 2.0
|
|
24
|
+
|
|
25
|
+
NAME: Final = "fluidattacks-agent"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Deliverer:
|
|
30
|
+
"""A sink that puts a report on its way out rather than in a directory."""
|
|
31
|
+
|
|
32
|
+
told: Delivery
|
|
33
|
+
batcher: Batcher = field(default_factory=Batcher)
|
|
34
|
+
outbox: Outbox = field(default_factory=Outbox)
|
|
35
|
+
carry: Callable[[Delivery, Batch, float], Verdict] = posted
|
|
36
|
+
# kept in hand rather than put back, so its place stays its place
|
|
37
|
+
holding: Batch | None = field(default=None, repr=False)
|
|
38
|
+
interval: float = INTERVAL
|
|
39
|
+
share: Callable[[], float] = fraction
|
|
40
|
+
# a wake says there is something to carry, never that it is welcome
|
|
41
|
+
due: float = 0.0
|
|
42
|
+
tries: int = 0
|
|
43
|
+
woken: threading.Event = field(default_factory=threading.Event, repr=False)
|
|
44
|
+
thread: threading.Thread | None = field(default=None, repr=False)
|
|
45
|
+
# the thread field alone, and never held across anything that can block
|
|
46
|
+
guard: threading.Lock = field(default_factory=threading.Lock, repr=False)
|
|
47
|
+
# one pass at a time: what is taken but not delivered lives in one place
|
|
48
|
+
passing: threading.Lock = field(default_factory=threading.Lock, repr=False)
|
|
49
|
+
# told once a period went by with the far end in reach and nothing left to
|
|
50
|
+
# carry, so what the probe still holds goes out before it is any older
|
|
51
|
+
remind: Callable[[], None] = field(default=lambda: None, repr=False)
|
|
52
|
+
# when the probe was last reminded: a far end that refuses and then takes
|
|
53
|
+
# ends a wait every standoff, and a wait ending is not a period going by
|
|
54
|
+
reminded: float = 0.0
|
|
55
|
+
leaving: bool = False
|
|
56
|
+
delivered: int = 0
|
|
57
|
+
|
|
58
|
+
def write(self, text: str) -> None:
|
|
59
|
+
"""Take a report for delivery, or refuse and be counted for it."""
|
|
60
|
+
if self.batcher.next(self.told, text, self.outbox.put) is None:
|
|
61
|
+
raise Stalled(errno.ENOSPC, "nothing taken", len(text))
|
|
62
|
+
self._rouse()
|
|
63
|
+
|
|
64
|
+
def _rouse(self) -> None:
|
|
65
|
+
"""Say there is something to carry, and find a carrier if there is none."""
|
|
66
|
+
with self.guard:
|
|
67
|
+
# a carrier that stopped takes delivery with it, and one is not
|
|
68
|
+
# worth starting for a process already on its way out
|
|
69
|
+
if not self.leaving and (self.thread is None or not self.thread.is_alive()):
|
|
70
|
+
# replicas start in the same second, so this is drawn; and
|
|
71
|
+
# once, so that it can never overwrite a standoff
|
|
72
|
+
if not self.due:
|
|
73
|
+
self.due = time.monotonic() + splay(self.interval, self.share())
|
|
74
|
+
# nothing here may wait on the carrier: a thread importing what
|
|
75
|
+
# a post needs blocks while its starter is mid-import
|
|
76
|
+
self.thread = threading.Thread(target=self._serving, name=NAME, daemon=True)
|
|
77
|
+
self.thread.start()
|
|
78
|
+
self.woken.set()
|
|
79
|
+
|
|
80
|
+
def _serving(self) -> None:
|
|
81
|
+
while not self.leaving:
|
|
82
|
+
# a thread that dies takes delivery with it and tells nobody
|
|
83
|
+
with contextlib.suppress(Exception):
|
|
84
|
+
roused = self.woken.wait(self._waiting())
|
|
85
|
+
self.woken.clear()
|
|
86
|
+
if time.monotonic() < self.due:
|
|
87
|
+
continue
|
|
88
|
+
self.offering()
|
|
89
|
+
# a period, never a wake: a wake says there is something to
|
|
90
|
+
# carry, and asking for more on one made each flush bring the
|
|
91
|
+
# next. Nothing in hand, or the far end is still refusing
|
|
92
|
+
now = time.monotonic()
|
|
93
|
+
if (
|
|
94
|
+
not roused
|
|
95
|
+
and self.holding is None
|
|
96
|
+
and not self.leaving
|
|
97
|
+
and now - self.reminded >= self.interval
|
|
98
|
+
):
|
|
99
|
+
self.reminded = now
|
|
100
|
+
self.remind()
|
|
101
|
+
|
|
102
|
+
def parting(self) -> None:
|
|
103
|
+
"""Give whatever is held one bounded chance to travel, then give up."""
|
|
104
|
+
self.leaving = True
|
|
105
|
+
self.woken.set()
|
|
106
|
+
limit = time.monotonic() + PARTING
|
|
107
|
+
# a carrier mid-pass is doing this work, so the wait is that budget
|
|
108
|
+
if not self.passing.acquire(timeout=PARTING):
|
|
109
|
+
return
|
|
110
|
+
try:
|
|
111
|
+
with contextlib.suppress(Exception):
|
|
112
|
+
# bounded by what is left, or the bound is only the moment a
|
|
113
|
+
# last attempt may begin at
|
|
114
|
+
while (left := limit - time.monotonic()) > 0 and self._offered(left):
|
|
115
|
+
pass
|
|
116
|
+
finally:
|
|
117
|
+
self.passing.release()
|
|
118
|
+
|
|
119
|
+
def forked(self) -> None:
|
|
120
|
+
"""Leave a child holding none of its parent's, and owing itself a carrier."""
|
|
121
|
+
# a lock or event held at the moment of the fork is held in the child
|
|
122
|
+
# forever, so each is made again rather than reset
|
|
123
|
+
self.guard = threading.Lock()
|
|
124
|
+
self.passing = threading.Lock()
|
|
125
|
+
self.woken = threading.Event()
|
|
126
|
+
self.thread = None
|
|
127
|
+
self.holding = None
|
|
128
|
+
# every worker of a pre-forking server was born in the same second
|
|
129
|
+
self.due = 0.0
|
|
130
|
+
self.tries = 0
|
|
131
|
+
self.reminded = 0.0
|
|
132
|
+
# a child is not on its way out merely because its parent was
|
|
133
|
+
self.leaving = False
|
|
134
|
+
self.batcher.forked()
|
|
135
|
+
self.outbox.forked()
|
|
136
|
+
|
|
137
|
+
def registered(self) -> None:
|
|
138
|
+
"""Ask the interpreter to say when this process has become two."""
|
|
139
|
+
# cannot be taken back once given, so exactly one per process
|
|
140
|
+
if hasattr(os, "register_at_fork"):
|
|
141
|
+
os.register_at_fork(after_in_child=self.forked)
|
|
142
|
+
|
|
143
|
+
def offering(self) -> None:
|
|
144
|
+
"""Offer what is waiting, one pass at a time, stopping at a refusal."""
|
|
145
|
+
if not self.passing.acquire(blocking=False):
|
|
146
|
+
return
|
|
147
|
+
try:
|
|
148
|
+
for _ in range(self.outbox.limit):
|
|
149
|
+
if not self._offered(TIMEOUT):
|
|
150
|
+
return
|
|
151
|
+
finally:
|
|
152
|
+
self.passing.release()
|
|
153
|
+
|
|
154
|
+
def _offered(self, bound: float) -> bool:
|
|
155
|
+
"""Offer one batch, and say whether offering another is worth trying."""
|
|
156
|
+
batch = self.holding or self.outbox.take()
|
|
157
|
+
if batch is None:
|
|
158
|
+
return False
|
|
159
|
+
# held before the attempt, so a cut-off leaves it findable
|
|
160
|
+
self.holding = batch
|
|
161
|
+
if (verdict := self.carry(self.told, batch, bound)) is not Verdict.DELIVERED:
|
|
162
|
+
self._stood_off(verdict)
|
|
163
|
+
return False
|
|
164
|
+
self.holding = None
|
|
165
|
+
self.due = 0.0
|
|
166
|
+
self.tries = 0
|
|
167
|
+
self.delivered += 1
|
|
168
|
+
return True
|
|
169
|
+
|
|
170
|
+
def _stood_off(self, verdict: Verdict) -> None:
|
|
171
|
+
"""Wait longer for each refusal, and longest for one about nothing of ours."""
|
|
172
|
+
self.tries = TOPMOST if verdict is Verdict.STOP else self.tries + 1
|
|
173
|
+
self.due = time.monotonic() + standoff(self.tries, self.share())
|
|
174
|
+
|
|
175
|
+
def _waiting(self) -> float:
|
|
176
|
+
"""Wait out a standoff if there is one, and otherwise a pass."""
|
|
177
|
+
left = self.due - time.monotonic()
|
|
178
|
+
return left if left > 0 else self.interval
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Map an imported module to the distribution that installed it."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from fluidattacks_agent.report import Confidence, Name, readable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class Dist:
|
|
10
|
+
"""The distribution behind a module, and how well its version is known."""
|
|
11
|
+
|
|
12
|
+
name: Name
|
|
13
|
+
version: str | None
|
|
14
|
+
confidence: Confidence
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Declared:
|
|
19
|
+
"""The versions read off installed metadata, and the ones that would not."""
|
|
20
|
+
|
|
21
|
+
versions: dict[Name, str]
|
|
22
|
+
unreadable: int
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def declared() -> Declared:
|
|
26
|
+
"""Read the version every installed distribution declares for itself."""
|
|
27
|
+
# imported here and not above: reading metadata costs twenty milliseconds of
|
|
28
|
+
# interpreter start, and it is a flush that needs it, not an import
|
|
29
|
+
from importlib.metadata import distributions # noqa: PLC0415
|
|
30
|
+
|
|
31
|
+
found: dict[Name, str] = {}
|
|
32
|
+
unreadable = 0
|
|
33
|
+
for dist in distributions():
|
|
34
|
+
# metadata is a file the workload owns: one unreadable distribution
|
|
35
|
+
# must not cost the map, since without it the probe reports nothing
|
|
36
|
+
try:
|
|
37
|
+
metadata = dist.metadata
|
|
38
|
+
name, version = metadata["Name"], metadata["Version"]
|
|
39
|
+
except Exception: # noqa: BLE001
|
|
40
|
+
unreadable += 1
|
|
41
|
+
continue
|
|
42
|
+
# the type keys it the way it will be looked up, which also folds two
|
|
43
|
+
# spellings of one project together, since PEP 503 says they are one
|
|
44
|
+
if name is not None and version is not None:
|
|
45
|
+
found.setdefault(Name(name), version)
|
|
46
|
+
return Declared(versions=found, unreadable=unreadable)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def resolve(names: list[str], versions: dict[Name, str]) -> Dist:
|
|
50
|
+
"""Choose the distribution behind a module, and say how sure that is."""
|
|
51
|
+
name = Name(names[0])
|
|
52
|
+
version = versions.get(name)
|
|
53
|
+
# a version the reader would refuse is one we could not read, and dropping
|
|
54
|
+
# the record over it would lose the package instead of the version
|
|
55
|
+
if version is None or not readable(version):
|
|
56
|
+
return Dist(name=name, version=None, confidence=Confidence.UNKNOWN)
|
|
57
|
+
known = Confidence.EXACT if len(names) == 1 else Confidence.INFERRED
|
|
58
|
+
return Dist(name=name, version=version, confidence=known)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def distribution_map() -> dict[str, Dist]:
|
|
62
|
+
"""
|
|
63
|
+
Read which distribution installed each top-level module, and its version.
|
|
64
|
+
|
|
65
|
+
This walks every installed distribution's metadata, so it is taken once per
|
|
66
|
+
report rather than once per module. The probe spends the workload's own cpu.
|
|
67
|
+
"""
|
|
68
|
+
from importlib.metadata import packages_distributions # noqa: PLC0415
|
|
69
|
+
|
|
70
|
+
versions = declared().versions
|
|
71
|
+
return {
|
|
72
|
+
module: resolve(names, versions)
|
|
73
|
+
for module, names in packages_distributions().items()
|
|
74
|
+
if names
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def distribution_of(module: str, installed: dict[str, Dist]) -> Dist | None:
|
|
79
|
+
"""Name the distribution a module came from, submodules included."""
|
|
80
|
+
return installed.get(module.split(".", maxsplit=1)[0])
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Watch code inside a package start running, where the interpreter allows it."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import PurePath
|
|
7
|
+
from typing import Final
|
|
8
|
+
|
|
9
|
+
from fluidattacks_agent.distributions import Dist, distribution_of
|
|
10
|
+
from fluidattacks_agent.report import Ecosystem, Evidence, Granularity, Record, keyed
|
|
11
|
+
|
|
12
|
+
# 3.15 makes the import below lazy from this; every version we support
|
|
13
|
+
# ignores it, so nothing changes until the floor moves
|
|
14
|
+
__lazy_modules__ = ["pathlib"]
|
|
15
|
+
|
|
16
|
+
# a real application runs some 26,000 distinct functions while it starts, and
|
|
17
|
+
# what filled the ceiling is what never gets named again for the process's life
|
|
18
|
+
MAX_FUNCTIONS: Final = 32768
|
|
19
|
+
|
|
20
|
+
OURS: Final = "fluidattacks_agent"
|
|
21
|
+
|
|
22
|
+
SOURCE: Final = ".py"
|
|
23
|
+
|
|
24
|
+
INITIALIZER: Final = "__init__"
|
|
25
|
+
PARENT: Final = ".."
|
|
26
|
+
|
|
27
|
+
TOOL: Final = 3
|
|
28
|
+
TOOL_NAME: Final = "fluidattacks-agent"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
TOOLS: Final = 6
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def available() -> bool:
|
|
35
|
+
"""Say whether this interpreter can report code starting at all."""
|
|
36
|
+
return hasattr(sys, "monitoring")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def alone(tool: int = TOOL) -> bool:
|
|
40
|
+
"""Say whether this is the only tool watching: restart_events takes none."""
|
|
41
|
+
held = (sys.monitoring.get_tool(other) for other in range(TOOLS) if other != tool)
|
|
42
|
+
return not any(name is not None for name in held)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def module_of(filename: str, sites: tuple[str, ...]) -> str | None:
|
|
46
|
+
"""Name the module whose source a running code object came from."""
|
|
47
|
+
for site in sites:
|
|
48
|
+
prefix = site + "/"
|
|
49
|
+
if not filename.startswith(prefix) or not filename.endswith(SOURCE):
|
|
50
|
+
continue
|
|
51
|
+
held = PurePath(filename[len(prefix) :])
|
|
52
|
+
parts = list(held.parts)
|
|
53
|
+
if not parts or held.is_absolute() or PARENT in parts:
|
|
54
|
+
return None
|
|
55
|
+
stem = parts.pop()[: -len(SOURCE)]
|
|
56
|
+
if not stem:
|
|
57
|
+
return None
|
|
58
|
+
if stem != INITIALIZER:
|
|
59
|
+
parts.append(stem)
|
|
60
|
+
return ".".join(parts) or None
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class RunObserver:
|
|
66
|
+
"""Functions that started, bounded the way the reader's own map is."""
|
|
67
|
+
|
|
68
|
+
sites: tuple[str, ...] = ()
|
|
69
|
+
seen: dict[str, int] = field(default_factory=dict)
|
|
70
|
+
moved: set[str] = field(default_factory=set)
|
|
71
|
+
suppressed: int = 0
|
|
72
|
+
|
|
73
|
+
def note(self, filename: str, qualname: str) -> str | None:
|
|
74
|
+
"""Take note of code starting, naming the function whenever there is room."""
|
|
75
|
+
module = module_of(filename, self.sites)
|
|
76
|
+
if module is None:
|
|
77
|
+
return None
|
|
78
|
+
if module.split(".", maxsplit=1)[0] == OURS:
|
|
79
|
+
return None
|
|
80
|
+
qualified = f"{module}.{qualname}"
|
|
81
|
+
if qualified in self.seen:
|
|
82
|
+
self.seen[qualified] += 1
|
|
83
|
+
self.moved.add(qualified)
|
|
84
|
+
return qualified
|
|
85
|
+
if len(self.seen) >= MAX_FUNCTIONS:
|
|
86
|
+
self.suppressed += 1
|
|
87
|
+
return None
|
|
88
|
+
self.seen[qualified] = 1
|
|
89
|
+
self.moved.add(qualified)
|
|
90
|
+
return qualified
|
|
91
|
+
|
|
92
|
+
def records(
|
|
93
|
+
self,
|
|
94
|
+
installed: dict[str, Dist],
|
|
95
|
+
carried: Mapping[tuple[str, str], int],
|
|
96
|
+
) -> list[Record]:
|
|
97
|
+
"""Report every function whose package a distribution installed."""
|
|
98
|
+
found: list[Record] = []
|
|
99
|
+
scanned = bool(installed)
|
|
100
|
+
for qualified in sorted(self.moved):
|
|
101
|
+
times = self.seen[qualified]
|
|
102
|
+
# what a report carried comes back only if note sees it again,
|
|
103
|
+
# and no distribution will own what a settled scan does not
|
|
104
|
+
if times <= carried.get(keyed(Evidence.EXECUTED, qualified), 0):
|
|
105
|
+
self.moved.discard(qualified)
|
|
106
|
+
continue
|
|
107
|
+
dist = distribution_of(qualified, installed)
|
|
108
|
+
if dist is None:
|
|
109
|
+
if scanned:
|
|
110
|
+
self.moved.discard(qualified)
|
|
111
|
+
continue
|
|
112
|
+
found.append(
|
|
113
|
+
Record(
|
|
114
|
+
ecosystem=Ecosystem.PYPI,
|
|
115
|
+
name=dist.name,
|
|
116
|
+
version=dist.version,
|
|
117
|
+
confidence=dist.confidence,
|
|
118
|
+
symbol=qualified,
|
|
119
|
+
granularity=Granularity.FUNCTION,
|
|
120
|
+
evidence=Evidence.EXECUTED,
|
|
121
|
+
frequency=times,
|
|
122
|
+
),
|
|
123
|
+
)
|
|
124
|
+
return found
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Start the probe on being imported, and only if a workload wants it."""
|
|
2
|
+
|
|
3
|
+
from fluidattacks_agent.switch import switched_on
|
|
4
|
+
|
|
5
|
+
# importing this runs it, because a site directory is what imports it. A
|
|
6
|
+
# workload that said no imports nothing above the switch: 26 ms of a start
|
|
7
|
+
if switched_on():
|
|
8
|
+
from fluidattacks_agent.startup import start
|
|
9
|
+
|
|
10
|
+
start()
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Account for package code the interpreter read without importing it."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from types import ModuleType
|
|
7
|
+
|
|
8
|
+
from fluidattacks_agent.distributions import Dist, distribution_of
|
|
9
|
+
from fluidattacks_agent.report import Ecosystem, Evidence, Granularity, Record, keyed
|
|
10
|
+
|
|
11
|
+
MAX_PACKAGES = 4096
|
|
12
|
+
|
|
13
|
+
OURS = "fluidattacks_agent"
|
|
14
|
+
|
|
15
|
+
METADATA = (".dist-info", ".egg-info")
|
|
16
|
+
|
|
17
|
+
CACHE = "__pycache__"
|
|
18
|
+
|
|
19
|
+
INITIALIZER = "__init__"
|
|
20
|
+
PARENT = ".."
|
|
21
|
+
|
|
22
|
+
CODE = (".py", ".pyc", ".so", ".pyd")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def package_of(path: str, sites: tuple[str, ...]) -> str | None:
|
|
26
|
+
"""Name the top level package a read file belongs to, when it is code."""
|
|
27
|
+
if not path.endswith(CODE):
|
|
28
|
+
return None
|
|
29
|
+
for site in sites:
|
|
30
|
+
prefix = site + os.sep
|
|
31
|
+
if not path.startswith(prefix):
|
|
32
|
+
continue
|
|
33
|
+
held = path[len(prefix) :]
|
|
34
|
+
# a step back out names a file the package it sits under does not own
|
|
35
|
+
if f"{os.sep}{PARENT}{os.sep}" in held:
|
|
36
|
+
return None
|
|
37
|
+
head, _, tail = held.partition(os.sep)
|
|
38
|
+
if head == CACHE:
|
|
39
|
+
head = tail.split(os.sep, maxsplit=1)[0]
|
|
40
|
+
if head.endswith(METADATA):
|
|
41
|
+
return None
|
|
42
|
+
named = head.split(".", maxsplit=1)[0]
|
|
43
|
+
return None if named == INITIALIZER else named or None
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class LoadObserver:
|
|
49
|
+
"""Packages whose code was read, bounded the way the reader's map is."""
|
|
50
|
+
|
|
51
|
+
sites: tuple[str, ...] = ()
|
|
52
|
+
# how many files of each package were read, not merely whether any was
|
|
53
|
+
seen: dict[str, int] = field(default_factory=dict)
|
|
54
|
+
moved: set[str] = field(default_factory=set)
|
|
55
|
+
suppressed: int = 0
|
|
56
|
+
|
|
57
|
+
def note(self, path: str) -> str | None:
|
|
58
|
+
"""Take note of a file opened, naming the package whenever there is room."""
|
|
59
|
+
package = package_of(path, self.sites)
|
|
60
|
+
if package is None or package == OURS:
|
|
61
|
+
return None
|
|
62
|
+
if package in self.seen:
|
|
63
|
+
self.seen[package] += 1
|
|
64
|
+
self.moved.add(package)
|
|
65
|
+
return package
|
|
66
|
+
if len(self.seen) >= MAX_PACKAGES:
|
|
67
|
+
self.suppressed += 1
|
|
68
|
+
return None
|
|
69
|
+
self.seen[package] = 1
|
|
70
|
+
self.moved.add(package)
|
|
71
|
+
return package
|
|
72
|
+
|
|
73
|
+
def records(
|
|
74
|
+
self,
|
|
75
|
+
modules: dict[str, ModuleType],
|
|
76
|
+
installed: dict[str, Dist],
|
|
77
|
+
carried: Mapping[tuple[str, str], int],
|
|
78
|
+
) -> list[Record]:
|
|
79
|
+
"""Report the packages that were read and never became modules."""
|
|
80
|
+
found: list[Record] = []
|
|
81
|
+
scanned = bool(installed)
|
|
82
|
+
for package in sorted(self.moved):
|
|
83
|
+
times = self.seen[package]
|
|
84
|
+
if times <= carried.get(keyed(Evidence.READ, package), 0):
|
|
85
|
+
self.moved.discard(package)
|
|
86
|
+
continue
|
|
87
|
+
if package in modules:
|
|
88
|
+
# kept, because a name can leave sys.modules and be read again
|
|
89
|
+
continue
|
|
90
|
+
dist = distribution_of(package, installed)
|
|
91
|
+
if dist is None:
|
|
92
|
+
if scanned:
|
|
93
|
+
self.moved.discard(package)
|
|
94
|
+
continue
|
|
95
|
+
found.append(
|
|
96
|
+
Record(
|
|
97
|
+
ecosystem=Ecosystem.PYPI,
|
|
98
|
+
name=dist.name,
|
|
99
|
+
version=dist.version,
|
|
100
|
+
confidence=dist.confidence,
|
|
101
|
+
symbol=package,
|
|
102
|
+
# a read file names the package it sits in, and naming the
|
|
103
|
+
# module it is would claim the interpreter reached it
|
|
104
|
+
granularity=Granularity.PACKAGE,
|
|
105
|
+
evidence=Evidence.READ,
|
|
106
|
+
frequency=times,
|
|
107
|
+
),
|
|
108
|
+
)
|
|
109
|
+
return found
|