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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ ThreatForge fills that gap.

## Status

ThreatForge is production-ready. The core application is fully functional with a modern diagramming canvas (40+ typed components), STRIDE threat engine, AI chat pane (BYOK), 13+ themes, keyboard shortcuts, and more. See the [roadmap](docs/plans/roadmap.md) for remaining launch tasks and future plans.
ThreatForge is production-ready. The core application is fully functional with a modern diagramming canvas (44 typed components + text annotations), STRIDE threat engine, AI chat pane (BYOK), 13+ themes, keyboard shortcuts, and more. See the [roadmap](docs/plans/roadmap.md) for remaining launch tasks and future plans.

## Development

Expand Down
2 changes: 1 addition & 1 deletion docs/knowledge/file-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ version → metadata → elements → data_flows → trust_boundaries → threat
|---------|---------|
| `version` | Schema version (currently `"1.0"`). Always first field. |
| `metadata` | Title, author, dates, description, authoring info |
| `elements` | DFD nodes: processes, data stores, external entities |
| `elements` | DFD nodes: processes, data stores, external entities, text annotations |
| `data_flows` | Connections between elements with protocol and data info |
| `trust_boundaries` | Security boundary groups containing elements |
| `threats` | STRIDE threats linked to elements/flows |
Expand Down
1 change: 1 addition & 0 deletions docs/knowledge/glossary.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
| Data Store | Tampering, Information Disclosure, Denial of Service |
| External Entity | Spoofing, Repudiation |
| Data Flow | Tampering, Information Disclosure, Denial of Service |
| Text (Annotation) | None — excluded from STRIDE analysis |

## Comparable Open-Source Projects

Expand Down
4 changes: 2 additions & 2 deletions docs/knowledge/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ ThreatForge is an open-source, AI-enhanced, cross-platform desktop threat modeli

ThreatForge's differentiation is the combination of three things no other tool brings together:

1. **UX gap filled** — A modern, keyboard-shortcut-driven interface with drag-and-drop canvas, 40+ typed components, custom themes, and resizable panes. The kind of experience developers expect from tools like Linear, Figma, or VS Code.
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.

Expand All @@ -36,7 +36,7 @@ ThreatForge's differentiation is the combination of three things no other tool b

ThreatForge is production-ready. The core application is fully functional:

- Modern diagramming canvas with 40+ typed components, drag-and-drop, trust boundaries
- Modern diagramming canvas with 44 typed components, drag-and-drop, trust boundaries, text annotations
- STRIDE threat engine with auto-generated threats per element
- AI chat pane (BYOK) with OpenAI, Anthropic, and Ollama support
- `.thf` YAML file format with clean git diffs
Expand Down
4 changes: 2 additions & 2 deletions docs/knowledge/product-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@

| Feature | Description |
|---------|-------------|
| Diagramming canvas | DFD canvas with drag-and-drop, trust boundaries, data flows, 40+ typed components |
| Diagramming canvas | DFD canvas with drag-and-drop, trust boundaries, data flows, text annotations, 44 typed components |
| `.thf` YAML file format | Human-readable, git-diffable, schema-validated, single-file |
| STRIDE threat engine | Auto-generated threats per element type, rule-based |
| AI chat pane (BYOK) | OpenAI, Anthropic, Ollama support; conversational threat analysis |
| Cross-platform | macOS, Windows, Linux via Tauri v2 |
| Component library | 40+ typed components across 9 categories with lucide icons |
| Component library | 44 typed components across 10 categories (incl. Annotations) with lucide icons |
| Themes | 13+ themes (light and dark), separate light/dark selection |
| Keyboard shortcuts | 27+ shortcuts, Cmd+K command palette |
| Resizable panes | Draggable left/right panels with min/max constraints |
Expand Down
2 changes: 1 addition & 1 deletion docs/plans/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ ThreatForge is production-ready. All core features are implemented and tested.

### What's Done

- Diagramming canvas with 40+ typed components, drag-and-drop, trust boundaries, self-loops
- Diagramming canvas with 44 typed components + text annotations, drag-and-drop, trust boundaries, self-loops
- `.thf` YAML file format (single-file, inline layout, clean git diffs)
- STRIDE threat engine with auto-generated threats per element type
- AI chat pane (BYOK: OpenAI, Anthropic, Ollama)
Expand Down
58 changes: 58 additions & 0 deletions docs/plans/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,64 @@ Shared execution plan for humans and LLM agents. Update this file before, during

