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.
@@ -0,0 +1,8 @@
1
+ # Rust build artifacts (top-level crate and the ebpf/ sub-crates)
2
+ target/
3
+
4
+ # Python tooling caches from the probe's lint and test targets
5
+ __pycache__/
6
+ .mypy_cache/
7
+ .pytest_cache/
8
+ .ruff_cache/
@@ -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
@@ -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])