worktree-shield 0.1.0__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.
- worktree_shield/__init__.py +8 -0
- worktree_shield/__main__.py +6 -0
- worktree_shield/cli.py +377 -0
- worktree_shield/collectors/__init__.py +1 -0
- worktree_shield/collectors/base.py +63 -0
- worktree_shield/collectors/compose.py +315 -0
- worktree_shield/collectors/envfiles.py +130 -0
- worktree_shield/collectors/layout.py +106 -0
- worktree_shield/collectors/node.py +87 -0
- worktree_shield/collectors/procfile.py +134 -0
- worktree_shield/collectors/python.py +128 -0
- worktree_shield/collectors/runtime.py +167 -0
- worktree_shield/collectors/static.py +25 -0
- worktree_shield/collectors/vite.py +133 -0
- worktree_shield/collisions.py +115 -0
- worktree_shield/config.py +188 -0
- worktree_shield/exit_codes.py +22 -0
- worktree_shield/files.py +70 -0
- worktree_shield/git/__init__.py +1 -0
- worktree_shield/git/discovery.py +85 -0
- worktree_shield/git/porcelain.py +48 -0
- worktree_shield/models.py +356 -0
- worktree_shield/names.py +83 -0
- worktree_shield/orchestrator.py +121 -0
- worktree_shield/overlap.py +175 -0
- worktree_shield/parsers/__init__.py +1 -0
- worktree_shield/parsers/commands.py +158 -0
- worktree_shield/parsers/compose.py +148 -0
- worktree_shield/parsers/dburl.py +156 -0
- worktree_shield/parsers/dotenv.py +91 -0
- worktree_shield/parsers/interpolate.py +142 -0
- worktree_shield/parsers/jsonc.py +48 -0
- worktree_shield/parsers/jstext.py +142 -0
- worktree_shield/plan.py +175 -0
- worktree_shield/privacy.py +237 -0
- worktree_shield/reports/__init__.py +1 -0
- worktree_shield/reports/findings.py +176 -0
- worktree_shield/reports/json_report.py +61 -0
- worktree_shield/reports/markdown.py +103 -0
- worktree_shield/reports/sarif.py +116 -0
- worktree_shield/reports/terminal.py +327 -0
- worktree_shield/rules/__init__.py +1 -0
- worktree_shield/rules/catalog.py +28 -0
- worktree_shield/rules/common.py +61 -0
- worktree_shield/rules/context.py +201 -0
- worktree_shield/rules/engine.py +60 -0
- worktree_shield/rules/explain.py +27 -0
- worktree_shield/rules/rules.md +112 -0
- worktree_shield/rules/runtime.py +164 -0
- worktree_shield/rules/shared.py +216 -0
- worktree_shield/runner.py +273 -0
- worktree_shield-0.1.0.dist-info/METADATA +151 -0
- worktree_shield-0.1.0.dist-info/RECORD +56 -0
- worktree_shield-0.1.0.dist-info/WHEEL +4 -0
- worktree_shield-0.1.0.dist-info/entry_points.txt +4 -0
- worktree_shield-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""Typed facts and findings shared by collectors, rules and renderers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
|
|
10
|
+
SCHEMA_VERSION = 1
|
|
11
|
+
"""Version of the snapshot/JSON report layout; bumped only for incompatible changes."""
|
|
12
|
+
|
|
13
|
+
FINDING_ID = re.compile(r"WTS-[A-Z]+-[0-9]{3}")
|
|
14
|
+
"""Finding codes are part of the public contract: ``WTS-<AREA>-<NNN>``, e.g. ``WTS-PORT-001``."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Status(StrEnum):
|
|
18
|
+
"""Outcome of one check.
|
|
19
|
+
|
|
20
|
+
Only ``PASS`` means that a check ran and found nothing shared. A check that could not
|
|
21
|
+
run is ``UNKNOWN``; one that was deliberately skipped (runtime inspection nobody asked
|
|
22
|
+
for) is ``NOT_TESTED``; one that does not apply to this repository is
|
|
23
|
+
``NOT_APPLICABLE``.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
PASS = "pass" # noqa: S105 - a status name, not a password
|
|
27
|
+
WARN = "warn"
|
|
28
|
+
FAIL = "fail"
|
|
29
|
+
UNKNOWN = "unknown"
|
|
30
|
+
NOT_APPLICABLE = "not_applicable"
|
|
31
|
+
NOT_TESTED = "not_tested"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Severity(StrEnum):
|
|
35
|
+
"""How much a finding matters, independent of certainty."""
|
|
36
|
+
|
|
37
|
+
INFO = "info"
|
|
38
|
+
LOW = "low"
|
|
39
|
+
MEDIUM = "medium"
|
|
40
|
+
HIGH = "high"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Confidence(StrEnum):
|
|
44
|
+
"""How well the evidence supports a finding."""
|
|
45
|
+
|
|
46
|
+
LOW = "low"
|
|
47
|
+
MEDIUM = "medium"
|
|
48
|
+
HIGH = "high"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Certainty(StrEnum):
|
|
52
|
+
"""How sure a collision is, from the kind of evidence behind it.
|
|
53
|
+
|
|
54
|
+
Static evidence (two configs declare the same port) is ``PROBABLE`` at most: it only
|
|
55
|
+
matters if both run at once on the same host. ``CONFIRMED`` needs a running process
|
|
56
|
+
attributed to a worktree by its working directory. ``POSSIBLE`` covers looser matches
|
|
57
|
+
(``localhost`` against ``127.0.0.1``); ``UNKNOWN`` means it could not be decided.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
CONFIRMED = "confirmed"
|
|
61
|
+
PROBABLE = "probable"
|
|
62
|
+
POSSIBLE = "possible"
|
|
63
|
+
UNKNOWN = "unknown"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True, slots=True)
|
|
67
|
+
class SourceRef:
|
|
68
|
+
"""Where a claim comes from: a file (relative to its worktree) and, if known, a line."""
|
|
69
|
+
|
|
70
|
+
worktree: str
|
|
71
|
+
path: str
|
|
72
|
+
line: int | None = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True, slots=True)
|
|
76
|
+
class Evidence:
|
|
77
|
+
"""One observed fact that a finding relies on; ``value`` is ``None`` when missing."""
|
|
78
|
+
|
|
79
|
+
source: str
|
|
80
|
+
key: str
|
|
81
|
+
value: str | None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True, slots=True)
|
|
85
|
+
class Finding:
|
|
86
|
+
"""A diagnosis with everything a user needs to judge and verify it.
|
|
87
|
+
|
|
88
|
+
``resource_key`` is the normalised shared resource (``tcp://*:5173``,
|
|
89
|
+
``db://postgres/localhost:5432/app_dev``…), ``worktrees`` the worktrees involved and
|
|
90
|
+
``sources`` where each claim was found. ``certainty`` applies to collisions.
|
|
91
|
+
``verify_command`` re-checks the result after acting on ``recommended_action``;
|
|
92
|
+
``caused_by`` names the finding this one follows from, so reports show one root
|
|
93
|
+
cause instead of a cascade.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
id: str
|
|
97
|
+
status: Status
|
|
98
|
+
severity: Severity
|
|
99
|
+
confidence: Confidence
|
|
100
|
+
summary: str
|
|
101
|
+
reason: str
|
|
102
|
+
resource_key: str | None = None
|
|
103
|
+
certainty: Certainty | None = None
|
|
104
|
+
worktrees: tuple[str, ...] = ()
|
|
105
|
+
sources: tuple[SourceRef, ...] = ()
|
|
106
|
+
evidence: tuple[Evidence, ...] = ()
|
|
107
|
+
recommended_action: str | None = None
|
|
108
|
+
verify_command: str | None = None
|
|
109
|
+
limitations: tuple[str, ...] = ()
|
|
110
|
+
caused_by: str | None = None
|
|
111
|
+
|
|
112
|
+
def __post_init__(self) -> None:
|
|
113
|
+
if not FINDING_ID.fullmatch(self.id):
|
|
114
|
+
raise ValueError(f"finding id {self.id!r} does not match WTS-<AREA>-<NNN>")
|
|
115
|
+
if self.caused_by is not None and not FINDING_ID.fullmatch(self.caused_by):
|
|
116
|
+
raise ValueError(f"caused_by {self.caused_by!r} does not match WTS-<AREA>-<NNN>")
|
|
117
|
+
if not self.summary.strip():
|
|
118
|
+
raise ValueError(f"finding {self.id} has an empty summary")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class FailureKind(StrEnum):
|
|
122
|
+
"""Why a probe produced no usable facts."""
|
|
123
|
+
|
|
124
|
+
NOT_FOUND = "not_found"
|
|
125
|
+
PERMISSION_DENIED = "permission_denied"
|
|
126
|
+
TIMEOUT = "timeout"
|
|
127
|
+
NONZERO_EXIT = "nonzero_exit"
|
|
128
|
+
MALFORMED_OUTPUT = "malformed_output"
|
|
129
|
+
OS_ERROR = "os_error"
|
|
130
|
+
PROBE_ERROR = "probe_error"
|
|
131
|
+
"""The probe ran, but reported that part of its own work failed."""
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass(frozen=True, slots=True)
|
|
135
|
+
class ProbeFailure:
|
|
136
|
+
"""A probe that could not deliver its facts; collectors return these instead of raising."""
|
|
137
|
+
|
|
138
|
+
probe: str
|
|
139
|
+
kind: FailureKind
|
|
140
|
+
detail: str
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def utc_timestamp() -> str:
|
|
144
|
+
"""Current time as an ISO 8601 UTC string with second precision."""
|
|
145
|
+
return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass(frozen=True, slots=True)
|
|
149
|
+
class Worktree:
|
|
150
|
+
"""One entry of ``git worktree list --porcelain``.
|
|
151
|
+
|
|
152
|
+
``branch`` is the full ref (``refs/heads/feature``) or ``None`` when detached or
|
|
153
|
+
bare. ``locked`` and ``prunable`` carry Git's reason, ``""`` when it gave none and
|
|
154
|
+
``None`` when the flag is not set. A prunable worktree is listed but its directory
|
|
155
|
+
is gone; it is reported, never pruned.
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
path: str
|
|
159
|
+
head: str | None = None
|
|
160
|
+
branch: str | None = None
|
|
161
|
+
bare: bool = False
|
|
162
|
+
detached: bool = False
|
|
163
|
+
locked: str | None = None
|
|
164
|
+
prunable: str | None = None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@dataclass(frozen=True, slots=True)
|
|
168
|
+
class Repository:
|
|
169
|
+
"""The repository the scan started in, and every worktree that shares its ``.git``.
|
|
170
|
+
|
|
171
|
+
``common_dir`` is the shared Git directory, ``current`` the top of the worktree the
|
|
172
|
+
scan started in (``None`` in a bare repository), ``worktrees`` Git's own list of all
|
|
173
|
+
worktrees attached to ``common_dir``, nothing found by searching the disk.
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
common_dir: str
|
|
177
|
+
current: str | None
|
|
178
|
+
bare: bool
|
|
179
|
+
worktrees: tuple[Worktree, ...]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class PolicyLevel(StrEnum):
|
|
183
|
+
"""What a kind of shared resource means for ``--strict``.
|
|
184
|
+
|
|
185
|
+
``allow`` reports it as information, ``warn`` as a warning, ``deny`` as a failure,
|
|
186
|
+
which makes ``--strict`` exit with 1.
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
ALLOW = "allow"
|
|
190
|
+
WARN = "warn"
|
|
191
|
+
DENY = "deny"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@dataclass(frozen=True, slots=True)
|
|
195
|
+
class Policy:
|
|
196
|
+
"""The effective policy: built-in defaults, then the repository's file, then flags.
|
|
197
|
+
|
|
198
|
+
``allow_ports`` are shared *infrastructure endpoints* that every worktree may
|
|
199
|
+
connect to (one Postgres on 5432 for all of them); they never excuse two processes
|
|
200
|
+
trying to *listen* on the same port. ``source`` is the policy file that was read,
|
|
201
|
+
``None`` for the defaults alone.
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
include_runtime: bool = True
|
|
205
|
+
include_env_files: bool = False
|
|
206
|
+
max_file_bytes: int = 262_144
|
|
207
|
+
shared_host_ports: PolicyLevel = PolicyLevel.DENY
|
|
208
|
+
shared_database: PolicyLevel = PolicyLevel.WARN
|
|
209
|
+
shared_compose_projects: PolicyLevel = PolicyLevel.WARN
|
|
210
|
+
fixed_container_names: PolicyLevel = PolicyLevel.WARN
|
|
211
|
+
shared_absolute_paths: PolicyLevel = PolicyLevel.WARN
|
|
212
|
+
allow_ports: tuple[int, ...] = ()
|
|
213
|
+
allow_paths: tuple[str, ...] = ()
|
|
214
|
+
source: str | None = None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class ResourceKind(StrEnum):
|
|
218
|
+
"""What a worktree claims.
|
|
219
|
+
|
|
220
|
+
A *listen* port is one its own process binds; a *client endpoint* is a server it
|
|
221
|
+
connects to (a shared Postgres on 5432). Two listeners on one port collide; two
|
|
222
|
+
clients of one server do not, which is why they are different kinds.
|
|
223
|
+
"""
|
|
224
|
+
|
|
225
|
+
LISTEN_PORT = "listen_port"
|
|
226
|
+
CLIENT_ENDPOINT = "client_endpoint"
|
|
227
|
+
DATABASE = "database"
|
|
228
|
+
COMPOSE_PROJECT = "compose_project"
|
|
229
|
+
CONTAINER_NAME = "container_name"
|
|
230
|
+
VOLUME = "volume"
|
|
231
|
+
ABSOLUTE_PATH = "absolute_path"
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class Observation(StrEnum):
|
|
235
|
+
"""How a claim was found: read from configuration, or seen on the running system."""
|
|
236
|
+
|
|
237
|
+
STATIC = "static"
|
|
238
|
+
RUNTIME = "runtime"
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@dataclass(frozen=True, slots=True)
|
|
242
|
+
class ResourceClaim:
|
|
243
|
+
"""One resource a worktree declares or uses, with where it was found.
|
|
244
|
+
|
|
245
|
+
``key`` identifies the resource (``tcp://*:5173``, ``db://postgres/localhost:5432/app``);
|
|
246
|
+
claims of different worktrees with overlapping keys are collision candidates.
|
|
247
|
+
``detail`` is what was read, already reduced to safe facts: never a password or the
|
|
248
|
+
value of a variable no rule needs. ``strict`` says whether the program fails when
|
|
249
|
+
the port is taken (``True``) or moves to another one (``False``); ``None`` when the
|
|
250
|
+
configuration does not say.
|
|
251
|
+
"""
|
|
252
|
+
|
|
253
|
+
kind: ResourceKind
|
|
254
|
+
key: str
|
|
255
|
+
worktree: str
|
|
256
|
+
source: SourceRef
|
|
257
|
+
detail: str
|
|
258
|
+
observation: Observation = Observation.STATIC
|
|
259
|
+
strict: bool | None = None
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@dataclass(frozen=True, slots=True)
|
|
263
|
+
class SkippedFile:
|
|
264
|
+
"""A configuration file that was found but not read, and why (too large, a link
|
|
265
|
+
out of the worktree, not UTF-8…). A skipped file can hide a collision."""
|
|
266
|
+
|
|
267
|
+
source: SourceRef
|
|
268
|
+
reason: str
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@dataclass(frozen=True, slots=True)
|
|
272
|
+
class Collision:
|
|
273
|
+
"""Two or more worktrees claiming the same resource.
|
|
274
|
+
|
|
275
|
+
``key`` names the resource (the first of the overlapping keys), ``claims`` are all
|
|
276
|
+
claims involved, ``certainty`` says how sure the overlap is and ``reason`` why, in a
|
|
277
|
+
sentence a report can show.
|
|
278
|
+
"""
|
|
279
|
+
|
|
280
|
+
kind: ResourceKind
|
|
281
|
+
key: str
|
|
282
|
+
certainty: Certainty
|
|
283
|
+
reason: str
|
|
284
|
+
claims: tuple[ResourceClaim, ...]
|
|
285
|
+
|
|
286
|
+
@property
|
|
287
|
+
def worktrees(self) -> tuple[str, ...]:
|
|
288
|
+
"""The worktrees involved, in the order of their first claim."""
|
|
289
|
+
return tuple(dict.fromkeys(claim.worktree for claim in self.claims))
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
@dataclass(frozen=True, slots=True)
|
|
293
|
+
class UnreadableValue:
|
|
294
|
+
"""A value that names a resource but could not be read safely, such as a database
|
|
295
|
+
URL whose host is uncertain. ``name`` is the variable or field, never its value."""
|
|
296
|
+
|
|
297
|
+
kind: ResourceKind
|
|
298
|
+
source: SourceRef
|
|
299
|
+
name: str
|
|
300
|
+
reason: str
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@dataclass(frozen=True, slots=True)
|
|
304
|
+
class Listener:
|
|
305
|
+
"""A listening socket that could not be tied to a worktree, kept when one of the
|
|
306
|
+
worktrees configures its port. ``note`` says why it is not attributed; ``process``
|
|
307
|
+
is the process name only, never its command line."""
|
|
308
|
+
|
|
309
|
+
protocol: str
|
|
310
|
+
address: str
|
|
311
|
+
port: int
|
|
312
|
+
pid: int | None
|
|
313
|
+
process: str | None
|
|
314
|
+
note: str
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
@dataclass(frozen=True, slots=True)
|
|
318
|
+
class RuntimeFacts:
|
|
319
|
+
"""What the runtime observation saw beyond the claims it made: listeners on
|
|
320
|
+
configured ports that belong to no worktree, and what limited the view."""
|
|
321
|
+
|
|
322
|
+
unattributed: tuple[Listener, ...] = ()
|
|
323
|
+
limitations: tuple[str, ...] = ()
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
@dataclass(frozen=True, slots=True)
|
|
327
|
+
class RedactionCount:
|
|
328
|
+
"""How many values of one kind (``home_path``, ``secret_value``…) were redacted."""
|
|
329
|
+
|
|
330
|
+
kind: str
|
|
331
|
+
count: int
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
@dataclass(frozen=True, slots=True)
|
|
335
|
+
class Snapshot:
|
|
336
|
+
"""Everything one scan observed and concluded; the input of every renderer.
|
|
337
|
+
|
|
338
|
+
``repository`` is ``None`` when discovery failed (see ``probe_failures``). ``policy``
|
|
339
|
+
is the effective policy, ``claims`` every resource found in the worktrees,
|
|
340
|
+
``skipped`` the configuration files that could not be read and ``unreadable`` the
|
|
341
|
+
values that name a resource without saying safely which one. ``runtime`` is
|
|
342
|
+
``None`` when running processes were not looked at.
|
|
343
|
+
"""
|
|
344
|
+
|
|
345
|
+
tool_version: str
|
|
346
|
+
created_at: str
|
|
347
|
+
schema_version: int = SCHEMA_VERSION
|
|
348
|
+
repository: Repository | None = None
|
|
349
|
+
policy: Policy | None = None
|
|
350
|
+
claims: tuple[ResourceClaim, ...] = ()
|
|
351
|
+
skipped: tuple[SkippedFile, ...] = ()
|
|
352
|
+
unreadable: tuple[UnreadableValue, ...] = ()
|
|
353
|
+
runtime: RuntimeFacts | None = None
|
|
354
|
+
probe_failures: tuple[ProbeFailure, ...] = ()
|
|
355
|
+
findings: tuple[Finding, ...] = ()
|
|
356
|
+
redactions: tuple[RedactionCount, ...] = ()
|
worktree_shield/names.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""What a variable name says about its value, and which literal values are plain hosts.
|
|
2
|
+
|
|
3
|
+
Shared by every collector, so ``DB_PORT`` means the same thing in a ``.env`` file and
|
|
4
|
+
in an npm script.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import ipaddress
|
|
10
|
+
|
|
11
|
+
from worktree_shield.models import ResourceKind
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _words(text: str) -> frozenset[str]:
|
|
15
|
+
return frozenset(text.split())
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# A *_PORT with one of these words names a server the app connects to, not one it runs.
|
|
19
|
+
CLIENT_WORDS = _words(
|
|
20
|
+
"DB DATABASE POSTGRES POSTGRESQL PG MYSQL MARIADB REDIS MONGO MONGODB RABBITMQ AMQP SMTP "
|
|
21
|
+
"MAIL EMAIL ELASTICSEARCH ELASTIC OPENSEARCH KAFKA MEMCACHED MINIO S3 NATS CLICKHOUSE"
|
|
22
|
+
)
|
|
23
|
+
_PATH_WORDS = _words(
|
|
24
|
+
"DIR DIRECTORY PATH ROOT FOLDER CACHE OUT OUTPUT OUTDIR STORAGE UPLOAD UPLOADS DATA LOG "
|
|
25
|
+
"LOGS TMP TEMP TMPDIR FILE LOCATION"
|
|
26
|
+
)
|
|
27
|
+
# Read-only by nature: search paths, installations, certificates and configuration.
|
|
28
|
+
_SEARCH_PATHS = _words(
|
|
29
|
+
"PATH PYTHONPATH NODE_PATH LD_LIBRARY_PATH DYLD_LIBRARY_PATH MANPATH CLASSPATH GOPATH "
|
|
30
|
+
"PKG_CONFIG_PATH CDPATH"
|
|
31
|
+
)
|
|
32
|
+
_READ_ONLY_WORDS = _words("HOME CERT CERTS KEY CA SSL TLS CONFIG CONF ETC")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def port_variable(name: str) -> ResourceKind | None:
|
|
36
|
+
"""``LISTEN_PORT`` for ``PORT`` and ``API_PORT``, ``CLIENT_ENDPOINT`` for ``DB_PORT``
|
|
37
|
+
and ``PGPORT``, ``None`` for a name that holds no port."""
|
|
38
|
+
upper = name.upper()
|
|
39
|
+
if upper == "PGPORT":
|
|
40
|
+
return ResourceKind.CLIENT_ENDPOINT
|
|
41
|
+
if upper != "PORT" and not upper.endswith("_PORT"):
|
|
42
|
+
return None
|
|
43
|
+
if CLIENT_WORDS.intersection(upper.split("_")):
|
|
44
|
+
return ResourceKind.CLIENT_ENDPOINT
|
|
45
|
+
return ResourceKind.LISTEN_PORT
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def host_variable(port_name: str) -> str:
|
|
49
|
+
"""The host variable that goes with a port variable: ``API_PORT`` → ``API_HOST``."""
|
|
50
|
+
upper = port_name.upper()
|
|
51
|
+
return "PGHOST" if upper == "PGPORT" else upper.removesuffix("PORT") + "HOST"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def is_mutable_path_variable(name: str) -> bool:
|
|
55
|
+
"""Whether a variable that holds a path is likely written to (``UPLOAD_DIR``,
|
|
56
|
+
``CACHE_PATH``) rather than read (``PYTHONPATH``, ``JAVA_HOME``, ``SSL_CERT_FILE``)."""
|
|
57
|
+
upper = name.upper()
|
|
58
|
+
if upper in _SEARCH_PATHS:
|
|
59
|
+
return False
|
|
60
|
+
words = set(upper.split("_"))
|
|
61
|
+
return bool(words & _PATH_WORDS) and not words & _READ_ONLY_WORDS
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def port_number(text: str) -> int | None:
|
|
65
|
+
"""A port written as plain ASCII digits, 1 to 65535."""
|
|
66
|
+
if not text.isascii() or not text.isdigit() or len(text) > 5:
|
|
67
|
+
return None
|
|
68
|
+
port = int(text)
|
|
69
|
+
return port if 1 <= port <= 65535 else None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def literal_host(text: str) -> str | None:
|
|
73
|
+
"""A host name or address as written, lower-cased; ``None`` for anything else
|
|
74
|
+
(references, spaces, non-ASCII names)."""
|
|
75
|
+
value = text.strip().lower()
|
|
76
|
+
try:
|
|
77
|
+
return str(ipaddress.ip_address(value.strip("[]")))
|
|
78
|
+
except ValueError:
|
|
79
|
+
pass
|
|
80
|
+
labels = value.split(".")
|
|
81
|
+
if value.isascii() and all(label and label.replace("-", "").isalnum() for label in labels):
|
|
82
|
+
return value
|
|
83
|
+
return None
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Runs the collectors and assembles the snapshot; no diagnosis happens here."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import replace
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import psutil
|
|
10
|
+
|
|
11
|
+
from worktree_shield import __version__
|
|
12
|
+
from worktree_shield.collectors.base import Collected, Scope
|
|
13
|
+
from worktree_shield.collectors.runtime import ProcessTable, PsutilTable, observe_runtime
|
|
14
|
+
from worktree_shield.collectors.static import collect_worktree
|
|
15
|
+
from worktree_shield.config import apply_overrides, policy_of
|
|
16
|
+
from worktree_shield.git.discovery import discover
|
|
17
|
+
from worktree_shield.models import (
|
|
18
|
+
ResourceClaim,
|
|
19
|
+
ResourceKind,
|
|
20
|
+
RuntimeFacts,
|
|
21
|
+
SkippedFile,
|
|
22
|
+
Snapshot,
|
|
23
|
+
SourceRef,
|
|
24
|
+
utc_timestamp,
|
|
25
|
+
)
|
|
26
|
+
from worktree_shield.runner import CommandRunner
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def inspect_worktrees(runner: CommandRunner, start: Path) -> Snapshot:
|
|
30
|
+
"""The repository containing ``start`` and its worktrees.
|
|
31
|
+
|
|
32
|
+
Raises ``NotARepositoryError`` when ``start`` is not inside a repository.
|
|
33
|
+
"""
|
|
34
|
+
repository, failures = discover(runner, start)
|
|
35
|
+
return Snapshot(
|
|
36
|
+
tool_version=__version__,
|
|
37
|
+
created_at=utc_timestamp(),
|
|
38
|
+
repository=repository,
|
|
39
|
+
probe_failures=failures,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_PORT = re.compile(r"([a-z]+)://.*:([0-9]+)")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def inspect_resources(
|
|
47
|
+
runner: CommandRunner,
|
|
48
|
+
start: Path,
|
|
49
|
+
*,
|
|
50
|
+
include_env_files: bool | None = None,
|
|
51
|
+
include_runtime: bool | None = None,
|
|
52
|
+
table: ProcessTable | None = None,
|
|
53
|
+
) -> Snapshot:
|
|
54
|
+
"""The worktrees of the repository, the effective policy and every resource the
|
|
55
|
+
worktrees declare in their configuration or, with ``include_runtime``, hold with a
|
|
56
|
+
running server.
|
|
57
|
+
|
|
58
|
+
Bare and prunable worktrees have no files to read and are left out. Raises
|
|
59
|
+
``NotARepositoryError`` outside a repository and ``PolicyError`` for a policy file
|
|
60
|
+
that cannot be used.
|
|
61
|
+
"""
|
|
62
|
+
snapshot = inspect_worktrees(runner, start)
|
|
63
|
+
if snapshot.repository is None:
|
|
64
|
+
return snapshot
|
|
65
|
+
policy = apply_overrides(
|
|
66
|
+
policy_of(snapshot.repository),
|
|
67
|
+
include_env_files=include_env_files,
|
|
68
|
+
include_runtime=include_runtime,
|
|
69
|
+
)
|
|
70
|
+
out = Collected()
|
|
71
|
+
with_files: list[str] = []
|
|
72
|
+
for worktree in snapshot.repository.worktrees:
|
|
73
|
+
if worktree.bare or worktree.prunable is not None:
|
|
74
|
+
continue
|
|
75
|
+
root = Path(worktree.path)
|
|
76
|
+
if not root.is_dir():
|
|
77
|
+
reason = "is listed by Git but is not a directory"
|
|
78
|
+
out.skipped.append(SkippedFile(SourceRef(worktree.path, "."), reason))
|
|
79
|
+
continue
|
|
80
|
+
with_files.append(worktree.path)
|
|
81
|
+
collect_worktree(Scope(root, worktree.path, policy.max_file_bytes), policy, out)
|
|
82
|
+
runtime = None
|
|
83
|
+
if policy.include_runtime:
|
|
84
|
+
runtime = _observe(table or PsutilTable(), with_files, out.claims)
|
|
85
|
+
return replace(
|
|
86
|
+
snapshot,
|
|
87
|
+
policy=policy,
|
|
88
|
+
claims=tuple(out.claims),
|
|
89
|
+
skipped=tuple(out.skipped),
|
|
90
|
+
unreadable=tuple(out.unreadable),
|
|
91
|
+
runtime=runtime,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _observe(
|
|
96
|
+
table: ProcessTable, worktrees: list[str], claims: list[ResourceClaim]
|
|
97
|
+
) -> RuntimeFacts:
|
|
98
|
+
"""Add the runtime claims to ``claims``; keep the unattributed listeners only on
|
|
99
|
+
ports some worktree configures, since only those can block one."""
|
|
100
|
+
try:
|
|
101
|
+
found, unattributed, limitations = observe_runtime(table, worktrees)
|
|
102
|
+
except (psutil.Error, OSError) as error:
|
|
103
|
+
return RuntimeFacts(limitations=(f"running processes could not be listed ({error})",))
|
|
104
|
+
configured = set()
|
|
105
|
+
for claim in claims:
|
|
106
|
+
match = _PORT.fullmatch(claim.key)
|
|
107
|
+
if claim.kind is ResourceKind.LISTEN_PORT and match is not None:
|
|
108
|
+
configured.add((match.group(1), int(match.group(2))))
|
|
109
|
+
claims += found
|
|
110
|
+
relevant = tuple(u for u in unattributed if (u.protocol, u.port) in configured)
|
|
111
|
+
return RuntimeFacts(relevant, tuple(limitations))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def diagnose(snapshot: Snapshot) -> Snapshot:
|
|
115
|
+
"""The snapshot with the findings of every built-in rule added."""
|
|
116
|
+
from worktree_shield.rules.catalog import builtin_rules
|
|
117
|
+
from worktree_shield.rules.engine import evaluate
|
|
118
|
+
|
|
119
|
+
if snapshot.repository is None:
|
|
120
|
+
return snapshot
|
|
121
|
+
return replace(snapshot, findings=evaluate(snapshot, builtin_rules()))
|