diff --git a/.github/workflows/leak-scan.yml b/.github/workflows/leak-scan.yml new file mode 100644 index 0000000..dd0a504 --- /dev/null +++ b/.github/workflows/leak-scan.yml @@ -0,0 +1,56 @@ +# Reusable leak scan. One source of truth for every public repo in this org. +# +# Callers add a four-line workflow and nothing else — no pattern list, no copy of +# the script, and in particular no setting that names the calling repo. Twelve +# vendored copies would drift, and the repo whose copy drifted would be the repo +# that stopped being checked. +# +# Call it with: +# jobs: +# leak-scan: +# uses: Back-Road-Creative/.github/.github/workflows/leak-scan.yml@main +name: leak-scan + +on: + workflow_call: + inputs: + history: + description: >- + Also scan every blob reachable from every ref. Off for the pull-request + gate: history cannot be changed by a pull request, so a finding there + would be permanently red and the gate would get switched off. Turn it on + for a scheduled audit. + type: boolean + default: false + +permissions: + contents: read + +jobs: + scan: + runs-on: ubuntu-latest + steps: + - name: Check out the repository being scanned + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # History mode needs the objects; the worktree gate does not. + fetch-depth: ${{ inputs.history && '0' || '1' }} + + - name: Check out the scanner + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: Back-Road-Creative/.github + # Untracked in the scanned repo, so `git ls-files` never sees it and the + # scanner cannot report its own pattern list as a finding. + path: .leak-scan-tool + + - name: Scan + env: + # Only used to raise the API rate limit. The org's public-repo listing is + # a public endpoint, so no extra scope is required. + GH_TOKEN: ${{ github.token }} + run: | + python3 .leak-scan-tool/scripts/leak_scan.py \ + --repo-root . \ + --self-name '${{ github.repository }}' \ + ${{ inputs.history && '--history' || '' }} diff --git a/README.md b/README.md index 316fc50..cb2da9c 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,95 @@ # .github -Organization profile README + +Organization profile README, and the shared CI this org's public repositories call. + +## leak-scan + +`scripts/leak_scan.py` checks that a public repository publishes its own work and +nothing else: no sibling project's name, no client's name, no layout convention +from the private monorepo several of these repos were extracted from, and no +absolute path from an authoring machine. + +It lives here once and is called by every repo, rather than vendored into each. +Twelve copies would drift, and the repo whose copy drifted would be the repo that +quietly stopped being checked. + +### Calling it + +Add this and nothing else. There is no pattern list to copy and no setting that +names the calling repo: + +```yaml +name: leak-scan +on: [pull_request, push] +jobs: + leak-scan: + uses: Back-Road-Creative/.github/.github/workflows/leak-scan.yml@main +``` + +Pass `with: {history: true}` for an audit run that also reads every blob in +history. The pull-request gate deliberately does not: only a force-push removes a +published blob, so a history finding would be permanently red on something no pull +request can fix, and a permanently red gate gets switched off. + +### Two things it will never do + +**Flag a repo for naming itself.** The name comes from `${{ github.repository }}`, +never from configuration, so there is no per-repo setting to get wrong and no repo +left holding a stale one. The lookahead ends the name rather than using `\b`: a +word boundary sits between `driftless` and the `-` of `driftless-archive`, so an +earlier version of this rule could not see the one private sibling it existed to +catch. A trailing `.git` passes, because a clone URL carries it and still names the +repo itself. + +**Flag a public sibling.** Naming a repository anyone can already open discloses +nothing. The public set is read from the org API at scan time rather than listed in +the script, because a hand-kept list goes stale the day a repo is published and +then fails every scan that mentions it. If that lookup fails the scan stops rather +than guess — mistaking a private repo for a public one is the error that matters. + +### What it looks for + +| Rule | Catches | +| --- | --- | +| `foreign-repo` | A repo in this org that is neither this one nor public. | +| `client-name` | A third-party client engagement. | +| `machine-path` | `/home/joe`, `/home/dev` — the two authoring accounts. | +| `private-project` | A project that exists only in private repos. | +| `workspace-convention` | `_active/`, `.data/`, `baton`. | + +Every pattern was validated against fresh clones of all thirteen public repos +before it was kept. Candidates that fired on legitimate content were dropped +rather than tightened: `GMS` (a test fixture name, and the industry term +"grant-management-software"), `gomoveshift` (a public brand), `workspaces` +(ordinary English), `openclaw` (a third-party product these tools support), and a +general `/home//` (documentation examples). An over-eager scanner gets +switched off, and a switched-off scanner protects nothing. + +### The allowlist + +A repo with a genuine exception adds `.github/leak-scan-allowlist.json`: + +```json +{ + "allow": [ + { + "rule": "workspace-convention", + "path": "CHANGELOG.md", + "match": ".data/plans", + "reason": "Records that this build plan stayed in the monorepo at extraction; names no file that ships here.", + "scope": "both" + } + ] +} +``` + +`rule`, `path`, `match` and `reason` are all required, and a `reason` under 20 +characters is rejected. This is enforced, not advised: the scan exits non-zero on a +malformed allowlist, so an unexplained exemption cannot be merged. An entry +suppresses a finding only where all three of rule, path glob and matched substring +line up, so it cannot silently widen. `scope` is `worktree`, `history` or `both` +(default). + +An exemption whose reason has been falsified by later code is the failure mode +here, and nothing rechecks a reason automatically. Re-derive each entry when you +touch the file it covers. diff --git a/scripts/__pycache__/leak_scan.cpython-312.pyc b/scripts/__pycache__/leak_scan.cpython-312.pyc new file mode 100644 index 0000000..c4f1d9a Binary files /dev/null and b/scripts/__pycache__/leak_scan.cpython-312.pyc differ diff --git a/scripts/__pycache__/test_leak_scan.cpython-312-pytest-9.1.1.pyc b/scripts/__pycache__/test_leak_scan.cpython-312-pytest-9.1.1.pyc new file mode 100644 index 0000000..4398a26 Binary files /dev/null and b/scripts/__pycache__/test_leak_scan.cpython-312-pytest-9.1.1.pyc differ diff --git a/scripts/test_leak_scan.py b/scripts/test_leak_scan.py index c7cb9fc..36cef64 100644 --- a/scripts/test_leak_scan.py +++ b/scripts/test_leak_scan.py @@ -4,10 +4,19 @@ from __future__ import annotations +import json +from dataclasses import replace + import pytest -from leak_scan import build_rules +from leak_scan import Finding, build_rules, is_allowed, load_allowlist ORG = "Back-Road-Creative" +FULL_ENTRY = { + "rule": "workspace-convention", + "path": "CHANGELOG.md", + "match": ".data/plans", + "reason": "Records that this plan stayed in the monorepo at extraction; ships no file.", +} def hits(text: str, self_name: str = "driftless") -> set[str]: @@ -55,3 +64,47 @@ def test_self_name_accepts_the_owner_slash_name_form() -> None: """The workflow feeds ${{ github.repository }}. Misread that and every repo reports itself.""" assert not hits(f"{ORG}/headlessmode", self_name=f"{ORG}/headlessmode") + + +@pytest.mark.parametrize( + "entry", + [ + {"rule": "client-name", "path": "a.md", "match": "x"}, # no reason + {"rule": "client-name", "path": "a.md", "match": "x", "reason": "legacy"}, # too short + {"rule": "client-name", "path": "a.md", "reason": "a" * 30}, # no match string + {"path": "a.md", "match": "x", "reason": "a" * 30}, # no rule + ], +) +def test_allowlist_entry_without_a_real_reason_is_fatal(tmp_path, entry) -> None: + """An unexplained exemption is how a guard quietly stops guarding, so this is a + hard error rather than a warning — the entry cannot be merged.""" + f = tmp_path / "allow.json" + f.write_text(json.dumps({"allow": [entry]})) + with pytest.raises(SystemExit): + load_allowlist(f) + + +def test_allowlist_accepts_a_fully_explained_entry(tmp_path) -> None: + f = tmp_path / "allow.json" + f.write_text(json.dumps({"allow": [FULL_ENTRY]})) + assert load_allowlist(f) == [FULL_ENTRY] + + +def test_absent_allowlist_is_empty_not_an_error(tmp_path) -> None: + assert load_allowlist(tmp_path / "nope.json") == [] + + +def test_entry_suppresses_only_its_own_rule_path_and_text() -> None: + """All three must line up, so an entry cannot silently widen into a blanket.""" + f = Finding("workspace-convention", "worktree", "CHANGELOG.md", 9, "see .data/plans/x.md") + assert is_allowed(f, [FULL_ENTRY]) + assert not is_allowed(replace(f, rule="client-name"), [FULL_ENTRY]) + assert not is_allowed(replace(f, path="OTHER.md"), [FULL_ENTRY]) + assert not is_allowed(replace(f, text="see _active/x"), [FULL_ENTRY]) + + +def test_scope_limits_an_entry_to_one_side_of_the_scan() -> None: + entry = {**FULL_ENTRY, "scope": "history"} + f = Finding("workspace-convention", "worktree", "CHANGELOG.md", 9, "see .data/plans/x.md") + assert not is_allowed(f, [entry]) + assert is_allowed(replace(f, where="history"), [entry])