diff-sentry 0.4.0__tar.gz → 0.4.2__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.2}/.github/workflows/ci.yml +33 -5
  2. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/.github/workflows/release.yml +11 -6
  3. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/CHANGELOG.md +86 -0
  4. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/CLAUDE.md +16 -0
  5. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/PKG-INFO +6 -7
  6. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/README.md +4 -5
  7. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/action.yml +11 -7
  8. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/diff_sentry/__init__.py +4 -2
  9. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/diff_sentry/cli.py +4 -5
  10. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/diff_sentry/indicators.py +151 -7
  11. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/diff_sentry/normalize.py +17 -5
  12. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/pyproject.toml +1 -1
  13. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/tests/corpus/change_corpus.json +28 -0
  14. diff_sentry-0.4.2/tests/fixtures/known-malicious.diff +1 -0
  15. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/tests/test_detection_quality.py +5 -4
  16. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/tests/test_indicators.py +165 -1
  17. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/uv.lock +1 -1
  18. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/.claude/rules/handoff.md +0 -0
  19. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/.env.example +0 -0
  20. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/.gitignore +0 -0
  21. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/.python-version +0 -0
  22. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/LICENSE +0 -0
  23. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/VENDOR.md +0 -0
  24. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/diff_sentry/__main__.py +0 -0
  25. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/diff_sentry/assemble.py +0 -0
  26. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/diff_sentry/config.py +0 -0
  27. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/diff_sentry/deep_classify.py +0 -0
  28. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/diff_sentry/detect.py +0 -0
  29. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/diff_sentry/emit.py +0 -0
  30. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/diff_sentry/fetch_tool.py +0 -0
  31. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/diff_sentry/ingest.py +0 -0
  32. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/diff_sentry/response.py +0 -0
  33. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/diff_sentry/rl_export.py +0 -0
  34. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/diff_sentry/rubric.py +0 -0
  35. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/diff_sentry/schema.py +0 -0
  36. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/diff_sentry/skills/ci-injection-patterns.md +0 -0
  37. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/diff_sentry/skills/prompt-injection-catalog.md +0 -0
  38. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/diff_sentry/skills/triage-a-change.md +0 -0
  39. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/eval/README.md +0 -0
  40. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/eval/diff_sentry_eval/__init__.py +0 -0
  41. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/eval/diff_sentry_eval/__main__.py +0 -0
  42. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/eval/diff_sentry_eval/cli.py +0 -0
  43. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/eval/diff_sentry_eval/judge.py +0 -0
  44. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/eval/diff_sentry_eval/schema.py +0 -0
  45. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/eval/diff_sentry_eval/score.py +0 -0
  46. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/eval/diff_sentry_eval/taskset.py +0 -0
  47. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/eval/pyproject.toml +0 -0
  48. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/eval/taskset.example.json +0 -0
  49. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/eval/tests/conftest.py +0 -0
  50. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/eval/tests/test_boundary.py +0 -0
  51. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/eval/tests/test_cli.py +0 -0
  52. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/eval/tests/test_judge.py +0 -0
  53. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/eval/tests/test_schema.py +0 -0
  54. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/eval/tests/test_score.py +0 -0
  55. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/eval/tests/test_taskset.py +0 -0
  56. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/studio/DESIGN.md +0 -0
  57. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/studio/README.md +0 -0
  58. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/studio/diff_sentry_studio/__init__.py +0 -0
  59. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/studio/diff_sentry_studio/app.py +0 -0
  60. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/studio/diff_sentry_studio/iterations.py +0 -0
  61. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/studio/diff_sentry_studio/live.py +0 -0
  62. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/studio/diff_sentry_studio/mapper.py +0 -0
  63. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/studio/pyproject.toml +0 -0
  64. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/studio/static/app.js +0 -0
  65. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/studio/static/index.html +0 -0
  66. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/studio/static/replay-core.js +0 -0
  67. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/studio/static/run-core.js +0 -0
  68. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/studio/static/style.css +0 -0
  69. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/studio/static/trajectory.js +0 -0
  70. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/studio/static/vendor/fonts/jetbrains-mono-400.woff2 +0 -0
  71. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/studio/static/vendor/fonts/jetbrains-mono-700.woff2 +0 -0
  72. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/studio/tests/replay-core.test.js +0 -0
  73. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/studio/tests/run-core.test.js +0 -0
  74. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/studio/tests/static-contract.test.js +0 -0
  75. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/studio/tests/test_app.py +0 -0
  76. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/studio/tests/test_iterations.py +0 -0
  77. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/studio/tests/test_live.py +0 -0
  78. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/studio/tests/test_mapper.py +0 -0
  79. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/studio/tests/trajectory.test.js +0 -0
  80. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/tests/__init__.py +0 -0
  81. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/tests/conftest.py +0 -0
  82. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/tests/corpus/hackerbot_claw_incident.json +0 -0
  83. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/tests/test_assemble.py +0 -0
  84. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/tests/test_cli.py +0 -0
  85. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/tests/test_config.py +0 -0
  86. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/tests/test_deep_classify.py +0 -0
  87. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/tests/test_detect.py +0 -0
  88. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/tests/test_emit.py +0 -0
  89. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/tests/test_export.py +0 -0
  90. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/tests/test_fetch_tool.py +0 -0
  91. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/tests/test_forward_offline.py +0 -0
  92. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/tests/test_incident_hackerbot_claw.py +0 -0
  93. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/tests/test_ingest.py +0 -0
  94. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/tests/test_injection_offline.py +0 -0
  95. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/tests/test_normalize.py +0 -0
  96. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/tests/test_provenance.py +0 -0
  97. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/tests/test_response.py +0 -0
  98. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/tests/test_rubric.py +0 -0
  99. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/tests/test_schema.py +0 -0
  100. {diff_sentry-0.4.0 → diff_sentry-0.4.2}/tests/test_subscription.py +0 -0
