threatwright 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. threatwright/__init__.py +3 -0
  2. threatwright/__main__.py +3 -0
  3. threatwright/arc.py +295 -0
  4. threatwright/assistant.py +290 -0
  5. threatwright/branding.py +4 -0
  6. threatwright/catalogs/attack-ics.yaml +87 -0
  7. threatwright/catalogs/cra.yaml +107 -0
  8. threatwright/catalogs/emb3d.yaml +90 -0
  9. threatwright/catalogs/iec62443.yaml +16 -0
  10. threatwright/ci.py +158 -0
  11. threatwright/ci_templates/github-actions.yml.j2 +95 -0
  12. threatwright/ci_templates/gitlab-ci.yml.j2 +69 -0
  13. threatwright/cli.py +370 -0
  14. threatwright/diagram.py +110 -0
  15. threatwright/interop/__init__.py +11 -0
  16. threatwright/interop/export.py +196 -0
  17. threatwright/interop/importers.py +411 -0
  18. threatwright/libraries/ot/elements.yaml +501 -0
  19. threatwright/libraries/ot/library.yaml +19 -0
  20. threatwright/libraries/ot/measures/data.yaml +170 -0
  21. threatwright/libraries/ot/measures/identity.yaml +468 -0
  22. threatwright/libraries/ot/measures/integrity.yaml +694 -0
  23. threatwright/libraries/ot/measures/logging.yaml +114 -0
  24. threatwright/libraries/ot/measures/network.yaml +361 -0
  25. threatwright/libraries/ot/measures/organisational.yaml +328 -0
  26. threatwright/libraries/ot/measures/remote.yaml +281 -0
  27. threatwright/libraries/ot/measures/safety.yaml +148 -0
  28. threatwright/libraries/ot/measures/supply-chain.yaml +167 -0
  29. threatwright/libraries/ot/threats/field.yaml +1461 -0
  30. threatwright/libraries/ot/threats/host.yaml +1568 -0
  31. threatwright/libraries/ot/threats/network.yaml +1434 -0
  32. threatwright/library.py +296 -0
  33. threatwright/method.py +296 -0
  34. threatwright/methods/tr-03183-1/method.yaml +223 -0
  35. threatwright/model/__init__.py +0 -0
  36. threatwright/model/objects.py +246 -0
  37. threatwright/model/refactor.py +54 -0
  38. threatwright/model/schema.py +31 -0
  39. threatwright/model/store.py +326 -0
  40. threatwright/oscal/__init__.py +19 -0
  41. threatwright/oscal/imports.py +393 -0
  42. threatwright/oscal/project.py +22 -0
  43. threatwright/oscal/schemas/1.2.3/README.md +32 -0
  44. threatwright/oscal/schemas/1.2.3/oscal_catalog_schema.json +1295 -0
  45. threatwright/oscal/schemas/1.2.3/oscal_complete_schema.json +6022 -0
  46. threatwright/oscal/schemas/1.2.3/oscal_component_schema.json +1821 -0
  47. threatwright/oscal/schemas/1.2.3/oscal_profile_schema.json +1616 -0
  48. threatwright/oscal/validate.py +150 -0
  49. threatwright/render/__init__.py +30 -0
  50. threatwright/render/build.py +316 -0
  51. threatwright/render/context.py +579 -0
  52. threatwright/render/env.py +89 -0
  53. threatwright/render/labels.py +247 -0
  54. threatwright/render/odt.py +296 -0
  55. threatwright/render/package.py +217 -0
  56. threatwright/render/pdf.py +66 -0
  57. threatwright/render/sections.py +475 -0
  58. threatwright/render/word.py +162 -0
  59. threatwright/report.py +96 -0
  60. threatwright/rules.py +382 -0
  61. threatwright/scaffold.py +71 -0
  62. threatwright/server.py +940 -0
  63. threatwright/templates/_shared/document.html.j2 +69 -0
  64. threatwright/templates/_shared/document.md.j2 +47 -0
  65. threatwright/templates/acceptance-criteria/template.docx +0 -0
  66. threatwright/templates/acceptance-criteria/template.md +2 -0
  67. threatwright/templates/acceptance-criteria/template.odt +0 -0
  68. threatwright/templates/acceptance-criteria/template.yaml +41 -0
  69. threatwright/templates/tr-analysis/template.docx +0 -0
  70. threatwright/templates/tr-analysis/template.html +2 -0
  71. threatwright/templates/tr-analysis/template.md +2 -0
  72. threatwright/templates/tr-analysis/template.odt +0 -0
  73. threatwright/templates/tr-analysis/template.yaml +29 -0
  74. threatwright/templates/tr-analysis-compact/template.docx +0 -0
  75. threatwright/templates/tr-analysis-compact/template.yaml +47 -0
  76. threatwright/vcs.py +349 -0
  77. threatwright/web/assets/elk.bundled-B7GIUaPm.js +24 -0
  78. threatwright/web/assets/index-CQEumOmL.css +1 -0
  79. threatwright/web/assets/index-CQtGvHdl.js +13 -0
  80. threatwright/web/index.html +13 -0
  81. threatwright-0.1.0.dist-info/METADATA +201 -0
  82. threatwright-0.1.0.dist-info/RECORD +85 -0
  83. threatwright-0.1.0.dist-info/WHEEL +4 -0
  84. threatwright-0.1.0.dist-info/entry_points.txt +2 -0
  85. threatwright-0.1.0.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1,3 @@
