pipewatch-cli 0.3.0__tar.gz → 0.5.0__tar.gz

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.
@@ -1,4 +1,4 @@
1
- Copyright (c) 2026 David Obi
1
+
2
2
 
3
3
  pipewatch-cli is licensed under the Creative Commons Attribution 4.0
4
4
  International License. To view a copy of this license, see below or
@@ -401,3 +401,5 @@ the avoidance of doubt, this paragraph does not form part of the
401
401
  public licenses.
402
402
 
403
403
  Creative Commons may be contacted at creativecommons.org.
404
+
405
+ Copyright (c) 2026 David Obi
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pipewatch-cli
3
- Version: 0.3.0
3
+ Version: 0.5.0
4
4
  Summary: Integrity monitoring and static analysis for CI/CD pipelines
5
5
  License-Expression: CC-BY-4.0
6
6
  Requires-Python: >=3.12
@@ -13,9 +13,9 @@ Dynamic: license-file
13
13
 
14
14
  **Integrity monitoring and static analysis for software supply chains.**
15
15
 
16
- CI/CD pipelines are high-value targets: they run with elevated permissions, pull from external registries, and execute arbitrary code on every commit. A single injected step, mutable action pin, or misconfigured trigger is enough to exfiltrate secrets, backdoor build artifacts, or pivot into production infrastructure and the attack surface is almost always left unmonitored.
16
+ CI/CD pipelines are high-value targets: they run with elevated permissions, pull from external registries, and execute arbitrary code on every commit. A single injected step, mutable action pin, or misconfigured trigger is enough to exfiltrate secrets, backdoor build artifacts, or pivot into production infrastructure -- and the attack surface is almost always left unmonitored.
17
17
 
18
- `pipewatch` audits GitHub Actions workflows, GitLab CI, and Jenkinsfiles for exactly these risks. It combines commit-anchored baseline diffing, per-step SHA-256 fingerprinting, static misconfiguration analysis, and runner environment tracking into a single CLI tool. All commands exit non-zero on findings and emit structured JSON suitable as a pipeline gate, a pre-merge check, or a periodic audit job.
18
+ `pipewatch` audits GitHub Actions workflows, GitLab CI, and Jenkinsfiles for exactly these risks. It combines commit-anchored baseline diffing, per-step SHA-256 fingerprinting, static misconfiguration analysis, and runner environment tracking into a single CLI tool. All commands exit non-zero on findings and emit structured JSON -- suitable as a pipeline gate, a pre-merge check, or a periodic audit job.
19
19
 
20
20
  ---
21
21
 
@@ -25,16 +25,16 @@ CI/CD pipelines are high-value targets: they run with elevated permissions, pull
25
25
  |---|---|
26
26
  | **Baseline diff** | Pipeline files modified, added, or deleted since a known-good commit |
27
27
  | **Step fingerprinting** | Individual steps injected, removed, or silently modified between commits |
28
- | **`pull_request_target` misuse** | Workflows that check out and execute PR head code with base-branch write permissions the pattern behind numerous CI poisoning attacks |
29
- | **Script injection** | `run:` blocks and `env:` blocks that route user-controlled context values (`github.event.pull_request.title`, `github.event.comment.body`, etc.) into shell commands both direct interpolation and the indirect `env: VAR: ${{ ctx }}` → `$VAR` pattern are detected |
30
- | **Permission misconfiguration** | Missing `permissions:` blocks, `write-all` scopes, `secrets: inherit` |
31
- | **Mutable action pins** | `uses:` references not locked to a full commit SHA tags like `v3` can be silently overwritten by a supply-chain attacker |
28
+ | **pull_request_target misuse** | Workflows that check out and execute PR head code with base-branch write permissions -- the pattern behind numerous CI poisoning attacks |
29
+ | **Script injection** | run: blocks and env: blocks that route user-controlled context values into shell commands -- both direct interpolation and the indirect env: VAR -> $VAR pattern are detected |
30
+ | **Permission misconfiguration** | Missing permissions: blocks, write-all scopes, secrets: inherit |
31
+ | **Mutable action pins** | uses: references not locked to a full commit SHA -- tags like v3 can be silently overwritten by a supply-chain attacker |
32
32
  | **Invalid pinned SHAs** | Pinned SHAs that do not exist in the upstream action repo (typos, deleted commits, fork SHAs) |
33
- | **Unsafe `workflow_run` chains** | Write-permissioned workflows triggered indirectly by external pull requests or issues, creating a privilege escalation path |
33
+ | **Unsafe workflow_run chains** | Write-permissioned workflows triggered indirectly by external pull requests or issues, creating a privilege escalation path |
34
34
  | **Self-hosted runners** | Non-ephemeral runners that persist state between runs and are not managed by GitHub |
35
35
  | **Runner environment drift** | Env var and tool version changes between CI runs that could indicate a compromised or modified runner |
36
36
 
37
- Findings are severity-ranked (CRITICAL HIGH MEDIUM LOW INFO) and always include a location, description, and evidence field.
37
+ Findings are severity-ranked (CRITICAL -> HIGH -> MEDIUM -> LOW -> INFO) and always include a location, description, and evidence field.
38
38
 
39
39
  ---
40
40
 
@@ -43,26 +43,30 @@ Findings are severity-ranked (CRITICAL → HIGH → MEDIUM → LOW → INFO) and
43
43
  Requires Python 3.12+. `pyyaml` is the only external dependency.
44
44
 
45
45
  **Via pip (recommended):**
46
- ```bash
47
- pip install pipewatch
46
+
47
+ ```
48
+ pip install pipewatch-cli
48
49
  ```
49
50
 
50
51
  **Directly from GitHub (no clone required):**
51
- ```bash
52
+
53
+ ```
52
54
  pip install git+https://github.com/TheVoidThatConsumes/pipewatch.git
53
55
  ```
54
56
 
55
- **Copy into your project** if you prefer to keep pipewatch self-contained inside a repo you're monitoring, download `pipewatch.py` and drop it anywhere in that repo. Install `pyyaml` manually:
56
- ```bash
57
+ **Copy into your project** -- download `pipewatch.py` and drop it anywhere in the repo you want to monitor. Install `pyyaml` manually:
58
+
59
+ ```
57
60
  pip install pyyaml
58
61
  ```
62
+
59
63
  Then run it as `py pipewatch.py <command>` instead of `pipewatch <command>`.
60
64
 
61
65
  ---
62
66
 
63
67
  ## Quick Start
64
68
 
65
- ```bash
69
+ ```
66
70
  # Record the current HEAD as known-good
67
71
  pipewatch baseline
68
72
 
@@ -78,18 +82,20 @@ pipewatch audit --verify-shas
78
82
  ## Commands
79
83
 
80
84
  ### `baseline`
81
- Records the current (or a specified) commit as the integrity reference point. Set `PIPEWATCH_HMAC_KEY` to store a signed baseline — if the file is modified to suppress future findings, the HMAC check will fail and the tool will refuse to proceed.
82
85
 
83
- ```bash
86
+ Records the current (or a specified) commit as the integrity reference point. Set `PIPEWATCH_HMAC_KEY` to store a signed baseline -- if the file is modified to suppress future findings, the HMAC check will fail and the tool will refuse to proceed.
87
+
88
+ ```
84
89
  pipewatch baseline [--repo PATH] [--commit SHA]
85
90
  ```
86
91
 
87
92
  ---
88
93
 
89
94
  ### `scan`
90
- Diffs pipeline files and per-step fingerprints against the baseline commit. Detects both file-level changes (unified diff) and step-level tampering — a step whose `uses`, `run`, `with`, `env`, or `shell` fields change gets flagged even if the surrounding file diff looks innocuous.
91
95
 
92
- ```bash
96
+ Diffs pipeline files and per-step fingerprints against the baseline commit. Detects both file-level changes (unified diff) and step-level tampering -- a step whose `uses`, `run`, `with`, `env`, or `shell` fields change gets flagged even if the surrounding file diff looks innocuous.
97
+
98
+ ```
93
99
  pipewatch scan [--repo PATH] [--baseline SHA] [--verbose] [--json]
94
100
  ```
95
101
 
@@ -98,37 +104,40 @@ Covers GitHub Actions, GitLab CI, and Jenkinsfile stages.
98
104
  ---
99
105
 
100
106
  ### `static`
107
+
101
108
  Static analysis with no baseline required. Audits all GitHub Actions workflow files in `.github/workflows/` for structural misconfigurations.
102
109
 
103
- ```bash
110
+ ```
104
111
  pipewatch static [--repo PATH] [--verbose] [--json]
105
112
  ```
106
113
 
107
- Checks: `pull_request_target` misuse · script injection · missing/overpermissioned `permissions:` blocks · `secrets: inherit` · self-hosted runners · unsafe `workflow_run` trigger chains.
114
+ Checks: `pull_request_target` misuse, script injection, missing or overpermissioned `permissions:` blocks, `secrets: inherit`, self-hosted runners, unsafe `workflow_run` trigger chains.
108
115
 
109
116
  ---
110
117
 
111
118
  ### `pin-audit`
119
+
112
120
  Flags `uses:` references not pinned to a full 40- or 64-character commit SHA. With `--verify-shas`, makes live GitHub API calls to confirm each pinned SHA actually exists in its upstream repository.
