diff-sentry 0.7.1__tar.gz → 0.9.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.9.0}/.claude/rules/indicators.md +11 -1
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/.claude/rules/monitoring.md +10 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/.claude/skills/release/SKILL.md +3 -1
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/.env.example +3 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/AGENTS.md +4 -4
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/CHANGELOG.md +27 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/PKG-INFO +6 -3
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/README.md +4 -2
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/action.yml +1 -1
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/diff_sentry/__init__.py +1 -1
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/diff_sentry/cli.py +64 -2
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/diff_sentry/indicators.py +167 -15
- diff_sentry-0.9.0/diff_sentry/releases.py +895 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/docs/how-it-works.md +3 -1
- diff_sentry-0.9.0/docs/release-watch.md +26 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/pyproject.toml +2 -1
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/tests/test_adversarial_input.py +1 -1
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/tests/test_indicators.py +7 -0
- diff_sentry-0.9.0/tests/test_python_loader.py +220 -0
- diff_sentry-0.9.0/tests/test_releases.py +614 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/uv.lock +3 -1
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/.claude/rules/detect-setup.md +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/.claude/rules/handoff.md +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/.claude/rules/rubric-and-eval.md +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/.github/workflows/ci.yml +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/.github/workflows/release.yml +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/.gitignore +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/.python-version +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/LICENSE +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/VENDOR.md +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/diff_sentry/__main__.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/diff_sentry/actors.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/diff_sentry/assemble.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/diff_sentry/config.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/diff_sentry/dbutil.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/diff_sentry/deep_classify.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/diff_sentry/detect.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/diff_sentry/emit.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/diff_sentry/fetch_tool.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/diff_sentry/hosts.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/diff_sentry/ingest.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/diff_sentry/linewide.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/diff_sentry/normalize.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/diff_sentry/response.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/diff_sentry/rl_export.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/diff_sentry/rubric.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/diff_sentry/schema.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/diff_sentry/shelllex.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/diff_sentry/skills/ci-and-workflows.md +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/diff_sentry/skills/concealment-and-provenance.md +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/diff_sentry/skills/developer-autorun.md +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/diff_sentry/skills/llm-directed-text.md +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/diff_sentry/skills/package-and-build-hooks.md +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/diff_sentry/skills/runtime-droppers-and-exfil.md +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/diff_sentry/skills/triage-a-change.md +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/diff_sentry/snapshot.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/diff_sentry/watch.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/docs/development.md +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/docs/limits.md +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/docs/watch.md +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/eval/README.md +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/eval/diff_sentry_eval/__init__.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/eval/diff_sentry_eval/__main__.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/eval/diff_sentry_eval/cli.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/eval/diff_sentry_eval/judge.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/eval/diff_sentry_eval/schema.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/eval/diff_sentry_eval/score.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/eval/diff_sentry_eval/taskset.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/eval/pyproject.toml +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/eval/taskset.example.json +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/eval/tests/conftest.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/eval/tests/test_boundary.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/eval/tests/test_cli.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/eval/tests/test_judge.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/eval/tests/test_schema.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/eval/tests/test_score.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/eval/tests/test_taskset.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/studio/DESIGN.md +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/studio/README.md +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/studio/diff_sentry_studio/__init__.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/studio/diff_sentry_studio/app.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/studio/diff_sentry_studio/iterations.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/studio/diff_sentry_studio/live.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/studio/diff_sentry_studio/mapper.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/studio/pyproject.toml +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/studio/static/app.js +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/studio/static/index.html +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/studio/static/replay-core.js +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/studio/static/run-core.js +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/studio/static/style.css +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/studio/static/trajectory.js +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/studio/static/vendor/fonts/jetbrains-mono-400.woff2 +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/studio/static/vendor/fonts/jetbrains-mono-700.woff2 +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/studio/tests/replay-core.test.js +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/studio/tests/run-core.test.js +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/studio/tests/static-contract.test.js +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/studio/tests/test_app.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/studio/tests/test_iterations.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/studio/tests/test_live.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/studio/tests/test_mapper.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/studio/tests/trajectory.test.js +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/tests/__init__.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/tests/conftest.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/tests/corpus/change_corpus.json +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/tests/corpus/hackerbot_claw_campaign.json +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/tests/corpus/hackerbot_claw_incident.json +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/tests/fixtures/known-malicious.diff +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/tests/oracle/__init__.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/tests/oracle/grammar.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/tests/oracle/harness.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/tests/oracle/lexrunner.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/tests/oracle/programs.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/tests/oracle/runner.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/tests/test_actors.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/tests/test_assemble.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/tests/test_campaign_replay.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/tests/test_cli.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/tests/test_cli_watch.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/tests/test_config.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/tests/test_deep_classify.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/tests/test_detect.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/tests/test_detection_quality.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/tests/test_dogfood_gate.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/tests/test_emit.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/tests/test_enclosure.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/tests/test_export.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/tests/test_fetch_tool.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/tests/test_file_scope.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/tests/test_file_structure.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/tests/test_forward_offline.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/tests/test_gate_edges.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/tests/test_grounding.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/tests/test_hosts.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/tests/test_incident_hackerbot_claw.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/tests/test_ingest.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/tests/test_ingest_metadata.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/tests/test_injection_offline.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/tests/test_inline_exec_and_bidi.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/tests/test_linewide.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/tests/test_metadata_rules.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/tests/test_normalize.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/tests/test_oracle.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/tests/test_oracle_harness.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/tests/test_provenance.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/tests/test_response.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/tests/test_rubric.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/tests/test_schema.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/tests/test_shelllex.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/tests/test_snapshot.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/tests/test_subscription.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.0}/tests/test_watch.py +0 -0
- {diff_sentry-0.7.1 → diff_sentry-0.9.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).
|
|
@@ -42,3 +42,13 @@ paths:
|
|
|
42
42
|
- **Adding a fact:** capture it in `ingest.py` (best-effort: a failed call omits the key, unless the value feeds the digest, in which case mark the event incomplete instead of reporting "none"), put it in `provenance` (or a bounded event field), read it in `scan_provenance` / `scan_metadata` with a negative case, and keep it sub-floor unless it is standalone evidence. Replay the incident corpus.
|
|
43
43
|
- **Read-only means read-only.** `--stats` and `actor` open the state `mode=ro` (`open_versioned(read_only=True)`) and neither create, stamp, nor requeue anything. Only `serve` and `--once` call `recover()`, under the lock.
|
|
44
44
|
- **Terminal output is sanitized.** `format_dossier` replaces control characters; `_json_out` escapes the C1 range `json.dumps` leaves raw; URL and token parsing cut at the first control character. A string an attacker wrote must not be able to drive the operator's terminal.
|
|
45
|
+
|
|
46
|
+
## Release watch (`releases.py`, `release-watch`)
|
|
47
|
+
|
|
48
|
+
- **A release is a change.** `release_event` builds a `kind="release"` event (`repo` `pypi/<name>`, run id `pypi__<name>-<version>`, `-r<n>` for files added to a release later) whose files are the members of the new artifacts with content the previous release lacked, labelled `<kind>/<path>` (`wheel/cook.pth`, `sdist/pyproject.toml`), with a unified diff as the patch. It goes through `cli.screen`, so every text and file-structure rule reads it; never add a second scanning path for releases.
|
|
49
|
+
- **Release facts are host-side facts.** `scan_release` turns `event["release"]` into baseline hits beside `scan_provenance` in `cli._baseline_indicators`: `release-new-install-root` (medium), `release-new-dependency` (low), and what was not read, all `high`: `release-attestation-dropped`, `release-shadowed-member` (two members of one artifact install to one path, which pip resolves to the `.data` copy), `release-code-not-read` (a changed member that runs, by suffix or as a build file or script, and was not shown as text: too large, past the diff's 20,000 lines, past a text or patch budget, compiled bytecode, a source file declaring a coding it cannot be read in, and a native module whose name, without its ABI tag, the previous release did not have), and `release-not-fully-read` (an artifact not downloaded, not matching its sha256, not read to its end for any reason, or a comparison given up after five attempts, which is screened and alerts on its own). `release_scope` keeps `prompt-injection` at `medium` in a release event: a package's files are read by the programs that import them, and an LLM library's own code talks about its prompts.
|
|
50
|
+
- **Artifacts are data.** Download only from `pypi.org` and `files.pythonhosted.org` over https, no redirects, each body checked against the index's sha256; read archives in memory, never extract, never install. Bounds: a download (`DS_RELEASE_MAX_ARTIFACT_MB`, 256), a release (1 GiB or four downloads), a zip's central directory (32 MiB: the largest of the size the end record states and the zip64 records whenever a locator sits before it, the one zipfile reads right before the locator and the one the locator names; never the member count, which zipfile ignores), members per artifact (50,000, a cut noted), decompressed bytes per artifact (256 MiB, counted by a capped stream so a tar header is bounded too), text kept per comparison (256 MiB), text per member (1 MiB; a larger one is compared by its sha256), patch text per event (16 MiB). A download PyPI does not serve is retried, never judged on part of a release.
|
|
51
|
+
- **Two passes.** The first reads digests only, to learn which members changed; the second keeps text for those and for the metadata alone, so a release of many platform wheels does not spend the text budget on what did not change. A Python source member is read as UTF-8 with replacement characters and its NULs kept, and, when its first two lines hold a `coding[:=]` declaration anywhere, also as that coding (resolved by `codecs.lookup` as CPython resolves it; a name of more than 200 characters is not resolved), both readings scanned (a `coding: utf-7` file hides its code from a UTF-8 read, and where CPython honours a declaration is not reproduced, so neither reading is left to the author); a declared coding that cannot be read is `release-code-not-read` (`site` runs a `.pth` line after a NUL line); any other member that is not UTF-8 is binary.
|
|
52
|
+
- **Paths are where pip puts them.** A wheel's `x.data/purelib/` and `platlib/` map to the site-packages root, `x.data/scripts/` to `{scripts}/`, `x.dist-info/` to `{dist-info}/`; an sdist member loses its top directory, and one at the root keeps its path (pip builds from the root when the members share no top directory). `RECORD`, `WHEEL` and `METADATA` of a wheel, and `PKG-INFO` and `*.egg-info/` of an sdist, are derived and not diffed; `Requires-Dist` is read from `METADATA`.
|
|
53
|
+
- **The previous release** is the highest version below the new one with a file that is not yanked; a pre-release is compared only when the new one is a pre-release too. Members are compared by content across all artifacts of a kind, so a payload in one platform's wheel shows.
|
|
54
|
+
- **State is per file.** First sight of a package (even an empty one) records every file as the baseline. After that, each file not yet recorded is judged with the other new files of its release, against the previous release, so a wheel uploaded after its release was judged, or added to an old release, is judged too. A file is recorded only after its comparison is screened, and the index's ETag only after every new file of it is: a watcher stopped mid-comparison judges the same files on its next pass. A failed comparison is retried on every pass (the ETag is not sent while one is pending), superseded when the files to judge change, and kept as `error` after five, with an alert that it was not compared. A `--check` runs under `...-check` and never stands in for the watcher's judgment. An alert the SIEM did not accept is re-sent from `screened/` a few per poll.
|
|
@@ -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.
|
|
@@ -79,3 +79,6 @@ DS_API_KEY=sk-...
|
|
|
79
79
|
# DS_WATCH_MAX_DEEP_PER_HOUR=0 # 0 = unlimited model runs per hour
|
|
80
80
|
# DS_WATCH_WORKERS=1 # model runs at once; above 1, each in a process of its own
|
|
81
81
|
# DS_WATCH_RATE_RESERVE=200 # pause polling when the API's remaining budget falls below this
|
|
82
|
+
# DS_RELEASE_PACKAGES=litellm,requests # release-watch: PyPI projects (or --packages / --packages-file)
|
|
83
|
+
# DS_RELEASE_INTERVAL=300 # release-watch: seconds between polls of each project's index
|
|
84
|
+
# DS_RELEASE_MAX_ARTIFACT_MB=256 # release-watch: a larger wheel or sdist is not compared, which alerts
|
|
@@ -16,7 +16,7 @@ rlm-harness is an exact PyPI pin (`rlm-harness==X.Y.Z` in `pyproject.toml`, lock
|
|
|
16
16
|
|
|
17
17
|
- Run via `cli` (`pr` / `issue` / `classify`), never an ad-hoc script. `cli.run(event, …)` is the programmatic entry: it records `<out>/traces/{run_id}.jsonl` (dropping a stale one first, since TraceRecorder appends), writes `<out>/responses/{run_id}.json`, and emits the SIEM signal host-side after the run. It never raises: a crash still writes a `status=failed` response that can signal off the evidence floor. Extend `cli.py` rather than driving `detect_from_event` / `build_response` directly.
|
|
18
18
|
- Offline: `python -m diff_sentry render <trace> <run_id>` re-renders a response; `python -m diff_sentry export "output/traces/*.jsonl" ds.json` exports the reward-free dataset; `actor <login>` prints an account's dossier and `archive verify` re-hashes the snapshot archive.
|
|
19
|
-
- Continuous collection goes through `watch`, which
|
|
19
|
+
- Continuous collection goes through `watch` and `release-watch`, which call `cli.run` (model) or `cli.screen` (deterministic, no creds) and never `detect_from_event` directly. `--screen-only` needs no model roles.
|
|
20
20
|
|
|
21
21
|
## Hard invariants: do not break
|
|
22
22
|
|
|
@@ -31,10 +31,10 @@ rlm-harness is an exact PyPI pin (`rlm-harness==X.Y.Z` in `pyproject.toml`, lock
|
|
|
31
31
|
- **The budget is hard: `max_retries=1`, no whole-RLM retry inside a run.** One change = one trajectory, so the trace stays valid training data. (`watch` may run a change again, but only when the previous attempt produced no result at all, an infrastructure failure; `cli.run` resets that run id's trace first, so exactly one trajectory per run id survives.) A failed run is infra before it is a schema bug; see `.claude/rules/detect-setup.md` for how the failure names itself.
|
|
32
32
|
- **A second model-judgement is a TOOL, never the sub-LM.** `deep_classify` (on rlm-harness's `make_model_tool`) is the swappable second-stage seam the planner chooses to call, so the decision is a `tool_call` in the trajectory. The analyst intercept (`intercept_sub_lm`) is tracing-only, zero transforms. Swapping the backend touches only `classify_backend` / `_selfclassify_chat`. The planner passes distilled findings, never the whole diff.
|
|
33
33
|
- **Sandboxed code builds no sub-agents.** In dspy 3.4.1 and later, sandboxed code can construct dspy programs that run on the host, call the analyst, can drive the task's tools, and record as one `sub_call` with no inner turns. The kit's default allows it, so `setup` passes `sandbox_sub_agents=False` explicitly, and production never injects an interpreter (which would bypass it); `test_program_offers_no_sub_agents` holds the behaviour.
|
|
34
|
-
- **GitHub ingest, monitoring, and SIEM emission are host-side plumbing, never planner tools.** `ingest.py` shells out to `gh` host-side (transport injectable); `emit.emit_signal` POSTs and never raises; `watch`, `actors` and `snapshot` poll, remember, and archive outside the trajectory. Only choices the policy makes are tools; delivering a finished result is plumbing. What the store learns reaches the signal only as provenance facts through `scan_provenance`, never as a second derivation (see `.claude/rules/monitoring.md`).
|
|
34
|
+
- **GitHub ingest, monitoring, and SIEM emission are host-side plumbing, never planner tools.** `ingest.py` shells out to `gh` host-side (transport injectable); `emit.emit_signal` POSTs and never raises; `watch`, `actors` and `snapshot` poll, remember, and archive outside the trajectory, and `releases` reads PyPI's own hosts only, as data that is never installed. Only choices the policy makes are tools; delivering a finished result is plumbing. What the store learns reaches the signal only as provenance facts through `scan_provenance`, never as a second derivation (see `.claude/rules/monitoring.md`).
|
|
35
35
|
- **This is a ROLLOUT source: trajectories, not reward.** `rl_export` passes `reward=None`; labels read the assembled verdict and metrics are objective effort counters. `deep_classify` tool_calls train the classifier; every other action is the planner's. A metric keeps ONE meaning across rlm-harness versions (the corpus mixes them; `run_start.payload.rlm_harness` names the writer): a quantity a newer kit makes observable gets a NEW key that is None where the trace cannot say (e.g. `analyst_failures`). The ATLAS rubric and the eval member stay reward-free too; see `.claude/rules/rubric-and-eval.md`.
|
|
36
36
|
- **Attack knowledge lives in `diff_sentry/skills/`, not the prompt.** The skill catalog is injected (`load_skills_as_tools(discovery="inject")`) and `read_skill(name)` pulls a body on demand. Fix a wrong convention by editing or adding a skill; `detect.INSTRUCTIONS` holds only identity, the MISSION frame, the tool cost model, the triage loop, and output-gating rules. Skills ship via `packages = ["diff_sentry"]`; a force-include duplicates them and breaks the build.
|
|
37
|
-
- **Keep the dspy-free modules dspy-free.** `config`, `schema`, `normalize`, `indicators`, `shelllex`, `linewide`, `assemble`, `response`, `emit`, `ingest`, `hosts`, `dbutil`, `actors`, `snapshot`, `watch`, `rl_export`, and `rubric` must not import dspy at module top, and `import diff_sentry` must not import dspy (`ClassifyChange` / `setup` / `run` / `screen` / `detect_from_event` are lazy PEP 562 re-exports).
|
|
37
|
+
- **Keep the dspy-free modules dspy-free.** `config`, `schema`, `normalize`, `indicators`, `shelllex`, `linewide`, `assemble`, `response`, `emit`, `ingest`, `hosts`, `dbutil`, `actors`, `snapshot`, `watch`, `releases`, `rl_export`, and `rubric` must not import dspy at module top, and `import diff_sentry` must not import dspy (`ClassifyChange` / `setup` / `run` / `screen` / `detect_from_event` are lazy PEP 562 re-exports).
|
|
38
38
|
- **The trace self-describes the run.** `run_start` meta carries the normalized event, instructions, source echo, `baseline_indicators`, `emit_on`, role→model names, budgets, and the rubric skeleton, so an offline re-render/export re-derives the same `signal` (reading `emit_on` from meta, never current config). Any new per-run config that affects read-time derivation must ride in meta.
|
|
39
39
|
|
|
40
40
|
## Topic guides
|
|
@@ -54,5 +54,5 @@ Claude Code loads each rule automatically when you touch a matching file; other
|
|
|
54
54
|
|
|
55
55
|
- rlm-harness owns the generic cores: `make_model_tool` (chat → retry → validate → circuit-break) under `deep_classify`, and the SSRF primitives (`is_safe_url`, `resolved_host_is_safe`, `parse_cidrs`) under `fetch_tool`, which adds the GitHub allowlist, the httpx provider, and tracing.
|
|
56
56
|
- When this consumer needs a workaround, fix the reusable gap in rlm-harness generically; never special-case diff-sentry there. Consumer values (`DS_*` roles, the verdict schema, indicator rules, the SIEM payload shape) stay here.
|
|
57
|
-
- The deterministic tier ships as `cli.screen` and `watch` (host-side, in front of `cli.run`). Next increments: an optional small single-shot model call inside that tier; a GH Archive backfill for account history older than the Events API's 30 days; per-repository workflow awareness (which repos run `issue_comment` or `pull_request_target` workflows) to weight the slash-command rule.
|
|
57
|
+
- The deterministic tier ships as `cli.screen`, `watch` and `release-watch` (host-side, in front of `cli.run`). Next increments: comparing a release's sdist with its git tag, and npm releases beside PyPI's; an optional small single-shot model call inside that tier; a GH Archive backfill for account history older than the Events API's 30 days; per-repository workflow awareness (which repos run `issue_comment` or `pull_request_target` workflows) to weight the slash-command rule.
|
|
58
58
|
- Two workspace members, never in the `diff_sentry` wheel: `studio/` (`diff-sentry-studio`, the detection console, behind its `live` extra) and `eval/` (`diff-sentry-eval`, the reward-free scorecard). Both read the trace / `DetectionResponse` contract through the public surface only. Keep the root wheel at `packages = ["diff_sentry"]` so `uv build` never sweeps them in.
|
|
@@ -2,6 +2,33 @@
|
|
|
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.9.0
|
|
6
|
+
|
|
7
|
+
Upgrade from 0.8.0: a new `release-watch` command, and one rule that fires less on Rust code. The GitHub Action's scan is otherwise unchanged, nothing needs configuring, and `rlm-harness==1.22.0` is unchanged. `packaging` is a new dependency.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **`diff-sentry release-watch` watches PyPI releases.** A compromised release need not pass through a pull request: litellm 1.107.0 was built from a lookalike fork, and its wheels held a `.pth` startup hook and a packed stealer the previous release did not. `release-watch` polls the packages you name, compares every new file with the release before it, and screens what changed with the same rules as a pull request, alerting the SIEM and archiving the comparison. Artifacts are read as data and never installed. See [Watching releases](docs/release-watch.md).
|
|
11
|
+
- **What a release does not let it read alerts on its own**: a changed file that runs but could not be read as text (compiled bytecode, a new native module, a file too large to read), two files that install to the same path, a download that fails or does not match its checksum, and attestations the previous release had and this one lacks.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
- **`data-exfiltration` no longer reads a Rust closure named `env`** (`|env| self.getenv(env)`) as a shell dumping its environment into a pipe.
|
|
15
|
+
|
|
16
|
+
## 0.8.0
|
|
17
|
+
|
|
18
|
+
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.
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
- **Rules for the litellm 1.107.0 loader shape** ([BerriAI/litellm#47826](https://github.com/BerriAI/litellm/issues/47826)), all `high`:
|
|
22
|
+
- `python-startup-hook`: a `.pth` line that Python runs at every start, and a `sitecustomize` or `usercustomize` module or package;
|
|
23
|
+
- `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`;
|
|
24
|
+
- `remote-code-exec`: a Python HTTP client and `exec` or `eval` in the same file;
|
|
25
|
+
- `detached-process-spawn` also covers Python's `os.fork` with `os.setsid`.
|
|
26
|
+
- **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.
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
- **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.
|
|
30
|
+
- **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.
|
|
31
|
+
|
|
5
32
|
## 0.7.1
|
|
6
33
|
|
|
7
34
|
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.9.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
|
|
@@ -8,6 +8,7 @@ License-File: LICENSE
|
|
|
8
8
|
Keywords: dspy,malicious-code-detection,prompt-injection,rlm,security,siem,supply-chain-security
|
|
9
9
|
Requires-Python: >=3.11
|
|
10
10
|
Requires-Dist: httpx>=0.27
|
|
11
|
+
Requires-Dist: packaging>=23
|
|
11
12
|
Requires-Dist: pydantic>=2.11.0
|
|
12
13
|
Requires-Dist: rlm-harness==1.22.0
|
|
13
14
|
Provides-Extra: classify
|
|
@@ -42,7 +43,7 @@ jobs:
|
|
|
42
43
|
- uses: actions/checkout@v7
|
|
43
44
|
with:
|
|
44
45
|
fetch-depth: 0 # the scan diffs against the PR base
|
|
45
|
-
- uses: qazbnm456/diff-sentry@v0.
|
|
46
|
+
- uses: qazbnm456/diff-sentry@v0.9.0
|
|
46
47
|
```
|
|
47
48
|
|
|
48
49
|
That is the whole setup. The job fails when an indicator at or above `high` fires.
|
|
@@ -53,7 +54,7 @@ That is the whole setup. The job fails when an indicator at or above `high` fire
|
|
|
53
54
|
|---|---|
|
|
54
55
|
| 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
56
|
| 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 |
|
|
57
|
+
| 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
58
|
| Exfiltration | secrets reaching a network sink, cross-process `/proc/<pid>/mem` reads, OAST callback services, IPFS and permaweb gateways |
|
|
58
59
|
| Diff-presentation evasion | payloads shoved past the viewport by a long whitespace run, bidi-override and zero-width characters |
|
|
59
60
|
| 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 |
|
|
@@ -95,6 +96,7 @@ If you operate the infrastructure, or maintain a project where a wrong call is e
|
|
|
95
96
|
- **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
97
|
- **The studio**: a local console that streams a run as it happens. See [`studio/README.md`](studio/README.md).
|
|
97
98
|
- **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).
|
|
99
|
+
- **Release watch**: compares each new release of the PyPI packages you depend on with the release before it, and alerts when the new wheels carry a startup hook or a packed loader that the source never showed. See [Watching releases](docs/release-watch.md).
|
|
98
100
|
- **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
101
|
|
|
100
102
|
None of this is needed for the Action, and none of it sends your code anywhere you did not configure yourself.
|
|
@@ -107,6 +109,7 @@ A strong prompt injection can skew a model's verdict, though never the evidence.
|
|
|
107
109
|
|
|
108
110
|
- [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
111
|
- [Watching a watchlist](docs/watch.md): the monitor, its alerts, its archive, and what the account dossier holds.
|
|
112
|
+
- [Watching releases](docs/release-watch.md): comparing each new PyPI release with the one before it.
|
|
110
113
|
- [Honest limits](docs/limits.md): what the rules and the pipeline do not see.
|
|
111
114
|
- [Development](docs/development.md): the test suites and CI.
|
|
112
115
|
- [CHANGELOG](CHANGELOG.md)
|
|
@@ -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.9.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 |
|
|
@@ -74,6 +74,7 @@ If you operate the infrastructure, or maintain a project where a wrong call is e
|
|
|
74
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
75
|
- **The studio**: a local console that streams a run as it happens. See [`studio/README.md`](studio/README.md).
|
|
76
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
|
+
- **Release watch**: compares each new release of the PyPI packages you depend on with the release before it, and alerts when the new wheels carry a startup hook or a packed loader that the source never showed. See [Watching releases](docs/release-watch.md).
|
|
77
78
|
- **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
|
|
|
79
80
|
None of this is needed for the Action, and none of it sends your code anywhere you did not configure yourself.
|
|
@@ -86,6 +87,7 @@ A strong prompt injection can skew a model's verdict, though never the evidence.
|
|
|
86
87
|
|
|
87
88
|
- [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
89
|
- [Watching a watchlist](docs/watch.md): the monitor, its alerts, its archive, and what the account dossier holds.
|
|
90
|
+
- [Watching releases](docs/release-watch.md): comparing each new PyPI release with the one before it.
|
|
89
91
|
- [Honest limits](docs/limits.md): what the rules and the pipeline do not see.
|
|
90
92
|
- [Development](docs/development.md): the test suites and CI.
|
|
91
93
|
- [CHANGELOG](CHANGELOG.md)
|
|
@@ -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.9.0'
|
|
48
48
|
|
|
49
49
|
outputs:
|
|
50
50
|
failed:
|
|
@@ -97,11 +97,14 @@ def _baseline_indicators(event: dict, *, content_hits: list[IndicatorHit] | None
|
|
|
97
97
|
detectors over the raw content ∪ the provenance detectors over the ingest facts. Both re-source into the
|
|
98
98
|
evidence union on read (MF3), so a false-benign SUBMIT can skew the verdict but never suppress these."""
|
|
99
99
|
from .indicators import keep_worse, scan_change, scan_provenance
|
|
100
|
+
from .releases import release_scope, scan_release
|
|
100
101
|
|
|
101
102
|
# `content_hits` lets a caller that already scanned the change (the watcher, once per change) avoid scanning
|
|
102
103
|
# the same text again: the scan is the expensive part.
|
|
103
104
|
hits = (scan_change(event) if content_hits is None else list(content_hits)) \
|
|
104
|
-
+ scan_provenance(event.get("provenance") or {})
|
|
105
|
+
+ scan_provenance(event.get("provenance") or {}) + scan_release(event.get("release") or {})
|
|
106
|
+
if event.get("kind") == "release":
|
|
107
|
+
hits = release_scope(hits)
|
|
105
108
|
# Hits an EARLIER version of this same change raised ride along, so an edit that removes the payload
|
|
106
109
|
# cannot also remove the evidence (the watcher supplies them; a change scanned on its own has none).
|
|
107
110
|
for raw in event.get("prior_indicators") or []:
|
|
@@ -336,7 +339,7 @@ def _cmd_scan(args) -> int:
|
|
|
336
339
|
label = path
|
|
337
340
|
if not args.include_deletions:
|
|
338
341
|
text = drop_deleted_lines(text)
|
|
339
|
-
hits.extend(scan_diff(text, location=label))
|
|
342
|
+
hits.extend(scan_diff(text, location=label, files=True))
|
|
340
343
|
|
|
341
344
|
worst = max((severity_rank(h.severity) for h in hits), default=-1)
|
|
342
345
|
failed = worst >= threshold
|
|
@@ -360,6 +363,50 @@ def _cmd_scan(args) -> int:
|
|
|
360
363
|
return 1 if failed else 0
|
|
361
364
|
|
|
362
365
|
|
|
366
|
+
def _cmd_release_watch(args) -> int:
|
|
367
|
+
import logging
|
|
368
|
+
import signal
|
|
369
|
+
|
|
370
|
+
from .releases import ReleaseWatcher, parse_packages
|
|
371
|
+
|
|
372
|
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
|
373
|
+
if not math.isfinite(args.interval) or args.interval <= 0:
|
|
374
|
+
print("release-watch: --interval must be a positive number of seconds", file=sys.stderr)
|
|
375
|
+
return 2
|
|
376
|
+
texts = list(args.packages) + [os.getenv("DS_RELEASE_PACKAGES", "")]
|
|
377
|
+
try:
|
|
378
|
+
if args.packages_file:
|
|
379
|
+
with open(args.packages_file, encoding="utf-8") as fh:
|
|
380
|
+
texts.append(fh.read())
|
|
381
|
+
packages = parse_packages(texts)
|
|
382
|
+
if args.check:
|
|
383
|
+
packages = parse_packages([args.check[0]])
|
|
384
|
+
watcher = ReleaseWatcher(packages, state_dir=args.state, out_dir=args.out, emit=not args.no_emit)
|
|
385
|
+
except (OSError, ValueError, RuntimeError) as exc:
|
|
386
|
+
print(f"release-watch: {exc}", file=sys.stderr)
|
|
387
|
+
return 2
|
|
388
|
+
try:
|
|
389
|
+
if args.check:
|
|
390
|
+
print(json.dumps(watcher.check(packages[0], args.check[1]), indent=2, default=str))
|
|
391
|
+
return 0
|
|
392
|
+
if not packages:
|
|
393
|
+
print("release-watch: no packages (--packages, --packages-file or DS_RELEASE_PACKAGES)", file=sys.stderr)
|
|
394
|
+
return 2
|
|
395
|
+
if args.once:
|
|
396
|
+
for result in watcher.poll_once():
|
|
397
|
+
print(json.dumps(result, default=str))
|
|
398
|
+
return 0
|
|
399
|
+
stopping = []
|
|
400
|
+
signal.signal(signal.SIGTERM, lambda *_: stopping.append(True))
|
|
401
|
+
try:
|
|
402
|
+
watcher.run(args.interval, stop=lambda: bool(stopping))
|
|
403
|
+
except KeyboardInterrupt:
|
|
404
|
+
pass
|
|
405
|
+
return 0
|
|
406
|
+
finally:
|
|
407
|
+
watcher.close()
|
|
408
|
+
|
|
409
|
+
|
|
363
410
|
def _cmd_render(args) -> int:
|
|
364
411
|
from rlm_harness.trace import load_events
|
|
365
412
|
|
|
@@ -616,6 +663,21 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
616
663
|
help="bring back runs that exhausted their attempts (e.g. a model that was down for hours)")
|
|
617
664
|
w.set_defaults(func=_cmd_watch)
|
|
618
665
|
|
|
666
|
+
rw = sub.add_parser("release-watch",
|
|
667
|
+
help="compare each new release of watched PyPI packages with the one before it (no model)")
|
|
668
|
+
rw.add_argument("--packages", nargs="+", default=[], help="PyPI project names (or set DS_RELEASE_PACKAGES)")
|
|
669
|
+
rw.add_argument("--packages-file", help="a file of project names, one per line (# comments allowed)")
|
|
670
|
+
rw.add_argument("--out", default="./output")
|
|
671
|
+
rw.add_argument("--state", default=os.getenv("DS_WATCH_STATE_DIR", "./output/state"),
|
|
672
|
+
help="state directory: releases.db, snapshots/")
|
|
673
|
+
rw.add_argument("--interval", type=float, default=float(os.getenv("DS_RELEASE_INTERVAL", "300")),
|
|
674
|
+
help="seconds between polls (DS_RELEASE_INTERVAL, default 300)")
|
|
675
|
+
rw.add_argument("--no-emit", action="store_true", help="do not emit SIEM signals")
|
|
676
|
+
rw.add_argument("--once", action="store_true", help="one poll, then exit")
|
|
677
|
+
rw.add_argument("--check", nargs=2, metavar=("PACKAGE", "VERSION"),
|
|
678
|
+
help="judge one release against the one before it now, print the result, and exit")
|
|
679
|
+
rw.set_defaults(func=_cmd_release_watch)
|
|
680
|
+
|
|
619
681
|
ac = sub.add_parser("actor", help="the dossier of an account the watcher has seen (offline)")
|
|
620
682
|
ac.add_argument("login", help="a login, or `id:<github id>`")
|
|
621
683
|
ac.add_argument("--state", default=os.getenv("DS_WATCH_STATE_DIR", "./output/state"))
|