diff --git a/.github/workflows/winget-publish.yml b/.github/workflows/winget-publish.yml new file mode 100644 index 00000000..70e5194c --- /dev/null +++ b/.github/workflows/winget-publish.yml @@ -0,0 +1,53 @@ +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 + concurrency: + group: winget-publish-${{ github.event.release.tag_name }} + cancel-in-progress: true + + 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 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 + release-tag: ${{ github.event.release.tag_name }} + token: ${{ secrets.WINGET_TOKEN }} diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 9d805a88..393dadde 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,7 @@ Release 0.14.0 (unreleased) =========================== +* 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/explanation/threat_model_supply_chain.rst b/doc/explanation/threat_model_supply_chain.rst index 3a38f3e4..047d8f8c 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. 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, 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 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 / — @@ -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 ------- @@ -784,6 +854,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 +948,11 @@ 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/doc/howto/contributing.rst b/doc/howto/contributing.rst index 9642506a..70c1e561 100644 --- a/doc/howto/contributing.rst +++ b/doc/howto/contributing.rst @@ -375,7 +375,11 @@ 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 + 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/threats.json b/security/threats.json index 27352445..a150bd92 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 2625b644..907f4c24 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 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." + ), + 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,111 @@ 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 + 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 = ( + "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 +681,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 +693,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 +972,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 +1020,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 +1213,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__":