diff-sentry 0.12.0__tar.gz → 0.14.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.12.0 → diff_sentry-0.14.0}/.claude/rules/monitoring.md +9 -1
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/.env.example +2 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/AGENTS.md +3 -3
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/CHANGELOG.md +25 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/PKG-INFO +4 -4
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/README.md +3 -3
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/action.yml +1 -1
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/diff_sentry/__init__.py +1 -1
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/diff_sentry/cli.py +22 -9
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/diff_sentry/images.py +217 -55
- diff_sentry-0.14.0/diff_sentry/npm.py +337 -0
- diff_sentry-0.14.0/diff_sentry/pycheck.py +88 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/diff_sentry/releases.py +149 -53
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/docs/release-watch.md +27 -4
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/pyproject.toml +1 -1
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/test_adversarial_input.py +1 -1
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/test_images.py +276 -10
- diff_sentry-0.14.0/tests/test_npm.py +413 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/test_releases.py +16 -1
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/uv.lock +1 -1
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/.claude/rules/detect-setup.md +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/.claude/rules/handoff.md +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/.claude/rules/indicators.md +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/.claude/rules/rubric-and-eval.md +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/.claude/skills/release/SKILL.md +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/.github/workflows/ci.yml +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/.github/workflows/release.yml +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/.gitignore +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/.python-version +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/LICENSE +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/VENDOR.md +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/diff_sentry/__main__.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/diff_sentry/actors.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/diff_sentry/assemble.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/diff_sentry/config.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/diff_sentry/dbutil.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/diff_sentry/deep_classify.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/diff_sentry/detect.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/diff_sentry/emit.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/diff_sentry/fetch_tool.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/diff_sentry/hosts.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/diff_sentry/indicators.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/diff_sentry/ingest.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/diff_sentry/linewide.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/diff_sentry/normalize.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/diff_sentry/response.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/diff_sentry/rl_export.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/diff_sentry/rubric.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/diff_sentry/schema.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/diff_sentry/shelllex.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/diff_sentry/skills/ci-and-workflows.md +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/diff_sentry/skills/concealment-and-provenance.md +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/diff_sentry/skills/developer-autorun.md +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/diff_sentry/skills/llm-directed-text.md +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/diff_sentry/skills/package-and-build-hooks.md +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/diff_sentry/skills/runtime-droppers-and-exfil.md +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/diff_sentry/skills/triage-a-change.md +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/diff_sentry/snapshot.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/diff_sentry/watch.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/docs/development.md +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/docs/how-it-works.md +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/docs/limits.md +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/docs/watch.md +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/eval/README.md +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/eval/diff_sentry_eval/__init__.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/eval/diff_sentry_eval/__main__.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/eval/diff_sentry_eval/cli.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/eval/diff_sentry_eval/judge.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/eval/diff_sentry_eval/schema.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/eval/diff_sentry_eval/score.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/eval/diff_sentry_eval/taskset.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/eval/pyproject.toml +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/eval/taskset.example.json +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/eval/tests/conftest.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/eval/tests/test_boundary.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/eval/tests/test_cli.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/eval/tests/test_judge.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/eval/tests/test_schema.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/eval/tests/test_score.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/eval/tests/test_taskset.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/studio/DESIGN.md +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/studio/README.md +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/studio/diff_sentry_studio/__init__.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/studio/diff_sentry_studio/app.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/studio/diff_sentry_studio/iterations.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/studio/diff_sentry_studio/live.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/studio/diff_sentry_studio/mapper.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/studio/pyproject.toml +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/studio/static/app.js +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/studio/static/index.html +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/studio/static/replay-core.js +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/studio/static/run-core.js +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/studio/static/style.css +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/studio/static/trajectory.js +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/studio/static/vendor/fonts/jetbrains-mono-400.woff2 +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/studio/static/vendor/fonts/jetbrains-mono-700.woff2 +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/studio/tests/replay-core.test.js +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/studio/tests/run-core.test.js +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/studio/tests/static-contract.test.js +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/studio/tests/test_app.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/studio/tests/test_iterations.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/studio/tests/test_live.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/studio/tests/test_mapper.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/studio/tests/trajectory.test.js +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/__init__.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/conftest.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/corpus/change_corpus.json +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/corpus/hackerbot_claw_campaign.json +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/corpus/hackerbot_claw_incident.json +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/fixtures/known-malicious.diff +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/oracle/__init__.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/oracle/grammar.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/oracle/harness.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/oracle/lexrunner.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/oracle/programs.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/oracle/runner.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/test_actors.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/test_assemble.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/test_campaign_replay.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/test_cli.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/test_cli_watch.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/test_config.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/test_deep_classify.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/test_detect.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/test_detection_quality.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/test_dogfood_gate.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/test_emit.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/test_enclosure.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/test_export.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/test_fetch_tool.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/test_file_scope.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/test_file_structure.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/test_forward_offline.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/test_gate_edges.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/test_grounding.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/test_hosts.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/test_incident_hackerbot_claw.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/test_indicators.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/test_ingest.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/test_ingest_metadata.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/test_injection_offline.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/test_inline_exec_and_bidi.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/test_linewide.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/test_lookalike_owner.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/test_metadata_rules.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/test_normalize.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/test_oracle.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/test_oracle_harness.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/test_provenance.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/test_python_loader.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/test_response.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/test_rubric.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/test_schema.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/test_shelllex.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/test_snapshot.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/test_subscription.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/test_watch.py +0 -0
- {diff_sentry-0.12.0 → diff_sentry-0.14.0}/tests/test_watch_pool.py +0 -0
|
@@ -54,10 +54,18 @@ paths:
|
|
|
54
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.
|
|
55
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.
|
|
56
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
|
+
|
|
57
64
|
## Image watch (`images.py`, `release-watch --images`)
|
|
58
65
|
|
|
59
|
-
- **Provenance
|
|
66
|
+
- **Provenance from the attestation.** `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. The layers are read separately (`layer_change`, below).
|
|
60
67
|
- **Judged against what is known, never against the last image.** A repository holds images from several workflows, so `images.known` holds the sources, the builders (repository and workflow) and the workflows seen building from protected refs: seeded at first sight from the watched floating tags and the newest `_SAMPLED_VERSIONS` version tags, and extended by every image judged clean. An image that signals never extends it, so one bad image cannot vouch for the next. On GHCR the namespace (`_namespace`) is a known owner from the start. `scan_image` grades by owner, since one organisation's repositories share a trust boundary: a source or building repository whose owner imitates a known owner is `image-source-lookalike` / `image-builder-lookalike` (critical, via `indicators.lookalike_owner`); one of an unknown owner is `image-source-changed` / `image-builder-changed` (high); another repository or workflow of a known owner is `medium` (`image-workflow-changed`, a nightly or branch build); an unprotected ref under a workflow known protected is `image-ref-unprotected` (high); an image without provenance while the repository's images carry it, a single manifest included, is `image-provenance-dropped` (high); notes are `release-not-fully-read` (high). A first-sight sample whose owners imitate each other is screened as `image-source-lookalike` (critical) and learns neither: both owners are kept as `known.suspect`, every image of the repository is `image-owner-unresolved` (high) and none is learned until the state is reset, and the suspect owners count among those an image's owner is checked for imitating (`_without_lookalikes`). The first-sight alert has a fixed run id and an `image_judgments` row, so it is sent once and re-sent when refused. An empty owner (Docker Hub has no namespace; a build outside GitHub Actions has no building repository) is never a known owner, and a first sight that read nothing and learned nothing is no baseline (retried next poll). The provenance is unsigned: a match proves nothing, only a mismatch is evidence.
|
|
61
68
|
- **What is judged**: a new tag (cosign's `sha256-<digest>.sig/.att/.sbom` skipped, newest first by the version numbers a tag starts with, at most `_MAX_NEW_TAGS` a poll), a watched floating tag that points at another digest, and one of the newest `_RECHECKED_VERSIONS` plain release tags (`_releases`: no `-nightly` or `-rc` suffix, so one version's pre-releases cannot crowd the current release out) pushed again. The first-sight sample takes the newest plain releases the same way. Digests are read with HEAD requests, which Docker Hub does not count toward its pull limit.
|
|
62
69
|
- **Registries are read anonymously and only from their own hosts**: GHCR and Docker Hub (`registry-1.docker.io`), a Bearer token asked for only from a realm on an allowed host, kept per registry and repository and asked for again on a 401 (Docker Hub's expire in minutes), a blob redirect followed only to their CDN hosts (`pkg-containers.githubusercontent.com`, Docker Hub's `production.cloudfront.docker.com` and `production.cloudflare.docker.com`); every response is bounded. A 429 (`RateLimited`) stops that image's poll and counts as no failure.
|
|
70
|
+
- **Layers by content** (`layer_change`): a judged image is compared with the image it replaces, `previous` in `judge` (a moved tag's stored digest; for a new tag, `_predecessor`: the newest release tag below it by `_version_key` whose digest is stored). Per platform (`_platforms`, attestation manifests aside; a platform the previous image lacks is a note), only the layers the two do not share are downloaded (`_MAX_LAYER_BYTES` each, `DS_RELEASE_MAX_LAYER_MB`, 1 GiB; `_MAX_LAYERS_BYTES`, 4 GiB, a comparison; each checked against its sha256 digest), and each run of layers is applied as a runtime applies it (`_filesystem`: a later layer's file replaces an earlier one's, whiteouts remove what is beneath, `_layer_path` keeps the whole path and drops `..`). The two file systems go through `releases.release_files` in the same two passes as a release (digests first, then text for what changed only), labelled `<platform>/<path>`; the files join the event and its `unread_code` and `notes` join `event["release"]`, so `scan_release` and every text rule read them. A layer that is too large, zstd (`releases._decompressed` names it), not read to its end (`_MAX_LAYER_READ`, 8 GiB decompressed) or not matching its digest is a note (`release-not-fully-read`). A `.pyc` member's digest is of its code past the 16-byte header (`pyc:` prefix), which holds the source's mtime and size and changes with every rebuild. Changed `__pycache__` bytecode whose source the artifact ships is checked against it (`releases._check_bytecode`, `pycheck.check_bytecode`, wheels too): a `python3.N -I -S` process whose magic number matches the pyc (found on `PATH`, or this interpreter) compiles the source and unmarshals the bytecode, runs neither, and compares the code objects (equality ignores the file name). The same code (line numbers included, so a pyc compiled from a shifted copy of the source alerts) is not `release-code-not-read`; other code, or no interpreter of that version here, is. The child runs under `RLIMIT_AS` (2 GiB) and `RLIMIT_CPU` (`pycheck._limits`, where the platform enforces them) and a timeout, since `marshal` is not safe against crafted data. A layer's whiteouts apply to the layers beneath before its own files (an opaque whiteout never hides what its layer adds), the first image of a platform is compared and a second is a note, attestation entries are dropped before `_MAX_PLATFORMS` applies, a member past `_MAX_MEMBER_BYTES` is hashed in chunks (`releases._large_member`) and never held whole (a Python source or bytecode up to `_MAX_CHECKED_BYTES`, 16 MiB, is kept, untexted, for the bytecode check), and a new tag with no release below it is compared with a watched floating tag's image. Each platform's config blob (`_config`, digest-checked, 4 MiB) is compared even when every layer is shared: rendered as Dockerfile lines (`_render_config`) into `<platform>/{config}` for the text rules, with a changed `_RUN_KEYS` setting as `image-run-config-changed` (high) and a `_LOADER_ENV` variable or any `LD_`/`DYLD_` one set or changed as `image-loader-env` (high), both read by `scan_image`; an unreadable config, the previous image's included, is a note, and nothing is compared unless both configs were read.
|
|
63
71
|
- **Like releases**, a comparison that fails is retried on later polls and, after five, screened as not compared (`release-not-fully-read`, which alerts); an alert the SIEM did not accept is re-sent from `screened/` a few per poll.
|
|
@@ -79,6 +79,8 @@ 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)
|
|
82
83
|
# DS_RELEASE_SOURCES=litellm=BerriAI/litellm # release-watch: the GitHub repository each package is built from, compared by tag (or --sources)
|
|
83
84
|
# DS_RELEASE_IMAGES=ghcr.io/owner/name # release-watch: container images compared by build provenance (or --images)
|
|
85
|
+
# DS_RELEASE_MAX_LAYER_MB=1024 # release-watch: a larger image layer is not read, which alerts
|
|
84
86
|
# 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, provenance, and the layers two images do not share, as data in memory, never unpacked or run). `pycheck` compiles a release's Python source and unmarshals its bytecode in a separate interpreter to compare them, and runs neither. 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`, `pycheck`, `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: an
|
|
57
|
+
- The deterministic tier ships as `cli.screen`, `watch` and `release-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.
|
|
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,31 @@
|
|
|
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.14.0
|
|
6
|
+
|
|
7
|
+
Upgrade from 0.13.0: `release-watch --images` now reads what each image changes, and checks compiled bytecode against its source. The GitHub Action's scan is unchanged, and `rlm-harness==1.24.0` is unchanged. For the bytecode check, install the Python version an image or wheel uses (`python3.N` on `PATH`) beside the watcher; without it, changed bytecode alerts that it could not be checked.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **`release-watch --images` compares an image's files with the image it replaces.** A moved tag is compared with the image it pointed at, and a new tag with the newest release below it. Only the layers the two do not share are downloaded and checked against their digests, then applied as a runtime applies them, and the files that changed are screened by the same rules as a release, so a `.pth` startup hook in an image's site-packages alerts as it does in a wheel. A layer it cannot read, a new platform, and two images for one platform alert that the image was not fully read. A layer can be capped with `DS_RELEASE_MAX_LAYER_MB` (1024 by default).
|
|
11
|
+
- **An image's run configuration is compared too**, even when every layer is reused: another entrypoint, command, shell, user, working directory, healthcheck or build trigger is `image-run-config-changed` (`high`), and an environment variable that loads code (`LD_PRELOAD`, `PYTHONPATH`, `PYTHONWARNINGS`, `NODE_OPTIONS`, `PATH` and the like) is `image-loader-env` (`high`).
|
|
12
|
+
- **Compiled bytecode is checked against its source**, in images and wheels. Python can run a cached `.pyc` instead of the source beside it, so a changed `.pyc` is compared with its source compiled, in a separate, bounded interpreter of the same Python version that runs neither. Bytecode that is not its source compiled alerts.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
- **A `.pyc` is compared by its code, not its header**, which every rebuild rewrites, so a rebuilt image or wheel no longer alerts on unchanged bytecode.
|
|
16
|
+
|
|
17
|
+
## 0.13.0
|
|
18
|
+
|
|
19
|
+
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.
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
- **`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:
|
|
23
|
+
- `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`.
|
|
24
|
+
- `npm-dependency-source` (`high`): a dependency newly installed from a git repository, a URL, a path or an `npm:` alias.
|
|
25
|
+
- `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.
|
|
26
|
+
- The publisher checks PyPI releases have, from the SLSA provenance the registry holds, which must name the tarball's own digest.
|
|
27
|
+
|
|
28
|
+
The manifests are read as npm extracts them, and one that cannot be read alerts. See [npm packages](docs/release-watch.md#npm-packages).
|
|
29
|
+
|
|
5
30
|
## 0.12.0
|
|
6
31
|
|
|
7
32
|
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.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: diff-sentry
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.14.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
|
|
@@ -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.14.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.14.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.14.0'
|
|
44
44
|
|
|
45
45
|
outputs:
|
|
46
46
|
failed:
|
|
@@ -33,8 +33,7 @@ async def detect_from_event(
|
|
|
33
33
|
on_event: Callable[[dict], None] | None = None,
|
|
34
34
|
extra_tools=(),
|
|
35
35
|
):
|
|
36
|
-
"""Run the classifier on one change event and return the planner's JUDGEMENT (`ChangeVerdict`).
|
|
37
|
-
Call `assemble.assemble_verdict(verdict, events)` to attach the deterministic indicators + signal."""
|
|
36
|
+
"""Run the classifier on one change event and return the planner's JUDGEMENT (`ChangeVerdict`). Call `assemble.assemble_verdict(verdict, events)` to attach the deterministic indicators + signal."""
|
|
38
37
|
import rlm_harness
|
|
39
38
|
|
|
40
39
|
from .detect import ClassifyChange, setup
|
|
@@ -337,6 +336,7 @@ def _cmd_release_watch(args) -> int:
|
|
|
337
336
|
import signal
|
|
338
337
|
|
|
339
338
|
from .images import ImageWatcher
|
|
339
|
+
from .npm import NpmWatcher, parse_npm_packages
|
|
340
340
|
from .releases import ReleaseWatcher, parse_packages, parse_sources
|
|
341
341
|
|
|
342
342
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
|
@@ -349,8 +349,11 @@ def _cmd_release_watch(args) -> int:
|
|
|
349
349
|
with open(args.packages_file, encoding="utf-8") as fh:
|
|
350
350
|
texts.append(fh.read())
|
|
351
351
|
packages = parse_packages(texts)
|
|
352
|
+
npm = parse_npm_packages([*args.npm, os.getenv("DS_RELEASE_NPM", "")])
|
|
353
|
+
check_npm = bool(args.check) and args.check[0].startswith("npm:")
|
|
352
354
|
if args.check:
|
|
353
|
-
packages = parse_packages([args.check[0]])
|
|
355
|
+
packages = [] if check_npm else parse_packages([args.check[0]])
|
|
356
|
+
npm = parse_npm_packages([args.check[0][4:]]) if check_npm else []
|
|
354
357
|
images = [i for text in [*args.images, os.getenv("DS_RELEASE_IMAGES", "")] for i in text.replace(",", " ").split()]
|
|
355
358
|
floating = tuple(args.image_tags or os.getenv("DS_RELEASE_IMAGE_TAGS", "latest").replace(",", " ").split())
|
|
356
359
|
image_watcher = ImageWatcher([] if args.check else images, state_dir=args.state, out_dir=args.out,
|
|
@@ -362,20 +365,26 @@ def _cmd_release_watch(args) -> int:
|
|
|
362
365
|
except BaseException:
|
|
363
366
|
image_watcher.close()
|
|
364
367
|
raise
|
|
368
|
+
try:
|
|
369
|
+
npm_watcher = NpmWatcher(npm, state_dir=args.state, out_dir=args.out, emit=not args.no_emit)
|
|
370
|
+
except BaseException:
|
|
371
|
+
image_watcher.close()
|
|
372
|
+
watcher.close()
|
|
373
|
+
raise
|
|
365
374
|
except (OSError, ValueError, RuntimeError) as exc:
|
|
366
375
|
print(f"release-watch: {exc}", file=sys.stderr)
|
|
367
376
|
return 2
|
|
368
377
|
try:
|
|
369
378
|
if args.check:
|
|
370
|
-
result = watcher.check(packages[0], args.check[1])
|
|
379
|
+
result = npm_watcher.check(npm[0], args.check[1]) if check_npm else watcher.check(packages[0], args.check[1])
|
|
371
380
|
print(json.dumps(result, indent=2, default=str))
|
|
372
381
|
return 1 if "error" in result else 0
|
|
373
|
-
if not packages and not images:
|
|
374
|
-
print("release-watch: nothing to watch (--packages, --packages-file, --images, or DS_RELEASE_PACKAGES / "
|
|
375
|
-
"DS_RELEASE_IMAGES)", file=sys.stderr)
|
|
382
|
+
if not packages and not npm and not images:
|
|
383
|
+
print("release-watch: nothing to watch (--packages, --packages-file, --npm, --images, or DS_RELEASE_PACKAGES / "
|
|
384
|
+
"DS_RELEASE_NPM / DS_RELEASE_IMAGES)", file=sys.stderr)
|
|
376
385
|
return 2
|
|
377
386
|
if args.once:
|
|
378
|
-
for result in watcher.poll_once() + image_watcher.poll_once():
|
|
387
|
+
for result in watcher.poll_once() + npm_watcher.poll_once() + image_watcher.poll_once():
|
|
379
388
|
print(json.dumps(result, default=str))
|
|
380
389
|
return 0
|
|
381
390
|
stopping = []
|
|
@@ -384,6 +393,7 @@ def _cmd_release_watch(args) -> int:
|
|
|
384
393
|
while not stopping:
|
|
385
394
|
started = time.monotonic()
|
|
386
395
|
watcher.poll_once()
|
|
396
|
+
npm_watcher.poll_once()
|
|
387
397
|
image_watcher.poll_once()
|
|
388
398
|
while not stopping and time.monotonic() - started < args.interval:
|
|
389
399
|
time.sleep(min(1.0, args.interval))
|
|
@@ -392,6 +402,7 @@ def _cmd_release_watch(args) -> int:
|
|
|
392
402
|
return 0
|
|
393
403
|
finally:
|
|
394
404
|
watcher.close()
|
|
405
|
+
npm_watcher.close()
|
|
395
406
|
image_watcher.close()
|
|
396
407
|
|
|
397
408
|
|
|
@@ -657,6 +668,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
657
668
|
help="compare each new release of watched PyPI packages with the one before it (no model)")
|
|
658
669
|
rw.add_argument("--packages", nargs="+", default=[], help="PyPI project names (or set DS_RELEASE_PACKAGES)")
|
|
659
670
|
rw.add_argument("--packages-file", help="a file of project names, one per line (# comments allowed)")
|
|
671
|
+
rw.add_argument("--npm", nargs="+", default=[], help="npm package names, @scope/name included (or set DS_RELEASE_NPM)")
|
|
660
672
|
rw.add_argument("--sources", nargs="+", default=[], metavar="PACKAGE=OWNER/NAME",
|
|
661
673
|
help="the GitHub repository each package is built from, to compare each release with its tag "
|
|
662
674
|
"(or set DS_RELEASE_SOURCES; default: the repository of the previous release's Trusted Publisher)")
|
|
@@ -673,7 +685,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
673
685
|
rw.add_argument("--no-emit", action="store_true", help="do not emit SIEM signals")
|
|
674
686
|
rw.add_argument("--once", action="store_true", help="one poll, then exit")
|
|
675
687
|
rw.add_argument("--check", nargs=2, metavar=("PACKAGE", "VERSION"),
|
|
676
|
-
help="judge one release against the one before it now, print the result, and exit"
|
|
688
|
+
help="judge one release against the one before it now, print the result, and exit "
|
|
689
|
+
"(npm:NAME for an npm package)")
|
|
677
690
|
rw.set_defaults(func=_cmd_release_watch)
|
|
678
691
|
|
|
679
692
|
ac = sub.add_parser("actor", help="the dossier of an account the watcher has seen (offline)")
|