Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion doc/explanation/security.rst
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,46 @@ for regeneration instructions.
threat_model_supply_chain
threat_model_usage

.. _identified-gaps:

Identified Gaps (Reserved Control IDs)
--------------------------------------

The runtime-usage threat model references four control IDs that are reserved
for improvements not yet implemented. Reserving the IDs keeps the control
numbering stable and makes each gap a first-class part of the risk analysis —
you can trace every "gap C-0xx" mention in the threat models back to this
table:

.. list-table::
:header-rows: 1
:widths: 10 90

* - ID
- Planned control
* - C-018
- Integrity-hash enforcement for archive dependencies. The
``integrity.hash`` manifest field is optional today; when it is absent no
content verification occurs. You can bootstrap the hash for an existing
manifest with ``dfetch freeze``.
* - C-019
- End-to-end content integrity for Git and SVN dependencies. Authenticity
currently relies on transport security (TLS / SSH) and the integrity of
the upstream repository; pinning to an immutable commit SHA is the
strongest mitigation available today.
* - C-020
- Patch-file integrity verification. ``patch:`` files referenced by the
manifest carry no hash, and the destination paths inside a patch are
validated by ``patch-ng`` rather than independently by dfetch.
* - C-035
- Upstream source-attestation verification. dfetch has no mechanism yet to
request or verify SLSA Source Provenance Attestations (:term:`VSA`) for
fetched repositories, and the manifest schema has no field to declare an
expected SLSA source level.

When one of these controls is implemented, it moves to the
:doc:`control_register` under its reserved ID.

CRA Compliance
--------------

Expand Down Expand Up @@ -235,7 +275,7 @@ OSCAL

:term:`OSCAL` (Open Security Controls Assessment Language) is a set of
NIST-published JSON/XML schemas for machine-readable security documentation.
dfetch uses OSCAL 1.1.2 for two artifacts:
dfetch uses OSCAL 1.2.2 for two artifacts:

- `OSCAL Catalog model <https://pages.nist.gov/OSCAL/reference/latest/catalog/>`_ —
used by ``cra_pren_4000014_oscal_catalog.json`` to represent the prEN 40000-1-4
Expand Down
38 changes: 27 additions & 11 deletions doc/explanation/threat_model_supply_chain.rst
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ Boundaries
- Python Package Index and its staging registry. dfetch publishes via OIDC trusted publishing - no long-lived API token stored.

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


Data Flow Diagram
Expand Down Expand Up @@ -258,7 +258,7 @@ Data Flow Diagram
shape = square;
color = black;
fontcolor = black;
label = "A-09: Winget\nCommunity\nRepository\n(microsoft/winget-\npkgs)";
label = "A-28: Winget\nCommunity\nRepository\n(microsoft/winget-\npkgs)";
margin = 0.02;
]

Expand Down Expand Up @@ -458,7 +458,7 @@ Sequence Diagram
entity process_APythonBuildwheelsdist_b2e5892d06 as "A-08: Python\nBuild (wheel\n/ sdist)"
database datastore_AdfetchBuildDevDependencies_990b886585 as "A-07: dfetch\nBuild / Dev\nDependencies"
database datastore_AbGitHubActionsBuildCache_9df04f8dae as "A-08b:\nGitHub\nActions\nBuild Cache"
entity externalentity_AWingetCommunityRepositorymicrosoftwingetpkgs_7113ed0f48 as "A-09: Winget\nCommunity\nRepository\n(microsoft/winget-pkgs)"
entity externalentity_AWingetCommunityRepositorymicrosoftwingetpkgs_7113ed0f48 as "A-28: Winget\nCommunity\nRepository\n(microsoft/winget-pkgs)"

