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.
- threatwright/__init__.py +3 -0
- threatwright/__main__.py +3 -0
- threatwright/arc.py +295 -0
- threatwright/assistant.py +290 -0
- threatwright/branding.py +4 -0
- threatwright/catalogs/attack-ics.yaml +87 -0
- threatwright/catalogs/cra.yaml +107 -0
- threatwright/catalogs/emb3d.yaml +90 -0
- threatwright/catalogs/iec62443.yaml +16 -0
- threatwright/ci.py +158 -0
- threatwright/ci_templates/github-actions.yml.j2 +95 -0
- threatwright/ci_templates/gitlab-ci.yml.j2 +69 -0
- threatwright/cli.py +370 -0
- threatwright/diagram.py +110 -0
- threatwright/interop/__init__.py +11 -0
- threatwright/interop/export.py +196 -0
- threatwright/interop/importers.py +411 -0
- threatwright/libraries/ot/elements.yaml +501 -0
- threatwright/libraries/ot/library.yaml +19 -0
- threatwright/libraries/ot/measures/data.yaml +170 -0
- threatwright/libraries/ot/measures/identity.yaml +468 -0
- threatwright/libraries/ot/measures/integrity.yaml +694 -0
- threatwright/libraries/ot/measures/logging.yaml +114 -0
- threatwright/libraries/ot/measures/network.yaml +361 -0
- threatwright/libraries/ot/measures/organisational.yaml +328 -0
- threatwright/libraries/ot/measures/remote.yaml +281 -0
- threatwright/libraries/ot/measures/safety.yaml +148 -0
- threatwright/libraries/ot/measures/supply-chain.yaml +167 -0
- threatwright/libraries/ot/threats/field.yaml +1461 -0
- threatwright/libraries/ot/threats/host.yaml +1568 -0
- threatwright/libraries/ot/threats/network.yaml +1434 -0
- threatwright/library.py +296 -0
- threatwright/method.py +296 -0
- threatwright/methods/tr-03183-1/method.yaml +223 -0
- threatwright/model/__init__.py +0 -0
- threatwright/model/objects.py +246 -0
- threatwright/model/refactor.py +54 -0
- threatwright/model/schema.py +31 -0
- threatwright/model/store.py +326 -0
- threatwright/oscal/__init__.py +19 -0
- threatwright/oscal/imports.py +393 -0
- threatwright/oscal/project.py +22 -0
- threatwright/oscal/schemas/1.2.3/README.md +32 -0
- threatwright/oscal/schemas/1.2.3/oscal_catalog_schema.json +1295 -0
- threatwright/oscal/schemas/1.2.3/oscal_complete_schema.json +6022 -0
- threatwright/oscal/schemas/1.2.3/oscal_component_schema.json +1821 -0
- threatwright/oscal/schemas/1.2.3/oscal_profile_schema.json +1616 -0
- threatwright/oscal/validate.py +150 -0
- threatwright/render/__init__.py +30 -0
- threatwright/render/build.py +316 -0
- threatwright/render/context.py +579 -0
- threatwright/render/env.py +89 -0
- threatwright/render/labels.py +247 -0
- threatwright/render/odt.py +296 -0
- threatwright/render/package.py +217 -0
- threatwright/render/pdf.py +66 -0
- threatwright/render/sections.py +475 -0
- threatwright/render/word.py +162 -0
- threatwright/report.py +96 -0
- threatwright/rules.py +382 -0
- threatwright/scaffold.py +71 -0
- threatwright/server.py +940 -0
- threatwright/templates/_shared/document.html.j2 +69 -0
- threatwright/templates/_shared/document.md.j2 +47 -0
- threatwright/templates/acceptance-criteria/template.docx +0 -0
- threatwright/templates/acceptance-criteria/template.md +2 -0
- threatwright/templates/acceptance-criteria/template.odt +0 -0
- threatwright/templates/acceptance-criteria/template.yaml +41 -0
- threatwright/templates/tr-analysis/template.docx +0 -0
- threatwright/templates/tr-analysis/template.html +2 -0
- threatwright/templates/tr-analysis/template.md +2 -0
- threatwright/templates/tr-analysis/template.odt +0 -0
- threatwright/templates/tr-analysis/template.yaml +29 -0
- threatwright/templates/tr-analysis-compact/template.docx +0 -0
- threatwright/templates/tr-analysis-compact/template.yaml +47 -0
- threatwright/vcs.py +349 -0
- threatwright/web/assets/elk.bundled-B7GIUaPm.js +24 -0
- threatwright/web/assets/index-CQEumOmL.css +1 -0
- threatwright/web/assets/index-CQtGvHdl.js +13 -0
- threatwright/web/index.html +13 -0
- threatwright-0.1.0.dist-info/METADATA +201 -0
- threatwright-0.1.0.dist-info/RECORD +85 -0
- threatwright-0.1.0.dist-info/WHEEL +4 -0
- threatwright-0.1.0.dist-info/entry_points.txt +2 -0
- threatwright-0.1.0.dist-info/licenses/LICENSE +661 -0
threatwright/__init__.py
ADDED
threatwright/__main__.py
ADDED
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
|
threatwright/branding.py
ADDED
|
@@ -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
|