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.
Files changed (56) hide show
  1. worktree_shield/__init__.py +8 -0
  2. worktree_shield/__main__.py +6 -0
  3. worktree_shield/cli.py +377 -0
  4. worktree_shield/collectors/__init__.py +1 -0
  5. worktree_shield/collectors/base.py +63 -0
  6. worktree_shield/collectors/compose.py +315 -0
  7. worktree_shield/collectors/envfiles.py +130 -0
  8. worktree_shield/collectors/layout.py +106 -0
  9. worktree_shield/collectors/node.py +87 -0
  10. worktree_shield/collectors/procfile.py +134 -0
  11. worktree_shield/collectors/python.py +128 -0
  12. worktree_shield/collectors/runtime.py +167 -0
  13. worktree_shield/collectors/static.py +25 -0
  14. worktree_shield/collectors/vite.py +133 -0
  15. worktree_shield/collisions.py +115 -0
  16. worktree_shield/config.py +188 -0
  17. worktree_shield/exit_codes.py +22 -0
  18. worktree_shield/files.py +70 -0
  19. worktree_shield/git/__init__.py +1 -0
  20. worktree_shield/git/discovery.py +85 -0
  21. worktree_shield/git/porcelain.py +48 -0
  22. worktree_shield/models.py +356 -0
  23. worktree_shield/names.py +83 -0
  24. worktree_shield/orchestrator.py +121 -0
  25. worktree_shield/overlap.py +175 -0
  26. worktree_shield/parsers/__init__.py +1 -0
  27. worktree_shield/parsers/commands.py +158 -0
  28. worktree_shield/parsers/compose.py +148 -0
  29. worktree_shield/parsers/dburl.py +156 -0
  30. worktree_shield/parsers/dotenv.py +91 -0
  31. worktree_shield/parsers/interpolate.py +142 -0
  32. worktree_shield/parsers/jsonc.py +48 -0
  33. worktree_shield/parsers/jstext.py +142 -0
  34. worktree_shield/plan.py +175 -0
  35. worktree_shield/privacy.py +237 -0
  36. worktree_shield/reports/__init__.py +1 -0
  37. worktree_shield/reports/findings.py +176 -0
  38. worktree_shield/reports/json_report.py +61 -0
  39. worktree_shield/reports/markdown.py +103 -0
  40. worktree_shield/reports/sarif.py +116 -0
  41. worktree_shield/reports/terminal.py +327 -0
  42. worktree_shield/rules/__init__.py +1 -0
  43. worktree_shield/rules/catalog.py +28 -0
  44. worktree_shield/rules/common.py +61 -0
  45. worktree_shield/rules/context.py +201 -0
  46. worktree_shield/rules/engine.py +60 -0
  47. worktree_shield/rules/explain.py +27 -0
  48. worktree_shield/rules/rules.md +112 -0
  49. worktree_shield/rules/runtime.py +164 -0
  50. worktree_shield/rules/shared.py +216 -0
  51. worktree_shield/runner.py +273 -0
  52. worktree_shield-0.1.0.dist-info/METADATA +151 -0
  53. worktree_shield-0.1.0.dist-info/RECORD +56 -0
  54. worktree_shield-0.1.0.dist-info/WHEEL +4 -0
  55. worktree_shield-0.1.0.dist-info/entry_points.txt +4 -0
  56. worktree_shield-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,116 @@
