From 393bc17efbae14cfd5ac23178097ed3fae045d49 Mon Sep 17 00:00:00 2001 From: izar Date: Tue, 13 Oct 2026 13:38:38 -0400 Subject: [PATCH 1/5] feat: add Control element and TM-BOM oriented model attributes Add attributes needed to describe a threat model in richer terms than pytm could so far: * Control: a named safeguard that declares the threat SIDs it mitigates and the elements it protects, with category, status and effectiveness. It is registered with the TM and round-trips through --json. * Threat/Finding: stride (default "unassigned"), impact, and capec/cwe ids parsed from the references. * Actor.kind: person, organization, system or persona. * Dataflow.isEncrypted: tri-state, unknown by default. All new attributes default to "unknown" and do not change existing findings. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 8 ++++ pytm/__init__.py | 16 +++++++- pytm/actor.py | 6 +++ pytm/control.py | 95 +++++++++++++++++++++++++++++++++++++++++++++++ pytm/dataflow.py | 4 ++ pytm/enums.py | 37 ++++++++++++++++++ pytm/finding.py | 29 ++++++++++++++- pytm/json.py | 18 ++++++++- pytm/pytm.py | 14 ++++++- pytm/taxonomy.py | 50 +++++++++++++++++++++++++ pytm/threat.py | 30 +++++++++++++++ pytm/tm.py | 3 ++ tests/output.json | 9 +++++ 13 files changed, 315 insertions(+), 4 deletions(-) create mode 100644 pytm/control.py create mode 100644 pytm/taxonomy.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 725a7814..8301e98a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ below). ## [Unreleased] +### Added + +- `Control` element (name, category, status, `mitigates`, `appliesTo`, + `effectiveness`), also in `--json` output and input. +- `Threat`/`Finding` attributes `stride` (default `unassigned`), `impact`, + `capec`, `cwe`. +- `Actor.kind` and `Dataflow.isEncrypted`. + ## [1.4.0] - 2026-05-21 ### BREAKING diff --git a/pytm/__init__.py b/pytm/__init__.py index de79fb18..d84e0355 100644 --- a/pytm/__init__.py +++ b/pytm/__init__.py @@ -4,6 +4,10 @@ "Actor", "Assumption", "Boundary", + "Control", + "ControlCategory", + "ControlStatus", + "ActorKind", "Classification", "TLSVersion", "Data", @@ -32,7 +36,16 @@ from .pytm import var # Import from new Pydantic models -from .enums import Action, Classification, DatastoreType, Lifetime, TLSVersion +from .enums import ( + Action, + ActorKind, + Classification, + ControlCategory, + ControlStatus, + DatastoreType, + Lifetime, + TLSVersion, +) from .base import Assumption, Controls from .element import Element from .data import Data @@ -44,6 +57,7 @@ from .process import Process from .dataflow import Dataflow from .boundary import Boundary +from .control import Control from .tm import TM # Rebuild models to resolve forward references diff --git a/pytm/actor.py b/pytm/actor.py index c46c72e5..993e58a7 100644 --- a/pytm/actor.py +++ b/pytm/actor.py @@ -5,6 +5,7 @@ from .element import Element from .base import DataSet +from .enums import ActorKind if TYPE_CHECKING: from .dataflow import Dataflow @@ -23,6 +24,7 @@ class Actor(Element): inputs (List[Dataflow]): Incoming Dataflows outputs (List[Dataflow]): Outgoing Dataflows isAdmin (bool): Indicates whether the actor has administrative privileges + kind (ActorKind): person, organization, system or persona """ port: int = Field( @@ -45,6 +47,10 @@ class Actor(Element): default=False, description="Indicates whether the actor has administrative privileges", ) + kind: ActorKind = Field( + default=ActorKind.UNKNOWN, + description="What kind of party this actor is: person, organization, system or persona", + ) @field_validator("data", mode="before") @classmethod diff --git a/pytm/control.py b/pytm/control.py new file mode 100644 index 00000000..9e416998 --- /dev/null +++ b/pytm/control.py @@ -0,0 +1,95 @@ +"""Control model - a safeguard that mitigates one or more threats.""" + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from .element import Element +from .enums import ControlCategory, ControlStatus + +EFFECTIVENESS = ("ineffective", "marginal", "adequate", "good", "excellent") + + +class Control(BaseModel): + """A safeguard or countermeasure that mitigates threats. + + Not to be confused with ``Controls``, the per-element bag of boolean flags + that threat conditions are evaluated against. A ``Control`` is a named + object that is declared once, says which threats (by SID) it mitigates and + which elements it protects. + + Attributes: + name (str): Name of the control + description (str): Description of the control + category (ControlCategory): preventive, detective, corrective, ... + status (ControlStatus): Implementation status + mitigates (List[str]): SIDs of the threats this control mitigates + appliesTo (List[Element]): Elements protected; empty means the whole model + effectiveness (str): ineffective, marginal, adequate, good or excellent + """ + + model_config = ConfigDict( + extra="allow", validate_assignment=True, arbitrary_types_allowed=True + ) + + name: str = Field(description="Name of the control") + description: str = Field(default="", description="Description of the control") + category: ControlCategory = Field( + default=ControlCategory.UNKNOWN, description="Category of the control" + ) + status: ControlStatus = Field( + default=ControlStatus.UNKNOWN, description="Implementation status" + ) + mitigates: list[str] = Field( + default_factory=list, description="SIDs of the threats this control mitigates" + ) + appliesTo: list[Element] = Field( + default_factory=list, + description="Elements protected by this control; empty means the whole model", + ) + effectiveness: str = Field( + default="", + description="Assessed effectiveness: " + ", ".join(EFFECTIVENESS), + ) + + @field_validator("effectiveness") + @classmethod + def _check_effectiveness(cls, value: str) -> str: + value = value.strip().lower() + if value and value not in EFFECTIVENESS: + raise ValueError(f"effectiveness must be one of {', '.join(EFFECTIVENESS)}") + return value + + @field_validator("mitigates", mode="before") + @classmethod + def _coerce_mitigates(cls, value): + """Accept a single SID or Threat objects as well as a list of SIDs.""" + if isinstance(value, str): + return [value] + return [getattr(v, "id", v) for v in value or []] + + @field_validator("appliesTo", mode="before") + @classmethod + def _coerce_applies_to(cls, value): + if value is None: + return [] + return [value] if isinstance(value, Element) else list(value) + + def __init__(self, name: str = None, **data): + if name is not None: + data["name"] = name + super().__init__(**data) + self._register_with_tm() + + def _register_with_tm(self) -> None: + """Register this control with the TM class.""" + try: + from .tm import TM + + TM._controls.append(self) + except ImportError: + pass + + def __repr__(self) -> str: + return f"<{self.__module__}.{type(self).__name__}({self.name})>" + + def __str__(self) -> str: + return f"Control({self.name})" diff --git a/pytm/dataflow.py b/pytm/dataflow.py index 3d7315a3..9735f9fd 100644 --- a/pytm/dataflow.py +++ b/pytm/dataflow.py @@ -28,6 +28,10 @@ class Dataflow(Element): default=TLSVersion.NONE, description="TLS version used" ) protocol: str = Field(default="", description="Protocol used in this data flow") + isEncrypted: bool | None = Field( + default=None, + description="Is this flow encrypted in transit? None means unknown", + ) data: DataSet = Field( default_factory=DataSet, description="pytm.Data object(s) in incoming data flows", diff --git a/pytm/enums.py b/pytm/enums.py index 2f492473..a2c6c28e 100644 --- a/pytm/enums.py +++ b/pytm/enums.py @@ -111,3 +111,40 @@ class TLSVersion(OrderedEnum): TLSv11 = 5 TLSv12 = 6 TLSv13 = 7 + + +class ActorKind(FlexibleEnumMixin, Enum): + """What kind of party an Actor represents (maps to a TM-BOM party).""" + + UNKNOWN = "unknown" + PERSON = "person" + ORGANIZATION = "organization" + SYSTEM = "system" + PERSONA = "persona" + + +class ControlCategory(FlexibleEnumMixin, Enum): + """How a Control acts on a threat.""" + + UNKNOWN = "unknown" + PREVENTIVE = "preventive" + DETECTIVE = "detective" + CORRECTIVE = "corrective" + COMPENSATING = "compensating" + DETERRENT = "deterrent" + RECOVERY = "recovery" + + +class ControlStatus(FlexibleEnumMixin, Enum): + """Implementation status of a Control.""" + + UNKNOWN = "unknown" + RECOMMENDED = "recommended" + PROPOSED = "proposed" + APPROVED = "approved" + REJECTED = "rejected" + PLANNED = "planned" + IN_PROGRESS = "in-progress" + IMPLEMENTED = "implemented" + VERIFIED = "verified" + DECOMMISSIONED = "decommissioned" diff --git a/pytm/finding.py b/pytm/finding.py index 92bd8e52..c5d1adc7 100644 --- a/pytm/finding.py +++ b/pytm/finding.py @@ -1,9 +1,10 @@ """Finding model - represents a finding linking an element to a threat.""" from typing import Optional, TYPE_CHECKING -from pydantic import BaseModel, Field, ConfigDict +from pydantic import BaseModel, Field, ConfigDict, field_validator from .base import Assumption +from .taxonomy import check_impact, check_stride if TYPE_CHECKING: from .element import Element @@ -28,6 +29,10 @@ class Finding(BaseModel): response (str): Describes how this threat matching this particular asset or dataflow is being handled. Can be one of: mitigated, transferred, avoided, accepted cvss (str): The CVSS score and/or vector likelihood (str): Likelihood of the threat + stride (str): STRIDE category of the threat + impact (str): Impact level of this finding (negligible ... catastrophic) + capec (List[int]): CAPEC ids of the threat + cwe (List[int]): CWE ids of the threat """ model_config = ConfigDict( @@ -59,6 +64,23 @@ class Finding(BaseModel): ) cvss: str = Field(default="", description="The CVSS score and/or vector") likelihood: str = Field(default="", description="Likelihood of the threat") + stride: str = Field( + default="unassigned", + description="STRIDE category of the threat, or unassigned", + ) + impact: str = Field(default="", description="Impact level of this finding") + capec: list[int] = Field(default_factory=list, description="CAPEC ids") + cwe: list[int] = Field(default_factory=list, description="CWE ids") + + @field_validator("stride", mode="before") + @classmethod + def _check_stride(cls, value: str) -> str: + return check_stride(value) + + @field_validator("impact") + @classmethod + def _check_impact(cls, value: str) -> str: + return check_impact(value) def __init__(self, *args, **kwargs): """Initialize a Finding. @@ -109,10 +131,15 @@ def __init__(self, *args, **kwargs): "references", "condition", "likelihood", + "stride", + "impact", ] for attr in threat_attrs: if attr not in kwargs: # Don't override explicit values kwargs[attr] = getattr(threat, attr, "") + for attr in ("capec", "cwe"): + if attr not in kwargs: + kwargs[attr] = list(getattr(threat, attr, [])) # Handle overrides from element threat_id = kwargs.get("threat_id", None) diff --git a/pytm/json.py b/pytm/json.py index a9137751..f176406b 100644 --- a/pytm/json.py +++ b/pytm/json.py @@ -3,6 +3,7 @@ from .tm import TM from .base import DataSet from .boundary import Boundary +from .control import Control from .data import Data from .dataflow import Dataflow from .asset import Agent, Asset, Server, ExternalEntity, Lambda, LLM @@ -46,7 +47,8 @@ def _decode(flat): boundaries = _decode_boundaries(flat.pop("boundaries", [])) data = _decode_data(flat.pop("data", [])) elements = _decode_elements(flat.pop("elements", []), boundaries, data) - _decode_flows(flat.pop("flows", []), elements, data) + flows = _decode_flows(flat.pop("flows", []), elements, data) + _decode_controls(flat.pop("controls", []), {**boundaries, **flows, **elements}) if "name" not in flat: raise ValueError("name property missing for threat model") @@ -181,3 +183,17 @@ def _decode_flows(flat, elements, data): if ref not in flows: raise ValueError(f"dataflow {name} references invalid dataflow {ref}") setattr(flows[name], attr, flows[ref]) + + return flows + + +def _decode_controls(flat, targets): + for i, e in enumerate(flat): + name = e.pop("name", None) + if name is None: + raise ValueError(f"name property missing in control {i}") + refs = e.pop("appliesTo", []) + missing = [r for r in refs if r not in targets] + if missing: + raise ValueError(f"control {name} references invalid element {missing[0]}") + Control(name, appliesTo=[targets[r] for r in refs], **e) diff --git a/pytm/pytm.py b/pytm/pytm.py index 211e3c69..dd59757a 100644 --- a/pytm/pytm.py +++ b/pytm/pytm.py @@ -18,7 +18,10 @@ # Import all the new Pydantic models from .enums import ( Action, + ActorKind, Classification, + ControlCategory, + ControlStatus, DatastoreType, Lifetime, TLSVersion, @@ -26,6 +29,7 @@ ) from .base import Assumption, Controls from .element import Element +from .control import Control from .data import Data from .threat import Threat from .finding import Finding @@ -130,18 +134,24 @@ def _print_components(classes): print("\nAttributes:") enumerated = set(_iter_subclasses(OrderedEnum)) - enumerated.update({Data, Action, Lifetime}) + enumerated.update( + {Data, Action, ActorKind, Control, ControlCategory, ControlStatus, Lifetime} + ) _print_components(list(enumerated)) _CLASS_REGISTRY = { "Action": Action, + "ActorKind": ActorKind, "Agent": Agent, "Actor": Actor, "Asset": Asset, "Element": Element, "Boundary": Boundary, "Classification": Classification, + "Control": Control, + "ControlCategory": ControlCategory, + "ControlStatus": ControlStatus, "Data": Data, "Dataflow": Dataflow, "Datastore": Datastore, @@ -533,6 +543,7 @@ def ts_tm(obj): @to_serializable.register(Controls) +@to_serializable.register(Control) @to_serializable.register(Data) @to_serializable.register(Threat) @to_serializable.register(Element) @@ -572,6 +583,7 @@ def serialize(obj, nested=False): "_elements", "_flows", "_data", + "_controls", "_boundaries", "assumptions", "findings", diff --git a/pytm/taxonomy.py b/pytm/taxonomy.py new file mode 100644 index 00000000..1ae165e9 --- /dev/null +++ b/pytm/taxonomy.py @@ -0,0 +1,50 @@ +"""Threat taxonomy helpers shared by Threat and Finding.""" + +import re + +STRIDE = ( + "spoofing", + "tampering", + "repudiation", + "information-disclosure", + "denial-of-service", + "elevation-of-privilege", +) +UNASSIGNED = "unassigned" +IMPACT_LEVELS = ("negligible", "low", "moderate", "major", "catastrophic") + +_CAPEC = re.compile(r"capec\.mitre\.org/data/definitions/(\d+)|\bCAPEC-(\d+)", re.I) +_CWE = re.compile(r"cwe\.mitre\.org/data/definitions/(\d+)|\bCWE-(\d+)", re.I) + + +def _choice(value: str, allowed: tuple, label: str) -> str: + """Normalise ``value`` ("Information Disclosure") and check it is allowed.""" + normalised = re.sub(r"[\s_]+", "-", (value or "").strip().lower()) + if normalised and normalised not in allowed: + raise ValueError(f"{label} must be one of: {', '.join(allowed)}") + return normalised + + +def check_stride(value: str) -> str: + """Validate a STRIDE category; empty means ``"unassigned"``.""" + return _choice(value, (*STRIDE, UNASSIGNED), "stride") or UNASSIGNED + + +def check_impact(value: str) -> str: + """Validate a TM-BOM impact level.""" + return _choice(value, IMPACT_LEVELS, "impact") + + +def capec_ids(references: str) -> list[int]: + """CAPEC ids mentioned in a references string, in order, without repeats.""" + return _ids(_CAPEC, references) + + +def cwe_ids(references: str) -> list[int]: + """CWE ids mentioned in a references string, in order, without repeats.""" + return _ids(_CWE, references) + + +def _ids(pattern: "re.Pattern[str]", text: str) -> list[int]: + found = [int(a or b) for a, b in pattern.findall(text or "")] + return list(dict.fromkeys(found)) diff --git a/pytm/threat.py b/pytm/threat.py index bbb9c6da..b714ac02 100644 --- a/pytm/threat.py +++ b/pytm/threat.py @@ -10,10 +10,13 @@ import builtins +from .taxonomy import capec_ids, check_impact, check_stride, cwe_ids + from pydantic import ( BaseModel, Field, ConfigDict, + field_validator, model_validator, PrivateAttr, ) @@ -165,6 +168,10 @@ class Threat(BaseModel): example (str): Example of the threat references (str): References for the threat target (Tuple): Target classes for this threat + stride (str): STRIDE category (spoofing, tampering, ...) + impact (str): Impact level (negligible, low, moderate, major, catastrophic) + capec (List[int]): CAPEC ids, parsed from references unless given + cwe (List[int]): CWE ids, parsed from references unless given """ model_config = ConfigDict( @@ -191,6 +198,23 @@ class Threat(BaseModel): example: str = Field(default="", description="Example of the threat") references: str = Field(default="", description="References for the threat") target: Tuple = Field(default=(), description="Target classes for this threat") + stride: str = Field( + default="unassigned", + description="STRIDE category of the threat, or unassigned", + ) + impact: str = Field(default="", description="Impact level of the threat") + capec: list[int] = Field(default_factory=list, description="CAPEC ids") + cwe: list[int] = Field(default_factory=list, description="CWE ids") + + @field_validator("stride", mode="before") + @classmethod + def _check_stride(cls, value: str) -> str: + return check_stride(value) + + @field_validator("impact") + @classmethod + def _check_impact(cls, value: str) -> str: + return check_impact(value) _compiled_condition: CodeType | None = PrivateAttr(default=None) _eval_globals: ClassVar[dict[str, Any] | None] = None @@ -210,6 +234,12 @@ def _normalize_input(cls, data: Any) -> Any: if "Likelihood Of Attack" in data: data.setdefault("likelihood", data.pop("Likelihood Of Attack")) + references = data.get("references", "") + if "capec" not in data: + data["capec"] = capec_ids(references) + if "cwe" not in data: + data["cwe"] = cwe_ids(references) + # Normalise target to a tuple target = data.get("target", "Element") if isinstance(target, str) or not isinstance(target, Iterable): diff --git a/pytm/tm.py b/pytm/tm.py index 891dee56..9a463079 100644 --- a/pytm/tm.py +++ b/pytm/tm.py @@ -31,6 +31,7 @@ from .actor import Actor from .dataflow import Dataflow from .boundary import Boundary + from .control import Control from .data import Data from .threat import Threat from .finding import Finding @@ -55,6 +56,7 @@ class TMState: threats: List["Threat"] = field(default_factory=list) boundaries: List["Boundary"] = field(default_factory=list) data: List["Data"] = field(default_factory=list) + controls: list[Control] = field(default_factory=list) threats_excluded: List[str] = field(default_factory=list) @@ -118,6 +120,7 @@ def _register_state_attribute(cls, name: str, descriptor: _StateAttribute) -> No _threats: ClassVar[_StateAttribute] = _StateAttribute("threats") _boundaries: ClassVar[_StateAttribute] = _StateAttribute("boundaries") _data: ClassVar[_StateAttribute] = _StateAttribute("data") + _controls: ClassVar[_StateAttribute] = _StateAttribute("controls") _threatsExcluded: ClassVar[_StateAttribute] = _StateAttribute("threats_excluded") @classmethod diff --git a/tests/output.json b/tests/output.json index 942519f0..69d15e55 100644 --- a/tests/output.json +++ b/tests/output.json @@ -57,6 +57,7 @@ "Show comments (*)" ], "isAdmin": false, + "kind": "ActorKind.UNKNOWN", "levels": [ 0 ], @@ -521,6 +522,7 @@ } ], "colormap": false, + "controls": [], "data": [ { "carriedBy": [ @@ -601,6 +603,7 @@ "Show comments (*)" ], "isAdmin": false, + "kind": "ActorKind.UNKNOWN", "levels": [ 0 ], @@ -996,6 +999,7 @@ "implementsCommunicationProtocol": false, "inBoundary": null, "inScope": true, + "isEncrypted": null, "isResponse": false, "levels": [ 0 @@ -1072,6 +1076,7 @@ "implementsCommunicationProtocol": false, "inBoundary": null, "inScope": true, + "isEncrypted": null, "isResponse": false, "levels": [ 0 @@ -1148,6 +1153,7 @@ "implementsCommunicationProtocol": false, "inBoundary": null, "inScope": true, + "isEncrypted": null, "isResponse": false, "levels": [ 0 @@ -1224,6 +1230,7 @@ "implementsCommunicationProtocol": false, "inBoundary": null, "inScope": true, + "isEncrypted": null, "isResponse": false, "levels": [ 0 @@ -1300,6 +1307,7 @@ "implementsCommunicationProtocol": false, "inBoundary": null, "inScope": true, + "isEncrypted": null, "isResponse": false, "levels": [ 0 @@ -1376,6 +1384,7 @@ "implementsCommunicationProtocol": false, "inBoundary": null, "inScope": true, + "isEncrypted": null, "isResponse": false, "levels": [ 0 From d4020223e0d01a891c2d22267a126576d50239b3 Mon Sep 17 00:00:00 2001 From: izar Date: Tue, 13 Oct 2026 13:38:47 -0400 Subject: [PATCH 2/5] feat: add --tmbom output mode (CycloneDX 2.0 TM-BOM) Print the threat model as a CycloneDX 2.0 TM-BOM JSON document on stdout: a data-flow blueprint (zones, boundaries crossed by flows, assets, actors, data stores, data sets, flows, assumptions), threats grouped by threat library SID with CAPEC/CWE links, risks per finding and the declared controls. The layout follows the 2.0 draft schema, pinned to a specific commit of CycloneDX/specification. The schema is not committed: the validation tests use tests/fetch_cyclonedx_schema.sh to obtain it and are skipped when it is absent. jsonschema is added as a dev dependency for them. docs/tmbom.md documents the mapping, caveats and trade-offs. Co-Authored-By: Claude Sonnet 5.5 --- .gitignore | 3 + CHANGELOG.md | 2 + README.md | 37 + docs/tmbom.md | 148 + poetry.lock | 209 +- pyproject.toml | 1 + pytm/pytm.py | 5 + pytm/tm.py | 8 +- pytm/tmbom.py | 796 +++++ tests/fetch_cyclonedx_schema.sh | 16 + tests/test_tmbom.py | 390 ++ tests/tmbom_output.json | 5928 +++++++++++++++++++++++++++++++ 12 files changed, 7540 insertions(+), 3 deletions(-) create mode 100644 docs/tmbom.md create mode 100644 pytm/tmbom.py create mode 100755 tests/fetch_cyclonedx_schema.sh create mode 100644 tests/test_tmbom.py create mode 100644 tests/tmbom_output.json diff --git a/.gitignore b/.gitignore index 2cfbfdad..e94fe54d 100644 --- a/.gitignore +++ b/.gitignore @@ -136,3 +136,6 @@ devbox.lock # zed .zed + +# Downloaded CycloneDX schema (tests/fetch_cyclonedx_schema.sh) +tests/schemas/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 8301e98a..9a59806a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ below). ### Added +- `--tmbom` CLI flag: prints a CycloneDX 2.0 TM-BOM (blueprint, threats and + risks) as JSON on stdout. - `Control` element (name, category, status, `mitigates`, `appliesTo`, `effectiveness`), also in `--json` output and input. - `Threat`/`Finding` attributes `stride` (default `unassigned`), `impact`, diff --git a/README.md b/README.md index 316dd5d6..6e3e7dff 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,8 @@ options: --describe DESCRIBE describe the properties available for a given element --list-elements list all elements which can be part of a threat model --json JSON output a JSON file + --tmbom output a CycloneDX 2.0 TM-BOM (threat model BOM) as + JSON --levels LEVELS [LEVELS ...] Select levels to be drawn in the threat model (int separated by comma). @@ -482,6 +484,41 @@ python tm2.py --dfd | dot -Tpng -o sample_json.png ``` +## Exporting a CycloneDX TM-BOM + +`--tmbom` prints the model as a [CycloneDX](https://cyclonedx.org/) 2.0 +threat model BOM (TM-BOM) JSON document on stdout: + +``` +./tm.py --tmbom > tm.cdx.json +``` + +| pytm | TM-BOM | +|------|--------| +| `Boundary` | `blueprints[].zones` (trust zones, nesting via `parent`) | +| flows crossing zones | `blueprints[].boundaries` | +| `Server`, `Process`, `Lambda`, `ExternalEntity`, `Agent`, `LLM`, `Actor` (`kind` unset) | `blueprints[].assets` | +| `Actor` with `kind` set | `blueprints[].actors` (person / organization / system / persona) | +| `Control` | `controls`, linked from threats and risk responses | +| `Datastore`, `Data`, `Dataflow`, `Assumption` | `dataStores`, `dataSets`, `flows`, `assumptions` | +| findings, one per threat library SID | `threats.threats` | +| every finding (including ones excluded by an assumption) | `risks.risks`, with `Finding.response` as the risk response | + +Notes: + +* The output follows the CycloneDX 2.0 draft schema, pinned to + [`CycloneDX/specification@906c579`](https://github.com/CycloneDX/specification/tree/906c57979d786c935787213ae1693a98097858ce/schema/2.0). + The schema is unpublished and may change. The tests validate against it when + it is present; fetch it with `tests/fetch_cyclonedx_schema.sh` (otherwise + those tests are skipped). +* pytm-specific details with no schema field (controls, ports, mitigation + text...) are kept as `pytm:`-prefixed `properties`. +* Threats carry CAPEC attack patterns and CWE weaknesses. STRIDE category, + impact, actor kind, control status and `Dataflow.isEncrypted` are exported + only when you set them. See `docs/tmbom.md` for the full mapping. +* `bom-ref`s are derived from element names, so they are stable across runs. + `serialNumber` and `metadata.timestamp` change on every run. + ## Making slides! Once a threat model is done and ready, the dreaded presentation stage comes in - and now pytm can help you there as well, with a template that expresses your threat model in slides, using the power of [RevealMD](https://github.com/webpro/reveal-md)! Just use the template docs/reveal.md and you will get some pretty slides, fully configurable, that you can present and share from your browser. diff --git a/docs/tmbom.md b/docs/tmbom.md new file mode 100644 index 00000000..e8c5616b --- /dev/null +++ b/docs/tmbom.md @@ -0,0 +1,148 @@ +# pytm → CycloneDX 2.0 TM-BOM mapping + +Generated by `python tm.py --tmbom` (`pytm/tmbom.py`). Schema: CycloneDX 2.0 +draft, pinned to `CycloneDX/specification@906c57979d786c935787213ae1693a98097858ce` +(`schema/2.0`). The schema is unpublished and may change. + +Conventions: + +* `pytm:x` means a TM-BOM `properties` entry named `pytm:x` (value always a + string). Used when the schema has no field for the pytm attribute. +* `bom-ref` values are `-`, with `-2`, `-3`... on collision. + They are stable across runs for an unchanged model. +* Empty/unset values are omitted rather than emitted as `""`/`[]`. + +## New pytm attributes and elements + +Added so that the export no longer has to leave TM-BOM fields empty. All are +optional and default to "unknown", in which case the field is omitted. + +| pytm `element.attribute` | Type / values | TM-BOM field | Notes | +|--------------------------|---------------|--------------|-------| +| `Threat.stride`, `Finding.stride` | `unassigned` (default), `spoofing`, `tampering`, `repudiation`, `information-disclosure`, `denial-of-service`, `elevation-of-privilege` (`"Information Disclosure"` is normalised; empty means `unassigned`) | `threats.threats[].categories[]` as `{taxonomy: "STRIDE", category}` | The bundled threat library assigns none, so every library threat is `unassigned`. The TM-BOM schema only allows the six STRIDE values in `category`, so `unassigned` cannot be written to the BOM: `categories` is simply omitted for those threats. Set `stride` on custom threats, or on a `Finding` override, to export it. | +| `Threat.impact`, `Finding.impact` | `negligible`, `low`, `moderate`, `major`, `catastrophic` | `risks.risks[].inherentRisk.impact.level` | Empty in the threat library: impact depends on the system. Set it on a `Threat`, or per element via an override `Finding(threat_id=..., impact="major")`. | +| `Threat.capec`, `Finding.capec` | `list[int]`; parsed from `references` (URLs or `CAPEC-n`) unless given | `threats.attackPatterns[]` (`capecId`, `name: "CAPEC-n"`), referenced from `threats.threats[].attackPatterns` | One attack pattern per distinct id; the pattern has no description because pytm only knows the id. | +| `Threat.cwe`, `Finding.cwe` | `list[int]`; parsed from `references` unless given | `threats.threats[].weaknesses[]` as `{cweId}` | | +| `Actor.kind` | `ActorKind`: `UNKNOWN` (default), `PERSON`, `ORGANIZATION`, `SYSTEM`, `PERSONA` | `blueprints[].actors[]` with `party` | `PERSON`/`ORGANIZATION` -> `party.person.name` / `party.organization.name`; `SYSTEM` -> `party.system.kind = software-system`; `PERSONA` -> `party.persona` (archetype `administrator` if `isAdmin`, else `end-user`). Party `roles` is mandatory in the schema, so a role is chosen from `isAdmin` (`operator` / `end-user`). System and persona parties, and `actors[]` itself, have no name field, so the name goes into `description` and `pytm:name`. `UNKNOWN` keeps the old behaviour (an asset of type `actor`). Flows reference the actor's `bom-ref` either way. | +| `Dataflow.isEncrypted` | `Optional[bool]`, default `None` (unknown) | `flows[].encrypted` | Takes precedence over the `tlsVersion` inference, so a flow can be explicitly `False`. | +| `Control` (new element) | see below | `controls[]` | New class; registered with the `TM` like `Data`; part of `--json` output/input as top-level `controls`. Not to be confused with `Controls`, the per-element boolean flags. | +| `Control.name`, `.description` | `str` | `controls[].name`, `.description` | | +| `Control.category` | `ControlCategory`: `UNKNOWN`, `PREVENTIVE`, `DETECTIVE`, `CORRECTIVE`, `COMPENSATING`, `DETERRENT`, `RECOVERY` | `controls[].category` | | +| `Control.status` | `ControlStatus`: `UNKNOWN`, `RECOMMENDED`, `PROPOSED`, `APPROVED`, `REJECTED`, `PLANNED`, `IN_PROGRESS`, `IMPLEMENTED`, `VERIFIED`, `DECOMMISSIONED` | `controls[].status` | Default is `UNKNOWN` (omitted) so declaring a control never silently claims it is implemented. | +| `Control.mitigates` | `list[str]` of threat SIDs (a `Threat` or one SID is accepted) | `threats.threats[].mitigations` (refs to the control) and `risks[].responses[].controls` | A risk links a control only when the SID matches and `appliesTo` is empty or contains the finding's element. A linked control with no explicit `Finding.response` gives the risk a `reduce` response. Linking does **not** change findings or `resolve()`: a threat is still reported as a finding even if a control mitigates it. | +| `Control.appliesTo` | `list[Element]` (any element, flow or boundary); empty = whole model | `controls[].appliesTo` | Elements that never appear in the BOM are dropped from the list. | +| `Control.effectiveness` | `ineffective`, `marginal`, `adequate`, `good`, `excellent` | `controls[].effectiveness.rating` | Validated on assignment. | + +### Existing attributes that are now mapped + +| pytm `element.attribute` | TM-BOM field | Notes | +|--------------------------|--------------|-------| +| `Data.isDestEncryptedAtRest` / `Data.isSourceEncryptedAtRest` | `dataSets[].placements[].encrypted` | A placement is created for each data store the data flows into (only if `Data.isStored`) or out of. `encrypted` is emitted only when true. **`check()` overwrites both flags from the stores' `controls.isEncryptedAtRest`**, so set that on the `Datastore`, not on the `Data`. | +| `Dataflow.usesSessionTokens` | `flows[].authentication` = custom `{name: "session-token"}` | The schema's `sessionManagement` (on boundaries) has no "uses tokens" flag, only token lifetimes and logout behaviour; a custom authentication type was the closest honest fit. | +| `Data.credentialsLife` | property `pytm:credentialsLife` | No schema field. Note `check()` sets it to `UNKNOWN` for credential data without a lifetime. | +| `Dataflow.response` | `flows[].synchronous = true` on the request flow | A flow that has a response is request/response. `bidirectional` is not used because the response is already emitted as its own flow. | + +## 1. Document level + +| pytm | TM-BOM | Notes / caveats | +|------|--------|-----------------| +| (generated) | `$schema`, `specFormat: "CycloneDX"`, `specVersion: "2.0"`, `version: 1` | `$schema` points at the pinned raw GitHub schema URL. | +| (generated) | `serialNumber` | New `urn:uuid:` each run, so two runs of an identical model differ. `version` is always 1; pytm does not track BOM revisions. | +| (generated) | `metadata.timestamp` | UTC, seconds resolution. | +| pytm itself | `metadata.tools.components[0]` (`application`, `pytm`, version) | Version is empty when run from a source tree (not installed). | +| `TM.name`, `TM.description` | `metadata.component` (`type: application`, `name`, `description`) and `blueprints[0].name/description` | The modelled system is declared as an *application* component. This is a guess: pytm does not know whether the system is an application, service, platform, etc. | +| `TM` | `blueprints[0]` with `modelTypes: ["data-flow"]` | One blueprint per run. | + +## 2. Elements + +| pytm element | TM-BOM target | Attributes translated | Notes / caveats | +|--------------|---------------|-----------------------|-----------------| +| `Boundary` | `blueprints[].zones[]` (`type: trust`) | `name`→`name`; `description`→`description`; `inBoundary`→`parent` (zone ref); `inScope`→`pytm:inScope` | pytm boundaries are *regions* (zones). The TM-BOM `boundaries[]` entry is the *line between two zones* (see next row), so the concept is split in two. | +| (derived from `Dataflow`) | `blueprints[].boundaries[]` (`type: trust`, `zones: [a, b]`) | One per unordered pair of zones that at least one flow crosses; `name` = `"A <-> B"` | pytm has no such object. Only crossings made by flows are recorded; two adjacent zones with no flow between them get no boundary. Schema requires ≥2 zones, so a boundary is never emitted for a flow inside one zone. | +| (derived) | implicit zone `zone-unbounded` / "Unbounded" | Added only when at least one boundary exists *and* some element has `inBoundary = None` | Lets flows from unplaced elements still cross a boundary. If the model has no boundaries at all, no zones/boundaries are emitted. | +| `Actor` | `blueprints[].assets[]` (`type: actor`), or `actors[]` when `kind` is set | `name`, `description`, `inBoundary`→`zone`, `maxClassification`→`classification.classification`, `inScope`, `protocol`, `port`, `isAdmin` (only when true), enabled `controls` → `pytm:*` | `actors[]` requires a `party` (organization / person / system / persona). pytm used to not know which, so actors stayed assets; setting the new `Actor.kind` now emits a real `actors[]` entry instead (see the new-attributes table). Actors with `kind` unset still come out as assets, so tools looking for `actors[]` won't find those. | +| `Server` | `assets[]` (`type: service`) | as `Actor` (plus `OS`) | Server-specific flags (`usesSessionTokens`, `usesCache`, `usesXMLParser`, ...) are **not** translated (see §4). | +| `Process` | `assets[]` (`type: process`) | as above | | +| `Lambda` | `assets[]` (`type: function`) | as above | | +| `ExternalEntity` | `assets[]` (`type: system`) | as above | "system" is the closest predefined type; a custom type could carry the original name. | +| `Agent` | `assets[]` (`type: agent`) | as above | AI-specific flags not translated. | +| `LLM` | `assets[]` (`type: model`) | as above | AI-specific flags not translated. | +| other `Asset` subclasses | `assets[]` (`type: component`) | as above | Fallback. | +| `Datastore` | `blueprints[].dataStores[]` | `name`, `description`, `inBoundary`→`zone`, `type`, `dataSets`, `isSQL`, `isShared`, `onRDS`, plus the asset properties above | `DatastoreType` mapping: `SQL`→`relational`, `FILE_SYSTEM`→`file`, `LDAP`→`hierarchical`, `AWS_S3`→`object`, `UNKNOWN`→custom `{name: "unknown"}` (type is mandatory). `isSQL` defaults to true in pytm, so it is always emitted even for non-SQL types and may contradict `type`. `dataSets` is *inferred*: the `Data` carried by flows whose sink is this store; pytm's own `Data.processedBy` is not used. | +| `Data` | `blueprints[].dataSets[]` | `name`, `description` (falls back to `name`, schema requires non-empty), `classification`, `format`, `isPII`, `isCredentials`, `credentialsLife`, `isStored` → `pytm:*`; at-rest encryption → `placements` | The data-set schema has no classification field, so classification is a property here (and a real field on assets). Booleans are emitted only when true. | +| `Dataflow` | `blueprints[].flows[]` (`type: data`) | `name`; `description`; `source`/`sink`→`source`/`destination`; `protocol`→`protocols[0]`; `tlsVersion`≠NONE→`encrypted: true`; `order`→`sequence`; `isEncrypted`→`encrypted`; `usesSessionTokens`→`authentication`; `response`→`synchronous`; `data`→`dataObjects[]` (`name`, `description`); `note`, `srcPort`, `dstPort`, `tlsVersion` (name), `usesVPN`, `isResponse`, `responseTo` (flow ref), `inScope`, enabled controls → `pytm:*` | `encrypted` comes from `Dataflow.isEncrypted` when set; otherwise it is `true` when a TLS version is set and omitted else, because "no TLS" ≠ "known unencrypted" (a non-TLS protocol such as SSH would be mis-labelled). Responses are ordinary flows; the link is only `pytm:responseTo`. `dataObjects` are inline copies linked to data sets by *name only*, not by ref. | +| `Assumption` (`TM.assumptions`, `Element.assumptions`) | `blueprints[].assumptions[]` | `name` + `description`→`description` (`"name: description"` when both); `exclude`→`impact` (`"Excludes threats: A, B"`); owning element→`relatedAssets` | The schema has no structure for "excludes threat X", so it is free text in `impact`. Element-level assumptions reference their element; global ones have none. Assumptions attached to flows are included; those attached to boundaries are not. An assumption with neither name nor description is dropped. | +| `Classification` | `assets[].classification.classification` (from `maxClassification`) | `PUBLIC`→`public`, `RESTRICTED`→`restricted`, `SENSITIVE`→`confidential`, `SECRET`/`TOP_SECRET`→`classified`, `UNKNOWN`→omitted | `SECRET` and `TOP_SECRET` collapse into one value (lossy). | +| `Controls` (`Element.controls`) | `pytm:controls.` properties | Every control that is true / non-empty (e.g. `isEncrypted`, `authenticationScheme`) | Only *enabled* controls appear. They are not translated into TM-BOM `controls`, see §5. | + +## 3. Findings + +| pytm | TM-BOM | Attributes | Notes / caveats | +|------|--------|-----------|-----------------| +| Findings + excluded findings, grouped by threat SID | `threats.threats[]` (`bom-ref: threat-`) | `description`→`name`; `details`→`description`; `source: "pytm"`; `stride`→`categories`; `cwe`→`weaknesses`; `capec`→`attackPatterns`; controls with matching `mitigates`→`mitigations`; affected elements (any element kind, deduplicated)→`affectedAssets`; http(s) links in `references`→`externalReferences` (`type: other`); `threat_id`, `severity`, `likelihood`, `condition`, `mitigations`, `example`, `references` → `pytm:*` | One TM-BOM threat per SID, shared by all elements it hit, whereas pytm has one finding per element × threat. Taken from the first finding of the group. `categories` is omitted when `stride` is empty (custom threat files). The library's free-text `mitigations` stays in `pytm:mitigations`; the TM-BOM `mitigations` field holds references to declared `Control`s. CVE ids and non-CAPEC/CWE references are only kept inside `pytm:references`. A threat appears here even if all its findings were excluded. | +| `Finding` | `risks.risks[]` (`bom-ref: risk-`) | `description` + `target`→`name`; `details`→`statement`; element→`affects`; SID→`relatedThreats`; `severity`→`inherentRisk.score.level`; `likelihood`→`inherentRisk.likelihood.level`; `impact`→`inherentRisk.impact.level`; linked controls→`responses[0].controls`; `response`→`responses[0].strategy` and `status`; `mitigations`→`responses[0].description`; `cvss`, `id` → `pytm:*`; `domains: [security]` | Severity: `Very High`/`Critical`→`critical`, `High`→`high`, `Medium`→`medium`, `Low`→`low`, `Very Low`→`info`. Likelihood: lower-cased and hyphenated, empty → omitted. Response: `mitigated`→`reduce`/`mitigated`, `transferred`→`transfer`/`transferred`, `accepted`→`accept`/`accepted`, `avoided`→`avoid`/`retired`; no response → status `identified`, no response entry. `impact` is only present when set (see above): pytm severity blends likelihood and impact and cannot be split. `retired` for "avoided" is a judgement call. | +| Excluded `Finding` (suppressed by an assumption) | `risks.risks[]` (`bom-ref: risk-excluded-`) | as above, `description`: `"Excluded by assumption: "` | Forced to status `accepted` with an `accept` response. Without this the excluded threats would silently vanish from the BOM. | + +## 4. pytm attributes not translated + +Dropped without a `pytm:` property: + +* Element: `levels`, `sourceFiles`, `overrides`, `severity` (the 0–5 element + rollup), `minTLSVersion`, `uuid` (random, not stable). +* Asset: `onAWS`, `handlesResources`, `usesEnvironmentVariables`, `inputs`, + `outputs`, `data` (default incoming data). +* Server / Process / Lambda / Agent / LLM / Datastore specific booleans other + than `isSQL`, `isShared`, `onRDS`. +* Data: `carriedBy`, `processedBy`. +* Dataflow: `implementsCommunicationProtocol`. +* TM: `isOrdered`, `mergeResponses`, `ignoreUnused`, `onDuplicates`, + `threatsFile`, `colormap`. +* Finding: `condition` appears only on the threat, not the risk. + +Not emitted at all (sections of the TM-BOM with no pytm counterpart or no +reliable data): `components`, `services`, `dependencies`, +`visualizations`, `useCases`, `requirements`, `scope`, +`attackTrees`/`attackPaths`, `threats.methodologies`, +`threatProfiles`. + +## 5. Trade-offs + +1. **Pinned to an unpublished draft.** Output is validated against a specific + 2.0 draft commit. Field names and enums may move before release; each bump + needs `COMMIT` in `tests/fetch_cyclonedx_schema.sh` (and `SCHEMA_COMMIT` in + `pytm/tmbom.py`) updated and the mapping rechecked. The schema is not + committed; the validation tests skip when it has not been fetched. Consumers built for a later draft may reject the file. +2. **Fidelity vs. validity.** Everything with no schema field goes into + `pytm:` string properties (booleans become `"true"`). This keeps the BOM + valid and lossless-ish, but tools will ignore them, and values are untyped. +3. **No invented data, but it is now the modeller's job.** Actor party, impact, + control status and explicit encryption are only exported when set; the + defaults stay "unknown" and are omitted. STRIDE is `unassigned` (and omitted) + for every bundled threat. +4. **Threat deduplication.** One threat per SID with a list of affected assets + matches the TM-BOM model but loses per-element detail from the threat + (that detail survives in the per-finding risks). Threat text comes from the + first finding in the group. +5. **Findings as risks.** Every finding becomes a risk, so a model with many + findings yields a large `risks` section (139 for the sample). It is + complete but noisy; there is no aggregation or prioritisation. +6. **Two notions of control.** The library's `mitigations` are still advice + text, and the `Controls` boolean flags still drive threat conditions and are + exported as properties. Only the new `Control` objects become TM-BOM + `controls`, and declaring one does not suppress findings or change + `resolve()`; linking is export-only. `Control` vs `Controls` is an easy + name clash. +7. **Inferred structure.** Boundaries are derived from flows, the unbounded + zone is synthesised, and data-store `dataSets` come from flow sinks. These + are reasonable but not facts in the pytm model. +8. **Name-based identity.** Refs derive from names (pytm has no stable ids), so + renaming an element changes its `bom-ref`; same-named elements get numeric + suffixes whose order depends on creation order. +9. **Non-reproducible by default.** `serialNumber` and `timestamp` change each + run. Tests inject fixed values via `build_tmbom(...)`; the CLI does not + offer a flag to do so. +10. **Output to stdout only**, like `--report`, rather than a file like + `--json`; use shell redirection. Resolution (`TM.resolve()`) is run + implicitly, so `--exclude` applies. +11. **No import path.** `--tmbom` is export only; `pytm.load` still reads only + pytm's own `--json` format. diff --git a/poetry.lock b/poetry.lock index 28648b73..5fcff4cb 100644 --- a/poetry.lock +++ b/poetry.lock @@ -12,6 +12,18 @@ files = [ {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, ] +[[package]] +name = "attrs" +version = "26.1.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309"}, + {file = "attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32"}, +] + [[package]] name = "black" version = "26.3.1" @@ -103,6 +115,43 @@ files = [ {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, ] +[[package]] +name = "jsonschema" +version = "4.26.0" +description = "An implementation of JSON Schema validation for Python" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce"}, + {file = "jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326"}, +] + +[package.dependencies] +attrs = ">=22.2.0" +jsonschema-specifications = ">=2023.3.6" +referencing = ">=0.28.4" +rpds-py = ">=0.25.0" + +[package.extras] +format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] +format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "rfc3987-syntax (>=1.1.0)", "uri-template", "webcolors (>=24.6.0)"] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe"}, + {file = "jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d"}, +] + +[package.dependencies] +referencing = ">=0.31.0" + [[package]] name = "mako" version = "1.3.12" @@ -576,6 +625,161 @@ files = [ [package.extras] dev = ["black", "build", "mypy", "pytest", "pytest-cov", "setuptools", "tox", "twine", "wheel"] +[[package]] +name = "referencing" +version = "0.37.0" +description = "JSON Referencing + Python" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231"}, + {file = "referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8"}, +] + +[package.dependencies] +attrs = ">=22.2.0" +rpds-py = ">=0.7.0" +typing-extensions = {version = ">=4.4.0", markers = "python_version < \"3.13\""} + +[[package]] +name = "rpds-py" +version = "2026.9.1" +description = "Python bindings to Rust's persistent data structures (rpds)" +optional = false +python-versions = ">=3.11" +groups = ["dev"] +files = [ + {file = "rpds_py-2026.9.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:2711d29b653b3bce48a63d18b9c6b53274669e6d6c4094dddeb4d9a0e45128b2"}, + {file = "rpds_py-2026.9.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3231c4c0e521dafa5be0c9f114ee2c2ad46650836f2d72caa86801950c3e7044"}, + {file = "rpds_py-2026.9.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e01b3c878c8641913e688edd1b3f08658c6783d29cf6b826bd3c0d1ae7a1ffaa"}, + {file = "rpds_py-2026.9.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3e524c7874ac72884d28e16dd5b8d839fd09e0fe76b020d3fbca23212a7b8c52"}, + {file = "rpds_py-2026.9.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:761fdae6728ceb99ab182fad2f0cc1e262f610834dc891aea1d1a2a2e634776f"}, + {file = "rpds_py-2026.9.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6b723eb406dec5bc9ec516c73ab9c3239a3284e017f7eb89ee2b3258bd504fb7"}, + {file = "rpds_py-2026.9.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:136a1c3fe4402b7008bc81cb62ee538481795b61a7e83df88dff3b3f02b726ff"}, + {file = "rpds_py-2026.9.1-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:839dde845559254f34885267c6878f60d61d5205180226d976fe488d45fa128e"}, + {file = "rpds_py-2026.9.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:56cd8b3f77d7b6812f533b662186a1f28316931166ddc00fb893b1b0db7e9888"}, + {file = "rpds_py-2026.9.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:074a4d198bc34d9a8ea425114fc3ded6d11ec01f6a314a8db67454a5152d8834"}, + {file = "rpds_py-2026.9.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6beb738155fe8ab8091afdfa5a3226b21c2b1593f1e50ebb90eb25b44dbc0391"}, + {file = "rpds_py-2026.9.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:57492a550a1d88d29d003247e5f78dd8cf04a701fac0e4c8db8745a6d2504e0a"}, + {file = "rpds_py-2026.9.1-cp311-cp311-win32.whl", hash = "sha256:d95a354e02393eada6d7351184671aced9d4cce109dabf927cb7aa99624352a1"}, + {file = "rpds_py-2026.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:ce4d4f52e2a4324396caddbd45a97d8d7be5f42edd25d2355282a9c34f9b2f7f"}, + {file = "rpds_py-2026.9.1-cp311-cp311-win_arm64.whl", hash = "sha256:fdcd198979b4ecffcc1beba366a7fbcf4eb41243691a82fe52ceb0b902f09c12"}, + {file = "rpds_py-2026.9.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:50906f5aea24b5a865cbd0a589698288631d9f3a54c3a937c83aefa95a0d14af"}, + {file = "rpds_py-2026.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e21c1429e205828ea886a2293a4a2c8e01f4c25d9893ca330e97a6cf73f52e7b"}, + {file = "rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2693b2728bbcc48d09a981a356954b0c47c53ff25b545856f28a889ea619f69a"}, + {file = "rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8601470267d938bcb7f3ab1a336100af51a4fd5b6ed030ef52461bb3ef5e7e07"}, + {file = "rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3890a6aa36e6baa53d5258a2a25d3ef8b37ad165a6ab27a892d7c3e3a432cd69"}, + {file = "rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6b5b393eda5ea42cca1c1a6665f2a4882b4fd5d1777e41ce0545a107fb008c9d"}, + {file = "rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:addeda51556dac7c1a2f14cda62db8b621cd12afba3091d03a96c72932387eab"}, + {file = "rpds_py-2026.9.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d9edf30457d74eebfd76b045535e36f1cd89062566a128a0db2145ca042d787e"}, + {file = "rpds_py-2026.9.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:815d26356930846a40c7bc1366e7b1b0320ab8a063e66c11298a208bed0fd237"}, + {file = "rpds_py-2026.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3b5a6f40f0a1486b4b36c888123afc67acdbd9f33235927acf5ff295429a0ba3"}, + {file = "rpds_py-2026.9.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b5b8b0753718d258fd454283fbd57e14545d3b40583fa672e27cb4f987626bcc"}, + {file = "rpds_py-2026.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:46d80bc76b51a6c24f9944368c28d38b8bcbcea1da4f2f8d3ebc31a67e8c6ec6"}, + {file = "rpds_py-2026.9.1-cp312-cp312-win32.whl", hash = "sha256:befc2d6a953e563f8a7bfd87a42c22ebf8a3e980dcb7b6a4d17b70b0e914e8a3"}, + {file = "rpds_py-2026.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:5ce8943f79c2210f7abcc28e86367b03b28d95027fd01c46d2472373ae70c86f"}, + {file = "rpds_py-2026.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:501909f2e4a1e2dee528ef766fe3c469060ebc17e54a8383d404ba07a81a6f02"}, + {file = "rpds_py-2026.9.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:a36b70596407634ca82d4b989a3729074a008537a0522e4c8046a67c729103e9"}, + {file = "rpds_py-2026.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eba5d173f7d5708b22a93815017a4611873ed54db9f268077c0dd1ed99cfc858"}, + {file = "rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:457866b85daf5034296666168b84a69e0b2e89dc4f1af102b46f6448a60b9063"}, + {file = "rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a3a52a3ba86436ab3aef510fbe21512abc2ddd1993005dfe50514bd2284ef025"}, + {file = "rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d7841166b7fa64c9c56404617ae4341448847482d45933b13135d26c130519e5"}, + {file = "rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:926bdd3e3b5998ddf70cc64bc8cf57209571f9044542913afb673799fec77dd0"}, + {file = "rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7868b85224291c6cb6759f9b5adb9745f486d226f62b16a614dd5a2a5ab2b35b"}, + {file = "rpds_py-2026.9.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:3cd182d7291d29b92c521a0069d9c01ba6193628a9a105531d11b40a6d731a33"}, + {file = "rpds_py-2026.9.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e6ea1cda8d8c688278430e4268a42f5e5da3bdd74578dfadc0820c3f1766ce83"}, + {file = "rpds_py-2026.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5943980471829f6de242a20b109de3111ba6b77e3af0ffc587028ac854b05e6c"}, + {file = "rpds_py-2026.9.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:76d3af9732d2dab69f28179b40ba2d87e2f1d5824b4a694780aa787d685e8f36"}, + {file = "rpds_py-2026.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:78326f4cb4427a56ba4996c0762b63be45f06b85f086526420d2b3a66e40f84d"}, + {file = "rpds_py-2026.9.1-cp313-cp313-win32.whl", hash = "sha256:172e47169583f46ce118cbec68e6795d0da0f4606b488b6434f8276bca0a058c"}, + {file = "rpds_py-2026.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:3e93b2cd69a9830be33e03945cd7cda940a0a8bfcfbff41d6144f0cb0d3d8bd9"}, + {file = "rpds_py-2026.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:d151e148117294133bf8af7eeace085e7e87432db15ab6adf640330298a47f6f"}, + {file = "rpds_py-2026.9.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c9d1aca01f49170fdcf5c92761b1fafe97f554b721ca4570c5949fff778f0d4b"}, + {file = "rpds_py-2026.9.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3f0e9ac28fc067d4d34b88ae43c48e9489455c97fee9633d851f7eeed5a05d35"}, + {file = "rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:07deecbfce94c78473018bc7d10b337cc651d12df87a1eb2cb3e4024bc9c33d0"}, + {file = "rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:821b2755db9194409254012f429c56643416fb96ef9be090be82ec8826b7f477"}, + {file = "rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c91c210ae7645626c608400e3519b4a642f837cce09ca830db3beb2e9f274d4"}, + {file = "rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:54ac2158a6f96cfbabff0b2eedaf94b90c5ec7ca8317fcadc61e1c2b2e0ff6ef"}, + {file = "rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eac2f5dbafd585dfe31f86a23ebf0d3ba480a9d49ebc87947267b5608d4ea0cd"}, + {file = "rpds_py-2026.9.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:8aa5dda18d39b6143eb24809d158f9252c88f402749b6f1b62a506cc7d96cc35"}, + {file = "rpds_py-2026.9.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5c90e7fa02e8f5de0d10c17595c568ada48c5302e749462c0ea1a4c362111a86"}, + {file = "rpds_py-2026.9.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e6d198bad4e49dd6732fbd636e2fc5c082f45c8cad0b4acb756b00c82c76072e"}, + {file = "rpds_py-2026.9.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:96beca19ec79de272e8668585380ff9092c47077c1d7a1e098e00bbd921f4785"}, + {file = "rpds_py-2026.9.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5cf77eb04f20b720be95265a3e00eb2a14814074255cc27069c551b2db53118"}, + {file = "rpds_py-2026.9.1-cp314-cp314-win32.whl", hash = "sha256:a03d57b86d2a51d0a66c92177e2be154ad015f357791d306e714569999cdb4cc"}, + {file = "rpds_py-2026.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:837c6b305e26fe0f75b15c92cf3b2ba29e0ae19dc40b1c557b026cb426347d0c"}, + {file = "rpds_py-2026.9.1-cp314-cp314-win_arm64.whl", hash = "sha256:fce4b85234a0cbad67bf8e6e1201ee815d172c9aebad75f25645bc4d834f8e31"}, + {file = "rpds_py-2026.9.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:3a72c11530d71abfb66c8d7696a2f86c43e63fca8b948f1a784ac490f4ec688e"}, + {file = "rpds_py-2026.9.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:068c37bba854ec2fe42f7365c640af11dd9895890ccbf2df5070d0c059bd7f96"}, + {file = "rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fca4eb6df565e2a928f1c7dad92d27db8f9df0f449e76423ed5d7e713ed445"}, + {file = "rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c933c6678c6f116ff8af47a4c6db0868b8ace74af0343016c0ef00f00272ea69"}, + {file = "rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:028ad274ea951dac64491b5d1e65712a4aeabfdbdb9fccf797b57bd899b0c495"}, + {file = "rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:740d0a99cf9de0b17a3943388e9294a59becf75e7c43421f387bd3c7a9901f7c"}, + {file = "rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0da298fb372dc192610a4b9ecbc68a0cd8b675bbbd1fc519d01b41cfd658333e"}, + {file = "rpds_py-2026.9.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:eb61be926bb81567c1f48bdc8aa22b9855048dc2efd53871f9f7e6e9a5632346"}, + {file = "rpds_py-2026.9.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:42e75466f83cd43f6026c81eab74246efb2bdadafb307b85700632d06c68f299"}, + {file = "rpds_py-2026.9.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:617f59cde379b4f648a09797b7f683d04b90a46344cddab85639da5aff0f5531"}, + {file = "rpds_py-2026.9.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3edae8c5ddfdb6985d49ae9d150516e5076888879022f91a26c2de9276ce0bdb"}, + {file = "rpds_py-2026.9.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0f045bb053c9057720d72c56dffe30dffdc05997b2897a827b9325f0ab6623fa"}, + {file = "rpds_py-2026.9.1-cp314-cp314t-win32.whl", hash = "sha256:bf35d0568abda97233239ce32896d3ad53fccc537832c104e30c94aa5fb93569"}, + {file = "rpds_py-2026.9.1-cp314-cp314t-win_amd64.whl", hash = "sha256:1e8d4d79d828299bf44a55db22a9388ab967b49d17132c88eab0f4360b48da8e"}, + {file = "rpds_py-2026.9.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:1d77b649e6f7cdf12ca5c2a98dad0ad37f9ea9b6f960408a92f0cb12bb3d04d9"}, + {file = "rpds_py-2026.9.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:00ba2d8c7dd4ee537978ddf4b3fbd712bef2d8751603f7f3146b3f4287768e25"}, + {file = "rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec450527cbf485e13c8d3602a54f428ab0432fdade0ede75efd74b735421c871"}, + {file = "rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:306ee1850d8105b5baf977e78d45fcadd12c1a54678d614c9baf217708446e91"}, + {file = "rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ef6b65b03247c54692ad4fd9ee97cb772781927db72e3cb05e70b3db6d1ff14f"}, + {file = "rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a575404ebc9cf2e91edd32eaf570ec1430eb900d4f56724ba7dd4bc1fc9c176d"}, + {file = "rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c16ab111bc27c646ba8aa005d0527754edc538ebb636f0b1bf8e244b48d1945"}, + {file = "rpds_py-2026.9.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:7664419f27db41d4f1c43a78dccda7dd6e8ef2428df3ee01d0c2a07a6b071297"}, + {file = "rpds_py-2026.9.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4b26b03d9d2658ee2fa234f8f4f19f38a09773fe5261028025032e26d4d35af0"}, + {file = "rpds_py-2026.9.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:be3e47e2d91aa3942ff9bf4077a505226005abfc39b6f7554a91c1b9393986b9"}, + {file = "rpds_py-2026.9.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:6307a0da524939decb8ca4a3933b8ab62525794411d6984fca6726e732804af6"}, + {file = "rpds_py-2026.9.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:159a7aab5c5e8b112c8830f54717ce56da1252ebdbb526f5be2df2309280b9e7"}, + {file = "rpds_py-2026.9.1-cp315-cp315-win32.whl", hash = "sha256:dbc2673f9223d420c91145599b3ba45a8a50c207d1976908e5fb5ddb0c9b9429"}, + {file = "rpds_py-2026.9.1-cp315-cp315-win_amd64.whl", hash = "sha256:75c38c50ab9aca840225d9a9a3810bf11d04bd5c1f186cabbb8aee56db3e9b15"}, + {file = "rpds_py-2026.9.1-cp315-cp315-win_arm64.whl", hash = "sha256:a431156bb41865fc14cd5d79bb9d7bbed83110b0159e34e62ae30951f96c0009"}, + {file = "rpds_py-2026.9.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ef0d8c843e2827d6c120ab4687e9423fb1d893db1df27b7c1506615bcb9734a0"}, + {file = "rpds_py-2026.9.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:45bc6bccf78b20fd834237d18db64965d7ee68ba7f60440a26c7ab71e7b8d51a"}, + {file = "rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d55198263bb51f557550c6ed2e6d1cb6a6fed6eb5c9120b741c5926bef8a45d"}, + {file = "rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a8763f20692da7df39b0afdd1ba3042b004c50a45994f76c2d9a25641f7673db"}, + {file = "rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e43d4a1f673e8a1cbd8533e809e02b4bf9d4f2280269bb640436556312121250"}, + {file = "rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea394a937f17a54c51239348bdbe2e3518124c8d4a8951ba04a311d3095bd18f"}, + {file = "rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdeaa99ce822dca76cfb1b993e9120c5ea212f2eb66d48950ad63c349668a018"}, + {file = "rpds_py-2026.9.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:b4f062343e7ad3fa94f2c66e5ae667dee47ee74dd41a9057c4fbe163236a123d"}, + {file = "rpds_py-2026.9.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:22ffd29a63d71fb1b81552c21f2c2b734949b7ac751a9be70675a939a900839b"}, + {file = "rpds_py-2026.9.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:08dae4a4095150a7c4545a1fb40b98e1ab1744fbc2770d92c977b9dadaa49ab6"}, + {file = "rpds_py-2026.9.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:9a0460d43603d1fd9ef59c30278531e15d78581721ddb538fa560aa7817ea4ad"}, + {file = "rpds_py-2026.9.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:1c2d1f6da5128eabf34e963d7163a818846075a52568250d006c4c953b40f903"}, + {file = "rpds_py-2026.9.1-cp315-cp315t-win32.whl", hash = "sha256:5c6ee90dee3e85e055ddfd502d611643d9b0fd94c818220bda84ec3dacd9b27b"}, + {file = "rpds_py-2026.9.1-cp315-cp315t-win_amd64.whl", hash = "sha256:fe5ad0664ec772b02c45859041aa17655709cced7a31005817fbbbd988c25567"}, + {file = "rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c0d2cb595a420b34d5086db0add011e26e2c09d6a024afbac4228bf8f863a30"}, + {file = "rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f3d6ed6a98cfd19155996605474982cc470d7601746a6439078f1a5a3fa8b050"}, + {file = "rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8171b44a054e5c67fd748ada04187f1250bf35b95f85e52ab64bcf3331a923bb"}, + {file = "rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fda1d96e542c37b6c804547dbf489c129fe7c97183a76a5ec275909ba1a063df"}, + {file = "rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:270bdcdaac5d5b6f73c5e22e7e135c7f2a50e789f71d9e241d5be8d90026e19a"}, + {file = "rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:84a6ecc0c940169190d2c23bd969debd48c94dbc855acd60188a68d71d421608"}, + {file = "rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ab4b2fda7c2b542f7f9d886cc6a838c5079d2b76f72e6081411faba11adde2c9"}, + {file = "rpds_py-2026.9.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:44b32a7c4f0da3d28af31c259e38ddcff096f855e205ed0671d02fcf44f1ea1c"}, + {file = "rpds_py-2026.9.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:a3dbc5ed9514908d5046107d7b1346bde71eea61de6e0e4919c19354f97e769f"}, + {file = "rpds_py-2026.9.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:6eae33003518fd4cb4f83a218d5371469dd3001aa3b87128c005b07762f7fe5e"}, + {file = "rpds_py-2026.9.1-pp311-pypy311_pp80-macosx_10_12_x86_64.whl", hash = "sha256:0483515261947e4e8b8e1375bf7463e7eb6ccfb3d86e7b554d90cd5285f20f32"}, + {file = "rpds_py-2026.9.1-pp311-pypy311_pp80-macosx_11_0_arm64.whl", hash = "sha256:4cfaf02209061880210819934de2f4f6aa83dc04dafe6770276acc240a56da31"}, + {file = "rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6cdc537c8633d7fd92a82e2e0d2ab74320a3f63d5e59fb9cf08711e08fe151c4"}, + {file = "rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:10e208f2425d973938afcd56e28a7c4be32e27b6a60b5d381f49fb9d8acf9759"}, + {file = "rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c0dbbcc19735fe5f8b0a54c07659d154a9e69f47e15d0a6ab7299215daf62cb"}, + {file = "rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:684fd492fff4fead00587544e059be2bbcb6f93454f21fa2a91b66fc7508be82"}, + {file = "rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1028417bb44037eb3069c1009bd7b7277212876cda22fbe565b0bca9fab6d2c"}, + {file = "rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:492e5e428cbe126221611f47e068f01660352feec4ad18bc0f5ea9b2ae88fb14"}, + {file = "rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:88b5268892fde430d5531f95bc560b6efbbd67c929662c586afd729a96e7461c"}, + {file = "rpds_py-2026.9.1-pp312-pypy312_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:01445c8d194aa032a08e944f16567672da1c62dbdbefd8b6d0693032e290cf68"}, + {file = "rpds_py-2026.9.1-pp312-pypy312_pp73-musllinux_1_2_i686.whl", hash = "sha256:eef6a03b0b6d08d0835ccfa8ec8d1bc70525e3801387567137b50c557695e6da"}, + {file = "rpds_py-2026.9.1-pp312-pypy312_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:6b9bf3135b4ad5981df9a73d71a35272d650a2985ae9c2746357b24d59de2448"}, + {file = "rpds_py-2026.9.1-pp312-pypy312_pp80-macosx_10_12_x86_64.whl", hash = "sha256:56c6952a9b15047466d0c2347c446a761d4527f89976156341e68f0ce5cc08b0"}, + {file = "rpds_py-2026.9.1-pp312-pypy312_pp80-macosx_11_0_arm64.whl", hash = "sha256:b242c27c8f836305a4a72df9cdd564386ac57b807bd252a063223331c9316b37"}, + {file = "rpds_py-2026.9.1.tar.gz", hash = "sha256:4793ef7f78268b124b73fa933440f01d258bbae01de9fa53e9080c9ab0425a12"}, +] + [[package]] name = "ruff" version = "0.15.11" @@ -610,11 +814,12 @@ version = "4.15.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] +markers = {dev = "python_version < \"3.13\""} [[package]] name = "typing-inspection" @@ -634,4 +839,4 @@ typing-extensions = ">=4.12.0" [metadata] lock-version = "2.1" python-versions = ">=3.11,<3.15" -content-hash = "f4238c8a1f5b27910133f8160430adf18273c52749fbd59c9250c3909fb35111" +content-hash = "916dfab814a34d5b925a2b8eb62e9f107fd795c1639741e78891edcb969bd8dc" diff --git a/pyproject.toml b/pyproject.toml index 83aadc07..7aef2bdb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ dev = [ "black>=25.9,<27.0", "pdoc3>=0.11.6,<0.12.0", "ruff>=0.15.11,<0.16.0", + "jsonschema>=4.26.0", ] [tool.ruff] diff --git a/pytm/pytm.py b/pytm/pytm.py index dd59757a..3e6b08b5 100644 --- a/pytm/pytm.py +++ b/pytm/pytm.py @@ -779,6 +779,11 @@ def get_args(): help="list all elements which can be part of a threat model", ) parser.add_argument("--json", help="output a JSON file") + parser.add_argument( + "--tmbom", + action="store_true", + help="output a CycloneDX 2.0 TM-BOM (threat model BOM) as JSON", + ) parser.add_argument( "--levels", type=int, diff --git a/pytm/tm.py b/pytm/tm.py index 9a463079..743d201b 100644 --- a/pytm/tm.py +++ b/pytm/tm.py @@ -425,7 +425,8 @@ def _process(self): print(self.dfd(colormap=getattr(result, "colormap", False), levels=levels)) needs_resolution = any( - getattr(result, attr, None) for attr in ("report", "json", "stale_days") + getattr(result, attr, None) + for attr in ("report", "json", "tmbom", "stale_days") ) if needs_resolution: @@ -440,6 +441,11 @@ def _process(self): exc, f"while trying to write to the result file ({result.json})" ) + if getattr(result, "tmbom", False): + from .tmbom import render_tmbom + + print(render_tmbom(self)) + if getattr(result, "report", None): print(self.report(result.report)) diff --git a/pytm/tmbom.py b/pytm/tmbom.py new file mode 100644 index 00000000..d4ffb298 --- /dev/null +++ b/pytm/tmbom.py @@ -0,0 +1,796 @@ +"""CycloneDX 2.0 TM-BOM (threat model bill of materials) output. + +The document layout follows the CycloneDX 2.0 draft schema, pinned to +``CycloneDX/specification`` commit :data:`SCHEMA_COMMIT`: + +* ``blueprints``: zones (pytm ``Boundary``), boundaries crossed by flows, assets, + actors, data stores, data sets, flows and assumptions. +* ``threats``: one entry per threat library SID, pointing at the affected assets, + plus the CAPEC attack patterns they reference. +* ``risks``: one entry per finding (including findings excluded by assumptions). +* ``controls``: pytm ``Control`` objects, linked from threats and risk responses. + +:func:`build_tmbom` is pure; :func:`render_tmbom` supplies the serial number, +timestamp and tool version. +""" + +from __future__ import annotations + +import json +import re +import uuid +from collections.abc import Iterable +from datetime import UTC, datetime +from importlib import metadata as importlib_metadata +from typing import Any + +from .actor import Actor +from .asset import LLM, Agent, Asset, ExternalEntity, Lambda, Server +from .boundary import Boundary +from .control import Control +from .data import Data +from .dataflow import Dataflow +from .datastore import Datastore +from .element import Element +from .enums import ( + ActorKind, + Classification, + ControlCategory, + ControlStatus, + DatastoreType, + Lifetime, + TLSVersion, +) +from .process import Process +from .taxonomy import UNASSIGNED + +SPEC_VERSION = "2.0" +SCHEMA_COMMIT = "906c57979d786c935787213ae1693a98097858ce" +SCHEMA_URL = ( + "https://raw.githubusercontent.com/CycloneDX/specification/" + f"{SCHEMA_COMMIT}/schema/2.0/cyclonedx-2.0.schema.json" +) + +_PREFIX = "pytm:" +_UNBOUNDED_NAME = "Unbounded" + +_ASSET_TYPES: dict[type, str] = { + Actor: "actor", + Agent: "agent", + ExternalEntity: "system", + Lambda: "function", + LLM: "model", + Process: "process", + Server: "service", + Asset: "component", +} +_DATASTORE_TYPES: dict[DatastoreType, str] = { + DatastoreType.FILE_SYSTEM: "file", + DatastoreType.SQL: "relational", + DatastoreType.LDAP: "hierarchical", + DatastoreType.AWS_S3: "object", +} +_CLASSIFICATIONS: dict[Classification, str] = { + Classification.PUBLIC: "public", + Classification.RESTRICTED: "restricted", + Classification.SENSITIVE: "confidential", + Classification.SECRET: "classified", + Classification.TOP_SECRET: "classified", +} +_SEVERITY_TO_SCORE: dict[str, str] = { + "very high": "critical", + "critical": "critical", + "high": "high", + "medium": "medium", + "low": "low", + "very low": "info", + "info": "info", +} +_LIKELIHOOD_TO_LEVEL: dict[str, str] = { + "very high": "very-high", + "high": "high", + "medium": "medium", + "low": "low", + "very low": "very-low", +} +# Finding.response -> (risk response strategy, risk status) +_PARTY_ROLES = {False: "end-user", True: "operator"} +_PERSONA_ARCHETYPES = {False: "end-user", True: "administrator"} +_RESPONSES: dict[str, tuple[str, str]] = { + "mitigated": ("reduce", "mitigated"), + "transferred": ("transfer", "transferred"), + "avoided": ("avoid", "retired"), + "accepted": ("accept", "accepted"), +} + + +def _slug(text: str) -> str: + """Lowercase ``text`` and collapse anything non-alphanumeric to dashes.""" + return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-") or "unnamed" + + +def _clean(obj: dict[str, Any]) -> dict[str, Any]: + """Drop ``None`` and empty string/list/dict values (keeps ``False`` and 0).""" + return { + k: v + for k, v in obj.items() + if v is not None and v != "" and v != [] and v != {} + } + + +def _props(**values: Any) -> list[dict[str, str]]: + """Build namespaced CycloneDX properties, skipping unset values.""" + props = [] + for key, value in values.items(): + if value is None or value == "" or value == []: + continue + text = str(value).lower() if isinstance(value, bool) else str(value) + props.append({"name": f"{_PREFIX}{key}", "value": text}) + return props + + +def _unused_ref(base: str, used: set[str]) -> str: + """``base``, or ``base-2``, ``base-3``... whichever is not in ``used``.""" + ref, n = base, 1 + while ref in used: + n += 1 + ref = f"{base}-{n}" + return ref + + +class _Refs: + """Stable ``bom-ref`` values for pytm objects, unique within the document.""" + + def __init__(self) -> None: + self._by_id: dict[int, str] = {} + self._used: set[str] = set() + + def reserve(self, ref: str) -> None: + """Mark ``ref`` as taken so generated refs never collide with it.""" + self._used.add(ref) + + def add(self, kind: str, obj: Any, name: str) -> str: + """Return the ref for ``obj``, creating one from ``kind`` and ``name``.""" + existing = self._by_id.get(id(obj)) + if existing is not None: + return existing + ref = _unused_ref(f"{kind}-{_slug(name)}", self._used) + self._used.add(ref) + self._by_id[id(obj)] = ref + return ref + + def get(self, obj: Any) -> str | None: + """Return the ref previously assigned to ``obj``, if any.""" + return self._by_id.get(id(obj)) + + +def _unique(items: Iterable[Any]) -> list[Any]: + """Deduplicate by identity, preserving order.""" + seen: set[int] = set() + out = [] + for item in items: + if id(item) not in seen: + seen.add(id(item)) + out.append(item) + return out + + +def _asset_type(element: Element) -> str: + """Map a pytm element class to a TM-BOM asset type.""" + for cls in type(element).__mro__: + if cls in _ASSET_TYPES: + return _ASSET_TYPES[cls] + return "component" + + +def _control_props(element: Element) -> list[dict[str, str]]: + """Expose enabled controls of ``element`` as properties.""" + controls = getattr(element, "controls", None) + if controls is None: + return [] + return _props( + **{ + f"controls.{name}": value + for name, value in sorted(controls.model_dump().items()) + if value not in (False, "", None) + } + ) + + +def _element_props(element: Element) -> list[dict[str, str]]: + """Properties shared by assets, data stores and actors.""" + port = getattr(element, "port", -1) + return _props( + inScope=element.inScope, + protocol=getattr(element, "protocol", ""), + port=port if port >= 0 else None, + isAdmin=getattr(element, "isAdmin", None) or None, + OS=getattr(element, "OS", ""), + ) + _control_props(element) + + +def _classification(value: Classification) -> str | None: + """Map a pytm classification to the TM-BOM data classification enum.""" + return _CLASSIFICATIONS.get(value) + + +def _sorted_data(items: Iterable[Data]) -> list[Data]: + """Order ``Data`` deterministically (they live in unordered sets).""" + return sorted(items, key=lambda d: (d.name, d.description)) + + +def _zone_ref(element: Element, refs: _Refs, unbounded: str | None) -> str | None: + """Zone ref of the boundary ``element`` lives in.""" + boundary = element.inBoundary + return refs.get(boundary) if boundary is not None else unbounded + + +def _build_zones( + boundaries: list[Boundary], refs: _Refs, unbounded: str | None +) -> list[dict[str, Any]]: + """One trust zone per boundary, plus the implicit unbounded zone.""" + zones = [ + _clean( + { + "bom-ref": refs.get(b), + "name": b.name, + "description": b.description, + "type": "trust", + "parent": refs.get(b.inBoundary) if b.inBoundary else None, + "properties": _props(inScope=b.inScope), + } + ) + for b in boundaries + ] + if unbounded: + zones.append({"bom-ref": unbounded, "name": _UNBOUNDED_NAME, "type": "trust"}) + return zones + + +def _build_boundaries( + boundaries: list[Boundary], + flows: list[Dataflow], + refs: _Refs, + unbounded: str | None, +) -> list[dict[str, Any]]: + """One trust boundary per pair of zones that a flow crosses.""" + pairs: dict[tuple[str, str], None] = {} + for flow in flows: + a = _zone_ref(flow.source, refs, unbounded) + b = _zone_ref(flow.sink, refs, unbounded) + if a and b and a != b: + pairs[tuple(sorted((a, b)))] = None + names = {refs.get(b): b.name for b in boundaries} + if unbounded: + names[unbounded] = _UNBOUNDED_NAME + return [ + { + "bom-ref": f"boundary-{a.removeprefix('zone-')}-{b.removeprefix('zone-')}", + "name": f"{names[a]} <-> {names[b]}", + "type": "trust", + "zones": [a, b], + } + for a, b in pairs + ] + + +def _build_asset(element: Element, refs: _Refs, unbounded: str | None) -> dict: + """Asset entry for an Actor or Asset element.""" + classification = _classification(element.maxClassification) + return _clean( + { + "bom-ref": refs.get(element), + "type": _asset_type(element), + "name": element.name, + "description": element.description, + "zone": _zone_ref(element, refs, unbounded), + "classification": ( + {"classification": classification} if classification else None + ), + "properties": _element_props(element), + } + ) + + +def _build_actor(actor: Actor, refs: _Refs, unbounded: str | None) -> dict: + """``actors[]`` entry for an Actor whose ``kind`` says which party it is. + + The schema's actor has no name, so system and persona parties (which have + none either) carry it in the description. + """ + admin = actor.isAdmin + identity = { + ActorKind.PERSON: {"person": {"name": actor.name}}, + ActorKind.ORGANIZATION: {"organization": {"name": actor.name}}, + ActorKind.SYSTEM: {"system": {"kind": "software-system"}}, + ActorKind.PERSONA: { + "persona": { + "description": actor.description or actor.name, + "archetype": _PERSONA_ARCHETYPES[admin], + } + }, + }[actor.kind] + nameless = actor.kind in (ActorKind.SYSTEM, ActorKind.PERSONA) + description = actor.description + if nameless and description != actor.name: + description = f"{actor.name}: {description}" if description else actor.name + return _clean( + { + "bom-ref": refs.get(actor), + "party": {"roles": [{"role": _PARTY_ROLES[admin]}], **identity}, + "description": description, + "zone": _zone_ref(actor, refs, unbounded), + "properties": _props(name=actor.name, kind=actor.kind.value) + + _element_props(actor), + } + ) + + +def _build_data_store( + store: Datastore, + flows: list[Dataflow], + refs: _Refs, + unbounded: str | None, +) -> dict: + """Data store entry; data sets are those carried by flows into the store.""" + store_type = _DATASTORE_TYPES.get(store.type) + carried = _unique(d for f in flows if f.sink is store for d in f.data) + return _clean( + { + "bom-ref": refs.get(store), + "name": store.name, + "description": store.description, + "type": store_type or {"name": "unknown"}, + "zone": _zone_ref(store, refs, unbounded), + "dataSets": [r for d in carried if (r := refs.get(d))], + "properties": _props( + isSQL=store.isSQL, + isShared=store.isShared, + onRDS=store.onRDS, + ) + + _element_props(store), + } + ) + + +def _placements(data: Data, flows: list[Dataflow], refs: _Refs) -> list[dict]: + """Where ``data`` rests: stores it flows into (if stored) or out of.""" + encrypted: dict[int, bool] = {} + stores: dict[int, Datastore] = {} + for flow in flows: + if data not in flow.data: + continue + if isinstance(flow.sink, Datastore) and data.isStored: + stores[id(flow.sink)] = flow.sink + encrypted[id(flow.sink)] = encrypted.get(id(flow.sink), False) or bool( + data.isDestEncryptedAtRest + ) + if isinstance(flow.source, Datastore): + stores[id(flow.source)] = flow.source + encrypted[id(flow.source)] = encrypted.get(id(flow.source), False) or bool( + data.isSourceEncryptedAtRest + ) + return [ + _clean({"dataStore": refs.get(store), "encrypted": encrypted[key] or None}) + for key, store in stores.items() + ] + + +def _build_data_set(data: Data, flows: list[Dataflow], refs: _Refs) -> dict: + """Data set entry for a pytm ``Data``.""" + return _clean( + { + "bom-ref": refs.get(data), + "name": data.name, + "description": data.description or data.name, + "placements": _placements(data, flows, refs), + "properties": _props( + classification=_classification(data.classification), + format=data.format, + isPII=data.isPII or None, + isCredentials=data.isCredentials or None, + credentialsLife=( + data.credentialsLife.name + if data.credentialsLife != Lifetime.NONE + else None + ), + isStored=data.isStored or None, + ), + } + ) + + +def _build_flow(flow: Dataflow, refs: _Refs, unbounded: str | None) -> dict[str, Any]: + """Flow entry for a ``Dataflow``.""" + encrypted = flow.isEncrypted + if encrypted is None and flow.tlsVersion != TLSVersion.NONE: + encrypted = True + return _clean( + { + "bom-ref": refs.get(flow), + "name": flow.name, + "description": flow.description, + "type": "data", + "source": refs.get(flow.source), + "destination": refs.get(flow.sink), + "encrypted": encrypted, + "synchronous": True if flow.response is not None else None, + "authentication": ( + [{"name": "session-token", "description": "pytm usesSessionTokens"}] + if flow.usesSessionTokens + else [] + ), + "protocols": [flow.protocol] if flow.protocol else [], + "dataObjects": [ + _clean({"name": d.name, "description": d.description}) + for d in _sorted_data(flow.data) + ], + "sequence": flow.order if flow.order >= 0 else None, + "properties": _props( + isResponse=flow.isResponse or None, + responseTo=( + refs.get(flow.responseTo) if flow.responseTo is not None else None + ), + srcPort=flow.srcPort if flow.srcPort >= 0 else None, + dstPort=flow.dstPort if flow.dstPort >= 0 else None, + tlsVersion=( + flow.tlsVersion.name if flow.tlsVersion != TLSVersion.NONE else None + ), + note=flow.note, + usesVPN=flow.usesVPN or None, + inScope=flow.inScope, + ) + + _control_props(flow), + } + ) + + +def _build_assumptions( + tm: Any, elements: list[Element], refs: _Refs +) -> list[dict[str, Any]]: + """Global assumptions plus those attached to individual elements.""" + owners: list[tuple[Any, Element | None]] = [(a, None) for a in tm.assumptions] + owners += [(a, e) for e in elements for a in getattr(e, "assumptions", None) or []] + out, used = [], set() + for assumption, element in owners: + text = ( + f"{assumption.name}: {assumption.description}" + if assumption.name and assumption.description + else assumption.description or assumption.name + ) + if not text: + continue + ref = _unused_ref(f"assumption-{_slug(assumption.name or text)}", used) + used.add(ref) + excluded = ", ".join(sorted(assumption.exclude)) + out.append( + _clean( + { + "bom-ref": ref, + "description": text, + "relatedAssets": ( + [r] if element and (r := refs.get(element)) else [] + ), + "impact": f"Excludes threats: {excluded}" if excluded else "", + } + ) + ) + return out + + +def _urls(references: str) -> list[dict[str, str]]: + """External references for the http(s) links in a threat's references.""" + links = [r.strip() for r in references.split(",")] + return [ + {"url": u, "type": "other"} + for u in links + if u.startswith(("http://", "https://")) + ] + + +def _capec_ref(capec_id: int) -> str: + """``bom-ref`` of the attack pattern for a CAPEC id.""" + return f"capec-{capec_id}" + + +def _build_attack_patterns(all_findings: list[Any]) -> list[dict[str, Any]]: + """One attack pattern per CAPEC id mentioned by any finding's threat.""" + ids = dict.fromkeys(i for f in all_findings for i in f.capec) + return [{"bom-ref": _capec_ref(i), "capecId": i, "name": f"CAPEC-{i}"} for i in ids] + + +def _mitigating(sid: str, controls: list[Control]) -> list[Control]: + """Controls declared to mitigate the threat ``sid``.""" + return [c for c in controls if sid in c.mitigates] + + +def _protecting(finding: Any, controls: list[Control]) -> list[Control]: + """Controls mitigating ``finding``'s threat on its element.""" + return [ + c + for c in _mitigating(finding.threat_id, controls) + if not c.appliesTo or any(a is finding.element for a in c.appliesTo) + ] + + +def _build_control(control: Control, refs: _Refs) -> dict[str, Any]: + """Control entry for a pytm ``Control``.""" + applies = [r for a in control.appliesTo if (r := refs.get(a))] + return _clean( + { + "bom-ref": refs.get(control), + "name": control.name, + "description": control.description, + "category": ( + control.category.value + if control.category != ControlCategory.UNKNOWN + else None + ), + "status": ( + control.status.value + if control.status != ControlStatus.UNKNOWN + else None + ), + "appliesTo": applies, + "effectiveness": ( + {"rating": control.effectiveness} if control.effectiveness else None + ), + "properties": _props(mitigates=",".join(sorted(control.mitigates))), + } + ) + + +def _build_threats( + all_findings: list[Any], + refs: _Refs, + threat_refs: dict[str, str], + controls: list[Control], +) -> list[dict[str, Any]]: + """One threat per threat library SID.""" + groups: dict[str, list[Any]] = {} + for finding in all_findings: + groups.setdefault(finding.threat_id, []).append(finding) + threats = [] + for sid, group in groups.items(): + first = group[0] + affected = [r for e in _unique(f.element for f in group) if (r := refs.get(e))] + threats.append( + _clean( + { + "bom-ref": threat_refs[sid], + "name": first.description or sid, + "description": first.details, + "source": "pytm", + "categories": ( + [{"taxonomy": "STRIDE", "category": first.stride}] + if first.stride != UNASSIGNED + else [] + ), + "weaknesses": [{"cweId": i} for i in first.cwe], + "attackPatterns": [_capec_ref(i) for i in first.capec], + "affectedAssets": affected, + "mitigations": [ + r for c in _mitigating(sid, controls) if (r := refs.get(c)) + ], + "externalReferences": _urls(first.references), + "properties": _props( + sid=sid, + severity=first.severity, + likelihood=first.likelihood, + condition=first.condition, + mitigations=first.mitigations, + example=first.example, + references=first.references, + ), + } + ) + ) + return threats + + +def _build_risk( + finding: Any, + ref: str, + refs: _Refs, + threat_refs: dict[str, str], + controls: list[Control], + excluded: bool, +) -> dict[str, Any]: + """Risk entry for a finding; excluded findings are accepted by assumption.""" + strategy, status = _RESPONSES.get(finding.response.lower(), (None, "identified")) + if excluded: + strategy, status = "accept", "accepted" + score = _SEVERITY_TO_SCORE.get(str(finding.severity).lower()) + likelihood = _LIKELIHOOD_TO_LEVEL.get(str(finding.likelihood).lower()) + linked = [] if excluded else _protecting(finding, controls) + if linked and not strategy: + strategy = "reduce" + rating = _clean( + { + "likelihood": {"level": likelihood} if likelihood else None, + "impact": {"level": finding.impact} if finding.impact else None, + "score": {"level": score} if score else None, + } + ) + assumption = finding.assumption + return _clean( + { + "bom-ref": ref, + "name": f"{finding.description} ({finding.target})", + "statement": finding.details or finding.description, + "description": ( + f"Excluded by assumption: {assumption.name}" if assumption else "" + ), + "domains": [{"type": "security"}], + "affects": [r for r in [refs.get(finding.element)] if r], + "relatedThreats": [threat_refs[finding.threat_id]], + "inherentRisk": rating, + "responses": ( + [ + _clean( + { + "bom-ref": f"response-{ref}", + "strategy": strategy, + "description": finding.mitigations if not excluded else "", + "controls": [r for c in linked if (r := refs.get(c))], + } + ) + ] + if strategy + else [] + ), + "status": status, + "properties": _props(cvss=finding.cvss, findingId=finding.id), + } + ) + + +def build_tmbom(tm: Any, *, serial: str, timestamp: str, tool_version: str) -> dict: + """Build the CycloneDX 2.0 TM-BOM document for a resolved threat model. + + Args: + tm: The ``TM`` instance; ``tm.resolve()`` should already have run so + findings are populated. + serial: BOM serial number (``urn:uuid:...``). + timestamp: RFC 3339 creation time. + tool_version: Version of pytm to record in the metadata. + + Returns: + A JSON-serializable dict. + """ + state = type(tm) + flows: list[Dataflow] = _unique(state._flows) + boundaries: list[Boundary] = _unique(state._boundaries) + endpoints = [e for f in flows for e in (f.source, f.sink)] + nodes = _unique( + e + for e in [*state._elements, *endpoints] + if isinstance(e, (Actor, Asset)) and not isinstance(e, Datastore) + ) + actors = [e for e in nodes if isinstance(e, Actor) and e.kind != ActorKind.UNKNOWN] + assets = [e for e in nodes if e not in actors] + controls: list[Control] = _unique(state._controls) + stores = _unique( + e for e in [*state._elements, *endpoints] if isinstance(e, Datastore) + ) + data = _unique([*state._data, *(d for f in flows for d in _sorted_data(f.data))]) + + placed = [*nodes, *stores] + needs_unbounded = bool(boundaries) and any(e.inBoundary is None for e in placed) + unbounded = "zone-unbounded" if needs_unbounded else None + + refs = _Refs() + if unbounded: + refs.reserve(unbounded) + for b in boundaries: + refs.add("zone", b, b.name) + for e in actors: + refs.add("actor", e, e.name) + for e in assets: + refs.add("asset", e, e.name) + for s in stores: + refs.add("datastore", s, s.name) + for d in data: + refs.add("dataset", d, d.name) + for f in flows: + refs.add("flow", f, f.name) + for c in controls: + refs.add("control", c, c.name) + + findings = list(tm.findings) + excluded = list(tm.excluded_findings) + for element in _unique(f.element for f in [*findings, *excluded]): + refs.add("element", element, element.name) + threat_refs = { + f.threat_id: f"threat-{_slug(f.threat_id)}" for f in [*findings, *excluded] + } + + blueprint = _clean( + { + "bom-ref": f"blueprint-{_slug(tm.name)}", + "name": tm.name, + "description": tm.description, + "modelTypes": ["data-flow"], + "zones": _build_zones(boundaries, refs, unbounded), + "boundaries": _build_boundaries(boundaries, flows, refs, unbounded), + "actors": [_build_actor(e, refs, unbounded) for e in actors], + "assets": [_build_asset(e, refs, unbounded) for e in assets], + "dataStores": [ + _build_data_store(s, flows, refs, unbounded) for s in stores + ], + "dataSets": [_build_data_set(d, flows, refs) for d in data], + "flows": [_build_flow(f, refs, unbounded) for f in flows], + "assumptions": _build_assumptions(tm, [*placed, *flows], refs), + } + ) + + threats = _build_threats([*findings, *excluded], refs, threat_refs, controls) + patterns = _build_attack_patterns([*findings, *excluded]) + risks = [ + _build_risk(f, f"risk-{f.id}", refs, threat_refs, controls, excluded=False) + for f in findings + ] + [ + _build_risk( + f, f"risk-excluded-{f.id}", refs, threat_refs, controls, excluded=True + ) + for f in excluded + ] + + return _clean( + { + "$schema": SCHEMA_URL, + "specFormat": "CycloneDX", + "specVersion": SPEC_VERSION, + "serialNumber": serial, + "version": 1, + "metadata": { + "timestamp": timestamp, + "tools": { + "components": [ + _clean( + { + "type": "application", + "name": "pytm", + "version": tool_version, + } + ) + ] + }, + "component": _clean( + { + "type": "application", + "bom-ref": f"component-{_slug(tm.name)}", + "name": tm.name, + "description": tm.description, + } + ), + }, + "blueprints": [blueprint], + "threats": ( + _clean({"threats": threats, "attackPatterns": patterns}) + if threats + else None + ), + "risks": {"risks": risks} if risks else None, + "controls": [_build_control(c, refs) for c in controls], + } + ) + + +def _tool_version() -> str: + """Installed pytm version, or empty when running from a source tree.""" + try: + return importlib_metadata.version("pytm") + except importlib_metadata.PackageNotFoundError: + return "" + + +def render_tmbom(tm: Any) -> str: + """Render ``tm`` as an indented TM-BOM JSON string (stamps now/new UUID).""" + document = build_tmbom( + tm, + serial=f"urn:uuid:{uuid.uuid4()}", + timestamp=datetime.now(UTC).isoformat(timespec="seconds"), + tool_version=_tool_version(), + ) + return json.dumps(document, indent=2) diff --git a/tests/fetch_cyclonedx_schema.sh b/tests/fetch_cyclonedx_schema.sh new file mode 100755 index 00000000..66c60979 --- /dev/null +++ b/tests/fetch_cyclonedx_schema.sh @@ -0,0 +1,16 @@ +#!/bin/sh +# Download the CycloneDX 2.0 draft schema used by tests/test_tmbom.py into +# tests/schemas/cyclonedx-2.0 (git-ignored). Keep COMMIT in sync with +# SCHEMA_COMMIT in pytm/tmbom.py. +set -eu +COMMIT=906c57979d786c935787213ae1693a98097858ce +DEST="$(dirname "$0")/schemas/cyclonedx-2.0" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT +git clone -q --filter=blob:none --no-checkout \ + https://github.com/CycloneDX/specification.git "$TMP/spec" +git -C "$TMP/spec" sparse-checkout set schema/2.0 +git -C "$TMP/spec" checkout -q "$COMMIT" +mkdir -p "$DEST" +cp -r "$TMP/spec/schema/2.0/model" "$TMP/spec/schema/2.0/cyclonedx-2.0.schema.json" "$DEST" +echo "schema written to $DEST" diff --git a/tests/test_tmbom.py b/tests/test_tmbom.py new file mode 100644 index 00000000..15dcdddf --- /dev/null +++ b/tests/test_tmbom.py @@ -0,0 +1,390 @@ +"""Tests for the CycloneDX 2.0 TM-BOM output (``--tmbom``).""" + +import json +import pathlib +import sys + +import pytest +from jsonschema import Draft202012Validator +from referencing import Registry, Resource + +from pytm import ( + TM, + Actor, + ActorKind, + Assumption, + Boundary, + Classification, + Control, + ControlCategory, + ControlStatus, + Data, + Dataflow, + Datastore, + Server, + Threat, + TLSVersion, + loads, +) +from pytm.pytm import to_serializable +from pytm.tmbom import build_tmbom + +HERE = pathlib.Path(__file__).parent +SCHEMA_DIR = HERE / "schemas" / "cyclonedx-2.0" +GOLDEN = HERE / "tmbom_output.json" +SERIAL = "urn:uuid:00000000-0000-4000-8000-000000000000" +STAMP = "2026-01-01T00:00:00+00:00" + +REF_KEYS = { + "zone", + "parent", + "source", + "destination", + "zones", + "affectedAssets", + "affects", + "relatedThreats", + "relatedAssets", + "dataSets", + "dataStore", + "controls", + "mitigations", + "attackPatterns", +} + + +@pytest.fixture(autouse=True) +def reset_tm(): + TM.reset() + yield + TM.reset() + + +@pytest.fixture(scope="module") +def validator() -> Draft202012Validator: + if not (SCHEMA_DIR / "cyclonedx-2.0.schema.json").exists(): + pytest.skip("run tests/fetch_cyclonedx_schema.sh to enable schema validation") + registry = Registry() + for path in SCHEMA_DIR.rglob("*.schema.json"): + registry = registry.with_resource( + path.name, Resource.from_contents(json.loads(path.read_text())) + ) + schema = json.loads((SCHEMA_DIR / "cyclonedx-2.0.schema.json").read_text()) + return Draft202012Validator(schema, registry=registry) + + +def make_model() -> TM: + tm = TM("my test tm", description="aaa", threatsFile="pytm/threatlib/threats.json") + tm.isOrdered = True + tm.assumptions = [Assumption("Tokens are rotated", exclude=["AA01"])] + internet = Boundary("Internet") + vpc = Boundary("VPC", inBoundary=internet) + user = Actor("User", inBoundary=internet, kind=ActorKind.PERSON) + web = Server("Web Server", inBoundary=vpc) + db = Datastore("SQL Database", inBoundary=vpc) + db.controls.isEncryptedAtRest = True + cookie = Data( + name="auth cookie", + description="auth cookie description", + classification=Classification.PUBLIC, + isCredentials=True, + isStored=True, + ) + Dataflow( + user, + web, + "Request", + protocol="HTTPS", + tlsVersion=TLSVersion.TLSv12, + data=cookie, + ) + query = Dataflow(web, db, "Query", note="ccc", data=cookie, usesSessionTokens=True) + Dataflow(db, web, "Query", responseTo=query) # duplicate name on purpose + Control( + "Parameterised queries", + category=ControlCategory.PREVENTIVE, + status=ControlStatus.IMPLEMENTED, + mitigates=["INP03"], + appliesTo=[web], + effectiveness="good", + ) + return tm + + +def build(tm: TM) -> dict: + assert tm.check() + tm.resolve() + return build_tmbom(tm, serial=SERIAL, timestamp=STAMP, tool_version="test") + + +def collect_refs(node, found: set, wanted: list) -> None: + """Collect defined bom-refs and every reference-valued field in ``node``.""" + if isinstance(node, list): + for item in node: + collect_refs(item, found, wanted) + elif isinstance(node, dict): + is_flow = "destination" in node + for key, value in node.items(): + if key == "bom-ref": + found.add(value) + elif key in REF_KEYS and (key != "source" or is_flow): + items = value if isinstance(value, list) else [value] + wanted.extend(i for i in items if isinstance(i, str)) + collect_refs( + [i for i in items if not isinstance(i, str)], found, wanted + ) + else: + collect_refs(value, found, wanted) + + +def test_validates_against_schema(validator): + doc = build(make_model()) + assert list(validator.iter_errors(doc)) == [] + + +def test_validator_rejects_bad_documents(validator): + doc = build(make_model()) + doc["blueprints"][0]["flows"][0]["type"] = 12 + assert list(validator.iter_errors(doc)) + + +def test_refs_resolve_and_are_unique(validator): + doc = build(make_model()) + defined: set = set() + referenced: list = [] + collect_refs(doc, defined, referenced) + assert referenced + assert set(referenced) <= defined + assert json.dumps(doc).count('"bom-ref"') == len(defined) + + +def test_duplicate_flow_names_get_distinct_refs(): + flows = build(make_model())["blueprints"][0]["flows"] + refs = [f["bom-ref"] for f in flows if f["name"] == "Query"] + assert len(refs) == 2 and len(set(refs)) == 2 + + +def test_zones_parents_and_boundaries(): + blueprint = build(make_model())["blueprints"][0] + zones = {z["name"]: z for z in blueprint["zones"]} + assert zones["VPC"]["parent"] == zones["Internet"]["bom-ref"] + assert [b["zones"] for b in blueprint["boundaries"]] == [ + sorted([zones["Internet"]["bom-ref"], zones["VPC"]["bom-ref"]]) + ] + + +def test_excluded_findings_become_accepted_risks(): + doc = build(make_model()) + excluded = [r for r in doc["risks"]["risks"] if "excluded" in r["bom-ref"]] + assert excluded + assert all(r["status"] == "accepted" for r in excluded) + assert any(t["bom-ref"] == "threat-aa01" for t in doc["threats"]["threats"]) + + +def test_flow_encryption_only_when_known(): + flows = {f["name"]: f for f in build(make_model())["blueprints"][0]["flows"]} + assert flows["Request"]["encrypted"] is True + assert "encrypted" not in flows["Query"] + + +def test_explicit_is_encrypted_wins_over_tls(): + tm = TM("enc", description="d", threatsFile="pytm/threatlib/threats.json") + a, b = Server("A"), Server("B") + Dataflow(a, b, "plain", tlsVersion=TLSVersion.TLSv13, isEncrypted=False) + Dataflow(b, a, "secret", isEncrypted=True) + flows = {f["name"]: f for f in build(tm)["blueprints"][0]["flows"]} + assert flows["plain"]["encrypted"] is False + assert flows["secret"]["encrypted"] is True + + +def test_actor_kind_goes_to_actors_section(validator): + doc = build(make_model()) + blueprint = doc["blueprints"][0] + (actor,) = blueprint["actors"] + assert actor["party"]["person"] == {"name": "User"} + assert "User" not in [a["name"] for a in blueprint["assets"]] + request = next(f for f in blueprint["flows"] if f["name"] == "Request") + assert request["source"] == actor["bom-ref"] + + +def test_actor_without_kind_stays_asset(): + tm = TM("k", description="d", threatsFile="pytm/threatlib/threats.json") + Dataflow(Actor("Anon"), Server("S"), "x") + blueprint = build(tm)["blueprints"][0] + assert "actors" not in blueprint + assert "Anon" in [a["name"] for a in blueprint["assets"]] + + +@pytest.mark.parametrize("kind", [k for k in ActorKind if k != ActorKind.UNKNOWN]) +def test_every_actor_kind_validates(kind, validator): + tm = TM("k", description="d", threatsFile="pytm/threatlib/threats.json") + Dataflow(Actor("Who", kind=kind, isAdmin=True), Server("S"), "x") + assert list(validator.iter_errors(build(tm))) == [] + + +def test_data_placements_and_credentials_life(): + blueprint = build(make_model())["blueprints"][0] + cookie = next(d for d in blueprint["dataSets"] if d["name"] == "auth cookie") + store = next(s for s in blueprint["dataStores"] if s["name"] == "SQL Database") + assert cookie["placements"] == [{"dataStore": store["bom-ref"], "encrypted": True}] + + +def test_session_tokens_and_response_flow_fields(): + flows = build(make_model())["blueprints"][0]["flows"] + query = [f for f in flows if f["name"] == "Query"] + request = next(f for f in query if f.get("synchronous")) + response = next(f for f in query if not f.get("synchronous")) + assert request["authentication"][0]["name"] == "session-token" + assert "authentication" not in response + assert "synchronous" not in next(f for f in flows if f["name"] == "Request") + + +def test_unassigned_stride_exports_no_category(): + doc = build(make_model()) + assert all("categories" not in t for t in doc["threats"]["threats"]) + + +def test_assigned_stride_exports_category(validator): + tm = make_model() + tm.check() + tm.resolve() + for f in tm.findings: + if f.threat_id == "INP03": + f.stride = "tampering" + doc = build_tmbom(tm, serial=SERIAL, timestamp=STAMP, tool_version="test") + threat = next( + t for t in doc["threats"]["threats"] if t["bom-ref"] == "threat-inp03" + ) + assert threat["categories"] == [{"taxonomy": "STRIDE", "category": "tampering"}] + assert list(validator.iter_errors(doc)) == [] + + +def test_capec_cwe_on_threats(): + doc = build(make_model()) + threat = next( + t for t in doc["threats"]["threats"] if t["bom-ref"] == "threat-inp03" + ) + patterns = {p["bom-ref"]: p for p in doc["threats"]["attackPatterns"]} + assert threat["attackPatterns"] and set(threat["attackPatterns"]) <= set(patterns) + assert all(w["cweId"] > 0 for w in threat["weaknesses"]) + + +def test_impact_reaches_risk_rating(): + tm = make_model() + tm.check() + tm.resolve() + for f in tm.findings: + f.impact = "major" + doc = build_tmbom(tm, serial=SERIAL, timestamp=STAMP, tool_version="test") + assert { + r["inherentRisk"]["impact"]["level"] + for r in doc["risks"]["risks"] + if "excluded" not in r["bom-ref"] + } == {"major"} + + +def test_control_links_threat_and_risk(validator): + doc = build(make_model()) + (control,) = doc["controls"] + assert control["category"] == "preventive" and control["status"] == "implemented" + assert control["effectiveness"] == {"rating": "good"} + threat = next( + t for t in doc["threats"]["threats"] if t["bom-ref"] == "threat-inp03" + ) + assert threat["mitigations"] == [control["bom-ref"]] + linked = [ + r + for r in doc["risks"]["risks"] + if r.get("responses") + and control["bom-ref"] in r["responses"][0].get("controls", []) + ] + assert linked and all(r["relatedThreats"] == ["threat-inp03"] for r in linked) + assert all(r["responses"][0]["strategy"] == "reduce" for r in linked) + + +def test_control_scoped_to_other_element_is_not_linked(): + tm = make_model() + tm.check() + tm.resolve() + other = Server("Elsewhere") + ctl = Control("Scoped", mitigates="INP03", appliesTo=other) + finding = next(f for f in tm.findings if f.threat_id == "INP03") + from pytm.tmbom import _protecting + + assert _protecting(finding, [ctl]) == [] + + +def test_model_without_boundaries(validator): + tm = TM("plain", description="d", threatsFile="pytm/threatlib/threats.json") + Dataflow(Actor("User"), Server("Web"), "Hit") + doc = build(tm) + blueprint = doc["blueprints"][0] + assert "zones" not in blueprint and "boundaries" not in blueprint + assert list(validator.iter_errors(doc)) == [] + + +def test_unbounded_zone_added_for_unplaced_elements(validator): + tm = TM("mixed", description="d", threatsFile="pytm/threatlib/threats.json") + outside = Boundary("Outside") + Dataflow(Actor("User", inBoundary=outside), Server("Web"), "Hit") + doc = build(tm) + names = [z["name"] for z in doc["blueprints"][0]["zones"]] + assert names == ["Outside", "Unbounded"] + assert list(validator.iter_errors(doc)) == [] + + +def test_output_matches_golden(): + text = json.dumps(build(make_model()), indent=2, sort_keys=True) + assert text == GOLDEN.read_text().strip() + + +def test_cli_prints_valid_json(monkeypatch, capsys, validator): + tm = make_model() + monkeypatch.setattr(sys, "argv", ["tm.py", "--tmbom"]) + tm.process() + doc = json.loads(capsys.readouterr().out) + assert doc["specVersion"] == "2.0" + assert doc["serialNumber"].startswith("urn:uuid:") + assert list(validator.iter_errors(doc)) == [] + + +def test_threat_parses_taxonomy_from_references(): + threat = Threat( + SID="X1", + stride="Information Disclosure", + references="https://capec.mitre.org/data/definitions/10.html, CWE-79, " + "http://cwe.mitre.org/data/definitions/119.html, CAPEC-10", + ) + assert threat.stride == "information-disclosure" + assert threat.capec == [10] + assert threat.cwe == [79, 119] + + +@pytest.mark.parametrize("field", ["stride", "impact"]) +def test_threat_rejects_unknown_category(field): + with pytest.raises(ValueError): + Threat(SID="X1", **{field: "bogus"}) + + +def test_stride_defaults_to_unassigned(): + assert Threat(SID="X1").stride == "unassigned" + assert Threat(SID="X1", stride="").stride == "unassigned" + tm = TM("lib", description="d") # loads the default threat library + assert tm._threats and {t.stride for t in tm._threats} == {"unassigned"} + + +def test_control_json_round_trip(): + tm = TM("rt", description="d", threatsFile="pytm/threatlib/threats.json") + web, db = Server("Web"), Datastore("DB") + Dataflow(web, db, "Write") + Control("WAF", status=ControlStatus.PLANNED, mitigates=["INP03"], appliesTo=web) + tm.check() + dumped = json.dumps(tm, default=to_serializable) + TM.reset() + loaded = loads(dumped) + (control,) = TM._controls + assert control.name == "WAF" + assert control.status == ControlStatus.PLANNED + assert control.mitigates == ["INP03"] + assert [e.name for e in control.appliesTo] == ["Web"] + assert loaded.name == "rt" diff --git a/tests/tmbom_output.json b/tests/tmbom_output.json new file mode 100644 index 00000000..b3704ecc --- /dev/null +++ b/tests/tmbom_output.json @@ -0,0 +1,5928 @@ +{ + "$schema": "https://raw.githubusercontent.com/CycloneDX/specification/906c57979d786c935787213ae1693a98097858ce/schema/2.0/cyclonedx-2.0.schema.json", + "blueprints": [ + { + "actors": [ + { + "bom-ref": "actor-user", + "party": { + "person": { + "name": "User" + }, + "roles": [ + { + "role": "end-user" + } + ] + }, + "properties": [ + { + "name": "pytm:name", + "value": "User" + }, + { + "name": "pytm:kind", + "value": "person" + }, + { + "name": "pytm:inScope", + "value": "true" + } + ], + "zone": "zone-internet" + } + ], + "assets": [ + { + "bom-ref": "asset-web-server", + "name": "Web Server", + "properties": [ + { + "name": "pytm:inScope", + "value": "true" + } + ], + "type": "service", + "zone": "zone-vpc" + } + ], + "assumptions": [ + { + "bom-ref": "assumption-tokens-are-rotated", + "description": "Tokens are rotated", + "impact": "Excludes threats: AA01" + } + ], + "bom-ref": "blueprint-my-test-tm", + "boundaries": [ + { + "bom-ref": "boundary-internet-vpc", + "name": "Internet <-> VPC", + "type": "trust", + "zones": [ + "zone-internet", + "zone-vpc" + ] + } + ], + "dataSets": [ + { + "bom-ref": "dataset-auth-cookie", + "description": "auth cookie description", + "name": "auth cookie", + "placements": [ + { + "dataStore": "datastore-sql-database", + "encrypted": true + } + ], + "properties": [ + { + "name": "pytm:classification", + "value": "public" + }, + { + "name": "pytm:isCredentials", + "value": "true" + }, + { + "name": "pytm:credentialsLife", + "value": "UNKNOWN" + }, + { + "name": "pytm:isStored", + "value": "true" + } + ] + } + ], + "dataStores": [ + { + "bom-ref": "datastore-sql-database", + "dataSets": [ + "dataset-auth-cookie" + ], + "name": "SQL Database", + "properties": [ + { + "name": "pytm:isSQL", + "value": "true" + }, + { + "name": "pytm:isShared", + "value": "false" + }, + { + "name": "pytm:onRDS", + "value": "false" + }, + { + "name": "pytm:inScope", + "value": "true" + }, + { + "name": "pytm:controls.isEncryptedAtRest", + "value": "true" + } + ], + "type": { + "name": "unknown" + }, + "zone": "zone-vpc" + } + ], + "description": "aaa", + "flows": [ + { + "bom-ref": "flow-request", + "dataObjects": [ + { + "description": "auth cookie description", + "name": "auth cookie" + } + ], + "destination": "asset-web-server", + "encrypted": true, + "name": "Request", + "properties": [ + { + "name": "pytm:tlsVersion", + "value": "TLSv12" + }, + { + "name": "pytm:inScope", + "value": "true" + } + ], + "sequence": 1, + "source": "actor-user", + "type": "data" + }, + { + "authentication": [ + { + "description": "pytm usesSessionTokens", + "name": "session-token" + } + ], + "bom-ref": "flow-query", + "dataObjects": [ + { + "description": "auth cookie description", + "name": "auth cookie" + } + ], + "destination": "datastore-sql-database", + "name": "Query", + "properties": [ + { + "name": "pytm:note", + "value": "ccc" + }, + { + "name": "pytm:inScope", + "value": "true" + } + ], + "sequence": 2, + "source": "asset-web-server", + "synchronous": true, + "type": "data" + }, + { + "bom-ref": "flow-query-2", + "destination": "asset-web-server", + "name": "Query", + "properties": [ + { + "name": "pytm:isResponse", + "value": "true" + }, + { + "name": "pytm:responseTo", + "value": "flow-query" + }, + { + "name": "pytm:inScope", + "value": "true" + } + ], + "sequence": 3, + "source": "datastore-sql-database", + "type": "data" + } + ], + "modelTypes": [ + "data-flow" + ], + "name": "my test tm", + "zones": [ + { + "bom-ref": "zone-internet", + "name": "Internet", + "properties": [ + { + "name": "pytm:inScope", + "value": "true" + } + ], + "type": "trust" + }, + { + "bom-ref": "zone-vpc", + "name": "VPC", + "parent": "zone-internet", + "properties": [ + { + "name": "pytm:inScope", + "value": "true" + } + ], + "type": "trust" + } + ] + } + ], + "controls": [ + { + "appliesTo": [ + "asset-web-server" + ], + "bom-ref": "control-parameterised-queries", + "category": "preventive", + "effectiveness": { + "rating": "good" + }, + "name": "Parameterised queries", + "properties": [ + { + "name": "pytm:mitigates", + "value": "INP03" + } + ], + "status": "implemented" + } + ], + "metadata": { + "component": { + "bom-ref": "component-my-test-tm", + "description": "aaa", + "name": "my test tm", + "type": "application" + }, + "timestamp": "2026-01-01T00:00:00+00:00", + "tools": { + "components": [ + { + "name": "pytm", + "type": "application", + "version": "test" + } + ] + } + }, + "risks": { + "risks": [ + { + "affects": [ + "asset-web-server" + ], + "bom-ref": "risk-1", + "domains": [ + { + "type": "security" + } + ], + "inherentRisk": { + "likelihood": { + "level": "high" + }, + "score": { + "level": "high" + } + }, + "name": "Server Side Include (SSI) Injection (Web Server)", + "properties": [ + { + "name": "pytm:findingId", + "value": "1" + } + ], + "relatedThreats": [ + "threat-inp03" + ], + "responses": [ + { + "bom-ref": "response-risk-1", + "controls": [ + "control-parameterised-queries" + ], + "description": "Set the OPTIONS IncludesNOEXEC in the global access.conf file or local .htaccess (Apache) file to deny SSI execution in directories that do not need them. All user controllable input must be appropriately sanitized before use in the application. This includes omitting, or encoding, certain characters or strings that have the potential of being interpreted as part of an SSI directive. Server Side Includes must be enabled only if there is a strong business reason to do so. Every additional component enabled on the web server increases the attack surface as well as administrative overhead.", + "strategy": "reduce" + } + ], + "statement": "An attacker can use Server Side Include (SSI) Injection to send code to a web application that then gets executed by the web server. Doing so enables the attacker to achieve similar results to Cross Site Scripting, viz., arbitrary code execution and information disclosure, albeit on a more limited scale, since the SSI directives are nowhere near as powerful as a full-fledged scripting language. Nonetheless, the attacker can conveniently gain access to sensitive files, such as password files, and execute shell commands.", + "status": "identified" + }, + { + "affects": [ + "asset-web-server" + ], + "bom-ref": "risk-2", + "domains": [ + { + "type": "security" + } + ], + "inherentRisk": { + "likelihood": { + "level": "low" + }, + "score": { + "level": "critical" + } + }, + "name": "Command Line Execution through SQL Injection (Web Server)", + "properties": [ + { + "name": "pytm:findingId", + "value": "2" + } + ], + "relatedThreats": [ + "threat-inp05" + ], + "statement": "An attacker uses standard SQL injection methods to inject data into the command line for execution. This could be done directly through misuse of directives such as MSSQL_xp_cmdshell or indirectly through injection of data into the database that would be interpreted as shell commands. Sometime later, an unscrupulous backend application (or could be part of the functionality of the same application) fetches the injected data stored in the database and uses this data as command line arguments without performing proper validation. The malicious data escapes that data plane by spawning new commands to be executed on the host.", + "status": "identified" + }, + { + "affects": [ + "asset-web-server" + ], + "bom-ref": "risk-3", + "domains": [ + { + "type": "security" + } + ], + "inherentRisk": { + "likelihood": { + "level": "high" + }, + "score": { + "level": "medium" + } + }, + "name": "Excavation (Web Server)", + "properties": [ + { + "name": "pytm:findingId", + "value": "3" + } + ], + "relatedThreats": [ + "threat-ds01" + ], + "statement": "An adversary actively probes the target in a manner that is designed to solicit information that could be leveraged for malicious purposes. This is achieved by exploring the target via ordinary interactions for the purpose of gathering intelligence about the target, or by sending data that is syntactically invalid or non-standard in an attempt to produce a response that contains the desired data. As a result of these interactions, the adversary is able to obtain information from the target that aids the attacker in making inferences about its security, configuration, or potential vulnerabilities. Examplar exchanges with the target may trigger unhandled exceptions or verbose error messages that reveal information like stack traces, configuration information, path information, or database design. This type of attack also includes the manipulation of query strings in a URI to produce invalid SQL queries, or by trying alternative path values in the hope that the server will return useful information.", + "status": "identified" + }, + { + "affects": [ + "asset-web-server" + ], + "bom-ref": "risk-4", + "domains": [ + { + "type": "security" + } + ], + "inherentRisk": { + "likelihood": { + "level": "low" + }, + "score": { + "level": "medium" + } + }, + "name": "Double Encoding (Web Server)", + "properties": [ + { + "name": "pytm:findingId", + "value": "4" + } + ], + "relatedThreats": [ + "threat-de02" + ], + "statement": "The adversary utilizes a repeating of the encoding process for a set of characters (that is, character encoding a character encoding of a character) to obfuscate the payload of a particular request. This may allow the adversary to bypass filters that attempt to detect illegal characters or strings, such as those that might be used in traversal or injection attacks. Filters may be able to catch illegal encoded strings, but may not catch doubly encoded strings. For example, a dot (.), often used in path traversal attacks and therefore often blocked by filters, could be URL encoded as %2E. However, many filters recognize this encoding and would still block the request. In a double encoding, the % in the above URL encoding would be encoded again as %25, resulting in %252E which some filters might not catch, but which could still be interpreted as a dot (.) by interpreters on the target.", + "status": "identified" + }, + { + "affects": [ + "asset-web-server" + ], + "bom-ref": "risk-5", + "domains": [ + { + "type": "security" + } + ], + "inherentRisk": { + "score": { + "level": "medium" + } + }, + "name": "Privilege Abuse (Web Server)", + "properties": [ + { + "name": "pytm:findingId", + "value": "5" + } + ], + "relatedThreats": [ + "threat-ac01" + ], + "statement": "An adversary is able to exploit features of the target that should be reserved for privileged users or administrators but are exposed to use by lower or non-privileged accounts. Access to sensitive information and functionality must be controlled to ensure that only authorized users are able to access these resources. If access control mechanisms are absent or misconfigured, a user may be able to access resources that are intended only for higher level users. An adversary may be able to exploit this to utilize a less trusted account to gain information and perform activities reserved for more trusted accounts. This attack differs from privilege escalation and other privilege stealing attacks in that the adversary never actually escalates their privileges but instead is able to use a lesser degree of privilege to access resources that should be (but are not) reserved for higher privilege accounts. Likewise, the adversary does not exploit trust or subvert systems - all control functionality is working as configured but the configuration does not adequately protect sensitive resources at an appropriate level.", + "status": "identified" + }, + { + "affects": [ + "asset-web-server" + ], + "bom-ref": "risk-6", + "domains": [ + { + "type": "security" + } + ], + "inherentRisk": { + "likelihood": { + "level": "high" + }, + "score": { + "level": "medium" + } + }, + "name": "Flooding (Web Server)", + "properties": [ + { + "name": "pytm:findingId", + "value": "6" + } + ], + "relatedThreats": [ + "threat-do01" + ], + "statement": "An adversary consumes the resources of a target by rapidly engaging in a large number of interactions with the target. This type of attack generally exposes a weakness in rate limiting or flow. When successful this attack prevents legitimate users from accessing the service and can cause the target to crash. This attack differs from resource depletion through leaks or allocations in that the latter attacks do not rely on the volume of requests made to the target but instead focus on manipulation of the target's operations. The key factor in a flooding attack is the number of requests the adversary can make in a given period of time. The greater this number, the more likely an attack is to succeed against a given target.", + "status": "identified" + }, + { + "affects": [ + "asset-web-server" + ], + "bom-ref": "risk-7", + "domains": [ + { + "type": "security" + } + ], + "inherentRisk": { + "likelihood": { + "level": "high" + }, + "score": { + "level": "critical" + } + }, + "name": "Path Traversal (Web Server)", + "properties": [ + { + "name": "pytm:findingId", + "value": "7" + } + ], + "relatedThreats": [ + "threat-ha01" + ], + "statement": "An adversary uses path manipulation methods to exploit insufficient input validation of a target to obtain access to data that should be not be retrievable by ordinary well-formed requests. A typical variety of this attack involves specifying a path to a desired file together with dot-dot-slash characters, resulting in the file access API or function traversing out of the intended directory structure and into the root file system. By replacing or modifying the expected path information the access function or API retrieves the file desired by the attacker. These attacks either involve the attacker providing a complete path to a targeted file or using control characters (e.g. path separators (/ or ) and/or dots (.)) to reach desired directories or files.", + "status": "identified" + }, + { + "affects": [ + "asset-web-server" + ], + "bom-ref": "risk-8", + "domains": [ + { + "type": "security" + } + ], + "inherentRisk": { + "likelihood": { + "level": "medium" + }, + "score": { + "level": "medium" + } + }, + "name": "Excessive Allocation (Web Server)", + "properties": [ + { + "name": "pytm:findingId", + "value": "8" + } + ], + "relatedThreats": [ + "threat-do02" + ], + "statement": "An adversary causes the target to allocate excessive resources to servicing the attackers' request, thereby reducing the resources available for legitimate services and degrading or denying services. Usually, this attack focuses on memory allocation, but any finite resource on the target could be the attacked, including bandwidth, processing cycles, or other resources. This attack does not attempt to force this allocation through a large number of requests (that would be Resource Depletion through Flooding) but instead uses one or a small number of requests that are carefully formatted to force the target to allocate excessive resources to service this request(s). Often this attack takes advantage of a bug in the target to cause the target to allocate resources vastly beyond what would be needed for a normal request.", + "status": "identified" + }, + { + "affects": [ + "asset-web-server" + ], + "bom-ref": "risk-9", + "domains": [ + { + "type": "security" + } + ], + "inherentRisk": { + "likelihood": { + "level": "high" + }, + "score": { + "level": "high" + } + }, + "name": "Format String Injection (Web Server)", + "properties": [ + { + "name": "pytm:findingId", + "value": "9" + } + ], + "relatedThreats": [ + "threat-inp08" + ], + "statement": "An adversary includes formatting characters in a string input field on the target application. Most applications assume that users will provide static text and may respond unpredictably to the presence of formatting character. For example, in certain functions of the C programming languages such as printf, the formatting character %s will print the contents of a memory location expecting this location to identify a string and the formatting character %n prints the number of DWORD written in the memory. An adversary can use this to read or write to memory locations or files, or simply to manipulate the value of the resulting text in unexpected ways. Reading or writing memory may result in program crashes and writing memory could result in the execution of arbitrary code if the adversary can write to the program stack.", + "status": "identified" + }, + { + "affects": [ + "asset-web-server" + ], + "bom-ref": "risk-10", + "domains": [ + { + "type": "security" + } + ], + "inherentRisk": { + "likelihood": { + "level": "high" + }, + "score": { + "level": "high" + } + }, + "name": "LDAP Injection (Web Server)", + "properties": [ + { + "name": "pytm:findingId", + "value": "10" + } + ], + "relatedThreats": [ + "threat-inp09" + ], + "statement": "An attacker manipulates or crafts an LDAP query for the purpose of undermining the security of the target. Some applications use user input to create LDAP queries that are processed by an LDAP server. For example, a user might provide their username during authentication and the username might be inserted in an LDAP query during the authentication process. An attacker could use this input to inject additional commands into an LDAP query that could disclose sensitive information. For example, entering a * in the aforementioned query might return information about all users on the system. This attack is very similar to an SQL injection attack in that it manipulates a query to gather additional information or coerce a particular return value.", + "status": "identified" + }, + { + "affects": [ + "asset-web-server" + ], + "bom-ref": "risk-11", + "domains": [ + { + "type": "security" + } + ], + "inherentRisk": { + "likelihood": { + "level": "medium" + }, + "score": { + "level": "medium" + } + }, + "name": "Parameter Injection (Web Server)", + "properties": [ + { + "name": "pytm:findingId", + "value": "11" + } + ], + "relatedThreats": [ + "threat-inp10" + ], + "statement": "An adversary manipulates the content of request parameters for the purpose of undermining the security of the target. Some parameter encodings use text characters as separators. For example, parameters in a HTTP GET message are encoded as name-value pairs separated by an ampersand (&). If an attacker can supply text strings that are used to fill in these parameters, then they can inject special characters used in the encoding scheme to add or modify parameters. For example, if user input is fed directly into an HTTP GET request and the user provides the value myInput&new_param=myValue, then the input parameter is set to myInput, but a new parameter (new_param) is also added with a value of myValue. This can significantly change the meaning of the query that is processed by the server. Any encoding scheme where parameters are identified and separated by text characters is potentially vulnerable to this attack - the HTTP GET encoding used above is just one example.", + "status": "identified" + }, + { + "affects": [ + "asset-web-server" + ], + "bom-ref": "risk-12", + "domains": [ + { + "type": "security" + } + ], + "inherentRisk": { + "likelihood": { + "level": "high" + }, + "score": { + "level": "high" + } + }, + "name": "Relative Path Traversal (Web Server)", + "properties": [ + { + "name": "pytm:findingId", + "value": "12" + } + ], + "relatedThreats": [ + "threat-inp11" + ], + "statement": "An attacker exploits a weakness in input validation on the target by supplying a specially constructed path utilizing dot and slash characters for the purpose of obtaining access to arbitrary files or resources. An attacker modifies a known path on the target in order to reach material that is not available through intended channels. These attacks normally involve adding additional path separators (/ or ) and/or dots (.), or encodings thereof, in various combinations in order to reach parent directories or entirely separate trees of the target's directory structure.", + "status": "identified" + }, + { + "affects": [ + "asset-web-server" + ], + "bom-ref": "risk-13", + "domains": [ + { + "type": "security" + } + ], + "inherentRisk": { + "score": { + "level": "medium" + } + }, + "name": "Input Data Manipulation (Web Server)", + "properties": [ + { + "name": "pytm:findingId", + "value": "13" + } + ], + "relatedThreats": [ + "threat-inp14" + ], + "statement": "An attacker exploits a weakness in input validation by controlling the format, structure, and composition of data to an input-processing interface. By supplying input of a non-standard or unexpected form an attacker can adversely impact the security of the target. For example, using a different character encoding might cause dangerous text to be treated as safe text. Alternatively, the attacker may use certain flags, such as file extensions, to make a target application believe that provided data should be handled using a certain interpreter when the data is not actually of the appropriate type. This can lead to bypassing protection mechanisms, forcing the target to use specific components for input processing, or otherwise causing the user's data to be handled differently than might otherwise be expected. This attack differs from Variable Manipulation in that Variable Manipulation attempts to subvert the target's processing through the value of the input while Input Data Manipulation seeks to control how the input is processed.", + "status": "identified" + }, + { + "affects": [ + "asset-web-server" + ], + "bom-ref": "risk-14", + "domains": [ + { + "type": "security" + } + ], + "inherentRisk": { + "likelihood": { + "level": "medium" + }, + "score": { + "level": "high" + } + }, + "name": "Dictionary-based Password Attack (Web Server)", + "properties": [ + { + "name": "pytm:findingId", + "value": "14" + } + ], + "relatedThreats": [ + "threat-cr03" + ], + "statement": "An attacker tries each of the words in a dictionary as passwords to gain access to the system via some user's account. If the password chosen by the user was a word within the dictionary, this attack will be successful (in the absence of other mitigations). This is a specific instance of the password brute forcing attack pattern.", + "status": "identified" + }, + { + "affects": [ + "asset-web-server" + ], + "bom-ref": "risk-15", + "domains": [ + { + "type": "security" + } + ], + "inherentRisk": { + "likelihood": { + "level": "high" + }, + "score": { + "level": "info" + } + }, + "name": "Footprinting (Web Server)", + "properties": [ + { + "name": "pytm:findingId", + "value": "15" + } + ], + "relatedThreats": [ + "threat-ds03" + ], + "statement": "An adversary engages in probing and exploration activities to identify constituents and properties of the target. Footprinting is a general term to describe a variety of information gathering techniques, often used by attackers in preparation for some attack. It consists of using tools to learn as much as possible about the composition, configuration, and security mechanisms of the targeted application, system or network. Information that might be collected during a footprinting effort could include open ports, applications and their versions, network topology, and similar information. While footprinting is not intended to be damaging (although certain activities, such as network scans, can sometimes cause disruptions to vulnerable applications inadvertently) it may often pave the way for more damaging attacks.", + "status": "identified" + }, + { + "affects": [ + "asset-web-server" + ], + "bom-ref": "risk-16", + "domains": [ + { + "type": "security" + } + ], + "inherentRisk": { + "likelihood": { + "level": "high" + }, + "score": { + "level": "critical" + } + }, + "name": "Using Malicious Files (Web Server)", + "properties": [ + { + "name": "pytm:findingId", + "value": "16" + } + ], + "relatedThreats": [ + "threat-ac06" + ], + "statement": "An attack of this type exploits a system's configuration that allows an attacker to either directly access an executable file, for example through shell access; or in a possible worst case allows an attacker to upload a file and then execute it. Web servers, ftp servers, and message oriented middleware systems which have many integration points are particularly vulnerable, because both the programmers and the administrators must be in synch regarding the interfaces and the correct privileges for each interface.", + "status": "identified" + }, + { + "affects": [ + "asset-web-server" + ], + "bom-ref": "risk-17", + "domains": [ + { + "type": "security" + } + ], + "inherentRisk": { + "likelihood": { + "level": "high" + }, + "score": { + "level": "low" + } + }, + "name": "Web Application Fingerprinting (Web Server)", + "properties": [ + { + "name": "pytm:findingId", + "value": "17" + } + ], + "relatedThreats": [ + "threat-ha03" + ], + "statement": "An attacker sends a series of probes to a web application in order to elicit version-dependent and type-dependent behavior that assists in identifying the target. An attacker could learn information such as software versions, error pages, and response headers, variations in implementations of the HTTP protocol, directory structures, and other similar information about the targeted service. This information can then be used by an attacker to formulate a targeted attack plan. While web application fingerprinting is not intended to be damaging (although certain activities, such as network scans, can sometimes cause disruptions to vulnerable applications inadvertently) it may often pave the way for more damaging attacks.", + "status": "identified" + }, + { + "affects": [ + "asset-web-server" + ], + "bom-ref": "risk-18", + "domains": [ + { + "type": "security" + } + ], + "inherentRisk": { + "likelihood": { + "level": "high" + }, + "score": { + "level": "critical" + } + }, + "name": "XSS Targeting Non-Script Elements (Web Server)", + "properties": [ + { + "name": "pytm:findingId", + "value": "18" + } + ], + "relatedThreats": [ + "threat-sc02" + ], + "statement": "This attack is a form of Cross-Site Scripting (XSS) where malicious scripts are embedded in elements that are not expected to host scripts such as image tags (), comments in XML documents (< !-CDATA->), etc. These tags may not be subject to the same input validation, output validation, and other content filtering and checking routines, so this can create an opportunity for an attacker to tunnel through the application's elements and launch a XSS attack through other elements. As with all remote attacks, it is important to differentiate the ability to launch an attack (such as probing an internal network for unpatched servers) and the ability of the remote attacker to collect and interpret the output of said attack.", + "status": "identified" + }, + { + "affects": [ + "asset-web-server" + ], + "bom-ref": "risk-19", + "domains": [ + { + "type": "security" + } + ], + "inherentRisk": { + "likelihood": { + "level": "high" + }, + "score": { + "level": "medium" + } + }, + "name": "Exploiting Incorrectly Configured Access Control Security Levels (Web Server)", + "properties": [ + { + "name": "pytm:findingId", + "value": "19" + } + ], + "relatedThreats": [ + "threat-ac07" + ], + "statement": "An attacker exploits a weakness in the configuration of access controls and is able to bypass the intended protection that these measures guard against and thereby obtain unauthorized access to the system or network. Sensitive functionality should always be protected with access controls. However configuring all but the most trivial access control systems can be very complicated and there are many opportunities for mistakes. If an attacker can learn of incorrectly configured access security settings, they may be able to exploit this in an attack. Most commonly, attackers would take advantage of controls that provided too little protection for sensitive activities in order to perform actions that should be denied to them. In some circumstances, an attacker may be able to take advantage of overly restrictive access control policies, initiating denial of services (if an application locks because it unexpectedly failed to be granted access) or causing other legitimate actions to fail due to security. The latter class of attacks, however, is usually less severe and easier to detect than attacks based on inadequate security restrictions. This attack pattern differs from CAPEC 1, Accessing Functionality Not Properly Constrained by ACLs in that the latter describes attacks where sensitive functionality lacks access controls, where, in this pattern, the access control is present, but incorrectly configured.", + "status": "identified" + }, + { + "affects": [ + "asset-web-server" + ], + "bom-ref": "risk-20", + "domains": [ + { + "type": "security" + } + ], + "inherentRisk": { + "likelihood": { + "level": "high" + }, + "score": { + "level": "high" + } + }, + "name": "Embedding Scripts within Scripts (Web Server)", + "properties": [ + { + "name": "pytm:findingId", + "value": "20" + } + ], + "relatedThreats": [ + "threat-sc03" + ], + "statement": "An attack of this type exploits a programs' vulnerabilities that are brought on by allowing remote hosts to execute scripts. The adversary leverages this capability to execute his/her own script by embedding it within other scripts that the target software is likely to execute. The adversary must have the ability to inject their script into a script that is likely to be executed. If this is done, then the adversary can potentially launch a variety of probes and attacks against the web server's local environment, in many cases the so-called DMZ, back end resources the web server can communicate with, and other hosts. With the proliferation of intermediaries, such as Web App Firewalls, network devices, and even printers having JVMs and Web servers, there are many locales where an attacker can inject malicious scripts. Since this attack pattern defines scripts within scripts, there are likely privileges to execute said attack on the host. These attacks are not solely limited to the server side, client side scripts like Ajax and client side JavaScript can contain malicious scripts as well.", + "status": "identified" + }, + { + "affects": [ + "asset-web-server" + ], + "bom-ref": "risk-21", + "domains": [ + { + "type": "security" + } + ], + "inherentRisk": { + "likelihood": { + "level": "high" + }, + "score": { + "level": "high" + } + }, + "name": "PHP Remote File Inclusion (Web Server)", + "properties": [ + { + "name": "pytm:findingId", + "value": "21" + } + ], + "relatedThreats": [ + "threat-inp16" + ], + "statement": "In this pattern the adversary is able to load and execute arbitrary code remotely available from the application. This is usually accomplished through an insecurely configured PHP runtime environment and an improperly sanitized include or require call, which the user can then control to point to any web-accessible file. This allows adversaries to hijack the targeted application and force it to execute their own instructions.", + "status": "identified" + }, + { + "affects": [ + "asset-web-server" + ], + "bom-ref": "risk-22", + "domains": [ + { + "type": "security" + } + ], + "inherentRisk": { + "score": { + "level": "medium" + } + }, + "name": "Principal Spoof (Web Server)", + "properties": [ + { + "name": "pytm:findingId", + "value": "22" + } + ], + "relatedThreats": [ + "threat-aa02" + ], + "statement": "A Principal Spoof is a form of Identity Spoofing where an adversary pretends to be some other person in an interaction. This is often accomplished by crafting a message (either written, verbal, or visual) that appears to come from a person other than the adversary. Phishing and Pharming attacks often attempt to do this so that their attempts to gather sensitive information appear to come from a legitimate source. A Principal Spoof does not use stolen or spoofed authentication credentials, instead relying on the appearance and content of the message to reflect identity. The possible outcomes of a Principal Spoof mirror those of Identity Spoofing. (e.g., escalation of privilege and false attribution of data or activities) Likewise, most techniques for Identity Spoofing (crafting messages or intercepting and replaying or modifying messages) can be used for a Principal Spoof attack. However, because a Principal Spoof is used to impersonate a person, social engineering can be both an attack technique (using social techniques to generate evidence in support of a false identity) as well as a possible outcome (manipulating people's perceptions by making statements or performing actions under a target's name).", + "status": "identified" + }, + { + "affects": [ + "asset-web-server" + ], + "bom-ref": "risk-23", + "domains": [ + { + "type": "security" + } + ], + "inherentRisk": { + "score": { + "level": "medium" + } + }, + "name": "XSS Targeting Error Pages (Web Server)", + "properties": [ + { + "name": "pytm:findingId", + "value": "23" + } + ], + "relatedThreats": [ + "threat-ds04" + ], + "statement": "An adversary distributes a link (or possibly some other query structure) with a request to a third party web server that is malformed and also contains a block of exploit code in order to have the exploit become live code in the resulting error page. When the third party web server receives the crafted request and notes the error it then creates an error message that echoes the malformed message, including the exploit. Doing this converts the exploit portion of the message into to valid language elements that are executed by the viewing browser. When a victim executes the query provided by the attacker the infected error message error message is returned including the exploit code which then runs in the victim's browser. XSS can result in execution of code as well as data leakage (e.g. session cookies can be sent to the attacker). This type of attack is especially dangerous since the exploit appears to come from the third party web server, who the victim may trust and hence be more vulnerable to deception.", + "status": "identified" + }, + { + "affects": [ + "asset-web-server" + ], + "bom-ref": "risk-24", + "domains": [ + { + "type": "security" + } + ], + "inherentRisk": { + "likelihood": { + "level": "high" + }, + "score": { + "level": "high" + } + }, + "name": "XSS Using Alternate Syntax (Web Server)", + "properties": [ + { + "name": "pytm:findingId", + "value": "24" + } + ], + "relatedThreats": [ + "threat-sc04" + ], + "statement": "An adversary uses alternate forms of keywords or commands that result in the same action as the primary form but which may not be caught by filters. For example, many keywords are processed in a case insensitive manner. If the site's web filtering algorithm does not convert all tags into a consistent case before the comparison with forbidden keywords it is possible to bypass filters (e.g., incomplete black lists) by using an alternate case structure. For example, the script tag using the alternate forms of Script or ScRiPt may bypass filters where script is the only form tested. Other variants using different syntax representations are also possible as well as using pollution meta-characters or entities that are eventually ignored by the rendering engine. The attack can result in the execution of otherwise prohibited functionality.", + "status": "identified" + }, + { + "affects": [ + "asset-web-server" + ], + "bom-ref": "risk-25", + "domains": [ + { + "type": "security" + } + ], + "inherentRisk": { + "likelihood": { + "level": "low" + }, + "score": { + "level": "low" + } + }, + "name": "Encryption Brute Forcing (Web Server)", + "properties": [ + { + "name": "pytm:findingId", + "value": "25" + } + ], + "relatedThreats": [ + "threat-cr05" + ], + "statement": "An attacker, armed with the cipher text and the encryption algorithm used, performs an exhaustive (brute force) search on the key space to determine the key that decrypts the cipher text to obtain the plaintext.", + "status": "identified" + }, + { + "affects": [ + "asset-web-server" + ], + "bom-ref": "risk-26", + "domains": [ + { + "type": "security" + } + ], + "inherentRisk": { + "score": { + "level": "medium" + } + }, + "name": "Manipulate Registry Information (Web Server)", + "properties": [ + { + "name": "pytm:findingId", + "value": "26" + } + ], + "relatedThreats": [ + "threat-ac08" + ], + "statement": "An adversary exploits a weakness in authorization in order to modify content within a registry (e.g., Windows Registry, Mac plist, application registry). Editing registry information can permit the adversary to hide configuration information or remove indicators of compromise to cover up activity. Many applications utilize registries to store configuration and service information. As such, modification of registry information can affect individual services (affecting billing, authorization, or even allowing for identity spoofing) or the overall configuration of a targeted application. For example, both Java RMI and SOAP use registries to track available services. Changing registry values is sometimes a preliminary step towards completing another attack pattern, but given the long term usage of many registry values, manipulation of registry information could be its own end.", + "status": "identified" + }, + { + "affects": [ + "asset-web-server" + ], + "bom-ref": "risk-27", + "domains": [ + { + "type": "security" + } + ], + "inherentRisk": { + "likelihood": { + "level": "medium" + }, + "score": { + "level": "high" + } + }, + "name": "Removing Important Client Functionality (Web Server)", + "properties": [ + { + "name": "pytm:findingId", + "value": "27" + } + ], + "relatedThreats": [ + "threat-sc05" + ], + "statement": "An attacker removes or disables functionality on the client that the server assumes to be present and trustworthy. Attackers can, in some cases, get around logic put in place to 'guard' sensitive functionality or data. Client applications may include functionality that a server relies on for correct and secure operation. This functionality can include, but is not limited to, filters to prevent the sending of dangerous content to the server, logical functionality such as price calculations, and authentication logic to ensure that only authorized users are utilizing the client. If an attacker can disable this functionality on the client, they can perform actions that the server believes are prohibited. This can result in client behavior that violates assumptions by the server leading to a variety of possible attacks. In the above examples, this could include the sending of dangerous content (such as scripts) to the server, incorrect price calculations, or unauthorized access to server resources.", + "status": "identified" + }, + { + "affects": [ + "asset-web-server" + ], + "bom-ref": "risk-28", + "domains": [ + { + "type": "security" + } + ], + "inherentRisk": { + "likelihood": { + "level": "medium" + }, + "score": { + "level": "medium" + } + }, + "name": "XSS Using MIME Type Mismatch (Web Server)", + "properties": [ + { + "name": "pytm:findingId", + "value": "28" + } + ], + "relatedThreats": [ + "threat-inp17" + ], + "statement": "An adversary creates a file with scripting content but where the specified MIME type of the file is such that scripting is not expected. The adversary tricks the victim into accessing a URL that responds with the script file. Some browsers will detect that the specified MIME type of the file does not match the actual type of its content and will automatically switch to using an interpreter for the real content type. If the browser does not invoke script filters before doing this, the adversary's script may run on the target unsanitized, possibly revealing the victim's cookies or executing arbitrary script in their browser.", + "status": "identified" + }, + { + "affects": [ + "asset-web-server" + ], + "bom-ref": "risk-29", + "domains": [ + { + "type": "security" + } + ], + "inherentRisk": { + "likelihood": { + "level": "high" + }, + "score": { + "level": "high" + } + }, + "name": "Exploitation of Trusted Credentials (Web Server)", + "properties": [ + { + "name": "pytm:findingId", + "value": "29" + } + ], + "relatedThreats": [ + "threat-aa03" + ], + "statement": "Attacks on session IDs and resource IDs take advantage of the fact that some software accepts user input without verifying its authenticity. For example, a message queuing system that allows service requesters to post messages to its queue through an open channel (such as anonymous FTP), authorization is done through checking group or role membership contained in the posted message. However, there is no proof that the message itself, the information in the message (such group or role membership), or indeed the process that wrote the message to the queue are authentic and authorized to do so. Many server side processes are vulnerable to these attacks because the server to server communications have not been analyzed from a security perspective or the processes trust other systems because they are behind a firewall. In a similar way servers that use easy to guess or spoofable schemes for representing digital identity can also be vulnerable. Such systems frequently use schemes without cryptography and digital signatures (or with broken cryptography). Session IDs may be guessed due to insufficient randomness, poor protection (passed in the clear), lack of integrity (unsigned), or improperly correlation with access control policy enforcement points. Exposed configuration and properties files that contain system passwords, database connection strings, and such may also give an attacker an edge to identify these identifiers. The net result is that spoofing and impersonation is possible leading to an attacker's ability to break authentication, authorization, and audit controls on the system.", + "status": "identified" + }, + { + "affects": [ + "asset-web-server" + ], + "bom-ref": "risk-30", + "domains": [ + { + "type": "security" + } + ], + "inherentRisk": { + "likelihood": { + "level": "medium" + }, + "score": { + "level": "medium" + } + }, + "name": "Functionality Misuse (Web Server)", + "properties": [ + { + "name": "pytm:findingId", + "value": "30" + } + ], + "relatedThreats": [ + "threat-ac09" + ], + "statement": "An adversary leverages a legitimate capability of an application in such a way as to achieve a negative technical impact. The system functionality is not altered or modified but used in a way that was not intended. This is often accomplished through the overuse of a specific functionality or by leveraging functionality with design flaws that enables the adversary to gain access to unauthorized, sensitive data.", + "status": "identified" + }, + { + "affects": [ + "asset-web-server" + ], + "bom-ref": "risk-31", + "domains": [ + { + "type": "security" + } + ], + "inherentRisk": { + "likelihood": { + "level": "high" + }, + "score": { + "level": "low" + } + }, + "name": "Fuzzing and observing application log data/errors for application mapping (Web Server)", + "properties": [ + { + "name": "pytm:findingId", + "value": "31" + } + ], + "relatedThreats": [ + "threat-inp18" + ], + "statement": "An attacker sends random, malformed, or otherwise unexpected messages to a target application and observes the application's log or error messages returned. Fuzzing techniques involve sending random or malformed messages to a target and monitoring the target's response. The attacker does not initially know how a target will respond to individual messages but by attempting a large number of message variants they may find a variant that trigger's desired behavior. In this attack, the purpose of the fuzzing is to observe the application's log and error messages, although fuzzing a target can also sometimes cause the target to enter an unstable state, causing a crash. By observing logs and error messages, the attacker can learn details about the configuration of the target application and might be able to cause the target to disclose sensitive information.", + "status": "identified" + }, + { + "affects": [ + "asset-web-server" + ], + "bom-ref": "risk-32", + "domains": [ + { + "type": "security" + } + ], + "inherentRisk": { + "likelihood": { + "level": "high" + }, + "score": { + "level": "high" + } + }, + "name": "Exploiting Trust in Client (Web Server)", + "properties": [ + { + "name": "pytm:findingId", + "value": "32" + } + ], + "relatedThreats": [ + "threat-aa04" + ], + "statement": "An attack of this type exploits vulnerabilities in client/server communication channel authentication and data integrity. It leverages the implicit trust a server places in the client, or more importantly, that which the server believes is the client. An attacker executes this type of attack by placing themselves in the communication channel between client and server such that communication directly to the server is possible where the server believes it is communicating only with a valid client. There are numerous variations of this type of attack.", + "status": "identified" + }, + { + "affects": [ + "asset-web-server" + ], + "bom-ref": "risk-33", + "domains": [ + { + "type": "security" + } + ], + "inherentRisk": { + "likelihood": { + "level": "low" + }, + "score": { + "level": "medium" + } + }, + "name": "XML External Entities Blowup (Web Server)", + "properties": [ + { + "name": "pytm:findingId", + "value": "33" + } + ], + "relatedThreats": [ + "threat-inp19" + ], + "statement": "This attack takes advantage of the entity replacement property of XML where the value of the replacement is a URI. A well-crafted XML document could have the entity refer to a URI that consumes a large amount of resources to create a denial of service condition. This can cause the system to either freeze, crash, or execute arbitrary code depending on the URI.", + "status": "identified" + }, + { + "affects": [ + "asset-web-server" + ], + "bom-ref": "risk-34", + "domains": [ + { + "type": "security" + } + ], + "inherentRisk": { + "likelihood": { + "level": "medium" + }, + "score": { + "level": "medium" + } + }, + "name": "Session Credential Falsification through Manipulation (Web Server)", + "properties": [ + { + "name": "pytm:findingId", + "value": "34" + } + ], + "relatedThreats": [ + "threat-ac11" + ], + "statement": "An attacker manipulates an existing credential in order to gain access to a target application. Session credentials allow users to identify themselves to a service after an initial authentication without needing to resend the authentication information (usually a username and password) with every message. An attacker may be able to manipulate a credential sniffed from an existing connection in order to gain access to a target server. For example, a credential in the form of a web cookie might have a field that indicates the access rights of a user. By manually tweaking this cookie, a user might be able to increase their access rights to the server. Alternately an attacker may be able to manipulate an existing credential to appear as a different user. This attack differs from falsification through prediction in that the user bases their modified credentials off existing credentials instead of using patterns detected in prior credentials to create a new credential that is accepted because it fits the pattern. As a result, an attacker may be able to impersonate other users or elevate their permissions to a targeted service.", + "status": "identified" + }, + { + "affects": [ + "asset-web-server" + ], + "bom-ref": "risk-35", + "domains": [ + { + "type": "security" + } + ], + "inherentRisk": { + "likelihood": { + "level": "medium" + }, + "score": { + "level": "medium" + } + }, + "name": "DTD Injection (Web Server)", + "properties": [ + { + "name": "pytm:findingId", + "value": "35" + } + ], + "relatedThreats": [ + "threat-inp21" + ], + "statement": "An attacker injects malicious content into an application's DTD in an attempt to produce a negative technical impact. DTDs are used to describe how XML documents are processed. Certain malformed DTDs (for example, those with excessive entity expansion as described in CAPEC 197) can cause the XML parsers that process the DTDs to consume excessive resources resulting in resource depletion.", + "status": "identified" + }, + { + "affects": [ + "asset-web-server" + ], + "bom-ref": "risk-36", + "domains": [ + { + "type": "security" + } + ], + "inherentRisk": { + "likelihood": { + "level": "high" + }, + "score": { + "level": "high" + } + }, + "name": "XML Attribute Blowup (Web Server)", + "properties": [ + { + "name": "pytm:findingId", + "value": "36" + } + ], + "relatedThreats": [ + "threat-inp22" + ], + "statement": "This attack exploits certain XML parsers which manage data in an inefficient manner. The attacker crafts an XML document with many attributes in the same XML node. In a vulnerable parser, this results in a denial of service condition owhere CPU resources are exhausted because of the parsing algorithm.", + "status": "identified" + }, + { + "affects": [ + "asset-web-server" + ], + "bom-ref": "risk-37", + "domains": [ + { + "type": "security" + } + ], + "inherentRisk": { + "likelihood": { + "level": "high" + }, + "score": { + "level": "high" + } + }, + "name": "XSS Targeting URI Placeholders (Web Server)", + "properties": [ + { + "name": "pytm:findingId", + "value": "37" + } + ], + "relatedThreats": [ + "threat-inp28" + ], + "statement": "An attack of this type exploits the ability of most browsers to interpret data, javascript or other URI schemes as client-side executable content placeholders. This attack consists of passing a malicious URI in an anchor tag HREF attribute or any other similar attributes in other HTML tags. Such malicious URI contains, for example, a base64 encoded HTML content with an embedded cross-site scripting payload. The attack is executed when the browser interprets the malicious content i.e., for example, when the victim clicks on the malicious link.", + "status": "identified" + }, + { + "affects": [ + "asset-web-server" + ], + "bom-ref": "risk-38", + "domains": [ + { + "type": "security" + } + ], + "inherentRisk": { + "likelihood": { + "level": "medium" + }, + "score": { + "level": "medium" + } + }, + "name": "XSS Using Doubled Characters (Web Server)", + "properties": [ + { + "name": "pytm:findingId", + "value": "38" + } + ], + "relatedThreats": [ + "threat-inp29" + ], + "statement": "The attacker bypasses input validation by using doubled characters in order to perform a cross-site scripting attack. Some filters fail to recognize dangerous sequences if they are preceded by repeated characters. For example, by doubling the < before a script command, (<). For the attacker to execute the same payload as in the previous example, he would need to send alert(1) if there was no filtering. The application makes use of the following regular expression as filter ((w+)s*(.*)|alert|eval|function|document) and replaces all matches by the empty string. For example each occurrence of alert(), eval(), foo() or even the string alert would be stripped. An attacker will then create a special payload to bypass this filter: this['al' + 'ert'](1) when the applications gets this input string, it won't replace anything and this piece of JavaScript has exactly the same runtime meaning as alert(1). The attacker could also have used non-alphanumeric XSS vectors to bypass the filter; for example, ($=[$=[]][(__=!$+$)[_=-~-~-~$]+({}+$)[_/_]+($$=($_=!''+$)[_/_]+$_[+$])])()[__[_/_]+__[_+~$]+$_[_]+$$](_/_) would be executed by the JavaScript engine like alert(1) is." + }, + { + "name": "pytm:references", + "value": "https://capec.mitre.org/data/definitions/199.html, http://cwe.mitre.org/data/definitions/87.html" + } + ], + "source": "pytm", + "weaknesses": [ + { + "cweId": 87 + } + ] + }, + { + "affectedAssets": [ + "asset-web-server", + "datastore-sql-database" + ], + "attackPatterns": [ + "capec-20" + ], + "bom-ref": "threat-cr05", + "description": "An attacker, armed with the cipher text and the encryption algorithm used, performs an exhaustive (brute force) search on the key space to determine the key that decrypts the cipher text to obtain the plaintext.", + "externalReferences": [ + { + "type": "other", + "url": "https://capec.mitre.org/data/definitions/20.html" + }, + { + "type": "other", + "url": "http://cwe.mitre.org/data/definitions/326.html" + }, + { + "type": "other", + "url": "http://cwe.mitre.org/data/definitions/327.html" + }, + { + "type": "other", + "url": "http://cwe.mitre.org/data/definitions/693.html" + }, + { + "type": "other", + "url": "http://cwe.mitre.org/data/definitions/719.html" + } + ], + "name": "Encryption Brute Forcing", + "properties": [ + { + "name": "pytm:sid", + "value": "CR05" + }, + { + "name": "pytm:severity", + "value": "Low" + }, + { + "name": "pytm:likelihood", + "value": "Low" + }, + { + "name": "pytm:condition", + "value": "target.controls.usesEncryptionAlgorithm != 'RSA' and target.controls.usesEncryptionAlgorithm != 'AES'" + }, + { + "name": "pytm:mitigations", + "value": "Use commonly accepted algorithms and recommended key sizes. The key size used will depend on how important it is to keep the data confidential and for how long.In theory a brute force attack performing an exhaustive key space search will always succeed, so the goal is to have computational security. Moore's law needs to be taken into account that suggests that computing resources double every eighteen months." + }, + { + "name": "pytm:example", + "value": "In 1997 the original DES challenge used distributed net computing to brute force the encryption key and decrypt the ciphertext to obtain the original plaintext. Each machine was given its own section of the key space to cover. The ciphertext was decrypted in 96 days." + }, + { + "name": "pytm:references", + "value": "https://capec.mitre.org/data/definitions/20.html, http://cwe.mitre.org/data/definitions/326.html, http://cwe.mitre.org/data/definitions/327.html, http://cwe.mitre.org/data/definitions/693.html, http://cwe.mitre.org/data/definitions/719.html" + } + ], + "source": "pytm", + "weaknesses": [ + { + "cweId": 326 + }, + { + "cweId": 327 + }, + { + "cweId": 693 + }, + { + "cweId": 719 + } + ] + }, + { + "affectedAssets": [ + "asset-web-server" + ], + "attackPatterns": [ + "capec-203" + ], + "bom-ref": "threat-ac08", + "description": "An adversary exploits a weakness in authorization in order to modify content within a registry (e.g., Windows Registry, Mac plist, application registry). Editing registry information can permit the adversary to hide configuration information or remove indicators of compromise to cover up activity. Many applications utilize registries to store configuration and service information. As such, modification of registry information can affect individual services (affecting billing, authorization, or even allowing for identity spoofing) or the overall configuration of a targeted application. For example, both Java RMI and SOAP use registries to track available services. Changing registry values is sometimes a preliminary step towards completing another attack pattern, but given the long term usage of many registry values, manipulation of registry information could be its own end.", + "externalReferences": [ + { + "type": "other", + "url": "https://capec.mitre.org/data/definitions/203.html" + }, + { + "type": "other", + "url": "http://cwe.mitre.org/data/definitions/15.html" + } + ], + "name": "Manipulate Registry Information", + "properties": [ + { + "name": "pytm:sid", + "value": "AC08" + }, + { + "name": "pytm:severity", + "value": "Medium" + }, + { + "name": "pytm:condition", + "value": "target.controls.hasAccessControl is False" + }, + { + "name": "pytm:mitigations", + "value": "Ensure proper permissions are set for Registry hives to prevent users from modifying keys.Employ a robust and layered defensive posture in order to prevent unauthorized users on your system.Employ robust identification and audit/blocking via whitelisting of applications on your system. Unnecessary applications, utilities, and configurations will have a presence in the system registry that can be leveraged by an adversary through this attack pattern." + }, + { + "name": "pytm:example", + "value": "Manipulating registration information can be undertaken in advance of a path traversal attack (inserting relative path modifiers) or buffer overflow attack (enlarging a registry value beyond an application's ability to store it)." + }, + { + "name": "pytm:references", + "value": "https://capec.mitre.org/data/definitions/203.html, http://cwe.mitre.org/data/definitions/15.html" + } + ], + "source": "pytm", + "weaknesses": [ + { + "cweId": 15 + } + ] + }, + { + "affectedAssets": [ + "asset-web-server" + ], + "bom-ref": "threat-sc05", + "description": "An attacker removes or disables functionality on the client that the server assumes to be present and trustworthy. Attackers can, in some cases, get around logic put in place to 'guard' sensitive functionality or data. Client applications may include functionality that a server relies on for correct and secure operation. This functionality can include, but is not limited to, filters to prevent the sending of dangerous content to the server, logical functionality such as price calculations, and authentication logic to ensure that only authorized users are utilizing the client. If an attacker can disable this functionality on the client, they can perform actions that the server believes are prohibited. This can result in client behavior that violates assumptions by the server leading to a variety of possible attacks. In the above examples, this could include the sending of dangerous content (such as scripts) to the server, incorrect price calculations, or unauthorized access to server resources.", + "externalReferences": [ + { + "type": "other", + "url": "http://cwe.mitre.org/data/definitions/602.html" + } + ], + "name": "Removing Important Client Functionality", + "properties": [ + { + "name": "pytm:sid", + "value": "SC05" + }, + { + "name": "pytm:severity", + "value": "High" + }, + { + "name": "pytm:likelihood", + "value": "Medium" + }, + { + "name": "pytm:condition", + "value": "target.controls.providesIntegrity is False or target.controls.usesCodeSigning is False" + }, + { + "name": "pytm:mitigations", + "value": "Design: For any security checks that are performed on the client side, ensure that these checks are duplicated on the server side.Design: Ship client-side application with integrity checks (code signing) when possible.Design: Use obfuscation and other techniques to prevent reverse engineering the client code." + }, + { + "name": "pytm:example", + "value": "Attacker reverse engineers a Java binary (by decompiling it) and identifies where license management code exists. Noticing that the license manager returns TRUE or FALSE as to whether or not the user is licensed, the Attacker simply overwrites both branch targets to return TRUE, recompiles, and finally redeploys the binary.Attacker uses click-through exploration of a Servlet-based website to map out its functionality, taking note of its URL-naming conventions and Servlet mappings. Using this knowledge and guessing the Servlet name of functionality they're not authorized to use, the Attacker directly navigates to the privileged functionality around the authorizing single-front controller (implementing programmatic authorization checks).Attacker reverse-engineers a Java binary (by decompiling it) and identifies where license management code exists. Noticing that the license manager returns TRUE or FALSE as to whether or not the user is licensed, the Attacker simply overwrites both branch targets to return TRUE, recompiles, and finally redeploys the binary." + }, + { + "name": "pytm:references", + "value": "http://cwe.mitre.org/data/definitions/602.html" + } + ], + "source": "pytm", + "weaknesses": [ + { + "cweId": 602 + } + ] + }, + { + "affectedAssets": [ + "asset-web-server" + ], + "bom-ref": "threat-inp17", + "description": "An adversary creates a file with scripting content but where the specified MIME type of the file is such that scripting is not expected. The adversary tricks the victim into accessing a URL that responds with the script file. Some browsers will detect that the specified MIME type of the file does not match the actual type of its content and will automatically switch to using an interpreter for the real content type. If the browser does not invoke script filters before doing this, the adversary's script may run on the target unsanitized, possibly revealing the victim's cookies or executing arbitrary script in their browser.", + "externalReferences": [ + { + "type": "other", + "url": "http://cwe.mitre.org/data/definitions/79.html" + }, + { + "type": "other", + "url": "http://cwe.mitre.org/data/definitions/20.html" + }, + { + "type": "other", + "url": "http://cwe.mitre.org/data/definitions/646.html" + } + ], + "name": "XSS Using MIME Type Mismatch", + "properties": [ + { + "name": "pytm:sid", + "value": "INP17" + }, + { + "name": "pytm:severity", + "value": "Medium" + }, + { + "name": "pytm:likelihood", + "value": "Medium" + }, + { + "name": "pytm:condition", + "value": "target.controls.validatesContentType is False or target.controls.invokesScriptFilters is False" + }, + { + "name": "pytm:mitigations", + "value": "Design: Browsers must invoke script filters to detect that the specified MIME type of the file matches the actual type of its content before deciding which script interpreter to use." + }, + { + "name": "pytm:example", + "value": "For example, the MIME type text/plain may be used where the actual content is text/javascript or text/html. Since text does not contain scripting instructions, the stated MIME type would indicate that filtering is unnecessary. However, if the target application subsequently determines the file's real type and invokes the appropriate interpreter, scripted content could be invoked.In another example, img tags in HTML content could reference a renderable type file instead of an expected image file. The file extension and MIME type can describe an image file, but the file content can be text/javascript or text/html resulting in script execution. If the browser assumes all references in img tags are images, and therefore do not need to be filtered for scripts, this would bypass content filters." + }, + { + "name": "pytm:references", + "value": "http://cwe.mitre.org/data/definitions/79.html, http://cwe.mitre.org/data/definitions/20.html, http://cwe.mitre.org/data/definitions/646.html" + } + ], + "source": "pytm", + "weaknesses": [ + { + "cweId": 79 + }, + { + "cweId": 20 + }, + { + "cweId": 646 + } + ] + }, + { + "affectedAssets": [ + "asset-web-server" + ], + "attackPatterns": [ + "capec-21" + ], + "bom-ref": "threat-aa03", + "description": "Attacks on session IDs and resource IDs take advantage of the fact that some software accepts user input without verifying its authenticity. For example, a message queuing system that allows service requesters to post messages to its queue through an open channel (such as anonymous FTP), authorization is done through checking group or role membership contained in the posted message. However, there is no proof that the message itself, the information in the message (such group or role membership), or indeed the process that wrote the message to the queue are authentic and authorized to do so. Many server side processes are vulnerable to these attacks because the server to server communications have not been analyzed from a security perspective or the processes trust other systems because they are behind a firewall. In a similar way servers that use easy to guess or spoofable schemes for representing digital identity can also be vulnerable. Such systems frequently use schemes without cryptography and digital signatures (or with broken cryptography). Session IDs may be guessed due to insufficient randomness, poor protection (passed in the clear), lack of integrity (unsigned), or improperly correlation with access control policy enforcement points. Exposed configuration and properties files that contain system passwords, database connection strings, and such may also give an attacker an edge to identify these identifiers. The net result is that spoofing and impersonation is possible leading to an attacker's ability to break authentication, authorization, and audit controls on the system.", + "externalReferences": [ + { + "type": "other", + "url": "https://capec.mitre.org/data/definitions/21.html" + }, + { + "type": "other", + "url": "http://cwe.mitre.org/data/definitions/290.html" + }, + { + "type": "other", + "url": "http://cwe.mitre.org/data/definitions/346.html" + }, + { + "type": "other", + "url": "http://cwe.mitre.org/data/definitions/664.html" + } + ], + "name": "Exploitation of Trusted Credentials", + "properties": [ + { + "name": "pytm:sid", + "value": "AA03" + }, + { + "name": "pytm:severity", + "value": "High" + }, + { + "name": "pytm:likelihood", + "value": "High" + }, + { + "name": "pytm:condition", + "value": "target.controls.providesIntegrity is False or target.controls.authenticatesSource is False or target.controls.usesStrongSessionIdentifiers is False" + }, + { + "name": "pytm:mitigations", + "value": "Design: utilize strong federated identity such as SAML to encrypt and sign identity tokens in transit.Implementation: Use industry standards session key generation mechanisms that utilize high amount of entropy to generate the session key. Many standard web and application servers will perform this task on your behalf.Implementation: If the session identifier is used for authentication, such as in the so-called single sign on use cases, then ensure that it is protected at the same level of assurance as authentication tokens.Implementation: If the web or application server supports it, then encrypting and/or signing the session ID (such as cookie) can protect the ID if intercepted.Design: Use strong session identifiers that are protected in transit and at rest.Implementation: Utilize a session timeout for all sessions, for example 20 minutes. If the user does not explicitly logout, the server terminates their session after this period of inactivity. If the user logs back in then a new session key is generated.Implementation: Verify of authenticity of all session IDs at runtime." + }, + { + "name": "pytm:example", + "value": "Thin client applications like web applications are particularly vulnerable to session ID attacks. Since the server has very little control over the client, but still must track sessions, data, and objects on the server side, cookies and other mechanisms have been used to pass the key to the session data between the client and server. When these session keys are compromised it is trivial for an attacker to impersonate a user's session in effect, have the same capabilities as the authorized user. There are two main ways for an attacker to exploit session IDs. A brute force attack involves an attacker repeatedly attempting to query the system with a spoofed session header in the HTTP request. A web server that uses a short session ID can be easily spoofed by trying many possible combinations so the parameters session-ID= 1234 has few possible combinations, and an attacker can retry several hundred or thousand request with little to no issue on their side. The second method is interception, where a tool such as wireshark is used to sniff the wire and pull off any unprotected session identifiers. The attacker can then use these variables and access the application." + }, + { + "name": "pytm:references", + "value": "https://capec.mitre.org/data/definitions/21.html, http://cwe.mitre.org/data/definitions/290.html, http://cwe.mitre.org/data/definitions/346.html, http://cwe.mitre.org/data/definitions/664.html" + } + ], + "source": "pytm", + "weaknesses": [ + { + "cweId": 290 + }, + { + "cweId": 346 + }, + { + "cweId": 664 + } + ] + }, + { + "affectedAssets": [ + "asset-web-server" + ], + "attackPatterns": [ + "capec-212" + ], + "bom-ref": "threat-ac09", + "description": "An adversary leverages a legitimate capability of an application in such a way as to achieve a negative technical impact. The system functionality is not altered or modified but used in a way that was not intended. This is often accomplished through the overuse of a specific functionality or by leveraging functionality with design flaws that enables the adversary to gain access to unauthorized, sensitive data.", + "externalReferences": [ + { + "type": "other", + "url": "https://capec.mitre.org/data/definitions/212.html" + } + ], + "name": "Functionality Misuse", + "properties": [ + { + "name": "pytm:sid", + "value": "AC09" + }, + { + "name": "pytm:severity", + "value": "Medium" + }, + { + "name": "pytm:likelihood", + "value": "Medium" + }, + { + "name": "pytm:condition", + "value": "target.controls.hasAccessControl is False or target.controls.authorizesSource is False" + }, + { + "name": "pytm:mitigations", + "value": "Perform comprehensive threat modeling, a process of identifying, evaluating, and mitigating potential threats to the application. This effort can help reveal potentially obscure application functionality that can be manipulated for malicious purposes.When implementing security features, consider how they can be misused and compromised." + }, + { + "name": "pytm:example", + "value": "An attacker clicks on the 'forgot password' and is presented with a single security question. The question is regarding the name of the first dog of the user. The system does not limit the number of attempts to provide the dog's name. An attacker goes through a list of 100 most popular dog names and finds the right name, thus getting the ability to reset the password and access the system." + }, + { + "name": "pytm:references", + "value": "https://capec.mitre.org/data/definitions/212.html" + } + ], + "source": "pytm" + }, + { + "affectedAssets": [ + "asset-web-server" + ], + "attackPatterns": [ + "capec-215" + ], + "bom-ref": "threat-inp18", + "description": "An attacker sends random, malformed, or otherwise unexpected messages to a target application and observes the application's log or error messages returned. Fuzzing techniques involve sending random or malformed messages to a target and monitoring the target's response. The attacker does not initially know how a target will respond to individual messages but by attempting a large number of message variants they may find a variant that trigger's desired behavior. In this attack, the purpose of the fuzzing is to observe the application's log and error messages, although fuzzing a target can also sometimes cause the target to enter an unstable state, causing a crash. By observing logs and error messages, the attacker can learn details about the configuration of the target application and might be able to cause the target to disclose sensitive information.", + "externalReferences": [ + { + "type": "other", + "url": "https://capec.mitre.org/data/definitions/215.html" + }, + { + "type": "other", + "url": "http://cwe.mitre.org/data/definitions/209.html" + }, + { + "type": "other", + "url": "http://cwe.mitre.org/data/definitions/532.html" + } + ], + "name": "Fuzzing and observing application log data/errors for application mapping", + "properties": [ + { + "name": "pytm:sid", + "value": "INP18" + }, + { + "name": "pytm:severity", + "value": "Low" + }, + { + "name": "pytm:likelihood", + "value": "High" + }, + { + "name": "pytm:condition", + "value": "target.controls.sanitizesInput is False or target.controls.encodesOutput is False" + }, + { + "name": "pytm:mitigations", + "value": "Design: Construct a 'code book' for error messages. When using a code book, application error messages aren't generated in string or stack trace form, but are catalogued and replaced with a unique (often integer-based) value 'coding' for the error. Such a technique will require helpdesk and hosting personnel to use a 'code book' or similar mapping to decode application errors/logs in order to respond to them normally.Design: wrap application functionality (preferably through the underlying framework) in an output encoding scheme that obscures or cleanses error messages to prevent such attacks. Such a technique is often used in conjunction with the above 'code book' suggestion.Implementation: Obfuscate server fields of HTTP response.Implementation: Hide inner ordering of HTTP response header.Implementation: Customizing HTTP error codes such as 404 or 500.Implementation: Hide HTTP response header software information filed.Implementation: Hide cookie's software information filed.Implementation: Obfuscate database type in Database API's error message." + }, + { + "name": "pytm:example", + "value": "The following code generates an error message that leaks the full pathname of the configuration file. $ConfigDir = /home/myprog/config;$uname = GetUserInput(username);ExitError(Bad hacker!) if ($uname !~ /^w+$/);$file = $ConfigDir/$uname.txt;if (! (-e $file)) { ExitError(Error: $file does not exist); }... If this code is running on a server, such as a web application, then the person making the request should not know what the full pathname of the configuration directory is. By submitting a username that does not produce a $file that exists, an attacker could get this pathname. It could then be used to exploit path traversal or symbolic link following problems that may exist elsewhere in the application." + }, + { + "name": "pytm:references", + "value": "https://capec.mitre.org/data/definitions/215.html, http://cwe.mitre.org/data/definitions/209.html, http://cwe.mitre.org/data/definitions/532.html" + } + ], + "source": "pytm", + "weaknesses": [ + { + "cweId": 209 + }, + { + "cweId": 532 + } + ] + }, + { + "affectedAssets": [ + "asset-web-server" + ], + "attackPatterns": [ + "capec-22" + ], + "bom-ref": "threat-aa04", + "description": "An attack of this type exploits vulnerabilities in client/server communication channel authentication and data integrity. It leverages the implicit trust a server places in the client, or more importantly, that which the server believes is the client. An attacker executes this type of attack by placing themselves in the communication channel between client and server such that communication directly to the server is possible where the server believes it is communicating only with a valid client. There are numerous variations of this type of attack.", + "externalReferences": [ + { + "type": "other", + "url": "https://capec.mitre.org/data/definitions/22.html" + }, + { + "type": "other", + "url": "http://cwe.mitre.org/data/definitions/287.html" + } + ], + "name": "Exploiting Trust in Client", + "properties": [ + { + "name": "pytm:sid", + "value": "AA04" + }, + { + "name": "pytm:severity", + "value": "High" + }, + { + "name": "pytm:likelihood", + "value": "High" + }, + { + "name": "pytm:condition", + "value": "target.controls.implementsServerSideValidation is False and (target.controls.providesIntegrity is False or target.controls.authorizesSource is False)" + }, + { + "name": "pytm:mitigations", + "value": "Design: Ensure that client process and/or message is authenticated so that anonymous communications and/or messages are not accepted by the system.Design: Do not rely on client validation or encoding for security purposes.Design: Utilize digital signatures to increase authentication assurance.Design: Utilize two factor authentication to increase authentication assurance.Implementation: Perform input validation for all remote content." + }, + { + "name": "pytm:example", + "value": "Web applications may use JavaScript to perform client side validation, request encoding/formatting, and other security functions, which provides some usability benefits and eliminates some client-server round-tripping. However, the web server cannot assume that the requests it receives have been subject to those validations, because an attacker can use an alternate method for crafting the HTTP Request and submit data that contains poisoned values designed to spoof a user and/or get the web server to disclose information.Web 2.0 style applications may be particularly vulnerable because they in large part rely on existing infrastructure which provides scalability without the ability to govern the clients. Attackers identify vulnerabilities that either assume the client side is responsible for some security services (without the requisite ability to ensure enforcement of these checks) and/or the lack of a hardened, default deny server configuration that allows for an attacker probing for weaknesses in unexpected ways. Client side validation, request formatting and other services may be performed, but these are strictly usability enhancements not security enhancements.Many web applications use client side scripting like JavaScript to enforce authentication, authorization, session state and other variables, but at the end of day they all make requests to the server. These client side checks may provide usability and performance gains, but they lack integrity in terms of the http request. It is possible for an attacker to post variables directly to the server without using any of the client script security checks and customize the patterns to impersonate other users or probe for more information.Many message oriented middleware systems like MQ Series are rely on information that is passed along with the message request for making authorization decisions, for example what group or role the request should be passed. However, if the message server does not or cannot authenticate the authorization information in the request then the server's policy decisions about authorization are trivial to subvert because the client process can simply elevate privilege by passing in elevated group or role information which the message server accepts and acts on." + }, + { + "name": "pytm:references", + "value": "https://capec.mitre.org/data/definitions/22.html, http://cwe.mitre.org/data/definitions/287.html" + } + ], + "source": "pytm", + "weaknesses": [ + { + "cweId": 287 + } + ] + }, + { + "affectedAssets": [ + "asset-web-server" + ], + "attackPatterns": [ + "capec-221" + ], + "bom-ref": "threat-inp19", + "description": "This attack takes advantage of the entity replacement property of XML where the value of the replacement is a URI. A well-crafted XML document could have the entity refer to a URI that consumes a large amount of resources to create a denial of service condition. This can cause the system to either freeze, crash, or execute arbitrary code depending on the URI.", + "externalReferences": [ + { + "type": "other", + "url": "https://capec.mitre.org/data/definitions/221.html" + }, + { + "type": "other", + "url": "http://cwe.mitre.org/data/definitions/611.html" + } + ], + "name": "XML External Entities Blowup", + "properties": [ + { + "name": "pytm:sid", + "value": "INP19" + }, + { + "name": "pytm:severity", + "value": "Medium" + }, + { + "name": "pytm:likelihood", + "value": "Low" + }, + { + "name": "pytm:condition", + "value": "target.usesXMLParser is False or target.controls.disablesDTD is False" + }, + { + "name": "pytm:mitigations", + "value": "This attack may be mitigated by tweaking the XML parser to not resolve external entities. If external entities are needed, then implement a custom XmlResolver that has a request timeout, data retrieval limit, and restrict resources it can retrieve locally." + }, + { + "name": "pytm:example", + "value": "In this example, the XML parser parses the attacker's XML and opens the malicious URI where the attacker controls the server and writes a massive amount of data to the response stream. In this example the malicious URI is a large file transfer. < !DOCTYPE bomb []>&detonate;" + }, + { + "name": "pytm:references", + "value": "https://capec.mitre.org/data/definitions/221.html, http://cwe.mitre.org/data/definitions/611.html" + } + ], + "source": "pytm", + "weaknesses": [ + { + "cweId": 611 + } + ] + }, + { + "affectedAssets": [ + "asset-web-server" + ], + "attackPatterns": [ + "capec-226" + ], + "bom-ref": "threat-ac11", + "description": "An attacker manipulates an existing credential in order to gain access to a target application. Session credentials allow users to identify themselves to a service after an initial authentication without needing to resend the authentication information (usually a username and password) with every message. An attacker may be able to manipulate a credential sniffed from an existing connection in order to gain access to a target server. For example, a credential in the form of a web cookie might have a field that indicates the access rights of a user. By manually tweaking this cookie, a user might be able to increase their access rights to the server. Alternately an attacker may be able to manipulate an existing credential to appear as a different user. This attack differs from falsification through prediction in that the user bases their modified credentials off existing credentials instead of using patterns detected in prior credentials to create a new credential that is accepted because it fits the pattern. As a result, an attacker may be able to impersonate other users or elevate their permissions to a targeted service.", + "externalReferences": [ + { + "type": "other", + "url": "https://capec.mitre.org/data/definitions/226.html" + }, + { + "type": "other", + "url": "http://cwe.mitre.org/data/definitions/565.html" + }, + { + "type": "other", + "url": "http://cwe.mitre.org/data/definitions/472.html" + } + ], + "name": "Session Credential Falsification through Manipulation", + "properties": [ + { + "name": "pytm:sid", + "value": "AC11" + }, + { + "name": "pytm:severity", + "value": "Medium" + }, + { + "name": "pytm:likelihood", + "value": "Medium" + }, + { + "name": "pytm:condition", + "value": "target.controls.usesStrongSessionIdentifiers is False" + }, + { + "name": "pytm:mitigations", + "value": "Implementation: Use session IDs that are difficult to guess or brute-force: One way for the attackers to obtain valid session IDs is by brute-forcing or guessing them. By choosing session identifiers that are sufficiently random, brute-forcing or guessing becomes very difficult. Implementation: Regenerate and destroy session identifiers when there is a change in the level of privilege: This ensures that even though a potential victim may have followed a link with a fixated identifier, a new one is issued when the level of privilege changes." + }, + { + "name": "pytm:example", + "value": "An adversary uses client side scripting(JavaScript) to set session ID in the victim's browser using document.cookie. This fixates a falsified session credential into victim's browser with the help of a crafted URL link. Once the victim clicks on the link, the attacker is able to bypass authentication or piggyback off some other authenticated victim's session." + }, + { + "name": "pytm:references", + "value": "https://capec.mitre.org/data/definitions/226.html, http://cwe.mitre.org/data/definitions/565.html, http://cwe.mitre.org/data/definitions/472.html" + } + ], + "source": "pytm", + "weaknesses": [ + { + "cweId": 565 + }, + { + "cweId": 472 + } + ] + }, + { + "affectedAssets": [ + "asset-web-server" + ], + "attackPatterns": [ + "capec-228" + ], + "bom-ref": "threat-inp21", + "description": "An attacker injects malicious content into an application's DTD in an attempt to produce a negative technical impact. DTDs are used to describe how XML documents are processed. Certain malformed DTDs (for example, those with excessive entity expansion as described in CAPEC 197) can cause the XML parsers that process the DTDs to consume excessive resources resulting in resource depletion.", + "externalReferences": [ + { + "type": "other", + "url": "https://capec.mitre.org/data/definitions/228.html" + }, + { + "type": "other", + "url": "http://cwe.mitre.org/data/definitions/829.html" + } + ], + "name": "DTD Injection", + "properties": [ + { + "name": "pytm:sid", + "value": "INP21" + }, + { + "name": "pytm:severity", + "value": "Medium" + }, + { + "name": "pytm:likelihood", + "value": "Medium" + }, + { + "name": "pytm:condition", + "value": "target.usesXMLParser is False or target.controls.disablesDTD is False" + }, + { + "name": "pytm:mitigations", + "value": "Design: Sanitize incoming DTDs to prevent excessive expansion or other actions that could result in impacts like resource depletion.Implementation: Disallow the inclusion of DTDs as part of incoming messages.Implementation: Use XML parsing tools that protect against DTD attacks." + }, + { + "name": "pytm:example", + "value": "Adversary injects XML External Entity (XEE) attack that can cause the disclosure of confidential information, execute abitrary code, create a Denial of Service of the targeted server, or several other malicious impacts." + }, + { + "name": "pytm:references", + "value": "https://capec.mitre.org/data/definitions/228.html, http://cwe.mitre.org/data/definitions/829.html" + } + ], + "source": "pytm", + "weaknesses": [ + { + "cweId": 829 + } + ] + }, + { + "affectedAssets": [ + "asset-web-server" + ], + "attackPatterns": [ + "capec-229" + ], + "bom-ref": "threat-inp22", + "description": "This attack exploits certain XML parsers which manage data in an inefficient manner. The attacker crafts an XML document with many attributes in the same XML node. In a vulnerable parser, this results in a denial of service condition owhere CPU resources are exhausted because of the parsing algorithm.", + "externalReferences": [ + { + "type": "other", + "url": "https://capec.mitre.org/data/definitions/229.html" + }, + { + "type": "other", + "url": "http://cwe.mitre.org/data/definitions/770.html" + } + ], + "name": "XML Attribute Blowup", + "properties": [ + { + "name": "pytm:sid", + "value": "INP22" + }, + { + "name": "pytm:severity", + "value": "High" + }, + { + "name": "pytm:likelihood", + "value": "High" + }, + { + "name": "pytm:condition", + "value": "target.usesXMLParser is False or target.controls.disablesDTD is False" + }, + { + "name": "pytm:mitigations", + "value": "This attack may be mitigated completely by using a parser that is not using a vulnerable container. Mitigation may also limit the number of attributes per XML element." + }, + { + "name": "pytm:example", + "value": "In this example, assume that the victim is running a vulnerable parser such as .NET framework 1.0. This results in a quadratic runtime of O(n^2). A document with n attributes results in (n^2)/2 operations to be performed. If an operation takes 100 nanoseconds then a document with 100,000 operations would take 500s to process. In this fashion a small message of less than 1MB causes a denial of service condition on the CPU resources." + }, + { + "name": "pytm:references", + "value": "https://capec.mitre.org/data/definitions/229.html, http://cwe.mitre.org/data/definitions/770.html" + } + ], + "source": "pytm", + "weaknesses": [ + { + "cweId": 770 + } + ] + }, + { + "affectedAssets": [ + "asset-web-server" + ], + "attackPatterns": [ + "capec-244" + ], + "bom-ref": "threat-inp28", + "description": "An attack of this type exploits the ability of most browsers to interpret data, javascript or other URI schemes as client-side executable content placeholders. This attack consists of passing a malicious URI in an anchor tag HREF attribute or any other similar attributes in other HTML tags. Such malicious URI contains, for example, a base64 encoded HTML content with an embedded cross-site scripting payload. The attack is executed when the browser interprets the malicious content i.e., for example, when the victim clicks on the malicious link.", + "externalReferences": [ + { + "type": "other", + "url": "https://capec.mitre.org/data/definitions/244.html" + }, + { + "type": "other", + "url": "http://cwe.mitre.org/data/definitions/83.html" + } + ], + "name": "XSS Targeting URI Placeholders", + "properties": [ + { + "name": "pytm:sid", + "value": "INP28" + }, + { + "name": "pytm:severity", + "value": "High" + }, + { + "name": "pytm:likelihood", + "value": "High" + }, + { + "name": "pytm:condition", + "value": "target.controls.validatesInput is False or target.controls.sanitizesInput is False or target.controls.encodesOutput is False" + }, + { + "name": "pytm:mitigations", + "value": "Design: Use browser technologies that do not allow client side scripting.Design: Utilize strict type, character, and encoding enforcement.Implementation: Ensure all content that is delivered to client is sanitized against an acceptable content specification.Implementation: Ensure all content coming from the client is using the same encoding; if not, the server-side application must canonicalize the data before applying any filtering.Implementation: Perform input validation for all remote content, including remote and user-generated contentImplementation: Perform output validation for all remote content.Implementation: Disable scripting languages such as JavaScript in browserImplementation: Patching software. There are many attack vectors for XSS on the client side and the server side. Many vulnerabilities are fixed in service packs for browser, web servers, and plug in technologies, staying current on patch release that deal with XSS countermeasures mitigates this." + }, + { + "name": "pytm:example", + "value": "The following payload data: text/html;base64,PGh0bWw+PGJvZHk+PHNjcmlwdD52YXIgaW1nID0gbmV3IEltYWdlKCk7IGltZy5zcmMgPSAiaHR0cDovL2F0dGFja2VyLmNvbS9jb29raWVncmFiYmVyPyIrIGVuY29kZVVSSUNvbXBvbmVudChkb2N1bWVudC5jb29raWVzKTs8L3NjcmlwdD48L2JvZHk+PC9odG1sPg== represents a base64 encoded HTML and uses the data URI scheme to deliver it to the browser. The decoded payload is the following piece of HTML code: Web applications that take user controlled inputs and reflect them in URI HTML placeholder without a proper validation are at risk for such an attack. An attacker could inject the previous payload that would be placed in a URI placeholder (for example in the anchor tag HREF attribute): My Link Once the victim clicks on the link, the browser will decode and execute the content from the payload. This will result on the execution of the cross-site scripting attack." + }, + { + "name": "pytm:references", + "value": "https://capec.mitre.org/data/definitions/244.html, http://cwe.mitre.org/data/definitions/83.html" + } + ], + "source": "pytm", + "weaknesses": [ + { + "cweId": 83 + } + ] + }, + { + "affectedAssets": [ + "asset-web-server" + ], + "attackPatterns": [ + "capec-245" + ], + "bom-ref": "threat-inp29", + "description": "The attacker bypasses input validation by using doubled characters in order to perform a cross-site scripting attack. Some filters fail to recognize dangerous sequences if they are preceded by repeated characters. For example, by doubling the < before a script command, (< The script itself will be invisible to anybody viewing the logs in a web browser (unless they view the source for the page)." + }, + { + "name": "pytm:references", + "value": "https://capec.mitre.org/data/definitions/268.html, https://capec.mitre.org/data/definitions/93.html" + } + ], + "source": "pytm" + }, + { + "affectedAssets": [ + "flow-request", + "flow-query", + "flow-query-2" + ], + "attackPatterns": [ + "capec-117" + ], + "bom-ref": "threat-de01", + "description": "An adversary monitors data streams to or from the target for information gathering purposes. This attack may be undertaken to solely gather sensitive information or to support a further attack against the target. This attack pattern can involve sniffing network traffic as well as other types of data streams (e.g. radio). The adversary can attempt to initiate the establishment of a data stream, influence the nature of the data transmitted, or passively observe the communications as they unfold. In all variants of this attack, the adversary is not the intended recipient of the data stream. In contrast to other means of gathering information (e.g., targeting data leaks), the adversary must actively position himself so as to observe explicit data channels (e.g. network traffic) and read the content.", + "externalReferences": [ + { + "type": "other", + "url": "https://capec.mitre.org/data/definitions/117.html" + }, + { + "type": "other", + "url": "http://cwe.mitre.org/data/definitions/319.html" + }, + { + "type": "other", + "url": "https://cwe.mitre.org/data/definitions/299.html" + } + ], + "name": "Interception", + "properties": [ + { + "name": "pytm:sid", + "value": "DE01" + }, + { + "name": "pytm:severity", + "value": "Medium" + }, + { + "name": "pytm:likelihood", + "value": "Medium" + }, + { + "name": "pytm:condition", + "value": "not target.controls.isEncrypted or (target.source.inScope and not target.isResponse and (not target.controls.authenticatesDestination or not target.controls.checksDestinationRevocation)) or target.tlsVersion < target.sink.minTLSVersion" + }, + { + "name": "pytm:mitigations", + "value": "Leverage encryption to encode the transmission of data thus making it accessible only to authorized parties." + }, + { + "name": "pytm:example", + "value": "Adversary tries to block, manipulate, and steal communications in an attempt to achieve a desired negative technical impact." + }, + { + "name": "pytm:references", + "value": "https://capec.mitre.org/data/definitions/117.html, http://cwe.mitre.org/data/definitions/319.html, https://cwe.mitre.org/data/definitions/299.html" + } + ], + "source": "pytm", + "weaknesses": [ + { + "cweId": 319 + }, + { + "cweId": 299 + } + ] + }, + { + "affectedAssets": [ + "flow-request", + "flow-query", + "flow-query-2" + ], + "attackPatterns": [ + "capec-148" + ], + "bom-ref": "threat-ac05", + "description": "An adversary modifies content to make it contain something other than what the original content producer intended while keeping the apparent source of the content unchanged. The term content spoofing is most often used to describe modification of web pages hosted by a target to display the adversary's content instead of the owner's content. However, any content can be spoofed, including the content of email messages, file transfers, or the content of other network communication protocols. Content can be modified at the source (e.g. modifying the source file for a web page) or in transit (e.g. intercepting and modifying a message between the sender and recipient). Usually, the adversary will attempt to hide the fact that the content has been modified, but in some cases, such as with web site defacement, this is not necessary. Content Spoofing can lead to malware exposure, financial fraud (if the content governs financial transactions), privacy violations, and other unwanted outcomes.", + "externalReferences": [ + { + "type": "other", + "url": "https://capec.mitre.org/data/definitions/148.html" + }, + { + "type": "other", + "url": "http://cwe.mitre.org/data/definitions/345.html" + }, + { + "type": "other", + "url": "https://cwe.mitre.org/data/definitions/299.html" + } + ], + "name": "Content Spoofing", + "properties": [ + { + "name": "pytm:sid", + "value": "AC05" + }, + { + "name": "pytm:severity", + "value": "Medium" + }, + { + "name": "pytm:likelihood", + "value": "Medium" + }, + { + "name": "pytm:condition", + "value": "((not target.source.controls.providesIntegrity or not target.sink.controls.providesIntegrity) and not target.controls.isEncrypted) or (target.source.inScope and not target.isResponse and (not target.controls.authenticatesDestination or not target.controls.checksDestinationRevocation))" + }, + { + "name": "pytm:mitigations", + "value": "Validation of user input for type, length, data-range, format, etc. Encoding any user input that will be output by the web application." + }, + { + "name": "pytm:example", + "value": "An attacker finds a site which is vulnerable to HTML Injection. He sends a URL with malicious code injected in the URL to the user of the website(victim) via email or some other social networking site. User visits the page because the page is located within trusted domain. When the victim accesses the page, the injected HTML code is rendered and presented to the user asking for username and password. The username and password are both sent to the attacker's server." + }, + { + "name": "pytm:references", + "value": "https://capec.mitre.org/data/definitions/148.html, http://cwe.mitre.org/data/definitions/345.html, https://cwe.mitre.org/data/definitions/299.html" + } + ], + "source": "pytm", + "weaknesses": [ + { + "cweId": 345 + }, + { + "cweId": 299 + } + ] + }, + { + "affectedAssets": [ + "flow-request", + "flow-query", + "flow-query-2" + ], + "attackPatterns": [ + "capec-157" + ], + "bom-ref": "threat-de03", + "description": "In this attack pattern, the adversary intercepts information transmitted between two third parties. The adversary must be able to observe, read, and/or hear the communication traffic, but not necessarily block the communication or change its content. The adversary may precipitate or indirectly influence the content of the observed transaction, but is never the intended recipient of the information. Any transmission medium can theoretically be sniffed if the adversary can examine the contents between the sender and recipient.", + "externalReferences": [ + { + "type": "other", + "url": "https://capec.mitre.org/data/definitions/157.html" + }, + { + "type": "other", + "url": "http://cwe.mitre.org/data/definitions/311.html" + } + ], + "name": "Sniffing Attacks", + "properties": [ + { + "name": "pytm:sid", + "value": "DE03" + }, + { + "name": "pytm:severity", + "value": "Medium" + }, + { + "name": "pytm:condition", + "value": "(target.protocol == 'HTTP' or target.controls.isEncrypted is False) or target.usesVPN is False" + }, + { + "name": "pytm:mitigations", + "value": "Encrypt sensitive information when transmitted on insecure mediums to prevent interception." + }, + { + "name": "pytm:example", + "value": "Attacker knows that the computer/OS/application can request new applications to install, or it periodically checks for an available update. The attacker loads the sniffer set up during Explore phase, and extracts the application code from subsequent communication. The attacker then proceeds to reverse engineer the captured code." + }, + { + "name": "pytm:references", + "value": "https://capec.mitre.org/data/definitions/157.html, http://cwe.mitre.org/data/definitions/311.html" + } + ], + "source": "pytm", + "weaknesses": [ + { + "cweId": 311 + } + ] + }, + { + "affectedAssets": [ + "flow-request", + "flow-query", + "flow-query-2" + ], + "attackPatterns": [ + "capec-216" + ], + "bom-ref": "threat-cr06", + "description": "An adversary manipulates a setting or parameter on communications channel in order to compromise its security. This can result in information exposure, insertion/removal of information from the communications stream, and/or potentially system compromise.", + "externalReferences": [ + { + "type": "other", + "url": "https://capec.mitre.org/data/definitions/216.html" + } + ], + "name": "Communication Channel Manipulation", + "properties": [ + { + "name": "pytm:sid", + "value": "CR06" + }, + { + "name": "pytm:severity", + "value": "High" + }, + { + "name": "pytm:likelihood", + "value": "Medium" + }, + { + "name": "pytm:condition", + "value": "(target.protocol != 'HTTPS' or target.usesVPN is False) and (target.controls.implementsAuthenticationScheme is False or target.controls.authorizesSource is False)" + }, + { + "name": "pytm:mitigations", + "value": "Encrypt all sensitive communications using properly-configured cryptography.Design the communication system such that it associates proper authentication/authorization with each channel/message." + }, + { + "name": "pytm:example", + "value": "Using MITM techniques, an attacker launches a blockwise chosen-boundary attack to obtain plaintext HTTP headers by taking advantage of an SSL session using an encryption protocol in CBC mode with chained initialization vectors (IV). This allows the attacker to recover session IDs, authentication cookies, and possibly other valuable data that can be used for further exploitation. Additionally this could allow for the insertion of data into the stream, allowing for additional attacks (CSRF, SQL inject, etc) to occur." + }, + { + "name": "pytm:references", + "value": "https://capec.mitre.org/data/definitions/216.html" + } + ], + "source": "pytm" + }, + { + "affectedAssets": [ + "flow-request", + "flow-query", + "flow-query-2" + ], + "attackPatterns": [ + "capec-220" + ], + "bom-ref": "threat-cr08", + "description": "An adversary takes advantage of weaknesses in the protocol by which a client and server are communicating to perform unexpected actions. Communication protocols are necessary to transfer messages between client and server applications. Moreover, different protocols may be used for different types of interactions. For example, an authentication protocol might be used to establish the identities of the server and client while a separate messaging protocol might be used to exchange data. If there is a weakness in a protocol used by the client and server, an attacker might take advantage of this to perform various types of attacks. For example, if the attacker is able to manipulate an authentication protocol, the attacker may be able spoof other clients or servers. If the attacker is able to manipulate a messaging protocol, the may be able to read sensitive information or modify message contents. This attack is often made easier by the fact that many clients and servers support multiple protocols to perform similar roles. For example, a server might support several different authentication protocols in order to support a wide range of clients, including legacy clients. Some of the older protocols may have vulnerabilities that allow an attacker to manipulate client-server interactions.", + "externalReferences": [ + { + "type": "other", + "url": "https://capec.mitre.org/data/definitions/220.html" + }, + { + "type": "other", + "url": "http://cwe.mitre.org/data/definitions/757.html" + } + ], + "name": "Client-Server Protocol Manipulation", + "properties": [ + { + "name": "pytm:sid", + "value": "CR08" + }, + { + "name": "pytm:severity", + "value": "Medium" + }, + { + "name": "pytm:likelihood", + "value": "Medium" + }, + { + "name": "pytm:condition", + "value": "not target.controls.isEncrypted or target.tlsVersion < target.sink.minTLSVersion" + }, + { + "name": "pytm:mitigations", + "value": "Use strong authentication protocols." + }, + { + "name": "pytm:example", + "value": "An adversary could exploit existing communication protocol vulnerabilities and can launch MITM attacks and gain sensitive information or spoof client/server identities." + }, + { + "name": "pytm:references", + "value": "https://capec.mitre.org/data/definitions/220.html, http://cwe.mitre.org/data/definitions/757.html" + } + ], + "source": "pytm", + "weaknesses": [ + { + "cweId": 757 + } + ] + }, + { + "affectedAssets": [ + "flow-request", + "flow-query" + ], + "bom-ref": "threat-ds06", + "description": "An attacker can access data in transit or at rest that is not sufficiently protected. If an attacker can decrypt a stored password, it might be used to authenticate against different services.", + "externalReferences": [ + { + "type": "other", + "url": "https://cwe.mitre.org/data/definitions/311.html" + }, + { + "type": "other", + "url": "https://cwe.mitre.org/data/definitions/312.html" + }, + { + "type": "other", + "url": "https://cwe.mitre.org/data/definitions/916.html" + }, + { + "type": "other", + "url": "https://cwe.mitre.org/data/definitions/653.html" + } + ], + "name": "Data Leak", + "properties": [ + { + "name": "pytm:sid", + "value": "DS06" + }, + { + "name": "pytm:severity", + "value": "Very High" + }, + { + "name": "pytm:likelihood", + "value": "High" + }, + { + "name": "pytm:condition", + "value": "target.hasDataLeaks()" + }, + { + "name": "pytm:mitigations", + "value": "All data should be encrypted in transit. All PII and restricted data must be encrypted at rest. If a service is storing credentials used to authenticate users or incoming connections, it must only store hashes of them created using cryptographic functions, so it is only possible to compare them against user input, without fully decoding them. If a client is storing credentials in either files or other data store, access to them must be as restrictive as possible, including using proper file permissions, database users with restricted access or separate storage." + }, + { + "name": "pytm:example", + "value": "An application, which connects to a database without TLS, performs a database query in which it compares the password to a stored hash, instead of fetching the hash and comparing it locally." + }, + { + "name": "pytm:references", + "value": "https://cwe.mitre.org/data/definitions/311.html, https://cwe.mitre.org/data/definitions/312.html, https://cwe.mitre.org/data/definitions/916.html, https://cwe.mitre.org/data/definitions/653.html" + } + ], + "source": "pytm", + "weaknesses": [ + { + "cweId": 311 + }, + { + "cweId": 312 + }, + { + "cweId": 916 + }, + { + "cweId": 653 + } + ] + }, + { + "affectedAssets": [ + "flow-request", + "flow-query" + ], + "bom-ref": "threat-dr01", + "description": "An attacker can access data in transit or at rest that is not sufficiently protected. If an attacker can decrypt a stored password, it might be used to authenticate against different services.", + "externalReferences": [ + { + "type": "other", + "url": "https://cwe.mitre.org/data/definitions/311.html" + }, + { + "type": "other", + "url": "https://cwe.mitre.org/data/definitions/312.html" + }, + { + "type": "other", + "url": "https://cwe.mitre.org/data/definitions/916.html" + }, + { + "type": "other", + "url": "https://cwe.mitre.org/data/definitions/653.html" + } + ], + "name": "Unprotected Sensitive Data", + "properties": [ + { + "name": "pytm:sid", + "value": "DR01" + }, + { + "name": "pytm:severity", + "value": "High" + }, + { + "name": "pytm:likelihood", + "value": "Low" + }, + { + "name": "pytm:condition", + "value": "(target.hasDataLeaks() or any(d.isCredentials or d.isPII for d in target.data)) and (not target.controls.isEncrypted or (not target.isResponse and any(d.isStored and d.isDestEncryptedAtRest for d in target.data)) or (target.isResponse and any(d.isStored and d.isSourceEncryptedAtRest for d in target.data)))" + }, + { + "name": "pytm:mitigations", + "value": "All data should be encrypted in transit. All PII and restricted data must be encrypted at rest. If a service is storing credentials used to authenticate users or incoming connections, it must only store hashes of them created using cryptographic functions, so it is only possible to compare them against user input, without fully decoding them. If a client is storing credentials in either files or other data store, access to them must be as restrictive as possible, including using proper file permissions, database users with restricted access or separate storage." + }, + { + "name": "pytm:references", + "value": "https://cwe.mitre.org/data/definitions/311.html, https://cwe.mitre.org/data/definitions/312.html, https://cwe.mitre.org/data/definitions/916.html, https://cwe.mitre.org/data/definitions/653.html" + } + ], + "source": "pytm", + "weaknesses": [ + { + "cweId": 311 + }, + { + "cweId": 312 + }, + { + "cweId": 916 + }, + { + "cweId": 653 + } + ] + }, + { + "affectedAssets": [ + "flow-request", + "flow-query" + ], + "bom-ref": "threat-ac23", + "description": "If credentials (passwords or certificates) have a long lifetime their disclosure can have severe consequences, if the credentials cannot quickly be revoked and/or rotated.", + "externalReferences": [ + { + "type": "other", + "url": "https://pages.nist.gov/800-63-3/sp800-63b.html#sec6" + } + ], + "name": "Credentials Disclosure", + "properties": [ + { + "name": "pytm:sid", + "value": "AC23" + }, + { + "name": "pytm:severity", + "value": "High" + }, + { + "name": "pytm:likelihood", + "value": "Medium" + }, + { + "name": "pytm:condition", + "value": "any(d.isCredentials for d in target.data) and target.sink.inScope and any(d.credentialsLife in (Lifetime.UNKNOWN, Lifetime.LONG, Lifetime.MANUAL) for d in target.data)" + }, + { + "name": "pytm:mitigations", + "value": "Long living credentials need to have high entropy and length to be future proof, especially if it is unknwon how long these credentials will be used. Further should there be a mechanism to revoke the credentials immediately if a disclosure is suspected. To detect disclosure of the credentials their use should be monitored for suspicions activity." + }, + { + "name": "pytm:references", + "value": "https://pages.nist.gov/800-63-3/sp800-63b.html#sec6" + } + ], + "source": "pytm" + }, + { + "affectedAssets": [ + "flow-query" + ], + "attackPatterns": [ + "capec-102" + ], + "bom-ref": "threat-cr01", + "description": "Session sidejacking takes advantage of an unencrypted communication channel between a victim and target system. The attacker sniffs traffic on a network looking for session tokens in unencrypted traffic. Once a session token is captured, the attacker performs malicious actions by using the stolen token with the targeted application to impersonate the victim. This attack is a specific method of session hijacking, which is exploiting a valid session token to gain unauthorized access to a target system or information. Other methods to perform a session hijacking are session fixation, cross-site scripting, or compromising a user or server machine and stealing the session token.", + "externalReferences": [ + { + "type": "other", + "url": "https://capec.mitre.org/data/definitions/102.html" + }, + { + "type": "other", + "url": "http://cwe.mitre.org/data/definitions/294.html" + }, + { + "type": "other", + "url": "http://cwe.mitre.org/data/definitions/614.html" + }, + { + "type": "other", + "url": "http://cwe.mitre.org/data/definitions/319.html" + }, + { + "type": "other", + "url": "http://cwe.mitre.org/data/definitions/523.html" + }, + { + "type": "other", + "url": "http://cwe.mitre.org/data/definitions/522.html" + } + ], + "name": "Session Sidejacking", + "properties": [ + { + "name": "pytm:sid", + "value": "CR01" + }, + { + "name": "pytm:severity", + "value": "High" + }, + { + "name": "pytm:likelihood", + "value": "High" + }, + { + "name": "pytm:condition", + "value": "(target.protocol == 'HTTP' or target.usesVPN is False) and target.usesSessionTokens is True" + }, + { + "name": "pytm:mitigations", + "value": "Make sure that HTTPS is used to communicate with the target system. Alternatively, use VPN if possible. It is important to ensure that all communication between the client and the server happens via an encrypted secure channel. Modify the session token with each transmission and protect it with cryptography. Add the idea of request sequencing that gives the server an ability to detect replay attacks." + }, + { + "name": "pytm:example", + "value": "The attacker and the victim are using the same WiFi public hotspot. When the victim connects to the hotspot, he has a hosted e-mail account open. This e-mail account uses AJAX on the client side which periodically asynchronously connects to the server side and transfers, amongst other things, the user's session token to the server. The communication is supposed to happen over HTTPS. However, the configuration in the public hotspot initially disallows the HTTPS connection (or any other connection) between the victim and the hosted e-mail servers because the victim first needs to register with the hotspot. The victim does so, but his e-mail client already defaulted to using a connection without HTTPS, since it was denied access the first time. Victim's session token is now flowing unencrypted between the victim's browser and the hosted e-mail servers. The attacker leverages this opportunity to capture the session token and gain access to the victim's hosted e-mail account." + }, + { + "name": "pytm:references", + "value": "https://capec.mitre.org/data/definitions/102.html, http://cwe.mitre.org/data/definitions/294.html, http://cwe.mitre.org/data/definitions/614.html, http://cwe.mitre.org/data/definitions/319.html, http://cwe.mitre.org/data/definitions/523.html, http://cwe.mitre.org/data/definitions/522.html" + } + ], + "source": "pytm", + "weaknesses": [ + { + "cweId": 294 + }, + { + "cweId": 614 + }, + { + "cweId": 319 + }, + { + "cweId": 523 + }, + { + "cweId": 522 + } + ] + }, + { + "affectedAssets": [ + "asset-web-server" + ], + "attackPatterns": [ + "capec-114" + ], + "bom-ref": "threat-aa01", + "description": "An attacker obtains unauthorized access to an application, service or device either through knowledge of the inherent weaknesses of an authentication mechanism, or by exploiting a flaw in the authentication scheme's implementation. In such an attack an authentication mechanism is functioning but a carefully controlled sequence of events causes the mechanism to grant access to the attacker. This attack may exploit assumptions made by the target's authentication procedures, such as assumptions regarding trust relationships or assumptions regarding the generation of secret values. This attack differs from Authentication Bypass attacks in that Authentication Abuse allows the attacker to be certified as a valid user through illegitimate means, while Authentication Bypass allows the user to access protected material without ever being certified as an authenticated user. This attack does not rely on prior sessions established by successfully authenticating users, as relied upon for the Exploitation of Session Variables, Resource IDs and other Trusted Credentials attack patterns.", + "externalReferences": [ + { + "type": "other", + "url": "https://capec.mitre.org/data/definitions/114.html" + }, + { + "type": "other", + "url": "http://cwe.mitre.org/data/definitions/287.html" + } + ], + "name": "Authentication Abuse/ByPass", + "properties": [ + { + "name": "pytm:sid", + "value": "AA01" + }, + { + "name": "pytm:severity", + "value": "Medium" + }, + { + "name": "pytm:condition", + "value": "target.controls.authenticatesSource is False" + }, + { + "name": "pytm:mitigations", + "value": "Use strong authentication and authorization mechanisms. A proven protocol is OAuth 2.0, which enables a third-party application to obtain limited access to an API." + }, + { + "name": "pytm:example", + "value": "An adversary that has previously obtained unauthorized access to certain device resources, uses that access to obtain information such as location and network information." + }, + { + "name": "pytm:references", + "value": "https://capec.mitre.org/data/definitions/114.html, http://cwe.mitre.org/data/definitions/287.html" + } + ], + "source": "pytm", + "weaknesses": [ + { + "cweId": 287 + } + ] + } + ] + }, + "version": 1 +} From 272988346d3309c6ec2877bb45442517c5a8cb7b Mon Sep 17 00:00:00 2001 From: izar Date: Tue, 13 Oct 2026 13:38:53 -0400 Subject: [PATCH 3/5] feat: list Control objects in report templates and the sample Expose the declared controls to report templates as "controls" and add a Controls section to docs/basic_template.md, docs/advanced_template.md and docs/reveal.md. The section is omitted when no controls are declared, so reports for existing models are unchanged. Add a global and a model-element-scoped Control to the sample tm.py. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 3 +++ docs/advanced_template.md | 16 +++++++++++ docs/basic_template.md | 12 ++++++++- docs/reveal.md | 19 +++++++++++++ pytm/tm.py | 1 + tests/test_report_controls.py | 50 +++++++++++++++++++++++++++++++++++ tm.py | 24 +++++++++++++++++ 7 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 tests/test_report_controls.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a59806a..9dfaaa4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ below). - `Threat`/`Finding` attributes `stride` (default `unassigned`), `impact`, `capec`, `cwe`. - `Actor.kind` and `Dataflow.isEncrypted`. +- `controls` is available to report templates; `docs/basic_template.md`, + `advanced_template.md` and `reveal.md` now list `Control` objects (the section + is omitted when there are none). ## [1.4.0] - 2026-05-21 diff --git a/docs/advanced_template.md b/docs/advanced_template.md index 9d4e340e..b5c8029b 100644 --- a/docs/advanced_template.md +++ b/docs/advanced_template.md @@ -156,6 +156,22 @@ Finding Count|{{item:call:getFindingCount}}| } +{controls:if: +## Controls +} + +{controls:repeat: +Name|{{item.name}} +|:----|:----| +Description|{{item.description}}| +Category|{{item.category.name}}| +Status|{{item.status.name}}| +Effectiveness|{{item.effectiveness}}| +Mitigates|{{item.mitigates:repeat:{{{{item}}}}
}}| +Applies To|{{item.appliesTo:repeat:{{{{item.name}}}}
}}{{item.appliesTo:not:whole model}}| + +} + {tm.excluded_findings:if: # Excluded Threats } diff --git a/docs/basic_template.md b/docs/basic_template.md index 31c30a66..5956b066 100644 --- a/docs/basic_template.md +++ b/docs/basic_template.md @@ -44,7 +44,17 @@ Name|Description|Classification   -## Potential Threats +{controls:if:## Controls +  + +Name|Category|Status|Mitigates|Applies To +|:----:|:----:|:----:|:----:|:----:| +{controls:repeat:|{{item.name}}|{{item.category.name}}|{{item.status.name}}|{{item.mitigates:repeat:{{{{item}}}}
}}|{{item.appliesTo:repeat:{{{{item.name}}}}
}}{{item.appliesTo:not:whole model}}| +} + +  + +}## Potential Threats     diff --git a/docs/reveal.md b/docs/reveal.md index 61a9c876..15f28135 100644 --- a/docs/reveal.md +++ b/docs/reveal.md @@ -167,3 +167,22 @@ ---- }} } + +{controls:if:--- + +## Controls + +---- + +{controls:repeat: +- **name** : {{item.name}} +- **description** : {{item.description}} +- **category** : {{item.category.name}} +- **status** : {{item.status.name}} +- **effectiveness** : {{item.effectiveness}} +- **mitigates** : {{item.mitigates:repeat:{{{{item}}}}
}} +- **applies to** : {{item.appliesTo:repeat:{{{{item.name}}}}
}}{{item.appliesTo:not:whole model}} + +---- +} +} diff --git a/pytm/tm.py b/pytm/tm.py index 743d201b..650e48b3 100644 --- a/pytm/tm.py +++ b/pytm/tm.py @@ -766,6 +766,7 @@ def encode_element_threat_data(elements): "actors": actors, "boundaries": boundaries, "data": TM._data, + "controls": TM._controls, } if not hasattr(self, "_sf") or self._sf is None: diff --git a/tests/test_report_controls.py b/tests/test_report_controls.py new file mode 100644 index 00000000..9c0c65dd --- /dev/null +++ b/tests/test_report_controls.py @@ -0,0 +1,50 @@ +"""The report templates under docs/ list Control objects.""" + +import pytest + +from pytm import TM, Control, ControlCategory, ControlStatus, Dataflow, Server + +TEMPLATES = ["basic_template", "advanced_template", "reveal"] + + +@pytest.fixture(autouse=True) +def reset_tm(): + TM.reset() + yield + TM.reset() + + +def make_tm() -> tuple[TM, Server]: + tm = TM("rep", description="d", threatsFile="pytm/threatlib/threats.json") + web, other = Server("Web"), Server("Other") + Dataflow(web, other, "Call") + return tm, web + + +@pytest.mark.parametrize("template", TEMPLATES) +def test_controls_are_rendered(template): + tm, web = make_tm() + Control( + "Input filter", + category=ControlCategory.PREVENTIVE, + status=ControlStatus.IMPLEMENTED, + mitigates=["INP03", "INP05"], + appliesTo=web, + ) + Control("Global WAF") + tm.check() + tm.resolve() + report = tm.report(f"docs/{template}.md") + assert "Input filter" in report + assert "PREVENTIVE" in report and "IMPLEMENTED" in report + assert "INP03
INP05
" in report + assert "Web
" in report + assert "Global WAF" in report and "whole model" in report + + +@pytest.mark.parametrize("template", TEMPLATES) +def test_no_controls_section_without_controls(template): + tm, _ = make_tm() + tm.check() + tm.resolve() + assert "Controls" not in tm.report(f"docs/{template}.md") diff --git a/tm.py b/tm.py index ed943d3c..295e31e8 100755 --- a/tm.py +++ b/tm.py @@ -6,6 +6,9 @@ Agent, Boundary, Classification, + Control, + ControlCategory, + ControlStatus, Data, Dataflow, Datastore, @@ -189,5 +192,26 @@ processedBy=[db, secretDb], ) +# A Control declares a safeguard and the threats (by SID) it mitigates. +# Without appliesTo it covers the whole model... +tls_everywhere = Control( + "TLS on all network links", + description="Every flow between components is encrypted in transit.", + category=ControlCategory.PREVENTIVE, + status=ControlStatus.PLANNED, + mitigates=["DE01", "DE03"], +) + +# ...with appliesTo it only protects the listed elements. +parameterized_queries = Control( + "Parameterized SQL queries", + description="The web server never builds SQL by string concatenation.", + category=ControlCategory.PREVENTIVE, + status=ControlStatus.IMPLEMENTED, + mitigates=["INP05"], + appliesTo=[web], + effectiveness="good", +) + if __name__ == "__main__": tm.process() From 18e7d89d9b2f344aaa5180ebabe20fb072c81af4 Mon Sep 17 00:00:00 2001 From: izar Date: Tue, 13 Oct 2026 13:52:32 -0400 Subject: [PATCH 4/5] chore: ignore local tooling and working files Co-Authored-By: Claude Sonnet 5.5 --- .gitignore | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.gitignore b/.gitignore index e94fe54d..76819a17 100644 --- a/.gitignore +++ b/.gitignore @@ -139,3 +139,11 @@ devbox.lock # Downloaded CycloneDX schema (tests/fetch_cyclonedx_schema.sh) tests/schemas/ + +# Local tooling and working files +.codex/ +.mcp.json +.vscode/ +AGENTS.md +tasks/ +uv.lock From 632613cd7442bf8c45feef4870dfef3c34491fcd Mon Sep 17 00:00:00 2001 From: izar Date: Tue, 13 Oct 2026 18:16:53 -0400 Subject: [PATCH 5/5] fix: emit data stores as assets so diagram tools draw them Flow endpoints in a TM-BOM are assets or actors, but data stores were only emitted in the dataStores detail section, so flows to and from them pointed at records that diagram tools do not draw. Tools such as Precogly dropped the stores and every flow touching them. Emit each Datastore as an asset of type data-store as well, make flows, zones, threats, risks and controls reference that asset, and keep the dataStores record for the storage detail, linked to the asset by a relationships entry. Data set placements still point at the dataStores record. Co-Authored-By: Claude Sonnet 5.5 --- docs/tmbom.md | 2 +- pytm/tmbom.py | 29 ++++++++++++++++++++++----- tests/test_tmbom.py | 21 ++++++++++++++++++++ tests/tmbom_output.json | 44 +++++++++++++++++++++++++++++++---------- 4 files changed, 80 insertions(+), 16 deletions(-) diff --git a/docs/tmbom.md b/docs/tmbom.md index e8c5616b..e1e8db46 100644 --- a/docs/tmbom.md +++ b/docs/tmbom.md @@ -68,7 +68,7 @@ optional and default to "unknown", in which case the field is omitted. | `Agent` | `assets[]` (`type: agent`) | as above | AI-specific flags not translated. | | `LLM` | `assets[]` (`type: model`) | as above | AI-specific flags not translated. | | other `Asset` subclasses | `assets[]` (`type: component`) | as above | Fallback. | -| `Datastore` | `blueprints[].dataStores[]` | `name`, `description`, `inBoundary`→`zone`, `type`, `dataSets`, `isSQL`, `isShared`, `onRDS`, plus the asset properties above | `DatastoreType` mapping: `SQL`→`relational`, `FILE_SYSTEM`→`file`, `LDAP`→`hierarchical`, `AWS_S3`→`object`, `UNKNOWN`→custom `{name: "unknown"}` (type is mandatory). `isSQL` defaults to true in pytm, so it is always emitted even for non-SQL types and may contradict `type`. `dataSets` is *inferred*: the `Data` carried by flows whose sink is this store; pytm's own `Data.processedBy` is not used. | +| `Datastore` | `blueprints[].assets[]` (`type: data-store`) **and** `blueprints[].dataStores[]`, linked by `relationships[]` (`associates`) | `name`, `description`, `inBoundary`→`zone`, `type`, `dataSets`, `isSQL`, `isShared`, `onRDS`, plus the asset properties above | `DatastoreType` mapping: `SQL`→`relational`, `FILE_SYSTEM`→`file`, `LDAP`→`hierarchical`, `AWS_S3`→`object`, `UNKNOWN`→custom `{name: "unknown"}` (type is mandatory). The asset is what flows, zones, controls, threats and risks reference (the schema says flow endpoints are assets or actors, and diagram tools such as Precogly draw only assets); the `dataStores` record carries the storage detail and is what `placements` point at. The two records duplicate the name and zone. `isSQL` defaults to true in pytm, so it is always emitted even for non-SQL types and may contradict `type`. `dataSets` is *inferred*: the `Data` carried by flows whose sink is this store; pytm's own `Data.processedBy` is not used. | | `Data` | `blueprints[].dataSets[]` | `name`, `description` (falls back to `name`, schema requires non-empty), `classification`, `format`, `isPII`, `isCredentials`, `credentialsLife`, `isStored` → `pytm:*`; at-rest encryption → `placements` | The data-set schema has no classification field, so classification is a property here (and a real field on assets). Booleans are emitted only when true. | | `Dataflow` | `blueprints[].flows[]` (`type: data`) | `name`; `description`; `source`/`sink`→`source`/`destination`; `protocol`→`protocols[0]`; `tlsVersion`≠NONE→`encrypted: true`; `order`→`sequence`; `isEncrypted`→`encrypted`; `usesSessionTokens`→`authentication`; `response`→`synchronous`; `data`→`dataObjects[]` (`name`, `description`); `note`, `srcPort`, `dstPort`, `tlsVersion` (name), `usesVPN`, `isResponse`, `responseTo` (flow ref), `inScope`, enabled controls → `pytm:*` | `encrypted` comes from `Dataflow.isEncrypted` when set; otherwise it is `true` when a TLS version is set and omitted else, because "no TLS" ≠ "known unencrypted" (a non-TLS protocol such as SSH would be mis-labelled). Responses are ordinary flows; the link is only `pytm:responseTo`. `dataObjects` are inline copies linked to data sets by *name only*, not by ref. | | `Assumption` (`TM.assumptions`, `Element.assumptions`) | `blueprints[].assumptions[]` | `name` + `description`→`description` (`"name: description"` when both); `exclude`→`impact` (`"Excludes threats: A, B"`); owning element→`relatedAssets` | The schema has no structure for "excludes threat X", so it is free text in `impact`. Element-level assumptions reference their element; global ones have none. Assumptions attached to flows are included; those attached to boundaries are not. An assumption with neither name nor description is dropped. | diff --git a/pytm/tmbom.py b/pytm/tmbom.py index d4ffb298..0b4efd48 100644 --- a/pytm/tmbom.py +++ b/pytm/tmbom.py @@ -4,7 +4,9 @@ ``CycloneDX/specification`` commit :data:`SCHEMA_COMMIT`: * ``blueprints``: zones (pytm ``Boundary``), boundaries crossed by flows, assets, - actors, data stores, data sets, flows and assumptions. + actors, data stores, data sets, flows and assumptions. A ``Datastore`` is both + an asset of type ``data-store`` (what flows connect to, and what diagram tools + draw) and a ``dataStores`` detail record, tied together by a relationship. * ``threats``: one entry per threat library SID, pointing at the affected assets, plus the CAPEC attack patterns they reference. * ``risks``: one entry per finding (including findings excluded by assumptions). @@ -60,6 +62,7 @@ ExternalEntity: "system", Lambda: "function", LLM: "model", + Datastore: "data-store", Process: "process", Server: "service", Asset: "component", @@ -143,6 +146,7 @@ class _Refs: def __init__(self) -> None: self._by_id: dict[int, str] = {} + self._detail: dict[int, str] = {} self._used: set[str] = set() def reserve(self, ref: str) -> None: @@ -159,6 +163,17 @@ def add(self, kind: str, obj: Any, name: str) -> str: self._by_id[id(obj)] = ref return ref + def add_detail(self, kind: str, obj: Any, name: str) -> str: + """Second ref for ``obj``: the structured-detail record of the same thing.""" + ref = _unused_ref(f"{kind}-{_slug(name)}", self._used) + self._used.add(ref) + self._detail[id(obj)] = ref + return ref + + def detail(self, obj: Any) -> str | None: + """Ref of the structured-detail record created by :meth:`add_detail`.""" + return self._detail.get(id(obj)) + def get(self, obj: Any) -> str | None: """Return the ref previously assigned to ``obj``, if any.""" return self._by_id.get(id(obj)) @@ -337,7 +352,7 @@ def _build_data_store( carried = _unique(d for f in flows if f.sink is store for d in f.data) return _clean( { - "bom-ref": refs.get(store), + "bom-ref": refs.detail(store), "name": store.name, "description": store.description, "type": store_type or {"name": "unknown"}, @@ -371,7 +386,7 @@ def _placements(data: Data, flows: list[Dataflow], refs: _Refs) -> list[dict]: data.isSourceEncryptedAtRest ) return [ - _clean({"dataStore": refs.get(store), "encrypted": encrypted[key] or None}) + _clean({"dataStore": refs.detail(store), "encrypted": encrypted[key] or None}) for key, store in stores.items() ] @@ -689,7 +704,8 @@ def build_tmbom(tm: Any, *, serial: str, timestamp: str, tool_version: str) -> d for e in assets: refs.add("asset", e, e.name) for s in stores: - refs.add("datastore", s, s.name) + refs.add("asset", s, s.name) + refs.add_detail("datastore", s, s.name) for d in data: refs.add("dataset", d, d.name) for f in flows: @@ -714,10 +730,13 @@ def build_tmbom(tm: Any, *, serial: str, timestamp: str, tool_version: str) -> d "zones": _build_zones(boundaries, refs, unbounded), "boundaries": _build_boundaries(boundaries, flows, refs, unbounded), "actors": [_build_actor(e, refs, unbounded) for e in actors], - "assets": [_build_asset(e, refs, unbounded) for e in assets], + "assets": [_build_asset(e, refs, unbounded) for e in [*assets, *stores]], "dataStores": [ _build_data_store(s, flows, refs, unbounded) for s in stores ], + "relationships": [ + {"ref": refs.get(s), "associates": [refs.detail(s)]} for s in stores + ], "dataSets": [_build_data_set(d, flows, refs) for d in data], "flows": [_build_flow(f, refs, unbounded) for f in flows], "assumptions": _build_assumptions(tm, [*placed, *flows], refs), diff --git a/tests/test_tmbom.py b/tests/test_tmbom.py index 15dcdddf..9866b18d 100644 --- a/tests/test_tmbom.py +++ b/tests/test_tmbom.py @@ -50,6 +50,8 @@ "controls", "mitigations", "attackPatterns", + "ref", + "associates", } @@ -158,6 +160,25 @@ def test_refs_resolve_and_are_unique(validator): assert json.dumps(doc).count('"bom-ref"') == len(defined) +def test_flow_endpoints_are_assets_or_actors(): + """Diagram tools draw flows only between assets, so stores must be assets.""" + blueprint = build(make_model())["blueprints"][0] + nodes = {a["bom-ref"] for a in blueprint["assets"] + blueprint["actors"]} + for flow in blueprint["flows"]: + assert flow["source"] in nodes and flow["destination"] in nodes + + +def test_datastore_is_asset_and_detail_record(validator): + blueprint = build(make_model())["blueprints"][0] + (asset,) = [a for a in blueprint["assets"] if a["name"] == "SQL Database"] + (detail,) = [d for d in blueprint["dataStores"] if d["name"] == "SQL Database"] + assert asset["type"] == "data-store" + assert asset["bom-ref"] != detail["bom-ref"] + assert {"ref": asset["bom-ref"], "associates": [detail["bom-ref"]]} in blueprint[ + "relationships" + ] + + def test_duplicate_flow_names_get_distinct_refs(): flows = build(make_model())["blueprints"][0]["flows"] refs = [f["bom-ref"] for f in flows if f["name"] == "Query"] diff --git a/tests/tmbom_output.json b/tests/tmbom_output.json index b3704ecc..77215844 100644 --- a/tests/tmbom_output.json +++ b/tests/tmbom_output.json @@ -44,6 +44,22 @@ ], "type": "service", "zone": "zone-vpc" + }, + { + "bom-ref": "asset-sql-database", + "name": "SQL Database", + "properties": [ + { + "name": "pytm:inScope", + "value": "true" + }, + { + "name": "pytm:controls.isEncryptedAtRest", + "value": "true" + } + ], + "type": "data-store", + "zone": "zone-vpc" } ], "assumptions": [ @@ -172,7 +188,7 @@ "name": "auth cookie" } ], - "destination": "datastore-sql-database", + "destination": "asset-sql-database", "name": "Query", "properties": [ { @@ -208,7 +224,7 @@ } ], "sequence": 3, - "source": "datastore-sql-database", + "source": "asset-sql-database", "type": "data" } ], @@ -216,6 +232,14 @@ "data-flow" ], "name": "my test tm", + "relationships": [ + { + "associates": [ + "datastore-sql-database" + ], + "ref": "asset-sql-database" + } + ], "zones": [ { "bom-ref": "zone-internet", @@ -1613,7 +1637,7 @@ }, { "affects": [ - "datastore-sql-database" + "asset-sql-database" ], "bom-ref": "risk-44", "domains": [ @@ -1641,7 +1665,7 @@ }, { "affects": [ - "datastore-sql-database" + "asset-sql-database" ], "bom-ref": "risk-45", "domains": [ @@ -1672,7 +1696,7 @@ }, { "affects": [ - "datastore-sql-database" + "asset-sql-database" ], "bom-ref": "risk-46", "domains": [ @@ -1703,7 +1727,7 @@ }, { "affects": [ - "datastore-sql-database" + "asset-sql-database" ], "bom-ref": "risk-47", "domains": [ @@ -2989,7 +3013,7 @@ { "affectedAssets": [ "asset-web-server", - "datastore-sql-database" + "asset-sql-database" ], "attackPatterns": [ "capec-122" @@ -3171,7 +3195,7 @@ { "affectedAssets": [ "asset-web-server", - "datastore-sql-database" + "asset-sql-database" ], "attackPatterns": [ "capec-130" @@ -4190,7 +4214,7 @@ { "affectedAssets": [ "asset-web-server", - "datastore-sql-database" + "asset-sql-database" ], "attackPatterns": [ "capec-20" @@ -5255,7 +5279,7 @@ }, { "affectedAssets": [ - "datastore-sql-database" + "asset-sql-database" ], "attackPatterns": [ "capec-268",