113
121
 
114
- ```bash
122
+ ```
115
123
  pipewatch pin-audit [--repo PATH] [--verify-shas] [--token TOKEN] [--verbose] [--json]
116
124
  ```
117
125
 
118
- Rate limit: 60 requests/hr unauthenticated, 5 000/hr with `--token` or `GITHUB_TOKEN`. API calls are deduplicated the same `owner/repo@sha` is only verified once regardless of how many steps reference it. Findings are **not** deduplicated: if the same invalid SHA appears across multiple workflow files or jobs, each affected step produces its own finding so the full scope of a compromised action is visible in the report.
126
+ Rate limit: 60 requests/hr unauthenticated, 5000/hr with `--token` or `GITHUB_TOKEN`. API calls are deduplicated -- the same `owner/repo@sha` is only verified once. Findings are **not** deduplicated: if the same invalid SHA appears across multiple files or jobs, each affected step produces its own finding so the full scope of a compromised action is visible.
119
127
 
120
128
  ---
121
129
 
122
130
  ### `snapshot`
123
- Captures the runner environment — environment variables and tool versions — to a JSON file for later comparison.
124
131
 
125
- ```bash
132
+ Captures the runner environment -- environment variables and tool versions -- to a JSON file for later comparison.
133
+
134
+ ```
126
135
  pipewatch snapshot [--output FILE]
127
136
  ```
128
137
 
129
138
  Tracked tools: `python3`, `python`, `node`, `npm`, `pip`, `pip3`, `git`, `curl`, `wget`, `docker`, `kubectl`, `terraform`, `aws`, `gcloud`, `az`. Volatile per-run variables (`GITHUB_RUN_ID`, `RUNNER_TEMP`, etc.) are excluded to prevent noise on every comparison.
130
139
 
131
- **Credential exclusion** environment variables whose names contain `token`, `secret`, `key`, `password`, `passwd`, `pwd`, `auth`, `credential`, `private`, or `api_key` (case-insensitive) are never written to the snapshot file. This prevents secrets such as `GITHUB_TOKEN` or `PIPEWATCH_HMAC_KEY` from being persisted to disk or uploaded to the Actions cache. Note that a legitimately non-sensitive variable with one of these terms in its name will also be excluded; rename it if you need `env-diff` to track it.
140
+ **Credential exclusion** -- environment variables whose names contain `token`, `secret`, `key`, `password`, `passwd`, `pwd`, `auth`, `credential`, `private`, or `api_key` (case-insensitive) are never written to the snapshot file. This prevents secrets such as `GITHUB_TOKEN` or `PIPEWATCH_HMAC_KEY` from being persisted to disk or uploaded to the Actions cache.
132
141
 
133
142
  Add the snapshot file to `.gitignore` to prevent it from being accidentally committed:
134
143
 
@@ -139,27 +148,30 @@ Add the snapshot file to `.gitignore` to prevent it from being accidentally comm
139
148
  ---
140
149
 
141
150
  ### `env-diff`
142
- Diffs two runner environment snapshots. Detects new, removed, or changed environment variables and tool versions — useful for identifying runner poisoning or unexpected environment mutations between runs. If `--current-snapshot` is omitted, captures the live environment.
143
151
 
144
- ```bash
152
+ Diffs two runner environment snapshots. Detects new, removed, or changed environment variables and tool versions -- useful for identifying runner poisoning or unexpected environment mutations between runs. If `--current-snapshot` is omitted, captures the live environment.
153
+
154
+ ```
145
155
  pipewatch env-diff BASELINE_SNAPSHOT [--current-snapshot FILE] [--repo PATH] [--verbose] [--json]
146
156
  ```
147
157
 
148
158
  ---
149
159
 
150
160
  ### `audit`
161
+
151
162
  Full audit in one pass: `scan` + `pin-audit` + `static`. Exits non-zero if any findings exist.
152
163
 
153
- ```bash
164
+ ```
154
165
  pipewatch audit [--repo PATH] [--baseline SHA] [--verify-shas] [--token TOKEN] [--verbose] [--json]
155
166
  ```
156
167
 
157
168
  ---
158
169
 
159
170
  ### `init-runner`
171
+
160
172
  Prints a ready-to-paste GitHub Actions step block that adds runner environment monitoring to any existing job.
161
173
 
162
- ```bash
174
+ ```
163
175
  pipewatch init-runner [--snapshot-path PATH]
