write_scan_meta.py 15 KB

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