fluidattacks-agent 0.1.1__tar.gz
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-0.1.1/.gitignore +8 -0
- fluidattacks_agent-0.1.1/PKG-INFO +95 -0
- fluidattacks_agent-0.1.1/README.md +74 -0
- fluidattacks_agent-0.1.1/fluidattacks_agent/__init__.py +0 -0
- fluidattacks_agent-0.1.1/fluidattacks_agent/batch.py +161 -0
- fluidattacks_agent-0.1.1/fluidattacks_agent/deliver.py +158 -0
- fluidattacks_agent-0.1.1/fluidattacks_agent/distributions.py +80 -0
- fluidattacks_agent-0.1.1/fluidattacks_agent/executions.py +122 -0
- fluidattacks_agent-0.1.1/fluidattacks_agent/gate.py +10 -0
- fluidattacks_agent-0.1.1/fluidattacks_agent/loads.py +109 -0
- fluidattacks_agent-0.1.1/fluidattacks_agent/observer.py +137 -0
- fluidattacks_agent-0.1.1/fluidattacks_agent/outbox.py +64 -0
- fluidattacks_agent-0.1.1/fluidattacks_agent/patience.py +38 -0
- fluidattacks_agent-0.1.1/fluidattacks_agent/post.py +175 -0
- fluidattacks_agent-0.1.1/fluidattacks_agent/report.py +286 -0
- fluidattacks_agent-0.1.1/fluidattacks_agent/settings.py +119 -0
- fluidattacks_agent-0.1.1/fluidattacks_agent/sink.py +163 -0
- fluidattacks_agent-0.1.1/fluidattacks_agent/startup.py +413 -0
- fluidattacks_agent-0.1.1/fluidattacks_agent/switch.py +22 -0
- fluidattacks_agent-0.1.1/fluidattacks_agent.pth +1 -0
- fluidattacks_agent-0.1.1/pyproject.toml +48 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: fluidattacks-agent
|
|
3
|
+
Version: 0.1.1
|
|
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,74 @@
|
|
|
1
|
+
# fluidattacks-agent
|
|
2
|
+
|
|
3
|
+
Reports what a running Python workload actually imports and runs, so that a
|
|
4
|
+
dependency inventory can say which of its findings are reachable at runtime and
|
|
5
|
+
which are not.
|
|
6
|
+
|
|
7
|
+
It is a library, not a service. It observes the interpreter it is installed in,
|
|
8
|
+
writes what it saw, and does nothing else. It takes no dependencies: the
|
|
9
|
+
standard library only, because it is installed into workloads we do not own.
|
|
10
|
+
|
|
11
|
+
## Installing
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
pip install fluidattacks-agent
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
That is the whole setup. A `.pth` file at the root of the wheel starts the probe
|
|
18
|
+
at interpreter start, before the workload's own program runs, so nothing has to
|
|
19
|
+
be imported or called by hand.
|
|
20
|
+
|
|
21
|
+
## Turning it off
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
FLUIDATTACKS_AGENT=off
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Also `0`, `false`, `no`, `disabled` or `none`. A workload that says no pays for
|
|
28
|
+
reading the setting and for nothing above it. Saying nothing is taken for yes,
|
|
29
|
+
because installing the package is the consent.
|
|
30
|
+
|
|
31
|
+
## What it observes
|
|
32
|
+
|
|
33
|
+
- distributions whose modules were imported, and which modules
|
|
34
|
+
- functions that were executed, where the interpreter offers that
|
|
35
|
+
- how often, in windows, and when a symbol was first reached
|
|
36
|
+
|
|
37
|
+
Only what an installed distribution owns is attributed. The standard library and
|
|
38
|
+
a workload's own first-party code produce no records.
|
|
39
|
+
|
|
40
|
+
## Where reports go
|
|
41
|
+
|
|
42
|
+
By default, one file per report under `/tmp/.watches-exec`, for a collector to
|
|
43
|
+
drain. Naming an endpoint sends them instead:
|
|
44
|
+
|
|
45
|
+
| | |
|
|
46
|
+
|---|---|
|
|
47
|
+
| `FLUIDATTACKS_AGENT_ENDPOINT` | where reports are posted, `https://` only |
|
|
48
|
+
| `FLUIDATTACKS_AGENT_TOKEN_FILE` | a file holding the credential, preferred |
|
|
49
|
+
| `FLUIDATTACKS_AGENT_TOKEN` | the credential itself, read only if no file is named |
|
|
50
|
+
| `FLUIDATTACKS_AGENT_GROUP` | what the reports are filed under |
|
|
51
|
+
| `FLUIDATTACKS_AGENT_WORKLOAD` | what this workload is called |
|
|
52
|
+
|
|
53
|
+
A file is preferred over a variable because a file can be mode 400, while an
|
|
54
|
+
environment variable is readable by any process of the same user.
|
|
55
|
+
|
|
56
|
+
An endpoint named without enough beside it to reach is a misconfiguration, not a
|
|
57
|
+
reason to fall back: the probe then holds nothing and counts every report it
|
|
58
|
+
refused, so a half-configured deployment is loud rather than a directory filling
|
|
59
|
+
up where nobody drains it.
|
|
60
|
+
|
|
61
|
+
## What travels, and what does not
|
|
62
|
+
|
|
63
|
+
Reports are gzipped and signed with a key derived from the credential; the
|
|
64
|
+
credential itself never travels, appears in no record, and is in no exception.
|
|
65
|
+
The far end must prove who it is — certificate chain and hostname both — and no
|
|
66
|
+
redirect is followed.
|
|
67
|
+
|
|
68
|
+
What a report contains is distribution names, versions, module and function
|
|
69
|
+
names, and counts. No arguments, no return values, no file contents, no
|
|
70
|
+
environment.
|
|
71
|
+
|
|
72
|
+
## Licence
|
|
73
|
+
|
|
74
|
+
MPL-2.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,158 @@
|
|
|
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
|
+
leaving: bool = False
|
|
50
|
+
delivered: int = 0
|
|
51
|
+
|
|
52
|
+
def write(self, text: str) -> None:
|
|
53
|
+
"""Take a report for delivery, or refuse and be counted for it."""
|
|
54
|
+
if self.batcher.next(self.told, text, self.outbox.put) is None:
|
|
55
|
+
raise Stalled(errno.ENOSPC, "nothing taken", len(text))
|
|
56
|
+
self._rouse()
|
|
57
|
+
|
|
58
|
+
def _rouse(self) -> None:
|
|
59
|
+
"""Say there is something to carry, and find a carrier if there is none."""
|
|
60
|
+
with self.guard:
|
|
61
|
+
# a carrier that stopped takes delivery with it, and one is not
|
|
62
|
+
# worth starting for a process already on its way out
|
|
63
|
+
if not self.leaving and (self.thread is None or not self.thread.is_alive()):
|
|
64
|
+
# replicas start in the same second, so this is drawn; and
|
|
65
|
+
# once, so that it can never overwrite a standoff
|
|
66
|
+
if not self.due:
|
|
67
|
+
self.due = time.monotonic() + splay(self.interval, self.share())
|
|
68
|
+
# nothing here may wait on the carrier: a thread importing what
|
|
69
|
+
# a post needs blocks while its starter is mid-import
|
|
70
|
+
self.thread = threading.Thread(target=self._serving, name=NAME, daemon=True)
|
|
71
|
+
self.thread.start()
|
|
72
|
+
self.woken.set()
|
|
73
|
+
|
|
74
|
+
def _serving(self) -> None:
|
|
75
|
+
while not self.leaving:
|
|
76
|
+
# a thread that dies takes delivery with it and tells nobody
|
|
77
|
+
with contextlib.suppress(Exception):
|
|
78
|
+
self.woken.wait(self._waiting())
|
|
79
|
+
self.woken.clear()
|
|
80
|
+
if time.monotonic() >= self.due:
|
|
81
|
+
self.offering()
|
|
82
|
+
|
|
83
|
+
def parting(self) -> None:
|
|
84
|
+
"""Give whatever is held one bounded chance to travel, then give up."""
|
|
85
|
+
self.leaving = True
|
|
86
|
+
self.woken.set()
|
|
87
|
+
limit = time.monotonic() + PARTING
|
|
88
|
+
# a carrier mid-pass is doing this work, so the wait is that budget
|
|
89
|
+
if not self.passing.acquire(timeout=PARTING):
|
|
90
|
+
return
|
|
91
|
+
try:
|
|
92
|
+
with contextlib.suppress(Exception):
|
|
93
|
+
# bounded by what is left, or the bound is only the moment a
|
|
94
|
+
# last attempt may begin at
|
|
95
|
+
while (left := limit - time.monotonic()) > 0 and self._offered(left):
|
|
96
|
+
pass
|
|
97
|
+
finally:
|
|
98
|
+
self.passing.release()
|
|
99
|
+
|
|
100
|
+
def forked(self) -> None:
|
|
101
|
+
"""Leave a child holding none of its parent's, and owing itself a carrier."""
|
|
102
|
+
# a lock or event held at the moment of the fork is held in the child
|
|
103
|
+
# forever, so each is made again rather than reset
|
|
104
|
+
self.guard = threading.Lock()
|
|
105
|
+
self.passing = threading.Lock()
|
|
106
|
+
self.woken = threading.Event()
|
|
107
|
+
self.thread = None
|
|
108
|
+
self.holding = None
|
|
109
|
+
# every worker of a pre-forking server was born in the same second
|
|
110
|
+
self.due = 0.0
|
|
111
|
+
self.tries = 0
|
|
112
|
+
# a child is not on its way out merely because its parent was
|
|
113
|
+
self.leaving = False
|
|
114
|
+
self.batcher.forked()
|
|
115
|
+
self.outbox.forked()
|
|
116
|
+
|
|
117
|
+
def registered(self) -> None:
|
|
118
|
+
"""Ask the interpreter to say when this process has become two."""
|
|
119
|
+
# cannot be taken back once given, so exactly one per process
|
|
120
|
+
if hasattr(os, "register_at_fork"):
|
|
121
|
+
os.register_at_fork(after_in_child=self.forked)
|
|
122
|
+
|
|
123
|
+
def offering(self) -> None:
|
|
124
|
+
"""Offer what is waiting, one pass at a time, stopping at a refusal."""
|
|
125
|
+
if not self.passing.acquire(blocking=False):
|
|
126
|
+
return
|
|
127
|
+
try:
|
|
128
|
+
for _ in range(self.outbox.limit):
|
|
129
|
+
if not self._offered(TIMEOUT):
|
|
130
|
+
return
|
|
131
|
+
finally:
|
|
132
|
+
self.passing.release()
|
|
133
|
+
|
|
134
|
+
def _offered(self, bound: float) -> bool:
|
|
135
|
+
"""Offer one batch, and say whether offering another is worth trying."""
|
|
136
|
+
batch = self.holding or self.outbox.take()
|
|
137
|
+
if batch is None:
|
|
138
|
+
return False
|
|
139
|
+
# held before the attempt, so a cut-off leaves it findable
|
|
140
|
+
self.holding = batch
|
|
141
|
+
if (verdict := self.carry(self.told, batch, bound)) is not Verdict.DELIVERED:
|
|
142
|
+
self._stood_off(verdict)
|
|
143
|
+
return False
|
|
144
|
+
self.holding = None
|
|
145
|
+
self.due = 0.0
|
|
146
|
+
self.tries = 0
|
|
147
|
+
self.delivered += 1
|
|
148
|
+
return True
|
|
149
|
+
|
|
150
|
+
def _stood_off(self, verdict: Verdict) -> None:
|
|
151
|
+
"""Wait longer for each refusal, and longest for one about nothing of ours."""
|
|
152
|
+
self.tries = TOPMOST if verdict is Verdict.STOP else self.tries + 1
|
|
153
|
+
self.due = time.monotonic() + standoff(self.tries, self.share())
|
|
154
|
+
|
|
155
|
+
def _waiting(self) -> float:
|
|
156
|
+
"""Wait out a standoff if there is one, and otherwise a pass."""
|
|
157
|
+
left = self.due - time.monotonic()
|
|
158
|
+
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])
|