actor_DeveloperContributor_d2006ce1bb -> externalentity_AbGitHubRepositoryfeaturebranchesPRs_0291419f72: DF-11: Push commits / open PR
externalentity_AbGitHubRepositoryfeaturebranchesPRs_0291419f72 -> process_AReleaseGateCodeReview_9345ab4c19: DF-22: PR enters code review
Expand Down Expand Up @@ -554,7 +554,7 @@ Asset Identification
- Data
- High / High / High
* - A-06: GitHub Actions Workflow
- CI/CD pipelines: test, build (wheel/msi/deb/rpm), lint, CodeQL, Scorecard, dependency-review, docs, release, winget-publish. All actions pinned by commit SHA. harden-runner used in every workflow that executes steps on a runner (egress: block with endpoint allowlist); ci.yml is a dispatcher-only workflow with no runner steps and does not include harden-runner. winget-publish.yml uses a stored PAT (WINGET_TOKEN, A-10) to submit manifest PRs to the Winget Community Repository (A-09).
- CI/CD pipelines: test, build (wheel/msi/deb/rpm), lint, CodeQL, Scorecard, dependency-review, docs, release, winget-publish. All actions pinned by commit SHA. harden-runner used in every workflow that executes steps on a runner (egress: block with endpoint allowlist); ci.yml is a dispatcher-only workflow with no runner steps and does not include harden-runner. winget-publish.yml uses a stored PAT (WINGET_TOKEN, A-29) to submit manifest PRs to the Winget Community Repository (A-28).
- Process
- Medium / Medium / Medium
* - A-07: dfetch Build / Dev Dependencies
Expand All @@ -569,11 +569,11 @@ Asset Identification
- GitHub Actions cache entries written and restored across pipeline runs. Used to speed up dependency installation (pip, gem) and incremental builds. Cache-poisoning from forked PRs (DFT-28, SLSA E6: poison the build cache) is mitigated by ref-scoped cache keys: build.yml includes ``${{ github.ref_name }}`` in both ``key`` and ``restore-keys`` (C-033), which isolates PR and release caches per branch so a fork cannot write into the release cache namespace.
- Datastore
- High / High / —
* - A-09: Winget Community Repository (microsoft/winget-pkgs)
- The Windows Package Manager Community Repository where the dfetch ``DFetch-org.DFetch`` manifest is hosted (https://github.com/microsoft/winget-pkgs). CI submits manifest update PRs via ``vedantmgoyal9/winget-releaser`` using a stored PAT (A-10); PRs are reviewed by ``microsoft/winget-pkgs`` maintainers before merging (C-041). Manifests contain SHA256 hashes of the installer binary; winget verifies the hash before installation. A compromised PAT or a fraudulent PR that passes review could redirect consumers to a malicious installer (DFT-35).
* - A-28: Winget Community Repository (microsoft/winget-pkgs)
- The Windows Package Manager Community Repository where the dfetch ``DFetch-org.DFetch`` manifest is hosted (https://github.com/microsoft/winget-pkgs). CI submits manifest update PRs via ``vedantmgoyal9/winget-releaser`` using a stored PAT (A-29); PRs are reviewed by ``microsoft/winget-pkgs`` maintainers before merging (C-041). Manifests contain SHA256 hashes of the installer binary; winget verifies the hash before installation. A compromised PAT or a fraudulent PR that passes review could redirect consumers to a malicious installer (DFT-35).
- ExternalEntity
- High / High / —
* - A-10: WINGET_TOKEN PAT
* - A-29: WINGET_TOKEN PAT
- Long-lived classic GitHub Personal Access Token (fine-grained PATs are not supported by the ``vedantmgoyal9/winget-releaser`` action) with ``public_repo`` scope, stored as a GitHub Actions environment secret in the ``winget`` environment. Used by ``winget-publish.yml`` to push a manifest branch to the pre-existing fork ``dfetch-org/winget-pkgs`` and open a PR against ``microsoft/winget-pkgs``. Unlike the PyPI OIDC token (A-05) which is short-lived and not stored, this PAT persists indefinitely until rotated. If exfiltrated from the CI environment, an attacker could submit fraudulent manifest PRs from outside the project's pipeline.
- Data
- High / High / —
Expand Down Expand Up @@ -677,16 +677,16 @@ Dataflows

* - DF-27: Winget manifest PR submission
- A-02: GitHub Actions Infrastructure
- A-09: Winget Community Repository (microsoft/winget-pkgs)
- A-28: Winget Community Repository (microsoft/winget-pkgs)
- HTTPS

* - DF-28: winget install dfetch
- Consumer / End User
- A-09: Winget Community Repository (microsoft/winget-pkgs)
- A-28: Winget Community Repository (microsoft/winget-pkgs)
- HTTPS

* - DF-29: Consumer downloads MSI via winget
- A-09: Winget Community Repository (microsoft/winget-pkgs)
- A-28: Winget Community Repository (microsoft/winget-pkgs)
- Consumer / End User
- HTTPS

Expand Down Expand Up @@ -728,6 +728,14 @@ Threats
| **STRIDE:** T S
| **Status:** Mitigate
- C-021
* - DFT-06
- Command injection via unsanitised subprocess input
- A-01: GitHub Repository (main / protected)
- | **Sev:** 🟠H
| **Risk:** 🟠H
| **STRIDE:** T E
| **Status:** Mitigate
- C-015 (CodeQL) and C-017 (bandit) perform static analysis that detects command injection patterns before code reaches production.
* - DFT-07
- CI/CD secret exfiltration via supply-chain attack on build environment
- A-08: Python Build (wheel / sdist)
Expand Down Expand Up @@ -856,9 +864,17 @@ Threats
| **STRIDE:** T
| **Status:** Mitigate
- C-038
* - DFT-34
- Long-lived stored credential enables persistent unauthorised publication after exfiltration
- A-29: WINGET_TOKEN PAT
- | **Sev:** 🟠H
| **Risk:** 🟠H
| **STRIDE:** I T
| **Status:** Mitigate
- C-042
* - DFT-35
- Compromised publish credential enables malicious installer URL injection via package manifest submission
- A-09: Winget Community Repository (microsoft/winget-pkgs)
- A-28: Winget Community Repository (microsoft/winget-pkgs)
- | **Sev:** 🟠H
| **Risk:** 🟠H
| **STRIDE:** T S
Expand Down
18 changes: 13 additions & 5 deletions doc/explanation/threat_model_usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -679,7 +679,7 @@ Asset Identification
- Datastore
- Critical / Critical / High
* - A-15: SBOM Output (CycloneDX)
- 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 A-04 in tm_supply_chain.py).
- 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 A-03 in tm_supply_chain.py).
- Datastore
- High / High / —
* - A-16: VCS Credentials
Expand Down Expand Up @@ -952,6 +952,14 @@ Threats
| **STRIDE:** T S
| **Status:** Mitigate
- C-005 mitigates archive deps when hash present. Git/SVN: no integrity mechanism; pinning to an immutable commit SHA is recommended but not enforced by dfetch.
* - DFT-06
- Command injection via unsanitised subprocess input
- A-22: dfetch Process
- | **Sev:** 🟠H
| **Risk:** 🟠H
| **STRIDE:** T E
| **Status:** Mitigate
- C-006 suppresses interactive credential prompts for Git and SVN; C-007 invokes all external commands with ``shell=False`` and list-form arguments, eliminating the shell-injection vector for non-interactive VCS operations.
* - DFT-07
- CI/CD secret exfiltration via supply-chain attack on build environment
- A-25: Patch Application (patch-ng)
Expand Down Expand Up @@ -1190,10 +1198,6 @@ Controls
- Manifest input validation
- DFT-04, DFT-08
- StrictYAML schema with ``SAFE_STR = Regex(r"^[^\x00-\x1F\x7F-\x9F]*$")`` rejects control characters in all string fields. ``dfetch/manifest/schema.py``
* - C-045
- Plaintext transport detection
- DFT-26
- ``plaintext_warning()`` (``dfetch/manifest/project.py``) inspects the resolved remote URL immediately before each VCS command is issued (inside the ``check_for_update`` and ``update`` spinners in ``subproject.py``). If the scheme is ``http://``, ``git://``, or ``svn://``, a visible warning is emitted naming the redacted URL (credentials stripped from the userinfo component) and recommending ``https://`` or ``svn+ssh://``. Detection only — dfetch still proceeds with the plaintext connection; the control raises user awareness but does not enforce scheme selection. ``dfetch/manifest/project.py, dfetch/project/subproject.py``
* - C-034
- Hash algorithm allowlist (SHA-256/384/512 only)
- DFT-30
Expand All @@ -1202,3 +1206,7 @@ Controls
- Persisted-metadata credential redaction
- DFT-13
- ``Metadata.dump()`` rebuilds the netloc of every persisted URL from ``parsed.hostname`` and ``parsed.port`` via ``urllib.parse.urlsplit`` / ``urlunsplit``, dropping any ``user:password@`` userinfo before writing ``.dfetch_data.yaml``. The same stripper is applied to each ``dependencies[].remote_url`` entry (git submodule, svn:external) so a credential in a nested upstream URL also never reaches disk. The in-memory ``Metadata`` object held by the running command keeps the original URL — only the on-disk representation is redacted, so an in-flight authenticated fetch is unaffected. ``dfetch/project/metadata.py``
* - C-045
- Plaintext transport detection
- DFT-26
- ``plaintext_warning()`` (``dfetch/manifest/project.py``) inspects the resolved remote URL immediately before each VCS command is issued (inside the ``check_for_update`` and ``update`` spinners in ``subproject.py``). If the scheme is ``http://``, ``git://``, or ``svn://``, a visible warning is emitted naming the redacted URL (credentials stripped from the userinfo component) and recommending ``https://`` or ``svn+ssh://``. Detection only — dfetch still proceeds with the plaintext connection; the control raises user awareness but does not enforce scheme selection. ``dfetch/manifest/project.py, dfetch/project/subproject.py``
2 changes: 1 addition & 1 deletion security/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ python -m security.compliance \