---

## 2026-03-03 — Text Element Feature (Architecture Annotations)

### Context
Adding a Text element type so users can place annotation labels, callouts, and descriptions directly on the canvas. Text elements are non-connectable and excluded from STRIDE analysis.

### Plan
- [x] Step 1: Data model — add `font_size`, `font_weight` to Element (Rust + TS)
- [x] Step 2: Component library — add "text" entry with `ShapeCategory: "text"`, `StrideCategory: "none"`
- [x] Step 3: STRIDE engine exclusion (TS skip "none" category, Rust add `Annotation` variant)
- [x] Step 4: Canvas store — route text elements to `textAnnotation` node type
- [x] Step 5: Create `TextAnnotationNode` component (borderless, no handles, inline edit)
- [x] Step 6: Register `textAnnotation` in DfdCanvas, reject text node connections
- [x] Step 7: Add "Text" to component palette GENERIC_ITEMS
- [x] Step 8: Properties panel — text-specific controls (hide irrelevant fields, add font size/weight)
- [x] Step 9: Tests — component library, STRIDE engine, Rust round-trip + backward compat
- [x] Step 10: Lint & validate — biome, tsc, vitest (415/415), cargo clippy, cargo test (59/59), cargo fmt

### Deep Audit (breadth pass)
- [x] AI prompts: add "text" to element type lists in `ai-prompt.ts` and `prompt.rs`
- [x] MCP server: add "text" to `VALID_ELEMENT_TYPES` in `mcp/server.rs`
- [x] Context menu: hide "View Threats" for text annotation nodes (`canvas-context-menu.tsx` + `dfd-canvas.tsx`)
- [x] Context menu test: added test for `isTextAnnotation` flag excluding "View Threats"
- [x] Docs: update component counts (44 typed + text annotations) in `README.md`, `overview.md`, `product-design.md`, `roadmap.md`
- [x] Docs: add Text (Annotation) row to STRIDE mapping table in `glossary.md`
- [x] Docs: add text annotations to elements description in `file-format.md`
- [x] Onboarding: update component count in `whats-new-overlay.tsx` (28 → 44 + text annotations)
- [x] Validate: biome clean, tsc clean, vitest 416/416, cargo clippy clean, cargo test 59/59, cargo fmt clean

### Notes
- New ReactFlow node type `textAnnotation` (alongside `dfdElement` and `trustBoundary`)
- Text styling via existing `fill_color`/`fill_opacity` (text color) + new `font_size`/`font_weight`
- No breaking schema changes — new fields are optional with serde defaults

### Files Created
| File | Purpose |
|------|---------|
| `src/components/canvas/nodes/text-annotation-node.tsx` | Text element ReactFlow node component |

### Files Modified
| File | Change |
|------|--------|
| `src-tauri/src/models/threat_model.rs` | Added `font_size`, `font_weight` to `Element` + 3 round-trip tests |
| `src-tauri/src/stride/mod.rs` | Added `Annotation` category, skip text elements, test for zero threats |
| `src-tauri/src/ai/prompt.rs` | Updated Element struct literals with new fields |
| `src-tauri/src/mcp/server.rs` | Updated Element struct literal with new fields |
| `src/types/threat-model.ts` | Added `font_size`, `font_weight` to `Element` interface |
| `src/lib/component-library.ts` | Added `"text"` ShapeCategory, `"none"` StrideCategory, Type icon, text component def |
| `src/lib/component-library.test.ts` | Updated valid shape/stride lists, 5 new text component tests |
| `src/lib/stride-engine.ts` | Skip `"none"` category elements |
| `src/lib/stride-engine.test.ts` | 2 new tests: text produces zero threats, doesn't affect other counts |
| `src/stores/canvas-store.ts` | Route text to `textAnnotation` node type, added `fontSize`/`fontWeight` to DfdNodeData |
| `src/components/canvas/dfd-canvas.tsx` | Register `TextAnnotationNode`, reject text node connections |
| `src/components/palette/component-palette.tsx` | Added "Text" to GENERIC_ITEMS |
| `src/components/panels/properties-tab.tsx` | Text-specific properties (hide irrelevant, add font controls) |
| `docs/plans/todo.md` | This plan |

