diff-sentry 0.12.0__tar.gz → 0.13.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/.claude/rules/monitoring.md +7 -0
  2. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/.env.example +1 -0
  3. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/AGENTS.md +3 -3
  4. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/CHANGELOG.md +13 -0
  5. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/PKG-INFO +4 -4
  6. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/README.md +3 -3
  7. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/action.yml +1 -1
  8. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/diff_sentry/__init__.py +1 -1
  9. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/diff_sentry/cli.py +21 -7
  10. diff_sentry-0.13.0/diff_sentry/npm.py +337 -0
  11. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/diff_sentry/releases.py +80 -35
  12. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/docs/release-watch.md +20 -2
  13. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/pyproject.toml +1 -1
  14. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/test_adversarial_input.py +1 -1
  15. diff_sentry-0.13.0/tests/test_npm.py +413 -0
  16. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/uv.lock +1 -1
  17. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/.claude/rules/detect-setup.md +0 -0
  18. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/.claude/rules/handoff.md +0 -0
  19. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/.claude/rules/indicators.md +0 -0
  20. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/.claude/rules/rubric-and-eval.md +0 -0
  21. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/.claude/skills/release/SKILL.md +0 -0
  22. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/.github/workflows/ci.yml +0 -0
  23. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/.github/workflows/release.yml +0 -0
  24. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/.gitignore +0 -0
  25. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/.python-version +0 -0
  26. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/LICENSE +0 -0
  27. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/VENDOR.md +0 -0
  28. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/diff_sentry/__main__.py +0 -0
  29. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/diff_sentry/actors.py +0 -0
  30. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/diff_sentry/assemble.py +0 -0
  31. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/diff_sentry/config.py +0 -0
  32. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/diff_sentry/dbutil.py +0 -0
  33. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/diff_sentry/deep_classify.py +0 -0
  34. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/diff_sentry/detect.py +0 -0
  35. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/diff_sentry/emit.py +0 -0
  36. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/diff_sentry/fetch_tool.py +0 -0
  37. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/diff_sentry/hosts.py +0 -0
  38. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/diff_sentry/images.py +0 -0
  39. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/diff_sentry/indicators.py +0 -0
  40. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/diff_sentry/ingest.py +0 -0
  41. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/diff_sentry/linewide.py +0 -0
  42. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/diff_sentry/normalize.py +0 -0
  43. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/diff_sentry/response.py +0 -0
  44. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/diff_sentry/rl_export.py +0 -0
  45. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/diff_sentry/rubric.py +0 -0
  46. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/diff_sentry/schema.py +0 -0
  47. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/diff_sentry/shelllex.py +0 -0
  48. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/diff_sentry/skills/ci-and-workflows.md +0 -0
  49. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/diff_sentry/skills/concealment-and-provenance.md +0 -0
  50. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/diff_sentry/skills/developer-autorun.md +0 -0
  51. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/diff_sentry/skills/llm-directed-text.md +0 -0
  52. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/diff_sentry/skills/package-and-build-hooks.md +0 -0
  53. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/diff_sentry/skills/runtime-droppers-and-exfil.md +0 -0
  54. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/diff_sentry/skills/triage-a-change.md +0 -0
  55. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/diff_sentry/snapshot.py +0 -0
  56. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/diff_sentry/watch.py +0 -0
  57. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/docs/development.md +0 -0
  58. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/docs/how-it-works.md +0 -0
  59. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/docs/limits.md +0 -0
  60. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/docs/watch.md +0 -0
  61. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/eval/README.md +0 -0
  62. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/eval/diff_sentry_eval/__init__.py +0 -0
  63. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/eval/diff_sentry_eval/__main__.py +0 -0
  64. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/eval/diff_sentry_eval/cli.py +0 -0
  65. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/eval/diff_sentry_eval/judge.py +0 -0
  66. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/eval/diff_sentry_eval/schema.py +0 -0
  67. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/eval/diff_sentry_eval/score.py +0 -0
  68. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/eval/diff_sentry_eval/taskset.py +0 -0
  69. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/eval/pyproject.toml +0 -0
  70. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/eval/taskset.example.json +0 -0
  71. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/eval/tests/conftest.py +0 -0
  72. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/eval/tests/test_boundary.py +0 -0
  73. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/eval/tests/test_cli.py +0 -0
  74. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/eval/tests/test_judge.py +0 -0
  75. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/eval/tests/test_schema.py +0 -0
  76. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/eval/tests/test_score.py +0 -0
  77. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/eval/tests/test_taskset.py +0 -0
  78. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/studio/DESIGN.md +0 -0
  79. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/studio/README.md +0 -0
  80. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/studio/diff_sentry_studio/__init__.py +0 -0
  81. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/studio/diff_sentry_studio/app.py +0 -0
  82. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/studio/diff_sentry_studio/iterations.py +0 -0
  83. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/studio/diff_sentry_studio/live.py +0 -0
  84. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/studio/diff_sentry_studio/mapper.py +0 -0
  85. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/studio/pyproject.toml +0 -0
  86. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/studio/static/app.js +0 -0
  87. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/studio/static/index.html +0 -0
  88. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/studio/static/replay-core.js +0 -0
  89. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/studio/static/run-core.js +0 -0
  90. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/studio/static/style.css +0 -0
  91. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/studio/static/trajectory.js +0 -0
  92. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/studio/static/vendor/fonts/jetbrains-mono-400.woff2 +0 -0
  93. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/studio/static/vendor/fonts/jetbrains-mono-700.woff2 +0 -0
  94. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/studio/tests/replay-core.test.js +0 -0
  95. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/studio/tests/run-core.test.js +0 -0
  96. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/studio/tests/static-contract.test.js +0 -0
  97. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/studio/tests/test_app.py +0 -0
  98. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/studio/tests/test_iterations.py +0 -0
  99. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/studio/tests/test_live.py +0 -0
  100. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/studio/tests/test_mapper.py +0 -0
  101. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/studio/tests/trajectory.test.js +0 -0
  102. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/__init__.py +0 -0
  103. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/conftest.py +0 -0
  104. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/corpus/change_corpus.json +0 -0
  105. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/corpus/hackerbot_claw_campaign.json +0 -0
  106. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/corpus/hackerbot_claw_incident.json +0 -0
  107. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/fixtures/known-malicious.diff +0 -0
  108. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/oracle/__init__.py +0 -0
  109. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/oracle/grammar.py +0 -0
  110. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/oracle/harness.py +0 -0
  111. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/oracle/lexrunner.py +0 -0
  112. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/oracle/programs.py +0 -0
  113. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/oracle/runner.py +0 -0
  114. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/test_actors.py +0 -0
  115. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/test_assemble.py +0 -0
  116. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/test_campaign_replay.py +0 -0
  117. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/test_cli.py +0 -0
  118. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/test_cli_watch.py +0 -0
  119. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/test_config.py +0 -0
  120. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/test_deep_classify.py +0 -0
  121. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/test_detect.py +0 -0
  122. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/test_detection_quality.py +0 -0
  123. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/test_dogfood_gate.py +0 -0
  124. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/test_emit.py +0 -0
  125. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/test_enclosure.py +0 -0
  126. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/test_export.py +0 -0
  127. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/test_fetch_tool.py +0 -0
  128. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/test_file_scope.py +0 -0
  129. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/test_file_structure.py +0 -0
  130. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/test_forward_offline.py +0 -0
  131. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/test_gate_edges.py +0 -0
  132. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/test_grounding.py +0 -0
  133. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/test_hosts.py +0 -0
  134. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/test_images.py +0 -0
  135. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/test_incident_hackerbot_claw.py +0 -0
  136. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/test_indicators.py +0 -0
  137. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/test_ingest.py +0 -0
  138. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/test_ingest_metadata.py +0 -0
  139. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/test_injection_offline.py +0 -0
  140. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/test_inline_exec_and_bidi.py +0 -0
  141. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/test_linewide.py +0 -0
  142. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/test_lookalike_owner.py +0 -0
  143. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/test_metadata_rules.py +0 -0
  144. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/test_normalize.py +0 -0
  145. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/test_oracle.py +0 -0
  146. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/test_oracle_harness.py +0 -0
  147. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/test_provenance.py +0 -0
  148. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/test_python_loader.py +0 -0
  149. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/test_releases.py +0 -0
  150. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/test_response.py +0 -0
  151. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/test_rubric.py +0 -0
  152. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/test_schema.py +0 -0
  153. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/test_shelllex.py +0 -0
  154. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/test_snapshot.py +0 -0
  155. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/test_subscription.py +0 -0
  156. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/test_watch.py +0 -0
  157. {diff_sentry-0.12.0 → diff_sentry-0.13.0}/tests/test_watch_pool.py +0 -0