164
176
  ```
165
177
 
@@ -171,10 +183,10 @@ pipewatch init-runner [--snapshot-path PATH]
171
183
  |---|---|---|
172
184
  | `PW-DIFF-NNN` | Pipeline file added / modified / deleted | HIGH / MEDIUM |
173
185
  | `PW-STEP-NNN` | Step fingerprint changed / injected / removed | HIGH / MEDIUM |
174
- | `PW-PRT-NNN` | `pull_request_target` misuse | HIGH |
186
+ | `PW-PRT-NNN` | pull_request_target misuse | HIGH |
175
187
  | `PW-INJ-NNN` | Script injection risk | HIGH |
176
188
  | `PW-PERM-NNN` | Permission misconfiguration | HIGH / MEDIUM |
177
- | `PW-CHAIN-NNN` | Unsafe `workflow_run` chain | HIGH |
189
+ | `PW-CHAIN-NNN` | Unsafe workflow_run chain | HIGH |
178
190
  | `PW-PIN-NNN` | Unpinned action reference | MEDIUM |
179
191
  | `PW-SHA-NNN` | Pinned SHA not found in upstream repo | HIGH |
180
192
  | `PW-RUNNER-NNN` | Self-hosted runner | INFO |
@@ -186,7 +198,7 @@ pipewatch init-runner [--snapshot-path PATH]
186
198
 
187
199
  All commands accept `--json`:
188
200
 
189
- ```json
201
+ ```
190
202
  {
191
203
  "tool": "pipewatch",
192
204
  "version": "0.2.0",
@@ -210,7 +222,7 @@ All commands accept `--json`:
210
222
 
211
223
  Gate on exit code, or filter with `jq`:
212
224
 
213
- ```bash
225
+ ```
214
226
  # fail the build on any HIGH or CRITICAL finding
215
227
  pipewatch audit --json | jq -e '.summary.HIGH == 0 and .summary.CRITICAL == 0'
216
228
 
@@ -224,7 +236,7 @@ pipewatch audit --json | jq -r '.findings[].title'
224
236
 
225
237
  Set `PIPEWATCH_HMAC_KEY` before running `baseline`. The baseline is stored as a signed JSON object:
226
238
 
227
- ```json
239
+ ```
228
240
  {
229
241
  "commit": "abc123...",
230
242
  "timestamp": "2025-01-01T00:00:00+00:00",
@@ -234,24 +246,24 @@ Set `PIPEWATCH_HMAC_KEY` before running `baseline`. The baseline is stored as a
234
246
 
235
247
  The HMAC is computed over both the commit SHA **and** the timestamp, bound together. This protects against two distinct attacks:
236
248
 
237
- - **Tampering** an attacker who modifies the `commit` field to point to an older known-good commit cannot produce a valid HMAC without the key.
238
- - **Replay** an attacker who reverts the baseline file to a previously-valid signed snapshot (to suppress findings introduced after that point) is also blocked, because the timestamp is part of the signed payload and the stored timestamp would no longer match the signature of any rewritten content.
249
+ - **Tampering** -- an attacker who modifies the `commit` field cannot produce a valid HMAC without the key.
250
+ - **Replay** -- an attacker who reverts the baseline file to a previously-valid signed snapshot is also blocked, because the timestamp is part of the signed payload.
239
251
 
240
252
  On `scan` or `audit`, the HMAC is verified before the baseline commit is trusted. Any verification failure causes an immediate exit with an error.
241
253
 
242
- **Format-downgrade protection** if `PIPEWATCH_HMAC_KEY` is set and the baseline file is in plain-text format (a raw commit SHA rather than signed JSON), the tool refuses to proceed. An attacker cannot bypass signing by overwriting the file with an unsigned commit SHA. If you set `PIPEWATCH_HMAC_KEY` for the first time on a repo that already has an unsigned baseline, re-run `pipewatch baseline` to generate a signed one before your next scan.
254
+ **Format-downgrade protection** -- if `PIPEWATCH_HMAC_KEY` is set and the baseline file is in plain-text format, the tool refuses to proceed. An attacker cannot bypass signing by overwriting the file with an unsigned commit SHA. If you set `PIPEWATCH_HMAC_KEY` for the first time on a repo that already has an unsigned baseline, re-run `pipewatch baseline` to generate a signed one before your next scan.
243
255
 
244
- Store the key in a CI secret (`PIPEWATCH_HMAC_KEY: ${{ secrets.PIPEWATCH_HMAC_KEY }}`).
256
+ Store the key in a CI secret.
245
257
 
246
258
  ---
247
259
 
248
260
  ## Pipeline Integration
249
261
 
250
- ```yaml
262
+ ```
251
263
  - name: pipewatch audit
252
264
  env:
253
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
254
- PIPEWATCH_HMAC_KEY: ${{ secrets.PIPEWATCH_HMAC_KEY }}
265
+ GITHUB_TOKEN: (your GITHUB_TOKEN secret)
266
+ PIPEWATCH_HMAC_KEY: (your PIPEWATCH_HMAC_KEY secret)
255
267
  run: |
256
268
  pipewatch audit --verify-shas --json | tee pw-report.json
257
269
  jq -e '.summary.HIGH == 0 and .summary.CRITICAL == 0' pw-report.json
@@ -263,13 +275,13 @@ Use `init-runner` to add runner environment monitoring alongside the audit step.
263
275
 
264
276
  ## Design Decisions
265
277
 
266
- **HMAC-signed baselines** tamper-evident storage for the known-good commit reference. The HMAC covers both the commit SHA and the timestamp, so an attacker who can write to the repo but wants to suppress findings would need to forge the HMAC not just overwrite the file or revert it to an older signed state. Attempting to downgrade a signed baseline to unsigned plain text is also refused when `PIPEWATCH_HMAC_KEY` is set.
278
+ **HMAC-signed baselines** -- tamper-evident storage for the known-good commit reference. The HMAC covers both the commit SHA and the timestamp, so an attacker who can write to the repo but wants to suppress findings would need to forge the HMAC -- not just overwrite the file or revert it to an older signed state. Attempting to downgrade a signed baseline to unsigned plain text is also refused when `PIPEWATCH_HMAC_KEY` is set.
267
279
 
268
- **Volatile variable exclusion** `snapshot` strips run-specific GitHub variables so that `env-diff` doesn't generate noise on every comparison. Only structurally stable variables that could indicate environment manipulation are tracked. Variables whose names suggest credentials (containing `token`, `secret`, `key`, `password`, etc.) are also excluded so secrets are never written to the snapshot file or the Actions cache.
280
+ **Volatile variable exclusion** -- `snapshot` strips run-specific GitHub variables so that `env-diff` does not generate noise on every comparison. Only structurally stable variables that could indicate environment manipulation are tracked. Variables whose names suggest credentials are also excluded so secrets are never written to the snapshot file or the Actions cache.
269
281
 
270
- **Conservative SHA verification** `--verify-shas` treats network failures as non-findings. A flaky connection or GitHub API outage should not produce spurious HIGH alerts.
282
+ **Conservative SHA verification** -- `--verify-shas` treats network failures as non-findings. A flaky connection or GitHub API outage should not produce spurious HIGH alerts.
271
283
 
272
- **Stdlib-first** `pyyaml` is the only dependency. The tool audits supply-chain risk; its own footprint is minimal by design.
284
+ **Stdlib-first** -- `pyyaml` is the only dependency. The tool audits supply-chain risk; its own footprint is minimal by design.
273
285
 
274
286
  ---
275
287
 
@@ -277,12 +289,12 @@ Use `init-runner` to add runner environment monitoring alongside the audit step.
277
289
 
278
290
  | Platform | Coverage |
279
291
  |---|---|
280
- | GitHub Actions (`.github/workflows/*.yml`) | Full file diff, step fingerprinting, static analysis, pin audit |
281
- | GitLab CI (`.gitlab-ci.yml`) | File diff + `before_script` / `script` / `after_script` fingerprinting |
282
- | Jenkins (`Jenkinsfile`) | File diff + best-effort stage / `sh` / `bat` fingerprinting (regex-based not a Groovy parser) |
292
+ | GitHub Actions (.github/workflows/*.yml) | Full -- file diff, step fingerprinting, static analysis, pin audit. Step evidence includes uses, run first line, or step name. |
293
+ | GitLab CI (.gitlab-ci.yml) | File diff + before_script / script / after_script fingerprinting. Step evidence shows the first command in the changed block. |
294
+ | Jenkins (Jenkinsfile) | File diff + best-effort stage / sh / bat fingerprinting (regex-based, not a Groovy parser). Step evidence shows the first sh/bat command where extractable; falls back to stage name. |
283
295
 
284
296
  ---
285
297
 
286
298
  ## License
287
299
 
288
- GPLv2. See [LICENSE](LICENSE).
300
+ Copyright (c) 2026 David Obi. Licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). See [LICENSE](LICENSE).
@@ -2,9 +2,9 @@
2
2
 
3
3
  **Integrity monitoring and static analysis for software supply chains.**
4
4
 
5
- CI/CD pipelines are high-value targets: they run with elevated permissions, pull from external registries, and execute arbitrary code on every commit. A single injected step, mutable action pin, or misconfigured trigger is enough to exfiltrate secrets, backdoor build artifacts, or pivot into production infrastructure and the attack surface is almost always left unmonitored.
5
+ CI/CD pipelines are high-value targets: they run with elevated permissions, pull from external registries, and execute arbitrary code on every commit. A single injected step, mutable action pin, or misconfigured trigger is enough to exfiltrate secrets, backdoor build artifacts, or pivot into production infrastructure -- and the attack surface is almost always left unmonitored.
6
6
 
7
- `pipewatch` audits GitHub Actions workflows, GitLab CI, and Jenkinsfiles for exactly these risks. It combines commit-anchored baseline diffing, per-step SHA-256 fingerprinting, static misconfiguration analysis, and runner environment tracking into a single CLI tool. All commands exit non-zero on findings and emit structured JSON suitable as a pipeline gate, a pre-merge check, or a periodic audit job.
7
+ `pipewatch` audits GitHub Actions workflows, GitLab CI, and Jenkinsfiles for exactly these risks. It combines commit-anchored baseline diffing, per-step SHA-256 fingerprinting, static misconfiguration analysis, and runner environment tracking into a single CLI tool. All commands exit non-zero on findings and emit structured JSON -- suitable as a pipeline gate, a pre-merge check, or a periodic audit job.
8
8
 
9
9
  ---
10
10
 
@@ -14,16 +14,16 @@ CI/CD pipelines are high-value targets: they run with elevated permissions, pull
14
14
  |---|---|
15
15
  | **Baseline diff** | Pipeline files modified, added, or deleted since a known-good commit |
16
16
  | **Step fingerprinting** | Individual steps injected, removed, or silently modified between commits |
17
- | **`pull_request_target` misuse** | Workflows that check out and execute PR head code with base-branch write permissions the pattern behind numerous CI poisoning attacks |
18
- | **Script injection** | `run:` blocks and `env:` blocks that route user-controlled context values (`github.event.pull_request.title`, `github.event.comment.body`, etc.) into shell commands both direct interpolation and the indirect `env: VAR: ${{ ctx }}` → `$VAR` pattern are detected |
19
- | **Permission misconfiguration** | Missing `permissions:` blocks, `write-all` scopes, `secrets: inherit` |
20
- | **Mutable action pins** | `uses:` references not locked to a full commit SHA tags like `v3` can be silently overwritten by a supply-chain attacker |
17
+ | **pull_request_target misuse** | Workflows that check out and execute PR head code with base-branch write permissions -- the pattern behind numerous CI poisoning attacks |
18
+ | **Script injection** | run: blocks and env: blocks that route user-controlled context values into shell commands -- both direct interpolation and the indirect env: VAR -> $VAR pattern are detected |
19
+ | **Permission misconfiguration** | Missing permissions: blocks, write-all scopes, secrets: inherit |
20
+ | **Mutable action pins** | uses: references not locked to a full commit SHA -- tags like v3 can be silently overwritten by a supply-chain attacker |
21
21
  | **Invalid pinned SHAs** | Pinned SHAs that do not exist in the upstream action repo (typos, deleted commits, fork SHAs) |
22
- | **Unsafe `workflow_run` chains** | Write-permissioned workflows triggered indirectly by external pull requests or issues, creating a privilege escalation path |
22
+ | **Unsafe workflow_run chains** | Write-permissioned workflows triggered indirectly by external pull requests or issues, creating a privilege escalation path |
23
23
  | **Self-hosted runners** | Non-ephemeral runners that persist state between runs and are not managed by GitHub |
24
24
  | **Runner environment drift** | Env var and tool version changes between CI runs that could indicate a compromised or modified runner |
25
25
 
26
- Findings are severity-ranked (CRITICAL HIGH MEDIUM LOW INFO) and always include a location, description, and evidence field.
26
+ Findings are severity-ranked (CRITICAL -> HIGH -> MEDIUM -> LOW -> INFO) and always include a location, description, and evidence field.
27
27
 
28
28
  ---
29
29
 
@@ -32,26 +32,30 @@ Findings are severity-ranked (CRITICAL → HIGH → MEDIUM → LOW → INFO) and
32
32
  Requires Python 3.12+. `pyyaml` is the only external dependency.
33
33
 
34
34
  **Via pip (recommended):**
35
- ```bash
36
- pip install pipewatch
35
+
36
+ ```
37
+ pip install pipewatch-cli
37
38
  ```
38
39
 
39
40
  **Directly from GitHub (no clone required):**
40
- ```bash
41
+
42
+ ```
41
43
  pip install git+https://github.com/TheVoidThatConsumes/pipewatch.git
42
44
  ```
43
45
 
44
- **Copy into your project** if you prefer to keep pipewatch self-contained inside a repo you're monitoring, download `pipewatch.py` and drop it anywhere in that repo. Install `pyyaml` manually:
45
- ```bash
46
+ **Copy into your project** -- download `pipewatch.py` and drop it anywhere in the repo you want to monitor. Install `pyyaml` manually:
47
+
48
+ ```
46
49
  pip install pyyaml
47
50
  ```
51
+
48
52
  Then run it as `py pipewatch.py <command>` instead of `pipewatch <command>`.
49
53
 
50
54
  ---
51
55
 
52
56
  ## Quick Start
53
57
 
54
- ```bash
58
+ ```
55
59
  # Record the current HEAD as known-good
56
60
  pipewatch baseline
57
61
 
@@ -67,18 +71,20 @@ pipewatch audit --verify-shas
67
71
  ## Commands
68
72
 
69
73
  ### `baseline`
70
- Records the current (or a specified) commit as the integrity reference point. Set `PIPEWATCH_HMAC_KEY` to store a signed baseline — if the file is modified to suppress future findings, the HMAC check will fail and the tool will refuse to proceed.
71
74
 
72
- ```bash
75
+ Records the current (or a specified) commit as the integrity reference point. Set `PIPEWATCH_HMAC_KEY` to store a signed baseline -- if the file is modified to suppress future findings, the HMAC check will fail and the tool will refuse to proceed.
76
+
77
+ ```
73
78
  pipewatch baseline [--repo PATH] [--commit SHA]
74
79
  ```
75
80
 
76
81
  ---
77
82
 
78
83
  ### `scan`
79
- Diffs pipeline files and per-step fingerprints against the baseline commit. Detects both file-level changes (unified diff) and step-level tampering — a step whose `uses`, `run`, `with`, `env`, or `shell` fields change gets flagged even if the surrounding file diff looks innocuous.
80
84
 
81
- ```bash
85
+ Diffs pipeline files and per-step fingerprints against the baseline commit. Detects both file-level changes (unified diff) and step-level tampering -- a step whose `uses`, `run`, `with`, `env`, or `shell` fields change gets flagged even if the surrounding file diff looks innocuous.
86
+
87
+ ```
82
88
  pipewatch scan [--repo PATH] [--baseline SHA] [--verbose] [--json]
83
89
  ```
84
90
 
@@ -87,37 +93,40 @@ Covers GitHub Actions, GitLab CI, and Jenkinsfile stages.
87
93
  ---
88
94
 
89
95
  ### `static`
96
+
90
97
  Static analysis with no baseline required. Audits all GitHub Actions workflow files in `.github/workflows/` for structural misconfigurations.
91
98
 
92
- ```bash
99
+ ```
93
100
  pipewatch static [--repo PATH] [--verbose] [--json]
94
101
  ```
95
102
 
96
- Checks: `pull_request_target` misuse · script injection · missing/overpermissioned `permissions:` blocks · `secrets: inherit` · self-hosted runners · unsafe `workflow_run` trigger chains.
103
+ Checks: `pull_request_target` misuse, script injection, missing or overpermissioned `permissions:` blocks, `secrets: inherit`, self-hosted runners, unsafe `workflow_run` trigger chains.
97
104
 
98
105
  ---
99
106
 
100
107
  ### `pin-audit`
108
+
101
109
  Flags `uses:` references not pinned to a full 40- or 64-character commit SHA. With `--verify-shas`, makes live GitHub API calls to confirm each pinned SHA actually exists in its upstream repository.
102
110
 
103
- ```bash
111
+ ```
104
112
  pipewatch pin-audit [--repo PATH] [--verify-shas] [--token TOKEN] [--verbose] [--json]
105
113
  ```
106
114
 
107
- Rate limit: 60 requests/hr unauthenticated, 5 000/hr with `--token` or `GITHUB_TOKEN`. API calls are deduplicated the same `owner/repo@sha` is only verified once regardless of how many steps reference it. Findings are **not** deduplicated: if the same invalid SHA appears across multiple workflow files or jobs, each affected step produces its own finding so the full scope of a compromised action is visible in the report.
115
+ Rate limit: 60 requests/hr unauthenticated, 5000/hr with `--token` or `GITHUB_TOKEN`. API calls are deduplicated -- the same `owner/repo@sha` is only verified once. Findings are **not** deduplicated: if the same invalid SHA appears across multiple files or jobs, each affected step produces its own finding so the full scope of a compromised action is visible.
108
116
 
109
117
  ---
110
118
 
111
119
  ### `snapshot`
112
- Captures the runner environment — environment variables and tool versions — to a JSON file for later comparison.
113
120
 
114
- ```bash
121
+ Captures the runner environment -- environment variables and tool versions -- to a JSON file for later comparison.
122
+
123
+ ```
115
124
  pipewatch snapshot [--output FILE]
116
125
  ```
117
126
 
118
127
  Tracked tools: `python3`, `python`, `node`, `npm`, `pip`, `pip3`, `git`, `curl`, `wget`, `docker`, `kubectl`, `terraform`, `aws`, `gcloud`, `az`. Volatile per-run variables (`GITHUB_RUN_ID`, `RUNNER_TEMP`, etc.) are excluded to prevent noise on every comparison.
119
128
 
120
- **Credential exclusion** environment variables whose names contain `token`, `secret`, `key`, `password`, `passwd`, `pwd`, `auth`, `credential`, `private`, or `api_key` (case-insensitive) are never written to the snapshot file. This prevents secrets such as `GITHUB_TOKEN` or `PIPEWATCH_HMAC_KEY` from being persisted to disk or uploaded to the Actions cache. Note that a legitimately non-sensitive variable with one of these terms in its name will also be excluded; rename it if you need `env-diff` to track it.
129
+ **Credential exclusion** -- environment variables whose names contain `token`, `secret`, `key`, `password`, `passwd`, `pwd`, `auth`, `credential`, `private`, or `api_key` (case-insensitive) are never written to the snapshot file. This prevents secrets such as `GITHUB_TOKEN` or `PIPEWATCH_HMAC_KEY` from being persisted to disk or uploaded to the Actions cache.
121
130
 
122
131
  Add the snapshot file to `.gitignore` to prevent it from being accidentally committed:
123
132
 
@@ -128,27 +137,30 @@ Add the snapshot file to `.gitignore` to prevent it from being accidentally comm
128
137
  ---
129
138
 
130
139
  ### `env-diff`
131
- Diffs two runner environment snapshots. Detects new, removed, or changed environment variables and tool versions — useful for identifying runner poisoning or unexpected environment mutations between runs. If `--current-snapshot` is omitted, captures the live environment.
132
140
 
133
- ```bash
141
+ Diffs two runner environment snapshots. Detects new, removed, or changed environment variables and tool versions -- useful for identifying runner poisoning or unexpected environment mutations between runs. If `--current-snapshot` is omitted, captures the live environment.
142
+
143
+ ```
134
144
  pipewatch env-diff BASELINE_SNAPSHOT [--current-snapshot FILE] [--repo PATH] [--verbose] [--json]
135
145
  ```
136
146
 
137
147
  ---
138
148
 
139
149
  ### `audit`
150
+
140
151
  Full audit in one pass: `scan` + `pin-audit` + `static`. Exits non-zero if any findings exist.
141
152
 
142
- ```bash
153
+ ```
143
154
  pipewatch audit [--repo PATH] [--baseline SHA] [--verify-shas] [--token TOKEN] [--verbose] [--json]
144
155
  ```
145
156
 
146
157
  ---
147
158
 
148
159
  ### `init-runner`
160
+
149
161
  Prints a ready-to-paste GitHub Actions step block that adds runner environment monitoring to any existing job.
150
162
 
151
- ```bash
163
+ ```
152
164
  pipewatch init-runner [--snapshot-path PATH]
153
165
  ```
154
166
 
@@ -160,10 +172,10 @@ pipewatch init-runner [--snapshot-path PATH]
160
172
  |---|---|---|
161
173
  | `PW-DIFF-NNN` | Pipeline file added / modified / deleted | HIGH / MEDIUM |
162
174
  | `PW-STEP-NNN` | Step fingerprint changed / injected / removed | HIGH / MEDIUM |
163
- | `PW-PRT-NNN` | `pull_request_target` misuse | HIGH |
175
+ | `PW-PRT-NNN` | pull_request_target misuse | HIGH |
164
176
  | `PW-INJ-NNN` | Script injection risk | HIGH |
165
177
  | `PW-PERM-NNN` | Permission misconfiguration | HIGH / MEDIUM |
166
- | `PW-CHAIN-NNN` | Unsafe `workflow_run` chain | HIGH |
178
+ | `PW-CHAIN-NNN` | Unsafe workflow_run chain | HIGH |
167
179
  | `PW-PIN-NNN` | Unpinned action reference | MEDIUM |
168
180
  | `PW-SHA-NNN` | Pinned SHA not found in upstream repo | HIGH |
169
181
  | `PW-RUNNER-NNN` | Self-hosted runner | INFO |
@@ -175,7 +187,7 @@ pipewatch init-runner [--snapshot-path PATH]
175
187
 
176
188
  All commands accept `--json`:
177
189
 
178
- ```json
190
+ ```
179
191
  {
180
192
  "tool": "pipewatch",
181
193
  "version": "0.2.0",
@@ -199,7 +211,7 @@ All commands accept `--json`:
199
211
 
200
212
  Gate on exit code, or filter with `jq`:
201
213
 
202
- ```bash
214
+ ```
203
215
  # fail the build on any HIGH or CRITICAL finding
204
216
  pipewatch audit --json | jq -e '.summary.HIGH == 0 and .summary.CRITICAL == 0'
205
217
 
@@ -213,7 +225,7 @@ pipewatch audit --json | jq -r '.findings[].title'
213
225
 
214
226
  Set `PIPEWATCH_HMAC_KEY` before running `baseline`. The baseline is stored as a signed JSON object:
215
227
 
216
- ```json
228
+ ```
217
229
  {
218
230
  "commit": "abc123...",
219
231
  "timestamp": "2025-01-01T00:00:00+00:00",
@@ -223,24 +235,24 @@ Set `PIPEWATCH_HMAC_KEY` before running `baseline`. The baseline is stored as a
223
235
 
224
236
  The HMAC is computed over both the commit SHA **and** the timestamp, bound together. This protects against two distinct attacks:
225
237
 
226
- - **Tampering** an attacker who modifies the `commit` field to point to an older known-good commit cannot produce a valid HMAC without the key.
227
- - **Replay** an attacker who reverts the baseline file to a previously-valid signed snapshot (to suppress findings introduced after that point) is also blocked, because the timestamp is part of the signed payload and the stored timestamp would no longer match the signature of any rewritten content.
238
+ - **Tampering** -- an attacker who modifies the `commit` field cannot produce a valid HMAC without the key.
239
+ - **Replay** -- an attacker who reverts the baseline file to a previously-valid signed snapshot is also blocked, because the timestamp is part of the signed payload.
228
240
 
229
241
  On `scan` or `audit`, the HMAC is verified before the baseline commit is trusted. Any verification failure causes an immediate exit with an error.
230
242
 
231
- **Format-downgrade protection** if `PIPEWATCH_HMAC_KEY` is set and the baseline file is in plain-text format (a raw commit SHA rather than signed JSON), the tool refuses to proceed. An attacker cannot bypass signing by overwriting the file with an unsigned commit SHA. If you set `PIPEWATCH_HMAC_KEY` for the first time on a repo that already has an unsigned baseline, re-run `pipewatch baseline` to generate a signed one before your next scan.
243
+ **Format-downgrade protection** -- if `PIPEWATCH_HMAC_KEY` is set and the baseline file is in plain-text format, the tool refuses to proceed. An attacker cannot bypass signing by overwriting the file with an unsigned commit SHA. If you set `PIPEWATCH_HMAC_KEY` for the first time on a repo that already has an unsigned baseline, re-run `pipewatch baseline` to generate a signed one before your next scan.
232
244
 
233
- Store the key in a CI secret (`PIPEWATCH_HMAC_KEY: ${{ secrets.PIPEWATCH_HMAC_KEY }}`).
245
+ Store the key in a CI secret.
234
246
 
235
247
  ---
236
248
 
237
249
  ## Pipeline Integration
238
250
 
239
- ```yaml
251
+ ```
240
252
  - name: pipewatch audit
241
253
  env:
242
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
243
- PIPEWATCH_HMAC_KEY: ${{ secrets.PIPEWATCH_HMAC_KEY }}
254
+ GITHUB_TOKEN: (your GITHUB_TOKEN secret)
255
+ PIPEWATCH_HMAC_KEY: (your PIPEWATCH_HMAC_KEY secret)
244
256
  run: |
245
257
  pipewatch audit --verify-shas --json | tee pw-report.json
246
258
  jq -e '.summary.HIGH == 0 and .summary.CRITICAL == 0' pw-report.json
@@ -252,13 +264,13 @@ Use `init-runner` to add runner environment monitoring alongside the audit step.
252
264
 
253
265
  ## Design Decisions
254
266
 
255
- **HMAC-signed baselines** tamper-evident storage for the known-good commit reference. The HMAC covers both the commit SHA and the timestamp, so an attacker who can write to the repo but wants to suppress findings would need to forge the HMAC not just overwrite the file or revert it to an older signed state. Attempting to downgrade a signed baseline to unsigned plain text is also refused when `PIPEWATCH_HMAC_KEY` is set.
267
+ **HMAC-signed baselines** -- tamper-evident storage for the known-good commit reference. The HMAC covers both the commit SHA and the timestamp, so an attacker who can write to the repo but wants to suppress findings would need to forge the HMAC -- not just overwrite the file or revert it to an older signed state. Attempting to downgrade a signed baseline to unsigned plain text is also refused when `PIPEWATCH_HMAC_KEY` is set.
256
268
 
257
- **Volatile variable exclusion** `snapshot` strips run-specific GitHub variables so that `env-diff` doesn't generate noise on every comparison. Only structurally stable variables that could indicate environment manipulation are tracked. Variables whose names suggest credentials (containing `token`, `secret`, `key`, `password`, etc.) are also excluded so secrets are never written to the snapshot file or the Actions cache.
269
+ **Volatile variable exclusion** -- `snapshot` strips run-specific GitHub variables so that `env-diff` does not generate noise on every comparison. Only structurally stable variables that could indicate environment manipulation are tracked. Variables whose names suggest credentials are also excluded so secrets are never written to the snapshot file or the Actions cache.
258
270
 
259
- **Conservative SHA verification** `--verify-shas` treats network failures as non-findings. A flaky connection or GitHub API outage should not produce spurious HIGH alerts.
271
+ **Conservative SHA verification** -- `--verify-shas` treats network failures as non-findings. A flaky connection or GitHub API outage should not produce spurious HIGH alerts.
260
272
 
261
- **Stdlib-first** `pyyaml` is the only dependency. The tool audits supply-chain risk; its own footprint is minimal by design.
273
+ **Stdlib-first** -- `pyyaml` is the only dependency. The tool audits supply-chain risk; its own footprint is minimal by design.
262
274
 
263
275
  ---
264
276
 
@@ -266,12 +278,12 @@ Use `init-runner` to add runner environment monitoring alongside the audit step.
266
278
 
267
279
  | Platform | Coverage |
268
280
  |---|---|
269
- | GitHub Actions (`.github/workflows/*.yml`) | Full file diff, step fingerprinting, static analysis, pin audit |
270
- | GitLab CI (`.gitlab-ci.yml`) | File diff + `before_script` / `script` / `after_script` fingerprinting |
271
- | Jenkins (`Jenkinsfile`) | File diff + best-effort stage / `sh` / `bat` fingerprinting (regex-based not a Groovy parser) |
281
+ | GitHub Actions (.github/workflows/*.yml) | Full -- file diff, step fingerprinting, static analysis, pin audit. Step evidence includes uses, run first line, or step name. |
282
+ | GitLab CI (.gitlab-ci.yml) | File diff + before_script / script / after_script fingerprinting. Step evidence shows the first command in the changed block. |
283
+ | Jenkins (Jenkinsfile) | File diff + best-effort stage / sh / bat fingerprinting (regex-based, not a Groovy parser). Step evidence shows the first sh/bat command where extractable; falls back to stage name. |
272
284
 
273
285
  ---
274
286
 
275
287
  ## License
276
288
 
277
- GPLv2. See [LICENSE](LICENSE).
289
+ Copyright (c) 2026 David Obi. Licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). See [LICENSE](LICENSE).
@@ -253,6 +253,51 @@ class StepChange:
253
253
  baseline_fp: Optional[str]
254
254
  current_fp: Optional[str]
255
255
  status: str
256
+ step_preview: Optional[str] = None # human-readable hint: uses/run first line/name
257
+
258
+
259
+ def _preview_step(step: dict) -> str:
260
+ """Return a one-line description of a GitHub Actions step."""
261
+ if not isinstance(step, dict):
262
+ return ""
263
+ if "uses" in step:
264
+ return f"uses={step['uses']}"
265
+ if "run" in step:
266
+ first = next((ln.strip() for ln in str(step["run"]).splitlines() if ln.strip()), "")
267
+ return f"run={(first[:80] + '…') if len(first) > 80 else first}"
268
+ if "name" in step:
269
+ return f"name={step['name']}"
270
+ return ""
271
+
272
+
273
+ def _preview_gitlab_block(doc: dict, job: str, block_idx: int) -> str:
274
+ """Return first command from a GitLab before_script/script/after_script block."""
275
+ block_name = ("before_script", "script", "after_script")[block_idx]
276
+ job_def = doc.get(job, {})
277
+ if not isinstance(job_def, dict):
278
+ return ""
279
+ cmds = job_def.get(block_name, [])
280
+ if isinstance(cmds, list) and cmds:
281
+ first = str(cmds[0]).strip()
282
+ return f"{block_name}={(first[:80] + '…') if len(first) > 80 else first}"
283
+ return block_name
284
+
285
+
286
+ def _preview_jenkins_stage(content: str, stage_name: str) -> str:
287
+ """Return first sh/bat command from a Jenkinsfile stage (best-effort, regex-based)."""
288
+ stages = list(_JENKINS_STAGE.finditer(content))
289
+ for i, m in enumerate(stages):
290
+ if m.group(1) != stage_name:
291
+ continue
292
+ start = m.start()
293
+ end = stages[i + 1].start() if i + 1 < len(stages) else len(content)
294
+ sh_match = _JENKINS_SH.search(content[start:end])
295
+ if sh_match:
296
+ cmd = next((g for g in sh_match.groups() if g is not None), "").strip()
297
+ if cmd:
298
+ return f"sh={(cmd[:80] + '…') if len(cmd) > 80 else cmd}"
299
+ return f"stage={stage_name}"
300
+ return ""
256
301
 
257
302
 
258
303
  def _fps_for_file(fpath: Path, rel: str, content: str) -> dict:
@@ -271,15 +316,37 @@ def diff_fingerprints(repo: Path, baseline: str) -> list[StepChange]:
271
316
  base_content = git_show(repo, baseline, rel)
272
317
  cur = _fps_for_file(fpath, rel, current_content)
273
318
  bas = _fps_for_file(fpath, rel, base_content) if base_content else {}
319
+
320
+ is_jenkins = fpath.name == "Jenkinsfile"
321
+ is_gitlab = fpath.name in (".gitlab-ci.yml", ".gitlab-ci.yaml")
322
+
323
+ try:
324
+ cur_doc = {} if is_jenkins else (yaml.safe_load(current_content) or {})
325
+ except yaml.YAMLError:
326
+ cur_doc = {}
327
+
328
+ def _get_preview(job_name: str, step_idx: int) -> str:
329
+ if is_jenkins:
330
+ return _preview_jenkins_stage(current_content, job_name)
331
+ if is_gitlab:
332
+ return _preview_gitlab_block(cur_doc, job_name, step_idx)
333
+ steps = (cur_doc.get("jobs") or {}).get(job_name, {}).get("steps") or []
334
+ step = steps[step_idx] if isinstance(steps, list) and step_idx < len(steps) else {}
335
+ return _preview_step(step)
336
+
274
337
  for key in sorted(set(cur) | set(bas)):
275
338
  c, b = cur.get(key), bas.get(key)
339
+ job_name, step_idx = key
340
+ preview = _get_preview(job_name, step_idx)
276
341
  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"))
342
+ changes.append(StepChange(rel, job_name, step_idx, c[0] if c else None,
343
+ None, c[1] if c else None, "added", preview))
279
344
  elif c is None:
280
- changes.append(StepChange(rel, key[0], key[1], b[0], b[1], None, "removed"))
345
+ changes.append(StepChange(rel, job_name, step_idx, b[0], b[1], None,
346
+ "removed", preview))
281
347
  elif c[1] != b[1]:
282
- changes.append(StepChange(rel, key[0], key[1], c[0], b[1], c[1], "modified"))
348
+ changes.append(StepChange(rel, job_name, step_idx, c[0], b[1], c[1],
349
+ "modified", preview))
283
350
  return changes
284
351
 
285
352
 
@@ -857,32 +924,53 @@ def _findings_from_steps(changes: list[StepChange]) -> list[dict]:
857
924
  out = []
858
925
  for i, c in enumerate(changes, 1):
859
926
  loc = f"{c.path}::{c.job}::step[{c.step_index}]"
927
+ preview = f" {c.step_preview}" if c.step_preview else ""
860
928
  if c.status == "modified":
861
929
  out.append(_finding(f"PW-STEP-{i:03d}", "HIGH", "step_fingerprint",
862
930
  f"Step fingerprint changed: {loc}",
863
931
  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}"))
932
+ loc, f"baseline={c.baseline_fp} current={c.current_fp}{preview}"))
865
933
  elif c.status == "added":
866
934
  out.append(_finding(f"PW-STEP-{i:03d}", "HIGH", "step_fingerprint",
867
935
  f"New step injected: {loc}",
868
936
  f"Step {c.step_index} in '{c.job}' of '{c.path}' did not exist at baseline.",
869
- loc, f"fingerprint={c.current_fp}"))
937
+ loc, f"fingerprint={c.current_fp}{preview}"))
870
938
  else:
871
939
  out.append(_finding(f"PW-STEP-{i:03d}", "MEDIUM", "step_fingerprint",
872
940
  f"Step removed: {loc}",
873
941
  f"Step {c.step_index} in '{c.job}' of '{c.path}' was removed since baseline.",
874
- loc, f"baseline_fp={c.baseline_fp}"))
942
+ loc, f"baseline_fp={c.baseline_fp}{preview}"))
875
943
  return out
876
944
 
877
945
 
878
946
  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)]
947
+ """One finding per unique action reference, listing all affected files and step count."""
948
+ # Group by uses_ref so actions/checkout@v3 across 16 files → one finding.
949
+ grouped: dict[str, list[PinningFinding]] = {}
950
+ for p in pinning:
951
+ grouped.setdefault(p.uses_ref, []).append(p)
952
+
953
+ out = []
954
+ for i, (ref, instances) in enumerate(sorted(grouped.items()), 1):
955
+ affected_files = sorted(set(p.path for p in instances))
956
+ step_count = len(instances)
957
+ file_count = len(affected_files)
958
+ action = ref.split("@")[0] if "@" in ref else ref
959
+ tag = ref.split("@")[1] if "@" in ref else "unversioned"
960
+ file_list = ", ".join(affected_files[:5])
961
+ if file_count > 5:
962
+ file_list += f" … and {file_count - 5} more"
963
+ out.append(_finding(
964
+ f"PW-PIN-{i:03d}", "MEDIUM", "unpinned_dependency",
965
+ f"Unpinned action: {ref}",
966
+ f"'{ref}' is not pinned to a commit SHA — mutable tags can be silently "
967
+ "overwritten by a supply-chain attacker. "
968
+ f"Found in {step_count} step(s) across {file_count} file(s).",
969
+ file_list,
970
+ f"fix: uses: {action}@<sha> # {tag}\n"
971
+ f" https://github.com/{action}/commits",
972
+ ))
973
+ return out
886
974
 
887
975
 
888
976
  def _findings_from_env(diff: EnvDiff) -> list[dict]:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pipewatch-cli
3
- Version: 0.3.0
3
+ Version: 0.5.0
4
4
  Summary: Integrity monitoring and static analysis for CI/CD pipelines
5
5
  License-Expression: CC-BY-4.0
6
6
  Requires-Python: >=3.12
@@ -13,9 +13,9 @@ Dynamic: license-file
13
13
 
14
14
  **Integrity monitoring and static analysis for software supply chains.**
15
15
 
16
- CI/CD pipelines are high-value targets: they run with elevated permissions, pull from external registries, and execute arbitrary code on every commit. A single injected step, mutable action pin, or misconfigured trigger is enough to exfiltrate secrets, backdoor build artifacts, or pivot into production infrastructure and the attack surface is almost always left unmonitored.
16
+ CI/CD pipelines are high-value targets: they run with elevated permissions, pull from external registries, and execute arbitrary code on every commit. A single injected step, mutable action pin, or misconfigured trigger is enough to exfiltrate secrets, backdoor build artifacts, or pivot into production infrastructure -- and the attack surface is almost always left unmonitored.
17
17
 
18
- `pipewatch` audits GitHub Actions workflows, GitLab CI, and Jenkinsfiles for exactly these risks. It combines commit-anchored baseline diffing, per-step SHA-256 fingerprinting, static misconfiguration analysis, and runner environment tracking into a single CLI tool. All commands exit non-zero on findings and emit structured JSON suitable as a pipeline gate, a pre-merge check, or a periodic audit job.
18
+ `pipewatch` audits GitHub Actions workflows, GitLab CI, and Jenkinsfiles for exactly these risks. It combines commit-anchored baseline diffing, per-step SHA-256 fingerprinting, static misconfiguration analysis, and runner environment tracking into a single CLI tool. All commands exit non-zero on findings and emit structured JSON -- suitable as a pipeline gate, a pre-merge check, or a periodic audit job.
19
19
 
20
20
  ---
21
21
 
@@ -25,16 +25,16 @@ CI/CD pipelines are high-value targets: they run with elevated permissions, pull
25
25
  |---|---|
26
26
  | **Baseline diff** | Pipeline files modified, added, or deleted since a known-good commit |
27
27
  | **Step fingerprinting** | Individual steps injected, removed, or silently modified between commits |
28
- | **`pull_request_target` misuse** | Workflows that check out and execute PR head code with base-branch write permissions the pattern behind numerous CI poisoning attacks |
29
- | **Script injection** | `run:` blocks and `env:` blocks that route user-controlled context values (`github.event.pull_request.title`, `github.event.comment.body`, etc.) into shell commands both direct interpolation and the indirect `env: VAR: ${{ ctx }}` → `$VAR` pattern are detected |
30
- | **Permission misconfiguration** | Missing `permissions:` blocks, `write-all` scopes, `secrets: inherit` |
31
- | **Mutable action pins** | `uses:` references not locked to a full commit SHA tags like `v3` can be silently overwritten by a supply-chain attacker |
28
+ | **pull_request_target misuse** | Workflows that check out and execute PR head code with base-branch write permissions -- the pattern behind numerous CI poisoning attacks |
29
+ | **Script injection** | run: blocks and env: blocks that route user-controlled context values into shell commands -- both direct interpolation and the indirect env: VAR -> $VAR pattern are detected |
30
+ | **Permission misconfiguration** | Missing permissions: blocks, write-all scopes, secrets: inherit |
31
+ | **Mutable action pins** | uses: references not locked to a full commit SHA -- tags like v3 can be silently overwritten by a supply-chain attacker |
32
32
  | **Invalid pinned SHAs** | Pinned SHAs that do not exist in the upstream action repo (typos, deleted commits, fork SHAs) |
33
- | **Unsafe `workflow_run` chains** | Write-permissioned workflows triggered indirectly by external pull requests or issues, creating a privilege escalation path |
33
+ | **Unsafe workflow_run chains** | Write-permissioned workflows triggered indirectly by external pull requests or issues, creating a privilege escalation path |
34
34
  | **Self-hosted runners** | Non-ephemeral runners that persist state between runs and are not managed by GitHub |
35
35
  | **Runner environment drift** | Env var and tool version changes between CI runs that could indicate a compromised or modified runner |
36
36
 
37
- Findings are severity-ranked (CRITICAL HIGH MEDIUM LOW INFO) and always include a location, description, and evidence field.
37
+ Findings are severity-ranked (CRITICAL -> HIGH -> MEDIUM -> LOW -> INFO) and always include a location, description, and evidence field.
38
38
 
39
39
  ---
40
40
 
@@ -43,26 +43,30 @@ Findings are severity-ranked (CRITICAL → HIGH → MEDIUM → LOW → INFO) and
43
43
  Requires Python 3.12+. `pyyaml` is the only external dependency.
44
44
 
45
45
  **Via pip (recommended):**
46
- ```bash
47
- pip install pipewatch
46
+
47
+ ```
48
+ pip install pipewatch-cli
48
49
  ```
49
50
 
50
51
  **Directly from GitHub (no clone required):**
51
- ```bash
52
+
53
+ ```
52
54
  pip install git+https://github.com/TheVoidThatConsumes/pipewatch.git
53
55
  ```
54
56
 
55
- **Copy into your project** if you prefer to keep pipewatch self-contained inside a repo you're monitoring, download `pipewatch.py` and drop it anywhere in that repo. Install `pyyaml` manually:
56
- ```bash
57
+ **Copy into your project** -- download `pipewatch.py` and drop it anywhere in the repo you want to monitor. Install `pyyaml` manually:
58
+
59
+ ```
57
60
  pip install pyyaml
58
61
  ```
62
+
59
63
  Then run it as `py pipewatch.py <command>` instead of `pipewatch <command>`.
60
64
 
61
65
  ---
62
66
 
63
67
  ## Quick Start
64
68
 
65
- ```bash
69
+ ```
66
70
  # Record the current HEAD as known-good
67
71
  pipewatch baseline
68
72
 
@@ -78,18 +82,20 @@ pipewatch audit --verify-shas
78
82
  ## Commands
79
83
 
80
84
  ### `baseline`
81
- Records the current (or a specified) commit as the integrity reference point. Set `PIPEWATCH_HMAC_KEY` to store a signed baseline — if the file is modified to suppress future findings, the HMAC check will fail and the tool will refuse to proceed.
82
85
 
83
- ```bash
86
+ Records the current (or a specified) commit as the integrity reference point. Set `PIPEWATCH_HMAC_KEY` to store a signed baseline -- if the file is modified to suppress future findings, the HMAC check will fail and the tool will refuse to proceed.
87
+
88
+ ```
84
89
  pipewatch baseline [--repo PATH] [--commit SHA]
85
90
  ```
86
91
 
87
92
  ---
88
93
 
89
94
  ### `scan`
90
- Diffs pipeline files and per-step fingerprints against the baseline commit. Detects both file-level changes (unified diff) and step-level tampering — a step whose `uses`, `run`, `with`, `env`, or `shell` fields change gets flagged even if the surrounding file diff looks innocuous.
91
95
 
92
- ```bash
96
+ Diffs pipeline files and per-step fingerprints against the baseline commit. Detects both file-level changes (unified diff) and step-level tampering -- a step whose `uses`, `run`, `with`, `env`, or `shell` fields change gets flagged even if the surrounding file diff looks innocuous.
97
+
98
+ ```
93
99
  pipewatch scan [--repo PATH] [--baseline SHA] [--verbose] [--json]
94
100
  ```
95
101
 
@@ -98,37 +104,40 @@ Covers GitHub Actions, GitLab CI, and Jenkinsfile stages.
98
104
  ---
99
105
 
100
106
  ### `static`
107
+
101
108
  Static analysis with no baseline required. Audits all GitHub Actions workflow files in `.github/workflows/` for structural misconfigurations.
102
109
 
103
- ```bash
110
+ ```
104
111
  pipewatch static [--repo PATH] [--verbose] [--json]
105
112
  ```
106
113
 
107
- Checks: `pull_request_target` misuse · script injection · missing/overpermissioned `permissions:` blocks · `secrets: inherit` · self-hosted runners · unsafe `workflow_run` trigger chains.
114
+ Checks: `pull_request_target` misuse, script injection, missing or overpermissioned `permissions:` blocks, `secrets: inherit`, self-hosted runners, unsafe `workflow_run` trigger chains.
108
115
 
109
116
  ---
110
117
 
111
118
  ### `pin-audit`
119
+
112
120
  Flags `uses:` references not pinned to a full 40- or 64-character commit SHA. With `--verify-shas`, makes live GitHub API calls to confirm each pinned SHA actually exists in its upstream repository.
113
121
 
114
- ```bash
122
+ ```
115
123
  pipewatch pin-audit [--repo PATH] [--verify-shas] [--token TOKEN] [--verbose] [--json]
116
124
  ```
117
125
 
118
- Rate limit: 60 requests/hr unauthenticated, 5 000/hr with `--token` or `GITHUB_TOKEN`. API calls are deduplicated the same `owner/repo@sha` is only verified once regardless of how many steps reference it. Findings are **not** deduplicated: if the same invalid SHA appears across multiple workflow files or jobs, each affected step produces its own finding so the full scope of a compromised action is visible in the report.
126
+ Rate limit: 60 requests/hr unauthenticated, 5000/hr with `--token` or `GITHUB_TOKEN`. API calls are deduplicated -- the same `owner/repo@sha` is only verified once. Findings are **not** deduplicated: if the same invalid SHA appears across multiple files or jobs, each affected step produces its own finding so the full scope of a compromised action is visible.
119
127
 
120
128
  ---
121
129
 
122
130
  ### `snapshot`
123
- Captures the runner environment — environment variables and tool versions — to a JSON file for later comparison.
124
131
 
125
- ```bash
132
+ Captures the runner environment -- environment variables and tool versions -- to a JSON file for later comparison.
133
+
134
+ ```
126
135
  pipewatch snapshot [--output FILE]
127
136
  ```
128
137
 
129
138
  Tracked tools: `python3`, `python`, `node`, `npm`, `pip`, `pip3`, `git`, `curl`, `wget`, `docker`, `kubectl`, `terraform`, `aws`, `gcloud`, `az`. Volatile per-run variables (`GITHUB_RUN_ID`, `RUNNER_TEMP`, etc.) are excluded to prevent noise on every comparison.
130
139
 
131
- **Credential exclusion** environment variables whose names contain `token`, `secret`, `key`, `password`, `passwd`, `pwd`, `auth`, `credential`, `private`, or `api_key` (case-insensitive) are never written to the snapshot file. This prevents secrets such as `GITHUB_TOKEN` or `PIPEWATCH_HMAC_KEY` from being persisted to disk or uploaded to the Actions cache. Note that a legitimately non-sensitive variable with one of these terms in its name will also be excluded; rename it if you need `env-diff` to track it.
140
+ **Credential exclusion** -- environment variables whose names contain `token`, `secret`, `key`, `password`, `passwd`, `pwd`, `auth`, `credential`, `private`, or `api_key` (case-insensitive) are never written to the snapshot file. This prevents secrets such as `GITHUB_TOKEN` or `PIPEWATCH_HMAC_KEY` from being persisted to disk or uploaded to the Actions cache.
132
141
 
133
142
  Add the snapshot file to `.gitignore` to prevent it from being accidentally committed:
134
143
 
@@ -139,27 +148,30 @@ Add the snapshot file to `.gitignore` to prevent it from being accidentally comm
139
148
  ---
140
149
 
141
150
  ### `env-diff`
142
- Diffs two runner environment snapshots. Detects new, removed, or changed environment variables and tool versions — useful for identifying runner poisoning or unexpected environment mutations between runs. If `--current-snapshot` is omitted, captures the live environment.
143
151
 
144
- ```bash
152
+ Diffs two runner environment snapshots. Detects new, removed, or changed environment variables and tool versions -- useful for identifying runner poisoning or unexpected environment mutations between runs. If `--current-snapshot` is omitted, captures the live environment.
153
+
154
+ ```
145
155
  pipewatch env-diff BASELINE_SNAPSHOT [--current-snapshot FILE] [--repo PATH] [--verbose] [--json]
146
156
  ```
147
157
 
148
158
  ---
149
159
 
150
160
  ### `audit`
161
+
151
162
  Full audit in one pass: `scan` + `pin-audit` + `static`. Exits non-zero if any findings exist.
152
163
 
153
- ```bash
164
+ ```
154
165
  pipewatch audit [--repo PATH] [--baseline SHA] [--verify-shas] [--token TOKEN] [--verbose] [--json]
155
166
  ```
156
167
 
157
168
  ---
158
169
 
159
170
  ### `init-runner`
171
+
160
172
  Prints a ready-to-paste GitHub Actions step block that adds runner environment monitoring to any existing job.
161
173
 
162
- ```bash
174
+ ```
163
175
  pipewatch init-runner [--snapshot-path PATH]
164
176
  ```
165
177
 
@@ -171,10 +183,10 @@ pipewatch init-runner [--snapshot-path PATH]
171
183
  |---|---|---|
172
184
  | `PW-DIFF-NNN` | Pipeline file added / modified / deleted | HIGH / MEDIUM |
173
185
  | `PW-STEP-NNN` | Step fingerprint changed / injected / removed | HIGH / MEDIUM |
174
- | `PW-PRT-NNN` | `pull_request_target` misuse | HIGH |
186
+ | `PW-PRT-NNN` | pull_request_target misuse | HIGH |
175
187
  | `PW-INJ-NNN` | Script injection risk | HIGH |
176
188
  | `PW-PERM-NNN` | Permission misconfiguration | HIGH / MEDIUM |
177
- | `PW-CHAIN-NNN` | Unsafe `workflow_run` chain | HIGH |
189
+ | `PW-CHAIN-NNN` | Unsafe workflow_run chain | HIGH |
178
190
  | `PW-PIN-NNN` | Unpinned action reference | MEDIUM |
179
191
  | `PW-SHA-NNN` | Pinned SHA not found in upstream repo | HIGH |
180
192
  | `PW-RUNNER-NNN` | Self-hosted runner | INFO |
@@ -186,7 +198,7 @@ pipewatch init-runner [--snapshot-path PATH]
186
198
 
187
199
  All commands accept `--json`:
188
200
 
189
- ```json
201
+ ```
190
202
  {
191
203
  "tool": "pipewatch",
192
204
  "version": "0.2.0",
@@ -210,7 +222,7 @@ All commands accept `--json`:
210
222
 
211
223
  Gate on exit code, or filter with `jq`:
212
224
 
213
- ```bash
225
+ ```
214
226
  # fail the build on any HIGH or CRITICAL finding
215
227
  pipewatch audit --json | jq -e '.summary.HIGH == 0 and .summary.CRITICAL == 0'
216
228
 
@@ -224,7 +236,7 @@ pipewatch audit --json | jq -r '.findings[].title'
224
236
 
225
237
  Set `PIPEWATCH_HMAC_KEY` before running `baseline`. The baseline is stored as a signed JSON object:
226
238
 
227
- ```json
239
+ ```
228
240
  {
229
241
  "commit": "abc123...",
230
242
  "timestamp": "2025-01-01T00:00:00+00:00",
@@ -234,24 +246,24 @@ Set `PIPEWATCH_HMAC_KEY` before running `baseline`. The baseline is stored as a
234
246
 
235
247
  The HMAC is computed over both the commit SHA **and** the timestamp, bound together. This protects against two distinct attacks:
236
248
 
237
- - **Tampering** an attacker who modifies the `commit` field to point to an older known-good commit cannot produce a valid HMAC without the key.
238
- - **Replay** an attacker who reverts the baseline file to a previously-valid signed snapshot (to suppress findings introduced after that point) is also blocked, because the timestamp is part of the signed payload and the stored timestamp would no longer match the signature of any rewritten content.
249
+ - **Tampering** -- an attacker who modifies the `commit` field cannot produce a valid HMAC without the key.
250
+ - **Replay** -- an attacker who reverts the baseline file to a previously-valid signed snapshot is also blocked, because the timestamp is part of the signed payload.
239
251
 
240
252
  On `scan` or `audit`, the HMAC is verified before the baseline commit is trusted. Any verification failure causes an immediate exit with an error.
241
253
 
242
- **Format-downgrade protection** if `PIPEWATCH_HMAC_KEY` is set and the baseline file is in plain-text format (a raw commit SHA rather than signed JSON), the tool refuses to proceed. An attacker cannot bypass signing by overwriting the file with an unsigned commit SHA. If you set `PIPEWATCH_HMAC_KEY` for the first time on a repo that already has an unsigned baseline, re-run `pipewatch baseline` to generate a signed one before your next scan.
254
+ **Format-downgrade protection** -- if `PIPEWATCH_HMAC_KEY` is set and the baseline file is in plain-text format, the tool refuses to proceed. An attacker cannot bypass signing by overwriting the file with an unsigned commit SHA. If you set `PIPEWATCH_HMAC_KEY` for the first time on a repo that already has an unsigned baseline, re-run `pipewatch baseline` to generate a signed one before your next scan.
243
255
 
244
- Store the key in a CI secret (`PIPEWATCH_HMAC_KEY: ${{ secrets.PIPEWATCH_HMAC_KEY }}`).
256
+ Store the key in a CI secret.
245
257
 
246
258
  ---
247
259
 
248
260
  ## Pipeline Integration
249
261
 
250
- ```yaml
262
+ ```
251
263
  - name: pipewatch audit
252
264
  env:
253
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
254
- PIPEWATCH_HMAC_KEY: ${{ secrets.PIPEWATCH_HMAC_KEY }}
265
+ GITHUB_TOKEN: (your GITHUB_TOKEN secret)
266
+ PIPEWATCH_HMAC_KEY: (your PIPEWATCH_HMAC_KEY secret)
255
267
  run: |
256
268
  pipewatch audit --verify-shas --json | tee pw-report.json
257
269
  jq -e '.summary.HIGH == 0 and .summary.CRITICAL == 0' pw-report.json
@@ -263,13 +275,13 @@ Use `init-runner` to add runner environment monitoring alongside the audit step.
263
275
 
264
276
  ## Design Decisions
265
277
 
266
- **HMAC-signed baselines** tamper-evident storage for the known-good commit reference. The HMAC covers both the commit SHA and the timestamp, so an attacker who can write to the repo but wants to suppress findings would need to forge the HMAC not just overwrite the file or revert it to an older signed state. Attempting to downgrade a signed baseline to unsigned plain text is also refused when `PIPEWATCH_HMAC_KEY` is set.
278
+ **HMAC-signed baselines** -- tamper-evident storage for the known-good commit reference. The HMAC covers both the commit SHA and the timestamp, so an attacker who can write to the repo but wants to suppress findings would need to forge the HMAC -- not just overwrite the file or revert it to an older signed state. Attempting to downgrade a signed baseline to unsigned plain text is also refused when `PIPEWATCH_HMAC_KEY` is set.
267
279
 
268
- **Volatile variable exclusion** `snapshot` strips run-specific GitHub variables so that `env-diff` doesn't generate noise on every comparison. Only structurally stable variables that could indicate environment manipulation are tracked. Variables whose names suggest credentials (containing `token`, `secret`, `key`, `password`, etc.) are also excluded so secrets are never written to the snapshot file or the Actions cache.
280
+ **Volatile variable exclusion** -- `snapshot` strips run-specific GitHub variables so that `env-diff` does not generate noise on every comparison. Only structurally stable variables that could indicate environment manipulation are tracked. Variables whose names suggest credentials are also excluded so secrets are never written to the snapshot file or the Actions cache.
269
281
 
270
- **Conservative SHA verification** `--verify-shas` treats network failures as non-findings. A flaky connection or GitHub API outage should not produce spurious HIGH alerts.
282
+ **Conservative SHA verification** -- `--verify-shas` treats network failures as non-findings. A flaky connection or GitHub API outage should not produce spurious HIGH alerts.
271
283
 
272
- **Stdlib-first** `pyyaml` is the only dependency. The tool audits supply-chain risk; its own footprint is minimal by design.
284
+ **Stdlib-first** -- `pyyaml` is the only dependency. The tool audits supply-chain risk; its own footprint is minimal by design.
273
285
 
274
286
  ---
275
287
 
@@ -277,12 +289,12 @@ Use `init-runner` to add runner environment monitoring alongside the audit step.
277
289
 
278
290
  | Platform | Coverage |
279
291
  |---|---|
280
- | GitHub Actions (`.github/workflows/*.yml`) | Full file diff, step fingerprinting, static analysis, pin audit |
281
- | GitLab CI (`.gitlab-ci.yml`) | File diff + `before_script` / `script` / `after_script` fingerprinting |
282
- | Jenkins (`Jenkinsfile`) | File diff + best-effort stage / `sh` / `bat` fingerprinting (regex-based not a Groovy parser) |
292
+ | GitHub Actions (.github/workflows/*.yml) | Full -- file diff, step fingerprinting, static analysis, pin audit. Step evidence includes uses, run first line, or step name. |
293
+ | GitLab CI (.gitlab-ci.yml) | File diff + before_script / script / after_script fingerprinting. Step evidence shows the first command in the changed block. |
294
+ | Jenkins (Jenkinsfile) | File diff + best-effort stage / sh / bat fingerprinting (regex-based, not a Groovy parser). Step evidence shows the first sh/bat command where extractable; falls back to stage name. |
283
295
 
284
296
  ---
285
297
 
286
298
  ## License
287
299
 
288
- GPLv2. See [LICENSE](LICENSE).
300
+ Copyright (c) 2026 David Obi. Licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). See [LICENSE](LICENSE).
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pipewatch-cli"
7
- version = "0.3.0"
7
+ version = "0.5.0"
8
8
  description = "Integrity monitoring and static analysis for CI/CD pipelines"
9
9
  readme = "README.md"
10
10
  license = "CC-BY-4.0"
File without changes