remit-actions 1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
remit/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Remit: report what a pull request changes about GitHub Actions workflow privileges."""
2
+
3
+ __version__ = "1"
remit/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
remit/cli.py ADDED
@@ -0,0 +1,101 @@
1
+ import argparse
2
+ import json
3
+ from pathlib import Path
4
+ import sys
5
+
6
+ from .report import markdown
7
+ from .sarif import sarif
8
+ from .scan import audit, compare, error_report, permissions
9
+ from .sources import InputError, directory, git_snapshot
10
+
11
+
12
+ def _outputs(parser):
13
+ parser.add_argument("--default-permissions", help='Trusted explicit JSON map, for example {"contents":"read"}')
14
+ parser.add_argument("--format", choices=("json", "markdown", "sarif"), default="json")
15
+ parser.add_argument("--output", type=Path)
16
+ parser.add_argument("--summary", type=Path, help="Explicit path to append a Markdown job summary")
17
+ parser.add_argument("--sarif", type=Path, help="Also write a SARIF 2.1.0 report to this path")
18
+
19
+
20
+ def _defaults(value):
21
+ if value is None:
22
+ return None
23
+ try:
24
+ defaults = json.loads(value)
25
+ if not isinstance(defaults, dict):
26
+ raise ValueError("Explicit map required")
27
+ return permissions(defaults)
28
+ except (ValueError, TypeError) as exc:
29
+ raise InputError("Default permissions must be a supported explicit JSON permission map.") from exc
30
+
31
+
32
+ def main(argv=None) -> int:
33
+ parser = argparse.ArgumentParser(prog="remit")
34
+ commands = parser.add_subparsers(dest="command", required=True)
35
+
36
+ scan = commands.add_parser("scan", help="Report what a change does to workflow privileges")
37
+ source = scan.add_mutually_exclusive_group(required=True)
38
+ source.add_argument("--dirs", nargs=2, metavar=("BASE_DIR", "HEAD_DIR"))
39
+ source.add_argument("--repo", help="Local Git repository; reads objects without checkout")
40
+ scan.add_argument("--base", help="Base ref, resolved and recorded as an exact commit")
41
+ scan.add_argument("--head", help="Head ref, resolved and recorded as an exact commit")
42
+ _outputs(scan)
43
+
44
+ check = commands.add_parser("audit", help="Report pull request content reachable from privileged workflows now "
45
+ "(WF010, WF011)")
46
+ target = check.add_mutually_exclusive_group(required=True)
47
+ target.add_argument("--dir", help="Directory containing .github/workflows")
48
+ target.add_argument("--repo", help="Local Git repository; reads objects without checkout")
49
+ check.add_argument("--ref", help="Commit or ref to audit with --repo (default: HEAD)")
50
+ check.add_argument("--fail-on-findings", action="store_true",
51
+ help="Exit 1 when the audit is complete and has findings")
52
+ _outputs(check)
53
+
54
+ args = parser.parse_args(argv)
55
+ if args.command == "scan":
56
+ if args.repo and (not args.base or not args.head):
57
+ parser.error("--repo requires --base and --head")
58
+ if args.dirs and (args.base or args.head):
59
+ parser.error("--base and --head are only used with --repo")
60
+ elif args.dir and args.ref:
61
+ parser.error("--ref is only used with --repo")
62
+
63
+ try:
64
+ defaults = _defaults(args.default_permissions)
65
+ if args.command == "audit":
66
+ snapshot = directory(args.dir) if args.dir else git_snapshot(args.repo, args.ref or "HEAD")
67
+ result = audit(snapshot, defaults)
68
+ else:
69
+ if args.dirs:
70
+ base, head = (directory(path) for path in args.dirs)
71
+ else:
72
+ base = git_snapshot(args.repo, args.base)
73
+ head = git_snapshot(args.repo, args.head)
74
+ result = compare(base, head, defaults)
75
+ except (InputError, OSError) as exc:
76
+ # Do not echo filesystem errors, source text, credentials, or git stderr.
77
+ reason = str(exc) if isinstance(exc, InputError) else "Could not read comparison inputs."
78
+ result = error_report(reason)
79
+ if args.command == "audit":
80
+ result["mode"] = "audit"
81
+
82
+ if args.format == "markdown":
83
+ text = markdown(result)
84
+ else:
85
+ text = json.dumps(sarif(result) if args.format == "sarif" else result, indent=2, sort_keys=True) + "\n"
86
+ try:
87
+ if args.output:
88
+ args.output.write_text(text, encoding="utf-8")
89
+ else:
90
+ sys.stdout.write(text)
91
+ if args.sarif:
92
+ args.sarif.write_text(json.dumps(sarif(result), indent=2, sort_keys=True) + "\n", encoding="utf-8")
93
+ if args.summary:
94
+ with args.summary.open("a", encoding="utf-8") as handle:
95
+ handle.write(markdown(result))
96
+ except OSError:
97
+ print("Could not write report output.", file=sys.stderr)
98
+ return 2
99
+ if result["analysis"] != "complete":
100
+ return 2
101
+ return 1 if args.command == "audit" and args.fail_on_findings and result["findings"] else 0
remit/contexts.py ADDED
@@ -0,0 +1,279 @@
1
+ """Configured runner/environment changes, without inventory or runtime claims."""
2
+ import re
3
+ from itertools import product
4
+ from .expressions import IDENTIFIER
5
+
6
+ # A deliberately explicit convention list, not discovery of runner ownership.
7
+ # Unknown/custom labels and groups merit review instead of being called self-hosted.
8
+ HOSTED_LABELS = set("""ubuntu-latest ubuntu-slim ubuntu-22.04 ubuntu-24.04 ubuntu-26.04
9
+ ubuntu-22.04-arm ubuntu-24.04-arm ubuntu-26.04-arm windows-latest windows-2022 windows-2025
10
+ windows-2025-vs2026 windows-11-arm windows-11-vs2026-arm macos-latest macos-13
11
+ macos-14 macos-15 macos-26 xcode-27
12
+ macos-15-intel macos-26-intel macos-14-large macos-14-xlarge macos-15-large
13
+ macos-15-xlarge macos-26-large macos-26-xlarge""".split())
14
+ MAX_MATRIX_ROWS = 256
15
+ MATRIX = re.compile(r"\s*\$\{\{\s*(?i:matrix)\.(" + IDENTIFIER + r")\s*\}\}\s*")
16
+
17
+
18
+ def _matrix(job):
19
+ strategy = (job or {}).get("strategy", {})
20
+ return strategy.get("matrix") if isinstance(strategy, dict) else None
21
+
22
+
23
+ def _is_dynamic(value):
24
+ if isinstance(value, str):
25
+ return "${{" in value
26
+ if isinstance(value, dict):
27
+ return any(_is_dynamic(x) for x in value.values())
28
+ if isinstance(value, list):
29
+ return any(_is_dynamic(x) for x in value)
30
+ return False
31
+
32
+
33
+ def _matrix_rows(matrix):
34
+ """Expand a small literal matrix using GitHub's exclude/include ordering.
35
+
36
+ Returns None when the matrix is dynamic, malformed, ambiguous or too large.
37
+ """
38
+ def fields(mapping):
39
+ # Matrix variable names are case-insensitive; reject case-only duplicates.
40
+ folded = {key.casefold(): value for key, value in mapping.items()}
41
+ return folded if len(folded) == len(mapping) else None
42
+
43
+ if _is_dynamic(matrix):
44
+ return None
45
+ axes = fields({k: v for k, v in matrix.items() if k not in ("include", "exclude")})
46
+ include, exclude = matrix.get("include", []), matrix.get("exclude", [])
47
+ if axes is None or any(not isinstance(v, list) or not v for v in axes.values()):
48
+ return None
49
+ for items in (include, exclude):
50
+ if not isinstance(items, list) or len(items) > MAX_MATRIX_ROWS:
51
+ return None
52
+ if any(not isinstance(x, dict) for x in items):
53
+ return None
54
+ include = [fields(item) for item in include]
55
+ exclude = [fields(item) for item in exclude]
56
+ if any(item is None for item in include + exclude):
57
+ return None
58
+ size = 1
59
+ for values in axes.values():
60
+ size *= len(values)
61
+ if size > MAX_MATRIX_ROWS:
62
+ return None
63
+
64
+ originals = [dict(zip(axes, values)) for values in product(*axes.values())] if axes else []
65
+ originals = [row for row in originals
66
+ if not any(all(k in row and row[k] == v for k, v in item.items()) for item in exclude)]
67
+ rows = [row.copy() for row in originals]
68
+ appended = []
69
+ for item in include:
70
+ # An include entry extends every original row it does not contradict;
71
+ # otherwise it becomes a new row.
72
+ matched = False
73
+ for original, row in zip(originals, rows):
74
+ if all(k not in original or original[k] == v for k, v in item.items()):
75
+ row.update(item)
76
+ matched = True
77
+ if not matched:
78
+ appended.append(item.copy())
79
+ rows += appended
80
+ return rows if len(rows) <= MAX_MATRIX_ROWS else None
81
+
82
+
83
+ def _options(value, job):
84
+ if not isinstance(value, str) or not value.strip():
85
+ raise ValueError("Runner/environment selectors must be nonempty strings.")
86
+ if "${{" not in value:
87
+ return [value], False
88
+ match = MATRIX.fullmatch(value)
89
+ matrix = _matrix(job)
90
+ if not match or not isinstance(matrix, dict):
91
+ return [], True
92
+ field = match.group(1).casefold()
93
+ rows = _matrix_rows(matrix)
94
+ if not rows or any(field not in row for row in rows):
95
+ return [], True
96
+ values = [row[field] for row in rows]
97
+ if not values or any(not isinstance(x, str) or not x.strip() or "${{" in x for x in values):
98
+ return [], True
99
+ return values, False
100
+
101
+
102
+ def _runner(job):
103
+ if not job or "runs-on" not in job:
104
+ return {"labels": [], "groups": []}, False
105
+ value = job["runs-on"]
106
+ groups = []
107
+ unknown = False
108
+ if isinstance(value, dict):
109
+ if not value or set(value) - {"group", "labels"}:
110
+ raise ValueError("Unsupported runs-on mapping.")
111
+ if "group" in value:
112
+ groups, unknown = _options(value["group"], job)
113
+ value = value.get("labels", [])
114
+ if isinstance(value, str):
115
+ value = [value]
116
+ if not isinstance(value, list):
117
+ raise ValueError("Unsupported runs-on declaration.")
118
+ if not value and not groups and not unknown:
119
+ raise ValueError("Empty runs-on selection.")
120
+ labels = []
121
+ for label in value:
122
+ options, unresolved = _options(label, job)
123
+ labels.extend(x.casefold() for x in options)
124
+ unknown |= unresolved
125
+ return {"labels": sorted(set(labels)), "groups": sorted(set(groups))}, unknown
126
+
127
+
128
+ def _environment(job):
129
+ if not job or "environment" not in job:
130
+ return {"names": []}, False
131
+ value = job["environment"]
132
+ if isinstance(value, dict):
133
+ if "name" not in value:
134
+ raise ValueError("Environment mapping needs a name.")
135
+ value = value["name"]
136
+ names, unknown = _options(value, job)
137
+ # Environment names are case insensitive. URL-only edits do not change context.
138
+ return {"names": sorted({x.casefold() for x in names})}, unknown
139
+
140
+
141
+ def _runner_targets(job, *, include_environment=False):
142
+ """Keep resolved selectors from each matrix row together, never cross-join unions."""
143
+ if not job or "runs-on" not in job:
144
+ return []
145
+ value = job["runs-on"]
146
+ group = value.get("group") if isinstance(value, dict) else None
147
+ labels = value.get("labels", []) if isinstance(value, dict) else value
148
+ if isinstance(labels, str):
149
+ labels = [labels]
150
+ environment = job.get("environment") if include_environment else None
151
+ if isinstance(environment, dict):
152
+ environment = environment.get("name")
153
+ selectors = labels + ([group] if group is not None else []) + ([environment] if environment is not None else [])
154
+ rows = _matrix_rows(_matrix(job)) if any(MATRIX.fullmatch(x) for x in selectors) else [{}]
155
+ # Called only after the corresponding resolvers established static values.
156
+ if not rows:
157
+ raise ValueError("Runner target combinations cannot be resolved.")
158
+
159
+ def selected(text, row):
160
+ match = MATRIX.fullmatch(text)
161
+ return row[match.group(1).casefold()] if match else text
162
+
163
+ targets = set()
164
+ for row in rows:
165
+ label_set = tuple(sorted({selected(label, row).casefold() for label in labels}))
166
+ groups = (selected(group, row),) if group is not None else ()
167
+ name = selected(environment, row).casefold() if environment is not None else None
168
+ targets.add((label_set, groups, name))
169
+ return [{"labels": list(labels), "groups": list(groups),
170
+ **({"environment": name} if include_environment else {})}
171
+ for labels, groups, name in sorted(targets)]
172
+
173
+
174
+ def _context_selection(job):
175
+ def selector(value, *, fold=False):
176
+ match = MATRIX.fullmatch(value)
177
+ if match:
178
+ return "matrix", match.group(1).casefold()
179
+ if "${{" in value:
180
+ return "expression", value
181
+ return "literal", value.casefold() if fold else value
182
+
183
+ runner = job.get("runs-on")
184
+ group = runner.get("group") if isinstance(runner, dict) else None
185
+ labels = runner.get("labels", []) if isinstance(runner, dict) else runner
186
+ if isinstance(labels, str):
187
+ labels = [labels]
188
+ # Normalize syntax without erasing which matrix field each row selects.
189
+ runner = (tuple(sorted({selector(label, fold=True) for label in labels})),
190
+ selector(group) if group is not None else None)
191
+ environment = job.get("environment")
192
+ if isinstance(environment, dict):
193
+ environment = environment.get("name")
194
+ return runner, selector(environment, fold=True), _matrix(job)
195
+
196
+
197
+ def _compare_associations(before, after, unresolved):
198
+ """Close the equal-marginals gap; individual context changes have their own rules."""
199
+ if not before or not all(field in job for job in (before, after) for field in ("runs-on", "environment")):
200
+ return [], []
201
+ if _context_selection(before) == _context_selection(after):
202
+ return [], []
203
+ if not unresolved["runs-on"] and _runner_targets(before) != _runner_targets(after):
204
+ return [], []
205
+ if any(unresolved.values()):
206
+ return [], [("incomplete", "Changed runner/environment associations cannot be fully resolved; "
207
+ "only direct static matrix fields are supported.")]
208
+ old = _runner_targets(before, include_environment=True)
209
+ new = _runner_targets(after, include_environment=True)
210
+ if not any(pair not in old for pair in new):
211
+ return [], []
212
+ return [{"rule": "WF008", "before": {"pairs": old}, "after": {"pairs": new},
213
+ "message": "New runner/environment pairing introduced while individual target sets stay unchanged. "
214
+ "Review the paired runner and environment-scoped settings; actual access and protection "
215
+ "policies are not verified."}], []
216
+
217
+
218
+ def compare_contexts(before, after):
219
+ findings, diagnostics, assumptions = [], [], []
220
+ unresolved = {}
221
+ for field, rule, resolve in (("runs-on", "WF005", _runner), ("environment", "WF007", _environment)):
222
+ try:
223
+ old, old_unknown = resolve(before)
224
+ new, new_unknown = resolve(after)
225
+ except ValueError as exc:
226
+ diagnostics.append(("error", str(exc)))
227
+ continue
228
+ unresolved[field] = old_unknown or new_unknown
229
+ old_selector, new_selector = (before or {}).get(field), after.get(field)
230
+ if field == "environment":
231
+ if isinstance(old_selector, dict):
232
+ old_selector = old_selector.get("name")
233
+ if isinstance(new_selector, dict):
234
+ new_selector = new_selector.get("name")
235
+ if old_unknown or new_unknown:
236
+ if old_selector == new_selector and _matrix(before) == _matrix(after):
237
+ assumptions.append(f"Unchanged unresolved {field} selector/matrix compares symbolically; "
238
+ "external runtime context is assumed fixed and is not observed.")
239
+ continue
240
+ diagnostics.append(("incomplete", f"Changed {field} selection cannot be fully resolved; "
241
+ "only direct static matrix fields are supported."))
242
+ if old_selector == new_selector:
243
+ # A matrix/context edit does not prove that the unchanged
244
+ # unresolved selector targets a different resource.
245
+ continue
246
+ old = {**old, "unresolved": old_unknown}
247
+ new = {**new, "unresolved": new_unknown}
248
+ elif old == new:
249
+ if field != "runs-on":
250
+ continue
251
+ if old_selector == new_selector and _matrix(before) == _matrix(after):
252
+ continue
253
+ try:
254
+ old_targets, new_targets = _runner_targets(before), _runner_targets(after)
255
+ except ValueError as exc:
256
+ diagnostics.append(("error", str(exc)))
257
+ continue
258
+ if old_targets == new_targets:
259
+ continue
260
+ # Equal unions can hide changed label/group combinations. Include
261
+ # row targets so the finding explains the actual configured change.
262
+ old, new = {**old, "targets": old_targets}, {**new, "targets": new_targets}
263
+ if field == "runs-on":
264
+ if not (old_unknown or new_unknown) and not (new["groups"] or set(new["labels"]) - HOSTED_LABELS):
265
+ continue
266
+ message = ("Runner selection changed to a self-hosted, custom/group, or unresolved context. "
267
+ "Review infrastructure access; ownership and execution are not verified.")
268
+ else:
269
+ message = ("Environment selection changed. Review scoped configuration and approval requirements; "
270
+ "secret availability and protection policies are not verified.")
271
+ findings.append({"rule": rule, "before": old, "after": new, "message": message})
272
+ if not findings and not diagnostics:
273
+ try:
274
+ changes, issues = _compare_associations(before, after, unresolved)
275
+ findings.extend(changes)
276
+ diagnostics.extend(issues)
277
+ except ValueError as exc:
278
+ diagnostics.append(("error", str(exc)))
279
+ return findings, diagnostics, assumptions
remit/expressions.py ADDED
@@ -0,0 +1,7 @@
1
+ """Shared lexical boundary for supported GitHub expression property names.
2
+
3
+ This is not an expression evaluator. Hyphens belong to identifiers, so a
4
+ property such as inputs.use-secrets must never become a secrets context read.
5
+ """
6
+
7
+ IDENTIFIER = r"[A-Za-z_][A-Za-z0-9_-]*"