garnet-guard 0.1.0a1__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 (194) hide show
  1. garnet_guard-0.1.0a1/.github/ISSUE_TEMPLATE/false-positive.md +13 -0
  2. garnet_guard-0.1.0a1/.github/ISSUE_TEMPLATE/missed-detection.md +15 -0
  3. garnet_guard-0.1.0a1/.github/dependabot.yml +25 -0
  4. garnet_guard-0.1.0a1/.github/pull_request_template.md +25 -0
  5. garnet_guard-0.1.0a1/.github/workflows/ci.yml +67 -0
  6. garnet_guard-0.1.0a1/.github/workflows/garnet-guard.yml +39 -0
  7. garnet_guard-0.1.0a1/.github/workflows/release.yml +116 -0
  8. garnet_guard-0.1.0a1/.github/workflows/scorecard.yml +38 -0
  9. garnet_guard-0.1.0a1/.gitignore +37 -0
  10. garnet_guard-0.1.0a1/.python-version +1 -0
  11. garnet_guard-0.1.0a1/AGENTS.md +66 -0
  12. garnet_guard-0.1.0a1/ARCHITECTURE.md +158 -0
  13. garnet_guard-0.1.0a1/CHANGELOG.md +49 -0
  14. garnet_guard-0.1.0a1/CLAUDE.md +66 -0
  15. garnet_guard-0.1.0a1/CODE_OF_CONDUCT.md +11 -0
  16. garnet_guard-0.1.0a1/CONTRIBUTING.md +59 -0
  17. garnet_guard-0.1.0a1/GOVERNANCE.md +23 -0
  18. garnet_guard-0.1.0a1/LICENSE +202 -0
  19. garnet_guard-0.1.0a1/NOTICE +8 -0
  20. garnet_guard-0.1.0a1/OPEN_CORE.md +67 -0
  21. garnet_guard-0.1.0a1/PKG-INFO +227 -0
  22. garnet_guard-0.1.0a1/PRINCIPLES.md +34 -0
  23. garnet_guard-0.1.0a1/PROJECT_BRIEF.md +27 -0
  24. garnet_guard-0.1.0a1/README.md +194 -0
  25. garnet_guard-0.1.0a1/ROADMAP.md +116 -0
  26. garnet_guard-0.1.0a1/SECURITY.md +40 -0
  27. garnet_guard-0.1.0a1/THREAT_MODEL.md +63 -0
  28. garnet_guard-0.1.0a1/VISION.md +43 -0
  29. garnet_guard-0.1.0a1/WORKING_RECORD.md +155 -0
  30. garnet_guard-0.1.0a1/action/comment/action.yml +71 -0
  31. garnet_guard-0.1.0a1/action/comment/find-pr.sh +32 -0
  32. garnet_guard-0.1.0a1/action/comment/render.sh +58 -0
  33. garnet_guard-0.1.0a1/action/examples/garnet-guard-comment.yml +34 -0
  34. garnet_guard-0.1.0a1/action/examples/garnet-guard-fork.yml +48 -0
  35. garnet_guard-0.1.0a1/action/examples/garnet-guard.yml +43 -0
  36. garnet_guard-0.1.0a1/action/post-comment.sh +49 -0
  37. garnet_guard-0.1.0a1/action/run.sh +220 -0
  38. garnet_guard-0.1.0a1/action.yml +113 -0
  39. garnet_guard-0.1.0a1/backtest/__init__.py +0 -0
  40. garnet_guard-0.1.0a1/backtest/__main__.py +213 -0
  41. garnet_guard-0.1.0a1/backtest/clean.toml +36 -0
  42. garnet_guard-0.1.0a1/backtest/corpus-npm-1000.json +14747 -0
  43. garnet_guard-0.1.0a1/backtest/corpus-npm.json +3020 -0
  44. garnet_guard-0.1.0a1/backtest/corpus-pypi-1000.json +15404 -0
  45. garnet_guard-0.1.0a1/backtest/corpus-results/REPORT.md +116 -0
  46. garnet_guard-0.1.0a1/backtest/corpus-results/after-pr8/summary.json +39 -0
  47. garnet_guard-0.1.0a1/backtest/corpus-results/after-pr8/updates.json +10672 -0
  48. garnet_guard-0.1.0a1/backtest/corpus-results/summary.json +39 -0
  49. garnet_guard-0.1.0a1/backtest/corpus-results/triage.csv +19 -0
  50. garnet_guard-0.1.0a1/backtest/corpus-results/triage.json +182 -0
  51. garnet_guard-0.1.0a1/backtest/corpus-results/updates.json +10671 -0
  52. garnet_guard-0.1.0a1/backtest/corpus-results-npm/REPORT.md +79 -0
  53. garnet_guard-0.1.0a1/backtest/corpus-results-npm/summary.json +40 -0
  54. garnet_guard-0.1.0a1/backtest/corpus-results-npm/updates.json +18789 -0
  55. garnet_guard-0.1.0a1/backtest/corpus-results-npm-1000/REPORT.md +133 -0
  56. garnet_guard-0.1.0a1/backtest/corpus-results-npm-1000/summary.json +48 -0
  57. garnet_guard-0.1.0a1/backtest/corpus-results-npm-1000/triage.json +1608 -0
  58. garnet_guard-0.1.0a1/backtest/corpus-results-npm-1000/updates.json +89870 -0
  59. garnet_guard-0.1.0a1/backtest/corpus-results-pypi-1000/REPORT.md +189 -0
  60. garnet_guard-0.1.0a1/backtest/corpus-results-pypi-1000/summary.json +53 -0
  61. garnet_guard-0.1.0a1/backtest/corpus-results-pypi-1000/triage.json +2444 -0
  62. garnet_guard-0.1.0a1/backtest/corpus-results-pypi-1000/updates.json +104884 -0
  63. garnet_guard-0.1.0a1/backtest/corpus.json +1584 -0
  64. garnet_guard-0.1.0a1/backtest/corpus.py +396 -0
  65. garnet_guard-0.1.0a1/backtest/incidents.toml +852 -0
  66. garnet_guard-0.1.0a1/backtest/npm-incidents.toml +88 -0
  67. garnet_guard-0.1.0a1/backtest/top-npm-1000.json +1008 -0
  68. garnet_guard-0.1.0a1/backtest/top-pypi-1000.json +1007 -0
  69. garnet_guard-0.1.0a1/docs/API.md +333 -0
  70. garnet_guard-0.1.0a1/docs/CODE_TOUR.md +209 -0
  71. garnet_guard-0.1.0a1/docs/DEVOPS.md +17 -0
  72. garnet_guard-0.1.0a1/docs/README.md +16 -0
  73. garnet_guard-0.1.0a1/docs/RELEASING.md +59 -0
  74. garnet_guard-0.1.0a1/docs/SPEC-v1.md +302 -0
  75. garnet_guard-0.1.0a1/docs/action.md +120 -0
  76. garnet_guard-0.1.0a1/docs/brand/README.md +13 -0
  77. garnet_guard-0.1.0a1/docs/brand/badge/checked-by-garnet-guard.svg +1 -0
  78. garnet_guard-0.1.0a1/docs/brand/brand-kit.md +144 -0
  79. garnet_guard-0.1.0a1/docs/brand/favicon/favicon-16.svg +1 -0
  80. garnet_guard-0.1.0a1/docs/brand/lockup/lockup-horizontal-reverse.svg +1 -0
  81. garnet_guard-0.1.0a1/docs/brand/lockup/lockup-horizontal.svg +1 -0
  82. garnet_guard-0.1.0a1/docs/brand/pr-comment/pr-comment-dark.svg +1 -0
  83. garnet_guard-0.1.0a1/docs/brand/pr-comment/pr-comment-light.svg +1 -0
  84. garnet_guard-0.1.0a1/docs/decisions/0001-release-diff-guard-python-first.md +16 -0
  85. garnet_guard-0.1.0a1/docs/decisions/0002-implementation-in-python.md +16 -0
  86. garnet_guard-0.1.0a1/docs/decisions/0003-apache-2-and-dco.md +16 -0
  87. garnet_guard-0.1.0a1/docs/decisions/0004-github-action-first-surface.md +16 -0
  88. garnet_guard-0.1.0a1/docs/decisions/0005-never-execute-package-code.md +16 -0
  89. garnet_guard-0.1.0a1/docs/decisions/0006-open-core-boundary.md +16 -0
  90. garnet_guard-0.1.0a1/docs/decisions/0007-distribution-name.md +18 -0
  91. garnet_guard-0.1.0a1/docs/decisions/0008-no-telemetry.md +16 -0
  92. garnet_guard-0.1.0a1/docs/decisions/0009-trust-history-boundary.md +17 -0
  93. garnet_guard-0.1.0a1/docs/decisions/0010-funding-gates.md +18 -0
  94. garnet_guard-0.1.0a1/docs/decisions/0011-public-from-day-one.md +16 -0
  95. garnet_guard-0.1.0a1/docs/decisions/0012-pricing-shape.md +26 -0
  96. garnet_guard-0.1.0a1/docs/decisions/0013-brand-identity.md +22 -0
  97. garnet_guard-0.1.0a1/docs/decisions/0014-runtime-dependencies-0-1.md +30 -0
  98. garnet_guard-0.1.0a1/docs/decisions/0015-core-and-premium-pricing.md +35 -0
  99. garnet_guard-0.1.0a1/docs/decisions/README.md +23 -0
  100. garnet_guard-0.1.0a1/docs/decisions/template.md +10 -0
  101. garnet_guard-0.1.0a1/docs/expert-brief.md +31 -0
  102. garnet_guard-0.1.0a1/docs/handoffs/.gitkeep +0 -0
  103. garnet_guard-0.1.0a1/docs/marketplace.md +38 -0
  104. garnet_guard-0.1.0a1/docs/policy.md +101 -0
  105. garnet_guard-0.1.0a1/docs/rule-coverage.md +126 -0
  106. garnet_guard-0.1.0a1/docs/rules/GRN-C001.md +96 -0
  107. garnet_guard-0.1.0a1/docs/rules/GRN-C002.md +134 -0
  108. garnet_guard-0.1.0a1/docs/rules/GRN-C003.md +156 -0
  109. garnet_guard-0.1.0a1/docs/rules/GRN-C004.md +75 -0
  110. garnet_guard-0.1.0a1/docs/rules/GRN-C005.md +76 -0
  111. garnet_guard-0.1.0a1/docs/rules/GRN-C006.md +75 -0
  112. garnet_guard-0.1.0a1/docs/rules/GRN-C007.md +119 -0
  113. garnet_guard-0.1.0a1/docs/rules/GRN-E001.md +69 -0
  114. garnet_guard-0.1.0a1/docs/rules/GRN-E002.md +74 -0
  115. garnet_guard-0.1.0a1/docs/rules/GRN-E003.md +88 -0
  116. garnet_guard-0.1.0a1/docs/rules/GRN-E004.md +65 -0
  117. garnet_guard-0.1.0a1/docs/rules/GRN-E005.md +65 -0
  118. garnet_guard-0.1.0a1/docs/rules/GRN-K001.md +83 -0
  119. garnet_guard-0.1.0a1/docs/rules/GRN-T001.md +118 -0
  120. garnet_guard-0.1.0a1/docs/rules/GRN-T002.md +78 -0
  121. garnet_guard-0.1.0a1/docs/rules/GRN-T003.md +75 -0
  122. garnet_guard-0.1.0a1/docs/rules/GRN-T004.md +142 -0
  123. garnet_guard-0.1.0a1/docs/rules/GRN-T005.md +77 -0
  124. garnet_guard-0.1.0a1/docs/rules/GRN-T006.md +74 -0
  125. garnet_guard-0.1.0a1/docs/rules/GRN-T007.md +77 -0
  126. garnet_guard-0.1.0a1/docs/rules/GRN-T008.md +76 -0
  127. garnet_guard-0.1.0a1/docs/rules/README.md +83 -0
  128. garnet_guard-0.1.0a1/pyproject.toml +149 -0
  129. garnet_guard-0.1.0a1/scripts/commit-msg-hook.sh +10 -0
  130. garnet_guard-0.1.0a1/src/garnet_guard/__init__.py +45 -0
  131. garnet_guard-0.1.0a1/src/garnet_guard/checks/__init__.py +52 -0
  132. garnet_guard-0.1.0a1/src/garnet_guard/checks/content.py +605 -0
  133. garnet_guard-0.1.0a1/src/garnet_guard/checks/docs.py +220 -0
  134. garnet_guard-0.1.0a1/src/garnet_guard/checks/trust.py +280 -0
  135. garnet_guard-0.1.0a1/src/garnet_guard/cli.py +332 -0
  136. garnet_guard-0.1.0a1/src/garnet_guard/ecosystems/__init__.py +4 -0
  137. garnet_guard-0.1.0a1/src/garnet_guard/ecosystems/github.py +198 -0
  138. garnet_guard-0.1.0a1/src/garnet_guard/ecosystems/gittree.py +386 -0
  139. garnet_guard-0.1.0a1/src/garnet_guard/ecosystems/npm.py +369 -0
  140. garnet_guard-0.1.0a1/src/garnet_guard/ecosystems/npm_provenance.py +380 -0
  141. garnet_guard-0.1.0a1/src/garnet_guard/ecosystems/osv.py +19 -0
  142. garnet_guard-0.1.0a1/src/garnet_guard/ecosystems/provenance.py +156 -0
  143. garnet_guard-0.1.0a1/src/garnet_guard/ecosystems/pypi.py +355 -0
  144. garnet_guard-0.1.0a1/src/garnet_guard/ecosystems/source.py +131 -0
  145. garnet_guard-0.1.0a1/src/garnet_guard/engine.py +365 -0
  146. garnet_guard-0.1.0a1/src/garnet_guard/facts/__init__.py +4 -0
  147. garnet_guard-0.1.0a1/src/garnet_guard/facts/inventory.py +294 -0
  148. garnet_guard-0.1.0a1/src/garnet_guard/facts/npmmeta.py +75 -0
  149. garnet_guard-0.1.0a1/src/garnet_guard/facts/pyindex.py +589 -0
  150. garnet_guard-0.1.0a1/src/garnet_guard/lockfiles/__init__.py +12 -0
  151. garnet_guard-0.1.0a1/src/garnet_guard/lockfiles/base.py +102 -0
  152. garnet_guard-0.1.0a1/src/garnet_guard/lockfiles/diff.py +66 -0
  153. garnet_guard-0.1.0a1/src/garnet_guard/lockfiles/pylock.py +63 -0
  154. garnet_guard-0.1.0a1/src/garnet_guard/lockfiles/requirements.py +73 -0
  155. garnet_guard-0.1.0a1/src/garnet_guard/lockfiles/toml_locks.py +93 -0
  156. garnet_guard-0.1.0a1/src/garnet_guard/markdown.py +314 -0
  157. garnet_guard-0.1.0a1/src/garnet_guard/models.py +346 -0
  158. garnet_guard-0.1.0a1/src/garnet_guard/net.py +187 -0
  159. garnet_guard-0.1.0a1/src/garnet_guard/policy.py +347 -0
  160. garnet_guard-0.1.0a1/src/garnet_guard/py.typed +0 -0
  161. garnet_guard-0.1.0a1/src/garnet_guard/report.py +128 -0
  162. garnet_guard-0.1.0a1/src/garnet_guard/safe_archive.py +366 -0
  163. garnet_guard-0.1.0a1/src/garnet_guard/sarif.py +275 -0
  164. garnet_guard-0.1.0a1/src/garnet_guard/workspace.py +140 -0
  165. garnet_guard-0.1.0a1/tests/__init__.py +0 -0
  166. garnet_guard-0.1.0a1/tests/builders.py +153 -0
  167. garnet_guard-0.1.0a1/tests/conftest.py +18 -0
  168. garnet_guard-0.1.0a1/tests/fixtures/npm/nx-21.4.1-attestations.json +146 -0
  169. garnet_guard-0.1.0a1/tests/fixtures/npm/nx-21.4.1-version.json +39 -0
  170. garnet_guard-0.1.0a1/tests/fixtures/npm/tinycolor-4.2.2-attestations.json +142 -0
  171. garnet_guard-0.1.0a1/tests/fixtures/npm/tinycolor-4.2.2-version.json +57 -0
  172. garnet_guard-0.1.0a1/tests/fixtures/npm/which-3.0.1-attestations.json +96 -0
  173. garnet_guard-0.1.0a1/tests/fixtures/npm/which-3.0.1-version.json +46 -0
  174. garnet_guard-0.1.0a1/tests/fixtures/sarif-schema-2.1.0.json +3389 -0
  175. garnet_guard-0.1.0a1/tests/test_action.py +488 -0
  176. garnet_guard-0.1.0a1/tests/test_backtest_corpus.py +113 -0
  177. garnet_guard-0.1.0a1/tests/test_checks.py +714 -0
  178. garnet_guard-0.1.0a1/tests/test_cli_and_report.py +92 -0
  179. garnet_guard-0.1.0a1/tests/test_github.py +118 -0
  180. garnet_guard-0.1.0a1/tests/test_gittree.py +257 -0
  181. garnet_guard-0.1.0a1/tests/test_lockfiles.py +201 -0
  182. garnet_guard-0.1.0a1/tests/test_markdown.py +210 -0
  183. garnet_guard-0.1.0a1/tests/test_net.py +63 -0
  184. garnet_guard-0.1.0a1/tests/test_npm.py +341 -0
  185. garnet_guard-0.1.0a1/tests/test_policy.py +371 -0
  186. garnet_guard-0.1.0a1/tests/test_pyindex.py +297 -0
  187. garnet_guard-0.1.0a1/tests/test_real_releases.py +92 -0
  188. garnet_guard-0.1.0a1/tests/test_replay.py +59 -0
  189. garnet_guard-0.1.0a1/tests/test_rule_matrix.py +574 -0
  190. garnet_guard-0.1.0a1/tests/test_safe_archive.py +144 -0
  191. garnet_guard-0.1.0a1/tests/test_sarif.py +229 -0
  192. garnet_guard-0.1.0a1/tests/test_source.py +154 -0
  193. garnet_guard-0.1.0a1/tests/test_version.py +17 -0
  194. garnet_guard-0.1.0a1/uv.lock +1539 -0