---

## 2026-03-03 — Revamp Pre-Made Templates (Show-Stopper Level)

### Context
Expand Down
4 changes: 3 additions & 1 deletion src-tauri/src/ai/prompt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ pub fn build_system_prompt(model: &ThreatModel) -> String {
Supported actions: add_element, update_element, delete_element, add_data_flow, update_data_flow, delete_data_flow, \
add_trust_boundary, update_trust_boundary, delete_trust_boundary, add_threat, update_threat, delete_threat.\n\
For updates, only include fields that need to change. For deletes, only the id is needed.\n\
Element types: \"process\", \"data_store\", \"external_entity\". \
Element types: \"process\", \"data_store\", \"external_entity\", \"text\" (annotation, no STRIDE threats). \
STRIDE categories: Spoofing, Tampering, Repudiation, Information Disclosure, Denial of Service, Elevation of Privilege. \
Severities: critical, high, medium, low, info.\n\
The user will review and approve actions before they are applied.\n\n",
Expand Down Expand Up @@ -197,6 +197,8 @@ mod tests {
stroke_color: None,
fill_opacity: None,
stroke_opacity: None,
font_size: None,
font_weight: None,
});

let prompt = build_system_prompt(&model);
Expand Down
4 changes: 3 additions & 1 deletion src-tauri/src/mcp/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ impl ThreatForgeServer {
}

/// Valid element types accepted by the MCP server.
const VALID_ELEMENT_TYPES: &[&str] = &["process", "data_store", "external_entity"];
const VALID_ELEMENT_TYPES: &[&str] = &["process", "data_store", "external_entity", "text"];

/// Generate a unique element ID, appending a suffix on collision.
fn unique_element_id(model: &ThreatModel, base: &str) -> String {
Expand Down Expand Up @@ -316,6 +316,8 @@ impl ThreatForgeServer {
stroke_color: None,
fill_opacity: None,
stroke_opacity: None,
font_size: None,
font_weight: None,
};
model.elements.push(element);
self.save_locked(&model)?;
Expand Down
66 changes: 66 additions & 0 deletions src-tauri/src/models/threat_model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@ pub struct Element {
pub fill_opacity: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stroke_opacity: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub font_size: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub font_weight: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
Expand Down Expand Up @@ -743,4 +747,66 @@ data_flows:
serde_json::from_str(&json).expect("Failed to deserialize layout");
assert_eq!(layout, deserialized);
}

#[test]
fn test_font_size_font_weight_round_trip() {
let yaml = r##"
version: "1.0"
metadata:
title: "Font Test"
author: "Test"
created: 2026-03-15
modified: 2026-03-15
elements:
- id: text-1
type: text
name: "API Gateway Cluster"
font_size: 20.0
font_weight: bold
fill_color: "#94A3B8"
fill_opacity: 0.8
"##;
let model: ThreatModel = serde_yaml::from_str(yaml).expect("Failed to parse");
assert_eq!(model.elements[0].element_type, "text");
assert_eq!(model.elements[0].font_size, Some(20.0));
assert_eq!(model.elements[0].font_weight, Some("bold".to_string()));

// Round-trip
let reserialized = serde_yaml::to_string(&model).expect("Failed to serialize");
let reparsed: ThreatModel = serde_yaml::from_str(&reserialized).expect("Failed to reparse");
assert_eq!(model, reparsed, "Round-trip should produce equal models");
}

#[test]
fn test_font_fields_omitted_when_none() {
let model = ThreatModel::new("Test", "Author");
let yaml = serde_yaml::to_string(&model).expect("Failed to serialize");
assert!(
!yaml.contains("font_size"),
"font_size should be omitted from YAML when None"
);
assert!(
!yaml.contains("font_weight"),
"font_weight should be omitted from YAML when None"
);
}

#[test]
fn test_old_yaml_without_font_fields_parses() {
let yaml = r#"
version: "1.0"
metadata:
title: "No Font Fields"
author: "Test"
created: 2026-03-15
modified: 2026-03-15
elements:
- id: app
type: process
name: "App"
"#;
let model: ThreatModel = serde_yaml::from_str(yaml).expect("Old format should parse");
assert!(model.elements[0].font_size.is_none());
assert!(model.elements[0].font_weight.is_none());
}
}
60 changes: 60 additions & 0 deletions src-tauri/src/stride/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ enum ComponentStrideCategory {
Service,
Store,
Actor,
Annotation,
}