@@ -29,13 +29,13 @@ jobs:
29
29
  matrix:
30
30
  python-version: ["3.11", "3.13"]
31
31
  steps:
32
- - uses: actions/checkout@v4
32
+ - uses: actions/checkout@v7
33
33
 
34
34
  - name: Install uv
35
35
  # Third-party action pinned to a commit SHA for supply-chain integrity (a moving major tag
36
36
  # could be repointed at malicious code that runs with this workflow's token). GitHub-owned
37
37
  # actions (checkout) stay on a major tag. Bump the SHA and the trailing version comment together.
38
- uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
38
+ uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
39
39
  with:
40
40
  enable-cache: true
41
41
 
@@ -75,7 +75,7 @@ jobs:
75
75
  name: self-scan (dogfood)
76
76
  runs-on: ubuntu-latest
77
77
  steps:
78
- - uses: actions/checkout@v4
78
+ - uses: actions/checkout@v7
79
79
  with:
80
80
  fetch-depth: 0 # the action needs the base commit to diff against
81
81
 
@@ -102,13 +102,41 @@ 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@v7
114
+ - name: Install uv
115
+ uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
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
108
136
  steps:
109
- - uses: actions/checkout@v4
137
+ - uses: actions/checkout@v7
110
138
  - name: Install uv
111
- uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
139
+ uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
112
140
  with:
113
141
  enable-cache: true
114
142
  - name: Lint
@@ -26,10 +26,10 @@ jobs:
26
26
  build:
27
27
  runs-on: ubuntu-latest
28
28
  steps:
29
- - uses: actions/checkout@v4
29
+ - uses: actions/checkout@v7
30
30
 
31
31
  - name: Install uv
32
- uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
32
+ uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
33
33
 
34
34
  - name: Build sdist + wheel
35
35
  run: uv build
