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

68 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-18 12:05 +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#: checksum in ``Formula/keel.rb``, 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#: Strictest tier — the fail-closed answer when the changed-file list could not be 

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

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

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

90UNKNOWN_TIER = 3 

91 

92 

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

94 for g in globs: 

95 if fnmatch.fnmatch(path, g): 

96 return True 

97 return False 

98 

99 

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

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

102 

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

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

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

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

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

108 

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

110 """ 

111 if not changed: 

112 return False 

113 for p in changed: 

114 if not _matches_any(p, docs_globs): 

115 return False 

116 return True 

117 

118 

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

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

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

122 

123 

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

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

126 

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

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

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

130 """ 

131 if not diff: 

132 return {} 

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

134 if not marks: 

135 return {} 

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

137 for index, mark in enumerate(marks): 

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

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

140 return out 

141 

142 

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

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

145 

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

147 

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

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

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

151 * that patch changes nothing privileged. 

152 """ 

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

154 return False 

155 patch = patches.get(path) 

156 if patch is None: 

157 return False 

158 privileged, _reason = privileged_change(patch) 

159 return not privileged 

160 

161 

162def tier_for_files( 

163 changed: list[str], 

164 *, 

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

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

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

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

169) -> int: 

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

171 

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

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

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

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

176 

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

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

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

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

181 (see :func:`is_docs_only`). 

182 """ 

183 if not changed: 

184 return DEFAULT_TIER 

185 

186 if tier3_globs: 

187 for p in changed: 

188 if not _matches_any(p, tier3_globs): 

189 continue 

190 if not _tier3_downgradable(p, patches): 

191 return 3 

192 

193 if docs_globs: 

194 for p in changed: 

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

196 return DEFAULT_TIER 

197 return 1 

198 

199 return DEFAULT_TIER