@@ -54,6 +54,13 @@ 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
66
  - **Provenance only, never a layer.** `image_provenance` reads an image's index, each BuildKit attestation manifest and its SLSA provenance layer; `provenance_facts` keeps the source repository (`vcs:source`; `owner/name` only for a URL whose host is exactly github.com, any other source as written, so a lookalike is only judged between GitHub repositories), revision, building repository, workflow without its ref, ref and ref protection. An attestation counts only when its `vnd.docker.reference.digest` is a platform image of the same index and the provenance's `subject` names that image (BuildKit always writes one); a platform image no attestation names is a note. A layer's content is a known gap.
@@ -79,6 +79,7 @@ DS_API_KEY=sk-...
79
79
  # DS_RELEASE_PACKAGES=litellm,requests # release-watch: PyPI projects (or --packages / --packages-file)
80
80
  # DS_RELEASE_INTERVAL=300 # release-watch: seconds between polls of each project's index
81
81
  # DS_RELEASE_MAX_ARTIFACT_MB=256 # release-watch: a larger wheel or sdist is not compared, which alerts
82
+ # DS_RELEASE_NPM=@scope/name,left-pad # release-watch: npm packages (or --npm)
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)
84
85
  # DS_RELEASE_IMAGE_TAGS=latest # release-watch: floating tags whose moves are judged (or --image-tags)