@@ -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,92 @@ 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.2
9
+
10
+ Scoping. Every fix here comes from the same root: the scan read a whole change as one
11
+ string, so rules that need two signals could take them from two unrelated files.
12
+
13
+ ### Fixed
14
+ - **The scan is scoped per file.** `scan_indicators` reads whatever it is handed as one string, and the
15
+ action hands it the whole `git diff`. Most rules fire on a single match, so that was harmless — but
16
+ two need a PAIR of signals (`pwn-request`: privileged trigger + PR-HEAD checkout;
17
+ `detached-process-spawn`: detached spawn + `child_process`), and a whole-blob read let those pair
18
+ ACROSS FILES. A `workflow_run:` added to one workflow plus a `ref: ${{ ... head.sha }}` sitting in a
19
+ doc example, a test fixture, or the very workflow a change is *deleting*, composed into a `critical`
20
+ that no single file contained. The new `scan_diff` splits a unified diff on its file headers and
21
+ scans each segment on its own; non-diff input (a region the planner pulled out of the REPL, a plain
22
+ file) falls through to the previous whole-text scan. Used by `scan` and by the in-loop
23
+ `scan_indicators` tool.
24
+ - **The host-side baseline is scoped per file too, via `scan_content`.** An event is not a unified diff:
25
+ `raw_content` concatenates the title, every `(filename, patch)` and the body with no `diff --git`
26
+ headers, so pointing the baseline at `scan_diff` would have scoped nothing at all. `scan_content`
27
+ splits on the structure the event already carries — and gives hits from a file that file's name. The
28
+ case is not hypothetical: a PR that adds a `workflow_run` workflow AND documents the trap in the same
29
+ commit is what a careful repo writes, and read as one blob it came out `critical`. `content_segments`
30
+ is now the single definition `raw_content` is built on, so the two cannot drift, and the
31
+ detection-quality corpus was moved onto the production path so it guards what actually ships.
32
+
33
+ ### Changed
34
+ - **`astral-sh/setup-uv` bumped to v10.0.1** (from v5.4.2) and **`actions/checkout` to v7**, both for
35
+ the Node 24 runtime — between them they clear GitHub's Node 20 deprecation warning. The setup-uv bump
36
+ covers `action.yml` too, so it reaches every repo running this action, not just this one's CI. Five
37
+ majors in one
38
+ jump, so what actually applies here was checked: v8 stopped publishing major tags (this repo already
39
+ SHA-pins), v9 flipped the `prune-cache` default, and v10 disables caching for `release` /
40
+ `workflow_run` / `pull_request_target` events under `enable-cache: auto` — CI passes `enable-cache:
41
+ true` explicitly and keeps its cache, and the release job never wanted one.
42
+
43
+ checkout v7 also **blocks checking out a fork PR head under `pull_request_target` / `workflow_run`** —
44
+ the `pwn-request` shape this project exists to detect, now refused by the action itself. Nothing here
45
+ checks out a PR head, so the bump is behaviour-neutral for these workflows and strictly better posture
46
+ for a detector to be running.
47
+
48
+ ### Added
49
+ - **`pwn-request` covers `allow-unsafe-pr-checkout: true`.** actions/checkout v5.1/v6.1/v7 refuse to
50
+ check out a fork PR head under `pull_request_target` / `workflow_run` unless a workflow opts back in
51
+ with that input. The opt-in is the same attack stated in words — no ref to trace, no dataflow to
52
+ infer — so it joins the `ref:` value and the hand-rolled `git fetch` as the rule's third form, with
53
+ its own title so a reader is not sent hunting for a `ref:` that is not there. It only counts under a
54
+ privileged trigger (beneath `pull_request` the input carries none of that meaning), a removed opt-in
55
+ does not fire, and neither does an explicit `false`.
56
+ - **Hits name the file they came from.** `location` was the name of the whole diff — `gated.diff` for
57
+ every hit, which meant reading the raw log to find out where a finding actually was. Per-file
58
+ scanning gives each hit its real path.
59
+ - **A hit id is now stable between a whole-diff scan and a single-file scan.** The segment the planner
60
+ pulls out is byte-identical to the one the baseline scanned, so the evidence snippets — and therefore
61
+ the ids — match, and the two de-duplicate to one union member on read. Whole-blob scanning could not
62
+ promise that: the snippet window moved with the byte offset.
63
+
64
+ ## 0.4.1
65
+
66
+ A correctness release for one rule: `pwn-request` was failing the very pattern this
67
+ project's README tells people to use.
68
+
69
+ ### Changed
70
+ - **`pypa/gh-action-pypi-publish` bumped to v1.14.2** (from v1.14.0). `uv build` now emits core
71
+ metadata 2.5, and the twine bundled in v1.14.0 rejects it — `InvalidDistribution: '2.5' is not a
72
+ valid metadata version` — so the first attempt at this release built cleanly and then failed at
73
+ the upload step. v1.14.2 carries Twine 7, which accepts metadata 2.5.
74
+
75
+ ### Fixed
76
+ - **`pwn-request` no longer fires on a workflow that merely READS the PR head.** The rule paired a
77
+ privileged trigger with a PR-HEAD *mention* anywhere in the text and called that a checkout, so the
78
+ validating-publisher shape — `workflow_run`, head SHA in an env var, checkout pinned to the default
79
+ branch — was graded `critical`, identically to the attack. That is the shape README.md tells people to
80
+ use, next to the sentence "diff-sentry's own rules make the same distinction, so the safe shape does
81
+ not trip them"; it did trip them. Escalation now requires the expression to actually reach a checkout:
82
+ an `actions/checkout` `ref:` value, or a `git checkout`/`git fetch`/`gh pr checkout` command. A bare
83
+ mention keeps the sub-floor `medium` `privileged-fork-trigger`, cited at the reference rather than the
84
+ trigger line so a reviewer still lands on the spot worth reading. Found by running the action against
85
+ a real publisher workflow.
86
+ - **`pwn-request` now follows one binding hop.** `HEAD: ${{ github.event.pull_request.head.sha }}`
87
+ followed by `ref: ${{ env.HEAD }}` is the same attack split across two lines. Requiring the expression
88
+ to sit on the `ref:` line would have turned the fix above into a free bypass, so a name bound to the
89
+ head expression is tracked and a `ref:` that reads it counts as the checkout.
90
+ - **`refs/pull/…` matching tolerates interpolation.** `refs/pull/${{ github.event.number }}/head` — the
91
+ form that actually appears in the wild — was missed, because the character class stopped at the space
92
+ inside the expression. A hand-rolled `git fetch` of the PR head is caught now.
93
+
8
94
  ## 0.4.0
