codetrail-cli 1.4.1__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.
- codetrail/__init__.py +3 -0
- codetrail/__main__.py +5 -0
- codetrail/agents/__init__.py +4 -0
- codetrail/agents/convert.py +92 -0
- codetrail/agents/export.py +284 -0
- codetrail/agents/instructions.py +42 -0
- codetrail/agents/protocol.py +111 -0
- codetrail/agents/tools.py +185 -0
- codetrail/assistant/__init__.py +161 -0
- codetrail/assistant/claude_code.py +330 -0
- codetrail/assistant/codex.py +260 -0
- codetrail/assistant/environment.py +66 -0
- codetrail/assistant/estimate.py +225 -0
- codetrail/assistant/fake.py +74 -0
- codetrail/assistant/guard.py +80 -0
- codetrail/assistant/guard_hook.py +58 -0
- codetrail/assistant/local.py +245 -0
- codetrail/assistant/local_tools.py +177 -0
- codetrail/assistant/prompts.py +404 -0
- codetrail/assistant/routing.py +86 -0
- codetrail/assistant/runner.py +120 -0
- codetrail/assistant/status.py +186 -0
- codetrail/assistant/usage.py +93 -0
- codetrail/bridge.py +292 -0
- codetrail/cli.py +407 -0
- codetrail/config.py +485 -0
- codetrail/database/__init__.py +60 -0
- codetrail/database/migrations/0001_facts.sql +34 -0
- codetrail/database/migrations/0002_settings.sql +5 -0
- codetrail/database/migrations/0003_learning.sql +34 -0
- codetrail/database/migrations/0004_assistant_usage.sql +24 -0
- codetrail/database/migrations/0005_page_failures.sql +7 -0
- codetrail/database/migrations/0006_metric_values.sql +9 -0
- codetrail/errors.py +5 -0
- codetrail/extract/__init__.py +249 -0
- codetrail/extract/adr.py +98 -0
- codetrail/extract/github_actions.py +140 -0
- codetrail/extract/openapi.py +101 -0
- codetrail/extract/python.py +221 -0
- codetrail/extract/swift.py +204 -0
- codetrail/extract/terraform.py +166 -0
- codetrail/extract/typescript.py +464 -0
- codetrail/facts/__init__.py +112 -0
- codetrail/facts/store.py +209 -0
- codetrail/generate/__init__.py +1 -0
- codetrail/generate/failures.py +31 -0
- codetrail/generate/outline.py +159 -0
- codetrail/generate/revise.py +72 -0
- codetrail/generate/run.py +530 -0
- codetrail/generate/scope.py +135 -0
- codetrail/generate/validate.py +218 -0
- codetrail/guide/__init__.py +147 -0
- codetrail/learn/__init__.py +143 -0
- codetrail/learn/routes.py +139 -0
- codetrail/locales/ar/LC_MESSAGES/codetrail.mo +0 -0
- codetrail/locales/ar/LC_MESSAGES/codetrail.po +2236 -0
- codetrail/locales/codetrail.pot +1942 -0
- codetrail/lock.py +114 -0
- codetrail/metrics/__init__.py +6 -0
- codetrail/metrics/activity.py +96 -0
- codetrail/metrics/churn.py +156 -0
- codetrail/metrics/commits.py +45 -0
- codetrail/metrics/coverage.py +114 -0
- codetrail/metrics/decisions.py +77 -0
- codetrail/metrics/history.py +47 -0
- codetrail/metrics/inventory.py +63 -0
- codetrail/metrics/rationale.py +74 -0
- codetrail/metrics/releases.py +26 -0
- codetrail/metrics/report.py +111 -0
- codetrail/metrics/repository.py +128 -0
- codetrail/metrics/size.py +95 -0
- codetrail/py.typed +0 -0
- codetrail/remove.py +50 -0
- codetrail/repo/__init__.py +4 -0
- codetrail/repo/git.py +56 -0
- codetrail/repo/gitleaks.toml +6 -0
- codetrail/repo/history.py +262 -0
- codetrail/repo/mirror.py +137 -0
- codetrail/repo/refresh.py +41 -0
- codetrail/repo/rules.py +83 -0
- codetrail/repo/secrets.py +145 -0
- codetrail/repo/signal.py +65 -0
- codetrail/repo/source.py +166 -0
- codetrail/search.py +178 -0
- codetrail/server.py +45 -0
- codetrail/system.py +484 -0
- codetrail/update.py +276 -0
- codetrail/web/__init__.py +1 -0
- codetrail/web/app.py +897 -0
- codetrail/web/diagrams.py +414 -0
- codetrail/web/documentation.py +146 -0
- codetrail/web/i18n.py +44 -0
- codetrail/web/navigation.py +127 -0
- codetrail/web/render.py +126 -0
- codetrail/web/repository_view.py +230 -0
- codetrail/web/security.py +171 -0
- codetrail/web/static/brand/favicon.svg +1 -0
- codetrail/web/static/brand/mark.svg +1 -0
- codetrail/web/static/css/base.css +192 -0
- codetrail/web/static/css/components.css +2254 -0
- codetrail/web/static/css/content.css +310 -0
- codetrail/web/static/css/layout.css +410 -0
- codetrail/web/static/css/print.css +42 -0
- codetrail/web/static/css/tokens.css +228 -0
- codetrail/web/static/js/api.js +49 -0
- codetrail/web/static/js/ask.js +182 -0
- codetrail/web/static/js/d2-worker.js +34 -0
- codetrail/web/static/js/d2.js +28 -0
- codetrail/web/static/js/diagram.js +52 -0
- codetrail/web/static/js/documentation.js +24 -0
- codetrail/web/static/js/learning.js +47 -0
- codetrail/web/static/js/main.js +38 -0
- codetrail/web/static/js/outline.js +44 -0
- codetrail/web/static/js/palette.js +211 -0
- codetrail/web/static/js/repository.js +528 -0
- codetrail/web/static/js/resize.js +126 -0
- codetrail/web/static/js/shortcuts.js +87 -0
- codetrail/web/static/js/sidebar.js +194 -0
- codetrail/web/static/js/theme-init.js +27 -0
- codetrail/web/static/js/theme.js +42 -0
- codetrail/web/static/js/update.js +107 -0
- codetrail/web/static/vendor/d2/LICENSE +375 -0
- codetrail/web/static/vendor/d2/LICENSE-elk +263 -0
- codetrail/web/static/vendor/d2/LICENSE-wasm_exec +27 -0
- codetrail/web/static/vendor/d2/VERSION +12 -0
- codetrail/web/static/vendor/d2/d2.wasm +0 -0
- codetrail/web/static/vendor/d2/elk.js +105914 -0
- codetrail/web/static/vendor/d2/wasm_exec.js +656 -0
- codetrail/web/static/vendor/inter/InterVariable-Italic.woff2 +0 -0
- codetrail/web/static/vendor/inter/InterVariable.woff2 +0 -0
- codetrail/web/static/vendor/inter/LICENSE.txt +92 -0
- codetrail/web/static/vendor/inter/VERSION +6 -0
- codetrail/web/static/vendor/noto-sans-arabic/NotoSansArabic-wght.woff2 +0 -0
- codetrail/web/static/vendor/noto-sans-arabic/OFL.txt +93 -0
- codetrail/web/static/vendor/noto-sans-arabic/VERSION +12 -0
- codetrail/web/target_view.py +63 -0
- codetrail/web/templates/answers.html +25 -0
- codetrail/web/templates/area.html +46 -0
- codetrail/web/templates/base.html +37 -0
- codetrail/web/templates/decisions.html +30 -0
- codetrail/web/templates/diagram.html +10 -0
- codetrail/web/templates/digests.html +24 -0
- codetrail/web/templates/documentation.html +247 -0
- codetrail/web/templates/error.html +9 -0
- codetrail/web/templates/estimate.html +48 -0
- codetrail/web/templates/fact.html +29 -0
- codetrail/web/templates/home.html +196 -0
- codetrail/web/templates/layout/ask_panel.html +27 -0
- codetrail/web/templates/layout/header.html +32 -0
- codetrail/web/templates/layout/icons.html +27 -0
- codetrail/web/templates/layout/outline.html +4 -0
- codetrail/web/templates/layout/palette.html +27 -0
- codetrail/web/templates/layout/shortcuts.html +27 -0
- codetrail/web/templates/layout/sidebar.html +79 -0
- codetrail/web/templates/layout/update_dialog.html +18 -0
- codetrail/web/templates/macros.html +13 -0
- codetrail/web/templates/page.html +93 -0
- codetrail/web/templates/progress.html +46 -0
- codetrail/web/templates/repository.html +369 -0
- codetrail/web/templates/search.html +31 -0
- codetrail/web/templates/source.html +15 -0
- codetrail/web/templates/system.html +43 -0
- codetrail/web/templates/system_arrows.html +17 -0
- codetrail/web/templates/system_legend.html +9 -0
- codetrail/web/templates/update_log.html +30 -0
- codetrail/web/trend.py +22 -0
- codetrail_cli-1.4.1.dist-info/METADATA +372 -0
- codetrail_cli-1.4.1.dist-info/RECORD +171 -0
- codetrail_cli-1.4.1.dist-info/WHEEL +4 -0
- codetrail_cli-1.4.1.dist-info/entry_points.txt +3 -0
- codetrail_cli-1.4.1.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""A target's history, filtered: logs and diffs never show excluded paths or secrets (design section 3.3).
|
|
2
|
+
|
|
3
|
+
Commit messages and diffs are scanned with gitleaks as text. A flagged message is withheld, and so is a file's patch
|
|
4
|
+
when it holds a finding, which catches a secret that a commit removed although the file is clean now.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
import secrets
|
|
11
|
+
from collections.abc import Callable, Sequence
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import date
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from codetrail.repo.git import run_git
|
|
17
|
+
from codetrail.repo.secrets import SecretScanner
|
|
18
|
+
|
|
19
|
+
WITHHELD_MESSAGE = "[withheld: gitleaks flagged this message]"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Commit:
|
|
24
|
+
sha: str
|
|
25
|
+
author: str
|
|
26
|
+
date: str
|
|
27
|
+
subject: str
|
|
28
|
+
body: str
|
|
29
|
+
files: list[str]
|
|
30
|
+
is_merge: bool
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class FileDiff:
|
|
35
|
+
path: str
|
|
36
|
+
status: str
|
|
37
|
+
patch: str | None # None when gitleaks flagged it
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def commits_between(
|
|
41
|
+
mirror: Path, start: str | None, end: str, visible: Callable[[str], bool], scanner: SecretScanner
|
|
42
|
+
) -> list[Commit]:
|
|
43
|
+
"""The commits after `start` up to `end`, oldest first; merges list the files they bring to the first parent.
|
|
44
|
+
|
|
45
|
+
Each commit is read on its own, with NUL between fields (git messages can't hold NUL), so no text in a message
|
|
46
|
+
can forge or break a record.
|
|
47
|
+
"""
|
|
48
|
+
revision_range = end if start is None else f"{start}..{end}"
|
|
49
|
+
revisions = run_git(["rev-list", "--reverse", "--end-of-options", revision_range], git_dir=mirror)
|
|
50
|
+
commits = [_read_commit(mirror, sha, visible) for sha in revisions.decode().split()]
|
|
51
|
+
return _withhold_flagged_messages(commits, scanner)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _read_commit(mirror: Path, sha: str, visible: Callable[[str], bool]) -> Commit:
|
|
55
|
+
header = run_git(["show", "-s", "--format=%H%x00%an%x00%aI%x00%P%x00%s%x00%b", sha], git_dir=mirror)
|
|
56
|
+
commit_sha, author, date, parents, subject, body = header.decode("utf-8", "replace").split("\0", 5)
|
|
57
|
+
parent_list = parents.split()
|
|
58
|
+
changed = ["diff-tree", "--no-commit-id", "-r", "-z", "--name-only"]
|
|
59
|
+
changed += [parent_list[0], sha] if parent_list else ["--root", sha]
|
|
60
|
+
names = run_git(changed, git_dir=mirror).decode("utf-8", "surrogateescape").split("\0")
|
|
61
|
+
files = [name for name in names if name and visible(name)]
|
|
62
|
+
return Commit(commit_sha, author, date, subject, body.strip(), files, len(parent_list) > 1)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
LOG_FIELDS = 5 # sha, author, date, subject, body
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def latest_commits(mirror: Path, end: str, limit: int, scanner: SecretScanner) -> list[Commit]:
|
|
69
|
+
"""The latest `limit` non-merge commits up to `end`, newest first, with flagged messages withheld.
|
|
70
|
+
|
|
71
|
+
One git call: fields and records are separated by NUL, which no message can hold. Files aren't listed.
|
|
72
|
+
"""
|
|
73
|
+
output = run_git(
|
|
74
|
+
["log", f"-n{limit}", "--no-merges", "-z", "--format=%H%x00%an%x00%aI%x00%s%x00%b", "--end-of-options", end],
|
|
75
|
+
git_dir=mirror,
|
|
76
|
+
).decode("utf-8", "replace")
|
|
77
|
+
fields = output.split("\0")
|
|
78
|
+
records = (fields[start : start + LOG_FIELDS] for start in range(0, len(fields) - LOG_FIELDS + 1, LOG_FIELDS))
|
|
79
|
+
commits = [
|
|
80
|
+
Commit(sha, author, date, subject, body.strip(), [], False) for sha, author, date, subject, body in records
|
|
81
|
+
]
|
|
82
|
+
return _withhold_flagged_messages(commits, scanner)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
WITHHELD_TAG = "[withheld: gitleaks flagged this tag]"
|
|
86
|
+
TAG_FIELDS = 9 # name, type, object, peeled type, peeled object, date, the commit's date, subject, body
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class Tag:
|
|
91
|
+
name: str
|
|
92
|
+
commit: str
|
|
93
|
+
date: int # seconds since the epoch: the tagger's date, or the commit's for a lightweight tag
|
|
94
|
+
message: str # an annotated tag's subject and body; empty for a lightweight or withheld tag
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def read_tags(mirror: Path, end: str, scanner: SecretScanner) -> list[Tag]:
|
|
98
|
+
"""The tags on commits in `end`'s history, with flagged names and messages withheld (design section 19.2).
|
|
99
|
+
|
|
100
|
+
One git call, fields separated by NUL, which no name or message can hold. Tags on anything but a commit are
|
|
101
|
+
skipped, so a tag on a blob of an excluded file is never read; the tagger is never read either. A tag with no
|
|
102
|
+
tagger date takes its commit's. The subject and
|
|
103
|
+
body are read apart because a signed tag's whole contents would end with its signature.
|
|
104
|
+
"""
|
|
105
|
+
output = run_git(["for-each-ref", f"--merged={end}", "--format=%(refname:strip=2)%00%(objecttype)%00%(objectname)"
|
|
106
|
+
"%00%(*objecttype)%00%(*objectname)%00%(creatordate:unix)%00%(*committerdate:unix)%00"
|
|
107
|
+
"%(contents:subject)%00%(contents:body)%00", "refs/tags"],
|
|
108
|
+
git_dir=mirror).decode("utf-8", "replace") # fmt: skip
|
|
109
|
+
fields = output.split("\0")
|
|
110
|
+
tags = []
|
|
111
|
+
for start in range(0, len(fields) - TAG_FIELDS + 1, TAG_FIELDS):
|
|
112
|
+
name, kind, sha, peeled_kind, peeled, date, commit_date, subject, body = fields[start : start + TAG_FIELDS]
|
|
113
|
+
name = name.lstrip("\n") # each record ends with a newline after its last NUL; a name can't hold one
|
|
114
|
+
if kind == "commit":
|
|
115
|
+
tags.append(Tag(name, sha, int(date), ""))
|
|
116
|
+
elif kind == "tag" and peeled_kind == "commit":
|
|
117
|
+
date = date or commit_date # a tag made before git recorded taggers has no date of its own
|
|
118
|
+
message = f"{subject}\n\n{body.strip()}" if body.strip() else subject
|
|
119
|
+
tags.append(Tag(name, peeled, int(date), message.strip()))
|
|
120
|
+
flagged = _flagged_indexes([f"{tag.name}\n{tag.message}" for tag in tags], scanner)
|
|
121
|
+
return [Tag(WITHHELD_TAG, tag.commit, tag.date, "") if index in flagged else tag for index, tag in enumerate(tags)]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def commit_totals(mirror: Path, end: str) -> tuple[int, int]:
|
|
125
|
+
"""How many commits `end`'s history holds, and how many of them are merges."""
|
|
126
|
+
total = run_git(["rev-list", "--count", "--end-of-options", end], git_dir=mirror)
|
|
127
|
+
merges = run_git(["rev-list", "--count", "--merges", "--end-of-options", end], git_dir=mirror)
|
|
128
|
+
return int(total), int(merges)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def commit_times(mirror: Path, end: str, limit: int) -> list[int]:
|
|
132
|
+
"""The author times of the latest `limit` commits, newest first: no message, name or email is read."""
|
|
133
|
+
output = run_git(["log", f"-n{limit}", "--format=%at", "--end-of-options", end], git_dir=mirror)
|
|
134
|
+
return [int(line) for line in output.split()]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def first_commit_time(mirror: Path, end: str) -> int | None:
|
|
138
|
+
"""The earliest author time among the root commits of `end`'s history: a cheap "started on" for the home card."""
|
|
139
|
+
output = run_git(["log", "--max-parents=0", "--format=%at", "--end-of-options", end], git_dir=mirror)
|
|
140
|
+
return min((int(line) for line in output.split()), default=None)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
COMMIT_MARK = "\x01"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def changed_files(mirror: Path, end: str, limit: int) -> list[tuple[int, list[str]]]:
|
|
147
|
+
"""Each of the latest `limit` non-merge commits' author time and changed paths, newest first (sections 19.1, 20.2).
|
|
148
|
+
|
|
149
|
+
One git call printing only a mark with the time per commit, and its file names, NUL-separated: no message, name or
|
|
150
|
+
email. The mark holds a random token drawn for this call, so no file name can pose as a commit, and only ASCII
|
|
151
|
+
digits follow it. Names aren't filtered here; callers name only allowed files.
|
|
152
|
+
"""
|
|
153
|
+
token = secrets.token_hex(16)
|
|
154
|
+
header_pattern = re.compile(re.escape(COMMIT_MARK + token) + r"([0-9]{1,12})")
|
|
155
|
+
output = run_git(["log", f"-n{limit}", "--no-merges", "-z", "--name-only", f"--format=%x01{token}%at",
|
|
156
|
+
"--end-of-options", end], git_dir=mirror).decode("utf-8", "surrogateescape") # fmt: skip
|
|
157
|
+
commits: list[tuple[int, list[str]]] = []
|
|
158
|
+
first_name = False
|
|
159
|
+
for field in output.split("\0"):
|
|
160
|
+
header = header_pattern.fullmatch(field.lstrip("\n"))
|
|
161
|
+
if header:
|
|
162
|
+
commits.append((int(header[1]), []))
|
|
163
|
+
first_name = True
|
|
164
|
+
elif field and commits:
|
|
165
|
+
name = field[1:] if first_name and field.startswith("\n") else field # a newline ends the header
|
|
166
|
+
first_name = False
|
|
167
|
+
if name:
|
|
168
|
+
commits[-1][1].append(name)
|
|
169
|
+
return commits
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def files_changed_since(mirror: Path, end: str, since: date) -> set[str]:
|
|
173
|
+
"""The paths that differ between the last commit before the date and `end`: names only, which callers filter.
|
|
174
|
+
|
|
175
|
+
One tree diff, however many commits came since; with no commit before the date, every path in `end` counts.
|
|
176
|
+
"""
|
|
177
|
+
base = run_git(["rev-list", "-1", f"--before={since.isoformat()}", "--end-of-options", end], git_dir=mirror)
|
|
178
|
+
if base.strip():
|
|
179
|
+
arguments = ["diff-tree", "-r", "--name-only", "--no-renames", "-z", "--end-of-options", base.decode().strip(),
|
|
180
|
+
end] # fmt: skip
|
|
181
|
+
else:
|
|
182
|
+
arguments = ["ls-tree", "-r", "--name-only", "-z", "--end-of-options", end]
|
|
183
|
+
output = run_git(arguments, git_dir=mirror)
|
|
184
|
+
return {name for name in output.decode("utf-8", "surrogateescape").split("\0") if name}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def merges_between(mirror: Path, start: str, end: str) -> int:
|
|
188
|
+
count = run_git(["rev-list", "--count", "--merges", "--first-parent", "--end-of-options", f"{start}..{end}"],
|
|
189
|
+
git_dir=mirror) # fmt: skip
|
|
190
|
+
return int(count)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def diff_between(
|
|
194
|
+
mirror: Path, start: str, end: str, visible: Callable[[str], bool], scanner: SecretScanner
|
|
195
|
+
) -> list[FileDiff]:
|
|
196
|
+
arguments = ["diff-tree", "-r", "--name-status", "--no-renames", "-z", "--end-of-options", start, end]
|
|
197
|
+
output = run_git(arguments, git_dir=mirror)
|
|
198
|
+
fields = output.decode("utf-8", "surrogateescape").split("\0")
|
|
199
|
+
changes = [(fields[i], fields[i + 1]) for i in range(0, len(fields) - 1, 2) if visible(fields[i + 1])]
|
|
200
|
+
patches = [
|
|
201
|
+
run_git(
|
|
202
|
+
["diff-tree", "-r", "-p", "--no-renames", "--end-of-options", start, end, "--", f":(literal){path}"],
|
|
203
|
+
git_dir=mirror,
|
|
204
|
+
).decode("utf-8", "replace")
|
|
205
|
+
for _status, path in changes
|
|
206
|
+
]
|
|
207
|
+
flagged = _flagged_indexes(patches, scanner)
|
|
208
|
+
return [
|
|
209
|
+
FileDiff(path, status, None if index in flagged else patch)
|
|
210
|
+
for index, ((status, path), patch) in enumerate(zip(changes, patches, strict=True))
|
|
211
|
+
]
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _withhold_flagged_messages(commits: list[Commit], scanner: SecretScanner) -> list[Commit]:
|
|
215
|
+
flagged = _flagged_indexes([f"{commit.subject}\n{commit.body}" for commit in commits], scanner)
|
|
216
|
+
return [
|
|
217
|
+
Commit(c.sha, c.author, c.date, WITHHELD_MESSAGE, "", c.files, c.is_merge) if index in flagged else c
|
|
218
|
+
for index, c in enumerate(commits)
|
|
219
|
+
]
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _flagged_indexes(texts: Sequence[str], scanner: SecretScanner) -> set[int]:
|
|
223
|
+
"""Scans all texts in one gitleaks run and returns the indexes of those holding a finding."""
|
|
224
|
+
if not texts:
|
|
225
|
+
return set()
|
|
226
|
+
first_lines, line = [], 1
|
|
227
|
+
for text in texts:
|
|
228
|
+
first_lines.append(line)
|
|
229
|
+
line += text.count("\n") + 1
|
|
230
|
+
combined = "\n".join(texts)
|
|
231
|
+
flagged = set()
|
|
232
|
+
for finding in scanner.scan_text(combined):
|
|
233
|
+
index = max(i for i, first in enumerate(first_lines) if first <= finding.line)
|
|
234
|
+
flagged.add(index)
|
|
235
|
+
return flagged
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def commit_count(mirror: Path, start: str, end: str) -> int:
|
|
239
|
+
return int(run_git(["rev-list", "--count", "--end-of-options", f"{start}..{end}"], git_dir=mirror))
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def changed_paths(mirror: Path, start: str, end: str) -> list[str]:
|
|
243
|
+
"""Paths changed between two commits; only names, which callers filter before showing."""
|
|
244
|
+
arguments = ["diff-tree", "-r", "--name-only", "--no-renames", "-z", "--end-of-options", start, end]
|
|
245
|
+
output = run_git(arguments, git_dir=mirror)
|
|
246
|
+
return [name for name in output.decode("utf-8", "surrogateescape").split("\0") if name]
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def recent_commits(
|
|
250
|
+
mirror: Path,
|
|
251
|
+
end: str,
|
|
252
|
+
paths: Sequence[str],
|
|
253
|
+
limit: int,
|
|
254
|
+
visible: Callable[[str], bool],
|
|
255
|
+
scanner: SecretScanner,
|
|
256
|
+
) -> list[Commit]:
|
|
257
|
+
"""The latest commits (at most `limit`, oldest first) that touched any of `paths`, filtered like commits_between."""
|
|
258
|
+
pathspecs = [f":(literal){path}" for path in paths if path] or ["."]
|
|
259
|
+
shas = run_git(["log", f"-n{limit}", "--format=%H", "--end-of-options", end, "--", *pathspecs],
|
|
260
|
+
git_dir=mirror).decode().split() # fmt: skip
|
|
261
|
+
commits = [_read_commit(mirror, sha, visible) for sha in reversed(shas)]
|
|
262
|
+
return _withhold_flagged_messages(commits, scanner)
|
codetrail/repo/mirror.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Codetrail's mirror of a target: a bare clone, fetched over the file:// transport (design section 3.2).
|
|
2
|
+
|
|
3
|
+
The target is only ever read, by git's upload-pack: no command runs inside the target, and `--no-local` stops a clone
|
|
4
|
+
from hardlinking the target's object files.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Iterator
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from codetrail.errors import CodetrailError
|
|
14
|
+
from codetrail.repo.git import run_git
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class TreeEntry:
|
|
19
|
+
mode: str
|
|
20
|
+
blob: str
|
|
21
|
+
path: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def repository_url(path: Path) -> str:
|
|
25
|
+
return path.expanduser().resolve().as_uri()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def check_branch(repository: Path, branch: str) -> None:
|
|
29
|
+
"""Fails unless the repository has the branch; reads it over file:// only."""
|
|
30
|
+
found = run_git(
|
|
31
|
+
["ls-remote", "--heads", "--", repository_url(repository), f"refs/heads/{branch}"],
|
|
32
|
+
allowed_exit_codes=(0, 2),
|
|
33
|
+
)
|
|
34
|
+
if not found.strip():
|
|
35
|
+
raise CodetrailError(f"{repository} has no branch {branch!r}.")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def refresh_mirror(mirror: Path, repository: Path, branch: str) -> str:
|
|
39
|
+
"""Clones or fetches the branch into the mirror, with the tags that point into it, and returns its head commit.
|
|
40
|
+
|
|
41
|
+
The branch is fetched without tags; then the mirror's tags that the target no longer has, or that point elsewhere
|
|
42
|
+
now, are deleted, and a second fetch of the branch brings the rest by git's tag auto-follow, which takes only tags
|
|
43
|
+
on objects the mirror holds, so no other branch's commits come in (design section 19.2). Its errors are replaced,
|
|
44
|
+
since they name tags.
|
|
45
|
+
"""
|
|
46
|
+
check_branch(repository, branch)
|
|
47
|
+
url = repository_url(repository)
|
|
48
|
+
if not (mirror / "HEAD").exists():
|
|
49
|
+
mirror.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
run_git(
|
|
51
|
+
[
|
|
52
|
+
"clone",
|
|
53
|
+
"--quiet",
|
|
54
|
+
"--bare",
|
|
55
|
+
"--no-local",
|
|
56
|
+
"--no-tags",
|
|
57
|
+
"--single-branch",
|
|
58
|
+
"--branch",
|
|
59
|
+
branch,
|
|
60
|
+
"--",
|
|
61
|
+
url,
|
|
62
|
+
str(mirror),
|
|
63
|
+
]
|
|
64
|
+
)
|
|
65
|
+
refspec = f"+refs/heads/{branch}:refs/heads/{branch}"
|
|
66
|
+
run_git(["fetch", "--quiet", "--no-tags", "--prune", "--", url, refspec], git_dir=mirror)
|
|
67
|
+
_drop_stale_tags(mirror, url)
|
|
68
|
+
try:
|
|
69
|
+
# Fetching the branch again, now up to date, brings only the tags git's auto-follow takes.
|
|
70
|
+
run_git(["fetch", "--quiet", "--", url, refspec], git_dir=mirror)
|
|
71
|
+
except CodetrailError:
|
|
72
|
+
# git's message names the tag it couldn't write, which no scan has passed yet; the update's error shows it.
|
|
73
|
+
raise CodetrailError("git couldn't fetch the branch's tags into the mirror.") from None
|
|
74
|
+
return head_commit(mirror, branch)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _drop_stale_tags(mirror: Path, url: str) -> None:
|
|
78
|
+
"""Deletes the mirror's tags that the target no longer has or that point elsewhere now; touches only refs/tags/."""
|
|
79
|
+
listed = run_git(["ls-remote", "--tags", "--", url]).decode("utf-8", "surrogateescape")
|
|
80
|
+
remote = {}
|
|
81
|
+
for line in listed.splitlines():
|
|
82
|
+
sha, _, ref = line.partition("\t")
|
|
83
|
+
if ref.startswith("refs/tags/") and not ref.endswith("^{}"):
|
|
84
|
+
remote[ref] = sha
|
|
85
|
+
local = run_git(["for-each-ref", "--format=%(refname)%00%(objectname)", "refs/tags"], git_dir=mirror)
|
|
86
|
+
stale = []
|
|
87
|
+
for line in local.decode("utf-8", "surrogateescape").splitlines():
|
|
88
|
+
ref, _, sha = line.partition("\0")
|
|
89
|
+
if ref.startswith("refs/tags/") and remote.get(ref) != sha:
|
|
90
|
+
stale.append(f"delete {ref}\0{sha}\0")
|
|
91
|
+
if stale:
|
|
92
|
+
try:
|
|
93
|
+
run_git(["update-ref", "-z", "--stdin"], git_dir=mirror,
|
|
94
|
+
input="".join(stale).encode("utf-8", "surrogateescape")) # fmt: skip
|
|
95
|
+
except CodetrailError:
|
|
96
|
+
# git's message names the tag, which no scan has passed yet; the update's error would show it.
|
|
97
|
+
raise CodetrailError(f"git couldn't remove {len(stale)} stale tag(s) from the mirror.") from None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def head_commit(mirror: Path, branch: str) -> str:
|
|
101
|
+
return run_git(["rev-parse", "--verify", f"refs/heads/{branch}^{{commit}}"], git_dir=mirror).decode().strip()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def list_tree(mirror: Path, commit: str) -> list[TreeEntry]:
|
|
105
|
+
output = run_git(["ls-tree", "-r", "-z", "--full-tree", commit], git_dir=mirror)
|
|
106
|
+
entries = []
|
|
107
|
+
for record in output.split(b"\0"):
|
|
108
|
+
if not record:
|
|
109
|
+
continue
|
|
110
|
+
header, path = record.split(b"\t", 1)
|
|
111
|
+
mode, _kind, blob = header.decode().split(" ")
|
|
112
|
+
entries.append(TreeEntry(mode=mode, blob=blob, path=path.decode("utf-8", "surrogateescape")))
|
|
113
|
+
return entries
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def read_file_at(mirror: Path, commit: str, path: str) -> bytes | None:
|
|
117
|
+
"""The file's content at the commit, or None when it doesn't exist there."""
|
|
118
|
+
try:
|
|
119
|
+
return run_git(["cat-file", "blob", f"{commit}:{path}"], git_dir=mirror)
|
|
120
|
+
except CodetrailError:
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def read_blobs(mirror: Path, blobs: list[str]) -> Iterator[tuple[str, bytes]]:
|
|
125
|
+
"""Each blob's content, in order, read with one `git cat-file --batch`."""
|
|
126
|
+
if not blobs:
|
|
127
|
+
return
|
|
128
|
+
output = run_git(["cat-file", "--batch"], git_dir=mirror, input="".join(f"{blob}\n" for blob in blobs).encode())
|
|
129
|
+
position = 0
|
|
130
|
+
for blob in blobs:
|
|
131
|
+
header_end = output.index(b"\n", position)
|
|
132
|
+
name, kind, size = output[position:header_end].decode().split(" ")
|
|
133
|
+
if name != blob or kind != "blob":
|
|
134
|
+
raise CodetrailError(f"git cat-file returned {kind} {name} for blob {blob}")
|
|
135
|
+
start = header_end + 1
|
|
136
|
+
yield blob, output[start : start + int(size)]
|
|
137
|
+
position = start + int(size) + 1
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Refreshes a target's sources: mirror, rules, scan and materialization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from codetrail.config import Paths, check_containment, load_global, load_target
|
|
6
|
+
from codetrail.lock import target_in_use, target_lock
|
|
7
|
+
from codetrail.repo.mirror import read_file_at, refresh_mirror
|
|
8
|
+
from codetrail.repo.rules import ExclusionRules
|
|
9
|
+
from codetrail.repo.secrets import SecretScanner
|
|
10
|
+
from codetrail.repo.source import SourceManifest, build_source
|
|
11
|
+
|
|
12
|
+
TARGET_IGNORE_FILE = ".codetrailignore"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def ignore_lines(paths: Paths, name: str, target_file: bytes | None) -> list[str]:
|
|
16
|
+
"""The target's own .codetrailignore (from the commit), then the founder's ignore file."""
|
|
17
|
+
lines = target_file.decode("utf-8", "replace").splitlines() if target_file else []
|
|
18
|
+
founder_file = paths.ignore_file(name)
|
|
19
|
+
if founder_file.exists():
|
|
20
|
+
lines += founder_file.read_text(encoding="utf-8").splitlines()
|
|
21
|
+
return lines
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def refresh_source(paths: Paths, name: str) -> SourceManifest:
|
|
25
|
+
"""Refreshes the target's sources under its lock, after checking no folder would be made inside the target."""
|
|
26
|
+
with target_in_use(paths, name):
|
|
27
|
+
check_containment(paths, load_target(paths, name).repository)
|
|
28
|
+
with target_lock(paths, name):
|
|
29
|
+
return refresh_while_locked(paths, name)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def refresh_while_locked(paths: Paths, name: str) -> SourceManifest:
|
|
33
|
+
"""Refreshes the target's sources; the caller holds the target's lock."""
|
|
34
|
+
target = load_target(paths, name)
|
|
35
|
+
check_containment(paths, target.repository)
|
|
36
|
+
scanner = SecretScanner(load_global(paths).tools)
|
|
37
|
+
data = paths.target_data(name)
|
|
38
|
+
mirror = data / "mirror.git"
|
|
39
|
+
commit = refresh_mirror(mirror, target.repository, target.branch)
|
|
40
|
+
rules = ExclusionRules(ignore_lines(paths, name, read_file_at(mirror, commit, TARGET_IGNORE_FILE)))
|
|
41
|
+
return build_source(mirror, commit, rules, scanner, data)
|
codetrail/repo/rules.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""The exclusion rules (design section 3.3; ADR 0001).
|
|
2
|
+
|
|
3
|
+
Built-in secret patterns come first and nothing can override them. Ignore lines, in gitignore syntax, come from the
|
|
4
|
+
target's `.codetrailignore` and the founder's ignore file, in that order.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import unicodedata
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
from enum import StrEnum
|
|
12
|
+
|
|
13
|
+
from pathspec import GitIgnoreSpec
|
|
14
|
+
|
|
15
|
+
# Environment files, Terraform variables, private keys and credential files: never read, whatever the rules say.
|
|
16
|
+
BUILTIN_FILE_PATTERNS: tuple[str, ...] = (
|
|
17
|
+
".env",
|
|
18
|
+
".env.*",
|
|
19
|
+
".dev.vars",
|
|
20
|
+
"*.p8",
|
|
21
|
+
"*.p12",
|
|
22
|
+
"*.pem",
|
|
23
|
+
"*.keystore",
|
|
24
|
+
"*.jks",
|
|
25
|
+
"*.tfstate",
|
|
26
|
+
"*.tfstate.backup",
|
|
27
|
+
"*.tfvars",
|
|
28
|
+
"*.tfvars.json",
|
|
29
|
+
"*.key",
|
|
30
|
+
"*.pfx",
|
|
31
|
+
"id_rsa*",
|
|
32
|
+
"id_ed25519*",
|
|
33
|
+
"*.ppk",
|
|
34
|
+
".netrc",
|
|
35
|
+
".npmrc",
|
|
36
|
+
".pypirc",
|
|
37
|
+
)
|
|
38
|
+
# Templates such as .env.example hold no secrets. The exception applies to file patterns only, so nothing under an
|
|
39
|
+
# excluded folder comes back.
|
|
40
|
+
EXAMPLE_EXCEPTION = "!*.example"
|
|
41
|
+
BUILTIN_DIRECTORY_PATTERNS: tuple[str, ...] = (".terraform/",)
|
|
42
|
+
# gitleaks obeys these from the folder it scans, so they never reach source/.
|
|
43
|
+
SCANNER_SETTINGS_PATTERNS: tuple[str, ...] = (".gitleaksignore", ".gitleaks.toml")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Code points HFS+ ignores in names, so ".g\u200cit" lands as ".git" (git's is_hfs_dotgit checks the same ones).
|
|
47
|
+
IGNORED_IN_NAMES = dict.fromkeys([*range(0x200C, 0x2010), *range(0x202A, 0x202F), *range(0x206A, 0x2070), 0xFEFF])
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def on_disk_key(path: str) -> str:
|
|
51
|
+
"""The name a case-insensitive, normalizing file system (APFS, HFS+) sees for a path."""
|
|
52
|
+
return unicodedata.normalize("NFC", path.translate(IGNORED_IN_NAMES)).casefold()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Reason(StrEnum):
|
|
56
|
+
SECRET_PATTERN = "secret pattern" # noqa: S105 - a reason label, not a password
|
|
57
|
+
IGNORED = "ignore rules"
|
|
58
|
+
GITLEAKS = "gitleaks"
|
|
59
|
+
SCANNER_SETTINGS = "scanner settings"
|
|
60
|
+
NOT_A_FILE = "symlink or submodule"
|
|
61
|
+
UNSAFE_PATH = "unsafe path"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ExclusionRules:
|
|
65
|
+
"""Decides whether a path in a target exists for Codetrail."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, ignore_lines: Sequence[str]) -> None:
|
|
68
|
+
self._secret_files = GitIgnoreSpec.from_lines([*BUILTIN_FILE_PATTERNS, EXAMPLE_EXCEPTION])
|
|
69
|
+
self._secret_directories = GitIgnoreSpec.from_lines(BUILTIN_DIRECTORY_PATTERNS)
|
|
70
|
+
self._scanner_settings = GitIgnoreSpec.from_lines(SCANNER_SETTINGS_PATTERNS)
|
|
71
|
+
# Ignore lines match in any case too, as git's do on macOS.
|
|
72
|
+
self._ignored = GitIgnoreSpec.from_lines([line.lower() for line in ignore_lines])
|
|
73
|
+
|
|
74
|
+
def reason(self, path: str) -> Reason | None:
|
|
75
|
+
# The built-in patterns are lower-case and match in any case: macOS volumes ignore case.
|
|
76
|
+
folded = path.lower()
|
|
77
|
+
if self._secret_directories.match_file(folded) or self._secret_files.match_file(folded):
|
|
78
|
+
return Reason.SECRET_PATTERN
|
|
79
|
+
if self._scanner_settings.match_file(on_disk_key(path)):
|
|
80
|
+
return Reason.SCANNER_SETTINGS
|
|
81
|
+
if self._ignored.match_file(folded):
|
|
82
|
+
return Reason.IGNORED
|
|
83
|
+
return None
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Scans for secrets with gitleaks (design section 3.4; ADR 0002).
|
|
2
|
+
|
|
3
|
+
gitleaks always runs with Codetrail's own configuration, ignores `gitleaks:allow` comments and `.gitleaksignore`
|
|
4
|
+
files, and never sees GITLEAKS_* settings, so nothing in a target can switch the scan off. Findings carry the path,
|
|
5
|
+
the rule and the line, never the value. A missing, failing or stuck gitleaks raises, so callers fail closed, with what
|
|
6
|
+
it printed. A mise shim is resolved to its binary first, since gitleaks runs in an empty folder where no version is set.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import shutil
|
|
15
|
+
import signal
|
|
16
|
+
import subprocess
|
|
17
|
+
import tempfile
|
|
18
|
+
from contextlib import suppress
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from importlib.resources import as_file, files
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from codetrail.config import ToolsSettings
|
|
24
|
+
from codetrail.errors import CodetrailError
|
|
25
|
+
from codetrail.repo.rules import on_disk_key
|
|
26
|
+
|
|
27
|
+
COMMON_FLAGS = (
|
|
28
|
+
"--report-format", "json", "--redact", "--no-banner", "--exit-code", "0", "--ignore-gitleaks-allow",
|
|
29
|
+
"--gitleaks-ignore-path", os.devnull, "--log-level", "error",
|
|
30
|
+
) # fmt: skip
|
|
31
|
+
POINT_AT_ANOTHER = "To run a particular gitleaks, set [tools] gitleaks in the configuration to its absolute path."
|
|
32
|
+
RAISE_THE_TIMEOUT = "For a large repository, raise [tools] gitleaks_timeout_seconds in the configuration."
|
|
33
|
+
ESCAPE_SEQUENCES = re.compile(r"\x1b\[[0-9;?]*[A-Za-z]|[^\x20-\x7e]")
|
|
34
|
+
MAX_EXPLANATION = 300
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Finding:
|
|
39
|
+
path: str
|
|
40
|
+
rule: str
|
|
41
|
+
line: int
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SecretScanner:
|
|
45
|
+
def __init__(self, tools: ToolsSettings) -> None:
|
|
46
|
+
self._executable = tools.gitleaks
|
|
47
|
+
self._timeout_seconds = tools.gitleaks_timeout_seconds
|
|
48
|
+
|
|
49
|
+
def scan_directory(self, root: Path) -> list[Finding]:
|
|
50
|
+
"""Every finding under `root`, with paths relative to it."""
|
|
51
|
+
if any(on_disk_key(child.name) == ".gitleaksignore" for child in root.iterdir()):
|
|
52
|
+
raise CodetrailError(f"{root} holds a .gitleaksignore, which gitleaks would obey; refusing to scan it.")
|
|
53
|
+
findings = self._run(["dir", str(root)], None)
|
|
54
|
+
resolved = root.resolve()
|
|
55
|
+
return [
|
|
56
|
+
Finding(path=str(Path(item.path).resolve().relative_to(resolved)), rule=item.rule, line=item.line)
|
|
57
|
+
for item in findings
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
def scan_text(self, text: str) -> list[Finding]:
|
|
61
|
+
"""Every finding in the text, with an empty path and the line within the text."""
|
|
62
|
+
return self._run(["stdin"], text.encode("utf-8", "surrogateescape"))
|
|
63
|
+
|
|
64
|
+
def _run(self, arguments: list[str], input: bytes | None) -> list[Finding]:
|
|
65
|
+
environment = {key: value for key, value in os.environ.items() if not key.startswith("GITLEAKS_")}
|
|
66
|
+
executable = self._binary(environment)
|
|
67
|
+
with as_file(files("codetrail.repo") / "gitleaks.toml") as config, tempfile.TemporaryDirectory() as folder:
|
|
68
|
+
report = Path(folder) / "report.json"
|
|
69
|
+
command = [executable, *arguments, "--config", str(config), *COMMON_FLAGS, "--report-path", str(report)]
|
|
70
|
+
# An empty working directory: `gitleaks stdin` would load ./.gitleaksignore from wherever it runs.
|
|
71
|
+
try:
|
|
72
|
+
result = subprocess.run( # noqa: S603
|
|
73
|
+
command, input=input, capture_output=True, env=environment, check=False, cwd=folder,
|
|
74
|
+
timeout=self._timeout_seconds,
|
|
75
|
+
) # fmt: skip
|
|
76
|
+
except subprocess.TimeoutExpired:
|
|
77
|
+
raise CodetrailError(
|
|
78
|
+
f"gitleaks didn't finish within {self._timeout_seconds:g} seconds "
|
|
79
|
+
f"([tools] gitleaks_timeout_seconds); the scan didn't complete. {RAISE_THE_TIMEOUT}"
|
|
80
|
+
) from None
|
|
81
|
+
if result.returncode != 0 or not report.exists():
|
|
82
|
+
raise CodetrailError(
|
|
83
|
+
f"gitleaks failed (exit code {result.returncode}); the scan didn't complete. "
|
|
84
|
+
f"It said: {_explanation(result.stderr)}. {POINT_AT_ANOTHER}"
|
|
85
|
+
)
|
|
86
|
+
items = json.loads(report.read_text() or "[]")
|
|
87
|
+
return [Finding(path=item["File"], rule=item["RuleID"], line=int(item["StartLine"])) for item in items]
|
|
88
|
+
|
|
89
|
+
def _binary(self, environment: dict[str, str]) -> str:
|
|
90
|
+
"""The gitleaks to run; for a mise shim, the one mise picks in the folder Codetrail was started from."""
|
|
91
|
+
found = shutil.which(self._executable)
|
|
92
|
+
if found is None:
|
|
93
|
+
raise CodetrailError(
|
|
94
|
+
f"gitleaks wasn't found ({self._executable!r}); Codetrail won't read a repository without it. "
|
|
95
|
+
f"Install it (for example with `mise install`). {POINT_AT_ANOTHER}"
|
|
96
|
+
)
|
|
97
|
+
shim = Path(found)
|
|
98
|
+
mise = shim.resolve()
|
|
99
|
+
if mise.name != "mise":
|
|
100
|
+
return found
|
|
101
|
+
# A shim picks the version from its working directory, and gitleaks runs in an empty one, so ask mise here.
|
|
102
|
+
# Its own process group, killed whole on a timeout or an interrupt (the terminal's Ctrl-C no longer reaches
|
|
103
|
+
# it): a template's exec() can start commands that outlive mise.
|
|
104
|
+
with subprocess.Popen( # noqa: S603
|
|
105
|
+
[str(mise), "which", shim.name],
|
|
106
|
+
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=environment,
|
|
107
|
+
start_new_session=True,
|
|
108
|
+
) as process: # fmt: skip
|
|
109
|
+
try:
|
|
110
|
+
stdout, stderr = process.communicate(timeout=self._timeout_seconds)
|
|
111
|
+
except BaseException as error:
|
|
112
|
+
with suppress(ProcessLookupError, PermissionError):
|
|
113
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
114
|
+
process.wait()
|
|
115
|
+
if not isinstance(error, subprocess.TimeoutExpired):
|
|
116
|
+
raise
|
|
117
|
+
raise CodetrailError(
|
|
118
|
+
f"{found} is a mise shim, and mise didn't say which gitleaks it runs here within "
|
|
119
|
+
f"{self._timeout_seconds:g} seconds ([tools] gitleaks_timeout_seconds); the scan didn't run. "
|
|
120
|
+
f"Check the mise configuration in the folder Codetrail was started from. {POINT_AT_ANOTHER}"
|
|
121
|
+
) from None
|
|
122
|
+
binary = Path(stdout.decode("utf-8", "replace").strip())
|
|
123
|
+
if process.returncode != 0 or not binary.is_absolute():
|
|
124
|
+
# Only mise's own error lines: it quotes the configuration line it couldn't parse on the lines after.
|
|
125
|
+
raise CodetrailError(
|
|
126
|
+
f"{found} is a mise shim, and mise couldn't say which gitleaks it runs here "
|
|
127
|
+
f"(exit code {process.returncode}). It said: {_explanation(stderr, 'mise ERROR')}. "
|
|
128
|
+
"Run Codetrail from a folder whose mise configuration sets gitleaks, or set a global version with "
|
|
129
|
+
f"`mise use -g gitleaks`. {POINT_AT_ANOTHER}"
|
|
130
|
+
)
|
|
131
|
+
# A trusted mise configuration in the folder, perhaps a target's, can name any program with a path: version.
|
|
132
|
+
installs = (shim.parent.parent / "installs" / shim.name).resolve()
|
|
133
|
+
resolved = binary.resolve() # checked and run as one path, so no link can change in between
|
|
134
|
+
if not resolved.is_relative_to(installs) or not resolved.is_file():
|
|
135
|
+
raise CodetrailError(
|
|
136
|
+
f"mise named {_explanation(stdout)} as gitleaks here, which isn't one of mise's own installs "
|
|
137
|
+
f"in {installs}; Codetrail won't run it. {POINT_AT_ANOTHER}"
|
|
138
|
+
)
|
|
139
|
+
return str(resolved)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _explanation(stderr: bytes, prefix: str = "") -> str:
|
|
143
|
+
"""A program's error lines that start with `prefix`, as one printable line without colour codes, cut short."""
|
|
144
|
+
lines = (ESCAPE_SEQUENCES.sub("", line).strip() for line in stderr.decode("utf-8", "replace").splitlines())
|
|
145
|
+
return "; ".join(line for line in lines if line.startswith(prefix) and line)[:MAX_EXPLANATION] or "nothing"
|