fluidattacks-agent 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fluidattacks_agent/__init__.py +0 -0
- fluidattacks_agent/batch.py +161 -0
- fluidattacks_agent/deliver.py +178 -0
- fluidattacks_agent/distributions.py +80 -0
- fluidattacks_agent/executions.py +124 -0
- fluidattacks_agent/gate.py +10 -0
- fluidattacks_agent/loads.py +109 -0
- fluidattacks_agent/observer.py +137 -0
- fluidattacks_agent/outbox.py +64 -0
- fluidattacks_agent/patience.py +38 -0
- fluidattacks_agent/post.py +175 -0
- fluidattacks_agent/report.py +286 -0
- fluidattacks_agent/settings.py +119 -0
- fluidattacks_agent/sink.py +163 -0
- fluidattacks_agent/startup.py +439 -0
- fluidattacks_agent/switch.py +22 -0
- fluidattacks_agent-0.1.2.dist-info/METADATA +95 -0
- fluidattacks_agent-0.1.2.dist-info/RECORD +20 -0
- fluidattacks_agent-0.1.2.dist-info/WHEEL +4 -0
- fluidattacks_agent.pth +1 -0
|
@@ -0,0 +1,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)
|