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,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)
|