attackmap 0.6.4__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.
- attackmap/__init__.py +2 -0
- attackmap/analyzer.py +449 -0
- attackmap/analyzer_contracts.py +194 -0
- attackmap/analyzers.py +1003 -0
- attackmap/anomalies.py +612 -0
- attackmap/asset_model.py +245 -0
- attackmap/attack_taxonomy.py +133 -0
- attackmap/auth_protocol.py +702 -0
- attackmap/authz.py +1695 -0
- attackmap/bench.py +467 -0
- attackmap/cli.py +1474 -0
- attackmap/config_scanner.py +389 -0
- attackmap/context_pack.py +82 -0
- attackmap/contracts.py +201 -0
- attackmap/control_model.py +280 -0
- attackmap/crossrepo.py +356 -0
- attackmap/crypto.py +240 -0
- attackmap/cve.py +717 -0
- attackmap/defensive_review.py +647 -0
- attackmap/detection_opportunities.py +1221 -0
- attackmap/detections/__init__.py +491 -0
- attackmap/detections/bola.yml +57 -0
- attackmap/detections/cloud.yml +609 -0
- attackmap/detections/injection.yml +167 -0
- attackmap/detections/secrets.yml +48 -0
- attackmap/detections/sigma.py +511 -0
- attackmap/detections/ssrf.yml +98 -0
- attackmap/detections/weakness.yml +62 -0
- attackmap/detections/webhook.yml +45 -0
- attackmap/detections/workflow.yml +121 -0
- attackmap/diagrams.py +237 -0
- attackmap/diff.py +301 -0
- attackmap/exploitability.py +493 -0
- attackmap/fingerprint.py +91 -0
- attackmap/fleet.py +370 -0
- attackmap/gitref.py +129 -0
- attackmap/graph.py +26 -0
- attackmap/handler_auth.py +639 -0
- attackmap/handler_span.py +62 -0
- attackmap/hunt_harness.py +505 -0
- attackmap/insights.py +701 -0
- attackmap/llm_review.py +846 -0
- attackmap/lockfiles.py +521 -0
- attackmap/mass_assignment.py +543 -0
- attackmap/md.py +175 -0
- attackmap/merge.py +333 -0
- attackmap/models.py +1284 -0
- attackmap/plugins_lock.py +114 -0
- attackmap/popular_packages.py +193 -0
- attackmap/progress.py +258 -0
- attackmap/recon_models.py +31 -0
- attackmap/recon_to_analysis.py +149 -0
- attackmap/redact.py +220 -0
- attackmap/report.py +424 -0
- attackmap/review_eval.py +335 -0
- attackmap/review_json.py +308 -0
- attackmap/review_prompts.py +796 -0
- attackmap/route_auth_fusion.py +474 -0
- attackmap/safe_fs.py +285 -0
- attackmap/sarif.py +373 -0
- attackmap/sbom.py +418 -0
- attackmap/scanner.py +1379 -0
- attackmap/sdk/__init__.py +278 -0
- attackmap/sdk/contracts.py +17 -0
- attackmap/sdk/fs.py +156 -0
- attackmap/sdk/models.py +59 -0
- attackmap/secrets_history.py +161 -0
- attackmap/security_overlay.py +71 -0
- attackmap/srcpaths.py +301 -0
- attackmap/suggest.py +394 -0
- attackmap/supply_chain.py +688 -0
- attackmap/suppress.py +740 -0
- attackmap/taint.py +1540 -0
- attackmap/taint_flow.py +1419 -0
- attackmap/taint_sinks.py +431 -0
- attackmap/taint_sinks.yaml +308 -0
- attackmap/taint_sources.py +396 -0
- attackmap/taxonomy.py +465 -0
- attackmap/threat_model.py +3441 -0
- attackmap/topology.py +316 -0
- attackmap/triage.py +151 -0
- attackmap/weaknesses.py +365 -0
- attackmap/webhardening.py +479 -0
- attackmap/webhook_verification.py +1067 -0
- attackmap/workflow_scanner.py +964 -0
- attackmap-0.6.4.dist-info/METADATA +226 -0
- attackmap-0.6.4.dist-info/RECORD +91 -0
- attackmap-0.6.4.dist-info/WHEEL +5 -0
- attackmap-0.6.4.dist-info/entry_points.txt +2 -0
- attackmap-0.6.4.dist-info/licenses/LICENSE +21 -0
- attackmap-0.6.4.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,964 @@
|
|
|
1
|
+
"""GitHub Actions / CI workflow security scanner (#142).
|
|
2
|
+
|
|
3
|
+
CI config is a real attack surface that AttackMap otherwise leaves
|
|
4
|
+
unanalyzed: `.github/workflows/*.yml` routinely carry supply-chain and
|
|
5
|
+
code-execution risks. This built-in pass parses each workflow at the repo
|
|
6
|
+
root's `.github/workflows/` and emits a `WorkflowIssue` per positively-present
|
|
7
|
+
misconfiguration. A hardened workflow produces nothing.
|
|
8
|
+
|
|
9
|
+
Detected patterns:
|
|
10
|
+
|
|
11
|
+
- **unpinned_action** — `uses: org/action@<tag-or-branch>` instead of a pinned
|
|
12
|
+
commit SHA. A moved tag / branch lets the action's owner (or anyone who
|
|
13
|
+
compromises them) change what runs in your pipeline. Semver tags are low;
|
|
14
|
+
branch/`latest` refs are medium.
|
|
15
|
+
- **pr_target_checkout** — `on: pull_request_target` combined with a checkout of
|
|
16
|
+
the PR head ref. `pull_request_target` runs with the base repo's secrets in
|
|
17
|
+
scope, so checking out and building untrusted PR code is a classic
|
|
18
|
+
confused-deputy that leaks secrets / achieves RCE. High.
|
|
19
|
+
- **secret_in_run** — `${{ secrets.* }}` interpolated straight into a `run:`
|
|
20
|
+
shell step, where it can leak via logs, `ps`, or a child process. Pass secrets
|
|
21
|
+
through `env:` instead. Medium.
|
|
22
|
+
- **script_injection** — an attacker-controlled context
|
|
23
|
+
(`github.event.*.{title,body,…}`, `github.head_ref`, …) interpolated into a
|
|
24
|
+
`run:` block, so a crafted issue/PR title runs arbitrary shell. High.
|
|
25
|
+
- **broad_permissions** — `permissions: write-all` (or the `write` shorthand),
|
|
26
|
+
granting the `GITHUB_TOKEN` far more than a job needs. Medium.
|
|
27
|
+
- **self_hosted_pr** — a self-hosted runner on a `pull_request` /
|
|
28
|
+
`pull_request_target` trigger, exposing your runner to arbitrary code from
|
|
29
|
+
forks on a public repo. Medium (high with `pull_request_target`).
|
|
30
|
+
|
|
31
|
+
Trigger trust (#246): ``pull_request_target``, ``issue_comment`` and
|
|
32
|
+
``workflow_run`` run with a write-capable token and secrets in scope while
|
|
33
|
+
reacting to attacker-controlled input. Rules below raise severity on them.
|
|
34
|
+
|
|
35
|
+
- **workflow_run_artifact_poisoning** — a ``workflow_run`` workflow downloads
|
|
36
|
+
the triggering run's artifacts (``download-artifact`` with ``run-id``,
|
|
37
|
+
``dawidd6/action-download-artifact``, ``gh run download``, github-script
|
|
38
|
+
``downloadArtifact``) or checks out its head, then executes it. High
|
|
39
|
+
(CWE-829) when a later step runs code; medium otherwise.
|
|
40
|
+
- **issue_comment_pr_checkout** — an ``issue_comment`` ("/ok-to-test")
|
|
41
|
+
workflow checks out the PR (``refs/pull/N/head``, ``gh pr checkout``). High.
|
|
42
|
+
- **github_script_injection** — an attacker-controlled context interpolated
|
|
43
|
+
into ``actions/github-script``'s ``script:``, which is evaluated as
|
|
44
|
+
JavaScript with the token in scope. High.
|
|
45
|
+
- **github_env_injection** — an attacker-controlled value written to
|
|
46
|
+
``$GITHUB_ENV`` / ``$GITHUB_PATH`` (or ``$GITHUB_OUTPUT``), directly or via an
|
|
47
|
+
``env:`` binding. A newline lets it set ``LD_PRELOAD``/``NODE_OPTIONS`` for
|
|
48
|
+
every later step. High (medium for ``$GITHUB_OUTPUT``).
|
|
49
|
+
- **default_token_permissions** — no ``permissions:`` block at the top level
|
|
50
|
+
and on some job, so the job gets the repo/org default token (read/write on
|
|
51
|
+
older repos). Low (medium on an untrusted trigger).
|
|
52
|
+
- **oidc_on_untrusted_trigger** — ``id-token: write`` on a PR/comment/run
|
|
53
|
+
triggered workflow: PR code can mint cloud credentials. High.
|
|
54
|
+
- **secrets_inherit** — ``secrets: inherit`` into a reusable workflow, handing
|
|
55
|
+
it every secret instead of the ones it needs. Medium (high on untrusted).
|
|
56
|
+
- **checkout_persist_credentials** — ``actions/checkout`` on an untrusted
|
|
57
|
+
trigger without ``persist-credentials: false`` before steps run code: the
|
|
58
|
+
write token is left in ``.git/config`` for that code to read. Medium.
|
|
59
|
+
- **cache_poisoning_pr_target** — ``actions/cache`` (or a ``setup-*`` cache)
|
|
60
|
+
in a ``pull_request_target`` job that ran PR code or keys the cache on PR
|
|
61
|
+
values: the entry lands in the base branch's cache scope. High.
|
|
62
|
+
- **docker_action_unpinned** — ``uses: docker://img:tag`` (or a docker
|
|
63
|
+
``action.yml`` image) not pinned by ``@sha256:``. Medium.
|
|
64
|
+
- **curl_pipe_shell** — ``curl … | sh`` in a ``run:`` step. Medium.
|
|
65
|
+
|
|
66
|
+
Line numbers are best-effort (PyYAML's safe_load discards them): we locate a
|
|
67
|
+
representative line by searching the raw text.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
from __future__ import annotations
|
|
71
|
+
|
|
72
|
+
import re
|
|
73
|
+
from dataclasses import dataclass, field
|
|
74
|
+
from pathlib import Path
|
|
75
|
+
|
|
76
|
+
from .safe_fs import is_contained, is_oversized, is_unsafe_link, walk_repo
|
|
77
|
+
from .models import WorkflowIssue
|
|
78
|
+
from .srcpaths import split_lines
|
|
79
|
+
|
|
80
|
+
# A pinned action ref is a full 40-hex commit SHA. Anything else is "unpinned".
|
|
81
|
+
_SHA_RE = re.compile(r"^[0-9a-f]{40}$")
|
|
82
|
+
# A semver-ish tag (`v4`, `v4.1.0`, `1.2.3`) — unpinned but lower risk than a
|
|
83
|
+
# branch ref like `main` / `master` / `latest`.
|
|
84
|
+
_SEMVER_TAG_RE = re.compile(r"^v?\d+(?:\.\d+)*$")
|
|
85
|
+
|
|
86
|
+
# `${{ <expr> }}` interpolation.
|
|
87
|
+
_EXPR_RE = re.compile(r"\$\{\{\s*(.*?)\s*\}\}", re.DOTALL)
|
|
88
|
+
|
|
89
|
+
# Attacker-controlled expression contexts that are dangerous inside a `run:`
|
|
90
|
+
# shell step (GitHub's own "untrusted input" list). Matched as substrings of
|
|
91
|
+
# the expression text.
|
|
92
|
+
_INJECTABLE_CONTEXTS = (
|
|
93
|
+
"github.event.issue.title",
|
|
94
|
+
"github.event.issue.body",
|
|
95
|
+
"github.event.pull_request.title",
|
|
96
|
+
"github.event.pull_request.body",
|
|
97
|
+
"github.event.pull_request.head.ref",
|
|
98
|
+
"github.event.pull_request.head.label",
|
|
99
|
+
"github.event.pull_request.head.repo.default_branch",
|
|
100
|
+
"github.event.comment.body",
|
|
101
|
+
"github.event.review.body",
|
|
102
|
+
"github.event.review_comment.body",
|
|
103
|
+
"github.event.discussion.title",
|
|
104
|
+
"github.event.discussion.body",
|
|
105
|
+
"github.event.head_commit.message",
|
|
106
|
+
"github.event.head_commit.author.email",
|
|
107
|
+
"github.event.head_commit.author.name",
|
|
108
|
+
"github.event.commits", # .*.message / .author.*
|
|
109
|
+
"github.event.pages", # .*.page_name
|
|
110
|
+
"github.head_ref",
|
|
111
|
+
# workflow_run (#246): the triggering run's branch / commit are fork-chosen.
|
|
112
|
+
"github.event.workflow_run.head_branch",
|
|
113
|
+
"github.event.workflow_run.head_commit.message",
|
|
114
|
+
"github.event.workflow_run.head_commit.author.email",
|
|
115
|
+
"github.event.workflow_run.head_commit.author.name",
|
|
116
|
+
"github.event.workflow_run.display_title",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# Expression references that indicate a checkout of untrusted PR head code.
|
|
120
|
+
_PR_HEAD_REFS = (
|
|
121
|
+
"github.event.pull_request.head.sha",
|
|
122
|
+
"github.event.pull_request.head.ref",
|
|
123
|
+
"github.head_ref",
|
|
124
|
+
)
|
|
125
|
+
# ... of the triggering run's head under `workflow_run` (#246).
|
|
126
|
+
_WORKFLOW_RUN_HEAD_REFS = (
|
|
127
|
+
"github.event.workflow_run.head_sha",
|
|
128
|
+
"github.event.workflow_run.head_branch",
|
|
129
|
+
"github.event.workflow_run.head_commit",
|
|
130
|
+
"github.event.workflow_run.pull_requests",
|
|
131
|
+
)
|
|
132
|
+
# Checkout refs that are *not* PR code even under issue_comment.
|
|
133
|
+
_SAFE_CHECKOUT_REFS = ("github.sha", "github.ref", "github.event.repository.default_branch")
|
|
134
|
+
|
|
135
|
+
# Triggers that run with a write-capable token / secrets while reacting to
|
|
136
|
+
# attacker-controlled input (#246).
|
|
137
|
+
_UNTRUSTED_TRIGGERS = frozenset({"pull_request_target", "issue_comment", "workflow_run"})
|
|
138
|
+
|
|
139
|
+
_ARTIFACT_ACTIONS_CROSS_RUN = ("dawidd6/action-download-artifact",)
|
|
140
|
+
_ARTIFACT_ACTION = "actions/download-artifact"
|
|
141
|
+
_GITHUB_SCRIPT_ACTION = "actions/github-script"
|
|
142
|
+
_CACHE_ACTIONS = ("actions/cache", "actions/cache/restore", "actions/cache/save")
|
|
143
|
+
_SETUP_ACTION_PREFIX = "actions/setup-"
|
|
144
|
+
|
|
145
|
+
# `git fetch origin pull/123/head`, `refs/pull/${{ … }}/merge`. Bounded so it
|
|
146
|
+
# stays linear on hostile input (#236).
|
|
147
|
+
_PULL_REF_RE = re.compile(r"pull/(?:\$\{\{[^}]{0,120}\}\}|[^\s/]{1,120})/(?:head|merge)\b")
|
|
148
|
+
# `$NAME` / `${NAME}` shell variable references.
|
|
149
|
+
_SHELL_VAR_RE = re.compile(r"\$\{?([A-Za-z_][A-Za-z0-9_]{0,100})")
|
|
150
|
+
# `inputs.<name>` / `github.event.inputs.<name>` references.
|
|
151
|
+
_INPUT_REF_RE = re.compile(r"\binputs\.([A-Za-z0-9_-]{1,100})")
|
|
152
|
+
_ENV_FILES = {"GITHUB_ENV": "high", "GITHUB_PATH": "high", "GITHUB_OUTPUT": "medium"}
|
|
153
|
+
_SHELLS = {"sh", "bash", "zsh", "dash", "ksh", "python", "python3", "perl", "ruby", "node"}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
_ACTION_FILENAMES = {"action.yml", "action.yaml"}
|
|
157
|
+
_ACTION_SKIP_DIRS = {"node_modules", ".git", ".venv", "venv", "vendor", "dist", "build"}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass
|
|
161
|
+
class _Ctx:
|
|
162
|
+
"""Per-workflow facts every rule consults."""
|
|
163
|
+
|
|
164
|
+
rel: str
|
|
165
|
+
lines: list[str]
|
|
166
|
+
triggers: set[str] = field(default_factory=set)
|
|
167
|
+
# Extra injectable expression prefixes with their severity (composite
|
|
168
|
+
# action / reusable-workflow `inputs.*`).
|
|
169
|
+
input_severity: dict[str, str] = field(default_factory=dict)
|
|
170
|
+
extra_contexts: tuple[str, ...] = ()
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def untrusted(self) -> set[str]:
|
|
174
|
+
return self.triggers & _UNTRUSTED_TRIGGERS
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def pr_target(self) -> bool:
|
|
178
|
+
return "pull_request_target" in self.triggers
|
|
179
|
+
|
|
180
|
+
def issue(self, kind: str, line: int | None, context: str, evidence: str, severity: str) -> WorkflowIssue:
|
|
181
|
+
return WorkflowIssue(
|
|
182
|
+
kind=kind, # type: ignore[arg-type]
|
|
183
|
+
file=self.rel,
|
|
184
|
+
line=line,
|
|
185
|
+
context=context,
|
|
186
|
+
evidence_text=evidence,
|
|
187
|
+
severity=severity, # type: ignore[arg-type]
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _action_name(uses: str) -> str:
|
|
192
|
+
return uses.split("@", 1)[0].strip().lower()
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _scan_action(data: dict, rel: str, lines: list[str]) -> list[WorkflowIssue]:
|
|
196
|
+
"""Composite action metadata (`action.yml`, #237). Its `inputs.*` come from
|
|
197
|
+
the calling workflow and often carry PR titles/branch names, so `inputs.*`
|
|
198
|
+
interpolated into a `run:` script is script injection, just like
|
|
199
|
+
`github.event.*` in a workflow. Docker actions are checked for an
|
|
200
|
+
unpinned `docker://` image (#246)."""
|
|
201
|
+
runs = data.get("runs")
|
|
202
|
+
if not isinstance(runs, dict):
|
|
203
|
+
return []
|
|
204
|
+
using = str(runs.get("using", "")).lower()
|
|
205
|
+
ctx = _Ctx(rel=rel, lines=lines, extra_contexts=("inputs.",))
|
|
206
|
+
if using == "docker":
|
|
207
|
+
image = runs.get("image")
|
|
208
|
+
if isinstance(image, str) and image.startswith("docker://") and "@sha256:" not in image:
|
|
209
|
+
return [
|
|
210
|
+
ctx.issue(
|
|
211
|
+
"docker_action_unpinned",
|
|
212
|
+
_line_for(lines, image) or _line_for(lines, "image:"),
|
|
213
|
+
"docker action image",
|
|
214
|
+
f"image: {image} (pin by @sha256: digest)",
|
|
215
|
+
"medium",
|
|
216
|
+
)
|
|
217
|
+
]
|
|
218
|
+
return []
|
|
219
|
+
if using != "composite":
|
|
220
|
+
return []
|
|
221
|
+
steps = runs.get("steps")
|
|
222
|
+
if not isinstance(steps, list):
|
|
223
|
+
return []
|
|
224
|
+
issues: list[WorkflowIssue] = []
|
|
225
|
+
runs_span = _child_key_spans(lines, "runs").get("steps")
|
|
226
|
+
step_spans = _item_spans(lines, "steps", (runs_span[0], runs_span[1]) if runs_span else None)
|
|
227
|
+
if len(step_spans) != len(steps):
|
|
228
|
+
step_spans = []
|
|
229
|
+
for index, step in enumerate(steps):
|
|
230
|
+
if not isinstance(step, dict):
|
|
231
|
+
continue
|
|
232
|
+
step_name = str(step.get("name") or step.get("id") or step.get("uses") or "step")
|
|
233
|
+
where = f"composite action step '{step_name}'"
|
|
234
|
+
ctx.lines = _scoped(lines, step_spans[index] if step_spans else None)
|
|
235
|
+
issues.extend(_scan_step(step, where, ctx, env={}, state=_JobState()))
|
|
236
|
+
ctx.lines = lines
|
|
237
|
+
return issues
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _load_yaml(path: Path, yaml): # type: ignore[no-untyped-def]
|
|
241
|
+
try:
|
|
242
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
243
|
+
data = yaml.safe_load(text)
|
|
244
|
+
except (OSError, yaml.YAMLError): # type: ignore[attr-defined]
|
|
245
|
+
return None, None
|
|
246
|
+
return (data, text) if isinstance(data, dict) else (None, None)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def scan_workflows(root: str | Path) -> list[WorkflowIssue]:
|
|
250
|
+
"""Scan `.github/workflows/*.yml` and composite-action `action.yml` files
|
|
251
|
+
under `root` for CI security issues."""
|
|
252
|
+
try:
|
|
253
|
+
import yaml # type: ignore[import-untyped]
|
|
254
|
+
except ImportError:
|
|
255
|
+
return []
|
|
256
|
+
|
|
257
|
+
root_path = Path(root)
|
|
258
|
+
issues: list[WorkflowIssue] = []
|
|
259
|
+
for path in walk_repo(root_path, prune=lambda name: name in _ACTION_SKIP_DIRS):
|
|
260
|
+
if path.name not in _ACTION_FILENAMES or is_oversized(path):
|
|
261
|
+
continue
|
|
262
|
+
data, text = _load_yaml(path, yaml)
|
|
263
|
+
if data is not None:
|
|
264
|
+
issues.extend(_scan_action(data, path.relative_to(root_path).as_posix(), split_lines(text)))
|
|
265
|
+
|
|
266
|
+
workflows_dir = root_path / ".github" / "workflows"
|
|
267
|
+
if not workflows_dir.is_dir() or not is_contained(root_path, workflows_dir):
|
|
268
|
+
return issues
|
|
269
|
+
|
|
270
|
+
for path in sorted(workflows_dir.iterdir()):
|
|
271
|
+
if path.suffix.lower() not in {".yml", ".yaml"} or not path.is_file():
|
|
272
|
+
continue
|
|
273
|
+
if is_unsafe_link(root_path, path) or is_oversized(path):
|
|
274
|
+
continue
|
|
275
|
+
try:
|
|
276
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
277
|
+
except OSError:
|
|
278
|
+
continue
|
|
279
|
+
try:
|
|
280
|
+
data = yaml.safe_load(text)
|
|
281
|
+
except yaml.YAMLError: # type: ignore[attr-defined]
|
|
282
|
+
continue
|
|
283
|
+
if not isinstance(data, dict):
|
|
284
|
+
continue
|
|
285
|
+
rel = str(path.relative_to(root_path))
|
|
286
|
+
lines = split_lines(text)
|
|
287
|
+
issues.extend(_scan_one(data, rel, lines))
|
|
288
|
+
return issues
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _raw_on(data: dict): # type: ignore[no-untyped-def]
|
|
292
|
+
raw = data.get("on")
|
|
293
|
+
if raw is None:
|
|
294
|
+
raw = data.get(True)
|
|
295
|
+
return raw
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _norm_triggers(data: dict) -> set[str]:
|
|
299
|
+
"""The workflow's `on:` triggers as a set of event names.
|
|
300
|
+
|
|
301
|
+
PyYAML parses the bare key ``on:`` as the boolean ``True`` (YAML 1.1), so we
|
|
302
|
+
look under both keys. ``on`` may be a string, a list, or a mapping.
|
|
303
|
+
"""
|
|
304
|
+
raw = _raw_on(data)
|
|
305
|
+
if raw is None:
|
|
306
|
+
return set()
|
|
307
|
+
if isinstance(raw, str):
|
|
308
|
+
return {raw}
|
|
309
|
+
if isinstance(raw, list):
|
|
310
|
+
return {str(x) for x in raw}
|
|
311
|
+
if isinstance(raw, dict):
|
|
312
|
+
return {str(k) for k in raw}
|
|
313
|
+
return set()
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _input_severities(data: dict, triggers: set[str]) -> dict[str, str]:
|
|
317
|
+
"""`inputs.<name>` → severity for free-text inputs of a reusable
|
|
318
|
+
(`workflow_call`, medium) or manually dispatched (`workflow_dispatch`,
|
|
319
|
+
low) workflow. Typed inputs (boolean/number/choice/environment) can't
|
|
320
|
+
carry shell metacharacters and are skipped."""
|
|
321
|
+
raw = _raw_on(data)
|
|
322
|
+
out: dict[str, str] = {}
|
|
323
|
+
if not isinstance(raw, dict):
|
|
324
|
+
return out
|
|
325
|
+
for trigger, severity in (("workflow_dispatch", "low"), ("workflow_call", "medium")):
|
|
326
|
+
spec = raw.get(trigger)
|
|
327
|
+
if trigger not in triggers or not isinstance(spec, dict):
|
|
328
|
+
continue
|
|
329
|
+
inputs = spec.get("inputs")
|
|
330
|
+
if not isinstance(inputs, dict):
|
|
331
|
+
continue
|
|
332
|
+
for name, decl in inputs.items():
|
|
333
|
+
kind = str(decl.get("type", "string")).lower() if isinstance(decl, dict) else "string"
|
|
334
|
+
if kind == "string" and out.get(str(name)) != "medium":
|
|
335
|
+
out[str(name)] = severity
|
|
336
|
+
return out
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
class _Scoped(list):
|
|
340
|
+
"""The workflow's lines plus the 1-based (start, end) span of the job or
|
|
341
|
+
step being checked. `_line_for` searches the span first, so two identical
|
|
342
|
+
`uses: actions/checkout@v4` steps in different jobs get their own lines
|
|
343
|
+
instead of both getting the first one."""
|
|
344
|
+
|
|
345
|
+
def __init__(self, lines: list[str], span: tuple[int, int] | None) -> None:
|
|
346
|
+
super().__init__(lines)
|
|
347
|
+
self.span = span
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _scoped(lines: list[str], span: tuple[int, int] | None) -> list[str]:
|
|
351
|
+
base = list(lines) if isinstance(lines, _Scoped) else lines
|
|
352
|
+
return _Scoped(base, span) if span else base
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _line_for(lines: list[str], *needles: str, contains_all: bool = False) -> int | None:
|
|
356
|
+
"""Best-effort 1-based line number of the first line matching needle(s),
|
|
357
|
+
looking inside the current job/step span first (see `_Scoped`)."""
|
|
358
|
+
|
|
359
|
+
def matches(line: str) -> bool:
|
|
360
|
+
return all(n in line for n in needles) if contains_all else any(n in line for n in needles)
|
|
361
|
+
|
|
362
|
+
span = getattr(lines, "span", None)
|
|
363
|
+
if span:
|
|
364
|
+
start, end = span
|
|
365
|
+
for idx in range(max(start, 1), min(end, len(lines)) + 1):
|
|
366
|
+
if matches(lines[idx - 1]):
|
|
367
|
+
return idx
|
|
368
|
+
for idx, line in enumerate(lines, start=1):
|
|
369
|
+
if matches(line):
|
|
370
|
+
return idx
|
|
371
|
+
return None
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
_KEY_LINE = re.compile(r"^(?P<indent>[ ]*)(?P<key>[\w.-]+)\s*:(?:\s|$)")
|
|
375
|
+
_ITEM_LINE = re.compile(r"^(?P<indent>[ ]*)-(?:\s|$)")
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _child_key_spans(lines: list[str], parent: str, region: tuple[int, int] | None = None) -> dict[str, tuple[int, int]]:
|
|
379
|
+
"""1-based spans of the keys directly under the block key `parent:`
|
|
380
|
+
(e.g. each job under `jobs:`), found from the text since PyYAML keeps no
|
|
381
|
+
positions. Empty when the layout isn't the usual block style."""
|
|
382
|
+
lo, hi = region or (1, len(lines))
|
|
383
|
+
parent_line = None
|
|
384
|
+
for idx in range(lo, hi + 1):
|
|
385
|
+
m = _KEY_LINE.match(lines[idx - 1])
|
|
386
|
+
if m and m.group("key") == parent:
|
|
387
|
+
parent_line, parent_indent = idx, len(m.group("indent"))
|
|
388
|
+
break
|
|
389
|
+
if parent_line is None:
|
|
390
|
+
return {}
|
|
391
|
+
starts: list[tuple[str, int]] = []
|
|
392
|
+
child_indent = None
|
|
393
|
+
for idx in range(parent_line + 1, hi + 1):
|
|
394
|
+
text = lines[idx - 1]
|
|
395
|
+
if not text.strip() or text.lstrip().startswith("#"):
|
|
396
|
+
continue
|
|
397
|
+
indent = len(text) - len(text.lstrip(" "))
|
|
398
|
+
if indent <= parent_indent:
|
|
399
|
+
hi = idx - 1
|
|
400
|
+
break
|
|
401
|
+
m = _KEY_LINE.match(text)
|
|
402
|
+
if m and (child_indent is None or indent == child_indent):
|
|
403
|
+
child_indent = indent
|
|
404
|
+
starts.append((m.group("key"), idx))
|
|
405
|
+
return {
|
|
406
|
+
key: (start, (starts[i + 1][1] - 1) if i + 1 < len(starts) else hi)
|
|
407
|
+
for i, (key, start) in enumerate(starts)
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _item_spans(lines: list[str], parent: str, region: tuple[int, int] | None) -> list[tuple[int, int]]:
|
|
412
|
+
"""1-based spans of the list items under `parent:` (e.g. each step under
|
|
413
|
+
`steps:`) within `region`."""
|
|
414
|
+
if not region:
|
|
415
|
+
return []
|
|
416
|
+
lo, hi = region
|
|
417
|
+
parent_line = None
|
|
418
|
+
for idx in range(lo, hi + 1):
|
|
419
|
+
m = _KEY_LINE.match(lines[idx - 1])
|
|
420
|
+
if m and m.group("key") == parent:
|
|
421
|
+
parent_line, parent_indent = idx, len(m.group("indent"))
|
|
422
|
+
break
|
|
423
|
+
if parent_line is None:
|
|
424
|
+
return []
|
|
425
|
+
starts: list[int] = []
|
|
426
|
+
item_indent = None
|
|
427
|
+
end = hi
|
|
428
|
+
for idx in range(parent_line + 1, hi + 1):
|
|
429
|
+
text = lines[idx - 1]
|
|
430
|
+
if not text.strip() or text.lstrip().startswith("#"):
|
|
431
|
+
continue
|
|
432
|
+
indent = len(text) - len(text.lstrip(" "))
|
|
433
|
+
m = _ITEM_LINE.match(text)
|
|
434
|
+
if m and (item_indent is None or indent == item_indent) and indent >= parent_indent:
|
|
435
|
+
item_indent = indent
|
|
436
|
+
starts.append(idx)
|
|
437
|
+
elif indent <= parent_indent and not m:
|
|
438
|
+
end = idx - 1
|
|
439
|
+
break
|
|
440
|
+
return [(start, (starts[i + 1] - 1) if i + 1 < len(starts) else end) for i, start in enumerate(starts)]
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _iter_jobs(data: dict):
|
|
444
|
+
jobs = data.get("jobs")
|
|
445
|
+
if not isinstance(jobs, dict):
|
|
446
|
+
return
|
|
447
|
+
for job_id, job in jobs.items():
|
|
448
|
+
if isinstance(job, dict):
|
|
449
|
+
yield str(job_id), job
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _runs_on_values(job: dict) -> list[str]:
|
|
453
|
+
raw = job.get("runs-on")
|
|
454
|
+
if isinstance(raw, str):
|
|
455
|
+
return [raw]
|
|
456
|
+
if isinstance(raw, list):
|
|
457
|
+
return [str(x) for x in raw]
|
|
458
|
+
if isinstance(raw, dict): # runs-on: { group: …, labels: [...] }
|
|
459
|
+
labels = raw.get("labels")
|
|
460
|
+
if isinstance(labels, str):
|
|
461
|
+
return [labels]
|
|
462
|
+
if isinstance(labels, list):
|
|
463
|
+
return [str(x) for x in labels]
|
|
464
|
+
return []
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _is_write_all(permissions) -> bool:
|
|
468
|
+
"""True when a `permissions:` value grants blanket write access."""
|
|
469
|
+
if isinstance(permissions, str):
|
|
470
|
+
return permissions.strip().lower() in {"write-all", "write"}
|
|
471
|
+
return False
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _grants_id_token(permissions) -> bool: # type: ignore[no-untyped-def]
|
|
475
|
+
if isinstance(permissions, dict):
|
|
476
|
+
return str(permissions.get("id-token", "")).strip().lower() == "write"
|
|
477
|
+
return _is_write_all(permissions)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _env_map(*blocks) -> dict[str, str]: # type: ignore[no-untyped-def]
|
|
481
|
+
out: dict[str, str] = {}
|
|
482
|
+
for block in blocks:
|
|
483
|
+
if isinstance(block, dict):
|
|
484
|
+
out.update({str(k): str(v) for k, v in block.items()})
|
|
485
|
+
return out
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _injectable(expr: str, ctx: _Ctx) -> tuple[str, str] | None:
|
|
489
|
+
"""`(matched context, severity)` when an expression is attacker-controlled."""
|
|
490
|
+
expr_flat = re.sub(r"\s+", "", expr)
|
|
491
|
+
for c in _INJECTABLE_CONTEXTS + ctx.extra_contexts:
|
|
492
|
+
if c in expr_flat:
|
|
493
|
+
return c, "high"
|
|
494
|
+
for m in _INPUT_REF_RE.finditer(expr_flat):
|
|
495
|
+
severity = ctx.input_severity.get(m.group(1))
|
|
496
|
+
if severity:
|
|
497
|
+
return m.group(0), severity
|
|
498
|
+
return None
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _first_injection(text: str, ctx: _Ctx) -> tuple[str, str, str] | None:
|
|
502
|
+
"""`(expr, matched context, severity)` of the first injectable `${{ }}`."""
|
|
503
|
+
for match in _EXPR_RE.finditer(text):
|
|
504
|
+
hit = _injectable(match.group(1), ctx)
|
|
505
|
+
if hit:
|
|
506
|
+
return match.group(1), hit[0], hit[1]
|
|
507
|
+
return None
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
@dataclass
|
|
511
|
+
class _JobState:
|
|
512
|
+
"""What earlier steps in the job did (rules that depend on step order)."""
|
|
513
|
+
|
|
514
|
+
pr_code_checked_out: bool = False # untrusted PR / run head is in the workspace
|
|
515
|
+
artifact_download: tuple[int | None, str, str] | None = None # (line, where, evidence)
|
|
516
|
+
credential_checkout: tuple[int | None, str] | None = None # checkout leaving the token behind
|
|
517
|
+
reported_artifact: bool = False
|
|
518
|
+
reported_credentials: bool = False
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _scan_one(data: dict, rel: str, lines: list[str]) -> list[WorkflowIssue]:
|
|
522
|
+
issues: list[WorkflowIssue] = []
|
|
523
|
+
triggers = _norm_triggers(data)
|
|
524
|
+
ctx = _Ctx(rel=rel, lines=lines, triggers=triggers, input_severity=_input_severities(data, triggers))
|
|
525
|
+
untrusted = ctx.untrusted
|
|
526
|
+
pr_trigger = bool(triggers & {"pull_request", "pull_request_target"})
|
|
527
|
+
pr_target = ctx.pr_target
|
|
528
|
+
trigger_label = ", ".join(sorted(untrusted))
|
|
529
|
+
|
|
530
|
+
top_perms = data.get("permissions")
|
|
531
|
+
# Top-level broad permissions.
|
|
532
|
+
if _is_write_all(top_perms):
|
|
533
|
+
issues.append(
|
|
534
|
+
ctx.issue(
|
|
535
|
+
"broad_permissions",
|
|
536
|
+
_line_for(lines, "permissions:"),
|
|
537
|
+
"workflow (top-level permissions)",
|
|
538
|
+
"permissions: write-all",
|
|
539
|
+
"high" if untrusted else "medium",
|
|
540
|
+
)
|
|
541
|
+
)
|
|
542
|
+
oidc_triggers = untrusted | (triggers & {"pull_request"})
|
|
543
|
+
if oidc_triggers and _grants_id_token(top_perms):
|
|
544
|
+
issues.append(_oidc_issue(ctx, "workflow (top-level permissions)", oidc_triggers))
|
|
545
|
+
|
|
546
|
+
jobs = list(_iter_jobs(data))
|
|
547
|
+
if "permissions" not in data and any("permissions" not in job for _, job in jobs):
|
|
548
|
+
unscoped = [job_id for job_id, job in jobs if "permissions" not in job]
|
|
549
|
+
issues.append(
|
|
550
|
+
ctx.issue(
|
|
551
|
+
"default_token_permissions",
|
|
552
|
+
_line_for(lines, "jobs:"),
|
|
553
|
+
f"job(s) {', '.join(repr(j) for j in unscoped[:5])} (no permissions: block)",
|
|
554
|
+
"no top-level or job permissions: block — the GITHUB_TOKEN gets the repo/org default"
|
|
555
|
+
+ (f" on an untrusted trigger ({trigger_label})" if untrusted else ""),
|
|
556
|
+
"medium" if untrusted else "low",
|
|
557
|
+
)
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
workflow_env = data.get("env")
|
|
561
|
+
all_lines = lines
|
|
562
|
+
job_spans = _child_key_spans(all_lines, "jobs")
|
|
563
|
+
for job_id, job in jobs:
|
|
564
|
+
job_span = job_spans.get(job_id)
|
|
565
|
+
lines = ctx.lines = _scoped(all_lines, job_span)
|
|
566
|
+
# Job-level broad permissions.
|
|
567
|
+
if _is_write_all(job.get("permissions")):
|
|
568
|
+
issues.append(
|
|
569
|
+
ctx.issue(
|
|
570
|
+
"broad_permissions",
|
|
571
|
+
_line_for(lines, f"{job_id}:") or _line_for(lines, "permissions:"),
|
|
572
|
+
f"job '{job_id}' permissions",
|
|
573
|
+
"permissions: write-all",
|
|
574
|
+
"high" if untrusted else "medium",
|
|
575
|
+
)
|
|
576
|
+
)
|
|
577
|
+
if oidc_triggers and _grants_id_token(job.get("permissions")):
|
|
578
|
+
issues.append(_oidc_issue(ctx, f"job '{job_id}' permissions", oidc_triggers))
|
|
579
|
+
|
|
580
|
+
# Reusable-workflow call: pinning and `secrets: inherit`.
|
|
581
|
+
job_uses = job.get("uses")
|
|
582
|
+
if isinstance(job_uses, str):
|
|
583
|
+
issues.extend(_check_pin(job_uses, f"job '{job_id}' (reusable workflow)", ctx))
|
|
584
|
+
if str(job.get("secrets", "")).strip().lower() == "inherit":
|
|
585
|
+
issues.append(
|
|
586
|
+
ctx.issue(
|
|
587
|
+
"secrets_inherit",
|
|
588
|
+
_line_for(lines, "secrets:", "inherit", contains_all=True),
|
|
589
|
+
f"job '{job_id}' calls {job_uses}",
|
|
590
|
+
f"secrets: inherit passes every repository secret to {job_uses}"
|
|
591
|
+
+ (f" on an untrusted trigger ({trigger_label})" if untrusted else ""),
|
|
592
|
+
"high" if untrusted else "medium",
|
|
593
|
+
)
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
# Self-hosted runner on a PR trigger.
|
|
597
|
+
if pr_trigger and any(v.lower() == "self-hosted" for v in _runs_on_values(job)):
|
|
598
|
+
issues.append(
|
|
599
|
+
ctx.issue(
|
|
600
|
+
"self_hosted_pr",
|
|
601
|
+
_line_for(lines, "runs-on", "self-hosted", contains_all=True)
|
|
602
|
+
or _line_for(lines, "self-hosted"),
|
|
603
|
+
f"job '{job_id}' (runs-on self-hosted, {'pull_request_target' if pr_target else 'pull_request'})",
|
|
604
|
+
"self-hosted runner reachable from pull-request code",
|
|
605
|
+
"high" if pr_target else "medium",
|
|
606
|
+
)
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
steps = job.get("steps")
|
|
610
|
+
if not isinstance(steps, list):
|
|
611
|
+
continue
|
|
612
|
+
state = _JobState()
|
|
613
|
+
job_env = _env_map(workflow_env, job.get("env"))
|
|
614
|
+
step_spans = _item_spans(all_lines, "steps", job_span)
|
|
615
|
+
if len(step_spans) != len(steps):
|
|
616
|
+
step_spans = [] # unusual layout: fall back to the job span
|
|
617
|
+
for index, step in enumerate(steps):
|
|
618
|
+
if not isinstance(step, dict):
|
|
619
|
+
continue
|
|
620
|
+
step_name = str(step.get("name") or step.get("id") or step.get("uses") or "step")
|
|
621
|
+
where = f"job '{job_id}' step '{step_name}'"
|
|
622
|
+
ctx.lines = _scoped(all_lines, step_spans[index] if step_spans else job_span)
|
|
623
|
+
issues.extend(_scan_step(step, where, ctx, env=job_env, state=state))
|
|
624
|
+
ctx.lines = lines
|
|
625
|
+
if state.artifact_download and not state.reported_artifact:
|
|
626
|
+
# Downloaded but never executed in this job: still untrusted data
|
|
627
|
+
# (it may be consumed as config or uploaded on), so medium.
|
|
628
|
+
line, dl_where, evidence = state.artifact_download
|
|
629
|
+
issues.append(
|
|
630
|
+
ctx.issue(
|
|
631
|
+
"workflow_run_artifact_poisoning",
|
|
632
|
+
line,
|
|
633
|
+
dl_where,
|
|
634
|
+
f"{evidence} — treat them as untrusted data",
|
|
635
|
+
"medium",
|
|
636
|
+
)
|
|
637
|
+
)
|
|
638
|
+
return issues
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _oidc_issue(ctx: _Ctx, where: str, triggers: set[str]) -> WorkflowIssue:
|
|
642
|
+
untrusted = triggers & _UNTRUSTED_TRIGGERS
|
|
643
|
+
return ctx.issue(
|
|
644
|
+
"oidc_on_untrusted_trigger",
|
|
645
|
+
_line_for(ctx.lines, "id-token", "write", contains_all=True) or _line_for(ctx.lines, "permissions:"),
|
|
646
|
+
where,
|
|
647
|
+
f"id-token: write on a {', '.join(sorted(triggers))}-triggered workflow — PR code can mint cloud OIDC credentials",
|
|
648
|
+
"high" if untrusted else "low",
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def _executes_code(step: dict) -> bool:
|
|
653
|
+
"""A step that runs workspace code: a `run:` script or a local action."""
|
|
654
|
+
uses = step.get("uses")
|
|
655
|
+
return isinstance(step.get("run"), str) or (isinstance(uses, str) and uses.startswith("./"))
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def _scan_step(step: dict, where: str, ctx: _Ctx, *, env: dict[str, str], state: _JobState) -> list[WorkflowIssue]:
|
|
659
|
+
issues: list[WorkflowIssue] = []
|
|
660
|
+
step_env = _env_map(env, step.get("env"))
|
|
661
|
+
with_ = step.get("with") if isinstance(step.get("with"), dict) else {}
|
|
662
|
+
|
|
663
|
+
# Step-order rules: something earlier in the job put untrusted code /
|
|
664
|
+
# a credential in the workspace, and this step executes code.
|
|
665
|
+
if _executes_code(step):
|
|
666
|
+
if state.artifact_download and not state.reported_artifact:
|
|
667
|
+
line, dl_where, evidence = state.artifact_download
|
|
668
|
+
state.reported_artifact = True
|
|
669
|
+
issues.append(
|
|
670
|
+
ctx.issue(
|
|
671
|
+
"workflow_run_artifact_poisoning",
|
|
672
|
+
line,
|
|
673
|
+
f"{dl_where} → executed by {where}",
|
|
674
|
+
f"{evidence}, then executes workspace code with the base repo's token/secrets",
|
|
675
|
+
"high",
|
|
676
|
+
)
|
|
677
|
+
)
|
|
678
|
+
untrusted_code = state.pr_code_checked_out or state.artifact_download is not None
|
|
679
|
+
if state.credential_checkout and untrusted_code and not state.reported_credentials:
|
|
680
|
+
line, co_where = state.credential_checkout
|
|
681
|
+
state.reported_credentials = True
|
|
682
|
+
issues.append(
|
|
683
|
+
ctx.issue(
|
|
684
|
+
"checkout_persist_credentials",
|
|
685
|
+
line,
|
|
686
|
+
co_where,
|
|
687
|
+
f"actions/checkout on {', '.join(sorted(ctx.untrusted))} without persist-credentials: false; "
|
|
688
|
+
f"later step {where} runs code that can read the token from .git/config",
|
|
689
|
+
"medium",
|
|
690
|
+
)
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
uses = step.get("uses")
|
|
694
|
+
if isinstance(uses, str):
|
|
695
|
+
issues.extend(_check_uses(uses, step, with_, where, ctx, state))
|
|
696
|
+
|
|
697
|
+
run = step.get("run")
|
|
698
|
+
if isinstance(run, str):
|
|
699
|
+
issues.extend(_check_run(run, where, ctx, step_env, state))
|
|
700
|
+
return issues
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def _check_pin(uses: str, where: str, ctx: _Ctx) -> list[WorkflowIssue]:
|
|
704
|
+
if uses.startswith("docker://"):
|
|
705
|
+
if "@sha256:" in uses:
|
|
706
|
+
return []
|
|
707
|
+
return [
|
|
708
|
+
ctx.issue(
|
|
709
|
+
"docker_action_unpinned",
|
|
710
|
+
_line_for(ctx.lines, uses) or _line_for(ctx.lines, "docker://"),
|
|
711
|
+
where,
|
|
712
|
+
f"uses: {uses} (pin by @sha256: digest)",
|
|
713
|
+
"medium",
|
|
714
|
+
)
|
|
715
|
+
]
|
|
716
|
+
# Local (`./…`) actions aren't tag-pinned refs.
|
|
717
|
+
if "@" not in uses or uses.startswith("."):
|
|
718
|
+
return []
|
|
719
|
+
ref = uses.split("@", 1)[1].strip()
|
|
720
|
+
if _SHA_RE.match(ref):
|
|
721
|
+
return []
|
|
722
|
+
return [
|
|
723
|
+
ctx.issue(
|
|
724
|
+
"unpinned_action",
|
|
725
|
+
_line_for(ctx.lines, uses) or _line_for(ctx.lines, "uses:"),
|
|
726
|
+
where,
|
|
727
|
+
f"uses: {uses} (pin to a full commit SHA)",
|
|
728
|
+
"low" if _SEMVER_TAG_RE.match(ref) else "medium",
|
|
729
|
+
)
|
|
730
|
+
]
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def _check_uses(
|
|
734
|
+
uses: str, step: dict, with_: dict, where: str, ctx: _Ctx, state: _JobState
|
|
735
|
+
) -> list[WorkflowIssue]:
|
|
736
|
+
issues = _check_pin(uses, where, ctx)
|
|
737
|
+
name = _action_name(uses)
|
|
738
|
+
lines = ctx.lines
|
|
739
|
+
|
|
740
|
+
if name.endswith("actions/checkout"):
|
|
741
|
+
ref_val = str(with_.get("ref", ""))
|
|
742
|
+
repo_val = str(with_.get("repository", ""))
|
|
743
|
+
target = f"{ref_val} {repo_val}"
|
|
744
|
+
if ctx.pr_target and any(head in target for head in _PR_HEAD_REFS):
|
|
745
|
+
state.pr_code_checked_out = True
|
|
746
|
+
issues.append(
|
|
747
|
+
ctx.issue(
|
|
748
|
+
"pr_target_checkout",
|
|
749
|
+
_line_for(lines, "ref:", "head", contains_all=True) or _line_for(lines, "actions/checkout"),
|
|
750
|
+
where,
|
|
751
|
+
f"pull_request_target checks out untrusted PR code: ref: {ref_val}",
|
|
752
|
+
"high",
|
|
753
|
+
)
|
|
754
|
+
)
|
|
755
|
+
if "workflow_run" in ctx.triggers and any(head in target for head in _WORKFLOW_RUN_HEAD_REFS):
|
|
756
|
+
state.pr_code_checked_out = True
|
|
757
|
+
issues.append(
|
|
758
|
+
ctx.issue(
|
|
759
|
+
"workflow_run_artifact_poisoning",
|
|
760
|
+
_line_for(lines, "workflow_run.head") or _line_for(lines, "actions/checkout"),
|
|
761
|
+
where,
|
|
762
|
+
f"workflow_run checks out the triggering run's untrusted head: ref: {ref_val}",
|
|
763
|
+
"high",
|
|
764
|
+
)
|
|
765
|
+
)
|
|
766
|
+
if "issue_comment" in ctx.triggers and _issue_comment_pr_ref(ref_val):
|
|
767
|
+
state.pr_code_checked_out = True
|
|
768
|
+
issues.append(
|
|
769
|
+
ctx.issue(
|
|
770
|
+
"issue_comment_pr_checkout",
|
|
771
|
+
_line_for(lines, "ref:", ref_val[:40]) if ref_val else _line_for(lines, "actions/checkout"),
|
|
772
|
+
where,
|
|
773
|
+
f"issue_comment workflow checks out pull-request code: ref: {ref_val}",
|
|
774
|
+
"high",
|
|
775
|
+
)
|
|
776
|
+
)
|
|
777
|
+
persist = str(with_.get("persist-credentials", "true")).strip().lower()
|
|
778
|
+
if ctx.untrusted and persist != "false" and state.credential_checkout is None:
|
|
779
|
+
state.credential_checkout = (
|
|
780
|
+
_line_for(lines, "actions/checkout"),
|
|
781
|
+
where,
|
|
782
|
+
)
|
|
783
|
+
|
|
784
|
+
if "workflow_run" in ctx.triggers and state.artifact_download is None:
|
|
785
|
+
cross_run = name in _ARTIFACT_ACTIONS_CROSS_RUN or (
|
|
786
|
+
name == _ARTIFACT_ACTION and ("run-id" in with_ or "github-token" in with_)
|
|
787
|
+
)
|
|
788
|
+
script = str(with_.get("script", "")) if name == _GITHUB_SCRIPT_ACTION else ""
|
|
789
|
+
if cross_run or "downloadArtifact" in script:
|
|
790
|
+
state.artifact_download = (
|
|
791
|
+
_line_for(lines, name.split("/", 1)[-1]) or _line_for(lines, uses),
|
|
792
|
+
where,
|
|
793
|
+
f"workflow_run downloads the triggering run's artifacts ({name})",
|
|
794
|
+
)
|
|
795
|
+
|
|
796
|
+
if name == _GITHUB_SCRIPT_ACTION:
|
|
797
|
+
script = with_.get("script")
|
|
798
|
+
hit = _first_injection(script, ctx) if isinstance(script, str) else None
|
|
799
|
+
if hit:
|
|
800
|
+
expr, matched, severity = hit
|
|
801
|
+
issues.append(
|
|
802
|
+
ctx.issue(
|
|
803
|
+
"github_script_injection",
|
|
804
|
+
_line_for(lines, expr) or _line_for(lines, matched) or _line_for(lines, "script:"),
|
|
805
|
+
where,
|
|
806
|
+
f"attacker-controlled ${{{{ {expr} }}}} interpolated into actions/github-script script: (evaluated as JavaScript)",
|
|
807
|
+
severity,
|
|
808
|
+
)
|
|
809
|
+
)
|
|
810
|
+
|
|
811
|
+
is_cache = name in _CACHE_ACTIONS or (name.startswith(_SETUP_ACTION_PREFIX) and with_.get("cache"))
|
|
812
|
+
if ctx.pr_target and is_cache:
|
|
813
|
+
key_text = f"{with_.get('key', '')} {with_.get('restore-keys', '')}"
|
|
814
|
+
pr_keyed = any(c in key_text for c in _PR_HEAD_REFS + ("github.event.pull_request",))
|
|
815
|
+
if state.pr_code_checked_out or pr_keyed:
|
|
816
|
+
reason = "after checking out PR code" if state.pr_code_checked_out else f"keyed on PR-controlled values ({key_text.strip()})"
|
|
817
|
+
issues.append(
|
|
818
|
+
ctx.issue(
|
|
819
|
+
"cache_poisoning_pr_target",
|
|
820
|
+
_line_for(lines, uses),
|
|
821
|
+
where,
|
|
822
|
+
f"{name} on pull_request_target {reason} — the entry is saved to the base branch's cache scope",
|
|
823
|
+
"high",
|
|
824
|
+
)
|
|
825
|
+
)
|
|
826
|
+
return issues
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def _issue_comment_pr_ref(ref_val: str) -> bool:
|
|
830
|
+
"""A checkout ref under issue_comment that resolves to PR code: any
|
|
831
|
+
expression other than the base commit/ref, or a literal `refs/pull/…`."""
|
|
832
|
+
if not ref_val.strip():
|
|
833
|
+
return False
|
|
834
|
+
if "refs/pull/" in ref_val or _PULL_REF_RE.search(ref_val):
|
|
835
|
+
return True
|
|
836
|
+
exprs = [m.group(1).strip() for m in _EXPR_RE.finditer(ref_val)]
|
|
837
|
+
return bool(exprs) and not all(e in _SAFE_CHECKOUT_REFS for e in exprs)
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def _check_run(
|
|
841
|
+
run: str, where: str, ctx: _Ctx, env: dict[str, str], state: _JobState
|
|
842
|
+
) -> list[WorkflowIssue]:
|
|
843
|
+
issues: list[WorkflowIssue] = []
|
|
844
|
+
lines = ctx.lines
|
|
845
|
+
untrusted = ctx.untrusted
|
|
846
|
+
|
|
847
|
+
hit = _first_injection(run, ctx)
|
|
848
|
+
if hit:
|
|
849
|
+
expr, matched, severity = hit
|
|
850
|
+
issues.append(
|
|
851
|
+
ctx.issue(
|
|
852
|
+
"script_injection",
|
|
853
|
+
_line_for(lines, expr) or _line_for(lines, matched) or _line_for(lines, "run:"),
|
|
854
|
+
where,
|
|
855
|
+
f"attacker-controlled ${{{{ {expr} }}}} interpolated into run: script",
|
|
856
|
+
severity,
|
|
857
|
+
)
|
|
858
|
+
)
|
|
859
|
+
for match in _EXPR_RE.finditer(run):
|
|
860
|
+
expr = match.group(1)
|
|
861
|
+
if re.sub(r"\s+", "", expr).startswith("secrets."):
|
|
862
|
+
issues.append(
|
|
863
|
+
ctx.issue(
|
|
864
|
+
"secret_in_run",
|
|
865
|
+
_line_for(lines, "secrets.") or _line_for(lines, "run:"),
|
|
866
|
+
where,
|
|
867
|
+
f"${{{{ {expr} }}}} interpolated into run: script (pass via env: instead)",
|
|
868
|
+
"high" if untrusted else "medium",
|
|
869
|
+
)
|
|
870
|
+
)
|
|
871
|
+
break
|
|
872
|
+
|
|
873
|
+
# PR checkout from a shell step under an untrusted trigger.
|
|
874
|
+
if untrusted and ("gh pr checkout" in run or _PULL_REF_RE.search(run)):
|
|
875
|
+
state.pr_code_checked_out = True
|
|
876
|
+
kind = (
|
|
877
|
+
"pr_target_checkout" if ctx.pr_target
|
|
878
|
+
else "issue_comment_pr_checkout" if "issue_comment" in untrusted
|
|
879
|
+
else "workflow_run_artifact_poisoning"
|
|
880
|
+
)
|
|
881
|
+
issues.append(
|
|
882
|
+
ctx.issue(
|
|
883
|
+
kind,
|
|
884
|
+
_line_for(lines, "gh pr checkout") or _line_for(lines, "pull/") or _line_for(lines, "run:"),
|
|
885
|
+
where,
|
|
886
|
+
f"run: step fetches/checks out pull-request code on {', '.join(sorted(untrusted))}",
|
|
887
|
+
"high",
|
|
888
|
+
)
|
|
889
|
+
)
|
|
890
|
+
if "workflow_run" in ctx.triggers and "gh run download" in run and state.artifact_download is None:
|
|
891
|
+
state.artifact_download = (
|
|
892
|
+
_line_for(lines, "gh run download"),
|
|
893
|
+
where,
|
|
894
|
+
"workflow_run downloads the triggering run's artifacts (gh run download)",
|
|
895
|
+
)
|
|
896
|
+
|
|
897
|
+
issues.extend(_check_env_file_writes(run, where, ctx, env))
|
|
898
|
+
issues.extend(_check_curl_pipe(run, where, ctx))
|
|
899
|
+
return issues
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
def _tainted_env_names(env: dict[str, str], ctx: _Ctx) -> dict[str, str]:
|
|
903
|
+
out: dict[str, str] = {}
|
|
904
|
+
for name, value in env.items():
|
|
905
|
+
hit = _first_injection(value, ctx)
|
|
906
|
+
if hit:
|
|
907
|
+
out[name] = hit[0]
|
|
908
|
+
return out
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
def _check_env_file_writes(run: str, where: str, ctx: _Ctx, env: dict[str, str]) -> list[WorkflowIssue]:
|
|
912
|
+
tainted: dict[str, str] | None = None
|
|
913
|
+
for raw_line in run.splitlines():
|
|
914
|
+
if ">>" not in raw_line:
|
|
915
|
+
continue
|
|
916
|
+
target = next((f for f in _ENV_FILES if f in raw_line.split(">>", 1)[1]), None)
|
|
917
|
+
if target is None:
|
|
918
|
+
continue
|
|
919
|
+
source = raw_line.split(">>", 1)[0]
|
|
920
|
+
hit = _first_injection(source, ctx)
|
|
921
|
+
via = f"${{{{ {hit[0]} }}}}" if hit else None
|
|
922
|
+
if via is None:
|
|
923
|
+
if tainted is None:
|
|
924
|
+
tainted = _tainted_env_names(env, ctx)
|
|
925
|
+
for m in _SHELL_VAR_RE.finditer(source):
|
|
926
|
+
if m.group(1) in tainted:
|
|
927
|
+
via = f"${m.group(1)} (env: ${{{{ {tainted[m.group(1)]} }}}})"
|
|
928
|
+
break
|
|
929
|
+
if via is None:
|
|
930
|
+
continue
|
|
931
|
+
return [
|
|
932
|
+
ctx.issue(
|
|
933
|
+
"github_env_injection",
|
|
934
|
+
_line_for(ctx.lines, target, ">>", contains_all=True) or _line_for(ctx.lines, "run:"),
|
|
935
|
+
where,
|
|
936
|
+
f"attacker-controlled {via} written to ${target} — a newline sets arbitrary variables for later steps",
|
|
937
|
+
_ENV_FILES[target],
|
|
938
|
+
)
|
|
939
|
+
]
|
|
940
|
+
return []
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
def _check_curl_pipe(run: str, where: str, ctx: _Ctx) -> list[WorkflowIssue]:
|
|
944
|
+
for raw_line in run.splitlines():
|
|
945
|
+
if "|" not in raw_line or ("curl" not in raw_line and "wget" not in raw_line):
|
|
946
|
+
continue
|
|
947
|
+
head, *tails = raw_line.split("|")
|
|
948
|
+
if "curl" not in head and "wget" not in head:
|
|
949
|
+
continue
|
|
950
|
+
for tail in tails:
|
|
951
|
+
words = tail.split()
|
|
952
|
+
while words and words[0] in {"sudo", "-E", "env"}:
|
|
953
|
+
words = words[1:]
|
|
954
|
+
if words and words[0].rsplit("/", 1)[-1] in _SHELLS:
|
|
955
|
+
return [
|
|
956
|
+
ctx.issue(
|
|
957
|
+
"curl_pipe_shell",
|
|
958
|
+
_line_for(ctx.lines, raw_line.strip()[:60]) or _line_for(ctx.lines, "run:"),
|
|
959
|
+
where,
|
|
960
|
+
f"remote script piped into a shell: {raw_line.strip()[:160]}",
|
|
961
|
+
"medium",
|
|
962
|
+
)
|
|
963
|
+
]
|
|
964
|
+
return []
|