1
+ """SARIF 2.1.0 output, for code scanning (GitHub and other SARIF viewers).
2
+
3
+ Only findings that need attention and point to a file become results: fail is
4
+ ``error``, warn ``warning``, unknown ``note``. Locations are the claims of the current
5
+ worktree, relative to it, so a viewer of that checkout opens the right line; claims of
6
+ other worktrees become related locations whose message names the worktree. Each result
7
+ has a fingerprint from its rule and resource, stable across runs, so a viewer can tell
8
+ a new collision from one it has seen. Pass a redacted snapshot.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import json
15
+ import re
16
+ from typing import Any
17
+
18
+ from worktree_shield.models import Finding, Snapshot, SourceRef, Status
19
+ from worktree_shield.reports.markdown import PROJECT_URL
20
+ from worktree_shield.rules.catalog import builtin_rules
21
+
22
+ SARIF_SCHEMA = "https://json.schemastore.org/sarif-2.1.0.json"
23
+ _LEVEL = {Status.FAIL: "error", Status.WARN: "warning", Status.UNKNOWN: "note"}
24
+
25
+
26
+ def _anchor(rule_id: str, title: str) -> str:
27
+ """The GitHub anchor of the rule's heading in docs/rules.md."""
28
+ heading = f"{rule_id}: {title}".lower()
29
+ return re.sub(r"[^a-z0-9 -]", "", heading).replace(" ", "-")
30
+
31
+
32
+ def render_sarif(snapshot: Snapshot) -> str:
33
+ """One SARIF run with every rule and a result per located problem."""
34
+ rules = builtin_rules()
35
+ index = {rule.id: i for i, rule in enumerate(rules)}
36
+ current = snapshot.repository.current if snapshot.repository else None
37
+ results = [
38
+ result
39
+ for finding in snapshot.findings
40
+ if finding.status in _LEVEL and finding.sources
41
+ for result in [_result(finding, index[finding.id], current)]
42
+ ]
43
+ document: dict[str, Any] = {
44
+ "$schema": SARIF_SCHEMA,
45
+ "version": "2.1.0",
46
+ "runs": [
47
+ {
48
+ "tool": {
49
+ "driver": {
50
+ "name": "worktree-shield",
51
+ "version": snapshot.tool_version,
52
+ "semanticVersion": snapshot.tool_version,
53
+ "informationUri": PROJECT_URL,
54
+ "rules": [
55
+ {
56
+ "id": rule.id,
57
+ "name": rule.id.replace("-", ""),
58
+ "shortDescription": {"text": rule.title},
59
+ "helpUri": f"{PROJECT_URL}/blob/main/docs/rules.md#"
60
+ + _anchor(rule.id, rule.title),
61
+ }
62
+ for rule in rules
63
+ ],
64
+ }
65
+ },
66
+ "results": results,
67
+ }
68
+ ],
69
+ }
70
+ return json.dumps(document, indent=2, ensure_ascii=False) + "\n"
71
+
72
+
73
+ def _location(source: SourceRef, message: str | None = None) -> dict[str, Any]:
74
+ physical: dict[str, Any] = {"artifactLocation": {"uri": source.path}}
75
+ if source.line is not None:
76
+ physical["region"] = {"startLine": source.line}
77
+ location: dict[str, Any] = {"physicalLocation": physical}
78
+ if message:
79
+ location["message"] = {"text": message}
80
+ return location
81
+
82
+
83
+ def _message(finding: Finding) -> str:
84
+ """Summary, reason and next step: SARIF fixes need file edits, so advice goes here."""
85
+ parts = [f"{finding.summary}.", finding.reason]
86
+ if finding.recommended_action:
87
+ parts.append(f"Next: {finding.recommended_action}")
88
+ return " ".join(parts)
89
+
90
+
91
+ def _result(finding: Finding, rule_index: int, current: str | None) -> dict[str, Any]:
92
+ files = [s for s in finding.sources if not s.path.startswith("pid ")]
93
+ here = [s for s in files if s.worktree == current] or files[:1]
94
+ elsewhere = [s for s in files if s not in here]
95
+ result: dict[str, Any] = {
96
+ "ruleId": finding.id,
97
+ "ruleIndex": rule_index,
98
+ "level": _LEVEL[finding.status],
99
+ "message": {"text": _message(finding)},
100
+ "locations": [_location(s) for s in here],
101
+ "partialFingerprints": {
102
+ "worktreeShield/v1": hashlib.sha256(
103
+ f"{finding.id}\0{finding.resource_key or finding.summary}".encode()
104
+ ).hexdigest()
105
+ },
106
+ "properties": {
107
+ "certainty": finding.certainty.value if finding.certainty else None,
108
+ "worktrees": list(finding.worktrees),
109
+ },
110
+ }
111
+ if elsewhere:
112
+ result["relatedLocations"] = [
113
+ {"id": i, **_location(s, f"in the worktree {s.worktree}")}
114
+ for i, s in enumerate(elsewhere)
115
+ ]
116
+ return result
@@ -0,0 +1,327 @@
1
+ """Terminal output of an already redacted snapshot.
2
+
3
+ Collected text (paths, branch names, lock reasons) is added as plain ``Text``, never as
4
+ Rich markup, so a branch called ``[bold]x`` is printed as it is.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from collections import defaultdict
11
+ from collections.abc import Callable
12
+ from pathlib import Path
13
+
14
+ from rich.console import Console
15
+ from rich.table import Table
16
+ from rich.text import Text
17
+
18
+ from worktree_shield.models import (
19
+ Policy,
20
+ Repository,
21
+ ResourceClaim,
22
+ ResourceKind,
23
+ Snapshot,
24
+ SourceRef,
25
+ Worktree,
26
+ )
27
+
28
+ _REDACTION_NAMES = {
29
+ "home_path": ("home path", "home paths"),
30
+ "user_name": ("user name", "user names"),
31
+ "host_name": ("host name", "host names"),
32
+ "secret_value": ("secret value", "secret values"),
33
+ "url_credentials": ("URL credential", "URL credentials"),
34
+ "token": ("token", "tokens"),
35
+ }
36
+
37
+
38
+ def redaction_line(snapshot: Snapshot) -> str | None:
39
+ """'Redacted from this output: 3 home paths, 1 secret value', or None if nothing was."""
40
+ if not snapshot.redactions:
41
+ return None
42
+ parts = []
43
+ for item in snapshot.redactions:
44
+ singular, plural = _REDACTION_NAMES.get(item.kind, (item.kind, item.kind))
45
+ parts.append(f"{item.count} {singular if item.count == 1 else plural}")
46
+ return "Redacted from this output: " + ", ".join(parts)
47
+
48
+
49
+ def display_path(path: str) -> str:
50
+ """A path on one line: newlines, tabs and other control characters shown escaped."""
51
+ out = []
52
+ for char in path:
53
+ if char == "\n":
54
+ out.append("\\n")
55
+ elif char == "\t":
56
+ out.append("\\t")
57
+ elif ord(char) < 32 or ord(char) == 127:
58
+ out.append(f"\\x{ord(char):02x}")
59
+ else:
60
+ out.append(char)
61
+ return "".join(out)
62
+
63
+
64
+ def common_parent(paths: list[str]) -> str | None:
65
+ """The directory all worktrees live in, if they share one below the root."""
66
+ try:
67
+ parent = os.path.commonpath(paths)
68
+ except ValueError: # different drives on Windows
69
+ return None
70
+ if parent in paths: # one worktree contains the others: use its parent
71
+ parent = str(Path(parent).parent)
72
+ return parent if parent not in {"", ".", "/", os.sep} else None
73
+
74
+
75
+ def _ref(worktree: Worktree) -> str:
76
+ if worktree.bare:
77
+ return "bare repository"
78
+ if worktree.branch:
79
+ return worktree.branch.removeprefix("refs/heads/")
80
+ if worktree.head:
81
+ return f"detached at {worktree.head[:7]}"
82
+ return "no commit"
83
+
84
+
85
+ def _notes(worktree: Worktree) -> str:
86
+ notes = []
87
+ if worktree.locked is not None:
88
+ notes.append(f"locked: {worktree.locked}" if worktree.locked else "locked")
89
+ if worktree.prunable is not None:
90
+ notes.append(f"prunable: {worktree.prunable}" if worktree.prunable else "prunable")
91
+ return "; ".join(notes)
92
+
93
+
94
+ def render_worktrees(snapshot: Snapshot, console: Console) -> None:
95
+ """Every worktree of the repository, the current one marked."""
96
+ repository: Repository | None = snapshot.repository
97
+ console.print(Text(f"worktree-shield {snapshot.tool_version}", style="bold"))
98
+ if repository is None:
99
+ console.print(Text("The worktrees could not be listed:"))
100
+ for failure in snapshot.probe_failures:
101
+ console.print(Text(f" {failure.probe}: {failure.detail}"))
102
+ return
103
+ count = len(repository.worktrees)
104
+ plural = "s" if count != 1 else ""
105
+ console.print(Text(f"{count} worktree{plural} sharing {display_path(repository.common_dir)}"))
106
+ parent = common_parent([w.path for w in repository.worktrees])
107
+ if parent is not None:
108
+ console.print(Text(f"Paths below are relative to {display_path(parent)}", style="dim"))
109
+ console.print()
110
+ table = Table(box=None, show_edge=False, pad_edge=False, header_style="bold")
111
+ table.add_column("", no_wrap=True)
112
+ table.add_column("Worktree", overflow="fold")
113
+ table.add_column("Branch", overflow="fold")
114
+ table.add_column("Notes")
115
+ for worktree in repository.worktrees:
116
+ here = "*" if repository.current is not None and worktree.path == repository.current else ""
117
+ shown = os.path.relpath(worktree.path, parent) if parent is not None else worktree.path
118
+ table.add_row(
119
+ Text(here), Text(display_path(shown)), Text(_ref(worktree)), Text(_notes(worktree))
120
+ )
121
+ console.print(table)
122
+ if any(w.prunable is not None for w in repository.worktrees):
123
+ console.print()
124
+ console.print(
125
+ Text(
126
+ "Prunable worktrees are listed by Git but their directory is gone; "
127
+ "worktree-shield never prunes them (git worktree prune would).",
128
+ style="dim",
129
+ )
130
+ )
131
+ line = redaction_line(snapshot)
132
+ if line:
133
+ console.print()
134
+ console.print(Text(line, style="dim"))
135
+
136
+
137
+ def _policy_rows(policy: Policy) -> list[tuple[str, str, str]]:
138
+ """(setting, value, default value) in the order of the policy file."""
139
+ default = Policy()
140
+
141
+ def flag(value: bool) -> str:
142
+ return "true" if value else "false"
143
+
144
+ def listed(values: tuple[object, ...]) -> str:
145
+ return ", ".join(display_path(str(v)) for v in values) if values else "none"
146
+
147
+ return [
148
+ ("scan.include_runtime", flag(policy.include_runtime), flag(default.include_runtime)),
149
+ ("scan.include_env_files", flag(policy.include_env_files), flag(default.include_env_files)),
150
+ ("scan.max_file_bytes", str(policy.max_file_bytes), str(default.max_file_bytes)),
151
+ ("policy.shared_host_ports", policy.shared_host_ports, default.shared_host_ports),
152
+ ("policy.shared_database", policy.shared_database, default.shared_database),
153
+ (
154
+ "policy.shared_compose_projects",
155
+ policy.shared_compose_projects,
156
+ default.shared_compose_projects,
157
+ ),
158
+ (
159
+ "policy.fixed_container_names",
160
+ policy.fixed_container_names,
161
+ default.fixed_container_names,
162
+ ),
163
+ (
164
+ "policy.shared_absolute_paths",
165
+ policy.shared_absolute_paths,
166
+ default.shared_absolute_paths,
167
+ ),
168
+ ("allow.ports", listed(policy.allow_ports), listed(default.allow_ports)),
169
+ ("allow.paths", listed(policy.allow_paths), listed(default.allow_paths)),
170
+ ]
171
+
172
+
173
+ def render_policy(policy: Policy, worktree: str | None, console: Console) -> None:
174
+ """The effective policy of a worktree and where each value comes from."""
175
+ if policy.source is not None:
176
+ console.print(Text(f"Policy from {display_path(policy.source)}", style="bold"))
177
+ elif worktree is not None:
178
+ console.print(Text("Built-in defaults", style="bold"))
179
+ console.print(Text(f"No .worktree-shield.toml in {display_path(worktree)}", style="dim"))
180
+ else:
181
+ console.print(
182
+ Text("Built-in defaults (a bare repository has no policy file)", style="bold")
183
+ )
184
+ console.print()
185
+ table = Table(box=None, show_edge=False, pad_edge=False, header_style="bold")
186
+ table.add_column("Setting", no_wrap=True)
187
+ table.add_column("Value", overflow="fold")
188
+ table.add_column("")
189
+ for name, value, default in _policy_rows(policy):
190
+ note = "" if value == default or policy.source is None else f"default: {default}"
191
+ table.add_row(Text(name), Text(str(value)), Text(note, style="dim"))
192
+ console.print(table)
193
+ console.print()
194
+ console.print(
195
+ Text(
196
+ "deny fails --strict, warn is reported, allow is information only. allow.ports "
197
+ "are endpoints the worktrees may share as clients; two listeners on one port are "
198
+ "never allowed by it.",
199
+ style="dim",
200
+ )
201
+ )
202
+
203
+
204
+ _KIND_TITLES = (
205
+ (ResourceKind.LISTEN_PORT, "Listen ports"),
206
+ (ResourceKind.DATABASE, "Databases"),
207
+ (ResourceKind.COMPOSE_PROJECT, "Compose projects"),
208
+ (ResourceKind.CONTAINER_NAME, "Container names"),
209
+ (ResourceKind.VOLUME, "Volumes"),
210
+ (ResourceKind.ABSOLUTE_PATH, "Absolute paths"),
211
+ (ResourceKind.CLIENT_ENDPOINT, "Servers the worktrees connect to"),
212
+ )
213
+
214
+
215
+ def _where(source: SourceRef) -> str:
216
+ line = f":{source.line}" if source.line is not None else ""
217
+ return display_path(source.path) + line
218
+
219
+
220
+ def worktree_names(repository: Repository) -> tuple[Callable[[str], str], str | None]:
221
+ """How reports name a worktree (relative to the directory all of them share) and
222
+ that directory, if any."""
223
+ parent = common_parent([w.path for w in repository.worktrees])
224
+
225
+ def name(worktree: str) -> str:
226
+ return display_path(os.path.relpath(worktree, parent) if parent is not None else worktree)
227
+
228
+ return name, parent
229
+
230
+
231
+ def render_failed_discovery(snapshot: Snapshot, console: Console) -> None:
232
+ console.print(Text("The worktrees could not be listed:"))
233
+ for failure in snapshot.probe_failures:
234
+ console.print(Text(f" {failure.probe}: {failure.detail}"))
235
+
236
+
237
+ def render_resources(snapshot: Snapshot, console: Console) -> None:
238
+ """Every resource the worktrees declare, grouped by kind; shared ones marked."""
239
+ repository = snapshot.repository
240
+ console.print(Text(f"worktree-shield {snapshot.tool_version}", style="bold"))
241
+ if repository is None:
242
+ render_failed_discovery(snapshot, console)
243
+ return
244
+ name, parent = worktree_names(repository)
245
+
246
+ by_key: dict[tuple[ResourceKind, str], list[ResourceClaim]] = defaultdict(list)
247
+ for claim in snapshot.claims:
248
+ by_key[(claim.kind, claim.key)].append(claim)
249
+ shared = sum(1 for claims in by_key.values() if len({c.worktree for c in claims}) > 1)
250
+ count = len(repository.worktrees)
251
+ console.print(
252
+ Text(
253
+ f"{count} worktree{'s' if count != 1 else ''}, {len(by_key)} "
254
+ f"resource{'s' if len(by_key) != 1 else ''}, {shared} used by more than one worktree"
255
+ )
256
+ )
257
+ if parent is not None:
258
+ console.print(Text(f"Worktrees are named relative to {display_path(parent)}", style="dim"))
259
+ for kind, title in _KIND_TITLES:
260
+ keys = sorted(key for key in by_key if key[0] is kind)
261
+ if not keys:
262
+ continue
263
+ console.print()
264
+ console.print(Text(title, style="bold"))
265
+ table = Table(box=None, show_edge=False, pad_edge=False, show_header=False)
266
+ table.add_column("Resource", no_wrap=True)
267
+ table.add_column("Worktree", overflow="fold")
268
+ table.add_column("Where", overflow="fold")
269
+ table.add_column("What", overflow="fold")
270
+ for key in keys:
271
+ claims = by_key[key]
272
+ users = len({c.worktree for c in claims})
273
+ label = Text(key[1])
274
+ if users > 1:
275
+ label.append(f" {users} worktrees", style="bold yellow")
276
+ for index, claim in enumerate(claims):
277
+ table.add_row(
278
+ label if index == 0 else Text(""),
279
+ Text(name(claim.worktree)),
280
+ Text(_where(claim.source)),
281
+ Text(claim.detail),
282
+ )
283
+ console.print(table)
284
+ if not by_key:
285
+ console.print()
286
+ console.print(Text("No ports, databases or paths were found in the configuration."))
287
+ _render_gaps(snapshot, name, console)
288
+
289
+
290
+ def _render_gaps(snapshot: Snapshot, label: Callable[[str], str], console: Console) -> None:
291
+ """What could not be read: skipped files, unreadable values, files left out."""
292
+ if snapshot.skipped:
293
+ console.print()
294
+ console.print(Text("Not read", style="bold"))
295
+ for skipped in snapshot.skipped:
296
+ where = _where(skipped.source)
297
+ console.print(Text(f" {label(skipped.source.worktree)} {where} {skipped.reason}"))
298
+ if snapshot.unreadable:
299
+ console.print()
300
+ console.print(Text("Could not be identified", style="bold"))
301
+ for value in snapshot.unreadable:
302
+ where = _where(value.source)
303
+ console.print(
304
+ Text(f" {label(value.source.worktree)} {where} {value.name}: {value.reason}")
305
+ )
306
+ runtime = snapshot.runtime
307
+ if runtime is not None and runtime.unattributed:
308
+ console.print()
309
+ console.print(Text("Configured ports in use outside the worktrees", style="bold"))
310
+ for listener in runtime.unattributed:
311
+ owner = listener.process or "a process"
312
+ if listener.pid is not None:
313
+ owner += f" (pid {listener.pid})"
314
+ address = f"[{listener.address}]" if ":" in listener.address else listener.address
315
+ console.print(
316
+ Text(f" {listener.protocol} {address}:{listener.port} {owner}: {listener.note}")
317
+ )
318
+ if runtime is not None:
319
+ for limitation in runtime.limitations:
320
+ console.print(Text(f"Running processes: {limitation}.", style="dim"))
321
+ elif snapshot.policy is not None:
322
+ console.print()
323
+ console.print(Text("Running processes were not looked at.", style="dim"))
324
+ line = redaction_line(snapshot)
325
+ if line:
326
+ console.print()
327
+ console.print(Text(line, style="dim"))
@@ -0,0 +1 @@
1
+ """The WTS rules: pure functions from a snapshot to findings (docs/rules.md lists them)."""
@@ -0,0 +1,28 @@
1
+ """The built-in rules, in the order reports list them."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from worktree_shield.rules import context, runtime, shared
6
+ from worktree_shield.rules.engine import Rule
7
+
8
+ _ORDER = (
9
+ "WTS-PORT-001",
10
+ "WTS-PORT-002",
11
+ "WTS-DB-001",
12
+ "WTS-DB-002",
13
+ "WTS-DOCKER-001",
14
+ "WTS-DOCKER-002",
15
+ "WTS-DOCKER-003",
16
+ "WTS-PATH-001",
17
+ "WTS-ENV-001",
18
+ "WTS-CONFIG-001",
19
+ "WTS-GIT-002",
20
+ "WTS-RUNTIME-001",
21
+ "WTS-POLICY-001",
22
+ )
23
+
24
+
25
+ def builtin_rules() -> tuple[Rule, ...]:
26
+ """Every rule, ordered by resource: ports, databases, Docker, paths, then context."""
27
+ rules = {rule.id: rule for rule in (*shared.RULES, *runtime.RULES, *context.RULES)}
28
+ return tuple(rules[rule_id] for rule_id in _ORDER)
@@ -0,0 +1,61 @@
1
+ """Pieces the rule modules share: key parsing, evidence and finding helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Iterable
7
+ from pathlib import PurePosixPath
8
+
9
+ from worktree_shield.collectors.envfiles import TEMPLATE_FILES
10
+ from worktree_shield.models import (
11
+ Confidence,
12
+ Evidence,
13
+ Finding,
14
+ Policy,
15
+ ResourceClaim,
16
+ Severity,
17
+ SourceRef,
18
+ Status,
19
+ )
20
+
21
+ VERIFY = "worktree-shield scan --strict"
22
+ VERIFY_WITH_ENV = VERIFY + " --include-env"
23
+
24
+
25
+ def verify_for(claims: Iterable[ResourceClaim], policy: Policy) -> str:
26
+ """The scan that can see the fix: a value from a ``.env`` template is fixed in a
27
+ ``.env``, which a scan reads only with ``--include-env`` (or the policy's opt-in)."""
28
+ from_template = any(PurePosixPath(c.source.path).name in TEMPLATE_FILES for c in claims)
29
+ return VERIFY_WITH_ENV if from_template and not policy.include_env_files else VERIFY
30
+
31
+
32
+ _PORT = re.compile(r"[a-z]+://.*:([0-9]+)")
33
+
34
+
35
+ def port_of(key: str) -> int | None:
36
+ """The port of a ``tcp://host:port`` or ``db://engine/host:port/db`` key."""
37
+ match = _PORT.fullmatch(key.rsplit("/", 1)[0] if key.startswith("db://") else key)
38
+ return int(match.group(1)) if match else None
39
+
40
+
41
+ def name_of(key: str) -> str:
42
+ """What follows the scheme: ``shop`` of ``compose-project://shop``."""
43
+ return key.split("://", 1)[-1]
44
+
45
+
46
+ def where(source: SourceRef) -> str:
47
+ return source.path if source.line is None else f"{source.path}:{source.line}"
48
+
49
+
50
+ def evidence(claims: tuple[ResourceClaim, ...] | list[ResourceClaim]) -> tuple[Evidence, ...]:
51
+ """One line per claim: where it was found, in which worktree, what was read."""
52
+ return tuple(Evidence(where(c.source), c.worktree, c.detail) for c in claims)
53
+
54
+
55
+ def plural(count: int, word: str) -> str:
56
+ return f"{count} {word}" if count == 1 else f"{count} {word}s"
57
+
58
+
59
+ def plain(rule: str, status: Status, summary: str, reason: str) -> Finding:
60
+ """A finding about no resource in particular: nothing to check, or nothing found."""
61
+ return Finding(rule, status, Severity.INFO, Confidence.MEDIUM, summary, reason)