/// Map a component type string to its STRIDE category.
Expand All @@ -22,6 +23,8 @@ fn stride_category_for_type(element_type: &str) -> ComponentStrideCategory {
// Clients / actors
"web_browser" | "mobile_app" | "desktop_app" | "iot_device" | "external_entity"
| "api_client" | "cli_tool" => ComponentStrideCategory::Actor,
// Annotations — excluded from STRIDE analysis
"text" => ComponentStrideCategory::Annotation,
// Everything else is a service (services, messaging, infra, security, networking, cloud, generic, process)
_ => ComponentStrideCategory::Service,
}
Expand Down Expand Up @@ -219,6 +222,9 @@ pub fn analyze(model: &ThreatModel) -> Vec<Threat> {
// Element-based rules
for element in &model.elements {
let element_category = stride_category_for_type(&element.element_type);
if element_category == ComponentStrideCategory::Annotation {
continue;
}
for rule in &rules {
if rule.targets_flows {
continue;
Expand Down Expand Up @@ -335,6 +341,8 @@ mod tests {
stroke_color: None,
fill_opacity: None,
stroke_opacity: None,
font_size: None,
font_weight: None,
},
Element {
id: "db".to_string(),
Expand All @@ -352,6 +360,8 @@ mod tests {
stroke_color: None,
fill_opacity: None,
stroke_opacity: None,
font_size: None,
font_weight: None,
},
Element {
id: "user".to_string(),
Expand All @@ -369,6 +379,8 @@ mod tests {
stroke_color: None,
fill_opacity: None,
stroke_opacity: None,
font_size: None,
font_weight: None,
},
],
data_flows: vec![DataFlow {
Expand Down Expand Up @@ -652,5 +664,53 @@ mod tests {
stride_category_for_type("cli_tool"),
ComponentStrideCategory::Actor
);

// Annotations
assert_eq!(
stride_category_for_type("text"),
ComponentStrideCategory::Annotation
);
}

#[test]
fn test_text_elements_produce_zero_threats() {
let mut model = sample_model();
model.elements.push(Element {
id: "text-1".to_string(),
element_type: "text".to_string(),
name: "API Gateway Cluster".to_string(),
trust_zone: String::new(),
subtype: None,
icon: None,
description: String::new(),
technologies: vec![],
stores: None,
encryption: None,
position: None,
fill_color: None,
stroke_color: None,
fill_opacity: None,
stroke_opacity: None,
font_size: Some(20.0),
font_weight: Some("bold".to_string()),
});

let threats = analyze(&model);
let text_threats: Vec<_> = threats
.iter()
.filter(|t| t.element.as_deref() == Some("text-1"))
.collect();
assert_eq!(
text_threats.len(),
0,
"Text annotation elements should produce zero STRIDE threats"
);

// Total count should remain the same as without the text element (14)
assert_eq!(
threats.len(),
14,
"Total threat count should not change when adding a text element"
);
}
}
13 changes: 13 additions & 0 deletions src/components/canvas/canvas-context-menu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,19 @@ describe("buildNodeMenuItems", () => {
const deleteItem = items.find((i) => i.label === "Delete");
expect(deleteItem?.variant).toBe("danger");
});

it("excludes View Threats for text annotations", () => {
const items = buildNodeMenuItems({
onEditProperties: vi.fn(),
onDelete: vi.fn(),
onDuplicate: vi.fn(),
onViewThreats: vi.fn(),
isTextAnnotation: true,
});

expect(items).toHaveLength(3);
expect(items.map((i) => i.label)).toEqual(["Edit Properties", "Duplicate", "Delete"]);
});
});

describe("buildEdgeMenuItems", () => {
Expand Down
Loading