diff --git a/CLAUDE.md b/CLAUDE.md index 45c9b65..a293dc3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,7 +83,8 @@ threat-forge/ │ │ ├── models/ # Rust types (serde YAML schema) │ │ ├── stride/ # STRIDE threat engine │ │ ├── ai/ # AI: key storage, streaming providers, system prompts -│ │ └── file_io/ # File read/write operations +│ │ ├── file_io/ # File read/write operations +│ │ └── importers/ # Import from external formats (TM7, etc.) │ ├── Cargo.toml │ └── tauri.conf.json ├── e2e/ # Playwright E2E tests diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dbecbb3..1350aaf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -81,7 +81,7 @@ Look for issues labeled [`good first issue`](https://github.com/exit-zero-labs/t ### Areas Where Help Is Needed - **STRIDE threat rules** -- expanding the rule set for better threat coverage -- **Import/export** -- converters for Microsoft TMT `.tm7` and OWASP Threat Dragon `.json` +- **Import/export** -- OWASP Threat Dragon `.json` import, PDF export - **Accessibility** -- keyboard navigation, screen reader support, WCAG compliance - **Internationalization** -- translations (once i18n architecture is in place) - **Documentation** -- tutorials, guides, example threat models diff --git a/README.md b/README.md index 8f818b0..e908c90 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,10 @@ Chat with Claude or GPT about your threat model. The AI sees your full architect Bring your own API key. Supports Anthropic (Claude Opus 4, Sonnet 4, Haiku 3.5) and OpenAI (GPT-4o, GPT-4o Mini). Keys are AES-256-GCM encrypted at rest. +### Import from Microsoft TMT + +Import existing `.tm7` files from Microsoft's Threat Modeling Tool. ThreatForge converts elements, data flows, trust boundaries, and threats to the native `.thf` format — preserving positions and STRIDE categories. No more Windows lock-in. + ### Pre-built Templates Start from six production-quality templates: **Cloud Microservices**, **E-Commerce Platform**, **Mobile Banking**, **SaaS Platform**, **IoT Smart Building**, and **Healthcare System**. Each includes a complete data flow diagram with elements, trust boundaries, data flows, and STRIDE threats. @@ -155,8 +159,8 @@ Produces a native desktop binary for your platform (~10MB). ### Test ```bash -npx vitest --run # 416+ frontend tests -cargo test --manifest-path src-tauri/Cargo.toml # 59+ Rust tests +npx vitest --run # 417+ frontend tests +cargo test --manifest-path src-tauri/Cargo.toml # 73+ Rust tests ``` ### Lint @@ -228,7 +232,7 @@ Contributions welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for setup instruct Areas where help is especially valuable: - STRIDE threat rule expansion -- Import/export (Microsoft TMT `.tm7`, OWASP Threat Dragon `.json`) +- Import/export (OWASP Threat Dragon `.json`, PDF export) - Accessibility (WCAG compliance) - Documentation and example threat models diff --git a/docs/knowledge/architecture.md b/docs/knowledge/architecture.md index 4e39a96..7609c85 100644 --- a/docs/knowledge/architecture.md +++ b/docs/knowledge/architecture.md @@ -15,6 +15,7 @@ ThreatForge Desktop App (Tauri v2) │ ├── File I/O (Read/Write YAML — serde + serde_yaml) │ ├── Schema Validation (strict serde deserialization) │ ├── STRIDE Engine (threat rule engine) +│ ├── Importers (TM7 XML → ThreatModel conversion) │ ├── Secure Key Storage (AES-256-GCM encrypted file) │ ├── Auto-Updater (Tauri plugin) │ └── Opt-in Telemetry @@ -85,7 +86,8 @@ threat-forge/ │ │ ├── models/ # Rust types (serde YAML schema) │ │ ├── stride/ # STRIDE threat engine │ │ ├── ai/ # AI: key storage, streaming providers, system prompts -│ │ └── file_io/ # File read/write operations +│ │ ├── file_io/ # File read/write operations +│ │ └── importers/ # Import from external formats (TM7, etc.) │ ├── Cargo.toml │ └── tauri.conf.json ├── e2e/ # Playwright E2E tests diff --git a/docs/knowledge/overview.md b/docs/knowledge/overview.md index 11efaad..de2f4d1 100644 --- a/docs/knowledge/overview.md +++ b/docs/knowledge/overview.md @@ -14,7 +14,7 @@ ThreatForge's differentiation is the combination of three things no other tool b 1. **UX gap filled** — A modern, keyboard-shortcut-driven interface with drag-and-drop canvas, 44 typed components + text annotations, custom themes, and resizable panes. The kind of experience developers expect from tools like Linear, Figma, or VS Code. -2. **File format gap filled** — The `.thf` YAML format is human-readable, git-diffable, and reviewable in pull requests. Microsoft's `.tm7` is binary. Threat Dragon's JSON is verbose. Neither can be opened in a text editor and understood. +2. **File format gap filled** — The `.thf` YAML format is human-readable, git-diffable, and reviewable in pull requests. Microsoft's `.tm7` is binary. Threat Dragon's JSON is verbose. Neither can be opened in a text editor and understood. ThreatForge can import `.tm7` files directly, converting them to `.thf` with all elements, flows, boundaries, and threats preserved. 3. **AI integration gap filled** — An integrated AI chat pane (BYOK) for conversational threat analysis and iterative model refinement. No other desktop tool combines interactive modeling with native AI assistance. @@ -40,9 +40,10 @@ ThreatForge is production-ready. The core application is fully functional: - STRIDE threat engine with auto-generated threats per element - AI chat pane (BYOK) with OpenAI + Anthropic support, model selector, persistent chat sessions, markdown rendering, stop generating - `.thf` YAML file format with clean git diffs +- Import from Microsoft TMT `.tm7` files with full element, flow, boundary, and threat conversion - 13+ themes (light and dark), resizable panes, keyboard shortcuts, command palette - Onboarding system with interactive guides -- 416+ frontend tests, 59+ Rust tests, 40+ E2E tests +- 417+ frontend tests, 73+ Rust tests, 40+ E2E tests - Cross-platform via Tauri v2 (macOS, Windows, Linux) **Remaining for public launch:** Cross-platform CI builds + code signing, auto-updater, landing page/website, launch marketing. diff --git a/docs/knowledge/product-design.md b/docs/knowledge/product-design.md index dc22187..d0c53a9 100644 --- a/docs/knowledge/product-design.md +++ b/docs/knowledge/product-design.md @@ -33,12 +33,13 @@ | Author tracking | Name/email settings, auto-populated created_by/modified_by | | Self-loop connectors | Data flows from a node to itself | | Canvas panning | Arrow key nudge for elements, arrow key pan when nothing selected | +| Import from MS TMT (.tm7) | XML parser converts elements, flows, boundaries, threats to `.thf` with positions preserved | ### Should-Have (future) | Feature | Priority | |---------|----------| -| Import from MS TMT (.tm7) and Threat Dragon (.json) | P0 | +| Import from Threat Dragon (.json) | P0 | | PDF/HTML export for stakeholders | P1 | | LINDDUN privacy methodology support | P2 | | Multiple diagrams per model | P2 | diff --git a/docs/plans/todo.md b/docs/plans/todo.md index c99841c..f29ed2e 100644 --- a/docs/plans/todo.md +++ b/docs/plans/todo.md @@ -4,6 +4,54 @@ Shared execution plan for humans and LLM agents. Update this file before, during --- +## 2026-03-07 — Import TM7 and Other File Formats + +### Plan +- [x] 1. Rust: TM7 XML parser module (`src-tauri/src/importers/`) + - [x] Add `quick-xml` crate to `Cargo.toml` + - [x] Create `src-tauri/src/importers/mod.rs` + - [x] Create `src-tauri/src/importers/tm7.rs` — parse TM7 XML → `ThreatModel` + - [x] Unit tests for TM7 parsing (6 tests: minimal TM7, empty TM7, unique IDs, STRIDE categories, severity, mitigation status) +- [x] 2. Rust: Tauri command for import + - [x] Create `src-tauri/src/commands/import_commands.rs` with `import_threat_model` command + - [x] Register command in `lib.rs` +- [x] 3. Frontend: Import flow + - [x] Add `importModel` to `useFileOperations` hook + - [x] Add "Import…" menu item to native menu (`menu.rs`) + - [x] Handle `file-import` menu event in `useNativeMenu` + - [x] Add import command to command palette (`command-registry.ts`) + - [x] Update `FileAdapter` with `importThreatModel` method + - [x] Implement in `TauriFileAdapter` with open dialog for `.tm7` files + - [x] Browser adapter stub (shows "desktop only" alert) +- [x] 4. Validation (initial) + - [x] `cargo clippy` — clean + - [x] `npx biome check --write .` — clean + - [x] `cargo test` — 69 tests pass (10 new) + - [x] `npx vitest --run` — 417 tests pass (1 new) + - [x] `cargo fmt` — clean +- [x] 5. Deep audit & fixes + - [x] Fix `parse_stencil_properties` stale `current_display_name` bug — clear after consuming Value + - [x] Fix `NeedsInvestigation` mapped to `InProgress` → should be `NotStarted` + - [x] Fix dead code in `TauriFileAdapter.openThreatModel` — redundant ternary + - [x] Add test: malformed XML returns error + - [x] Add test: `NeedsInvestigation` maps to `NotStarted` + - [x] Add test: unknown STRIDE TypeId prefix falls back to Tampering + - [x] Add test: self-loop flow (from == to) imported correctly + - [x] Update README.md — add "Import from Microsoft TMT" to Features, fix Contributing + - [x] Update CONTRIBUTING.md — TM7 import done, only Threat Dragon remains + - [x] Update overview.md — add import to status list, update test counts + - [x] Update product-design.md — move TM7 import to Must-Have table + - [x] Final validation: clippy clean, biome clean, 73 Rust tests, 417 frontend tests + - [ ] Manual test with a real TM7 file + +### Notes +- TM7 is Microsoft's XML-based format with .NET serialization artifacts +- Architecture is extensible — `importers/` module can support future formats (Threat Dragon JSON, OTMF, etc.) +- Import produces a ThreatModel that the user must "Save As" to create a .thf file +- Deep audit found and fixed: stale state bug in property parser, incorrect mitigation mapping, dead code, missing tests, outdated docs + +--- + ## 2026-03-03 — AI Chat Pane Improvements ### Plan diff --git a/docs/runbooks/releasing-a-version.md b/docs/runbooks/releasing-a-version.md index d41cb80..ced6335 100644 --- a/docs/runbooks/releasing-a-version.md +++ b/docs/runbooks/releasing-a-version.md @@ -80,6 +80,7 @@ Monitor the workflow at: `Actions > Release > v0.2.0` 2. Download binaries for each platform and smoke test: - App launches without errors - Can create, save, and reopen a `.thf` file + - Import a `.tm7` file (File > Import) — elements, flows, boundaries, and threats appear on canvas - AI chat works (with valid API key): model selector, chat sessions, stop generating, markdown rendering - STRIDE analysis runs 3. Publish the draft release (click "Publish release" on GitHub) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 101607d..9725732 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2825,7 +2825,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64 0.22.1", "indexmap 2.13.0", - "quick-xml", + "quick-xml 0.38.4", "serde", "time", ] @@ -2977,6 +2977,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + [[package]] name = "quick-xml" version = "0.38.4" @@ -4536,6 +4545,7 @@ dependencies = [ "aes-gcm", "chrono", "futures", + "quick-xml 0.37.5", "rand 0.8.5", "reqwest 0.12.28", "rmcp", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f3819ca..b620348 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -32,6 +32,7 @@ schemars = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" +quick-xml = "0.37" thiserror = "2" uuid = { version = "1", features = ["v4", "serde"] } chrono = { version = "0.4", features = ["serde"] } diff --git a/src-tauri/src/commands/import_commands.rs b/src-tauri/src/commands/import_commands.rs new file mode 100644 index 0000000..191d743 --- /dev/null +++ b/src-tauri/src/commands/import_commands.rs @@ -0,0 +1,15 @@ +use crate::importers::tm7; +use crate::models::ThreatModel; +use std::path::PathBuf; + +#[tauri::command] +pub fn import_threat_model(path: String, format: String) -> Result { + let path = PathBuf::from(&path); + let contents = + std::fs::read_to_string(&path).map_err(|e| format!("Failed to read file: {e}"))?; + + match format.as_str() { + "tm7" => tm7::parse_tm7(&contents).map_err(|e| e.to_string()), + _ => Err(format!("Unsupported import format: {format}")), + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index afa6a48..d29d459 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,9 +1,11 @@ mod ai_commands; mod file_commands; +mod import_commands; mod stride_commands; mod update_commands; pub use ai_commands::*; pub use file_commands::*; +pub use import_commands::*; pub use stride_commands::*; pub use update_commands::*; diff --git a/src-tauri/src/importers/mod.rs b/src-tauri/src/importers/mod.rs new file mode 100644 index 0000000..f0bd783 --- /dev/null +++ b/src-tauri/src/importers/mod.rs @@ -0,0 +1 @@ +pub mod tm7; diff --git a/src-tauri/src/importers/tm7.rs b/src-tauri/src/importers/tm7.rs new file mode 100644 index 0000000..bd4de63 --- /dev/null +++ b/src-tauri/src/importers/tm7.rs @@ -0,0 +1,1813 @@ +//! Parser for Microsoft Threat Modeling Tool `.tm7` XML files. +//! +//! TM7 files use .NET DataContract XML serialization with multiple namespaces. +//! This parser uses `quick-xml` to walk the XML tree and extract elements, +//! data flows, trust boundaries, and threats, converting them to our +//! `ThreatModel` format. + +use crate::models::{ + DataFlow, Diagram, Element, Metadata, Mitigation, MitigationStatus, Position, Severity, Size, + StrideCategory, Threat, ThreatModel, TrustBoundary, Viewport, +}; +use quick_xml::events::{BytesStart, Event}; +use quick_xml::Reader; +use std::collections::HashMap; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum Tm7Error { + #[error("XML parsing error: {0}")] + Xml(#[from] quick_xml::Error), +} + +/// Intermediate representation of a TM7 stencil element. +#[derive(Debug, Default)] +struct Tm7Element { + guid: String, + generic_type_id: String, + name: String, + left: f64, + top: f64, + width: f64, + height: f64, + out_of_scope: bool, + /// e.g. StencilRectangle, StencilEllipse, StencilParallelLines, BorderBoundary + stencil_type: String, +} + +/// Intermediate representation of a TM7 connector/line. +#[derive(Debug, Default)] +struct Tm7Line { + guid: String, + generic_type_id: String, + name: String, + source_guid: String, + target_guid: String, + source_x: f64, + source_y: f64, + target_x: f64, + target_y: f64, + /// e.g. Connector, LineBoundary + line_type: String, +} + +/// Intermediate representation of a TM7 threat instance. +#[derive(Debug, Default)] +struct Tm7Threat { + id: String, + flow_guid: String, + source_guid: String, + target_guid: String, + priority: String, + state: String, + state_information: String, + type_id: String, + properties: HashMap, +} + +/// Intermediate TM7 metadata. +#[derive(Debug, Default)] +struct Tm7Meta { + name: String, + owner: String, + description: String, +} + +/// Parse a TM7 XML string into a `ThreatModel`. +pub fn parse_tm7(xml: &str) -> Result { + let mut reader = Reader::from_str(xml); + reader.config_mut().trim_text(true); + + let mut meta = Tm7Meta::default(); + let mut border_elements: Vec = Vec::new(); + let mut lines: Vec = Vec::new(); + let mut threats: Vec = Vec::new(); + let mut diagram_name = String::from("Level 0 DFD"); + + let mut buf = Vec::new(); + + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(ref e)) => { + let local = local_name(e); + match local.as_str() { + "MetaInformation" => { + meta = parse_meta_information(&mut reader)?; + } + "DrawingSurfaceModel" => { + let (name, borders, surface_lines) = parse_drawing_surface(&mut reader)?; + if !name.is_empty() { + diagram_name = name; + } + border_elements.extend(borders); + lines.extend(surface_lines); + } + "ThreatInstances" => { + threats = parse_threat_instances(&mut reader)?; + } + _ => {} + } + } + Ok(Event::Eof) => break, + Err(e) => return Err(Tm7Error::Xml(e)), + _ => {} + } + buf.clear(); + } + + // Build GUID → kebab-id mapping for elements + let mut guid_to_id: HashMap = HashMap::new(); + let mut id_counter: HashMap = HashMap::new(); + + let mut thf_elements: Vec = Vec::new(); + let mut thf_boundaries: Vec = Vec::new(); + + for elem in &border_elements { + if elem.stencil_type == "BorderBoundary" { + let id = make_unique_id(&elem.name, "boundary", &mut id_counter); + guid_to_id.insert(elem.guid.clone(), id.clone()); + thf_boundaries.push(TrustBoundary { + id, + name: elem.name.clone(), + contains: Vec::new(), // populated later by containment check + position: Some(Position { + x: elem.left, + y: elem.top, + }), + size: Some(Size { + width: elem.width, + height: elem.height, + }), + fill_color: None, + stroke_color: None, + fill_opacity: None, + stroke_opacity: None, + }); + } else if !elem.out_of_scope { + let element_type = match elem.generic_type_id.as_str() { + "GE.P" => "process", + "GE.DS" => "data_store", + "GE.EI" => "external_entity", + _ => "process", + }; + let id = make_unique_id(&elem.name, element_type, &mut id_counter); + guid_to_id.insert(elem.guid.clone(), id.clone()); + thf_elements.push(Element { + id, + element_type: element_type.to_string(), + name: elem.name.clone(), + trust_zone: String::new(), + subtype: None, + icon: None, + description: String::new(), + technologies: Vec::new(), + stores: None, + encryption: None, + position: Some(Position { + x: elem.left, + y: elem.top, + }), + fill_color: None, + stroke_color: None, + fill_opacity: None, + stroke_opacity: None, + font_size: None, + font_weight: None, + }); + } + } + + // Line boundaries → trust boundaries + for line in &lines { + if line.line_type == "LineBoundary" { + let id = make_unique_id(&line.name, "boundary", &mut id_counter); + guid_to_id.insert(line.guid.clone(), id.clone()); + // Convert line boundary to a rectangular boundary using endpoint coordinates + let x = line.source_x.min(line.target_x); + let y = line.source_y.min(line.target_y); + let w = (line.target_x - line.source_x).abs().max(200.0); + let h = (line.target_y - line.source_y).abs().max(100.0); + thf_boundaries.push(TrustBoundary { + id, + name: line.name.clone(), + contains: Vec::new(), + position: Some(Position { x, y }), + size: Some(Size { + width: w, + height: h, + }), + fill_color: None, + stroke_color: None, + fill_opacity: None, + stroke_opacity: None, + }); + } + } + + // Build a lookup from element ID to its TM7 dimensions for accurate containment + let elem_dimensions: HashMap<&str, (f64, f64)> = border_elements + .iter() + .filter(|e| e.stencil_type != "BorderBoundary") + .filter_map(|e| { + guid_to_id + .get(&e.guid) + .map(|id| (id.as_str(), (e.width, e.height))) + }) + .collect(); + + // Determine containment: which elements are inside which boundaries + for boundary in &mut thf_boundaries { + let (bx, by, bw, bh) = match (&boundary.position, &boundary.size) { + (Some(p), Some(s)) => (p.x, p.y, s.width, s.height), + _ => continue, + }; + + for elem in &thf_elements { + if let Some(ref pos) = elem.position { + // Use actual element dimensions from TM7, falling back to 100x100 + let (ew, eh) = elem_dimensions + .get(elem.id.as_str()) + .copied() + .unwrap_or((100.0, 100.0)); + let cx = pos.x + ew / 2.0; + let cy = pos.y + eh / 2.0; + if cx >= bx && cx <= bx + bw && cy >= by && cy <= by + bh { + boundary.contains.push(elem.id.clone()); + } + } + } + } + + // Data flows + let mut thf_flows: Vec = Vec::new(); + let mut flow_counter: u32 = 0; + for line in &lines { + if line.line_type == "Connector" { + let from_id = guid_to_id.get(&line.source_guid).cloned(); + let to_id = guid_to_id.get(&line.target_guid).cloned(); + // Skip flows referencing out-of-scope or unknown elements + if let (Some(from), Some(to)) = (from_id, to_id) { + flow_counter += 1; + let flow_id = format!("flow-{flow_counter}"); + guid_to_id.insert(line.guid.clone(), flow_id.clone()); + thf_flows.push(DataFlow { + id: flow_id, + flow_number: Some(flow_counter), + name: line.name.clone(), + from, + to, + protocol: String::new(), + data: Vec::new(), + authenticated: false, + label_offset: None, + source_handle: None, + target_handle: None, + stroke_color: None, + stroke_opacity: None, + }); + } + } + } + + // Threats + let mut thf_threats: Vec = Vec::new(); + for tm7_threat in &threats { + let title = tm7_threat + .properties + .get("Title") + .cloned() + .unwrap_or_default(); + if title.is_empty() { + continue; + } + + let category_str = tm7_threat + .properties + .get("UserThreatCategory") + .cloned() + .unwrap_or_default(); + let category = parse_stride_category(&category_str, &tm7_threat.type_id); + + let description = tm7_threat + .properties + .get("UserThreatDescription") + .cloned() + .unwrap_or_default(); + + let severity = parse_severity(&tm7_threat.priority); + let mitigation = parse_mitigation_status(&tm7_threat.state, &tm7_threat.state_information); + + // Resolve element references via GUID mapping + let element = guid_to_id.get(&tm7_threat.source_guid).cloned(); + let flow = guid_to_id.get(&tm7_threat.flow_guid).cloned(); + + let threat_id = format!("threat-{}", thf_threats.len() + 1); + thf_threats.push(Threat { + id: threat_id, + title, + category, + element, + flow, + severity, + description, + mitigation: Some(mitigation), + }); + } + + let today = chrono::Local::now().date_naive(); + let title = if meta.name.is_empty() { + "Imported Threat Model".to_string() + } else { + meta.name + }; + + Ok(ThreatModel { + version: "1.0".to_string(), + metadata: Metadata { + title, + author: if meta.owner.is_empty() { + String::new() + } else { + meta.owner + }, + created: today, + modified: today, + description: meta.description, + created_by: None, + modified_by: None, + last_edit_timestamp: None, + settings: None, + }, + elements: thf_elements, + data_flows: thf_flows, + trust_boundaries: thf_boundaries, + threats: thf_threats, + diagrams: vec![Diagram { + id: "main-dfd".to_string(), + name: diagram_name, + layout_file: None, + viewport: Some(Viewport { + x: 0.0, + y: 0.0, + zoom: 1.0, + }), + }], + }) +} + +// --------------------------------------------------------------------------- +// XML parsing helpers +// --------------------------------------------------------------------------- + +/// Extract the local name (without namespace prefix) from an XML element. +fn local_name(e: &BytesStart<'_>) -> String { + let binding = e.local_name(); + let full = String::from_utf8_lossy(binding.as_ref()); + full.to_string() +} + +/// Read all text content until the matching end tag, skipping nested elements. +fn read_text_content(reader: &mut Reader<&[u8]>) -> Result { + let mut buf = Vec::new(); + let mut text = String::new(); + let mut depth: u32 = 1; + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Text(e)) => { + if depth == 1 { + // Use unwrap_or_default for resilience — malformed XML entities + // (e.g. &invalid;) produce empty text rather than failing the import. + text.push_str(&e.unescape().unwrap_or_default()); + } + } + Ok(Event::Start(_)) => { + depth += 1; + } + Ok(Event::End(_)) => { + depth -= 1; + if depth == 0 { + break; + } + } + Ok(Event::Eof) => break, + Err(e) => return Err(Tm7Error::Xml(e)), + _ => {} + } + buf.clear(); + } + Ok(text) +} + +/// Skip over an element and all its children until the matching end tag. +fn skip_element(reader: &mut Reader<&[u8]>) -> Result<(), Tm7Error> { + let mut buf = Vec::new(); + let mut depth: u32 = 1; + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(_)) => depth += 1, + Ok(Event::End(_)) => { + depth -= 1; + if depth == 0 { + break; + } + } + Ok(Event::Eof) => break, + Err(e) => return Err(Tm7Error::Xml(e)), + _ => {} + } + buf.clear(); + } + Ok(()) +} + +/// Get the value of the `i:type` attribute from a start element. +fn get_itype(e: &BytesStart<'_>) -> Option { + for attr in e.attributes().flatten() { + let key = String::from_utf8_lossy(attr.key.as_ref()); + if key == "i:type" || key.ends_with(":type") { + return Some(String::from_utf8_lossy(&attr.value).to_string()); + } + } + None +} + +/// Check if an element has `i:nil="true"`. +fn is_nil(e: &BytesStart<'_>) -> bool { + for attr in e.attributes().flatten() { + let key = String::from_utf8_lossy(attr.key.as_ref()); + if key == "i:nil" || key.ends_with(":nil") { + return String::from_utf8_lossy(&attr.value) == "true"; + } + } + false +} + +// --------------------------------------------------------------------------- +// Section parsers +// --------------------------------------------------------------------------- + +fn parse_meta_information(reader: &mut Reader<&[u8]>) -> Result { + let mut meta = Tm7Meta::default(); + let mut buf = Vec::new(); + let mut depth: u32 = 1; + + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(ref e)) => { + depth += 1; + let name = local_name(e); + match name.as_str() { + "ThreatModelName" => meta.name = read_text_content(reader)?, + "Owner" => meta.owner = read_text_content(reader)?, + "HighLevelSystemDescription" => { + meta.description = read_text_content(reader)?; + } + _ => {} + } + // read_text_content consumes the end tag, so adjust depth + if matches!( + name.as_str(), + "ThreatModelName" | "Owner" | "HighLevelSystemDescription" + ) { + depth -= 1; + } + } + Ok(Event::End(_)) => { + depth -= 1; + if depth == 0 { + break; + } + } + Ok(Event::Eof) => break, + Err(e) => return Err(Tm7Error::Xml(e)), + _ => {} + } + buf.clear(); + } + Ok(meta) +} + +fn parse_drawing_surface( + reader: &mut Reader<&[u8]>, +) -> Result<(String, Vec, Vec), Tm7Error> { + let mut buf = Vec::new(); + let mut depth: u32 = 1; + let mut diagram_name = String::new(); + let mut borders: Vec = Vec::new(); + let mut lines: Vec = Vec::new(); + let mut found_borders = false; + + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(ref e)) => { + depth += 1; + let name = local_name(e); + match name.as_str() { + "Borders" => { + found_borders = true; + borders = parse_borders(reader)?; + depth -= 1; // parse_borders consumed end tag + } + "Lines" => { + lines = parse_lines(reader)?; + depth -= 1; + } + // Properties before Borders is the diagram-level properties + "Properties" if !found_borders => { + diagram_name = parse_properties_name(reader)?; + depth -= 1; + } + _ => {} + } + } + Ok(Event::End(_)) => { + depth -= 1; + if depth == 0 { + break; + } + } + Ok(Event::Eof) => break, + Err(e) => return Err(Tm7Error::Xml(e)), + _ => {} + } + buf.clear(); + } + Ok((diagram_name, borders, lines)) +} + +/// Parse the `` section containing stencil elements and boundary boxes. +fn parse_borders(reader: &mut Reader<&[u8]>) -> Result, Tm7Error> { + let mut elements = Vec::new(); + let mut buf = Vec::new(); + let mut depth: u32 = 1; + + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(ref e)) => { + depth += 1; + let name = local_name(e); + if name == "Value" { + let itype = get_itype(e).unwrap_or_default(); + let elem = parse_border_value(reader, &itype)?; + elements.push(elem); + depth -= 1; // parse_border_value consumed end tag + } + } + Ok(Event::End(_)) => { + depth -= 1; + if depth == 0 { + break; + } + } + Ok(Event::Eof) => break, + Err(e) => return Err(Tm7Error::Xml(e)), + _ => {} + } + buf.clear(); + } + Ok(elements) +} + +/// Parse a single `` element inside ``. +fn parse_border_value(reader: &mut Reader<&[u8]>, itype: &str) -> Result { + let mut elem = Tm7Element { + stencil_type: itype.to_string(), + ..Default::default() + }; + let mut buf = Vec::new(); + let mut depth: u32 = 1; + + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(ref e)) => { + depth += 1; + let name = local_name(e); + match name.as_str() { + "GenericTypeId" => { + elem.generic_type_id = read_text_content(reader)?; + depth -= 1; + } + "Guid" => { + elem.guid = read_text_content(reader)?; + depth -= 1; + } + "Left" => { + elem.left = read_text_content(reader)?.parse().unwrap_or(0.0); + depth -= 1; + } + "Top" => { + elem.top = read_text_content(reader)?.parse().unwrap_or(0.0); + depth -= 1; + } + "Width" => { + elem.width = read_text_content(reader)?.parse().unwrap_or(100.0); + depth -= 1; + } + "Height" => { + elem.height = read_text_content(reader)?.parse().unwrap_or(100.0); + depth -= 1; + } + "Properties" => { + let (prop_name, out_of_scope) = parse_stencil_properties(reader)?; + elem.name = prop_name; + elem.out_of_scope = out_of_scope; + depth -= 1; + } + _ => {} + } + } + Ok(Event::End(_)) => { + depth -= 1; + if depth == 0 { + break; + } + } + Ok(Event::Eof) => break, + Err(e) => return Err(Tm7Error::Xml(e)), + _ => {} + } + buf.clear(); + } + Ok(elem) +} + +/// Parse `` section containing connectors and line boundaries. +fn parse_lines(reader: &mut Reader<&[u8]>) -> Result, Tm7Error> { + let mut lines = Vec::new(); + let mut buf = Vec::new(); + let mut depth: u32 = 1; + + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(ref e)) => { + depth += 1; + let name = local_name(e); + if name == "Value" { + let itype = get_itype(e).unwrap_or_default(); + let line = parse_line_value(reader, &itype)?; + lines.push(line); + depth -= 1; + } + } + Ok(Event::End(_)) => { + depth -= 1; + if depth == 0 { + break; + } + } + Ok(Event::Eof) => break, + Err(e) => return Err(Tm7Error::Xml(e)), + _ => {} + } + buf.clear(); + } + Ok(lines) +} + +/// Parse a single `` element inside ``. +fn parse_line_value(reader: &mut Reader<&[u8]>, itype: &str) -> Result { + let mut line = Tm7Line { + line_type: itype.to_string(), + ..Default::default() + }; + let mut buf = Vec::new(); + let mut depth: u32 = 1; + + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(ref e)) => { + depth += 1; + let name = local_name(e); + match name.as_str() { + "GenericTypeId" => { + line.generic_type_id = read_text_content(reader)?; + depth -= 1; + } + "Guid" => { + line.guid = read_text_content(reader)?; + depth -= 1; + } + "SourceGuid" => { + line.source_guid = read_text_content(reader)?; + depth -= 1; + } + "TargetGuid" => { + line.target_guid = read_text_content(reader)?; + depth -= 1; + } + "SourceX" => { + line.source_x = read_text_content(reader)?.parse().unwrap_or(0.0); + depth -= 1; + } + "SourceY" => { + line.source_y = read_text_content(reader)?.parse().unwrap_or(0.0); + depth -= 1; + } + "TargetX" => { + line.target_x = read_text_content(reader)?.parse().unwrap_or(0.0); + depth -= 1; + } + "TargetY" => { + line.target_y = read_text_content(reader)?.parse().unwrap_or(0.0); + depth -= 1; + } + "Properties" => { + line.name = parse_properties_name(reader)?; + depth -= 1; + } + _ => {} + } + } + Ok(Event::End(_)) => { + depth -= 1; + if depth == 0 { + break; + } + } + Ok(Event::Eof) => break, + Err(e) => return Err(Tm7Error::Xml(e)), + _ => {} + } + buf.clear(); + } + + // Detect line boundaries by checking for nil GUIDs + if line.line_type == "LineBoundary" + || line.generic_type_id == "GE.TB.L" + || line.generic_type_id.starts_with("GE.TB") + { + line.line_type = "LineBoundary".to_string(); + } + + Ok(line) +} + +/// Parse `` section. +fn parse_threat_instances(reader: &mut Reader<&[u8]>) -> Result, Tm7Error> { + let mut threats = Vec::new(); + let mut buf = Vec::new(); + let mut depth: u32 = 1; + + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(ref e)) => { + depth += 1; + let name = local_name(e); + // The threat value element name varies but always contains "Value" + if name == "Value" { + let threat = parse_threat_value(reader)?; + threats.push(threat); + depth -= 1; + } + } + Ok(Event::End(_)) => { + depth -= 1; + if depth == 0 { + break; + } + } + Ok(Event::Eof) => break, + Err(e) => return Err(Tm7Error::Xml(e)), + _ => {} + } + buf.clear(); + } + Ok(threats) +} + +/// Parse a single threat `` element. +fn parse_threat_value(reader: &mut Reader<&[u8]>) -> Result { + let mut threat = Tm7Threat::default(); + let mut buf = Vec::new(); + let mut depth: u32 = 1; + + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(ref e)) => { + depth += 1; + let name = local_name(e); + if is_nil(e) { + skip_element(reader)?; + depth -= 1; + buf.clear(); + continue; + } + match name.as_str() { + "Id" => { + threat.id = read_text_content(reader)?; + depth -= 1; + } + "FlowGuid" => { + threat.flow_guid = read_text_content(reader)?; + depth -= 1; + } + "SourceGuid" => { + threat.source_guid = read_text_content(reader)?; + depth -= 1; + } + "TargetGuid" => { + threat.target_guid = read_text_content(reader)?; + depth -= 1; + } + "Priority" => { + threat.priority = read_text_content(reader)?; + depth -= 1; + } + "State" => { + threat.state = read_text_content(reader)?; + depth -= 1; + } + "StateInformation" => { + threat.state_information = read_text_content(reader)?; + depth -= 1; + } + "TypeId" => { + threat.type_id = read_text_content(reader)?; + depth -= 1; + } + "Properties" => { + threat.properties = parse_threat_properties(reader)?; + depth -= 1; + } + _ => {} + } + } + Ok(Event::Empty(_)) => { + // Self-closing nil elements (e.g. ) — skip + } + Ok(Event::End(_)) => { + depth -= 1; + if depth == 0 { + break; + } + } + Ok(Event::Eof) => break, + Err(e) => return Err(Tm7Error::Xml(e)), + _ => {} + } + buf.clear(); + } + + // Fallback: if priority is empty, try from properties + if threat.priority.is_empty() { + if let Some(p) = threat.properties.get("Priority") { + threat.priority = p.clone(); + } + } + + Ok(threat) +} + +/// Parse key-value properties inside a threat `` element. +fn parse_threat_properties( + reader: &mut Reader<&[u8]>, +) -> Result, Tm7Error> { + let mut props = HashMap::new(); + let mut buf = Vec::new(); + let mut depth: u32 = 1; + let mut current_key = String::new(); + + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(ref e)) => { + depth += 1; + let name = local_name(e); + if name == "Key" { + current_key = read_text_content(reader)?; + depth -= 1; + } else if name == "Value" { + let value = read_text_content(reader)?; + if !current_key.is_empty() { + props.insert(current_key.clone(), value); + } + depth -= 1; + } + } + Ok(Event::Empty(ref e)) => { + // Handle self-closing + let name = local_name(e); + if name == "Value" && !current_key.is_empty() { + props.insert(current_key.clone(), String::new()); + } + } + Ok(Event::End(_)) => { + depth -= 1; + if depth == 0 { + break; + } + } + Ok(Event::Eof) => break, + Err(e) => return Err(Tm7Error::Xml(e)), + _ => {} + } + buf.clear(); + } + Ok(props) +} + +/// Extract the "Name" property from a `` element. +/// Also returns whether "Out Of Scope" is true. +fn parse_stencil_properties(reader: &mut Reader<&[u8]>) -> Result<(String, bool), Tm7Error> { + let mut name = String::new(); + let mut out_of_scope = false; + let mut buf = Vec::new(); + let mut depth: u32 = 1; + let mut current_display_name = String::new(); + + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(ref e)) => { + depth += 1; + let local = local_name(e); + match local.as_str() { + "DisplayName" => { + current_display_name = read_text_content(reader)?; + depth -= 1; + } + "Value" => { + if is_nil(e) { + skip_element(reader)?; + depth -= 1; + current_display_name.clear(); + buf.clear(); + continue; + } + let value = read_text_content(reader)?; + match current_display_name.as_str() { + "Name" => name = value, + "Out Of Scope" => out_of_scope = value == "true", + _ => {} + } + current_display_name.clear(); + depth -= 1; + } + _ => {} + } + } + Ok(Event::Empty(ref e)) => { + // Handle self-closing Value with i:nil="true" + let local = local_name(e); + if local == "Value" && current_display_name == "Name" && is_nil(e) { + // Name is nil — keep empty + } + } + Ok(Event::End(_)) => { + depth -= 1; + if depth == 0 { + break; + } + } + Ok(Event::Eof) => break, + Err(e) => return Err(Tm7Error::Xml(e)), + _ => {} + } + buf.clear(); + } + Ok((name, out_of_scope)) +} + +/// Extract just the "Name" property from a `` element. +fn parse_properties_name(reader: &mut Reader<&[u8]>) -> Result { + let (name, _) = parse_stencil_properties(reader)?; + Ok(name) +} + +// --------------------------------------------------------------------------- +// Conversion helpers +// --------------------------------------------------------------------------- + +/// Generate a unique kebab-case ID from a name. +fn make_unique_id(name: &str, fallback: &str, counter: &mut HashMap) -> String { + let base = if name.is_empty() { + fallback.to_string() + } else { + name.to_lowercase() + .chars() + .map(|c| if c.is_alphanumeric() { c } else { '-' }) + .collect::() + .split('-') + .filter(|s| !s.is_empty()) + .collect::>() + .join("-") + }; + + let entry = counter.entry(base.clone()).or_insert(0); + *entry += 1; + if *entry == 1 { + base + } else { + format!("{base}-{}", *entry) + } +} + +/// Map a TM7 STRIDE category string or TypeId prefix to our `StrideCategory`. +fn parse_stride_category(category: &str, type_id: &str) -> StrideCategory { + // Try the explicit category string first + match category { + "Spoofing" => return StrideCategory::Spoofing, + "Tampering" => return StrideCategory::Tampering, + "Repudiation" => return StrideCategory::Repudiation, + "Information Disclosure" => return StrideCategory::InformationDisclosure, + "Denial Of Service" | "Denial of Service" => return StrideCategory::DenialOfService, + "Elevation Of Privilege" | "Elevation of Privilege" => { + return StrideCategory::ElevationOfPrivilege; + } + _ => {} + } + + // Fallback: infer from TypeId prefix (S1, T7, I23, etc.) + match type_id.chars().next() { + Some('S') => StrideCategory::Spoofing, + Some('T') => StrideCategory::Tampering, + Some('R') => StrideCategory::Repudiation, + Some('I') => StrideCategory::InformationDisclosure, + Some('D') => StrideCategory::DenialOfService, + Some('E') => StrideCategory::ElevationOfPrivilege, + _ => StrideCategory::Tampering, // safe default + } +} + +/// Map a TM7 priority string to our `Severity`. +fn parse_severity(priority: &str) -> Severity { + match priority { + "Critical" => Severity::Critical, + "High" => Severity::High, + "Medium" => Severity::Medium, + "Low" => Severity::Low, + _ => Severity::Medium, + } +} + +/// Map a TM7 state to our `Mitigation` struct. +fn parse_mitigation_status(state: &str, justification: &str) -> Mitigation { + let status = match state { + "Mitigated" => MitigationStatus::Mitigated, + "NotApplicable" => MitigationStatus::Accepted, + "NeedsInvestigation" | "Not Started" => MitigationStatus::NotStarted, + // "AutoGenerated" and anything else → not started + _ => MitigationStatus::NotStarted, + }; + Mitigation { + status, + description: justification.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const MINIMAL_TM7: &str = r#" + + + + DRAWINGSURFACE + aaaa-bbbb + + + Name + Diagram 1 + + + DRAWINGSURFACE + + + guid-user + + GE.EI + guid-user + + + Name + User + + + some-type + 100 + 10 + 81 + 100 + + + + guid-webapp + + GE.P + guid-webapp + + + Name + Web Application + + + some-type + 100 + 449 + 94 + 100 + + + + guid-db + + GE.DS + guid-db + + + Name + SQL Database + + + some-type + 100 + 684 + 93 + 100 + + + + guid-boundary + + boundary-type + guid-boundary + + + Name + Internet DMZ + + + some-type + 231 + 222 + 26 + 611 + + + + + + guid-flow-1 + + GE.DF + guid-flow-1 + + + Name + HTTPS Request + + + some-type + 300 + 88 + guid-user + 110 + 131 + guid-webapp + 449 + 144 + + + + guid-flow-2 + + GE.DF + guid-flow-2 + + + Name + SQL + + + some-type + 624 + 88 + guid-webapp + 544 + 144 + guid-db + 684 + 143 + + + + + + + + + + A sample web application threat model. + Alice + + Sample Web App + + + + + T-guid-webapp-guid-flow-2-guid-db + + + aaaa-bbbb + guid-flow-2 + 1 + guid-webapp:guid-flow-2:guid-db + 0001-01-01T00:00:00 + High + + + Title + SQL Injection on Database + + + UserThreatCategory + Tampering + + + UserThreatDescription + An attacker could inject malicious SQL queries. + + + Priority + High + + + guid-webapp + Mitigated + Using parameterized queries. + guid-db + + T7 + false + + + + S-guid-user-guid-flow-1-guid-webapp + + + aaaa-bbbb + guid-flow-1 + 2 + guid-user:guid-flow-1:guid-webapp + 0001-01-01T00:00:00 + Medium + + + Title + Spoofing of User + + + UserThreatCategory + Spoofing + + + UserThreatDescription + An attacker could impersonate a legitimate user. + + + Priority + Medium + + + guid-user + AutoGenerated + + guid-webapp + + S1 + false + + + + true + 4.3 +"#; + + #[test] + fn test_parse_minimal_tm7() { + let model = parse_tm7(MINIMAL_TM7).expect("Failed to parse TM7"); + + // Metadata + assert_eq!(model.metadata.title, "Sample Web App"); + assert_eq!(model.metadata.author, "Alice"); + assert_eq!( + model.metadata.description, + "A sample web application threat model." + ); + + // Elements + assert_eq!(model.elements.len(), 3); + assert_eq!(model.elements[0].name, "User"); + assert_eq!(model.elements[0].element_type, "external_entity"); + assert_eq!( + model.elements[0].position, + Some(Position { x: 10.0, y: 81.0 }) + ); + + assert_eq!(model.elements[1].name, "Web Application"); + assert_eq!(model.elements[1].element_type, "process"); + + assert_eq!(model.elements[2].name, "SQL Database"); + assert_eq!(model.elements[2].element_type, "data_store"); + + // Trust boundaries + assert_eq!(model.trust_boundaries.len(), 1); + assert_eq!(model.trust_boundaries[0].name, "Internet DMZ"); + assert_eq!( + model.trust_boundaries[0].position, + Some(Position { x: 222.0, y: 26.0 }) + ); + assert_eq!( + model.trust_boundaries[0].size, + Some(Size { + width: 611.0, + height: 231.0 + }) + ); + // Web Application and SQL Database should be inside the boundary + assert!(model.trust_boundaries[0] + .contains + .contains(&model.elements[1].id)); + assert!(model.trust_boundaries[0] + .contains + .contains(&model.elements[2].id)); + + // Data flows + assert_eq!(model.data_flows.len(), 2); + assert_eq!(model.data_flows[0].name, "HTTPS Request"); + assert_eq!(model.data_flows[0].from, model.elements[0].id); // User + assert_eq!(model.data_flows[0].to, model.elements[1].id); // Web Application + assert_eq!(model.data_flows[1].name, "SQL"); + assert_eq!(model.data_flows[1].from, model.elements[1].id); // Web App + assert_eq!(model.data_flows[1].to, model.elements[2].id); // SQL Database + + // Threats + assert_eq!(model.threats.len(), 2); + + assert_eq!(model.threats[0].title, "SQL Injection on Database"); + assert_eq!(model.threats[0].category, StrideCategory::Tampering); + assert_eq!(model.threats[0].severity, Severity::High); + assert_eq!(model.threats[0].flow, Some(model.data_flows[1].id.clone())); + assert_eq!( + model.threats[0].mitigation.as_ref().unwrap().status, + MitigationStatus::Mitigated + ); + assert_eq!( + model.threats[0].mitigation.as_ref().unwrap().description, + "Using parameterized queries." + ); + + assert_eq!(model.threats[1].title, "Spoofing of User"); + assert_eq!(model.threats[1].category, StrideCategory::Spoofing); + assert_eq!(model.threats[1].severity, Severity::Medium); + assert_eq!( + model.threats[1].mitigation.as_ref().unwrap().status, + MitigationStatus::NotStarted + ); + + // Diagram + assert_eq!(model.diagrams.len(), 1); + assert_eq!(model.diagrams[0].name, "Diagram 1"); + } + + #[test] + fn test_parse_empty_tm7() { + let xml = r#" + + + + + + + + + 4.3 +"#; + + let model = parse_tm7(xml).expect("Failed to parse empty TM7"); + assert_eq!(model.metadata.title, "Imported Threat Model"); + assert!(model.elements.is_empty()); + assert!(model.data_flows.is_empty()); + assert!(model.threats.is_empty()); + } + + #[test] + fn test_unique_id_generation() { + let mut counter = HashMap::new(); + assert_eq!( + make_unique_id("Web App", "process", &mut counter), + "web-app" + ); + assert_eq!( + make_unique_id("Web App", "process", &mut counter), + "web-app-2" + ); + assert_eq!( + make_unique_id("Web App", "process", &mut counter), + "web-app-3" + ); + assert_eq!(make_unique_id("DB", "data_store", &mut counter), "db"); + } + + #[test] + fn test_stride_category_parsing() { + assert_eq!( + parse_stride_category("Spoofing", "S1"), + StrideCategory::Spoofing + ); + assert_eq!( + parse_stride_category("Information Disclosure", "I23"), + StrideCategory::InformationDisclosure + ); + assert_eq!( + parse_stride_category("Denial Of Service", "D5"), + StrideCategory::DenialOfService + ); + // Fallback from type_id prefix + assert_eq!( + parse_stride_category("Unknown", "E12"), + StrideCategory::ElevationOfPrivilege + ); + assert_eq!(parse_stride_category("", "R3"), StrideCategory::Repudiation); + } + + #[test] + fn test_severity_parsing() { + assert_eq!(parse_severity("High"), Severity::High); + assert_eq!(parse_severity("Medium"), Severity::Medium); + assert_eq!(parse_severity("Low"), Severity::Low); + assert_eq!(parse_severity("Critical"), Severity::Critical); + assert_eq!(parse_severity("Unknown"), Severity::Medium); + } + + #[test] + fn test_mitigation_status_parsing() { + let m = parse_mitigation_status("Mitigated", "Fixed it"); + assert_eq!(m.status, MitigationStatus::Mitigated); + assert_eq!(m.description, "Fixed it"); + + let m = parse_mitigation_status("AutoGenerated", ""); + assert_eq!(m.status, MitigationStatus::NotStarted); + + let m = parse_mitigation_status("NotApplicable", "Not relevant"); + assert_eq!(m.status, MitigationStatus::Accepted); + } + + #[test] + fn test_out_of_scope_elements_excluded() { + let xml = r#" + + + + DRAWINGSURFACE + ds-1 + + + Name + Diagram 1 + + + DRAWINGSURFACE + + + guid-in-scope + + GE.P + guid-in-scope + + + Name + In Scope App + + + Out Of Scope + false + + + t + 100 + 100 + 100 + 100 + + + + guid-out-of-scope + + GE.P + guid-out-of-scope + + + Name + Out Of Scope App + + + Out Of Scope + true + + + t + 100 + 400 + 100 + 100 + + + + + + + Scope Test + + 4.3 +"#; + + let model = parse_tm7(xml).expect("Failed to parse"); + assert_eq!( + model.elements.len(), + 1, + "Out-of-scope element should be excluded" + ); + assert_eq!(model.elements[0].name, "In Scope App"); + } + + #[test] + fn test_threat_with_orphaned_flow_guid() { + // Flow references an out-of-scope element, so it's skipped. + // The threat referencing that flow should still appear but with flow: None. + let xml = r#" + + + + DRAWINGSURFACE + ds-1 + + + Name + D + + + DRAWINGSURFACE + + + guid-a + + GE.P + guid-a + + + Name + App A + + + t + 100 + 0 + 0 + 100 + + + + + + + Orphan Test + + + T-orphan + + guid-nonexistent-flow + 1 + High + + TitleOrphaned Threat + UserThreatCategoryTampering + UserThreatDescriptionDesc + + guid-a + AutoGenerated + + guid-unknown + T1 + + + + 4.3 +"#; + + let model = parse_tm7(xml).expect("Failed to parse"); + assert_eq!(model.threats.len(), 1); + assert_eq!(model.threats[0].title, "Orphaned Threat"); + assert!( + model.threats[0].flow.is_none(), + "Flow should be None when GUID doesn't resolve" + ); + assert_eq!( + model.threats[0].element, + Some("app-a".to_string()), + "Source element should still resolve" + ); + } + + #[test] + fn test_duplicate_element_names_get_unique_ids() { + let xml = r#" + + + + DRAWINGSURFACE + ds-1 + + + Name + D + + + DRAWINGSURFACE + + + guid-1 + + GE.P + guid-1 + + + Name + Web App + + + t + 100 + 0 + 0 + 100 + + + + guid-2 + + GE.P + guid-2 + + + Name + Web App + + + t + 100 + 200 + 0 + 100 + + + + + + + Dup Test + + 4.3 +"#; + + let model = parse_tm7(xml).expect("Failed to parse"); + assert_eq!(model.elements.len(), 2); + assert_eq!(model.elements[0].id, "web-app"); + assert_eq!(model.elements[1].id, "web-app-2"); + } + + #[test] + fn test_line_boundary_parsed() { + let xml = r#" + + + + DRAWINGSURFACE + ds-1 + + + Name + D + + + DRAWINGSURFACE + + + + guid-lb + + GE.TB.L + guid-lb + + + Name + Internet Boundary + + + t + 00000000-0000-0000-0000-000000000000 + 100 + 10 + 00000000-0000-0000-0000-000000000000 + 100 + 350 + + + + + + Line Boundary Test + + 4.3 +"#; + + let model = parse_tm7(xml).expect("Failed to parse"); + assert_eq!(model.trust_boundaries.len(), 1); + assert_eq!(model.trust_boundaries[0].name, "Internet Boundary"); + // Line boundary should be converted to a rectangle + assert!(model.trust_boundaries[0].position.is_some()); + assert!(model.trust_boundaries[0].size.is_some()); + let size = model.trust_boundaries[0].size.as_ref().unwrap(); + assert!(size.width >= 200.0, "Width should be at least 200 (min)"); + assert_eq!(size.height, 340.0, "Height should be |350 - 10|"); + } + + #[test] + fn test_malformed_xml_returns_error() { + let xml = " + + + + + + guid-node + + GE.P + guid-node + + + Name + Server + + + t + 80 + 50 + 50 + 80 + + + + + + guid-selfloop + + GE.DF + guid-selfloop + + + Name + Self-refresh + + + t + guid-node + guid-node + + + + + + Self Loop Test + +"#; + + let model = parse_tm7(xml).expect("Failed to parse self-loop TM7"); + assert_eq!(model.data_flows.len(), 1); + assert_eq!( + model.data_flows[0].from, model.data_flows[0].to, + "Self-loop should have from == to" + ); + assert_eq!(model.data_flows[0].name, "Self-refresh"); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fbe17e2..4cf7b35 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ mod ai; mod commands; mod errors; mod file_io; +mod importers; pub mod mcp; mod menu; pub mod models; @@ -9,8 +10,8 @@ mod stride; use commands::{ analyze_stride, cancel_chat_stream, check_for_update, create_new_model, delete_api_key, - get_api_key_status, install_update, open_layout, open_threat_model, save_layout, - save_threat_model, send_chat_message, set_api_key, write_text_file, + get_api_key_status, import_threat_model, install_update, open_layout, open_threat_model, + save_layout, save_threat_model, send_chat_message, set_api_key, write_text_file, }; use std::sync::atomic::AtomicBool; use std::sync::Arc; @@ -79,6 +80,7 @@ pub fn run() { send_chat_message, cancel_chat_stream, write_text_file, + import_threat_model, check_for_update, install_update, ]) diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index fd27be7..688a58c 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -27,6 +27,8 @@ pub fn build_menu(app: &App) -> Result, Box> { true, Some("CmdOrCtrl+Shift+E"), )?; + let file_import = + MenuItem::with_id(handle, "file-import", "Import\u{2026}", true, None::<&str>)?; let file_close = MenuItem::with_id( handle, "file-close", @@ -42,6 +44,7 @@ pub fn build_menu(app: &App) -> Result, Box> { &[ &file_new, &file_open, + &file_import, &PredefinedMenuItem::separator(handle)?, &file_save, &file_save_as, diff --git a/src/components/command-palette.tsx b/src/components/command-palette.tsx index 80716d9..8559e00 100644 --- a/src/components/command-palette.tsx +++ b/src/components/command-palette.tsx @@ -23,7 +23,7 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) { const inputRef = useRef(null); const listRef = useRef(null); - const { newModel, openModel, saveModel, saveModelAs } = useFileOperations(); + const { newModel, openModel, importModel, saveModel, saveModelAs } = useFileOperations(); const hasModel = useModelStore((s) => s.model !== null); const allCommands = useMemo( @@ -31,11 +31,12 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) { buildCommands({ newModel: () => void newModel(), openModel: () => void openModel(), + importModel: () => void importModel(), saveModel: () => void saveModel(), saveModelAs: () => void saveModelAs(), hasModel, }), - [newModel, openModel, saveModel, saveModelAs, hasModel], + [newModel, openModel, importModel, saveModel, saveModelAs, hasModel], ); const filtered = useMemo(() => searchCommands(allCommands, query), [allCommands, query]); diff --git a/src/hooks/use-file-operations.ts b/src/hooks/use-file-operations.ts index ff3251d..0147b8a 100644 --- a/src/hooks/use-file-operations.ts +++ b/src/hooks/use-file-operations.ts @@ -200,6 +200,41 @@ export function useFileOperations() { useCanvasStore.getState().syncFromModel(); }, [isDirty, clearModel]); + const importModel = useCallback(async () => { + if (isDirty) { + const adapter = await getFileAdapter(); + const discard = await adapter.confirmDiscard(); + if (!discard) return; + } + + const adapter = await getFileAdapter(); + let result: { model: ThreatModel } | null; + try { + result = await adapter.importThreatModel(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + window.alert(`Import failed: ${msg}`); + return; + } + if (!result) return; + + const { model: imported } = result; + + // Build layout from inline positions + const inlineLayout = buildLayoutFromModel(imported); + if (inlineLayout) { + useCanvasStore.getState().setPendingLayout(inlineLayout); + } else { + useCanvasStore.getState().setPendingLayout(null); + } + + // Imported models have no file path — user must Save As to create a .thf file + setModel(imported, null); + useHistoryStore.getState().clear(); + // TM7 imports have no ThreatForge file settings — clear to defaults + useSettingsStore.getState().clearFileSettings(); + }, [isDirty, setModel]); + const exportAsHtml = useCallback(async () => { if (!model) return; @@ -211,5 +246,5 @@ export function useFileOperations() { await adapter.exportAsHtml(html, defaultName); }, [model]); - return { newModel, openModel, saveModel, saveModelAs, closeModel, exportAsHtml }; + return { newModel, openModel, importModel, saveModel, saveModelAs, closeModel, exportAsHtml }; } diff --git a/src/hooks/use-native-menu.ts b/src/hooks/use-native-menu.ts index 5a89dda..849802a 100644 --- a/src/hooks/use-native-menu.ts +++ b/src/hooks/use-native-menu.ts @@ -32,7 +32,7 @@ async function openFileByPath(filePath: string) { * No-op in web builds where Tauri APIs are unavailable. */ export function useNativeMenu() { - const { newModel, openModel, saveModel, saveModelAs, closeModel, exportAsHtml } = + const { newModel, openModel, importModel, saveModel, saveModelAs, closeModel, exportAsHtml } = useFileOperations(); useEffect(() => { @@ -60,6 +60,9 @@ export function useNativeMenu() { case "file-open": void openModel(); break; + case "file-import": + void importModel(); + break; case "file-save": void saveModel(); break; @@ -182,5 +185,5 @@ export function useNativeMenu() { unlistenMenu?.(); unlistenFile?.(); }; - }, [newModel, openModel, saveModel, saveModelAs, closeModel, exportAsHtml]); + }, [newModel, openModel, importModel, saveModel, saveModelAs, closeModel, exportAsHtml]); } diff --git a/src/lib/adapters/browser-file-adapter.ts b/src/lib/adapters/browser-file-adapter.ts index f79234b..353fb1b 100644 --- a/src/lib/adapters/browser-file-adapter.ts +++ b/src/lib/adapters/browser-file-adapter.ts @@ -60,6 +60,11 @@ export class BrowserFileAdapter implements FileAdapter { return { model: parsed, path: file.name }; } + async importThreatModel(): Promise<{ model: ThreatModel } | null> { + window.alert("Import is only available in the desktop app."); + return null; + } + async saveThreatModel(model: ThreatModel, _path: string | null): Promise { const yamlString = yaml.dump(model, { lineWidth: -1, diff --git a/src/lib/adapters/file-adapter.ts b/src/lib/adapters/file-adapter.ts index b02221e..68fa478 100644 --- a/src/lib/adapters/file-adapter.ts +++ b/src/lib/adapters/file-adapter.ts @@ -11,6 +11,8 @@ export interface FileAdapter { createNewModel(title: string, author: string): Promise; /** Show an open dialog and load a threat model from YAML. */ openThreatModel(): Promise<{ model: ThreatModel; path: string | null } | null>; + /** Show an open dialog for importing from external formats (TM7, etc.) and convert to ThreatModel. */ + importThreatModel(): Promise<{ model: ThreatModel } | null>; /** Save a threat model to YAML. If no path provided, show save dialog. */ saveThreatModel(model: ThreatModel, path: string | null): Promise; /** @deprecated Layout data is now inline in the YAML. Kept for old-format fallback. */ diff --git a/src/lib/adapters/tauri-file-adapter.ts b/src/lib/adapters/tauri-file-adapter.ts index 9bf5b4a..3ac3163 100644 --- a/src/lib/adapters/tauri-file-adapter.ts +++ b/src/lib/adapters/tauri-file-adapter.ts @@ -8,6 +8,11 @@ const YAML_FILTER = { extensions: ["thf"], }; +const IMPORT_FILTER = { + name: "Microsoft Threat Model", + extensions: ["tm7"], +}; + export class TauriFileAdapter implements FileAdapter { async createNewModel(title: string, author: string): Promise { return invoke("create_new_model", { title, author }); @@ -20,9 +25,22 @@ export class TauriFileAdapter implements FileAdapter { }); if (!selected) return null; - const path = typeof selected === "string" ? selected : selected; - const model = await invoke("open_threat_model", { path }); - return { model, path }; + const model = await invoke("open_threat_model", { path: selected }); + return { model, path: selected }; + } + + async importThreatModel(): Promise<{ model: ThreatModel } | null> { + const selected = await open({ + multiple: false, + filters: [IMPORT_FILTER], + }); + if (!selected) return null; + + const ext = selected.split(".").pop()?.toLowerCase() ?? ""; + const format = ext === "tm7" ? "tm7" : ext; + + const model = await invoke("import_threat_model", { path: selected, format }); + return { model }; } async saveThreatModel(model: ThreatModel, path: string | null): Promise { diff --git a/src/lib/command-registry.test.ts b/src/lib/command-registry.test.ts index c3be62e..cf053c3 100644 --- a/src/lib/command-registry.test.ts +++ b/src/lib/command-registry.test.ts @@ -32,6 +32,7 @@ describe("command-registry", () => { const mockDeps = { newModel: vi.fn(), openModel: vi.fn(), + importModel: vi.fn(), saveModel: vi.fn(), saveModelAs: vi.fn(), hasModel: false, @@ -76,6 +77,14 @@ describe("command-registry", () => { expect(mockDeps.newModel).toHaveBeenCalled(); }); + it("file:import action calls importModel", () => { + const commands = buildCommands(mockDeps); + const importCmd = commands.find((c) => c.id === "file:import"); + expect(importCmd).toBeDefined(); + importCmd?.action(); + expect(mockDeps.importModel).toHaveBeenCalled(); + }); + it("excludes component commands when hasModel is false", () => { const commands = buildCommands({ ...mockDeps, hasModel: false }); const componentCmds = commands.filter((c) => c.category === "component"); @@ -101,6 +110,7 @@ describe("command-registry", () => { const mockDeps = { newModel: vi.fn(), openModel: vi.fn(), + importModel: vi.fn(), saveModel: vi.fn(), saveModelAs: vi.fn(), hasModel: false, diff --git a/src/lib/command-registry.ts b/src/lib/command-registry.ts index ff570c1..ec69f2f 100644 --- a/src/lib/command-registry.ts +++ b/src/lib/command-registry.ts @@ -44,6 +44,7 @@ function getViewportCenter(): { x: number; y: number } { export function buildCommands(deps: { newModel: () => void; openModel: () => void; + importModel: () => void; saveModel: () => void; saveModelAs: () => void; hasModel: boolean; @@ -64,6 +65,12 @@ export function buildCommands(deps: { shortcut: fmt("\u2318O", "Ctrl+O"), action: deps.openModel, }, + { + id: "file:import", + label: "Import\u2026", + category: "file", + action: deps.importModel, + }, { id: "file:save", label: "Save",