This produces:

- `security/dfetch.component-definition.json` — OSCAL 1.1.2 Component Definition implementing
- `security/dfetch.component-definition.json` — OSCAL 1.2.2 Component Definition implementing
prEN 40000-1-4 Security Objectives for dfetch
- `doc/explanation/compliance_track.rst` — human-readable RST (built into the Sphinx docs)

Expand Down
8 changes: 4 additions & 4 deletions security/dfetch.component-definition.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
"uuid": "eddc2e8c-a2ae-5a42-8d35-88f5c2af55b2",
"metadata": {
"title": "dfetch CRA Compliance Component Definition",
"last-modified": "2026-06-17T00:00:00Z",
"version": "0.15.0",
"last-modified": "2026-07-02T00:00:00Z",
"version": "0.14.0",
"oscal-version": "1.2.2",
"document-ids": [
{
Expand Down Expand Up @@ -82,7 +82,7 @@
"props": [
{
"name": "software-version",
"value": "0.15.0"
"value": "0.14.0"
},
{
"name": "asset-type",
Expand Down Expand Up @@ -1367,4 +1367,4 @@
]
}
}
}
}
4 changes: 2 additions & 2 deletions security/tm_controls_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,7 @@ def to_pytm(self) -> str:
Control(
id="C-041",
name="Winget manifest PRs reviewed by community maintainers",
assets=["A-09"],
assets=["A-28"],
threats=["DFT-35"],
reference=".github/workflows/winget-publish.yml",
description=(
Expand All @@ -319,7 +319,7 @@ def to_pytm(self) -> str:
Control(
id="C-042",
name="WINGET_TOKEN scoped to dedicated Winget environment",
assets=["A-10"],
assets=["A-29"],
threats=["DFT-34"],
reference=".github/workflows/winget-publish.yml",
description=(
Expand Down
58 changes: 57 additions & 1 deletion security/tm_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import re
import sys
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any

from pytm import TM, Data, Element
Expand Down Expand Up @@ -120,6 +121,46 @@ def _render_asset_rows(
return "".join(_asset_row(e, cia) for e in assets)


@dataclass
class _CatalogRow:
"""Stand-in finding for a threat documented via ``ThreatResponse`` only.

Two situations leave a responded-to threat without any pytm finding:
the mitigation is already modelled in the element properties, so the
threat's condition no longer matches (e.g. DFT-06 with parameterized
subprocess input), or the threat targets ``Data`` assets, which pytm's
``resolve()`` never evaluates (it iterates ``TM._elements`` only, while
``Data`` lives in ``TM._data`` — e.g. DFT-34). Rendering these rows
from the threat catalog keeps the decision visible in the report instead
of silently dropping it.
"""

threat_id: str
description: str
severity: str
target: str = "—"


def _validate_threat_coverage(
findings: Any,
responses: list[ThreatResponse],
catalog: dict[str, Any],
) -> None:
"""Fail generation when findings and responses have drifted apart.

Every pytm finding must have a documented ``ThreatResponse``, and every
response must reference a threat that exists in the catalog. Raising here
means an undocumented threat can never silently render as an empty row.
"""
resp_ids = {r.threat_id for r in responses}
unknown = sorted(resp_ids - set(catalog))
if unknown:
raise ValueError(f"Responses reference unknown threat IDs: {unknown}")
unhandled = sorted({f.threat_id for f in findings} - resp_ids)
if unhandled:
raise ValueError(f"Findings without a ThreatResponse: {unhandled}")


def _render_threat_rows(
tm: Any, controls: list[Control], responses: list[ThreatResponse]
) -> str:
Expand All @@ -129,16 +170,31 @@ def _render_threat_rows(
threat that applies to many elements produces many byte-identical rows.
Collapse them to a single row documented against the threat's canonical
target (``ThreatResponse.target`` when set, otherwise the matched element).

Responses whose threat produced no pytm finding are rendered from the
threat catalog instead (see ``_CatalogRow``), so every documented risk
decision appears in the table.
"""
findings = tm.findings
if not findings:
if not findings and not responses:
return ""

catalog: dict[str, Any] = {t.id: t for t in getattr(TM, "_threats", [])}
_validate_threat_coverage(findings, responses, catalog)

resp_map: dict[str, ThreatResponse] = {r.threat_id: r for r in responses}

representative: dict[str, Any] = {}
for f in findings:
representative.setdefault(f.threat_id, f)
for r in responses:
if r.threat_id not in representative:
threat = catalog[r.threat_id]
representative[r.threat_id] = _CatalogRow(
threat_id=r.threat_id,
description=str(threat.description),
severity=str(threat.severity),
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

def _row(f: Any) -> str:
resp = resp_map.get(f.threat_id)
Expand Down
Loading
Loading