9
95
 
10
96
  The first release, and a repositioning: the front door is now a GitHub Action anyone installs in fifteen
@@ -100,6 +100,22 @@ One companion rule ships under `.claude/rules/`:
100
100
  network-fetch + file-write — are `high`. A rule that needs BOTH halves is how a common primitive
101
101
  becomes evidence without becoming noise. The corpus pins this hit/miss behavior, and every rule ships
102
102
  with its NEGATIVE case. Hit evidence is a BOUNDED snippet (`_MAX_EVIDENCE`), never the whole diff.
103
+ - **`pwn-request` has THREE checkout forms**, and all three need the privileged trigger to count: the
104
+ expression on a `ref:` (directly or one binding hop away), a hand-rolled `git fetch`/`gh pr checkout`,
105
+ and `allow-unsafe-pr-checkout: true` — the input actions/checkout v5.1+ added for opting back into
106
+ the fork-PR checkout it now blocks. The last one needs no dataflow: the line states the intent. Each
107
+ form carries its own title, because reporting an opt-in as "checks out the PR HEAD" sends a reader
108
+ hunting for a `ref:` that is not there.
109
+ - **A paired rule only pairs WITHIN one file.** `scan_diff` splits a unified diff on its file headers;
110
+ `scan_content` does the same for an EVENT, whose files `raw_content` would otherwise concatenate into
111
+ one header-less blob that `scan_diff` cannot scope at all. `scan` and the in-loop tool take the first,
112
+ the host-side baseline takes the second, and `content_segments` is the single definition both
113
+ `raw_content` and the baseline are built on, so they cannot drift.
114
+ Scanning the diff as one blob let the two halves come from different files — a `workflow_run:` in one
115
+ workflow and a `ref: ${{ … head.sha }}` in a doc example composed into a `critical` no file contained.
116
+ It also makes a hit id stable between a whole-diff scan and a single-file one, because the segment is
117
+ byte-identical either way, which is what lets the baseline and an in-loop scan de-duplicate (MF3).
118
+ Non-diff input falls through to a plain whole-text scan; keep that fallback.
103
119
  - **The indicator tool registers as exactly `scan_indicators`.** dspy registers a tool under its
104
120
  `__name__` and the prompt says `scan_indicators(region)` — a rename makes every sandbox call a
105
121
  NameError (a shipped regression; see the `fix(detect)` commit). The inner callable is renamed on
@@ -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.2
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,17 +44,16 @@ 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.2
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
 
55
54
  | Family | Examples |
56
55
  |---|---|
57
- | Workflow configuration | `pull_request_target` that checks out the PR head (the pwn-request that broke AsyncAPI), `permissions: write-all`, CODEOWNERS reassignment |
56
+ | Workflow configuration | `pull_request_target` that checks out the PR head (the pwn-request that broke AsyncAPI) — by `ref:`, by a hand-rolled `git fetch`, or by opting back in with `allow-unsafe-pr-checkout: true` — plus `permissions: write-all` and CODEOWNERS reassignment |
58
57
  | Obfuscation | base64 that decodes to a shell payload, `_0x…` machine-mangled JavaScript, high-entropy blobs |
