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.
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/.github/workflows/ci.yml +33 -5
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/.github/workflows/release.yml +11 -6
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/CHANGELOG.md +86 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/CLAUDE.md +16 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/PKG-INFO +6 -7
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/README.md +4 -5
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/action.yml +11 -7
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/diff_sentry/__init__.py +4 -2
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/diff_sentry/cli.py +4 -5
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/diff_sentry/indicators.py +151 -7
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/diff_sentry/normalize.py +17 -5
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/pyproject.toml +1 -1
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/tests/corpus/change_corpus.json +28 -0
- diff_sentry-0.4.2/tests/fixtures/known-malicious.diff +1 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/tests/test_detection_quality.py +5 -4
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/tests/test_indicators.py +165 -1
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/uv.lock +1 -1
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/.claude/rules/handoff.md +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/.env.example +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/.gitignore +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/.python-version +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/LICENSE +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/VENDOR.md +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/diff_sentry/__main__.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/diff_sentry/assemble.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/diff_sentry/config.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/diff_sentry/deep_classify.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/diff_sentry/detect.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/diff_sentry/emit.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/diff_sentry/fetch_tool.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/diff_sentry/ingest.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/diff_sentry/response.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/diff_sentry/rl_export.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/diff_sentry/rubric.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/diff_sentry/schema.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/diff_sentry/skills/ci-injection-patterns.md +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/diff_sentry/skills/prompt-injection-catalog.md +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/diff_sentry/skills/triage-a-change.md +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/eval/README.md +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/eval/diff_sentry_eval/__init__.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/eval/diff_sentry_eval/__main__.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/eval/diff_sentry_eval/cli.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/eval/diff_sentry_eval/judge.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/eval/diff_sentry_eval/schema.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/eval/diff_sentry_eval/score.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/eval/diff_sentry_eval/taskset.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/eval/pyproject.toml +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/eval/taskset.example.json +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/eval/tests/conftest.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/eval/tests/test_boundary.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/eval/tests/test_cli.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/eval/tests/test_judge.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/eval/tests/test_schema.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/eval/tests/test_score.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/eval/tests/test_taskset.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/studio/DESIGN.md +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/studio/README.md +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/studio/diff_sentry_studio/__init__.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/studio/diff_sentry_studio/app.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/studio/diff_sentry_studio/iterations.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/studio/diff_sentry_studio/live.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/studio/diff_sentry_studio/mapper.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/studio/pyproject.toml +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/studio/static/app.js +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/studio/static/index.html +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/studio/static/replay-core.js +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/studio/static/run-core.js +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/studio/static/style.css +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/studio/static/trajectory.js +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/studio/static/vendor/fonts/jetbrains-mono-400.woff2 +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/studio/static/vendor/fonts/jetbrains-mono-700.woff2 +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/studio/tests/replay-core.test.js +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/studio/tests/run-core.test.js +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/studio/tests/static-contract.test.js +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/studio/tests/test_app.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/studio/tests/test_iterations.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/studio/tests/test_live.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/studio/tests/test_mapper.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/studio/tests/trajectory.test.js +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/tests/__init__.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/tests/conftest.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/tests/corpus/hackerbot_claw_incident.json +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/tests/test_assemble.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/tests/test_cli.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/tests/test_config.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/tests/test_deep_classify.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/tests/test_detect.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/tests/test_emit.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/tests/test_export.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/tests/test_fetch_tool.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/tests/test_forward_offline.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/tests/test_incident_hackerbot_claw.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/tests/test_ingest.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/tests/test_injection_offline.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/tests/test_normalize.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/tests/test_provenance.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/tests/test_response.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/tests/test_rubric.py +0 -0
- {diff_sentry-0.4.0 → diff_sentry-0.4.2}/tests/test_schema.py +0 -0
- {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@
|
|
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@
|
|
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@
|
|
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@
|
|
137
|
+
- uses: actions/checkout@v7
|
|
110
138
|
- name: Install uv
|
|
111
|
-
uses: astral-sh/setup-uv@
|
|
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@
|
|
29
|
+
- uses: actions/checkout@v7
|
|
30
30
|
|
|
31
31
|
- name: Install uv
|
|
32
|
-
uses: astral-sh/setup-uv@
|
|
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
|
-
|
|
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,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.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: diff-sentry
|
|
3
|
-
Version: 0.4.
|
|
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@
|
|
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.
|
|
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)
|
|
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` |
|
|
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@
|
|
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.
|
|
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)
|
|
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` |
|
|
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.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@
|
|
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
|
|
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.
|
|
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
|
|
95
|
-
from .normalize import raw_content
|
|
94
|
+
from .indicators import scan_content, scan_provenance
|
|
96
95
|
|
|
97
|
-
return ([h.model_dump() for h in
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
+
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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"
|