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.
Files changed (91) hide show
  1. attackmap/__init__.py +2 -0
  2. attackmap/analyzer.py +449 -0
  3. attackmap/analyzer_contracts.py +194 -0
  4. attackmap/analyzers.py +1003 -0
  5. attackmap/anomalies.py +612 -0
  6. attackmap/asset_model.py +245 -0
  7. attackmap/attack_taxonomy.py +133 -0
  8. attackmap/auth_protocol.py +702 -0
  9. attackmap/authz.py +1695 -0
  10. attackmap/bench.py +467 -0
  11. attackmap/cli.py +1474 -0
  12. attackmap/config_scanner.py +389 -0
  13. attackmap/context_pack.py +82 -0
  14. attackmap/contracts.py +201 -0
  15. attackmap/control_model.py +280 -0
  16. attackmap/crossrepo.py +356 -0
  17. attackmap/crypto.py +240 -0
  18. attackmap/cve.py +717 -0
  19. attackmap/defensive_review.py +647 -0
  20. attackmap/detection_opportunities.py +1221 -0
  21. attackmap/detections/__init__.py +491 -0
  22. attackmap/detections/bola.yml +57 -0
  23. attackmap/detections/cloud.yml +609 -0
  24. attackmap/detections/injection.yml +167 -0
  25. attackmap/detections/secrets.yml +48 -0
  26. attackmap/detections/sigma.py +511 -0
  27. attackmap/detections/ssrf.yml +98 -0
  28. attackmap/detections/weakness.yml +62 -0
  29. attackmap/detections/webhook.yml +45 -0
  30. attackmap/detections/workflow.yml +121 -0
  31. attackmap/diagrams.py +237 -0
  32. attackmap/diff.py +301 -0
  33. attackmap/exploitability.py +493 -0
  34. attackmap/fingerprint.py +91 -0
  35. attackmap/fleet.py +370 -0
  36. attackmap/gitref.py +129 -0
  37. attackmap/graph.py +26 -0
  38. attackmap/handler_auth.py +639 -0
  39. attackmap/handler_span.py +62 -0
  40. attackmap/hunt_harness.py +505 -0
  41. attackmap/insights.py +701 -0
  42. attackmap/llm_review.py +846 -0
  43. attackmap/lockfiles.py +521 -0
  44. attackmap/mass_assignment.py +543 -0
  45. attackmap/md.py +175 -0
  46. attackmap/merge.py +333 -0
  47. attackmap/models.py +1284 -0
  48. attackmap/plugins_lock.py +114 -0
  49. attackmap/popular_packages.py +193 -0
  50. attackmap/progress.py +258 -0
  51. attackmap/recon_models.py +31 -0
  52. attackmap/recon_to_analysis.py +149 -0
  53. attackmap/redact.py +220 -0
  54. attackmap/report.py +424 -0
  55. attackmap/review_eval.py +335 -0
  56. attackmap/review_json.py +308 -0
  57. attackmap/review_prompts.py +796 -0
  58. attackmap/route_auth_fusion.py +474 -0
  59. attackmap/safe_fs.py +285 -0
  60. attackmap/sarif.py +373 -0
  61. attackmap/sbom.py +418 -0
  62. attackmap/scanner.py +1379 -0
  63. attackmap/sdk/__init__.py +278 -0
  64. attackmap/sdk/contracts.py +17 -0
  65. attackmap/sdk/fs.py +156 -0
  66. attackmap/sdk/models.py +59 -0
  67. attackmap/secrets_history.py +161 -0
  68. attackmap/security_overlay.py +71 -0
  69. attackmap/srcpaths.py +301 -0
  70. attackmap/suggest.py +394 -0
  71. attackmap/supply_chain.py +688 -0
  72. attackmap/suppress.py +740 -0
  73. attackmap/taint.py +1540 -0
  74. attackmap/taint_flow.py +1419 -0
  75. attackmap/taint_sinks.py +431 -0
  76. attackmap/taint_sinks.yaml +308 -0
  77. attackmap/taint_sources.py +396 -0
  78. attackmap/taxonomy.py +465 -0
  79. attackmap/threat_model.py +3441 -0
  80. attackmap/topology.py +316 -0
  81. attackmap/triage.py +151 -0
  82. attackmap/weaknesses.py +365 -0
  83. attackmap/webhardening.py +479 -0
  84. attackmap/webhook_verification.py +1067 -0
  85. attackmap/workflow_scanner.py +964 -0
  86. attackmap-0.6.4.dist-info/METADATA +226 -0
  87. attackmap-0.6.4.dist-info/RECORD +91 -0
  88. attackmap-0.6.4.dist-info/WHEEL +5 -0
  89. attackmap-0.6.4.dist-info/entry_points.txt +2 -0
  90. attackmap-0.6.4.dist-info/licenses/LICENSE +21 -0
  91. 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 []