Skip to content

Commit 1c28be5

Browse files
authored
Add c4 diagrams and move exporters to dir for maintainability (#10)
1 parent 7d332cb commit 1c28be5

8 files changed

Lines changed: 355 additions & 98 deletions

File tree

packages/constructor/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ uv run scp-cli scan ./path/to/repos --export json -o graph.json
2727

2828
# Scan and export OpenC2 actuator profile (for SOAR)
2929
uv run scp-cli scan ./path/to/repos --export openc2 -o actuators.json
30+
31+
# Scan and export C4 PlantUML diagram
32+
uv run scp-cli scan ./path/to/repos --export c4 -o architecture.puml
3033
```
3134

3235
### Transform JSON to Other Formats
@@ -36,6 +39,7 @@ uv run scp-cli scan ./path/to/repos --export openc2 -o actuators.json
3639
uv run scp-cli scan ./repos --export json -o graph.json
3740
uv run scp-cli transform graph.json --export mermaid -o diagram.mmd
3841
uv run scp-cli transform graph.json --export openc2 -o actuators.json
42+
uv run scp-cli transform graph.json --export c4 -o architecture.puml
3943
```
4044

4145
### Scan GitHub Organization
@@ -73,6 +77,7 @@ uv run scp-cli scan ./repos --export neo4j
7377

7478
- **JSON**: Graph with nodes/edges arrays
7579
- **Mermaid**: Flowchart diagram with tier styling
80+
- **C4**: PlantUML C4 Container diagram with domain boundaries
7681
- **OpenC2**: Actuator profile for SOAR integration
7782
- **Neo4j**: Direct sync to Neo4j graph database
7883

packages/constructor/src/scp_constructor/cli.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from .scanner.local import scan_directory
2020
from .scanner.github import scan_github_org
2121
from .neo4j_sync import Neo4jGraph
22-
from .export import export_json, export_mermaid, export_openc2, import_json
22+
from .exporters import export_c4, export_json, export_mermaid, export_openc2, import_json
2323

2424
app = typer.Typer(
2525
name="scp",
@@ -123,9 +123,12 @@ def _export_manifests(
123123
data = export_openc2(manifest_list)
124124
content = json.dumps(data, indent=2)
125125
default_ext = "json"
126+
elif export_format == "c4":
127+
content = export_c4(manifest_list)
128+
default_ext = "puml"
126129
else:
127130
console.print(
128-
f"[red]Unknown export format:[/] {export_format}. Use: json, mermaid, openc2, neo4j"
131+
f"[red]Unknown export format:[/] {export_format}. Use: json, mermaid, openc2, c4, neo4j"
129132
)
130133
raise typer.Exit(1)
131134

@@ -144,7 +147,7 @@ def _export_manifests(
144147
def scan(
145148
path: Path = typer.Argument(..., help="Directory to scan for scp.yaml files"),
146149
export_format: Optional[str] = typer.Option(
147-
None, "--export", "-e", help="Export format: json, mermaid, openc2, neo4j"
150+
None, "--export", "-e", help="Export format: json, mermaid, openc2, c4, neo4j"
148151
),
149152
output: Optional[Path] = typer.Option(
150153
None, "--output", "-o", help="Output file (default: scp.json or scp.mmd)"
@@ -220,7 +223,7 @@ def scan_github(
220223
None, "--token", envvar="GITHUB_TOKEN", help="GitHub personal access token"
221224
),
222225
export_format: Optional[str] = typer.Option(
223-
None, "--export", "-e", help="Export format: json, mermaid, openc2, neo4j"
226+
None, "--export", "-e", help="Export format: json, mermaid, openc2, c4, neo4j"
224227
),
225228
output: Optional[Path] = typer.Option(
226229
None, "--output", "-o", help="Output file (default: scp.json or scp.mmd)"
@@ -330,7 +333,7 @@ def validate(
330333
def transform(
331334
input_file: Path = typer.Argument(..., help="JSON file from 'scp-cli scan' output"),
332335
export_format: str = typer.Option(
333-
..., "--export", "-e", help="Export format: mermaid, openc2, neo4j"
336+
..., "--export", "-e", help="Export format: mermaid, openc2, c4, neo4j"
334337
),
335338
output: Optional[Path] = typer.Option(None, "--output", "-o", help="Output file"),
336339
stdout: bool = typer.Option(
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
"""Export functions for architecture graph data.
2+
3+
This module provides exporters to convert SCP manifests into various output formats.
4+
"""
5+
6+
from .c4 import export_c4
7+
from .json import export_json, import_json
8+
from .mermaid import export_mermaid
9+
from .openc2 import export_openc2
10+
11+
__all__ = [
12+
"export_c4",
13+
"export_json",
14+
"import_json",
15+
"export_mermaid",
16+
"export_openc2",
17+
]
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
"""C4 PlantUML export for architecture diagrams.
2+
3+
Generates C4 Container diagrams using the C4-PlantUML library syntax.
4+
See: https://github.com/plantuml-stdlib/C4-PlantUML
5+
"""
6+
7+
from scp_sdk import SCPManifest
8+
9+
10+
def export_c4(manifests: list[SCPManifest], title: str = "System Architecture") -> str:
11+
"""Export manifests to a C4 PlantUML Container diagram.
12+
13+
Generates a C4 Container-level diagram with:
14+
- Internal systems as Container elements
15+
- External systems (tier 4-5 or unscanned dependencies) as System_Ext
16+
- Dependencies as Rel relationships with capability labels
17+
18+
Args:
19+
manifests: List of SCP manifests
20+
title: Diagram title
21+
22+
Returns:
23+
PlantUML C4 diagram string (.puml format)
24+
"""
25+
lines = [
26+
"@startuml",
27+
"!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Container.puml",
28+
"",
29+
f"title {title}",
30+
"",
31+
]
32+
33+
# Track all systems and their metadata
34+
systems: dict[str, dict] = {}
35+
# Track systems that are scanned (have full manifests)
36+
scanned_urns: set[str] = set()
37+
# Track dependencies
38+
dependencies: list[tuple[str, str, str, str]] = [] # (from, to, label, type)
39+
40+
# First pass: collect all system info from manifests
41+
for manifest in manifests:
42+
urn = manifest.system.urn
43+
scanned_urns.add(urn)
44+
45+
tier = (
46+
manifest.system.classification.tier
47+
if manifest.system.classification
48+
else None
49+
)
50+
domain = (
51+
manifest.system.classification.domain
52+
if manifest.system.classification
53+
else None
54+
)
55+
56+
systems[urn] = {
57+
"name": manifest.system.name,
58+
"description": manifest.system.description or "",
59+
"tier": tier,
60+
"domain": domain,
61+
"is_external": tier is not None and tier >= 4,
62+
}
63+
64+
# Collect dependencies
65+
if manifest.depends:
66+
for dep in manifest.depends:
67+
label = dep.capability or "uses"
68+
dep_type = dep.type or ""
69+
dependencies.append((urn, dep.system, label, dep_type))
70+
71+
# Add stub for unknown dependencies
72+
if dep.system not in systems:
73+
dep_name = dep.system.split(":")[-1].replace("-", " ").title()
74+
systems[dep.system] = {
75+
"name": dep_name,
76+
"description": "External system",
77+
"tier": None,
78+
"domain": None,
79+
"is_external": True,
80+
}
81+
82+
# Group systems by domain for boundaries
83+
domains: dict[str, list[str]] = {}
84+
no_domain: list[str] = []
85+
86+
for urn, info in systems.items():
87+
if info["is_external"]:
88+
continue # External systems go outside boundaries
89+
domain = info.get("domain")
90+
if domain:
91+
if domain not in domains:
92+
domains[domain] = []
93+
domains[domain].append(urn)
94+
else:
95+
no_domain.append(urn)
96+
97+
# Output external systems first
98+
external_urns = [urn for urn, info in systems.items() if info["is_external"]]
99+
if external_urns:
100+
lines.append("' External Systems")
101+
for urn in sorted(external_urns):
102+
info = systems[urn]
103+
alias = _urn_to_alias(urn)
104+
lines.append(f'System_Ext({alias}, "{info["name"]}", "{info["description"]}")')
105+
lines.append("")
106+
107+
# Output internal systems grouped by domain
108+
if domains:
109+
for domain, urns in sorted(domains.items()):
110+
boundary_id = _sanitize_alias(domain)
111+
lines.append(f'System_Boundary({boundary_id}, "{domain.title()}") {{')
112+
for urn in sorted(urns):
113+
info = systems[urn]
114+
alias = _urn_to_alias(urn)
115+
tier_tag = f" [Tier {info['tier']}]" if info["tier"] else ""
116+
desc = info["description"] or f"Internal service{tier_tag}"
117+
lines.append(f' Container({alias}, "{info["name"]}", "", "{desc}")')
118+
lines.append("}")
119+
lines.append("")
120+
121+
# Output systems without domain
122+
if no_domain:
123+
lines.append("' Internal Systems (no domain)")
124+
for urn in sorted(no_domain):
125+
info = systems[urn]
126+
alias = _urn_to_alias(urn)
127+
tier_tag = f" [Tier {info['tier']}]" if info["tier"] else ""
128+
desc = info["description"] or f"Internal service{tier_tag}"
129+
lines.append(f'Container({alias}, "{info["name"]}", "", "{desc}")')
130+
lines.append("")
131+
132+
# Output relationships
133+
if dependencies:
134+
lines.append("' Relationships")
135+
for from_urn, to_urn, label, dep_type in dependencies:
136+
from_alias = _urn_to_alias(from_urn)
137+
to_alias = _urn_to_alias(to_urn)
138+
tech = f", {dep_type}" if dep_type else ""
139+
lines.append(f'Rel({from_alias}, {to_alias}, "{label}"{tech})')
140+
lines.append("")
141+
142+
lines.append("@enduml")
143+
return "\n".join(lines)
144+
145+
146+
def _urn_to_alias(urn: str) -> str:
147+
"""Convert a URN to a valid PlantUML alias."""
148+
# Extract the service name and sanitize
149+
parts = urn.split(":")
150+
name = parts[-1] if parts else urn
151+
return name.replace("-", "_")
152+
153+
154+
def _sanitize_alias(text: str) -> str:
155+
"""Convert text to a valid PlantUML alias."""
156+
return text.replace("-", "_").replace(" ", "_").replace(".", "_").lower()
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""JSON export and import for architecture graph data."""
2+
3+
from typing import Any
4+
5+
from scp_sdk import (
6+
SCPManifest,
7+
export_graph_json,
8+
import_graph_json,
9+
)
10+
11+
12+
def export_json(manifests: list[SCPManifest]) -> dict[str, Any]:
13+
"""Export manifests to a JSON-serializable graph structure.
14+
15+
This is a wrapper around scp_sdk.export_graph_json() for backward compatibility.
16+
17+
Args:
18+
manifests: List of SCP manifests
19+
20+
Returns:
21+
Dictionary with nodes and edges lists
22+
"""
23+
return export_graph_json(manifests)
24+
25+
26+
def import_json(data: dict[str, Any]) -> list[SCPManifest]:
27+
"""Import manifests from a previously exported JSON graph.
28+
29+
This is a wrapper around scp_sdk.import_graph_json() for backward compatibility.
30+
31+
Reconstructs SCPManifest objects from the JSON export format,
32+
allowing transformation to other formats without re-scanning.
33+
34+
Args:
35+
data: Dictionary from export_json() output
36+
37+
Returns:
38+
List of reconstructed SCP manifests
39+
"""
40+
return import_graph_json(data)

packages/constructor/src/scp_constructor/export.py renamed to packages/constructor/src/scp_constructor/exporters/mermaid.py

Lines changed: 2 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,6 @@
1-
"""Export functions for architecture graph data."""
1+
"""Mermaid diagram export for architecture graph data."""
22

3-
from typing import Any
4-
5-
from scp_sdk import (
6-
SCPManifest,
7-
export_graph_json,
8-
import_graph_json,
9-
)
10-
11-
12-
def export_json(manifests: list[SCPManifest]) -> dict[str, Any]:
13-
"""Export manifests to a JSON-serializable graph structure.
14-
15-
This is a wrapper around scp_sdk.export_graph_json() for backward compatibility.
16-
17-
Args:
18-
manifests: List of SCP manifests
19-
20-
Returns:
21-
Dictionary with nodes and edges lists
22-
"""
23-
return export_graph_json(manifests)
3+
from scp_sdk import SCPManifest
244

255

266
def export_mermaid(manifests: list[SCPManifest], direction: str = "LR") -> str:
@@ -203,73 +183,3 @@ def _sanitize_id(text: str) -> str:
203183
"""Convert text to a valid Mermaid ID (alphanumeric + underscore)."""
204184
# Replace hyphens and spaces with underscores, remove other special chars
205185
return text.replace("-", "_").replace(" ", "_").replace(".", "_").lower()
206-
207-
208-
def export_openc2(manifests: list[SCPManifest]) -> dict[str, Any]:
209-
"""Export OpenC2 actuator profile for SOAR discovery.
210-
211-
Extracts security capabilities from manifests and formats them
212-
as an OpenC2-compatible actuator inventory.
213-
214-
Args:
215-
manifests: List of SCP manifests
216-
217-
Returns:
218-
Dictionary with actuators list for SOAR consumption
219-
"""
220-
actuators: list[dict] = []
221-
222-
for manifest in manifests:
223-
if not manifest.provides:
224-
continue
225-
226-
for cap in manifest.provides:
227-
if not cap.x_security:
228-
continue
229-
230-
actuators.append(
231-
{
232-
"actuator_id": manifest.system.urn,
233-
"name": manifest.system.name,
234-
"capability": cap.capability,
235-
"profile": cap.x_security.actuator_profile,
236-
"actions": cap.x_security.actions,
237-
"targets": cap.x_security.targets,
238-
"api": {
239-
"type": cap.type,
240-
"contract": cap.contract.ref if cap.contract else None,
241-
},
242-
"metadata": {
243-
"team": manifest.ownership.team if manifest.ownership else None,
244-
"tier": manifest.system.classification.tier
245-
if manifest.system.classification
246-
else None,
247-
"domain": manifest.system.classification.domain
248-
if manifest.system.classification
249-
else None,
250-
},
251-
}
252-
)
253-
254-
return {
255-
"openc2_version": "1.0",
256-
"actuators": actuators,
257-
"count": len(actuators),
258-
}
259-
260-
261-
def import_json(data: dict[str, Any]) -> list[SCPManifest]:
262-
"""Import manifests from a previously exported JSON graph.
263-
264-
This is a wrapper around scp_sdk.import_graph_json() for backward compatibility.
265-
266-
Reconstructs SCPManifest objects from the JSON export format,
267-
allowing transformation to other formats without re-scanning.
268-
269-
Args:
270-
data: Dictionary from export_json() output
271-
272-
Returns:
273-
List of reconstructed SCP manifests
274-
"""
275-
return import_graph_json(data)

0 commit comments

Comments
 (0)