@@ -31,10 +31,10 @@ rlm-harness is an exact PyPI pin (`rlm-harness==X.Y.Z` in `pyproject.toml`, lock
31
31
  - **The budget is hard: `max_retries=1`, no whole-RLM retry inside a run.** One change = one trajectory, so the trace stays valid training data. (`watch` may run a change again, but only when the previous attempt produced no result at all, an infrastructure failure; `cli.run` resets that run id's trace first, so exactly one trajectory per run id survives.) A failed run is infra before it is a schema bug; see `.claude/rules/detect-setup.md` for how the failure names itself.
32
32
  - **A second model-judgement is a TOOL, never the sub-LM.** `deep_classify` (on rlm-harness's `make_model_tool`) is the swappable second-stage seam the planner chooses to call, so the decision is a `tool_call` in the trajectory. The analyst intercept (`intercept_sub_lm`) is tracing-only, zero transforms. Swapping the backend touches only `classify_backend` / `_selfclassify_chat`. The planner passes distilled findings, never the whole diff.
33
33
  - **Sandboxed code builds no sub-agents.** In dspy 3.4.1 and later, sandboxed code can construct dspy programs that run on the host, call the analyst, can drive the task's tools, and record as one `sub_call` with no inner turns. The kit's default allows it, so `setup` passes `sandbox_sub_agents=False` explicitly, and production never injects an interpreter (which would bypass it); `test_program_offers_no_sub_agents` holds the behaviour.
34
- - **GitHub ingest, monitoring, and SIEM emission are host-side plumbing, never planner tools.** `ingest.py` shells out to `gh` host-side (transport injectable); `emit.emit_signal` POSTs and never raises; `watch`, `actors` and `snapshot` poll, remember, and archive outside the trajectory, `releases` reads PyPI's own hosts, and the refs and trees of a package's GitHub repository, as data that is never installed, and `images` reads the GHCR and Docker Hub APIs only (manifests and provenance, never a layer). Only choices the policy makes are tools; delivering a finished result is plumbing. What the store learns reaches the signal only as provenance facts through `scan_provenance`, never as a second derivation (see `.claude/rules/monitoring.md`).
34
+ - **GitHub ingest, monitoring, and SIEM emission are host-side plumbing, never planner tools.** `ingest.py` shells out to `gh` host-side (transport injectable); `emit.emit_signal` POSTs and never raises; `watch`, `actors` and `snapshot` poll, remember, and archive outside the trajectory, `releases` and `npm` read PyPI's and npm's own hosts, and the refs and trees of a package's GitHub repository, as data that is never installed, and `images` reads the GHCR and Docker Hub APIs only (manifests and provenance, never a layer). Only choices the policy makes are tools; delivering a finished result is plumbing. What the store learns reaches the signal only as provenance facts through `scan_provenance`, never as a second derivation (see `.claude/rules/monitoring.md`).
35
35
  - **This is a ROLLOUT source: trajectories, not reward.** `rl_export` passes `reward=None`; labels read the assembled verdict and metrics are objective effort counters. `deep_classify` tool_calls train the classifier; every other action is the planner's. A metric keeps ONE meaning across rlm-harness versions (the corpus mixes them; `run_start.payload.rlm_harness` names the writer): a quantity a newer kit makes observable gets a NEW key that is None where the trace cannot say (e.g. `analyst_failures`). The ATLAS rubric and the eval member stay reward-free too; see `.claude/rules/rubric-and-eval.md`.
36
36
  - **Attack knowledge lives in `diff_sentry/skills/`, not the prompt.** The skill catalog is injected (`load_skills_as_tools(discovery="inject")`) and `read_skill(name)` pulls a body on demand. Fix a wrong convention by editing or adding a skill; `detect.INSTRUCTIONS` holds only identity, the MISSION frame, the tool cost model, the triage loop, and output-gating rules. Skills ship via `packages = ["diff_sentry"]`; a force-include duplicates them and breaks the build.
37
- - **Keep the dspy-free modules dspy-free.** `config`, `schema`, `normalize`, `indicators`, `shelllex`, `linewide`, `assemble`, `response`, `emit`, `ingest`, `hosts`, `dbutil`, `actors`, `snapshot`, `watch`, `releases`, `images`, `rl_export`, and `rubric` must not import dspy at module top, and `import diff_sentry` must not import dspy (`ClassifyChange` / `setup` / `run` / `screen` / `detect_from_event` are lazy PEP 562 re-exports).
37
+ - **Keep the dspy-free modules dspy-free.** `config`, `schema`, `normalize`, `indicators`, `shelllex`, `linewide`, `assemble`, `response`, `emit`, `ingest`, `hosts`, `dbutil`, `actors`, `snapshot`, `watch`, `releases`, `npm`, `images`, `rl_export`, and `rubric` must not import dspy at module top, and `import diff_sentry` must not import dspy (`ClassifyChange` / `setup` / `run` / `screen` / `detect_from_event` are lazy PEP 562 re-exports).
38
38
  - **The trace self-describes the run.** `run_start` meta carries the normalized event, instructions, source echo, `baseline_indicators`, `emit_on`, role→model names, budgets, and the rubric skeleton, so an offline re-render/export re-derives the same `signal` (reading `emit_on` from meta, never current config). Any new per-run config that affects read-time derivation must ride in meta.
39
39
 
40
40
  ## Topic guides
@@ -54,5 +54,5 @@ Claude Code loads each rule automatically when you touch a matching file; other
54
54
 
55
55
  - rlm-harness owns the generic cores: `make_model_tool` (chat → retry → validate → circuit-break) under `deep_classify`, and the SSRF primitives (`is_safe_url`, `resolved_host_is_safe`, `parse_cidrs`) under `fetch_tool`, which adds the GitHub allowlist, the httpx provider, and tracing.
56
56
  - When this consumer needs a workaround, fix the reusable gap in rlm-harness generically; never special-case diff-sentry there. Consumer values (`DS_*` roles, the verdict schema, indicator rules, the SIEM payload shape) stay here.
57
- - The deterministic tier ships as `cli.screen`, `watch` and `release-watch` (host-side, in front of `cli.run`). Next increments: an image's new layers by content, and npm releases beside PyPI's; an optional small single-shot model call inside that tier; a GH Archive backfill for account history older than the Events API's 30 days; per-repository workflow awareness (which repos run `issue_comment` or `pull_request_target` workflows) to weight the slash-command rule.
57
+ - The deterministic tier ships as `cli.screen`, `watch` and `release-watch` (host-side, in front of `cli.run`). Next increments: an image's new layers by content; an optional small single-shot model call inside that tier; a GH Archive backfill for account history older than the Events API's 30 days; per-repository workflow awareness (which repos run `issue_comment` or `pull_request_target` workflows) to weight the slash-command rule.
58
58
  - Two workspace members, never in the `diff_sentry` wheel: `studio/` (`diff-sentry-studio`, the detection console, behind its `live` extra) and `eval/` (`diff-sentry-eval`, the reward-free scorecard). Both read the trace / `DetectionResponse` contract through the public surface only. Keep the root wheel at `packages = ["diff_sentry"]` so `uv build` never sweeps them in.
@@ -2,6 +2,19 @@
2
2
 
3
3
  All notable changes to diff-sentry, a BewAIre-style detector that classifies one GitHub change (PR, issue or push) for malicious intent on [`rlm-harness`](https://github.com/qazbnm456/rlm-harness).
4
4
 
5
+ ## 0.13.0
6
+
7
+ Upgrade from 0.12.0: `release-watch` can watch npm packages. The GitHub Action's scan is unchanged, nothing needs configuring, and `rlm-harness==1.24.0` is unchanged.
8
+
9
+ ### Added
10
+ - **`release-watch --npm` watches npm packages** (`DS_RELEASE_NPM`, or `--check npm:NAME VERSION` for one version). Each new version is compared with the version before it in semver order, as a PyPI release is: the tarball is read from `registry.npmjs.org` as data, checked against the registry's sha512, and every new or changed file is screened by the same rules. The npm worms of 2025 ran their payload from an install script, and a compromised version can also depend on a package published hours before, so it adds what only npm shows:
11
+ - `npm-install-script-added` (`high`): a `preinstall`, `install` or `postinstall` script, or the `node-gyp rebuild` a `binding.gyp` implies, that the previous version did not run, in the package or in a dependency it bundles. A script that changes command is `medium`.
12
+ - `npm-dependency-source` (`high`): a dependency newly installed from a git repository, a URL, a path or an `npm:` alias.
13
+ - `npm-dependency-runs-at-install` and `npm-dependency-young` (`high`): a dependency newly taken from the registry whose versions run a script at install, or that was created within 30 days of the version or is not on the registry.
14
+ - The publisher checks PyPI releases have, from the SLSA provenance the registry holds, which must name the tarball's own digest.
15
+
16
+ The manifests are read as npm extracts them, and one that cannot be read alerts. See [npm packages](docs/release-watch.md#npm-packages).
17
+
5
18
  ## 0.12.0
6
19
 
7
20
  Upgrade from 0.11.0: `release-watch` can compare each release with its git tag, and `rlm-harness` moves to 1.24.0. The GitHub Action's scan is unchanged, and nothing needs configuring.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: diff-sentry
3
- Version: 0.12.0
3
+ Version: 0.13.0
4
4
  Summary: Catch malicious pull requests before they merge: scan a diff for supply-chain attack patterns with deterministic evidence a model cannot suppress.
5
5
  Author: Boik Su
6
6
  License-Expression: MIT
@@ -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.12.0
46
+ - uses: qazbnm456/diff-sentry@v0.13.0
47
47
  ```
48
48
 
49
49
  That is the whole setup. The job fails when an indicator at or above `high` fires. Add `push` to `on:` to also scan commits pushed straight to a branch. A push that bypasses branch protection can also delete this workflow, so `watch` and `release-watch` read the repository and its releases from outside.
@@ -96,7 +96,7 @@ If you operate the infrastructure, or maintain a project where a wrong call is e
96
96
  - **Deep classification**: a reasoning model reads a PR, issue or push in a sandbox and returns a verdict next to the evidence. See [How it works](docs/how-it-works.md).
97
97
  - **The studio**: a local console that streams a run as it happens. See [`studio/README.md`](studio/README.md).
98
98
  - **Watch**: monitors a list of repositories, alerts the moment the rules fire, and remembers the accounts and payload hosts it has seen. See [Watching a watchlist](docs/watch.md).
99
- - **Release watch**: compares each new release of the PyPI packages you depend on with the release before it, and alerts when the new wheels carry a startup hook or a packed loader that the source never showed. See [Watching releases](docs/release-watch.md).
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 the one before it.
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.12.0
24
+ - uses: qazbnm456/diff-sentry@v0.13.0
25
25
  ```
26
26
 
27
27
  That is the whole setup. The job fails when an indicator at or above `high` fires. Add `push` to `on:` to also scan commits pushed straight to a branch. A push that bypasses branch protection can also delete this workflow, so `watch` and `release-watch` read the repository and its releases from outside.
@@ -74,7 +74,7 @@ If you operate the infrastructure, or maintain a project where a wrong call is e
74
74
  - **Deep classification**: a reasoning model reads a PR, issue or push in a sandbox and returns a verdict next to the evidence. See [How it works](docs/how-it-works.md).
75
75
  - **The studio**: a local console that streams a run as it happens. See [`studio/README.md`](studio/README.md).
76
76
  - **Watch**: monitors a list of repositories, alerts the moment the rules fire, and remembers the accounts and payload hosts it has seen. See [Watching a watchlist](docs/watch.md).
77
- - **Release watch**: compares each new release of the PyPI packages you depend on with the release before it, and alerts when the new wheels carry a startup hook or a packed loader that the source never showed. See [Watching releases](docs/release-watch.md).
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 the one before it.
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.12.0'
43
+ default: '0.13.0'
44
44
 
45
45
  outputs:
46
46
  failed:
@@ -72,7 +72,7 @@ __all__ = [
72
72
  "screen",
73
73
  ]
74
74
 
75
- __version__ = "0.12.0"
75
+ __version__ = "0.13.0"
76
76
 
77
77
 
78
78
  def __getattr__(name: str): # PEP 562: defer the dspy import to first use
@@ -337,6 +337,7 @@ def _cmd_release_watch(args) -> int:
337
337
  import signal
338
338
 
339
339
  from .images import ImageWatcher
340
+ from .npm import NpmWatcher, parse_npm_packages
340
341
  from .releases import ReleaseWatcher, parse_packages, parse_sources
341
342
 
342
343
  logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
@@ -349,8 +350,11 @@ def _cmd_release_watch(args) -> int:
349
350
  with open(args.packages_file, encoding="utf-8") as fh:
350
351
  texts.append(fh.read())
351
352
  packages = parse_packages(texts)
353
+ npm = parse_npm_packages([*args.npm, os.getenv("DS_RELEASE_NPM", "")])
354
+ check_npm = bool(args.check) and args.check[0].startswith("npm:")
352
355
  if args.check:
353
- packages = parse_packages([args.check[0]])
356
+ packages = [] if check_npm else parse_packages([args.check[0]])
357
+ npm = parse_npm_packages([args.check[0][4:]]) if check_npm else []
354
358
  images = [i for text in [*args.images, os.getenv("DS_RELEASE_IMAGES", "")] for i in text.replace(",", " ").split()]
355
359
  floating = tuple(args.image_tags or os.getenv("DS_RELEASE_IMAGE_TAGS", "latest").replace(",", " ").split())
356
360
  image_watcher = ImageWatcher([] if args.check else images, state_dir=args.state, out_dir=args.out,
@@ -362,20 +366,26 @@ def _cmd_release_watch(args) -> int:
362
366
  except BaseException:
363
367
  image_watcher.close()
364
368
  raise
369
+ try:
370
+ npm_watcher = NpmWatcher(npm, state_dir=args.state, out_dir=args.out, emit=not args.no_emit)
371
+ except BaseException:
372
+ image_watcher.close()
373
+ watcher.close()
374
+ raise
365
375
  except (OSError, ValueError, RuntimeError) as exc:
366
376
  print(f"release-watch: {exc}", file=sys.stderr)
367
377
  return 2
368
378
  try:
369
379
  if args.check:
370
- result = watcher.check(packages[0], args.check[1])
380
+ result = npm_watcher.check(npm[0], args.check[1]) if check_npm else watcher.check(packages[0], args.check[1])
371
381
  print(json.dumps(result, indent=2, default=str))
372
382
  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)
383
+ if not packages and not npm and not images:
384
+ print("release-watch: nothing to watch (--packages, --packages-file, --npm, --images, or DS_RELEASE_PACKAGES / "
385
+ "DS_RELEASE_NPM / DS_RELEASE_IMAGES)", file=sys.stderr)
376
386
  return 2
377
387
  if args.once:
378
- for result in watcher.poll_once() + image_watcher.poll_once():
388
+ for result in watcher.poll_once() + npm_watcher.poll_once() + image_watcher.poll_once():
379
389
  print(json.dumps(result, default=str))
380
390
  return 0
381
391
  stopping = []
@@ -384,6 +394,7 @@ def _cmd_release_watch(args) -> int:
384
394
  while not stopping:
385
395
  started = time.monotonic()
386
396
  watcher.poll_once()
397
+ npm_watcher.poll_once()
387
398
  image_watcher.poll_once()
388
399
  while not stopping and time.monotonic() - started < args.interval:
389
400
  time.sleep(min(1.0, args.interval))
@@ -392,6 +403,7 @@ def _cmd_release_watch(args) -> int:
392
403
  return 0
393
404
  finally:
394
405
  watcher.close()
406
+ npm_watcher.close()
395
407
  image_watcher.close()
396
408
 
397
409
 
@@ -657,6 +669,7 @@ def build_parser() -> argparse.ArgumentParser:
657
669
  help="compare each new release of watched PyPI packages with the one before it (no model)")
658
670
  rw.add_argument("--packages", nargs="+", default=[], help="PyPI project names (or set DS_RELEASE_PACKAGES)")
659
671
  rw.add_argument("--packages-file", help="a file of project names, one per line (# comments allowed)")
672
+ rw.add_argument("--npm", nargs="+", default=[], help="npm package names, @scope/name included (or set DS_RELEASE_NPM)")
660
673
  rw.add_argument("--sources", nargs="+", default=[], metavar="PACKAGE=OWNER/NAME",
661
674
  help="the GitHub repository each package is built from, to compare each release with its tag "
662
675
  "(or set DS_RELEASE_SOURCES; default: the repository of the previous release's Trusted Publisher)")
@@ -673,7 +686,8 @@ def build_parser() -> argparse.ArgumentParser:
673
686
  rw.add_argument("--no-emit", action="store_true", help="do not emit SIEM signals")
674
687
  rw.add_argument("--once", action="store_true", help="one poll, then exit")
675
688
  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")
689
+ help="judge one release against the one before it now, print the result, and exit "
690
+ "(npm:NAME for an npm package)")
677
691
  rw.set_defaults(func=_cmd_release_watch)
678
692
 
679
693
  ac = sub.add_parser("actor", help="the dossier of an account the watcher has seen (offline)")
@@ -0,0 +1,337 @@
1
+ """npm watch: each new version of a watched npm package, compared with the version before it.
2
+
3
+ The npm worms of 2025 published versions of popular packages with stolen tokens, each with a `preinstall` or `postinstall` script that runs the payload on every install; a version published with a stolen token also lacks the provenance a package's own release workflow attaches. So a version is a release like a PyPI one (`releases.release_event` with `registry="npm"`): its one tarball, read as DATA and never installed, against the previous version's, every new or changed file screened by the same rules, and the facts only npm shows added here. Every version's tarball is checked against the registry's sha512 `integrity`; publishers are read from the registry's SLSA provenance, bound to the tarball by its digest.
4
+
5
+ Host-side plumbing like `releases`: it fetches only from `registry.npmjs.org`, through `releases._httpx_fetch`. Pure stdlib; no dspy.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import io
12
+ import json
13
+ import logging
14
+ import posixpath
15
+ import re
16
+ import tarfile
17
+ from datetime import UTC, datetime
18
+ from urllib.parse import quote, urlsplit
19
+
20
+ from .releases import DownloadError, Fetch, ReleaseFile, ReleaseWatcher
21
+
22
+ log = logging.getLogger(__name__)
23
+
24
+ _PACKUMENT_URL = "https://registry.npmjs.org/{name}"
25
+ _ACCEPT = "application/vnd.npm.install-v1+json" # the abbreviated document npm itself installs from
26
+ _MAX_PACKUMENT_BYTES = 64 << 20
27
+ _MAX_ATTESTATION_BYTES = 4 << 20
28
+ _MAX_MANIFEST_BYTES = 16 << 20 # one package.json
29
+ _SLSA = "https://slsa.dev/provenance/v1"
30
+ _NAME_RE = re.compile(r"(?:@[A-Za-z0-9][A-Za-z0-9._~-]{0,212}/)?[A-Za-z0-9][A-Za-z0-9._~-]{0,212}")
31
+ _SEMVER_RE = re.compile(r"v?([0-9]{1,20})\.([0-9]{1,20})\.([0-9]{1,20})(?:-([0-9A-Za-z.-]{1,200}))?(?:\+[0-9A-Za-z.-]{1,200})?")
32
+ _GITHUB_RE = re.compile(r"https://github\.com/([A-Za-z0-9][A-Za-z0-9-]{0,38}/[A-Za-z0-9._-]{1,100}?)(?:\.git)?/?")
33
+ # the scripts npm runs when a package is installed from the registry (`prepare` runs only for a git or local install)
34
+ _INSTALL_SCRIPTS = ("preinstall", "install", "postinstall")
35
+ _YOUNG_DAYS = 30 # a dependency created this recently is not yet a known package
36
+ _MAX_DEPENDENCY_LOOKUPS = 50
37
+ _MAX_FULL_DOCUMENT_BYTES = 64 << 20 # the full document gives the creation and publish times
38
+ _DEPENDENCY_KEYS = ("dependencies", "optionalDependencies", "peerDependencies") # npm 7 and later installs peers
39
+ # a semver range or a dist-tag never holds `:` or `/`; a git, URL, path, `github:` or `npm:` alias spec does
40
+ _OFF_REGISTRY_RE = re.compile(r"[:/\\]")
41
+
42
+
43
+ class _Semver(tuple):
44
+ """A semver version's order: numbers, then a release after its pre-releases, whose identifiers order numbers first."""
45
+
46
+ @property
47
+ def is_prerelease(self) -> bool:
48
+ return not self[3]
49
+
50
+
51
+ def semver_key(version: str) -> _Semver | None:
52
+ m = _SEMVER_RE.fullmatch(version)
53
+ if not m:
54
+ return None
55
+ pre = m.group(4)
56
+ ids = tuple((0, int(x), "") if x.isdigit() else (1, 0, x) for x in pre.split(".")) if pre else ()
57
+ return _Semver((int(m.group(1)), int(m.group(2)), int(m.group(3)), pre is None, ids))
58
+
59
+
60
+ def parse_npm_packages(texts: list[str]) -> list[str]:
61
+ """npm package names (`left-pad`, `@scope/name`) from arguments and files: whitespace, comma or newline separated, `#` comments allowed."""
62
+ out: list[str] = []
63
+ for text in texts:
64
+ for line in text.splitlines() or [text]:
65
+ for word in line.split("#", 1)[0].replace(",", " ").split():
66
+ if len(word) > 214 or not _NAME_RE.fullmatch(word):
67
+ raise ValueError(f"not an npm package name: {word!r}")
68
+ if word not in out:
69
+ out.append(word)
70
+ return out
71
+
72
+
73
+ def parse_packument(body: bytes) -> list[ReleaseFile]:
74
+ """Each version of an abbreviated packument as its one tarball, with the registry's sha512 and whether the registry holds its provenance. A tarball served from another host is left out: only the registry's are read."""
75
+ data = json.loads(body.decode("utf-8"))
76
+ out: list[ReleaseFile] = []
77
+ for version, meta in (data.get("versions") or {}).items():
78
+ dist = meta.get("dist") if isinstance(meta, dict) else None
79
+ if not isinstance(dist, dict) or semver_key(str(version)) is None:
80
+ continue
81
+ url = str(dist.get("tarball") or "")
82
+ if urlsplit(url).scheme != "https" or urlsplit(url).hostname != "registry.npmjs.org":
83
+ continue
84
+ integrity = str(dist.get("integrity") or "")
85
+ attestations = dist.get("attestations") if isinstance(dist.get("attestations"), dict) else {}
86
+ out.append(ReleaseFile(
87
+ filename=f"{posixpath.basename(urlsplit(url).path)}", url=url, version=str(version), kind="npm", size=0,
88
+ sha256="", upload_time="", yanked=False, attested=bool((attestations or {}).get("provenance")),
89
+ provenance=str(attestations.get("url") or "") if attestations else "",
90
+ sha512=integrity[len("sha512-"):] if integrity.startswith("sha512-") else ""))
91
+ return out
92
+
93
+
94
+ def npm_publishers(fetch: Fetch, f: ReleaseFile, notes: list[str]) -> set[str]:
95
+ """The publisher a version's SLSA provenance names, as `releases.publisher_identity` would write it (`github:owner/name:.github/workflows/x.yml::`). The statement must name the tarball's own sha512, or it is a note."""
96
+ try:
97
+ status, _, body = fetch(f.provenance, {"Accept": "application/json"}, _MAX_ATTESTATION_BYTES)
98
+ except ValueError as exc:
99
+ notes.append(f"{f.filename}: its provenance was not read ({str(exc)[:120]})")
100
+ return set()
101
+ if status == 404:
102
+ return set()
103
+ if status != 200:
104
+ raise DownloadError(f"{f.filename}: its provenance answered HTTP {status}")
105
+ found: set[str] = set()
106
+ try:
107
+ for att in json.loads(body.decode("utf-8")).get("attestations") or []:
108
+ if att.get("predicateType") != _SLSA:
109
+ continue
110
+ statement = json.loads(base64.b64decode(att["bundle"]["dsseEnvelope"]["payload"]))
111
+ digests = {str((s.get("digest") or {}).get("sha512") or "") for s in statement.get("subject") or []}
112
+ if not f.sha512 or base64.b64decode(f.sha512).hex() not in digests:
113
+ notes.append(f"{f.filename}: its provenance names another tarball, or the registry gives no sha512 to bind it to")
114
+ continue
115
+ workflow = statement["predicate"]["buildDefinition"]["externalParameters"]["workflow"]
116
+ m = _GITHUB_RE.fullmatch(str(workflow.get("repository") or ""))
117
+ repo = m.group(1).lower() if m else str(workflow.get("repository") or "").lower()
118
+ kind = "github" if m else "other"
119
+ found.add(":".join(str(x).replace(":", "_") for x in (kind, repo, workflow.get("path") or "", "", "")))
120
+ except (ValueError, TypeError, KeyError, AttributeError):
121
+ notes.append(f"{f.filename}: its provenance could not be read")
122
+ return found
123
+
124
+
125
+ def tarball_manifests(filename: str, data: bytes) -> tuple[dict[str, dict], set[str], list[str]]:
126
+ """Every `package.json` of a tarball as npm reads it, by the directory of its package (`""` the root, `node_modules/helper` a bundled dependency, whose install scripts npm runs too), with the package directories that hold a `binding.gyp`, and notes for what could not be read. As npm extracts: 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. Read from the tarball itself, never through the screen's text budget, and an unreadable manifest is a note (`release-not-fully-read`), never a package without scripts."""
127
+ from .releases import _MAX_MEMBERS, _MAX_READ_BYTES, _Capped, _decompressed
128
+
129
+ raw: dict[str, bytes | None] = {}
130
+ gyp: set[str] = set()
131
+ notes: list[str] = []
132
+ try:
133
+ stream = io.BufferedReader(_Capped(_decompressed(data), _MAX_READ_BYTES))
134
+ with tarfile.open(fileobj=stream, mode="r|") as tf:
135
+ for count, info in enumerate(tf, 1):
136
+ if count > _MAX_MEMBERS:
137
+ notes.append(f"{filename}: more than {_MAX_MEMBERS} members, its manifests not read past them")
138
+ break
139
+ parts = [p for p in info.name.split("/") if p not in ("", ".")]
140
+ if len(parts) < 2 or ".." in parts: # npm refuses a path that climbs out
141
+ continue
142
+ path = "/".join(parts[1:])
143
+ folder, base = posixpath.dirname(path), posixpath.basename(path).lower()
144
+ if not _package_dir(folder):
145
+ continue
146
+ if base == "binding.gyp": # npm on a case-insensitive file system finds `Binding.gyp` too
147
+ gyp.add(folder)
148
+ if base != "package.json":
149
+ continue
150
+ if not info.isfile() or info.size > _MAX_MANIFEST_BYTES:
151
+ raw[path] = None
152
+ continue
153
+ fh = tf.extractfile(info)
154
+ raw[path] = fh.read() if fh is not None else None
155
+ except Exception as exc: # noqa: BLE001 (fail CLOSED: a tarball whose manifests cannot be read is evidence)
156
+ notes.append(f"{filename}: its manifests were not read to the end ({type(exc).__name__})")
157
+ folded: dict[str, list[str]] = {}
158
+ for path in raw:
159
+ folded.setdefault(path.lower(), []).append(path)
160
+ for paths in folded.values():
161
+ if len(paths) > 1: # which one npm reads depends on the file system: each is read, and the pair alerts
162
+ notes.append(f"{filename}: manifests whose paths differ only in case ({', '.join(sorted(paths))})"[:240])
163
+ manifests: dict[str, dict] = {}
164
+ for path, body in raw.items():
165
+ folder, where = posixpath.dirname(path), path
166
+ key = folder if posixpath.basename(path) == "package.json" else f"{folder} [{posixpath.basename(path)}]".strip()
167
+ if body is None:
168
+ notes.append(f"{filename}: {where} is not a regular file of at most {_MAX_MANIFEST_BYTES >> 20} MiB")
169
+ continue
170
+ try:
171
+ manifest = json.loads(body.decode("utf-8-sig", "replace"))
172
+ except (ValueError, RecursionError):
173
+ notes.append(f"{filename}: {where} could not be read as JSON")
174
+ continue
175
+ if isinstance(manifest, dict):
176
+ manifests[key] = manifest
177
+ else:
178
+ notes.append(f"{filename}: {where} is not a JSON object")
179
+ if "" not in manifests and not notes:
180
+ notes.append(f"{filename}: no package.json")
181
+ return manifests, gyp, notes
182
+
183
+
184
+ def _package_dir(folder: str) -> bool:
185
+ """The root, or a dependency's directory under `node_modules`, at any depth (`node_modules/@s/x/node_modules/y`)."""
186
+ parts = folder.split("/") if folder else []
187
+ i = 0
188
+ while i < len(parts):
189
+ if parts[i] != "node_modules" or i + 1 >= len(parts):
190
+ return False
191
+ i += 3 if parts[i + 1].startswith("@") else 2
192
+ if i > len(parts):
193
+ return False
194
+ return True
195
+
196
+
197
+ def _install_scripts(manifests: dict[str, dict], gyp: set[str]) -> dict[str, str]:
198
+ """The scripts an install from the registry runs, by `<package dir> <script>`: `preinstall`, `install`, `postinstall`, and the `node-gyp rebuild` npm runs for a `binding.gyp` when neither install script is given."""
199
+ found: dict[str, str] = {}
200
+ for folder, manifest in manifests.items():
201
+ scripts = manifest.get("scripts")
202
+ mine = {k: str(v) for k, v in (scripts.items() if isinstance(scripts, dict) else []) if k in _INSTALL_SCRIPTS}
203
+ if folder.split(" [", 1)[0] in gyp and not {"install", "preinstall"} & set(mine):
204
+ mine["install"] = "node-gyp rebuild (implied by binding.gyp)"
205
+ found.update({f"{folder} {k}".strip(): v for k, v in mine.items()})
206
+ return found
207
+
208
+
209
+ def _dependencies(manifest: dict) -> set[str]:
210
+ return {str(name) for key in _DEPENDENCY_KEYS if isinstance(manifest.get(key), dict) for name in manifest[key]}
211
+
212
+
213
+ def _registry_dependencies(manifests: dict[str, dict]) -> set[str]:
214
+ """Each dependency npm takes from the registry, by `<package dir> <name>`, in every manifest (a bundled package's missing dependencies are installed too)."""
215
+ return {f"{folder} {name}".strip() for folder, manifest in manifests.items() for key in _DEPENDENCY_KEYS
216
+ if isinstance(manifest.get(key), dict) for name, spec in manifest[key].items()
217
+ if not (_OFF_REGISTRY_RE.search(str(spec)) or str(spec).strip().startswith((".", "~/")))}
218
+
219
+
220
+ def published_at(fetch: Fetch, package: str, version: str) -> datetime | None:
221
+ """When the registry says `version` of `package` was published (`time[version]` of its full document), so a dependency's age is measured as of the version, not the clock; None when that cannot be read."""
222
+ try:
223
+ status, _, body = fetch(_PACKUMENT_URL.format(name=quote(package, safe="@")), {"Accept": "application/json"},
224
+ _MAX_FULL_DOCUMENT_BYTES)
225
+ return datetime.fromisoformat(str(json.loads(body.decode("utf-8"))["time"][version])) if status == 200 else None
226
+ except (ValueError, KeyError, TypeError, AttributeError):
227
+ return None
228
+
229
+
230
+ def dependency_facts(fetch: Fetch, names: list[str], notes: list[str], now: datetime | None = None) -> dict:
231
+ """What the registry says of each dependency a version newly takes from it: one whose versions run a script at install (`hasInstallScript`), and one created within `_YOUNG_DAYS` before `now` (the version's publish time) or not on the registry at all. A compromised version need not carry the payload itself: it can depend on a package published hours before, whose install script runs it. A full document past its bound is a note, never an established package: an author pads a package's versions as easily as they publish them."""
232
+ now = now or datetime.now(UTC)
233
+ runs, young = [], []
234
+ if len(names) > _MAX_DEPENDENCY_LOOKUPS:
235
+ notes.append(f"{len(names)} new dependencies, the first {_MAX_DEPENDENCY_LOOKUPS} looked up")
236
+ for name in names[:_MAX_DEPENDENCY_LOOKUPS]:
237
+ if not _NAME_RE.fullmatch(name):
238
+ continue
239
+ url = _PACKUMENT_URL.format(name=quote(name, safe="@"))
240
+ status, _, body = fetch(url, {"Accept": _ACCEPT}, _MAX_PACKUMENT_BYTES)
241
+ if status == 404:
242
+ young.append(f"{name} (not on the registry)")
243
+ continue
244
+ if status != 200:
245
+ raise DownloadError(f"the registry's document of {name} answered HTTP {status}")
246
+ try:
247
+ versions = json.loads(body.decode("utf-8")).get("versions") or {}
248
+ scripted = sorted((v for v, meta in versions.items() if isinstance(meta, dict) and meta.get("hasInstallScript")),
249
+ key=lambda v: (semver_key(v) is not None, semver_key(v) or (), v))
250
+ except (ValueError, AttributeError):
251
+ notes.append(f"the registry's document of {name} could not be read")
252
+ continue
253
+ if scripted:
254
+ runs.append(f"{name} ({len(scripted)} of {len(versions)} versions, {scripted[-1]} the last)"[:200])
255
+ try:
256
+ status, _, body = fetch(url, {"Accept": "application/json"}, _MAX_FULL_DOCUMENT_BYTES)
257
+ except ValueError:
258
+ notes.append(f"the registry's full document of {name} is past {_MAX_FULL_DOCUMENT_BYTES >> 20} MiB, so its age was not read")
259
+ continue
260
+ if status != 200:
261
+ raise DownloadError(f"the registry's full document of {name} answered HTTP {status}")
262
+ try:
263
+ created = datetime.fromisoformat(str(json.loads(body.decode("utf-8"))["time"]["created"]))
264
+ except (ValueError, KeyError, TypeError, AttributeError):
265
+ notes.append(f"the registry does not say when {name} was created")
266
+ continue
267
+ if (now - created).days < _YOUNG_DAYS:
268
+ young.append(f"{name} (created {created.date().isoformat()})")
269
+ return {"dependencies_run_at_install": runs, "dependencies_young": young}
270
+
271
+
272
+ def _sources(manifests: dict[str, dict]) -> dict[str, str]:
273
+ """Each dependency npm installs from somewhere other than the registry, by `<package dir> <name>`: a git repository, a URL, a path, or an `npm:` alias to another package. npm fetches that code at install and runs its install scripts (a git dependency's `prepare` too), so a version needs no script of its own to run code."""
274
+ found: dict[str, str] = {}
275
+ for folder, manifest in manifests.items():
276
+ for key in _DEPENDENCY_KEYS:
277
+ deps = manifest.get(key)
278
+ for name, spec in (deps.items() if isinstance(deps, dict) else []):
279
+ spec = str(spec).strip()
280
+ if _OFF_REGISTRY_RE.search(spec) or spec.startswith((".", "~/")):
281
+ found[f"{folder} {name}".strip()] = spec
282
+ return found
283
+
284
+
285
+ def npm_facts(new: list[tuple[str, bytes]], old: list[tuple[str, bytes]], notes: list[str],
286
+ fetch: Fetch | None = None, package: str = "", version: str = "") -> dict:
287
+ """What a version runs at install and what it depends on, against the previous version, from the tarballs' manifests (`tarball_manifests`): an install script it adds (`npm-install-script-added`, high) or changes (`npm-install-script-changed`, medium), its own or a bundled dependency's, a dependency it now installs from outside the registry or from another such source (`npm-dependency-source`, high), and a new dependency (low). What of the new version's manifests could not be read is a note; the previous version's unread manifests count as none, so a script there is added. A new dependency from the registry is looked up there (`dependency_facts`)."""
288
+ if not new:
289
+ return {}
290
+ manifests, gyp = {}, set()
291
+ for filename, body in new:
292
+ found, dirs, unread = tarball_manifests(filename, body)
293
+ manifests.update(found)
294
+ gyp |= dirs
295
+ notes.extend(unread)
296
+ before_manifests, before_gyp = {}, set()
297
+ for filename, body in old:
298
+ found, dirs, _ = tarball_manifests(filename, body)
299
+ before_manifests.update(found)
300
+ before_gyp |= dirs
301
+ now, before = _install_scripts(manifests, gyp), _install_scripts(before_manifests, before_gyp)
302
+ sources, before_sources = _sources(manifests), _sources(before_manifests)
303
+ # a dependency newly taken from the registry, in any manifest: a new name, or one that came from elsewhere before
304
+ added = sorted({key.rsplit(" ", 1)[-1] for key in _registry_dependencies(manifests) - _registry_dependencies(before_manifests)})
305
+ looked = {}
306
+ if old and added and fetch is not None:
307
+ looked = dependency_facts(fetch, added, notes, published_at(fetch, package, version) if package else None)
308
+ return {
309
+ **looked,
310
+ "dependency_sources": [f"{k}: {v}"[:200] for k, v in sorted(sources.items()) if before_sources.get(k) != v]
311
+ if old else [],
312
+ "install_scripts_added": [f"{k}: {v}"[:200] for k, v in sorted(now.items()) if k not in before] if old else [],
313
+ "install_scripts_changed": [f"{k}: {before[k]}"[:100] + f" -> {v}"[:100] for k, v in sorted(now.items())
314
+ if k in before and before[k] != v],
315
+ "new_requires": sorted(_dependencies(manifests.get("", {})) - _dependencies(before_manifests.get("", {})))[:50]
316
+ if old else [],
317
+ }
318
+
319
+
320
+ class NpmWatcher(ReleaseWatcher):
321
+ """`ReleaseWatcher` over the npm registry: the same per-file state, retries and alerts, in `npm.db`, with a version's tarball as its one file."""
322
+
323
+ registry = "npm"
324
+ state_file = "npm.db"
325
+
326
+ def _order(self, version: str):
327
+ return semver_key(version)
328
+
329
+ def _index(self, package: str, etag: str | None) -> tuple[list[ReleaseFile] | None, str | None]:
330
+ headers = {"Accept": _ACCEPT, **({"If-None-Match": etag} if etag else {})}
331
+ status, resp_headers, body = self.fetch(_PACKUMENT_URL.format(name=quote(package, safe="@")), headers,
332
+ _MAX_PACKUMENT_BYTES)
333
+ if status == 304:
334
+ return None, etag
335
+ if status != 200:
336
+ raise RuntimeError(f"the registry's document of {package} answered HTTP {status}")
337
+ return parse_packument(body), {k.lower(): v for k, v in resp_headers.items()}.get("etag")