Skip to content

Commit c5e12fc

Browse files
spoorccclaude
andauthored
Automate Winget manifest submission on release (#1263)
* Automate Winget manifest submission on release Add winget-publish.yml workflow that triggers on release: published (same event as PyPI publish) and uses vedantmgoyal9/winget-releaser to submit an updated manifest PR to microsoft/winget-pkgs automatically. The workflow skips the rolling 'latest' tag, uses harden-runner with egress blocking, and pins all actions to commit SHAs following the project's existing security pattern. Requires a WINGET_TOKEN secret (GitHub PAT with public_repo scope) to be set in repository settings. https://claude.ai/code/session_01Sf3iaNmGSZ7UAhFrXZRYzi * Extend supply chain threat model with Winget Add Winget Community Repository (A-09) and WINGET_TOKEN PAT (A-10) as new assets, with three new dataflows: DF-27 (CI manifest PR submission), DF-28 (consumer winget install), and DF-29 (MSI download via winget). New threats: - DFT-34: Long-lived stored PAT enables persistent publish rights after exfiltration (unlike OIDC's short-lived tokens used for PyPI) - DFT-35: Compromised PAT enables malicious installer URL injection via manifest PR (Winget distributes references to binaries, not binaries directly, so an attacker can craft a valid-hash PR for a trojanised MSI) New controls: - C-041: Winget manifest PRs reviewed by microsoft/winget-pkgs maintainers - C-042: WINGET_TOKEN scoped to dedicated 'winget' GitHub environment Regenerated doc/explanation/threat_model_supply_chain.rst from source. https://claude.ai/code/session_01Sf3iaNmGSZ7UAhFrXZRYzi * Review comments * Add setup docs * Review comment --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4800144 commit c5e12fc

6 files changed

Lines changed: 353 additions & 7 deletions

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
name: Publish to WinGet
2+
3+
on:
4+
release:
5+
types: [published]
6+
7+
permissions:
8+
contents: read
9+
10+
jobs:
11+
publish:
12+
name: Publish to WinGet
13+
# Only publish versioned releases — skip the rolling 'latest' tag on main
14+
if: github.event.release.tag_name != 'latest'
15+
runs-on: ubuntu-latest
16+
concurrency:
17+
group: winget-publish-${{ github.event.release.tag_name }}
18+
cancel-in-progress: true
19+
20+
environment:
21+
name: winget
22+
url: https://github.com/microsoft/winget-pkgs
23+
24+
steps:
25+
- name: "Harden the runner (Block egress traffic: Only allow calls to allowed endpoints)"
26+
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
27+
with:
28+
egress-policy: block
29+
allowed-endpoints: >+
30+
github.com:443
31+
api.github.com:443
32+
release-assets.githubusercontent.com:443
33+
uploads.github.com:443
34+
35+
- name: Publish to WinGet
36+
# Requires WINGET_TOKEN secret in the 'winget' environment.
37+
#
38+
# Setup — create a fine-grained PAT:
39+
# 1. GitHub → Settings → Developer settings → Personal access tokens
40+
# → Fine-grained tokens → Generate new token
41+
# 2. Resource owner: DFetch-org (or your user)
42+
# 3. Repository access: All repositories
43+
# (needed to fork microsoft/winget-pkgs and push the manifest branch)
44+
# 4. Permissions:
45+
# Contents → Read and write
46+
# Pull requests → Read and write
47+
# 5. Store the token as secret WINGET_TOKEN in:
48+
# Repo → Settings → Environments → winget → Environment secrets
49+
uses: vedantmgoyal9/winget-releaser@4ffc7888bffd451b357355dc214d43bb9f23917e # v2
50+
with:
51+
identifier: DFetch-org.DFetch
52+
release-tag: ${{ github.event.release.tag_name }}
53+
token: ${{ secrets.WINGET_TOKEN }}

CHANGELOG.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
Release 0.14.0 (unreleased)
22
===========================
33

