diff --git a/.claude/settings.json b/.claude/settings.json index 530d55384..1bc349884 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -25,7 +25,9 @@ "Bash(make -C doc latexpdf)", "Bash(make -C doc clean)", "Bash(dfetch add:*)", - "Bash(dfetch update:*)" + "Bash(dfetch update:*)", + "Bash(python -m security.tm_supply_chain:*)", + "Bash(python -m security.tm_usage:*)" ], "additionalDirectories": [ "/workspaces/dfetch/.claude" diff --git a/doc/explanation/threat_model_supply_chain.rst b/doc/explanation/threat_model_supply_chain.rst index dad2e28d9..598a9b472 100644 --- a/doc/explanation/threat_model_supply_chain.rst +++ b/doc/explanation/threat_model_supply_chain.rst @@ -1,3 +1,6 @@ +dfetch Supply Chain +=================== + .. ============================================================ .. Auto-generated file — do not edit manually. .. Regenerate with (see security/README.md for exact commands): @@ -28,27 +31,384 @@ Assumptions * - CI runner posture - GitHub Actions environments inherit the security posture of the GitHub-hosted runner. Ephemeral runner isolation is provided by GitHub. - * - Harden-runner in block mode - - The ``harden-runner`` egress policy is set to ``block`` with an allowlist of permitted endpoints. Outbound network connections from CI runners are blocked unless explicitly permitted. - - * - Build deps without hash pinning - - dfetch's own build and dev dependencies are installed without ``--require-hashes``, so a compromised PyPI mirror can substitute malicious build tools. - - * - Attacker: Network-adjacent - - Positioned on the same network segment as a CI runner or developer workstation - shared cloud tenant, BGP hijack, compromised DNS resolver, or corporate proxy. Can intercept and modify unencrypted traffic (http://, svn://) and inject HTTP redirects. Cannot break correctly implemented TLS or SSH. - - * - Attacker: Compromised upstream - - A dependency maintainer account taken over via phishing, credential stuffing, or MFA bypass - or a maintainer acting maliciously. Delivers attacker-controlled content over an authenticated channel; transport security provides no protection. Mitigated only by commit-SHA pinning and human review before accepting any update. - - * - Attacker: Compromised registry or CDN - - Holds write access to a public package registry (PyPI) or an archive CDN node, or is BGP-adjacent to one. Serves malicious content under a valid TLS certificate - transport integrity does not detect server-side substitution. Only cryptographic content hashes or signed attestations provide a defence. - - * - Attacker: Local filesystem - - Holds write access to the developer workstation or CI runner working tree - gained via a compromised dev dependency, malicious post-install hook, or lateral movement. Can tamper with ``.dfetch_data.yaml``, patch files, and vendored source after dfetch writes them to disk. - - * - Attacker: Malicious manifest contributor - - A repository contributor who introduces a malicious ``dfetch.yaml`` change: redirecting a dep to an attacker-controlled URL, pointing ``dst:`` at a sensitive path, or embedding a credential-bearing URL. dfetch is not the control point for this threat; code review is the intended mitigating boundary. +.. raw:: html + +
+ +Data Flow Diagram +----------------- + +.. uml:: + + @startdot + digraph tm { + graph [ + fontname = Arial; + fontsize = 14; + ] + node [ + fontname = Arial; + fontsize = 14; + rankdir = lr; + ] + edge [ + shape = none; + arrowtail = onormal; + fontname = Arial; + fontsize = 12; + ] + labelloc = "t"; + fontsize = 20; + nodesep = 1; + + subgraph cluster_boundary_ConsumerEnvironment_88f2d9c06f { + graph [ + fontsize = 10; + fontcolor = black; + style = dashed; + color = firebrick2; + label = <Consumer\nEnvironment>; + ] + + actor_ConsumerEndUser_f8af758679 [ + shape = square; + color = black; + fontcolor = black; + label = "Consumer / End\nUser"; + margin = 0.02; + ] + + } + + subgraph cluster_boundary_GitHubPlatform_579e9aae81 { + graph [ + fontsize = 10; + fontcolor = black; + style = dashed; + color = firebrick2; + label = <GitHub Platform>; + ] + + externalentity_AGitHubRepositorymainprotected_2c440ebe53 [ + shape = square; + color = black; + fontcolor = black; + label = "A-01: GitHub\nRepository (main /\nprotected)"; + margin = 0.02; + ] + + externalentity_AbGitHubRepositoryfeaturebranchesPRs_0291419f72 [ + shape = square; + color = black; + fontcolor = black; + label = "A-01b: GitHub\nRepository\n(feature branches\n/ PRs)"; + margin = 0.02; + ] + + externalentity_AGitHubActionsInfrastructure_c76a0a7067 [ + shape = square; + color = black; + fontcolor = black; + label = "A-02: GitHub\nActions\nInfrastructure"; + margin = 0.02; + ] + + process_ReleaseGateCodeReview_9345ab4c19 [ + shape = circle; + color = black; + fontcolor = black; + label = "Release Gate /\nCode Review"; + margin = 0.02; + ] + + process_GitHubActionsWorkflow_86e4604564 [ + shape = circle; + color = black; + fontcolor = black; + label = "GitHub Actions\nWorkflow"; + margin = 0.02; + ] + + process_PythonBuildwheelsdist_b2e5892d06 [ + shape = circle; + color = black; + fontcolor = black; + label = "Python Build\n(wheel / sdist)"; + margin = 0.02; + ] + + datastore_AdfetchBuildDevDependencies_990b886585 [ + shape = cylinder; + color = black; + fontcolor = black; + label = "A-07: dfetch Build\n/ Dev Dependencies"; + ] + + datastore_AbGitHubActionsBuildCache_9df04f8dae [ + shape = cylinder; + color = black; + fontcolor = black; + label = "A-08b: GitHub\nActions Build\nCache"; + ] + + } + + subgraph cluster_boundary_LocalDeveloperEnvironment_acf3059e70 { + graph [ + fontsize = 10; + fontcolor = black; + style = dashed; + color = firebrick2; + label = <Local Developer\nEnvironment>; + ] + + actor_DeveloperContributor_d2006ce1bb [ + shape = square; + color = black; + fontcolor = black; + label = "Developer /\nContributor"; + margin = 0.02; + ] + + } + + subgraph cluster_boundary_PyPITestPyPI_f2eb7a3ff7 { + graph [ + fontsize = 10; + fontcolor = black; + style = dashed; + color = firebrick2; + label = <PyPI / TestPyPI>; + ] + + externalentity_APyPITestPyPI_c6f87088c2 [ + shape = square; + color = black; + fontcolor = black; + label = "A-03: PyPI /\nTestPyPI"; + margin = 0.02; + ] + + } + + actor_DeveloperContributor_d2006ce1bb -> externalentity_AbGitHubRepositoryfeaturebranchesPRs_0291419f72 [ + color = black; + fontcolor = black; + dir = forward; + label = "DF-11: Push\ncommits / open PR"; + ] + + externalentity_AbGitHubRepositoryfeaturebranchesPRs_0291419f72 -> process_ReleaseGateCodeReview_9345ab4c19 [ + color = black; + fontcolor = black; + dir = forward; + label = "DF-22: PR enters\ncode review"; + ] + + externalentity_AGitHubRepositorymainprotected_2c440ebe53 -> process_GitHubActionsWorkflow_86e4604564 [ + color = black; + fontcolor = black; + dir = forward; + label = "DF-12: Main branch\nworkflows drive CI\nexecution"; + ] + + externalentity_AbGitHubRepositoryfeaturebranchesPRs_0291419f72 -> externalentity_AGitHubActionsInfrastructure_c76a0a7067 [ + color = black; + fontcolor = black; + dir = forward; + label = "DF-13a: PR CI\ncheckout"; + ] + + externalentity_AGitHubRepositorymainprotected_2c440ebe53 -> externalentity_AGitHubActionsInfrastructure_c76a0a7067 [ + color = black; + fontcolor = black; + dir = forward; + label = "DF-13b: Release CI\ncheckout"; + ] + + datastore_AbGitHubActionsBuildCache_9df04f8dae -> externalentity_AGitHubActionsInfrastructure_c76a0a7067 [ + color = black; + fontcolor = black; + dir = forward; + label = "DF-14: CI cache\nrestore"; + ] + + process_GitHubActionsWorkflow_86e4604564 -> process_PythonBuildwheelsdist_b2e5892d06 [ + color = black; + fontcolor = black; + dir = forward; + label = "DF-15: Workflow\ntriggers build\nstep"; + ] + + process_PythonBuildwheelsdist_b2e5892d06 -> externalentity_AGitHubActionsInfrastructure_c76a0a7067 [ + color = black; + fontcolor = black; + dir = forward; + label = "DF-15b: Built\nwheel/sdist\nartifacts"; + ] + + externalentity_APyPITestPyPI_c6f87088c2 -> datastore_AdfetchBuildDevDependencies_990b886585 [ + color = black; + fontcolor = black; + dir = forward; + label = "DF-16: CI fetches\nbuild/dev deps\nfrom PyPI"; + ] + + datastore_AdfetchBuildDevDependencies_990b886585 -> process_PythonBuildwheelsdist_b2e5892d06 [ + color = black; + fontcolor = black; + dir = forward; + label = "DF-17: Build tools\nconsumed by build\nstep"; + ] + + externalentity_AGitHubActionsInfrastructure_c76a0a7067 -> datastore_AbGitHubActionsBuildCache_9df04f8dae [ + color = black; + fontcolor = black; + dir = forward; + label = "DF-18: CI cache\nwrite"; + ] + + externalentity_AGitHubActionsInfrastructure_c76a0a7067 -> externalentity_AGitHubRepositorymainprotected_2c440ebe53 [ + color = black; + fontcolor = black; + dir = forward; + label = "DF-19: CI write-\nback (SARIF /\nartifacts)"; + ] + + process_ReleaseGateCodeReview_9345ab4c19 -> externalentity_AGitHubRepositorymainprotected_2c440ebe53 [ + color = black; + fontcolor = black; + dir = forward; + label = "DF-23: Approved\nmerge to main"; + ] + + externalentity_AGitHubActionsInfrastructure_c76a0a7067 -> externalentity_APyPITestPyPI_c6f87088c2 [ + color = black; + fontcolor = black; + dir = forward; + label = "DF-24: Publish\nwheel to PyPI\n(OIDC)"; + ] + + actor_ConsumerEndUser_f8af758679 -> externalentity_APyPITestPyPI_c6f87088c2 [ + color = black; + fontcolor = black; + dir = forward; + label = "DF-25: pip install\ndfetch"; + ] + + externalentity_APyPITestPyPI_c6f87088c2 -> actor_ConsumerEndUser_f8af758679 [ + color = black; + fontcolor = black; + dir = forward; + label = "DF-26: Consumer\ndownloads dfetch\nfrom PyPI"; + ] + + } + @enddot + +.. raw:: html + +
+ + + +.. raw:: html + +
+ +Sequence Diagram +---------------- + +.. uml:: + + @startuml + skinparam defaultFontSize 16 + actor actor_DeveloperContributor_d2006ce1bb as "Developer /\nContributor" + actor actor_ConsumerEndUser_f8af758679 as "Consumer /\nEnd User" + entity externalentity_AGitHubRepositorymainprotected_2c440ebe53 as "A-01: GitHub\nRepository\n(main /\nprotected)" + entity externalentity_AbGitHubRepositoryfeaturebranchesPRs_0291419f72 as "A-01b:\nGitHub\nRepository\n(feature\nbranches /\nPRs)" + entity externalentity_AGitHubActionsInfrastructure_c76a0a7067 as "A-02: GitHub\nActions\nInfrastructure" + entity externalentity_APyPITestPyPI_c6f87088c2 as "A-03: PyPI /\nTestPyPI" + entity process_ReleaseGateCodeReview_9345ab4c19 as "Release Gate\n/ Code\nReview" + entity process_GitHubActionsWorkflow_86e4604564 as "GitHub\nActions\nWorkflow" + entity process_PythonBuildwheelsdist_b2e5892d06 as "Python Build\n(wheel /\nsdist)" + database datastore_AdfetchBuildDevDependencies_990b886585 as "A-07: dfetch\nBuild / Dev\nDependencies" + database datastore_AbGitHubActionsBuildCache_9df04f8dae as "A-08b:\nGitHub\nActions\nBuild Cache" + + actor_DeveloperContributor_d2006ce1bb -> externalentity_AbGitHubRepositoryfeaturebranchesPRs_0291419f72: DF-11: Push commits / open PR + externalentity_AbGitHubRepositoryfeaturebranchesPRs_0291419f72 -> process_ReleaseGateCodeReview_9345ab4c19: DF-22: PR enters code review + externalentity_AGitHubRepositorymainprotected_2c440ebe53 -> process_GitHubActionsWorkflow_86e4604564: DF-12: Main branch workflows drive CI execution + externalentity_AbGitHubRepositoryfeaturebranchesPRs_0291419f72 -> externalentity_AGitHubActionsInfrastructure_c76a0a7067: DF-13a: PR CI checkout + externalentity_AGitHubRepositorymainprotected_2c440ebe53 -> externalentity_AGitHubActionsInfrastructure_c76a0a7067: DF-13b: Release CI checkout + datastore_AbGitHubActionsBuildCache_9df04f8dae -> externalentity_AGitHubActionsInfrastructure_c76a0a7067: DF-14: CI cache restore + process_GitHubActionsWorkflow_86e4604564 -> process_PythonBuildwheelsdist_b2e5892d06: DF-15: Workflow triggers build step + process_PythonBuildwheelsdist_b2e5892d06 -> externalentity_AGitHubActionsInfrastructure_c76a0a7067: DF-15b: Built wheel/sdist artifacts + externalentity_APyPITestPyPI_c6f87088c2 -> datastore_AdfetchBuildDevDependencies_990b886585: DF-16: CI fetches build/dev deps from PyPI + datastore_AdfetchBuildDevDependencies_990b886585 -> process_PythonBuildwheelsdist_b2e5892d06: DF-17: Build tools consumed by build step + externalentity_AGitHubActionsInfrastructure_c76a0a7067 -> datastore_AbGitHubActionsBuildCache_9df04f8dae: DF-18: CI cache write + externalentity_AGitHubActionsInfrastructure_c76a0a7067 -> externalentity_AGitHubRepositorymainprotected_2c440ebe53: DF-19: CI write-back (SARIF / artifacts) + process_ReleaseGateCodeReview_9345ab4c19 -> externalentity_AGitHubRepositorymainprotected_2c440ebe53: DF-23: Approved merge to main + 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 + @enduml + +.. raw:: html + +
+ + Dataflows --------- @@ -62,39 +422,84 @@ Dataflows - To - Protocol - * - DF-11: Submit pull request - - Contributor / Attacker - - A-01: GitHub Repository + * - DF-11: Push commits / open PR + - Developer / Contributor + - A-01b: GitHub Repository (feature branches / PRs) - HTTPS - * - DF-14: pip install dfetch - - Consumer / End User - - A-03: PyPI / TestPyPI - - HTTPS + * - DF-22: PR enters code review + - A-01b: GitHub Repository (feature branches / PRs) + - Release Gate / Code Review + - + + * - DF-12: Main branch workflows drive CI execution + - A-01: GitHub Repository (main / protected) + - GitHub Actions Workflow + - - * - DF-12: CI checkout and build - - A-01: GitHub Repository + * - DF-13a: PR CI checkout + - A-01b: GitHub Repository (feature branches / PRs) - A-02: GitHub Actions Infrastructure - - + - - * - DF-13: Publish to PyPI (OIDC) + * - 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 + - A-02: GitHub Actions Infrastructure + - HTTPS + + * - DF-15: Workflow triggers build step + - GitHub Actions Workflow + - Python Build (wheel / sdist) + - + + * - DF-15b: Built wheel/sdist artifacts + - Python Build (wheel / sdist) + - A-02: GitHub Actions Infrastructure + - + + * - DF-16: CI fetches build/dev deps from PyPI - A-03: PyPI / TestPyPI + - A-07: dfetch Build / Dev Dependencies - HTTPS + * - DF-17: Build tools consumed by build step + - A-07: dfetch Build / Dev Dependencies + - Python Build (wheel / sdist) + - + * - DF-18: CI cache write - A-02: GitHub Actions Infrastructure - A-08b: GitHub Actions Build Cache - HTTPS - * - DF-19: CI cache restore - - A-08b: GitHub Actions Build Cache + * - DF-19: CI write-back (SARIF / artifacts) - A-02: GitHub Actions Infrastructure + - A-01: GitHub Repository (main / protected) - HTTPS - * - DF-17: CI write-back (SARIF / artifacts / cache) + * - DF-23: Approved merge to main + - Release Gate / Code Review + - A-01: GitHub Repository (main / protected) + - + + * - DF-24: Publish wheel to PyPI (OIDC) - A-02: GitHub Actions Infrastructure - - A-01: GitHub Repository + - A-03: PyPI / TestPyPI + - HTTPS + + * - DF-25: pip install dfetch + - Consumer / End User + - A-03: PyPI / TestPyPI + - HTTPS + + * - DF-26: Consumer downloads dfetch from PyPI + - A-03: PyPI / TestPyPI + - Consumer / End User - HTTPS @@ -124,11 +529,8 @@ Actors * - Name - Description - * - Developer - - dfetch project contributor: writes code, reviews PRs, cuts releases. Trusted at workstation time; responsible for correct branch-protection and release workflow configuration. - - * - Contributor / Attacker - - External contributor submitting pull requests, or an adversary attempting supply-chain manipulation (malicious PR, action-poisoning, or MITM on CI network traffic). Code review, branch protection, and SHA-pinned Actions are the primary controls at this boundary. + * - Developer / Contributor + - Anyone who writes code for dfetch: core maintainers who push directly and cut releases, and external contributors who submit pull requests. Maintainers are trusted at workstation time and are responsible for correct branch-protection and release workflow configuration. External contributors are untrusted until their PR passes code review and CI. * - Consumer / End User - Installs dfetch from PyPI (``pip install dfetch``) or from binary installer, then invokes it on a developer workstation or in a CI pipeline. Can verify five complementary attestation types using ``gh attestation verify`` as documented in the release-integrity guide (see C-026, C-037, C-039, C-040): SBOM attestation on the PyPI wheel; SBOM, SLSA build provenance, and VSA on binary installers; SLSA build provenance, in-toto test result attestation, and SLSA Source Provenance Attestation on the source archive and main-branch commits. @@ -147,15 +549,15 @@ Boundaries * - Local Developer Environment - Developer workstation or local CI runner. Assumed trusted at invocation time. Hosts the manifest (``dfetch.yaml``), vendor directory, dependency metadata (``.dfetch_data.yaml``), and patch files. - * - GitHub Actions Infrastructure - - Microsoft-operated ephemeral runners executing the CI/CD workflows. Egress traffic is blocked (``harden-runner`` with ``egress-policy: block``) with an allowlist of permitted endpoints; ``ci.yml`` forwards only explicitly named secrets to child workflows (``CODACY_PROJECT_TOKEN`` to ``test.yml``, ``GH_DFETCH_ORG_DEPLOY`` to ``docs.yml``). + * - Consumer Environment + - End-user workstation or downstream CI pipeline where dfetch is installed and invoked. Distinct from the developer environment: no source checkout, no signing keys, no deploy access. The consumer is trusted at invocation time but has no special relationship with the dfetch release infrastructure. + + * - GitHub Platform + - GitHub-hosted infrastructure: repository, CI/CD runners, Actions workflows, build cache, and code-scanning results. Egress traffic on runners is blocked (``harden-runner`` with ``egress-policy: block``) with an allowlist of permitted endpoints; ``ci.yml`` forwards only explicitly named secrets to child workflows (``CODACY_PROJECT_TOKEN`` to ``test.yml``, ``GH_DFETCH_ORG_DEPLOY`` to ``docs.yml``). * - PyPI / TestPyPI - Python Package Index and its staging registry. dfetch publishes via OIDC trusted publishing - no long-lived API token stored. - * - Internet - - Public internet - upstream package registries, PyPI, GitHub, CDN nodes, and other external endpoints reachable by the CI/CD infrastructure. - Assets ------ @@ -168,8 +570,12 @@ Assets - Description - Type - * - A-01: GitHub Repository - - Source code, PRs, releases, and workflow definitions. GitHub Actions workflows (``.github/workflows/``) with ``contents:write`` permission can modify repository state and trigger releases. + * - A-01: GitHub Repository (main / protected) + - The protected ``main`` branch: force-push disabled, merges require passing CI and at least one approving review. Contains the authoritative workflow definitions (``.github/workflows/``), release tags, and published release assets. Workflow files on main are what GitHub Actions actually executes — a PR cannot override them for its own CI run. ``contents:write`` permission allows CI to upload SARIF results and release assets. + - ExternalEntity + + * - A-01b: GitHub Repository (feature branches / PRs) + - Unprotected feature branches and fork PRs: no mandatory review, no CI-gate requirement to push. Any authenticated GitHub user can open a PR modifying ``.github/workflows/`` files; those changes are reviewed before merging to main but execute with restricted permissions during CI (no access to production secrets). A malicious PR modifying workflow files could attempt to exfiltrate secrets during the PR CI run, mitigated by ``ci.yml`` secret scoping (C-024) and harden-runner egress block (C-013). - ExternalEntity * - A-02: GitHub Actions Infrastructure @@ -177,7 +583,7 @@ Assets - ExternalEntity * - A-03: PyPI / TestPyPI - - Python Package Index. dfetch is published via OIDC trusted publishing (no long-lived API token). Account takeover or registry compromise would affect every consumer installing dfetch. + - Python Package Index - both the registry service and the published dfetch wheel/sdist (https://pypi.org/project/dfetch/). Published via OIDC trusted publishing; no long-lived API token stored. A machine-readable CycloneDX SBOM is generated during the build and published alongside the release. Account takeover, registry compromise, or namespace-squatting would affect every consumer installing dfetch. - ExternalEntity * - Release Gate / Code Review @@ -192,22 +598,10 @@ Assets - Runs ``python -m build`` to produce wheel and sdist. Build deps (setuptools, build, fpm, gem) fetched from PyPI/RubyGems without hash pinning. SLSA provenance attestations are generated by the release workflow. - Process - * - A-04: dfetch PyPI Package - - Published wheel and sdist on PyPI (https://pypi.org/project/dfetch/). Published via OIDC trusted publishing - no long-lived API token stored. A machine-readable CycloneDX SBOM is generated during the build and published alongside the release. Compromise of the PyPI account or registry affects every consumer. - - Datastore - - * - A-06: OpenSSF Scorecard Results - - Weekly OSSF Scorecard SARIF results uploaded to GitHub Code Scanning. Covers: branch-protection, CI-tests, code-review, maintained, packaging, pinned-dependencies, SAST, signed-releases, token-permissions, vulnerabilities, dangerous-workflow, binary-artifacts, fuzzing, license, CII-best-practices, security-policy, webhooks. Suppression or forgery hides supply-chain regressions. - - Datastore - * - A-07: dfetch Build / Dev Dependencies - Python packages installed during CI: setuptools, build, pylint, bandit, mypy, pytest, etc. Ruby gem ``fpm`` for platform builds. Installed via ``pip install .`` and ``pip install --upgrade pip build`` without ``--require-hashes`` - a compromised PyPI mirror or BGP hijack can substitute malicious build tools. ``gem install fpm`` and ``choco install svn/zig`` are also not hash-verified. - Datastore - * - A-08: GitHub Actions Workflows - - ``.github/workflows/*.yml`` - CI/CD configuration checked into the repository. 11 workflows: ci, build, run, test, docs, release, python-publish, dependency-review, codeql-analysis, scorecard, devcontainer. A malicious PR that modifies workflows can exfiltrate secrets or publish a backdoored release. Mitigated by: SHA-pinned actions, persist-credentials:false, minimal permissions. - - Datastore - * - A-08b: GitHub Actions Build Cache - 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 @@ -259,12 +653,6 @@ Controls - Information Disclosure, Tampering - DFT-07, DFT-29 - Mitigates: ``step-security/harden-runner`` is used in every workflow with ``egress-policy: block`` and an allowlist of permitted endpoints. All non-allowlisted outbound connections are blocked. ``.github/workflows/*.yml`` - * - C-014 - - OpenSSF Scorecard - - Low - - Tampering - - DFT-07, DFT-10 - - Mitigates: Weekly OSSF Scorecard analysis uploaded to GitHub Code Scanning covers the full set of OpenSSF Scorecard checks. ``.github/workflows/scorecard.yml`` * - C-015 - CodeQL static analysis - Medium @@ -370,3 +758,4 @@ Gaps - Spoofing, Elevation of Privilege - DFT-11 - Affects: No hardware-token (FIDO2/WebAuthn) MFA or mandatory second-approver sign-off is required for accounts with merge or release-trigger rights. A compromised maintainer account - via phishing, credential stuffing, or SMS-TOTP bypass - can merge a backdoored PR and trigger the release workflow without any automated block. Enforce FIDO2 MFA on all accounts with merge rights and add a required reviewer to the ``pypi`` deployment environment. + diff --git a/doc/explanation/threat_model_usage.rst b/doc/explanation/threat_model_usage.rst index 2341ee3c8..1033d9c2c 100644 --- a/doc/explanation/threat_model_usage.rst +++ b/doc/explanation/threat_model_usage.rst @@ -1,3 +1,6 @@ +dfetch Runtime Usage +==================== + .. ============================================================ .. Auto-generated file — do not edit manually. .. Regenerate with (see security/README.md for exact commands): @@ -184,6 +187,36 @@ Dataflows - A-21: Audit / Check Reports - + * - DF-22: Copy extracted content to vendor directory + - A-20: Local VCS Cache (temp) + - A-22: dfetch Process + - + + * - DF-18: Read integrity hash for archive verification + - A-14: Integrity Hash Record + - A-22: dfetch Process + - + + * - DF-18b: Write computed hash to manifest (dfetch freeze) + - A-22: dfetch Process + - A-14: Integrity Hash Record + - + + * - DF-20: Author / maintain dfetch.yaml + - Developer + - A-12: dfetch Manifest + - + + * - DF-19: VCS server publishes source attestation (not consumed by dfetch) + - A-09: Remote VCS Server + - A-23: Upstream Source Attestation (VSA) + - + + * - DF-21: Create / maintain patch files + - Developer + - A-19: Patch Files + - + Data Dictionary --------------- @@ -321,6 +354,457 @@ Assets +Data Flow Diagram +----------------- + +.. uml:: + + @startdot + digraph tm { + graph [ + fontname = Arial; + fontsize = 14; + ] + node [ + fontname = Arial; + fontsize = 14; + rankdir = lr; + ] + edge [ + shape = none; + arrowtail = onormal; + fontname = Arial; + fontsize = 12; + ] + labelloc = "t"; + fontsize = 20; + nodesep = 1; + + subgraph cluster_boundary_ArchiveContentSpace_b8773cb4e7 { + graph [ + fontsize = 10; + fontcolor = black; + style = dashed; + color = firebrick2; + label = <Archive Content\nSpace>; + ] + + process_ArchiveExtractiontarfilezipfile_da43120000 [ + shape = circle; + color = black; + fontcolor = black; + label = "Archive Extraction\n(tarfile /\nzipfile)"; + margin = 0.02; + ] + + } + + subgraph cluster_boundary_Internet_88f2d9c06f { + graph [ + fontsize = 10; + fontcolor = black; + style = dashed; + color = firebrick2; + label = <Internet>; + ] + + + } + + subgraph cluster_boundary_LocalDeveloperEnvironment_acf3059e70 { + graph [ + fontsize = 10; + fontcolor = black; + style = dashed; + color = firebrick2; + label = <Local Developer\nEnvironment>; + ] + + actor_Developer_f2eb7a3ff7 [ + shape = square; + color = black; + fontcolor = black; + label = "Developer"; + margin = 0.02; + ] + + externalentity_AConsumerBuildSystem_0291419f72 [ + shape = square; + color = black; + fontcolor = black; + label = "A-11: Consumer\nBuild System"; + margin = 0.02; + ] + + process_AdfetchProcess_c76a0a7067 [ + shape = circle; + color = black; + fontcolor = black; + label = "A-22: dfetch\nProcess"; + margin = 0.02; + ] + + process_PatchApplicationpatchng_c6f87088c2 [ + shape = circle; + color = black; + fontcolor = black; + label = "Patch Application\n(patch-ng)"; + margin = 0.02; + ] + + datastore_AdfetchManifest_9345ab4c19 [ + shape = cylinder; + color = black; + fontcolor = black; + label = "A-12: dfetch\nManifest"; + ] + + datastore_AFetchedSourceCode_86e4604564 [ + shape = cylinder; + color = black; + fontcolor = black; + label = "A-13: Fetched\nSource Code"; + ] + + datastore_AIntegrityHashRecord_b2e5892d06 [ + shape = cylinder; + color = black; + fontcolor = black; + label = "A-14: Integrity\nHash Record"; + ] + + datastore_ASBOMOutputCycloneDX_990b886585 [ + shape = cylinder; + color = black; + fontcolor = black; + label = "A-15: SBOM Output\n(CycloneDX)"; + ] + + datastore_ADependencyMetadata_9df04f8dae [ + shape = cylinder; + color = black; + fontcolor = black; + label = "A-18: Dependency\nMetadata"; + ] + + datastore_APatchFiles_65d0d57a00 [ + shape = cylinder; + color = black; + fontcolor = black; + label = "A-19: Patch Files"; + ] + + process_SVNExportsvnexport_7eb89910ee [ + shape = circle; + color = black; + fontcolor = black; + label = "SVN Export (svn\nexport)"; + margin = 0.02; + ] + + datastore_ALocalVCSCachetemp_ae6e32d09a [ + shape = cylinder; + color = black; + fontcolor = black; + label = "A-20: Local VCS\nCache (temp)"; + ] + + datastore_AAuditCheckReports_d0c0ca0a3b [ + shape = cylinder; + color = black; + fontcolor = black; + label = "A-21: Audit /\nCheck Reports"; + ] + + } + + subgraph cluster_boundary_RemoteVCSInfrastructure_579e9aae81 { + graph [ + fontsize = 10; + fontcolor = black; + style = dashed; + color = firebrick2; + label = <Remote VCS\nInfrastructure>; + ] + + externalentity_ARemoteVCSServer_d2006ce1bb [ + shape = square; + color = black; + fontcolor = black; + label = "A-09: Remote VCS\nServer"; + margin = 0.02; + ] + + externalentity_AArchiveHTTPServer_f8af758679 [ + shape = square; + color = black; + fontcolor = black; + label = "A-10: Archive HTTP\nServer"; + margin = 0.02; + ] + + datastore_AUpstreamSourceAttestationVSA_2c440ebe53 [ + shape = cylinder; + color = black; + fontcolor = black; + label = "A-23: Upstream\nSource Attestation\n(VSA)"; + ] + + } + + actor_Developer_f2eb7a3ff7 -> process_AdfetchProcess_c76a0a7067 [ + color = black; + fontcolor = black; + dir = forward; + label = "DF-01: Invoke\ndfetch command"; + ] + + datastore_AdfetchManifest_9345ab4c19 -> process_AdfetchProcess_c76a0a7067 [ + color = black; + fontcolor = black; + dir = forward; + label = "DF-02: Read\nmanifest"; + ] + + process_AdfetchProcess_c76a0a7067 -> externalentity_ARemoteVCSServer_d2006ce1bb [ + color = black; + fontcolor = black; + dir = forward; + label = "DF-03a: Fetch VCS\ncontent -\nHTTPS/SSH"; + ] + + process_AdfetchProcess_c76a0a7067 -> externalentity_ARemoteVCSServer_d2006ce1bb [ + color = black; + fontcolor = black; + dir = forward; + label = "DF-03b: Fetch VCS\ncontent - svn:// /\nhttp://"; + ] + + externalentity_ARemoteVCSServer_d2006ce1bb -> process_AdfetchProcess_c76a0a7067 [ + color = black; + fontcolor = black; + dir = forward; + label = "DF-04a: VCS\ncontent inbound -\nHTTPS/SSH"; + ] + + externalentity_ARemoteVCSServer_d2006ce1bb -> process_AdfetchProcess_c76a0a7067 [ + color = black; + fontcolor = black; + dir = forward; + label = "DF-04b: VCS\ncontent inbound -\nsvn:// / http://"; + ] + + process_AdfetchProcess_c76a0a7067 -> externalentity_AArchiveHTTPServer_f8af758679 [ + color = black; + fontcolor = black; + dir = forward; + label = "DF-05a: Archive\ndownload request -\nHTTPS"; + ] + + process_AdfetchProcess_c76a0a7067 -> externalentity_AArchiveHTTPServer_f8af758679 [ + color = black; + fontcolor = black; + dir = forward; + label = "DF-05b: Archive\ndownload request -\nHTTP"; + ] + + externalentity_AArchiveHTTPServer_f8af758679 -> process_AdfetchProcess_c76a0a7067 [ + color = black; + fontcolor = black; + dir = forward; + label = "DF-06a: Archive\nbytes - HTTPS"; + ] + + externalentity_AArchiveHTTPServer_f8af758679 -> process_AdfetchProcess_c76a0a7067 [ + color = black; + fontcolor = black; + dir = forward; + label = "DF-06b: Archive\nbytes - HTTP\n(plaintext risk)"; + ] + + process_AdfetchProcess_c76a0a7067 -> datastore_AFetchedSourceCode_86e4604564 [ + color = black; + fontcolor = black; + dir = forward; + label = "DF-07: Write\nvendored files"; + ] + + process_AdfetchProcess_c76a0a7067 -> datastore_ADependencyMetadata_9df04f8dae [ + color = black; + fontcolor = black; + dir = forward; + label = "DF-08: Write\ndependency\nmetadata"; + ] + + process_AdfetchProcess_c76a0a7067 -> datastore_ASBOMOutputCycloneDX_990b886585 [ + color = black; + fontcolor = black; + dir = forward; + label = "DF-09: Write SBOM"; + ] + + datastore_ADependencyMetadata_9df04f8dae -> process_AdfetchProcess_c76a0a7067 [ + color = black; + fontcolor = black; + dir = forward; + label = "DF-16: Read\ndependency\nmetadata"; + ] + + datastore_APatchFiles_65d0d57a00 -> process_PatchApplicationpatchng_c6f87088c2 [ + color = black; + fontcolor = black; + dir = forward; + label = "DF-10: Read patch\nfor application"; + ] + + process_PatchApplicationpatchng_c6f87088c2 -> datastore_AFetchedSourceCode_86e4604564 [ + color = black; + fontcolor = black; + dir = forward; + label = "DF-10b: Write\npatched files to\nvendor directory"; + ] + + datastore_AFetchedSourceCode_86e4604564 -> externalentity_AConsumerBuildSystem_0291419f72 [ + color = black; + fontcolor = black; + dir = forward; + label = "DF-15: Vendored\nsource to build"; + ] + + process_AdfetchProcess_c76a0a7067 -> process_ArchiveExtractiontarfilezipfile_da43120000 [ + color = black; + fontcolor = black; + dir = forward; + label = "DF-11: Dispatch\narchive bytes to\nextraction"; + ] + + process_ArchiveExtractiontarfilezipfile_da43120000 -> datastore_ALocalVCSCachetemp_ae6e32d09a [ + color = black; + fontcolor = black; + dir = forward; + label = "DF-12: Write\nextracted archive\nto temp dir"; + ] + + process_AdfetchProcess_c76a0a7067 -> process_SVNExportsvnexport_7eb89910ee [ + color = black; + fontcolor = black; + dir = forward; + label = "DF-13: Dispatch\nSVN export\nsubprocess"; + ] + + process_SVNExportsvnexport_7eb89910ee -> datastore_ALocalVCSCachetemp_ae6e32d09a [ + color = black; + fontcolor = black; + dir = forward; + label = "DF-14: Write SVN\nexport to temp dir"; + ] + + process_AdfetchProcess_c76a0a7067 -> datastore_AAuditCheckReports_d0c0ca0a3b [ + color = black; + fontcolor = black; + dir = forward; + label = "DF-17: Write audit\n/ check reports"; + ] + + datastore_ALocalVCSCachetemp_ae6e32d09a -> process_AdfetchProcess_c76a0a7067 [ + color = black; + fontcolor = black; + dir = forward; + label = "DF-22: Copy\nextracted content\nto vendor\ndirectory"; + ] + + datastore_AIntegrityHashRecord_b2e5892d06 -> process_AdfetchProcess_c76a0a7067 [ + color = black; + fontcolor = black; + dir = forward; + label = "DF-18: Read\nintegrity hash for\narchive\nverification"; + ] + + process_AdfetchProcess_c76a0a7067 -> datastore_AIntegrityHashRecord_b2e5892d06 [ + color = black; + fontcolor = black; + dir = forward; + label = "DF-18b: Write\ncomputed hash to\nmanifest (dfetch\nfreeze)"; + ] + + actor_Developer_f2eb7a3ff7 -> datastore_AdfetchManifest_9345ab4c19 [ + color = black; + fontcolor = black; + dir = forward; + label = "DF-20: Author /\nmaintain\ndfetch.yaml"; + ] + + externalentity_ARemoteVCSServer_d2006ce1bb -> datastore_AUpstreamSourceAttestationVSA_2c440ebe53 [ + color = black; + fontcolor = black; + dir = forward; + label = "DF-19: VCS server\npublishes source\nattestation (not\nconsumed by\ndfetch)"; + ] + + actor_Developer_f2eb7a3ff7 -> datastore_APatchFiles_65d0d57a00 [ + color = black; + fontcolor = black; + dir = forward; + label = "DF-21: Create /\nmaintain patch\nfiles"; + ] + + } + @enddot + +Sequence Diagram +---------------- + +.. uml:: + + @startuml + actor actor_Developer_f2eb7a3ff7 as "Developer" + entity externalentity_ARemoteVCSServer_d2006ce1bb as "A-09: Remote VCS Server" + entity externalentity_AArchiveHTTPServer_f8af758679 as "A-10: Archive HTTP Server" + database datastore_AUpstreamSourceAttestationVSA_2c440ebe53 as "A-23: Upstream Source Attestation (VSA)" + entity externalentity_AConsumerBuildSystem_0291419f72 as "A-11: Consumer Build System" + entity process_AdfetchProcess_c76a0a7067 as "A-22: dfetch Process" + entity process_PatchApplicationpatchng_c6f87088c2 as "Patch Application (patch-ng)" + database datastore_AdfetchManifest_9345ab4c19 as "A-12: dfetch Manifest" + database datastore_AFetchedSourceCode_86e4604564 as "A-13: Fetched Source Code" + database datastore_AIntegrityHashRecord_b2e5892d06 as "A-14: Integrity Hash Record" + database datastore_ASBOMOutputCycloneDX_990b886585 as "A-15: SBOM Output (CycloneDX)" + database datastore_ADependencyMetadata_9df04f8dae as "A-18: Dependency Metadata" + database datastore_APatchFiles_65d0d57a00 as "A-19: Patch Files" + entity process_ArchiveExtractiontarfilezipfile_da43120000 as "Archive Extraction (tarfile / zipfile)" + entity process_SVNExportsvnexport_7eb89910ee as "SVN Export (svn export)" + database datastore_ALocalVCSCachetemp_ae6e32d09a as "A-20: Local VCS Cache (temp)" + database datastore_AAuditCheckReports_d0c0ca0a3b as "A-21: Audit / Check Reports" + + actor_Developer_f2eb7a3ff7 -> process_AdfetchProcess_c76a0a7067: DF-01: Invoke dfetch command + datastore_AdfetchManifest_9345ab4c19 -> process_AdfetchProcess_c76a0a7067: DF-02: Read manifest + process_AdfetchProcess_c76a0a7067 -> externalentity_ARemoteVCSServer_d2006ce1bb: DF-03a: Fetch VCS content - HTTPS/SSH + process_AdfetchProcess_c76a0a7067 -> externalentity_ARemoteVCSServer_d2006ce1bb: DF-03b: Fetch VCS content - svn:// / http:// + externalentity_ARemoteVCSServer_d2006ce1bb -> process_AdfetchProcess_c76a0a7067: DF-04a: VCS content inbound - HTTPS/SSH + externalentity_ARemoteVCSServer_d2006ce1bb -> process_AdfetchProcess_c76a0a7067: DF-04b: VCS content inbound - svn:// / http:// + process_AdfetchProcess_c76a0a7067 -> externalentity_AArchiveHTTPServer_f8af758679: DF-05a: Archive download request - HTTPS + process_AdfetchProcess_c76a0a7067 -> externalentity_AArchiveHTTPServer_f8af758679: DF-05b: Archive download request - HTTP + externalentity_AArchiveHTTPServer_f8af758679 -> process_AdfetchProcess_c76a0a7067: DF-06a: Archive bytes - HTTPS + externalentity_AArchiveHTTPServer_f8af758679 -> process_AdfetchProcess_c76a0a7067: DF-06b: Archive bytes - HTTP (plaintext risk) + process_AdfetchProcess_c76a0a7067 -> datastore_AFetchedSourceCode_86e4604564: DF-07: Write vendored files + process_AdfetchProcess_c76a0a7067 -> datastore_ADependencyMetadata_9df04f8dae: DF-08: Write dependency metadata + process_AdfetchProcess_c76a0a7067 -> datastore_ASBOMOutputCycloneDX_990b886585: DF-09: Write SBOM + datastore_ADependencyMetadata_9df04f8dae -> process_AdfetchProcess_c76a0a7067: DF-16: Read dependency metadata + datastore_APatchFiles_65d0d57a00 -> process_PatchApplicationpatchng_c6f87088c2: DF-10: Read patch for application + process_PatchApplicationpatchng_c6f87088c2 -> datastore_AFetchedSourceCode_86e4604564: DF-10b: Write patched files to vendor directory + datastore_AFetchedSourceCode_86e4604564 -> externalentity_AConsumerBuildSystem_0291419f72: DF-15: Vendored source to build + process_AdfetchProcess_c76a0a7067 -> process_ArchiveExtractiontarfilezipfile_da43120000: DF-11: Dispatch archive bytes to extraction + process_ArchiveExtractiontarfilezipfile_da43120000 -> datastore_ALocalVCSCachetemp_ae6e32d09a: DF-12: Write extracted archive to temp dir + process_AdfetchProcess_c76a0a7067 -> process_SVNExportsvnexport_7eb89910ee: DF-13: Dispatch SVN export subprocess + process_SVNExportsvnexport_7eb89910ee -> datastore_ALocalVCSCachetemp_ae6e32d09a: DF-14: Write SVN export to temp dir + process_AdfetchProcess_c76a0a7067 -> datastore_AAuditCheckReports_d0c0ca0a3b: DF-17: Write audit / check reports + datastore_ALocalVCSCachetemp_ae6e32d09a -> process_AdfetchProcess_c76a0a7067: DF-22: Copy extracted content to vendor directory + datastore_AIntegrityHashRecord_b2e5892d06 -> process_AdfetchProcess_c76a0a7067: DF-18: Read integrity hash for archive verification + process_AdfetchProcess_c76a0a7067 -> datastore_AIntegrityHashRecord_b2e5892d06: DF-18b: Write computed hash to manifest (dfetch freeze) + actor_Developer_f2eb7a3ff7 -> datastore_AdfetchManifest_9345ab4c19: DF-20: Author / maintain dfetch.yaml + externalentity_ARemoteVCSServer_d2006ce1bb -> datastore_AUpstreamSourceAttestationVSA_2c440ebe53: DF-19: VCS server publishes source attestation (not consumed by dfetch) + actor_Developer_f2eb7a3ff7 -> datastore_APatchFiles_65d0d57a00: DF-21: Create / maintain patch files + @enduml + Controls -------- diff --git a/security/tm_common.py b/security/tm_common.py index 05e8f0fc2..01f55c274 100644 --- a/security/tm_common.py +++ b/security/tm_common.py @@ -5,6 +5,7 @@ """ import os +import re import sys from collections.abc import Callable from dataclasses import dataclass, field @@ -111,22 +112,6 @@ def make_supply_chain_assumptions() -> list[Assumption]: "GitHub-hosted runner. Ephemeral runner isolation is provided by GitHub." ), ), - Assumption( - "Harden-runner in block mode", - description=( - "The ``harden-runner`` egress policy is set to ``block`` with an " - "allowlist of permitted endpoints. Outbound network connections from " - "CI runners are blocked unless explicitly permitted." - ), - ), - Assumption( - "Build deps without hash pinning", - description=( - "dfetch's own build and dev dependencies are installed without " - "``--require-hashes``, so a compromised PyPI mirror can substitute " - "malicious build tools." - ), - ), ] @@ -309,6 +294,136 @@ def _fixed(element: Any) -> str: ReportUtils.getInScopeFindings = staticmethod(_fixed) +def _fix_pytm_dot_output(dot: str) -> str: + """Replace non-portable pytm image references with Graphviz-native shapes. + + pytm emits absolute filesystem paths like + ``image = "/home/user/.../pytm/images/datastore_black.png"`` + which break on any machine other than the one that generated the diagram. + Replace the four-attribute image block with a single portable cylinder shape, + then rename ``xlabel`` to ``label`` and drop the now-redundant ``label = ""``. + """ + dot = re.sub( + r"([ \t]*)shape = none;\n[ \t]*fixedsize = shape;\n" + r'[ \t]*image = "[^"]*pytm/images/[^"]*";\n[ \t]*imagescale = true;', + r"\1shape = cylinder;", + dot, + ) + dot = re.sub(r"\bxlabel\b", "label", dot) + dot = re.sub(r'[ \t]+label = "";\n', "", dot) + return dot + + +_FULLSCREEN_HTML = """\ +.. raw:: html + +
+ +{uml_block} + +.. raw:: html + +
+ + +""" + + +def _fullscreen_wrap(uml_rst: str) -> str: + """Wrap a ``.. uml::`` RST block with click-to-fullscreen HTML (HTML output only).""" + return _FULLSCREEN_HTML.format(uml_block=uml_rst.rstrip("\n")) + + +def _render_dfd_section(tm: Any) -> str: + """Render the DFD as an RST ``.. uml::`` block using PlantUML @startdot. + + Returns an empty string when ``tm.dfd()`` produces no content. + """ + dfd = tm.dfd() + if not dfd or not dfd.strip(): + return "" + dfd = _fix_pytm_dot_output(dfd) + wrapped = "@startdot\n" + dfd + "\n@enddot" + indented = "\n".join(" " + line if line else "" for line in wrapped.splitlines()) + uml_rst = ( + "Data Flow Diagram\n" "-----------------\n\n" ".. uml::\n\n" f"{indented}\n" + ) + return _fullscreen_wrap(uml_rst) + + +def _wrap_seq_participant_labels(seq: str, max_chars: int = 12) -> str: + """Wrap long participant labels at word boundaries to narrow each column. + + PlantUML uses a literal ``\\n`` inside quoted strings as a line break. + Narrower participant boxes shrink the natural diagram width, so when Sphinx + scales the image to page width the font displays proportionally larger. + """ + + def _wrap(label: str) -> str: + words = label.split() + lines: list[str] = [] + current: list[str] = [] + width = 0 + for word in words: + gap = 1 if current else 0 + if width + gap + len(word) > max_chars and current: + lines.append(" ".join(current)) + current = [word] + width = len(word) + else: + current.append(word) + width += gap + len(word) + if current: + lines.append(" ".join(current)) + return r"\n".join(lines) + + return re.sub(r'as "([^"]+)"', lambda m: f'as "{_wrap(m.group(1))}"', seq) + + +def _render_seq_section(tm: Any) -> str: + """Render the sequence diagram as an RST ``.. uml::`` block. + + Returns an empty string when ``tm.seq()`` produces no content. + """ + seq = tm.seq() + if not seq or not seq.strip(): + return "" + seq = _wrap_seq_participant_labels(seq) + seq = seq.replace( + "@startuml\n", + "@startuml\nskinparam defaultFontSize 16\n", + 1, + ) + indented = "\n".join(" " + line if line else "" for line in seq.splitlines()) + uml_rst = "Sequence Diagram\n" "----------------\n\n" ".. uml::\n\n" f"{indented}\n" + return _fullscreen_wrap(uml_rst) + + def run_model(build_model_fn: Callable[[], Any], controls: list[Control]) -> None: """Run a threat model from a ``__main__`` entry point. @@ -323,62 +438,18 @@ def run_model(build_model_fn: Callable[[], Any], controls: list[Control]) -> Non print(f"Usage: python {script} --report ", file=sys.stderr) raise SystemExit(1) tm.resolve() - print(tm.report(sys.argv[_idx + 1])) + title = tm.name + print(title) + print("=" * len(title)) + print() + rendered = tm.report(sys.argv[_idx + 1]) + dfd = _render_dfd_section(tm) + seq = _render_seq_section(tm) + rendered = rendered.replace( + "\nDataflows\n---------", + f"\n{dfd}\n{seq}\nDataflows\n---------", + ) + print(rendered) print(render_controls_section(controls)) else: tm.process() - - -def make_attacker_profiles() -> list[Assumption]: - """Return Assumption objects describing the in-scope adversary classes.""" - return [ - Assumption( - "Attacker: Network-adjacent", - description=( - "Positioned on the same network segment as a CI runner or developer " - "workstation - shared cloud tenant, BGP hijack, compromised DNS resolver, " - "or corporate proxy. Can intercept and modify unencrypted traffic " - "(http://, svn://) and inject HTTP redirects. Cannot break correctly " - "implemented TLS or SSH." - ), - ), - Assumption( - "Attacker: Compromised upstream", - description=( - "A dependency maintainer account taken over via phishing, credential " - "stuffing, or MFA bypass - or a maintainer acting maliciously. " - "Delivers attacker-controlled content over an authenticated channel; " - "transport security provides no protection. Mitigated only by " - "commit-SHA pinning and human review before accepting any update." - ), - ), - Assumption( - "Attacker: Compromised registry or CDN", - description=( - "Holds write access to a public package registry (PyPI) or an archive " - "CDN node, or is BGP-adjacent to one. Serves malicious content under " - "a valid TLS certificate - transport integrity does not detect " - "server-side substitution. Only cryptographic content hashes or " - "signed attestations provide a defence." - ), - ), - Assumption( - "Attacker: Local filesystem", - description=( - "Holds write access to the developer workstation or CI runner working " - "tree - gained via a compromised dev dependency, malicious post-install " - "hook, or lateral movement. Can tamper with ``.dfetch_data.yaml``, " - "patch files, and vendored source after dfetch writes them to disk." - ), - ), - Assumption( - "Attacker: Malicious manifest contributor", - description=( - "A repository contributor who introduces a malicious ``dfetch.yaml`` " - "change: redirecting a dep to an attacker-controlled URL, pointing " - "``dst:`` at a sensitive path, or embedding a credential-bearing URL. " - "dfetch is not the control point for this threat; code review is the " - "intended mitigating boundary." - ), - ), - ] diff --git a/security/tm_supply_chain.py b/security/tm_supply_chain.py index 7ea7a9ba9..2cb8cad63 100644 --- a/security/tm_supply_chain.py +++ b/security/tm_supply_chain.py @@ -38,9 +38,7 @@ ControlAssessment, apply_report_utils_patch, build_asset_controls_index, - make_attacker_profiles, make_dev_env_boundary, - make_network_boundary, make_supply_chain_assumptions, run_model, ) @@ -49,52 +47,51 @@ def _make_sc_boundaries() -> tuple[Boundary, Boundary, Boundary, Boundary]: """Create and return supply-chain trust boundaries.""" b_dev = make_dev_env_boundary() - b_github = Boundary("GitHub Actions Infrastructure") + b_consumer = Boundary("Consumer Environment") + b_consumer.description = ( + "End-user workstation or downstream CI pipeline where dfetch is installed " + "and invoked. Distinct from the developer environment: no source checkout, " + "no signing keys, no deploy access. The consumer is trusted at invocation " + "time but has no special relationship with the dfetch release infrastructure." + ) + b_github = Boundary("GitHub Platform") b_github.description = ( - "Microsoft-operated ephemeral runners executing the CI/CD workflows. " - "Egress traffic is blocked (``harden-runner`` with ``egress-policy: block``) " - "with an allowlist of permitted endpoints; ``ci.yml`` forwards only " - "explicitly named secrets to child workflows (``CODACY_PROJECT_TOKEN`` " - "to ``test.yml``, ``GH_DFETCH_ORG_DEPLOY`` to ``docs.yml``)." + "GitHub-hosted infrastructure: repository, CI/CD runners, Actions workflows, " + "build cache, and code-scanning results. " + "Egress traffic on runners is blocked (``harden-runner`` with " + "``egress-policy: block``) with an allowlist of permitted endpoints; " + "``ci.yml`` forwards only explicitly named secrets to child workflows " + "(``CODACY_PROJECT_TOKEN`` to ``test.yml``, ``GH_DFETCH_ORG_DEPLOY`` " + "to ``docs.yml``)." ) b_pypi = Boundary("PyPI / TestPyPI") b_pypi.description = ( "Python Package Index and its staging registry. dfetch publishes via " "OIDC trusted publishing - no long-lived API token stored." ) - b_net = make_network_boundary( - description=( - "Public internet - upstream package registries, PyPI, GitHub, CDN nodes, " - "and other external endpoints reachable by the CI/CD infrastructure." - ) - ) - return b_dev, b_github, b_pypi, b_net + return b_dev, b_consumer, b_github, b_pypi def _make_sc_actors_and_entities( b_dev: Boundary, + b_consumer: Boundary, b_github: Boundary, b_pypi: Boundary, - b_net: Boundary, -) -> tuple[Actor, Actor, ExternalEntity, ExternalEntity, ExternalEntity]: - """Create actors and external entities; return those referenced by dataflows.""" - developer = Actor("Developer") +) -> tuple[ + Actor, Actor, ExternalEntity, ExternalEntity, ExternalEntity, ExternalEntity +]: + """Create actors and external entities; return all for use in dataflows.""" + developer = Actor("Developer / Contributor") developer.inBoundary = b_dev developer.description = ( - "dfetch project contributor: writes code, reviews PRs, cuts releases. " - "Trusted at workstation time; responsible for correct branch-protection " - "and release workflow configuration." - ) - contributor = Actor("Contributor / Attacker") - contributor.inBoundary = b_net - contributor.description = ( - "External contributor submitting pull requests, or an adversary attempting " - "supply-chain manipulation (malicious PR, action-poisoning, or MITM on CI " - "network traffic). Code review, branch protection, and SHA-pinned Actions " - "are the primary controls at this boundary." + "Anyone who writes code for dfetch: core maintainers who push directly and " + "cut releases, and external contributors who submit pull requests. " + "Maintainers are trusted at workstation time and are responsible for correct " + "branch-protection and release workflow configuration. " + "External contributors are untrusted until their PR passes code review and CI." ) consumer = Actor("Consumer / End User") - consumer.inBoundary = b_dev + consumer.inBoundary = b_consumer consumer.description = ( "Installs dfetch from PyPI (``pip install dfetch``) or from binary installer, " "then invokes it on a developer workstation or in a CI pipeline. " @@ -104,13 +101,32 @@ def _make_sc_actors_and_entities( "installers; SLSA build provenance, in-toto test result attestation, and SLSA Source " "Provenance Attestation on the source archive and main-branch commits." ) - gh_repository = ExternalEntity("A-01: GitHub Repository") + gh_repository = ExternalEntity("A-01: GitHub Repository (main / protected)") gh_repository.inBoundary = b_github gh_repository.classification = Classification.RESTRICTED gh_repository.description = ( - "Source code, PRs, releases, and workflow definitions. " - "GitHub Actions workflows (``.github/workflows/``) with " - "``contents:write`` permission can modify repository state and trigger releases." + "The protected ``main`` branch: force-push disabled, merges require passing CI " + "and at least one approving review. Contains the authoritative workflow " + "definitions (``.github/workflows/``), release tags, and published release assets. " + "Workflow files on main are what GitHub Actions actually executes — a PR cannot " + "override them for its own CI run. " + "``contents:write`` permission allows CI to upload SARIF results and release assets." + ) + gh_repository.controls.hasAccessControl = True + gh_repository.controls.providesIntegrity = True + gh_repository_branches = ExternalEntity( + "A-01b: GitHub Repository (feature branches / PRs)" + ) + gh_repository_branches.inBoundary = b_github + gh_repository_branches.classification = Classification.RESTRICTED + gh_repository_branches.description = ( + "Unprotected feature branches and fork PRs: no mandatory review, no CI-gate " + "requirement to push. Any authenticated GitHub user can open a PR modifying " + "``.github/workflows/`` files; those changes are reviewed before merging to main " + "but execute with restricted permissions during CI (no access to production secrets). " + "A malicious PR modifying workflow files could attempt to exfiltrate secrets " + "during the PR CI run, mitigated by ``ci.yml`` secret scoping (C-024) and " + "harden-runner egress block (C-013)." ) gh_actions_runner = ExternalEntity("A-02: GitHub Actions Infrastructure") gh_actions_runner.inBoundary = b_github @@ -123,17 +139,34 @@ def _make_sc_actors_and_entities( ) pypi = ExternalEntity("A-03: PyPI / TestPyPI") pypi.inBoundary = b_pypi - pypi.classification = Classification.PUBLIC + pypi.classification = Classification.SENSITIVE pypi.description = ( - "Python Package Index. dfetch is published via OIDC trusted publishing " - "(no long-lived API token). Account takeover or registry compromise " - "would affect every consumer installing dfetch." + "Python Package Index - both the registry service and the published dfetch " + "wheel/sdist (https://pypi.org/project/dfetch/). " + "Published via OIDC trusted publishing; no long-lived API token stored. " + "A machine-readable CycloneDX SBOM is generated during the build and " + "published alongside the release. " + "Account takeover, registry compromise, or namespace-squatting would affect " + "every consumer installing dfetch." + ) + pypi.controls.usesCodeSigning = ( + True # Sigstore SBOM attestation (actions/attest; predicate cyclonedx.org/bom) + ) + pypi.controls.isHardened = ( + True # OIDC trusted publishing; no stored long-lived token + ) + return ( + developer, + consumer, + gh_repository, + gh_repository_branches, + gh_actions_runner, + pypi, ) - return contributor, consumer, gh_repository, gh_actions_runner, pypi -def _make_sc_processes(b_github: Boundary) -> None: - """Create CI/CD process elements; all auto-register with TM.""" +def _make_sc_processes(b_github: Boundary) -> tuple[Process, Process, Process]: + """Create CI/CD process elements; return all for use in dataflows.""" release_gate = Process("Release Gate / Code Review") release_gate.inBoundary = b_github release_gate.description = ( @@ -184,29 +217,11 @@ def _make_sc_processes(b_github: Boundary) -> None: False # build deps installed without --require-hashes; see gap C-023 ) python_build.controls.isCiPipeline = True # CI/release pipeline build step + return release_gate, gh_actions_workflow, python_build -def _make_sc_datastores(b_github: Boundary, b_pypi: Boundary) -> Datastore: - """Create data assets; return gh_actions_cache (referenced by dataflows).""" - pypi_package = Datastore("A-04: dfetch PyPI Package") - pypi_package.inBoundary = b_pypi - pypi_package.description = ( - "Published wheel and sdist on PyPI (https://pypi.org/project/dfetch/). " - "Published via OIDC trusted publishing - no long-lived API token stored. " - "A machine-readable CycloneDX SBOM is generated during the build and " - "published alongside the release. " - "Compromise of the PyPI account or registry affects every consumer." - ) - pypi_package.storesSensitiveData = False - pypi_package.hasWriteAccess = True # publish pipeline writes new releases - pypi_package.isSQL = False - pypi_package.classification = Classification.SENSITIVE - pypi_package.controls.usesCodeSigning = ( - True # Sigstore SBOM attestation (actions/attest; predicate cyclonedx.org/bom) - ) - pypi_package.controls.isHardened = ( - True # OIDC trusted publishing; no stored long-lived token - ) +def _make_sc_datastores(b_github: Boundary) -> tuple[Datastore, Datastore]: + """Create data assets; return all for use in dataflows.""" Data( "A-05: PyPI OIDC Identity", description=( @@ -223,25 +238,6 @@ def _make_sc_datastores(b_github: Boundary, b_pypi: Boundary) -> Datastore: isDestEncryptedAtRest=False, isSourceEncryptedAtRest=True, ) - scorecard_results = Datastore("A-06: OpenSSF Scorecard Results") - scorecard_results.inBoundary = b_github - scorecard_results.description = ( - "Weekly OSSF Scorecard SARIF results uploaded to GitHub Code Scanning. " - "Covers: branch-protection, CI-tests, code-review, maintained, packaging, " - "pinned-dependencies, SAST, signed-releases, token-permissions, vulnerabilities, " - "dangerous-workflow, binary-artifacts, fuzzing, license, CII-best-practices, " - "security-policy, webhooks. " - "Suppression or forgery hides supply-chain regressions." - ) - scorecard_results.storesSensitiveData = False - scorecard_results.hasWriteAccess = ( - True # CI runner writes SARIF uploads here (DF-17) - ) - scorecard_results.isSQL = False - scorecard_results.classification = Classification.RESTRICTED - scorecard_results.controls.providesIntegrity = ( - True # authenticated output from OSSF Scorecard action - ) dfetch_dev_deps = Datastore("A-07: dfetch Build / Dev Dependencies") dfetch_dev_deps.inBoundary = b_github dfetch_dev_deps.description = ( @@ -256,24 +252,6 @@ def _make_sc_datastores(b_github: Boundary, b_pypi: Boundary) -> Datastore: dfetch_dev_deps.hasWriteAccess = False dfetch_dev_deps.isSQL = False dfetch_dev_deps.classification = Classification.RESTRICTED - gh_workflows = Datastore("A-08: GitHub Actions Workflows") - gh_workflows.inBoundary = b_github - gh_workflows.description = ( - "``.github/workflows/*.yml`` - CI/CD configuration checked into the repository. " - "11 workflows: ci, build, run, test, docs, release, python-publish, " - "dependency-review, codeql-analysis, scorecard, devcontainer. " - "A malicious PR that modifies workflows can exfiltrate secrets or publish " - "a backdoored release. " - "Mitigated by: SHA-pinned actions, persist-credentials:false, minimal permissions." - ) - gh_workflows.storesSensitiveData = False - gh_workflows.hasWriteAccess = True # PRs can modify .github/workflows/ definitions - gh_workflows.isSQL = False - gh_workflows.classification = Classification.RESTRICTED - gh_workflows.controls.isHardened = True - gh_workflows.controls.providesIntegrity = ( - True # branch protection + mandatory code review - ) gh_actions_cache = Datastore("A-08b: GitHub Actions Build Cache") gh_actions_cache.inBoundary = b_github gh_actions_cache.description = ( @@ -291,75 +269,155 @@ def _make_sc_datastores(b_github: Boundary, b_pypi: Boundary) -> Datastore: ) gh_actions_cache.isSQL = False gh_actions_cache.classification = Classification.RESTRICTED - return gh_actions_cache + return dfetch_dev_deps, gh_actions_cache -def _make_sc_contrib_flows( - contributor: Actor, - consumer: Actor, - gh_repository: ExternalEntity, - pypi: ExternalEntity, +def _make_sc_code_contribution_flows( + developer: Actor, + gh_repository_branches: ExternalEntity, + release_gate: Process, ) -> None: - """Create contributor- and consumer-facing dataflows; all auto-register with TM.""" - df11 = Dataflow(contributor, gh_repository, "DF-11: Submit pull request") + """Create code-contribution and review dataflows (DF-11, DF-22); auto-register with TM.""" + df11 = Dataflow(developer, gh_repository_branches, "DF-11: Push commits / open PR") df11.description = ( - "External contributor opens a PR against the dfetch repository. " - "Workflow files in ``.github/workflows/`` can be modified by PRs. " - "``ci.yml`` only passes required repository secrets to the test and docs " - "workflows, preventing malicious PR steps from exfiltrating secrets." + "Developer or contributor pushes commits or opens a pull request targeting a " + "feature branch (A-01b). The entry point for all code changes. " + "Workflow files in ``.github/workflows/`` can be modified by PRs but are " + "reviewed before merging; ``ci.yml`` only passes required repository secrets " + "to child workflows, preventing PR CI steps from exfiltrating unrelated secrets." ) df11.protocol = "HTTPS" + df11.controls.isEncrypted = True df11.controls.hasAccessControl = True - df11.controls.isHardened = ( - True # git commit SHAs provide content-addressable integrity - ) df11.controls.providesIntegrity = True - df14 = Dataflow(consumer, pypi, "DF-14: pip install dfetch") - df14.description = ( - "Consumer installs dfetch from PyPI. The installed wheel contains the dfetch " - "CLI; the handoff to the runtime-usage model occurs when the consumer invokes " - "dfetch with a manifest. " - "The consumer can verify the SBOM (CycloneDX) attestation of the wheel using " - "``gh attestation verify`` with ``--predicate-type https://cyclonedx.org/bom`` " - "and cert-identity pinned to ``python-publish.yml`` (see C-026)." + + df22 = Dataflow( + gh_repository_branches, release_gate, "DF-22: PR enters code review" ) - df14.protocol = "HTTPS" - df14.controls.isEncrypted = True - df14.controls.isNetworkFlow = True + df22.description = ( + "Pull request from a feature branch (A-01b) flows into the Release Gate / " + "Code Review process. Branch-protection rules require passing CI status " + "checks and at least one approving review before any merge to main is permitted." + ) + df22.controls.hasAccessControl = True -def _make_sc_ci_flows( +def _make_sc_workflow_checkout_flows( gh_repository: ExternalEntity, + gh_actions_workflow: Process, + gh_repository_branches: ExternalEntity, gh_actions_runner: ExternalEntity, - pypi: ExternalEntity, - gh_actions_cache: Datastore, ) -> None: - """Create CI infrastructure dataflows; all auto-register with TM.""" - df12 = Dataflow(gh_repository, gh_actions_runner, "DF-12: CI checkout and build") + """Create workflow-definition and CI-checkout dataflows (DF-12, DF-13a/b); auto-register with TM.""" + df12 = Dataflow( + gh_repository, + gh_actions_workflow, + "DF-12: Main branch workflows drive CI execution", + ) df12.description = ( - "GitHub Actions checks out source, installs deps, runs tests, lints, builds. " - "``persist-credentials:false`` on all checkout steps. " - "All third-party actions pinned by commit SHA." + "The ``.github/workflows/*.yml`` YAML files on the protected main branch (A-01) " + "define what GitHub Actions executes. GitHub enforces that PR CI runs use the " + "workflow files from the base branch (main), not from the PR branch — a malicious " + "PR cannot override the workflow for its own CI run. " + "Mitigated by SHA-pinned actions, minimal permissions, and mandatory code review " + "before any workflow change reaches main (C-009, C-011)." ) df12.controls.isHardened = True - df12.controls.providesIntegrity = True - df13 = Dataflow(gh_actions_runner, pypi, "DF-13: Publish to PyPI (OIDC)") - df13.description = ( - "On release event, wheel/sdist uploaded to PyPI via " - "``pypa/gh-action-pypi-publish``. OIDC trusted publishing: no stored API token. " - "Four attestation types are generated by the release pipeline and anchored in Sigstore: " - "SLSA build provenance, SBOM (CycloneDX), VSA (Verification Summary Attestation) on " - "binary installers, and in-toto test result attestation on the source archive." - ) - df13.protocol = "HTTPS" - df13.controls.isEncrypted = True - df13.controls.isHardened = ( - True # OIDC token exchange; no long-lived credential in transit + + df13a = Dataflow( + gh_repository_branches, gh_actions_runner, "DF-13a: PR CI checkout" ) - df13.controls.providesIntegrity = ( - True # Sigstore SBOM attestation covers published artifacts + df13a.description = ( + "GitHub Actions checks out source from the feature branch / PR (A-01b) for " + "CI runs triggered by pull requests. ``persist-credentials:false`` on all " + "checkout steps. Runs with restricted permissions — no access to production " + "secrets (see C-024)." + ) + df13a.controls.isHardened = True + + df13b = Dataflow(gh_repository, gh_actions_runner, "DF-13b: Release CI checkout") + df13b.description = ( + "GitHub Actions checks out the tagged commit from main (A-01) for release " + "and build workflows. ``persist-credentials:false`` on all checkout steps. " + "All third-party actions pinned by commit SHA." ) - df13.controls.usesCodeSigning = True # SBOM attestation via actions/attest + df13b.controls.isHardened = True + df13b.controls.providesIntegrity = True + + +def _make_sc_cache_restore_flow( + gh_actions_cache: Datastore, + gh_actions_runner: ExternalEntity, +) -> None: + """Create CI cache-restore dataflow (DF-14); auto-registers with TM.""" + df14 = Dataflow(gh_actions_cache, gh_actions_runner, "DF-14: CI cache restore") + df14.description = ( + "GitHub Actions runner restores a previously written cache entry before " + "running build steps. Ref-scoped cache keys (C-033) ensure a release-tag build " + "restores only entries written under the same ref, not those written by a less-privileged " + "PR workflow. Residual risk: if ref-scoped keys were removed or misconfigured, the release " + "build could consume attacker-controlled artifacts." + ) + df14.protocol = "HTTPS" + df14.controls.isEncrypted = True + + +def _make_sc_build_step_flows( + gh_actions_workflow: Process, + python_build: Process, + pypi: ExternalEntity, + dfetch_dev_deps: Datastore, + gh_actions_runner: ExternalEntity, +) -> None: + """Create build-trigger, dep-fetch, build-tool-consume, and artifact-output flows (DF-15 to DF-17b).""" + df15 = Dataflow( + gh_actions_workflow, python_build, "DF-15: Workflow triggers build step" + ) + df15.description = ( + "The executing workflow process invokes the Python Build step " + "(``python -m build``) to produce wheel and sdist artefacts. " + "Build tools (setuptools, fpm, etc.) are installed without hash-pinning at this " + "point; a compromised package from PyPI could execute arbitrary code during the " + "build (gap C-023)." + ) + + df15b = Dataflow( + python_build, gh_actions_runner, "DF-15b: Built wheel/sdist artifacts" + ) + df15b.description = ( + "The Python Build step produces wheel and sdist artefacts, which are written to " + "the runner filesystem and later consumed by the publish step (DF-24). " + "A compromised build tool (DF-17 gap) can tamper with the artefact content at " + "this point before attestations are generated." + ) + + df16 = Dataflow(pypi, dfetch_dev_deps, "DF-16: CI fetches build/dev deps from PyPI") + df16.description = ( + "The CI runner installs build and dev dependencies from public PyPI: " + "setuptools, build, pylint, mypy, pytest, etc. " + "No ``--require-hashes`` flag is used, so a compromised PyPI mirror or BGP " + "hijack can substitute malicious build tooling without triggering any checksum " + "failure (gap C-023)." + ) + df16.protocol = "HTTPS" + df16.controls.isEncrypted = True + df16.controls.isNetworkFlow = True + + df17 = Dataflow( + dfetch_dev_deps, python_build, "DF-17: Build tools consumed by build step" + ) + df17.description = ( + "Installed build/dev dependencies (A-07) are consumed by the Python Build " + "process. If any dependency was substituted by an attacker (DF-16 gap), the " + "malicious code runs here with runner-level privileges." + ) + + +def _make_sc_cache_write_flow( + gh_actions_runner: ExternalEntity, + gh_actions_cache: Datastore, +) -> None: + """Create CI cache-write dataflow (DF-18); auto-registers with TM.""" df18 = Dataflow(gh_actions_runner, gh_actions_cache, "DF-18: CI cache write") df18.description = ( "GitHub Actions runner writes build cache entries (pip dependencies, gem packages, " @@ -371,38 +429,132 @@ def _make_sc_ci_flows( ) df18.protocol = "HTTPS" df18.controls.isEncrypted = True - df19 = Dataflow(gh_actions_cache, gh_actions_runner, "DF-19: CI cache restore") - df19.description = ( - "GitHub Actions runner restores a previously written cache entry before " - "running build steps. Ref-scoped cache keys (C-033) ensure a release-tag build " - "restores only entries written under the same ref, not those written by a less-privileged " - "PR workflow. Residual risk: if ref-scoped keys were removed or misconfigured, the release " - "build could consume attacker-controlled artifacts." - ) - df19.protocol = "HTTPS" - df19.controls.isEncrypted = True - df17 = Dataflow( + + +def _make_sc_ci_reporting_flows( + gh_actions_runner: ExternalEntity, + gh_repository: ExternalEntity, +) -> None: + """Create CI write-back dataflow (DF-19); auto-registers with TM.""" + df19 = Dataflow( gh_actions_runner, gh_repository, - "DF-17: CI write-back (SARIF / artifacts / cache)", + "DF-19: CI write-back (SARIF / artifacts)", ) - df17.description = ( + df19.description = ( "CI runner uploads artifacts back to the repository: SARIF results to GitHub " - "Code Scanning (CodeQL, Scorecard), release assets, and build cache. " - "Suppression or falsification of SARIF uploads (A-06) would hide supply-chain " - "regressions from the security dashboard. " + "Code Scanning (CodeQL, Scorecard) and release assets. " "Mitigated by: authenticated GitHub API (GITHUB_TOKEN scoped to ``security-events: write``), " "SHA-pinned upload actions." ) - df17.protocol = "HTTPS" - df17.controls.isEncrypted = True - df17.controls.isHardened = ( - True # GITHUB_TOKEN is scoped; harden-runner blocks exfiltration + df19.protocol = "HTTPS" + df19.controls.isEncrypted = True + df19.controls.isHardened = True + df19.controls.providesIntegrity = True + df19.controls.hasAccessControl = True + + +def _make_sc_merge_flow( + release_gate: Process, + gh_repository: ExternalEntity, +) -> None: + """Create approved-merge dataflow (DF-23); auto-registers with TM.""" + df23 = Dataflow(release_gate, gh_repository, "DF-23: Approved merge to main") + df23.description = ( + "After peer review approval and all required CI checks pass, the Release Gate " + "permits the merge. The resulting commit on main may trigger downstream " + "CI workflows including the release pipeline." + ) + df23.controls.hasAccessControl = True + df23.controls.providesIntegrity = True + + +def _make_sc_publish_flow( + gh_actions_runner: ExternalEntity, + pypi: ExternalEntity, +) -> None: + """Create the PyPI publish dataflow (DF-24); auto-registers with TM.""" + df24 = Dataflow(gh_actions_runner, pypi, "DF-24: Publish wheel to PyPI (OIDC)") + df24.description = ( + "On release event, the GitHub Actions runner uses ``pypa/gh-action-pypi-publish`` " + "with OIDC trusted publishing to upload the wheel and sdist produced by the Python " + "Build step to PyPI (A-03). No long-lived API token " + "is stored; the OIDC token is scoped to the ``pypi`` deployment environment. " + "Four attestation types are generated and anchored in Sigstore: " + "SLSA build provenance, SBOM (CycloneDX), VSA on binary installers, and in-toto " + "test result attestation on the source archive (C-021, C-039, C-040)." + ) + df24.protocol = "HTTPS" + df24.controls.isEncrypted = True + df24.controls.isHardened = ( + True # OIDC token exchange; no long-lived credential in transit ) - df17.controls.providesIntegrity = True - df17.controls.hasAccessControl = ( - True # GITHUB_TOKEN permission scoping per workflow + df24.controls.providesIntegrity = True + df24.controls.usesCodeSigning = True # Sigstore attestations via actions/attest + + +def _make_sc_consumer_install_flows( + consumer: Actor, + pypi: ExternalEntity, +) -> None: + """Create consumer pip-install and download dataflows (DF-25, DF-26); auto-register with TM.""" + df25 = Dataflow(consumer, pypi, "DF-25: pip install dfetch") + df25.description = ( + "Consumer installs dfetch from PyPI. The installed wheel contains the dfetch " + "CLI; the handoff to the runtime-usage model occurs when the consumer invokes " + "dfetch with a manifest. " + "The consumer can verify the SBOM (CycloneDX) attestation of the wheel using " + "``gh attestation verify`` with ``--predicate-type https://cyclonedx.org/bom`` " + "and cert-identity pinned to ``python-publish.yml`` (see C-026)." ) + df25.protocol = "HTTPS" + df25.controls.isEncrypted = True + df25.controls.isNetworkFlow = True + + df26 = Dataflow(pypi, consumer, "DF-26: Consumer downloads dfetch from PyPI") + df26.description = ( + "The published dfetch wheel (A-03) is downloaded to the consumer workstation " + "during ``pip install dfetch``. The consumer can verify the SBOM (CycloneDX) " + "attestation using ``gh attestation verify`` as documented in C-026. " + "Without verification, a compromised PyPI account or namespace-squatting " + "could serve a malicious package undetected (DFT-17)." + ) + df26.protocol = "HTTPS" + df26.controls.isEncrypted = True + df26.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.""" + ( + developer, + consumer, + gh_repository, + gh_repository_branches, + gh_actions_runner, + pypi, + ) = _make_sc_actors_and_entities(b_dev, b_consumer, b_github, b_pypi) + release_gate, gh_actions_workflow, python_build = _make_sc_processes(b_github) + dfetch_dev_deps, gh_actions_cache = _make_sc_datastores(b_github) + # Lifecycle: commit/PR → CI → review → merge → publish → consumer install + _make_sc_code_contribution_flows(developer, gh_repository_branches, release_gate) + _make_sc_workflow_checkout_flows( + gh_repository, gh_actions_workflow, gh_repository_branches, gh_actions_runner + ) + _make_sc_cache_restore_flow(gh_actions_cache, gh_actions_runner) + _make_sc_build_step_flows( + gh_actions_workflow, python_build, pypi, dfetch_dev_deps, gh_actions_runner + ) + _make_sc_cache_write_flow(gh_actions_runner, gh_actions_cache) + _make_sc_ci_reporting_flows(gh_actions_runner, gh_repository) + _make_sc_merge_flow(release_gate, gh_repository) + _make_sc_publish_flow(gh_actions_runner, pypi) + _make_sc_consumer_install_flows(consumer, pypi) def build_model() -> TM: @@ -421,15 +573,9 @@ def build_model() -> TM: mergeResponses=True, threatsFile=THREATS_FILE, ) - model.assumptions = make_supply_chain_assumptions() + make_attacker_profiles() - b_dev, b_github, b_pypi, b_net = _make_sc_boundaries() - contributor, consumer, gh_repository, gh_actions_runner, pypi = ( - _make_sc_actors_and_entities(b_dev, b_github, b_pypi, b_net) - ) - _make_sc_processes(b_github) - gh_actions_cache = _make_sc_datastores(b_github, b_pypi) - _make_sc_contrib_flows(contributor, consumer, gh_repository, pypi) - _make_sc_ci_flows(gh_repository, gh_actions_runner, pypi, gh_actions_cache) + model.assumptions = make_supply_chain_assumptions() + b_dev, b_consumer, b_github, b_pypi = _make_sc_boundaries() + _make_sc_elements_and_flows(b_dev, b_consumer, b_github, b_pypi) return model @@ -440,7 +586,7 @@ def build_model() -> TM: Control( id="C-009", name="Actions commit-SHA pinning", - assets=["A-08", "A-02"], + assets=["A-01", "A-02"], threats=["DFT-07"], reference=".github/workflows/*.yml", assessment=ControlAssessment(risk="High", stride=["Tampering"]), @@ -452,7 +598,7 @@ def build_model() -> TM: Control( id="C-010", name="OIDC trusted publishing", - assets=["A-05", "A-04"], + assets=["A-05", "A-03"], threats=["DFT-07"], reference=".github/workflows/python-publish.yml", assessment=ControlAssessment( @@ -466,7 +612,7 @@ def build_model() -> TM: Control( id="C-011", name="Minimal workflow permissions", - assets=["A-08"], + assets=["A-01"], threats=["DFT-07"], reference=".github/workflows/*.yml", assessment=ControlAssessment(risk="Medium", stride=["Elevation of Privilege"]), @@ -478,7 +624,7 @@ def build_model() -> TM: Control( id="C-012", name="persist-credentials: false", - assets=["A-08", "A-01"], + assets=["A-01"], threats=["DFT-07"], reference=".github/workflows/*.yml", assessment=ControlAssessment( @@ -493,7 +639,7 @@ def build_model() -> TM: Control( id="C-013", name="Harden-runner (egress block)", - assets=["A-08", "A-02"], + assets=["A-01", "A-02"], threats=["DFT-07", "DFT-29"], reference=".github/workflows/*.yml", assessment=ControlAssessment( @@ -505,22 +651,10 @@ def build_model() -> TM: "All non-allowlisted outbound connections are blocked." ), ), - Control( - id="C-014", - name="OpenSSF Scorecard", - assets=["A-01", "A-06"], - threats=["DFT-07", "DFT-10"], - reference=".github/workflows/scorecard.yml", - assessment=ControlAssessment(risk="Low", stride=["Tampering"]), - description=( - "Weekly OSSF Scorecard analysis uploaded to GitHub Code Scanning " - "covers the full set of OpenSSF Scorecard checks." - ), - ), Control( id="C-015", name="CodeQL static analysis", - assets=["A-01", "A-08"], + assets=["A-01"], threats=["DFT-03", "DFT-06"], reference=".github/workflows/codeql-analysis.yml", assessment=ControlAssessment( @@ -559,7 +693,7 @@ def build_model() -> TM: Control( id="C-021", name="Sigstore SBOM attestation", - assets=["A-04"], + assets=["A-03"], threats=["DFT-05"], assessment=ControlAssessment(risk="Medium", stride=["Spoofing", "Tampering"]), description=( @@ -572,7 +706,7 @@ def build_model() -> TM: Control( id="C-022", name="CycloneDX SBOM on PyPI", - assets=["A-04", "A-07"], + assets=["A-03", "A-07"], threats=["DFT-02"], assessment=ControlAssessment(risk="Low", stride=["Repudiation"]), description=( @@ -583,7 +717,7 @@ def build_model() -> TM: Control( id="C-024", name="``secrets: inherit`` scope", - assets=["A-08", "A-05"], + assets=["A-01", "A-05"], threats=["DFT-07"], assessment=ControlAssessment(risk="Medium", stride=["Information Disclosure"]), description=( @@ -595,7 +729,7 @@ def build_model() -> TM: Control( id="C-026", name="Consumer-side package provenance verification", - assets=["A-04"], + assets=["A-03"], threats=["DFT-17", "DFT-25"], assessment=ControlAssessment( status="implemented", risk="Medium", stride=["Spoofing", "Tampering"] @@ -623,7 +757,7 @@ def build_model() -> TM: Control( id="C-032", name="Consumer attestation verification pins to release tag ref", - assets=["A-08", "A-04"], + assets=["A-01", "A-03"], threats=["DFT-27"], reference="doc/tutorials/installation.rst", assessment=ControlAssessment( @@ -702,7 +836,7 @@ def build_model() -> TM: Control( id="C-039", name="Source build provenance and VSA attestations", - assets=["A-01", "A-02", "A-04"], + assets=["A-01", "A-02", "A-03"], threats=["DFT-31", "DFT-25"], reference="doc/howto/verify-integrity.rst", assessment=ControlAssessment( @@ -767,7 +901,7 @@ def build_model() -> TM: Control( id="C-025", name="No hardware-token MFA for release operations", - assets=["A-01", "A-04"], + assets=["A-01", "A-03"], threats=["DFT-11"], assessment=ControlAssessment( status="gap", risk="High", stride=["Spoofing", "Elevation of Privilege"] @@ -786,13 +920,11 @@ def build_model() -> TM: _SUPPLY_CHAIN_ASSET_IDS = { "A-01", + "A-01b", "A-02", "A-03", - "A-04", "A-05", - "A-06", "A-07", - "A-08", "A-08b", } ASSET_CONTROLS: dict[str, list[Control]] = build_asset_controls_index( diff --git a/security/tm_usage.py b/security/tm_usage.py index c7f9675bd..7653dc938 100644 --- a/security/tm_usage.py +++ b/security/tm_usage.py @@ -50,7 +50,6 @@ ControlAssessment, apply_report_utils_patch, build_asset_controls_index, - make_attacker_profiles, make_dev_env_boundary, make_network_boundary, make_usage_assumptions, @@ -73,8 +72,8 @@ def _make_usage_boundaries() -> tuple[Boundary, Boundary]: def _make_usage_actors_and_external_entities( b_dev: Boundary, b_remote_vcs: Boundary, -) -> tuple[Actor, ExternalEntity, ExternalEntity, ExternalEntity]: - """Create actors and external entities; return those referenced by dataflows.""" +) -> tuple[Actor, ExternalEntity, ExternalEntity, ExternalEntity, Datastore]: + """Create actors and external entities; return all for use in dataflows.""" developer = Actor("Developer") developer.inBoundary = b_dev developer.description = ( @@ -92,7 +91,14 @@ def _make_usage_actors_and_external_entities( "Not controlled by the dfetch project; content is untrusted until verified. " "The SLSA source level of any upstream is unknown and unverified - dfetch does " "not check whether the upstream enforces branch protection, mandatory review, or " - "ancestry enforcement, and no VSA is fetched alongside repository content (A-23)." + "ancestry enforcement, and no VSA is fetched alongside repository content (A-23). " + "Threat postures: a compromised upstream maintainer account (phishing, credential " + "stuffing, or MFA bypass) delivers attacker-controlled commits over an authenticated " + "channel where transport security gives no protection — mitigated only by commit-SHA " + "pinning and review before accepting any update. " + "A network-adjacent attacker (BGP hijack, compromised DNS resolver) can intercept " + "unencrypted traffic (svn://, http://) and inject redirects, but cannot break " + "correctly implemented TLS or SSH." ) archive_server = ExternalEntity("A-10: Archive HTTP Server") archive_server.inBoundary = b_remote_vcs @@ -100,7 +106,13 @@ def _make_usage_actors_and_external_entities( archive_server.description = ( "HTTP/HTTPS server serving ``.tar.gz``, ``.tgz``, ``.tar.bz2``, ``.tar.xz``, " "or ``.zip`` files. CRITICAL: ``http://`` (non-TLS) URLs are accepted without " - "enforcement of integrity hashes - the ``integrity.hash`` field is optional." + "enforcement of integrity hashes - the ``integrity.hash`` field is optional. " + "Threat postures: a network-adjacent attacker (BGP hijack, compromised DNS resolver, " + "or corporate proxy) can intercept ``http://`` traffic and serve a malicious archive " + "transparently — ``integrity.hash`` is the only defence for plain-HTTP URLs. " + "A compromised CDN node or registry can serve malicious content under a valid TLS " + "certificate; transport integrity gives no protection — only a verified content hash " + "or signed attestation detects server-side substitution." ) upstream_source_attestation = Datastore("A-23: Upstream Source Attestation (VSA)") upstream_source_attestation.inBoundary = b_remote_vcs @@ -131,7 +143,13 @@ def _make_usage_actors_and_external_entities( "Build system that compiles fetched source code (A-13). " "Not controlled by dfetch - it receives untrusted third-party source." ) - return developer, remote_git_svn, archive_server, consumer_build + return ( + developer, + remote_git_svn, + archive_server, + consumer_build, + upstream_source_attestation, + ) def _make_usage_processes(b_dev: Boundary) -> tuple[Process, Process]: @@ -181,8 +199,8 @@ def _make_usage_processes(b_dev: Boundary) -> tuple[Process, Process]: def _make_usage_datastores_a( b_dev: Boundary, -) -> tuple[Datastore, Datastore, Datastore]: - """Create primary data stores; return those referenced by dataflows.""" +) -> tuple[Datastore, Datastore, Datastore, Datastore]: + """Create primary data stores; return all for use in dataflows.""" manifest_store = Datastore("A-12: dfetch Manifest") manifest_store.inBoundary = b_dev manifest_store.description = ( @@ -191,7 +209,15 @@ def _make_usage_datastores_a( "integrity hashes. " "Tampering redirects fetches to attacker-controlled sources. " "RISK: ``integrity.hash`` is Optional in schema - archive deps can be declared " - "without any content-authenticity guarantee." + "without any content-authenticity guarantee. " + "Threat postures: a malicious manifest contributor introduces a ``dfetch.yaml`` " + "change that redirects a dep to an attacker-controlled URL, points ``dst:`` at a " + "sensitive path, or embeds a credential-bearing URL — dfetch is not the control " + "point; code review at the PR boundary is the intended mitigating control. " + "A local-filesystem attacker with write access to the working tree (gained via a " + "compromised dev dependency, malicious post-install hook, or lateral movement) can " + "tamper with ``.dfetch_data.yaml``, patch files, and vendored source after dfetch " + "writes them to disk." ) manifest_store.storesSensitiveData = ( False # config only (URLs, pins, hashes), not credentials/PII @@ -244,7 +270,7 @@ def _make_usage_datastores_a( sbom_output.hasWriteAccess = True sbom_output.isSQL = False sbom_output.classification = Classification.RESTRICTED - return manifest_store, fetched_source, sbom_output + return manifest_store, fetched_source, sbom_output, integrity_hash_record def _make_usage_datastores_b(b_dev: Boundary) -> tuple[Data, Datastore, Datastore]: @@ -530,7 +556,7 @@ def _make_usage_patch_and_build_flows( def _make_usage_extraction_elements_and_flows( b_dev: Boundary, dfetch_cli: Process ) -> None: - """Create archive/SVN extraction elements and their dataflows; all auto-register with TM.""" + """Create archive/SVN extraction elements, their dataflows, and the cache-copy flow.""" b_archive = Boundary("Archive Content Space") b_archive.description = ( "Downloaded archive bytes before extraction and validation. " @@ -636,6 +662,150 @@ def _make_usage_extraction_elements_and_flows( "dfetch check writes SARIF, Jenkins warnings-ng, or Code Climate JSON; " "falsification hides vulnerabilities from downstream dashboards." ) + df22 = Dataflow( + local_vcs_cache, dfetch_cli, "DF-22: Copy extracted content to vendor directory" + ) + df22.description = ( + "dfetch reads the validated content from the temporary directory (A-20) and " + "copies it to the ``dst:`` path declared in the manifest. " + "``check_no_path_traversal()`` is applied before each file write (C-001); " + "symlinks are validated post-extraction." + ) + df22.controls.sanitizesInput = True + df22.controls.isHardened = True + + +def _make_usage_integrity_flows( + dfetch_cli: Process, + manifest_store: Datastore, + integrity_hash_record: Datastore, + developer: Actor, +) -> None: + """Wire integrity-hash read/write and manifest-authorship flows.""" + df18 = Dataflow( + integrity_hash_record, + dfetch_cli, + "DF-18: Read integrity hash for archive verification", + ) + df18.description = ( + "dfetch reads the ``integrity.hash`` field (A-14) from the manifest when " + "verifying a downloaded archive. The hash is compared with the content " + "digest via ``hmac.compare_digest`` (constant-time). " + "When the field is absent no verification occurs (gap C-018)." + ) + df18.controls.providesIntegrity = True + df18.controls.validatesInput = True + + df18b = Dataflow( + dfetch_cli, + integrity_hash_record, + "DF-18b: Write computed hash to manifest (dfetch freeze)", + ) + df18b.description = ( + "``dfetch freeze`` computes the SHA-256/384/512 digest of a downloaded archive " + "and writes the ``integrity.hash`` value back into ``dfetch.yaml`` (A-14). " + "This is the recommended mechanism for bootstrapping a hash-pinned manifest " + "entry; without it the field remains absent and no content verification occurs." + ) + df18b.controls.providesIntegrity = True + + df20 = Dataflow(developer, manifest_store, "DF-20: Author / maintain dfetch.yaml") + df20.description = ( + "Developer writes and updates ``dfetch.yaml`` (A-12): selecting upstream " + "sources, pinning revisions, setting ``dst:`` paths, adding ``integrity.hash`` " + "fields, and listing patch references. " + "The manifest is subject to code review; a malicious change can redirect " + "any dependency fetch to an attacker-controlled URL (assumption: Manifest under " + "code review)." + ) + df20.controls.hasAccessControl = True + + +def _make_usage_gap_doc_flows( + developer: Actor, + patch_store: Datastore, + remote_git_svn: ExternalEntity, + upstream_source_attestation: Datastore, +) -> None: + """Wire patch-file authorship and VSA gap-documentation flows.""" + df19 = Dataflow( + remote_git_svn, + upstream_source_attestation, + "DF-19: VCS server publishes source attestation (not consumed by dfetch)", + ) + df19.description = ( + "An upstream VCS host with SLSA Source Level support may publish a " + "Source Provenance Attestation or Verification Summary Attestation (VSA) " + "alongside repository content. " + "CRITICAL: dfetch has no mechanism to request or verify these attestations " + "(gap C-035). The flow is modelled to document the gap — dfetch does not " + "fetch or validate A-23 at any point in its current implementation." + ) + df19.controls.providesIntegrity = False + + df21 = Dataflow(developer, patch_store, "DF-21: Create / maintain patch files") + df21.description = ( + "Developer authors unified-diff ``.patch`` files referenced by ``patch:`` " + "entries in the manifest (A-19). " + "Patch files carry no integrity hash and are applied directly by patch-ng; " + "a tampered or attacker-supplied patch can write to arbitrary paths (gap C-020)." + ) + + +def _make_usage_stores_and_flows( # pylint: disable=too-many-arguments,too-many-positional-arguments + b_dev: Boundary, + developer: Actor, + remote_git_svn: ExternalEntity, + archive_server: ExternalEntity, + consumer_build: ExternalEntity, + upstream_source_attestation: Datastore, + dfetch_cli: Process, + patch_apply: Process, +) -> None: + """Create datastores and wire all usage-model dataflows.""" + manifest_store, fetched_source, sbom_output, integrity_hash_record = ( + _make_usage_datastores_a(b_dev) + ) + embedded_url_credential, metadata_store, patch_store = _make_usage_datastores_b( + b_dev + ) + _make_usage_vcs_dataflows(developer, dfetch_cli, manifest_store, remote_git_svn) + _make_usage_archive_dataflows(dfetch_cli, archive_server) + _make_usage_output_flows( + dfetch_cli, fetched_source, metadata_store, sbom_output, embedded_url_credential + ) + _make_usage_patch_and_build_flows( + patch_apply, patch_store, fetched_source, consumer_build + ) + _make_usage_extraction_elements_and_flows(b_dev, dfetch_cli) + _make_usage_integrity_flows( + dfetch_cli, manifest_store, integrity_hash_record, developer + ) + _make_usage_gap_doc_flows( + developer, patch_store, remote_git_svn, upstream_source_attestation + ) + + +def _make_usage_elements_and_flows(b_dev: Boundary, b_remote_vcs: Boundary) -> None: + """Create all usage-model elements and wire their dataflows.""" + ( + developer, + remote_git_svn, + archive_server, + consumer_build, + upstream_source_attestation, + ) = _make_usage_actors_and_external_entities(b_dev, b_remote_vcs) + dfetch_cli, patch_apply = _make_usage_processes(b_dev) + _make_usage_stores_and_flows( + b_dev, + developer, + remote_git_svn, + archive_server, + consumer_build, + upstream_source_attestation, + dfetch_cli, + patch_apply, + ) def build_model() -> TM: @@ -656,25 +826,9 @@ def build_model() -> TM: mergeResponses=True, threatsFile=THREATS_FILE, ) - model.assumptions = make_usage_assumptions() + make_attacker_profiles() + model.assumptions = make_usage_assumptions() b_dev, b_remote_vcs = _make_usage_boundaries() - developer, remote_git_svn, archive_server, consumer_build = ( - _make_usage_actors_and_external_entities(b_dev, b_remote_vcs) - ) - dfetch_cli, patch_apply = _make_usage_processes(b_dev) - manifest_store, fetched_source, sbom_output = _make_usage_datastores_a(b_dev) - embedded_url_credential, metadata_store, patch_store = _make_usage_datastores_b( - b_dev - ) - _make_usage_vcs_dataflows(developer, dfetch_cli, manifest_store, remote_git_svn) - _make_usage_archive_dataflows(dfetch_cli, archive_server) - _make_usage_output_flows( - dfetch_cli, fetched_source, metadata_store, sbom_output, embedded_url_credential - ) - _make_usage_patch_and_build_flows( - patch_apply, patch_store, fetched_source, consumer_build - ) - _make_usage_extraction_elements_and_flows(b_dev, dfetch_cli) + _make_usage_elements_and_flows(b_dev, b_remote_vcs) return model