1414import argparse
1515import json
1616import os
17+ import re
1718import sys
1819import urllib .error
1920import urllib .request
2930# `cancelled` and `timed_out` included, because that is how silent failures look.
3031HEALTHY = {"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
3441class 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+
4875def 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+
72122def 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" ]:
0 commit comments