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.
Files changed (171) hide show
  1. codetrail/__init__.py +3 -0
  2. codetrail/__main__.py +5 -0
  3. codetrail/agents/__init__.py +4 -0
  4. codetrail/agents/convert.py +92 -0
  5. codetrail/agents/export.py +284 -0
  6. codetrail/agents/instructions.py +42 -0
  7. codetrail/agents/protocol.py +111 -0
  8. codetrail/agents/tools.py +185 -0
  9. codetrail/assistant/__init__.py +161 -0
  10. codetrail/assistant/claude_code.py +330 -0
  11. codetrail/assistant/codex.py +260 -0
  12. codetrail/assistant/environment.py +66 -0
  13. codetrail/assistant/estimate.py +225 -0
  14. codetrail/assistant/fake.py +74 -0
  15. codetrail/assistant/guard.py +80 -0
  16. codetrail/assistant/guard_hook.py +58 -0
  17. codetrail/assistant/local.py +245 -0
  18. codetrail/assistant/local_tools.py +177 -0
  19. codetrail/assistant/prompts.py +404 -0
  20. codetrail/assistant/routing.py +86 -0
  21. codetrail/assistant/runner.py +120 -0
  22. codetrail/assistant/status.py +186 -0
  23. codetrail/assistant/usage.py +93 -0
  24. codetrail/bridge.py +292 -0
  25. codetrail/cli.py +407 -0
  26. codetrail/config.py +485 -0
  27. codetrail/database/__init__.py +60 -0
  28. codetrail/database/migrations/0001_facts.sql +34 -0
  29. codetrail/database/migrations/0002_settings.sql +5 -0
  30. codetrail/database/migrations/0003_learning.sql +34 -0
  31. codetrail/database/migrations/0004_assistant_usage.sql +24 -0
  32. codetrail/database/migrations/0005_page_failures.sql +7 -0
  33. codetrail/database/migrations/0006_metric_values.sql +9 -0
  34. codetrail/errors.py +5 -0
  35. codetrail/extract/__init__.py +249 -0
  36. codetrail/extract/adr.py +98 -0
  37. codetrail/extract/github_actions.py +140 -0
  38. codetrail/extract/openapi.py +101 -0
  39. codetrail/extract/python.py +221 -0
  40. codetrail/extract/swift.py +204 -0
  41. codetrail/extract/terraform.py +166 -0
  42. codetrail/extract/typescript.py +464 -0
  43. codetrail/facts/__init__.py +112 -0
  44. codetrail/facts/store.py +209 -0
  45. codetrail/generate/__init__.py +1 -0
  46. codetrail/generate/failures.py +31 -0
  47. codetrail/generate/outline.py +159 -0
  48. codetrail/generate/revise.py +72 -0
  49. codetrail/generate/run.py +530 -0
  50. codetrail/generate/scope.py +135 -0
  51. codetrail/generate/validate.py +218 -0
  52. codetrail/guide/__init__.py +147 -0
  53. codetrail/learn/__init__.py +143 -0
  54. codetrail/learn/routes.py +139 -0
  55. codetrail/locales/ar/LC_MESSAGES/codetrail.mo +0 -0
  56. codetrail/locales/ar/LC_MESSAGES/codetrail.po +2236 -0
  57. codetrail/locales/codetrail.pot +1942 -0
  58. codetrail/lock.py +114 -0
  59. codetrail/metrics/__init__.py +6 -0
  60. codetrail/metrics/activity.py +96 -0
  61. codetrail/metrics/churn.py +156 -0
  62. codetrail/metrics/commits.py +45 -0
  63. codetrail/metrics/coverage.py +114 -0
  64. codetrail/metrics/decisions.py +77 -0
  65. codetrail/metrics/history.py +47 -0
  66. codetrail/metrics/inventory.py +63 -0
  67. codetrail/metrics/rationale.py +74 -0
  68. codetrail/metrics/releases.py +26 -0
  69. codetrail/metrics/report.py +111 -0
  70. codetrail/metrics/repository.py +128 -0
  71. codetrail/metrics/size.py +95 -0
  72. codetrail/py.typed +0 -0
  73. codetrail/remove.py +50 -0
  74. codetrail/repo/__init__.py +4 -0
  75. codetrail/repo/git.py +56 -0
  76. codetrail/repo/gitleaks.toml +6 -0
  77. codetrail/repo/history.py +262 -0
  78. codetrail/repo/mirror.py +137 -0
  79. codetrail/repo/refresh.py +41 -0
  80. codetrail/repo/rules.py +83 -0
  81. codetrail/repo/secrets.py +145 -0
  82. codetrail/repo/signal.py +65 -0
  83. codetrail/repo/source.py +166 -0
  84. codetrail/search.py +178 -0
  85. codetrail/server.py +45 -0
  86. codetrail/system.py +484 -0
  87. codetrail/update.py +276 -0
  88. codetrail/web/__init__.py +1 -0
  89. codetrail/web/app.py +897 -0
  90. codetrail/web/diagrams.py +414 -0
  91. codetrail/web/documentation.py +146 -0
  92. codetrail/web/i18n.py +44 -0
  93. codetrail/web/navigation.py +127 -0
  94. codetrail/web/render.py +126 -0
  95. codetrail/web/repository_view.py +230 -0
  96. codetrail/web/security.py +171 -0
  97. codetrail/web/static/brand/favicon.svg +1 -0
  98. codetrail/web/static/brand/mark.svg +1 -0
  99. codetrail/web/static/css/base.css +192 -0
  100. codetrail/web/static/css/components.css +2254 -0
  101. codetrail/web/static/css/content.css +310 -0
  102. codetrail/web/static/css/layout.css +410 -0
  103. codetrail/web/static/css/print.css +42 -0
  104. codetrail/web/static/css/tokens.css +228 -0
  105. codetrail/web/static/js/api.js +49 -0
  106. codetrail/web/static/js/ask.js +182 -0
  107. codetrail/web/static/js/d2-worker.js +34 -0
  108. codetrail/web/static/js/d2.js +28 -0
  109. codetrail/web/static/js/diagram.js +52 -0
  110. codetrail/web/static/js/documentation.js +24 -0
  111. codetrail/web/static/js/learning.js +47 -0
  112. codetrail/web/static/js/main.js +38 -0
  113. codetrail/web/static/js/outline.js +44 -0
  114. codetrail/web/static/js/palette.js +211 -0
  115. codetrail/web/static/js/repository.js +528 -0
  116. codetrail/web/static/js/resize.js +126 -0
  117. codetrail/web/static/js/shortcuts.js +87 -0
  118. codetrail/web/static/js/sidebar.js +194 -0
  119. codetrail/web/static/js/theme-init.js +27 -0
  120. codetrail/web/static/js/theme.js +42 -0
  121. codetrail/web/static/js/update.js +107 -0
  122. codetrail/web/static/vendor/d2/LICENSE +375 -0
  123. codetrail/web/static/vendor/d2/LICENSE-elk +263 -0
  124. codetrail/web/static/vendor/d2/LICENSE-wasm_exec +27 -0
  125. codetrail/web/static/vendor/d2/VERSION +12 -0
  126. codetrail/web/static/vendor/d2/d2.wasm +0 -0
  127. codetrail/web/static/vendor/d2/elk.js +105914 -0
  128. codetrail/web/static/vendor/d2/wasm_exec.js +656 -0
  129. codetrail/web/static/vendor/inter/InterVariable-Italic.woff2 +0 -0
  130. codetrail/web/static/vendor/inter/InterVariable.woff2 +0 -0
  131. codetrail/web/static/vendor/inter/LICENSE.txt +92 -0
  132. codetrail/web/static/vendor/inter/VERSION +6 -0
  133. codetrail/web/static/vendor/noto-sans-arabic/NotoSansArabic-wght.woff2 +0 -0
  134. codetrail/web/static/vendor/noto-sans-arabic/OFL.txt +93 -0
  135. codetrail/web/static/vendor/noto-sans-arabic/VERSION +12 -0
  136. codetrail/web/target_view.py +63 -0
  137. codetrail/web/templates/answers.html +25 -0
  138. codetrail/web/templates/area.html +46 -0
  139. codetrail/web/templates/base.html +37 -0
  140. codetrail/web/templates/decisions.html +30 -0
  141. codetrail/web/templates/diagram.html +10 -0
  142. codetrail/web/templates/digests.html +24 -0
  143. codetrail/web/templates/documentation.html +247 -0
  144. codetrail/web/templates/error.html +9 -0
  145. codetrail/web/templates/estimate.html +48 -0
  146. codetrail/web/templates/fact.html +29 -0
  147. codetrail/web/templates/home.html +196 -0
  148. codetrail/web/templates/layout/ask_panel.html +27 -0
  149. codetrail/web/templates/layout/header.html +32 -0
  150. codetrail/web/templates/layout/icons.html +27 -0
  151. codetrail/web/templates/layout/outline.html +4 -0
  152. codetrail/web/templates/layout/palette.html +27 -0
  153. codetrail/web/templates/layout/shortcuts.html +27 -0
  154. codetrail/web/templates/layout/sidebar.html +79 -0
  155. codetrail/web/templates/layout/update_dialog.html +18 -0
  156. codetrail/web/templates/macros.html +13 -0
  157. codetrail/web/templates/page.html +93 -0
  158. codetrail/web/templates/progress.html +46 -0
  159. codetrail/web/templates/repository.html +369 -0
  160. codetrail/web/templates/search.html +31 -0
  161. codetrail/web/templates/source.html +15 -0
  162. codetrail/web/templates/system.html +43 -0
  163. codetrail/web/templates/system_arrows.html +17 -0
  164. codetrail/web/templates/system_legend.html +9 -0
  165. codetrail/web/templates/update_log.html +30 -0
  166. codetrail/web/trend.py +22 -0
  167. codetrail_cli-1.4.1.dist-info/METADATA +372 -0
  168. codetrail_cli-1.4.1.dist-info/RECORD +171 -0
  169. codetrail_cli-1.4.1.dist-info/WHEEL +4 -0
  170. codetrail_cli-1.4.1.dist-info/entry_points.txt +3 -0
  171. 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)
@@ -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)
@@ -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"