diff-sentry 0.7.1__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.1 → diff_sentry-0.8.0}/.claude/rules/indicators.md +11 -1
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/.claude/skills/release/SKILL.md +3 -1
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/CHANGELOG.md +16 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/PKG-INFO +3 -3
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/README.md +2 -2
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/action.yml +1 -1
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/diff_sentry/__init__.py +1 -1
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/diff_sentry/cli.py +1 -1
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/diff_sentry/indicators.py +164 -13
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/docs/how-it-works.md +3 -1
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/pyproject.toml +1 -1
- diff_sentry-0.8.0/tests/test_python_loader.py +220 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/uv.lock +1 -1
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/.claude/rules/detect-setup.md +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/.claude/rules/handoff.md +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/.claude/rules/monitoring.md +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/.claude/rules/rubric-and-eval.md +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/.env.example +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/.github/workflows/ci.yml +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/.github/workflows/release.yml +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/.gitignore +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/.python-version +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/AGENTS.md +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/LICENSE +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/VENDOR.md +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/diff_sentry/__main__.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/diff_sentry/actors.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/diff_sentry/assemble.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/diff_sentry/config.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/diff_sentry/dbutil.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/diff_sentry/deep_classify.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/diff_sentry/detect.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/diff_sentry/emit.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/diff_sentry/fetch_tool.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/diff_sentry/hosts.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/diff_sentry/ingest.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/diff_sentry/linewide.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/diff_sentry/normalize.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/diff_sentry/response.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/diff_sentry/rl_export.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/diff_sentry/rubric.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/diff_sentry/schema.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/diff_sentry/shelllex.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/diff_sentry/skills/ci-and-workflows.md +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/diff_sentry/skills/concealment-and-provenance.md +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/diff_sentry/skills/developer-autorun.md +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/diff_sentry/skills/llm-directed-text.md +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/diff_sentry/skills/package-and-build-hooks.md +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/diff_sentry/skills/runtime-droppers-and-exfil.md +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/diff_sentry/skills/triage-a-change.md +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/diff_sentry/snapshot.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/diff_sentry/watch.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/docs/development.md +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/docs/limits.md +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/docs/watch.md +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/eval/README.md +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/eval/diff_sentry_eval/__init__.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/eval/diff_sentry_eval/__main__.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/eval/diff_sentry_eval/cli.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/eval/diff_sentry_eval/judge.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/eval/diff_sentry_eval/schema.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/eval/diff_sentry_eval/score.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/eval/diff_sentry_eval/taskset.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/eval/pyproject.toml +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/eval/taskset.example.json +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/eval/tests/conftest.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/eval/tests/test_boundary.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/eval/tests/test_cli.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/eval/tests/test_judge.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/eval/tests/test_schema.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/eval/tests/test_score.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/eval/tests/test_taskset.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/studio/DESIGN.md +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/studio/README.md +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/studio/diff_sentry_studio/__init__.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/studio/diff_sentry_studio/app.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/studio/diff_sentry_studio/iterations.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/studio/diff_sentry_studio/live.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/studio/diff_sentry_studio/mapper.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/studio/pyproject.toml +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/studio/static/app.js +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/studio/static/index.html +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/studio/static/replay-core.js +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/studio/static/run-core.js +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/studio/static/style.css +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/studio/static/trajectory.js +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/studio/static/vendor/fonts/jetbrains-mono-400.woff2 +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/studio/static/vendor/fonts/jetbrains-mono-700.woff2 +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/studio/tests/replay-core.test.js +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/studio/tests/run-core.test.js +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/studio/tests/static-contract.test.js +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/studio/tests/test_app.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/studio/tests/test_iterations.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/studio/tests/test_live.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/studio/tests/test_mapper.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/studio/tests/trajectory.test.js +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/tests/__init__.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/tests/conftest.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/tests/corpus/change_corpus.json +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/tests/corpus/hackerbot_claw_campaign.json +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/tests/corpus/hackerbot_claw_incident.json +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/tests/fixtures/known-malicious.diff +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/tests/oracle/__init__.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/tests/oracle/grammar.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/tests/oracle/harness.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/tests/oracle/lexrunner.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/tests/oracle/programs.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/tests/oracle/runner.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/tests/test_actors.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/tests/test_adversarial_input.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/tests/test_assemble.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/tests/test_campaign_replay.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/tests/test_cli.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/tests/test_cli_watch.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/tests/test_config.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/tests/test_deep_classify.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/tests/test_detect.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/tests/test_detection_quality.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/tests/test_dogfood_gate.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/tests/test_emit.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/tests/test_enclosure.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/tests/test_export.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/tests/test_fetch_tool.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/tests/test_file_scope.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/tests/test_file_structure.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/tests/test_forward_offline.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/tests/test_gate_edges.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/tests/test_grounding.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/tests/test_hosts.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/tests/test_incident_hackerbot_claw.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/tests/test_indicators.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/tests/test_ingest.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/tests/test_ingest_metadata.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/tests/test_injection_offline.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/tests/test_inline_exec_and_bidi.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/tests/test_linewide.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/tests/test_metadata_rules.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/tests/test_normalize.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/tests/test_oracle.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/tests/test_oracle_harness.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/tests/test_provenance.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/tests/test_response.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/tests/test_rubric.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/tests/test_schema.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/tests/test_shelllex.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/tests/test_snapshot.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/tests/test_subscription.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/tests/test_watch.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.8.0}/tests/test_watch_pool.py +0 -0
|
@@ -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).
|
|
@@ -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.
|
|
@@ -2,6 +2,22 @@
|
|
|
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
|
+
|
|
5
21
|
## 0.7.1
|
|
6
22
|
|
|
7
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.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: diff-sentry
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
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
|
|
@@ -42,7 +42,7 @@ jobs:
|
|
|
42
42
|
- uses: actions/checkout@v7
|
|
43
43
|
with:
|
|
44
44
|
fetch-depth: 0 # the scan diffs against the PR base
|
|
45
|
-
- uses: qazbnm456/diff-sentry@v0.
|
|
45
|
+
- uses: qazbnm456/diff-sentry@v0.8.0
|
|
46
46
|
```
|
|
47
47
|
|
|
48
48
|
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
|
| 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
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 |
|
|
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
57
|
| Exfiltration | secrets reaching a network sink, cross-process `/proc/<pid>/mem` reads, OAST callback services, IPFS and permaweb gateways |
|
|
58
58
|
| Diff-presentation evasion | payloads shoved past the viewport by a long whitespace run, bidi-override and zero-width characters |
|
|
59
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 |
|
|
@@ -21,7 +21,7 @@ jobs:
|
|
|
21
21
|
- uses: actions/checkout@v7
|
|
22
22
|
with:
|
|
23
23
|
fetch-depth: 0 # the scan diffs against the PR base
|
|
24
|
-
- uses: qazbnm456/diff-sentry@v0.
|
|
24
|
+
- uses: qazbnm456/diff-sentry@v0.8.0
|
|
25
25
|
```
|
|
26
26
|
|
|
27
27
|
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
|
| 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
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 |
|
|
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
36
|
| Exfiltration | secrets reaching a network sink, cross-process `/proc/<pid>/mem` reads, OAST callback services, IPFS and permaweb gateways |
|
|
37
37
|
| Diff-presentation evasion | payloads shoved past the viewport by a long whitespace run, bidi-override and zero-width characters |
|
|
38
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 |
|
|
@@ -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:
|
|
@@ -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
|
|
@@ -30,6 +30,7 @@ import posixpath
|
|
|
30
30
|
import re
|
|
31
31
|
import unicodedata
|
|
32
32
|
import warnings
|
|
33
|
+
import zlib
|
|
33
34
|
from collections import Counter, deque
|
|
34
35
|
from collections.abc import Callable, Iterable, Iterator
|
|
35
36
|
from functools import partial
|
|
@@ -401,6 +402,31 @@ _JS_NET_FETCH_RE = re.compile(
|
|
|
401
402
|
re.IGNORECASE)
|
|
402
403
|
_FILE_WRITE_RE = re.compile(r"writeFileSync|createWriteStream|\bfs\.write", re.IGNORECASE)
|
|
403
404
|
|
|
405
|
+
# Python's side of a loader (the litellm 1.107.0 wheels): code run straight out of a decoding call, code fetched and run,
|
|
406
|
+
# and a daemon that leaves its session. `exec(` / `eval(` are the builtins: a method (`.exec(`) or a longer name
|
|
407
|
+
# (`literal_eval(`) is not one. The decoding call may sit behind a module alias (`import zlib as _a; exec(_a.decompress(`).
|
|
408
|
+
# The decoding calls: the base64 family, hex, compression, and `marshal.loads` (compiled code). A bare name imported with
|
|
409
|
+
# `from base64 import b64decode` and a module loaded with `__import__('zlib')` count as well as `zlib.` and an alias.
|
|
410
|
+
_DECODE_CALL = (r"(?:(?:[\w.]{0,80}\.|__import__\s{0,20}\(\s{0,20}['\"]\w{1,20}['\"]\s{0,20}\)\s{0,20}\.)?(?:urlsafe_|standard_)?"
|
|
411
|
+
r"(?:decompress|b64decode|b32decode|b16decode|b85decode|a85decode|decodebytes|unhexlify|fromhex)"
|
|
412
|
+
r"|marshal\s{0,20}\.\s{0,20}loads)\s{0,20}\(")
|
|
413
|
+
# The builtins `exec` / `eval`, also reached through `builtins.` or `getattr(__builtins__, 'exec')`; not a method (`.exec(`) or
|
|
414
|
+
# a longer name (`literal_eval(`).
|
|
415
|
+
_PY_EXEC = (r"(?:(?<![\w.$])(?:exec|eval)|(?<![\w.])(?:__builtins__|builtins)\s{0,20}\.\s{0,20}(?:exec|eval)"
|
|
416
|
+
r"|\bgetattr\s{0,20}\(\s{0,20}(?:__builtins__|builtins)\s{0,20},\s{0,20}['\"](?:exec|eval)['\"]\s{0,20}\))\s{0,20}\(")
|
|
417
|
+
_PACKED_EXEC_RE = re.compile(
|
|
418
|
+
_PY_EXEC + r"\s{0,20}(?:compile\s{0,20}\(\s{0,20})?" + _DECODE_CALL
|
|
419
|
+
# decoded into a name on the same line, then that name run: `c = zlib.decompress(base64.b64decode(s)); exec(c)`
|
|
420
|
+
+ r"|(?<![\w.])(?P<name>\w{1,40})\s{0,20}=\s{0,20}" + _DECODE_CALL + r"[^\n]{0,400}?;[ \t]{0,20}" + _PY_EXEC
|
|
421
|
+
+ r"\s{0,20}(?:compile\s{0,20}\(\s{0,20})?(?P=name)\b"
|
|
422
|
+
+ r"|(?<![\w.$])(?:eval|new[ \t]+Function)\s{0,20}\(\s{0,20}(?:window\.)?(?:atob|Buffer\s{0,20}\.\s{0,20}from)\s{0,20}\(")
|
|
423
|
+
_PY_EXEC_RE = re.compile(_PY_EXEC)
|
|
424
|
+
_PY_NET_FETCH_RE = re.compile(
|
|
425
|
+
r"\brequests\s{0,20}\.\s{0,20}(?:get|post|request|Session)\b|\burlopen\s{0,20}\(|\burlretrieve\s{0,20}\("
|
|
426
|
+
r"|\bhttpx\s{0,20}\.\s{0,20}(?:get|post|request|Client|AsyncClient)\b|\baiohttp\b|\burllib3\b")
|
|
427
|
+
_PY_FORK_RE = re.compile(r"\bos\s{0,20}\.\s{0,20}fork\s{0,20}\(")
|
|
428
|
+
_PY_SETSID_RE = re.compile(r"\bos\s{0,20}\.\s{0,20}setsid\s{0,20}\(")
|
|
429
|
+
|
|
404
430
|
# Tier 3: content-addressed / permaweb gateways. Miasma staged its second-stage binary on IPFS. Same
|
|
405
431
|
# FP-safe reasoning as the dev tunnels: these have real mainstream use (web3 apps, NFT metadata), so a
|
|
406
432
|
# reference alone is a sub-floor `medium`. When the change actually FETCHES from one and writes the
|
|
@@ -555,26 +581,54 @@ def _mostly_text(decoded: str) -> bool:
|
|
|
555
581
|
return odd <= max(1, len(decoded) // 10)
|
|
556
582
|
|
|
557
583
|
|
|
584
|
+
# What the compressed blobs of one text may inflate to, in all, and one blob at most per byte of it: a small blob can
|
|
585
|
+
# decompress huge, and every inflated byte is read by the rules again. A packer's source compresses about threefold.
|
|
586
|
+
_INFLATE_BUDGET = 256 << 10
|
|
587
|
+
_INFLATE_RATIO = 16
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _compressed(raw: bytes) -> bool:
|
|
591
|
+
"""A gzip member, or a zlib stream of any window size (its two header bytes are a multiple of 31, deflate method)."""
|
|
592
|
+
return raw[:2] == b"\x1f\x8b" or (len(raw) >= 2 and raw[0] & 0x0F == 8 and raw[0] >> 4 <= 7
|
|
593
|
+
and ((raw[0] << 8) | raw[1]) % 31 == 0)
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _inflated(raw: bytes, limit: int) -> bytes:
|
|
597
|
+
"""`raw` decompressed when it is a zlib or gzip stream (the packer's `zlib.compress` before `b64encode`), at most
|
|
598
|
+
`limit` bytes of it; any other bytes as they are."""
|
|
599
|
+
if limit <= 0 or not _compressed(raw):
|
|
600
|
+
return raw
|
|
601
|
+
try:
|
|
602
|
+
return zlib.decompressobj(wbits=47).decompress(raw, limit)
|
|
603
|
+
except zlib.error:
|
|
604
|
+
return raw
|
|
605
|
+
|
|
606
|
+
|
|
558
607
|
def _scan_obfuscation(text: str) -> list[IndicatorHit]:
|
|
559
608
|
"""Decode base64 blobs and RE-SCAN the decoded bytes for shell/exfil payloads (de-obfuscation)."""
|
|
560
609
|
hits: list[IndicatorHit] = []
|
|
561
610
|
seen: set[str] = set()
|
|
611
|
+
budget = _INFLATE_BUDGET
|
|
562
612
|
for m in _B64_RE.finditer(text):
|
|
563
613
|
blob = m.group(0)
|
|
564
614
|
if blob in seen:
|
|
565
615
|
continue
|
|
566
616
|
seen.add(blob)
|
|
567
617
|
try:
|
|
568
|
-
|
|
618
|
+
raw = base64.b64decode(blob + "=" * (-len(blob) % 4), validate=True)
|
|
569
619
|
except (binascii.Error, ValueError):
|
|
570
620
|
continue
|
|
621
|
+
inflated = _inflated(raw, min(budget, _INFLATE_RATIO * len(raw)))
|
|
622
|
+
if inflated is not raw:
|
|
623
|
+
budget -= len(inflated)
|
|
624
|
+
decoded = inflated.decode("utf-8", "replace")
|
|
571
625
|
if not _mostly_text(decoded):
|
|
572
626
|
continue
|
|
573
627
|
# Only flag base64 that DECODES to something suspicious; a plain data blob isn't an indicator.
|
|
574
628
|
# (Rescan shell/exfil/URL/callback-domain payloads; CI-skip and permission markers aren't usefully
|
|
575
629
|
# base64-wrapped, so they're not in the inner set.)
|
|
576
630
|
inner = (_scan_shell(decoded, False) + _scan_urls(decoded) + _scan_exfil(decoded)
|
|
577
|
-
+ _scan_exfil_domains(decoded) + _scan_js_exec(decoded))
|
|
631
|
+
+ _scan_exfil_domains(decoded) + _scan_js_exec(decoded) + _scan_py_exec(decoded))
|
|
578
632
|
if inner:
|
|
579
633
|
worst = max((h.severity for h in inner), key=lambda s: _sev_rank(s))
|
|
580
634
|
hits.append(_hit("obfuscated-payload", worst,
|
|
@@ -757,6 +811,27 @@ def _scan_js_exec(text: str, patch: bool = False) -> list[IndicatorHit]:
|
|
|
757
811
|
return hits
|
|
758
812
|
|
|
759
813
|
|
|
814
|
+
def _scan_py_exec(text: str) -> list[IndicatorHit]:
|
|
815
|
+
"""Running code that arrives packed or over the network, and Python's daemon shape. Each has no ordinary reading:
|
|
816
|
+
a program that ships its own source runs it, it does not decode it first; a client that fetches data parses it."""
|
|
817
|
+
hits: list[IndicatorHit] = []
|
|
818
|
+
packed = _PACKED_EXEC_RE.search(text)
|
|
819
|
+
if packed:
|
|
820
|
+
hits.append(_hit("packed-code-exec", "high", "runs code straight out of a decoding call (packed payload)",
|
|
821
|
+
_snip(text, packed.start(), packed.end())))
|
|
822
|
+
fetch = _PY_NET_FETCH_RE.search(text)
|
|
823
|
+
run = _PY_EXEC_RE.search(text) if fetch else None
|
|
824
|
+
if run:
|
|
825
|
+
hits.append(_hit("remote-code-exec", "high", "fetches over the network AND runs code with `exec` / `eval` (loader shape)",
|
|
826
|
+
_snip(text, fetch.start(), fetch.end())))
|
|
827
|
+
fork = _PY_FORK_RE.search(text)
|
|
828
|
+
if fork and _PY_SETSID_RE.search(text):
|
|
829
|
+
hits.append(_hit("detached-process-spawn", "high",
|
|
830
|
+
"forks a process that leaves its session (`os.fork` + `os.setsid`, a daemon outliving its parent)",
|
|
831
|
+
_snip(text, fork.start(), fork.end())))
|
|
832
|
+
return hits
|
|
833
|
+
|
|
834
|
+
|
|
760
835
|
def _scan_hex_identifiers(text: str) -> list[IndicatorHit]:
|
|
761
836
|
"""javascript-obfuscator's `_0x…` mangling: obfuscation that base64 detection cannot see."""
|
|
762
837
|
found = _HEX_IDENT_RE.findall(text)
|
|
@@ -842,7 +917,32 @@ def _sev_rank(sev: str) -> int:
|
|
|
842
917
|
# The trade is explicit: a payload deliberately split across two files does not pair. That is the
|
|
843
918
|
# right way round: a detector that invents a critical out of two unrelated files trains people to
|
|
844
919
|
# disable it, and the single-signal rules (most of them) are unaffected.
|
|
845
|
-
|
|
920
|
+
# git quotes a path with a byte outside printable ASCII (`core.quotePath`, on by default), a quote, a backslash or a control
|
|
921
|
+
# character: `diff --git "a/p\\303\\251/x.pth" "b/p\\303\\251/x.pth"`, each side on its own.
|
|
922
|
+
_DIFF_FILE_RE = re.compile(r'^diff --git (?:"a/(?P<qa>(?:[^"\\\n]|\\.)+)"|a/(?P<a>.+?)) (?:"b/(?P<qb>(?:[^"\\\n]|\\.)+)"|b/(?P<b>.+?))$',
|
|
923
|
+
re.MULTILINE)
|
|
924
|
+
_GIT_ESCAPES = {"a": 7, "b": 8, "t": 9, "n": 10, "v": 11, "f": 12, "r": 13}
|
|
925
|
+
_GIT_OCTAL_RE = re.compile(r"\\([0-3][0-7]{2})|\\(.)", re.DOTALL)
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
def _git_unquote(path: str) -> str:
|
|
929
|
+
"""A path as git's quoting spells it, with its C escapes (`\\303\\251`, `\\t`, `\\"`) turned back into its bytes."""
|
|
930
|
+
out = bytearray()
|
|
931
|
+
last = 0
|
|
932
|
+
for m in _GIT_OCTAL_RE.finditer(path):
|
|
933
|
+
out += path[last:m.start()].encode("utf-8", "surrogatepass")
|
|
934
|
+
out.append(int(m.group(1), 8) if m.group(1) else _GIT_ESCAPES.get(m.group(2), ord(m.group(2)) if m.group(2) < "\x80" else 63))
|
|
935
|
+
last = m.end()
|
|
936
|
+
out += path[last:].encode("utf-8", "surrogatepass")
|
|
937
|
+
return out.decode("utf-8", "replace")
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
def _diff_path(m: re.Match) -> str:
|
|
941
|
+
if m.group("qb") is not None:
|
|
942
|
+
return _git_unquote(m.group("qb"))
|
|
943
|
+
if m.group("b") is not None:
|
|
944
|
+
return m.group("b")
|
|
945
|
+
return _git_unquote(m.group("qa")) if m.group("qa") is not None else m.group("a")
|
|
846
946
|
|
|
847
947
|
|
|
848
948
|
def split_diff_by_file(text: str) -> list[tuple[str, str]]:
|
|
@@ -860,7 +960,7 @@ def split_diff_by_file(text: str) -> list[tuple[str, str]]:
|
|
|
860
960
|
out.append(("", head))
|
|
861
961
|
for i, m in enumerate(marks):
|
|
862
962
|
end = marks[i + 1].start() if i + 1 < len(marks) else len(text)
|
|
863
|
-
out.append((
|
|
963
|
+
out.append((_diff_path(m), text[m.start():end]))
|
|
864
964
|
return out
|
|
865
965
|
|
|
866
966
|
|
|
@@ -3200,12 +3300,29 @@ def scan_content(event: dict) -> list[IndicatorHit]:
|
|
|
3200
3300
|
return list(deduped.values())
|
|
3201
3301
|
|
|
3202
3302
|
|
|
3203
|
-
|
|
3303
|
+
_DIFF_ADDED_RE = re.compile(r"^(?:new file mode |--- /dev/null\r?$)", re.MULTILINE)
|
|
3304
|
+
_DIFF_RENAMED_RE = re.compile(r"^rename to ", re.MULTILINE)
|
|
3305
|
+
_DIFF_HUNK_RE = re.compile(r"^@@", re.MULTILINE)
|
|
3306
|
+
|
|
3307
|
+
|
|
3308
|
+
def _diff_file(path: str, segment: str) -> dict:
|
|
3309
|
+
"""One file of a `diff --git` diff in the shape of an event's file entry: added when its header says so, its patch the
|
|
3310
|
+
hunks (a binary file's `Binary files ... differ` has none), its additions counted."""
|
|
3311
|
+
hunk = _DIFF_HUNK_RE.search(segment)
|
|
3312
|
+
header, patch = (segment[:hunk.start()], segment[hunk.start():]) if hunk else (segment, "")
|
|
3313
|
+
status = "added" if _DIFF_ADDED_RE.search(header) else "renamed" if _DIFF_RENAMED_RE.search(header) else "modified"
|
|
3314
|
+
return {"filename": path, "status": status,
|
|
3315
|
+
"additions": sum(1 for line in patch.split("\n") if line.startswith("+")), "deletions": 0, "patch": patch}
|
|
3316
|
+
|
|
3317
|
+
|
|
3318
|
+
def scan_diff(text: str, *, location: str = "", files: bool = False) -> list[IndicatorHit]:
|
|
3204
3319
|
"""`scan_indicators` with per-file scoping: a paired rule can only fire when ONE file carries both
|
|
3205
3320
|
halves. Non-diff input (a region the planner pulled out, a plain file) falls through to a single
|
|
3206
3321
|
whole-text scan, so this is safe as the entry point for either shape. Both are read as a patch (a leading `+` is a diff marker):
|
|
3207
3322
|
the host-side baseline reads every channel with its own flag, and the union keeps its stricter hit. Only the files of a
|
|
3208
|
-
`diff --git` diff are a file's patch to `inline-code-exec` (`patch`); the fallthrough is read as it is.
|
|
3323
|
+
`diff --git` diff are a file's patch to `inline-code-exec` (`patch`); the fallthrough is read as it is. `files` also runs the
|
|
3324
|
+
file-structure rules on each file of the diff, as `scan_files` does on an event's file list: the Action's `scan` reads a
|
|
3325
|
+
diff of the change, while a diff the planner finds quoted in a comment is no file of the change."""
|
|
3209
3326
|
raw = text or ""
|
|
3210
3327
|
text = _lf(raw)
|
|
3211
3328
|
segments = split_diff_by_file(text)
|
|
@@ -3213,10 +3330,16 @@ def scan_diff(text: str, *, location: str = "") -> list[IndicatorHit]:
|
|
|
3213
3330
|
return scan_indicators(raw, location=location)
|
|
3214
3331
|
lone_cr = _LONE_CR_RE.search(raw) or "\0" in raw # the files were cut out of the text `_lf` made
|
|
3215
3332
|
deduped: dict[str, IndicatorHit] = {}
|
|
3333
|
+
# cut with only CRLF made LF: a lone CR, which `_lf` makes a line break, is a character of a `.pth` line to the patch
|
|
3334
|
+
entries = [_diff_file(path, segment) for path, segment in split_diff_by_file(raw.replace("\r\n", "\n")) if path] if files else []
|
|
3335
|
+
added = frozenset(posixpath.normpath(f["filename"]) for f in entries if f["status"] == "added")
|
|
3216
3336
|
for path, segment in segments:
|
|
3217
3337
|
found = scan_indicators(segment, location=path or location, patch=bool(path) and not lone_cr)
|
|
3218
3338
|
for h in _or_closed(partial(_scope_to_file, found, path, segment), found, "_scope_to_file") if path else found:
|
|
3219
3339
|
keep_worse(deduped, h)
|
|
3340
|
+
for f in entries: # the file-structure rules an event's file list gets (`scan_files`), from the diff's own headers
|
|
3341
|
+
for h in _file_hits(f, added):
|
|
3342
|
+
keep_worse(deduped, h)
|
|
3220
3343
|
if _LONE_CR_RE.search(raw):
|
|
3221
3344
|
# `scan_indicators` reads a lone CR as a character of a word for a text it is handed as it is; the files of a diff were cut out of the text with the CR made a newline
|
|
3222
3345
|
letters = _lf(_LONE_CR_RE.sub("x", raw))
|
|
@@ -3233,7 +3356,7 @@ def scan_diff(text: str, *, location: str = "") -> list[IndicatorHit]:
|
|
|
3233
3356
|
_DETECTORS = (
|
|
3234
3357
|
_scan_shell, _scan_obfuscation, _scan_exfil, _scan_urls, _scan_exfil_domains, _scan_ci_paths, _scan_ci_bypass,
|
|
3235
3358
|
_scan_workflow_perms, _scan_injection, _scan_entropy, _scan_workflow_config, _scan_diff_evasion, _scan_js_exec,
|
|
3236
|
-
_scan_hex_identifiers, _scan_string_array, _scan_run_asset,
|
|
3359
|
+
_scan_hex_identifiers, _scan_string_array, _scan_run_asset, _scan_py_exec,
|
|
3237
3360
|
)
|
|
3238
3361
|
|
|
3239
3362
|
|
|
@@ -3696,6 +3819,15 @@ def _file_structure_hits(f: dict, added: frozenset[str]) -> list[IndicatorHit]:
|
|
|
3696
3819
|
found, autorun = _autorun_hits(path, tokens)
|
|
3697
3820
|
hits += found
|
|
3698
3821
|
hits += _run_entry_hits(f, added, tokens, autorun)
|
|
3822
|
+
base = posixpath.basename(low)
|
|
3823
|
+
if base.endswith(".pth"):
|
|
3824
|
+
line = next((part for part in _pth_written(patch) if _PTH_IMPORT_RE.match(part)), None)
|
|
3825
|
+
if line is not None:
|
|
3826
|
+
hits.append(_hit("python-startup-hook", "high", "a `.pth` file that runs code at every Python start",
|
|
3827
|
+
line[:_MAX_EVIDENCE], location=path, form=path))
|
|
3828
|
+
elif _CUSTOMIZE_RE.search(low) and (_added_lines(patch).strip() or f["status"] == "renamed"):
|
|
3829
|
+
hits.append(_hit("python-startup-hook", "high", f"a `{_CUSTOMIZE_RE.search(low).group(1)}` module (Python runs it at every start)",
|
|
3830
|
+
_snip(_added_lines(patch), 0, 0, pad=160) or f"renamed to {path[:160]}", location=path, form=path))
|
|
3699
3831
|
if _DEV_CONFIG_RE.search(path):
|
|
3700
3832
|
new = _added_lines(patch)
|
|
3701
3833
|
if _scan_string_array(new):
|
|
@@ -3705,6 +3837,21 @@ def _file_structure_hits(f: dict, added: frozenset[str]) -> list[IndicatorHit]:
|
|
|
3705
3837
|
return hits
|
|
3706
3838
|
|
|
3707
3839
|
|
|
3840
|
+
# The lines `site` executes in a `.pth` file of site-packages: those that start with `import` and a blank, at every start
|
|
3841
|
+
# of the interpreter (the rest of the file is a list of paths). It reads the file as `utf-8-sig` and splits it with
|
|
3842
|
+
# `str.splitlines` (3.13; earlier, text mode, which also ends a line at a lone CR), so a line of the patch can hold several.
|
|
3843
|
+
_PTH_IMPORT_RE = re.compile(r"\ufeff?import[ \t]")
|
|
3844
|
+
# `sitecustomize` and `usercustomize`, as a module or a package, which `site` imports at every start.
|
|
3845
|
+
_CUSTOMIZE_RE = re.compile(r"(?:^|/)((?:site|user)customize)(?:\.py|/__init__\.py)$")
|
|
3846
|
+
|
|
3847
|
+
|
|
3848
|
+
def _pth_written(patch: str) -> list[str]:
|
|
3849
|
+
"""The lines of a `.pth` file that the patch writes, split as `site` splits them: a lone CR, a form feed and the other
|
|
3850
|
+
`str.splitlines` breaks end a line there, though no line of the patch ends at them."""
|
|
3851
|
+
return [part for line in patch.split("\n") if line.startswith("+") and not line.startswith("+++")
|
|
3852
|
+
for part in line[1:].splitlines()]
|
|
3853
|
+
|
|
3854
|
+
|
|
3708
3855
|
def _added_lines(patch: str) -> str:
|
|
3709
3856
|
return "\n".join(text for text, written in _new_side(patch) if written)
|
|
3710
3857
|
|
|
@@ -3718,16 +3865,20 @@ def scan_files(event: dict) -> list[IndicatorHit]:
|
|
|
3718
3865
|
added = frozenset(posixpath.normpath(f["filename"]) for f in files if f["status"] == "added")
|
|
3719
3866
|
deduped: dict[str, IndicatorHit] = {}
|
|
3720
3867
|
for f in files:
|
|
3721
|
-
|
|
3722
|
-
found = _file_structure_hits(f, added)
|
|
3723
|
-
except Exception as exc: # noqa: BLE001 (fail CLOSED, as `scan_indicators` does)
|
|
3724
|
-
found = [_hit("scanner-error", "high", "a file-structure rule raised on this file, so it was not fully scanned",
|
|
3725
|
-
f"_file_structure_hits: {type(exc).__name__}", location=f["filename"])]
|
|
3726
|
-
for h in found:
|
|
3868
|
+
for h in _file_hits(f, added):
|
|
3727
3869
|
keep_worse(deduped, h)
|
|
3728
3870
|
return list(deduped.values())
|
|
3729
3871
|
|
|
3730
3872
|
|
|
3873
|
+
def _file_hits(f: dict, added: frozenset[str]) -> list[IndicatorHit]:
|
|
3874
|
+
"""`_file_structure_hits` of one file, or a `scanner-error` for it when a rule raises."""
|
|
3875
|
+
try:
|
|
3876
|
+
return _file_structure_hits(f, added)
|
|
3877
|
+
except Exception as exc: # noqa: BLE001 (fail CLOSED, as `scan_indicators` does)
|
|
3878
|
+
return [_hit("scanner-error", "high", "a file-structure rule raised on this file, so it was not fully scanned",
|
|
3879
|
+
f"_file_structure_hits: {type(exc).__name__}", location=f["filename"])]
|
|
3880
|
+
|
|
3881
|
+
|
|
3731
3882
|
def scan_change(event: dict) -> list[IndicatorHit]:
|
|
3732
3883
|
"""Everything an event's own content and structure show: the text rules over each file, the title, the
|
|
3733
3884
|
body, the head branch and the author's/outsiders' comments (`scan_content`), plus the structural rules
|
|
@@ -49,8 +49,10 @@ None of this is required to use the Action, and none of it sends your code anywh
|
|
|
49
49
|
|
|
50
50
|
## Grounded in real incidents
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
Three incidents are reconstructed offline and pinned by tests, so a rule change that breaks coverage fails the build.
|
|
53
53
|
|
|
54
54
|
**`hackerbot-claw`** (Datadog's [BewAIre writeup](https://www.datadoghq.com/blog/engineering/stopping-hackerbot-claw-with-bewaire/)). The original artifacts are gone and the attacker account was deleted, so `tests/corpus/hackerbot_claw_incident.json` reconstructs all three events: a `${IFS}`/base64 payload hidden in a *filename* under `documentation/rules/` that de-obfuscates to a published pipe-to-shell IOC, a prompt injection telling a triage LLM to bulk-label and exfiltrate, and a prompt injection appending a fake owner to CODEOWNERS. Each is asserted under a neutral benign verdict, so the alert is evidence-driven: a false-benign self-report could not have suppressed any of them.
|
|
55
55
|
|
|
56
56
|
**AsyncAPI "Miasma"**. The attack was Node end to end, and `tests/test_indicators.py` reconstructs its stages: the `pull_request_target` workflow that checks out the PR head, the payload pushed off the diff viewport by a whitespace run, the IPFS fetch-to-disk dropper, and the `_0x` obfuscation injected into a source file. Each rule ships with its negative case, so the tuning is pinned as tightly as the detection.
|
|
57
|
+
|
|
58
|
+
**litellm 1.107.0** ([BerriAI/litellm#47826](https://github.com/BerriAI/litellm/issues/47826)). The wheels carried a `.pth` file that imports a zlib+base64-packed credential stealer at every Python start, and a `sitecustomize.py` that forks a daemon to fetch code and run it. The commits came from a lookalike fork straight into the release build, so no pull request held them. `tests/test_python_loader.py` holds that the same files, sent as a pull request, fail the Action's scan.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "diff-sentry"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.8.0"
|
|
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"
|