59
58
  | Execution | pipe-to-shell installs, `${IFS}` space-evasion, detached child processes, inline `node -e`, fetch-to-disk droppers |
60
59
  | Exfiltration | secrets reaching a network sink, cross-process `/proc/<pid>/mem` reads, OAST callback services, IPFS and permaweb gateways |
@@ -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,17 +23,16 @@ 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.2
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
 
34
33
  | Family | Examples |
35
34
  |---|---|
36
- | Workflow configuration | `pull_request_target` that checks out the PR head (the pwn-request that broke AsyncAPI), `permissions: write-all`, CODEOWNERS reassignment |
35
+ | Workflow configuration | `pull_request_target` that checks out the PR head (the pwn-request that broke AsyncAPI) — by `ref:`, by a hand-rolled `git fetch`, or by opting back in with `allow-unsafe-pr-checkout: true` — plus `permissions: write-all` and CODEOWNERS reassignment |
37
36
  | Obfuscation | base64 that decodes to a shell payload, `_0x…` machine-mangled JavaScript, high-entropy blobs |
38
37
  | Execution | pipe-to-shell installs, `${IFS}` space-evasion, detached child processes, inline `node -e`, fetch-to-disk droppers |
39
38
  | Exfiltration | secrets reaching a network sink, cross-process `/proc/<pid>/mem` reads, OAST callback services, IPFS and permaweb gateways |
@@ -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.2'
44
48
 
45
49
  outputs:
46
50
  failed:
@@ -65,7 +69,7 @@ runs:
65
69
  - name: Install uv
66
70
  # SHA-pinned: a moving major tag on a third-party action could be repointed at code that runs
67
71
  # with the calling workflow's token. Bump the SHA and the trailing version comment together.
68
- uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
72
+ uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
69
73
  with:
70
74
  enable-cache: true
71
75
 
@@ -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
@@ -22,7 +22,7 @@ from __future__ import annotations
22
22
  from .assemble import assemble_verdict, verdict_from_events
23
23
  from .config import DetectConfig
24
24
  from .emit import EmitResult, emit_signal, signal_payload
25
- from .indicators import hits_from_events, scan_indicators
25
+ from .indicators import hits_from_events, scan_content, scan_diff, scan_indicators
26
26
  from .ingest import event_from_payload, issue_event, pr_event
27
27
  from .normalize import event_metadata, normalize_event
28
28
  from .response import build_failed_response, build_response