@@ -0,0 +1,13 @@
1
+ ---
2
+ name: False positive
3
+ about: Garnet warned or blocked on an update that was fine
4
+ labels: false-positive
5
+ ---
6
+
7
+ **Package and versions:** <!-- e.g. numpy 2.1.0 → 2.1.1 (PyPI) -->
8
+
9
+ **Finding(s):** <!-- rule IDs and the text Garnet printed -->
10
+
11
+ **Why it's fine:** <!-- e.g. the project moved its release workflow; link to the announcement -->
12
+
13
+ **Garnet version and how you ran it:** <!-- CLI / Action, version -->
@@ -0,0 +1,15 @@
1
+ ---
2
+ name: Missed detection
3
+ about: A compromised or suspicious release Garnet didn't flag
4
+ labels: missed-detection
5
+ ---
6
+
7
+ <!-- If this is a novel bypass attackers could use today, report it privately instead (see SECURITY.md). -->
8
+
9
+ **Package and versions:**
10
+
11
+ **What was wrong with the release:** <!-- links to write-ups -->
12
+
13
+ **What Garnet reported:**
14
+
15
+ **Signal you think should have caught it:**
@@ -0,0 +1,25 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: github-actions
4
+ directory: /
5
+ schedule:
6
+ interval: weekly
7
+ cooldown:
8
+ default-days: 7
9
+ groups:
10
+ actions:
11
+ patterns: ["*"]
12
+ commit-message:
13
+ prefix: ci
14
+
15
+ - package-ecosystem: uv
16
+ directory: /
17
+ schedule:
18
+ interval: weekly
19
+ cooldown:
20
+ default-days: 7
21
+ groups:
22
+ dev:
23
+ dependency-type: development
24
+ commit-message:
25
+ prefix: deps
@@ -0,0 +1,25 @@
1
+ ## What changes
2
+
3
+ <!-- What a user or reviewer would notice. -->
4
+
5
+ ## Why
6
+
7
+ <!-- Link the issue. -->
8
+
9
+ ## Sensitive paths touched
10
+
11
+ <!-- safe_archive.py, action/, workflows, release config, default policy. Write "none" if none. -->
12
+
13
+ ## Rule changes
14
+
15
+ <!-- For rule changes: IDs affected, fixtures added, backtest warn-rate before → after. Otherwise "n/a". -->
16
+
17
+ ## AI assistance
18
+
19
+ <!-- Which parts were generated by an agent, if any. -->
20
+
21
+ ## Checklist
22
+
23
+ - [ ] Tests, ruff and mypy pass locally
24
+ - [ ] No hard rule in AGENTS.md is broken
25
+ - [ ] Commits are signed off (DCO)
@@ -0,0 +1,67 @@
1
+ name: CI
2
+
3
+ on:
4
+ pull_request:
5
+ push:
6
+ branches: [main]
7
+ workflow_call:
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ concurrency:
13
+ group: ci-${{ github.workflow }}-${{ github.ref }}
14
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
15
+
16
+ jobs:
17
+ lint:
18
+ name: Lint and typecheck
19
+ runs-on: ubuntu-latest
20
+ timeout-minutes: 10
21
+ steps:
22
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
23
+ with:
24
+ persist-credentials: false
25
+ - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
26
+ with:
27
+ python-version: "3.11"
28
+ enable-cache: false
29
+ - run: uv sync --locked
30
+ - run: uv run ruff check .
31
+ - run: uv run ruff format --check .
32
+ - run: uv run mypy src
33
+ - run: uv run lint-imports
34
+
35
+ test:
36
+ name: Tests (Python ${{ matrix.python }})
37
+ runs-on: ubuntu-latest
38
+ timeout-minutes: 10
39
+ strategy:
40
+ fail-fast: false
41
+ matrix:
42
+ python: ["3.11", "3.12", "3.13", "3.14"]
43
+ steps:
44
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
45
+ with:
46
+ persist-credentials: false
47
+ - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
48
+ with:
49
+ python-version: ${{ matrix.python }}
50
+ enable-cache: false
51
+ - run: uv sync --locked
52
+ - run: uv run pytest
53
+
54
+ build:
55
+ name: Build distributions
56
+ runs-on: ubuntu-latest
57
+ timeout-minutes: 10
58
+ steps:
59
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
60
+ with:
61
+ persist-credentials: false
62
+ - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
63
+ with:
64
+ enable-cache: false
65
+ - run: uv build --no-sources
66
+ - name: Smoke-test the built wheel
67
+ run: uvx --isolated --from dist/*.whl garnet --help
@@ -0,0 +1,39 @@
1
+ # Garnet Guard checks this repository's own dependency pull requests, the same
2
+ # way a user's repository would (action/examples/garnet-guard.yml). Dependabot
3
+ # opens a pull request for uv.lock each week (.github/dependabot.yml).
4
+ #
5
+ # The Action is pinned to a commit on main, not run from the pull request
6
+ # (uses: ./). From the pull request it would install Garnet Guard from the
7
+ # pull request's own uv.lock, so the updated packages would run before they
8
+ # were checked. Move the pin forward by hand after the Action changes, and to
9
+ # a release commit once one is tagged.
10
+ name: Garnet Guard
11
+
12
+ on:
13
+ pull_request:
14
+ paths:
15
+ - "uv.lock"
16
+
17
+ permissions: {}
18
+
19
+ concurrency:
20
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
21
+ cancel-in-progress: true
22
+
23
+ jobs:
24
+ garnet-guard:
25
+ name: Check dependency changes
26
+ runs-on: ubuntu-latest
27
+ timeout-minutes: 10
28
+ permissions:
29
+ contents: read # read the lockfiles and the base commit
30
+ pull-requests: write # post or update the comment
31
+ security-events: write # upload the SARIF report to code scanning
32
+ steps:
33
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
34
+ with:
35
+ persist-credentials: false
36
+ - uses: orca-solutions/garnet-guard@69c8e01d6ad51d7716fb9f9729e87bc2d21eda2b # main, 2026-10-30
37
+ with:
38
+ fail-on: block
39
+ sarif: true
@@ -0,0 +1,116 @@
1
+ name: Release
2
+
3
+ # Publishes garnet-guard to PyPI when the maintainer pushes a version tag.
4
+ # Trusted Publishing (OIDC) replaces any stored token, and
5
+ # pypa/gh-action-pypi-publish signs PEP 740 attestations for every file.
6
+
7
+ on:
8
+ push:
9
+ tags: ["v*"]
10
+
11
+ permissions: {}
12
+
13
+ concurrency:
14
+ group: release
15
+ cancel-in-progress: false
16
+
17
+ jobs:
18
+ checks:
19
+ name: CI
20
+ uses: ./.github/workflows/ci.yml
21
+ permissions:
22
+ contents: read
23
+
24
+ build:
25
+ name: Build
26
+ needs: checks
27
+ runs-on: ubuntu-latest
28
+ timeout-minutes: 10
29
+ permissions:
30
+ contents: read
31
+ steps:
32
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
33
+ with:
34
+ persist-credentials: false
35
+ fetch-depth: 0
36
+ - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
37
+ with:
38
+ enable-cache: false
39
+ - name: Check the tag is on main
40
+ run: |
41
+ git fetch --no-tags origin main
42
+ if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then
43
+ echo "::error::Tag $GITHUB_REF_NAME points at $GITHUB_SHA, which is not on main. Release tags must be cut from main."
44
+ exit 1
45
+ fi
46
+ - name: Check the tag matches the project version
47
+ run: |
48
+ version="$(uv version --short)"
49
+ if [ "$GITHUB_REF_NAME" != "v$version" ]; then
50
+ echo "::error::Tag $GITHUB_REF_NAME does not match the version in pyproject.toml ($version). Bump the version in a PR, merge it, then tag v$version."
51
+ exit 1
52
+ fi
53
+ - run: uv build --no-sources
54
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
55
+ with:
56
+ name: dist
57
+ path: dist/
58
+ if-no-files-found: error
59
+
60
+ publish:
61
+ name: Publish to PyPI
62
+ needs: build
63
+ runs-on: ubuntu-latest
64
+ timeout-minutes: 10
65
+ environment:
66
+ name: pypi
67
+ url: https://pypi.org/project/garnet-guard/
68
+ permissions:
69
+ id-token: write # Trusted Publishing and attestation signing; nothing else
70
+ steps:
71
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
72
+ with:
73
+ name: dist
74
+ path: dist/
75
+ - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
76
+ with:
77
+ attestations: true
78
+
79
+ verify:
80
+ name: Verify attestations on PyPI
81
+ needs: publish
82
+ runs-on: ubuntu-latest
83
+ timeout-minutes: 15
84
+ permissions:
85
+ contents: read
86
+ steps:
87
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
88
+ with:
89
+ persist-credentials: false
90
+ - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
91
+ with:
92
+ enable-cache: false
93
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
94
+ with:
95
+ name: dist
96
+ path: dist/
97
+ - run: uv sync --locked
98
+ - name: Verify each published file against this repository
99
+ run: |
100
+ for path in dist/*; do
101
+ file="$(basename "$path")"
102
+ ok=""
103
+ for attempt in 1 2 3 4 5 6; do
104
+ if uv run python -m pypi_attestations verify pypi \
105
+ --repository "https://github.com/$GITHUB_REPOSITORY" "pypi:$file"; then
106
+ ok=1
107
+ break
108
+ fi
109
+ echo "Attestation for $file not verifiable yet (attempt $attempt); retrying in 30s."
110
+ sleep 30
111
+ done
112
+ if [ -z "$ok" ]; then
113
+ echo "::error::Could not verify the PyPI attestation for $file."
114
+ exit 1
115
+ fi
116
+ done
@@ -0,0 +1,38 @@
1
+ name: OpenSSF Scorecard
2
+
3
+ on:
4
+ branch_protection_rule:
5
+ schedule:
6
+ - cron: "23 6 * * 1"
7
+ push:
8
+ branches: [main]
9
+ workflow_dispatch:
10
+
11
+ permissions: {}
12
+
13
+ jobs:
14
+ analysis:
15
+ name: Scorecard analysis
16
+ runs-on: ubuntu-latest
17
+ permissions:
18
+ contents: read
19
+ actions: read
20
+ security-events: write # upload results to code scanning
21
+ id-token: write # publish results to the Scorecard API for the badge
22
+ steps:
23
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
24
+ with:
25
+ persist-credentials: false
26
+ - uses: ossf/scorecard-action@fa7117db83c52d787ae5a89f350999701505d78e # v2.5.0
27
+ with:
28
+ results_file: results.sarif
29
+ results_format: sarif
30
+ publish_results: true
31
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
32
+ with:
33
+ name: scorecard-results
34
+ path: results.sarif
35
+ retention-days: 5
36
+ - uses: github/codeql-action/upload-sarif@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
37
+ with:
38
+ sarif_file: results.sarif
@@ -0,0 +1,37 @@
1
+ # Maintainer's private operating method; supplied through project instructions, never committed.
2
+ # AGENTS.md and CLAUDE.md are public (Garnet Guard's contributor and agent rules).
3
+ /.claude/
4
+ PR_WORKFLOW.md
5
+ docs/AGENT_ROLES.md
6
+ HANDOFF_TEMPLATES.md
7
+ SOFTWARE_OPERATING_GUIDE.md
8
+
9
+ # Internal, dated infrastructure inventory; never publish with app source.
10
+ /docs/PLATFORM_SNAPSHOT.md
11
+
12
+ # Common
13
+ .DS_Store
14
+ node_modules/
15
+ .env
16
+ .env.*
17
+ !.env.example
18
+
19
+ # Python
20
+ __pycache__/
21
+ *.py[cod]
22
+ .venv/
23
+ dist/
24
+ build/
25
+ *.egg-info/
26
+ .pytest_cache/
27
+ .mypy_cache/
28
+ .ruff_cache/
29
+ .coverage
30
+ htmlcov/
31
+ .hypothesis/
32
+
33
+ # Generated by python -m backtest
34
+ backtest/results.json
35
+
36
+ # Personal agent overrides stay local; shared entry points remain tracked.
37
+ CLAUDE.local.md
@@ -0,0 +1 @@
1
+ 3.13
@@ -0,0 +1,66 @@
1
+ # Garnet Guard: instructions for coding agents
2
+
3
+ `AGENTS.md` and `CLAUDE.md` are identical copies and must stay identical. Codex reads `AGENTS.md`; Claude Code reads `CLAUDE.md`. If the maintainer's private operating guide (`SOFTWARE_OPERATING_GUIDE.md`, `PR_WORKFLOW.md`, `docs/AGENT_ROLES.md`) is present in your workspace, follow it as well; it is supplied through project instructions, not the repository. Report unavailable guidance instead of claiming it was checked.
4
+
5
+ ## Commands
6
+
7
+ - Install: `uv sync`
8
+ - Dev server: none (CLI and GitHub Action)
9
+ - Focused tests: `uv run pytest tests/<path> -k <name>`; all unit tests: `uv run pytest` (no network); network tests only when asked: `uv run pytest -m network`
10
+ - Lint / typecheck: `uv run ruff check . && uv run ruff format --check . && uv run mypy src && uv run lint-imports`
11
+ - Build: `uv build`
12
+ - Backtest (required for rule changes): `uv run python -m backtest`
13
+
14
+ Run tests, ruff and mypy before saying a change is done.
15
+
16
+ ## Start here
17
+
18
+ Read [PROJECT_BRIEF.md](PROJECT_BRIEF.md) and `WORKING_RECORD.md`, then [ARCHITECTURE.md](ARCHITECTURE.md), [docs/SPEC-v1.md](docs/SPEC-v1.md) and [docs/API.md](docs/API.md) before changing code, and [PRINCIPLES.md](PRINCIPLES.md) when a choice is unclear. Read [THREAT_MODEL.md](THREAT_MODEL.md) before touching anything that reads package contents or runs in CI.
19
+
20
+ ## Project-specific
21
+
22
+ ### What Garnet Guard is
23
+
24
+ A release-diff guard for dependency updates. It compares a new version of a package against the version a project already trusts and reports trust downgrades, risky content changes and known-bad matches.
25
+
26
+ ### Hard rules
27
+
28
+ These are not style preferences. A change that breaks one is wrong even if tests pass.
29
+
30
+ 1. **Never execute inspected package code.** No `import`, `exec`, `subprocess`, build backend or install of anything Garnet downloads. Parse with `ast` and read files as bytes.
31
+ 2. **All archive access goes through `safe_archive.py`.** Never call `zipfile`/`tarfile` extraction elsewhere.
32
+ 3. **Checks are pure.** No I/O in `checks/`. Fetching belongs in `ecosystems/`. Respect the import rules in docs/API.md §2.
33
+ 4. **"Couldn't check" is never "pass".** Errors surface as findings or a non-zero exit.
34
+ 5. **Escape all package-supplied strings** before they reach markdown, SARIF or a terminal.
35
+ 6. **Rule IDs are permanent.** Don't renumber or reuse them.
36
+ 7. **No new runtime dependency** without an ADR in `docs/decisions/`. Dev dependencies are fine.
37
+ 8. **Unit tests use fixtures, not the network.**
38
+ 9. **Never weaken a test, skip a test or lower a backtest threshold to get green.**
39
+
40
+ ### High-risk paths
41
+
42
+ These are Garnet Guard's high-risk class: changes to them get focused independent review and the maintainer's deliberate review. Call them out at the top of the PR description.
43
+
44
+ - `src/garnet_guard/safe_archive.py` and `src/garnet_guard/net.py`
45
+ - `action/` and `.github/workflows/`
46
+ - release and publishing configuration
47
+ - default policy and rule severities
48
+
49
+ Other routine changes are verified by their author.
50
+
51
+ ### How work flows
52
+
53
+ - Work happens on a branch, one issue per branch, and lands through a pull request. Agents open PRs as drafts; only the maintainer merges.
54
+ - Keep PRs small. If a task grows, stop and propose a split in the PR description.
55
+ - A PR that adds or changes a rule includes positive and negative fixtures, `garnet explain` text and the backtest delta.
56
+ - If a requirement is unclear, leave a question in the PR rather than guessing at behavior users will see.
57
+ - Commits are signed (configured signing) and signed off (`git commit -s`, DCO).
58
+ - **No co-author trailers or "generated by" lines in commits.** The commit-msg hook strips them. Disclose agent use in the PR description's "AI assistance" section instead.
59
+
60
+ ### Style
61
+
62
+ - **User-facing text** (findings, `explain` text, PR comments, CLI messages) follows the voice rules in docs/brand/brand-kit.md: calm, exact, never "safe", evidence on every line.
63
+ - Python 3.11+, full type hints, `mypy --strict` on `src/`.
64
+ - Dataclasses (frozen) for models. Keep the public API in `garnet_guard/__init__.py` small.
65
+ - Findings must be deterministic: sort output, avoid timestamps in report bodies.
66
+ - Error messages and `explain` text are written for a developer reviewing a dependency PR: say what changed, why it matters, and what to do.
@@ -0,0 +1,158 @@
1
+ # Garnet Guard architecture
2
+
3
+ This describes the engine Garnet is built around and the surfaces that use it. It is the reference for anyone, human or agent, adding code. Decisions behind it are recorded in [docs/decisions/](docs/decisions/). The exact behavior is specified in [docs/SPEC-v1.md](docs/SPEC-v1.md) and the code design in [docs/API.md](docs/API.md); where they differ from this overview, they win.
4
+
5
+ ## Shape in one picture
6
+
7
+ ```text
8
+ lockfile (base) ─┐
9
+ ├─► LockfileDiff ─► [ReleasePair, ...]
10
+ lockfile (head) ─┘ │
11
+ ▼
12
+ Ecosystem adapter (pypi, npm)
13
+ metadata · artifacts · provenance · tags
14
+ (content-addressed cache)
15
+ │
16
+ ▼
17
+ ReleaseFacts(old) ReleaseFacts(new)
18
+ │
19
+ ▼
20
+ Checks: trust · content · known-bad ─► [Finding, ...]
21
+ │
22
+ ▼
23
+ Policy (garnet.toml) ─► Verdict
24
+ │
25
+ ▼
26
+ Reporters: text · JSON · SARIF · PR comment markdown
27
+ │
28
+ ┌───────────────────────┬───────┴──────────────┐
29
+ ▼ ▼ ▼
30
+ GitHub Action CLI (garnet check) Agent / install hooks
31
+ (month 2) (month 1) (month 3)
32
+ ```
33
+
34
+ Every surface is a thin shell over one library call: `garnet_guard.evaluate(diff, policy) -> Report`.
35
+
36
+ ## Core concepts
37
+
38
+ **ReleasePair.** One dependency changing: ecosystem, package name, `old` version (the baseline you trusted) and `new` version. A newly added dependency has no `old`; it gets only known-bad and absolute checks, not diff checks.
39
+
40
+ **Baseline.** In the open-source tool the baseline is simply the version in the base lockfile, i.e. the version this project already runs. If an update skips versions (2.6.1 → 2.6.4), Garnet diffs against 2.6.1 and also reports trust signals for each skipped release, since a compromise can hide in the middle. In v2 the engine also computes **per-package history from the registry** (how the last N releases were published), so the baseline isn't limited to one release; that stays in the free engine (ADR-0009). The hosted app adds what only a service can see: history observed across many organizations.
41
+
42
+ **ReleaseFacts.** Everything Garnet knows about one release, extracted once and cached:
43
+
44
+ - registry metadata (upload time, uploader, yanked status, files and hashes)
45
+ - provenance per file (attestation present, Sigstore identity, source repo, workflow, ref, Trusted Publishing yes/no)
46
+ - source link (repository URL, whether a matching git tag exists and which commit it points to)
47
+ - content inventory per artifact (file list, sizes, hashes, file types, `.pth` files, entry points, build backend, install scripts for npm)
48
+ - a static index of Python modules: top-level statements per module, imports, and calls to sensitive APIs
49
+
50
+ **Check.** A pure function over two `ReleaseFacts` that returns findings. Checks do no I/O; adapters fetch, checks judge. That makes every check unit-testable from fixtures.
51
+
52
+ ```python
53
+ class Check(Protocol):
54
+ id: str # stable rule ID, e.g. "GRN-T001"
55
+ ecosystems: frozenset[str] # {"pypi"}, {"npm"} or both
56
+ def run(self, pair: ReleasePair, old: ReleaseFacts | None,
57
+ new: ReleaseFacts) -> list[Finding]: ...
58
+ ```
59
+
60
+ **Finding.** `rule_id`, `severity` (info / warn / block), `package`, `old`, `new`, `title`, `evidence` (list of file path + line, URL, or attestation field), `explanation` and `remediation`. Findings must be deterministic for the same inputs so PR comments don't churn.
61
+
62
+ **Policy.** `garnet.toml` (or `[tool.garnet]` in `pyproject.toml`) maps rules to severities and holds allow-lists:
63
+
64
+ ```toml
65
+ [garnet]
66
+ default = "warn" # warn | block
67
+
68
+ [garnet.rules]
69
+ GRN-T001 = "block" # lost provenance
70
+ GRN-C004 = "off" # new binaries are normal for us
71
+
72
+ [[garnet.ignore]]
73
+ package = "numpy"
74
+ rules = ["GRN-C004"]
75
+ reason = "ships compiled wheels"
76
+ until = "2027-06-30" # ignores expire
77
+ ```
78
+
79
+ **Verdict.** The worst severity across findings after policy: `pass`, `warn` or `block`. Exit codes: 0 pass, 1 warn (only when `--fail-on warn`), 2 block, 3 Garnet error.
80
+
81
+ ## Ecosystem adapters
82
+
83
+ Each ecosystem implements one interface, so checks and reporters never touch registry specifics:
84
+
85
+ ```python
86
+ class Ecosystem(Protocol):
87
+ name: str
88
+ def parse_lockfile(self, path: Path) -> dict[str, str]: ... # name -> version
89
+ def release_facts(self, name: str, version: str) -> ReleaseFacts: ...
90
+ ```
91
+
92
+ **PyPI (month 1).** JSON API for metadata and files, the Integrity API (`/integrity/<project>/<version>/<filename>/provenance`) for PEP 740 attestations, verified with the `pypi-attestations` library. Wheels and the sdist are both inspected, because sdists carry build code. Lockfile parsers: uv.lock, poetry.lock, pylock.toml, pinned requirements.txt with hashes.
93
+
94
+ **npm (month 2).** Registry packument and tarball, npm provenance / Sigstore attestations, `scripts` and `bin` from `package.json`. Lockfile parsers: package-lock.json, pnpm-lock.yaml, yarn.lock.
95
+
96
+ **Source host.** Tag lookups go to the repository named by provenance first, falling back to project URLs in metadata. GitHub only at first, through the public API with an optional token for rate limits.
97
+
98
+ ## Initial rule catalog
99
+
100
+ IDs are stable forever. A retired rule keeps its ID reserved. Exact firing conditions and default severities are in SPEC §6.
101
+
102
+ | ID | Rule | Signal |
103
+ |---|---|---|
104
+ | GRN-T001 | lost-provenance | Old release had an attestation; new one has none. |
105
+ | GRN-T002 | publisher-changed | Attested source repository changed. |
106
+ | GRN-T003 | workflow-changed | Same repo, different publishing workflow file or environment. |
107
+ | GRN-T004 | no-matching-tag | No git tag for the new version in the attested or declared repo. |
108
+ | GRN-T005 | outside-trusted-publishing | Old files came through Trusted Publishing; new ones were uploaded with a token. |
109
+ | GRN-T006 | files-uploaded-late | A file was added to a release long after the release was first published. |
110
+ | GRN-C001 | new-pth-file | A `.pth` file appears that the old release didn't have. |
111
+ | GRN-C002 | new-build-hook | New `setup.py` logic, custom build backend, or new npm install-time script. |
112
+ | GRN-C003 | new-import-time-exec | New module-level calls to `exec`, `eval`, `compile`, `subprocess`, `os.system`, `ctypes`, sockets or HTTP clients. |
113
+ | GRN-C004 | new-binary | New native binaries or executables not explained by the build (e.g. outside the platform wheel's normal extension modules). |
114
+ | GRN-C005 | obfuscated-blob | New long base64/hex/compressed literals, especially passed to exec or decode. |
115
+ | GRN-C006 | sdist-wheel-mismatch | Wheel contains files with no counterpart in the sdist. |
116
+ | GRN-C007 | code-not-in-source | A Python file in the release isn't the one at its git tag and has sensitive calls the source lacks (code injected by the build, as in ultralytics 8.3.42). |
117
+ | GRN-T007 | invalid-attestation | An attestation exists but fails verification (blocks by default). |
118
+ | GRN-T008 | hash-mismatch | A download doesn't match the registry's published hash (blocks by default). |
119
+ | GRN-K001 | known-malicious | OSV `MAL-` advisory for this package version. |
120
+ | GRN-E001–E005 | couldn't-check | Fetch, parse or budget failures, unpinned or unsupported sources. Reported, never silently passed. |
121
+
122
+ Heuristic content rules run **only on files that changed** between the two releases. That is the main lever on false positives. GuardDog's Semgrep/YARA rules are a useful reference for patterns, with their licenses respected.
123
+
124
+ ## Safety of the engine itself
125
+
126
+ Garnet handles hostile input by design. These are invariants, enforced in code review and tests:
127
+
128
+ - **No execution.** Nothing from an inspected package is ever imported, run, built or installed. Python source is parsed with `ast` only.
129
+ - **Safe extraction.** Archives are read in memory or into a private temp dir with limits on total size, file count, compression ratio and path depth. Absolute paths, `..` segments, symlinks, hard links and device files are rejected.
130
+ - **Bounded work.** Timeouts on every network call, and a per-package budget; on a budget overrun Garnet reports an error finding rather than silently passing.
131
+ - **Fail visible.** If Garnet can't fetch or verify something, it says so in the report. It never turns "couldn't check" into "pass".
132
+
133
+ See [THREAT_MODEL.md](THREAT_MODEL.md) for the full analysis.
134
+
135
+ ## Caching and determinism
136
+
137
+ Artifacts and facts are cached by content hash under the user cache dir (and `actions/cache` in CI). Facts for a given `(ecosystem, name, version)` are immutable once a release is published, except for yanks and late file uploads, which are re-checked. Outputs sort findings deterministically.
138
+
139
+ ## Surfaces
140
+
141
+ **CLI.** `garnet check --base <lockfile> --head <lockfile>`, `garnet explain <rule>`, `garnet diff <pkg> <old> <new>`. Run with `uvx garnet-guard` or install with `uv tool install garnet-guard`.
142
+
143
+ **GitHub Action (month 2).** Runs on `pull_request` when a lockfile changes, compares the base and head lockfiles, and posts or updates one sticky comment plus an optional SARIF upload. Design constraints:
144
+
145
+ - Never use `pull_request_target` with a checkout of PR code.
146
+ - Least privilege: `contents: read`, and `pull-requests: write` only in the step that comments.
147
+ - Dependabot-triggered runs get a restricted token. If commenting directly isn't possible, use the two-workflow pattern: the analysis job uploads the report as an artifact, and a `workflow_run` job with write permission posts it without checking out PR code. *Verify current GitHub behavior before building.*
148
+ - The Action is pinned by commit SHA in all docs, and its own dependencies are hash-locked.
149
+
150
+ **Install wrapper and agent hooks (month 3).** `garnet pip install …` / `garnet uv sync` resolve first, run `evaluate` on the resulting change, then hand off to the real tool. Claude Code and Codex hooks call the same entry point before a dependency is added.
151
+
152
+ ## Package layout
153
+
154
+ See [docs/API.md](docs/API.md) §9 for the full layout and §2 for the import rules between layers. The distribution is `garnet-guard` and the import package is `garnet_guard` (ADR-0007). The CLI command is `garnet`.
155
+
156
+ ## Backtesting
157
+
158
+ `backtest/` replays known incidents (47 malicious releases of 36 compromised PyPI packages, listed in `backtest/incidents.toml`, and four npm account takeovers in `backtest/npm-incidents.toml`) and a clean corpus of recent releases of the top ~500 PyPI packages. Every rule change runs against it. Two numbers gate releases: detection on the incident set and warn rate on the clean corpus. Where an incident's malicious artifacts were removed from the registry, the replay uses recorded `ReleaseFacts` from public write-ups rather than the files.