4+
* Update Winget manifest to the Windows Package Manager Community Repository on new release (#1263)
45
* Check for new dfetch version during ``dfetch check`` & ``dfetch environment`` (#1262)
56
* Respect the superproject's line-ending preference (#1260)
67
* Strip ``user:password@`` userinfo before storing metadata (#1206)

doc/explanation/threat_model_supply_chain.rst

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ This report follows the risk-based approach of `BSI TR-03183-1
1818
<https://www.bsi.bund.de/SharedDocs/Downloads/EN/BSI/Publications/TechGuidelines/TR03183/BSI-TR-03183-1.pdf>`_
1919
Chapter 5.
2020

21-
Threat model for dfetch. Covers the pre-install lifecycle: code contribution, CI/CD, build (wheel / sdist), PyPI distribution, and consumer installation. The installed dfetch package is the handoff point to tm_usage.py.
21+
Threat model for dfetch. Covers the pre-install lifecycle: code contribution, CI/CD, build (wheel / sdist), PyPI distribution, Winget manifest submission, and consumer installation. The installed dfetch package is the handoff point to tm_usage.py.
2222

2323
Assumptions
2424
-----------
@@ -79,6 +79,9 @@ Boundaries
7979
* - PyPI / TestPyPI
8080
- Python Package Index and its staging registry. dfetch publishes via OIDC trusted publishing - no long-lived API token stored.
8181

82+
* - Winget Community Repository
83+
- The Windows Package Manager Community Repository (https://github.com/microsoft/winget-pkgs) where dfetch's Winget manifest is hosted. Manifest PRs are submitted automatically by the CI release pipeline (winget-publish.yml) using the stored WINGET_TOKEN PAT (A-10). Consumer installations resolve manifests from this repository; winget downloads the MSI installer from the URL declared in the manifest (pointing to GitHub Releases, A-01) and verifies its SHA256 hash.
84+
8285

8386
Data Flow Diagram
8487
-----------------
@@ -240,6 +243,25 @@ Data Flow Diagram
240243

241244
}
242245

246+
subgraph cluster_boundary_WingetCommunityRepository_98b81486cc {
247+
graph [
248+
fontsize = 10;
249+
fontcolor = black;
250+
style = dashed;
251+
color = firebrick2;
252+
label = <<i>Winget Community\nRepository</i>>;
253+
]
254+
255+
externalentity_AWingetCommunityRepositorymicrosoftwingetpkgs_7113ed0f48 [
256+
shape = square;
257+
color = black;
258+
fontcolor = black;
259+
label = "A-09: Winget\nCommunity\nRepository\n(microsoft/winget-\npkgs)";
260+
margin = 0.02;
261+
]
262+
263+
}
264+
243265
actor_DeveloperContributor_d2006ce1bb -> externalentity_AbGitHubRepositoryfeaturebranchesPRs_0291419f72 [
244266
color = black;
245267
fontcolor = black;
@@ -352,6 +374,27 @@ Data Flow Diagram
352374
label = "DF-26: Consumer\ndownloads dfetch\nfrom PyPI";
353375
]
354376

377+
externalentity_AGitHubActionsInfrastructure_c76a0a7067 -> externalentity_AWingetCommunityRepositorymicrosoftwingetpkgs_7113ed0f48 [
378+
color = black;
379+
fontcolor = black;
380+
dir = forward;
381+
label = "DF-27: Winget\nmanifest PR\nsubmission";
382+
]
383+
384+
actor_ConsumerEndUser_f8af758679 -> externalentity_AWingetCommunityRepositorymicrosoftwingetpkgs_7113ed0f48 [
385+
color = black;
386+
fontcolor = black;
387+
dir = forward;
388+
label = "DF-28: winget\ninstall dfetch";
389+
]
390+
391+
externalentity_AWingetCommunityRepositorymicrosoftwingetpkgs_7113ed0f48 -> actor_ConsumerEndUser_f8af758679 [
392+
color = black;
393+
fontcolor = black;
394+
dir = forward;
395+
label = "DF-29: Consumer\ndownloads MSI via\nwinget";
396+
]
397+
355398
}
356399
@enddot
357400

@@ -413,6 +456,7 @@ Sequence Diagram
413456
entity process_APythonBuildwheelsdist_b2e5892d06 as "A-08: Python\nBuild (wheel\n/ sdist)"
414457
database datastore_AdfetchBuildDevDependencies_990b886585 as "A-07: dfetch\nBuild / Dev\nDependencies"
415458
database datastore_AbGitHubActionsBuildCache_9df04f8dae as "A-08b:\nGitHub\nActions\nBuild Cache"
459+
entity externalentity_AWingetCommunityRepositorymicrosoftwingetpkgs_7113ed0f48 as "A-09: Winget\nCommunity\nRepository\n(microsoft/winget-pkgs)"
416460

417461
actor_DeveloperContributor_d2006ce1bb -> externalentity_AbGitHubRepositoryfeaturebranchesPRs_0291419f72: DF-11: Push commits / open PR
418462
externalentity_AbGitHubRepositoryfeaturebranchesPRs_0291419f72 -> process_AReleaseGateCodeReview_9345ab4c19: DF-22: PR enters code review
@@ -430,6 +474,9 @@ Sequence Diagram
430474
externalentity_AGitHubActionsInfrastructure_c76a0a7067 -> externalentity_APyPITestPyPI_c6f87088c2: DF-24: Publish wheel to PyPI (OIDC)
431475
actor_ConsumerEndUser_f8af758679 -> externalentity_APyPITestPyPI_c6f87088c2: DF-25: pip install dfetch
432476
externalentity_APyPITestPyPI_c6f87088c2 -> actor_ConsumerEndUser_f8af758679: DF-26: Consumer downloads dfetch from PyPI
477+
externalentity_AGitHubActionsInfrastructure_c76a0a7067 -> externalentity_AWingetCommunityRepositorymicrosoftwingetpkgs_7113ed0f48: DF-27: Winget manifest PR submission
478+
actor_ConsumerEndUser_f8af758679 -> externalentity_AWingetCommunityRepositorymicrosoftwingetpkgs_7113ed0f48: DF-28: winget install dfetch
479+
externalentity_AWingetCommunityRepositorymicrosoftwingetpkgs_7113ed0f48 -> actor_ConsumerEndUser_f8af758679: DF-29: Consumer downloads MSI via winget
433480
@enduml
434481

435482
.. raw:: html
@@ -505,7 +552,7 @@ Asset Identification
505552
- Data
506553
- High / High / High
507554
* - A-06: GitHub Actions Workflow
508-
- CI/CD pipelines: test, build (wheel/msi/deb/rpm), lint, CodeQL, Scorecard, dependency-review, docs, release. All actions pinned by commit SHA. harden-runner used in every workflow that executes steps on a runner (egress: block with endpoint allowlist); ci.yml is a dispatcher-only workflow with no runner steps and does not include harden-runner.
555+
- CI/CD pipelines: test, build (wheel/msi/deb/rpm), lint, CodeQL, Scorecard, dependency-review, docs, release, winget-publish. All actions pinned by commit SHA. harden-runner used in every workflow that executes steps on a runner (egress: block with endpoint allowlist); ci.yml is a dispatcher-only workflow with no runner steps and does not include harden-runner. winget-publish.yml uses a stored PAT (WINGET_TOKEN, A-10) to submit manifest PRs to the Winget Community Repository (A-09).
509556
- Process
510557
- Medium / Medium / Medium
511558
* - A-07: dfetch Build / Dev Dependencies
@@ -520,6 +567,14 @@ Asset Identification
520567
- GitHub Actions cache entries written and restored across pipeline runs. Used to speed up dependency installation (pip, gem) and incremental builds. Cache-poisoning from forked PRs (DFT-28, SLSA E6: poison the build cache) is mitigated by ref-scoped cache keys: build.yml includes ``${{ github.ref_name }}`` in both ``key`` and ``restore-keys`` (C-033), which isolates PR and release caches per branch so a fork cannot write into the release cache namespace.
521568
- Datastore
522569
- High / High / —
570+
* - A-09: Winget Community Repository (microsoft/winget-pkgs)
571+
- The Windows Package Manager Community Repository where the dfetch ``DFetch-org.DFetch`` manifest is hosted (https://github.com/microsoft/winget-pkgs). CI submits manifest update PRs via ``vedantmgoyal9/winget-releaser`` using a stored PAT (A-10); PRs are reviewed by ``microsoft/winget-pkgs`` maintainers before merging (C-041). Manifests contain SHA256 hashes of the installer binary; winget verifies the hash before installation. A compromised PAT or a fraudulent PR that passes review could redirect consumers to a malicious installer (DFT-35).
572+
- ExternalEntity
573+
- High / High / —
574+
* - A-10: WINGET_TOKEN PAT
575+
- Long-lived GitHub Personal Access Token with ``public_repo`` scope, stored as a GitHub Actions environment secret in the ``winget`` environment. Used by ``winget-publish.yml`` to fork ``microsoft/winget-pkgs`` and submit manifest update PRs. Unlike the PyPI OIDC token (A-05) which is short-lived and not stored, this PAT persists indefinitely until rotated. If exfiltrated from the CI environment, an attacker could submit fraudulent manifest PRs from outside the project's pipeline.
576+
- Data
577+
- High / High / —
523578

524579

525580

@@ -618,6 +673,21 @@ Dataflows
618673
- Consumer / End User
619674
- HTTPS
620675

676+
* - DF-27: Winget manifest PR submission
677+
- A-02: GitHub Actions Infrastructure
678+
- A-09: Winget Community Repository (microsoft/winget-pkgs)
679+
- HTTPS
680+
681+
* - DF-28: winget install dfetch
682+
- Consumer / End User
683+
- A-09: Winget Community Repository (microsoft/winget-pkgs)
684+
- HTTPS
685+
686+
* - DF-29: Consumer downloads MSI via winget
687+
- A-09: Winget Community Repository (microsoft/winget-pkgs)
688+
- Consumer / End User
689+
- HTTPS
690+
621691

622692
Threats
623693
-------
@@ -784,6 +854,14 @@ Threats
784854
| **STRIDE:** T
785855
| **Status:** Mitigate
786856
- C-038
857+
* - DFT-35
858+
- Compromised publish credential enables malicious installer URL injection via package manifest submission
859+
- A-09: Winget Community Repository (microsoft/winget-pkgs)
860+
- | **Sev:** 🟠H
861+
| **Risk:** 🟠H
862+
| **STRIDE:** T S
863+
| **Status:** Mitigate
864+
- C-041
787865

788866

789867
Controls
@@ -870,3 +948,11 @@ Controls
870948
- Test result attestation on source archive
871949
- DFT-31
872950
- The CI test workflow (``test.yml``) generates an in-toto test result attestation (predicate type ``https://in-toto.io/attestation/test-result/v0.1``) for every release and main-branch commit. The attestation proves the full CI test suite ran against the exact source archive and every check passed, before any binary was produced from that source. Consumers can verify it using ``gh attestation verify dfetch-source.tar.gz`` with ``--predicate-type https://in-toto.io/attestation/test-result/v0.1`` and ``--cert-identity`` pinned to ``test.yml`` at the release tag ref. This provides an additional layer of assurance beyond build provenance: not only was the artifact produced from the official commit, but the test suite demonstrably passed on that exact source before any binary was built. ``.github/workflows/test.yml``
951+
* - C-041
952+
- Winget manifest PRs reviewed by community maintainers
953+
- DFT-35
954+
- Manifest update PRs submitted to ``microsoft/winget-pkgs`` by ``winget-publish.yml`` go through the standard Winget community review process before merging. ``microsoft/winget-pkgs`` maintainers verify the publisher identity and inspect manifest changes including installer URLs and hashes. This provides a manual review gate between a fraudulent PR submission and consumer exposure. Residual risk: a reviewer who approves without independently verifying the installer URL origin could merge a fraudulent manifest. ``.github/workflows/winget-publish.yml``
955+
* - C-042
956+
- WINGET_TOKEN scoped to dedicated Winget environment
957+
- DFT-34
958+
- ``WINGET_TOKEN`` is stored in the ``winget`` GitHub Actions deployment environment, limiting its exposure: the PAT is only injected into workflows that explicitly reference that environment. Only ``winget-publish.yml`` references the ``winget`` environment, so the PAT is not available to other workflows. Residual risk: unlike PyPI which uses OIDC (A-05, no stored long-lived token), Winget does not support OIDC trusted publishing; the PAT must be stored and rotated manually (DFT-34). ``.github/workflows/winget-publish.yml``

doc/howto/contributing.rst

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -375,7 +375,11 @@ Releasing
375375
git push --tags
376376
377377
- The ``ci.yml`` job will automatically create a draft release in `GitHub Releases <https://github.com/dfetch-org/dfetch/releases/>`_ with all artifacts.
378-
- Once the release is published, a new package is automatically pushed to `PyPi <https://pypi.org/project/dfetch/>`_.
378+
- Once the release is published, a new package is automatically pushed to `PyPi <https://pypi.org/project/dfetch/>`_
379+
and a manifest PR is automatically submitted to the `Winget Community Repository <https://github.com/microsoft/winget-pkgs>`_.
380+
The Winget submission requires the ``WINGET_TOKEN`` secret (a GitHub PAT with ``public_repo`` scope) to be configured
381+
as an environment secret in the ``winget`` environment settings (not at the repository level),
382+
so it is only accessible to workflows that deploy to that environment.
379383

380384
- After release, add new header to ``CHANGELOG.rst``:
381385

0 commit comments

Comments
 (0)