@@ -44,6 +44,8 @@ __all__ = [
44
44
  "DetectionResponse",
45
45
  "RefusalInfo",
46
46
  "ProcessInfo",
47
+ "scan_content",
48
+ "scan_diff",
47
49
  "scan_indicators",
48
50
  "hits_from_events",
49
51
  "normalize_event",
@@ -68,7 +70,7 @@ __all__ = [
68
70
  "detect_from_event",
69
71
  ]
70
72
 
71
- __version__ = "0.4.0"
73
+ __version__ = "0.4.2"
72
74
 
73
75
 
74
76
  def __getattr__(name: str): # PEP 562 — defer the dspy import to first use
@@ -91,10 +91,9 @@ def _baseline_indicators(event: dict) -> list[dict]:
91
91
  """The host-side deterministic evidence recorded in run_start meta BEFORE the planner runs: the text
92
92
  detectors over the raw content ∪ the provenance detectors over the ingest facts. Both re-source into the
93
93
  evidence union on read (MF3), so a false-benign SUBMIT can skew the verdict but never suppress these."""
94
- from .indicators import scan_indicators, scan_provenance
95
- from .normalize import raw_content
94
+ from .indicators import scan_content, scan_provenance
96
95
 
97
- return ([h.model_dump() for h in scan_indicators(raw_content(event))]
96
+ return ([h.model_dump() for h in scan_content(event)]
98
97
  + [h.model_dump() for h in scan_provenance(event.get("provenance") or {})])
99
98
 
100
99
 
@@ -245,7 +244,7 @@ def _cmd_scan(args) -> int:
245
244
  `DS_*` creds, and the only way to hand creds to a fork PR's run is `pull_request_target` — the exact
246
245
  misconfiguration that opened the AsyncAPI "Miasma" supply-chain compromise. `scan` needs nothing, so
247
246
  it runs under the same read-only `contents: read` token an untrusted fork PR already gets."""
248
- from .indicators import scan_indicators
247
+ from .indicators import scan_diff
249
248
  from .schema import severity_rank
250
249
 
251
250
  threshold = severity_rank(args.fail_on)
@@ -259,7 +258,7 @@ def _cmd_scan(args) -> int:
259
258
  label = path
260
259
  if not args.include_deletions:
261
260
  text = drop_deleted_lines(text)
262
- hits.extend(scan_indicators(text, location=label))
261
+ hits.extend(scan_diff(text, location=label))
263
262
 
264
263
  worst = max((severity_rank(h.severity) for h in hits), default=-1)
265
264
  failed = worst >= threshold
@@ -127,9 +127,47 @@ _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
+
160
+ # The third way to check out a fork PR head, and the only one that says so in words. actions/checkout
161
+ # v5.1/v6.1/v7 REFUSE to check out a fork PR under `pull_request_target`/`workflow_run` unless the
162
+ # workflow opts back in with `allow-unsafe-pr-checkout: true`. There is no ref to trace and no dataflow
163
+ # to infer — the line is the intent, written out. Under `pull_request` it means nothing (no privileged
164
+ # context to protect), which is why it only counts inside `_scan_workflow_config`'s trigger guard.
165
+ # Literal `true` only: an expression value is a parameterisation question, not evidence, and the
166
+ # `^[+ ]?` anchor keeps a REMOVED opt-in (the hardening you want) from firing.
167
+ _UNSAFE_PR_CHECKOUT_RE = re.compile(
168
+ r"^[+ ]?\s*allow-unsafe-pr-checkout\s*:\s*[\"']?true[\"']?\s*$",
169
+ re.IGNORECASE | re.MULTILINE)
170
+ _IDENT_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_-]*")
133
171
 
134
172
  # Diff-PRESENTATION evasion — attacks aimed at the human reading the diff, not at the runtime. Miasma
135
173
  # prepended ~700 spaces to shove its payload off the right edge of a standard diff viewer. No source
@@ -281,16 +319,52 @@ def _scan_workflow_perms(text: str) -> list[IndicatorHit]:
281
319
  for m in _WRITE_ALL_RE.finditer(text)]
282
320
 
283
321
 
322
+ _PWN_TITLE = ("privileged fork trigger checks out the PR HEAD — untrusted code with the base "
323
+ "repo's token/secrets")
324
+ _PWN_OPTIN_TITLE = ("privileged fork trigger re-enables the fork-PR checkout actions/checkout now "
325
+ "blocks by default — untrusted code with the base repo's token/secrets")
326
+
327
+
328
+ def _pr_head_checkout(text: str) -> tuple[re.Match[str], str] | None:
329
+ """Where a PR-HEAD checkout actually happens, and which of the three forms it took.
330
+
331
+ Returns the match to cite plus the title for it — the opt-in reads differently enough from a ref
332
+ that reporting it as "checks out the PR HEAD" would send a reader looking for a `ref:` that is not
333
+ there."""
334
+ optin = _UNSAFE_PR_CHECKOUT_RE.search(text)
335
+ if optin:
336
+ return optin, _PWN_OPTIN_TITLE
337
+ direct = _PR_HEAD_CHECKOUT_RE.search(text)
338
+ if direct:
339
+ return direct, _PWN_TITLE
340
+ # `ref:` is not a binding — a `ref:` holding the expression IS the direct case above.
341
+ bound = {m.group(1).lower() for m in _HEAD_BINDING_RE.finditer(text)} - {"ref"}
342
+ if not bound:
343
+ return None
344
+ for m in _REF_VALUE_RE.finditer(text):
345
+ if any(name.lower() in bound for name in _IDENT_RE.findall(m.group(1))):
346
+ return m, _PWN_TITLE
347
+ return None
348
+
349
+
284
350
  def _scan_workflow_config(text: str) -> list[IndicatorHit]:
285
351
  """What a workflow BECOMES, not merely that one was touched (`workflow-tamper`'s job)."""
286
352
  trigger = _PRIV_TRIGGER_RE.search(text)
287
353
  if not trigger:
288
354
  return []
355
+ checkout = _pr_head_checkout(text)
356
+ if checkout:
357
+ match, title = checkout
358
+ return [_hit("pwn-request", "critical", title,
359
+ _snip(text, match.start(), match.end()))]
289
360
  head = _PR_HEAD_REF_RE.search(text)
290
361
  if head:
291
- return [_hit("pwn-request", "critical",
292
- "privileged fork trigger checks out the PR HEAD — untrusted code with the base "
293
- "repo's token/secrets",
362
+ # Reads the head ref without checking it out. That is the validating-publisher shape, which is
363
+ # the RECOMMENDED way to comment on a fork PR — so it stays sub-floor, but it is cited rather
364
+ # than the trigger line, because this is the spot a reviewer should look at.
365
+ return [_hit("privileged-fork-trigger", "medium",
366
+ "`pull_request_target`/`workflow_run` reads the PR HEAD without checking it out — "
367
+ "confirm the ref never reaches a checkout",
294
368
  _snip(text, head.start(), head.end()))]
295
369
  return [_hit("privileged-fork-trigger", "medium",
296
370
  "`pull_request_target`/`workflow_run` — a fork PR runs in a privileged context",
@@ -391,6 +465,72 @@ def _sev_rank(sev: str) -> int:
391
465
  return severity_rank(sev)
392
466
 
393
467
 
468
+ # ── diff SCOPING ──────────────────────────────────────────────────────────────────────────────────────
469
+ # A change is many files, and `scan_indicators` reads whatever it is handed as ONE string. That is right
470
+ # for the rules that fire on a single match, but two of them require a PAIR of signals — `pwn-request`
471
+ # (privileged trigger + PR-HEAD checkout) and `detached-process-spawn` (detached spawn + child_process).
472
+ # Read as one blob, those pair ACROSS FILES: a `workflow_run:` added to one workflow and a
473
+ # `ref: ${{ github.event.pull_request.head.sha }}` sitting in an unrelated file — a doc example, a test
474
+ # fixture, or the very workflow the change is DELETING — compose into a `critical` that no single file
475
+ # contains. Scoping the scan per file removes that class outright, and names the file in `location`
476
+ # while it is there: evidence that used to read `gated.diff` for every hit now points at what to open.
477
+ #
478
+ # The trade is explicit: a payload deliberately split across two files no longer pairs. That is the
479
+ # right way round — a detector that invents a critical out of two unrelated files trains people to
480
+ # disable it, and the single-signal rules (which are most of them) are unaffected either way.
481
+ _DIFF_FILE_RE = re.compile(r"^diff --git a/(?P<a>.+?) b/(?P<b>.+?)$", re.MULTILINE)
482
+
483
+
484
+ def split_diff_by_file(text: str) -> list[tuple[str, str]]:
485
+ """Split a unified diff into `(path, segment)` pairs; `[]` when `text` is not a diff.
486
+
487
+ Anything BEFORE the first file header comes back first with an empty path. That chunk is not
488
+ padding — for `raw_content` it is the PR title and body, which is exactly where prompt injection
489
+ lives, so it has to keep being scanned rather than skipped as preamble."""
490
+ marks = list(_DIFF_FILE_RE.finditer(text or ""))
491
+ if not marks:
492
+ return []
493
+ out: list[tuple[str, str]] = []
494
+ head = text[:marks[0].start()]
495
+ if head.strip():
496
+ out.append(("", head))
497
+ for i, m in enumerate(marks):
498
+ end = marks[i + 1].start() if i + 1 < len(marks) else len(text)
499
+ out.append((m.group("b") or m.group("a"), text[m.start():end]))
500
+ return out
501
+
502
+
503
+ def scan_content(event: dict) -> list[IndicatorHit]:
504
+ """Scan ONE event's untrusted content with the same per-file scoping `scan_diff` gives a raw diff.
505
+
506
+ An event is not a unified diff — `raw_content` concatenates the title, the author, every
507
+ (filename, patch) and the body into one string, with no `diff --git` headers for `scan_diff` to
508
+ split on. Handing that blob to `scan_diff` therefore scopes NOTHING: a paired rule could still take
509
+ one half from a workflow and the other from an unrelated file. The event already carries the
510
+ structure a raw diff has to be parsed for, so scan it piece by piece and let hits from a file name
511
+ that file."""
512
+ from .normalize import content_segments
513
+ deduped: dict[str, IndicatorHit] = {}
514
+ for label, text in content_segments(event):
515
+ for h in scan_indicators(text, location=label):
516
+ deduped.setdefault(h.id, h)
517
+ return list(deduped.values())
518
+
519
+
520
+ def scan_diff(text: str, *, location: str = "") -> list[IndicatorHit]:
521
+ """`scan_indicators` with per-file scoping — a paired rule can only fire when ONE file carries both
522
+ halves. Non-diff input (a region the planner pulled out, a plain file) falls through to a single
523
+ whole-text scan, so this is safe as the entry point for either shape."""
524
+ segments = split_diff_by_file(text)
525
+ if not segments:
526
+ return scan_indicators(text, location=location)
527
+ deduped: dict[str, IndicatorHit] = {}
528
+ for path, segment in segments:
529
+ for h in scan_indicators(segment, location=path or location):
530
+ deduped.setdefault(h.id, h)
531
+ return list(deduped.values())
532
+
533
+
394
534
  def scan_indicators(text: str, *, location: str = "") -> list[IndicatorHit]:
395
535
  """Run every detector over `text` and return the DEDUPED union of hits (by deterministic id).
396
536
 
@@ -538,7 +678,11 @@ def make_indicator_tool() -> Callable[[str], str]:
538
678
  injection, obfuscated payloads, secret exfiltration, known exfil/OAST callback services,
539
679
  CI/CODEOWNERS tampering, workflow permission escalation, CI-skip bypass, prompt injection. Returns
540
680
  the hits found (id, rule, severity, title). Cite an id in your final `indicator_ids`."""
541
- hits = scan_indicators(region or "")
681
+ # scan_diff, not scan_indicators: when the planner hands back a whole diff this scopes it per
682
+ # file exactly as the host-side baseline did, so the two scans of the same content still mint
683
+ # the same ids and de-duplicate to one union member (MF3). A sub-region is not a diff and falls
684
+ # through to the plain whole-text scan.
685
+ hits = scan_diff(region or "")
542
686
  record_tool_call("scan_indicators", args={"region": (region or "")[:200]}, ok=True,
543
687
  hits=[h.model_dump() for h in hits], n=len(hits))
544
688
  if not hits:
@@ -159,13 +159,25 @@ def has_groundable_content(event_str: str) -> bool:
159
159
  return bool(body) and body != NO_CONTENT_SENTINEL
160
160
 
161
161
 
162
+ def content_segments(event: dict) -> list[tuple[str, str]]:
163
+ """The untrusted content split the way the event already carries it: `(label, text)` per piece —
164
+ the title+author, then one segment per file, then the body.
165
+
166
+ `raw_content` is the concatenation of exactly these, in this order, so the two cannot drift and the
167
+ content digest built on it stays byte-stable. The split exists because the baseline scan needs the
168
+ same per-file scoping `scan_diff` gives a raw diff: read as one blob, a rule that needs two signals
169
+ could take one from a workflow and the other from an unrelated file. A file's segment keeps its
170
+ filename INSIDE the text as well as in the label — `workflow-tamper` reads the path itself."""
171
+ files = _files(event)
172
+ segments = [("", f"{event.get('title', '') or ''}\n{event.get('author', '') or ''}")]
173
+ segments += [(f["filename"], f"{f['filename']}\n{f['patch']}") for f in files]
174
+ segments.append(("", str(event.get("body", "") or "")))
175
+ return segments
176
+
177
+
162
178
  def raw_content(event: dict) -> str:
163
179
  """The concatenated untrusted content the host-side BASELINE scan runs on — TITLE + author +
164
180
  (filename + patch) per file + body. The title is included so a title-borne prompt injection is caught
165
181
  by the deterministic baseline even when the planner is skewed by the same payload (MF3): an issue/PR
166
182
  title is a canonical injection channel, and its hits must reach the signal regardless of the verdict."""
167
- files = _files(event)
168
- parts = [str(event.get("title", "") or ""), str(event.get("author", "") or "")]
169
- parts += [f"{f['filename']}\n{f['patch']}" for f in files]
170
- parts.append(str(event.get("body", "") or ""))
171
- return "\n".join(parts)
183
+ return "\n".join(text for _, text in content_segments(event))
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "diff-sentry"
3
- version = "0.4.0"
3
+ version = "0.4.2"
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"