Coverage for src/keel/classify.py: 100%

68 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-02 20:26 +0000

1"""Risk classification — which tier a change is, from the files it touches. 

2 

3Pure and deterministic: the tier is a function of the changed paths and the 

4project's globs, with no I/O. The tier drives the reviewer count (see 

5:func:`keel.ship.reviewer_count`). 

6""" 

7 

8from __future__ import annotations 

9 

10import fnmatch 

11import re 

12 

13#: Default tier when nothing else matches. 

14DEFAULT_TIER = 2 

15 

16#: Paths where a *diff* may lower the tier a path alone would set (#794). 

17#: 

18#: Only workflow YAML. For the other tier-3 paths the content **is** the risk — a 

19#: url in ``packaging/homebrew/``, a pin in ``.github/requirements`` — so there is 

20#: no such thing as a cosmetic change there and every edit stays TIER-3. 

21DIFF_CLASSIFIED_GLOBS = ( 

22 ".github/workflows/*.yml", 

23 ".github/workflows/*.yaml", 

24) 

25 

26#: What makes a workflow diff privileged, regardless of how small it is. 

27#: 

28#: ``.github/workflows/**`` used to be TIER-3 wholesale, which tiered up a comment, 

29#: two added CI jobs and a change that *tightened* four action pins — all waived — 

30#: while leaving the formula ``brew install`` runs at TIER-2 (#786). Splitting the 

31#: glob by write permissions fixed three of those four; the fourth stays because a 

32#: path cannot say what was done to the file. These patterns can. 

33_PRIVILEGED_LINE = re.compile( 

34 r""" 

35 \buses\s*: # a third-party action: a pin swap is the attack 

36 | \bsecrets\s*\. # reading a stored credential 

37 | ^\s*permissions\s*: # granting or widening a token scope 

38 | ^\s*[\w-]+\s*:\s*write\b # a scope inside a permissions block 

39 | ^\s*on\s*: # the trigger surface (pull_request_target…) 

40 | \b(?:curl|wget|nc|ssh|scp)\b # a run: step reaching the network 

41 | \bpip\s+install\b 

42 | \bnpm\s+(?:i|install|ci)\b 

43 | \bgh\s+(?:api|auth)\b 

44 """, 

45 re.VERBOSE | re.MULTILINE, 

46) 

47 

48#: A diff line that changes content: an addition or a removal, not context and not 

49#: the ``+++``/``---`` file headers. 

50_CHANGED_LINE = re.compile(r"(?m)^[+-](?![+-])(.*)$") 

51 

52#: A whole-line comment — YAML/shell ``#`` or an HTML comment. Skipped before the 

53#: privilege match, because a comment cannot execute: #775's only workflow change 

54#: was a generated banner and a prose line reading ``pip install "git+https://…"``, 

55#: and matching inside it is what kept a comment-only edit at TIER-3. 

56#: 

57#: This is not a bypass. A line that starts with ``#`` is inert in both YAML and 

58#: the shell, so hiding a ``uses:`` or a ``secrets.`` reference behind one buys an 

59#: attacker a lower tier on a change that also does nothing. 

60_COMMENT_LINE = re.compile(r"^\s*(?:#|<!--)") 

61 

62 

63def privileged_change(patch: str) -> tuple[bool, str]: 

64 """Whether a workflow diff changes what the workflow *can do*. 

65 

66 Returns ``(privileged, reason)``. ``reason`` names the first line that decided 

67 it, so a TIER-3 call is explainable rather than an assertion. 

68 

69 **Fails closed.** An empty or unreadable patch is privileged: a classifier that 

70 silently downgrades what it cannot parse is worse than the glob it replaces, 

71 because the glob at least never guessed. Only a diff that was read *and* 

72 contained nothing privileged earns the lower tier. 

73 """ 

74 if not patch or not patch.strip(): 

75 return True, "empty or unreadable patch" 

76 changed = _CHANGED_LINE.findall(patch) 

77 if not changed: 

78 return True, "no add/remove lines found — patch not understood" 

79 for line in changed: 

80 if _COMMENT_LINE.match(line): 

81 continue 

82 if _PRIVILEGED_LINE.search(line): 

83 return True, line.strip()[:120] 

84 return False, "" 

85 

86 

87#: Strictest tier — the fail-closed answer when the changed-file list could not be 

88#: read at all. An unreadable diff must never classify as the *default* tier: that 

89#: is the answer for "an empty changeset", and it silently drops a reviewer and the 

90#: gating jury on a change nobody has seen. 

91UNKNOWN_TIER = 3 

92 

93 