1
+ """Threatwright: threat modelling and risk analysis after BSI TR-03183-1."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from threatwright.cli import app
2
+
3
+ app()
threatwright/arc.py ADDED
@@ -0,0 +1,295 @@
1
+ """Applicability of Adaptable Risk-based Controls (ARC), BSI TR-03183-1 §6 (p. 54-57).
2
+
3
+ A control carries one or more risk scenarios (§6.1, p. 54): prose, affected
4
+ assets with impact, and an environment (access restriction, interface
5
+ restriction, user capability). Per §6.2 (p. 54) a control applies to the PwDE
6
+ or a part of it if that part handles an asset with at least the listed impact
7
+ and expects one of the listed values for every environment parameter ("*"
8
+ matches any value).
9
+
10
+ The BSI's OSCAL encoding of risk scenarios is not public. Threatwright reads
11
+ them from control parts named `risk-scenario` (ADR-0012, assumption); imported
12
+ controls keep their parts in `oscal_extra.parts`, and hand-written library
13
+ entries use the same structure there.
14
+ """
15
+
16
+ import re
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+ from typing import Any, Literal, cast
20
+
21
+ from ruamel.yaml.comments import CommentedMap
22
+
23
+ from threatwright.library import Libraries, MeasureTemplate
24
+ from threatwright.method import Cia, Method, asset_impact, load_method
25
+ from threatwright.model.objects import Asset, Element, Environment, Flow, Strict, Threat
26
+ from threatwright.model.store import Project, merge_into, read_yaml, to_plain, write_yaml
27
+
28
+ OVERRIDES_FILE = Path("profiles") / "arc-overrides.yaml"
29
+ SCENARIO_PART = "risk-scenario"
30
+ ENV_PROPS = {
31
+ "interface": "interface-restriction",
32
+ "access": "access-restriction",
33
+ "user": "user-capability",
34
+ }
35
+ IMPACT = re.compile(r"^\s*([CIA])\s*[:(]\s*([1-5])\s*\)?\s*$")
36
+ EnvName = Literal["interface", "access", "user"]
37
+ ENV_LABELS = {
38
+ "interface": ("Schnittstelle", "interface"),
39
+ "access": ("Zugang", "access"),
40
+ "user": ("Nutzer", "user"),
41
+ }
42
+
43
+
44
+ def normalize(value: str) -> str:
45
+ """TR wording ("External network", "Layman") -> method key (external_network, layman)."""
46
+ value = value.strip()
47
+ return value if value == "*" else re.sub(r"[^a-z0-9]+", "_", value.lower()).strip("_")
48
+
49
+
50
+ @dataclass
51
+ class RiskScenario:
52
+ prose: str = ""
53
+ impact: dict[Cia, int] = field(default_factory=dict) # minimum impact; empty = any
54
+ asset_classes: list[str] = field(default_factory=list) # empty = any
55
+ environment: dict[str, list[str]] = field(default_factory=dict) # missing / "*" = any
56
+
57
+
58
+ def risk_scenarios(entry: MeasureTemplate) -> list[RiskScenario]:
59
+ found: list[RiskScenario] = []
60
+
61
+ def walk(parts: list[Any]) -> None:
62
+ for part in parts:
63
+ if not isinstance(part, dict):
64
+ continue
65
+ if part.get("name") == SCENARIO_PART:
66
+ found.append(_scenario(part))
67
+ walk(part.get("parts", []))
68
+
69
+ walk(entry.oscal_extra.get("parts", []))
70
+ return found
71
+
72
+
73
+ def _scenario(part: dict[str, Any]) -> RiskScenario:
74
+ s = RiskScenario(prose=str(part.get("prose", "")))
75
+ for prop in part.get("props", []):
76
+ name, value = prop.get("name"), str(prop.get("value", ""))
77
+ if name == "asset-impact" and (m := IMPACT.match(value)):
78
+ dim = cast(Cia, m[1])
79
+ s.impact[dim] = min(s.impact.get(dim, 5), int(m[2]))
80
+ elif name == "asset-class":
81
+ s.asset_classes.append(value.strip())
82
+ for env, prop_name in ENV_PROPS.items():
83
+ if name == prop_name:
84
+ s.environment.setdefault(env, []).append(normalize(value))
85
+ return s
86
+
87
+
88
+ # --- model parts ----------------------------------------------------------------------
89
+
90
+
91
+ @dataclass
92
+ class _Asset:
93
+ id: str
94
+ title: str
95
+ asset_class: str
96
+ impact: dict[Cia, int | None]
97
+
98
+
99
+ @dataclass
100
+ class _Part:
101
+ label: str
102
+ env: dict[str, set[str]]
103
+ assets: list[_Asset]
104
+
105
+
106
+ def _env(env: Environment) -> dict[str, set[str]]:
107
+ return {name: {v} if (v := getattr(env, name)) else set() for name in ENV_PROPS}
108
+
109
+
110
+ def _parts(project: Project, model_name: str, method: Method, combined: bool) -> list[_Part]:
111
+ """Elements (zone environment) and assessed threats, each with the assets they handle."""
112
+ sub = project.models[model_name]
113
+ assets = cast(dict[str, Asset], sub.objects["assets"])
114
+ elements = cast(dict[str, Element], sub.objects["elements"])
115
+ flows = cast(dict[str, Flow], sub.objects["flows"])
116
+ threats = cast(dict[str, Threat], sub.objects["threats"])
117
+ zones = {z.id: z.environment for z in sub.meta.zones}
118
+
119
+ def info(ids: list[str]) -> list[_Asset]:
120
+ return [
121
+ _Asset(a, assets[a].title, assets[a].asset_class, asset_impact(method, assets, a))
122
+ for a in dict.fromkeys(ids)
123
+ if a in assets
124
+ ]
125
+
126
+ parts: list[_Part] = []
127
+ for el in elements.values():
128
+ handled = [a for f in flows.values() if el.id in (f.source, f.target) for a in f.assets]
129
+ handled += [a for t in threats.values() if el.id in t.targets for a in t.assets]
130
+ env = zones.get(el.zone) if el.zone else None
131
+ parts.append(_Part(f"{el.id} {el.title}", _env(env or Environment()), info(handled)))
132
+ for t in threats.values():
133
+ parts.append(_Part(f"{t.id} {t.title}", _env(t.assessment), info(t.assets)))
134
+ if combined: # §6.3 (p. 54-55): one profile of all assets and all environments
135
+ merged: dict[str, set[str]] = {name: set() for name in ENV_PROPS}
136
+ for p in parts:
137
+ for name, values in p.env.items():
138
+ merged[name] |= values
139
+ return [_Part("combined", merged, info(list(assets)))]
140
+ return parts
141
+
142
+
143
+ def _env_ok(s: RiskScenario, part: _Part) -> bool:
144
+ return all(
145
+ not wanted or "*" in wanted or bool(part.env[name] & set(wanted))
146
+ for name, wanted in s.environment.items()
147
+ )
148
+
149
+
150
+ def _asset_hit(s: RiskScenario, part: _Part) -> tuple[_Asset, Cia, int] | None | Literal[True]:
151
+ """Matching asset with dimension and value; True when the scenario has no asset condition."""
152
+ if not s.impact and not s.asset_classes:
153
+ return True
154
+ for a in part.assets:
155
+ if s.asset_classes and a.asset_class not in s.asset_classes:
156
+ continue
157
+ if not s.impact:
158
+ return a, "C", 0
159
+ for dim, minimum in s.impact.items():
160
+ value = a.impact.get(dim)
161
+ if value is not None and value >= minimum:
162
+ return a, dim, value
163
+ return None
164
+
165
+
166
+ # --- result -------------------------------------------------------------------------
167
+
168
+
169
+ @dataclass
170
+ class ControlApplicability:
171
+ measure_id: str
172
+ control_id: str | None
173
+ applies: bool
174
+ rationale: dict[str, str] # {"de": ..., "en": ...}
175
+ part: str | None = None
176
+ scenario: int | None = None # 1-based index of the matching risk scenario
177
+ overridden: bool = False
178
+
179
+
180
+ def _env_text(env: dict[str, set[str]] | dict[str, list[str]], lang: int) -> str:
181
+ return ", ".join(f"{ENV_LABELS[n][lang]} {'/'.join(sorted(v)) or '?'}" for n, v in env.items())
182
+
183
+
184
+ def _requirement(s: RiskScenario, lang: int) -> str:
185
+ alt = (" oder ", " or ")[lang]
186
+ items = []
187
+ if s.impact:
188
+ items.append(alt.join(f"{d}≥{v}" for d, v in s.impact.items()))
189
+ if s.asset_classes:
190
+ items.append(("Assetklasse ", "asset class ")[lang] + "/".join(s.asset_classes))
191
+ env = {n: v for n, v in s.environment.items() if v and "*" not in v}
192
+ if env:
193
+ items.append(_env_text(env, lang))
194
+ return "; ".join(items) or ("keine Bedingung", "no condition")[lang]
195
+
196
+
197
+ def _applies(
198
+ entry: MeasureTemplate, scenarios: list[RiskScenario], parts: list[_Part]
199
+ ) -> ControlApplicability:
200
+ control_id = entry.oscal_ref.control_id if entry.oscal_ref else None
201
+ for n, s in enumerate(scenarios, 1):
202
+ for part in parts:
203
+ hit = _asset_hit(s, part) if _env_ok(s, part) else None
204
+ if hit is None:
205
+ continue
206
+ if hit is True:
207
+ why_de, why_en = "keine Asset-Bedingung", "no asset condition"
208
+ else:
209
+ a, dim, value = hit
210
+ cond = f"{dim}={value}" if s.impact else a.asset_class
211
+ why_de = f"Asset {a.id} ({a.title}) mit {cond}"
212
+ why_en = f"asset {a.id} ({a.title}) with {cond}"
213
+ rationale = {
214
+ "de": f"Risikoszenario {n} trifft auf {part.label} zu: {why_de}; "
215
+ f"Umgebung {_env_text(part.env, 0)} (TR-03183-1 §6.2, S. 54).",
216
+ "en": f"Risk scenario {n} matches {part.label}: {why_en}; "
217
+ f"environment {_env_text(part.env, 1)} (TR-03183-1 §6.2, p. 54).",
218
+ }
219
+ return ControlApplicability(entry.id, control_id, True, rationale, part.label, n)
220
+ need_de = " | ".join(f"Szenario {n}: {_requirement(s, 0)}" for n, s in enumerate(scenarios, 1))
221
+ need_en = " | ".join(f"scenario {n}: {_requirement(s, 1)}" for n, s in enumerate(scenarios, 1))
222
+ rationale = {
223
+ "de": "Kein Teil des Modells verarbeitet ein Asset mit der geforderten Auswirkung "
224
+ f"in passender Umgebung ({need_de}; TR-03183-1 §6.2, S. 54).",
225
+ "en": "No part of the model handles an asset with the required impact in a matching "
226
+ f"environment ({need_en}; TR-03183-1 §6.2, p. 54).",
227
+ }
228
+ return ControlApplicability(entry.id, control_id, False, rationale)
229
+
230
+
231
+ def applicability(
232
+ project: Project, model_name: str, library: Libraries, combined: bool = False
233
+ ) -> list[ControlApplicability]:
234
+ """Per library measure with risk scenarios: does it apply to the model? Overrides win."""
235
+ method = load_method(project.root, project.config)
236
+ parts = _parts(project, model_name, method, combined)
237
+ overrides = load_overrides(project.root).models.get(model_name, {})
238
+ out: list[ControlApplicability] = []
239
+ for entry in sorted(library.measures.values(), key=lambda m: m.id):
240
+ scenarios = risk_scenarios(entry)
241
+ if not scenarios:
242
+ continue
243
+ result = _applies(entry, scenarios, parts)
244
+ if (o := overrides.get(entry.id)) is not None:
245
+ prefix = ("Manuell festgelegt", "Manual override")
246
+ by = f" ({o.by})" if o.by else ""
247
+ result.applies = o.applies
248
+ result.overridden = True
249
+ result.rationale = {
250
+ "de": f"{prefix[0]}{by}: {o.rationale}",
251
+ "en": f"{prefix[1]}{by}: {o.rationale}",
252
+ }
253
+ out.append(result)
254
+ return out
255
+
256
+
257
+ # --- overrides -------------------------------------------------------------------------
258
+
259
+
260
+ class ArcOverride(Strict):
261
+ applies: bool
262
+ rationale: str
263
+ by: str | None = None
264
+
265
+
266
+ class ArcOverrides(Strict):
267
+ """Content of profiles/arc-overrides.yaml: model name -> library measure id -> decision."""
268
+
269
+ schema_version: Literal[1] = 1
270
+ models: dict[str, dict[str, ArcOverride]] = {}
271
+
272
+
273
+ def load_overrides(project_root: Path) -> ArcOverrides:
274
+ path = Path(project_root) / OVERRIDES_FILE
275
+ if not path.exists():
276
+ return ArcOverrides()
277
+ return ArcOverrides.model_validate(to_plain(read_yaml(path)))
278
+
279
+
280
+ def set_override(
281
+ project_root: Path, model_name: str, measure_id: str, override: ArcOverride | None
282
+ ) -> None:
283
+ """Store or (with None) remove a manual decision; comments in the file survive."""
284
+ path = Path(project_root) / OVERRIDES_FILE
285
+ doc = read_yaml(path) if path.exists() else CommentedMap()
286
+ data = load_overrides(project_root)
287
+ per_model = data.models.setdefault(model_name, {})
288
+ if override is None:
289
+ per_model.pop(measure_id, None)
290
+ else:
291
+ per_model[measure_id] = override
292
+ if not per_model:
293
+ data.models.pop(model_name)
294
+ if merge_into(doc, data) or not path.exists():
295
+ write_yaml(path, doc)
@@ -0,0 +1,290 @@
1
+ """Turn library entries into model objects; STRIDE suggestions and coverage.
2
+
3
+ Environment of an adopted threat: the interface comes from the threat template
4
+ (it describes the attack path); access and user come from the zone of the
5
+ target where set, otherwise from the template. Measures suggested by the
6
+ template are reused when the model already contains them (same library entry).
7
+ """
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Literal
11
+
12
+ from threatwright.library import REF, ElementType, Libraries, MeasureTemplate, ThreatTemplate
13
+ from threatwright.method import Method
14
+ from threatwright.model.objects import (
15
+ Assessment,
16
+ Asset,
17
+ Element,
18
+ Environment,
19
+ Flow,
20
+ Measure,
21
+ ModelObject,
22
+ NodeLayout,
23
+ ProjectConfig,
24
+ Threat,
25
+ )
26
+ from threatwright.model.store import SubModel
27
+
28
+ Lang = Literal["de", "en"]
29
+
30
+
31
+ def _lang(config: ProjectConfig) -> Lang:
32
+ return config.language
33
+
34
+
35
+ def library_type(obj: ModelObject) -> str | None:
36
+ """Element type id an element or flow was created from."""
37
+ m = REF.match(obj.library_ref or "")
38
+ return m[3] if m and m[2] == "element" else None
39
+
40
+
41
+ def _entry_id(obj: ModelObject, kind: str) -> str | None:
42
+ m = REF.match(obj.library_ref or "")
43
+ return m[3] if m and m[2] == kind else None
44
+
45
+
46
+ def _zone_env(sub: SubModel, target: ModelObject) -> Environment:
47
+ element = target
48
+ if isinstance(target, Flow):
49
+ element = sub.objects["elements"].get(target.target, target)
50
+ zone_id = element.zone if isinstance(element, Element) else None
51
+ zone = next((z for z in sub.meta.zones if z.id == zone_id), None)
52
+ return zone.environment if zone else Environment()
53
+
54
+
55
+ def _new_assets(
56
+ sub: SubModel, libs: Libraries, etype: ElementType, owner_title: str, lang: Lang
57
+ ) -> list[str]:
58
+ ids = []
59
+ for a in etype.assets:
60
+ asset_id = sub.next_id("assets")
61
+ sub.objects["assets"][asset_id] = Asset(
62
+ id=asset_id,
63
+ title=f"{getattr(a.title, lang)} ({owner_title})",
64
+ asset_class=a.asset_class,
65
+ description=getattr(a.rationale, lang) if a.rationale else None,
66
+ )
67
+ ids.append(asset_id)
68
+ return ids
69
+
70
+
71
+ def add_element(
72
+ sub: SubModel,
73
+ libs: Libraries,
74
+ config: ProjectConfig,
75
+ type_id: str,
76
+ title: str | None = None,
77
+ zone: str | None = None,
78
+ position: tuple[float, float] | None = None,
79
+ ) -> Element:
80
+ """New element from a library element type, with its typical assets."""
81
+ etype = libs.elements[type_id]
82
+ if etype.kind == "flow":
83
+ raise ValueError(f"'{type_id}' is a data flow type")
84
+ lang = _lang(config)
85
+ element = Element(
86
+ id=sub.next_id("elements"),
87
+ title=title or getattr(etype.name, lang),
88
+ kind=etype.kind,
89
+ zone=zone,
90
+ library_ref=libs.ref("element", type_id),
91
+ description=getattr(etype.description, lang) if etype.description else None,
92
+ )
93
+ sub.objects["elements"][element.id] = element
94
+ _new_assets(sub, libs, etype, element.title, lang)
95
+ if position is not None:
96
+ sub.layout.nodes[element.id] = NodeLayout(x=position[0], y=position[1])
97
+ return element
98
+
99
+
100
+ def add_flow(
101
+ sub: SubModel,
102
+ libs: Libraries,
103
+ config: ProjectConfig,
104
+ source: str,
105
+ target: str,
106
+ type_id: str | None = None,
107
+ ) -> Flow:
108
+ """New data flow between two elements, optionally typed by a library flow type."""
109
+ for end in (source, target):
110
+ if end not in sub.objects["elements"]:
111
+ raise KeyError(end)
112
+ lang = _lang(config)
113
+ etype = libs.elements.get(type_id) if type_id else None
114
+ if etype is not None and etype.kind != "flow":
115
+ raise ValueError(f"'{type_id}' is not a data flow type")
116
+ names = [sub.objects["elements"][e].title for e in (source, target)]
117
+ title = getattr(etype.name, lang) if etype else f"{names[0]} → {names[1]}"
118
+ flow_id = sub.next_id("flows")
119
+ assets = _new_assets(sub, libs, etype, f"{flow_id}", lang) if etype else []
120
+ flow = Flow(
121
+ id=flow_id,
122
+ title=title,
123
+ source=source,
124
+ target=target,
125
+ protocol=etype.name.en if etype else None,
126
+ assets=assets,
127
+ library_ref=libs.ref("element", type_id) if etype and type_id else None,
128
+ )
129
+ sub.objects["flows"][flow.id] = flow
130
+ return flow
131
+
132
+
133
+ def _measure_for(sub: SubModel, libs: Libraries, template: MeasureTemplate, lang: Lang) -> str:
134
+ for m in sub.objects["measures"].values():
135
+ if _entry_id(m, "measure") == template.id:
136
+ return m.id
137
+ measure = Measure(
138
+ id=sub.next_id("measures"),
139
+ title=getattr(template.title, lang),
140
+ description=getattr(template.description, lang),
141
+ kind=template.kind,
142
+ compensating=template.compensating,
143
+ evidence=getattr(template.evidence, lang) if template.evidence else None,
144
+ cra_refs=list(template.cra_refs),
145
+ library_ref=libs.ref("measure", template.id),
146
+ )
147
+ sub.objects["measures"][measure.id] = measure
148
+ return measure.id
149
+
150
+
151
+ def adopt_threat(
152
+ sub: SubModel, libs: Libraries, config: ProjectConfig, template_id: str, target_id: str
153
+ ) -> Threat:
154
+ """New threat on a target from a library template incl. suggested measures."""
155
+ template: ThreatTemplate = libs.threats[template_id]
156
+ target = sub.objects["elements"].get(target_id) or sub.objects["flows"].get(target_id)
157
+ if target is None:
158
+ raise KeyError(target_id)
159
+ lang = _lang(config)
160
+
161
+ def fill(text: str) -> str:
162
+ return text.replace("{element}", target.title)
163
+
164
+ zone = _zone_env(sub, target)
165
+ env = template.environment
166
+ measures = [
167
+ _measure_for(sub, libs, libs.measures[m], lang)
168
+ for m in template.measures
169
+ if m in libs.measures
170
+ ]
171
+ threat = Threat(
172
+ id=sub.next_id("threats"),
173
+ title=fill(getattr(template.title, lang)),
174
+ stride=list(template.stride),
175
+ targets=[target_id],
176
+ assets=list(target.assets) if isinstance(target, Flow) else [],
177
+ configurations=list(getattr(target, "configurations", [])),
178
+ scenario=fill(getattr(template.scenario, lang)),
179
+ consequence=fill(getattr(template.consequence, lang)),
180
+ safety_related=template.safety_related,
181
+ assessment=Assessment(
182
+ interface=env.interface or zone.interface,
183
+ access=zone.access or env.access,
184
+ user=zone.user or env.user,
185
+ impact=template.impact,
186
+ ),
187
+ measures=measures,
188
+ cra_refs=list(template.cra_refs),
189
+ library_ref=libs.ref("threat", template_id),
190
+ )
191
+ sub.objects["threats"][threat.id] = threat
192
+ return threat
193
+
194
+
195
+ # --- suggestions and coverage -------------------------------------------------------
196
+
197
+
198
+ @dataclass
199
+ class Suggestion:
200
+ template_id: str
201
+ title: str
202
+ scenario: str
203
+ impact: int | None
204
+ safety_related: bool
205
+ adopted: bool
206
+
207
+
208
+ @dataclass
209
+ class Category:
210
+ relevant: bool
211
+ existing: list[str] = field(default_factory=list)
212
+ not_relevant: str | None = None
213
+ suggestions: list[Suggestion] = field(default_factory=list)
214
+
215
+
216
+ def _kind(obj: ModelObject) -> str:
217
+ return obj.kind if isinstance(obj, Element) else "flow"
218
+
219
+
220
+ def suggestions(
221
+ sub: SubModel, libs: Libraries, method: Method, config: ProjectConfig, target_id: str
222
+ ) -> dict[str, Category]:
223
+ """Per STRIDE category: existing threats and fitting library templates."""
224
+ target = sub.objects["elements"].get(target_id) or sub.objects["flows"].get(target_id)
225
+ if not isinstance(target, Element | Flow):
226
+ raise KeyError(target_id)
227
+ lang = _lang(config)
228
+ kind = _kind(target)
229
+ etype = library_type(target)
230
+ if etype in libs.elements:
231
+ types = {etype}
232
+ elif kind == "flow":
233
+ # flow templates are protocol specific (fieldbus, VPN, Wi-Fi); none fits an untyped flow
234
+ types = set()
235
+ else: # no library type: offer templates for element types of the same kind
236
+ types = {e.id for e in libs.elements.values() if e.kind == kind}
237
+ threats = [t for t in sub.objects["threats"].values() if isinstance(t, Threat)]
238
+ adopted = {(_entry_id(t, "threat"), tuple(t.targets)) for t in threats}
239
+ relevant = set(method.stride_per_kind.map.get(kind, []))
240
+ out: dict[str, Category] = {}
241
+ for s in "STRIDE":
242
+ cat = Category(
243
+ relevant=s in relevant,
244
+ existing=[t.id for t in threats if target_id in t.targets and s in t.stride],
245
+ not_relevant=target.stride_not_relevant.get(s), # type: ignore[call-overload]
246
+ )
247
+ for tpl in libs.threats.values():
248
+ if s in tpl.stride and types & set(tpl.applies_to):
249
+ cat.suggestions.append(
250
+ Suggestion(
251
+ template_id=tpl.id,
252
+ title=getattr(tpl.title, lang).replace("{element}", target.title),
253
+ scenario=getattr(tpl.scenario, lang).replace("{element}", target.title),
254
+ impact=tpl.impact,
255
+ safety_related=tpl.safety_related,
256
+ adopted=any(a == tpl.id and target_id in tg for a, tg in adopted),
257
+ )
258
+ )
259
+ out[s] = cat
260
+ return out
261
+
262
+
263
+ @dataclass
264
+ class CoverageRow:
265
+ id: str
266
+ title: str
267
+ kind: str
268
+ cells: dict[str, Category]
269
+
270
+
271
+ def coverage(sub: SubModel, method: Method) -> list[CoverageRow]:
272
+ """Elements and flows x STRIDE: threat count and 'not relevant' marks."""
273
+ threats = [t for t in sub.objects["threats"].values() if isinstance(t, Threat)]
274
+ rows = []
275
+ for coll in ("elements", "flows"):
276
+ for obj in sub.objects[coll].values():
277
+ if not isinstance(obj, Element | Flow):
278
+ continue
279
+ kind = _kind(obj)
280
+ relevant = set(method.stride_per_kind.map.get(kind, []))
281
+ cells = {
282
+ s: Category(
283
+ relevant=s in relevant,
284
+ existing=[t.id for t in threats if obj.id in t.targets and s in t.stride],
285
+ not_relevant=obj.stride_not_relevant.get(s), # type: ignore[call-overload]
286
+ )
287
+ for s in "STRIDE"
288
+ }
289
+ rows.append(CoverageRow(obj.id, obj.title, kind, cells))
290
+ return rows
@@ -0,0 +1,4 @@
1
+ """Product naming. The only place the product name is defined."""
2
+
3
+ PRODUCT_NAME = "Threatwright"
4
+ PROJECT_FILE = "threatwright.yaml"
@@ -0,0 +1,87 @@
1
+ # MITRE ATT&CK for ICS technique IDs, used to validate library references.
2
+ # (c) The MITRE Corporation. ATT&CK is reproduced and distributed with the
3
+ # permission of The MITRE Corporation (https://attack.mitre.org/resources/legal-and-branding/terms-of-use/).
4
+ schema_version: 1
5
+ id: attack-ics
6
+ title: MITRE ATT&CK for ICS
7
+ version: v20
8
+ retrieved: '2026-10-19'
9
+ source: https://attack.mitre.org/techniques/ics/
10
+ entries:
11
+ T0800: Activate Firmware Update Mode
12
+ T0802: Automated Collection
13
+ T0806: Brute Force I/O
14
+ T0809: Data Destruction
15
+ T0811: Data from Information Repositories
16
+ T0813: Denial of Control
17
+ T0814: Denial of Service
18
+ T0815: Denial of View
19
+ T0816: Device Restart/Shutdown
20
+ T0817: Drive-by Compromise
21
+ T0819: Exploit Public-Facing Application
22
+ T0820: Exploitation for Evasion
23
+ T0821: Modify Controller Tasking
24
+ T0822: External Remote Services
25
+ T0823: Graphical User Interface
26
+ T0826: Loss of Availability
27
+ T0827: Loss of Control
28
+ T0828: Loss of Productivity and Revenue
29
+ T0829: Loss of View
30
+ T0830: Adversary-in-the-Middle
31
+ T0831: Manipulation of Control
32
+ T0832: Manipulation of View
33
+ T0834: Native API
34
+ T0835: Manipulate I/O Image
35
+ T0836: Modify Parameter
36
+ T0837: Loss of Protection
37
+ T0838: Modify Alarm Settings
38
+ T0840: Network Connection Enumeration
39
+ T0842: Network Sniffing
40
+ T0843: Program Download
41
+ T0845: Program Upload
42
+ T0846: Remote System Discovery
43
+ T0847: Replication Through Removable Media
44
+ T0848: Rogue Master
45
+ T0849: Masquerading
46
+ T0851: Rootkit
47
+ T0852: Screen Capture
48
+ T0858: Change Operating Mode
49
+ T0859: Valid Accounts
50
+ T0860: Wireless Compromise
51
+ T0861: Point & Tag Identification
52
+ T0862: Supply Chain Compromise
53
+ T0863: User Execution
54
+ T0864: Transient Cyber Asset
55
+ T0865: Spearphishing Attachment
56
+ T0866: Exploitation of Remote Services
57
+ T0867: Lateral Tool Transfer
58
+ T0868: Detect Operating Mode
59
+ T0869: Standard Application Layer Protocol
60
+ T0871: Execution through API
61
+ T0872: Indicator Removal on Host
62
+ T0873: Project File Infection
63
+ T0874: Hooking
64
+ T0877: I/O Image
65
+ T0878: Alarm Suppression
66
+ T0879: Damage to Property
67
+ T0880: Loss of Safety
68
+ T0881: Service Stop
69
+ T0882: Theft of Operational Information
70
+ T0883: Internet Accessible Device
71
+ T0884: Connection Proxy
72
+ T0885: Commonly Used Port
73
+ T0886: Remote Services
74
+ T0887: Wireless Sniffing
75
+ T0888: Remote System Information Discovery
76
+ T0889: Modify Program
77
+ T0890: Exploitation for Privilege Escalation
78
+ T0892: Change Credential
79
+ T0893: Data from Local System
80
+ T0894: System Binary Proxy Execution
81
+ T0895: Autorun Image
82
+ T1691: Block Operational Technology Message
83
+ T1692: Unauthorized Message
84
+ T1693: Modify Firmware
85
+ T1694: Insecure Credentials
86
+ T1695: Block Communications
87
+ T1701: Command and Scripting Interpreter