diff-sentry 0.13.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.13.0 → diff_sentry-0.14.0}/.claude/rules/monitoring.md +2 -1
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/.env.example +1 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/AGENTS.md +3 -3
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/CHANGELOG.md +12 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/PKG-INFO +2 -2
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/README.md +1 -1
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/action.yml +1 -1
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/diff_sentry/__init__.py +1 -1
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/diff_sentry/cli.py +1 -2
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/diff_sentry/images.py +217 -55
- diff_sentry-0.14.0/diff_sentry/pycheck.py +88 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/diff_sentry/releases.py +70 -19
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/docs/release-watch.md +7 -2
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/pyproject.toml +1 -1
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/test_images.py +276 -10
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/test_releases.py +16 -1
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/uv.lock +1 -1
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/.claude/rules/detect-setup.md +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/.claude/rules/handoff.md +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/.claude/rules/indicators.md +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/.claude/rules/rubric-and-eval.md +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/.claude/skills/release/SKILL.md +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/.github/workflows/ci.yml +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/.github/workflows/release.yml +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/.gitignore +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/.python-version +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/LICENSE +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/VENDOR.md +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/diff_sentry/__main__.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/diff_sentry/actors.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/diff_sentry/assemble.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/diff_sentry/config.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/diff_sentry/dbutil.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/diff_sentry/deep_classify.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/diff_sentry/detect.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/diff_sentry/emit.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/diff_sentry/fetch_tool.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/diff_sentry/hosts.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/diff_sentry/indicators.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/diff_sentry/ingest.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/diff_sentry/linewide.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/diff_sentry/normalize.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/diff_sentry/npm.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/diff_sentry/response.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/diff_sentry/rl_export.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/diff_sentry/rubric.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/diff_sentry/schema.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/diff_sentry/shelllex.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/diff_sentry/skills/ci-and-workflows.md +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/diff_sentry/skills/concealment-and-provenance.md +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/diff_sentry/skills/developer-autorun.md +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/diff_sentry/skills/llm-directed-text.md +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/diff_sentry/skills/package-and-build-hooks.md +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/diff_sentry/skills/runtime-droppers-and-exfil.md +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/diff_sentry/skills/triage-a-change.md +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/diff_sentry/snapshot.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/diff_sentry/watch.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/docs/development.md +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/docs/how-it-works.md +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/docs/limits.md +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/docs/watch.md +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/eval/README.md +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/eval/diff_sentry_eval/__init__.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/eval/diff_sentry_eval/__main__.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/eval/diff_sentry_eval/cli.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/eval/diff_sentry_eval/judge.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/eval/diff_sentry_eval/schema.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/eval/diff_sentry_eval/score.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/eval/diff_sentry_eval/taskset.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/eval/pyproject.toml +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/eval/taskset.example.json +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/eval/tests/conftest.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/eval/tests/test_boundary.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/eval/tests/test_cli.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/eval/tests/test_judge.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/eval/tests/test_schema.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/eval/tests/test_score.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/eval/tests/test_taskset.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/studio/DESIGN.md +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/studio/README.md +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/studio/diff_sentry_studio/__init__.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/studio/diff_sentry_studio/app.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/studio/diff_sentry_studio/iterations.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/studio/diff_sentry_studio/live.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/studio/diff_sentry_studio/mapper.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/studio/pyproject.toml +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/studio/static/app.js +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/studio/static/index.html +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/studio/static/replay-core.js +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/studio/static/run-core.js +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/studio/static/style.css +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/studio/static/trajectory.js +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/studio/static/vendor/fonts/jetbrains-mono-400.woff2 +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/studio/static/vendor/fonts/jetbrains-mono-700.woff2 +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/studio/tests/replay-core.test.js +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/studio/tests/run-core.test.js +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/studio/tests/static-contract.test.js +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/studio/tests/test_app.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/studio/tests/test_iterations.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/studio/tests/test_live.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/studio/tests/test_mapper.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/studio/tests/trajectory.test.js +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/__init__.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/conftest.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/corpus/change_corpus.json +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/corpus/hackerbot_claw_campaign.json +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/corpus/hackerbot_claw_incident.json +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/fixtures/known-malicious.diff +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/oracle/__init__.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/oracle/grammar.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/oracle/harness.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/oracle/lexrunner.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/oracle/programs.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/oracle/runner.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/test_actors.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/test_adversarial_input.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/test_assemble.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/test_campaign_replay.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/test_cli.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/test_cli_watch.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/test_config.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/test_deep_classify.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/test_detect.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/test_detection_quality.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/test_dogfood_gate.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/test_emit.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/test_enclosure.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/test_export.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/test_fetch_tool.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/test_file_scope.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/test_file_structure.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/test_forward_offline.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/test_gate_edges.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/test_grounding.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/test_hosts.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/test_incident_hackerbot_claw.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/test_indicators.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/test_ingest.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/test_ingest_metadata.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/test_injection_offline.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/test_inline_exec_and_bidi.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/test_linewide.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/test_lookalike_owner.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/test_metadata_rules.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/test_normalize.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/test_npm.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/test_oracle.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/test_oracle_harness.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/test_provenance.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/test_python_loader.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/test_response.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/test_rubric.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/test_schema.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/test_shelllex.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/test_snapshot.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/test_subscription.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/test_watch.py +0 -0
- {diff_sentry-0.13.0 → diff_sentry-0.14.0}/tests/test_watch_pool.py +0 -0
|
@@ -63,8 +63,9 @@ paths:
|
|
|
63
63
|
|
|
64
64
|
## Image watch (`images.py`, `release-watch --images`)
|
|
65
65
|
|
|
66
|
-
- **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).
|
|
67
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.
|
|
68
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.
|
|
69
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.
|
|
70
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.
|
|
@@ -82,4 +82,5 @@ DS_API_KEY=sk-...
|
|
|
82
82
|
# DS_RELEASE_NPM=@scope/name,left-pad # release-watch: npm packages (or --npm)
|
|
83
83
|
# DS_RELEASE_SOURCES=litellm=BerriAI/litellm # release-watch: the GitHub repository each package is built from, compared by tag (or --sources)
|
|
84
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
|
|
85
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` 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
|
|
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`, `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).
|
|
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,18 @@
|
|
|
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
|
+
|
|
5
17
|
## 0.13.0
|
|
6
18
|
|
|
7
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.
|
|
@@ -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.
|
|
@@ -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.
|
|
@@ -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
|
|
@@ -1,24 +1,17 @@
|
|
|
1
|
-
"""Image watch: each new or moved tag of a watched container image, judged by its build provenance against what the
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
So the provenance of an image names where it was built from and what built it, without reading a layer. A repository
|
|
7
|
-
holds images from several workflows (releases, nightlies, branch builds), so an image is judged against the set of
|
|
8
|
-
sources and builders the repository is known to use, learned from a sample at first sight and from every image judged
|
|
9
|
-
clean since, never from one that raised an alert. The comparison is screened like a release (`kind="release"`, facts
|
|
10
|
-
in `release.image`), and `scan_image` turns it into baseline hits.
|
|
11
|
-
|
|
12
|
-
Host-side plumbing, like `releases`: it reads the registry's API only (manifests, the provenance attestation), from
|
|
13
|
-
the registries' own hosts and anonymous pull tokens, with every response bounded. Layers are never downloaded. Pure
|
|
14
|
-
stdlib + httpx; no dspy.
|
|
1
|
+
"""Image watch: each new or moved tag of a watched container image, judged by its build provenance against what the repository's images have been built from before, and by the files it changes.
|
|
2
|
+
|
|
3
|
+
litellm 1.107.0's images on GHCR and Docker Hub, `latest` and `main-stable` among them, were built by its own release workflow from the attacker's `Berri-AI/litellm`, and BuildKit wrote that in each image's SLSA provenance (`vcs:source`). So the provenance of an image names where it was built from and what built it. A repository holds images from several workflows (releases, nightlies, branch builds), so an image is judged against the set of sources and builders the repository is known to use, learned from a sample at first sight and from every image judged clean since, never from one that raised an alert. The provenance is unsigned, so the image's content is read too: the layers it does not share with the image it replaces, compared file by file (`layer_change`). The comparison is screened like a release (`kind="release"`, facts in `release.image`, changed files as the event's files), and `scan_image` turns the provenance into baseline hits.
|
|
4
|
+
|
|
5
|
+
Host-side plumbing, like `releases`: it reads the registry's API only (manifests, the provenance attestation, the layers two images do not share), from the registries' own hosts and anonymous pull tokens, with every response bounded. A layer is read as data in memory, never unpacked to disk or run. Pure stdlib + httpx; no dspy.
|
|
15
6
|
"""
|
|
16
7
|
|
|
17
8
|
from __future__ import annotations
|
|
18
9
|
|
|
10
|
+
import hashlib
|
|
19
11
|
import json
|
|
20
12
|
import logging
|
|
21
13
|
import os
|
|
14
|
+
import posixpath
|
|
22
15
|
import re
|
|
23
16
|
import time
|
|
24
17
|
from collections.abc import Callable
|
|
@@ -27,12 +20,12 @@ from datetime import UTC, datetime
|
|
|
27
20
|
from urllib.parse import quote, urlsplit
|
|
28
21
|
|
|
29
22
|
from .dbutil import open_versioned
|
|
23
|
+
from .releases import Artifact
|
|
30
24
|
from .schema import IndicatorHit
|
|
31
25
|
|
|
32
26
|
log = logging.getLogger(__name__)
|
|
33
27
|
|
|
34
|
-
# A fetch maps (url, request headers, byte limit) to (status, response headers, body), and never follows a redirect. A
|
|
35
|
-
# request header `X-Method: HEAD` asks for a HEAD (a digest without the manifest, which Docker Hub does not count).
|
|
28
|
+
# A fetch maps (url, request headers, byte limit) to (status, response headers, body), and never follows a redirect. A request header `X-Method: HEAD` asks for a HEAD (a digest without the manifest, which Docker Hub does not count).
|
|
36
29
|
Fetch = Callable[[str, dict, int], tuple[int, dict, bytes]]
|
|
37
30
|
|
|
38
31
|
# The registries an image may be read from, the hosts their tokens come from, and the hosts their blobs redirect to.
|
|
@@ -108,9 +101,7 @@ class RateLimited(RuntimeError):
|
|
|
108
101
|
|
|
109
102
|
|
|
110
103
|
class Registry:
|
|
111
|
-
"""A registry's API read anonymously: a pull token is asked for when the registry answers 401 with a Bearer
|
|
112
|
-
challenge (again when a token expires), from the realm it names (an allowed host), kept per registry and repository;
|
|
113
|
-
a blob's redirect is followed to an allowed host only."""
|
|
104
|
+
"""A registry's API read anonymously: a pull token is asked for when the registry answers 401 with a Bearer challenge (again when a token expires), from the realm it names (an allowed host), kept per registry and repository; a blob's redirect is followed to an allowed host only."""
|
|
114
105
|
|
|
115
106
|
def __init__(self, fetch: Fetch):
|
|
116
107
|
self.fetch = fetch
|
|
@@ -191,15 +182,13 @@ class Registry:
|
|
|
191
182
|
# ── provenance ───────────────────────────────────────────────────────────────────────────────────────────
|
|
192
183
|
|
|
193
184
|
def _source_repo(url: str) -> str:
|
|
194
|
-
"""`owner/name` of a GitHub repository URL, lower case; any other source as written, lower case (never read as a
|
|
195
|
-
GitHub owner, so a lookalike is only ever judged between GitHub repositories)."""
|
|
185
|
+
"""`owner/name` of a GitHub repository URL, lower case; any other source as written, lower case (never read as a GitHub owner, so a lookalike is only ever judged between GitHub repositories)."""
|
|
196
186
|
m = _GITHUB_REPO_RE.fullmatch((url or "").strip())
|
|
197
187
|
return m.group(1).lower() if m else (url or "").strip().lower()
|
|
198
188
|
|
|
199
189
|
|
|
200
190
|
def provenance_facts(statement: dict) -> dict:
|
|
201
|
-
"""What a BuildKit SLSA provenance (v0.2 or v1) says about where an image was built from and what built it: the
|
|
202
|
-
source repository and revision, and the GitHub repository, workflow (without its ref) and ref protection of the run."""
|
|
191
|
+
"""What a BuildKit SLSA provenance (v0.2 or v1) says about where an image was built from and what built it: the source repository and revision, and the GitHub repository, workflow (without its ref) and ref protection of the run."""
|
|
203
192
|
predicate = statement.get("predicate") or {}
|
|
204
193
|
meta = ((predicate.get("runDetails") or {}).get("metadata") or predicate.get("metadata") or {})
|
|
205
194
|
vcs = (meta.get("buildkit_metadata") or meta.get("https://mobyproject.org/buildkit@v1#metadata") or {}).get("vcs") or {}
|
|
@@ -223,10 +212,7 @@ def provenance_facts(statement: dict) -> dict:
|
|
|
223
212
|
|
|
224
213
|
|
|
225
214
|
def image_provenance(registry: Registry, ref: ImageRef, reference: str) -> tuple[str, list[dict], list[str], bool]:
|
|
226
|
-
"""The image's digest, the provenance facts of each platform it carries, notes on what could not be read, and
|
|
227
|
-
whether it is a multi-platform index (a single manifest carries no BuildKit attestation). An attestation counts only
|
|
228
|
-
when it names (`vnd.docker.reference.digest`) a platform image of the same index; a platform image no attestation
|
|
229
|
-
names is a note."""
|
|
215
|
+
"""The image's digest, the provenance facts of each platform it carries, notes on what could not be read, and whether it is a multi-platform index (a single manifest carries no BuildKit attestation). An attestation counts only when it names (`vnd.docker.reference.digest`) a platform image of the same index; a platform image no attestation names is a note."""
|
|
230
216
|
digest, top = registry.manifest(ref, reference)
|
|
231
217
|
notes: list[str] = []
|
|
232
218
|
if top.get("mediaType") not in _INDEX_TYPES and "manifests" not in top:
|
|
@@ -264,6 +250,176 @@ def image_provenance(registry: Registry, ref: ImageRef, reference: str) -> tuple
|
|
|
264
250
|
return digest, facts, notes, True
|
|
265
251
|
|
|
266
252
|
|
|
253
|
+
# ── layers ───────────────────────────────────────────────────────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
_MAX_LAYER_BYTES = int(os.getenv("DS_RELEASE_MAX_LAYER_MB", "1024") or "1024") << 20 # one compressed layer
|
|
256
|
+
_MAX_LAYERS_BYTES = 4 << 30 # every differing layer of one comparison, both images and all platforms
|
|
257
|
+
_MAX_LAYER_READ = 8 << 30 # what one layer may decompress to
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _platform(desc: dict) -> str:
|
|
261
|
+
p = desc.get("platform") or {}
|
|
262
|
+
return "-".join(str(x) for x in (p.get("os"), p.get("architecture"), p.get("variant")) if x) or "default"
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _platforms(registry: Registry, ref: ImageRef, digest: str, notes: list[str]) -> dict[str, dict]:
|
|
266
|
+
"""Each platform image of an index (attestation manifests aside), or the one image of a single manifest, by platform. A runtime pulls the first entry for its platform, so that one is kept, and a second entry for a platform is a note."""
|
|
267
|
+
_, top = registry.manifest(ref, digest)
|
|
268
|
+
if top.get("mediaType") not in _INDEX_TYPES and "manifests" not in top:
|
|
269
|
+
return {"default": top}
|
|
270
|
+
images = [d for d in top.get("manifests") or [] if isinstance(d, dict)
|
|
271
|
+
and (d.get("annotations") or {}).get("vnd.docker.reference.type") != "attestation-manifest"]
|
|
272
|
+
if len(images) > _MAX_PLATFORMS:
|
|
273
|
+
notes.append(f"{len(images)} platform images, the first {_MAX_PLATFORMS} compared")
|
|
274
|
+
out: dict[str, dict] = {}
|
|
275
|
+
for desc in images[:_MAX_PLATFORMS]:
|
|
276
|
+
key = _platform(desc)
|
|
277
|
+
if key in out:
|
|
278
|
+
notes.append(f"{key}: two images for one platform; the first, which a runtime pulls, is compared")
|
|
279
|
+
continue
|
|
280
|
+
out[key] = registry.manifest(ref, desc["digest"])[1]
|
|
281
|
+
return out
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _layer_path(name: str) -> str | None:
|
|
285
|
+
"""A layer member's path in the image's file system (`./usr/bin/x` and `/usr/bin/x` are `usr/bin/x`)."""
|
|
286
|
+
parts = [p for p in name.split("/") if p not in ("", ".")]
|
|
287
|
+
return None if not parts or ".." in parts or name.endswith("/") else "/".join(parts)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _filesystem(kind: str, layers: list[tuple[str, bytes]], budget) -> Artifact:
|
|
291
|
+
"""The files a run of layers leaves, applied in order as a container runtime applies them: a layer's whiteouts (`.wh.name`, `.wh..wh..opq`) first remove what the layers beneath it hold, then its files replace earlier ones (no shadow)."""
|
|
292
|
+
from .releases import _read_tar, _Spent
|
|
293
|
+
|
|
294
|
+
merged = Artifact(kind, kind)
|
|
295
|
+
for digest, body in layers:
|
|
296
|
+
layer = Artifact(f"{kind} layer {digest[7:19]}", kind)
|
|
297
|
+
try:
|
|
298
|
+
_read_tar(layer, body, budget, path_of=_layer_path, limit=_MAX_LAYER_READ)
|
|
299
|
+
except _Spent:
|
|
300
|
+
layer.notes.append(f"{layer.filename}: not read to its end (more than {_MAX_LAYER_READ >> 30} GiB decompressed)")
|
|
301
|
+
except Exception as exc: # noqa: BLE001 (fail CLOSED: a layer this cannot read is evidence)
|
|
302
|
+
layer.notes.append(f"{layer.filename}: not read to its end ({str(exc)[:80] or type(exc).__name__})")
|
|
303
|
+
merged.notes += layer.notes
|
|
304
|
+
# a layer's whiteouts remove what the layers beneath it hold, never what the layer itself adds (the OCI layer spec): so they apply first
|
|
305
|
+
for member in layer.members.values():
|
|
306
|
+
folder, base = posixpath.split(member.path)
|
|
307
|
+
if base == ".wh..wh..opq":
|
|
308
|
+
for gone in [k for k in merged.members if k.startswith(f"{folder}/" if folder else "")]:
|
|
309
|
+
del merged.members[gone]
|
|
310
|
+
elif base.startswith(".wh."):
|
|
311
|
+
target = posixpath.join(folder, base[4:])
|
|
312
|
+
for gone in [k for k in merged.members if k == target or k.startswith((f"{target}/", f"{target}\0"))]:
|
|
313
|
+
del merged.members[gone]
|
|
314
|
+
for key, member in layer.members.items():
|
|
315
|
+
if not posixpath.basename(member.path).startswith(".wh."):
|
|
316
|
+
merged.members[key] = member
|
|
317
|
+
return merged
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
# what the config sets that decides what a container runs, and the environment variables that load code into a process
|
|
321
|
+
_RUN_KEYS = ("Entrypoint", "Cmd", "Shell", "User", "WorkingDir", "OnBuild", "Healthcheck")
|
|
322
|
+
_LOADER_ENV = frozenset({"PATH", "PYTHONWARNINGS", "PYTHONEXECUTABLE", "PYTHONPLATLIBDIR", "GCONV_PATH", "NODE_EXTRA_CA_CERTS", "LD_PRELOAD", "LD_LIBRARY_PATH", "LD_AUDIT", "PYTHONPATH", "PYTHONSTARTUP", "PYTHONHOME",
|
|
323
|
+
"PYTHONINSPECT", "PYTHONUSERBASE", "NODE_OPTIONS", "NODE_PATH", "BASH_ENV", "ENV", "PERL5OPT",
|
|
324
|
+
"PERL5LIB", "RUBYOPT", "RUBYLIB", "JAVA_TOOL_OPTIONS", "_JAVA_OPTIONS", "DYLD_INSERT_LIBRARIES"})
|
|
325
|
+
_MAX_CONFIG_BYTES = 4 << 20
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _config(registry: Registry, ref: ImageRef, manifest: dict, notes: list[str]) -> dict | None:
|
|
329
|
+
"""A platform image's run configuration (its config blob's `config`), checked against its digest; None, with a note, when it cannot be read."""
|
|
330
|
+
desc = manifest.get("config") if isinstance(manifest.get("config"), dict) else {}
|
|
331
|
+
digest = str(desc.get("digest") or "")
|
|
332
|
+
try:
|
|
333
|
+
body = registry.blob(ref, digest, _MAX_CONFIG_BYTES)
|
|
334
|
+
if not digest.startswith("sha256:") or hashlib.sha256(body).hexdigest() != digest[7:]:
|
|
335
|
+
raise ValueError("does not match its digest")
|
|
336
|
+
config = json.loads(body.decode("utf-8")).get("config") or {}
|
|
337
|
+
return config if isinstance(config, dict) else {}
|
|
338
|
+
except RateLimited:
|
|
339
|
+
raise
|
|
340
|
+
except (RuntimeError, ValueError, AttributeError) as exc:
|
|
341
|
+
notes.append(f"the image config {digest[7:19]} was not read ({str(exc)[:80]})")
|
|
342
|
+
return None
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _env(config: dict) -> dict[str, str]:
|
|
346
|
+
return {str(e).split("=", 1)[0]: str(e).split("=", 1)[-1] for e in config.get("Env") or [] if isinstance(e, str)}
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _render_config(config: dict) -> str:
|
|
350
|
+
"""The run configuration as a Dockerfile would write it, one setting a line, so the text rules read its commands."""
|
|
351
|
+
lines = [f"{key.upper()} {json.dumps(config[key])}" for key in _RUN_KEYS if config.get(key) not in (None, [], "")]
|
|
352
|
+
lines += [f"ENV {k}={v}" for k, v in sorted(_env(config).items())]
|
|
353
|
+
return "\n".join(lines) + "\n"
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def layer_change(registry: Registry, ref: ImageRef, digest: str, previous: str) -> tuple[list[dict], list[str], list[str], dict]:
|
|
357
|
+
"""The files an image changes against the image it replaces, platform by platform, and its run configuration against the replaced one's (`{config}`, rendered as Dockerfile lines, with the run settings and loader variables it changes as facts): only the layers the two do not share are read (a shared layer is the same bytes), in two passes like a release (`releases.release_event`), and their files compared by path, so a rebuilt layer shows only what changed in it. Returns the change's files, the members that run and were not shown as text, and notes on what was not read. Layers are read as DATA, in memory, and never unpacked or run."""
|
|
358
|
+
from .releases import _Budget, _patch, pyc_source, release_files
|
|
359
|
+
|
|
360
|
+
files: list[dict] = []
|
|
361
|
+
unread: list[str] = []
|
|
362
|
+
notes: list[str] = []
|
|
363
|
+
runs: list[str] = []
|
|
364
|
+
loaders: list[str] = []
|
|
365
|
+
now, before = _platforms(registry, ref, digest, notes), _platforms(registry, ref, previous, [])
|
|
366
|
+
budget = [_MAX_LAYERS_BYTES]
|
|
367
|
+
|
|
368
|
+
def download(layers: list[dict]) -> list[tuple[str, bytes]]:
|
|
369
|
+
out = []
|
|
370
|
+
for layer in layers:
|
|
371
|
+
size, digest_ = int(layer.get("size") or 0), str(layer.get("digest") or "")
|
|
372
|
+
if size > _MAX_LAYER_BYTES or size > budget[0]:
|
|
373
|
+
notes.append(f"layer {digest_[7:19]}: {size} bytes, not read (more than DS_RELEASE_MAX_LAYER_MB, or the comparison's budget)")
|
|
374
|
+
continue
|
|
375
|
+
body = registry.blob(ref, digest_, _MAX_LAYER_BYTES)
|
|
376
|
+
if not digest_.startswith("sha256:") or hashlib.sha256(body).hexdigest() != digest_[7:]:
|
|
377
|
+
notes.append(f"layer {digest_[7:19]}: the download does not match its digest")
|
|
378
|
+
continue
|
|
379
|
+
budget[0] -= len(body)
|
|
380
|
+
out.append((digest_, body))
|
|
381
|
+
return out
|
|
382
|
+
|
|
383
|
+
for platform, manifest in now.items():
|
|
384
|
+
old = before.get(platform)
|
|
385
|
+
if old is None:
|
|
386
|
+
notes.append(f"{platform}: a platform the previous image did not have, so its layers were not compared")
|
|
387
|
+
continue
|
|
388
|
+
config, before_config = _config(registry, ref, manifest, notes), _config(registry, ref, old, notes)
|
|
389
|
+
if config is not None and before_config is not None and config != before_config:
|
|
390
|
+
patch, added, removed, _ = _patch(_render_config(before_config), _render_config(config))
|
|
391
|
+
files.append({"filename": f"{platform}/{{config}}", "status": "modified", "additions": added,
|
|
392
|
+
"deletions": removed, "patch": patch})
|
|
393
|
+
for key in _RUN_KEYS:
|
|
394
|
+
if config.get(key) != before_config.get(key):
|
|
395
|
+
runs.append(f"{platform} {key}: {json.dumps(config.get(key))}"[:200])
|
|
396
|
+
env, before_env = _env(config), _env(before_config)
|
|
397
|
+
loaders.extend(f"{platform} {k}={v}"[:200] for k, v in sorted(env.items())
|
|
398
|
+
if (k in _LOADER_ENV or k.startswith(("LD_", "DYLD_"))) and before_env.get(k) != v)
|
|
399
|
+
mine = [layer for layer in manifest.get("layers") or [] if isinstance(layer, dict)]
|
|
400
|
+
theirs = [layer for layer in old.get("layers") or [] if isinstance(layer, dict)]
|
|
401
|
+
shared = {layer.get("digest") for layer in mine} & {layer.get("digest") for layer in theirs}
|
|
402
|
+
if all(layer.get("digest") in shared for layer in mine):
|
|
403
|
+
continue
|
|
404
|
+
new_bodies = download([layer for layer in mine if layer.get("digest") not in shared])
|
|
405
|
+
old_bodies = download([layer for layer in theirs if layer.get("digest") not in shared])
|
|
406
|
+
skim = _Budget(keep=lambda path, digest_: False)
|
|
407
|
+
old_digests = {(m.path, m.digest) for m in _filesystem(platform, old_bodies, skim).members.values()}
|
|
408
|
+
wanted = {(m.path, m.digest) for m in _filesystem(platform, new_bodies, skim).members.values()
|
|
409
|
+
if (m.path, m.digest) not in old_digests}
|
|
410
|
+
paths = {p for p, _ in wanted}
|
|
411
|
+
sources = {src for p in paths if (src := pyc_source(p))} # read too, to check the bytecode against
|
|
412
|
+
text = _Budget()
|
|
413
|
+
new = _filesystem(platform, new_bodies, _Budget.share(
|
|
414
|
+
text, lambda path, digest_, w=wanted, s=sources: (path, digest_) in w or path in s))
|
|
415
|
+
old_fs = _filesystem(platform, old_bodies, _Budget.share(text, lambda path, digest_, p=paths: path in p))
|
|
416
|
+
notes += new.notes
|
|
417
|
+
changed, missed = release_files([new], [old_fs])
|
|
418
|
+
files += changed
|
|
419
|
+
unread += missed
|
|
420
|
+
return files, unread[:50], notes[:50], {"config_run_changed": runs[:50], "config_loader_env": loaders[:50]}
|
|
421
|
+
|
|
422
|
+
|
|
267
423
|
# ── facts as evidence ────────────────────────────────────────────────────────────────────────────────────
|
|
268
424
|
|
|
269
425
|
def _owner(repo: str) -> str:
|
|
@@ -277,14 +433,7 @@ def _imitates(owner: str, owners: set[str]) -> bool:
|
|
|
277
433
|
|
|
278
434
|
|
|
279
435
|
def scan_image(facts: dict) -> list[IndicatorHit]:
|
|
280
|
-
"""An image's provenance against what its repository is known to be built from and by (`known`) and, on GHCR, the
|
|
281
|
-
namespace it is published under (a GitHub owner). A source or building repository whose owner imitates a known owner
|
|
282
|
-
is `critical`; one of an owner not known is `high`; another repository or workflow of a known owner is `medium` (a
|
|
283
|
-
repository's nightly and branch builds run other workflows, even from another repository of the same organisation); a build from an unprotected
|
|
284
|
-
ref by a workflow whose known builds ran from protected refs is `high` (litellm's attacker turned protection off
|
|
285
|
-
first). An image without provenance where the repository's images carry it is `high`, and so is anything that could
|
|
286
|
-
not be read. The provenance is unsigned: whoever controls the push can write any statement, so a match proves
|
|
287
|
-
nothing and only a mismatch is evidence."""
|
|
436
|
+
"""An image's provenance against what its repository is known to be built from and by (`known`) and, on GHCR, the namespace it is published under (a GitHub owner). A source or building repository whose owner imitates a known owner is `critical`; one of an owner not known is `high`; another repository or workflow of a known owner is `medium` (a repository's nightly and branch builds run other workflows, even from another repository of the same organisation); a build from an unprotected ref by a workflow whose known builds ran from protected refs is `high` (litellm's attacker turned protection off first). An image without provenance where the repository's images carry it is `high`, and so is anything that could not be read. The provenance is unsigned: whoever controls the push can write any statement, so a match proves nothing and only a mismatch is evidence."""
|
|
288
437
|
from .indicators import _hit
|
|
289
438
|
|
|
290
439
|
if not isinstance(facts, dict) or not facts:
|
|
@@ -341,6 +490,14 @@ def scan_image(facts: dict) -> list[IndicatorHit]:
|
|
|
341
490
|
hits.append(_hit("image-ref-unprotected", "high",
|
|
342
491
|
"a workflow whose known builds ran from protected refs built this image from an unprotected one",
|
|
343
492
|
f"{item.get('workflow')} @ {item.get('ref')}"[:240], location=where))
|
|
493
|
+
if facts.get("config_run_changed"):
|
|
494
|
+
hits.append(_hit("image-run-config-changed", "high",
|
|
495
|
+
"the image runs another entrypoint, command, shell, user, healthcheck or build trigger than the image it replaces",
|
|
496
|
+
"; ".join(str(x) for x in facts["config_run_changed"])[:240], location=where))
|
|
497
|
+
if facts.get("config_loader_env"):
|
|
498
|
+
hits.append(_hit("image-loader-env", "high",
|
|
499
|
+
"the image sets an environment variable that loads code into its processes (LD_PRELOAD, PYTHONPATH, NODE_OPTIONS...)",
|
|
500
|
+
"; ".join(str(x) for x in facts["config_loader_env"])[:240], location=where))
|
|
344
501
|
notes = [str(n) for n in facts.get("notes") or []]
|
|
345
502
|
if notes:
|
|
346
503
|
hits.append(_hit("release-not-fully-read", "high", "part of the image's provenance was not read",
|
|
@@ -358,10 +515,7 @@ def _learn(known: dict, facts: list[dict]) -> dict:
|
|
|
358
515
|
|
|
359
516
|
|
|
360
517
|
def _without_lookalikes(known: dict, namespace: str) -> tuple[dict, list[str]]:
|
|
361
|
-
"""`known` without any source or building repository whose owner imitates another known owner or the namespace: a
|
|
362
|
-
first-sight sample that already holds a lookalike cannot tell which side is the real one, so neither is learned: both
|
|
363
|
-
owners are kept as `suspect`, and every image from either is `high` (`image-owner-unresolved`) until the state is
|
|
364
|
-
reset after an investigation."""
|
|
518
|
+
"""`known` without any source or building repository whose owner imitates another known owner or the namespace: a first-sight sample that already holds a lookalike cannot tell which side is the real one, so neither is learned: both owners are kept as `suspect`, and every image from either is `high` (`image-owner-unresolved`) until the state is reset after an investigation."""
|
|
365
519
|
repos = set(known.get("sources") or []) | {b.split(" ", 1)[0] for b in known.get("builders") or []}
|
|
366
520
|
owners = {_owner(r) for r in repos} | {namespace}
|
|
367
521
|
suspect = {o for o in owners if o and _imitates(o, owners - {o})}
|
|
@@ -402,8 +556,7 @@ _VERSION_NUMBERS_RE = re.compile(r"v?(\d{1,9}(?:\.\d{1,9}){0,3})")
|
|
|
402
556
|
|
|
403
557
|
|
|
404
558
|
def _version_key(tag: str) -> tuple:
|
|
405
|
-
"""Order tags by the version numbers they start with (`v1.106.2` after `v1.83.9-nightly`), a plain release after its
|
|
406
|
-
suffixed builds, and tags without numbers before every version; ties by name."""
|
|
559
|
+
"""Order tags by the version numbers they start with (`v1.106.2` after `v1.83.9-nightly`), a plain release after its suffixed builds, and tags without numbers before every version; ties by name."""
|
|
407
560
|
m = _VERSION_NUMBERS_RE.match(tag)
|
|
408
561
|
if not m:
|
|
409
562
|
return ((), 0, tag)
|
|
@@ -412,8 +565,7 @@ def _version_key(tag: str) -> tuple:
|
|
|
412
565
|
|
|
413
566
|
|
|
414
567
|
def _releases(tags: list[str]) -> list[str]:
|
|
415
|
-
"""The version tags that are plain releases (`v1.106.2`, `1.106.2`, not `-nightly` or `-rc.1`), oldest first: the
|
|
416
|
-
images a re-push would hurt, which a run of one version's pre-releases must not crowd out of a window."""
|
|
568
|
+
"""The version tags that are plain releases (`v1.106.2`, `1.106.2`, not `-nightly` or `-rc.1`), oldest first: the images a re-push would hurt, which a run of one version's pre-releases must not crowd out of a window."""
|
|
417
569
|
return sorted((t for t in tags if _VERSION_TAG_RE.fullmatch(t) and _version_key(t)[1] == 1), key=_version_key)
|
|
418
570
|
|
|
419
571
|
|
|
@@ -423,10 +575,7 @@ def _namespace(ref: ImageRef) -> str:
|
|
|
423
575
|
|
|
424
576
|
|
|
425
577
|
class ImageWatcher:
|
|
426
|
-
"""Polls each image's tags. On first sight every tag is the baseline, and the provenance of the watched floating tags
|
|
427
|
-
and the newest version tags seeds what the repository is known to be built from and by. After that a new tag (cosign's
|
|
428
|
-
`.sig`/`.att`/`.sbom` tags aside, at most `_MAX_NEW_TAGS` a poll), a watched floating tag that points at another image,
|
|
429
|
-
and one of the newest version tags re-pushed, are judged; an image judged clean adds what built it to what is known."""
|
|
578
|
+
"""Polls each image's tags. On first sight every tag is the baseline, and the provenance of the watched floating tags and the newest version tags seeds what the repository is known to be built from and by. After that a new tag (cosign's `.sig`/`.att`/`.sbom` tags aside, at most `_MAX_NEW_TAGS` a poll), a watched floating tag that points at another image, and one of the newest version tags re-pushed, are judged; an image judged clean adds what built it to what is known."""
|
|
430
579
|
|
|
431
580
|
def __init__(self, images: list[str], *, state_dir: str, out_dir: str, floating: tuple[str, ...] = ("latest",),
|
|
432
581
|
fetch: Fetch | None = None, emit: bool = True, poster=None, config=None,
|
|
@@ -511,7 +660,7 @@ class ImageWatcher:
|
|
|
511
660
|
fresh = sorted((t for t in tags if t not in known or t in pending), key=_version_key, reverse=True)[:_MAX_NEW_TAGS]
|
|
512
661
|
results = []
|
|
513
662
|
for tag in fresh: # newest first: junk tags cannot hold a new release back
|
|
514
|
-
result = self.judge(ref, tag, record=True)
|
|
663
|
+
result = self.judge(ref, tag, record=True, previous=known.get(tag) or self._predecessor(tag, known))
|
|
515
664
|
if result.get("rate_limited"):
|
|
516
665
|
return results
|
|
517
666
|
results.append(result)
|
|
@@ -521,13 +670,13 @@ class ImageWatcher:
|
|
|
521
670
|
continue
|
|
522
671
|
digest = self.registry.digest(ref, tag)
|
|
523
672
|
if known.get(tag) != digest:
|
|
524
|
-
results.append(self.judge(ref, tag, record=True, digest=digest
|
|
673
|
+
results.append(self.judge(ref, tag, record=True, digest=digest,
|
|
674
|
+
previous=known.get(tag) or self._predecessor(tag, known)))
|
|
525
675
|
return results
|
|
526
676
|
|
|
527
|
-
def judge(self, ref: ImageRef, tag: str, *, record: bool = False, digest: str | None = None
|
|
528
|
-
|
|
529
|
-
its evidence reaches the floor), and record it; a comparison that fails is retried, then screened as not compared
|
|
530
|
-
(`release-not-fully-read`, which alerts). Never raises."""
|
|
677
|
+
def judge(self, ref: ImageRef, tag: str, *, record: bool = False, digest: str | None = None,
|
|
678
|
+
previous: str | None = None) -> dict:
|
|
679
|
+
"""Judge the image a tag names against what the repository is known to use, screen it (the early SIEM alert when its evidence reaches the floor), and record it; a comparison that fails is retried, then screened as not compared (`release-not-fully-read`, which alerts). Never raises."""
|
|
531
680
|
from .cli import screen
|
|
532
681
|
|
|
533
682
|
stamp = self._stamp()
|
|
@@ -536,9 +685,14 @@ class ImageWatcher:
|
|
|
536
685
|
digest, facts, notes, index = image_provenance(self.registry, ref, digest or tag)
|
|
537
686
|
image = {"image": ref.label, "tag": tag, "digest": digest, "index": index, "provenance": facts, "known": known,
|
|
538
687
|
"namespace": _namespace(ref), "notes": notes[:50]}
|
|
688
|
+
event = self._event(ref, tag, image)
|
|
689
|
+
if previous and previous != digest: # the files the image changes against the one it replaces
|
|
690
|
+
files, unread, layer_notes, config_facts = layer_change(self.registry, ref, digest, previous)
|
|
691
|
+
event["files"] = files
|
|
692
|
+
image.update(config_facts)
|
|
693
|
+
event["release"].update({"previous": previous, "unread_code": unread, "notes": layer_notes})
|
|
539
694
|
run_id = image_run_id(ref, tag, digest)
|
|
540
|
-
art = screen(
|
|
541
|
-
emit=self.emit, poster=self.poster)
|
|
695
|
+
art = screen(event, run_id=run_id, outdir=self.out_dir, config=self.config, emit=self.emit, poster=self.poster)
|
|
542
696
|
except RateLimited as exc: # not a failure of the image: the next poll tries again
|
|
543
697
|
log.warning("image %s:%s: %s", ref.label, tag, exc)
|
|
544
698
|
return {"image": ref.label, "tag": tag, "rate_limited": True}
|
|
@@ -557,6 +711,14 @@ class ImageWatcher:
|
|
|
557
711
|
"floor": resp.floor_indicator_severity, "path": art.path,
|
|
558
712
|
"indicators": [(h.rule, h.severity, h.location) for h in resp.indicators]}
|
|
559
713
|
|
|
714
|
+
def _predecessor(self, tag: str, known: dict[str, str | None]) -> str | None:
|
|
715
|
+
"""The image a new tag is compared with: the newest release tag below it (by the version numbers it starts with) whose digest is known, or the newest such release when the tag carries no version; without one, a watched floating tag's image (a repository tagged only `latest` and `sha-…`)."""
|
|
716
|
+
releases = [t for t in _releases([t for t, d in known.items() if d]) if t != tag]
|
|
717
|
+
below = [t for t in releases if _version_key(t) < _version_key(tag)] if _VERSION_NUMBERS_RE.match(tag) else releases
|
|
718
|
+
if below:
|
|
719
|
+
return known[below[-1]]
|
|
720
|
+
return next((known[t] for t in self.floating if known.get(t) and t != tag), None)
|
|
721
|
+
|
|
560
722
|
@staticmethod
|
|
561
723
|
def _event(ref: ImageRef, tag: str, image: dict) -> dict:
|
|
562
724
|
return {"repo": f"oci/{ref.label}", "kind": "release", "number": None, "author": "", "body": "", "files": [],
|