94def _matches_any(path: str, globs: tuple[str, ...]) -> bool: 

95 for g in globs: 

96 if fnmatch.fnmatch(path, g): 

97 return True 

98 return False 

99 

100 

101def is_docs_only(changed: list[str], docs_globs: tuple[str, ...]) -> bool: 

102 """Whether *every* changed path is a docs-surface path (and there is at least one). 

103 

104 Asked directly rather than inferred from ``tier_for_files(...) == 1``, because the 

105 two questions have deliberately different answers: ``allowlist_globs`` may keep a 

106 change classified TIER-1 without making it docs-*only*. The CI empty-check-set 

107 carve-out needs this stricter question — a generated site file riding along with a 

108 docs edit is precisely the case where a workflow *should* have run. 

109 

110 An empty list is not docs-only: an unreadable or empty changeset must fail closed. 

111 """ 

112 if not changed: 

113 return False 

114 for p in changed: 

115 if not _matches_any(p, docs_globs): 

116 return False 

117 return True 

118 

119 

120#: ``diff --git a/<old> b/<new>`` — the header that starts each file in a unified 

121#: diff. The *new* name is the key, matching what the changed-file list reports. 

122_DIFF_HEADER = re.compile(r"(?m)^diff --git a/(?:\S+) b/(\S+)$") 

123 

124 

125def split_unified_diff(diff: str | None) -> dict[str, str]: 

126 """Split a whole-repo unified diff into per-file patches, keyed by new path. 

127 

128 ``None`` or an unparseable diff yields ``{}`` — no evidence, so every path keeps 

129 the tier it would have had. Never a partial mapping: a diff that produced no 

130 headers is not silently read as "no files changed". 

131 """ 

132 if not diff: 

133 return {} 

134 marks = list(_DIFF_HEADER.finditer(diff)) 

135 if not marks: 

136 return {} 

137 out: dict[str, str] = {} 

138 for index, mark in enumerate(marks): 

139 end = marks[index + 1].start() if index + 1 < len(marks) else len(diff) 

140 out[mark.group(1)] = diff[mark.start() : end] 

141 return out 

142 

143 

144def _tier3_downgradable(path: str, patches: dict[str, str] | None) -> bool: 

145 """Whether ``path``'s TIER-3 match may be lowered on the strength of its diff. 

146 

147 Three things must all hold, and any one missing keeps TIER-3: 

148 

149 * the path is one we know how to read a diff for (workflow YAML); 

150 * a patch for it was actually supplied — no patch means no evidence, and no 

151 evidence means the path decides, exactly as before this existed; 

152 * that patch changes nothing privileged. 

153 """ 

154 if patches is None or not _matches_any(path, DIFF_CLASSIFIED_GLOBS): 

155 return False 

156 patch = patches.get(path) 

157 if patch is None: 

158 return False 

159 privileged, _reason = privileged_change(patch) 

160 return not privileged 

161 

162 

163def tier_for_files( 

164 changed: list[str], 

165 *, 

166 tier3_globs: tuple[str, ...] = (), 

167 docs_globs: tuple[str, ...] = (), 

168 allowlist_globs: tuple[str, ...] = (), 

169 patches: dict[str, str] | None = None, 

170) -> int: 

171 """Classify a change into TIER 1/2/3 from its changed files. 

172 

173 * any file matching ``tier3_globs`` (migrations, CI, core code…) ⇒ **TIER-3**; 

174 * otherwise, if *every* changed file matches ``docs_globs`` or ``allowlist_globs`` 

175 (docs-only) ⇒ **TIER-1**; 

176 * otherwise ⇒ **TIER-2** (the default). An empty changeset is TIER-2 (unknown). 

177 

178 ``allowlist_globs`` (``knobs.docs_only_allowlist``) are paths permitted to ride along 

179 in a docs change without forcing code-risk classification — generated site output, 

180 metadata. They widen *this* judgement only: they are not a docs surface, so they do 

181 not relax scope-creep tolerance and they do not buy the empty-CI-check carve-out 

182 (see :func:`is_docs_only`). 

183 """ 

184 if not changed: 

185 return DEFAULT_TIER 

186 

187 if tier3_globs: 

188 for p in changed: 

189 if not _matches_any(p, tier3_globs): 

190 continue 

191 if not _tier3_downgradable(p, patches): 

192 return 3 

193 

194 if docs_globs: 

195 for p in changed: 

196 if not (_matches_any(p, docs_globs) or _matches_any(p, allowlist_globs)): 

197 return DEFAULT_TIER 

198 return 1 

199 

200 return DEFAULT_TIER