diff-sentry 0.7.0__tar.gz → 0.8.0__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.7.0 → diff_sentry-0.8.0}/.claude/rules/detect-setup.md +3 -2
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/.claude/rules/indicators.md +12 -2
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/.claude/rules/rubric-and-eval.md +1 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/.claude/skills/release/SKILL.md +3 -1
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/.github/workflows/ci.yml +3 -3
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/.github/workflows/release.yml +6 -6
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/AGENTS.md +1 -1
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/CHANGELOG.md +29 -0
- diff_sentry-0.8.0/PKG-INFO +116 -0
- diff_sentry-0.8.0/README.md +95 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/action.yml +1 -1
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/__init__.py +3 -3
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/actors.py +1 -1
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/assemble.py +5 -5
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/cli.py +14 -14
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/config.py +8 -8
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/deep_classify.py +23 -6
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/detect.py +9 -9
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/emit.py +6 -6
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/fetch_tool.py +4 -4
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/indicators.py +245 -68
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/ingest.py +22 -22
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/normalize.py +9 -9
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/response.py +5 -5
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/rl_export.py +11 -11
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/rubric.py +16 -16
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/schema.py +11 -11
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/snapshot.py +1 -1
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/watch.py +11 -11
- diff_sentry-0.8.0/docs/development.md +14 -0
- diff_sentry-0.8.0/docs/how-it-works.md +58 -0
- diff_sentry-0.8.0/docs/limits.md +17 -0
- diff_sentry-0.8.0/docs/watch.md +43 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/eval/diff_sentry_eval/__init__.py +3 -3
- diff_sentry-0.8.0/eval/diff_sentry_eval/__main__.py +6 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/eval/diff_sentry_eval/cli.py +3 -3
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/eval/diff_sentry_eval/judge.py +35 -16
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/eval/diff_sentry_eval/schema.py +7 -7
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/eval/diff_sentry_eval/score.py +7 -7
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/eval/diff_sentry_eval/taskset.py +6 -6
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/eval/tests/conftest.py +1 -1
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/eval/tests/test_boundary.py +1 -1
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/eval/tests/test_cli.py +1 -1
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/eval/tests/test_judge.py +27 -1
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/eval/tests/test_schema.py +1 -1
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/eval/tests/test_score.py +1 -1
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/eval/tests/test_taskset.py +2 -2
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/pyproject.toml +10 -10
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/studio/README.md +1 -1
- diff_sentry-0.8.0/studio/diff_sentry_studio/__init__.py +1 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/studio/diff_sentry_studio/app.py +17 -17
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/studio/diff_sentry_studio/iterations.py +14 -14
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/studio/diff_sentry_studio/live.py +16 -16
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/studio/diff_sentry_studio/mapper.py +11 -11
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/studio/pyproject.toml +3 -3
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/studio/static/app.js +16 -16
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/studio/static/index.html +1 -1
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/studio/static/replay-core.js +3 -3
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/studio/static/run-core.js +9 -9
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/studio/static/style.css +7 -7
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/studio/static/trajectory.js +2 -2
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/studio/tests/static-contract.test.js +2 -2
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/studio/tests/test_app.py +3 -3
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/studio/tests/test_iterations.py +2 -2
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/studio/tests/test_live.py +5 -5
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/studio/tests/test_mapper.py +5 -5
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/conftest.py +29 -4
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_assemble.py +2 -2
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_cli.py +9 -9
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_cli_watch.py +1 -1
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_config.py +2 -2
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_deep_classify.py +37 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_detect.py +2 -2
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_detection_quality.py +8 -8
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_export.py +5 -5
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_fetch_tool.py +1 -1
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_forward_offline.py +5 -5
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_grounding.py +34 -10
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_incident_hackerbot_claw.py +6 -6
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_indicators.py +18 -18
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_ingest.py +1 -1
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_injection_offline.py +5 -5
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_inline_exec_and_bidi.py +0 -9
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_metadata_rules.py +1 -1
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_provenance.py +5 -5
- diff_sentry-0.8.0/tests/test_python_loader.py +220 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_response.py +4 -4
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_rubric.py +2 -2
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_schema.py +1 -1
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_subscription.py +2 -2
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/uv.lock +5 -5
- diff_sentry-0.7.0/PKG-INFO +0 -214
- diff_sentry-0.7.0/README.md +0 -193
- diff_sentry-0.7.0/eval/diff_sentry_eval/__main__.py +0 -6
- diff_sentry-0.7.0/studio/diff_sentry_studio/__init__.py +0 -1
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/.claude/rules/handoff.md +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/.claude/rules/monitoring.md +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/.env.example +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/.gitignore +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/.python-version +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/LICENSE +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/VENDOR.md +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/__main__.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/dbutil.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/hosts.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/linewide.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/shelllex.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/skills/ci-and-workflows.md +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/skills/concealment-and-provenance.md +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/skills/developer-autorun.md +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/skills/llm-directed-text.md +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/skills/package-and-build-hooks.md +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/skills/runtime-droppers-and-exfil.md +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/skills/triage-a-change.md +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/eval/README.md +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/eval/pyproject.toml +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/eval/taskset.example.json +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/studio/DESIGN.md +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/studio/static/vendor/fonts/jetbrains-mono-400.woff2 +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/studio/static/vendor/fonts/jetbrains-mono-700.woff2 +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/studio/tests/replay-core.test.js +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/studio/tests/run-core.test.js +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/studio/tests/trajectory.test.js +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/__init__.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/corpus/change_corpus.json +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/corpus/hackerbot_claw_campaign.json +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/corpus/hackerbot_claw_incident.json +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/fixtures/known-malicious.diff +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/oracle/__init__.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/oracle/grammar.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/oracle/harness.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/oracle/lexrunner.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/oracle/programs.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/oracle/runner.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_actors.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_adversarial_input.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_campaign_replay.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_dogfood_gate.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_emit.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_enclosure.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_file_scope.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_file_structure.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_gate_edges.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_hosts.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_ingest_metadata.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_linewide.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_normalize.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_oracle.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_oracle_harness.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_shelllex.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_snapshot.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_watch.py +0 -0
- {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_watch_pool.py +0 -0
|
@@ -11,9 +11,10 @@ paths:
|
|
|
11
11
|
# Model roles, auth, and run failures
|
|
12
12
|
|
|
13
13
|
- **Failures name themselves.** With `max_retries=1` (set in `detect.setup`), a non-retryable LM error (auth / billing / configuration / unsupported model) escapes after one attempt as the raw `dspy.LMError` subclass. An `RLMTaskError` means the single attempt ended in an adapter parse failure, an invalid result, or a retryable endpoint fault (timeout / 5xx / transport). `cli.run` turns both into a `status=failed` response. Check the endpoint before suspecting the schema.
|
|
14
|
-
- **An empty answer at the cap is a planner that thought too long, not one that cannot follow the format.** A reasoning planner whose call ends `finish_reason=length` with empty content fails the run with `AdapterParseError
|
|
14
|
+
- **An empty answer at the cap is a planner that thought too long, not one that cannot follow the format.** A reasoning planner whose call ends `finish_reason=length` with empty content fails the run with `AdapterParseError`. Its message starts "This reply carried no content" when the reply quoted is the promoted reasoning channel, and quotes any other reply as the model wrote it; the raw call's `finish_reason` tells a cut at the cap from a reply that ignored the format. The fix is a thinking budget below `DS_PLANNER_MAX_TOKENS` through `DS_ROOT_LM_KWARGS`, never a whole-run retry or a different adapter.
|
|
15
15
|
- **`DS_ROOT_LM_KWARGS` / `DS_SUB_LM_KWARGS` are aliases of rlm-harness's `RLM_MAIN_LM_KWARGS` / `RLM_SUB_LM_KWARGS`.** `setup` builds the kit's config itself and never calls the kit's `from_env`, the only reader of those two; `DetectConfig.from_env` reads both names and refuses them set to different values. It also refuses the keys the kit owns (model, endpoint, timeout), so a bad value stops the process rather than failing every run. The analyst's parameters never reach the classifier, which builds its own request.
|
|
16
|
-
- **`DS_CLASSIFIER_LM_KWARGS` is the classifier's own**, over its `temperature=0.1`. `from_env` refuses the keys its settings own (model, messages, stream, n, timeout, max_tokens, max_completion_tokens) and, when the `classify` extra is installed, any key the OpenAI client does not take
|
|
16
|
+
- **`DS_CLASSIFIER_LM_KWARGS` is the classifier's own**, over its `temperature=0.1`. `from_env` refuses the keys its settings own (model, messages, stream, n, timeout, max_tokens, max_completion_tokens) and, when the `classify` extra is installed, any key the OpenAI client does not take, so a bad value stops the process rather than failing every call.
|
|
17
17
|
- **A classifier reply cut at `max_tokens` is an unusable reply that names the cut** (`deep_classify._CutReply`, read by the validator), not an endpoint error. The same request is cut the same way, so it is not re-sent, and a classifier that keeps thinking through its budget trips the circuit breaker. A reply cut after a whole verdict still counts. The tool_call records `cut: true`. The fix is a thinking budget in `DS_CLASSIFIER_LM_KWARGS` (raising `DS_CLASSIFIER_MAX_TOKENS` alone can turn a slow model's cut into a timeout).
|
|
18
|
+
- **An error status that repeats is not re-sent** (`deep_classify.is_fast_fail_http`, passed to `make_model_tool` as `is_fast_fail`): a 400, 401, 403, 404 or 422 from the classifier's endpoint is an endpoint error after one call, and it counts toward the circuit breaker, so a wrong model name, key or request stops being called. A timeout, a transport error, a 408, 409, 429 or 5xx keeps the transient retry and never counts. The eval judge holds its own copy without 400 and 422: its breaker spans a whole batch, and a status one oversized change earns there would leave the later runs unscored.
|
|
18
19
|
- **Subscription auth is a lazy, opt-in branch.** A role whose model is `claude-agent-sdk/<id>` runs on rlm-harness's `ClaudeAgentLM`, imported only inside `detect._maybe_subscription_lm` and never from `__init__.py`, so `import diff_sentry` stays dspy-free and a proxy-only install never touches the SDK.
|
|
19
20
|
- **The classifier never runs on the subscription.** It is a separate OpenAI-compatible client (`deep_classify._selfclassify_chat`), so `config.from_env` rejects a `claude-agent-sdk/` classifier model, whether set explicitly or inherited from `DS_SUB_LM`. Mixed auth across roles is by design.
|
|
@@ -44,7 +44,7 @@ paths:
|
|
|
44
44
|
|
|
45
45
|
## `curl-pipe-shell`
|
|
46
46
|
|
|
47
|
-
A download piped into a shell or an interpreter is `critical`. It has three forms that are judged separately: the pipe, the command substitution and PowerShell's. In prose (a `title`, `body` or `comment`, or a documentation file: `.md .mdx .markdown .rst .adoc .ipynb .html .htm`) a documented installer is `medium`. Code files (`.sh`, `.go`, `.yml`, a Dockerfile, `requirements.txt`) and branches are never softened. The segment kind decides, never a label. The planner's `scan_indicators` applies the same softening to the region it is given.
|
|
47
|
+
A download piped into a shell or an interpreter is `critical`. It has three forms that are judged separately: the pipe, the command substitution and PowerShell's. In prose (a `title`, `body` or `comment`, or a documentation file: `.md .mdx .markdown .rst .adoc .ipynb .html .htm`) a documented installer is `medium`. Code files (`.sh`, `.go`, `.yml`, a Dockerfile, `requirements.txt`) and branches are never softened. The segment kind decides, never a label. The planner's `scan_indicators` applies the same softening to the region it is given. A region with added lines and a `- ` line but no hunk or file header could be a patch slice or a list, so it is not softened, unless every line of it is a whole line, diff marker and all, of a documentation file's patch (`_Change.patch_of`): it is then read as the baseline reads that file.
|
|
48
48
|
|
|
49
49
|
### What it matches
|
|
50
50
|
|
|
@@ -121,7 +121,7 @@ All in `indicators._soften_prose`, beside `invisible-unicode` in right-to-left t
|
|
|
121
121
|
|
|
122
122
|
## The file-structure rules (`scan_files`)
|
|
123
123
|
|
|
124
|
-
A dev machine infected by a loader of the Contagious Interview / PolinRider family pushes its payload with ordinary work: obfuscated JavaScript appended to a config that runs on lint or build, a "font" that is a script, and a way to run it nobody types (a VS Code task that runs on folder open, an npm script now starting with `node <new file> &&`). `scan_files` reads what only the file list knows; `scan_change` runs it beside `scan_content` and `scan_metadata
|
|
124
|
+
A dev machine infected by a loader of the Contagious Interview / PolinRider family pushes its payload with ordinary work: obfuscated JavaScript appended to a config that runs on lint or build, a "font" that is a script, and a way to run it nobody types (a VS Code task that runs on folder open, an npm script now starting with `node <new file> &&`). `scan_files` reads what only the file list knows; `scan_change` runs it beside `scan_content` and `scan_metadata`, and the Action's `scan` (`scan_diff(files=True)`) runs the same rules on each file of a diff, reading whether it is added or renamed from its header (`new file mode`, `--- /dev/null`, `rename to`) and its patch from its hunks. A path git quotes (`"b/p\303\251/x.pth"`, a byte outside printable ASCII) is unquoted. The planner's scan does not run them: a diff quoted in a comment is no file of the change, and the baseline already ran them on the change's own files.
|
|
125
125
|
|
|
126
126
|
- **`package.json` and the VS Code files are read as JSONC tokens, never line by line** (`_jsonc_tokens` over the new side of the patch, each token marked written or context). A comment between a key and its value, a value on the next line, a `\u` escape, a one-line `"scripts": {...}`, a task's `args` array and `allowAutomaticTasks` nested under `"task"` are ordinary spellings, and a line regex misses each of them. A hit needs a token the change wrote.
|
|
127
127
|
- **It pairs a run entry with the change's file LIST, not with another file's text.** `run-entry-new-file` asks whether a `package.json` script, or a VS Code task `command` with its `args`, runs a file whose status is `added`. That is a structural fact, so it does not break "a paired rule only pairs within one file". A target resolves against the `package.json` directory, and against the parent of the `.vscode` folder for a VS Code file.
|
|
@@ -131,11 +131,21 @@ A dev machine infected by a loader of the Contagious Interview / PolinRider fami
|
|
|
131
131
|
- **`vscode-autorun` is `high`**: a written `"runOn": "folderOpen"` in a `.vscode/` file or a `.code-workspace`, and a written `task.allowAutomaticTasks` set to `true` or `"on"`, which turns automatic tasks on for the workspace (VS Code planned to make `off` the default). A maintainer adding a folder-open watcher task is an accepted over-alert: the setting runs code on open, the campaign's own trigger.
|
|
132
132
|
- **`binary-name-holds-text` is `high`**: a text patch under a font, image, archive or native-binary name, or added lines with no patch (a text diff too large to show). A real file of those formats has a NUL in its first bytes, so GitHub sends no patch and counts no lines. A Git LFS pointer is exempt only when its whole new side is pointer keys (`version` first, then `oid sha256:<64 hex>`, `size <n>`), so an update that rewrites only `oid` and `size` stays exempt and a script with a pointer line inside does not. `.svg` is text by nature and `.pdf` is often stored as text, so neither is on the list.
|
|
133
133
|
- **`obfuscated-dev-config` is `critical`**: `obfuscated-string-array` in the ADDED lines of a config a developer's tools execute (`eslint`, `vite`, `vitest`, `webpack`, `rollup`, `next`, `nuxt`, `tailwind`, `postcss`, `babel`, `jest`, `playwright`, `prettier`, `hardhat`, `commitlint`, `stylelint` configs and their `rc` files, `truffle-config`, `gatsby-*`, `.mocharc`, gulpfile, Gruntfile, `karma.conf`). Removing it is cleanup.
|
|
134
|
+
- **`python-startup-hook` is `high`**: a written `.pth` line that starts with `import` and a blank (the only lines `site` executes; the rest of a `.pth` is a list of paths), after an optional BOM, with each line of the patch split as `site` splits it (`str.splitlines`, so a lone CR or a form feed starts a line of its own); and any written line, or a rename, of a `sitecustomize` or `usercustomize` module or package. A `.pth` renamed without a change to its content shows no line and is a known gap. Python runs both at every start of an interpreter whose site-packages holds them, the litellm 1.107.0 wheels' trigger. The shims of packaging and instrumentation tools (virtualenv, setuptools' `distutils-precedence.pth`, coverage, OpenTelemetry's `sitecustomize.py`) are an accepted over-alert: they run code at every start too.
|
|
134
135
|
- **The token walks are bounded**: a comment never spans two hunks (the lines between them are unseen), and a `command`'s object is searched at most `_OBJECT_REACH` tokens each way, for at most `_MAX_RUN_ENTRIES` entries a file.
|
|
135
136
|
- **A structural hit's id carries its path** (`form=path`): the same setting in `tasks.json` and `settings.json` is two findings, not one deduped into the other.
|
|
136
137
|
- **`obfuscated-string-array` (`high`) runs everywhere**: javascript-obfuscator's rotation loop `while(!![])` with the push/shift rotation or 20 hex-indexed decoder calls. It needs no `_0x` names, which a mangled-name setting removes (`obfuscated-identifiers` still reads those). Hand-rewritten output (`while(!0)`) is a known gap.
|
|
137
138
|
- **Measured:** over 857 live changes (6,858 files from four large repositories) the rules fired only on the one change they were written for, and the string-array signature appeared in none of 3,553 JavaScript files over 30 KB (bundles and minified libraries among them). Terser and esbuild write `!0` and `for(;;)`, never `!![]`.
|
|
138
139
|
|
|
140
|
+
## Python loaders (`_scan_py_exec`)
|
|
141
|
+
|
|
142
|
+
Text rules, so they read every channel and a value the planner decodes. Over 39,589 distinct Python files of PyPI packages (725 MB), the three fired on two files: an `eval` beside `urlopen` in a test helper and mypy's daemon.
|
|
143
|
+
|
|
144
|
+
- **`packed-code-exec` is `high`**: `exec` / `eval` (the builtins, also as `builtins.exec` or `getattr(__builtins__, 'exec')`; not a method `.exec(`, not `literal_eval`) whose argument is a decoding call (the base64 family, `decodebytes`, hex, `decompress`, `marshal.loads`; not `json.loads`), through `compile(`, a module alias, a bare imported name or `__import__('zlib')`; a name assigned a decoding call and run after a `;` on the same line; and JavaScript's `eval` / `new Function` over `atob(` or `Buffer.from(`. A decoded value run on a later line is the model stage's to read.
|
|
145
|
+
- **`remote-code-exec` is `high`**: a Python HTTP client (`requests`, `urlopen`, `urlretrieve`, `httpx`, `aiohttp`, `urllib3`) and `exec` / `eval` in the same file.
|
|
146
|
+
- **`detached-process-spawn` also covers Python**: `os.fork` and `os.setsid` in the same file. `subprocess.Popen(start_new_session=True)` is a known gap.
|
|
147
|
+
- **`obfuscated-payload` inflates first**: a base64 blob that decodes to a zlib stream (any window size) or a gzip member is decompressed before it is read for a payload, each blob to at most 16 times its size and the blobs of one text to 256 KiB in all, because every inflated byte is read by the rules again. A raw deflate stream, and a blob past the budget, are read as they decode: decoy blobs in front can spend the budget, and the `exec` that runs the payload still fires `packed-code-exec` on its own.
|
|
148
|
+
|
|
139
149
|
## What the kind of a segment changes
|
|
140
150
|
|
|
141
151
|
`_scope_to_file` runs on every segment of an event but the branch name (`scan_content`) and on every file of a diff (`scan_diff`), after the detectors and the prose softening. A branch name keeps every hit, and so does the planner's `scan_indicators`, but for a maintainer's `inline-code-exec` it copies (`_written_by_maintainer`, above).
|
|
@@ -14,6 +14,7 @@ paths:
|
|
|
14
14
|
|
|
15
15
|
- `rl_export.rubric_signal` attaches the ATLAS 4-category (TF/TA/TG/PA) decomposition as LABELS: a fixed, no-LLM skeleton in `run_start` meta (`rubric_to_meta(default_rubric())`, one criterion per category, since the task is constant; there is no `generate_rubric` path) plus deterministic per-criterion `criteria_facts`.
|
|
16
16
|
- The facts are a re-lens over `run_labels` / `run_metrics`, not a second derivation: `rubric.trace_facts` reuses them, so a criterion's `observed` cannot drift from what a trainer reads. `_CATEGORY_LENS` maps TF↔(verdict / signal / hit_iteration_cap), TA↔(scan / deep_classify / analyst / fetch / skill counts + circuit-breaks + analyst failures), TG↔(indicator_count / max_indicator_severity / signal / cited_unknown), PA↔(verdict / cited_unknown).
|
|
17
|
+
- `deep_classify_circuit_breaks` counts the tool_calls the breaker short-circuited. From rlm-harness 1.22.0 a repeating error status (`is_fast_fail_http`) counts toward the breaker as an unusable reply does, so a break in a newer trace can mean a misconfigured classifier endpoint as well as a thrashing planner; the status in the run's endpoint-error tool_calls (`Error code: 401`) tells them apart, since a 429, a 5xx or a timeout records one too and does not count.
|
|
17
18
|
- `CriterionFact` has no score/met field; the trainer scores. Never add a reward/score/met field to any rubric type.
|
|
18
19
|
- `rubric.py` is a dspy-free leaf: it imports only `.schema` at top, and its `rl_export` reuse is a function-level import (that path is dspy-free too), so `response.py` can import it at top.
|
|
19
20
|
- `response._rubric` surfaces it as `DetectionResponse.rubric` (`RubricReport`); the studio shows it as a card tagged `labels — not a score` that adds no judgement. Keep the card legacy-guarded: a response without a `rubric` renders nothing.
|
|
@@ -33,5 +33,7 @@ check it by hand.
|
|
|
33
33
|
…`, notes = the CHANGELOG section). The user publishes it in the web UI with "Publish this Action to
|
|
34
34
|
the GitHub Marketplace" ticked (the CLI cannot tick it). Publishing is what builds, verifies, and
|
|
35
35
|
uploads to PyPI via Trusted Publishing, and the upload is irreversible.
|
|
36
|
-
6. After `release.yml` succeeds
|
|
36
|
+
6. After `release.yml` succeeds and PyPI's simple index lists the version (`curl -s -H "Accept:
|
|
37
|
+
application/vnd.pypi.simple.v1+json" https://pypi.org/simple/diff-sentry/`; the index lags the upload by
|
|
38
|
+
minutes, and a rerun before it lists the version fails to resolve), re-run the failed consumer-install job
|
|
37
39
|
(`gh run rerun <ci run id> --failed`); green proves the published package installs and scans.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
name: CI
|
|
2
2
|
|
|
3
|
-
# Offline suite only: no live LLM, no network calls in tests, no Deno sandbox
|
|
3
|
+
# Offline suite only: no live LLM, no network calls in tests, no Deno sandbox: dspy-bearing paths use
|
|
4
4
|
# DummyLM / rlm-harness's ScriptedInterpreter, transports are injected fakes, and the corpus pins the
|
|
5
5
|
# indicator suite's hit/miss behavior (see AGENTS.md "Verify"). `uv sync` resolves rlm-harness from PyPI
|
|
6
6
|
# at the exact version pinned in pyproject.toml / uv.lock.
|
|
@@ -9,7 +9,7 @@ on:
|
|
|
9
9
|
branches: [main]
|
|
10
10
|
pull_request:
|
|
11
11
|
|
|
12
|
-
# Least privilege: this workflow only checks out code and runs tests/lint
|
|
12
|
+
# Least privilege: this workflow only checks out code and runs tests/lint; it never writes. Specifying
|
|
13
13
|
# `permissions:` also drops every unlisted scope to `none`, so a compromised dep/action on an untrusted
|
|
14
14
|
# fork PR can't use the token to push, tag, or open issues.
|
|
15
15
|
permissions:
|
|
@@ -62,7 +62,7 @@ jobs:
|
|
|
62
62
|
for f in studio/tests/*.test.js; do echo "▸ $f"; node "$f"; done
|
|
63
63
|
|
|
64
64
|
# Dogfood: this repo's own CI runs the ACTION this repo publishes (`uses: ./`), not a copy of it.
|
|
65
|
-
# So every PR here is also an integration test of the thing users install
|
|
65
|
+
# So every PR here is also an integration test of the thing users install: a drifted action.yml
|
|
66
66
|
# fails our own build before it reaches anyone's Marketplace install.
|
|
67
67
|
#
|
|
68
68
|
# DELIBERATELY the deterministic scan and not the full `classify` pipeline. `classify` needs DS_*
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
name: Release
|
|
2
2
|
|
|
3
|
-
# Publish to PyPI via Trusted Publishing (OIDC)
|
|
3
|
+
# Publish to PyPI via Trusted Publishing (OIDC), with NO API token. INERT until a GitHub Release is
|
|
4
4
|
# published, and needs one-time setup before the first release:
|
|
5
5
|
# 1. On PyPI, add a Trusted Publisher for project `diff-sentry`:
|
|
6
6
|
# owner = qazbnm456, repo = diff-sentry, workflow = release.yml, environment = pypi
|
|
@@ -11,7 +11,7 @@ name: Release
|
|
|
11
11
|
# consumers pin in `uses: qazbnm456/diff-sentry@vX`. So cutting a release ships the package AND the
|
|
12
12
|
# Action; keep the README's quickstart ref in step with the tag you publish.
|
|
13
13
|
#
|
|
14
|
-
# Supply-chain: the two third-party actions are SHA-pinned
|
|
14
|
+
# Supply-chain: the two third-party actions are SHA-pinned: setup-uv, and (highest blast radius, it
|
|
15
15
|
# uploads to PyPI) pypi-publish. Bump a SHA and its trailing version comment together. GitHub-owned
|
|
16
16
|
# checkout/*-artifact stay on major tags.
|
|
17
17
|
|
|
@@ -34,7 +34,7 @@ jobs:
|
|
|
34
34
|
- name: Build sdist + wheel
|
|
35
35
|
run: uv build
|
|
36
36
|
|
|
37
|
-
# Verify the ARTIFACT, not the checkout
|
|
37
|
+
# Verify the ARTIFACT, not the checkout: a wheel can build cleanly and still be unusable. Without
|
|
38
38
|
# a `[project.scripts]` entry point, for example, `uvx diff-sentry` (the exact command the GitHub
|
|
39
39
|
# Action runs) cannot work, and nothing in the source tree says so. Installing the built wheel into
|
|
40
40
|
# a clean environment and running it is what catches that.
|
|
@@ -75,7 +75,7 @@ jobs:
|
|
|
75
75
|
exit 1
|
|
76
76
|
fi
|
|
77
77
|
|
|
78
|
-
- uses: actions/upload-artifact@
|
|
78
|
+
- uses: actions/upload-artifact@v7
|
|
79
79
|
with:
|
|
80
80
|
name: dist
|
|
81
81
|
path: dist/
|
|
@@ -87,14 +87,14 @@ jobs:
|
|
|
87
87
|
# would fire this job and ask PyPI to publish `diff-sentry` over OIDC. The Trusted Publisher binding
|
|
88
88
|
# (owner+repo+workflow+environment) would reject it, but that is a rejected publish attempt against
|
|
89
89
|
# OUR project, plus a probe surface and a confusing red run in someone else's repo. `build` above is
|
|
90
|
-
# deliberately NOT guarded
|
|
90
|
+
# deliberately NOT guarded: it only builds and tests an artifact, and a fork verifying its own
|
|
91
91
|
# build is fine.
|
|
92
92
|
if: github.repository == 'qazbnm456/diff-sentry'
|
|
93
93
|
environment: pypi
|
|
94
94
|
permissions:
|
|
95
95
|
id-token: write # OIDC token for PyPI Trusted Publishing
|
|
96
96
|
steps:
|
|
97
|
-
- uses: actions/download-artifact@
|
|
97
|
+
- uses: actions/download-artifact@v8
|
|
98
98
|
with:
|
|
99
99
|
name: dist
|
|
100
100
|
path: dist/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# diff-sentry: agent guide
|
|
2
2
|
|
|
3
|
-
A BewAIre-style malicious-change detector built on `rlm-harness`. It classifies ONE GitHub change (PR / issue / push) for malicious intent: the change is UNTRUSTED DATA held in a sandboxed REPL, the planner SUBMITs a judgement-only verdict, and the deterministic indicator EVIDENCE is unioned on read into a SIEM signal. `README.md`
|
|
3
|
+
A BewAIre-style malicious-change detector built on `rlm-harness`. It classifies ONE GitHub change (PR / issue / push) for malicious intent: the change is UNTRUSTED DATA held in a sandboxed REPL, the planner SUBMITs a judgement-only verdict, and the deterministic indicator EVIDENCE is unioned on read into a SIEM signal. `README.md` is the user-facing overview and `docs/` holds the pipeline and the honest limits; rlm-harness's "Building a consumer" is the extension contract this project lives within.
|
|
4
4
|
|
|
5
5
|
rlm-harness is an exact PyPI pin (`rlm-harness==X.Y.Z` in `pyproject.toml`, locked in `uv.lock`). Overlay `uv pip install -e ../rlm-harness` only while co-developing the kit, and bump the pin once the fix ships.
|
|
6
6
|
|
|
@@ -2,6 +2,35 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to diff-sentry, a BewAIre-style detector that classifies one GitHub change (PR, issue or push) for malicious intent on [`rlm-harness`](https://github.com/qazbnm456/rlm-harness).
|
|
4
4
|
|
|
5
|
+
## 0.8.0
|
|
6
|
+
|
|
7
|
+
Upgrade from 0.7.1: the GitHub Action now runs the file-structure rules and catches Python loaders, so a pull request it passed before can fail it now (see **Added** and **Fixed**). Nothing needs configuring, and `rlm-harness==1.22.0` is unchanged.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **Rules for the litellm 1.107.0 loader shape** ([BerriAI/litellm#47826](https://github.com/BerriAI/litellm/issues/47826)), all `high`:
|
|
11
|
+
- `python-startup-hook`: a `.pth` line that Python runs at every start, and a `sitecustomize` or `usercustomize` module or package;
|
|
12
|
+
- `packed-code-exec`: `exec` or `eval` over a decoding call such as `zlib.decompress(base64.b64decode(...))`, and JavaScript's `eval` or `new Function` over `atob` or `Buffer.from`;
|
|
13
|
+
- `remote-code-exec`: a Python HTTP client and `exec` or `eval` in the same file;
|
|
14
|
+
- `detached-process-spawn` also covers Python's `os.fork` with `os.setsid`.
|
|
15
|
+
- **A packed payload is inflated before it is read**: a base64 blob holding a zlib or gzip stream is decompressed, within a size budget, and scanned for a payload.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- **The Action's scan runs the file-structure rules**: a VS Code task that runs on folder open, an npm lifecycle hook or task that runs a file the change adds, a script under an image or font name, obfuscated code in a developer tool's config, and the new startup hooks. Before, only the model pipeline and `watch` ran them.
|
|
19
|
+
- **A path git quotes in a diff header** (a byte outside printable ASCII) is read as its file instead of folding into the file before it.
|
|
20
|
+
|
|
21
|
+
## 0.7.1
|
|
22
|
+
|
|
23
|
+
Upgrade from 0.7.0: `rlm-harness==1.22.0`. The GitHub Action's scan is unchanged; the model pipeline (`pr`, `issue`, `classify`, `watch`) raises fewer false signals on documentation changes and stops calling a misconfigured classifier sooner. Nothing needs configuring.
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
- **A classifier endpoint that answers 400, 401, 403, 404 or 422 is called once**, not retried, and counts toward the circuit breaker, so a wrong model name, key or request stops being called. Timeouts, 429 and 5xx are retried as before. The eval judge does the same for 401, 403 and 404.
|
|
27
|
+
- **A planner run that fails on an unparseable reply quotes the reply as the model wrote it**, and says so when the reply carried only reasoning.
|
|
28
|
+
- **`rlm-harness==1.22.0`.**
|
|
29
|
+
- **The README covers using diff-sentry**; how it works, the watcher, the known limits and development moved to `docs/`.
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
- **A planner re-scan of a slice of a documentation patch is softened as the scan of that file is**, so a README change that adds a documented installer and removes an indented line no longer signals as `critical`.
|
|
33
|
+
|
|
5
34
|
## 0.7.0
|
|
6
35
|
|
|
7
36
|
Upgrade from 0.6.0: `rlm-harness==1.20.0` and `dspy` 3.4.1. The signal now reads only the evidence the planner grounded in the change, and several rules read lower or higher than before, so a SIEM that alerts on severity sees fewer false signals and some new ones (see **Changed**). Nothing needs configuring; the new `DS_*` settings are optional.
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: diff-sentry
|
|
3
|
+
Version: 0.8.0
|
|
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
|
+
Author: Boik Su
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: dspy,malicious-code-detection,prompt-injection,rlm,security,siem,supply-chain-security
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Requires-Dist: httpx>=0.27
|
|
11
|
+
Requires-Dist: pydantic>=2.11.0
|
|
12
|
+
Requires-Dist: rlm-harness==1.22.0
|
|
13
|
+
Provides-Extra: classify
|
|
14
|
+
Requires-Dist: openai>=1.0; extra == 'classify'
|
|
15
|
+
Provides-Extra: observe
|
|
16
|
+
Requires-Dist: langfuse>=4.6.1; extra == 'observe'
|
|
17
|
+
Requires-Dist: openinference-instrumentation-dspy>=0.1.36; extra == 'observe'
|
|
18
|
+
Provides-Extra: subscription
|
|
19
|
+
Requires-Dist: claude-agent-sdk>=0.1.60; extra == 'subscription'
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# diff-sentry
|
|
23
|
+
|
|
24
|
+
**Catch a malicious pull request before you merge it.**
|
|
25
|
+
|
|
26
|
+
diff-sentry reads a change the way an attacker hopes you won't: it treats the diff as untrusted data, looks for the shapes that real supply-chain attacks use, and reports evidence rather than an opinion.
|
|
27
|
+
|
|
28
|
+
The fastest way to use it is as a GitHub Action. It needs no API key, no model, and no network access, so it runs on fork pull requests under the read-only token they already get.
|
|
29
|
+
|
|
30
|
+
```yaml
|
|
31
|
+
# .github/workflows/diff-sentry.yml
|
|
32
|
+
name: diff-sentry
|
|
33
|
+
on: pull_request
|
|
34
|
+
|
|
35
|
+
permissions:
|
|
36
|
+
contents: read
|
|
37
|
+
|
|
38
|
+
jobs:
|
|
39
|
+
scan:
|
|
40
|
+
runs-on: ubuntu-latest
|
|
41
|
+
steps:
|
|
42
|
+
- uses: actions/checkout@v7
|
|
43
|
+
with:
|
|
44
|
+
fetch-depth: 0 # the scan diffs against the PR base
|
|
45
|
+
- uses: qazbnm456/diff-sentry@v0.8.0
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
That is the whole setup. The job fails when an indicator at or above `high` fires.
|
|
49
|
+
|
|
50
|
+
## What it catches
|
|
51
|
+
|
|
52
|
+
| Family | Examples |
|
|
53
|
+
|---|---|
|
|
54
|
+
| 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 |
|
|
55
|
+
| Obfuscation | base64 that decodes to a shell payload, `_0x…` machine-mangled JavaScript, high-entropy blobs |
|
|
56
|
+
| Execution | pipe-to-shell installs, `${IFS}` space-evasion, detached child processes, inline `node -e`, fetch-to-disk droppers, Python startup hooks (`.pth`, `sitecustomize.py`), packed or downloaded code run with `exec` |
|
|
57
|
+
| Exfiltration | secrets reaching a network sink, cross-process `/proc/<pid>/mem` reads, OAST callback services, IPFS and permaweb gateways |
|
|
58
|
+
| Diff-presentation evasion | payloads shoved past the viewport by a long whitespace run, bidi-override and zero-width characters |
|
|
59
|
+
| Provenance | forged `[bot]` identities, bot-mimicking new accounts, accounts created days before the event, profiles that describe an autonomous agent, unsigned or identity-mismatched commits, a fork deleted after the PR |
|
|
60
|
+
| Author-controlled channels | a PR branch name carrying shell syntax, slash-command comments that fire `issue_comment` workflows |
|
|
61
|
+
| Campaign memory (`watch` only) | an account or payload host already flagged in another repository, an edit that removes a flagged payload, a burst of changes from a new account |
|
|
62
|
+
| Prompt injection | instructions aimed at an LLM reviewer or triage workflow |
|
|
63
|
+
|
|
64
|
+
A plain workflow edit, or a `pull_request_target` on its own, is `medium`, below the failure threshold, so a benign workflow PR does not break your build. Deleted lines are not scanned, so removing a payload is not flagged. [How it works](docs/how-it-works.md#severities-are-tuned-against-noise) explains the tuning.
|
|
65
|
+
|
|
66
|
+
## Inputs
|
|
67
|
+
|
|
68
|
+
| Input | Default | Purpose |
|
|
69
|
+
|---|---|---|
|
|
70
|
+
| `fail-on` | `high` | Severity that fails the build (`info`/`low`/`medium`/`high`/`critical`). |
|
|
71
|
+
| `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. |
|
|
72
|
+
| `base-sha` | derived | Override the commit to diff against. |
|
|
73
|
+
| `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. |
|
|
74
|
+
|
|
75
|
+
Outputs: `failed`, `hit-count`, `max-severity`, and `report` (a JSON path).
|
|
76
|
+
|
|
77
|
+
## Do not reach for `pull_request_target`
|
|
78
|
+
|
|
79
|
+
Sooner or later you will want the scan to post its results as a PR comment, and a `pull_request` job on a fork PR has a read-only token. The tempting fix is `pull_request_target`, which is the exact misconfiguration that opened the AsyncAPI "Miasma" compromise, and which this action reports as `critical`.
|
|
80
|
+
|
|
81
|
+
Do it the safe way instead: keep the scan on `pull_request` with no secrets, upload the `report` as an artifact, and post it from a separate `workflow_run` job that runs the base branch's code and never checks out the PR head. diff-sentry's own rules make the same distinction, so the safe shape does not trip them.
|
|
82
|
+
|
|
83
|
+
## Evidence a model cannot suppress
|
|
84
|
+
|
|
85
|
+
An LLM reviewer can be argued out of its conclusion by the diff it reads. The Action runs no model at all. In the full pipeline, the rules record their hits before any model takes a turn, and the alert is derived from those hits, so a prompt injection can skew a verdict but cannot remove the evidence ([details](docs/how-it-works.md#evidence-a-model-cannot-suppress)).
|
|
86
|
+
|
|
87
|
+
## Beyond the Action
|
|
88
|
+
|
|
89
|
+
https://github.com/user-attachments/assets/d200aee8-263b-483e-ad55-f90ae69f3ab0
|
|
90
|
+
|
|
91
|
+
_The studio console: paste a real payload, watch the detection stream live, then read the evidence-framed verdict._
|
|
92
|
+
|
|
93
|
+
If you operate the infrastructure, or maintain a project where a wrong call is expensive, the rest of the system runs locally with a model endpoint you choose:
|
|
94
|
+
|
|
95
|
+
- **Deep classification**: a reasoning model reads a PR, issue or push in a sandbox and returns a verdict next to the evidence. See [How it works](docs/how-it-works.md).
|
|
96
|
+
- **The studio**: a local console that streams a run as it happens. See [`studio/README.md`](studio/README.md).
|
|
97
|
+
- **Watch**: monitors a list of repositories, alerts the moment the rules fire, and remembers the accounts and payload hosts it has seen. See [Watching a watchlist](docs/watch.md).
|
|
98
|
+
- **Trajectories for training**: every run is recorded, and `export` turns the traces into a reward-free dataset for fine-tuning a smaller planner. See [The full pipeline](docs/how-it-works.md#the-full-pipeline).
|
|
99
|
+
|
|
100
|
+
None of this is needed for the Action, and none of it sends your code anywhere you did not configure yourself.
|
|
101
|
+
|
|
102
|
+
## Limits
|
|
103
|
+
|
|
104
|
+
A strong prompt injection can skew a model's verdict, though never the evidence. Some pipe-to-shell shapes, and a secret that reaches a network call across lines, are missed. An external watcher cannot stop an attack that fires the moment a PR opens. [Honest limits](docs/limits.md) lists the known gaps.
|
|
105
|
+
|
|
106
|
+
## Documentation
|
|
107
|
+
|
|
108
|
+
- [How it works](docs/how-it-works.md): severity tuning, the evidence union, the full pipeline, running it locally, and the incidents the tests reconstruct.
|
|
109
|
+
- [Watching a watchlist](docs/watch.md): the monitor, its alerts, its archive, and what the account dossier holds.
|
|
110
|
+
- [Honest limits](docs/limits.md): what the rules and the pipeline do not see.
|
|
111
|
+
- [Development](docs/development.md): the test suites and CI.
|
|
112
|
+
- [CHANGELOG](CHANGELOG.md)
|
|
113
|
+
|
|
114
|
+
## License
|
|
115
|
+
|
|
116
|
+
MIT. Built on [`rlm-harness`](https://github.com/qazbnm456/rlm-harness).
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# diff-sentry
|
|
2
|
+
|
|
3
|
+
**Catch a malicious pull request before you merge it.**
|
|
4
|
+
|
|
5
|
+
diff-sentry reads a change the way an attacker hopes you won't: it treats the diff as untrusted data, looks for the shapes that real supply-chain attacks use, and reports evidence rather than an opinion.
|
|
6
|
+
|
|
7
|
+
The fastest way to use it is as a GitHub Action. It needs no API key, no model, and no network access, so it runs on fork pull requests under the read-only token they already get.
|
|
8
|
+
|
|
9
|
+
```yaml
|
|
10
|
+
# .github/workflows/diff-sentry.yml
|
|
11
|
+
name: diff-sentry
|
|
12
|
+
on: pull_request
|
|
13
|
+
|
|
14
|
+
permissions:
|
|
15
|
+
contents: read
|
|
16
|
+
|
|
17
|
+
jobs:
|
|
18
|
+
scan:
|
|
19
|
+
runs-on: ubuntu-latest
|
|
20
|
+
steps:
|
|
21
|
+
- uses: actions/checkout@v7
|
|
22
|
+
with:
|
|
23
|
+
fetch-depth: 0 # the scan diffs against the PR base
|
|
24
|
+
- uses: qazbnm456/diff-sentry@v0.8.0
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
That is the whole setup. The job fails when an indicator at or above `high` fires.
|
|
28
|
+
|
|
29
|
+
## What it catches
|
|
30
|
+
|
|
31
|
+
| Family | Examples |
|
|
32
|
+
|---|---|
|
|
33
|
+
| 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 |
|
|
34
|
+
| Obfuscation | base64 that decodes to a shell payload, `_0x…` machine-mangled JavaScript, high-entropy blobs |
|
|
35
|
+
| Execution | pipe-to-shell installs, `${IFS}` space-evasion, detached child processes, inline `node -e`, fetch-to-disk droppers, Python startup hooks (`.pth`, `sitecustomize.py`), packed or downloaded code run with `exec` |
|
|
36
|
+
| Exfiltration | secrets reaching a network sink, cross-process `/proc/<pid>/mem` reads, OAST callback services, IPFS and permaweb gateways |
|
|
37
|
+
| Diff-presentation evasion | payloads shoved past the viewport by a long whitespace run, bidi-override and zero-width characters |
|
|
38
|
+
| Provenance | forged `[bot]` identities, bot-mimicking new accounts, accounts created days before the event, profiles that describe an autonomous agent, unsigned or identity-mismatched commits, a fork deleted after the PR |
|
|
39
|
+
| Author-controlled channels | a PR branch name carrying shell syntax, slash-command comments that fire `issue_comment` workflows |
|
|
40
|
+
| Campaign memory (`watch` only) | an account or payload host already flagged in another repository, an edit that removes a flagged payload, a burst of changes from a new account |
|
|
41
|
+
| Prompt injection | instructions aimed at an LLM reviewer or triage workflow |
|
|
42
|
+
|
|
43
|
+
A plain workflow edit, or a `pull_request_target` on its own, is `medium`, below the failure threshold, so a benign workflow PR does not break your build. Deleted lines are not scanned, so removing a payload is not flagged. [How it works](docs/how-it-works.md#severities-are-tuned-against-noise) explains the tuning.
|
|
44
|
+
|
|
45
|
+
## Inputs
|
|
46
|
+
|
|
47
|
+
| Input | Default | Purpose |
|
|
48
|
+
|---|---|---|
|
|
49
|
+
| `fail-on` | `high` | Severity that fails the build (`info`/`low`/`medium`/`high`/`critical`). |
|
|
50
|
+
| `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. |
|
|
51
|
+
| `base-sha` | derived | Override the commit to diff against. |
|
|
52
|
+
| `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. |
|
|
53
|
+
|
|
54
|
+
Outputs: `failed`, `hit-count`, `max-severity`, and `report` (a JSON path).
|
|
55
|
+
|
|
56
|
+
## Do not reach for `pull_request_target`
|
|
57
|
+
|
|
58
|
+
Sooner or later you will want the scan to post its results as a PR comment, and a `pull_request` job on a fork PR has a read-only token. The tempting fix is `pull_request_target`, which is the exact misconfiguration that opened the AsyncAPI "Miasma" compromise, and which this action reports as `critical`.
|
|
59
|
+
|
|
60
|
+
Do it the safe way instead: keep the scan on `pull_request` with no secrets, upload the `report` as an artifact, and post it from a separate `workflow_run` job that runs the base branch's code and never checks out the PR head. diff-sentry's own rules make the same distinction, so the safe shape does not trip them.
|
|
61
|
+
|
|
62
|
+
## Evidence a model cannot suppress
|
|
63
|
+
|
|
64
|
+
An LLM reviewer can be argued out of its conclusion by the diff it reads. The Action runs no model at all. In the full pipeline, the rules record their hits before any model takes a turn, and the alert is derived from those hits, so a prompt injection can skew a verdict but cannot remove the evidence ([details](docs/how-it-works.md#evidence-a-model-cannot-suppress)).
|
|
65
|
+
|
|
66
|
+
## Beyond the Action
|
|
67
|
+
|
|
68
|
+
https://github.com/user-attachments/assets/d200aee8-263b-483e-ad55-f90ae69f3ab0
|
|
69
|
+
|
|
70
|
+
_The studio console: paste a real payload, watch the detection stream live, then read the evidence-framed verdict._
|
|
71
|
+
|
|
72
|
+
If you operate the infrastructure, or maintain a project where a wrong call is expensive, the rest of the system runs locally with a model endpoint you choose:
|
|
73
|
+
|
|
74
|
+
- **Deep classification**: a reasoning model reads a PR, issue or push in a sandbox and returns a verdict next to the evidence. See [How it works](docs/how-it-works.md).
|
|
75
|
+
- **The studio**: a local console that streams a run as it happens. See [`studio/README.md`](studio/README.md).
|
|
76
|
+
- **Watch**: monitors a list of repositories, alerts the moment the rules fire, and remembers the accounts and payload hosts it has seen. See [Watching a watchlist](docs/watch.md).
|
|
77
|
+
- **Trajectories for training**: every run is recorded, and `export` turns the traces into a reward-free dataset for fine-tuning a smaller planner. See [The full pipeline](docs/how-it-works.md#the-full-pipeline).
|
|
78
|
+
|
|
79
|
+
None of this is needed for the Action, and none of it sends your code anywhere you did not configure yourself.
|
|
80
|
+
|
|
81
|
+
## Limits
|
|
82
|
+
|
|
83
|
+
A strong prompt injection can skew a model's verdict, though never the evidence. Some pipe-to-shell shapes, and a secret that reaches a network call across lines, are missed. An external watcher cannot stop an attack that fires the moment a PR opens. [Honest limits](docs/limits.md) lists the known gaps.
|
|
84
|
+
|
|
85
|
+
## Documentation
|
|
86
|
+
|
|
87
|
+
- [How it works](docs/how-it-works.md): severity tuning, the evidence union, the full pipeline, running it locally, and the incidents the tests reconstruct.
|
|
88
|
+
- [Watching a watchlist](docs/watch.md): the monitor, its alerts, its archive, and what the account dossier holds.
|
|
89
|
+
- [Honest limits](docs/limits.md): what the rules and the pipeline do not see.
|
|
90
|
+
- [Development](docs/development.md): the test suites and CI.
|
|
91
|
+
- [CHANGELOG](CHANGELOG.md)
|
|
92
|
+
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
MIT. Built on [`rlm-harness`](https://github.com/qazbnm456/rlm-harness).
|
|
@@ -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.
|
|
47
|
+
default: '0.8.0'
|
|
48
48
|
|
|
49
49
|
outputs:
|
|
50
50
|
failed:
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""diff-sentry
|
|
1
|
+
"""diff-sentry: classify a GitHub change for malicious intent, as a traced RLM harness.
|
|
2
2
|
|
|
3
3
|
A downstream *consumer* of rlm-harness (an exact PyPI pin) reproducing Datadog's BewAIre defense shape:
|
|
4
4
|
ingest a GitHub PR/issue/push → analyze the diff as UNTRUSTED data in a sandboxed REPL → produce a
|
|
@@ -80,10 +80,10 @@ __all__ = [
|
|
|
80
80
|
"screen",
|
|
81
81
|
]
|
|
82
82
|
|
|
83
|
-
__version__ = "0.
|
|
83
|
+
__version__ = "0.8.0"
|
|
84
84
|
|
|
85
85
|
|
|
86
|
-
def __getattr__(name: str): # PEP 562
|
|
86
|
+
def __getattr__(name: str): # PEP 562: defer the dspy import to first use
|
|
87
87
|
if name in ("ClassifyChange", "setup"):
|
|
88
88
|
from . import detect
|
|
89
89
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Host-side actor store
|
|
1
|
+
"""Host-side actor store: who is behind a change, and what else they did.
|
|
2
2
|
|
|
3
3
|
A per-change classifier sees one PR. The campaigns worth catching span many: hackerbot-claw hit seven
|
|
4
4
|
repositories in ten days from one account, with one payload host. The defender of each repository saw one
|
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
"""Assemble the canonical verdict from the trace
|
|
1
|
+
"""Assemble the canonical verdict from the trace: the read-time step that makes the EVIDENCE a fact.
|
|
2
2
|
|
|
3
3
|
`assemble_verdict(verdict, events)` attaches the deterministic fields the SYSTEM owns to the planner's
|
|
4
4
|
JUDGEMENT (`ChangeVerdict`, no hits): the UNION of ALL indicator hits from the trace (baseline ∪ every
|
|
5
|
-
`scan_indicators` tool call
|
|
5
|
+
`scan_indicators` tool call; MF3), the derived max severity, whether to SIGNAL, and any indicator ids
|
|
6
6
|
the planner cited that match no recorded hit (a fabrication tell).
|
|
7
7
|
|
|
8
8
|
The `signal` decision is DETERMINISTIC and partly independent of the planner: it fires if the planner's
|
|
9
9
|
verdict is in `emit_on` OR a high/critical deterministic indicator fired. So a false-benign self-report
|
|
10
|
-
cannot suppress hard evidence from reaching the SIEM
|
|
10
|
+
cannot suppress hard evidence from reaching the SIEM: the planner can skew the verdict, never hide the
|
|
11
11
|
indicators. (This is deterministic reduction over facts, NOT a model judgement smuggled into the read
|
|
12
|
-
path
|
|
12
|
+
path: the verdict tier stays the planner's; only the evidence-driven floor is ours.)
|
|
13
13
|
|
|
14
|
-
This runs everywhere the result is consumed
|
|
14
|
+
This runs everywhere the result is consumed: the live path (cli), re-render, and rl_export, so labels
|
|
15
15
|
read facts too. A trace missing a newer field still reads: pydantic ignores unknown keys, and hits
|
|
16
16
|
re-source from the trace.
|
|
17
17
|
|
|
@@ -11,11 +11,11 @@
|
|
|
11
11
|
python -m diff_sentry archive verify # re-hash the snapshot archive
|
|
12
12
|
|
|
13
13
|
`run()` is the programmatic entry. It records `traces/{run_id}.jsonl`, writes `responses/{run_id}.json`,
|
|
14
|
-
and
|
|
14
|
+
and (host-side, AFTER the run) emits a SIEM signal when the assembled verdict warrants it. A host-side
|
|
15
15
|
BASELINE indicator scan runs at ingest and rides in the run_start meta, so the deterministic evidence is
|
|
16
16
|
in the trace before the planner takes a turn (MF3). `scan`/`render`/`export` work offline;
|
|
17
17
|
`pr`/`issue`/`classify` need model creds (DS_* env) + a Deno sandbox (`classify` only *ingests* offline).
|
|
18
|
-
`scan` exposes that deterministic baseline on its own
|
|
18
|
+
`scan` exposes that deterministic baseline on its own: the half we can run in our own CI on an untrusted
|
|
19
19
|
fork PR, where no creds exist and none may be handed over (see `_cmd_scan`).
|
|
20
20
|
"""
|
|
21
21
|
|
|
@@ -56,7 +56,7 @@ async def detect_from_event(
|
|
|
56
56
|
setup(config)
|
|
57
57
|
task = ClassifyChange(config=config, extra_tools=extra_tools, event=event)
|
|
58
58
|
event_str = normalize_event(event)
|
|
59
|
-
# Host-side BASELINE scan
|
|
59
|
+
# Host-side BASELINE scan: deterministic evidence in the trace before the planner runs (MF3).
|
|
60
60
|
baseline = _baseline_indicators(event)
|
|
61
61
|
|
|
62
62
|
async def _run():
|
|
@@ -67,23 +67,23 @@ async def detect_from_event(
|
|
|
67
67
|
# The run's INITIAL STATE (the untrusted event is a REPL variable, not a chat turn) + prompt.
|
|
68
68
|
"event": event_str,
|
|
69
69
|
"instructions": task.instructions,
|
|
70
|
-
# The change under review
|
|
70
|
+
# The change under review: echoed into the response envelope + the SIEM signal.
|
|
71
71
|
"source": _source_echo(event),
|
|
72
72
|
# The host-derived facts about the author and the event (account age as of the event, profile,
|
|
73
73
|
# timestamps, fork, commit offsets), so an offline reader sees what the ingest saw.
|
|
74
74
|
**({"provenance": event["provenance"]} if event.get("provenance") else {}),
|
|
75
|
-
# The deterministic baseline
|
|
75
|
+
# The deterministic baseline: assemble unions this with every scan_indicators tool call.
|
|
76
76
|
"baseline_indicators": baseline,
|
|
77
77
|
# The emit threshold THIS run used, so an offline re-render/export re-derives the SAME signal.
|
|
78
78
|
"emit_on": list(config.emit_on),
|
|
79
|
-
# The models actually used this run (roles → concrete names)
|
|
79
|
+
# The models actually used this run (roles → concrete names): self-describing trace.
|
|
80
80
|
"planner": config.main_model,
|
|
81
81
|
"analyst": config.sub_model,
|
|
82
82
|
"classifier": config.classifier_model,
|
|
83
83
|
# The budget THIS run ran under, so an offline reader computes hit_iteration_cap correctly.
|
|
84
84
|
"max_iterations": config.max_iterations,
|
|
85
85
|
"max_llm_calls": config.max_llm_calls,
|
|
86
|
-
# The ATLAS rubric decomposition carried as LABELS (structure only
|
|
86
|
+
# The ATLAS rubric decomposition carried as LABELS (structure only; the trainer scores dᵢ). A
|
|
87
87
|
# FIXED skeleton (default_rubric): diff-sentry's task is constant, so only the per-criterion
|
|
88
88
|
# FACTS vary per run (re-sourced from the trace on read; see rubric.criteria_facts).
|
|
89
89
|
"rubric": rubric_to_meta(default_rubric()),
|
|
@@ -166,7 +166,7 @@ def run(
|
|
|
166
166
|
emit_failed: bool = True,
|
|
167
167
|
) -> RunArtifacts:
|
|
168
168
|
"""THE programmatic entry: classify one change, write the response, and (host-side, after the run)
|
|
169
|
-
emit a SIEM signal when warranted. Never raises on a run failure
|
|
169
|
+
emit a SIEM signal when warranted. Never raises on a run failure: a failed run still writes an
|
|
170
170
|
informative response and returns result-less RunArtifacts."""
|
|
171
171
|
from rlm_harness.trace import load_events
|
|
172
172
|
|
|
@@ -181,7 +181,7 @@ def run(
|
|
|
181
181
|
try:
|
|
182
182
|
verdict = asyncio.run(detect_from_event(event, config, trace_path=trace_path, run_id=run_id,
|
|
183
183
|
on_event=on_event, extra_tools=extra_tools))
|
|
184
|
-
except Exception as exc: # noqa: BLE001
|
|
184
|
+
except Exception as exc: # noqa: BLE001 (a failed run is still self-contained + navigable)
|
|
185
185
|
events = load_events(trace_path, run_id) if os.path.exists(trace_path) else []
|
|
186
186
|
resp = build_failed_response(run_id, events, f"{type(exc).__name__}: {exc}")
|
|
187
187
|
response_path = _write((os.path.join(outdir, "responses"), f"{run_id}.json"),
|
|
@@ -299,11 +299,11 @@ def drop_deleted_lines(text: str) -> str:
|
|
|
299
299
|
"""Strip a unified diff's `-` deletion lines, keeping additions and context.
|
|
300
300
|
|
|
301
301
|
A diff carries the code being REMOVED as well as the code being added, and the detectors read plain
|
|
302
|
-
text
|
|
302
|
+
text, so scanning a raw diff flags a change for a payload it is DELETING. That turns every
|
|
303
303
|
remediation commit red, which is the worst possible failure mode for a security gate: it penalises
|
|
304
304
|
exactly the change you wanted. `---` (the file header) is kept; only true `-` deletion lines go.
|
|
305
305
|
|
|
306
|
-
Non-diff input is returned untouched
|
|
306
|
+
Non-diff input is returned untouched: the check is per line, so a plain source file has no `-`-
|
|
307
307
|
prefixed lines to lose. `scan --include-deletions` opts back in when you want to audit what a change
|
|
308
308
|
removed (a payload can also be hidden by deleting a check)."""
|
|
309
309
|
if not text:
|
|
@@ -314,10 +314,10 @@ def drop_deleted_lines(text: str) -> str:
|
|
|
314
314
|
|
|
315
315
|
def _cmd_scan(args) -> int:
|
|
316
316
|
"""The deterministic HALF of the pipeline, standalone: run the indicator suite over text and exit
|
|
317
|
-
non-zero on a hit at/above the floor. No LLM, no network, no creds, no Deno
|
|
317
|
+
non-zero on a hit at/above the floor. No LLM, no network, no creds, no Deno, just `indicators`.
|
|
318
318
|
|
|
319
319
|
This is what makes the detector safe to dogfood in our OWN CI. The full `pr`/`classify` path needs
|
|
320
|
-
`DS_*` creds, and the only way to hand creds to a fork PR's run is `pull_request_target
|
|
320
|
+
`DS_*` creds, and the only way to hand creds to a fork PR's run is `pull_request_target`, the exact
|
|
321
321
|
misconfiguration that opened the AsyncAPI "Miasma" supply-chain compromise. `scan` needs nothing, so
|
|
322
322
|
it runs under the same read-only `contents: read` token an untrusted fork PR already gets."""
|
|
323
323
|
from .indicators import scan_diff
|
|
@@ -336,7 +336,7 @@ def _cmd_scan(args) -> int:
|
|
|
336
336
|
label = path
|
|
337
337
|
if not args.include_deletions:
|
|
338
338
|
text = drop_deleted_lines(text)
|
|
339
|
-
hits.extend(scan_diff(text, location=label))
|
|
339
|
+
hits.extend(scan_diff(text, location=label, files=True))
|
|
340
340
|
|
|
341
341
|
worst = max((severity_rank(h.severity) for h in hits), default=-1)
|
|
342
342
|
failed = worst >= threshold
|