Skip to content

Commit c92e86d

Browse files
committed
feat(health): report workflows that run under a personal token
Read each active workflow file on every tracked branch and flag any secret other than GITHUB_TOKEN. Automation running under a personal token got a maintainer's account suspended; this makes it visible the day it returns. Disabled workflows are skipped, so turning one off in the Actions settings clears the finding. A repository that genuinely needs another secret lists it under allowed_secrets in repos.json. The tests also pin TechMachine's own workflows and the generated satellite workflows to GITHUB_TOKEN only.
1 parent 3f13af7 commit c92e86d

4 files changed

Lines changed: 95 additions & 0 deletions

File tree

‎README.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ It queries each workflow separately rather than scanning "the last N runs",
2020
because weekly jobs scroll out of any fixed window behind the daily ones — and
2121
the weekly jobs are the ones that go quiet.
2222

23+
It also reads each active workflow file and reports any that uses a secret
24+
other than `GITHUB_TOKEN`. Automation here must act as `github-actions[bot]`,
25+
never as a person: workflows running under a personal token are how a
26+
maintainer's account got suspended. Disabling the workflow in the Actions
27+
settings clears the finding; a non-token secret a repository genuinely needs
28+
goes in that repository's `allowed_secrets` in `repos.json`.
29+
2330
```bash
2431
python -m machine.health # print the report
2532
python -m machine.health --issue # also update the issue (needs GITHUB_TOKEN)

‎machine/health.py‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import argparse
1515
import json
1616
import os
17+
import re
1718
import sys
1819
import urllib.error
1920
import urllib.request
@@ -29,6 +30,12 @@
2930
# `cancelled` and `timed_out` included, because that is how silent failures look.
3031
HEALTHY = {"success", "skipped", "neutral"}
3132

33+
# Automation must act as github-actions[bot], never as a person. Any other
34+
# secret in an active workflow is usually a personal token, and automation
35+
# running under one got a maintainer's account suspended.
36+
SECRET = re.compile(r"secrets\.(\w+)")
37+
BUILTIN_TOKEN = "GITHUB_TOKEN"
38+
3239

3340
@dataclass
3441
class Finding:
@@ -45,6 +52,26 @@ def _get(url: str, token: str | None) -> Any:
4552
return json.loads(response.read().decode("utf-8"))
4653

4754

55+
def _get_text(url: str, token: str | None) -> str:
56+
request = urllib.request.Request(url, headers={"Accept": "application/vnd.github.raw+json"})
57+
if token:
58+
request.add_header("Authorization", f"Bearer {token}")
59+
with urllib.request.urlopen(request, timeout=30) as response:
60+
return response.read().decode("utf-8")
61+
62+
63+
def foreign_secrets(workflow_text: str, allowed: set[str] = frozenset()) -> list[str]:
64+
"""Secrets a workflow uses other than the built-in token (and any allowed)."""
65+
return sorted(set(SECRET.findall(workflow_text)) - {BUILTIN_TOKEN} - allowed)
66+
67+
68+
def token_finding(where: str, workflow_text: str, url: str, allowed: set[str] = frozenset()) -> Finding | None:
69+
names = foreign_secrets(workflow_text, allowed)
70+
if not names:
71+
return None
72+
return Finding(where, f"uses {', '.join(names)} — automation must not run as a person", url)
73+
74+
4875
def latest_runs(runs: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
4976
"""Newest completed run per workflow name. `runs` arrive newest first."""
5077
newest: dict[str, dict[str, Any]] = {}
@@ -69,10 +96,34 @@ def endpoint_finding(name: str, url: str, expect: str, body: Any) -> Finding | N
6996
return None
7097

7198

99+
def workflow_token_findings(repo: str, branch: str, workflow: dict[str, Any],
100+
token: str | None, allowed: set[str]) -> list[Finding]:
101+
"""Flag an active workflow that reads a secret other than GITHUB_TOKEN.
102+
103+
Only active workflows count: disabling one in the Actions settings is
104+
enough to stop it acting, and clears the finding.
105+
"""
106+
path = workflow.get("path", "")
107+
if not path.startswith(".github/workflows/"):
108+
return [] # dynamic workflows such as pages-build-deployment have no file
109+
where = f"{repo}@{branch} · {workflow['name']}"
110+
try:
111+
text = _get_text(f"{API}/repos/{repo}/contents/{path}?ref={branch}", token)
112+
except urllib.error.HTTPError as exc:
113+
if exc.code == 404:
114+
return [] # the file is not on this branch
115+
return [Finding(where, f"could not read workflow file: {exc}")]
116+
except urllib.error.URLError as exc:
117+
return [Finding(where, f"could not read workflow file: {exc}")]
118+
finding = token_finding(where, text, f"https://github.com/{repo}/blob/{branch}/{path}", allowed)
119+
return [finding] if finding else []
120+
121+
72122
def check(config: dict[str, Any], token: str | None) -> list[Finding]:
73123
findings: list[Finding] = []
74124
for repo in config["repos"]:
75125
name = repo["name"]
126+
allowed = set(repo.get("allowed_secrets", []))
76127
try:
77128
workflows = _get(f"{API}/repos/{name}/actions/workflows?per_page=100", token)
78129
except urllib.error.URLError as exc:
@@ -92,6 +143,7 @@ def check(config: dict[str, Any], token: str | None) -> list[Finding]:
92143
runs.extend(_get(url, token).get("workflow_runs", []))
93144
except urllib.error.URLError as exc:
94145
findings.append(Finding(f"{name} · {workflow['name']}", f"could not read runs: {exc}"))
146+
findings.extend(workflow_token_findings(name, branch, workflow, token, allowed))
95147
findings.extend(run_findings(name, branch, runs))
96148

97149
for endpoint in config["endpoints"]:

‎tests/test_health.py‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,31 @@ def test_fingerprint_ignores_order_and_links():
8484
a = [Finding("x", "failure", "https://run/1"), Finding("y", "cancelled", "https://run/2")]
8585
b = [Finding("y", "cancelled", "https://run/9"), Finding("x", "failure", "https://run/8")]
8686
assert fingerprint(a) == fingerprint(b)
87+
88+
89+
# --- automation must not run as a person -----------------------------------
90+
91+
from pathlib import Path # noqa: E402
92+
93+
from machine.health import foreign_secrets, token_finding # noqa: E402
94+
95+
ROOT = Path(__file__).resolve().parent.parent
96+
97+
98+
def test_personal_token_is_a_problem():
99+
text = "token: ${{ secrets.TECHENGINEBOT_TOKEN || secrets.TECHAPI_TOKEN }}"
100+
finding = token_finding("org/repo@main · weekly-ingest", text, "https://x")
101+
assert finding and "TECHAPI_TOKEN" in finding.what and "TECHENGINEBOT_TOKEN" in finding.what
102+
103+
104+
def test_builtin_token_is_fine():
105+
assert token_finding("w", "GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}", "https://x") is None
106+
107+
108+
def test_allowed_secret_is_fine():
109+
assert foreign_secrets("${{ secrets.SOME_API_KEY }}", {"SOME_API_KEY"}) == []
110+
111+
112+
def test_techmachine_itself_uses_only_the_builtin_token():
113+
for path in sorted((ROOT / ".github" / "workflows").glob("*.yml")):
114+
assert foreign_secrets(path.read_text(encoding="utf-8")) == [], path

‎tests/test_new_satellite.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,11 @@ def test_refuses_to_overwrite(generated: Path):
5858
def test_unknown_placeholder_is_an_error():
5959
with pytest.raises(KeyError):
6060
render("{{nope}}", {})
61+
62+
63+
def test_generated_workflows_use_only_the_builtin_token(generated: Path):
64+
from machine.health import foreign_secrets
65+
workflows = sorted((generated / ".github" / "workflows").glob("*.yml"))
66+
assert workflows
67+
for path in workflows:
68+
assert foreign_secrets(path.read_text(encoding="utf-8")) == [], path

0 commit comments

Comments
 (0)