From 336cee42e3fe7ae63385fce1daea00e02b2e1a93 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 07:29:37 +0000 Subject: [PATCH 1/5] 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 --- .github/workflows/winget-publish.yml | 39 +++++++++++++++++++ CHANGELOG.rst | 1 + doc/explanation/threat_model_supply_chain.rst | 2 +- doc/howto/contributing.rst | 5 ++- 4 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/winget-publish.yml diff --git a/.github/workflows/winget-publish.yml b/.github/workflows/winget-publish.yml new file mode 100644 index 000000000..448d9ac17 --- /dev/null +++ b/.github/workflows/winget-publish.yml @@ -0,0 +1,39 @@ +name: Publish to WinGet + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + publish: + name: Publish to WinGet + # Only publish versioned releases — skip the rolling 'latest' tag on main + if: github.event.release.tag_name != 'latest' + runs-on: ubuntu-latest + + environment: + name: winget + url: https://github.com/microsoft/winget-pkgs + + steps: + - name: "Harden the runner (Block egress traffic: Only allow calls to allowed endpoints)" + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: block + allowed-endpoints: >+ + github.com:443 + api.github.com:443 + release-assets.githubusercontent.com:443 + uploads.github.com:443 + + - name: Publish to WinGet + # Requires WINGET_TOKEN secret: a GitHub PAT with public_repo scope, + # used to fork microsoft/winget-pkgs and open the manifest PR. + uses: vedantmgoyal9/winget-releaser@4ffc7888bffd451b357355dc214d43bb9f23917e # v2 + with: + identifier: DFetch-org.DFetch + release-tag: ${{ github.event.release.tag_name }} + token: ${{ secrets.WINGET_TOKEN }} diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 9d805a88a..2380671a8 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,7 @@ Release 0.14.0 (unreleased) =========================== +* Winget manifest is now submitted automatically to the Windows Package Manager Community Repository when a new release is published (#TODO) * Check for new dfetch version during ``dfetch check`` & ``dfetch environment`` (#1262) * Respect the superproject's line-ending preference (#1260) * Strip ``user:password@`` userinfo before storing metadata (#1206) diff --git a/doc/explanation/threat_model_supply_chain.rst b/doc/explanation/threat_model_supply_chain.rst index 3a38f3e49..02b485ee9 100644 --- a/doc/explanation/threat_model_supply_chain.rst +++ b/doc/explanation/threat_model_supply_chain.rst @@ -505,7 +505,7 @@ Asset Identification - Data - High / High / High * - A-06: GitHub Actions Workflow - - 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. + - 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``) to submit manifest PRs to the Winget Community Repository. - Process - Medium / Medium / Medium * - A-07: dfetch Build / Dev Dependencies diff --git a/doc/howto/contributing.rst b/doc/howto/contributing.rst index 9642506a8..a6bf07c9e 100644 --- a/doc/howto/contributing.rst +++ b/doc/howto/contributing.rst @@ -375,7 +375,10 @@ Releasing git push --tags - The ``ci.yml`` job will automatically create a draft release in `GitHub Releases `_ with all artifacts. -- Once the release is published, a new package is automatically pushed to `PyPi `_. +- Once the release is published, a new package is automatically pushed to `PyPi `_ + and a manifest PR is automatically submitted to the `Winget Community Repository `_. + The Winget submission requires the ``WINGET_TOKEN`` secret (a GitHub PAT with ``public_repo`` scope) to be configured + in the repository settings. - After release, add new header to ``CHANGELOG.rst``: From 89e92835d283793b2dc1c82f5317ba215c24cdd3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 07:50:53 +0000 Subject: [PATCH 2/5] 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 --- doc/explanation/threat_model_supply_chain.rst | 116 +++++++++++- security/threats.json | 30 +++ security/tm_supply_chain.py | 175 +++++++++++++++++- 3 files changed, 307 insertions(+), 14 deletions(-) diff --git a/doc/explanation/threat_model_supply_chain.rst b/doc/explanation/threat_model_supply_chain.rst index 02b485ee9..637e4bc83 100644 --- a/doc/explanation/threat_model_supply_chain.rst +++ b/doc/explanation/threat_model_supply_chain.rst @@ -18,7 +18,7 @@ This report follows the risk-based approach of `BSI TR-03183-1 `_ Chapter 5. -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. +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. Assumptions ----------- @@ -79,6 +79,9 @@ Boundaries * - PyPI / TestPyPI - Python Package Index and its staging registry. dfetch publishes via OIDC trusted publishing - no long-lived API token stored. + * - Winget Community Repository + - 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. + Data Flow Diagram ----------------- @@ -240,6 +243,25 @@ Data Flow Diagram } + subgraph cluster_boundary_WingetCommunityRepository_98b81486cc { + graph [ + fontsize = 10; + fontcolor = black; + style = dashed; + color = firebrick2; + label = <Winget Community\nRepository>; + ] + + externalentity_AWingetCommunityRepositorymicrosoftwingetpkgs_7113ed0f48 [ + shape = square; + color = black; + fontcolor = black; + label = "A-09: Winget\nCommunity\nRepository\n(microsoft/winget-\npkgs)"; + margin = 0.02; + ] + + } + actor_DeveloperContributor_d2006ce1bb -> externalentity_AbGitHubRepositoryfeaturebranchesPRs_0291419f72 [ color = black; fontcolor = black; @@ -352,6 +374,27 @@ Data Flow Diagram label = "DF-26: Consumer\ndownloads dfetch\nfrom PyPI"; ] + externalentity_AGitHubActionsInfrastructure_c76a0a7067 -> externalentity_AWingetCommunityRepositorymicrosoftwingetpkgs_7113ed0f48 [ + color = black; + fontcolor = black; + dir = forward; + label = "DF-27: Winget\nmanifest PR\nsubmission"; + ] + + actor_ConsumerEndUser_f8af758679 -> externalentity_AWingetCommunityRepositorymicrosoftwingetpkgs_7113ed0f48 [ + color = black; + fontcolor = black; + dir = forward; + label = "DF-28: winget\ninstall dfetch"; + ] + + externalentity_AWingetCommunityRepositorymicrosoftwingetpkgs_7113ed0f48 -> actor_ConsumerEndUser_f8af758679 [ + color = black; + fontcolor = black; + dir = forward; + label = "DF-29: Consumer\ndownloads MSI via\nwinget"; + ] + } @enddot @@ -413,6 +456,7 @@ Sequence Diagram entity process_APythonBuildwheelsdist_b2e5892d06 as "A-08: Python\nBuild (wheel\n/ sdist)" database datastore_AdfetchBuildDevDependencies_990b886585 as "A-07: dfetch\nBuild / Dev\nDependencies" database datastore_AbGitHubActionsBuildCache_9df04f8dae as "A-08b:\nGitHub\nActions\nBuild Cache" + entity externalentity_AWingetCommunityRepositorymicrosoftwingetpkgs_7113ed0f48 as "A-09: Winget\nCommunity\nRepository\n(microsoft/winget-pkgs)" actor_DeveloperContributor_d2006ce1bb -> externalentity_AbGitHubRepositoryfeaturebranchesPRs_0291419f72: DF-11: Push commits / open PR externalentity_AbGitHubRepositoryfeaturebranchesPRs_0291419f72 -> process_AReleaseGateCodeReview_9345ab4c19: DF-22: PR enters code review @@ -430,6 +474,9 @@ Sequence Diagram externalentity_AGitHubActionsInfrastructure_c76a0a7067 -> externalentity_APyPITestPyPI_c6f87088c2: DF-24: Publish wheel to PyPI (OIDC) actor_ConsumerEndUser_f8af758679 -> externalentity_APyPITestPyPI_c6f87088c2: DF-25: pip install dfetch externalentity_APyPITestPyPI_c6f87088c2 -> actor_ConsumerEndUser_f8af758679: DF-26: Consumer downloads dfetch from PyPI + externalentity_AGitHubActionsInfrastructure_c76a0a7067 -> externalentity_AWingetCommunityRepositorymicrosoftwingetpkgs_7113ed0f48: DF-27: Winget manifest PR submission + actor_ConsumerEndUser_f8af758679 -> externalentity_AWingetCommunityRepositorymicrosoftwingetpkgs_7113ed0f48: DF-28: winget install dfetch + externalentity_AWingetCommunityRepositorymicrosoftwingetpkgs_7113ed0f48 -> actor_ConsumerEndUser_f8af758679: DF-29: Consumer downloads MSI via winget @enduml .. raw:: html @@ -505,7 +552,7 @@ Asset Identification - Data - High / High / High * - A-06: GitHub Actions Workflow - - 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``) to submit manifest PRs to the Winget Community Repository. + - 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). - Process - Medium / Medium / Medium * - A-07: dfetch Build / Dev Dependencies @@ -520,6 +567,14 @@ Asset Identification - 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. - Datastore - High / High / — + * - A-09: Winget Community Repository (microsoft/winget-pkgs) + - 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). + - ExternalEntity + - High / High / — + * - A-10: WINGET_TOKEN PAT + - Long-lived GitHub Personal Access Token with ``public_repo`` scope, stored as a GitHub Actions repository secret. 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. + - Data + - High / High / — @@ -546,22 +601,22 @@ Dataflows * - DF-22: PR enters code review - A-01b: GitHub Repository (feature branches / PRs) - A-04: Release Gate / Code Review - - + - * - DF-12: Main branch workflows drive CI execution - A-01: GitHub Repository (main / protected) - A-06: GitHub Actions Workflow - - + - * - DF-13a: PR CI checkout - A-01b: GitHub Repository (feature branches / PRs) - A-02: GitHub Actions Infrastructure - - + - * - DF-13b: Release CI checkout - A-01: GitHub Repository (main / protected) - A-02: GitHub Actions Infrastructure - - + - * - DF-14: CI cache restore - A-08b: GitHub Actions Build Cache @@ -571,12 +626,12 @@ Dataflows * - DF-15: Workflow triggers build step - A-06: GitHub Actions Workflow - A-08: Python Build (wheel / sdist) - - + - * - DF-15b: Built wheel/sdist artifacts - A-08: Python Build (wheel / sdist) - A-02: GitHub Actions Infrastructure - - + - * - DF-16: CI fetches build/dev deps from PyPI - A-03: PyPI / TestPyPI @@ -586,7 +641,7 @@ Dataflows * - DF-17: Build tools consumed by build step - A-07: dfetch Build / Dev Dependencies - A-08: Python Build (wheel / sdist) - - + - * - DF-18: CI cache write - A-02: GitHub Actions Infrastructure @@ -601,7 +656,7 @@ Dataflows * - DF-23: Approved merge to main - A-04: Release Gate / Code Review - A-01: GitHub Repository (main / protected) - - + - * - DF-24: Publish wheel to PyPI (OIDC) - A-02: GitHub Actions Infrastructure @@ -618,6 +673,21 @@ Dataflows - Consumer / End User - HTTPS + * - DF-27: Winget manifest PR submission + - A-02: GitHub Actions Infrastructure + - A-09: Winget Community Repository (microsoft/winget-pkgs) + - HTTPS + + * - DF-28: winget install dfetch + - Consumer / End User + - A-09: Winget Community Repository (microsoft/winget-pkgs) + - HTTPS + + * - DF-29: Consumer downloads MSI via winget + - A-09: Winget Community Repository (microsoft/winget-pkgs) + - Consumer / End User + - HTTPS + Threats ------- @@ -720,6 +790,14 @@ Threats | **STRIDE:** S T | **Status:** Accept - Abandoned namespace reclaim is a registry-level concern outside dfetch's control. Accepted based on the **CI runner posture** assumption: GitHub Actions environments inherit the security posture of the GitHub-hosted runner, including its access to the public PyPI registry; registry-level namespace integrity is outside the scope of this model. + * - DFT-21 + - Unsigned or forged VCS tag accepted as a trusted version pin + - DF-27: Winget manifest PR submission + - | **Sev:** 🟠H + | **Risk:** — + | **STRIDE:** — + | **Status:** — + - — * - DFT-23 - Replay or freeze attack delivers stale content to suppress security updates - DF-16: CI fetches build/dev deps from PyPI @@ -784,6 +862,14 @@ Threats | **STRIDE:** T | **Status:** Mitigate - C-038 + * - DFT-35 + - Compromised publish credential enables malicious installer URL injection via package manifest submission + - A-09: Winget Community Repository (microsoft/winget-pkgs) + - | **Sev:** 🟠H + | **Risk:** 🟠H + | **STRIDE:** T S + | **Status:** Mitigate + - C-041 Controls @@ -870,3 +956,13 @@ Controls - Test result attestation on source archive - DFT-31 - 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`` + * - C-041 + - Winget manifest PRs reviewed by community maintainers + - DFT-35 + - 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`` + * - C-042 + - WINGET_TOKEN scoped to dedicated Winget environment + - DFT-34 + - ``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`` + + diff --git a/security/threats.json b/security/threats.json index 27352445b..a150bd92a 100644 --- a/security/threats.json +++ b/security/threats.json @@ -493,5 +493,35 @@ "mitigations": "Pin VCS dependencies to immutable commit SHAs and, after each fetch, verify that the pinned SHA is reachable from the upstream's current default branch tip. Prefer upstream repositories that enforce SLSA Source Level 2+ ancestry protection (no force-push policy on protected branches). Use Source Provenance Attestations that include lineage records for additional assurance. Monitor upstream repositories for unexpected history rewrites as part of regular dependency hygiene.", "example": "A project pins a dependency to commit abc123, which was reviewed before acceptance. The upstream maintainer later performs a history rewrite to strip accidentally committed secrets, producing new commit hashes throughout the tree. The original abc123 becomes an orphan — not reachable from main. A fresh clone no longer contains abc123, making it impossible to verify the relationship between the pinned commit and the current release branch, and silently breaking any reproducibility check.", "references": "https://slsa.dev/spec/v1.2/source-requirements#level-2, https://capec.mitre.org/data/definitions/690.html, https://cwe.mitre.org/data/definitions/345.html" + }, + { + "SID": "DFT-34", + "target": [ + "Data" + ], + "description": "Long-lived stored credential enables persistent unauthorised publication after exfiltration", + "details": "Unlike short-lived OIDC tokens that expire within minutes of issuance, a long-lived personal access token (PAT) stored as a CI/CD secret retains its publish rights indefinitely once exfiltrated. A compromised CI step — through a backdoored dependency, a malicious third-party action, or a supply-chain attack — can read the PAT from the runner environment variables and transmit it to an attacker-controlled endpoint. The attacker can subsequently use the stolen PAT to perform privileged operations — such as submitting package manifests, pushing code, or triggering releases — from outside the CI pipeline, long after the compromised build run has ended. OIDC short-lived tokens (used for PyPI publishing) expire in minutes and cannot be reused; a stored PAT has no such natural expiry.", + "Likelihood Of Attack": "Low", + "severity": "High", + "condition": "target.isCredentials is True and target.isStored is True and target.isDestEncryptedAtRest is True", + "prerequisites": "A long-lived PAT is stored as a CI/CD secret. A pipeline step can execute attacker-controlled code — through a backdoored dependency, a compromised third-party action pinned to a mutable tag, or a malicious PR that reaches the production runner with secret access. Outbound egress from the CI environment is not fully blocked for all endpoints required to receive the exfiltrated credential.", + "mitigations": "Prefer OIDC-based short-lived credential exchange over long-lived stored PATs wherever the target service supports it. Where a PAT is unavoidable, scope it to the minimum required permissions and store it in a deployment environment that requires explicit reviewer approval before unlocking. Rotate PATs on a regular schedule and immediately on suspected compromise. Block all non-allowlisted egress from the CI environment so credential exfiltration channels are closed. Monitor PAT usage logs for activity outside expected CI build windows.", + "example": "A compromised third-party Winget releaser action reads the WINGET_TOKEN PAT from the runner environment and posts it to an attacker-controlled server. The attacker subsequently uses the token to submit a manifest PR pointing to a trojanised installer, bypassing the normal CI release pipeline entirely.", + "references": "https://slsa.dev/spec/v1.0/threats#e-build-process, https://capec.mitre.org/data/definitions/560.html, https://cwe.mitre.org/data/definitions/522.html" + }, + { + "SID": "DFT-35", + "target": [ + "ExternalEntity" + ], + "description": "Compromised publish credential enables malicious installer URL injection via package manifest submission", + "details": "Package registries that distribute software by manifest reference — where the manifest contains a URL pointing to the binary and its hash — face a distinct threat from registries that store the binary directly. An attacker who obtains a valid publish credential can craft a manifest that: (1) points the installer URL to an attacker-controlled binary, (2) computes the correct SHA256 hash of that binary and places it in the manifest. The manifest passes all automated hash-verification checks. If the PR is merged by a reviewer who does not inspect the installer URL or does not independently verify the binary, consumers who install the package receive the malicious binary. This differs from direct binary upload attacks: the attacker does not need to compromise the package registry itself — only the credential required to submit a manifest PR.", + "Likelihood Of Attack": "Low", + "severity": "High", + "condition": "target.controls.hasAccessControl is True and target.controls.providesIntegrity is True and target.controls.usesCodeSigning is False and target.controls.isHardened is False", + "prerequisites": "The attacker has obtained a valid publish credential (PAT or equivalent) for the package registry. The registry accepts manifests that specify installer URLs external to the registry itself. The manifest review process does not independently verify that the installer URL points to a binary produced by the project's official release pipeline. The binary served at the attacker-controlled URL passes any hash check declared in the manifest.", + "mitigations": "Pin installer URLs in manifests to the project's official release infrastructure and document that URL changes must be scrutinised as carefully as hash changes. Store publish credentials in a deployment environment requiring reviewer approval. Use OIDC-based credentials where available so exfiltrated tokens expire quickly. Monitor PAT usage for activity outside expected CI build windows.", + "example": "An attacker exfiltrates the WINGET_TOKEN PAT from a CI run via a backdoored dependency. They fork microsoft/winget-pkgs, update the dfetch manifest to point the installer URL to attacker.example/dfetch-0.14.0-win.msi (a trojaned MSI), compute its correct SHA256 hash, and open a PR. A reviewer sees the hash matches the declared value and approves. Consumers who run winget install DFetch-org.DFetch download and install the malicious binary.", + "references": "https://slsa.dev/spec/v1.0/threats#f-artifact-publication, https://capec.mitre.org/data/definitions/186.html, https://cwe.mitre.org/data/definitions/494.html" } ] diff --git a/security/tm_supply_chain.py b/security/tm_supply_chain.py index 2625b6443..934a7ac7b 100644 --- a/security/tm_supply_chain.py +++ b/security/tm_supply_chain.py @@ -193,11 +193,13 @@ def _make_sc_processes(b_github: Boundary) -> tuple[Process, Process, Process]: gh_actions_workflow.inBoundary = b_github gh_actions_workflow.description = ( "CI/CD pipelines: test, build (wheel/msi/deb/rpm), lint, CodeQL, Scorecard, " - "dependency-review, docs, release. " + "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." + "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)." ) gh_actions_workflow.controls.isHardened = ( True # SHA-pinned actions, harden-runner egress:block on all runner workflows @@ -226,6 +228,25 @@ def _make_sc_processes(b_github: Boundary) -> tuple[Process, Process, Process]: def _make_sc_datastores(b_github: Boundary) -> tuple[Datastore, Datastore]: """Create data assets; return all for use in dataflows.""" + Data( + "A-10: WINGET_TOKEN PAT", + description=( + "Long-lived GitHub Personal Access Token with ``public_repo`` scope, " + "stored as a GitHub Actions repository secret. " + "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." + ), + classification=Classification.SECRET, + isCredentials=True, + isPII=False, + isStored=True, + isDestEncryptedAtRest=True, # GitHub encrypts Actions secrets at rest + isSourceEncryptedAtRest=True, + ) Data( "A-05: PyPI OIDC Identity", description=( @@ -531,13 +552,106 @@ def _make_sc_consumer_install_flows( df26.controls.isNetworkFlow = True +def _make_sc_winget_elements_and_flows( + gh_actions_runner: ExternalEntity, + consumer: Actor, +) -> None: + """Create Winget community-repo assets and publishing/installation flows (DF-27 through DF-29).""" + b_winget = Boundary("Winget Community Repository") + b_winget.description = ( + "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." + ) + + winget_repo = ExternalEntity( + "A-09: Winget Community Repository (microsoft/winget-pkgs)" + ) + winget_repo.inBoundary = b_winget + winget_repo.classification = Classification.SENSITIVE + winget_repo.description = ( + "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)." + ) + winget_repo.controls.hasAccessControl = True # PAT required to submit PRs + winget_repo.controls.providesIntegrity = True # manifests contain SHA256 installer hashes + winget_repo.controls.usesCodeSigning = ( + False # manifests are not cryptographically signed by the project + ) + winget_repo.controls.isHardened = ( + False # relies on manual community PR review, not automated technical controls + ) + + df27 = Dataflow( + gh_actions_runner, + winget_repo, + "DF-27: Winget manifest PR submission", + ) + df27.description = ( + "On release event, ``winget-publish.yml`` uses ``vedantmgoyal9/winget-releaser`` " + "to discover the MSI asset in the GitHub release, compute its SHA256 hash, " + "generate updated Winget manifests, and submit a PR to A-09 " + "(``microsoft/winget-pkgs``) authenticated via the WINGET_TOKEN PAT (A-10). " + "The PR is reviewed by ``microsoft/winget-pkgs`` maintainers before merging " + "(C-041). " + "Residual risk: a compromised PAT (DFT-34) or a fraudulent submission that " + "passes review could redirect consumers to a malicious installer (DFT-35)." + ) + df27.protocol = "HTTPS" + df27.controls.isEncrypted = True + df27.controls.isHardened = True # harden-runner egress block; SHA-pinned action + df27.controls.hasAccessControl = True # WINGET_TOKEN PAT authentication + df27.controls.isNetworkFlow = True + + df28 = Dataflow(consumer, winget_repo, "DF-28: winget install dfetch") + df28.description = ( + "Consumer runs ``winget install -e --id DFetch-org.DFetch``. " + "The winget client resolves the manifest from A-09 (``microsoft/winget-pkgs``). " + "The manifest contains the installer URL (pointing to GitHub Releases, A-01) " + "and the SHA256 hash of the MSI. " + "The consumer can verify the MSI attestations using ``gh attestation verify`` " + "as documented in C-026 and C-039." + ) + df28.protocol = "HTTPS" + df28.controls.isEncrypted = True + df28.controls.isNetworkFlow = True + df28.controls.providesIntegrity = True # winget verifies SHA256 hash from manifest + + df29 = Dataflow(winget_repo, consumer, "DF-29: Consumer downloads MSI via winget") + df29.description = ( + "winget resolves the installer URL from the manifest in A-09 and downloads " + "the MSI directly from GitHub Releases (A-01). " + "The SHA256 hash declared in the manifest is verified against the downloaded " + "binary before installation begins. " + "The consumer can additionally verify SLSA build provenance, SBOM, and VSA " + "attestations using ``gh attestation verify`` as documented in C-026 and C-039." + ) + df29.protocol = "HTTPS" + df29.controls.isEncrypted = True + df29.controls.providesIntegrity = True # SHA256 hash in manifest verified by winget + df29.controls.isNetworkFlow = True + + def _make_sc_elements_and_flows( b_dev: Boundary, b_consumer: Boundary, b_github: Boundary, b_pypi: Boundary, ) -> None: - """Create all supply-chain model elements and wire dataflows DF-11 through DF-26.""" + """Create all supply-chain model elements and wire dataflows DF-11 through DF-29.""" ( developer, consumer, @@ -562,6 +676,7 @@ def _make_sc_elements_and_flows( _make_sc_merge_flow(release_gate, gh_repository) _make_sc_publish_flow(gh_actions_runner, pypi) _make_sc_consumer_install_flows(consumer, pypi) + _make_sc_winget_elements_and_flows(gh_actions_runner, consumer) def build_model() -> TM: @@ -573,7 +688,8 @@ def build_model() -> TM: description=( "Threat model for dfetch. " "Covers the pre-install lifecycle: code contribution, CI/CD, " - "build (wheel / sdist), PyPI distribution, and consumer installation. " + "build (wheel / sdist), PyPI distribution, Winget manifest submission, " + "and consumer installation. " "The installed dfetch package is the handoff point to tm_usage.py." ), isOrdered=True, @@ -851,6 +967,41 @@ def build_model() -> TM: "suite demonstrably passed on that exact source before any binary was built." ), ), + Control( + id="C-041", + name="Winget manifest PRs reviewed by community maintainers", + assets=["A-09"], + threats=["DFT-35"], + reference=".github/workflows/winget-publish.yml", + description=( + "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." + ), + ), + Control( + id="C-042", + name="WINGET_TOKEN scoped to dedicated Winget environment", + assets=["A-10"], + threats=["DFT-34"], + reference=".github/workflows/winget-publish.yml", + description=( + "``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)." + ), + ), ] _SUPPLY_CHAIN_ASSET_IDS = { @@ -864,6 +1015,8 @@ def build_model() -> TM: "A-07", "A-08", "A-08b", + "A-09", + "A-10", } ASSET_CONTROLS: dict[str, list[Control]] = build_asset_controls_index( CONTROLS, _SUPPLY_CHAIN_ASSET_IDS @@ -1055,6 +1208,20 @@ def build_model() -> TM: stride=["Tampering"], target="DF-26: Consumer downloads dfetch from PyPI", ), + ThreatResponse( + "DFT-34", + "mitigate", + risk="High", + stride=["Information Disclosure", "Tampering"], + target="A-10: WINGET_TOKEN PAT", + ), + ThreatResponse( + "DFT-35", + "mitigate", + risk="High", + stride=["Tampering", "Spoofing"], + target="A-09: Winget Community Repository (microsoft/winget-pkgs)", + ), ] if __name__ == "__main__": From 322d91f394304191ff7c2fe79a9ae4236da711f5 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 14 Jun 2026 14:35:06 +0000 Subject: [PATCH 3/5] Review comments --- .github/workflows/winget-publish.yml | 3 +++ CHANGELOG.rst | 2 +- doc/howto/contributing.rst | 3 ++- security/tm_supply_chain.py | 7 ++++++- 4 files changed, 12 insertions(+), 3 deletions(-) diff --git a/.github/workflows/winget-publish.yml b/.github/workflows/winget-publish.yml index 448d9ac17..7cebeb07f 100644 --- a/.github/workflows/winget-publish.yml +++ b/.github/workflows/winget-publish.yml @@ -13,6 +13,9 @@ jobs: # Only publish versioned releases — skip the rolling 'latest' tag on main if: github.event.release.tag_name != 'latest' runs-on: ubuntu-latest + concurrency: + group: winget-publish-${{ github.event.release.tag_name }} + cancel-in-progress: true environment: name: winget diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 2380671a8..393daddec 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,7 +1,7 @@ Release 0.14.0 (unreleased) =========================== -* Winget manifest is now submitted automatically to the Windows Package Manager Community Repository when a new release is published (#TODO) +* Update Winget manifest to the Windows Package Manager Community Repository on new release (#1263) * Check for new dfetch version during ``dfetch check`` & ``dfetch environment`` (#1262) * Respect the superproject's line-ending preference (#1260) * Strip ``user:password@`` userinfo before storing metadata (#1206) diff --git a/doc/howto/contributing.rst b/doc/howto/contributing.rst index a6bf07c9e..70c1e561d 100644 --- a/doc/howto/contributing.rst +++ b/doc/howto/contributing.rst @@ -378,7 +378,8 @@ Releasing - Once the release is published, a new package is automatically pushed to `PyPi `_ and a manifest PR is automatically submitted to the `Winget Community Repository `_. The Winget submission requires the ``WINGET_TOKEN`` secret (a GitHub PAT with ``public_repo`` scope) to be configured - in the repository settings. + as an environment secret in the ``winget`` environment settings (not at the repository level), + so it is only accessible to workflows that deploy to that environment. - After release, add new header to ``CHANGELOG.rst``: diff --git a/security/tm_supply_chain.py b/security/tm_supply_chain.py index 934a7ac7b..a7a4d8661 100644 --- a/security/tm_supply_chain.py +++ b/security/tm_supply_chain.py @@ -587,7 +587,9 @@ def _make_sc_winget_elements_and_flows( "consumers to a malicious installer (DFT-35)." ) winget_repo.controls.hasAccessControl = True # PAT required to submit PRs - winget_repo.controls.providesIntegrity = True # manifests contain SHA256 installer hashes + winget_repo.controls.providesIntegrity = ( + True # manifests contain SHA256 installer hashes + ) winget_repo.controls.usesCodeSigning = ( False # manifests are not cryptographically signed by the project ) @@ -615,6 +617,9 @@ def _make_sc_winget_elements_and_flows( df27.controls.isHardened = True # harden-runner egress block; SHA-pinned action df27.controls.hasAccessControl = True # WINGET_TOKEN PAT authentication df27.controls.isNetworkFlow = True + df27.controls.providesIntegrity = ( + True # action computes SHA256 of MSI and embeds it in manifest + ) df28 = Dataflow(consumer, winget_repo, "DF-28: winget install dfetch") df28.description = ( From 3e6d1ebac3dbb1a10ce73d27ff2205ad8c9e8be7 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 14 Jun 2026 15:08:33 +0000 Subject: [PATCH 4/5] Add setup docs --- .github/workflows/winget-publish.yml | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/.github/workflows/winget-publish.yml b/.github/workflows/winget-publish.yml index 7cebeb07f..70e5194c9 100644 --- a/.github/workflows/winget-publish.yml +++ b/.github/workflows/winget-publish.yml @@ -33,8 +33,19 @@ jobs: uploads.github.com:443 - name: Publish to WinGet - # Requires WINGET_TOKEN secret: a GitHub PAT with public_repo scope, - # used to fork microsoft/winget-pkgs and open the manifest PR. + # Requires WINGET_TOKEN secret in the 'winget' environment. + # + # Setup — create a fine-grained PAT: + # 1. GitHub → Settings → Developer settings → Personal access tokens + # → Fine-grained tokens → Generate new token + # 2. Resource owner: DFetch-org (or your user) + # 3. Repository access: All repositories + # (needed to fork microsoft/winget-pkgs and push the manifest branch) + # 4. Permissions: + # Contents → Read and write + # Pull requests → Read and write + # 5. Store the token as secret WINGET_TOKEN in: + # Repo → Settings → Environments → winget → Environment secrets uses: vedantmgoyal9/winget-releaser@4ffc7888bffd451b357355dc214d43bb9f23917e # v2 with: identifier: DFetch-org.DFetch From 82852617109b21d1c8b2212211327822fbd21635 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 14 Jun 2026 15:21:48 +0000 Subject: [PATCH 5/5] Review comment --- doc/explanation/threat_model_supply_chain.rst | 28 ++++++------------- security/tm_supply_chain.py | 2 +- 2 files changed, 10 insertions(+), 20 deletions(-) diff --git a/doc/explanation/threat_model_supply_chain.rst b/doc/explanation/threat_model_supply_chain.rst index 637e4bc83..047d8f8c8 100644 --- a/doc/explanation/threat_model_supply_chain.rst +++ b/doc/explanation/threat_model_supply_chain.rst @@ -572,7 +572,7 @@ Asset Identification - ExternalEntity - High / High / — * - A-10: WINGET_TOKEN PAT - - Long-lived GitHub Personal Access Token with ``public_repo`` scope, stored as a GitHub Actions repository secret. 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. + - 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. - Data - High / High / — @@ -601,22 +601,22 @@ Dataflows * - DF-22: PR enters code review - A-01b: GitHub Repository (feature branches / PRs) - A-04: Release Gate / Code Review - - + - * - DF-12: Main branch workflows drive CI execution - A-01: GitHub Repository (main / protected) - A-06: GitHub Actions Workflow - - + - * - DF-13a: PR CI checkout - A-01b: GitHub Repository (feature branches / PRs) - A-02: GitHub Actions Infrastructure - - + - * - DF-13b: Release CI checkout - A-01: GitHub Repository (main / protected) - A-02: GitHub Actions Infrastructure - - + - * - DF-14: CI cache restore - A-08b: GitHub Actions Build Cache @@ -626,12 +626,12 @@ Dataflows * - DF-15: Workflow triggers build step - A-06: GitHub Actions Workflow - A-08: Python Build (wheel / sdist) - - + - * - DF-15b: Built wheel/sdist artifacts - A-08: Python Build (wheel / sdist) - A-02: GitHub Actions Infrastructure - - + - * - DF-16: CI fetches build/dev deps from PyPI - A-03: PyPI / TestPyPI @@ -641,7 +641,7 @@ Dataflows * - DF-17: Build tools consumed by build step - A-07: dfetch Build / Dev Dependencies - A-08: Python Build (wheel / sdist) - - + - * - DF-18: CI cache write - A-02: GitHub Actions Infrastructure @@ -656,7 +656,7 @@ Dataflows * - DF-23: Approved merge to main - A-04: Release Gate / Code Review - A-01: GitHub Repository (main / protected) - - + - * - DF-24: Publish wheel to PyPI (OIDC) - A-02: GitHub Actions Infrastructure @@ -790,14 +790,6 @@ Threats | **STRIDE:** S T | **Status:** Accept - Abandoned namespace reclaim is a registry-level concern outside dfetch's control. Accepted based on the **CI runner posture** assumption: GitHub Actions environments inherit the security posture of the GitHub-hosted runner, including its access to the public PyPI registry; registry-level namespace integrity is outside the scope of this model. - * - DFT-21 - - Unsigned or forged VCS tag accepted as a trusted version pin - - DF-27: Winget manifest PR submission - - | **Sev:** 🟠H - | **Risk:** — - | **STRIDE:** — - | **Status:** — - - — * - DFT-23 - Replay or freeze attack delivers stale content to suppress security updates - DF-16: CI fetches build/dev deps from PyPI @@ -964,5 +956,3 @@ Controls - WINGET_TOKEN scoped to dedicated Winget environment - DFT-34 - ``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`` - - diff --git a/security/tm_supply_chain.py b/security/tm_supply_chain.py index a7a4d8661..907f4c241 100644 --- a/security/tm_supply_chain.py +++ b/security/tm_supply_chain.py @@ -232,7 +232,7 @@ def _make_sc_datastores(b_github: Boundary) -> tuple[Datastore, Datastore]: "A-10: WINGET_TOKEN PAT", description=( "Long-lived GitHub Personal Access Token with ``public_repo`` scope, " - "stored as a GitHub Actions repository secret. " + "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, "