Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .github/workflows/leak-scan.yml
Original file line number Diff line number Diff line change
@@ -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' || '' }}
95 changes: 94 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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/<path>`, `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/<user>/` (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.
Binary file added scripts/__pycache__/leak_scan.cpython-312.pyc
Binary file not shown.
Binary file not shown.
55 changes: 54 additions & 1 deletion scripts/test_leak_scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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])