diff-sentry 0.11.0__tar.gz → 0.13.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.11.0 → diff_sentry-0.13.0}/.claude/rules/monitoring.md +9 -1
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/.claude/rules/rubric-and-eval.md +1 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/.env.example +2 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/AGENTS.md +3 -3
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/CHANGELOG.md +23 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/PKG-INFO +5 -5
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/README.md +3 -3
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/action.yml +1 -1
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/diff_sentry/__init__.py +1 -1
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/diff_sentry/cli.py +28 -9
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/diff_sentry/deep_classify.py +1 -1
- diff_sentry-0.13.0/diff_sentry/npm.py +337 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/diff_sentry/releases.py +391 -40
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/docs/release-watch.md +39 -5
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/eval/tests/conftest.py +1 -1
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/pyproject.toml +2 -2
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/studio/diff_sentry_studio/mapper.py +2 -2
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/studio/tests/test_mapper.py +1 -1
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/conftest.py +1 -1
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/test_adversarial_input.py +1 -1
- diff_sentry-0.13.0/tests/test_npm.py +413 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/test_releases.py +441 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/uv.lock +5 -5
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/.claude/rules/detect-setup.md +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/.claude/rules/handoff.md +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/.claude/rules/indicators.md +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/.claude/skills/release/SKILL.md +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/.github/workflows/ci.yml +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/.github/workflows/release.yml +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/.gitignore +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/.python-version +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/LICENSE +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/VENDOR.md +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/diff_sentry/__main__.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/diff_sentry/actors.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/diff_sentry/assemble.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/diff_sentry/config.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/diff_sentry/dbutil.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/diff_sentry/detect.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/diff_sentry/emit.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/diff_sentry/fetch_tool.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/diff_sentry/hosts.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/diff_sentry/images.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/diff_sentry/indicators.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/diff_sentry/ingest.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/diff_sentry/linewide.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/diff_sentry/normalize.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/diff_sentry/response.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/diff_sentry/rl_export.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/diff_sentry/rubric.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/diff_sentry/schema.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/diff_sentry/shelllex.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/diff_sentry/skills/ci-and-workflows.md +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/diff_sentry/skills/concealment-and-provenance.md +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/diff_sentry/skills/developer-autorun.md +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/diff_sentry/skills/llm-directed-text.md +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/diff_sentry/skills/package-and-build-hooks.md +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/diff_sentry/skills/runtime-droppers-and-exfil.md +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/diff_sentry/skills/triage-a-change.md +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/diff_sentry/snapshot.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/diff_sentry/watch.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/docs/development.md +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/docs/how-it-works.md +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/docs/limits.md +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/docs/watch.md +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/eval/README.md +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/eval/diff_sentry_eval/__init__.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/eval/diff_sentry_eval/__main__.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/eval/diff_sentry_eval/cli.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/eval/diff_sentry_eval/judge.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/eval/diff_sentry_eval/schema.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/eval/diff_sentry_eval/score.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/eval/diff_sentry_eval/taskset.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/eval/pyproject.toml +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/eval/taskset.example.json +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/eval/tests/test_boundary.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/eval/tests/test_cli.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/eval/tests/test_judge.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/eval/tests/test_schema.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/eval/tests/test_score.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/eval/tests/test_taskset.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/studio/DESIGN.md +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/studio/README.md +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/studio/diff_sentry_studio/__init__.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/studio/diff_sentry_studio/app.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/studio/diff_sentry_studio/iterations.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/studio/diff_sentry_studio/live.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/studio/pyproject.toml +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/studio/static/app.js +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/studio/static/index.html +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/studio/static/replay-core.js +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/studio/static/run-core.js +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/studio/static/style.css +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/studio/static/trajectory.js +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/studio/static/vendor/fonts/jetbrains-mono-400.woff2 +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/studio/static/vendor/fonts/jetbrains-mono-700.woff2 +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/studio/tests/replay-core.test.js +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/studio/tests/run-core.test.js +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/studio/tests/static-contract.test.js +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/studio/tests/test_app.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/studio/tests/test_iterations.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/studio/tests/test_live.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/studio/tests/trajectory.test.js +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/__init__.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/corpus/change_corpus.json +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/corpus/hackerbot_claw_campaign.json +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/corpus/hackerbot_claw_incident.json +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/fixtures/known-malicious.diff +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/oracle/__init__.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/oracle/grammar.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/oracle/harness.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/oracle/lexrunner.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/oracle/programs.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/oracle/runner.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/test_actors.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/test_assemble.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/test_campaign_replay.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/test_cli.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/test_cli_watch.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/test_config.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/test_deep_classify.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/test_detect.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/test_detection_quality.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/test_dogfood_gate.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/test_emit.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/test_enclosure.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/test_export.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/test_fetch_tool.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/test_file_scope.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/test_file_structure.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/test_forward_offline.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/test_gate_edges.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/test_grounding.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/test_hosts.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/test_images.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/test_incident_hackerbot_claw.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/test_indicators.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/test_ingest.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/test_ingest_metadata.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/test_injection_offline.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/test_inline_exec_and_bidi.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/test_linewide.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/test_lookalike_owner.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/test_metadata_rules.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/test_normalize.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/test_oracle.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/test_oracle_harness.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/test_provenance.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/test_python_loader.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/test_response.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/test_rubric.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/test_schema.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/test_shelllex.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/test_snapshot.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/test_subscription.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/test_watch.py +0 -0
- {diff_sentry-0.11.0 → diff_sentry-0.13.0}/tests/test_watch_pool.py +0 -0
|
@@ -47,12 +47,20 @@ paths:
|
|
|
47
47
|
|
|
48
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
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.
|
|
50
|
+
- **Artifacts are data.** Download only from `pypi.org` and `files.pythonhosted.org` (and the tag comparison's refs and trees from `github.com` and `api.github.com`) 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
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
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 source tag** (`tag_facts`): with a source repository (`--sources`, else the one GitHub repository the previous release's Trusted Publishers name), the members Python runs (`_compared`: `.py`, `.pyw`, `.pth` and build files; not a wheel's `{dist-info}/` or `{scripts}/`, not an sdist's derived files) are compared by git blob sha1 (`Member.blobs`, also of the bytes with CRLF as LF) with the tree of the tag for its version. Tags come from GitHub's `git fetch` ref advertisement (`tag_commits`, no API quota; an annotated tag's peeled commit), a tree from the API (`GITHUB_TOKEN` when set). A tag matches by `packaging` version after a non-digit prefix (`_tags_of`); a prefix that tags both releases is preferred, then one naming the package, then the shortest (`_pick`). Where an artifact sits in the repository (`src/`, a monorepo directory) is voted by its members' blobs (`_prefix`). The previous release against its own tag gives the paths a build writes (`_differences`), and only a difference at another path is `release-differs-from-tag` (high). Expectations are by exact path, never by directory: a directory key would let a generated `_version.py` vouch for every file beside it, and a generated root module for a root `.pth`. A comparison a judgment could not make leaves a `tag_checks` row: a release without a tag for its version (`release-tag-missing` at `medium`), or one judged while the API refused for its rate limit (`tag_limited`, a fact). One row per version (`<run id without revision>-tag`), since the comparison covers the whole release; a judgment that owes a comparison opens a closed row again with fresh clocks, so a wheel added to the release after its comparison is compared too, and each opening screens under its own id (`-tag2`, …) so a refused alert of an earlier one is still re-sent. `_recheck_tags` looks at the refs every poll: once a tag for the version appears and `_api_room` says the API has two calls left, `tag_event` reads the tag and both trees first (`_resolve_tag`) and only then downloads the release again for its blobs, and the tag facts alone are screened (an event with no files, so the content findings are not sent twice) under that run id. A release still untagged `_TAG_GRACE` (an hour) after it was judged is screened as `release-tag-missing` (high) without a download, and a comparison still owed `_TAG_DEADLINE` (six hours) after the judgment, whatever the reason (a tree the API keeps refusing, an API with no room), is `release-not-fully-read`: nothing owed is dropped without an alert. A Trusted Publisher's repository GitHub stops serving ends the check quietly (`unserved`), as at judgment. A release PyPI no longer lists when its tag appears is a note, not a clean comparison. No tag for the previous release compares nothing, a fact (`tag_compared`). A configured repository GitHub does not serve (401, 404, a redirect) is a note (`release-not-fully-read`); the Trusted Publisher's is a fact. The API's rate limit (403, 429) defers the comparison to `_recheck_tags`; any other GitHub failure is retried like a download; a truncated tree is a note.
|
|
53
54
|
- **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
55
|
- **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.
|
|
55
56
|
|
|
57
|
+
## npm watch (`npm.py`, `release-watch --npm`)
|
|
58
|
+
|
|
59
|
+
- **A version is a release.** `NpmWatcher` is `ReleaseWatcher` with `registry = "npm"` and its own `npm.db`: the same per-file state, retries, give-up and alert re-sending, with a version's tarball as its one file (kind `npm`, labelled `npm/<path>`, the `package/` top directory dropped). `release_event(registry="npm")` orders versions by semver (`semver_key`: a release after its pre-releases, numeric identifiers before others), keeps the name as written (`@scope/name`; run id `npm___scope_name-<version>`), skips the tag comparison and the wheel facts, and adds `npm_facts`.
|
|
60
|
+
- **Read from the registry only.** The abbreviated packument (`application/vnd.npm.install-v1+json`, ETag-polled) from `registry.npmjs.org`; a version whose tarball URL is on another host is left out (`parse_packument`). Each tarball is checked against the registry's sha512 `integrity`, a mismatch a note.
|
|
61
|
+
- **Publishers come from the registry's SLSA provenance** (`npm_publishers`): the `https://slsa.dev/provenance/v1` statement's `externalParameters.workflow` repository and path, written as `releases.publisher_identity` would (`github:owner/name:path::`), so `scan_release`'s publisher rules apply unchanged. The statement's subject must name the tarball's own sha512 (and a version the registry gives no sha512 for cannot be bound), or it is a note and no publisher. A 404 is no provenance; another status is retried like a download.
|
|
62
|
+
- **Install scripts** (`npm_facts`): never the packument's `hasInstallScript`, and never through the screen's text budget, whose bounds and decoding fail open for a manifest. `tarball_manifests` reads the tarball itself as npm extracts it: the first path component dropped (a member at the root is not installed), the last entry of a path standing, the bytes decoded as UTF-8 with a BOM dropped and invalid bytes replaced, every `package.json` of the root and of a dependency under `node_modules` at any depth (npm runs a bundled dependency's install scripts). A manifest that is missing, not a regular file, past 16 MiB, not JSON (a `RecursionError` included) or not an object is a note (`release-not-fully-read`), never a package without scripts; the previous version's unread manifests count as none. The scripts are those an install from the registry runs (`preinstall`, `install`, `postinstall`; `prepare` runs only for a git or local install) and the implied `node-gyp rebuild` of a `binding.gyp` without either install script, keyed by package directory. Added is `npm-install-script-added` (high), a changed command `npm-install-script-changed` (medium). A dependency of any manifest (`dependencies`, `optionalDependencies`, `peerDependencies`) whose spec holds `:`, `/` or `\\`, or starts with `.` or `~/` (a git repository, a URL, a path, an `npm:` alias; a semver range or a dist-tag never does), new or with another spec than before, is `npm-dependency-source` (high). Manifest and `binding.gyp` names match in any case, as a case-insensitive file system finds them; two manifests whose paths differ only in case are each read and are a note, since which one npm reads depends on the file system. A path with `..` is not read, as npm refuses it. A new name in the root's `dependencies`, `optionalDependencies` or `peerDependencies` is `release-new-dependency` (low). A dependency newly taken from the registry in any manifest, keyed `<dir> <name>` (`_registry_dependencies`; a name whose spec moves onto the registry counts), is looked up (`dependency_facts`, at most 50): any version with `hasInstallScript` in its abbreviated document is `npm-dependency-runs-at-install` (high), and a package whose full document's `time.created` is under 30 days before the version's publish time (`published_at`, the watched package's `time[version]`; the clock only when that cannot be read), or a 404, is `npm-dependency-young` (high). A full document past 64 MiB is a note, never an established package, since an author pads versions as easily as they publish them; another failure is retried like a download.
|
|
63
|
+
|
|
56
64
|
## Image watch (`images.py`, `release-watch --images`)
|
|
57
65
|
|
|
58
66
|
- **Provenance only, never a layer.** `image_provenance` reads an image's index, each BuildKit attestation manifest and its SLSA provenance layer; `provenance_facts` keeps the source repository (`vcs:source`; `owner/name` only for a URL whose host is exactly github.com, any other source as written, so a lookalike is only judged between GitHub repositories), revision, building repository, workflow without its ref, ref and ref protection. An attestation counts only when its `vnd.docker.reference.digest` is a platform image of the same index and the provenance's `subject` names that image (BuildKit always writes one); a platform image no attestation names is a note. A layer's content is a known gap.
|
|
@@ -15,6 +15,7 @@ paths:
|
|
|
15
15
|
- `rl_export.rubric_signal` attaches the ATLAS 4-category (TF/TA/TG/PA) decomposition as LABELS: a fixed, no-LLM skeleton in `run_start` meta (`rubric_to_meta(default_rubric())`, one criterion per category, since the task is constant; there is no `generate_rubric` path) plus deterministic per-criterion `criteria_facts`.
|
|
16
16
|
- The facts are a re-lens over `run_labels` / `run_metrics`, not a second derivation: `rubric.trace_facts` reuses them, so a criterion's `observed` cannot drift from what a trainer reads. `_CATEGORY_LENS` maps TF↔(verdict / signal / hit_iteration_cap), TA↔(scan / deep_classify / analyst / fetch / skill counts + circuit-breaks + analyst failures), TG↔(indicator_count / max_indicator_severity / signal / cited_unknown), PA↔(verdict / cited_unknown).
|
|
17
17
|
- `deep_classify_circuit_breaks` counts the tool_calls the breaker short-circuited. From rlm-harness 1.22.0 a repeating error status (`is_fast_fail_http`) counts toward the breaker as an unusable reply does, so a break in a newer trace can mean a misconfigured classifier endpoint as well as a thrashing planner; the status in the run's endpoint-error tool_calls (`Error code: 401`) tells them apart, since a 429, a 5xx or a timeout records one too and does not count.
|
|
18
|
+
- The per-action `cause` that `rl_export` re-exports from the kit (`export_actions`, read by `payload_cause`) is the kit's field: from rlm-harness 1.24.0 a tool_call without `ok` reads as `unknown`, where it read as `invalid` before, so an older trace's `read_skill` rows move. Split a corpus by `run_start.payload.rlm_harness` before comparing `cause` rates across it.
|
|
18
19
|
- `CriterionFact` has no score/met field; the trainer scores. Never add a reward/score/met field to any rubric type.
|
|
19
20
|
- `rubric.py` is a dspy-free leaf: it imports only `.schema` at top, and its `rl_export` reuse is a function-level import (that path is dspy-free too), so `response.py` can import it at top.
|
|
20
21
|
- `response._rubric` surfaces it as `DetectionResponse.rubric` (`RubricReport`); the studio shows it as a card tagged `labels — not a score` that adds no judgement. Keep the card legacy-guarded: a response without a `rubric` renders nothing.
|
|
@@ -79,5 +79,7 @@ DS_API_KEY=sk-...
|
|
|
79
79
|
# DS_RELEASE_PACKAGES=litellm,requests # release-watch: PyPI projects (or --packages / --packages-file)
|
|
80
80
|
# DS_RELEASE_INTERVAL=300 # release-watch: seconds between polls of each project's index
|
|
81
81
|
# DS_RELEASE_MAX_ARTIFACT_MB=256 # release-watch: a larger wheel or sdist is not compared, which alerts
|
|
82
|
+
# DS_RELEASE_NPM=@scope/name,left-pad # release-watch: npm packages (or --npm)
|
|
83
|
+
# DS_RELEASE_SOURCES=litellm=BerriAI/litellm # release-watch: the GitHub repository each package is built from, compared by tag (or --sources)
|
|
82
84
|
# DS_RELEASE_IMAGES=ghcr.io/owner/name # release-watch: container images compared by build provenance (or --images)
|
|
83
85
|
# DS_RELEASE_IMAGE_TAGS=latest # release-watch: floating tags whose moves are judged (or --image-tags)
|
|
@@ -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, `releases`
|
|
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, `releases` and `npm` read PyPI's and npm's own hosts, and the refs and trees of a package's GitHub repository, as data that is never installed, and `images` reads the GHCR and Docker Hub APIs only (manifests and provenance, never a layer). 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`, `releases`, `images`, `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`, `npm`, `images`, `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`, `watch` and `release-watch` (host-side, in front of `cli.run`). Next increments:
|
|
57
|
+
- The deterministic tier ships as `cli.screen`, `watch` and `release-watch` (host-side, in front of `cli.run`). Next increments: an image's new layers by content; 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,29 @@
|
|
|
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.13.0
|
|
6
|
+
|
|
7
|
+
Upgrade from 0.12.0: `release-watch` can watch npm packages. The GitHub Action's scan is unchanged, nothing needs configuring, and `rlm-harness==1.24.0` is unchanged.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **`release-watch --npm` watches npm packages** (`DS_RELEASE_NPM`, or `--check npm:NAME VERSION` for one version). Each new version is compared with the version before it in semver order, as a PyPI release is: the tarball is read from `registry.npmjs.org` as data, checked against the registry's sha512, and every new or changed file is screened by the same rules. The npm worms of 2025 ran their payload from an install script, and a compromised version can also depend on a package published hours before, so it adds what only npm shows:
|
|
11
|
+
- `npm-install-script-added` (`high`): a `preinstall`, `install` or `postinstall` script, or the `node-gyp rebuild` a `binding.gyp` implies, that the previous version did not run, in the package or in a dependency it bundles. A script that changes command is `medium`.
|
|
12
|
+
- `npm-dependency-source` (`high`): a dependency newly installed from a git repository, a URL, a path or an `npm:` alias.
|
|
13
|
+
- `npm-dependency-runs-at-install` and `npm-dependency-young` (`high`): a dependency newly taken from the registry whose versions run a script at install, or that was created within 30 days of the version or is not on the registry.
|
|
14
|
+
- The publisher checks PyPI releases have, from the SLSA provenance the registry holds, which must name the tarball's own digest.
|
|
15
|
+
|
|
16
|
+
The manifests are read as npm extracts them, and one that cannot be read alerts. See [npm packages](docs/release-watch.md#npm-packages).
|
|
17
|
+
|
|
18
|
+
## 0.12.0
|
|
19
|
+
|
|
20
|
+
Upgrade from 0.11.0: `release-watch` can compare each release with its git tag, and `rlm-harness` moves to 1.24.0. The GitHub Action's scan is unchanged, and nothing needs configuring.
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
- **`release-watch` compares each release with its git tag**, when it knows the GitHub repository a package is built from (`--sources litellm=BerriAI/litellm`, `DS_RELEASE_SOURCES`, or else the repository of the previous release's Trusted Publisher). The files Python runs are compared with the tag for the version by git blob hash, so nothing is downloaded from GitHub but its tag list and two file trees. A file the tag does not hold, or holds with other content, is `release-differs-from-tag` (`high`); what the previous release also differed by at the same path, such as a version file the build writes, is expected. A release whose repository tagged the previous one and has no tag for it (`BerriAI/litellm` has no `v1.107.0`) is `release-tag-missing`: `medium` at first, compared once the tag appears, and `high` after an hour. A comparison that still cannot be made six hours after the release was judged alerts that it was not made. See [Comparing a release with its git tag](docs/release-watch.md#comparing-a-release-with-its-git-tag).
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
- **`rlm-harness` 1.24.0.** A tool call recorded without an outcome now reads as unknown rather than as a decline; every call diff-sentry records declares its outcome.
|
|
27
|
+
|
|
5
28
|
## 0.11.0
|
|
6
29
|
|
|
7
30
|
Upgrade from 0.10.0: `release-watch` can now watch container images. The GitHub Action's scan is unchanged, nothing needs configuring, and `rlm-harness==1.22.0` is unchanged.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: diff-sentry
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.13.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
|
|
@@ -10,7 +10,7 @@ Requires-Python: >=3.11
|
|
|
10
10
|
Requires-Dist: httpx>=0.27
|
|
11
11
|
Requires-Dist: packaging>=23
|
|
12
12
|
Requires-Dist: pydantic>=2.11.0
|
|
13
|
-
Requires-Dist: rlm-harness==1.
|
|
13
|
+
Requires-Dist: rlm-harness==1.24.0
|
|
14
14
|
Provides-Extra: classify
|
|
15
15
|
Requires-Dist: openai>=1.0; extra == 'classify'
|
|
16
16
|
Provides-Extra: observe
|
|
@@ -43,7 +43,7 @@ jobs:
|
|
|
43
43
|
- uses: actions/checkout@v7
|
|
44
44
|
with:
|
|
45
45
|
fetch-depth: 0 # the scan diffs against the PR base
|
|
46
|
-
- uses: qazbnm456/diff-sentry@v0.
|
|
46
|
+
- uses: qazbnm456/diff-sentry@v0.13.0
|
|
47
47
|
```
|
|
48
48
|
|
|
49
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.
|
|
@@ -96,7 +96,7 @@ If you operate the infrastructure, or maintain a project where a wrong call is e
|
|
|
96
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).
|
|
97
97
|
- **The studio**: a local console that streams a run as it happens. See [`studio/README.md`](studio/README.md).
|
|
98
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
|
|
99
|
+
- **Release watch**: compares each new release of the PyPI and npm packages you depend on with the release before it, and alerts when it carries a startup hook, a packed loader or a new install script, or was published without its usual provenance. See [Watching releases](docs/release-watch.md).
|
|
100
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).
|
|
101
101
|
|
|
102
102
|
None of this is needed for the Action, and none of it sends your code anywhere you did not configure yourself.
|
|
@@ -109,7 +109,7 @@ A strong prompt injection can skew a model's verdict, though never the evidence.
|
|
|
109
109
|
|
|
110
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.
|
|
111
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
|
|
112
|
+
- [Watching releases](docs/release-watch.md): comparing each new PyPI or npm release, and each container image, with what came before.
|
|
113
113
|
- [Honest limits](docs/limits.md): what the rules and the pipeline do not see.
|
|
114
114
|
- [Development](docs/development.md): the test suites and CI.
|
|
115
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.13.0
|
|
25
25
|
```
|
|
26
26
|
|
|
27
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.
|
|
@@ -74,7 +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
|
|
77
|
+
- **Release watch**: compares each new release of the PyPI and npm packages you depend on with the release before it, and alerts when it carries a startup hook, a packed loader or a new install script, or was published without its usual provenance. See [Watching releases](docs/release-watch.md).
|
|
78
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).
|
|
79
79
|
|
|
80
80
|
None of this is needed for the Action, and none of it sends your code anywhere you did not configure yourself.
|
|
@@ -87,7 +87,7 @@ A strong prompt injection can skew a model's verdict, though never the evidence.
|
|
|
87
87
|
|
|
88
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.
|
|
89
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
|
|
90
|
+
- [Watching releases](docs/release-watch.md): comparing each new PyPI or npm release, and each container image, with what came before.
|
|
91
91
|
- [Honest limits](docs/limits.md): what the rules and the pipeline do not see.
|
|
92
92
|
- [Development](docs/development.md): the test suites and CI.
|
|
93
93
|
- [CHANGELOG](CHANGELOG.md)
|
|
@@ -40,7 +40,7 @@ inputs:
|
|
|
40
40
|
that PR rather than by the ones already on main.
|
|
41
41
|
required: false
|
|
42
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.
|
|
43
|
+
default: '0.13.0'
|
|
44
44
|
|
|
45
45
|
outputs:
|
|
46
46
|
failed:
|
|
@@ -337,7 +337,8 @@ def _cmd_release_watch(args) -> int:
|
|
|
337
337
|
import signal
|
|
338
338
|
|
|
339
339
|
from .images import ImageWatcher
|
|
340
|
-
from .
|
|
340
|
+
from .npm import NpmWatcher, parse_npm_packages
|
|
341
|
+
from .releases import ReleaseWatcher, parse_packages, parse_sources
|
|
341
342
|
|
|
342
343
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
|
343
344
|
if not math.isfinite(args.interval) or args.interval <= 0:
|
|
@@ -349,31 +350,42 @@ def _cmd_release_watch(args) -> int:
|
|
|
349
350
|
with open(args.packages_file, encoding="utf-8") as fh:
|
|
350
351
|
texts.append(fh.read())
|
|
351
352
|
packages = parse_packages(texts)
|
|
353
|
+
npm = parse_npm_packages([*args.npm, os.getenv("DS_RELEASE_NPM", "")])
|
|
354
|
+
check_npm = bool(args.check) and args.check[0].startswith("npm:")
|
|
352
355
|
if args.check:
|
|
353
|
-
packages = parse_packages([args.check[0]])
|
|
356
|
+
packages = [] if check_npm else parse_packages([args.check[0]])
|
|
357
|
+
npm = parse_npm_packages([args.check[0][4:]]) if check_npm else []
|
|
354
358
|
images = [i for text in [*args.images, os.getenv("DS_RELEASE_IMAGES", "")] for i in text.replace(",", " ").split()]
|
|
355
359
|
floating = tuple(args.image_tags or os.getenv("DS_RELEASE_IMAGE_TAGS", "latest").replace(",", " ").split())
|
|
356
360
|
image_watcher = ImageWatcher([] if args.check else images, state_dir=args.state, out_dir=args.out,
|
|
357
361
|
floating=floating, emit=not args.no_emit)
|
|
358
362
|
try:
|
|
359
|
-
|
|
363
|
+
sources = parse_sources([*args.sources, os.getenv("DS_RELEASE_SOURCES", "")])
|
|
364
|
+
watcher = ReleaseWatcher(packages, state_dir=args.state, out_dir=args.out, emit=not args.no_emit,
|
|
365
|
+
sources=sources)
|
|
360
366
|
except BaseException:
|
|
361
367
|
image_watcher.close()
|
|
362
368
|
raise
|
|
369
|
+
try:
|
|
370
|
+
npm_watcher = NpmWatcher(npm, state_dir=args.state, out_dir=args.out, emit=not args.no_emit)
|
|
371
|
+
except BaseException:
|
|
372
|
+
image_watcher.close()
|
|
373
|
+
watcher.close()
|
|
374
|
+
raise
|
|
363
375
|
except (OSError, ValueError, RuntimeError) as exc:
|
|
364
376
|
print(f"release-watch: {exc}", file=sys.stderr)
|
|
365
377
|
return 2
|
|
366
378
|
try:
|
|
367
379
|
if args.check:
|
|
368
|
-
result = watcher.check(packages[0], args.check[1])
|
|
380
|
+
result = npm_watcher.check(npm[0], args.check[1]) if check_npm else watcher.check(packages[0], args.check[1])
|
|
369
381
|
print(json.dumps(result, indent=2, default=str))
|
|
370
382
|
return 1 if "error" in result else 0
|
|
371
|
-
if not packages and not images:
|
|
372
|
-
print("release-watch: nothing to watch (--packages, --packages-file, --images, or DS_RELEASE_PACKAGES / "
|
|
373
|
-
"DS_RELEASE_IMAGES)", file=sys.stderr)
|
|
383
|
+
if not packages and not npm and not images:
|
|
384
|
+
print("release-watch: nothing to watch (--packages, --packages-file, --npm, --images, or DS_RELEASE_PACKAGES / "
|
|
385
|
+
"DS_RELEASE_NPM / DS_RELEASE_IMAGES)", file=sys.stderr)
|
|
374
386
|
return 2
|
|
375
387
|
if args.once:
|
|
376
|
-
for result in watcher.poll_once() + image_watcher.poll_once():
|
|
388
|
+
for result in watcher.poll_once() + npm_watcher.poll_once() + image_watcher.poll_once():
|
|
377
389
|
print(json.dumps(result, default=str))
|
|
378
390
|
return 0
|
|
379
391
|
stopping = []
|
|
@@ -382,6 +394,7 @@ def _cmd_release_watch(args) -> int:
|
|
|
382
394
|
while not stopping:
|
|
383
395
|
started = time.monotonic()
|
|
384
396
|
watcher.poll_once()
|
|
397
|
+
npm_watcher.poll_once()
|
|
385
398
|
image_watcher.poll_once()
|
|
386
399
|
while not stopping and time.monotonic() - started < args.interval:
|
|
387
400
|
time.sleep(min(1.0, args.interval))
|
|
@@ -390,6 +403,7 @@ def _cmd_release_watch(args) -> int:
|
|
|
390
403
|
return 0
|
|
391
404
|
finally:
|
|
392
405
|
watcher.close()
|
|
406
|
+
npm_watcher.close()
|
|
393
407
|
image_watcher.close()
|
|
394
408
|
|
|
395
409
|
|
|
@@ -655,6 +669,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
655
669
|
help="compare each new release of watched PyPI packages with the one before it (no model)")
|
|
656
670
|
rw.add_argument("--packages", nargs="+", default=[], help="PyPI project names (or set DS_RELEASE_PACKAGES)")
|
|
657
671
|
rw.add_argument("--packages-file", help="a file of project names, one per line (# comments allowed)")
|
|
672
|
+
rw.add_argument("--npm", nargs="+", default=[], help="npm package names, @scope/name included (or set DS_RELEASE_NPM)")
|
|
673
|
+
rw.add_argument("--sources", nargs="+", default=[], metavar="PACKAGE=OWNER/NAME",
|
|
674
|
+
help="the GitHub repository each package is built from, to compare each release with its tag "
|
|
675
|
+
"(or set DS_RELEASE_SOURCES; default: the repository of the previous release's Trusted Publisher)")
|
|
658
676
|
rw.add_argument("--images", nargs="+", default=[],
|
|
659
677
|
help="container images (ghcr.io/owner/name, docker.io/owner/name) whose tags are compared by provenance "
|
|
660
678
|
"(or set DS_RELEASE_IMAGES)")
|
|
@@ -668,7 +686,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
668
686
|
rw.add_argument("--no-emit", action="store_true", help="do not emit SIEM signals")
|
|
669
687
|
rw.add_argument("--once", action="store_true", help="one poll, then exit")
|
|
670
688
|
rw.add_argument("--check", nargs=2, metavar=("PACKAGE", "VERSION"),
|
|
671
|
-
help="judge one release against the one before it now, print the result, and exit"
|
|
689
|
+
help="judge one release against the one before it now, print the result, and exit "
|
|
690
|
+
"(npm:NAME for an npm package)")
|
|
672
691
|
rw.set_defaults(func=_cmd_release_watch)
|
|
673
692
|
|
|
674
693
|
ac = sub.add_parser("actor", help="the dossier of an account the watcher has seen (offline)")
|
|
@@ -143,7 +143,7 @@ def make_deep_classify_tool(config: DetectConfig, chat_fn: Callable[[str], str]
|
|
|
143
143
|
return ("DEEP_CLASSIFY CIRCUIT BREAKER — too many unusable replies or failing calls in a row. Decide from the "
|
|
144
144
|
"deterministic indicators and your own reading; do not keep re-asking.")
|
|
145
145
|
if r.endpoint_error is not None:
|
|
146
|
-
record_tool_call("deep_classify", args={"findings": findings[:400]}, error=r.endpoint_error)
|
|
146
|
+
record_tool_call("deep_classify", args={"findings": findings[:400]}, ok=False, error=r.endpoint_error)
|
|
147
147
|
return f"DEEP_CLASSIFY ENDPOINT ERROR: {r.endpoint_error}. Decide from the indicators yourself."
|
|
148
148
|
v: ClassifyValidation = r.validated
|
|
149
149
|
# SEAM: a delegated rlm-harness HARNESS backend (make_harness_tool) returns
|