write_scan_meta.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  1. #!/usr/bin/env python3
  2. """Write scan-meta.json for a run: the record of what was scanned.
  3. Mints the scan's id and captures, from git itself: the revision, the scan
  4. root's path within the repository, the credential-free https form of its
  5. remote and, for a whole-repository scan, the tree's top-level directories,
  6. printed as a JSON array on a `top_level_dirs:` line and recorded in the meta
  7. file with any root-level symbolic links left out of them.
  8. Usage:
  9. write_scan_meta.py <run_dir> <scan_root> --mode scan|changes|commit
  10. --effort low|medium|high|max [--scope a,b] [--base <ref>]
  11. [--merge-base <sha>] [--commit <sha>]
  12. Exits 0 on success, 1 on a refusal naming what is wrong, 2 on a usage error;
  13. the file is written only on success.
  14. Python 3.9-compatible, stdlib only.
  15. """
  16. from __future__ import annotations
  17. import argparse
  18. import os
  19. import re
  20. import stat
  21. import subprocess
  22. import sys
  23. import uuid
  24. from pathlib import Path
  25. from typing import Literal, NamedTuple, TypedDict
  26. from urllib.parse import quote, unquote, urlsplit
  27. # The lib/ package lives next to this script. Python normally adds a script's own
  28. # directory to the import path, but not under -P or PYTHONSAFEPATH, so we add it here.
  29. sys.path.insert(0, str(Path(__file__).resolve().parent))
  30. from lib import absolute, console, plugin, strictjson
  31. GIT_ENV = dict(os.environ, GIT_TERMINAL_PROMPT="0")
  32. class Revision(TypedDict, total=False):
  33. """What was scanned. `versioned` is always present; the rest when in git."""
  34. versioned: bool
  35. commit: str | None
  36. parent: str | None
  37. branch: str | None
  38. dirty: bool | None
  39. sparse: Literal[True]
  40. not_checked_out_dirs: list[str]
  41. base: str | None
  42. merge_base: str | None
  43. class Args(argparse.Namespace):
  44. """The parsed command line."""
  45. run_dir: str = ""
  46. scan_root: str = ""
  47. mode: str = ""
  48. effort: str = ""
  49. scope: str = ""
  50. base: str | None = None
  51. merge_base: str | None = None
  52. commit: str | None = None
  53. class MetaError(Exception):
  54. """A refusal: the command line was well-formed but the run cannot be recorded."""
  55. def git(cwd: str, *args: str) -> str | None:
  56. """One read-only git call, prompts suppressed. None on any failure."""
  57. try:
  58. out = subprocess.run(
  59. ["git", "-C", cwd, *args],
  60. env=GIT_ENV,
  61. stdout=subprocess.PIPE,
  62. stderr=subprocess.DEVNULL,
  63. timeout=30,
  64. check=False,
  65. )
  66. except (OSError, subprocess.SubprocessError):
  67. return None
  68. if out.returncode != 0:
  69. return None
  70. return out.stdout.decode("utf-8", "surrogateescape").rstrip("\r\n")
  71. class Extent(NamedTuple):
  72. """The scan target's top-level directories, its root-level symbolic links, and
  73. the tracked top-level directories its working tree does not hold."""
  74. dirs: list[str]
  75. symlinks: list[str]
  76. absent: list[str]
  77. def tree_extent(scan_root: str) -> Extent | None:
  78. """The scan target's top-level directories, computed from the tree itself.
  79. Inside a git work tree the tracked files decide; where nothing is tracked
  80. the immediate subdirectories do. Entries are classified without following
  81. symbolic links, so nothing outside the checkout is read: a root-level
  82. symbolic link is never one of the directories, and is named in `symlinks`
  83. so the report can say it was not followed. `.git` and `CLAUDE-SECURITY-*`
  84. report directories are excluded. None when the tree could not be listed.
  85. """
  86. names: set[str] = set()
  87. symlinks: set[str] = set()
  88. listing = git(scan_root, "ls-files", "-z")
  89. if listing:
  90. for path in listing.split("\0"):
  91. top, sep, _rest = path.partition("/")
  92. if sep and top:
  93. names.add(top)
  94. elif path:
  95. try:
  96. mode = os.lstat(os.path.join(scan_root, path)).st_mode
  97. except OSError:
  98. continue
  99. if stat.S_ISLNK(mode):
  100. symlinks.add(path)
  101. elif stat.S_ISDIR(mode):
  102. names.add(path)
  103. else:
  104. try:
  105. with os.scandir(scan_root) as entries:
  106. for entry in entries:
  107. if entry.is_symlink():
  108. symlinks.add(entry.name)
  109. elif entry.is_dir(follow_symlinks=False):
  110. names.add(entry.name)
  111. except OSError:
  112. return None
  113. names.discard(".git")
  114. kept = sorted(n for n in names if not n.startswith(plugin.REPORT_DIR_PREFIX))
  115. on_disk = {n for n in kept if os.path.lexists(os.path.join(scan_root, n))}
  116. dirs = [n for n in kept if n in on_disk]
  117. return Extent(dirs, sorted(symlinks), [n for n in kept if n not in on_disk])
  118. def sparse_checkout(scan_root: str, extent: Extent | None) -> list[str] | None:
  119. """The tracked top-level directories a sparse checkout left out; None when it is not one."""
  120. if git(scan_root, "config", "--bool", "core.sparseCheckout") != "true":
  121. return None
  122. return extent.absent if extent else []
  123. REMOTE_SCHEMES = frozenset({"http", "https", "ssh", "git", "git+ssh"})
  124. def sanitize_remote(url: str | None) -> str | None:
  125. """`url` as a credential-free https URL naming the same repository, or None.
  126. Userinfo, query and fragment are stripped; the scheme becomes https and the
  127. host lowercase; a port survives only from an http or https URL; scp-like
  128. `user@host:path` is read as ssh; a trailing `/` or `.git` is dropped, so
  129. the ssh and https spellings of one repository come out equal. A URL that
  130. does not name a hosted repository is None.
  131. """
  132. text = (url or "").strip()
  133. if not text or "[" in text or "]" in text or strictjson.has_lone_surrogate(text):
  134. return None
  135. if "://" in text:
  136. try:
  137. parts = urlsplit(text)
  138. port = parts.port if parts.scheme.lower() in {"http", "https"} else None
  139. except ValueError:
  140. return None
  141. if parts.scheme.lower() not in REMOTE_SCHEMES:
  142. return None
  143. host = (parts.hostname or "").lower()
  144. location = host if port is None else f"{host}:{port}"
  145. path = parts.path
  146. else:
  147. # Userinfo splits off first: an optional user@ group backtracks and leaks the secret.
  148. rest = text.rpartition("@")[2]
  149. matched = re.match(r"([^@:/\\]{2,}):(.*)", rest)
  150. if not matched:
  151. return None
  152. location = matched[1].lower()
  153. path = matched[2]
  154. if not re.fullmatch(r"[a-z0-9.-]+(?::\d+)?", location):
  155. return None
  156. path = quote(unquote(path.strip("/"))).removesuffix(".git").rstrip("/")
  157. if not path:
  158. return None
  159. return f"https://{location}/{path}"
  160. def worktree_dirty(scan_root: str) -> bool | None:
  161. """True/False/None (unknown) for the working tree, ignoring report dirs."""
  162. status = git(scan_root, "status", "--porcelain", "--untracked-files=all")
  163. if status is None:
  164. return None
  165. for line in status.splitlines():
  166. if len(line) < len("XY P"):
  167. continue
  168. path = line[3:].split(" -> ")[-1]
  169. if any(part.startswith(plugin.REPORT_DIR_PREFIX) for part in path.split("/")[:-1]):
  170. continue
  171. return True
  172. return False
  173. def capture_revision(scan_root: str, opts: Args) -> Revision:
  174. versioned = git(scan_root, "rev-parse", "--is-inside-work-tree") == "true"
  175. if opts.mode == "commit":
  176. if not versioned:
  177. msg = f"--mode commit needs a git repository; {scan_root!r} is not one"
  178. raise MetaError(msg)
  179. commit_arg = opts.commit or ""
  180. sha = git(scan_root, "rev-parse", "--verify", "--quiet", commit_arg + "^{commit}")
  181. if not sha:
  182. msg = f"--commit {commit_arg!r} does not resolve to a commit"
  183. raise MetaError(msg)
  184. return {
  185. "versioned": True,
  186. "commit": sha,
  187. "parent": git(scan_root, "rev-parse", "--verify", "--quiet", sha + "^") or None,
  188. "branch": git(scan_root, "rev-parse", "--abbrev-ref", "HEAD"),
  189. "dirty": False,
  190. }
  191. if not versioned:
  192. return {"versioned": False}
  193. revision: Revision = {
  194. "versioned": True,
  195. "commit": git(scan_root, "rev-parse", "HEAD"),
  196. "branch": git(scan_root, "rev-parse", "--abbrev-ref", "HEAD"),
  197. "dirty": worktree_dirty(scan_root),
  198. }
  199. if opts.mode == "changes":
  200. revision["base"] = opts.base
  201. revision["merge_base"] = opts.merge_base
  202. return revision
  203. def scoped(entry: str, scan_root: str) -> str:
  204. """A scope entry relative to the scan root; an absolute one that is not inside it is refused."""
  205. if not absolute.spelled(entry):
  206. return entry
  207. literal = os.path.abspath(entry)
  208. parent, name = os.path.split(literal)
  209. try:
  210. resolutions = [
  211. literal,
  212. os.path.join(os.path.realpath(parent), name),
  213. os.path.realpath(entry),
  214. ]
  215. except OSError:
  216. resolutions = [literal]
  217. for resolved in resolutions:
  218. try:
  219. relative = os.path.relpath(resolved, scan_root).replace("\\", "/")
  220. except ValueError:
  221. continue
  222. if relative != ".." and not relative.startswith("../"):
  223. return relative
  224. msg = f"--scope entry {entry!r} is not inside the scan root {scan_root!r}"
  225. raise MetaError(msg)
  226. def parse_options(argv: list[str]) -> Args:
  227. """The parsed command line; anything wrong with it is argparse's exit 2."""
  228. ap = argparse.ArgumentParser(prog="write_scan_meta")
  229. ap.add_argument("run_dir")
  230. ap.add_argument("scan_root")
  231. ap.add_argument("--mode", required=True, choices=plugin.MODES)
  232. ap.add_argument("--effort", required=True, choices=["low", "medium", "high", "max"])
  233. ap.add_argument("--scope")
  234. ap.add_argument("--base")
  235. ap.add_argument("--merge-base", dest="merge_base")
  236. ap.add_argument("--commit")
  237. opts = ap.parse_args(argv, namespace=Args())
  238. if opts.mode == "commit" and not opts.commit:
  239. ap.error("--mode commit requires --commit <sha>")
  240. if not os.path.isdir(opts.run_dir):
  241. ap.error(f"run directory does not exist: {opts.run_dir}")
  242. return opts
  243. def main(argv: list[str]) -> int:
  244. opts = parse_options(argv)
  245. run_dir = os.path.realpath(os.path.abspath(opts.run_dir))
  246. scan_root = os.path.realpath(os.path.abspath(opts.scan_root))
  247. revision = capture_revision(scan_root, opts)
  248. extent = tree_extent(scan_root)
  249. absent = sparse_checkout(scan_root, extent) if revision.get("versioned") else None
  250. if absent is not None:
  251. revision["sparse"] = True
  252. revision["not_checked_out_dirs"] = absent
  253. scan_prefix = (
  254. git(scan_root, "rev-parse", "--show-prefix") if revision.get("versioned") else None
  255. )
  256. remote = (
  257. sanitize_remote(git(scan_root, "remote", "get-url", "origin"))
  258. if revision.get("versioned")
  259. else None
  260. )
  261. scope = [scoped(entry.strip(), scan_root) for entry in opts.scope.split(",") if entry.strip()]
  262. if scope and all(s in {".", "./"} for s in scope):
  263. scope = []
  264. whole_repo = opts.mode == "scan" and not scope
  265. if whole_repo and extent is None:
  266. sys.stderr.write(f"write_scan_meta: could not list {scan_root}; top_level_dirs unknown\n")
  267. top_level, symlinks = (extent.dirs, extent.symlinks) if whole_repo and extent else (None, None)
  268. if symlinks:
  269. sys.stderr.write(
  270. "write_scan_meta: root-level symbolic links not followed, "
  271. f"left out of top_level_dirs: {', '.join(symlinks)}\n"
  272. )
  273. meta: dict[str, object] = {
  274. "scan_id": str(uuid.uuid4()),
  275. "scan_root": scan_root,
  276. "scan_prefix": scan_prefix,
  277. "remote": remote,
  278. "run_dir": run_dir,
  279. "flow": "scan" if opts.mode == "scan" else "changes",
  280. "agent": f"{plugin.NAME}:{plugin.NAME}",
  281. "mode": opts.mode,
  282. "scope": scope,
  283. "effort": opts.effort,
  284. "model": None,
  285. "revision": revision,
  286. "revision_source": "self-reported",
  287. "top_level_dirs": top_level,
  288. "unfollowed_symlinks": symlinks,
  289. }
  290. path = os.path.join(run_dir, "scan-meta.json")
  291. with open(path, "w", encoding="utf-8", newline="\n") as out:
  292. out.write(strictjson.text(meta, indent=2) + "\n")
  293. sys.stdout.write(f"scan-meta.json written: {path}\n")
  294. sys.stdout.write(f"revision: {revision.get('commit') or 'UNVERSIONED'}\n")
  295. if absent is not None:
  296. listed = strictjson.text(absent)
  297. sys.stdout.write(f"sparse checkout: top-level directories not checked out: {listed}\n")
  298. sys.stdout.write(f"top_level_dirs: {strictjson.text(top_level)}\n")
  299. return 0
  300. if __name__ == "__main__":
  301. console.tolerate_undecodable_names()
  302. try:
  303. sys.exit(main(sys.argv[1:]))
  304. except MetaError as error:
  305. sys.stderr.write(f"write_scan_meta: {error}\n")
  306. sys.exit(1)
  307. except OSError as error:
  308. sys.stderr.write(f"write_scan_meta: could not write the run's output: {error}\n")
  309. sys.exit(1)