diff-sentry 0.4.1__tar.gz → 0.4.3__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.1 → diff_sentry-0.4.3}/.github/workflows/ci.yml +7 -7
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/.github/workflows/release.yml +2 -2
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/CHANGELOG.md +104 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/CLAUDE.md +29 -4
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/PKG-INFO +4 -4
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/README.md +2 -2
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/action.yml +2 -2
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/diff_sentry/__init__.py +4 -2
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/diff_sentry/cli.py +4 -5
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/diff_sentry/config.py +2 -1
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/diff_sentry/indicators.py +102 -9
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/diff_sentry/normalize.py +17 -5
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/pyproject.toml +3 -3
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/studio/diff_sentry_studio/iterations.py +12 -3
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/studio/diff_sentry_studio/live.py +3 -1
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/studio/tests/test_iterations.py +12 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/tests/corpus/change_corpus.json +28 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/tests/test_detection_quality.py +5 -4
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/tests/test_indicators.py +120 -1
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/uv.lock +11 -317
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/.claude/rules/handoff.md +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/.env.example +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/.gitignore +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/.python-version +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/LICENSE +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/VENDOR.md +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/diff_sentry/__main__.py +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/diff_sentry/assemble.py +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/diff_sentry/deep_classify.py +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/diff_sentry/detect.py +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/diff_sentry/emit.py +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/diff_sentry/fetch_tool.py +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/diff_sentry/ingest.py +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/diff_sentry/response.py +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/diff_sentry/rl_export.py +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/diff_sentry/rubric.py +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/diff_sentry/schema.py +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/diff_sentry/skills/ci-injection-patterns.md +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/diff_sentry/skills/prompt-injection-catalog.md +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/diff_sentry/skills/triage-a-change.md +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/eval/README.md +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/eval/diff_sentry_eval/__init__.py +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/eval/diff_sentry_eval/__main__.py +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/eval/diff_sentry_eval/cli.py +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/eval/diff_sentry_eval/judge.py +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/eval/diff_sentry_eval/schema.py +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/eval/diff_sentry_eval/score.py +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/eval/diff_sentry_eval/taskset.py +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/eval/pyproject.toml +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/eval/taskset.example.json +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/eval/tests/conftest.py +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/eval/tests/test_boundary.py +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/eval/tests/test_cli.py +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/eval/tests/test_judge.py +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/eval/tests/test_schema.py +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/eval/tests/test_score.py +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/eval/tests/test_taskset.py +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/studio/DESIGN.md +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/studio/README.md +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/studio/diff_sentry_studio/__init__.py +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/studio/diff_sentry_studio/app.py +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/studio/diff_sentry_studio/mapper.py +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/studio/pyproject.toml +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/studio/static/app.js +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/studio/static/index.html +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/studio/static/replay-core.js +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/studio/static/run-core.js +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/studio/static/style.css +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/studio/static/trajectory.js +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/studio/static/vendor/fonts/jetbrains-mono-400.woff2 +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/studio/static/vendor/fonts/jetbrains-mono-700.woff2 +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/studio/tests/replay-core.test.js +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/studio/tests/run-core.test.js +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/studio/tests/static-contract.test.js +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/studio/tests/test_app.py +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/studio/tests/test_live.py +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/studio/tests/test_mapper.py +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/studio/tests/trajectory.test.js +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/tests/__init__.py +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/tests/conftest.py +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/tests/corpus/hackerbot_claw_incident.json +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/tests/fixtures/known-malicious.diff +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/tests/test_assemble.py +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/tests/test_cli.py +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/tests/test_config.py +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/tests/test_deep_classify.py +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/tests/test_detect.py +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/tests/test_emit.py +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/tests/test_export.py +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/tests/test_fetch_tool.py +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/tests/test_forward_offline.py +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/tests/test_incident_hackerbot_claw.py +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/tests/test_ingest.py +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/tests/test_injection_offline.py +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/tests/test_normalize.py +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/tests/test_provenance.py +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/tests/test_response.py +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/tests/test_rubric.py +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/tests/test_schema.py +0 -0
- {diff_sentry-0.4.1 → diff_sentry-0.4.3}/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
|
|
|
@@ -110,9 +110,9 @@ jobs:
|
|
|
110
110
|
name: consumer install path (PyPI)
|
|
111
111
|
runs-on: ubuntu-latest
|
|
112
112
|
steps:
|
|
113
|
-
- uses: actions/checkout@
|
|
113
|
+
- uses: actions/checkout@v7
|
|
114
114
|
- name: Install uv
|
|
115
|
-
uses: astral-sh/setup-uv@
|
|
115
|
+
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
|
116
116
|
with:
|
|
117
117
|
enable-cache: true
|
|
118
118
|
- name: The released package installs from PyPI and flags a malicious diff
|
|
@@ -134,9 +134,9 @@ jobs:
|
|
|
134
134
|
name: ruff
|
|
135
135
|
runs-on: ubuntu-latest
|
|
136
136
|
steps:
|
|
137
|
-
- uses: actions/checkout@
|
|
137
|
+
- uses: actions/checkout@v7
|
|
138
138
|
- name: Install uv
|
|
139
|
-
uses: astral-sh/setup-uv@
|
|
139
|
+
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
|
140
140
|
with:
|
|
141
141
|
enable-cache: true
|
|
142
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
|
|
@@ -5,6 +5,110 @@ 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.3
|
|
9
|
+
|
|
10
|
+
Dependencies. `rlm-harness` moves from `1.0.0` to `1.10.1` — ten minor releases of the kit — and `dspy`
|
|
11
|
+
from `3.2.1` to `3.3.1` with it. Nothing in the `diff_sentry` package changed: every kit name this
|
|
12
|
+
project imports was checked against 1.10.1 by name, both offline suites, the studio suite and the node
|
|
13
|
+
tests pass on the new pin, and a live classification ran clean. The one real risk, a doubled
|
|
14
|
+
`sub_call`, was tested for before the bump and is not there (below).
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
- **`rlm-harness==1.10.1`, `dspy` 3.3.1.** The pin stays exact. dspy 3.3 drops `asyncer`, `typeguard`,
|
|
18
|
+
`numpy` and `xxhash` from its exact pins, so a fresh install resolves fewer packages, not more.
|
|
19
|
+
- **The trace carries more of the run; nothing here reads it differently.** `run_start` records the kit
|
|
20
|
+
version that wrote it (`rlm_harness`); `run_end` gains `budgets` (the generation cap actually APPLIED
|
|
21
|
+
per role, plus the iteration caps and whether dspy accepted them — `dropped: true` means all three
|
|
22
|
+
reverted to dspy's defaults, so exclude those runs before any claim about iteration exhaustion),
|
|
23
|
+
`usage` (completion tokens per ATTEMPT, not per turn — a distribution over runs wants the per-run
|
|
24
|
+
`max`, never the sum) and `error_chain` (the causes below a failed run's outer exception). Every tool
|
|
25
|
+
call and `sub_call` carries a measured `duration_s`. `run_labels`, `run_metrics`, the rubric facts
|
|
26
|
+
and the SIEM signal derivation are untouched; the new fields ride along for a future reader.
|
|
27
|
+
- **A non-retryable LM error fails fast, as itself.** An auth / billing / configuration /
|
|
28
|
+
unsupported-model error from dspy escapes the kit after ONE attempt as the original `dspy.LMError`
|
|
29
|
+
subclass instead of burning the retry budget and arriving wrapped in `RLMTaskError`. `cli.run` already
|
|
30
|
+
turns every exception into a `status=failed` response, so the never-raises contract holds; the
|
|
31
|
+
response now names the dspy class, which tells "fix the credential" apart from "the model kept
|
|
32
|
+
producing invalid output". `RLMTaskError` still means the latter, or a retryable endpoint fault
|
|
33
|
+
(timeout / 5xx / transport) that outlasted the single attempt.
|
|
34
|
+
- **The studio's "took" label on a tool call reads the kit's own timing.** The kit now measures each
|
|
35
|
+
call; the console used to estimate a tool's duration as the gap since the previous live event, which
|
|
36
|
+
charged the whole preceding planner turn to a sub-millisecond scan. It prefers the recorded
|
|
37
|
+
`duration_s` and falls back to the gap for a trace that predates it.
|
|
38
|
+
|
|
39
|
+
### Fixed (upstream, reaching this project)
|
|
40
|
+
- **An analyst escalation is recorded once.** Since kit 1.7.0 a plain `sub_lm` is wrapped for tracing
|
|
41
|
+
automatically. This project already wraps the analyst with `intercept_sub_lm`, whose wrapper declares
|
|
42
|
+
`records_sub_call = True`; the kit honours that opt-out and leaves it alone, so a `sub_call` lands in
|
|
43
|
+
the trace — and in the exported datasets — exactly once. Verified at runtime and on a live run.
|
|
44
|
+
- **`intercept_sub_lm` handles dspy's typed `LMResponse`.** Under dspy 3.3 a sub-LM can return an
|
|
45
|
+
`LMResponse` rather than a list of strings, and the 1.0.0 wrapper mishandled it. The analyst intercept
|
|
46
|
+
is that wrapper.
|
|
47
|
+
- **A lone surrogate in a payload no longer loses the event.** The change under review is
|
|
48
|
+
attacker-authored text; a payload carrying an unpaired surrogate used to fail the recorder's JSON dump
|
|
49
|
+
and drop the event. It is written now.
|
|
50
|
+
|
|
51
|
+
### Docs
|
|
52
|
+
- **`CLAUDE.md` names all five version sites** and which of them `release.yml` gates (the README's
|
|
53
|
+
`uses:` example is the one it does not), and its `RLMTaskError` guidance matches the fail-fast
|
|
54
|
+
behaviour above.
|
|
55
|
+
|
|
56
|
+
## 0.4.2
|
|
57
|
+
|
|
58
|
+
Scoping. Every fix here comes from the same root: the scan read a whole change as one
|
|
59
|
+
string, so rules that need two signals could take them from two unrelated files.
|
|
60
|
+
|
|
61
|
+
### Fixed
|
|
62
|
+
- **The scan is scoped per file.** `scan_indicators` reads whatever it is handed as one string, and the
|
|
63
|
+
action hands it the whole `git diff`. Most rules fire on a single match, so that was harmless — but
|
|
64
|
+
two need a PAIR of signals (`pwn-request`: privileged trigger + PR-HEAD checkout;
|
|
65
|
+
`detached-process-spawn`: detached spawn + `child_process`), and a whole-blob read let those pair
|
|
66
|
+
ACROSS FILES. A `workflow_run:` added to one workflow plus a `ref: ${{ ... head.sha }}` sitting in a
|
|
67
|
+
doc example, a test fixture, or the very workflow a change is *deleting*, composed into a `critical`
|
|
68
|
+
that no single file contained. The new `scan_diff` splits a unified diff on its file headers and
|
|
69
|
+
scans each segment on its own; non-diff input (a region the planner pulled out of the REPL, a plain
|
|
70
|
+
file) falls through to the previous whole-text scan. Used by `scan` and by the in-loop
|
|
71
|
+
`scan_indicators` tool.
|
|
72
|
+
- **The host-side baseline is scoped per file too, via `scan_content`.** An event is not a unified diff:
|
|
73
|
+
`raw_content` concatenates the title, every `(filename, patch)` and the body with no `diff --git`
|
|
74
|
+
headers, so pointing the baseline at `scan_diff` would have scoped nothing at all. `scan_content`
|
|
75
|
+
splits on the structure the event already carries — and gives hits from a file that file's name. The
|
|
76
|
+
case is not hypothetical: a PR that adds a `workflow_run` workflow AND documents the trap in the same
|
|
77
|
+
commit is what a careful repo writes, and read as one blob it came out `critical`. `content_segments`
|
|
78
|
+
is now the single definition `raw_content` is built on, so the two cannot drift, and the
|
|
79
|
+
detection-quality corpus was moved onto the production path so it guards what actually ships.
|
|
80
|
+
|
|
81
|
+
### Changed
|
|
82
|
+
- **`astral-sh/setup-uv` bumped to v10.0.1** (from v5.4.2) and **`actions/checkout` to v7**, both for
|
|
83
|
+
the Node 24 runtime — between them they clear GitHub's Node 20 deprecation warning. The setup-uv bump
|
|
84
|
+
covers `action.yml` too, so it reaches every repo running this action, not just this one's CI. Five
|
|
85
|
+
majors in one
|
|
86
|
+
jump, so what actually applies here was checked: v8 stopped publishing major tags (this repo already
|
|
87
|
+
SHA-pins), v9 flipped the `prune-cache` default, and v10 disables caching for `release` /
|
|
88
|
+
`workflow_run` / `pull_request_target` events under `enable-cache: auto` — CI passes `enable-cache:
|
|
89
|
+
true` explicitly and keeps its cache, and the release job never wanted one.
|
|
90
|
+
|
|
91
|
+
checkout v7 also **blocks checking out a fork PR head under `pull_request_target` / `workflow_run`** —
|
|
92
|
+
the `pwn-request` shape this project exists to detect, now refused by the action itself. Nothing here
|
|
93
|
+
checks out a PR head, so the bump is behaviour-neutral for these workflows and strictly better posture
|
|
94
|
+
for a detector to be running.
|
|
95
|
+
|
|
96
|
+
### Added
|
|
97
|
+
- **`pwn-request` covers `allow-unsafe-pr-checkout: true`.** actions/checkout v5.1/v6.1/v7 refuse to
|
|
98
|
+
check out a fork PR head under `pull_request_target` / `workflow_run` unless a workflow opts back in
|
|
99
|
+
with that input. The opt-in is the same attack stated in words — no ref to trace, no dataflow to
|
|
100
|
+
infer — so it joins the `ref:` value and the hand-rolled `git fetch` as the rule's third form, with
|
|
101
|
+
its own title so a reader is not sent hunting for a `ref:` that is not there. It only counts under a
|
|
102
|
+
privileged trigger (beneath `pull_request` the input carries none of that meaning), a removed opt-in
|
|
103
|
+
does not fire, and neither does an explicit `false`.
|
|
104
|
+
- **Hits name the file they came from.** `location` was the name of the whole diff — `gated.diff` for
|
|
105
|
+
every hit, which meant reading the raw log to find out where a finding actually was. Per-file
|
|
106
|
+
scanning gives each hit its real path.
|
|
107
|
+
- **A hit id is now stable between a whole-diff scan and a single-file scan.** The segment the planner
|
|
108
|
+
pulls out is byte-identical to the one the baseline scanned, so the evidence snippets — and therefore
|
|
109
|
+
the ids — match, and the two de-duplicate to one union member on read. Whole-blob scanning could not
|
|
110
|
+
promise that: the snippet window moved with the byte offset.
|
|
111
|
+
|
|
8
112
|
## 0.4.1
|
|
9
113
|
|
|
10
114
|
A correctness release for one rule: `pwn-request` was failing the very pattern this
|
|
@@ -4,7 +4,7 @@ A **BewAIre-style malicious-change detector** built on `rlm-harness` — a downs
|
|
|
4
4
|
scaffold. It classifies ONE GitHub change (PR/issue/push)
|
|
5
5
|
for malicious intent: the diff is UNTRUSTED DATA held in a sandboxed REPL, the planner SUBMITs a
|
|
6
6
|
judgement-only verdict, and the deterministic indicator EVIDENCE is unioned on read into a SIEM signal.
|
|
7
|
-
rlm-harness is consumed as an **exact PyPI pin** (`rlm-harness==1.
|
|
7
|
+
rlm-harness is consumed as an **exact PyPI pin** (`rlm-harness==1.10.1`, resolved from the index and
|
|
8
8
|
recorded in `uv.lock`); overlay `uv pip install -e ../rlm-harness` only when co-developing the kit
|
|
9
9
|
locally, and bump the pin once the change ships. See
|
|
10
10
|
`README.md` for the pipeline table + the honest caveats (prompt-injection residual, deep-tier scale),
|
|
@@ -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
|
|
@@ -108,8 +124,12 @@ One companion rule ships under `.claude/rules/`:
|
|
|
108
124
|
`DS_CLASSIFIER_LM` classifier, defaulting to the analyst). Refer to them by role in code, docs, and
|
|
109
125
|
the prompt. No hardcoded model name.
|
|
110
126
|
- **The budget is HARD — `max_retries=1`, no whole-RLM retry** (set in `detect.setup`). One change =
|
|
111
|
-
one trajectory, so the trace stays valid training data.
|
|
112
|
-
|
|
127
|
+
one trajectory, so the trace stays valid training data. A failed run is INFRA before it is a schema
|
|
128
|
+
bug — check the endpoint first. Since rlm-harness 1.2.1 the failure names itself: a non-retryable
|
|
129
|
+
LM error (auth / billing / configuration / unsupported model) escapes after ONE attempt as the raw
|
|
130
|
+
`dspy.LMError` subclass, while an `RLMTaskError` means the single attempt ended in an adapter parse
|
|
131
|
+
failure, an invalid result, or a retryable endpoint fault (timeout / 5xx / transport). `cli.run`
|
|
132
|
+
catches BOTH into a `status=failed` response.
|
|
113
133
|
- **A second model-judgement is a TOOL, never the sub-LM.** `deep_classify` is the swappable
|
|
114
134
|
second-stage SEAM, built on rlm-harness's `make_model_tool` (chat → transient-retry → validate →
|
|
115
135
|
circuit-break) — the planner CHOOSES to consult it, so the decision is a `tool_call` in the
|
|
@@ -198,4 +218,9 @@ One companion rule ships under `.claude/rules/`:
|
|
|
198
218
|
|
|
199
219
|
## Versioning
|
|
200
220
|
|
|
201
|
-
-
|
|
221
|
+
- A release moves FIVE version sites together: `pyproject.toml` `[project].version`,
|
|
222
|
+
`diff_sentry.__version__`, `action.yml`'s `version` input default, the README quickstart's
|
|
223
|
+
`uses: qazbnm456/diff-sentry@vX.Y.Z`, and `uv.lock` (via `uv lock`) — plus a `CHANGELOG.md` entry.
|
|
224
|
+
`release.yml` gates the first three against the tag and the built wheel; the README `uses:` line is
|
|
225
|
+
gated by nothing, so a stale one sends new adopters to the previous release. Publishing the GitHub
|
|
226
|
+
Release (tag `vX.Y.Z`) is what builds, verifies, and uploads to PyPI, and what Marketplace lists.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: diff-sentry
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.3
|
|
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
|
|
@@ -9,7 +9,7 @@ Keywords: dspy,malicious-code-detection,prompt-injection,rlm,security,siem,suppl
|
|
|
9
9
|
Requires-Python: >=3.11
|
|
10
10
|
Requires-Dist: httpx>=0.27
|
|
11
11
|
Requires-Dist: pydantic>=2.0
|
|
12
|
-
Requires-Dist: rlm-harness==1.
|
|
12
|
+
Requires-Dist: rlm-harness==1.10.1
|
|
13
13
|
Provides-Extra: classify
|
|
14
14
|
Requires-Dist: openai>=1.0; extra == 'classify'
|
|
15
15
|
Provides-Extra: observe
|
|
@@ -44,7 +44,7 @@ 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@v0.4.
|
|
47
|
+
- uses: qazbnm456/diff-sentry@v0.4.3
|
|
48
48
|
```
|
|
49
49
|
|
|
50
50
|
That is the whole setup. The job fails when an indicator at or above `high` fires.
|
|
@@ -53,7 +53,7 @@ That is the whole setup. The job fails when an indicator at or above `high` fire
|
|
|
53
53
|
|
|
54
54
|
| Family | Examples |
|
|
55
55
|
|---|---|
|
|
56
|
-
| 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 |
|
|
57
57
|
| Obfuscation | base64 that decodes to a shell payload, `_0x…` machine-mangled JavaScript, high-entropy blobs |
|
|
58
58
|
| Execution | pipe-to-shell installs, `${IFS}` space-evasion, detached child processes, inline `node -e`, fetch-to-disk droppers |
|
|
59
59
|
| Exfiltration | secrets reaching a network sink, cross-process `/proc/<pid>/mem` reads, OAST callback services, IPFS and permaweb gateways |
|
|
@@ -23,7 +23,7 @@ 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@v0.4.
|
|
26
|
+
- uses: qazbnm456/diff-sentry@v0.4.3
|
|
27
27
|
```
|
|
28
28
|
|
|
29
29
|
That is the whole setup. The job fails when an indicator at or above `high` fires.
|
|
@@ -32,7 +32,7 @@ That is the whole setup. The job fails when an indicator at or above `high` fire
|
|
|
32
32
|
|
|
33
33
|
| Family | Examples |
|
|
34
34
|
|---|---|
|
|
35
|
-
| 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 |
|
|
36
36
|
| Obfuscation | base64 that decodes to a shell payload, `_0x…` machine-mangled JavaScript, high-entropy blobs |
|
|
37
37
|
| Execution | pipe-to-shell installs, `${IFS}` space-evasion, detached child processes, inline `node -e`, fetch-to-disk droppers |
|
|
38
38
|
| Exfiltration | secrets reaching a network sink, cross-process `/proc/<pid>/mem` reads, OAST callback services, IPFS and permaweb gateways |
|
|
@@ -44,7 +44,7 @@ inputs:
|
|
|
44
44
|
# KEEP IN STEP WITH [project].version. release.yml fails the build if this drifts
|
|
45
45
|
# from the tag being released, because a stale default here would silently run an
|
|
46
46
|
# old scanner under a new action tag.
|
|
47
|
-
default: '0.4.
|
|
47
|
+
default: '0.4.3'
|
|
48
48
|
|
|
49
49
|
outputs:
|
|
50
50
|
failed:
|
|
@@ -69,7 +69,7 @@ runs:
|
|
|
69
69
|
- name: Install uv
|
|
70
70
|
# SHA-pinned: a moving major tag on a third-party action could be repointed at code that runs
|
|
71
71
|
# with the calling workflow's token. Bump the SHA and the trailing version comment together.
|
|
72
|
-
uses: astral-sh/setup-uv@
|
|
72
|
+
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
|
73
73
|
with:
|
|
74
74
|
enable-cache: true
|
|
75
75
|
|
|
@@ -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.3"
|
|
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
|
|
@@ -72,7 +72,8 @@ class DetectConfig:
|
|
|
72
72
|
# Generous, NOT None: a reasoning planner truncated before its answer returns empty content.
|
|
73
73
|
planner_max_tokens: int | None = 16384
|
|
74
74
|
# HARD ceiling on the single RLM episode. No outer multi-run loop (max_retries=1) — one change =
|
|
75
|
-
# one trajectory, so the trace stays valid training data.
|
|
75
|
+
# one trajectory, so the trace stays valid training data. A failed run is INFRA, not a schema bug:
|
|
76
|
+
# a non-retryable LM error escapes as the raw dspy.LMError (kit >= 1.2.1), the rest as RLMTaskError.
|
|
76
77
|
max_iterations: int = 25
|
|
77
78
|
max_llm_calls: int = 8 # caps ONLY analyst (llm_query) escalations
|
|
78
79
|
max_output_chars: int = 10_000 # head+tail char cap dspy.RLM applies to each REPL output
|
|
@@ -156,6 +156,17 @@ _HEAD_BINDING_RE = re.compile(
|
|
|
156
156
|
rf"^[+ ]?\s*([A-Za-z_][A-Za-z0-9_-]*)\s*[:=]\s*[^\n]*(?:{_PR_HEAD_EXPR})",
|
|
157
157
|
re.IGNORECASE | re.MULTILINE)
|
|
158
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)
|
|
159
170
|
_IDENT_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_-]*")
|
|
160
171
|
|
|
161
172
|
# Diff-PRESENTATION evasion — attacks aimed at the human reading the diff, not at the runtime. Miasma
|
|
@@ -308,18 +319,31 @@ def _scan_workflow_perms(text: str) -> list[IndicatorHit]:
|
|
|
308
319
|
for m in _WRITE_ALL_RE.finditer(text)]
|
|
309
320
|
|
|
310
321
|
|
|
311
|
-
|
|
312
|
-
|
|
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
|
|
313
337
|
direct = _PR_HEAD_CHECKOUT_RE.search(text)
|
|
314
338
|
if direct:
|
|
315
|
-
return direct
|
|
339
|
+
return direct, _PWN_TITLE
|
|
316
340
|
# `ref:` is not a binding — a `ref:` holding the expression IS the direct case above.
|
|
317
341
|
bound = {m.group(1).lower() for m in _HEAD_BINDING_RE.finditer(text)} - {"ref"}
|
|
318
342
|
if not bound:
|
|
319
343
|
return None
|
|
320
344
|
for m in _REF_VALUE_RE.finditer(text):
|
|
321
345
|
if any(name.lower() in bound for name in _IDENT_RE.findall(m.group(1))):
|
|
322
|
-
return m
|
|
346
|
+
return m, _PWN_TITLE
|
|
323
347
|
return None
|
|
324
348
|
|
|
325
349
|
|
|
@@ -330,10 +354,9 @@ def _scan_workflow_config(text: str) -> list[IndicatorHit]:
|
|
|
330
354
|
return []
|
|
331
355
|
checkout = _pr_head_checkout(text)
|
|
332
356
|
if checkout:
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
_snip(text, checkout.start(), checkout.end()))]
|
|
357
|
+
match, title = checkout
|
|
358
|
+
return [_hit("pwn-request", "critical", title,
|
|
359
|
+
_snip(text, match.start(), match.end()))]
|
|
337
360
|
head = _PR_HEAD_REF_RE.search(text)
|
|
338
361
|
if head:
|
|
339
362
|
# Reads the head ref without checking it out. That is the validating-publisher shape, which is
|
|
@@ -442,6 +465,72 @@ def _sev_rank(sev: str) -> int:
|
|
|
442
465
|
return severity_rank(sev)
|
|
443
466
|
|
|
444
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
|
+
|
|
445
534
|
def scan_indicators(text: str, *, location: str = "") -> list[IndicatorHit]:
|
|
446
535
|
"""Run every detector over `text` and return the DEDUPED union of hits (by deterministic id).
|
|
447
536
|
|
|
@@ -589,7 +678,11 @@ def make_indicator_tool() -> Callable[[str], str]:
|
|
|
589
678
|
injection, obfuscated payloads, secret exfiltration, known exfil/OAST callback services,
|
|
590
679
|
CI/CODEOWNERS tampering, workflow permission escalation, CI-skip bypass, prompt injection. Returns
|
|
591
680
|
the hits found (id, rule, severity, title). Cite an id in your final `indicator_ids`."""
|
|
592
|
-
|
|
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 "")
|
|
593
686
|
record_tool_call("scan_indicators", args={"region": (region or "")[:200]}, ok=True,
|
|
594
687
|
hits=[h.model_dump() for h in hits], n=len(hits))
|
|
595
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.3"
|
|
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"
|
|
@@ -8,8 +8,8 @@ license = "MIT"
|
|
|
8
8
|
authors = [{ name = "Boik Su" }]
|
|
9
9
|
keywords = ["rlm", "dspy", "security", "malicious-code-detection", "supply-chain-security", "prompt-injection", "siem"]
|
|
10
10
|
dependencies = [
|
|
11
|
-
# Pinned exactly
|
|
12
|
-
"rlm-harness==1.
|
|
11
|
+
# Pinned exactly (the kit's public surface has been SemVer-frozen since 1.0.0); bump deliberately.
|
|
12
|
+
"rlm-harness==1.10.1",
|
|
13
13
|
"pydantic>=2.0",
|
|
14
14
|
"httpx>=0.27", # the GitHub-allowlisted enrichment fetcher + the host-side SIEM emitter
|
|
15
15
|
]
|
|
@@ -57,11 +57,19 @@ def _gap(ts: float | None, prev: float | None) -> float | None:
|
|
|
57
57
|
return round(ts - prev, 3) if (ts is not None and prev is not None) else None
|
|
58
58
|
|
|
59
59
|
|
|
60
|
+
def _took(p: dict, gap: float | None) -> float | None:
|
|
61
|
+
"""How long a tool / analyst call took. rlm-harness >= 1.8.3 measures every call and writes
|
|
62
|
+
`payload.duration_s`; prefer that. The gap since the previous live event is only a FALLBACK for a
|
|
63
|
+
trace that predates it — it charges the whole preceding planner turn to a sub-millisecond scan."""
|
|
64
|
+
d = p.get("duration_s")
|
|
65
|
+
return round(float(d), 6) if isinstance(d, (int, float)) and not isinstance(d, bool) else gap
|
|
66
|
+
|
|
67
|
+
|
|
60
68
|
def _tool_entry(p: dict, gap: float | None) -> dict:
|
|
61
69
|
"""One tool_call → a UI-ready entry: a label, the input it was given, the output it returned."""
|
|
62
70
|
tool = p.get("tool")
|
|
63
71
|
args = p.get("args") or {}
|
|
64
|
-
e: dict = {"kind": "tool", "tool": tool, "ok": p.get("ok"), "duration_s": gap}
|
|
72
|
+
e: dict = {"kind": "tool", "tool": tool, "ok": p.get("ok"), "duration_s": _took(p, gap)}
|
|
65
73
|
if tool == "scan_indicators":
|
|
66
74
|
hits = p.get("hits") or []
|
|
67
75
|
e.update(label="scan", target=_preview(args.get("region")), n=p.get("n") or len(hits),
|
|
@@ -92,7 +100,7 @@ def _tool_entry(p: dict, gap: float | None) -> dict:
|
|
|
92
100
|
def _sub_entry(p: dict, gap: float | None) -> dict:
|
|
93
101
|
"""One sub_call (an analyst escalation) → the distilled question + the answer it returned."""
|
|
94
102
|
return {"kind": "analyst", "label": "analyst", "model": p.get("name") or p.get("model"),
|
|
95
|
-
"duration_s": gap, "input": _preview(p.get("input")),
|
|
103
|
+
"duration_s": _took(p, gap), "input": _preview(p.get("input")),
|
|
96
104
|
"output": _preview(p.get("processed") or p.get("raw")), "error": p.get("error")}
|
|
97
105
|
|
|
98
106
|
|
|
@@ -141,7 +149,8 @@ def build_iterations(events: list[dict]) -> dict:
|
|
|
141
149
|
finalize, so their ts cluster at one instant; we detect that, set `per_turn_timing=False`, and skip
|
|
142
150
|
per-turn durations rather than fake them.
|
|
143
151
|
- `timeline` — the `tool_call`/`sub_call` events, ALWAYS recorded LIVE with real `ts`. Each entry
|
|
144
|
-
carries `rel_s` (since run start) and `duration_s` (the
|
|
152
|
+
carries `rel_s` (since run start) and `duration_s` (the kit's measured call time, else the gap
|
|
153
|
+
since the previous live event). The
|
|
145
154
|
accurate "where did the time go" signal either way, and the headline for debugging a slow run.
|
|
146
155
|
"""
|
|
147
156
|
evs = sorted(events, key=_step_key)
|
|
@@ -123,7 +123,9 @@ def _describe_exc(exc: BaseException) -> str:
|
|
|
123
123
|
"""One line describing a run failure, INCLUDING its underlying cause. `RLMTaskError` reports the
|
|
124
124
|
opaque "Failed to produce a valid 'result' after N attempts"; the real reason (a planner-endpoint
|
|
125
125
|
error, an adapter parse failure) is on `__cause__`. Surfacing it stops an infra hiccup from reading
|
|
126
|
-
like a content/schema problem (diff-sentry's own CLAUDE.md:
|
|
126
|
+
like a content/schema problem (diff-sentry's own CLAUDE.md: a failed run is infra before it is a
|
|
127
|
+
schema bug). A non-retryable LM error (rlm-harness >= 1.2.1) arrives unwrapped as the raw
|
|
128
|
+
`dspy.LMError` subclass, so its own class name already says "fix the credential"."""
|
|
127
129
|
out = f"{type(exc).__name__}: {exc}"
|
|
128
130
|
cause = exc.__cause__ or exc.__context__
|
|
129
131
|
if cause is not None and cause is not exc:
|