Skip to content

feat(extensions): verify catalog archive sha256 before install#3080

Open
zied-jlassi wants to merge 2 commits into
github:mainfrom
zied-jlassi:fix/extension-archive-integrity
Open

feat(extensions): verify catalog archive sha256 before install#3080
zied-jlassi wants to merge 2 commits into
github:mainfrom
zied-jlassi:fix/extension-archive-integrity

Conversation

@zied-jlassi

Copy link
Copy Markdown

Description

Extension and preset archives are downloaded over HTTPS and unpacked (with Zip-Slip protection), but the downloaded bytes were never checked against a known digest. Trust rested entirely on TLS plus the integrity of the third-party release host.

This is a defense-in-depth / supply-chain hardening change, not an exploit against spec-kit's own code. But the threat model is real: the publishing guide notes that maintainers do not audit extension code, so if a third-party release is tampered with or swapped (compromised release, hijacked download_url), the altered archive would be installed silently. Consumer-side integrity is the only remaining defense.

This PR lets a catalog entry pin an optional sha256 digest:

"download_url": "https://.../my-ext.zip",
"sha256": "<hex digest of the archive>"
  • When present, the downloaded archive is verified before it is written to disk and installed; a mismatch aborts with a clear error.
  • When absent, install behaves exactly as before (a DEBUG line records that the download was unverified), so this is fully backwards compatible.
  • The check runs on both download paths — ExtensionCatalog.download_extension and PresetCatalog.download_pack — through a single shared helper (shared_infra.verify_archive_sha256) so the two cannot drift apart.

The optional sha256 field is documented in both publishing guides.

Testing

  • Ran existing tests with pytest (full suite green, no regressions)
  • Tested locally with uv run specify --help
  • Added tests: shared-helper unit tests (match / mismatch / sha256: prefix / case-insensitive / no-digest DEBUG) and download-path tests for matching, mismatch, and no-digest on both extensions and presets

AI Disclosure

  • I did not use AI assistance for this contribution
  • I did use AI assistance (describe below)

AI assistance (an AI coding agent) was used for the security review that identified the missing integrity check, and to help draft the implementation, tests, and documentation. All changes were reviewed and verified by me (red→green tests including path-parity across both download paths, full suite, ruff) before submitting.

Extension and preset archives were downloaded over HTTPS and unpacked
(with Zip-Slip protection) but their bytes were never checked against a
known digest. Trust rested entirely on TLS and the integrity of the
release host, so a tampered or swapped archive from a compromised
third-party release would be installed silently. Maintainers do not audit
extension code, so consumer-side integrity is the only available defence.

Catalog entries may now pin an optional `sha256` digest. When present, the
downloaded archive is verified before it is written to disk and installed;
a mismatch aborts with a clear error. Entries without `sha256` keep
working unchanged (a DEBUG line records that the download was unverified),
so the change is backwards compatible. The check runs on both download
paths (extensions and presets) via a single shared helper so the two stay
in parity.

- Add `verify_archive_sha256` helper in shared_infra (digest match,
  `sha256:` prefix, case-insensitive; DEBUG log when no digest declared)
- Enforce it in ExtensionCatalog.download_extension and
  PresetCatalog.download_pack, before the archive is written to disk
- Document the optional `sha256` field in the publishing guides
- Tests: helper unit tests + matching/mismatch/no-digest on both paths

Signed-off-by: Zied Jlassi <6190550+zied-jlassi@users.noreply.github.com>
Assisted-by: AI
@zied-jlassi zied-jlassi requested a review from mnriem as a code owner June 21, 2026 21:35
@mnriem mnriem requested a review from Copilot June 22, 2026 13:57

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot couldn't run its full agentic review because no GitHub Actions runner was available. Make sure your repository has a runner available to run Copilot's review, or add a copilot-setup-steps.yml file specifying one with the runs-on attribute. See the docs for more details.

Adds optional SHA-256 pinning for extension/preset archives to harden installs against tampered or swapped downloads by verifying downloaded bytes before writing/installing.

Changes:

  • Introduces shared_infra.verify_archive_sha256 and integrates it into both extension and preset download paths.
  • Adds unit + integration-style tests covering match/mismatch/prefix/case/no-digest behaviors.
  • Documents the optional sha256 field in both publishing guides.
