From ebbd2585dadf0edefbe94ce86bd1293b0d8300e7 Mon Sep 17 00:00:00 2001 From: Remi Antczak <140408771+remigiusz-antczak@users.noreply.github.com> Date: Sat, 31 Oct 2026 01:12:14 +0100 Subject: [PATCH] Wave 12: route-auth lead-finder, install update hygiene, heavy lease in a sandbox, "new": null for dirty files Review (deep-code-review): - scripts/route_auth_map.py DIR: a report-only, stdlib-only lead-finder for missing authentication (CWE-306/862). It inventories HTTP handlers (Next.js app routes, pages/api, "use server" exports, Express, Router, Hono and Fastify calls, FastAPI and Flask decorators) and prints one row per handler with the auth evidence it found. A mutating handler with no evidence is HIGH, a GET with none is INFO. A Next middleware or proxy whose matcher covers the route counts as evidence when it names an identity check or can answer 401/403, so a custom-named session gate counts. A quoted /path literal in that file reads as an allow-list entry (a trailing (.*), /* or /:name* is dropped first), unless it is a prefix of a matcher entry or sits in the condition of an if whose body can refuse. Known ceilings: conditions are not evaluated; a scope check written as a ternary, an && chain, a switch case or a redirect-only body reads as an exemption; regex, computed or imported allow-lists are not read; and a 401/403 that checks no identity (CSRF, geo, rate limit) also counts. - review_checks.sh --full runs it as tool route-auth (HIGH rows as rule missing-auth); references/security-appsec.md routes it from the Anonymous GET sweep. - review_checks.sh diff mode: "new" is null (unknown) for every row in a file that differs from HEAD in the working tree. The file list comes from a lock-free git status that leaves .git/index alone; a failed read omits the key and says so on stderr. method-situational.md documents the third value. Install (install.sh, perun_marker.py, vendor_drift.py): - A version bump of an unedited install leaves no skill backup and no .new copies: the fresh backup is dropped when it equals the previous install recorded in the marker (a backup holding any entry that install did not write is kept), an unedited slash command is refreshed in place, and an existing .new / .new-N that already holds the shipped file stops a duplicate. Install deletes no file it did not create in that run, so .new files already on disk stay. Edited, pre-Perun and differing files are never touched. - The install summary's Generated list scans only the paths install writes. It used to scan whole host dirs for newer files, so under a home target it also listed session transcripts, todos and other files that other processes wrote during the run. Known ceiling: a file another process writes inside one of those paths during the run (a rewritten settings.local.json, a new file in .claude/commands) still lists. - vendor_drift.py compares an "(install placeholder)" stub with the docs file install.sh copies over it, so a clean install no longer reads as drift; a missing docs file fails closed. Delivery (perun_policy.py, stage_run.py): - On macOS, the heavy-job throttle and the exclusive train lease work inside a Claude Code sandbox. heavy-acquire could not write its lease under the read-only user cache dir, so sandboxed scanners ran unleased and no other session could count them. Writers now use the first writable of the usual dir and a per-user fallback under the sandbox temp dir (only a real directory owned by the user with mode 0700, with no symlinked or foreign directory below it), and every reader, including the heavy_gate.py hook, checks both. PERUN_HEAVY_DIR still pins one dir. Other platforms keep the 1.628.0 behaviour. - Readers open lease files without blocking and skip anything that is not a regular file, so a planted FIFO cannot hang the hook. stage_run only unlinks a stale lease outside the usual dir and never signals a process group named there. - Known ceiling: anything running in the sandbox can write the fallback, so it can forge or block a lease, or stall another sandboxed writer, and so delay or deny heavy commands. The same user's sessions already share that temp dir. Tests: 82/82 affected suite sections (run-ci-tests.sh --affected 6eb3f0399, run before the install-summary test fold, which changes only an install.sh comment, that test and a changelog line); test-install-hygiene.sh 15/15 on the final tree. Co-Authored-By: Claude Opus 5.5 --- .claude/skills/INDEX.md | 1 + .../agentic-delivery/scripts/perun_policy.py | 136 +- .../agentic-delivery/scripts/stage_run.py | 13 +- .../agentic-delivery/scripts/vendor_drift.py | 14 +- .../skills/deep-code-review/INDEX-scripts.md | 1 + .claude/skills/deep-code-review/INDEX.md | 6 +- .../references/method-situational.md | 2 +- .../references/security-appsec.md | 3 + .../deep-code-review/scripts/review_checks.sh | 31 +- .../scripts/route_auth_map.py | 360 ++++ SHA256SUMS | 17 +- changelog.d/install-summary-own-writes.md | 3 + changelog.d/install-update-hygiene.md | 5 + changelog.d/lease-sandbox-writable.md | 7 + changelog.d/review-checks-new-null.md | 3 + changelog.d/route-auth-map.md | 4 + docs/INDEX.md | 4 +- docs/getting-started.md | 2 +- docs/perun-doctor.md | 2 +- install.sh | 92 +- scripts/ci-tests.d/install-update-hygiene.sh | 2 + scripts/ci-tests.d/lease-sandbox-fallback.sh | 2 + scripts/ci-tests.d/lease-sandbox-hardening.sh | 2 + scripts/ci-tests.d/route-auth-map.sh | 2 + scripts/mustload-budgets.tsv | 11 +- scripts/perun_marker.py | 46 +- scripts/size-budgets.tsv | 8 +- scripts/test-install-hygiene.sh | 36 + scripts/test-review-checks-new-rows.sh | 54 +- scripts/test_appsec_split.py | 4 +- scripts/test_install_commands.py | 4 +- scripts/test_install_update_hygiene.py | 410 ++++ scripts/test_lease_sandbox_fallback.py | 266 +++ scripts/test_lease_sandbox_hardening.py | 174 ++ scripts/test_prepush_gate_cache.py | 2 +- scripts/test_route_auth_map.py | 1702 +++++++++++++++++ 36 files changed, 3325 insertions(+), 106 deletions(-) create mode 100644 .claude/skills/deep-code-review/scripts/route_auth_map.py create mode 100644 changelog.d/install-summary-own-writes.md create mode 100644 changelog.d/install-update-hygiene.md create mode 100644 changelog.d/lease-sandbox-writable.md create mode 100644 changelog.d/review-checks-new-null.md create mode 100644 changelog.d/route-auth-map.md create mode 100644 scripts/ci-tests.d/install-update-hygiene.sh create mode 100644 scripts/ci-tests.d/lease-sandbox-fallback.sh create mode 100644 scripts/ci-tests.d/lease-sandbox-hardening.sh create mode 100644 scripts/ci-tests.d/route-auth-map.sh create mode 100644 scripts/test_install_update_hygiene.py create mode 100644 scripts/test_lease_sandbox_fallback.py create mode 100644 scripts/test_lease_sandbox_hardening.py create mode 100644 scripts/test_route_auth_map.py diff --git a/.claude/skills/INDEX.md b/.claude/skills/INDEX.md index caf4ac712..5ccff1cb4 100644 --- a/.claude/skills/INDEX.md +++ b/.claude/skills/INDEX.md @@ -147,6 +147,7 @@ Run `python3 /scripts/` (`bash` for `.sh`). `name: purpose — argu - post_review.sh: post review findings to a PR (opt-in). — [--post] - provision_tools.sh: make review_checks.sh scanners actually run. — [--print] [DIR] - review_checks.sh: run local, read-only analyzers over the files changed since… — [--base REF] [--tests] [--history]… +- route_auth_map.py: heuristic lead-finder for HTTP handlers with no authentication… — [--paths] DIR - score_review.py: Score a review's findings against a ground-truth file: recall… — GROUND_TRUTH.json FINDINGS.json… - security_verify.py: verify-or-drop triage of security findings. — [findings.json|-] [override-file] - sibling_sweep.py: leads for an incomplete fix: a call changed here, its… — --repo DIR --base REF [--head REF] diff --git a/.claude/skills/agentic-delivery/scripts/perun_policy.py b/.claude/skills/agentic-delivery/scripts/perun_policy.py index ced9e3149..71e765c1d 100644 --- a/.claude/skills/agentic-delivery/scripts/perun_policy.py +++ b/.claude/skills/agentic-delivery/scripts/perun_policy.py @@ -55,6 +55,8 @@ perun_policy.py heavy-release drop this shell's lease (idempotent) perun_policy.py --selftest +Leases live in `/perun/heavy` ( = `$XDG_CACHE_HOME` or `~/.cache`). On macOS only, where that is read-only (a Claude Code sandbox) writers use `/tmp/claude-/perun/heavy` (a 0700 dir you own, else skipped) and every reader checks both and skips anything that is not a regular file; `$PERUN_HEAVY_DIR` pins exactly one dir. Every sandboxed session of the user shares that fallback, so one can forge a holder there and delay or deny heavy commands (accepted). + Exit 0 ok, 2 usage / malformed policy / unknown dimension. """ import contextlib @@ -62,6 +64,7 @@ import os import re import socket +import stat import subprocess import sys import time @@ -290,11 +293,89 @@ def fanout_workers(cores: int, read, wait=30.0, sleep=time.sleep, now=time.time) def lease_dir() -> Path: - """Machine-wide lease dir: `$PERUN_HEAVY_DIR` (tests), else `$XDG_CACHE_HOME` or `~/.cache`, /perun/heavy.""" + """Machine-wide lease dir: `$PERUN_HEAVY_DIR` (tests), else `$XDG_CACHE_HOME` or `~/.cache`, /perun/heavy. Pure: touches no disk. + Writers take `writable_lease_dir()`; readers pass this to `_leases`/`lease_load`/`exclusive_holder`, which add the sandbox fallback.""" d = os.environ.get("PERUN_HEAVY_DIR") return Path(d) if d else Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache") / "perun" / "heavy" +def _fallback(create: bool = False) -> Path | None: + """macOS only (None elsewhere): the sandbox fallback lease dir, `/perun/heavy` with root `$PERUN_HEAVY_FALLBACK_ROOT` (tests) or + `/tmp/claude-`, the one place a Claude Code sandbox can write when `~/.cache` is read-only (hooks run unsandboxed, so readers + must look here too). None unless root is a real directory (not a symlink) owned by this user with mode exactly 0700, and `perun` and + `heavy` below it, where they exist, are real directories (not symlinks) owned by this user: /tmp is shared, and a planted root or + component would redirect writes or forge a lease holder. `create` makes a missing root 0700 (writers only; readers never create it). + Accepted limit: every sandboxed session of this user can write here, so one can forge a holder (a lease or `heavy.exclusive` naming a + live pid) and make the hook and the train gate delay or deny heavy commands, or plant a FIFO where a sandboxed writer is about to + create its temp or `.waiting` file and stall that writer (only reads are hardened against non-regular files). That is a same-user + denial of service, not a way to run or signal anything: readers skip non-regular files and stage_run only unlinks stale leases here. + The forged holder is pinned by test_lease_sandbox_hardening.""" + if sys.platform != "darwin": + return None + root = Path(os.environ.get("PERUN_HEAVY_FALLBACK_ROOT") or f"/tmp/claude-{os.getuid()}") + try: + if create: + with contextlib.suppress(FileExistsError): + root.mkdir(mode=0o700) + st = root.lstat() + except OSError: + return None + if not (stat.S_ISDIR(st.st_mode) and st.st_uid == os.getuid() and stat.S_IMODE(st.st_mode) == 0o700): + return None + d = root / "perun" / "heavy" + for p in (d.parent, d): # a missing one is made by a writer; a symlink or foreign dir planted in its place is refused + try: + st = p.lstat() + except FileNotFoundError: + continue + except OSError: + return None + if not (stat.S_ISDIR(st.st_mode) and st.st_uid == os.getuid()): + return None + return d + + +def lease_dirs() -> list: + """Every dir a lease can live in, for readers: `lease_dir()` plus the sandbox fallback when it passes `_fallback()` and is not the + same directory (realpath). `$PERUN_HEAVY_DIR` pins exactly that one dir. Read-only: creates nothing.""" + d = lease_dir() + fb = None if os.environ.get("PERUN_HEAVY_DIR") else _fallback() + return [d] if fb is None or os.path.realpath(fb) == os.path.realpath(d) else [d, fb] + + +def _dirs(d: Path) -> list: + """`lease_dirs()` when `d` is one of them (a reader handed the primary or the fallback sees both), else `[d]`: a dir a test or a + caller names explicitly stays isolated.""" + ds = lease_dirs() + return ds if os.path.realpath(d) in map(os.path.realpath, ds) else [d] + + +def _writable(d: Path) -> bool: + """True when a file can really be created in `d` and in its parent (where write_lease and exclusive_acquire put theirs). Creates `d` + if missing. os.access lies under a sandbox and mkdir(exist_ok) passes on a read-only dir that exists, so only a real create tells.""" + import tempfile # lazy: hooks import this module per call, only writers probe + try: + d.mkdir(parents=True, exist_ok=True) + for p in (d, d.parent): + tempfile.TemporaryFile(dir=p).close() + except OSError: + return False + return True + + +def writable_lease_dir() -> Path: + """Where this process writes leases: `lease_dir()` when a file can be created there, else (macOS only) the sandbox fallback (root + created 0700 if missing), else `lease_dir()` anyway so the caller fails as before (stage_run runs unleased, the CLI exits 2). + `$PERUN_HEAVY_DIR` pins it, and any other platform just takes `lease_dir()`, both with no probe. ponytail: `.waiting` and + `.gate.lock` live in this dir, so a sandboxed and an unsandboxed lane queue and lock apart; they still count each other's + leases (readers see both dirs).""" + d = lease_dir() + if sys.platform != "darwin" or os.environ.get("PERUN_HEAVY_DIR") or _writable(d): + return d + fb = _fallback(create=True) + return fb if fb and _writable(fb) else d + + def _alive(pid: int) -> bool: if pid <= 0: # kill(0|-n, 0) probes a process group: never a lease holder return False @@ -307,6 +388,19 @@ def _alive(pid: int) -> bool: return True +def read_lease(f: Path) -> str: + """First 4096 bytes of lease file `f` as text (a lease is under 400), OSError unless it is a regular file. Opens without blocking and + without following a symlink, then checks the open fd: the sandbox fallback is writable by confined code and read by unsandboxed + code, and a FIFO planted there would block a plain read_text forever. Every lease, holder and queue-marker read goes through here.""" + fd = os.open(f, os.O_RDONLY | os.O_NONBLOCK | os.O_NOFOLLOW) + try: + if not stat.S_ISREG(os.fstat(fd).st_mode): + raise OSError(f"not a regular file: {f}") + return os.read(fd, 4096).decode() + finally: + os.close(fd) + + LIGHT_WEIGHT = 0.25 # a light lane (read-only, docs) holds this fraction of a heavy lane's slot LIGHT_KINDS = ("light", "doc", "docs", "read-only", "readonly") LIGHT_CPU_PCT = 15.0 # an undeclared lane whose process tree burns less than this % of one core counts as light... @@ -329,11 +423,11 @@ def _leases(d: Path, now: float, exclude: int | None = None) -> list: plus an optional declared kind word (`light`, ...). stage_run.py writes six words, ` `: when the recorded pid start time differs from the live one the pid was reused and the lease is dead; an optional seventh word is the host, and a lease from another host is ignored. A lease with a dead pid, a start older than LEASE_TTL (2h) - or unreadable/malformed content is ignored, never deleted.""" + or unreadable/malformed content is ignored, never deleted. Reads every dir of `lease_dirs()` when `d` is one of them.""" out = [] - for f in d.glob("*") if d.is_dir() else []: + for f in (f for x in _dirs(d) if x.is_dir() for f in x.glob("*")): try: - t = f.read_text().split() + t = read_lease(f).split() pid, start = int(t[0]), int(t[1]) except (OSError, ValueError, IndexError): continue @@ -418,7 +512,7 @@ def heavy_acquire(d: Path, slots: int, pid: int, kind: str | None = None) -> boo load, w = lease_load(d, exclude=pid), lane_weight(kind, 0, None) wait = d / ".waiting" # a weighted stage that does not fit marks itself; newer small stages then queue behind it (no starvation) try: - queued = time.time() - wait.stat().st_mtime < 30 and wait.read_text() != str(pid) + queued = time.time() - wait.stat().st_mtime < 30 and read_lease(wait) != str(pid) except OSError: queued = False if load >= slots or (w > 1 and load > 0 and load + w > slots) or (queued and w < 1): @@ -478,14 +572,20 @@ def heavy_release(d: Path, pid: int) -> None: def exclusive_holder(d: Path): - """Pid of the live EXCLUSIVE lease (a train gate running), else None. Stored beside the lease dir + """Pid of the live EXCLUSIVE lease (a train gate running), else None; the first holder found across `lease_dirs()` when `d` is one + of them (see `_exclusive_in` for the file and its validity rules). Read-only.""" + return next(filter(None, map(_exclusive_in, _dirs(d))), None) + + +def _exclusive_in(d: Path): + """Pid of the live EXCLUSIVE lease stored beside lease dir `d`, else None. Stored beside the lease dir (`heavy.exclusive`, content ` `) so it never fills a slot. Valid only while the pid is alive AND the file's mtime is younger than LEASE_TTL: the holder renews it (exclusive_renew) at each step, so a long gate keeps it and a pid reused after a reboot (live pid, old mtime) expires. Dead pid, expired, unreadable or malformed = None (heavy_gate stays fail-open); nothing is deleted here.""" f = d.parent / "heavy.exclusive" try: - pid, _ = (int(x) for x in f.read_text().split()) + pid, _ = (int(x) for x in read_lease(f).split()) fresh = time.time() - f.stat().st_mtime < LEASE_TTL except (OSError, ValueError): return None @@ -496,8 +596,12 @@ def exclusive_acquire(d: Path, pid: int) -> bool: """Take the machine-wide exclusive lease for `pid`; False while another valid holder has it. The content (`pid ts`) is written whole to a temp file and hard-linked into place (atomic and exclusive: nobody sees an empty file, two gates cannot both win). A lease is unlinked only after two expiry checks; our own is replaced by rename. While held, - heavy_gate.py denies every heavy command ("gate running: wait"). Side effect: creates `d.parent`.""" + heavy_gate.py denies every heavy command ("gate running: wait"). Side effect: creates `d.parent`. A live holder in any + other dir of `lease_dirs()` also refuses. ponytail: that check spans two dirs, so it is not atomic across them (a sandboxed + and an unsandboxed gate started in the same instant can both win; same-dir races stay exclusive via the link).""" f = d.parent / "heavy.exclusive" + if any(h not in (None, pid) for h in map(_exclusive_in, _dirs(d))): + return False d.parent.mkdir(parents=True, exist_ok=True) tmp = d.parent / f"heavy.exclusive.{pid}.tmp" tmp.write_text(f"{pid} {int(time.time())}") @@ -507,13 +611,13 @@ def exclusive_acquire(d: Path, pid: int) -> bool: os.link(tmp, f) return True except FileExistsError: - h = exclusive_holder(d) + h = _exclusive_in(d) if h == pid: os.replace(tmp, f) return True if h is not None: return False - if exclusive_holder(d) is None: # re-check right before unlink + if _exclusive_in(d) is None: # re-check right before unlink f.unlink(missing_ok=True) return False finally: @@ -522,13 +626,13 @@ def exclusive_acquire(d: Path, pid: int) -> bool: def exclusive_renew(d: Path, pid: int) -> None: """Refresh the lease mtime while `pid` still holds it (called by the train at each step); no-op otherwise.""" - if exclusive_holder(d) == pid: + if _exclusive_in(d) == pid: os.utime(d.parent / "heavy.exclusive") def exclusive_release(d: Path, pid: int) -> None: """Drop the exclusive lease only if `pid` holds it; a missing one is fine.""" - if exclusive_holder(d) == pid: + if _exclusive_in(d) == pid: (d.parent / "heavy.exclusive").unlink(missing_ok=True) @@ -619,9 +723,9 @@ def fresh(): # idle% is cached per process: drop it so each wait iteration re-s if argv in (["heavy-exclusive"], ["heavy-exclusive-release"], ["heavy-exclusive-renew"]): # caller shell = holder hp = holder_pid() if argv != ["heavy-exclusive"]: - (exclusive_release if argv[0].endswith("release") else exclusive_renew)(lease_dir(), hp) + (exclusive_release if argv[0].endswith("release") else exclusive_renew)(writable_lease_dir(), hp) return 0 - ok = exclusive_acquire(lease_dir(), hp) + ok = exclusive_acquire(writable_lease_dir(), hp) print(hp if ok else "HELD") return 0 if ok else 1 if argv == ["heavy-load"]: @@ -630,11 +734,11 @@ def fresh(): # idle% is cached per process: drop it so each wait iteration re-s if argv == ["heavy-release"] or argv[:1] == ["heavy-acquire"] and (len(argv) == 1 or len(argv) == 3 and argv[1] == "--kind"): pid = os.getppid() # the calling shell outlives this CLI; its death frees the lease if argv == ["heavy-release"]: - heavy_release(lease_dir(), pid) + heavy_release(writable_lease_dir(), pid) return 0 import host_probe load1, cores = host_probe.read_load1_and_cores() - ok = heavy_acquire(lease_dir(), heavy_slots(cores or os.cpu_count() or 1, load1, pol["tokens"], host_probe.read_cpu_idle_pct()), + ok = heavy_acquire(writable_lease_dir(), heavy_slots(cores or os.cpu_count() or 1, load1, pol["tokens"], host_probe.read_cpu_idle_pct()), pid, argv[2] if len(argv) == 3 else None) print(pid if ok else "FULL") return 0 if ok else 1 diff --git a/.claude/skills/agentic-delivery/scripts/stage_run.py b/.claude/skills/agentic-delivery/scripts/stage_run.py index 7df6ceeb8..63aa7c2c1 100644 --- a/.claude/skills/agentic-delivery/scripts/stage_run.py +++ b/.claude/skills/agentic-delivery/scripts/stage_run.py @@ -33,7 +33,8 @@ (a lease from another host is ignored: its pids mean nothing here). If stage_run is SIGKILLed its child keeps running and the pid may be reused: perun_policy._leases ignores a lease whose recorded pid start time no longer matches, and the next stage_run kills the group of such a dead lease when the child (same pgid and start time) is still alive, then -removes the lease. ponytail: a group whose leader already exited but whose grandchildren live on is not found; add a +removes the lease (a stale lease in the macOS sandbox fallback dir is only removed, its group never signalled). +ponytail: a group whose leader already exited but whose grandchildren live on is not found; add a `ps -g` sweep if that is measured. """ import argparse @@ -129,16 +130,18 @@ def stamp(d, pid: int, own: str, pgid: int = 0, child: str = "-", kind: str = "h def reap(d) -> None: - """Kill the stage child of every lease whose stage_run is dead (or its pid reused), then drop that lease.""" + """Kill the stage child of every lease whose stage_run is dead (or its pid reused), then drop that lease. A lease in any dir but + `pp.lease_dir()` (the sandbox fallback, which confined code can write) is only dropped: its text never picks a group to signal.""" + own = os.path.realpath(d) == os.path.realpath(pp.lease_dir()) for f in d.glob("*") if d.is_dir() else []: try: - t = f.read_text().split() + t = pp.read_lease(f).split() pid, pgid = int(t[0]), int(t[4]) except (OSError, ValueError, IndexError): continue if len(t) not in (6, 7) or (len(t) == 7 and t[6] != pp.HOST) or (pp._alive(pid) and (t[3] == "-" or pp.pstart(pid) in ("", t[3]))): continue - if pgid > 1 and t[5] != "-" and pp.pstart(pgid) == t[5]: + if own and pgid > 1 and t[5] != "-" and pp.pstart(pgid) == t[5]: print(f"stage_run: reaping orphaned stage group {pgid} of dead stage_run {pid}", file=sys.stderr) if not kill_group(pgid): continue # not signalable: keep the lease so the next run retries @@ -179,7 +182,7 @@ def main(argv=None) -> int: print(f"stage_run: run dir {a.run_dir} already exists; refusing to reuse a run id (pick a fresh one)", file=sys.stderr) return 1 os.environ["PERUN_RUN_DIR"] = a.run_dir - pid, d, t0, ppid0 = os.getpid(), pp.lease_dir(), time.time(), os.getppid() + pid, d, t0, ppid0 = os.getpid(), pp.lease_dir() if a.hook else pp.writable_lease_dir(), time.time(), os.getppid() # a hook takes no lease: no probe leased = not a.hook # a commit hook is a fast check, not a heavy job: no lease, no slot wait cls = "browser" if a.name == "train-browser" else "train" if a.name.startswith("train-") else "heavy" # train_gate classes enforce = pol["train_gate"] == "enforce" diff --git a/.claude/skills/agentic-delivery/scripts/vendor_drift.py b/.claude/skills/agentic-delivery/scripts/vendor_drift.py index 9c2a3da31..c5f255b7f 100755 --- a/.claude/skills/agentic-delivery/scripts/vendor_drift.py +++ b/.claude/skills/agentic-delivery/scripts/vendor_drift.py @@ -6,6 +6,8 @@ vendors. Prints `DRIFT ` (content differs), `MISSING ` (canonical file absent from the copy) and `EXTRA ` (file only in the copy). `__pycache__`, `*.pyc` and `.DS_Store` are ignored. By default only skills TARGET has a dir for are compared; `--skills a,b` names the skills TARGET must vendor, and one whose dir is gone prints `MISSING /` (a deleted copy is drift). +A canonical file whose first line says "(install placeholder)" is hashed as `/../../docs/`, the file install.sh copies over it; +when that docs file is missing it is hashed as the stub itself, so an installed full copy then reads DRIFT (fail closed). Exit 0 = no drift, 1 = drift, a skills dir missing, or nothing compared (fail closed: zero overlap is not "clean"), 2 = usage. Read-only. Caller: scripts/update-installed.sh runs it on each TARGET after the update and warns on drift. """ @@ -17,7 +19,8 @@ SKILLS = ".claude/skills" -def tree(root): +def tree(root, docs=None): + """{relative path: sha256}. With `docs`, a stub (first line holds "(install placeholder)") is hashed as docs/ when that file exists.""" out = {} for d, _, fs in os.walk(root): for f in fs: @@ -25,7 +28,11 @@ def tree(root): continue p = os.path.join(d, f) with open(p, "rb") as fh: - out[os.path.relpath(p, root)] = hashlib.sha256(fh.read()).hexdigest() + data = fh.read() + if docs and b"(install placeholder)" in data.split(b"\n", 1)[0] and os.path.isfile(os.path.join(docs, f)): + with open(os.path.join(docs, f), "rb") as fh: + data = fh.read() + out[os.path.relpath(p, root)] = hashlib.sha256(data).hexdigest() return out @@ -35,6 +42,7 @@ def drift(target, src, want=()): if not (os.path.isdir(src) and os.path.isdir(tdir)): return None lines, compared = [], 0 + docs = os.path.normpath(os.path.join(src, "..", "..", "docs")) # /docs: what install.sh copies over a stub for name in sorted(set(want) | set(os.listdir(src))): if not os.path.isdir(os.path.join(src, name)): continue @@ -43,7 +51,7 @@ def drift(target, src, want=()): lines.append(f"MISSING {name}/") continue compared += 1 - a, b = tree(os.path.join(src, name)), tree(os.path.join(tdir, name)) + a, b = tree(os.path.join(src, name), docs), tree(os.path.join(tdir, name)) for rel in sorted(set(a) | set(b)): tag = "MISSING" if rel not in b else "EXTRA" if rel not in a else "DRIFT" if a[rel] != b[rel] else "" if tag: diff --git a/.claude/skills/deep-code-review/INDEX-scripts.md b/.claude/skills/deep-code-review/INDEX-scripts.md index b52520456..acd6f3af9 100644 --- a/.claude/skills/deep-code-review/INDEX-scripts.md +++ b/.claude/skills/deep-code-review/INDEX-scripts.md @@ -34,6 +34,7 @@ Generated by `scripts/skill_index.py` (do not hand-edit). The script half of `IN | `scripts/reaper_lint.py` | a heuristic, opt-in lint that flags a bulk/fleet-wide process-reaper pattern in a cleanup script be… | — | | `scripts/review_checks.sh` | run local, read-only analyzers over the files changed since REF (default origin/main, compared as R… | review_checks.sh [--base REF] [--tests] [--history] [--full] [--timeout SECS] | | `scripts/review_feedback.py` | repo-local accept/dismiss ledger for review findings. | — | +| `scripts/route_auth_map.py` | heuristic lead-finder for HTTP handlers with no authentication evidence (CWE-306/862). | route_auth_map.py [--paths] DIR | | `scripts/score_review.py` | Score a review's findings against a ground-truth file: recall and precision under an FP budget. | score_review.py GROUND_TRUTH.json FINDINGS.json [--max-fp N] | | `scripts/security_verify.py` | verify-or-drop triage of security findings. | security_verify.py [findings.json\|-] [override-file] Input: JSON list of findings on stdin or a file, or a semgrep --js… | | `scripts/sibling_sweep.py` | leads for an incomplete fix: a call changed here, its unchanged siblings there. | sibling_sweep.py --repo DIR --base REF [--head REF] | diff --git a/.claude/skills/deep-code-review/INDEX.md b/.claude/skills/deep-code-review/INDEX.md index 4194e55d4..09ecef6a3 100644 --- a/.claude/skills/deep-code-review/INDEX.md +++ b/.claude/skills/deep-code-review/INDEX.md @@ -98,7 +98,7 @@ Generated by `scripts/skill_index.py` (do not hand-edit; rerun it after changing | `references/method-diff.md` | the review scope is a DIFF or FILE (a large or blast-radius-flagged DIFF, a fix that is itself the… | 1968 | | `references/method-lenses.md` | method.md routes here: a call site may bypass or re-derive a shared helper, component or predicate,… | 1935 | | `references/method-report.md` | writing the Phase 5 report (delivery shape, out-of-tree vs in-repo, ledger and claim reconciliation… | 2292 | -| `references/method-situational.md` | method.md routes here (its "Situational Phase 1 checks" bullet and its perceived-absent-premise par… | 11175 | +| `references/method-situational.md` | method.md routes here (its "Situational Phase 1 checks" bullet and its perceived-absent-premise par… | 11174 | | `references/method.md` | executing a review: after stating SCOPE / START_SHA / COVERAGE_LEDGER, work the phases in order. | 7484 | | `references/migration-parity.md` | the reference you are matching is a prototype / mockup / design export (a static page, a design-too… | 7610 | | `references/mobile-appsec.md` | the target is (or ships) a native mobile client — an iOS (Swift / Objective-C) or Android (Kotlin /… | 6478 | @@ -125,7 +125,7 @@ Generated by `scripts/skill_index.py` (do not hand-edit; rerun it after changing | `references/security-agent-skills.md` | the target is a skill, installs skills, or loads skill files (SKILL.md, skill.json, manifest.json,… | 1627 | | `references/security-ai-agents.md` | the code calls an LLM, embeds/retrieves (RAG), or runs an agent that plans, calls tools, executes c… | 9786 | | `references/security-api.md` | the target serves its own HTTP, GraphQL, gRPC, or WebSocket API (always for api / service) | 4361 | -| `references/security-appsec.md` | Read when the target exposes any network surface (web app, API, service, CLI touching untrusted inp… | 6999 | +| `references/security-appsec.md` | Read when the target exposes any network surface (web app, API, service, CLI touching untrusted inp… | 7061 | | `references/security-review-mode.md` | the user asks for a security audit or security review of a whole app (not a diff). | 2697 | | `references/skill-authoring-and-size.md` | the target repository ships or installs agent skills (/SKILL.md, .claude/skills/, .cursor/skills/,… | 991 | | `references/small-diff-read-budget.md` | reviewing a small DIFF (the fast path in SKILL.md), before the first read or command. | 131 | @@ -152,4 +152,4 @@ Generated by `scripts/skill_index.py` (do not hand-edit; rerun it after changing ## Scripts -38 scripts. Read `INDEX-scripts.md` when you need a script (a trigger and CLI per script). `../INDEX.md`, `### deep-code-review`, has only each one-line purpose and arguments. +39 scripts. Read `INDEX-scripts.md` when you need a script (a trigger and CLI per script). `../INDEX.md`, `### deep-code-review`, has only each one-line purpose and arguments. diff --git a/.claude/skills/deep-code-review/references/method-situational.md b/.claude/skills/deep-code-review/references/method-situational.md index 9719be861..531064bbe 100644 --- a/.claude/skills/deep-code-review/references/method-situational.md +++ b/.claude/skills/deep-code-review/references/method-situational.md @@ -9,7 +9,7 @@ Read this when `method.md` routes here (its "Situational Phase 1 checks" bullet gitleaks, trufflehog (offline, `--no-verification`: unverified; a row is detector, file, line, never the secret), semgrep with the repo's rules or else the bundled taint rules plus p/default (`REVIEW_SEMGREP_REGISTRY=0` drops the registry pack), trivy and osv-scanner offline, osv online with `REVIEW_OSV=1`) on the changed files and prints findings JSON; `--history` scans commit history, `--tests` adds the declared `REVIEW_TEST_CMD`; stderr ends with a one-line ran/skipped summary. Its findings are leads: verify each against the code before reporting, report a `not run` line or `not-checked` row as unchecked surface, and never read an empty - list as clean (analyzers miss fail-open and logic defects). In diff mode every row carries `"new": true|false` (true when its file and line are on a line the diff adds; line 0 and file-level rows are false; `--full` and `--history` omit the key): verify new rows first, and report a pre-existing row in a touched file only when the diff makes it reachable or worse. Its gitleaks pass uses `templates/gitleaks.toml` + list as clean (analyzers miss fail-open and logic defects). In diff mode every row carries `"new"`: true on a line the diff adds, else false (line 0, file-level rows), null in a file with uncommitted edits (`--full`/`--history` omit it): verify true/null rows first, and report a pre-existing row in a touched file only when the diff makes it reachable or worse. Its gitleaks pass uses `templates/gitleaks.toml` (default rules plus a skip list for `.history/`, `fixtures/`, `testdata/` and generated registry JSON and lockfiles; the skip count is printed) when the repo has no `.gitleaks.toml`. An optional repo-root `.gitleaks-allow` (`rule path-glob sha256-of-secret` per line, format in the template) drops known false positives by content hash, so it survives history rewrites; it is read from the base ref only, and a malformed list is ignored with a warning. **Verify or drop every scanner lead:** open the cited line, confirm the code is reachable and, for a secret, that a real value is present (a name like `api_key` or `*_key` with no value, a placeholder or a fixture is not a secret); drop what fails; report diff --git a/.claude/skills/deep-code-review/references/security-appsec.md b/.claude/skills/deep-code-review/references/security-appsec.md index 52d63b24c..a5bb2a446 100644 --- a/.claude/skills/deep-code-review/references/security-appsec.md +++ b/.claude/skills/deep-code-review/references/security-appsec.md @@ -96,6 +96,9 @@ status + size; **also** follow redirects separately, grep bodies for sentinel/ca **diff anon vs authenticated body** for the same URL (subset-equality = pass — size alone misses small leaks and login-shell 200s). +Seed the route list with `python3 -I .claude/skills/deep-code-review/scripts/route_auth_map.py --paths DIR` (GET and ANY routes, +one per line). Without `--paths` it flags handlers with no auth evidence as CWE-306/862 leads; silence is not proof. + ```bash # Tune BASE + paths from the route inventory; no auth headers. Local/dev first. for path in / /api/meta /api/... ; do diff --git a/.claude/skills/deep-code-review/scripts/review_checks.sh b/.claude/skills/deep-code-review/scripts/review_checks.sh index 6675afe7b..ac54c97f0 100755 --- a/.claude/skills/deep-code-review/scripts/review_checks.sh +++ b/.claude/skills/deep-code-review/scripts/review_checks.sh @@ -3,7 +3,7 @@ # changed since REF (default origin/main, compared as REF...HEAD) and print findings as a JSON array on # stdout: [{"file","line","rule","message","text","tool"}]. `text` ("rule: message") is the key # scripts/score_review.py matches on. Findings are LEADS: verify each against the code before reporting. -# Diff mode (not --full or --history) adds "new": true|false to every row: its file+line is on a line REF...HEAD adds (rename-aware; line 0 and file-level rows are false). No row is ever dropped. +# Diff mode (not --full or --history) adds "new": true|false|null to every row: its file+line is on a line REF...HEAD adds (rename-aware; line 0 and file-level rows are false); null for every row in a file whose working copy differs from HEAD (edited, staged or not, or untracked), since most rows read the working tree and the diff reads commits. No row is ever dropped; if a git read for the flags fails, the key is omitted and stderr says so. # # Tools (linters, gitleaks, trufflehog, osv-scanner, semgrep, trivy): see references/method-situational.md. Each runs only when # installed and only on matching changed files. Secrets are redacted and never printed; trufflehog runs offline (`--no-verification --no-update`) over the changed files (every tracked file under --full), @@ -24,7 +24,7 @@ # zstd, xz, 7z, lz4, bzip2, avro, pdf, zlib; the list and its ceilings are in committed_personal_data.py). gitleaks looks for secrets, not for exported personal data. # Built-in too, base mode only (a --full scan has no diff): sibling_sweep.py lists the unchanged call sites, in non-test code, of a function whose call arguments the diff changed elsewhere (tool `sibling-sweep`, # rule `unchanged-sibling-call`, message starts "lead:"): the sibling route a fix may have missed. Leads to open, never findings; a name with more than 25 call sites is skipped; scope and ceilings are in its docstring. -# `--full` scans every tracked file (base = git's empty tree) instead of a diff; it also runs tenant_scope_check.py (SKIPPED without a *.prisma). gitleaks runs once over the working tree. gitleaks drops +# `--full` scans every tracked file (base = git's empty tree) instead of a diff; it also runs tenant_scope_check.py (SKIPPED without a *.prisma) and route_auth_map.py (tool `route-auth`, rule `missing-auth`: a state-changing HTTP handler with no authentication evidence; SKIPPED without handlers). gitleaks runs once over the working tree. gitleaks drops # fingerprints listed in the repo's .gitleaksignore (as of BASE, HEAD under --full). # gitleaks generic-api-key hits tagged likely-fixture collapse to one summary row per top fixture dir when it has >3 (every other rule, and name-only matches, stay rows); REVIEW_FIXTURE_ROWS=all lists every hit. # `--history` also runs gitleaks over BASE..HEAD (`--full-history`: the whole history). node_modules, vendor, build @@ -799,6 +799,15 @@ if [ "$full" = 1 ]; then echo "tenant-scope: heuristic; scanned $(sed -nE 's/.*"server_files": ([0-9]+).*/\1/p' "$out" | tail -n1) server files; silence is not proof of tenant isolation" >&2 while IFS=$'\t' read -r p l r m; do add tenant-scope "$p" "$l" "$r" "$m"; done < <(sed -nE 's/^(.+):([0-9]+): (HIGH|MED): ([^:]+): (.*)$/\1\t\2\t\4\t\5/p' "$out" | awk -F'\t' 'BEGIN{OFS=FS}{gsub(/ /,"-",$3)}1') fi + # route_auth_map.py: HIGH rows (a state-changing handler with no auth evidence) become rows; INFO rows stay in the script's own report. SKIPPED when it finds no handler. + if have python3 && run_one route-auth python3 -I "$here/route_auth_map.py" "$PWD" && scan_ok route-auth; then + n=$(sed -nE 's/.*"handlers": ([0-9]+).*/\1/p' "$out" | tail -n1) + if [ "${n:-0}" = 0 ]; then skip route-auth "SKIPPED: no HTTP handlers" + else + echo "route-auth: heuristic; scanned $n handlers; silence is not proof of authentication" >&2 + while IFS=$'\t' read -r p l m; do add route-auth "$p" "$l" missing-auth "$m"; done < <(sed -nE 's/^(.+):([0-9]+): HIGH: (.*)$/\1\t\2\t\3/p' "$out") + fi + fi fi lease_drop @@ -849,8 +858,10 @@ fi r=; for t in $ran; do case " $failed " in *" ${t%%(*} "*) ;; *) r+=" $t" ;; esac; done; ran=$r # "new" flags, diff mode only (a --full or --history row may sit on a line no diff added). Flags are pinned so user git config cannot change the parse: # --inter-hunk-context=0 and --no-color keep hunk counts and headers exact, the prefixes fix the "+++ b/" name, and -a gives a text file with a NUL byte (invisible-chars rows) hunks instead of "Binary files differ". -ud=; if [ "$wr" = 0 ] && [ "$hist" = 0 ]; then ud=$td/udiff; git -c core.quotePath=false diff -U0 --inter-hunk-context=0 -M -a --no-color --no-ext-diff --no-textconv --src-prefix=a/ --dst-prefix=b/ "$base"...HEAD >"$ud" || ud=; fi -REVIEW_STATE="$(git rev-parse --git-common-dir)/review-checks-last.json" HERE="$here" python3 - "$raw" "$ran" "$missing" "$nex" "$dg" "$w1" "$nice19" "$wired" "$gap" "$wiredx" "$ud" <<'PY' +# Most rows read the working tree but the diff reads commits (sibling-sweep reads HEAD, so for it null is only cautious), so a row in a file whose working copy differs from HEAD (edited, staged or not, or untracked) is flagged null, not a guessed true/false: $td/dirty is `git status --porcelain -z` (an "XY path" entry per file; --no-renames lists both sides of a rename, -uall every untracked file). --no-optional-locks keeps it a pure read: `git diff HEAD` rewrote .git/index for a file that only changed its mtime even with that flag (git 2.50.1). If a git read here fails, "new" is omitted from every row and stderr says so. +# ponytail: ignored untracked files and assume-unchanged/skip-worktree edits are not listed (add --ignored if a row ever needs the first); a file staged different from HEAD but reverted in the working tree is listed too, where null is only cautious. +ud=; if [ "$wr" = 0 ] && [ "$hist" = 0 ]; then ud=$td/udiff; { git -c core.quotePath=false diff -U0 --inter-hunk-context=0 -M -a --no-color --no-ext-diff --no-textconv --src-prefix=a/ --dst-prefix=b/ "$base"...HEAD >"$ud" && git --no-optional-locks status --porcelain -z --no-renames -uall >"$td/dirty"; } || { ud=; echo 'review_checks: could not list files that differ from HEAD; "new" omitted' >&2; }; fi +REVIEW_STATE="$(git rev-parse --git-common-dir)/review-checks-last.json" HERE="$here" python3 - "$raw" "$ran" "$missing" "$nex" "$dg" "$w1" "$nice19" "$wired" "$gap" "$wiredx" "$ud" "$td/dirty" <<'PY' import json, os, re, shlex, sys, time rows = [l.rstrip("\n").split("\t") for l in open(sys.argv[1], encoding="utf-8", errors="replace")] rows = [t for i, t in enumerate(rows) if t[3:4] != ["not-checked"] or t not in rows[:i]] # a tool failing on N files is one not-checked row @@ -886,8 +897,9 @@ for t in data: k = key(t) if not k or len(groups[k]) <= 3: out.append(row(t)) elif k not in done: done.add(k); out.append(summ(k, groups[k])) -ud = sys.argv[11] # `git diff -U0` of BASE...HEAD; "" under --full/--history (no "new" key) +ud = sys.argv[11] # `git diff -U0` of BASE...HEAD; "" under --full/--history or when a git read above failed (no "new" key) if ud: + dirty = {e[3:] for e in open(sys.argv[12], "rb").read().decode("utf-8", "replace").split("\0")} # "XY path" entries: files with a staged or working-tree change, or untracked adds, cur, inh = {}, None, False # adds: file -> [(first line, count)] of added lines; cur: the file whose hunks are being read; inh: past its first hunk header, where a "+++ " line is content for l in open(ud, encoding="utf-8", errors="replace"): if l.startswith("diff --git "): cur, inh = None, False @@ -895,8 +907,9 @@ if ud: inh = True; m = re.match(r"@@ -\S+ \+(\d+)(?:,(\d+))? @@", l) if cur and m: adds[cur].append((int(m[1]), 1 if m[2] is None else int(m[2]))) # "+N" is one line, "+N,0" none elif not inh and l.startswith("+++ b/"): cur = l[6:].rstrip("\n").split("\t")[0]; adds[cur] = [] # git adds a tab after a name with a space; a quoted name or /dev/null leaves cur unset - for r in out: # file-level rows (line 0, no file) are never new; every other path is read relative to the repo root, as the tools print it ("./x", absolute) - r["new"] = r["line"] > 0 and r["file"] not in ("", "-") and any(a <= r["line"] < a + c for a, c in adds.get(os.path.relpath(r["file"]), ())) + for r in out: # a row in a dirty file is null (unknown); else file-level rows (line 0, no file) are never new; every path is read relative to the repo root, as the tools print it ("./x", absolute) + f = os.path.relpath(r["file"]) if r["file"] not in ("", "-") else None + r["new"] = None if f in dirty else r["line"] > 0 and f is not None and any(a <= r["line"] < a + c for a, c in adds.get(f, ())) print(json.dumps(out, indent=1)) n = len(out) dg, w1 = sys.argv[5].split(), sys.argv[6].split() # lease full: "1 worker" only for tools whose single-worker flag ran; "nice 19" only when nice worked @@ -904,7 +917,9 @@ def lab(t): b = t.split("(")[0] return t + (f" (degraded: lease full{', nice 19' if sys.argv[7] == '1' else ''}{', 1 worker' if b in w1 else ''})" if b in dg else "") print(f"review_checks: {n} findings; {sys.argv[4]} files excluded; analyzers ran: {', '.join(lab(t) for t in sys.argv[2].split()) or 'none'}; skipped (not installed): {', '.join(sys.argv[3].split()) or 'none'}", file=sys.stderr) -if ud: print(f"review_checks: {sum(r['new'] for r in out)} of {n} rows are on lines this diff adds", file=sys.stderr) +if ud: + k = sum(r["new"] is None for r in out) + print(f"review_checks: {sum(r['new'] is True for r in out)} of {n} rows are on lines this diff adds" + (f", {k} unknown (file differs from HEAD)" if k else ""), file=sys.stderr) # "ran X / wired Y": wired = scanners with work in this run (their files matched); fixable = wired and either missing or running without a cache a provision warms. # argv[10] (shellcheck, ruff/pyflakes, gitleaks-history) is wired too, but provision_tools.sh cannot install it, so it is never fixable. wired = sys.argv[8].split() diff --git a/.claude/skills/deep-code-review/scripts/route_auth_map.py b/.claude/skills/deep-code-review/scripts/route_auth_map.py new file mode 100644 index 000000000..1592a56e8 --- /dev/null +++ b/.claude/skills/deep-code-review/scripts/route_auth_map.py @@ -0,0 +1,360 @@ +#!/usr/bin/env python3 +"""route_auth_map.py [--paths] DIR -- heuristic lead-finder for HTTP handlers with no authentication evidence (CWE-306/862). Report-only, stdlib only. + +Read this when you review an API surface for missing authentication or run the anonymous GET sweep in security-appsec.md: a handler that +mutates state and never touches an identity check is reachable by anyone, and generic linters do not see it. Run +`python3 -I route_auth_map.py DIR` (add `--paths` to list the GET and ANY routes, one per line). A row is a lead to verify, and SILENCE IS NOT PROOF +OF AUTHENTICATION: the scan only sees the call shapes listed below. + +1. Inventory every handler: Next.js app/**/route.(ts|tsx|js|jsx|mjs) exported GET/POST/PUT/PATCH/DELETE (function, const, destructured or re-exported), + pages/api/** (methods read from `req.method` checks, else ANY), "use server" exported async functions (METHOD ACTION), Express/Router/Hono/ + Fastify `.get|post|put|patch|delete|all("/path", ...)`, FastAPI `@x.get(...)` and Flask `@x.route(..., methods=[...])` decorators. +2. One row per handler: `path:line: SEV: METHOD route auth=`. Evidence, first hit wins: a call, decorator or `Depends(...)` naming an + identity check (IDENTITY below: getServerSession, auth(), currentUser, requireAuth*, verify*, login_required...; a Depends/Security argument + with user, auth, token, current, bearer, jwt, api_key, verify or login in it) or `req.user`/`current_user.`/`g.user` in the handler, in a + same-file function it names, or at module level (a `.use()` or Fastify addHook on the same receiver BEFORE the route counts, with its + path prefix); else `token` (a header read in the handler and a comparison with a secret/token/api-key/password word on one line); else `middleware` (a Next + middleware.ts/proxy.ts of the same app that names an identity check, or can answer 401/403 under any name (`status: 401`, `.status(403)`, + `new Response("Unauthorized"`), and whose matcher covers the route, less any route a quoted `/path` in that file names as an allow-list entry + (a trailing `(.*)`, `/*` or `/:name*` is dropped first; a path in the condition of an `if` that refuses is a scope check, not an entry)); else `none`. +3. SEV: HIGH when auth is none and the handler mutates (any method but GET; ANY and ACTION count as mutating), else INFO. + +Known limits (NOT covered, so silence proves nothing): auth applied at a mount in another file (`app.use("/api", authedRouter)`) reads none; +a middleware's path rules are read only as quoted `/path` literals: any such literal in the file (an allow-list array or Set, a Clerk-style `createRouteMatcher([...])` +list, or any other path rule narrower than the matcher) takes that route and what is under it out of the middleware's coverage, except a prefix of a matcher entry (the matcher's +own paths, `startsWith("/api")` under `/api/:path*`) and a literal in the condition of an `if` whose braced block or one statement can refuse (a scope +check, nested refusals included: `if (p.startsWith("/api/")) { return <401> }`; a braceless body runs over a line break when the next line starts with `.`, `?`, `:`, +`&&` or `||` or this one ends in `?`, `:`, `&&` or `||`); a trailing `(.*)`, `/*` or `/:name*` is dropped from a literal (`"/sign-in(.*)"` takes `/sign-in` and what is +under it out; a wildcard inside a path, `/a/*/b`, is not read) and only a route that starts with the literal is taken out, so `includes("/hook")` or `endsWith("/hook")` +leaves `/api/hook` reading `middleware` (it is open); conditions are not evaluated and a literal held in a collection or variable is not followed +to its uses, so `if (!p.startsWith("/open")) return <401>` reads `/open` as gated (it is open) and `if (!p.startsWith("/api")) return next()` reads `/api` as +open (it is gated) unless a matcher entry starts with it, as does a scope check that is no `if` with a refusing body (a ternary `p.startsWith("/api/") ? <401> : next()`, +an `&&` chain, a `switch` `case`, a body that only redirects to a login page); a regex, computed or imported allow-list is not read, so a route it lets through, or one +covered by a 401/403 that checks no identity (CSRF, geo, rate limit), still reads `middleware`, while a gate that only redirects to a login page under +custom names reads none; an identity check that calls no listed name (`ctx.state.session`) is missed; evidence one function away is followed +only one level through same-file functions (from a route's own arguments, not from `.use(guard)`), and a name in IDENTITY covers by name alone; +Flask `before_request` and FastAPI `@app.middleware` guards are not followed (those routes read none); server actions are not matched against +middleware; the shown route has no mount prefix; ANY has no method check, so a read-only +pages/api handler can be over-flagged; not handled: Django urls, NestJS decorators, `.route(path).get()` chains, calls chained on a call result +(`new Elysia().get(...)`) or split from their receiver by whitespace, `fastify.route({...})`, `app.on(...)`, a call with a spread argument +(`app.post("/x", ...guards, h)`), inline "use server", wrapped action consts (`export const a = wrap(async ...)`), template or computed route +paths, `.all("/x/*", guard)` as a gate (it reads as an ANY route), TS generics on a route call. A decorator above the route decorator +(Flask/FastAPI register the undecorated function) is ignored, so `@login_required` placed there reads none. A JS handler ends at the next line +that starts at column 0 and a Python one at the next line indented no deeper than its decorator, so minified one-line files blur spans. +Public-by-design routes (login, webhooks, health, NextAuth `[...nextauth]`) read none, or `middleware` when a gate lets them through by a rule that is not a literal path: +after reviewing one, put a `route-auth-ok` comment on its line or the line above and the row reads `auth=public`. Edit IDENTITY and RECEIVERS for house guards. + +Output: one row per handler sorted by path then line, one JSON summary line (handlers, files, high, findings = the HIGH rows) and a stderr line +`heuristic; scanned N files with M handlers; ...`. `--paths` prints the unique GET and ANY routes (dynamic segments as `:name`) and no JSON. +Exit 0 always on a readable DIR (report-only: never fails a build on its own), 2 on usage error. Reads files only; writes nothing. +ponytail: text scan on the tenant_scope_check.py masker, not an AST -- regex literals with quotes can desync it. Upgrade to a parser if +precision matters more. +""" +import json, os, re, sys + +sys.dont_write_bytecode = True # `python3 -I` drops this dir from sys.path: add it for the sibling import and leave no __pycache__ behind +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from tenant_scope_check import SKIP_DIR, close_of, lineno, mask, parts # noqa: E402 + +IDENTITY = ( # configurable: a call, decorator or (in `.use`/route middleware) bare name like these counts as an identity check + "getServerSession", "getSession", "getToken", "getAuth", "auth", "currentUser", "getUser", "getCurrentUser", + r"require(?:Auth|User|Session|Login|Admin)\w*", "withAuth", r"auth(?:Middleware|Guard)", r"authenticate\w*", r"authorize\w*", + "isAuthenticated", "isLoggedIn", r"ensure(?:Auth|LoggedIn)\w*", "protect", "validateRequest", r"verify\w*", "constructEvent", + "jwt", "expressjwt", "checkJwt", "bearerAuth", "basicAuth", "clerkMiddleware", + "login_required", "jwt_required", "token_required", "auth_required", "requires_auth", "permission_required", r"get_current_\w+") +RECEIVERS = {"app", "router", "server", "fastify"} # `.get("/x", ...)` is a route even without a framework constructor in the file +EXT = (".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py") +MARK = "route-auth-ok" +VERBS = ("GET", "POST", "PUT", "PATCH", "DELETE") +V = "|".join(VERBS) +H = V.lower() # route method names as written in code: app.get, @app.post +ID = "|".join(IDENTITY) +CALL = re.compile(r"(? :id, [...x] -> :x*.""" + return "/" + "/".join(seg(s) for s in path.split("/") if not re.fullmatch(r"\(.*\)", s)) + + +def pyroute(p): + p = re.sub(r"<(?:[\w.]+:)?(\w+)>", r":\1", re.sub(r"\{(\w+)(?::[^}]*)?\}", r":\1", p)) + return p if p.startswith("/") else "/" + p + + +def methods(k): + """GET/POST/PUT/PATCH/DELETE a pages/api file tests for (`req.method ===`, `case "X":`), in canonical order.""" + rx = r"""\bmethod\s*[!=]==?\s*"(%s)"|"(%s)"\s*[!=]==?\s*\w+\.method|\bcase\s+"(%s)"\s*:""" % (V, V, V) + found = {g for t in re.findall(rx, k.replace("'", '"')) for g in t} + return [v for v in VERBS if v in found] + + +def items(s): + """[(local, exported)] of an `export { a as B, C }` or destructuring name list (a trailing comma gives an empty pair).""" + return [(p[0], p[-1]) for p in (re.split(r"\s*(?::|\bas\b)\s*", x.strip()) for x in s.split(","))] + + +def nextdecl(m, i): + x = END.search(m, i + 1) + return x.start() if x else len(m) + + +def defs(m, py): + """{name: (start, end)} of column-0 function definitions: start just after the name (Python: at its decorators), end at the next column-0 declaration or statement.""" + out = {} + for t in (PYFN if py else JSFN).finditer(m): + e = re.compile(r"\n(?=\S)").search(m, t.end()) if py else END.search(m, t.end()) + out[next(g for g in t.groups() if g)] = (t.start() if py else t.end(), e.start() if e else len(m)) + return out + + +def p2re(p): + """Next matcher path (path-to-regexp subset) to a regex source; (?: groups survive the :name rewrite.""" + p = re.sub(r"/:\w+\*", "(?:/.*)?", p) + p = re.sub(r"/:\w+\+", "/.+", p) + p = re.sub(r"/:\w+\?", "(?:/[^/]+)?", p) + return re.sub(r"(?.get|post|...(path, ...)` routes and positional `.use(...)` / `.addHook(...)` auth.""" + rcv = RECEIVERS | set(RCV.findall(m)) + for t in re.finditer(r"\b(\w+)\.(use|addHook|%s|all)\(" % H, m): + r, k = t.group(1), t.group(2) + op = t.end() - 1 + cl = close_of(m, op) if r in rcv else -1 + ps = parts(m[op + 1:cl], mk[op + 1:cl]) if cl > 0 else None + if not ps: continue + p0 = ps[0][1].strip() + lit = LIT.fullmatch(p0) + if k in ("use", "addHook"): + gone.append((t.start(), cl + 1)) + if k == "addHook" and not HOOK.fullmatch(p0): continue + lab = ident(",".join(a[0] for a in ps), True) + if lab: uses.append((r, t.start(), lit.group(2).rstrip("*/") if lit else "", lab)) + elif lit and len(ps) > 1: + gone.append((op, cl + 1)) + hs.append((t.start(), "ANY" if k == "all" else k.upper(), lit.group(2), [(op + 2 + len(ps[0][0]), cl)], + ident(",".join(a[0] for a in ps[:-1]), True), r, None)) + + +def py_hs(m, mk, hs, gone): + """FastAPI `@x.get("/p")` and Flask `@x.route("/p", methods=[...])` decorators; the span is the route decorator, the ones below it, the signature and the body. + Decorators ABOVE the route decorator are not evidence for it: the framework registers the undecorated function.""" + for t in PYDEC.finditer(m): + op = t.end() - 1 + cl = close_of(m, op) + ps = parts(m[op + 1:cl], mk[op + 1:cl]) if cl > 0 else None + pm = ps and re.fullmatch(r"""(["'])(/[^"']*|)\1""", ps[0][1].strip()) + dm = PYDEF.search(m, cl) if pm else None + sc = close_of(m, dm.end() - 1) if dm else -1 + if sc < 0: continue + km = re.search(r"\bmethods\s*=\s*[\[(]([^\])]*)", mk[op + 1:cl]) + if t.group(2) != "route": meths = [t.group(2).upper()] + elif km: meths = [v for v in VERBS if re.search(r"""['"]%s['"]""" % v, km.group(1), re.I)] + else: meths = ["GET"] + if not meths: continue + e = re.compile(r"\n[ \t]{0,%d}(?=\S)" % len(t.group(1))).search(m, sc) + end = e.start() if e else len(m) + gone.append((t.start(), end)) + hs.append((t.start(), "|".join(meths), pyroute(pm.group(2)), [(t.start(), dm.start()), (dm.end() - 1, end)], None, None, None)) + + +def analyze(rel, raw, mws): + """[(path, line, METHOD, route, auth)] for one file.""" + py = rel.endswith(".py") + mk_ = pymask if py else mask + m, mk, rl = mk_(raw), mk_(raw, True), raw.splitlines() + fn = defs(m, py) + hs, uses, gone = [], [], [] # handlers (pos, METHOD, route, spans, pre-label, receiver, middleware base); `.use` auth; spans hidden from module-level text + if py: py_hs(m, mk, hs, gone) + else: + n, p = NAPP.match(rel), NAPI.match(rel) + if n: next_hs(m, nroute(n.group(2) or ""), n.group(1) or "", hs, gone, fn) + elif p: + segs = p.group(2).split("/") + d = re.search(r"^(?:export\s+default|module\.exports)", m, re.M) + hs.append((d.start() if d else 0, "|".join(methods(mk)) or "ANY", nroute("/".join(segs[:-1] if segs[-1] == "index" else segs)), [(0, len(m))], None, None, p.group(1) or "")) + if re.match(r"\s*(['\"])use server\1", mk): + for t in re.finditer(r"^export\s+async\s+function\s+(\w+)|^export\s+const\s+(\w+)\s*=\s*async\b", m, re.M): + gone.append((t.end(), nextdecl(m, t.start()))) + hs.append((t.start(), "ACTION", t.group(1) or t.group(2), [gone[-1]], None, None, None)) + call_hs(m, mk, hs, uses, gone) + hide = gone + list(fn.values()) + mod = ident(blank(m, hide)) # module level: what is outside every handler, function definition and `.use` call; scanned once, per handler it would make a file with many routes quadratic + out = [] + for pos, meth, route, spans, pre, rcv, mwb in hs: + ln = lineno(m, pos) + own = "\n".join(m[a:b] for a, b in spans) + got = [(ident(m[a:b]), tok(mk[a:b])) for a, b in spans + [fn[w] for w in sorted(set(re.findall(r"[\w$]+", own))) if w in fn]] + [(mod, False)] + use = next((u[3] for u in uses if u[0] == rcv and u[1] < pos and (route == u[2] or route.startswith(u[2] + "/"))), None) + pats = mws.get(mwb) if mwb is not None else None + mw = "middleware" if pats and any(x.fullmatch(route) for x in pats) else None + lab = pre or use or next((g[0] for g in got if g[0]), None) or ("token" if any(g[1] for g in got) else None) or mw or "none" + if any(MARK in l for l in rl[max(0, ln - 2):ln]): lab = "public" + out.append((rel, ln, meth, route, lab)) + return out + + +def read(root, rel): + try: + with open(os.path.join(root, rel), encoding="utf-8-sig", errors="replace") as f: return f.read() + except OSError: return None + + +def scan(root): + files = [] + for dp, dn, fn in os.walk(root): + dn[:] = sorted(d for d in dn if d not in SKIP_DIR) + for f in sorted(fn): + rel = os.path.relpath(os.path.join(dp, f), root).replace(os.sep, "/") + if f.endswith(EXT) and not TEST.search(rel): files.append(rel) + mws, rows, seen = {}, [], 0 + for rel in files: + raw = read(root, rel) if MWF.fullmatch(os.path.basename(rel)) else None + if raw is not None: mws[os.path.dirname(rel)] = mw_pats(mask(raw), mask(raw, True)) + for rel in files: + raw = read(root, rel) + if raw is None or not (NAPP.match(rel) or NAPI.match(rel) or (PYDEC if rel.endswith(".py") else JSPRE).search(raw)): continue + seen += 1 + rows += analyze(rel, raw, mws) + return sorted(rows), seen + + +def main(argv): + a = [x for x in argv[1:] if x != "--paths"] + if len(a) != 1 or not os.path.isdir(a[0]): + print("usage: route_auth_map.py [--paths] DIR", file=sys.stderr); return 2 + rows, files = scan(a[0]) + print("heuristic; scanned %d files with %d handlers; silence is not proof of authentication" % (files, len(rows)), file=sys.stderr) + if "--paths" in argv[1:]: + for r in sorted({r[3] for r in rows if {"GET", "ANY"} & set(r[2].split("|"))}): print(r) + return 0 + out = [(r, "HIGH" if r[4] == "none" and any(x != "GET" for x in r[2].split("|")) else "INFO") for r in rows] + for r, s in out: print("%s:%d: %s: %s %s auth=%s" % (r[0], r[1], s, r[2], r[3], r[4])) + hi = [(r, s) for r, s in out if s == "HIGH"] + print(json.dumps({"tool": "route_auth_map", "handlers": len(rows), "files": files, "high": len(hi), + "findings": [{"file": r[0], "line": r[1], "severity": s, "message": "%s %s auth=%s" % r[2:]} for r, s in hi]})) + return 0 + + +if __name__ == "__main__": sys.exit(main(sys.argv)) diff --git a/SHA256SUMS b/SHA256SUMS index 67046808f..620cd564d 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -115,7 +115,7 @@ cdcb175e41eecbfe0be0c8d406d520d8c9713460b21d460645afccdb06e1813a .claude/skills bd5b655445d1dca330de9023c925adb0fe582f6219e45ecb5ae8eb206eb4ce1c .claude/skills/agentic-delivery/scripts/partial_pass.py cf3176b7983d6ce2ebac93b31c44ed65e3883299f7c38d6731b882d12826fde4 .claude/skills/agentic-delivery/scripts/peer_ack.py b79368e5d6d041d639e4a64ba6c04b5f153d695ed7ae9b508c8a8c2067201ca1 .claude/skills/agentic-delivery/scripts/perun_auto_update.py -963f1e47d3491ea6f82419b91ec5fa45b109922ef958ecd6d860ed7efa310dd6 .claude/skills/agentic-delivery/scripts/perun_policy.py +d6577a194d16d945cd59b2b62dd105092c0cdb068747ccfe513f721a48eb9464 .claude/skills/agentic-delivery/scripts/perun_policy.py aa1c7b7790ae1eedfbd42033e18877887dc24a5df060fad4c6ec86f480427404 .claude/skills/agentic-delivery/scripts/pipe_mask_guard.py a208e71ad99d0eb5d195aa18a105a8e2db5b21310dee885115f33be54f9f6f7c .claude/skills/agentic-delivery/scripts/pipeline_hub.py f829b4e68baa5237bf03d2de6f736c01fb1ede33bba7952ca3dda6e2e5cdfac6 .claude/skills/agentic-delivery/scripts/pipeline_profile.py @@ -146,7 +146,7 @@ b1f5ce756aeb140eb3c0eaa9c42231b7e6c533c3a799aa1510f1dc235215811a .claude/skills 5f0542d49e149013b056e460e9b837f214c2faa38767d328d11dda8e772ed77d .claude/skills/agentic-delivery/scripts/serial_gate.py 0df8813fe0e45bf85d90867ec62df414097af477638145cfb78f67825db20ceb .claude/skills/agentic-delivery/scripts/session_brief.py 2d27148a5fdb985a391e702fac5c9f52f619e798f4a67e98485742136c8abb5c .claude/skills/agentic-delivery/scripts/spend_report.py -a5ced69cc724fb4af9d130e09813343ef810d5746d384f3a2feedc82c330b529 .claude/skills/agentic-delivery/scripts/stage_run.py +55d5f294faec643cef6d84d5a597da5bad50ad393270d9a48db2587421dd82ad .claude/skills/agentic-delivery/scripts/stage_run.py 9688829fd9b7d306f9cab4087af0a0f71f494d3db490003ef16d51f191faa146 .claude/skills/agentic-delivery/scripts/stall_guard.py 2040d063434dc6a34721f447cc7076619aee290a728c10b60c500edcd413b6af .claude/skills/agentic-delivery/scripts/subagent_brief_lint.py dfc2f70740240091bccf669534afc1d8e6807a5a0ef30ba63745d75f23a3204a .claude/skills/agentic-delivery/scripts/subagent_start_inject.py @@ -161,7 +161,7 @@ dfc2f70740240091bccf669534afc1d8e6807a5a0ef30ba63745d75f23a3204a .claude/skills f9f52a68e95903243d58b5be0e5172f2a06c78af1d207e925d8a46c7537e963c .claude/skills/agentic-delivery/scripts/train_yield.py 52ced8537f3c809f17722d979a447555387a62dff77afafbb7633e6eadf13d3d .claude/skills/agentic-delivery/scripts/union_overlap.py b759691e776aff3bcc2c92b422179bce1f24522728c472155b8bc227ff094686 .claude/skills/agentic-delivery/scripts/vacuous_test_gate.py -94f8051c37455858854fb8cae79613f4bfd49b8dcda4dfcb51e56cafc81d297b .claude/skills/agentic-delivery/scripts/vendor_drift.py +10fe341db80f83f9029bc76d9dcc54ca8024bbdde22430722051ebf2739bd4fc .claude/skills/agentic-delivery/scripts/vendor_drift.py 2bcc94d0ba8a0124a6d698e9015598c2170753502ee17967673e5bf95ef7ac13 .claude/skills/agentic-delivery/scripts/wired_check.py bbbcec171d247196c9859105239149c8d02627918b50a2b3c52775d14eed668e .claude/skills/agentic-delivery/templates/global-rules.settings.json 98ec99b865d0ebf249960be4722e16af57489a1c78af06adc4afcd5a37de9223 .claude/skills/agentic-delivery/templates/handoff.md @@ -204,8 +204,8 @@ d98f3e4380da3496061ba63be7beebe75017ab4ee1734eda9a3e798e19007b0e .claude/skills 6108f7f8d13cefb9a23ca3960cc3bc73200f8ef88fe39c9d1a9b49b9ec7222f1 .claude/skills/contribution/scripts/learning_to_pr.py 92d22cc5d0a60ba6bdb1f4a9b597823dec5b8412cec09a5be6cf93b3973f4226 .claude/skills/contribution/scripts/prefile_check.sh 18f20db537ab784460ad33cf218f7a01ac0a172fc38413f14c0e19e1ce6821cc .claude/skills/contribution/scripts/share_learning.py -63e7d07ffa54b224a83abee280456f6a24dc8222b9b55f6e249b10ea0c7f5afc .claude/skills/deep-code-review/INDEX-scripts.md -3145a0f11e8ecc8b510ae32e7de8eff9bea24e1e9bf46ed662221b7b4d386de2 .claude/skills/deep-code-review/INDEX.md +57ab71eae0e846ea4e5e8287da26679a5a010cf576fed89ca37c94e0c900180f .claude/skills/deep-code-review/INDEX-scripts.md +9fcb6a002fb7e3fe28163199783968f80b920744c83280cff211a348215c9c0d .claude/skills/deep-code-review/INDEX.md 0a06dbe95a174c6233c3edfc792d9a8ae4318cd7270ed6cb0d0f885369a2a631 .claude/skills/deep-code-review/SKILL.md cee20fa16a8c48df6dfdcbfd6d593097424f687f01993d8f99661789e2401a2e .claude/skills/deep-code-review/VERSION b97d95bc93cc23a8bc689e2e23db261a138367bcbc69d2f8a0452d5c2c6dfb68 .claude/skills/deep-code-review/evals/evals.json @@ -302,7 +302,7 @@ cfdc3609c63b7669dd0ddcab3546dbae6036e5214668802acfb0cfc9117a8f25 .claude/skills 712ff4adad24308045101ee386340a61dd80230ddcbdedc30603d18b412378d3 .claude/skills/deep-code-review/references/method-diff.md 1c428659366bb21fad34ab66f42390410dbf5191d97c07ca40db8b0476081fa2 .claude/skills/deep-code-review/references/method-lenses.md 43ce8532a5f85d5784bcfd1d052ed6d7e832902602d2f7e5e8c4fafebef0b8e2 .claude/skills/deep-code-review/references/method-report.md -9a97de6c253d9d39bfa108c0608dfc6ff820abbad64e9d5b6362695d472ea8d4 .claude/skills/deep-code-review/references/method-situational.md +b671ca3a20c315cb3c14ac418afdbcf5b5f3fd1840f6379379d66563a4fceddf .claude/skills/deep-code-review/references/method-situational.md fd354313f768db8a13a99e4012379a2c75f804a120c8e2de8556a8cac3981742 .claude/skills/deep-code-review/references/method.md 5fd086a47f860ab2b946d9409d1e1de03b1338cf832d5f1539fb3dfe63d85001 .claude/skills/deep-code-review/references/migration-parity.md 17838a58489145ecb8a466737ae5e12ac37e98260d335d160aff95d3d5354804 .claude/skills/deep-code-review/references/mobile-appsec.md @@ -329,7 +329,7 @@ d8075eb0c9482e05a96c8ded3f885f98b165a43cf19b52ba461fb90339b00cd7 .claude/skills 9aa91ea0e701c629d68345b8d408223c1ab6ab4d486e79302e1b3db8f603be5f .claude/skills/deep-code-review/references/security-agent-skills.md 7b8e6447fd2d804bdeed3b08dcf64be069c14deac6a26836cc8fa88705c272f2 .claude/skills/deep-code-review/references/security-ai-agents.md 28f1d98a261743d86b96700b97ad01160ef64d5ee37a1f16445d811858580fb6 .claude/skills/deep-code-review/references/security-api.md -50870ca8af7b29b649733407ce2a5d47ee8b1fc24fc055554a98d18c3cedea67 .claude/skills/deep-code-review/references/security-appsec.md +a5336193d4d69be0d7f29ba6482b94d1e017decf0966352a8445ef1b3ec5e4ba .claude/skills/deep-code-review/references/security-appsec.md 9fded29ad50b4aeb0c08f76788319b037ff7adf71f05146f9e19b9babd0a6328 .claude/skills/deep-code-review/references/security-review-mode.md e22c43592533cdeaa5dd6d6967d2a5519124dc2e5401b595caa182e7e0e68fb5 .claude/skills/deep-code-review/references/skill-authoring-and-size.md df698c4cbc9eb45aec1ffe62191801d713c9d6f923ed36914a703d3c9c3e71f1 .claude/skills/deep-code-review/references/small-diff-read-budget.md @@ -397,8 +397,9 @@ e2903a566deee3423cc377b000475b30d6698ccebd640cb62b86b6d2001c4822 .claude/skills 5d6910164ddf6dfd4ed62dfc365a2f615f49dc07878ed3d1f881aa9518e481c2 .claude/skills/deep-code-review/scripts/post_review.sh b01cae69d80ca560cc414c1c70ef0ddfdbb910da5b68df4e9d46cf0daf3f8b16 .claude/skills/deep-code-review/scripts/provision_tools.sh f1108e2700864195a239744b319b1d04953879589f8a2b83708bc45a080e3d86 .claude/skills/deep-code-review/scripts/reaper_lint.py -6315b222c3993981e09b9b608502b71b055cbee6ef4819b3e5dc6293029b0c7d .claude/skills/deep-code-review/scripts/review_checks.sh +eb9d92d46e92e757581a931ee63d1871664a460c3d411e9e9ed70416aed7fb0e .claude/skills/deep-code-review/scripts/review_checks.sh 8c87c02a050a975e453b52ecacbaf01481c7134466a9c67feaf409d22e36d9f7 .claude/skills/deep-code-review/scripts/review_feedback.py +9ccd6f708c2ee25b0ac4ba49644ccbd10a730f41b893c719cd1a5d6bb92cdb20 .claude/skills/deep-code-review/scripts/route_auth_map.py 859c67d6a6a781c873f47d5662d403da2321da53e6994ca3d0ccdbf74afd39ac .claude/skills/deep-code-review/scripts/score_review.py e9dfa337ad16d0022a3b7a87e0ba49acb5c2c9c0a5ee24396ff80523f2f8fa1a .claude/skills/deep-code-review/scripts/security_verify.py 90bbc7f05448c6606824423595ab1c43ee5724699c1fbe7f8f5be523be3e1b2f .claude/skills/deep-code-review/scripts/sibling_sweep.py diff --git a/changelog.d/install-summary-own-writes.md b/changelog.d/install-summary-own-writes.md new file mode 100644 index 000000000..2bedde347 --- /dev/null +++ b/changelog.d/install-summary-own-writes.md @@ -0,0 +1,3 @@ +### Fixed +- The `install.sh` end-of-run summary no longer lists a running session's transcripts (`.claude/projects/`), its todos (`.claude/todos/`) and other files outside the paths install writes as "Generated". It found the files a run wrote by scanning whole host dirs (`.claude`, `.cursor`, `.agents` and the other hosts) for anything newer than the run's start stamp, so under a home target those files appeared as if install had created them. It now scans only the subpaths install itself writes: the installed skill dirs, each host's `skills/INDEX.md`, `.claude/commands`, `.claude/agents/delivery-lane.md`, `.claude/house-defaults.md`, the install record and flags files, `settings.local.json` and the `.new` and `.bak` siblings of the settings files, the CI-gate files and their `.new` siblings, `.perun/policy.json` and `AGENTS.md`. The rows for a project target are otherwise unchanged; the one difference is that `.github/skills/INDEX.md` and `.github/workflows/dcr-gates.yml` no longer print twice with `--with-extra-hosts --with-gates`, because `.github` used to be a scan root twice. The summary still lists another process's write to a path it does scan: a new file in `.claude/commands` (scanned whole) or an edit to `.claude/settings.local.json` during the run; an exact per-file record of what install wrote would lift that limit. +- `scripts/test-install-hygiene.sh` gains 6 cases (15 in all). Two plant files with a 2099 mtime, one each in `.claude/projects/` and `.claude/todos/`, and a generic stray in every host dir and in `.perun`, `.githooks`, `scripts` and `.github/workflows`, and assert none of them appear in the summary (both fail on the previous `install.sh`). One asserts that every file a `--full --with-gates --with-terse-replies --with-extra-hosts --with-codex` install wrote, the policy and settings files included, still appears, and one asserts a `(N files)` row for every installed skill dir. Two install into an existing target with edited gate files, a `settings.local.json` and a stale operating-layer `.new`, and assert the `.new`, `.new-1`, `.bak` and local-flags rows appear, so dropping a trailing `*` from a scan root fails. The installs run with `PERUN_ROLE` unset. diff --git a/changelog.d/install-update-hygiene.md b/changelog.d/install-update-hygiene.md new file mode 100644 index 000000000..a1ad7e0cd --- /dev/null +++ b/changelog.d/install-update-hygiene.md @@ -0,0 +1,5 @@ +### Fixed +- `vendor_drift.py` no longer reports a clean install as drift. A canonical file whose first line says "(install placeholder)" is compared as `docs/` under the canonical source root, the file `install.sh` copies over it. When that docs file is missing, the stub itself is the reference, so an installed full copy reads as drift (fail closed). Before, the check `update-installed.sh` runs after an update flagged the two placeholder reference files on every clean install. +- A version bump of an unedited install no longer leaves a skill backup. `install.sh` now also drops the fresh backup when it equals what the marker says the previous install wrote (read before the new install rewrites the marker), next to the existing "identical to the fresh copy" rule. Only bytecode under `__pycache__` is ignored in that comparison, like `hash_dir`. An edited or extended copy, a pre-Perun copy (no marker entry), a backup holding any other file under `__pycache__` or any entry the previous install did not write (an empty directory, a FIFO), a symlink anywhere in it, and an unreadable marker all keep the backup. Deletion still goes through the existing per-file walk, never a shell `rm` on a computed path. +- Slash commands and `--with-gates` outputs no longer pile up `.new` copies. `write_gate_file` writes nothing when the destination, or an existing `.new` / `.new-N` next to it, already holds the shipped file, so an identical re-run no longer adds `.new-1`, `.new-2` and so on. A command that install wrote and nobody edited (its sha256 still matches the marker) is refreshed in place on a version bump and re-recorded, so uninstall still removes it. Install deletes no file it did not create in that run, so a `.new` / `.new-N` already next to it stays. Only a regular file named exactly `.new` or `.new-N` counts as already holding the shipped file; a symlink or a lookalike name never does. An edited command, a pre-Perun file and any `.new` that differs from the destination are unchanged: the file is never touched and a new `.new` is written. `--with-gates` outputs are not recorded in the marker, so they are never refreshed in place: an unedited one still gets a `.new` when its shipped template changes. +- `scripts/test_install_update_hygiene.py` (registered in `scripts/ci-tests.d/install-update-hygiene.sh`) covers all three on a fictional release source in a scratch directory with a scratch `HOME`: consecutive version bumps add no backup and no `.new`, while an edited skill, a pre-Perun copy, an edited command, a lookalike `.new-` name, a symlinked `.new`, an empty directory in a backup, a pre-existing `.new` of any kind and a differing `.new` are all kept. The existing `scripts/test_install_commands.py::test_existing_file_kept_new_written_and_uninstall_keeps_user_file` pinned the old behavior of a second identical `review.md.new-1` and now expects a single `review.md.new`. diff --git a/changelog.d/lease-sandbox-writable.md b/changelog.d/lease-sandbox-writable.md new file mode 100644 index 000000000..8d744d695 --- /dev/null +++ b/changelog.d/lease-sandbox-writable.md @@ -0,0 +1,7 @@ +### Fixed +- The machine-wide heavy-job throttle and the exclusive train lease were inert inside a Claude Code sandbox: `perun_policy.py heavy-acquire` could not create its lease file under the read-only user cache dir, exited 2, and `review_checks.sh` then ran its scanners unleased, so no sandboxed session ever left a lease for the others to count. On macOS only, writers (`heavy-acquire`, `heavy-release`, the `heavy-exclusive*` commands and `stage_run.py`) now use the first writable of the usual lease dir and a per-user fallback dir under the system temp dir, and every reader (the slot count, the exclusive holder, the `heavy_gate.py` hook and `heavy-load`) checks both, deduplicated by real path. The fallback is used only when its root, its `perun` dir and its `heavy` dir are real directories (not symlinks) owned by the current user and the root has mode 0700; otherwise it is skipped and behaviour is as before. On other platforms nothing changes. `PERUN_HEAVY_DIR` still pins exactly one dir; `PERUN_HEAVY_FALLBACK_ROOT` moves the fallback root for tests. +- Every lease, holder-file and queue-marker read now goes through `perun_policy.read_lease`, which opens without blocking or following a symlink and skips anything that is not a regular file, so a FIFO planted in a lease dir can no longer hang a read: the `heavy_gate.py` hook, `heavy-load`, the slot and queue checks of `heavy-acquire`, and `stage_run.py`. `stage_run.py` still drops a stale lease it finds outside the primary lease dir, but never signals the process group such a lease names. +- Accepted limit, documented in `perun_policy._fallback` (the forged holder is pinned by a test): every sandboxed session of the same user can write the shared fallback dir, so one of them can forge a holder (a lease or `heavy.exclusive` naming a live pid) and make the hook and the train gate delay or deny heavy commands, or plant a FIFO where a sandboxed writer is about to create its temp or `.waiting` file and stall that writer. That is a same-user delay, not a way to run or signal anything. +- `scripts/test_lease_sandbox_fallback.py` (registered in `scripts/ci-tests.d/lease-sandbox-fallback.sh`): fixtures only, never the real temp dir; the fallback cases run on macOS only. It covers an unwritable primary falling back and releasing, a fallback holder counting against the slots of a process whose primary is writable, an exclusive fallback holder blocking acquire, the hook seeing fallback holders, a symlinked, foreign-owned or loosely moded fallback being refused, and a pinned dir never using the fallback. +- `scripts/test_lease_sandbox_hardening.py` (registered in `scripts/ci-tests.d/lease-sandbox-hardening.sh`): FIFOs in a pinned dir and in the fallback never block the hook, `heavy-load`, `heavy-acquire`, `stage_run.py` or the train gate (every subprocess has a timeout), `read_lease` rejects symlinks, directories and FIFOs without leaking a descriptor, no fallback is read, written or created off macOS, a symlinked or foreign-owned `perun` or `heavy` component is refused, a writable lease dir under a read-only parent falls back, a forged fallback holder delays as documented, and the orphan reap never signals a group named by a lease outside the primary dir. +- `scripts/test_prepush_gate_cache.py` pins `PERUN_HEAVY_DIR` so the real `stage_run.py` it runs never counts leases from the machine-wide fallback dir. diff --git a/changelog.d/review-checks-new-null.md b/changelog.d/review-checks-new-null.md new file mode 100644 index 000000000..78d3c4330 --- /dev/null +++ b/changelog.d/review-checks-new-null.md @@ -0,0 +1,3 @@ +### Fixed +- `review_checks.sh` diff mode: a row's `"new"` flag (`true` when its file and line are on a line `REF...HEAD` adds, else `false`) is now `null`, meaning unknown, for every row in a file that differs from `HEAD` in the working tree, whether edited (staged or not) or untracked. Most rows are read from the working tree while the flag comes from the committed diff, so a `true` or `false` there could name the wrong line. `--full` and `--history` still omit the key and no row is dropped. With K null rows the stderr count line reads `N of T rows are on lines this diff adds, K unknown (file differs from HEAD)`; with none it is unchanged. The files come from `git --no-optional-locks status --porcelain -z --no-renames -uall`: it leaves `.git/index` alone (`git diff HEAD` rewrote it for a file that only changed its mtime, even with that flag) and takes no revision, so a root file named `HEAD` cannot break it. If the read fails, `"new"` is omitted from every row and stderr says `review_checks: could not list files that differ from HEAD; "new" omitted`, where it used to vanish silently. Ignored untracked files and `assume-unchanged` edits are not listed. `references/method-situational.md` documents the third value. +- `scripts/test-review-checks-new-rows.sh` gains 16 cases (32 in all): an unstaged edit, a staged edit to a path with a space, an untracked file named by a fake `tsc`, the clean files that keep `true` or `false` (one the diff does not touch), the exact count of null rows and the count line, `.git/index` left alone after a mtime-only change, a root file named `HEAD`, and an unreadable index. Eight of them fail on the script before this change. diff --git a/changelog.d/route-auth-map.md b/changelog.d/route-auth-map.md new file mode 100644 index 000000000..65b75b61a --- /dev/null +++ b/changelog.d/route-auth-map.md @@ -0,0 +1,4 @@ +### Added +- deep-code-review `scripts/route_auth_map.py DIR`: report-only, stdlib-only lead-finder for missing authentication (CWE-306/862). It inventories every HTTP handler (Next.js `app/**/route` exports, `pages/api/**`, `"use server"` exports, Express/Router/Hono/Fastify `.get/.post/...` calls, FastAPI and Flask decorators) and prints one `path:line: SEV: METHOD route auth=` row each. Evidence is an identity call (editable `IDENTITY` list), a Next `middleware.ts`/`proxy.ts` whose matcher covers the route (the file names an identity check, or can answer 401/403 under any name, so a custom-named session gate counts; a quoted `/path` elsewhere in the file is read as an allow-list entry, so that route and what is under it read none (a trailing `(.*)`, `/*` or `/:name*` is dropped, so a Clerk-style `createRouteMatcher(["/sign-in(.*)"])` list counts; only a route that starts with the literal is taken out, so `includes("/hook")` or `endsWith("/hook")` leaves `/api/hook` reading `middleware`), unless the literal is a prefix of a matcher entry or sits in the condition of an `if` whose body can refuse (a scope check such as `if (p.startsWith("/api/")) { return <401> }`, nested refusals included; a braceless body runs over a line break when the next line starts with `.`, `?`, `:`, `&&` or `||` or this one ends in `?`, `:`, `&&` or `||`); conditions are not evaluated, so a negated refusal reads its path as gated and a literal held in a collection or variable is read as an exemption wherever it is used; a scope check written as a ternary, an `&&` chain, a `switch` `case` or a body that only redirects to a login page is no `if` with a refusing body, so its literal reads as an exemption and the route it gates reads none; a regex or computed allow-list is not read, and a 401/403 that checks no identity (CSRF, geo, rate limit) still reads `middleware`), or a router-level `.use()` before the route; a shared-secret header compare counts but reads `token`. A mutating handler with no evidence is HIGH, a GET with none is INFO, and a `route-auth-ok` comment marks a reviewed public route. `--paths` prints the unique GET and ANY-method routes, one per line, for the anonymous GET sweep. Exit 0 on a readable DIR, 2 on a usage error; silence is not proof of authentication. +- `review_checks.sh --full` runs it as tool `route-auth` (HIGH rows as rule `missing-auth`, SKIPPED when the repo has no handler files), and `references/security-appsec.md` routes it from the Anonymous GET sweep. `scripts/test_route_auth_map.py` (registered in `scripts/ci-tests.d`) pins every rule with golden rows over fictional fixtures and mutation-checks each one. +size-budget-raise: .claude/skills/deep-code-review/references/security-appsec.md 27998→28245 one sentence and the command routing the Anonymous GET sweep to route_auth_map.py diff --git a/docs/INDEX.md b/docs/INDEX.md index f9622ba65..d86ec0f36 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -8,7 +8,7 @@ Generated by `python3 .claude/skills/agentic-delivery/scripts/focus_kit.py docs- | `docs/example-review-report.md` | Example review report (fictional): you want to see what a finished FULL deliverable looks like — confidence markers (CONFIRMED / CORROBORAT… | 1667 | | `docs/for-fleets.md` | Perun for agent fleets: If you run several agents in parallel, install the delivery overlay and wire the gates: ./install.sh --full --with-… | 3572 | | `docs/for-leaders.md` | Perun for leaders: Perun is a free checklist that makes an AI coding assistant review software the same careful way every time. | 3178 | -| `docs/getting-started.md` | Getting started with Perun: You can try the review with no install by pasting one file into an AI chat. | 2133 | +| `docs/getting-started.md` | Getting started with Perun: You can try the review with no install by pasting one file into an AI chat. | 2148 | | `docs/host-safety.md` | Host safety: what each agent host can and cannot contain: only some hosts have an OS-level sandbox, and none of them is on by default excep… | 3225 | | `docs/idea-critic-calibration-1019/PREREG.md` | Preregistration: idea-critic calibration fix, held-out run (frozen 2026-10-19, before any held-out call): exploratory, not a valid preregis… | 1273 | | `docs/idea-critic-calibration-1019/README.md` | idea-critic calibration bench (2026-10-19, exploratory): exploratory, not a valid preregistered run. | 1532 | @@ -16,7 +16,7 @@ Generated by `python3 .claude/skills/agentic-delivery/scripts/focus_kit.py docs- | `docs/notes/security-seeded-1023/README.md` | Seeded security bench: Perun vs the claude-code-security-review prompt: UNFROZEN. This bench was not preregistered: no SHA256-frozen.txt an… | 2212 | | `docs/owner-rules.md` | Mandatory owner rules (reply cap, subagent narration, decision gate): three rules ship as hooks, default-on in the operating layer, and ./i… | 937 | | `docs/peer-survey-2026-10-16.md` | Peer survey and field measurements, 2026-10-16: The roadmap in roadmap.md cites this page as the source for peer-reported pain points. | 335 | -| `docs/perun-doctor.md` | Perun doctor and uninstall: Installed scripts and hooks can sit in a repo and never run: an operating layer that needed an extra flag, a st… | 1985 | +| `docs/perun-doctor.md` | Perun doctor and uninstall: Installed scripts and hooks can sit in a repo and never run: an operating layer that needed an extra flag, a st… | 2003 | | `docs/perun-vs-rivals.md` | Perun vs other skill bundles: Harness: run rival rows with scripts/rival_eval_keys.py run ..., a wrapper around the hash-pinned scripts/riv… | 33358 | | `docs/review-cost-per-recall.md` | Review cost per real bug caught, and false alarms per case: on the one set with per-case data (S1, n=22, patch-only), Perun's real skill ca… | 2915 | | `docs/roadmap.md` | Roadmap: Two parts. The product roadmap below is what to build or fix next for Perun, produced by the CEO improvement loop: ranked by a pri… | 15600 | diff --git a/docs/getting-started.md b/docs/getting-started.md index f9e3132df..4c6c7f9a6 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -145,7 +145,7 @@ everything the install recorded. By hand instead: delete the skill folders it cr `.cursor/skills/`, `.agents/skills/` (and any extra host roots), and delete the `` … `` block from `AGENTS.md` (plus the `dcr-overlays` block if you installed overlays). If the -installer replaced an older copy, that copy is under `./skill-backups/` (a copy identical to the new one is not kept; install never deletes a backup). +installer replaced an older copy, that copy is under `./skill-backups/` (a copy identical to the new one, or to the unedited copy the previous install wrote, is not kept; install never deletes any other backup). ## Next diff --git a/docs/perun-doctor.md b/docs/perun-doctor.md index dfc568ec6..45b5557cb 100644 --- a/docs/perun-doctor.md +++ b/docs/perun-doctor.md @@ -27,7 +27,7 @@ Run from your deep-code-review checkout (pull it first: "latest" means this chec After the table the doctor (and `install.sh`, before its last three lines) prints one advisory section, **To run autonomously, add:**, from `agentic-delivery/scripts/sandbox_preflight.py`. It is read-only and never changes the exit code or writes a settings file. It reads the repo's `package.json` scripts, Makefile, git hooks, `scripts/` and python tool config plus the settings that already apply (`.claude/settings.json`, `.claude/settings.local.json`, `~/.claude/settings.json`) and prints only what is missing. The safety boundary is the OS sandbox, so the main output is sandbox config: `sandbox.enabled`, `sandbox.excludedCommands` for commands the sandbox breaks (tsx IPC, headless browsers, database clients, `gh` TLS on macOS, docker, git over SSH on macOS, followed through scripts to the command the agent types), the allowed domains, `allowLocalBinding` and `allowWrite` those flows need. It adds `permissions.deny` for destructive git, `rm -rf` and `sudo`, and `permissions.ask` for publish, deploy and release commands. `permissions.allow` patterns for the repo's test, lint and build commands (by name) and `git push HEAD` are printed with a disclosure that they run repo-controlled code and are safe only with the sandbox enabled; if the sandbox is not enabled in the settings that apply, no allow rule is printed and it tells you to enable the sandbox first. It also lists what stays blocked or asks you on purpose (release commands, force-push, writes outside the repo, other repos, `gh pr create`) and the per-host mechanism the docs confirm (Claude Code settings and `/permissions`, Codex `approval_policy` and `sandbox_mode`). Every allow rule is checked against `destructive_guard.py`. The scan is regex-based, so treat each line as a lead; sources and dates are in `docs/standards-index.md` (2026-10-16). Run it alone with `python3 .claude/skills/agentic-delivery/scripts/sandbox_preflight.py [REPO]`. -`--fix` prints the plan and what the default-on operating layer would add to your settings, then needs a typed `yes` on a terminal (or `--yes`, which prints a warning). It exits non-zero if the reinstall fails. It only re-runs `install.sh` with the recorded flags (existing skill copies move to `/skill-backups/` first; a backup identical to the fresh copy is not kept, so unchanged re-runs add nothing; install never deletes any other backup). It does not delete loose forks, touch the global copy, or invent a janitor or policy. +`--fix` prints the plan and what the default-on operating layer would add to your settings, then needs a typed `yes` on a terminal (or `--yes`, which prints a warning). It exits non-zero if the reinstall fails. It only re-runs `install.sh` with the recorded flags (existing skill copies move to `/skill-backups/` first; a backup identical to the fresh copy, or to the unedited copy the previous install wrote, is not kept, so unchanged re-runs and version bumps add nothing; install never deletes any other backup). It does not delete loose forks, touch the global copy, or invent a janitor or policy. ## Default-on operating layer diff --git a/install.sh b/install.sh index cecfb90ef..aad2287b5 100755 --- a/install.sh +++ b/install.sh @@ -408,9 +408,10 @@ install_skill_copy() { fi fi if [[ -n "${backup}" ]]; then - # A backup byte-identical to the fresh copy is just the shipped files again: drop it so unchanged re-runs add nothing. - # Any other backup (an edited copy, an older release, your own copy) is never deleted by install. - python3 "${SCRIPT_DIR}/scripts/perun_marker.py" dedupe "${backup_root}" "${backup}" "${cmp_dir}" + # A backup byte-identical to the fresh copy is just the shipped files again, and one equal to what the marker says the last install wrote + # (still the pre-record() marker here) is an earlier release nobody edited: drop either so re-runs and version bumps add nothing. + # Any other backup (an edited copy, a pre-Perun copy, your own files) is never deleted by install. + python3 "${SCRIPT_DIR}/scripts/perun_marker.py" dedupe "${backup_root}" "${backup}" "${cmp_dir}" "${TARGET_DIR}" "${dest#"${TARGET_DIR}"/}" [[ ! -d "${backup}" ]] || BACKUP_DIRS+=("${backup#"${TARGET_DIR}"/}") fi local skill_ver="unknown" @@ -504,32 +505,53 @@ if [[ -f "${HS_TSV}" ]]; then done fi -# write_gate_file — copy a CI-gates template -# into the target. NEVER overwrites an existing file at : if one is -# already there, writes .new (or .new-N on a further collision) instead -# and prints a note so the owner reviews/merges by hand. This is the only -# writer for --with-gates output; both call sites below share it so the -# never-overwrite rule cannot drift between the workflow and the runner. +# write_gate_file — copy a CI-gates template or +# slash command into the target. NEVER overwrites a file you may have edited: a +# that differs gets .new (or .new-N on a further collision) instead +# and a note so the owner reviews/merges by hand. Writes nothing when , or +# an existing .new / .new-N, already holds , so a re-run adds no +# sibling. One exception: a the install marker recorded (install wrote +# it) and nobody has edited since is refreshed in place (a release bump, via +# perun_marker.py refresh). Nothing is deleted: a .new already there stays. +# GATE_WROTE=1 tells the caller it wrote itself, fresh or refreshed, so +# it can record the new sha256. This is the only writer for --with-gates output +# and slash commands, so the never-overwrite rule cannot drift between them. +GATE_WROTE=0 write_gate_file() { - local src="$1" dest="$2" make_exec="$3" + local src="$1" dest="$2" make_exec="$3" f re='^\.new(-[0-9]+)?$' + GATE_WROTE=0 mkdir -p "$(dirname "${dest}")" - if [[ -e "${dest}" ]]; then - local newdest="${dest}.new" - if [[ -e "${newdest}" ]]; then - local n=1 - while [[ -e "${newdest}-${n}" ]]; do - n=$((n + 1)) - done - newdest="${newdest}-${n}" - fi - cp "${src}" "${newdest}" - [[ "${make_exec}" -eq 1 ]] && chmod +x "${newdest}" - echo "note: ${dest} already exists -> wrote ${newdest} instead (review and merge by hand)" - else + if [[ ! -e "${dest}" ]]; then cp "${src}" "${dest}" [[ "${make_exec}" -eq 1 ]] && chmod +x "${dest}" + GATE_WROTE=1 echo "installed: ${dest}" + return 0 + fi + cmp -s "${src}" "${dest}" && return 0 # already current + if python3 "${SCRIPT_DIR}/scripts/perun_marker.py" refresh "${TARGET_DIR}" "${dest#"${TARGET_DIR}"/}" "${src}"; then + [[ "${make_exec}" -eq 1 ]] && chmod +x "${dest}" + GATE_WROTE=1 + echo "updated: ${dest} (unedited since install)" + return 0 fi + for f in "${dest}.new" "${dest}".new-[0-9]*; do + if [[ "${f#"${dest}"}" =~ ${re} && -f "${f}" && ! -L "${f}" ]] && cmp -s "${src}" "${f}"; then # exact .new / .new-N names only + echo "note: ${dest} differs from the shipped file; ${f} already holds the shipped one (review and merge by hand)" + return 0 + fi + done + local newdest="${dest}.new" + if [[ -e "${newdest}" ]]; then + local n=1 + while [[ -e "${newdest}-${n}" ]]; do + n=$((n + 1)) + done + newdest="${newdest}-${n}" + fi + cp "${src}" "${newdest}" + [[ "${make_exec}" -eq 1 ]] && chmod +x "${newdest}" + echo "note: ${dest} already exists -> wrote ${newdest} instead (review and merge by hand)" } if [[ "${WITH_GATES}" -eq 1 ]]; then @@ -723,7 +745,8 @@ fi # Slash commands: copy commands/*.md into .claude/commands/, but only those whose skill is installed. # review, deliver always; cost-retro, perun, perun-run need agentic-ceo/agentic-delivery; # critic, council need idea-critic; idea needs idea-critic and agentic-delivery. -# Never overwrites (write_gate_file writes .new); only files written fresh go in the uninstall record. +# Never overwrites a file you edited (write_gate_file writes .new); a file this install wrote and you left alone is refreshed in place. +# Only files write_gate_file wrote itself (fresh or refreshed) go in the uninstall record, so a refresh updates the recorded sha256. if [[ -d "${SCRIPT_DIR}/commands" ]]; then for cmd in "${SCRIPT_DIR}"/commands/*.md; do name="$(basename "${cmd}" .md)" @@ -735,10 +758,8 @@ if [[ -d "${SCRIPT_DIR}/commands" ]]; then *) continue ;; esac dest="${TARGET_DIR}/.claude/commands/${name}.md" - [[ -e "${dest}" ]] && cmp -s "${cmd}" "${dest}" && continue # re-install: already current - existed=0; [[ -e "${dest}" ]] && existed=1 write_gate_file "${cmd}" "${dest}" 0 - [[ "${existed}" -eq 1 ]] || COMMAND_FILES+=(".claude/commands/${name}.md") + [[ "${GATE_WROTE}" -eq 0 ]] || COMMAND_FILES+=(".claude/commands/${name}.md") done fi @@ -1007,12 +1028,19 @@ if [[ -f "${PERUN_POLICY_FILE}" ]]; then POLICY_LINE="$(PERUN_POLICY="${PERUN_PO else POLICY_LINE="defaults (no .perun/policy.json)"; fi # Summary: every file this run wrote, split into vendored (copied verbatim from Perun) and generated (created for this repo). -perun_summary() { +# The scan roots are only the subpaths install itself writes, never a whole host dir: under a home target .claude/ also holds +# files other processes write during the run (session transcripts, todos). The globs add the .new and .bak siblings. +# ponytail: .claude/commands is scanned whole and install owns settings.local.json, so another process's mid-run write to either still lists; exact per-file record if that matters. +perun_summary() ( + cd "${TARGET_DIR}" || return 0 + shopt -s nullglob local roots=() r - for r in "${HOSTS[@]}" .perun .github .githooks scripts AGENTS.md; do [[ -e "${TARGET_DIR}/${r}" ]] && roots+=("${r}"); done - [[ ${#roots[@]} -gt 0 ]] || return 0 + for r in "${HOSTS[@]}"; do roots+=("${r}/skills/INDEX.md"); done + roots+=("${INSTALLED_DIRS[@]}" .claude/commands .claude/agents/delivery-lane.md .claude/house-defaults.md .claude/.perun-install.json + .claude/.dcr-install-flags* .claude/settings.local.json* .claude/settings.*.json.new* .github/workflows/dcr-gates.yml* + scripts/dcr-gates.sh* scripts/integrator-gate.sh* .githooks/pre-push* .perun/policy.json AGENTS.md) local list - list="$(cd "${TARGET_DIR}" && find "${roots[@]}" -type f -newer "${STAMP}" 2>/dev/null | LC_ALL=C sort | awk -F/ ' + list="$({ find "${roots[@]}" -type f -newer "${STAMP}" 2>/dev/null || true; } | LC_ALL=C sort -u | awk -F/ ' $2 == "skills" && NF >= 4 { c[$1 "/skills/" $3 "/"]++; next } $2 == "skills" || /^(\.github\/workflows\/dcr-gates\.yml|scripts\/(dcr-gates|integrator-gate)\.sh|\.githooks\/pre-push)$/ { print "V " $0; next } { print "G " $0 } @@ -1022,7 +1050,7 @@ perun_summary() { printf '%s\n' "${list}" | grep '^V ' | cut -c4- | sed 's/^/ /' || true echo "Generated (created for this repo; machine-local ones are in .git/info/exclude):" printf '%s\n' "${list}" | grep '^G ' | cut -c4- | sed 's/^/ /' || true -} +) if [[ "${DRY_RUN}" -eq 1 ]]; then exec 1>&3 diff --git a/scripts/ci-tests.d/install-update-hygiene.sh b/scripts/ci-tests.d/install-update-hygiene.sh new file mode 100644 index 000000000..befa31875 --- /dev/null +++ b/scripts/ci-tests.d/install-update-hygiene.sh @@ -0,0 +1,2 @@ +# Registers the install update-hygiene tests (no skill backup or .new file per unedited version bump, vendor_drift stub-vs-docs compare) with CI. +python3 scripts/test_install_update_hygiene.py diff --git a/scripts/ci-tests.d/lease-sandbox-fallback.sh b/scripts/ci-tests.d/lease-sandbox-fallback.sh new file mode 100644 index 000000000..178e9d8ff --- /dev/null +++ b/scripts/ci-tests.d/lease-sandbox-fallback.sh @@ -0,0 +1,2 @@ +# Registers scripts/test_lease_sandbox_fallback.py with CI (see the "Per-PR tests" step in ci.yml). +python3 scripts/test_lease_sandbox_fallback.py diff --git a/scripts/ci-tests.d/lease-sandbox-hardening.sh b/scripts/ci-tests.d/lease-sandbox-hardening.sh new file mode 100644 index 000000000..8616a2c7e --- /dev/null +++ b/scripts/ci-tests.d/lease-sandbox-hardening.sh @@ -0,0 +1,2 @@ +# Registers scripts/test_lease_sandbox_hardening.py with CI (see the "Per-PR tests" step in ci.yml). +python3 scripts/test_lease_sandbox_hardening.py diff --git a/scripts/ci-tests.d/route-auth-map.sh b/scripts/ci-tests.d/route-auth-map.sh new file mode 100644 index 000000000..15184e3b0 --- /dev/null +++ b/scripts/ci-tests.d/route-auth-map.sh @@ -0,0 +1,2 @@ +# Registers scripts/test_route_auth_map.py with CI. +python3 scripts/test_route_auth_map.py diff --git a/scripts/mustload-budgets.tsv b/scripts/mustload-budgets.tsv index ad1d27b59..685694b8d 100644 --- a/scripts/mustload-budgets.tsv +++ b/scripts/mustload-budgets.tsv @@ -287,12 +287,12 @@ # the gate has no scope-conditional class, so both stay off the floors. A DIFF or FILE review therefore loads phase-floor-light + 1968 tokens (method-diff.md, 7872 B, modelled by scripts/review_load_bytes.py). # Floors re-pinned down: phase-floor-light 21711->18368, phase-floor-full 29692->26349 (method.md 43524->29936 B, full-map.md +216 B for the routing line). No archetype row moves. # Latency + streaming split: performance-db-cost.md and api-contracts.md shed verbatim depth into performance-latency-depth.md and api-contracts-streaming.md (lazy, off every must-load set). api / service 18381->16714, data / ETL 19991->17003, lib / SDK 11038->9368. Floors phase-floor-light 18551->18578, phase-floor-full 26530->26557 (+27 each, two routing cells in full-map.md). -web 18870 -mobile 13496 -api / service 16743 +web 18913 +mobile 13539 +api / service 16786 data / ETL 17003 -agent / LLM / MCP 18431 -desktop / native 8180 +agent / LLM / MCP 18474 +desktop / native 8242 IaC / platform 4342 lib / SDK 9368 # 1.539.0: SKILL.md routes references/compact-mode.md for non-Claude models (one line). Floors: phase-floor-light 23257->23274, phase-floor-full 31476->31493 (+17 each); no archetype row changed. @@ -323,3 +323,4 @@ phase-conditional redflags-situational.md # App pentest and supply-chain routes: full-map.md gains one Also line naming app-pentest.md and supply-chain-check.md (+101 bytes); both new references are conditional, off every must-load set. Floors: phase-floor-light 18593->18618, phase-floor-full 26572->26597 (+25 each); web 18851->18870, mobile 13477->13496, api / service 16724->16743, agent / LLM / MCP 18412->18431 (+19 each). # Tool hub: full-map.md names tool-hub.md (one line), offset by wording trims in unpinned lines. Floors: phase-floor-light 18618->18617, phase-floor-full 26597->26596 (-1 each); no archetype row changed. # Dynamic probe route: full-map.md Also line names dynamic-probe.md (+35 bytes); the reference is conditional, off every must-load set. Floors: phase-floor-light 18617->18626, phase-floor-full 26596->26605 (+9 each); no archetype row changed. +# Route-auth map route: security-appsec.md's Anonymous GET sweep gains one sentence naming the route_auth_map.py command (+247 bytes, 27998->28245 B; the script itself is read only when run). Archetypes web 18870->18913, mobile 13496->13539, api / service 16743->16786, agent / LLM / MCP 18431->18474 (+43 each), desktop / native 8180->8242 (+62). Floors and the other archetype rows unchanged. diff --git a/scripts/perun_marker.py b/scripts/perun_marker.py index 26de07c8d..fa4cea138 100644 --- a/scripts/perun_marker.py +++ b/scripts/perun_marker.py @@ -3,7 +3,9 @@ Usage (called by install.sh): perun_marker.py TARGET --dirs REL... [--pre FILE --template FILE] [--agent-created] [--files REL...] [--backups REL...] [--agents-block NAME...] [--agents-created] [--settings-created] [--version V] [--remote URL] perun_marker.py backup-ok TARGET BACKUP_ROOT (exit 1 when BACKUP_ROOT or a parent below TARGET is a symlink) - perun_marker.py dedupe BACKUP_ROOT NEW DEST (drop NEW only when identical to the installed DEST) + perun_marker.py dedupe BACKUP_ROOT NEW DEST [TARGET KEY] (drop NEW only when identical to the installed DEST, or, with TARGET and + KEY = /skills/, to what the marker still holds for KEY: the last install, unedited) + perun_marker.py refresh TARGET REL SRC (exit 0 after overwriting TARGET/REL with SRC; only when the marker's files sha says install wrote it and nobody edited it) Writes TARGET/.claude/.perun-install.json, merged with any previous marker: skills: {/skills/: {: sha256}} hashes of the copy just installed @@ -85,19 +87,42 @@ def symlink_free(root, path): return not any((root.joinpath(*parts[:i + 1])).is_symlink() for i in range(len(parts))) -def drop_identical_backup(root, new, dest): - """Remove the fresh backup `new` only when it is provably the shipped copy: byte-identical to the just-installed `dest` - (same entries, same sha256), with no symlink in either tree or anywhere on the path. Anything else stays. Returns a status line.""" +def drop_identical_backup(root, new, dest, prev=None): + """Remove the fresh backup `new` only when it is provably a copy install itself shipped: byte-identical to the just-installed `dest` + (same entries, same sha256), or equal to `prev`, the marker's skills hashes for this dir from BEFORE this install (the release install + wrote last time, unedited since). Like hash_dir, that comparison leaves __pycache__ out, but only bytecode: any other file under a + __pycache__ counts as yours, and so does any entry the previous install did not write (an empty directory, a FIFO). No symlink in + either tree or anywhere on the path. + Anything else stays: an edited copy, a pre-Perun copy (no `prev`), your own files. Returns a status line.""" root, new, dest = Path(root), Path(new), Path(dest) if not (symlink_free(root, new) and new.is_dir() and dest.is_dir() and not dest.is_symlink()): return "backup: kept (symlink or unexpected path)" ne, de = _entries(new, True), _entries(dest, True) # every entry counts, __pycache__ included - if ne is None or de is None or ne != de or any((new / e).is_file() != (dest / e).is_file() or ((new / e).is_file() and sha(new / e) != sha(dest / e)) for e in ne): + same = ne is not None and de is not None and ne == de and not any((new / e).is_file() != (dest / e).is_file() or ((new / e).is_file() and sha(new / e) != sha(dest / e)) for e in ne) + cache = [Path(e) for e in ne or () if "__pycache__" in Path(e).parts] + older = ne is not None and bool(prev) and hash_dir(new) == prev and all(((new / c).is_dir() and c.name == "__pycache__") or (c.suffix == ".pyc" and c.parent.name == "__pycache__") for c in cache) + older = older and all(e in prev or any(k.startswith(e + "/") for k in prev) for e in ne if "__pycache__" not in Path(e).parts) # only files and their parent dirs + if not (same or older): return "backup: kept (differs from the installed copy)" for f in sorted(new.rglob("*"), reverse=True): # files first, then dirs, one at a time; the walk above proved no symlinks (f.rmdir if f.is_dir() else f.unlink)() new.rmdir() - return "backup: identical to the installed copy, not kept" + return "backup: identical to the installed copy, not kept" if same else "backup: unedited copy of the previous install, not kept" + + +def refresh_unedited(target, rel, src): + """Overwrite TARGET/rel with the file `src` in place, only when install wrote it (the marker's `files` sha256 still matches: unedited, a plain + file, no symlink on its path). Deletes nothing: a `rel.new` / `rel.new-N` next to it stays. Returns False and touches nothing in every + other case, an unreadable marker included.""" + p = Path(target) / rel + try: + want = load(target)["files"][rel] + except (OSError, ValueError, LookupError, TypeError): + return False + if not (symlink_free(target, p) and p.is_file() and sha(p) == want): + return False + p.write_bytes(Path(src).read_bytes()) + return True def _skill_of(backup): @@ -167,8 +192,15 @@ def record(target, dirs, pre=None, template=None, agent_created=False, files=(), if sys.argv[1:2] == ["backup-ok"]: sys.exit(0 if symlink_free(sys.argv[2], sys.argv[3]) else 1) if sys.argv[1:2] == ["dedupe"]: - print(drop_identical_backup(*sys.argv[2:5])) + a = sys.argv[2:] + try: # install.sh calls this before record() rewrites the marker, so it still holds the previous install's hashes + prev = load(a[3])["skills"][a[4]] if len(a) > 4 else None + except (OSError, ValueError, LookupError, TypeError): # no marker or no entry for this dir (a pre-Perun copy), or unreadable: keep the backup + prev = None + print(drop_identical_backup(*a[:3], prev)) sys.exit(0) + if sys.argv[1:2] == ["refresh"]: + sys.exit(0 if refresh_unedited(*sys.argv[2:5]) else 1) ap = argparse.ArgumentParser() ap.add_argument("target") ap.add_argument("--dirs", nargs="*", default=[]) diff --git a/scripts/size-budgets.tsv b/scripts/size-budgets.tsv index a212557ab..6287734cf 100644 --- a/scripts/size-budgets.tsv +++ b/scripts/size-budgets.tsv @@ -139,7 +139,7 @@ .claude/skills/deep-code-review/references/language-stack-redflags.md 10082 .claude/skills/deep-code-review/references/machine-report.md 13064 .claude/skills/deep-code-review/references/merge-operations.md 79430 -.claude/skills/deep-code-review/references/method-situational.md 44702 +.claude/skills/deep-code-review/references/method-situational.md 44699 .claude/skills/deep-code-review/references/method.md 29936 .claude/skills/deep-code-review/references/method-diff.md 7872 .claude/skills/deep-code-review/references/method-lenses.md 7743 @@ -161,7 +161,7 @@ .claude/skills/deep-code-review/references/security-agent-skills.md 6509 .claude/skills/deep-code-review/references/security-ai-agents.md 39145 .claude/skills/deep-code-review/references/security-api.md 17447 -.claude/skills/deep-code-review/references/security-appsec.md 27998 +.claude/skills/deep-code-review/references/security-appsec.md 28245 .claude/skills/deep-code-review/references/skill-authoring-and-size.md 3966 .claude/skills/deep-code-review/references/standards-index.md 470 .claude/skills/deep-code-review/references/testing-ai-evals.md 10259 @@ -208,11 +208,11 @@ README.md 16118 .claude/skills/product-discovery/references/full-map.md 12384 .claude/skills/growth-analytics/references/full-map.md 10661 .claude/skills/positioning/references/full-map.md 9520 -.claude/skills/INDEX.md 16565 +.claude/skills/INDEX.md 16670 .claude/skills/agentic-delivery/INDEX.md 4672 .claude/skills/agentic-delivery/INDEX-scripts.md 17631 .claude/skills/deep-code-review/INDEX.md 20553 -.claude/skills/deep-code-review/INDEX-scripts.md 6642 +.claude/skills/deep-code-review/INDEX-scripts.md 6797 .claude/skills/agentic-delivery/references/pipeline-optimizer.md 19857 .claude/skills/agentic-delivery/references/focus-kit.md 5235 .claude/skills/communication-structure/references/artifact-templates.md 9443 diff --git a/scripts/test-install-hygiene.sh b/scripts/test-install-hygiene.sh index f65b0e65b..35fca9142 100644 --- a/scripts/test-install-hygiene.sh +++ b/scripts/test-install-hygiene.sh @@ -38,5 +38,41 @@ st="$(git -C "$T" status --porcelain -uall)" DCR_NO_PROBE=1 "$SH" "$ROOT/install.sh" --with-delivery "$T" >/dev/null 2>&1 n="$(grep -cxF '/.perun/' "$T/.git/info/exclude")" [ "$n" -eq 1 ]; ok $? "re-install does not duplicate exclude entries (got $n)" + +# The summary names only what this run wrote: files other processes write under a host dir during the install (a home target's +# .claude/ holds session transcripts and todos) never appear, and every file the run did write still does. The 2099 mtimes are newer +# than the install's own stamp; zz-other.txt is a generic stray in every host dir, .perun, .githooks, scripts and .github/workflows, so a scan root covering a whole +# dir would list it. PERUN_ROLE is unset so the policy and settings rows always appear. +T2="$WORK/tgt2"; mkdir -p "$T2/.claude/projects" "$T2/.claude/todos"; git -C "$T2" init -q +echo '{}' > "$T2/.claude/projects/p.jsonl"; echo '[]' > "$T2/.claude/todos/t.json" +touch -t 209901010000 "$T2/.claude/projects/p.jsonl" "$T2/.claude/todos/t.json" +for d in .claude .cursor .agents .codex .gemini .opencode .github .windsurf .hermes .kiro .cline .roo .factory .qwen .grok .perun .githooks scripts .github/workflows; do + mkdir -p "$T2/$d"; echo x > "$T2/$d/zz-other.txt"; touch -t 209901010000 "$T2/$d/zz-other.txt" +done +out2="$(env -u PERUN_ROLE DCR_NO_PROBE=1 "$SH" "$ROOT/install.sh" --full --with-gates --with-terse-replies --with-extra-hosts --with-codex "$T2" 2>/dev/null)" +! grep -qE 'projects/p\.jsonl|todos/t\.json' <<<"$out2"; ok $? "summary omits other processes' files under .claude/" +! grep -q 'zz-other' <<<"$out2"; ok $? "summary omits a stray file in every host dir, .perun, .githooks, scripts and .github/workflows" +miss="$(cd "$T2" && find . -path ./.git -prune -o -type f ! -path '*/skills/*/*' ! -path './.claude/projects/*' ! -path './.claude/todos/*' ! -name zz-other.txt -print \ + | sed 's|^\./||' | while IFS= read -r f; do grep -qxF -e " $f" <<<"$out2" || echo "$f"; done)" +[ -z "$miss" ] && grep -q '^ AGENTS\.md$' <<<"$out2" && grep -q '^ \.perun/policy\.json$' <<<"$out2" && grep -q '^ \.claude/settings\.local\.json$' <<<"$out2" +ok $? "summary still lists every file the run wrote (missing: ${miss:-none})" +rows="$(sed -nE 's|^ (\.claude/skills/[^/]+/) \([1-9][0-9]* files\)$|\1|p' <<<"$out2" | LC_ALL=C sort)" +[ "$rows" = "$(cd "$T2" && ls -d .claude/skills/*/ | LC_ALL=C sort)" ] && grep -q '^\.claude/skills/deep-code-review/$' <<<"$rows" +ok $? "summary has one (N files) row per installed skill dir, deep-code-review included" + +# An existing target: edited gate files and a settings.local.json make install write .new and .bak siblings, and a stale .new puts the +# operating-layer snippet at .new-1. Each sibling glob in the summary's scan roots must list its file, so dropping a trailing * fails. +rowmiss() { local f; for f in "${@:2}"; do grep -qxF -e " $f" <<<"$1" || printf '%s ' "$f"; done; } # summary rows of $1 that are absent +T3="$WORK/tgt3"; mkdir -p "$T3/.github/workflows" "$T3/scripts" "$T3/.githooks" "$T3/.claude"; git -C "$T3" init -q +for f in .github/workflows/dcr-gates.yml scripts/dcr-gates.sh scripts/integrator-gate.sh .githooks/pre-push; do echo '# local edit' > "$T3/$f"; done +echo '{}' > "$T3/.claude/settings.local.json"; echo '{}' > "$T3/.claude/settings.operating-layer.json.new" +out3="$(env -u PERUN_ROLE DCR_NO_PROBE=1 "$SH" "$ROOT/install.sh" --with-delivery --with-gates "$T3" 2>/dev/null)" +m="$(rowmiss "$out3" .github/workflows/dcr-gates.yml.new scripts/dcr-gates.sh.new scripts/integrator-gate.sh.new .githooks/pre-push.new)" +[ -z "$m" ] && grep -qE '^ \.claude/settings\.local\.json\.bak\.[0-9]{8}-[0-9]{6}(-[0-9]+)?$' <<<"$out3" +ok $? "summary lists the gate-file .new siblings and the settings.local.json .bak (missing: ${m:-none})" +out4="$(env -u PERUN_ROLE DCR_NO_PROBE=1 "$SH" "$ROOT/install.sh" --with-delivery --with-operating-layer --no-sandbox "$T3" 2>/dev/null)" +m="$(rowmiss "$out4" .claude/settings.operating-layer.json.new-1 .claude/.dcr-install-flags.local)" +[ -z "$m" ] && ! grep -qxF ' .claude/settings.operating-layer.json.new' <<<"$out4" +ok $? "summary lists the operating-layer .new-1 and the local flags file, not the stale .new (missing: ${m:-none})" echo "Tests: $pass/$total" [ "$pass" -eq "$total" ] diff --git a/scripts/test-review-checks-new-rows.sh b/scripts/test-review-checks-new-rows.sh index 358c94af6..36d46494b 100644 --- a/scripts/test-review-checks-new-rows.sh +++ b/scripts/test-review-checks-new-rows.sh @@ -1,9 +1,12 @@ #!/usr/bin/env bash # review_checks.sh marks each diff-mode row "new": true when its file+line is on a line BASE...HEAD adds, false for a pre-existing -# line (also one shifted down, or in a renamed file) and for a file-level (line 0) row; --full and --history omit the key; no row is -# dropped. A fake shellcheck on a hermetic PATH reports one row per FIXME line, so the same rows come out on any host. The repo -# carries hostile git config (no rename detection, no prefixes, merged hunks, forced colour, an external diff, a textconv that drops -# a line): the script must pin its own diff flags. +# line (also one shifted down, or in a renamed file) and for a file-level (line 0) row, and null in a file whose working copy differs +# from HEAD (an edit, staged or not, or an untracked file: most rows read the working tree, the diff reads commits); --full and --history +# omit the key, as does a failed working-tree listing (which says so on stderr); no row is dropped. A fake shellcheck on a hermetic PATH +# reports one row per FIXME line, and a fake tsc one row per file it is told to blame (a project-wide analyzer, so it can name a file +# outside the diff), so the same rows come out on any host. +# The repo carries hostile git config (no rename detection, no prefixes, merged hunks, forced colour, an external diff, a textconv +# that drops a line, status hiding untracked files): the script must pin its own git flags. # REVIEW_CHECKS overrides the script under test (to prove the cases fail on the old one). set -uo pipefail # no -e: a failed jq must reach chk, which prints the FAIL line ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -26,7 +29,7 @@ STUB chmod +x "$T/bin/shellcheck" cd "$T/repo" && g init -q -b main . git config diff.renames false; git config diff.noprefix true; git config diff.interHunkContext 8; git config color.ui always -git config diff.external echo; git config diff.skip1.textconv 'sed 1d'; echo '*.sh diff=skip1' >.gitattributes +git config status.showUntrackedFiles no; git config diff.external echo; git config diff.skip1.textconv 'sed 1d'; echo '*.sh diff=skip1' >.gitattributes printf 'echo one\necho FIXME old\necho three\necho four\n' >a.sh printf 'echo 1\necho FIXME renamed\necho 3\necho 4\necho 5\necho 6\n' >ren.sh printf 'echo FIXME pure\necho 2\n' >pure.sh @@ -40,7 +43,8 @@ printf 'echo FIXME\n' >"sp ace.sh" printf 'echo a\necho FIXME\0\n' >nul.sh # a NUL byte makes git call the file binary unless diffed with -a g add -A; g commit -q -m feat run() { PATH="$T/bin:$T/sys" DCR_USER_BIN= bash "$RC" --base base "$@" 2>"$T/err" >"$T/out"; } -row() { jq -e --arg f "$1" --argjson l "$2" --argjson n "$3" 'any(.[]; .tool == "shellcheck" and .file == $f and .line == $l and .new == $n)' "$T/out" >/dev/null; } +row() { jq -e --arg f "$1" --argjson l "$2" --argjson n "$3" --arg t "${4:-shellcheck}" 'any(.[]; .tool == $t and .file == $f and .line == $l and has("new") and .new == $n)' "$T/out" >/dev/null; } +nul() { jq -e --arg f "$1" '[.[] | select(.file == $f)] | length > 0 and all(.[]; has("new") and .new == null)' "$T/out" >/dev/null; } # file has rows, every one null run row a.sh 2 true; chk $? "edited file: the added line is new" row a.sh 3 false; chk $? "edited file: a pre-existing finding shifted down, between two hunks, is not new and its row is kept" @@ -60,4 +64,42 @@ for mode in --full --history; do jq -e 'length > 0 and all(.[]; has("new") | not)' "$T/out" >/dev/null; chk $? "$mode: rows have no new key" ! grep -q 'rows are on lines this diff adds' "$T/err"; chk $? "$mode: no count line" done +# Uncommitted edits: most rows read the working tree, the diff reads commits, so a file whose working copy differs from HEAD gets null, +# never a wrong true or false; a clean file keeps its flag, one the diff does not touch included. +printf 'echo ok\n' >app.ts; echo '{}' >tsconfig.json; g add app.ts tsconfig.json; g commit -q -m ts # a changed .ts plus a tsconfig make tsc run +cat >"$T/bin/tsc" <<'STUB' +#!/usr/bin/env bash +echo "loose.ts(1,1): error TS9001: in an untracked file" +echo ".gitattributes(1,1): error TS9002: in a tracked file the diff does not touch" +exit 0 +STUB +chmod +x "$T/bin/tsc" +{ echo 'echo hdr'; cat a.sh; } >a.sh.new && mv a.sh.new a.sh # unstaged edit: every line moves down one +printf 'echo FIXME ZERO\n' >>"sp ace.sh"; g add "sp ace.sh" # staged edit; adds a file-level row too +echo x >loose.ts # untracked +run +nul a.sh; chk $? "unstaged edit: every row in the file is null" +nul "sp ace.sh"; chk $? "staged edit, path with a space: every row is null, the file-level one too" +row loose.ts 1 null tsc; chk $? "untracked file: its row is null" +row .gitattributes 1 false tsc; chk $? "clean tracked file the diff does not touch: still false" +row moved.sh 7 true; chk $? "clean edited file: an added line is still true" +row moved.sh 2 false; chk $? "clean edited file: a pre-existing line is still false" +row fresh.sh 2 true; chk $? "clean new file: its line is still true" +row fresh.sh 0 false; chk $? "clean new file: its file-level row is still false" +jq -e 'length > 0 and all(.[]; has("new") and (.new == null or (.new | type == "boolean")))' "$T/out" >/dev/null; chk $? "every row carries true, false or null" +n=$(jq '[.[] | select(.new == true)] | length' "$T/out"); k=$(jq '[.[] | select(.new == null)] | length' "$T/out"); m=$(jq length "$T/out") +[ "$k" -eq 7 ]; chk $? "exactly the 7 rows of the three uncommitted files are null, no clean row is (got $k)" +grep -qxF "review_checks: $n of $m rows are on lines this diff adds, $k unknown (file differs from HEAD)" "$T/err"; chk $? "stderr: $n of $m rows are on lines this diff adds, $k unknown (file differs from HEAD)" +# The listing is a read: a file whose mtime changed but whose content did not (so git would refresh its index entry) must not make the run rewrite .git/index. +touch moved.sh; i=$(ls -i .git/index); run; [ "$(ls -i .git/index)" = "$i" ]; chk $? "the run leaves .git/index alone even when a tracked file's mtime changed" +# A root file named HEAD makes a bare `git diff HEAD` fail as ambiguous; the listing names no revision, so the flags survive. +echo hi >HEAD; g add -- HEAD; g commit -q -m 'root file named HEAD' -- HEAD +printf '#!/usr/bin/env bash\necho "HEAD(1,1): error TS9003: in a root file named HEAD"\n' >"$T/bin/tsc" +run +row HEAD 1 true tsc; chk $? "root file named HEAD: its row keeps its flag" +nul a.sh; chk $? "root file named HEAD: a file with uncommitted edits is still null" +# A listing that fails must say so: silence would look like --full. Garbage in .git/index fails the working-tree read, not the commit diff. +echo garbage >.git/index; run +jq -e 'length > 0 and all(.[]; has("new") | not)' "$T/out" >/dev/null; chk $? "unreadable index: rows have no new key" +grep -qxF 'review_checks: could not list files that differ from HEAD; "new" omitted' "$T/err"; chk $? "unreadable index: stderr says why new is missing" echo "Tests: $pass/$total"; [ "$pass" -eq "$total" ] diff --git a/scripts/test_appsec_split.py b/scripts/test_appsec_split.py index 93c6d43a9..e729959aa 100644 --- a/scripts/test_appsec_split.py +++ b/scripts/test_appsec_split.py @@ -18,7 +18,7 @@ R = ROOT / ".claude/skills/deep-code-review/references" PARENT = "security-appsec.md" SPLITS = ("appsec-egress.md", "appsec-flows.md", "appsec-corroboration.md") -PARENT_CAP = 28000 # old file 32790 B (-14.6% at the cap) +PARENT_CAP = 28300 # old file 32790 B (-13.7% at the cap) TRIGGERS = { # index row -> phrase its trigger must carry "appsec-egress.md": "SSRF guard", "appsec-flows.md": "check-then-act", @@ -50,6 +50,8 @@ "## A06:2025 — Insecure Design (depth: detect steps for business flows)", "Read this when a finding claims that input reaches a sink", ' is not "clean" (the `Secrets` rule in security-appsec.md still holds:', + "Seed the route list with `python3 -I .claude/skills/deep-code-review/scripts/route_auth_map.py --paths DIR`", + "one per line). Without `--paths` it flags handlers with no auth evidence as CWE-306/862 leads; silence is not proof.", ) h = lambda s: hashlib.sha256(s.strip().encode()).hexdigest()[:16] # noqa: E731 text = lambda n: (R / n).read_text(encoding="utf-8") # noqa: E731 diff --git a/scripts/test_install_commands.py b/scripts/test_install_commands.py index abdb9f7d8..ffd820c31 100644 --- a/scripts/test_install_commands.py +++ b/scripts/test_install_commands.py @@ -51,8 +51,8 @@ def test_existing_file_kept_new_written_and_uninstall_keeps_user_file(self): self.assertEqual(mine.read_text(), "mine\n") self.assertTrue((repo / ".claude/commands/review.md.new").is_file()) self.assertIn("review.md.new", r.stdout) - self.assertEqual(install(repo).returncode, 0) # idempotent: no extra .new for identical deliver.md - self.assertEqual(sorted(names(repo) - ALWAYS), ["review.md.new", "review.md.new-1"]) + self.assertEqual(install(repo).returncode, 0) # idempotent: the .new already holds the shipped review.md, so no .new-1; deliver.md is current + self.assertEqual(sorted(names(repo) - ALWAYS), ["review.md.new"]) subprocess.run([sys.executable, str(ROOT / "scripts/perun_uninstall.py"), str(repo), "--apply"], capture_output=True, text=True) self.assertEqual(mine.read_text(), "mine\n") self.assertFalse((repo / ".claude/commands/deliver.md").exists()) diff --git a/scripts/test_install_update_hygiene.py b/scripts/test_install_update_hygiene.py new file mode 100644 index 000000000..41f6ac26f --- /dev/null +++ b/scripts/test_install_update_hygiene.py @@ -0,0 +1,410 @@ +#!/usr/bin/env python3 +"""Update hygiene: re-running install.sh after a version bump adds no skill backup and no .new file when you edited nothing, +and vendor_drift.py reads an install-placeholder stub as the docs/ file install.sh copies over it. + +Principle: install never overwrites anything you may have edited and never deletes a file it did not create in this run. The +one thing it drops is the fresh backup it just moved aside, and only when that is provably a copy it shipped. An edited skill +keeps its backup, an edited or pre-Perun slash command is never touched (a differing .new is written), and every .new survives. + +Fixtures: a tiny fictional release source (the real install.sh, scripts/ and commands/, one skill with two placeholder +stubs and their docs/ originals, four gate templates) plus an empty target repo, all under $TMPDIR. A "version bump" +rewrites that source. Every process runs with a scratch HOME and no network probe.""" +import hashlib, json, os, shutil, subprocess, sys, tempfile, unittest +from pathlib import Path + +sys.dont_write_bytecode = True +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "scripts")) +import perun_marker # noqa: E402 + +DRIFT = ROOT / ".claude/skills/agentic-delivery/scripts/vendor_drift.py" +MARKER = ROOT / "scripts/perun_marker.py" +REVIEW = ".claude/commands/review.md" + + +def run(*cmd, env=None): + return subprocess.run([str(c) for c in cmd], capture_output=True, text=True, env=env) + + +def sha(text): + return hashlib.sha256(text.encode()).hexdigest() + + +class Tmp(unittest.TestCase): + def setUp(self): + self.tmp = Path(tempfile.mkdtemp(dir=os.environ.get("TMPDIR") or None)) + self.addCleanup(shutil.rmtree, self.tmp, True) + + +class Release(Tmp): + """A fictional release source and an empty target repo; `release(n)` makes the source version n.""" + + def setUp(self): + super().setUp() + self.src, self.repo, self.home = self.tmp / "src", self.tmp / "repo", self.tmp / "home" + self.skill = self.src / ".claude/skills/deep-code-review" + for d in (self.skill / "references", self.skill / "templates", self.src / "docs", self.repo, self.home): + d.mkdir(parents=True) + shutil.copy(ROOT / "install.sh", self.src) + (self.src / "scripts").symlink_to(ROOT / "scripts") + shutil.copytree(ROOT / "commands", self.src / "commands") + self.review = (self.src / "commands/review.md").read_text() + for n in ("dcr-gates.yml", "dcr-gates.sh", "pre-push-verify.sh", "integrator-gate.sh"): + (self.skill / "templates" / n).write_text(f"# fictional {n}\n") + for n in ("standards-index", "example-review-report"): + (self.skill / "references" / f"{n}.md").write_text(f"# {n} (install placeholder)\n\nstub\n") + (self.src / "docs" / f"{n}.md").write_text(f"# {n}\n\nfull text\n") + self.env = {**os.environ, "HOME": str(self.home), "XDG_CACHE_HOME": str(self.home / ".cache"), "DCR_NO_PROBE": "1", "PYTHONDONTWRITEBYTECODE": "1"} + self.release(1) + + def release(self, n): + (self.skill / "SKILL.md").write_text(f"---\nname: deep-code-review\ndescription: fictional\n---\nbody v{n}\n") + (self.skill / "VERSION").write_text(f"{n}.0.0\n") + (self.src / "commands/review.md").write_text(self.review + f"\nrelease {n} note\n") + + def install(self, *flags): + p = run("bash", self.src / "install.sh", *flags, self.repo, env=self.env) + self.assertEqual(p.returncode, 0, p.stderr) + return p + + def backups(self): + d = self.repo / ".claude/skill-backups" + return sorted(p.name for p in d.iterdir()) if d.is_dir() else [] + + def news(self, under=".claude/commands"): + base = self.repo / under + return sorted(str(p.relative_to(base)) for p in base.rglob("*.new*")) + + def marker(self): + return json.loads((self.repo / ".claude/.perun-install.json").read_text()) + + +class VendorDriftStubs(Release): + """A: a stub in the canonical source is compared as /docs/, what install.sh copies over it.""" + + def drift(self): + p = run(sys.executable, "-I", DRIFT, self.repo, "--src", self.src / ".claude/skills") + return p.returncode, p.stdout.strip() + + def test_installed_docs_copy_is_not_drift_but_an_edit_is(self): + self.install() + ref = self.repo / ".claude/skills/deep-code-review/references/standards-index.md" + self.assertEqual(ref.read_text(), "# standards-index\n\nfull text\n") # install put the docs file over the stub + self.assertEqual(self.drift(), (0, "no drift")) + ref.write_text(ref.read_text() + "my edit\n") + self.assertEqual(self.drift(), (1, "DRIFT deep-code-review/references/standards-index.md")) # the swap must not hide an edit + + def test_stub_left_in_the_copy_is_drift(self): + self.install() + ref = self.repo / ".claude/skills/deep-code-review/references/example-review-report.md" + ref.write_text("# example-review-report (install placeholder)\n\nstub\n") # the overlay never happened + self.assertEqual(self.drift(), (1, "DRIFT deep-code-review/references/example-review-report.md")) + + def test_missing_docs_file_fails_closed_against_the_stub(self): + self.install() + (self.src / "docs/standards-index.md").unlink() + self.assertEqual(self.drift(), (1, "DRIFT deep-code-review/references/standards-index.md")) # a full copy is not the stub + (self.repo / ".claude/skills/deep-code-review/references/standards-index.md").write_text("# standards-index (install placeholder)\n\nstub\n") + self.assertEqual(self.drift(), (0, "no drift")) # only the stub itself matches the stub + + def test_only_a_first_line_marks_a_stub(self): + note = self.skill / "references/notes.md" + note.write_text("# notes\n\nA stub says (install placeholder) on its first line.\n") # mentions the phrase, but is not a stub + (self.src / "docs/notes.md").write_text("# a different file that happens to share the name\n") + self.install() + self.assertEqual((self.repo / ".claude/skills/deep-code-review/references/notes.md").read_text(), note.read_text()) + self.assertEqual(self.drift(), (0, "no drift")) # compared as itself, not as docs/notes.md + + def test_real_install_has_no_drift_against_the_real_source(self): + p = run("bash", ROOT / "install.sh", "--minimal", self.repo, env=self.env) + self.assertEqual(p.returncode, 0, p.stderr) + d = run(sys.executable, "-I", DRIFT, self.repo, "--src", ROOT / ".claude/skills") + self.assertEqual((d.returncode, d.stdout.strip()), (0, "no drift"), d.stderr) + + +class UpdateBackups(Release): + """B: a backup of an earlier release nobody edited is not kept; every other backup is.""" + + def test_version_bumps_add_no_backup(self): + self.install() + for n in (2, 3): + self.release(n) + self.install() + self.assertEqual(self.backups(), []) + self.assertIn("body v3", (self.repo / ".claude/skills/deep-code-review/SKILL.md").read_text()) + + def test_version_bumps_add_no_backup_on_copied_hosts(self): + self.env["DCR_COPY_HOSTS"] = "1" # a real copy in every host root, not a symlink to the .claude one + self.install() + for n in (2, 3): + self.release(n) + self.install() + self.assertGreater(len([p for p in self.repo.glob(".*/skills/deep-code-review") if not p.is_symlink()]), 1) + self.assertEqual(list(self.repo.glob(".*/skill-backups/*")), []) + + def test_version_bumps_with_a_relative_target_add_nothing(self): # `install.sh .`: the marker keys must still match the paths install.sh builds + def install_here(): + p = subprocess.run(["bash", str(self.src / "install.sh"), "."], cwd=self.repo, capture_output=True, text=True, env=self.env) + self.assertEqual(p.returncode, 0, p.stderr) + install_here() + for n in (2, 3): + self.release(n) + install_here() + self.assertEqual((self.backups(), self.news()), ([], [])) + self.assertTrue((self.repo / REVIEW).read_text().endswith("release 3 note\n")) + + def test_edited_skill_keeps_its_backup_and_later_bumps_add_none(self): + self.install() + skill = self.repo / ".claude/skills/deep-code-review" + (skill / "SKILL.md").write_text("my edit\n") + self.release(2) + self.install() + (skill / "notes.md").write_text("my notes\n") # an added file is an edit too + self.release(3) + self.install() + for n in (4, 5): # unedited since the last install: nothing more is kept + self.release(n) + self.install() + kept = self.repo / ".claude/skill-backups" + self.assertEqual(len(self.backups()), 2) + self.assertEqual([p.read_text() for p in kept.glob("*/notes.md")], ["my notes\n"]) + self.assertEqual([p.read_text() for p in kept.glob("*/SKILL.md")].count("my edit\n"), 1) + + def test_pre_perun_copy_is_kept(self): + skill = self.repo / ".claude/skills/deep-code-review" + skill.mkdir(parents=True) + (skill / "SKILL.md").write_text("mine, from before Perun\n") + self.install() + kept = self.backups() + self.assertEqual(len(kept), 1) + self.release(2) + self.install() # the first install's own copy is dropped, the pre-Perun one is not + self.assertEqual(self.backups(), kept) + self.assertEqual((self.repo / ".claude/skill-backups" / kept[0] / "SKILL.md").read_text(), "mine, from before Perun\n") + + +class DedupeAgainstPrevious(Tmp): + """perun_marker.drop_identical_backup with `prev`: the marker's hashes of what the last install wrote.""" + + def setUp(self): + super().setUp() + self.root, self.dest = self.tmp / "skill-backups", self.tmp / "dest" + for d in (self.root, self.dest): + d.mkdir() + (self.dest / "SKILL.md").write_text("this release\n") + self.prev = {"SKILL.md": sha("last release\n")} + + def backup(self, name="demo-20260101-000000", text="last release\n"): + d = self.root / name + d.mkdir() + (d / "SKILL.md").write_text(text) + return d + + def drop(self, new, prev): + return perun_marker.drop_identical_backup(self.root, new, self.dest, prev) + + def test_unedited_copy_of_the_last_install_is_dropped(self): + new = self.backup() + self.assertIn("previous install", self.drop(new, self.prev)) + self.assertFalse(new.exists()) + + def test_pycache_is_ignored_like_hash_dir(self): + new = self.backup() + (new / "__pycache__").mkdir() + (new / "__pycache__/x.cpython-311.pyc").write_bytes(b"\0") + self.assertIn("previous install", self.drop(new, self.prev)) + self.assertFalse(new.exists()) + + def test_anything_else_is_kept(self): + extra = self.backup("extra-20260101-000000") + (extra / "notes.md").write_text("mine\n") + edited = self.backup("edited-20260101-000000", "my edit\n") + plain = self.backup("plain-20260101-000000") + cached = self.backup("cached-20260101-000000") + (cached / "__pycache__").mkdir() + (cached / "__pycache__/data.txt").write_text("mine\n") # only bytecode is ignored under __pycache__ + for new, prev in ((extra, self.prev), (edited, self.prev), (cached, self.prev), (plain, None), (plain, {})): + with self.subTest(new=new.name, prev=prev): + self.assertIn("kept", self.drop(new, prev)) + self.assertTrue(new.is_dir()) + + def test_an_entry_the_last_install_did_not_write_is_kept(self): + empty = self.backup("empty-20260101-000000") + (empty / "mine").mkdir() # no file inside, so hash_dir cannot see it + nested = self.backup("nested-20260101-000000") + (nested / "a/b").mkdir(parents=True) + fifo = self.backup("fifo-20260101-000000") + os.mkfifo(fifo / "pipe") + for new in (empty, nested, fifo): + with self.subTest(new=new.name): + self.assertIn("kept", self.drop(new, self.prev)) + self.assertTrue(new.is_dir()) + + def test_a_symlink_inside_is_never_followed_or_deleted(self): + user = self.tmp / "user.txt" + user.write_text("user data\n") + new = self.backup() + (new / "link").symlink_to(user) + prev = {**self.prev, "link": sha("user data\n")} # hash_dir follows the link, so only the symlink gate keeps this backup + self.assertIn("kept", self.drop(new, prev)) + self.assertTrue((new / "link").is_symlink()) + self.assertEqual(user.read_text(), "user data\n") + + def test_cli_reads_the_marker_entry_for_the_dir(self): + key = ".claude/skills/demo" + marker = self.tmp / ".claude/.perun-install.json" + marker.parent.mkdir() + cases = [(json.dumps({"skills": {key: self.prev}}), True), ("{not json", False), (json.dumps({"skills": {}}), False), (None, False)] + for i, (text, dropped) in enumerate(cases): + with self.subTest(marker=text): + marker.unlink(missing_ok=True) + if text is not None: + marker.write_text(text) + new = self.backup(f"demo-2026010{i}-000000") + p = run(sys.executable, "-I", MARKER, "dedupe", self.root, new, self.dest, self.tmp, key) + self.assertEqual(p.returncode, 0, p.stderr) + self.assertEqual(new.exists(), not dropped) + + +class RefreshUnedited(Tmp): + """perun_marker.py refresh: overwrite a file only when the marker says install wrote it and nobody edited it.""" + + def setUp(self): + super().setUp() + self.repo, self.rel, self.shipped = self.tmp / "repo", ".claude/commands/demo.md", self.tmp / "shipped.md" + self.dest = self.repo / self.rel + self.dest.parent.mkdir(parents=True) + self.shipped.write_text("v2\n") + self.marker = self.repo / ".claude/.perun-install.json" + + def refresh(self): + return run(sys.executable, "-I", MARKER, "refresh", self.repo, self.rel, self.shipped).returncode + + def test_overwrites_an_unedited_file_and_leaves_every_new_sibling_alone(self): + self.dest.write_text("v1\n") + self.marker.write_text(json.dumps({"files": {self.rel: sha("v1\n")}})) + (self.dest.parent / "demo.md.new").write_text("v2\n") # identical to the shipped file, and still not install's to delete + (self.dest.parent / "demo.md.new-1").write_text("mine\n") + self.assertEqual(self.refresh(), 0) + self.assertEqual(self.dest.read_text(), "v2\n") + self.assertEqual(sorted(p.name for p in self.dest.parent.iterdir()), ["demo.md", "demo.md.new", "demo.md.new-1"]) + self.assertEqual([(self.dest.parent / n).read_text() for n in ("demo.md.new", "demo.md.new-1")], ["v2\n", "mine\n"]) + + def test_leaves_everything_else_alone(self): + user = self.tmp / "user.md" + user.write_text("v1\n") + for what in ("edited", "unrecorded", "no marker", "corrupt marker", "symlink"): + with self.subTest(what): + self.dest.unlink(missing_ok=True) + self.marker.unlink(missing_ok=True) + if what == "symlink": + self.dest.symlink_to(user) # the recorded sha matches the link target: only the symlink gate refuses + else: + self.dest.write_text("my edit\n" if what == "edited" else "v1\n") + files = {} if what == "unrecorded" else {self.rel: sha("v1\n")} + if what != "no marker": + self.marker.write_text("{not json" if what == "corrupt marker" else json.dumps({"files": files})) + self.assertEqual(self.refresh(), 1) + self.assertEqual(self.dest.read_text(), "my edit\n" if what == "edited" else "v1\n") + self.assertEqual(user.read_text(), "v1\n") + + +class UpdateCommands(Release): + """C: a slash command install wrote and you left alone is refreshed in place; one you edited, or never from Perun, gets a .new.""" + + def test_version_bumps_refresh_an_unedited_command_in_place(self): + self.install() + for n in (2, 3): + self.release(n) + self.install() + cmd = self.repo / REVIEW + self.assertTrue(cmd.read_text().endswith("release 3 note\n")) + self.assertEqual(self.news(), []) + self.assertEqual(self.marker()["files"][REVIEW], sha(cmd.read_text())) # re-recorded, so uninstall still knows install wrote it + p = run(sys.executable, ROOT / "scripts/perun_uninstall.py", self.repo, "--apply", env=self.env) + self.assertEqual(p.returncode, 0, p.stderr) + self.assertFalse(cmd.exists()) + + def test_edited_command_gets_a_new_file_and_stays_untouched(self): + self.install() + cmd = self.repo / REVIEW + cmd.write_text("my edit\n") + self.release(2) + self.install() + self.assertEqual(cmd.read_text(), "my edit\n") + self.assertEqual(self.news(), ["review.md.new"]) + self.assertTrue((cmd.parent / "review.md.new").read_text().endswith("release 2 note\n")) + self.install() # same release again: the .new already holds it + self.assertEqual(self.news(), ["review.md.new"]) + self.release(3) + self.install() # a different release is a different file + self.assertEqual(self.news(), ["review.md.new", "review.md.new-1"]) + self.assertEqual(cmd.read_text(), "my edit\n") + + def test_pre_perun_command_is_never_refreshed(self): + cmd = self.repo / REVIEW + cmd.parent.mkdir(parents=True) + cmd.write_text("mine\n") + self.install() + self.install() # no marker entry for it: kept, and the identical .new is not written twice + self.assertEqual(self.news(), ["review.md.new"]) + self.release(2) + self.install() + self.assertEqual(self.news(), ["review.md.new", "review.md.new-1"]) + self.assertEqual(cmd.read_text(), "mine\n") + + def test_a_lookalike_name_does_not_stand_in_for_the_new_file(self): + cmd = self.repo / REVIEW + cmd.parent.mkdir(parents=True) + cmd.write_text("mine\n") + (cmd.parent / "review.md.new-1abc").write_text((self.src / "commands/review.md").read_text()) # the shipped bytes, but not a .new-N name + self.install() + self.install() + self.assertEqual(self.news(), ["review.md.new", "review.md.new-1abc"]) + + def test_refresh_deletes_no_new_file_and_adds_none(self): + self.install() + self.release(2) + shipped = (self.src / "commands/review.md").read_text() + cmds = self.repo / ".claude/commands" + (cmds / "review.md.new").write_text(shipped) # identical to what the refresh writes, and still not install's to delete + (cmds / "review.md.new-1").write_text("my notes\n") + (cmds / "review.md.new-notes").write_text(shipped) + (cmds / "review.md.new-2").symlink_to(self.src / "commands/review.md") + self.install() + self.assertEqual((cmds / "review.md").read_text(), shipped) + self.assertEqual(self.news(), ["review.md.new", "review.md.new-1", "review.md.new-2", "review.md.new-notes"]) + self.assertEqual([(cmds / n).read_text() for n in ("review.md.new", "review.md.new-1", "review.md.new-notes")], [shipped, "my notes\n", shipped]) + self.assertTrue((cmds / "review.md.new-2").is_symlink()) + + def test_a_symlinked_new_file_does_not_stand_in_for_the_shipped_one(self): + self.install() + cmd = self.repo / REVIEW + cmd.write_text("my edit\n") + shipped = (self.src / "commands/review.md").read_text() + mine = self.tmp / "mine.md" + mine.write_text(shipped) # the shipped bytes, in a file install does not own + link = cmd.parent / "review.md.new" + link.symlink_to(mine) + self.install() + self.assertEqual((os.readlink(link), mine.read_text(), cmd.read_text()), (str(mine), shipped, "my edit\n")) + real = cmd.parent / "review.md.new-1" + self.assertTrue(real.is_file() and not real.is_symlink()) + self.assertEqual(real.read_text(), shipped) + self.install() + self.assertEqual(self.news(), ["review.md.new", "review.md.new-1"]) # the regular copy now stands in + + def test_gate_outputs_add_no_new_file_on_rerun_and_an_edit_survives(self): + self.install("--with-gates") + self.install("--with-gates") + self.assertEqual(self.news("."), []) + gate = self.repo / "scripts/dcr-gates.sh" + gate.write_text("my gate\n") + self.install("--with-gates") + self.install("--with-gates") + self.assertEqual(gate.read_text(), "my gate\n") + self.assertEqual(self.news("."), ["scripts/dcr-gates.sh.new"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_lease_sandbox_fallback.py b/scripts/test_lease_sandbox_fallback.py new file mode 100644 index 000000000..35480e612 --- /dev/null +++ b/scripts/test_lease_sandbox_fallback.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 +"""Heavy-lease sandbox fallback (macOS only: these cases skip elsewhere; test_lease_sandbox_hardening.py covers the rest). A Claude Code +sandbox writes only cwd and /tmp/claude-, so `~/.cache/perun/heavy` is read-only there: writers (heavy-acquire, heavy-exclusive, +stage_run) fall back to `/perun/heavy`, and every reader (slot count, exclusive gate, the unsandboxed heavy_gate hook) checks both +dirs. Hermetic: the cache dir, the fallback root (PERUN_HEAVY_FALLBACK_ROOT) and the policy are fixtures, so the real /tmp/claude- +is never touched. `Fixture` holds the setup and helpers; test_lease_sandbox_hardening.py reuses it.""" +import contextlib +import io +import json +import os +import stat +import subprocess +import sys +import tempfile +import time +import unittest +from pathlib import Path +from unittest import mock + +ROOT = Path(__file__).resolve().parent.parent +AD = ROOT / ".claude/skills/agentic-delivery/scripts" +PP, HG, SR = AD / "perun_policy.py", AD / "heavy_gate.py", AD / "stage_run.py" +sys.path.insert(0, str(AD)) +import perun_policy as pp # noqa: E402 + + +class Fixture(unittest.TestCase): + """No cases of its own: the hermetic primary + fallback dirs and the helpers the case classes use.""" + + def setUp(self): + td = tempfile.TemporaryDirectory(prefix="dcr-ls.") + self.addCleanup(td.cleanup) + self.t = Path(td.name).resolve() + self.cache, self.fb = self.t / "cache", self.t / "fb" # primary /perun/heavy, fallback /perun/heavy + self.primary, self.fbdir = self.cache / "perun" / "heavy", self.fb / "perun" / "heavy" + self.fb.mkdir() + os.chmod(self.fb, 0o700) # mkdir honours the umask; the fallback root must be exactly 0700 + (self.t / "p.json").write_text("{}") + self.env = {k: v for k, v in os.environ.items() if not k.startswith(("PERUN_", "XDG_"))} + self.env.update(XDG_CACHE_HOME=str(self.cache), PERUN_HEAVY_FALLBACK_ROOT=str(self.fb), PERUN_POLICY=str(self.t / "p.json")) + patch = mock.patch.dict(os.environ, self.env, clear=True) + patch.start() + self.addCleanup(patch.stop) + self.sleepers = [] + self.addCleanup(self._kill_sleepers) + + def _kill_sleepers(self): + for p in self.sleepers: + p.kill() + p.wait() + + def sleeper(self) -> int: + """Pid of a live process of ours: a lease holder that stays alive for the test.""" + p = subprocess.Popen(["sleep", "60"]) + self.sleepers.append(p) + return p.pid + + def plant(self, root: Path, pid: int, kind: str = "heavy") -> Path: + """A live lease for `pid` in `/perun/heavy`.""" + d = root / "perun" / "heavy" + d.mkdir(parents=True, exist_ok=True) + (d / str(pid)).write_text(f"{pid} {int(time.time())} {kind}") + return d / str(pid) + + def plant_exclusive(self, root: Path, pid: int) -> Path: + f = root / "perun" / "heavy.exclusive" + f.parent.mkdir(parents=True, exist_ok=True) + f.write_text(f"{pid} {int(time.time())}") + return f + + def lock_primary(self): + """Make the primary lease dir refuse new files while it still exists, as a sandbox does (chmod 500). As root chmod does not bind, + so a regular file in place of the cache dir stands in.""" + if os.geteuid() == 0: + self.cache.write_text("not a dir") + return + self.primary.mkdir(parents=True) + dirs = (self.primary, self.primary.parent) + for d in dirs: + os.chmod(d, 0o500) + self.addCleanup(lambda: [os.chmod(d, 0o700) for d in dirs]) + + def cli(self, *args, env=None): + """`perun_policy.py ARGS` as a child of this process (so its caller shell, the lease holder, is us): (rc, stdout).""" + r = subprocess.run([sys.executable, str(PP), *args], env=env or self.env, cwd=self.t, capture_output=True, text=True, timeout=30) + return r.returncode, r.stdout.strip() + + def hook(self, n: int, env=None) -> str: + """The heavy_gate PreToolUse deny reason for a heavy command on an idle injected host ("" = allowed). `n` makes each command + distinct: the same one denied three times is let through by the retry ledger.""" + e = dict(env or self.env, PERUN_GATE_LOAD1="0", PERUN_GATE_CORES="1", PERUN_GATE_FREE_RAM_PCT="60", PERUN_HEAVY_HOOK_WAIT_SECS="0") + r = subprocess.run([sys.executable, str(HG)], input=json.dumps({"tool_input": {"command": f"pytest tests/t{n}"}, "cwd": "/x"}), + env=e, cwd=self.t, capture_output=True, text=True, timeout=30) + return json.loads(r.stdout)["hookSpecificOutput"]["permissionDecisionReason"] if r.stdout.strip() else "" + + +@unittest.skipUnless(sys.platform == "darwin", "the sandbox fallback is macOS only") +class FallbackCase(Fixture): + # -- writers ---------------------------------------------------------------------------------------------------- + + def test_acquire_and_release_use_fallback_when_primary_unwritable(self): + self.lock_primary() + rc, out = self.cli("heavy-acquire") + self.assertEqual((rc, out), (0, str(os.getpid()))) + self.assertTrue((self.fbdir / str(os.getpid())).is_file(), "lease must land in the fallback dir") + self.assertEqual(list(self.primary.glob("*")) if self.primary.is_dir() else [], [], "nothing may be written to the primary") + self.assertEqual(self.cli("heavy-load"), (0, "1")) + self.assertEqual(self.cli("heavy-release")[0], 0) + self.assertFalse((self.fbdir / str(os.getpid())).exists(), "release must clear the fallback lease") + self.assertEqual(self.cli("heavy-load"), (0, "0")) + + def test_exclusive_acquire_renew_release_use_fallback_when_primary_unwritable(self): + self.lock_primary() + f = self.fb / "perun" / "heavy.exclusive" + self.assertEqual(self.cli("heavy-exclusive"), (0, str(os.getpid()))) + self.assertTrue(f.is_file()) + self.assertEqual(pp.exclusive_holder(pp.lease_dir()), os.getpid(), "a reader of the primary must see the fallback holder") + old = time.time() - 3600 + os.utime(f, (old, old)) + self.assertEqual(self.cli("heavy-exclusive-renew")[0], 0) + self.assertGreater(f.stat().st_mtime, old + 3000, "renew must refresh the fallback lease") + self.assertEqual(self.cli("heavy-exclusive-release")[0], 0) + self.assertFalse(f.exists()) + self.assertIsNone(pp.exclusive_holder(pp.lease_dir())) + + def test_missing_fallback_root_is_created_0700_by_a_writer_only(self): + new = self.t / "fb-new" + env = dict(self.env, PERUN_HEAVY_FALLBACK_ROOT=str(new)) + self.assertEqual(self.cli("heavy-load", env=env), (0, "0")) + self.assertFalse(new.exists(), "a reader must never create the fallback root") + self.lock_primary() + self.assertEqual(self.cli("heavy-acquire", env=env), (0, str(os.getpid()))) + self.assertEqual(stat.S_IMODE(new.lstat().st_mode), 0o700) + self.assertTrue((new / "perun" / "heavy" / str(os.getpid())).is_file()) + + def test_stage_run_leases_in_fallback_and_a_hook_takes_none(self): + self.lock_primary() + p = subprocess.Popen([sys.executable, str(SR), "--name", "lsfb", "--", "sleep", "8"], env=self.env, cwd=self.t, + stderr=subprocess.PIPE, text=True) + lease = self.fbdir / str(p.pid) + try: + for _ in range(300): + if lease.exists() or p.poll() is not None: + break + time.sleep(0.05) + seen = lease.exists() + p.terminate() # stage_run kills its stage group and releases the lease + err = p.communicate(timeout=30)[1] + finally: + p.kill() + self.assertTrue(seen, f"stage_run must lease in the fallback dir; stderr: {err}") + self.assertNotIn("heavy lease unavailable", err) + self.assertFalse(lease.exists(), "stage_run must release its fallback lease on exit") + hook_fb = self.t / "hook-fb" # a commit hook takes no lease: it must not even create the fallback root + r = subprocess.run([sys.executable, str(SR), "--hook", "--name", "h", "--", "true"], env=dict(self.env, PERUN_HEAVY_FALLBACK_ROOT=str(hook_fb)), + cwd=self.t, capture_output=True, text=True) + self.assertEqual(r.returncode, 0, r.stderr) + self.assertFalse(hook_fb.exists()) + + # -- readers ---------------------------------------------------------------------------------------------------- + + def test_fallback_holder_counts_against_slots_for_a_writable_primary(self): + other, mine = self.sleeper(), os.getpid() + lease = self.plant(self.fb, other) + self.assertEqual(pp.lease_load(pp.lease_dir()), 1) + self.assertEqual(pp.active_leases(pp.lease_dir()), [other]) + self.assertEqual(self.cli("heavy-load"), (0, "1")) + self.assertEqual(pp.lease_load(self.t / "elsewhere"), 0, "a dir a caller names explicitly stays isolated") + self.assertFalse(pp.heavy_acquire(pp.lease_dir(), 1, mine), "1 slot, held by a fallback lease: must refuse") + self.assertFalse((self.primary / str(mine)).exists()) + lease.unlink() + self.assertTrue(pp.heavy_acquire(pp.lease_dir(), 1, mine), "control: same call with the holder gone must succeed") + self.assertTrue((self.primary / str(mine)).is_file(), "a writable primary stays the write target") + + def test_fallback_exclusive_holder_blocks_acquire(self): + other = self.sleeper() + f = self.plant_exclusive(self.fb, other) + self.assertEqual(self.cli("heavy-exclusive"), (1, "HELD")) + self.assertFalse((self.cache / "perun" / "heavy.exclusive").exists()) + self.assertEqual(pp.exclusive_holder(pp.lease_dir()), other) + f.unlink() + self.assertEqual(self.cli("heavy-exclusive"), (0, str(os.getpid())), "control: same call with the holder gone must succeed") + self.assertTrue((self.cache / "perun" / "heavy.exclusive").is_file()) + self.assertEqual(self.cli("heavy-exclusive-release")[0], 0) + self.assertFalse((self.cache / "perun" / "heavy.exclusive").exists()) + + def test_heavy_gate_hook_sees_fallback_holders(self): + other = self.sleeper() + self.assertEqual(self.hook(0), "", "control: no holder, idle host: allowed") + ex = self.plant_exclusive(self.fb, other) + self.assertIn("gate running: wait", self.hook(1)) + self.assertIn(f"train gate pid {other}", self.hook(2)) + ex.unlink() + why = self.hook(3) + self.assertEqual(why, "", "control: exclusive gone, no leases: allowed") + self.plant(self.fb, other) + why = self.hook(4) + self.assertIn("heavy-lease-full 1/1", why) + self.assertIn(f"pid {other} (heavy, 0m)", why) + + def test_realpath_dedupe_counts_one_dir_once(self): + a, b = self.sleeper(), self.sleeper() + self.plant(self.cache, a) + self.plant(self.fb, b) + self.assertEqual(pp.lease_load(pp.lease_dir()), 2, "distinct primary and fallback add up") + link = self.t / "link" # primary that is the fallback under another name + link.symlink_to(self.fb) + with mock.patch.dict(os.environ, {"XDG_CACHE_HOME": str(link)}): + self.assertEqual(pp.lease_dirs(), [link / "perun" / "heavy"]) + self.assertEqual(pp.lease_load(pp.lease_dir()), 1, "one dir reached by two names must count once") + + def test_pinned_dir_is_exactly_one_dir(self): + self.plant(self.fb, self.sleeper()) + self.assertEqual(self.cli("heavy-load"), (0, "1"), "control: unpinned, the fallback lease counts") + pin = self.t / "pin" + env = dict(self.env, PERUN_HEAVY_DIR=str(pin)) + self.assertEqual(self.cli("heavy-load", env=env), (0, "0")) + pin.write_text("a file, not a dir") # unusable pinned dir: fail as before, never fall back + rc, _ = self.cli("heavy-acquire", env=env) + self.assertEqual(rc, 2) + self.assertFalse((self.fbdir / str(os.getpid())).exists(), "a pinned dir that fails must not fall back") + + # -- the fallback root must be trustworthy ---------------------------------------------------------------------- + + def test_symlinked_fallback_root_is_refused(self): + real = self.t / "real" + real.mkdir(mode=0o700) + os.chmod(real, 0o700) + self.plant(real, self.sleeper()) + with mock.patch.dict(os.environ, {"PERUN_HEAVY_FALLBACK_ROOT": str(real)}): + self.assertEqual(pp.lease_load(pp.lease_dir()), 1, "control: the same root, not a symlink, is read") + link = self.t / "link" + link.symlink_to(real) + self.lock_primary() + with mock.patch.dict(os.environ, {"PERUN_HEAVY_FALLBACK_ROOT": str(link)}): + self.assertIsNone(pp._fallback()) + self.assertEqual(pp.lease_load(pp.lease_dir()), 0) + self.assertEqual(pp.writable_lease_dir(), pp.lease_dir(), "no safe fallback: fail as before, never write through the link") + rc, _ = self.cli("heavy-acquire", env=dict(self.env, PERUN_HEAVY_FALLBACK_ROOT=str(link))) + self.assertEqual(rc, 2) + self.assertFalse((real / "perun" / "heavy" / str(os.getpid())).exists(), "nothing may be written through the symlink") + + def test_foreign_owned_or_loose_fallback_root_is_refused(self): + self.plant(self.fb, self.sleeper()) + self.lock_primary() + self.assertEqual(pp.lease_load(pp.lease_dir()), 1, "control: a 0700 root we own is read") + self.assertEqual(pp.writable_lease_dir(), self.fbdir) + with mock.patch.object(os, "getuid", return_value=os.getuid() + 1): # the root now belongs to someone else + self.assertIsNone(pp._fallback()) + self.assertEqual(pp.lease_load(pp.lease_dir()), 0) + self.assertEqual(pp.writable_lease_dir(), pp.lease_dir()) + out = io.StringIO() + with contextlib.redirect_stdout(out): + self.assertEqual(pp.main(["heavy-load"]), 0) + self.assertEqual(out.getvalue().strip(), "0") + for mode in (0o755, 0o770, 0o750): + os.chmod(self.fb, mode) + self.assertIsNone(pp._fallback(), oct(mode)) + self.assertEqual(pp.lease_load(pp.lease_dir()), 0, oct(mode)) + self.assertEqual(pp.writable_lease_dir(), pp.lease_dir(), oct(mode)) + os.chmod(self.fb, 0o700) + self.assertEqual(pp.lease_load(pp.lease_dir()), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_lease_sandbox_hardening.py b/scripts/test_lease_sandbox_hardening.py new file mode 100644 index 000000000..397010f30 --- /dev/null +++ b/scripts/test_lease_sandbox_hardening.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +"""Hardening of the heavy-lease sandbox fallback (the base cases and the shared `Fixture` live in test_lease_sandbox_fallback.py). Pins: lease +and holder reads never block on a FIFO and skip anything that is not a regular file; the fallback exists on macOS only; the `perun` and +`heavy` components of the fallback must be real directories we own; stage_run's orphan reap never signals a group named by a lease outside +the primary dir; the parent-dir probe of `_writable`; and the accepted limit that a forged holder in the shared fallback can delay or deny +heavy commands. Hermetic like the base file (fixture cache dir, fallback root and policy). Every subprocess has a timeout, so a regression +that blocks fails its case instead of hanging the run.""" +import contextlib +import io +import os +import subprocess +import sys +import time +import unittest +from pathlib import Path +from unittest import mock + +from test_lease_sandbox_fallback import SR, Fixture, pp # noqa: E402 (also puts the skill scripts on sys.path) + +import stage_run as sr # noqa: E402 + +mac = unittest.skipUnless(sys.platform == "darwin", "the sandbox fallback is macOS only") + + +class Hardening(Fixture): + def platform(self, name: str): + """Run the rest of this test as platform `name`. In-process only: a child process keeps the real one.""" + p = mock.patch.object(sys, "platform", name) + p.start() + self.addCleanup(p.stop) + + # -- a non-regular file never blocks a reader or feeds it --------------------------------------------------------- + + def test_read_lease_takes_regular_files_only(self): + f = self.t / "lease" + f.write_text("12 34") + self.assertEqual(pp.read_lease(f), "12 34") + f.write_text("x" * 10000) + self.assertEqual(len(pp.read_lease(f)), 4096, "a lease is under 400 characters: a huge file is never read whole") + link = self.t / "link" + link.symlink_to(f) + fifo = self.t / "fifo" + os.mkfifo(fifo) + wfd = os.open(fifo, os.O_RDWR) # a FIFO with a writer and a lease-looking line waiting is still no lease + self.addCleanup(os.close, wfd) + os.write(wfd, b"4242 1") + fds = len(os.listdir("/dev/fd")) + for bad in (link, self.t, self.t / "missing", fifo): # a symlink, a directory, nothing, a FIFO + for _ in range(20): + with self.assertRaises(OSError): + pp.read_lease(bad) + self.assertEqual(len(os.listdir("/dev/fd")), fds, "a rejected file must not leak its descriptor") + + def test_fifo_in_a_pinned_dir_never_blocks_any_reader(self): + pin = self.t / "pin" / "heavy" # a FIFO named like a lease, one like the holder file, one like the queue marker + pin.mkdir(parents=True) + for f in (pin / "4242", pin.parent / "heavy.exclusive", pin / ".waiting"): + os.mkfifo(f) + env = dict(self.env, PERUN_HEAVY_DIR=str(pin)) + self.assertEqual(self.cli("heavy-load", env=env), (0, "0"), "_leases skips the FIFO") + self.assertEqual(self.hook(0, env), "", "the hook reads the holder file and the leases") + self.assertEqual(self.cli("heavy-acquire", env=env), (0, str(os.getpid())), "heavy_acquire reads the queue marker") + self.assertEqual(self.cli("heavy-release", env=env)[0], 0) + r = subprocess.run([sys.executable, str(SR), "--name", "fifo", "--", "true"], env=env, cwd=self.t, capture_output=True, text=True, timeout=60) + self.assertEqual(r.returncode, 0, r.stderr) # reap reads every lease in the dir + self.assertNotIn("heavy lease unavailable", r.stderr) + + @mac + def test_fifo_in_the_fallback_never_blocks_the_hook_or_the_train_gate(self): + """What confined code can plant for the unsandboxed readers: a FIFO named like a lease and one named like the holder file.""" + other = self.sleeper() + self.plant(self.fb, other) + os.mkfifo(self.fbdir / "4242") + os.mkfifo(self.fb / "perun" / "heavy.exclusive") + self.assertEqual(self.cli("heavy-load"), (0, "1"), "the FIFO is skipped, the real lease beside it still counts") + why = self.hook(0) + self.assertIn("heavy-lease-full 1/1", why) + self.assertIn(f"pid {other} (heavy, 0m)", why) + self.assertEqual(self.cli("heavy-exclusive"), (0, str(os.getpid())), "a FIFO is no holder: the train gate takes the lease") + self.assertEqual(self.cli("heavy-exclusive-release")[0], 0) + + # -- macOS only ------------------------------------------------------------------------------------------------- + + def test_off_macos_the_fallback_is_neither_read_nor_written(self): + self.platform("linux") + other = self.sleeper() + self.plant(self.fb, other) + self.plant_exclusive(self.fb, other) + self.assertIsNone(pp._fallback()) + self.assertEqual(pp.lease_dirs(), [pp.lease_dir()]) + self.assertEqual(pp.lease_load(pp.lease_dir()), 0, "a lease in the fallback means nothing off macOS") + self.assertIsNone(pp.exclusive_holder(pp.lease_dir())) + self.assertEqual(pp.writable_lease_dir(), pp.lease_dir()) + self.assertFalse(self.cache.exists(), "no probe: nothing is created just to pick the dir") + self.lock_primary() + new = self.t / "fb-new" + with mock.patch.dict(os.environ, {"PERUN_HEAVY_FALLBACK_ROOT": str(new)}): + self.assertEqual(pp.writable_lease_dir(), pp.lease_dir(), "a read-only lease dir is not worked around off macOS") + with self.assertRaises(OSError): + pp.heavy_acquire(pp.writable_lease_dir(), 1, os.getpid()) + self.assertFalse(new.exists(), "the fallback root is not created either") + self.assertEqual([f.name for f in self.fbdir.iterdir()], [str(other)], "nothing was written to the existing fallback") + + # -- the fallback's own path components ----------------------------------------------------------------------------- + + def test_symlinked_or_foreign_perun_and_heavy_components_are_refused(self): + self.platform("darwin") + elsewhere = self.t / "elsewhere" + planted = self.plant(elsewhere, self.sleeper()) # a live lease that a followed symlink would expose + self.lock_primary() + + def refused(why): + self.assertIsNone(pp._fallback(), why) + self.assertEqual(pp.lease_load(pp.lease_dir()), 0, why) + self.assertEqual(pp.writable_lease_dir(), pp.lease_dir(), why) + self.assertEqual([f.name for f in planted.parent.iterdir()], [planted.name], f"{why}: nothing may be written through it") + + (self.fb / "perun").symlink_to(elsewhere / "perun") + refused("perun is a symlink") + (self.fb / "perun").unlink() + (self.fb / "perun").mkdir() + self.fbdir.symlink_to(elsewhere / "perun" / "heavy") + refused("heavy is a symlink") + self.fbdir.unlink() + self.plant(self.fb, self.sleeper()) + self.assertEqual(pp.lease_load(pp.lease_dir()), 1, "control: real directories are read") + lstat = Path.lstat + for name in ("perun", "heavy"): + def as_foreign(p, name=name): # the same directory, seen as owned by someone else + st = lstat(p) + return os.stat_result((*st[:4], st.st_uid + 1, *st[5:10])) if p.name == name else st + with mock.patch.object(Path, "lstat", as_foreign): + refused(f"{name} is owned by another user") + + @unittest.skipIf(os.geteuid() == 0, "chmod does not bind root") + def test_a_writable_heavy_dir_under_a_read_only_parent_falls_back(self): + self.platform("darwin") + self.primary.mkdir(parents=True) # write_lease puts its temp file beside the lease dir, so this primary is unusable + os.chmod(self.primary.parent, 0o555) + self.addCleanup(os.chmod, self.primary.parent, 0o700) + self.assertFalse(pp._writable(self.primary)) + self.assertEqual(pp.writable_lease_dir(), self.fbdir) + + # -- what confined code can still do, and what it cannot ------------------------------------------------------------ + + @mac + def test_a_forged_holder_in_the_fallback_is_an_accepted_denial_of_service(self): + """Every sandboxed session of the user writes the fallback, so one can name a live pid (1 here) as the train gate or a lease + holder. The hook then denies heavy commands and heavy-exclusive reports HELD: same-user delay, accepted (see perun_policy._fallback).""" + self.plant_exclusive(self.fb, 1) + self.assertIn("gate running: wait", self.hook(0)) + self.assertEqual(self.cli("heavy-exclusive"), (1, "HELD")) + (self.fb / "perun" / "heavy.exclusive").unlink() + self.plant(self.fb, 1) + self.assertEqual(self.cli("heavy-load"), (0, "1"), "a forged lease holds a slot until it expires") + + def test_reap_outside_the_primary_dir_unlinks_without_signalling(self): + """A lease in the shared fallback can name any process group: stage_run drops a stale one but never signals what it names.""" + sent = [] + text = f"4242 {int(time.time())} heavy S 4321 S {pp.HOST}" # stage_run pid, start, kind, its start, child pgid, child start, host + with mock.patch.object(pp, "_alive", return_value=False), mock.patch.object(pp, "pstart", return_value="S"), \ + mock.patch.object(sr, "kill_group", side_effect=lambda g, p=None: sent.append(g) or True): + for d, signalled in ((self.fbdir, []), (pp.lease_dir(), [4321])): # the fallback, then the primary as the control + d.mkdir(parents=True, exist_ok=True) + (d / "4242").write_text(text) + sent.clear() + with contextlib.redirect_stderr(io.StringIO()): # reap says which group it kills + sr.reap(d) + self.assertEqual(sent, signalled, str(d)) + self.assertFalse((d / "4242").exists(), f"a stale lease is dropped from {d}") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_prepush_gate_cache.py b/scripts/test_prepush_gate_cache.py index da66d2be2..9ef00bf31 100644 --- a/scripts/test_prepush_gate_cache.py +++ b/scripts/test_prepush_gate_cache.py @@ -26,7 +26,7 @@ def setUp(self): self.repo.mkdir() self.counter = self.tmp / "counter" self.env = {k: v for k, v in os.environ.items() if not k.startswith(("GIT_", "PERUN_", "DCR_"))} - self.env.update(HOME=str(self.tmp), GIT_CONFIG_GLOBAL="/dev/null", GIT_CONFIG_NOSYSTEM="1", COUNTER=str(self.counter)) + self.env.update(HOME=str(self.tmp), PERUN_HEAVY_DIR=str(self.tmp / "heavy"), GIT_CONFIG_GLOBAL="/dev/null", GIT_CONFIG_NOSYSTEM="1", COUNTER=str(self.counter)) self.git("init", "-q", "-b", "main") (self.repo / "a.txt").write_text("one\n") self.commit("base") diff --git a/scripts/test_route_auth_map.py b/scripts/test_route_auth_map.py new file mode 100644 index 000000000..4b60d115c --- /dev/null +++ b/scripts/test_route_auth_map.py @@ -0,0 +1,1702 @@ +"""route_auth_map.py (static missing-authentication lead-finder, CWE-306/862): golden rows over fictional fixtures, the JSON / stderr / +--paths / exit-code contract, the review_checks.sh --full wiring, and a mutation table proving each rule is pinned (a mutated copy of the +script must change the output). Fixtures are fictional (Acme, example.com); one new file per feature, nothing appended to a shared spec.""" +import concurrent.futures +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +SK = ROOT / ".claude/skills/deep-code-review/scripts" +SCRIPT = SK / "route_auth_map.py" +RC = SK / "review_checks.sh" + +FX = { + # Next App Router: per-handler scoping, file-level helper guard, const/wrapped exports, destructured + re-exported verbs. + "app/api/notes/route.ts": r'''import { getServerSession } from "next-auth"; + +export async function GET() { + return Response.json([]); +} + +export async function POST(req: Request) { + const session = await getServerSession(); + if (!session) return new Response("no", { status: 401 }); + return Response.json({ ok: true }); +} + +export async function PUT(req: Request) { + return Response.json({ ok: true }); +} +''', + "app/api/helper/route.ts": r'''async function guard() { + const session = await auth(); + if (!session) throw new Error("unauthorized"); +} + +export async function POST(req: Request) { + await guard(); + return Response.json({ ok: true }); +} +''', + "app/api/export/route.ts": r'''import { withAuth } from "@/lib/with-auth"; + +export const GET = async () => { + return Response.json([]); +}; + +export const PATCH = withAuth(async (req: Request) => { + return Response.json({ ok: true }); +}); + +export const POST = compose(logger, async (req: Request) => { + return Response.json({ ok: true }); +}); +''', + "app/api/oneliner/route.ts": r'''export const GET = () => Response.json([]); +export const POST = async () => { + await auth(); + return Response.json({ ok: true }); +}; +''', + "app/api/modlevel/route.ts": r'''export async function GET() { + return Response.json([]); +} + +await auth(); + +export async function POST() { + return Response.json({ ok: true }); +} +''', + "app/api/auth/[...nextauth]/route.ts": r'''import { handlers } from "@/auth"; + +// route-auth-ok: NextAuth handlers are public by design +export const { GET, handlePost: POST, auth } = handlers; +''', + "app/api/legacy/route.js": r'''async function handler(req) { + return Response.json({ ok: true }); +} + +async function secured() { + await auth(); + return Response.json({ ok: true }); +} + +export { handler as PUT, handler as DELETE, secured as PATCH, handler as dynamic }; +''', + "app/api/reexport/route.ts": r'''export { GET, POST } from "./impl"; +''', + "app/api/unused/route.ts": r'''async function neverCalled() { + await auth(); +} + +export async function POST() { + return Response.json({ ok: true }); +} +''', + "app/api/vars/route.js": r'''export let GET = async () => Response.json([]); +export var POST = async () => Response.json({ ok: true }); +export function DELETE() { + return Response.json({ ok: true }); +} +export const DELETED = 1; +''', + "app/api/helpers/route.ts": r'''const g1 = async () => { + await auth(); +}; + +const g2 = () => { + requireAuth(); +}; + +const g3 = function () { + getUser(); +}; + +export async function POST() { + await g1(); + return Response.json({ ok: true }); +} + +export async function PUT() { + g2(); + return Response.json({ ok: true }); +} + +export async function PATCH() { + g3(); + return Response.json({ ok: true }); +} + +export async function DELETE() { + return Response.json({ ok: true }); +} +''', + "app/docs/[[...slug]]/route.mjs": r'''export async function GET() { return new Response("docs"); } +''', + "app/(shop)/orders/[id]/route.ts": r'''export async function DELETE(req: Request) { + await requireUser(req); + return new Response(null, { status: 204 }); +} +''', + "app/api/token/route.ts": r'''export async function POST(req: Request) { + if (req.headers.get("authorization") !== process.env.ACME_API_KEY) { + return new Response("no", { status: 401 }); + } + return Response.json({ ok: true }); +} +''', + "app/api/ping/route.ts": r'''export async function POST(req: Request) { + if (req.headers.get("x-kind") !== "ping") { + return new Response("no", { status: 400 }); + } + return Response.json({ ok: true }); +} +''', + # Next middleware: root matcher, monorepo lookahead matcher + base scoping, no-matcher auth export, identity-free i18n middleware. + "middleware.ts": r'''export { default } from "next-auth/middleware"; + +export const config = { matcher: ["/dashboard/:path*", "/api/private/:path*"] }; +''', + "app/api/private/data/route.ts": r'''export async function POST(req: Request) { + return Response.json({ ok: true }); +} +''', + "app/api/public/data/route.ts": r'''export async function POST(req: Request) { + return Response.json({ ok: true }); +} +''', + "apps/web/middleware.ts": r'''import { auth } from "@/auth"; + +export default auth((req) => { + if (!req.auth) return Response.redirect(new URL("/login", req.url)); +}); + +export const config = { + matcher: ["/((?!api|_next|.*\\.(?:png|ico)).*)"], +}; +''', + "apps/web/app/dashboard/route.ts": r'''export async function POST() { + return Response.json({ ok: true }); +} +''', + "apps/web/app/api/hook/route.ts": r'''export async function POST() { + return Response.json({ ok: true }); +} +''', + "apps/web2/app/dashboard/route.ts": r'''export async function POST() { + return Response.json({ ok: true }); +} +''', + "apps/admin/middleware.ts": r'''export { auth as middleware } from "@/auth"; +''', + "apps/admin/app/api/users/route.ts": r'''export async function DELETE() { + return new Response(null, { status: 204 }); +} +''', + "apps/site/middleware.ts": r'''import createMiddleware from "next-intl/middleware"; + +export default createMiddleware({ locales: ["en", "de"], defaultLocale: "en" }); + +export const config = { matcher: ["/((?!api|_next).*)"] }; +''', + "apps/m/a-middleware.ts": "export const x = 1;\n", # decoys: not middleware files, must not shadow the real one + "apps/m/middleware.jsx": "export const y = 1;\n", + "apps/js/middleware.js": 'export { auth as middleware } from "@/auth";\n', + "apps/sq/middleware.ts": "export { auth as middleware } from '@/auth';\n\nexport const config = { matcher: '/sq/:path*' };\n", + "apps/bt/middleware.ts": "export { auth as middleware } from '@/auth';\n\nexport const config = { matcher: `/bt/:path*` };\n", + "apps/neg/middleware.ts": r'''import { auth } from "@/auth"; + +export default auth(() => {}); + +export const config = { matcher: ["/((?!api|_next/static|favicon.ico).*)"] }; +''', + "apps/m/middleware.ts": r'''export { auth as middleware } from "@/auth"; + +export const config = { matcher: ["/a/:path*", '/b/:path+', "/c/:id", "/d/:opt?", `/(?:e|f)/x`, "/(unclosed"] }; +''', + "apps/dyn/middleware.ts": r'''import { auth } from "@/auth"; + +const patterns = ["/x"]; + +export default auth(() => {}); + +export const config = { matcher: patterns }; + +export const fallback = "/x"; +''', + "apps/proxy/proxy.ts": r'''import { auth } from "@/auth"; + +export default auth(() => {}); + +export const config = { matcher: "/admin/:path*" }; +''', + "apps/old/middleware.ts": r'''export { default } from "next-auth/middleware"; +''', + "apps/old/pages/api/orders.ts": r'''export default function handler(req, res) { + if (req.method === "POST") return res.json({ ok: true }); + return res.status(405).end(); +} +''', + # Next pages/api: method detection, ANY default, index stripped. + "pages/api/users/[id].ts": r'''import type { NextApiRequest, NextApiResponse } from "next"; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method === "DELETE") return res.status(204).end(); + if (req.method === "GET") return res.json({ id: req.query.id }); + return res.status(405).end(); +} +''', + "pages/api/health.ts": r'''export default function handler(req, res) { + res.status(200).json({ ok: true }); +} +''', + "pages/api/me.ts": r'''import { getServerSession } from "next-auth"; + +export default async function handler(req, res) { + const session = await getServerSession(req, res); + res.json(session); +} +''', + "pages/api/orders/index.ts": r'''export default async function handler(req, res) { + if (req.method != "POST") return res.status(405).end(); + const session = await getSession({ req }); + res.json({ ok: !!session }); +} +''', + "pages/api/items.ts": r'''export default function handler(req, res) { + switch (req.method) { + case "PUT": + return res.json({ ok: true }); + case 'PATCH': + return res.json({ ok: true }); + } + if ("DELETE" === req.method) return res.status(204).end(); + return res.status(405).end(); +} +''', + "pages/about.tsx": r'''export default function About() { + return null; +} +''', + # 'use server' actions: exported async functions only; a function's own name is not evidence. + "app/actions.ts": r'''// Acme notes actions +"use server"; + +export async function saveNote(text: string) { + return text; +} + +export async function getUser(id: string) { + return id; +} + +export const removeNote = async (id: string) => { + const session = await auth(); + return session ? id : null; +}; + +export function syncHelper() { + return 1; +} + +async function internal() { + return 1; +} + +export const NOTE_LIMIT = 5; +''', + "app/bom/actions.ts": '"use server";\n\nexport async function savePrefs(x: string) {\n return x;\n}\n', + "app/form/actions.jsx": r'''"use server"; + +export async function submitForm(data) { + return data; +} +''', + "app/form/more.tsx": r'''"use server"; + +export async function saveMore(data: string) { + return data; +} +''', + "lib/plain.ts": r'''const label = "use server"; + +export async function saveThing(x: string) { + return x; +} +''', + # Express / Hono / Fastify: receivers, middle-arg middleware, positional .use / addHook, prefix boundary, markers. + "server/app.js": r'''const express = require("express"); +const { requireAuth, authenticate } = require("./auth"); + +const app = express(); +const admin = express.Router(); + +app.get("/health", (req, res) => res.send("ok")); +app.post("/orders", (req, res) => res.json({ ok: true })); +app.put("/orders/:id", requireAuth, (req, res) => res.json({ ok: true })); +app.delete("/orders/:id", (req, res) => { + authenticate(req); + res.json({ by: req.user.id }); +}); +app.patch("/orders/:id", (req, res) => res.json({ ok: true })); +app.all("/hook", (req, res) => res.sendStatus(200)); +app.get(process.env.PATH_X, (req, res) => res.send("x")); +const port = app.get("port"); +app.get("/solo"); +// app.post("/commented", (req, res) => res.send("c")); +const note = 'app.post("/in-string", handler)'; +app.get('/sq', (req, res) => res.send("q")); +app.get(`/tpl`, (req, res) => res.send("t")); +app.get(`/t/${id}`, (req, res) => res.send("t")); + +app.post("/signin", authenticate); +app.post("/me/update", (req, res) => res.json({ id: req.user.id })); +app.post("/preauth", (req, res) => { + preauth(req); + $auth(req); + res.send("x"); +}); +app.get("/pre", preauth, (req, res) => res.send("p")); +app.post("/bare", (req, res) => res.json({ ok: isLoggedIn })); +app.post("/verify", (req, res) => { + verifyToken(req); + res.send("v"); +}); +app.post("/store-key", (req, res) => { + const header = req.headers["x-acme-token"]; + res.json({ stored: header }); +}); +app.post("/signup2", (req, res) => { + if (req.body.password === process.env.ACME_PASSWORD) return res.sendStatus(200); + res.sendStatus(403); +}); +app.post("/hook2", (req, res) => { + const ok = crypto.timingSafeEqual(Buffer.from(req.headers["x-hook-key"] || ""), Buffer.from(process.env.ACME_HOOK_SECRET || "")); + res.sendStatus(ok ? 200 : 403); +}); +app.post("/hook3", (req, res) => { + if (req.get("Authorization") === process.env.ACME_SHARED_SECRET) return res.sendStatus(200); + res.sendStatus(403); +}); +app.post("/hook4", (req, res) => { + if (req.headers.authorization !== process.env.ACME_PASSWORD) return res.sendStatus(403); + res.sendStatus(200); +}); +app.post("/hook5", (req, res) => { + if (req.header("x-acme-sig") != process.env.ACME_ACCESS_TOKEN) return res.sendStatus(403); + res.sendStatus(200); +}); +app.post("/hook6", (req, res) => { + if (req.headers["x-api-key"] !== process.env.ACME_KEY) return res.sendStatus(403); + res.sendStatus(200); +}); + +app.post("/two", (req, res) => { + if (!req.user) return res.sendStatus(401); + authenticate(req); + res.send("t"); +}); +app.post("/ra", (req, res) => res.json(req.auth)); +app.post("/ru", (request, res) => res.json(request.user)); +app.post("/hook7", (req, res) => { + if (req.headers.authorization !== process.env.ACME_APIKEY) return res.sendStatus(403); + res.sendStatus(200); +}); +app.post("/spread", ...guards, (req, res) => res.send("s")); + +admin.use(requireAuth); +admin.post("/users", (req, res) => res.json({ ok: true })); + +app.post("/before-mark", (req, res) => res.json({ ok: true })); +// route-auth-ok: public sign-up +app.post("/signup", (req, res) => res.json({ ok: true })); +app.post("/login", (req, res) => res.json({ ok: true })); // route-auth-ok + +app.post("/after-login", (req, res) => res.json({ ok: true })); +''', + "routes/items.js": r'''const router = require("express").Router(); + +router.get("/early", (req, res) => res.json([])); +router.post("/x", (req, res) => res.json({ ok: true })); +router.use("/other", requireAuth()); +router.post("/other/y", (req, res) => res.json({ ok: true })); +router.post("/others", (req, res) => res.json({ ok: true })); +router.use("/trail/", requireAuth); +router.post("/trail/z", (req, res) => res.json({ ok: true })); +router.use("/exact", requireAuth); +router.post("/exact", (req, res) => res.json({ ok: true })); +router.use("/wild/*", requireAuth); +router.post("/wild/q", (req, res) => res.json({ ok: true })); +router.use(requireAuth); +router.post("/y", (req, res) => res.json({ ok: true })); +router.all("*", (req, res) => res.sendStatus(404)); +''', + "routes/users.js": r'''const express = require("express"); +const users = express.Router(); + +users.delete("/:id", (req, res) => res.sendStatus(204)); +''', + "server/receivers.ts": r'''import express, { Router } from "express"; +import { Hono } from "hono"; +import Fastify from "fastify"; + +const users = express.Router(); +const api: Router = Router(); +const edge = new Hono(); +const srv = await Fastify(); +const f2 = fastify(); +const web = new Elysia(); +const cache = new Map(); + +users.delete("/u/:id", (req, res) => res.sendStatus(204)); +api.post("/a", (req, res) => res.sendStatus(204)); +edge.put("/e", (c) => c.text("ok")); +srv.patch("/s", async () => ({ ok: true })); +f2.post("/f", async () => ({ ok: true })); +web.get("/w", () => "ok"); +server.delete("/srv/:id", (req, res) => res.sendStatus(204)); +cache.get("/c"); +''', + "server/esm.mjs": r'''import express from "express"; + +const app = express(); + +export function checkAuth(req) { + return requireUser(req); +} + +export async function checkAsync(req) { + return getSession(req); +} + +export const checkArrow = async (req) => { + return currentUser(req); +}; + +app.post("/guarded", (req, res) => { + checkAuth(req); + res.send("ok"); +}); +app.post("/open-esm", (req, res) => res.send("o")); +''', + "server/legacy.cjs": r'''const app = require("express")(); +app.post("/legacy", (req, res) => res.send("ok")); +''', + "server/one.js": 'const app = require("express")(); app.post("/one-liner", (req, res) => res.send("x")); // route-auth-ok\nconst later = 1;\n', + "lib/latin1.js": b'const app = require("express")();\n// caf\xe9\napp.post("/latin", (req, res) => res.send("ok"));\n', + "api/f3.js": r'''const fastify = require("fastify")(); + +fastify.addHook("onSend", fastify.authenticate); +fastify.post("/late", async (req) => ({ ok: true })); +''', + "server/oldstyle.js": r'''var app = require("express")(); +var guard = function (req, res, next) { + if (!req.user) return res.sendStatus(401); + next(); +}; +let guard2 = function (req, res, next) { + authenticate(req); + next(); +}; + +app.post("/old-var", guard, function (req, res) { + res.send("ok"); +}); +app.post("/old-let", guard2, function (req, res) { + res.send("ok"); +}); +app.post("/old-open", function (req, res) { + res.send("ok"); +}); +''', + "server/attestation.js": 'const app = require("express")();\napp.post("/attest", (req, res) => res.send("ok"));\n', + "api/hooks.js": r'''const a = Fastify(); +const b = Fastify(); +const c = Fastify(); + +a.addHook("preParsing", a.authenticate); +a.post("/pp", async () => ({ ok: true })); +b.addHook("preValidation", b.authenticate); +b.post("/pv", async () => ({ ok: true })); +c.addHook("preHandler", c.authenticate); +c.post("/ph", async () => ({ ok: true })); +''', + "verbs/all.js": 'app.all(\n "/v-all",\n h\n);\n', + "lib/keys.js": "const v = cache.get(key);\n", + "app/route.ts": "export async function GET() {\n return Response.json({ ok: true });\n}\n", + "app/api/modparen/route.ts": r'''export async function GET() { + return Response.json([]); +} +(async () => { await auth(); })(); +export async function POST() { + return Response.json({ ok: true }); +} +''', + "pages/api/old.js": "// Acme legacy handler\nmodule.exports = function handler(req, res) {\n res.status(200).end();\n};\n", + "pages/api/ping2.mjs": 'export default (req, res) => res.end("pong");\n', + "lib/client.js": r'''const axios = require("axios"); +const api = axios.create({ baseURL: "https://example.com" }); +const cache = new Map(); + +async function load() { + await api.get("/items"); + await api.post("/items", { a: 1 }); + return cache.get("/items"); +} +module.exports = { load }; +''', + "src/index.ts": r'''import { Hono } from "hono"; +import { jwt } from "hono/jwt"; + +const app = new Hono(); + +app.use("/api/*", jwt({ secret: "acme-test-secret" })); + +app.get("/api/me", (c) => c.json({ me: true })); +app.post("/api/items", (c) => c.json({ ok: true })); +app.post("/public/items", (c) => c.json({ ok: true })); +''', + "api/f1.js": r'''const fastify = require("fastify")(); + +fastify.post("/early", async (req) => ({ ok: true })); +fastify.addHook("onRequest", fastify.authenticate); +fastify.post("/late", async (req) => ({ ok: true })); +''', + "api/f2.js": r'''const fastify = require("fastify")(); + +fastify.post("/secure", { preHandler: [fastify.authenticate] }, async (req) => ({ ok: true })); +fastify.post("/open", { config: { auth: false } }, async (req) => ({ ok: true })); +''', + # FastAPI / Flask. + "api/main.py": r'''from fastapi import Depends, FastAPI + +app = FastAPI() + + +@app.get("/health") +def health(): + return {"ok": True} + + +@app.post("/items") +def create_item(item: dict): + return item + + +@app.patch("/items/{item_id}") +def patch_item(item_id: int): + return {"patched": item_id} + + +@app.delete("/items/{item_id}") +def delete_item(item_id: int, user=Depends(get_current_user)): + return {"deleted": item_id} + + +@app.get("/files/{file_path:path}") +def read_file(file_path: str): + return file_path + + +@app.get("/verify_email") +def verify_email(): + return {"ok": True} + + +@app.post("/db") +def db_route(s=Depends(get_session)): + return {"ok": True} + + +@app.post("/spaced") +def spaced(u=Depends( get_user )): + return {"ok": True} + + +@app.post("/scoped") +def scoped(u=Security(get_current_active_user, scopes=["me"])): + return {"ok": True} + + +@app.post("/commented-sig") +def with_comment(a, # old signature ( + b): + return 1 + + +@app.post("/cu") +def cu(u=Depends(CurrentUser)): + return {"ok": True} + + +@app.get(PREFIX + "/dyn") +def dyn(): + return 1 + + +@app.post("/strs") +def strs(a="x#(", b='y#('): + return 1 + + +@app.post("/esc") +def esc(a="q\"(", b='q\'('): + return 1 + + +async def refresh_user(): + return g.user + + +# @app.post("/commented") +def commented(): + """Docs. + @app.post("/in-doc") looks like a route + """ + return 1 +''', + "api/authrouter.py": r'''from fastapi import APIRouter + +auth = APIRouter() + + +@auth.post("/register") +def register(data: dict): + return data +''', + "api/triple.py": r"""@app.get("/t1") +def t1(): + return ''' +@app.post("/in-sq-doc") +def fake(): + pass +''' +""", + "api/secured.py": r'''from fastapi import APIRouter, Depends + +router = APIRouter(dependencies=[Depends(verify_token)]) + + +@router.get("/secure/ping") +def ping(): + return {"ok": True} + + +@router.post("/secure/items") +def create(): + return {"ok": True} + + +@router.get("") +def root(): + return {"ok": True} +''', + "web/views.py": r'''from flask import Blueprint, g +from flask_login import current_user, login_required + +bp = Blueprint("views", __name__) + + +@bp.route("/profile") +@login_required +def profile(): + return "ok" + + +@bp.route("/profile/", methods=["POST", "DELETE"]) +def edit_profile(uid): + return "ok" + + +@bp.route("/tag/") +def tag(slug): + return slug + + +@bp.route("/opt", methods=["OPTIONS"]) +def opt(): + return "" + + +@bp.route("/lower", methods=["post"]) +def lower(): + return "" + + +@bp.route("/tuple", methods=('PUT',)) +def tuple_route(): + return "" + + +@jwt_required() +@bp.route("/above", methods=["POST"]) +def above(): + return "ok" + + +@login_required +def helper_not_a_route(): + return 1 + + +@bp.route("/pub") +def pub(): + return "ok" + + +@bp.route("/secret") +@login_required +def secret(): + return "ok" + + +@bp.route("/gme") +def gme(): + return g.user.name + + +@bp.route("/me") +def me(): + return current_user.name +''', + "web/auth_bp.py": r'''from flask import Blueprint + +auth = Blueprint("auth", __name__) + + +@auth.route("/login", methods=["POST"]) +def login(): + return "ok" + + +@auth.route("/me") +@auth.route("/whoami") +def me(): + return "ok" +''', + "web/factory.py": r'''from flask import Flask +from flask_login import login_required + + +def create_app(): + app = Flask(__name__) + + @app.route("/ping", methods=["POST"]) + def ping(): + return "pong" + + @app.route("/inner") + @login_required + def inner(): + return "ok" + + return app +''', + "web/hooks.py": r'''import hmac +from flask import Blueprint, request + +bp = Blueprint("hooks", __name__) +SHARED_SECRET = "acme-test-secret" + + +@bp.route("/hook", methods=["POST"]) +def hook(): + if not hmac.compare_digest(request.headers.get("X-Sig", ""), SHARED_SECRET): + return "no", 401 + return "ok" +''', + # Silent: vendored dirs, test files, non-code files. + "node_modules/pkg/index.js": 'const app = require("express")();\napp.post("/vendored", (req, res) => res.send("v"));\n', + "app/api/foo/route.test.ts": 'export async function POST() { return Response.json({}); }\n', + "tests/api.test.ts": 'const app = express();\napp.post("/t", (req, res) => res.send("t"));\n', + "test_api.py": '@app.post("/t")\ndef t():\n return 1\n', + "tests/test_x.py": '@app.post("/t")\ndef t():\n return 1\n', + "docs/notes.md": 'app.post("/doc", (req, res) => res.send("d"));\n', +} + +GOLD = [ # (file, text unique to the handler line, "SEV: METHOD route auth=X"); line numbers are looked up, never typed + ("app/api/notes/route.ts", "function GET", "INFO: GET /api/notes auth=none"), + ("app/api/notes/route.ts", "function POST", "INFO: POST /api/notes auth=getServerSession"), + ("app/api/notes/route.ts", "function PUT", "HIGH: PUT /api/notes auth=none"), + ("app/api/helper/route.ts", "function POST", "INFO: POST /api/helper auth=auth"), + ("app/api/export/route.ts", "const GET", "INFO: GET /api/export auth=none"), + ("app/api/export/route.ts", "const PATCH", "INFO: PATCH /api/export auth=withAuth"), + ("app/api/export/route.ts", "const POST", "HIGH: POST /api/export auth=none"), + ("app/api/oneliner/route.ts", "const GET", "INFO: GET /api/oneliner auth=none"), + ("app/api/oneliner/route.ts", "const POST", "INFO: POST /api/oneliner auth=auth"), + ("app/api/modlevel/route.ts", "function GET", "INFO: GET /api/modlevel auth=auth"), + ("app/api/modlevel/route.ts", "function POST", "INFO: POST /api/modlevel auth=auth"), + ("app/api/auth/[...nextauth]/route.ts", "export const { GET", "INFO: GET /api/auth/:nextauth* auth=public"), + ("app/api/auth/[...nextauth]/route.ts", "export const { GET", "INFO: POST /api/auth/:nextauth* auth=public"), + ("app/api/legacy/route.js", "export { handler", "HIGH: PUT /api/legacy auth=none"), + ("app/api/legacy/route.js", "export { handler", "HIGH: DELETE /api/legacy auth=none"), + ("app/api/legacy/route.js", "export { handler", "INFO: PATCH /api/legacy auth=auth"), + ("app/api/reexport/route.ts", "export {", "INFO: GET /api/reexport auth=none"), + ("app/api/reexport/route.ts", "export {", "HIGH: POST /api/reexport auth=none"), + ("app/api/unused/route.ts", "function POST", "HIGH: POST /api/unused auth=none"), + ("app/api/vars/route.js", "let GET", "INFO: GET /api/vars auth=none"), + ("app/api/vars/route.js", "var POST", "HIGH: POST /api/vars auth=none"), + ("app/api/vars/route.js", "function DELETE", "HIGH: DELETE /api/vars auth=none"), + ("app/api/helpers/route.ts", "function POST", "INFO: POST /api/helpers auth=auth"), + ("app/api/helpers/route.ts", "function PUT", "INFO: PUT /api/helpers auth=requireAuth"), + ("app/api/helpers/route.ts", "function PATCH", "INFO: PATCH /api/helpers auth=getUser"), + ("app/api/helpers/route.ts", "function DELETE", "HIGH: DELETE /api/helpers auth=none"), + ("app/docs/[[...slug]]/route.mjs", "function GET", "INFO: GET /docs/:slug* auth=none"), + ("app/(shop)/orders/[id]/route.ts", "function DELETE", "INFO: DELETE /orders/:id auth=requireUser"), + ("app/api/token/route.ts", "function POST", "INFO: POST /api/token auth=token"), + ("app/api/ping/route.ts", "function POST", "HIGH: POST /api/ping auth=none"), + ("app/api/private/data/route.ts", "function POST", "INFO: POST /api/private/data auth=middleware"), + ("app/api/public/data/route.ts", "function POST", "HIGH: POST /api/public/data auth=none"), + ("apps/web/app/dashboard/route.ts", "function POST", "INFO: POST /dashboard auth=middleware"), + ("apps/web/app/api/hook/route.ts", "function POST", "HIGH: POST /api/hook auth=none"), + ("apps/web2/app/dashboard/route.ts", "function POST", "HIGH: POST /dashboard auth=none"), + ("apps/admin/app/api/users/route.ts", "function DELETE", "INFO: DELETE /api/users auth=middleware"), + ("apps/old/pages/api/orders.ts", "export default", "INFO: POST /api/orders auth=middleware"), + ("pages/api/users/[id].ts", "export default", "HIGH: GET|DELETE /api/users/:id auth=none"), + ("pages/api/health.ts", "export default", "HIGH: ANY /api/health auth=none"), + ("pages/api/me.ts", "export default", "INFO: ANY /api/me auth=getServerSession"), + ("pages/api/orders/index.ts", "export default", "INFO: POST /api/orders auth=getSession"), + ("pages/api/items.ts", "export default", "HIGH: PUT|PATCH|DELETE /api/items auth=none"), + ("app/actions.ts", "function saveNote", "HIGH: ACTION saveNote auth=none"), + ("app/actions.ts", "function getUser", "HIGH: ACTION getUser auth=none"), + ("app/actions.ts", "const removeNote", "INFO: ACTION removeNote auth=auth"), + ("app/bom/actions.ts", "function savePrefs", "HIGH: ACTION savePrefs auth=none"), + ("app/form/actions.jsx", "function submitForm", "HIGH: ACTION submitForm auth=none"), + ("app/form/more.tsx", "function saveMore", "HIGH: ACTION saveMore auth=none"), + ("server/app.js", 'app.get("/health"', "INFO: GET /health auth=none"), + ("server/app.js", 'app.post("/orders"', "HIGH: POST /orders auth=none"), + ("server/app.js", 'app.put("/orders/:id"', "INFO: PUT /orders/:id auth=requireAuth"), + ("server/app.js", 'app.delete("/orders/:id"', "INFO: DELETE /orders/:id auth=authenticate"), + ("server/app.js", 'app.patch("/orders/:id"', "HIGH: PATCH /orders/:id auth=none"), + ("server/app.js", 'app.all("/hook"', "HIGH: ANY /hook auth=none"), + ("server/app.js", "app.get('/sq'", "INFO: GET /sq auth=none"), + ("server/app.js", "app.get(`/tpl`", "INFO: GET /tpl auth=none"), + ("server/app.js", 'app.post("/signin"', "HIGH: POST /signin auth=none"), + ("server/app.js", 'app.post("/me/update"', "INFO: POST /me/update auth=req.user"), + ("server/app.js", 'app.post("/preauth"', "HIGH: POST /preauth auth=none"), + ("server/app.js", 'app.get("/pre"', "INFO: GET /pre auth=none"), + ("server/app.js", 'app.post("/bare"', "HIGH: POST /bare auth=none"), + ("server/app.js", 'app.post("/verify"', "INFO: POST /verify auth=verifyToken"), + ("server/app.js", 'app.post("/store-key"', "HIGH: POST /store-key auth=none"), + ("server/app.js", 'app.post("/signup2"', "HIGH: POST /signup2 auth=none"), + ("server/app.js", 'app.post("/hook2"', "INFO: POST /hook2 auth=token"), + ("server/app.js", 'app.post("/hook3"', "INFO: POST /hook3 auth=token"), + ("server/app.js", 'app.post("/hook4"', "INFO: POST /hook4 auth=token"), + ("server/app.js", 'app.post("/hook5"', "INFO: POST /hook5 auth=token"), + ("server/app.js", 'app.post("/hook6"', "INFO: POST /hook6 auth=token"), + ("server/app.js", 'admin.post("/users"', "INFO: POST /users auth=requireAuth"), + ("server/app.js", 'app.post("/before-mark"', "HIGH: POST /before-mark auth=none"), + ("server/app.js", 'app.post("/signup"', "INFO: POST /signup auth=public"), + ("server/app.js", 'app.post("/login"', "INFO: POST /login auth=public"), + ("server/app.js", 'app.post("/after-login"', "HIGH: POST /after-login auth=none"), + ("routes/items.js", 'router.get("/early"', "INFO: GET /early auth=none"), + ("routes/items.js", 'router.post("/x"', "HIGH: POST /x auth=none"), + ("routes/items.js", 'router.post("/other/y"', "INFO: POST /other/y auth=requireAuth"), + ("routes/items.js", 'router.post("/others"', "HIGH: POST /others auth=none"), + ("routes/items.js", 'router.post("/trail/z"', "INFO: POST /trail/z auth=requireAuth"), + ("routes/items.js", 'router.post("/exact"', "INFO: POST /exact auth=requireAuth"), + ("routes/items.js", 'router.post("/y"', "INFO: POST /y auth=requireAuth"), + ("routes/users.js", 'users.delete("/:id"', "HIGH: DELETE /:id auth=none"), + ("server/receivers.ts", 'users.delete("/u/:id"', "HIGH: DELETE /u/:id auth=none"), + ("server/receivers.ts", 'api.post("/a"', "HIGH: POST /a auth=none"), + ("server/receivers.ts", 'edge.put("/e"', "HIGH: PUT /e auth=none"), + ("server/receivers.ts", 'srv.patch("/s"', "HIGH: PATCH /s auth=none"), + ("server/receivers.ts", 'f2.post("/f"', "HIGH: POST /f auth=none"), + ("server/receivers.ts", 'web.get("/w"', "INFO: GET /w auth=none"), + ("server/esm.mjs", 'app.post("/guarded"', "INFO: POST /guarded auth=requireUser"), + ("server/legacy.cjs", 'app.post("/legacy"', "HIGH: POST /legacy auth=none"), + ("server/one.js", 'app.post("/one-liner"', "INFO: POST /one-liner auth=public"), + ("lib/latin1.js", 'app.post("/latin"', "HIGH: POST /latin auth=none"), + ("src/index.ts", 'app.get("/api/me"', "INFO: GET /api/me auth=jwt"), + ("src/index.ts", 'app.post("/api/items"', "INFO: POST /api/items auth=jwt"), + ("src/index.ts", 'app.post("/public/items"', "HIGH: POST /public/items auth=none"), + ("api/f1.js", 'fastify.post("/early"', "HIGH: POST /early auth=none"), + ("api/f1.js", 'fastify.post("/late"', "INFO: POST /late auth=authenticate"), + ("api/f2.js", 'fastify.post("/secure"', "INFO: POST /secure auth=authenticate"), + ("api/f2.js", 'fastify.post("/open"', "HIGH: POST /open auth=none"), + ("api/f3.js", 'fastify.post("/late"', "HIGH: POST /late auth=none"), + ("api/main.py", '@app.get("/health")', "INFO: GET /health auth=none"), + ("api/main.py", '@app.post("/items")', "HIGH: POST /items auth=none"), + ("api/main.py", '@app.patch("/items/{item_id}")', "HIGH: PATCH /items/:item_id auth=none"), + ("api/main.py", '@app.delete("/items/{item_id}")', "INFO: DELETE /items/:item_id auth=Depends(get_current_user)"), + ("api/main.py", '@app.get("/files/{file_path:path}")', "INFO: GET /files/:file_path auth=none"), + ("api/main.py", '@app.get("/verify_email")', "INFO: GET /verify_email auth=none"), + ("api/main.py", '@app.post("/db")', "HIGH: POST /db auth=none"), + ("api/main.py", '@app.post("/spaced")', "INFO: POST /spaced auth=Depends(get_user)"), + ("api/main.py", '@app.post("/scoped")', "INFO: POST /scoped auth=Security(get_current_active_user)"), + ("api/main.py", '@app.post("/commented-sig")', "HIGH: POST /commented-sig auth=none"), + ("api/secured.py", '@router.get("/secure/ping")', "INFO: GET /secure/ping auth=Depends(verify_token)"), + ("api/secured.py", '@router.post("/secure/items")', "INFO: POST /secure/items auth=Depends(verify_token)"), + ("api/secured.py", '@router.get("")', "INFO: GET / auth=Depends(verify_token)"), + ("web/views.py", '@bp.route("/profile")', "INFO: GET /profile auth=login_required"), + ("web/views.py", '@bp.route("/profile/")', "INFO: GET /tag/:slug auth=none"), + ("web/views.py", '@bp.route("/lower"', "HIGH: POST /lower auth=none"), + ("web/views.py", '@bp.route("/above"', "HIGH: POST /above auth=none"), + ("web/views.py", '@bp.route("/pub")', "INFO: GET /pub auth=none"), + ("web/views.py", '@bp.route("/secret")', "INFO: GET /secret auth=login_required"), + ("web/views.py", '@bp.route("/gme")', "INFO: GET /gme auth=g.user"), + ("web/views.py", '@bp.route("/me")', "INFO: GET /me auth=current_user"), + ("web/auth_bp.py", '@auth.route("/login"', "HIGH: POST /login auth=none"), + ("web/auth_bp.py", '@auth.route("/me")', "INFO: GET /me auth=none"), + ("web/auth_bp.py", '@auth.route("/whoami")', "INFO: GET /whoami auth=none"), + ("web/hooks.py", '@bp.route("/hook"', "INFO: POST /hook auth=token"), + ("app/route.ts", "function GET", "INFO: GET / auth=none"), + ("app/api/modparen/route.ts", "function GET", "INFO: GET /api/modparen auth=auth"), + ("app/api/modparen/route.ts", "function POST", "INFO: POST /api/modparen auth=auth"), + ("pages/api/old.js", "module.exports", "HIGH: ANY /api/old auth=none"), + ("pages/api/ping2.mjs", "export default", "HIGH: ANY /api/ping2 auth=none"), + ("server/app.js", 'app.post("/two"', "INFO: POST /two auth=req.user"), + ("server/app.js", 'app.post("/ra"', "INFO: POST /ra auth=req.auth"), + ("server/app.js", 'app.post("/ru"', "INFO: POST /ru auth=request.user"), + ("server/app.js", 'app.post("/hook7"', "INFO: POST /hook7 auth=token"), + ("routes/items.js", 'router.post("/wild/q"', "INFO: POST /wild/q auth=requireAuth"), + ("server/receivers.ts", 'server.delete("/srv/:id"', "HIGH: DELETE /srv/:id auth=none"), + ("server/esm.mjs", 'app.post("/open-esm"', "HIGH: POST /open-esm auth=none"), + ("server/oldstyle.js", 'app.post("/old-var"', "INFO: POST /old-var auth=req.user"), + ("server/oldstyle.js", 'app.post("/old-let"', "INFO: POST /old-let auth=authenticate"), + ("server/oldstyle.js", 'app.post("/old-open"', "HIGH: POST /old-open auth=none"), + ("server/attestation.js", 'app.post("/attest"', "HIGH: POST /attest auth=none"), + ("api/hooks.js", 'a.post("/pp"', "INFO: POST /pp auth=authenticate"), + ("api/hooks.js", 'b.post("/pv"', "INFO: POST /pv auth=authenticate"), + ("api/hooks.js", 'c.post("/ph"', "INFO: POST /ph auth=authenticate"), + ("verbs/all.js", "app.all(", "HIGH: ANY /v-all auth=none"), + ("api/main.py", '@app.post("/cu")', "INFO: POST /cu auth=Depends(CurrentUser)"), + ("api/main.py", '@app.post("/strs")', "HIGH: POST /strs auth=none"), + ("api/main.py", '@app.post("/esc")', "HIGH: POST /esc auth=none"), + ("api/authrouter.py", '@auth.post("/register")', "HIGH: POST /register auth=none"), + ("api/triple.py", '@app.get("/t1")', "INFO: GET /t1 auth=none"), + ("web/factory.py", '@app.route("/ping"', "HIGH: POST /ping auth=none"), + ("web/factory.py", '@app.route("/inner")', "INFO: GET /inner auth=login_required"), + ("web/views.py", '@bp.route("/tuple"', "HIGH: PUT /tuple auth=none"), +] + +POST1 = "export async function POST() {\n return Response.json({ ok: true });\n}\n" +MW = [ # (app dir, route dir, route, auth): which routes the app's Next middleware covers + ("apps/site", "contact", "/contact", "none"), # an i18n middleware names no identity check + ("apps/web", "og.png", "/og.png", "none"), # its matcher excludes .png through an escaped dot + ("apps/m", "a", "/a", "middleware"), ("apps/m", "a/x", "/a/x", "middleware"), + ("apps/m", "b", "/b", "none"), ("apps/m", "b/x", "/b/x", "middleware"), ("apps/m", "b/x/y", "/b/x/y", "middleware"), + ("apps/m", "c/[id]", "/c/:id", "middleware"), ("apps/m", "c", "/c", "none"), + ("apps/m", "d", "/d", "middleware"), ("apps/m", "d/x/y", "/d/x/y", "none"), + ("apps/dyn", "x", "/x", "none"), # a matcher held in a variable cannot be read: covers nothing + ("apps/proxy", "admin", "/admin", "middleware"), ("apps/proxy", "open", "/open", "none"), + ("apps/m", "e/x", "/e/x", "middleware"), # a `(?:` group in a matcher is not a :name parameter + ("apps/js", "x", "/x", "middleware"), # a middleware.js without a matcher covers everything + ("apps/sq", "sq/x", "/sq/x", "middleware"), ("apps/bt", "bt/x", "/bt/x", "middleware"), # string matchers: single quote, backtick + ("apps/neg", "x", "/x", "middleware"), ("apps/neg", "api", "/api", "none"), # the stock negative-lookahead matcher + ("apps/strmw", "auth/x", "/auth/x", "none"), # a middleware that merely mentions `auth` in a matcher string checks nothing + ("apps/gate", "api/x", "/api/x", "middleware"), # a proxy that can answer 401 gates what its matcher covers, whatever its session check is called + ("apps/gate-colon", "api/x", "/api/x", "middleware"), ("apps/gate-call", "api/x", "/api/x", "middleware"), # `status: 403` and `.status(401)` + ("apps/gate-body", "api/x", "/api/x", "middleware"), ("apps/gate-forbidden", "api/x", "/api/x", "middleware"), # an Unauthorized / Forbidden response + ("apps/gate-headers", "api/x", "/api/x", "none"), # a proxy that only sets headers gates nothing + ("apps/gate-decoys", "api/x", "/api/x", "none"), # other statuses, `http_status`, a bare "Forbidden" and a commented-out refusal are no gate + ("apps/gate-part", "in/x", "/in/x", "middleware"), ("apps/gate-part", "out/x", "/out/x", "none"), # the matcher decides which routes the refusal covers + ("apps/open", "acme-api/v1.0/notes", "/acme-api/v1.0/notes", "middleware"), # a gate that lists public paths still covers the rest + ("apps/open", "acme-api/v1.0/hook", "/acme-api/v1.0/hook", "none"), ("apps/open", "acme-api/v1.0/hook/ping", "/acme-api/v1.0/hook/ping", "none"), # a listed path and what is under it + ("apps/open", "acme-api/v1.0/hooks", "/acme-api/v1.0/hooks", "middleware"), # a sibling that merely shares the string prefix is not exempt + ("apps/open", "acme-api/v1.0/legacy", "/acme-api/v1.0/legacy", "none"), ("apps/open", "acme-api/v1.0/a+b", "/acme-api/v1.0/a+b", "none"), # trailing slash, regex characters + ("apps/open", "acme-api/v1.0/late", "/acme-api/v1.0/late", "none"), # a literal written after the matcher counts too + ("apps/open", "admin/x", "/admin/x", "middleware"), ("apps/open", "billing", "/billing", "middleware"), # a literal that is a matcher entry is a scope check, not a way out + ("apps/open-all", "api/pay", "/api/pay", "none"), ("apps/open-all", "api/notes", "/api/notes", "middleware"), # a middleware without a matcher: the whole file is read + ("apps/scope", "acme-api/x", "/acme-api/x", "middleware"), # a path in the condition of an `if` that refuses scopes the gate, it is no way out, whatever the matcher + ("apps/scope-deep", "acme-api/x", "/acme-api/x", "middleware"), # the refusal may sit deeper in the block, after an inner block closed, be a bare response string, and brackets in strings do not count + ("apps/scope-line", "acme-api/x", "/acme-api/x", "middleware"), # an unbraced body on the next line runs to the end of its multi-line call + ("apps/scope-line", "acme-skip/x", "/acme-skip/x", "none"), ("apps/scope-line", "acme-login", "/acme-login", "none"), # a body without `;` ends at its line; a path in the refusal itself is no scope + ("apps/scope-stmt", "acme-skip/x", "/acme-skip/x", "none"), ("apps/scope-stmt", "acme-open/x", "/acme-open/x", "none"), # a later statement on the line and an else branch are no part of the body + ("apps/scope-neg", "acme-web/x", "/acme-web/x", "middleware"), # ceiling: a negated refusal is not read, so the open part reads covered + ("apps/set", "acme-health", "/acme-health", "none"), ("apps/set", "acme-api/boot", "/acme-api/boot", "none"), # a Set allow-list is a literal list like any other + ("apps/set", "acme-api/notes", "/acme-api/notes", "middleware"), ("apps/set", "acme-late", "/acme-late", "none"), # siblings stay covered; a literal after the refusing condition is an exemption + ("apps/scope-cont", "acme-skip/x", "/acme-skip/x", "none"), # a line break that carries nothing on still ends the body + ("apps/scope-cont", "acme-api/x", "/acme-api/x", "middleware"), ("apps/scope-cont", "acme-and/x", "/acme-and/x", "middleware"), # a ternary or `&&` body runs over its lines; comments between them hide nothing + ("apps/scope-cont", "acme-chain/x", "/acme-chain/x", "middleware"), # each line start (. ? : && ||) and line end (? : && ||) that carries the statement on + ("apps/scope-eq", "acme-api/x", "/acme-api/x", "middleware"), # the path may be the last operand of the condition + ("apps/open-brace", "acme-open/x", "/acme-open/x", "none"), # brackets inside an allow-list body close again: the refusal after it is not part of the body + ("apps/scope-arr", "acme-arr/x", "/acme-arr/x", "middleware"), # a line break inside an open bracket does not end the body + ("apps/glob", "sign-in/x", "/sign-in/x", "none"), ("apps/glob", "acme-api/webhooks/stripe", "/acme-api/webhooks/stripe", "none"), # a glob list (Clerk createRouteMatcher): a trailing (.*) ... + ("apps/glob", "acme-glob/x", "/acme-glob/x", "none"), ("apps/glob", "acme-path/x", "/acme-path/x", "none"), # ... /* or /:name* names the path before it + ("apps/glob", "acme-seg/x", "/acme-seg/x", "none"), # the slash before a wildcard goes too + ("apps/glob", "acme-api/other", "/acme-api/other", "middleware"), ("apps/glob", "acme-mid/x", "/acme-mid/x", "middleware"), # siblings stay covered; a wildcard inside the path is not read +] +for base, d, route, lab in MW: + FX["%s/app/%s/route.ts" % (base, d)] = POST1 + GOLD.append(("%s/app/%s/route.ts" % (base, d), "function POST", "%s: POST %s auth=%s" % ("HIGH" if lab == "none" else "INFO", route, lab))) + + +def gate(body, matcher='"/:path*"'): + """A Next proxy.ts that names no identity check and runs body before it lets the request through (no config at all when matcher is empty).""" + return ('import { NextResponse, type NextRequest } from "next/server";\n\nexport function proxy(req: NextRequest) {\n%s return NextResponse.next();\n}\n%s' + % (body, "\nexport const config = { matcher: %s };\n" % matcher if matcher else "")) + + +FX["apps/gate/proxy.ts"] = gate(' if (!isMemberSession(req)) return new NextResponse("Unauthorized", { status: 401 });\n') +FX["apps/gate-colon/proxy.ts"] = gate(' if (!isMemberSession(req)) return NextResponse.json({ ok: false }, { status: 403 });\n') +FX["apps/gate-call/proxy.ts"] = gate(' if (!isMemberSession(req)) return reply.status(401).send("denied");\n') +FX["apps/gate-body/proxy.ts"] = gate(' if (!isMemberSession(req)) {\n return new Response(\n "Unauthorized request",\n { status: code },\n );\n }\n') +FX["apps/gate-forbidden/proxy.ts"] = gate(" if (!isMemberSession(req)) return new NextResponse('Forbidden', init);\n") +FX["apps/gate-headers/proxy.ts"] = gate(' req.headers.set("x-acme-region", "eu");\n') +FX["apps/gate-decoys/proxy.ts"] = gate( + ' // return new NextResponse("Unauthorized", { status: 401 });\n const log = { http_status: 401 };\n const reason = "Forbidden";\n' + " if (isOverQuota(req)) return new NextResponse(null, { status: 402 });\n if (isGone(req)) return new NextResponse(null, { status: 404 });\n") +FX["apps/gate-part/proxy.ts"] = gate(' if (!isMemberSession(req)) return new NextResponse(null, { status: 401 });\n', '["/in/:path*"]') +# Allow-lists: a quoted "/path" anywhere in the file is a route the function lets through, unless it is a prefix of a matcher entry (a scope check). +FX["apps/open/proxy.ts"] = gate( + ' const open = ["/acme-api/v1.0/hook", "/acme-api/v1.0/legacy/", "/acme-api/v1.0/a+b"];\n const path = req.nextUrl.pathname;\n' + ' // "/acme-api/v1.0/notes" is gated like the rest: a path in a comment lets nothing through\n' + ' if (path === "/") return NextResponse.next();\n if (!path.startsWith("/acme-api") || path === "/acme-api/v1.0") return NextResponse.next();\n' + ' if (open.some((p) => path.startsWith(p))) return NextResponse.next();\n' + ' if (path === "/billing" && !isMemberSession(req)) return new NextResponse(null, { status: 403 });\n' + ' if (!isMemberSession(req)) return new NextResponse(null, { status: 401 });\n', + '["/admin/:path*", "/acme-api/v1.0/:path*", "/billing"]') + 'export const late = ["/acme-api/v1.0/late"];\n' +FX["apps/open-all/proxy.ts"] = gate( + ' if (req.nextUrl.pathname === "/") return NextResponse.next();\n if (req.nextUrl.pathname.startsWith("/api/pay")) return NextResponse.next();\n' + ' if (!isMemberSession(req)) return new NextResponse(null, { status: 401 });\n', "") +# Scope checks: a quoted path in the condition of an `if` whose body can refuse protects that area, it does not let it through (a catch-all matcher has no entry to be a prefix of). +FX["apps/scope/proxy.ts"] = gate( + " const p = req.nextUrl.pathname;\n" + " if (p.startsWith('/acme-api/') || p.startsWith('/_next/')) { return new NextResponse('Unauthorized', { status: 401 }) }\n") +FX["apps/scope-deep/proxy.ts"] = gate( + " const p = req.nextUrl.pathname;\n if(p.startsWith('/acme-api/') && !req.method.includes(':)')) {\n if (req.method === ':)') { return NextResponse.next() }\n" + " if (!isMemberSession(req)) return new NextResponse('Unauthorized', init);\n }\n") +FX["apps/scope-line/proxy.ts"] = gate( + " const p = req.nextUrl.pathname\n if (p.startsWith('/acme-skip/')) return NextResponse.next()\n" + " if (p.startsWith('/acme-api/'))\n return NextResponse.json(\n { error: 'denied', login: '/acme-login' },\n { status: 403 },\n )\n") +FX["apps/scope-stmt/proxy.ts"] = gate( + " const p = req.nextUrl.pathname;\n" + " if (p.startsWith('/acme-skip/')) return NextResponse.next(); if (!isMemberSession(req)) return new NextResponse('Unauthorized', { status: 401 });\n" + " if (p.startsWith('/acme-open/')) { return NextResponse.next() } else { return new NextResponse('Unauthorized', { status: 401 }) }\n") +FX["apps/scope-neg/proxy.ts"] = gate( + " if (!req.nextUrl.pathname.startsWith('/acme-web')) return new NextResponse('Unauthorized', { status: 401 });\n") +FX["apps/set/proxy.ts"] = gate( + " const open = new Set(['/acme-health', '/acme-api/boot']);\n const pathname = req.nextUrl.pathname;\n if (open.has(pathname)) return NextResponse.next();\n" + " if (!isMemberSession(req)) return new NextResponse('Unauthorized', { status: 401 });\n") + "export const late = ['/acme-late'];\n" +# A braceless body runs over a line break that cannot end a statement; the chain needs every continuation at once (one per line break). +FX["apps/scope-cont/proxy.ts"] = gate( + " const p = req.nextUrl.pathname;\n if (p.startsWith('/acme-skip/')) return NextResponse.next()\n" + " if (p.startsWith('/acme-api/'))\n return !getToken(req)\n ? new NextResponse('Unauthorized', { status: 401 })\n : NextResponse.next();\n" + " if (p.startsWith('/acme-and/'))\n return !getToken(req) && // no session\n // refuse\n new NextResponse('Unauthorized', { status: 401 });\n" + " if (p.startsWith('/acme-chain/'))\n return isOpen(req)\n ? ctx.res\n .next()\n : isStaff(req) &&\n isBot(req) ||\n isMember(req)\n && isTeam(req)\n" + " || isGuest(req) ?\n isAdmin(req) :\n NextResponse.json({ error: 'denied' }, { status: 403 })\n") +FX["apps/scope-eq/proxy.ts"] = gate( + " const p = req.nextUrl.pathname;\n if (p === '/acme-api') return new NextResponse(null, { status: 401 });\n") +FX["apps/open-brace/proxy.ts"] = gate( + " const p = req.nextUrl.pathname;\n if (p.startsWith('/acme-open')) return NextResponse.next({ headers: [req.headers] });\n" + " if (!isMemberSession(req)) return new NextResponse('Unauthorized', { status: 401 });\n") +FX["apps/scope-arr/proxy.ts"] = gate( + " const p = req.nextUrl.pathname;\n if (p.startsWith('/acme-arr/'))\n return [\n new NextResponse('Unauthorized', { status: 401 })\n ][0];\n") +FX["apps/glob/proxy.ts"] = gate( + " const open = createRouteMatcher(['/sign-in(.*)', '/acme-api/webhooks(.*)', '/acme-seg/(.*)', '/acme-glob/*', '/acme-path/:path*', '/acme-mid/*/x']);\n" + " if (open(req)) return NextResponse.next();\n if (!isMemberSession(req)) return new NextResponse('Unauthorized', { status: 401 });\n") + +DEPKW = ("user", "auth", "token", "current", "bearer", "jwt", "api_key", "apikey", "verify", "login") # one FastAPI route per Depends keyword +FX["api/deps.py"] = "from fastapi import Depends, FastAPI\n\napp = FastAPI()\n" + "".join( + '\n\n@app.post("/d-%s")\ndef d_%s(x=Depends(f_%s_dep)):\n return 1\n' % (k, k, k) for k in DEPKW) +GOLD += [("api/deps.py", '@app.post("/d-%s")' % k, "INFO: POST /d-%s auth=Depends(f_%s_dep)" % (k, k)) for k in DEPKW] + +JSID = ("getServerSession", "getSession", "getToken", "getAuth", "auth", "currentUser", "getUser", "getCurrentUser", "requireAuth", "requireAuthLevel", "requireUserRole", + "requireSessionX", "requireLoginX", "requireAdminX", "withAuth", "authMiddleware", "authGuard", "authenticate", "authenticateUser", "authorize", "authorizeRole", + "isAuthenticated", "isLoggedIn", "ensureAuth", "ensureAuthX", "ensureLoggedInX", "protect", "validateRequest", "verify", "verifyToken", "constructEvent", + "jwt", "expressjwt", "checkJwt", "bearerAuth", "basicAuth", "clerkMiddleware") # one Express route per IDENTITY name that reads as a call; the bare forms pin the `\w*` suffixes +FX["server/idents.js"] = 'const app = require("express")();\n' + "".join('app.post("/i-%s", (req, res) => { %s(req); res.send("ok"); });\n' % (n, n) for n in JSID) +GOLD += [("server/idents.js", '"/i-%s"' % n, "INFO: POST /i-%s auth=%s" % (n, n)) for n in JSID] +PYID = ("login_required", "jwt_required", "token_required", "auth_required", "requires_auth", "permission_required", "get_current_admin") # one Flask route per decorator name +FX["api/idents.py"] = "from flask import Flask\n\napp = Flask(__name__)\n" + "".join('\n\n@app.post("/p-%s")\n@%s\ndef f_%s():\n return 1\n' % (n, n, n) for n in PYID) +GOLD += [("api/idents.py", '@app.post("/p-%s")' % n, "INFO: POST /p-%s auth=%s" % (n, n)) for n in PYID] + +FX["server/latest.js"] = 'const app = require("express")();\napp.post("/latest", (req, res) => res.send("x"));\n' # `test.` inside a file name does not make a test file +FX["lib/contests/api.js"] = 'const app = require("express")();\napp.post("/contest", (req, res) => res.send("x"));\n' # nor does `tests/` inside a directory name +FX["app/api/og/route.tsx"] = POST1 # JSX route handlers are routes too +FX["pages/api/legacy.jsx"] = 'export default function handler(req, res) {\n res.end("x");\n}\n' +FX["pages/api/app/route.js.py"] = "x = 1\n" # a Python file named like a Next route: only a missing `$` anchor would scan it +GOLD += [("server/latest.js", 'app.post("/latest"', "HIGH: POST /latest auth=none"), ("lib/contests/api.js", 'app.post("/contest"', "HIGH: POST /contest auth=none"), + ("app/api/og/route.tsx", "function POST", "HIGH: POST /api/og auth=none"), ("pages/api/legacy.jsx", "export default", "HIGH: ANY /api/legacy auth=none")] + +# One fixture per rule the mutation table pins: a mutated copy of the script must change these rows. +FX["server/decoys.js"] = 'const app = require("express")();\n\napp.post("/dollar", $auth, (req, res) => res.send("x"));\napp.get("/a" + suffix, (req, res) => res.send("x"));\n' # `$auth` is no `auth`; a computed path is no route +FX["server/order.js"] = 'const app = require("express")();\n\napp.use(requireAuth);\napp.post("/both", checkJwt, (req, res) => res.send("x"));\n' # the route's own guard is named before the router-level one +FX["server/guard.js"] = 'const app = require("express")();\n\napp.post("/g", (req, res) => { guard(req); res.send("ok"); });\napp.post("/g2", (req, res) => res.send("ok"));\n\nfunction guard(req) { return auth(); }\n' # a one-line function that ends the file +FX["api/f4.js"] = "const fastify = require(\"fastify\")();\n\nfastify.addHook('onRequest', requireAuth);\nfastify.post(\"/sq-hook\", async (req) => ({ ok: true }));\n" +FX["api/contest_api.py"] = '@app.post("/contest-entry")\ndef contest_entry():\n return 1\n' # `test_` inside a file name does not make a test file +FX["api/misc.py"] = r'''from flask import Flask + +app = Flask(__name__) + + +@app.post("/dotted") +@auth.login_required +def dotted(): + return 1 + + +@app.get('/sq-deco') +def sq_deco(): + return 1 + + +@app.get( "/pad" ) +def pad(): + return 1 + + +def make(): + app = Flask(__name__) + + @app.post("/nested-last") + def nested_last(): + return 1 + + +@login_required +def other(): + return 2 + + +@app.get("/unclosed" +''' +FX["api/strings.py"] = r'''from flask import Flask + +app = Flask(__name__) +NOTE = """say \""" then x""" + + +@app.post("/after-esc") +@login_required +def after_esc(): + """Docs.""" + return 1 + + +def e1(a=''): + return 1 + + +@app.post("/e-route") +@login_required +def e2(b=''): + return 2 + + +@app.post("/esc-dq") +def esc_dq(a="\\", b="x("): + return 1 + + +@app.post("/esc-sq") +def esc_sq(a='\\', b='x('): + return 1 + + +@app.get("/d1") +def d1(): + """ + @app.post("/in-dq-doc") + def fake(): + pass + """ + return 1 +''' +FX["app/api/lastline/route.ts"] = 'export async function GET() {\n return Response.json([]);\n}\n\nexport const POST = async () => { await auth(); return Response.json({}); };\n' # a one-line export that ends the file +FX["app/api/nexts/route.ts"] = 'export async function POSTER() {\n return Response.json({ ok: true });\n}\n\nexport let { GET } = h;\nexport var { PUT } = h;\n' # POSTER is no POST +FX["app/api/reqdecoy/route.ts"] = 'export async function POST(req: Request) {\n const a = prereq.user;\n const b = req.username;\n return Response.json({ a, b });\n}\n' +FX["app/api/split/route.ts"] = 'export async function POST(req: Request) {\n const kind = req.headers.get("x-kind");\n if (kind !== "ping") return new Response("no", { status: 400 });\n const token = await mintToken();\n return Response.json({ token });\n}\n' # header, comparison and secret word on three lines +FX["pages/api/rev.js"] = 'export default function handler(req, res) {\n if ("POST" == req.method) return res.json({ ok: true });\n return res.status(405).end();\n}\n' +FX["pages/api/private/export.ts"] = 'export default function handler(req, res) {\n res.status(200).json({ ok: true });\n}\n' # the root middleware matcher covers it +FX["pages/api/helped.ts"] = 'function check(req) {\n return getServerSession(req);\n}\n\nexport default function handler(req, res) {\n res.end("x");\n}\n' # a pages/api handler is the whole file +FX["app/sq/actions.js"] = "'use server';\n\nexport async function sqAction(x) {\n return x;\n}\n" +FX["apps/m/app/a/tok/route.ts"] = 'export async function POST(req: Request) {\n if (req.headers.get("x-api-key") !== process.env.ACME_KEY) return new Response("no", { status: 401 });\n return Response.json({ ok: true });\n}\n' # a token check outranks the covering middleware +FX["apps/m/middleware.tsx"] = "export const z = 1;\n" # sorts after the real middleware.ts: must not replace it +FX["apps/rootm/middleware.ts"] = 'export { auth as middleware } from "@/auth";\n\nexport const config = { matcher: ["/:id"] };\n' +FX["apps/rootm/app/route.ts"] = POST1 # `/:id` needs a segment: it does not cover `/` +FX["apps/strmw/middleware.ts"] = 'export function middleware() {}\n\nexport const config = { matcher: ["/auth/:path*"] };\n' # `auth` only inside a string is no identity check +GOLD += [ + ("server/decoys.js", 'app.post("/dollar"', "HIGH: POST /dollar auth=none"), + ("server/order.js", 'app.post("/both"', "INFO: POST /both auth=checkJwt"), + ("server/guard.js", 'app.post("/g"', "INFO: POST /g auth=auth"), + ("server/guard.js", 'app.post("/g2"', "HIGH: POST /g2 auth=none"), + ("api/f4.js", 'fastify.post("/sq-hook"', "INFO: POST /sq-hook auth=requireAuth"), + ("api/contest_api.py", '@app.post("/contest-entry")', "HIGH: POST /contest-entry auth=none"), + ("api/misc.py", '@app.post("/dotted")', "INFO: POST /dotted auth=login_required"), + ("api/misc.py", "@app.get('/sq-deco')", "INFO: GET /sq-deco auth=none"), + ("api/misc.py", '@app.get( "/pad" )', "INFO: GET /pad auth=none"), + ("api/misc.py", '@app.post("/nested-last")', "HIGH: POST /nested-last auth=none"), + ("api/strings.py", '@app.post("/after-esc")', "INFO: POST /after-esc auth=login_required"), + ("api/strings.py", '@app.post("/e-route")', "INFO: POST /e-route auth=login_required"), + ("api/strings.py", '@app.post("/esc-dq")', "HIGH: POST /esc-dq auth=none"), + ("api/strings.py", '@app.post("/esc-sq")', "HIGH: POST /esc-sq auth=none"), + ("api/strings.py", '@app.get("/d1")', "INFO: GET /d1 auth=none"), + ("app/api/lastline/route.ts", "function GET", "INFO: GET /api/lastline auth=none"), + ("app/api/lastline/route.ts", "const POST", "INFO: POST /api/lastline auth=auth"), + ("app/api/nexts/route.ts", "export let { GET }", "INFO: GET /api/nexts auth=none"), + ("app/api/nexts/route.ts", "export var { PUT }", "HIGH: PUT /api/nexts auth=none"), + ("app/api/reqdecoy/route.ts", "function POST", "HIGH: POST /api/reqdecoy auth=none"), + ("app/api/split/route.ts", "function POST", "HIGH: POST /api/split auth=none"), + ("pages/api/rev.js", "export default", "HIGH: POST /api/rev auth=none"), + ("pages/api/private/export.ts", "export default", "INFO: ANY /api/private/export auth=middleware"), + ("pages/api/helped.ts", "export default", "INFO: ANY /api/helped auth=getServerSession"), + ("app/sq/actions.js", "function sqAction", "HIGH: ACTION sqAction auth=none"), + ("apps/m/app/a/tok/route.ts", "function POST", "INFO: POST /a/tok auth=token"), + ("apps/rootm/app/route.ts", "function POST", "HIGH: POST / auth=none"), +] + +JS_T = 'const app = require("express")();\napp.post("/t", (req, res) => res.send("t"));\n' +FX["webapp/api/route.ts"] = POST1 # `webapp/` is not an `app/` directory: not a Next route +for rel in ("test/app.js", "tests/app.js", "src/tests/app.js", "e2e/app.js", "__tests__/app.js", "src/x.spec.js", "src/x.test.js"): # one silent file per TEST alternative + FX[rel] = JS_T +for rel in ("api/conftest.py", "api/items_test.py", "api/test_items.py"): + FX[rel] = '@app.post("/t")\ndef t():\n return 1\n' +QUIET = ("lib/client.js", "lib/keys.js", "lib/plain.ts") # scanned (they look like they hold a route) but yield no handler row + + +def write(root, files): + for rel, text in files.items(): + p = Path(root) / rel + p.parent.mkdir(parents=True, exist_ok=True) + if isinstance(text, bytes): p.write_bytes(text) + else: p.write_text(text, encoding="utf-8") + + +def run(script, d, *flags): + return subprocess.run([sys.executable, "-I", str(script), *flags, str(d)], capture_output=True, text=True) + + +def golden(): + out = [] + for path, marker, expect in GOLD: + src = FX[path].decode("latin-1") if isinstance(FX[path], bytes) else FX[path] + hits = [n for n, line in enumerate(src.splitlines(), 1) if marker in line] + assert len(hits) == 1, (path, marker, hits) + out.append("%s:%d: %s" % (path, hits[0], expect)) + return out + + +def split(res): + lines = res.stdout.splitlines() + return lines[:-1], json.loads(lines[-1]) + + +def each(old, a, b, sep=", "): + """(old, new) mutants that each drop one `sep`-separated member of old (the text between its first `a` and its last `b`).""" + i, j = old.index(a) + len(a), old.rindex(b) + ms = old[i:j].split(sep) + return [(old, old[:i] + sep.join(ms[:k] + ms[k + 1:]) + old[j:]) for k in range(len(ms))] + + +# One row per rule: replacing `old` (which must occur exactly once in route_auth_map.py) by `new` must change what the script prints. +MUTANTS = [ + # header: the sibling import must work under -I and leave no bytecode beside the script + ("sys.dont_write_bytecode = True", "sys.dont_write_bytecode = False"), + ("sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))", "pass"), + # configuration + *each('RECEIVERS = {"app", "router", "server", "fastify"}', "{", "}"), + *each('EXT = (".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py")', "(", ")"), + *each('VERBS = ("GET", "POST", "PUT", "PATCH", "DELETE")', "(", ")"), + ('MARK = "route-auth-ok"', 'MARK = "route-auth-no"'), + ("H = V.lower()", "H = V"), + # IDENTITY regex members (the plain members are mutated one by one in identity_mutants) + *each(r"require(?:Auth|User|Session|Login|Admin)\w*", "(?:", ")", "|"), + *each(r"auth(?:Middleware|Guard)", "(?:", ")", "|"), + *each(r"ensure(?:Auth|LoggedIn)\w*", "(?:", ")", "|"), + (r"require(?:Auth|User|Session|Login|Admin)\w*", r"require(?:Auth|User|Session|Login|Admin)"), + (r"ensure(?:Auth|LoggedIn)\w*", r"ensure(?:Auth|LoggedIn)"), + (r"authenticate\w*", "authenticate"), (r"authorize\w*", "authorize"), (r'verify\w*"', 'verify"'), (r'get_current_\w+"', 'get_current_"'), + # CALL / USE: a called or bare identity name, not the tail of a longer name or an object key + (r"(?", r"<(\w+)>"), (r"\{(\w+)(?::[^}]*)?\}", r"\{(\w+)\}"), + ('return p if p.startswith("/") else "/" + p', "return p"), + # methods / items + (r"\bmethod\s*[!=]==?", r"\bmethod\s*==="), (r"[!=]==?\s*\w+\.method", r"===\s*\w+\.method"), + (r"""k.replace("'", '"')""", "k"), ("[v for v in VERBS if v in found]", "sorted(found)"), + (r"(?::|\bas\b)", r"(?::)"), (r"(?::|\bas\b)", r"(?:\bas\b)"), + ("(p[0], p[-1])", "(p[0], p[0])"), ("(p[0], p[-1])", "(p[-1], p[-1])"), + # nextdecl / defs + ("END.search(m, i + 1)", "END.search(m, i)"), ("return x.start() if x else len(m)", "return x.start() if x else 0"), + ("(PYFN if py else JSFN)", "(JSFN if py else PYFN)"), ("e.start() if e else len(m))", "e.start() if e else 0)"), + # p2re + (r're.sub(r"/:\w+\*", "(?:/.*)?", p)', "p"), (r'"(?:/.*)?"', r'"/.*"'), + (r're.sub(r"/:\w+\+", "/.+", p)', "p"), + (r're.sub(r"/:\w+\?", "(?:/[^/]+)?", p)', "p"), (r'"(?:/[^/]+)?"', r'"(?:/.+)?"'), + (r"(? 1:", "elif lit:"), ("elif lit and len(ps) > 1:", "elif lit and len(ps) > 2:"), ("gone.append((op, cl + 1))", "pass"), + ('"ANY" if k == "all" else k.upper()', "k.upper()"), ('"ANY" if k == "all" else k.upper()', '"ANY" if k == "all" else k'), + ("lit.group(2), [(op", "lit.group(0), [(op"), + ('ident(",".join(a[0] for a in ps[:-1]), True), r, None', 'ident(",".join(a[0] for a in ps), True), r, None'), + ('ident(",".join(a[0] for a in ps[:-1]), True), r, None', 'ident(",".join(a[0] for a in ps[:-1])), r, None'), + ('ident(",".join(a[0] for a in ps[:-1]), True), r, None', 'ident(",".join(a[0] for a in ps[:-1]), True), None, None'), + # py_hs + ("pm = ps and re.fullmatch(", "pm = re.fullmatch("), ("ps[0][1].strip())", "ps[0][1])"), + (r"""(/[^"']*|)""", r"""(/[^"']*)"""), (r"""(["'])(/[^"']*|)""", r"""(["])(/[^"']*|)"""), (r"""(["'])(/[^"']*|)""", r"""(['])(/[^"']*|)"""), + ("dm = PYDEF.search(m, cl) if pm else None", "dm = PYDEF.search(m, cl)"), ("if sc < 0: continue", "if False: continue"), + (r"[\[(]([^\])]*)", r"\[([^\])]*)"), + ('meths = [t.group(2).upper()]', "meths = [t.group(2)]"), ("km.group(1), re.I)]", "km.group(1))]"), + (r"""['"]%s['"]""", r"""["]%s["]"""), (r"""['"]%s['"]""", r"""[']%s[']"""), + ('else: meths = ["GET"]', 'else: meths = ["ANY"]'), ("if not meths: continue", "if not meths: pass"), + (r"[ \t]{0,%d}(?=\S)", r"[ \t]{%d}(?=\S)"), ("% len(t.group(1))", "% 0"), ("end = e.start() if e else len(m)", "end = e.start() if e else 0"), + ("[(t.start(), dm.start()), (dm.end() - 1, end)]", "[(dm.start(), dm.start()), (dm.end() - 1, end)]"), + ("[(t.start(), dm.start()), (dm.end() - 1, end)]", "[(t.start(), dm.start())]"), + # analyze + ("mk_ = pymask if py else mask", "mk_ = mask"), + ('nroute(n.group(2) or "")', "nroute(n.group(2))"), ('n.group(1) or "", hs', "n.group(1), hs"), ('p.group(1) or ""))', "p.group(1)))"), + ('"|".join(methods(mk)) or "ANY"', '"|".join(methods(mk)) or "GET"'), + ('segs[:-1] if segs[-1] == "index" else segs', "segs"), ('segs[-1] == "index"', 'segs[-1] != "index"'), ("[(0, len(m))]", "[(0, 0)]"), + (r"(?:export\s+default|module\.exports)", r"(?:module\.exports)"), (r"(?:export\s+default|module\.exports)", r"(?:export\s+default)"), + (r'module\.exports)", m, re.M)', r'module\.exports)", m)'), + ("d.start() if d else 0", "0"), + ("if re.match(", "if re.search("), (r"""r"\s*(['\"])use server\1", mk)""", r"""r"(['\"])use server\1", mk)"""), + (r"""r"\s*(['\"])use server\1", mk)""", r"""r"\s*(['])use server\1", mk)"""), (r"""r"\s*(['\"])use server\1", mk)""", r"""r"\s*([\"])use server\1", mk)"""), + (r"^export\s+async\s+function\s+(\w+)|", r"^export\s+function\s+(\w+)|"), + (r"|^export\s+const\s+(\w+)\s*=\s*async\b", r"|^export\s+const\s+(\w+)\s*=\s*"), + ("hide = gone + list(fn.values())", "hide = gone"), ("hide = gone + list(fn.values())", "hide = list(fn.values())"), + ("mod = ident(blank(m, hide))", "mod = ident(m)"), + ("+ [(mod, False)]", ""), ("+ [(mod, False)]", "+ [(mod, True)]"), + ('for a, b in spans + [fn[w] for w in sorted(set(re.findall(r"[\\w$]+", own))) if w in fn]]', "for a, b in spans]"), + ("tok(mk[a:b])", "tok(m[a:b])"), ("ident(m[a:b])", "ident(m[a:b], True)"), + ("u[0] == rcv and u[1] < pos and", "u[1] < pos and"), ("u[0] == rcv and u[1] < pos and", "u[0] == rcv and"), + ('(route == u[2] or route.startswith(u[2] + "/"))', 'route.startswith(u[2] + "/")'), + ('(route == u[2] or route.startswith(u[2] + "/"))', "(route == u[2] or route.startswith(u[2]))"), + ('(route == u[2] or route.startswith(u[2] + "/"))', "route == u[2]"), + ("mws.get(mwb) if mwb is not None else None", "mws.get(mwb) if mwb else None"), + ("x.fullmatch(route)", "x.match(route)"), ("x.fullmatch(route)", "x.search(route)"), + ("any(x.fullmatch(route) for x in pats)", "all(x.fullmatch(route) for x in pats)"), + ("lab = pre or use or", "lab = use or"), ("lab = pre or use or", "lab = pre or"), ("lab = pre or use or", "lab = use or pre or"), + ('next((g[0] for g in got if g[0]), None) or ("token"', '("token"'), + ('or ("token" if any(g[1] for g in got) else None) or mw', "or mw"), ("else None) or mw or \"none\"", 'else None) or "none"'), + ('or ("token" if any(g[1] for g in got) else None) or mw or "none"', 'or mw or ("token" if any(g[1] for g in got) else None) or "none"'), + ("any(g[1] for g in got)", "all(g[1] for g in got)"), + ("rl[max(0, ln - 2):ln]", "rl[max(0, ln - 1):ln]"), ("rl[max(0, ln - 2):ln]", "rl[max(0, ln - 3):ln]"), + ("rl[max(0, ln - 2):ln]", "rl[max(0, ln - 2):ln - 1]"), ("rl[max(0, ln - 2):ln]", "rl[max(0, ln - 2):ln + 1]"), ("rl[max(0, ln - 2):ln]", "rl[ln - 2:ln]"), + # read / scan + ('encoding="utf-8-sig"', 'encoding="utf-8"'), ('errors="replace"', 'errors="strict"'), ("except OSError: return None", "except OSError: raise"), + ("dn[:] = sorted(d for d in dn if d not in SKIP_DIR)", "dn[:] = sorted(dn)"), + ("if f.endswith(EXT) and not TEST.search(rel)", "if f.endswith(EXT)"), ("TEST.search(rel)", "TEST.match(rel)"), + ("MWF.fullmatch(os.path.basename(rel))", "MWF.fullmatch(rel)"), ("MWF.fullmatch(os.path.basename(rel))", "MWF.match(os.path.basename(rel))"), + ("MWF.fullmatch(os.path.basename(rel))", "MWF.search(os.path.basename(rel))"), + ("mw_pats(mask(raw), mask(raw, True))", "mw_pats(mask(raw, True), mask(raw, True))"), + ("if raw is None or not (NAPP", "if not (NAPP"), ("not (NAPP.match(rel) or NAPI", "not (NAPI"), ("or NAPI.match(rel) or (PYDEC", "or (PYDEC"), + ('(PYDEC if rel.endswith(".py") else JSPRE)', '(JSPRE if rel.endswith(".py") else PYDEC)'), ("seen += 1", "seen += 2"), + # main + ("len(a) != 1 or not os.path.isdir(a[0])", "len(a) < 1 or not os.path.isdir(a[0])"), ("len(a) != 1 or not os.path.isdir(a[0])", "len(a) != 1"), + ("print(\"usage: route_auth_map.py [--paths] DIR\", file=sys.stderr); return 2", "print(\"usage: route_auth_map.py [--paths] DIR\", file=sys.stderr); return 1"), + ('if "--paths" in argv[1:]:', 'if "--paths" in argv[2:]:'), + ('{"GET", "ANY"} & set(r[2].split("|"))', '{"GET"} & set(r[2].split("|"))'), ('{"GET", "ANY"} & set(r[2].split("|"))', '{"ANY"} & set(r[2].split("|"))'), + ('{"GET", "ANY"} & set(r[2].split("|"))', '{"GET", "ANY"} & {r[2]}'), + (" return 0\n out =", " return 1\n out ="), + ('r[4] == "none" and any(x != "GET" for x in r[2].split("|"))', 'r[4] != "public" and any(x != "GET" for x in r[2].split("|"))'), + ('r[4] == "none" and any(x != "GET" for x in r[2].split("|"))', 'r[4] == "none" and all(x != "GET" for x in r[2].split("|"))'), + ('r[4] == "none" and any(x != "GET" for x in r[2].split("|"))', 'r[4] == "none" and any(x == "POST" for x in r[2].split("|"))'), + ('r[4] == "none" and any(x != "GET" for x in r[2].split("|"))', 'any(x != "GET" for x in r[2].split("|"))'), + ('"%s:%d: %s: %s %s auth=%s" % (r[0], r[1], s, r[2], r[3], r[4])', '("%s:%d: %s: %s %s auth=%s" % (r[0], r[1], s, r[2], r[3], r[4]))[:-1]'), +] + + +def mutants(src): + """([(label, mutated script)], [bad rows]): every MUTANTS row applied once, plus each IDENTITY name replaced by a nonsense one.""" + ok, bad = [], [] + for old, new in MUTANTS: + if src.count(old) == 1 and old != new: ok.append((old[:70], src.replace(old, new))) + else: bad.append(old[:70]) + a, b = src.index("IDENTITY = ("), src.index("RECEIVERS =") + ok += [(t.group(0), src[:a + t.start()] + '"zz9"' + src[a + t.end():]) for t in re.finditer(r'r?"[^"\n]*"', src[a:b])] + return ok, bad + + +def verdict(text, fx, base): + """Run a copy of the script holding text (beside its sibling) over the probes: "killed" when any output differs from base, "broken" when it does not compile, else "alive".""" + with tempfile.TemporaryDirectory() as d: + shutil.copy(SK / "tenant_scope_check.py", d) + script = Path(d) / "route_auth_map.py" + script.write_text(text, encoding="utf-8") + try: compile(text, "mutant", "exec") + except SyntaxError: return "broken" + for argv, want in zip(probes(fx), base): + try: + r = subprocess.run([sys.executable, "-I", str(script), *argv], capture_output=True, encoding="utf-8", errors="replace", timeout=30) + except subprocess.TimeoutExpired: + return "killed" + if (r.returncode, r.stdout, r.stderr) != want: return "killed" + return "killed" if sorted(os.listdir(d)) != ["route_auth_map.py", "tenant_scope_check.py"] else "alive" # a leftover __pycache__ is a change too + + +def probes(fx): + """argv lists, the most telling first (a killed mutant stops at its first difference): the scan, --paths, then the usage errors.""" + return [[str(fx)], ["--paths", str(fx)], [], [str(fx), str(fx)], [str(fx) + "/nope"]] + + +class RouteAuthMap(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.td = tempfile.TemporaryDirectory() + cls.addClassCleanup(cls.td.cleanup) + cls.fx = Path(cls.td.name) / "fx" + write(cls.fx, FX) + os.symlink("missing-target.js", cls.fx / "broken.js") # a file that cannot be read must be skipped, not crash the scan + cls.res = run(SCRIPT, cls.fx) + cls.gold = golden() + + def test_rows_match_golden(self): + self.assertEqual(self.res.returncode, 0, self.res.stderr) + rows, _ = split(self.res) + self.assertEqual(sorted(rows), sorted(self.gold)) + keys = [(r.split(":")[0], int(r.split(":")[1])) for r in rows] + self.assertEqual(keys, sorted(keys), "rows are ordered by (path, line)") + + def test_json_summary_and_stderr(self): + _, j = split(self.res) + high = [g for g in self.gold if ": HIGH: " in g] + self.assertEqual((j["tool"], j["handlers"], j["high"]), ("route_auth_map", len(self.gold), len(high))) + got = {(f["file"], f["line"], f["severity"], f["message"]) for f in j["findings"]} + exp = set() + for g in high: + head, _, msg = g.partition(": HIGH: ") + path, _, ln = head.rpartition(":") + exp.add((path, int(ln), "HIGH", msg)) + self.assertEqual(got, exp) + self.assertEqual(j["files"], len({g.split(":")[0] for g in self.gold}) + len(QUIET)) # every file with a row, plus the files that look like routes but yield none + self.assertEqual(self.res.stderr, "heuristic; scanned %d files with %d handlers; silence is not proof of authentication\n" % (j["files"], j["handlers"])) + + def test_paths_lists_get_routes(self): + res = run(SCRIPT, self.fx, "--paths") + want = set() + for g in self.gold: + m = re.search(r": (?:INFO|HIGH): (\S+) (\S+) auth=", g) + if {"GET", "ANY"} & set(m.group(1).split("|")): + want.add(m.group(2)) + self.assertEqual(res.returncode, 0) + self.assertEqual(res.stdout.splitlines(), sorted(want)) + self.assertIn("/health", want) + self.assertNotIn("saveNote", want) + self.assertNotIn("/orders", want) + + def test_exit_codes(self): + with tempfile.TemporaryDirectory() as t: + res = run(SCRIPT, t) + self.assertEqual(res.returncode, 0) + self.assertEqual(json.loads(res.stdout)["handlers"], 0) + for argv in ([], [t, t], ["--paths"], ["--bogus", t], [os.path.join(t, "nope")]): + r = subprocess.run([sys.executable, "-I", str(SCRIPT), *argv], capture_output=True, text=True) + self.assertEqual(r.returncode, 2, argv) + self.assertIn("usage: route_auth_map.py [--paths] DIR", r.stderr) + + def test_writes_nothing_beside_the_script(self): + with tempfile.TemporaryDirectory() as t: + for f in (SCRIPT, SK / "tenant_scope_check.py"): + shutil.copy(f, t) + self.assertEqual(run(Path(t) / "route_auth_map.py", self.fx).returncode, 0) + self.assertEqual(sorted(os.listdir(t)), ["route_auth_map.py", "tenant_scope_check.py"]) + + def test_review_checks_full_wiring(self): + env = dict(os.environ, PATH="/usr/bin:/bin") + with tempfile.TemporaryDirectory() as t: + def repo(name, files): + g = Path(t) / name + write(g, files) + for a in (["init", "-q"], ["add", "-A"], ["-c", "user.email=a@example.com", "-c", "user.name=Acme", "commit", "-qm", "fixture"]): + subprocess.run(["git", "-C", str(g), *a], check=True, capture_output=True, env=env) + return g + + r = subprocess.run(["bash", str(RC), "--full", "--repo", str(repo("g", FX))], capture_output=True, text=True, env=env) + rows = [x for x in json.loads(r.stdout) if x["tool"] == "route-auth"] + want = {g.split(": HIGH: ")[0] + "|" + g.split(": HIGH: ")[1] for g in self.gold if ": HIGH: " in g} + got = {"%s:%d|%s" % (x["file"], x["line"], x["message"]) for x in rows} + self.assertEqual(got, want) + self.assertEqual({x["rule"] for x in rows}, {"missing-auth"}) + self.assertIn("route-auth: heuristic; scanned %d handlers; silence is not proof of authentication" % len(self.gold), r.stderr) + r = subprocess.run(["bash", str(RC), "--full", "--repo", str(repo("g2", {"a.ts": "export const a = 1;\n"}))], capture_output=True, text=True, env=env) + self.assertIn("route-auth (SKIPPED: no HTTP handlers)", r.stderr) + self.assertEqual([(x["rule"], x["message"]) for x in json.loads(r.stdout) if x["tool"] == "route-auth"], [("not-checked", "SKIPPED: no HTTP handlers")]) + + def test_every_rule_is_mutation_checked(self): + src = SCRIPT.read_text(encoding="utf-8") + ms, bad = mutants(src) + self.assertEqual(bad, [], "MUTANTS rows whose old text is missing, repeated or equal to the new text") + base = [(r.returncode, r.stdout, r.stderr) for r in (subprocess.run([sys.executable, "-I", str(SCRIPT), *a], capture_output=True, encoding="utf-8", errors="replace") for a in probes(self.fx))] + self.assertEqual(base[0][0], 0) + self.assertEqual(verdict(src, self.fx, base), "alive") # control: the unmutated script is not killed + with concurrent.futures.ThreadPoolExecutor(min(8, os.cpu_count() or 2)) as ex: + res = list(ex.map(lambda m: verdict(m[1], self.fx, base), ms)) + left = [(v, m[0]) for m, v in zip(ms, res) if v != "killed"] + self.assertEqual(left, [], "mutations the tests do not notice (alive) or that do not run (broken)") + + +if __name__ == "__main__": + unittest.main()