fluidattacks-agent 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,119 @@
1
+ """Read what the workload was told about the probe, and believe little of it."""
2
+
3
+ import os
4
+ import stat
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass, field
7
+ from typing import Final
8
+
9
+ ENDPOINT: Final = "FLUIDATTACKS_AGENT_ENDPOINT"
10
+ TOKEN: Final = "FLUIDATTACKS_AGENT_TOKEN" # noqa: S105
11
+ TOKEN_FILE: Final = "FLUIDATTACKS_AGENT_TOKEN_FILE" # noqa: S105
12
+ GROUP: Final = "FLUIDATTACKS_AGENT_GROUP"
13
+ WORKLOAD: Final = "FLUIDATTACKS_AGENT_WORKLOAD"
14
+
15
+ # evidence about what a workload runs is not something to hand to a listener
16
+ # that cannot prove who it is
17
+ SCHEME: Final = "https://"
18
+
19
+ # a value the workload names, and a file it points at: both are bounded because
20
+ # both are read into a process that may not spend the memory
21
+ MAX_SETTING: Final = 4096
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class Secret:
26
+ """A credential that no repr, no log and no traceback can spell."""
27
+
28
+ # out of the repr rather than out of reach: nothing stops the one place
29
+ # that spends it from reading it, and slots keep it out of a logger's walk
30
+ held: str = field(repr=False)
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class Delivery:
35
+ """Where reports go, and what the workload says it is."""
36
+
37
+ endpoint: str
38
+ token: Secret
39
+ group: str | None
40
+ workload: str | None
41
+
42
+
43
+ def _readable(named: str) -> bool:
44
+ # all of these travel in a request, where a byte above ascii has to be
45
+ # encoded by whatever carries it and a control byte ends the line early
46
+ return bool(named) and len(named) <= MAX_SETTING and named.isascii() and named.isprintable()
47
+
48
+
49
+ def _setting(held: Mapping[str, str], key: str) -> str | None:
50
+ value = held.get(key)
51
+ if value is None:
52
+ return None
53
+ named = value.strip()
54
+ return named if _readable(named) else None
55
+
56
+
57
+ def _token(held: Mapping[str, str]) -> Secret | None:
58
+ # not _setting: a path is not going into a request, so what a header can
59
+ # carry is not the question to ask of one
60
+ path = held.get(TOKEN_FILE, "").strip()
61
+ if not path:
62
+ # a blank is what a chart renders for a setting nobody filled in, so it
63
+ # names nothing and the variable is still the place to look
64
+ return _credential(held.get(TOKEN))
65
+ if len(path) > MAX_SETTING:
66
+ return None
67
+ return _held(path)
68
+
69
+
70
+ def _held(path: str) -> Secret | None:
71
+ try:
72
+ # a path can name something a read never returns from, and finding that
73
+ # out by waiting is a start the workload does not come back from
74
+ opened = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
75
+ with os.fdopen(opened, encoding="utf-8", errors="strict") as fh:
76
+ if not stat.S_ISREG(os.fstat(fh.fileno()).st_mode):
77
+ return None
78
+ return _credential(fh.read(MAX_SETTING + 1))
79
+ except (OSError, ValueError):
80
+ # never the value, only that there was not one: what is put in a message
81
+ # here is put wherever the host sends what it cannot read
82
+ return None
83
+
84
+
85
+ def _credential(value: str | None) -> Secret | None:
86
+ if value is None:
87
+ return None
88
+ named = value.strip()
89
+ # a credential travels in a header, so a blank inside one would end the line
90
+ # and start something the workload wrote
91
+ return Secret(named) if _readable(named) else None
92
+
93
+
94
+ def asked(environ: Mapping[str, str] | None = None) -> bool:
95
+ """Say whether the workload asked for its reports to be delivered at all."""
96
+ try:
97
+ held = os.environ if environ is None else environ
98
+ return _setting(held, ENDPOINT) is not None or ENDPOINT in held
99
+ except Exception: # noqa: BLE001
100
+ return False
101
+
102
+
103
+ def delivery(environ: Mapping[str, str] | None = None) -> Delivery | None:
104
+ """Read where reports go, or nothing at all if it cannot be read whole."""
105
+ try:
106
+ held = os.environ if environ is None else environ
107
+ endpoint = _setting(held, ENDPOINT)
108
+ token = _token(held)
109
+ if endpoint is None or token is None or not endpoint.startswith(SCHEME):
110
+ return None
111
+ return Delivery(
112
+ endpoint=endpoint,
113
+ token=token,
114
+ group=_setting(held, GROUP),
115
+ workload=_setting(held, WORKLOAD),
116
+ )
117
+ except Exception: # noqa: BLE001
118
+ # a setting that cannot be read is not one to act on
119
+ return None
@@ -0,0 +1,163 @@
1
+ """Leave a report where something can pick it up, or refuse and say which."""
2
+
3
+ import contextlib
4
+ import errno
5
+ import os
6
+ import stat
7
+ from dataclasses import dataclass, field
8
+ from typing import Final, Protocol
9
+
10
+ # the path is a contract with the agent that drains it
11
+ REPORT_DIR: Final = "/tmp/.watches-exec" # noqa: S108
12
+ REPORT_SUFFIX: Final = ".report"
13
+ STAGE_SUFFIX: Final = ".tmp"
14
+
15
+ # reports of its own, bytes any report holds, and entries it will walk to count
16
+ MAX_OUTSTANDING: Final = 64
17
+ MAX_OUTSTANDING_BYTES: Final = 8 << 20
18
+ MAX_ENTRIES: Final = 4096
19
+
20
+ SHARED: Final = 0o077
21
+
22
+
23
+ class Stalled(OSError):
24
+ """Nothing was taken, and what was not taken is still owed."""
25
+
26
+
27
+ class Sink(Protocol):
28
+ """Somewhere a report can go, which may refuse it and must never lie."""
29
+
30
+ def write(self, text: str) -> None: ...
31
+
32
+
33
+ def ours(status: os.stat_result) -> bool:
34
+ """Say whether what the status describes is ours alone to write."""
35
+ return status.st_uid == os.geteuid() and not status.st_mode & SHARED
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class Held:
40
+ """What one directory entry costs, and whether the sink left it there."""
41
+
42
+ size: int
43
+ mine: bool
44
+
45
+
46
+ def _held(entry: os.DirEntry[str], stem: str) -> Held:
47
+ if not entry.name.endswith(REPORT_SUFFIX):
48
+ return Held(size=0, mine=False)
49
+ try:
50
+ status = entry.stat(follow_symlinks=False)
51
+ except OSError:
52
+ return Held(size=0, mine=False)
53
+ if not stat.S_ISREG(status.st_mode):
54
+ return Held(size=0, mine=False)
55
+ return Held(size=status.st_size, mine=entry.name.startswith(stem))
56
+
57
+
58
+ class Holding:
59
+ """A sink for a workload that asked for delivery, which nothing yet does."""
60
+
61
+ def write(self, text: str) -> None:
62
+ """Take nothing, and be counted for it."""
63
+ raise Stalled(errno.ENOSYS, "nothing delivers yet", len(text))
64
+
65
+
66
+ @dataclass
67
+ class FileSink:
68
+ """Reports left in a directory of our own for the agent to drain."""
69
+
70
+ directory: str | os.PathLike[str] = REPORT_DIR
71
+ written: int = 0
72
+ # so a report of ours cannot be named by anything that did not write it,
73
+ # and a reused pid never counts its predecessor's against itself
74
+ token: str = field(default_factory=lambda: os.urandom(8).hex())
75
+
76
+ def write(self, text: str) -> None:
77
+ """Take a report, unless what is already here says to stop."""
78
+ directory = self._open_directory()
79
+ try:
80
+ # installed on its own nothing drains this, so the ceiling is ours
81
+ if self._piled(directory):
82
+ raise Stalled(errno.ENOSPC, "reports outstanding")
83
+ self._write_into(directory, text)
84
+ finally:
85
+ os.close(directory)
86
+
87
+ def _open_directory(self) -> int:
88
+ # every way this can fail is a flush that did not land, and one that
89
+ # said so as anything else was a mute nothing counted: a directory this
90
+ # cannot use costs an attacker one bit of its mode
91
+ try:
92
+ with contextlib.suppress(FileExistsError):
93
+ os.mkdir(self.directory, 0o700) # noqa: PTH102
94
+ handle = os.open(self.directory, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
95
+ except OSError as refused:
96
+ raise Stalled(errno.EACCES, "no directory", str(self.directory)) from refused
97
+ # the mode a directory was made with says nothing about one that was
98
+ # already there, and the descriptor is what the answer has to be about
99
+ if ours(os.fstat(handle)):
100
+ return handle
101
+ os.close(handle)
102
+ raise Stalled(errno.EACCES, "not ours", str(self.directory))
103
+
104
+ def _piled(self, directory: int) -> bool:
105
+ mine = 0
106
+ bytes_held = 0
107
+ walked = 0
108
+ with os.scandir(directory) as entries:
109
+ for entry in entries:
110
+ walked += 1
111
+ # too full to keep writing into, whoever filled it
112
+ if walked > MAX_ENTRIES:
113
+ return True
114
+ held = _held(entry, self._stem())
115
+ bytes_held += held.size
116
+ mine += int(held.mine)
117
+ if mine >= MAX_OUTSTANDING or bytes_held >= MAX_OUTSTANDING_BYTES:
118
+ return True
119
+ return False
120
+
121
+ def _stem(self) -> str:
122
+ return f"{os.getpid()}-{self.token}-"
123
+
124
+ def _write_into(self, directory: int, text: str) -> None:
125
+ payload = text.encode()
126
+ stem = f"{self._stem()}{self.written}"
127
+ staged = stem + STAGE_SUFFIX
128
+ # spent the moment it is tried: what sits under a name is not always
129
+ # ours to clear, and coming back to one forever is how one silences us
130
+ self.written += 1
131
+ try:
132
+ self._stage(directory, staged, payload)
133
+ os.replace(
134
+ staged,
135
+ stem + REPORT_SUFFIX,
136
+ src_dir_fd=directory,
137
+ dst_dir_fd=directory,
138
+ )
139
+ except OSError as refused:
140
+ # nothing will ever read what a failed attempt staged, and the name
141
+ # it holds counts against the entries this is willing to walk
142
+ with contextlib.suppress(OSError):
143
+ os.unlink(staged, dir_fd=directory)
144
+ raise Stalled(errno.ENOSPC, "not taken", staged) from refused
145
+
146
+ def _stage(self, directory: int, name: str, payload: bytes) -> None:
147
+ handle = self._create(directory, name)
148
+ try:
149
+ written = os.write(handle, payload)
150
+ finally:
151
+ os.close(handle)
152
+ # os.write does not raise on a partial write, and the statement is the
153
+ # last line, so a truncation takes it first
154
+ if written != len(payload):
155
+ raise OSError(errno.ENOSPC, "short write", name)
156
+
157
+ def _create(self, directory: int, name: str) -> int:
158
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
159
+ try:
160
+ return os.open(name, flags, 0o600, dir_fd=directory)
161
+ except FileExistsError:
162
+ os.unlink(name, dir_fd=directory)
163
+ return os.open(name, flags, 0o600, dir_fd=directory)