Show a summary per file
File Description
src/specify_cli/shared_infra.py Adds shared SHA-256 verification helper with debug logging for unpinned downloads
src/specify_cli/extensions.py Verifies extension archive bytes against optional catalog sha256 before writing
src/specify_cli/presets/__init__.py Verifies preset pack archive bytes against optional catalog sha256 before writing
tests/test_shared_infra_integrity.py Unit tests for helper behavior (match/mismatch/prefix/case/absent digest)
tests/test_extensions.py Download-path tests for extensions with matching/mismatching/absent sha256
tests/test_presets.py Download-path tests for presets with matching/mismatching sha256
extensions/EXTENSION-PUBLISHING-GUIDE.md Documents optional sha256 field for extension catalog entries
presets/PUBLISHING.md Documents optional sha256 field for preset catalog entries

Copilot's findings

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

  • Files reviewed: 8/8 changed files
  • Comments generated: 4

Comment thread tests/test_extensions.py Outdated
Comment thread tests/test_presets.py Outdated
Comment thread tests/test_presets.py
Comment thread src/specify_cli/shared_infra.py
@mnriem

mnriem commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator

Please address Copilot feedback

Follow-up to the review on github#3080:

- shared_infra.verify_archive_sha256: strip only a literal `sha256:`
  algorithm prefix (case-insensitive) instead of `split(':', 1)[-1]`,
  which silently dropped any prefix — so `md5:<64-hex>` was accepted as
  if it were a valid SHA-256. Validate that the declared value is exactly
  64 hex characters and raise a clear error otherwise, and compare with
  `hmac.compare_digest` for a constant-time check. Add tests covering a
  malformed digest and a non-`sha256:` prefix (both previously accepted).
- Download test helpers: configure the context-manager mock via
  `__enter__.return_value`/`__exit__.return_value` rather than assigning a
  `lambda s: s`, which is clearer and independent of the invocation arity.

Assisted-by: AI
Signed-off-by: Zied Jlassi (Architect AI) <6190550+zied-jlassi@users.noreply.github.com>
@mnriem mnriem requested a review from Copilot June 22, 2026 17:17
@zied-jlassi

Copy link
Copy Markdown
Author

Thanks @mnriem — went through the Copilot feedback. Here's what I changed and what I checked, honestly:

Applied — the substantive one (shared_infra.verify_archive_sha256):

  • The digest parsing used split(':', 1)[-1], which strips any prefix — so md5:<valid-sha256-hex> was silently accepted as a valid SHA-256. Now only a literal sha256: prefix (case-insensitive) is stripped, the value must be exactly 64 hex chars (clear error otherwise), and the comparison uses hmac.compare_digest (constant-time).
  • Added two tests: a malformed digest and a non-sha256: prefix — both of which the previous code accepted. (Verified the old behaviour empirically before/after.)

Checked but left as-is (Copilot false positives — happy to change if you prefer):

  • __enter__ = lambda s: s in the download test helpers does not raise TypeError: MagicMock binds an assigned function with self, so the with block enters fine (those 12 tests pass). I still switched to the canonical __enter__.return_value = resp form since it's clearer and arity-independent.
  • zipfile in the test_presets.py helper is already imported at module level (line 19, used in ~9 tests), so there's no NameError; I kept it consistent with the file's existing convention rather than adding a redundant local import.

On framing: to be straight about severity — this is defense-in-depth / supply-chain hardening, not an exploitable vulnerability. Transport is already HTTPS; this adds optional consumer-side integrity pinning (sha256 is opt-in, installs without it are unchanged). If you'd rather not carry the feature, no problem — feel free to close.

Validation: test_shared_infra_integrity.py 7 passed, test_extensions.py + test_presets.py 600 passed. No new ruff findings on src/.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot's findings

  • Files reviewed: 8/8 changed files
  • Comments generated: 1

Comment on lines +48 to +53
if not expected:
logger.debug(
"No sha256 declared for %r; archive integrity was not verified.",
name,
)
return

@mnriem mnriem left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please address Copilot feedback

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants