torusguard 1.3.5 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/.torusguard/.manifest.json +37 -36
  2. package/.torusguard/runs/report-latest.html +5862 -315
  3. package/.torusguard/runs/results-latest.sarif +20 -0
  4. package/.torusguard/runs/run-20260918-104221-audit/findings.json +1 -0
  5. package/.torusguard/runs/run-20260918-104221-audit/findings.md +8 -0
  6. package/.torusguard/runs/run-20260918-104221-audit/manifest.json +12 -0
  7. package/.torusguard/runs/run-20260918-104221-audit/summary.md +5 -0
  8. package/.torusguard/runs/run-20260918-104236-audit/findings.json +2314 -0
  9. package/.torusguard/runs/run-20260918-104236-audit/findings.md +960 -0
  10. package/.torusguard/runs/run-20260918-104236-audit/manifest.json +12 -0
  11. package/.torusguard/runs/run-20260918-104236-audit/summary.md +5 -0
  12. package/.torusguard/runs/run-20260918-105723-audit/findings.json +2382 -0
  13. package/.torusguard/runs/run-20260918-105723-audit/findings.md +960 -0
  14. package/.torusguard/runs/run-20260918-105723-audit/manifest.json +12 -0
  15. package/.torusguard/runs/run-20260918-105723-audit/summary.md +5 -0
  16. package/.torusguard/runs/run-20260918-105731-audit/findings.json +1 -0
  17. package/.torusguard/runs/run-20260918-105731-audit/findings.md +8 -0
  18. package/.torusguard/runs/run-20260918-105731-audit/manifest.json +12 -0
  19. package/.torusguard/runs/run-20260918-105731-audit/summary.md +5 -0
  20. package/.torusguard/runs/run-20260918-105940-audit/findings.json +1 -0
  21. package/.torusguard/runs/run-20260918-105940-audit/findings.md +8 -0
  22. package/.torusguard/runs/run-20260918-105940-audit/manifest.json +13 -0
  23. package/.torusguard/runs/run-20260918-105940-audit/remediation.md +7 -0
  24. package/.torusguard/runs/run-20260918-105940-audit/summary.md +5 -0
  25. package/.torusguard/runs/run-20260919-145701-audit/findings.json +1 -0
  26. package/.torusguard/runs/run-20260919-145701-audit/findings.md +8 -0
  27. package/.torusguard/runs/run-20260919-145701-audit/manifest.json +12 -0
  28. package/.torusguard/runs/run-20260919-145701-audit/summary.md +5 -0
  29. package/.torusguard/runs/run-20260919-151729-audit/findings.json +1 -0
  30. package/.torusguard/runs/run-20260919-151729-audit/findings.md +8 -0
  31. package/.torusguard/runs/run-20260919-151729-audit/manifest.json +13 -0
  32. package/.torusguard/runs/run-20260919-151729-audit/remediation.md +7 -0
  33. package/.torusguard/runs/run-20260919-151729-audit/summary.md +5 -0
  34. package/.torusguard/runs/run-20260919-152340-audit/findings.json +1 -0
  35. package/.torusguard/runs/run-20260919-152340-audit/findings.md +8 -0
  36. package/.torusguard/runs/run-20260919-152340-audit/manifest.json +12 -0
  37. package/.torusguard/runs/run-20260919-152340-audit/summary.md +5 -0
  38. package/.torusguard/runs/run-20260919-152345-audit/findings.json +1 -0
  39. package/.torusguard/runs/run-20260919-152345-audit/findings.md +8 -0
  40. package/.torusguard/runs/run-20260919-152345-audit/manifest.json +12 -0
  41. package/.torusguard/runs/run-20260919-152345-audit/summary.md +5 -0
  42. package/.torusguard/runs/run-20260919-153500-audit/findings.json +1 -0
  43. package/.torusguard/runs/run-20260919-153500-audit/findings.md +8 -0
  44. package/.torusguard/runs/run-20260919-153500-audit/manifest.json +12 -0
  45. package/.torusguard/runs/run-20260919-153500-audit/summary.md +5 -0
  46. package/.torusguard/scripts/__pycache__/apply_runner.cpython-314.pyc +0 -0
  47. package/.torusguard/scripts/__pycache__/audit_runner.cpython-314.pyc +0 -0
  48. package/.torusguard/scripts/__pycache__/diff_guard.cpython-314.pyc +0 -0
  49. package/.torusguard/scripts/__pycache__/finding_scorer.cpython-314.pyc +0 -0
  50. package/.torusguard/scripts/__pycache__/harden_runner.cpython-314.pyc +0 -0
  51. package/.torusguard/scripts/__pycache__/html_reporter.cpython-314.pyc +0 -0
  52. package/.torusguard/scripts/__pycache__/manifest_builder.cpython-314.pyc +0 -0
  53. package/.torusguard/scripts/__pycache__/memory_engine.cpython-314.pyc +0 -0
  54. package/.torusguard/scripts/__pycache__/monorepo_detector.cpython-314.pyc +0 -0
  55. package/.torusguard/scripts/__pycache__/recheck_runner.cpython-314.pyc +0 -0
  56. package/.torusguard/scripts/__pycache__/recipes_runner.cpython-314.pyc +0 -0
  57. package/.torusguard/scripts/__pycache__/report_sync.cpython-314.pyc +0 -0
  58. package/.torusguard/scripts/__pycache__/run_manager.cpython-314.pyc +0 -0
  59. package/.torusguard/scripts/__pycache__/sarif_exporter.cpython-314.pyc +0 -0
  60. package/.torusguard/scripts/__pycache__/stack_detect.cpython-314.pyc +0 -0
  61. package/.torusguard/scripts/__pycache__/term_ui.cpython-314.pyc +0 -0
  62. package/.torusguard/scripts/apply_runner.py +108 -9
  63. package/.torusguard/scripts/audit_runner.py +336 -24
  64. package/.torusguard/scripts/harden_runner.py +124 -70
  65. package/.torusguard/scripts/html_reporter.py +3585 -648
  66. package/.torusguard/scripts/manifest_builder.py +2 -0
  67. package/.torusguard/scripts/recheck_runner.py +5 -4
  68. package/.torusguard/scripts/recipes_runner.py +91 -8
  69. package/.torusguard/scripts/report_sync.py +496 -73
  70. package/.torusguard/scripts/sarif_exporter.py +48 -8
  71. package/.torusguard/scripts/stack_detect.py +44 -2
  72. package/.torusguard/scripts/status_runner.py +267 -0
  73. package/.torusguard/scripts/term_ui.py +1 -1
  74. package/.torusguard/skills/torusguard/SKILL.md +75 -72
  75. package/.torusguard/skills/torusguard-apply/SKILL.md +84 -80
  76. package/.torusguard/skills/torusguard-audit/SKILL.md +87 -83
  77. package/.torusguard/skills/torusguard-authorize/SKILL.md +7 -2
  78. package/.torusguard/skills/torusguard-exploit-check/SKILL.md +7 -2
  79. package/.torusguard/skills/torusguard-full/SKILL.md +7 -2
  80. package/.torusguard/skills/torusguard-harden/SKILL.md +98 -94
  81. package/.torusguard/skills/torusguard-init/SKILL.md +68 -64
  82. package/.torusguard/skills/torusguard-recheck/SKILL.md +81 -77
  83. package/.torusguard/skills/torusguard-report/SKILL.md +93 -72
  84. package/.torusguard/skills/torusguard-status/SKILL.md +62 -58
  85. package/.torusguard/skills/torusguard-verify/SKILL.md +7 -2
  86. package/.torusguard/skills/torusguard-web-validate/SKILL.md +7 -2
  87. package/.torusguard/workflows/apply.md +1 -1
  88. package/.torusguard/workflows/audit.md +1 -1
  89. package/.torusguard/workflows/authorize.md +1 -1
  90. package/.torusguard/workflows/exploit-check.md +1 -1
  91. package/.torusguard/workflows/harden.md +1 -1
  92. package/.torusguard/workflows/init.md +1 -1
  93. package/.torusguard/workflows/memory.md +1 -1
  94. package/.torusguard/workflows/recheck.md +1 -1
  95. package/.torusguard/workflows/report.md +1 -1
  96. package/.torusguard/workflows/status.md +1 -1
  97. package/.torusguard/workflows/verify.md +1 -1
  98. package/.torusguard/workflows/web-validate.md +1 -1
  99. package/README.md +115 -81
  100. package/bin/torusguard.js +244 -38
  101. package/package.json +6 -2
  102. package/skills/torusguard/SKILL.md +1 -1
  103. package/skills/torusguard/__pycache__/bootstrap.cpython-314.pyc +0 -0
  104. package/skills/torusguard/bootstrap.py +52 -8
  105. package/skills/torusguard/payload/.manifest.json +51 -36
  106. package/skills/torusguard/payload/scripts/__pycache__/apply_runner.cpython-314.pyc +0 -0
  107. package/skills/torusguard/payload/scripts/__pycache__/audit_runner.cpython-311.pyc +0 -0
  108. package/skills/torusguard/payload/scripts/__pycache__/audit_runner.cpython-314.pyc +0 -0
  109. package/skills/torusguard/payload/scripts/__pycache__/diff_guard.cpython-311.pyc +0 -0
  110. package/skills/torusguard/payload/scripts/__pycache__/diff_guard.cpython-314.pyc +0 -0
  111. package/skills/torusguard/payload/scripts/__pycache__/finding_scorer.cpython-311.pyc +0 -0
  112. package/skills/torusguard/payload/scripts/__pycache__/finding_scorer.cpython-314.pyc +0 -0
  113. package/skills/torusguard/payload/scripts/__pycache__/harden_runner.cpython-314.pyc +0 -0
  114. package/skills/torusguard/payload/scripts/__pycache__/html_reporter.cpython-311.pyc +0 -0
  115. package/skills/torusguard/payload/scripts/__pycache__/html_reporter.cpython-314.pyc +0 -0
  116. package/skills/torusguard/payload/scripts/__pycache__/manifest_builder.cpython-314.pyc +0 -0
  117. package/skills/torusguard/payload/scripts/__pycache__/memory_engine.cpython-311.pyc +0 -0
  118. package/skills/torusguard/payload/scripts/__pycache__/memory_engine.cpython-314.pyc +0 -0
  119. package/skills/torusguard/payload/scripts/__pycache__/monorepo_detector.cpython-311.pyc +0 -0
  120. package/skills/torusguard/payload/scripts/__pycache__/monorepo_detector.cpython-314.pyc +0 -0
  121. package/skills/torusguard/payload/scripts/__pycache__/recheck_runner.cpython-314.pyc +0 -0
  122. package/skills/torusguard/payload/scripts/__pycache__/report_sync.cpython-311.pyc +0 -0
  123. package/skills/torusguard/payload/scripts/__pycache__/report_sync.cpython-314.pyc +0 -0
  124. package/skills/torusguard/payload/scripts/__pycache__/rules_sync.cpython-311.pyc +0 -0
  125. package/skills/torusguard/payload/scripts/__pycache__/run_manager.cpython-311.pyc +0 -0
  126. package/skills/torusguard/payload/scripts/__pycache__/run_manager.cpython-314.pyc +0 -0
  127. package/skills/torusguard/payload/scripts/__pycache__/sarif_exporter.cpython-314.pyc +0 -0
  128. package/skills/torusguard/payload/scripts/__pycache__/stack_detect.cpython-311.pyc +0 -0
  129. package/skills/torusguard/payload/scripts/__pycache__/stack_detect.cpython-314.pyc +0 -0
  130. package/skills/torusguard/payload/scripts/__pycache__/term_ui.cpython-311.pyc +0 -0
  131. package/skills/torusguard/payload/scripts/__pycache__/term_ui.cpython-314.pyc +0 -0
  132. package/skills/torusguard/payload/scripts/apply_runner.py +108 -9
  133. package/skills/torusguard/payload/scripts/audit_runner.py +336 -24
  134. package/skills/torusguard/payload/scripts/harden_runner.py +124 -70
  135. package/skills/torusguard/payload/scripts/html_reporter.py +3585 -648
  136. package/skills/torusguard/payload/scripts/manifest_builder.py +2 -0
  137. package/skills/torusguard/payload/scripts/recheck_runner.py +5 -4
  138. package/skills/torusguard/payload/scripts/recipes_runner.py +91 -8
  139. package/skills/torusguard/payload/scripts/report_sync.py +496 -73
  140. package/skills/torusguard/payload/scripts/sarif_exporter.py +48 -8
  141. package/skills/torusguard/payload/scripts/stack_detect.py +44 -2
  142. package/skills/torusguard/payload/scripts/status_runner.py +267 -0
  143. package/skills/torusguard/payload/scripts/term_ui.py +1 -1
  144. package/skills/torusguard/payload/skills/torusguard/SKILL.md +1 -1
  145. package/skills/torusguard/payload/skills/torusguard-apply/SKILL.md +1 -1
  146. package/skills/torusguard/payload/skills/torusguard-audit/SKILL.md +1 -1
  147. package/skills/torusguard/payload/skills/torusguard-authorize/SKILL.md +1 -1
  148. package/skills/torusguard/payload/skills/torusguard-exploit-check/SKILL.md +1 -1
  149. package/skills/torusguard/payload/skills/torusguard-full/SKILL.md +1 -1
  150. package/skills/torusguard/payload/skills/torusguard-harden/SKILL.md +1 -1
  151. package/skills/torusguard/payload/skills/torusguard-init/SKILL.md +1 -1
  152. package/skills/torusguard/payload/skills/torusguard-recheck/SKILL.md +1 -1
  153. package/skills/torusguard/payload/skills/torusguard-report/SKILL.md +40 -23
  154. package/skills/torusguard/payload/skills/torusguard-status/SKILL.md +1 -1
  155. package/skills/torusguard/payload/skills/torusguard-verify/SKILL.md +1 -1
  156. package/skills/torusguard/payload/skills/torusguard-web-validate/SKILL.md +1 -1
  157. package/skills/torusguard/payload/workflows/apply.md +9 -1
  158. package/skills/torusguard/payload/workflows/audit.md +8 -2
  159. package/skills/torusguard/payload/workflows/authorize.md +1 -1
  160. package/skills/torusguard/payload/workflows/exploit-check.md +1 -1
  161. package/skills/torusguard/payload/workflows/harden.md +6 -1
  162. package/skills/torusguard/payload/workflows/init.md +7 -1
  163. package/skills/torusguard/payload/workflows/memory.md +1 -1
  164. package/skills/torusguard/payload/workflows/recheck.md +1 -1
  165. package/skills/torusguard/payload/workflows/recipes.md +55 -0
  166. package/skills/torusguard/payload/workflows/report.md +59 -55
  167. package/skills/torusguard/payload/workflows/status.md +4 -1
  168. package/skills/torusguard/payload/workflows/torusguard-apply.md +110 -0
  169. package/skills/torusguard/payload/workflows/torusguard-audit.md +105 -0
  170. package/skills/torusguard/payload/workflows/torusguard-authorize.md +96 -0
  171. package/skills/torusguard/payload/workflows/torusguard-exploit-check.md +98 -0
  172. package/skills/torusguard/payload/workflows/torusguard-harden.md +104 -0
  173. package/skills/torusguard/payload/workflows/torusguard-init.md +106 -0
  174. package/skills/torusguard/payload/workflows/torusguard-recheck.md +101 -0
  175. package/skills/torusguard/payload/workflows/torusguard-recipes.md +55 -0
  176. package/skills/torusguard/payload/workflows/torusguard-report.md +105 -0
  177. package/skills/torusguard/payload/workflows/torusguard-status.md +103 -0
  178. package/skills/torusguard/payload/workflows/torusguard-verify.md +100 -0
  179. package/skills/torusguard/payload/workflows/torusguard-web-validate.md +96 -0
  180. package/skills/torusguard/payload/workflows/torusguard.md +19 -0
  181. package/skills/torusguard/payload/workflows/verify.md +1 -1
  182. package/skills/torusguard/payload/workflows/web-validate.md +1 -1
  183. package/skills/torusguard-apply/SKILL.md +1 -1
  184. package/skills/torusguard-audit/SKILL.md +1 -1
  185. package/skills/torusguard-authorize/SKILL.md +1 -1
  186. package/skills/torusguard-exploit-check/SKILL.md +1 -1
  187. package/skills/torusguard-full/SKILL.md +1 -1
  188. package/skills/torusguard-harden/SKILL.md +1 -1
  189. package/skills/torusguard-init/SKILL.md +1 -1
  190. package/skills/torusguard-recheck/SKILL.md +1 -1
  191. package/skills/torusguard-report/SKILL.md +40 -23
  192. package/skills/torusguard-status/SKILL.md +1 -1
  193. package/skills/torusguard-verify/SKILL.md +1 -1
  194. package/skills/torusguard-web-validate/SKILL.md +1 -1
@@ -1,648 +1,3585 @@
1
- #!/usr/bin/env python3
2
- """
3
- TorusGuard Visual HTML Report Emitter (v1.2.0)
4
- Compiles a self-contained, zero-external-CDN, interactive dark-mode dashboard.
5
- Embeds SVG gauges, 7-stage pipeline radar, Golden Fix Recipes, and attack surface analytics.
6
- Zero external runtime dependencies (100% Python 3.10+ standard library).
7
- """
8
-
9
- import sys
10
- import json
11
- import html
12
- import datetime
13
- import argparse
14
- from pathlib import Path
15
- from typing import Any
16
-
17
- # Windows console UTF-8 support
18
- if sys.stdout and hasattr(sys.stdout, "reconfigure"):
19
- try:
20
- getattr(sys.stdout, "reconfigure")(encoding="utf-8", errors="replace")
21
- except (AttributeError, OSError):
22
- pass
23
- if sys.stderr and hasattr(sys.stderr, "reconfigure"):
24
- try:
25
- getattr(sys.stderr, "reconfigure")(encoding="utf-8", errors="replace")
26
- except (AttributeError, OSError):
27
- pass
28
-
29
-
30
- def find_project_root(start_dir: str | Path | None = None) -> Path:
31
- """Detect project root directory."""
32
- current = Path(start_dir or Path.cwd()).resolve()
33
- markers = [".git", "package.json", "pyproject.toml", ".torusguard"]
34
- for m in markers:
35
- if (current / m).exists():
36
- return current
37
- for parent in current.parents:
38
- for m in markers:
39
- if (parent / m).exists():
40
- return parent
41
- return current
42
-
43
-
44
- def load_report_telemetry(root_dir: Path) -> dict[str, Any]:
45
- """Gather all project telemetry, rules catalog, and memory state."""
46
- tg_dir = root_dir / ".torusguard"
47
- memory_dir = tg_dir / "memory"
48
- config_file = tg_dir / "config" / "torusguard.json"
49
-
50
- cfg: dict[str, Any] = {}
51
- if config_file.exists():
52
- try:
53
- cfg = json.loads(config_file.read_text(encoding="utf-8"))
54
- except (json.JSONDecodeError, OSError):
55
- cfg = {}
56
-
57
- profile_file = memory_dir / "profile.json"
58
- patterns_file = memory_dir / "patterns.json"
59
- context_file = memory_dir / "context.json"
60
-
61
- profile: dict[str, Any] = {}
62
- patterns: list[dict[str, Any]] = []
63
- context: dict[str, Any] = {}
64
-
65
- if profile_file.exists():
66
- try:
67
- profile = json.loads(profile_file.read_text(encoding="utf-8"))
68
- except (json.JSONDecodeError, OSError):
69
- profile = {}
70
-
71
- if patterns_file.exists():
72
- try:
73
- patterns = json.loads(patterns_file.read_text(encoding="utf-8"))
74
- except (json.JSONDecodeError, OSError):
75
- patterns = []
76
-
77
- if context_file.exists():
78
- try:
79
- context = json.loads(context_file.read_text(encoding="utf-8"))
80
- except (json.JSONDecodeError, OSError):
81
- context = {}
82
-
83
- stack_info = cfg.get("detected_stack", {})
84
- if not stack_info:
85
- try:
86
- import stack_detect # type: ignore
87
- stack_info = stack_detect.detect_stack(root_dir)
88
- except Exception:
89
- stack_info = {"language": "Universal", "framework": "None", "data_layer": "None"}
90
-
91
- ist_tz = datetime.timezone(datetime.timedelta(hours=5, minutes=30), name="IST")
92
- now_ist = datetime.datetime.now(ist_tz)
93
- return {
94
- "project_name": root_dir.name or "Project",
95
- "root_dir": str(root_dir),
96
- "generated_at": now_ist.strftime("%Y-%m-%d %H:%M:%S IST"),
97
- "config": cfg,
98
- "profile": profile,
99
- "patterns": patterns,
100
- "context": context,
101
- "detected_stack": stack_info
102
- }
103
-
104
-
105
- def compute_posture_score(telemetry: dict[str, Any]) -> int:
106
- """Calculate an overall health score (0-100) based on verified fixes and regressions."""
107
- profile = telemetry.get("profile", {})
108
- patterns = telemetry.get("patterns", [])
109
-
110
- base_score = 85
111
- fix_rate = profile.get("fix_rate_percentage")
112
- if fix_rate is not None:
113
- base_score = int(fix_rate)
114
-
115
- regressions = sum(1 for p in patterns if p.get("pattern_type") == "regression_watch")
116
- recipes = sum(1 for p in patterns if p.get("pattern_type") == "golden_fix_recipe")
117
-
118
- score = base_score - (regressions * 5) + (recipes * 3)
119
- return min(max(score, 10), 100)
120
-
121
-
122
- def generate_html_dashboard(telemetry: dict[str, Any]) -> str:
123
- """Build a complete, standalone, single-file HTML dashboard."""
124
- score = compute_posture_score(telemetry)
125
- profile = telemetry.get("profile", {})
126
- patterns = telemetry.get("patterns", [])
127
- cfg = telemetry.get("config", {})
128
- detected = cfg.get("detected_stack", {})
129
- stack = profile.get("stack", [])
130
- if not stack and detected.get("language") and detected.get("language") != "Unknown":
131
- stack = [detected["language"]]
132
- if detected.get("framework") and detected["framework"] != "None":
133
- stack.append(detected["framework"])
134
- if stack:
135
- stack_label = ", ".join(stack)
136
- else:
137
- # Avoid hardcoding "Universal Polyglot Application" if stack is empty
138
- detected_lang = telemetry.get("detected_stack", {}).get("language", "Unknown")
139
- stack_label = detected_lang if detected_lang != "Unknown" else "Polyglot Application"
140
-
141
- recipes = [p for p in patterns if p.get("pattern_type") == "golden_fix_recipe"]
142
- regressions = [p for p in patterns if p.get("pattern_type") == "regression_watch"]
143
- false_positives = [p for p in patterns if p.get("pattern_type") == "false_positive_class"]
144
- recurring = [p for p in patterns if p.get("pattern_type") in ("recurring_fix", "common_vulnerability")]
145
-
146
- # SVG Circle Math for Gauge (radius 40, circ = 251.2)
147
- circumference = 251.2
148
- dash_offset = circumference - (circumference * (score / 100.0))
149
- score_color = "#3fb950" if score >= 80 else ("#d29922" if score >= 60 else "#f85149")
150
-
151
- # Generate recipe cards HTML
152
- recipe_cards_html = ""
153
- if recipes:
154
- for r in recipes[:6]:
155
- rule = html.escape(r.get("rule_id", "TG-SEC"))
156
- desc = html.escape(r.get("description", "Verified fix"))
157
- rdata = r.get("recipe_data", {})
158
- diff = html.escape(rdata.get("diff_snippet", ""))
159
- verified = rdata.get("verified_count", 1)
160
- metrics = rdata.get("ponytail_metrics", {})
161
- adds = metrics.get("additions", 0)
162
- dels = metrics.get("deletions", 0)
163
-
164
- recipe_cards_html += f"""
165
- <div class="card recipe-card">
166
- <div class="card-header">
167
- <span class="badge badge-success">{rule}</span>
168
- <span class="meta-label">Verified {verified}x &middot; +{adds}/-{dels} Ponytail</span>
169
- </div>
170
- <p class="recipe-desc">{desc}</p>
171
- <pre class="diff-block"><code>{diff}</code></pre>
172
- </div>
173
- """
174
- else:
175
- recipe_cards_html = """
176
- <div class="empty-state">
177
- <p>No golden fix recipes captured yet. Run <code>npx torusguard apply</code> (CLI) or <code>/torusguard-apply</code> (AI Chat) on verified remediations to distill reusable recipes.</p>
178
- </div>
179
- """
180
-
181
- # Generate regression watch & suppression rows
182
- regression_rows_html = ""
183
- combined_watches: list[dict[str, str]] = []
184
- for reg in regressions:
185
- combined_watches.append({
186
- "badge_class": "badge-danger",
187
- "rule": reg.get("rule_id", "TG-REG"),
188
- "desc": reg.get("description", "Active watch"),
189
- "target": ", ".join(reg.get("affected_files", [])) or "Monitored files"
190
- })
191
- for fp in false_positives:
192
- combined_watches.append({
193
- "badge_class": "badge-warning",
194
- "rule": fp.get("rule_id", "TG-FP"),
195
- "desc": fp.get("description", "False positive suppression"),
196
- "target": fp.get("reason") or "Suppressed rule pattern"
197
- })
198
-
199
- if combined_watches:
200
- for item in combined_watches:
201
- rule = html.escape(item["rule"])
202
- desc = html.escape(item["desc"])
203
- target = html.escape(item["target"])
204
- bclass = item["badge_class"]
205
- regression_rows_html += f"""
206
- <tr class="reg-row">
207
- <td><span class="badge {bclass}">{rule}</span></td>
208
- <td>{desc}</td>
209
- <td><code>{target}</code></td>
210
- </tr>
211
- """
212
- else:
213
- regression_rows_html = "<tr><td colspan='3' class='empty-cell'>Zero active security regressions or suppressions. Posture clean.</td></tr>"
214
-
215
- stack_info = telemetry.get("detected_stack", {})
216
- primary_lang = stack_info.get("language", "Unknown")
217
- if primary_lang in ("Unknown", "None", "", None):
218
- root_path = Path(telemetry.get("root_dir", "."))
219
- if (root_path / "tsconfig.json").is_file() or list(root_path.glob("*.ts")) or list((root_path / "src").glob("*.ts*")):
220
- primary_lang = "TypeScript"
221
- elif (root_path / "package.json").is_file():
222
- primary_lang = "JavaScript / TypeScript"
223
- elif (root_path / "pyproject.toml").is_file() or (root_path / "requirements.txt").is_file() or list(root_path.glob("*.py")):
224
- primary_lang = "Python"
225
- elif (root_path / "go.mod").is_file():
226
- primary_lang = "Go"
227
- else:
228
- primary_lang = "Unknown"
229
-
230
- primary_fw = stack_info.get("framework", "None")
231
- primary_orm = stack_info.get("data_layer", "None")
232
- sub_stacks = stack_info.get("sub_stacks", [])
233
-
234
- chips = [f'<span class="chip chip-primary">&#9679; {html.escape(primary_lang)}</span>']
235
- if primary_fw and primary_fw != "None":
236
- chips.append(f'<span class="chip">&#9881; {html.escape(primary_fw)}</span>')
237
- if primary_orm and primary_orm != "None":
238
- chips.append(f'<span class="chip">&#128451; {html.escape(primary_orm)}</span>')
239
- for pkg in sub_stacks[:8]:
240
- pkg_p = html.escape(pkg.get("path", ""))
241
- pkg_l = html.escape(pkg.get("language", ""))
242
- pkg_f = html.escape(pkg.get("framework", ""))
243
- chips.append(f'<span class="chip" style="border-color: rgba(63, 185, 80, 0.4); color: var(--accent-green);">&#128230; {pkg_p} ({pkg_l} / {pkg_f})</span>')
244
- stack_chips_html = "\n".join(chips)
245
-
246
- return f"""<!DOCTYPE html>
247
- <html lang="en">
248
- <head>
249
- <meta charset="UTF-8">
250
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
251
- <title>TorusGuard Security Posture &middot; {html.escape(telemetry['project_name'])}</title>
252
- <style>
253
- :root {{
254
- --bg-dark: #0d1117;
255
- --bg-surface: #161b22;
256
- --bg-card: #21262d;
257
- --border-subtle: #30363d;
258
- --text-main: #f0f6fc;
259
- --text-muted: #8b949e;
260
- --accent-blue: #58a6ff;
261
- --accent-green: #3fb950;
262
- --accent-yellow: #d29922;
263
- --accent-red: #f85149;
264
- --font-stack: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji";
265
- }}
266
- * {{ box-sizing: border-box; margin: 0; padding: 0; }}
267
- body {{
268
- background: var(--bg-dark);
269
- color: var(--text-main);
270
- font-family: var(--font-stack);
271
- line-height: 1.5;
272
- padding: 24px;
273
- }}
274
- .container {{ max-width: 1200px; margin: 0 auto; }}
275
- header {{
276
- display: flex;
277
- justify-content: space-between;
278
- align-items: center;
279
- padding-bottom: 20px;
280
- border-bottom: 1px solid var(--border-subtle);
281
- margin-bottom: 24px;
282
- }}
283
- .logo-group h1 {{
284
- font-size: 24px;
285
- font-weight: 700;
286
- display: flex;
287
- align-items: center;
288
- gap: 8px;
289
- }}
290
- .shield-icon {{ font-size: 26px; }}
291
- .project-meta {{ color: var(--text-muted); font-size: 14px; margin-top: 4px; }}
292
- .status-badge {{
293
- background: rgba(63, 185, 80, 0.15);
294
- border: 1px solid var(--accent-green);
295
- color: var(--accent-green);
296
- padding: 6px 14px;
297
- border-radius: 20px;
298
- font-size: 13px;
299
- font-weight: 600;
300
- }}
301
- .grid-top {{
302
- display: grid;
303
- grid-template-columns: 280px 1fr 1fr 1fr;
304
- gap: 16px;
305
- margin-bottom: 24px;
306
- }}
307
- @media (max-width: 900px) {{
308
- .grid-top {{ grid-template-columns: 1fr 1fr; }}
309
- }}
310
- @media (max-width: 600px) {{
311
- .grid-top {{ grid-template-columns: 1fr; }}
312
- }}
313
- .card {{
314
- background: var(--bg-surface);
315
- border: 1px solid var(--border-subtle);
316
- border-radius: 8px;
317
- padding: 20px;
318
- }}
319
- .gauge-card {{
320
- display: flex;
321
- flex-direction: column;
322
- align-items: center;
323
- justify-content: center;
324
- text-align: center;
325
- }}
326
- .gauge-svg {{ width: 120px; height: 120px; transform: rotate(-90deg); }}
327
- .gauge-bg {{ fill: none; stroke: var(--bg-card); stroke-width: 8; }}
328
- .gauge-fill {{
329
- fill: none;
330
- stroke: {score_color};
331
- stroke-width: 8;
332
- stroke-linecap: round;
333
- stroke-dasharray: 251.2;
334
- stroke-dashoffset: {dash_offset};
335
- transition: stroke-dashoffset 0.8s ease;
336
- }}
337
- .gauge-value {{
338
- position: absolute;
339
- font-size: 32px;
340
- font-weight: 700;
341
- color: var(--text-main);
342
- }}
343
- .stat-label {{ font-size: 13px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; }}
344
- .stat-value {{ font-size: 28px; font-weight: 700; color: var(--text-main); }}
345
- .stat-sub {{ font-size: 12px; color: var(--text-muted); margin-top: 6px; }}
346
- .pipeline-section {{ margin-bottom: 24px; }}
347
- .pipeline-tracker {{
348
- display: flex;
349
- justify-content: space-between;
350
- position: relative;
351
- margin-top: 16px;
352
- }}
353
- .pipeline-step {{
354
- display: flex;
355
- flex-direction: column;
356
- align-items: center;
357
- flex: 1;
358
- text-align: center;
359
- position: relative;
360
- z-index: 2;
361
- }}
362
- .step-circle {{
363
- width: 32px;
364
- height: 32px;
365
- border-radius: 50%;
366
- background: var(--bg-card);
367
- border: 2px solid var(--accent-blue);
368
- display: flex;
369
- align-items: center;
370
- justify-content: center;
371
- font-size: 13px;
372
- font-weight: 700;
373
- color: var(--accent-blue);
374
- margin-bottom: 8px;
375
- }}
376
- .step-circle.active {{ background: var(--accent-blue); color: var(--bg-dark); }}
377
- .step-name {{ font-size: 12px; font-weight: 600; color: var(--text-main); }}
378
- .step-cmd {{ font-size: 11px; color: var(--text-muted); font-family: monospace; margin-top: 2px; }}
379
- .pipeline-line {{
380
- position: absolute;
381
- top: 16px;
382
- left: 6%;
383
- right: 6%;
384
- height: 2px;
385
- background: var(--border-subtle);
386
- z-index: 1;
387
- }}
388
- .polyglot-chips {{
389
- display: flex;
390
- flex-wrap: wrap;
391
- gap: 8px;
392
- margin-top: 10px;
393
- }}
394
- .chip {{
395
- background: var(--bg-card);
396
- border: 1px solid var(--border-subtle);
397
- border-radius: 16px;
398
- padding: 4px 12px;
399
- font-size: 12px;
400
- font-weight: 500;
401
- color: var(--text-main);
402
- display: inline-flex;
403
- align-items: center;
404
- gap: 6px;
405
- }}
406
- .chip-primary {{
407
- border-color: var(--accent-blue);
408
- color: var(--accent-blue);
409
- background: rgba(88, 166, 255, 0.1);
410
- }}
411
- .grid-bottom {{
412
- display: grid;
413
- grid-template-columns: 1fr 1fr;
414
- gap: 24px;
415
- margin-top: 24px;
416
- }}
417
- @media (max-width: 800px) {{
418
- .grid-bottom {{ grid-template-columns: 1fr; }}
419
- }}
420
- .section-title {{
421
- font-size: 18px;
422
- font-weight: 600;
423
- margin-bottom: 16px;
424
- display: flex;
425
- align-items: center;
426
- gap: 8px;
427
- }}
428
- .badge {{
429
- display: inline-block;
430
- padding: 3px 8px;
431
- border-radius: 12px;
432
- font-size: 11px;
433
- font-weight: 600;
434
- font-family: monospace;
435
- }}
436
- .badge-success {{ background: rgba(63, 185, 80, 0.15); color: var(--accent-green); border: 1px solid rgba(63, 185, 80, 0.3); }}
437
- .badge-danger {{ background: rgba(248, 81, 73, 0.15); color: var(--accent-red); border: 1px solid rgba(248, 81, 73, 0.3); }}
438
- .recipe-card {{
439
- background: var(--bg-card);
440
- border: 1px solid var(--border-subtle);
441
- border-radius: 6px;
442
- padding: 12px;
443
- margin-bottom: 12px;
444
- }}
445
- .card-header {{ display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }}
446
- .meta-label {{ font-size: 11px; color: var(--text-muted); }}
447
- .recipe-desc {{ font-size: 13px; color: var(--text-main); margin-bottom: 8px; }}
448
- .diff-block {{
449
- background: var(--bg-dark);
450
- border: 1px solid var(--border-subtle);
451
- border-radius: 4px;
452
- padding: 8px 12px;
453
- font-family: monospace;
454
- font-size: 12px;
455
- overflow-x: auto;
456
- color: #e6edf3;
457
- }}
458
- table {{ width: 100%; border-collapse: collapse; }}
459
- th, td {{ padding: 10px 12px; text-align: left; font-size: 13px; }}
460
- th {{ color: var(--text-muted); border-bottom: 1px solid var(--border-subtle); font-weight: 600; font-size: 12px; text-transform: uppercase; }}
461
- td {{ border-bottom: 1px solid rgba(48, 54, 61, 0.5); }}
462
- .empty-state {{ text-align: center; padding: 24px; color: var(--text-muted); font-size: 13px; }}
463
- .empty-cell {{ text-align: center; color: var(--text-muted); padding: 16px; font-style: italic; }}
464
- footer {{
465
- margin-top: 40px;
466
- text-align: center;
467
- color: var(--text-muted);
468
- font-size: 12px;
469
- border-top: 1px solid var(--border-subtle);
470
- padding-top: 20px;
471
- }}
472
- </style>
473
- </head>
474
- <body>
475
- <div class="container">
476
- <header>
477
- <div class="logo-group">
478
- <h1><span class="shield-icon">&#128737;</span> TorusGuard Security Posture</h1>
479
- <div class="project-meta">Workspace: <strong>{html.escape(telemetry['project_name'])}</strong> &middot; Stack: <strong>{html.escape(stack_label)}</strong> &middot; {telemetry['generated_at']}</div>
480
- </div>
481
- <div class="status-badge">&check; Local Governance Active</div>
482
- </header>
483
-
484
- <!-- Top KPI Grid -->
485
- <div class="grid-top">
486
- <div class="card gauge-card">
487
- <div style="position: relative; display: flex; align-items: center; justify-content: center;">
488
- <svg class="gauge-svg" viewBox="0 0 100 100">
489
- <circle class="gauge-bg" cx="50" cy="50" r="40" />
490
- <circle class="gauge-fill" cx="50" cy="50" r="40" />
491
- </svg>
492
- <div class="gauge-value">{score}</div>
493
- </div>
494
- <div class="stat-label" style="margin-top: 12px;">Security Posture Score</div>
495
- </div>
496
-
497
- <div class="card">
498
- <div class="stat-label">Verified Golden Recipes</div>
499
- <div class="stat-value" style="color: var(--accent-green);">{len(recipes)}</div>
500
- <div class="stat-sub">Distilled Ponytail fixes (<=35 add, <=25 del)</div>
501
- </div>
502
-
503
- <div class="card">
504
- <div class="stat-label">Active Memory Patterns</div>
505
- <div class="stat-value">{len(patterns)}</div>
506
- <div class="stat-sub">{len(false_positives)} suppressed &middot; {len(recurring)} recurring</div>
507
- </div>
508
-
509
- <div class="card">
510
- <div class="stat-label">Security Rules Catalog</div>
511
- <div class="stat-value" style="color: var(--accent-blue);">71</div>
512
- <div class="stat-sub">11 rule families &middot; Multi-agent verified</div>
513
- </div>
514
- </div>
515
-
516
- <!-- Polyglot Architecture & Workspace Intelligence -->
517
- <div class="card" style="margin-bottom: 24px;">
518
- <div class="section-title">&#9670; Polyglot Architecture &amp; Workspace Intelligence</div>
519
- <div style="font-size: 13px; color: var(--text-muted); margin-bottom: 12px;">
520
- Primary Language: <strong style="color: var(--text-main);">{html.escape(primary_lang)}</strong>
521
- &middot; Framework: <strong style="color: var(--text-main);">{html.escape(primary_fw)}</strong>
522
- &middot; Data Layer: <strong style="color: var(--text-main);">{html.escape(primary_orm)}</strong>
523
- </div>
524
- <div class="polyglot-chips">
525
- {stack_chips_html}
526
- </div>
527
- </div>
528
-
529
- <!-- 7-Stage Canonical Pipeline Tracker -->
530
- <div class="card pipeline-section">
531
- <div class="section-title">&#9658; Canonical 7-Stage Closed-Loop Governance Flow</div>
532
- <div class="pipeline-tracker">
533
- <div class="pipeline-line"></div>
534
- <div class="pipeline-step">
535
- <div class="step-circle active">1</div>
536
- <div class="step-name">Scan</div>
537
- <div class="step-cmd">audit &middot; /audit</div>
538
- </div>
539
- <div class="pipeline-step">
540
- <div class="step-circle active">2</div>
541
- <div class="step-name">Score</div>
542
- <div class="step-cmd">0-100 Score</div>
543
- </div>
544
- <div class="pipeline-step">
545
- <div class="step-circle active">3</div>
546
- <div class="step-name">Harden</div>
547
- <div class="step-cmd">harden &middot; /harden</div>
548
- </div>
549
- <div class="pipeline-step">
550
- <div class="step-circle active">4</div>
551
- <div class="step-name">Authorize</div>
552
- <div class="step-cmd">Human Gate</div>
553
- </div>
554
- <div class="pipeline-step">
555
- <div class="step-circle active">5</div>
556
- <div class="step-name">Apply</div>
557
- <div class="step-cmd">apply &middot; .bak</div>
558
- </div>
559
- <div class="pipeline-step">
560
- <div class="step-circle active">6</div>
561
- <div class="step-name">Recheck</div>
562
- <div class="step-cmd">recheck &middot; verify</div>
563
- </div>
564
- <div class="pipeline-step">
565
- <div class="step-circle active">7</div>
566
- <div class="step-name">Report</div>
567
- <div class="step-cmd">report &middot; SARIF</div>
568
- </div>
569
- </div>
570
- </div>
571
-
572
- <!-- Bottom Details Grid -->
573
- <div class="grid-bottom">
574
- <!-- Golden Recipes Catalog -->
575
- <div class="card">
576
- <div class="section-title">&#10024; Active Golden Fix Recipes ({len(recipes)})</div>
577
- <div class="recipes-list">
578
- {recipe_cards_html}
579
- </div>
580
- </div>
581
-
582
- <!-- Regression Watches & False Positives -->
583
- <div class="card">
584
- <div class="section-title">&#128065; Regression Watches & Suppressions</div>
585
- <table>
586
- <thead>
587
- <tr>
588
- <th>Rule ID</th>
589
- <th>Description / Reason</th>
590
- <th>Target Path</th>
591
- </tr>
592
- </thead>
593
- <tbody>
594
- {regression_rows_html}
595
- </tbody>
596
- </table>
597
- </div>
598
- </div>
599
-
600
- <footer>
601
- TorusGuard v1.3.0 &middot; Autonomous Security Engine for AI-Built Applications &middot; 100% Local-First &middot; Zero Cloud Telemetry Leakage
602
- </footer>
603
- </div>
604
- </body>
605
- </html>
606
- """
607
-
608
-
609
- def emit_html_report(
610
- target_path: str | Path | None = None,
611
- root_dir: str | Path | None = None
612
- ) -> dict[str, Any]:
613
- """Emit the standalone HTML report to disk."""
614
- root = Path(root_dir or find_project_root()).resolve()
615
- telemetry = load_report_telemetry(root)
616
- html_content = generate_html_dashboard(telemetry)
617
-
618
- out_file = Path(target_path) if target_path else (root / ".torusguard" / "runs" / "report-latest.html")
619
- out_file.parent.mkdir(parents=True, exist_ok=True)
620
- out_file.write_text(html_content, encoding="utf-8")
621
-
622
- return {
623
- "status": "success",
624
- "report_path": str(out_file),
625
- "file_size_bytes": len(html_content.encode("utf-8")),
626
- "posture_score": compute_posture_score(telemetry)
627
- }
628
-
629
-
630
- def main():
631
- parser = argparse.ArgumentParser(description="TorusGuard Visual HTML Report Emitter")
632
- parser.add_argument("--out", help="Output HTML file path (default: .torusguard/runs/report-latest.html)")
633
- parser.add_argument("--root", help="Project root override")
634
- parser.add_argument("--json", action="store_true", help="Output raw JSON summary")
635
-
636
- args = parser.parse_args()
637
- root = Path(args.root).resolve() if args.root else None
638
-
639
- res = emit_html_report(target_path=args.out, root_dir=root)
640
- if args.json:
641
- print(json.dumps(res, indent=2))
642
- else:
643
- print(f"[SUCCESS] Emitted TorusGuard visual dashboard: {res['report_path']}")
644
- print(f"Posture Score: {res['posture_score']}/100 · Size: {res['file_size_bytes']} bytes")
645
-
646
-
647
- if __name__ == "__main__":
648
- main()
1
+ #!/usr/bin/env python3
2
+ """
3
+ TorusGuard Visual HTML Report Emitter (v2.3.0)
4
+ Compiles a self-contained, zero-external-CDN, interactive dashboard
5
+ focused on ACTUAL SECURITY POSTURE & GOVERNED REMEDIATION:
6
+ 1. Safe Defenses vs. Harmed Exposure (5-Layer Architectural Invariant Matrix)
7
+ 2. Severity Breakdown (Dynamic Circular SVG Gauge with Glow & Count-Up, KPI Cards)
8
+ 3. Interactive Findings Drawers (Vulnerable Snippets, Invariant Rules, Fix Commands)
9
+ 4. Interactive "What-If" Posture Score Simulator (Live Dynamic Gauge Recalculation)
10
+ 5. Interactive Directory Attack Surface Heatmap (Click-to-Filter)
11
+ 6. OWASP Top 10 (2021/2026) Compliance Radar
12
+ 7. Enterprise Compliance Framework Mapping (SOC 2 Type II, ISO/IEC 27001:2022, HIPAA)
13
+ 8. Root-Cause Architectural Clusters & Blast Radius
14
+ 9. Golden Recipe Explorer (Distilled Verified Fixes, Diffs & Before/After Views)
15
+ 10. Prescriptive "Next Best Defenses" Advisory (Stack-Aware Gaps)
16
+ 11. In-Browser Artifact Exporters (Zero-Network SARIF v2.1.0 & CSV Downloaders)
17
+ 12. Zero-CDN Executive Dark / Light Mode Switcher with LocalStorage Persistence
18
+ 13. High-Contrast Print & PDF Export Styling (@media print)
19
+
20
+ Pure Python 3.10+ standard library (100% zero external dependencies, zero CDNs).
21
+ """
22
+
23
+ import sys
24
+ import json
25
+ import html
26
+ import datetime
27
+ import argparse
28
+ from pathlib import Path
29
+ from typing import Any, TypedDict
30
+
31
+
32
+ class ComplianceControl(TypedDict):
33
+ code: str
34
+ families: str
35
+ rules: list[str]
36
+
37
+
38
+ class ComplianceFramework(TypedDict):
39
+ name: str
40
+ subtitle: str
41
+ controls: list[ComplianceControl]
42
+
43
+
44
+ # Windows console UTF-8 support
45
+ if sys.stdout and hasattr(sys.stdout, "reconfigure"):
46
+ try:
47
+ getattr(sys.stdout, "reconfigure")(encoding="utf-8", errors="replace")
48
+ except (AttributeError, OSError):
49
+ pass
50
+ if sys.stderr and hasattr(sys.stderr, "reconfigure"):
51
+ try:
52
+ getattr(sys.stderr, "reconfigure")(encoding="utf-8", errors="replace")
53
+ except (AttributeError, OSError):
54
+ pass
55
+
56
+
57
+ FAMILY_METADATA: dict[str, dict[str, str]] = {
58
+ "TG-AGENT": {
59
+ "layer_id": "layer-app",
60
+ "layer_name": "Application & LLM Defense",
61
+ "domain": "AI Agent & LLM Injection Defense",
62
+ "guarantee": "Structural user prompt isolation, inert delimiters, and tool call schema validation. Prevents direct and indirect prompt injection attacks.",
63
+ "owasp": "OWASP LLM01: Prompt Injection",
64
+ "compliance": "ISO 27001 A.8.28 (Secure Coding)"
65
+ },
66
+ "TG-INPUT": {
67
+ "layer_id": "layer-app",
68
+ "layer_name": "Application & LLM Defense",
69
+ "domain": "Input Sanitization & Traversal",
70
+ "guarantee": "Strict path normalization, command argument escaping, and safe DOM assignment. Prevents path traversal, OS command injection, and Reflected/DOM XSS.",
71
+ "owasp": "OWASP A03:2021 - Injection",
72
+ "compliance": "SOC 2 CC6.6 / ISO 27001 A.8.28"
73
+ },
74
+ "TG-AUTH": {
75
+ "layer_id": "layer-auth",
76
+ "layer_name": "Identity, Auth & Real-Time",
77
+ "domain": "Authentication & Session Integrity",
78
+ "guarantee": "Timing-safe secret compares, strong argon2id/bcrypt password hashing, explicit JWT algorithm pinning, and secure cookie session attributes.",
79
+ "owasp": "OWASP A07:2021 - Identification & Authentication Failures",
80
+ "compliance": "SOC 2 CC6.1 / ISO 27001 A.8.24 / HIPAA § 164.312(a)(1)"
81
+ },
82
+ "TG-CSRF": {
83
+ "layer_id": "layer-auth",
84
+ "layer_name": "Identity, Auth & Real-Time",
85
+ "domain": "Cross-Site Request Forgery",
86
+ "guarantee": "Cryptographic anti-CSRF token verification on state-changing requests and SameSite=Lax/Strict cookie isolation.",
87
+ "owasp": "OWASP A01:2021 - Broken Access Control",
88
+ "compliance": "SOC 2 CC6.1 / ISO 27001 A.8.28"
89
+ },
90
+ "TG-WS": {
91
+ "layer_id": "layer-auth",
92
+ "layer_name": "Identity, Auth & Real-Time",
93
+ "domain": "WebSocket & Real-Time Security",
94
+ "guarantee": "Handshake origin validation, connection ticket verification, and inbound frame size bounds to eliminate CSWSH and socket exhaustion.",
95
+ "owasp": "OWASP A01:2021 - Broken Access Control / A05: Misconfiguration",
96
+ "compliance": "SOC 2 CC6.6 / ISO 27001 A.8.20"
97
+ },
98
+ "TG-BIZ": {
99
+ "layer_id": "layer-auth",
100
+ "layer_name": "Identity, Auth & Real-Time",
101
+ "domain": "Business Logic & Workflow Limits",
102
+ "guarantee": "Strict positive financial amount constraints, atomic database transaction locks, and coupon stacking bounds to prevent race conditions.",
103
+ "owasp": "OWASP A04:2021 - Insecure Design",
104
+ "compliance": "HIPAA § 164.312(c)(1) Data Integrity / SOC 2 CC7.1"
105
+ },
106
+ "TG-DB": {
107
+ "layer_id": "layer-db",
108
+ "layer_name": "Database, GraphQL & Cache",
109
+ "domain": "Database Isolation & Injection",
110
+ "guarantee": "Zero unparameterized SQL concatenation. Enforces prepared statements with bound parameters and mandatory tenant partition scoping.",
111
+ "owasp": "OWASP A03:2021 - Injection / A01: Broken Access Control",
112
+ "compliance": "SOC 2 CC6.1 / ISO 27001 A.8.28 / HIPAA § 164.312(a)(1)"
113
+ },
114
+ "TG-GQL": {
115
+ "layer_id": "layer-db",
116
+ "layer_name": "Database, GraphQL & Cache",
117
+ "domain": "GraphQL Safety & Introspection",
118
+ "guarantee": "Query depth limiting (max depth <= 7), circular reference blocking, field complexity cost analysis, and production schema introspection suppression.",
119
+ "owasp": "OWASP A05:2021 - Security Misconfiguration",
120
+ "compliance": "SOC 2 CC6.6 / ISO 27001 A.8.28"
121
+ },
122
+ "TG-CACHE": {
123
+ "layer_id": "layer-db",
124
+ "layer_name": "Database, GraphQL & Cache",
125
+ "domain": "Cache Poisoning & Cache Timing",
126
+ "guarantee": "Mandatory Cache-Control: no-store on authenticated sensitive responses and unkeyed HTTP header sanitization to prevent web cache poisoning.",
127
+ "owasp": "OWASP A05:2021 - Security Misconfiguration",
128
+ "compliance": "SOC 2 CC6.7 / ISO 27001 A.8.20"
129
+ },
130
+ "TG-SSRF": {
131
+ "layer_id": "layer-net",
132
+ "layer_name": "Network, SSRF & Webhooks",
133
+ "domain": "Server-Side Request Forgery",
134
+ "guarantee": "Outbound URL hostname whitelisting, RFC1918 private IP range blocking (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.1) and cloud metadata blocking (169.254.169.254).",
135
+ "owasp": "OWASP A10:2021 - Server-Side Request Forgery (SSRF)",
136
+ "compliance": "SOC 2 CC6.6 / ISO 27001 A.8.20"
137
+ },
138
+ "TG-WEBHOOK": {
139
+ "layer_id": "layer-net",
140
+ "layer_name": "Network, SSRF & Webhooks",
141
+ "domain": "Inbound Webhook Verification",
142
+ "guarantee": "Mandatory cryptographic HMAC-SHA256 signature verification over raw request buffers and replay attack protection with <= 300s timestamp limits.",
143
+ "owasp": "OWASP A08:2021 - Software and Data Integrity Failures",
144
+ "compliance": "SOC 2 CC6.6 / ISO 27001 A.8.20 / HIPAA § 164.312(c)(1)"
145
+ },
146
+ "TG-RATE": {
147
+ "layer_id": "layer-net",
148
+ "layer_name": "Network, SSRF & Webhooks",
149
+ "domain": "Rate Limiting & Resource Protection",
150
+ "guarantee": "Sliding-window IP rate limiting on auth and write endpoints, bounded JSON payload parsers (<= 1MB), and slowloris connection timeouts.",
151
+ "owasp": "OWASP A04:2021 - Insecure Design / A07: Auth Failures",
152
+ "compliance": "SOC 2 CC6.6 / ISO 27001 A.8.20"
153
+ },
154
+ "TG-PLATFORM": {
155
+ "layer_id": "layer-platform",
156
+ "layer_name": "Platform, Supply Chain & Secrets",
157
+ "domain": "Server Hardening & Security Headers",
158
+ "guarantee": "Mandatory production security headers (CSP, HSTS max-age>=31536000, X-Content-Type-Options: nosniff, X-Frame-Options: DENY) and debug mode suppression.",
159
+ "owasp": "OWASP A05:2021 - Security Misconfiguration",
160
+ "compliance": "SOC 2 CC6.7 / ISO 27001 A.8.20 / HIPAA § 164.312(e)(1)"
161
+ },
162
+ "TG-SUPPLY": {
163
+ "layer_id": "layer-platform",
164
+ "layer_name": "Platform, Supply Chain & Secrets",
165
+ "domain": "Supply Chain & Dependency Health",
166
+ "guarantee": "Lockfile cryptographic integrity checks, known high/critical CVE build gates, verified registry origins, and build script execution bounds.",
167
+ "owasp": "OWASP A06:2021 - Vulnerable and Outdated Components",
168
+ "compliance": "SOC 2 CC7.1 / ISO 27001 A.8.28"
169
+ },
170
+ "TG-CLIENT": {
171
+ "layer_id": "layer-platform",
172
+ "layer_name": "Platform, Supply Chain & Secrets",
173
+ "domain": "Client Bundle & Frontend Secrets",
174
+ "guarantee": "Zero server role keys, database master credentials, or private API secrets leaked into frontend bundles or public environment variables.",
175
+ "owasp": "OWASP A01:2021 - Broken Access Control / A05: Misconfiguration",
176
+ "compliance": "SOC 2 CC6.1 / ISO 27001 A.8.24 / HIPAA § 164.312(e)(1)"
177
+ },
178
+ "TG-EDGE": {
179
+ "layer_id": "layer-platform",
180
+ "layer_name": "Platform, Supply Chain & Secrets",
181
+ "domain": "Edge Computing & Serverless Limits",
182
+ "guarantee": "Subrequest fan-out limits (<= 50 per invocation), serverless wall-clock execution timeouts (<= 30s), and memory bounds for edge functions.",
183
+ "owasp": "OWASP A04:2021 - Insecure Design",
184
+ "compliance": "SOC 2 CC6.6 / ISO 27001 A.8.20"
185
+ },
186
+ "TG-DIFF": {
187
+ "layer_id": "layer-platform",
188
+ "layer_name": "Platform, Supply Chain & Secrets",
189
+ "domain": "Polyglot Bypass & Churn Bounds",
190
+ "guarantee": "Strict Ponytail Protocol enforcement (<= 35 additions, <= 25 deletions per patch) and zero security suppression pragmas (nosec pragmas, InsecureSkipVerify).",
191
+ "owasp": "OWASP A08:2021 - Software Integrity",
192
+ "compliance": "SOC 2 CC7.1 / ISO 27001 A.8.28 / HIPAA § 164.312(c)(1)"
193
+ },
194
+ "TG-SEC": {
195
+ "layer_id": "layer-platform",
196
+ "layer_name": "Platform, Supply Chain & Secrets",
197
+ "domain": "Core Secrets & API Tokens",
198
+ "guarantee": "Zero hardcoded API keys, private certificates, AWS/GCP/Stripe tokens, or JWT secrets in tracked source code. Mandatory externalized environment configuration.",
199
+ "owasp": "OWASP A02:2021 - Cryptographic Failures",
200
+ "compliance": "SOC 2 CC6.7 / ISO 27001 A.8.24 / HIPAA § 164.312(e)(1)"
201
+ }
202
+ }
203
+
204
+ ARCHITECTURAL_LAYERS: list[dict[str, Any]] = [
205
+ {
206
+ "id": "layer-app",
207
+ "name": "Application & LLM Defense",
208
+ "icon": "🛡️",
209
+ "families": ["TG-AGENT", "TG-INPUT"],
210
+ "description": "User prompt isolation, inert delimiters, traversal defense, and input escaping."
211
+ },
212
+ {
213
+ "id": "layer-auth",
214
+ "name": "Identity, Auth & Real-Time",
215
+ "icon": "🔑",
216
+ "families": ["TG-AUTH", "TG-CSRF", "TG-WS", "TG-BIZ"],
217
+ "description": "Constant-time comparisons, password hashing, algorithm pinning, SameSite cookies, and business flow bounds."
218
+ },
219
+ {
220
+ "id": "layer-db",
221
+ "name": "Database, GraphQL & Cache",
222
+ "icon": "💾",
223
+ "families": ["TG-DB", "TG-GQL", "TG-CACHE"],
224
+ "description": "Parameterized SQL queries, multi-tenant row partitioning, GraphQL query depth limits, and cache timing guards."
225
+ },
226
+ {
227
+ "id": "layer-net",
228
+ "name": "Network, SSRF & Webhooks",
229
+ "icon": "🌐",
230
+ "families": ["TG-SSRF", "TG-WEBHOOK", "TG-RATE"],
231
+ "description": "Private IP / metadata blocking, cryptographic HMAC webhook authentication, and sliding-window rate limiters."
232
+ },
233
+ {
234
+ "id": "layer-platform",
235
+ "name": "Platform, Supply Chain & Secrets",
236
+ "icon": "🏗️",
237
+ "families": ["TG-PLATFORM", "TG-SUPPLY", "TG-CLIENT", "TG-EDGE", "TG-DIFF", "TG-SEC"],
238
+ "description": "Security headers, dependency lockfile integrity, zero client bundle secrets, serverless limits, and Ponytail bounds."
239
+ }
240
+ ]
241
+
242
+
243
+ def find_project_root(start_dir: str | Path | None = None) -> Path:
244
+ """Detect project root directory."""
245
+ current = Path(start_dir or Path.cwd()).resolve()
246
+ markers = [".git", "package.json", "pyproject.toml", ".torusguard"]
247
+ for m in markers:
248
+ if (current / m).exists():
249
+ return current
250
+ for parent in current.parents:
251
+ for m in markers:
252
+ if (parent / m).exists():
253
+ return parent
254
+ return current
255
+
256
+
257
+ def load_all_rule_catalog() -> list[dict[str, Any]]:
258
+ """Load complete 74-rule catalog from audit_runner."""
259
+ try:
260
+ scripts_dir = Path(__file__).resolve().parent
261
+ if str(scripts_dir) not in sys.path:
262
+ sys.path.insert(0, str(scripts_dir))
263
+ import audit_runner # type: ignore
264
+ catalog = []
265
+ for r in getattr(audit_runner, "RULE_PATTERNS", []):
266
+ rid = r.get("rule_id", "TG-GEN")
267
+ fam = rid.rsplit("-", 1)[0]
268
+ meta = FAMILY_METADATA.get(fam, {})
269
+ catalog.append({
270
+ "rule_id": rid,
271
+ "title": r.get("title", "Security Invariant"),
272
+ "severity": r.get("severity", "Medium"),
273
+ "category": r.get("category", "general"),
274
+ "cluster": r.get("cluster", "cluster-general"),
275
+ "layer_id": meta.get("layer_id", "layer-platform"),
276
+ "layer_name": meta.get("layer_name", "Platform & Security"),
277
+ "guarantee": meta.get("guarantee", "Enforces security guardrails without bypasses."),
278
+ "owasp": meta.get("owasp", "OWASP Security Standard"),
279
+ "compliance": meta.get("compliance", "Enterprise Security Baseline")
280
+ })
281
+ if catalog:
282
+ return catalog
283
+ except Exception:
284
+ pass
285
+
286
+ # Fallback built-in catalog if audit_runner cannot be loaded
287
+ return [
288
+ {"rule_id": "TG-SEC-001", "title": "Hardcoded Secret or API Key", "severity": "Critical", "category": "secrets", "layer_id": "layer-platform", "layer_name": "Platform, Supply Chain & Secrets", "guarantee": "Zero hardcoded secrets in source.", "owasp": "OWASP A02:2021", "compliance": "SOC 2 CC6.7"},
289
+ {"rule_id": "TG-SEC-002", "title": "Public Client Environment Secret", "severity": "Critical", "category": "secrets", "layer_id": "layer-platform", "layer_name": "Platform, Supply Chain & Secrets", "guarantee": "Zero private keys in frontend bundles.", "owasp": "OWASP A01:2021", "compliance": "SOC 2 CC6.1"},
290
+ {"rule_id": "TG-AUTH-001", "title": "Weak Password Storage", "severity": "Critical", "category": "auth", "layer_id": "layer-auth", "layer_name": "Identity, Auth & Real-Time", "guarantee": "Enforces strong password hashing.", "owasp": "OWASP A07:2021", "compliance": "SOC 2 CC6.1"},
291
+ {"rule_id": "TG-DB-001", "title": "Raw SQL Concatenation", "severity": "Critical", "category": "database", "layer_id": "layer-db", "layer_name": "Database, GraphQL & Cache", "guarantee": "Enforces parameterized queries.", "owasp": "OWASP A03:2021", "compliance": "SOC 2 CC6.1"},
292
+ {"rule_id": "TG-INPUT-001", "title": "Missing Server Input Validation", "severity": "High", "category": "input", "layer_id": "layer-app", "layer_name": "Application & LLM Defense", "guarantee": "Enforces boundary schema validation.", "owasp": "OWASP A03:2021", "compliance": "ISO 27001 A.8.28"},
293
+ {"rule_id": "TG-RATE-001", "title": "Unlimited Auth Endpoint", "severity": "High", "category": "rate", "layer_id": "layer-net", "layer_name": "Network, SSRF & Webhooks", "guarantee": "Enforces auth rate limiting.", "owasp": "OWASP A04:2021", "compliance": "SOC 2 CC6.6"},
294
+ {"rule_id": "TG-SSRF-001", "title": "Unvalidated Outbound HTTP Request", "severity": "High", "category": "ssrf", "layer_id": "layer-net", "layer_name": "Network, SSRF & Webhooks", "guarantee": "Blocks private VPC and metadata IPs.", "owasp": "OWASP A10:2021", "compliance": "SOC 2 CC6.6"},
295
+ {"rule_id": "TG-PLATFORM-001", "title": "Missing Security Headers", "severity": "Medium", "category": "platform", "layer_id": "layer-platform", "layer_name": "Platform, Supply Chain & Secrets", "guarantee": "Enforces HTTP hardening headers.", "owasp": "OWASP A05:2021", "compliance": "SOC 2 CC6.7"}
296
+ ]
297
+
298
+
299
+ def load_report_telemetry(root_dir: Path) -> dict[str, Any]:
300
+ """Gather all project telemetry, findings, rules catalog, and memory state."""
301
+ tg_dir = root_dir / ".torusguard"
302
+ memory_dir = tg_dir / "memory"
303
+ config_file = tg_dir / "config" / "torusguard.json"
304
+
305
+ cfg: dict[str, Any] = {}
306
+ if config_file.exists():
307
+ try:
308
+ cfg = json.loads(config_file.read_text(encoding="utf-8"))
309
+ except (json.JSONDecodeError, OSError):
310
+ cfg = {}
311
+
312
+ profile_file = memory_dir / "profile.json"
313
+ patterns_file = memory_dir / "patterns.json"
314
+ context_file = memory_dir / "context.json"
315
+
316
+ profile: dict[str, Any] = {}
317
+ patterns: list[dict[str, Any]] = []
318
+ context: dict[str, Any] = {}
319
+
320
+ if profile_file.exists():
321
+ try:
322
+ profile = json.loads(profile_file.read_text(encoding="utf-8"))
323
+ except (json.JSONDecodeError, OSError):
324
+ profile = {}
325
+
326
+ if patterns_file.exists():
327
+ try:
328
+ patterns = json.loads(patterns_file.read_text(encoding="utf-8"))
329
+ except (json.JSONDecodeError, OSError):
330
+ patterns = []
331
+
332
+ if context_file.exists():
333
+ try:
334
+ context = json.loads(context_file.read_text(encoding="utf-8"))
335
+ except (json.JSONDecodeError, OSError):
336
+ context = {}
337
+
338
+ stack_info = cfg.get("detected_stack", {})
339
+ if not stack_info:
340
+ try:
341
+ import stack_detect # type: ignore
342
+ stack_info = stack_detect.detect_stack(root_dir)
343
+ except Exception:
344
+ stack_info = {"language": "Universal", "framework": "None", "data_layer": "None"}
345
+
346
+ # Load actual findings from latest audit run
347
+ runs_dir = tg_dir / "runs"
348
+ latest_findings: list[dict[str, Any]] = []
349
+ latest_run_name = "none"
350
+ if runs_dir.is_dir():
351
+ run_folders = sorted(
352
+ [d for d in runs_dir.iterdir() if d.is_dir() and (d / "findings.json").is_file()],
353
+ key=lambda p: p.stat().st_mtime,
354
+ reverse=True
355
+ )
356
+ if run_folders:
357
+ findings_file = run_folders[0] / "findings.json"
358
+ latest_run_name = run_folders[0].name
359
+ try:
360
+ latest_findings = json.loads(findings_file.read_text(encoding="utf-8"))
361
+ except (json.JSONDecodeError, OSError):
362
+ latest_findings = []
363
+
364
+ # Ingest historical & resolved findings from security_report.md
365
+ report_file = root_dir / "security_report.md"
366
+ resolved_findings: list[dict[str, Any]] = []
367
+ if report_file.is_file():
368
+ try:
369
+ import report_sync # type: ignore
370
+ parsed = report_sync.parse_existing_report(report_file)
371
+ for fid, rf in parsed.get("findings", {}).items():
372
+ st = str(rf.get("status", "")).upper()
373
+ if "RESOLVED" in st or "FIXED" in st:
374
+ tgt = rf.get("target", "")
375
+ fpath = tgt.rsplit(":", 1)[0] if ":" in tgt else tgt
376
+ try:
377
+ lineno = int(tgt.rsplit(":", 1)[1]) if ":" in tgt else 1
378
+ except ValueError:
379
+ lineno = 1
380
+ conf_str = str(rf.get("confidence", "70%"))
381
+ try:
382
+ cval = int(conf_str.split("%")[0].strip())
383
+ except Exception:
384
+ cval = 70
385
+ resolved_findings.append({
386
+ "finding_id": fid,
387
+ "rule_id": rf.get("rule_id", "TG-GEN"),
388
+ "title": rf.get("title", "Resolved Security Flaw"),
389
+ "severity": rf.get("severity", "Medium"),
390
+ "status": "resolved",
391
+ "confidence_score": cval,
392
+ "file_path": fpath,
393
+ "line_number": lineno,
394
+ "cluster": rf.get("cluster", "cluster-general"),
395
+ "description": rf.get("description", "Security violation remediated and hardened."),
396
+ "is_canary": False
397
+ })
398
+ except Exception:
399
+ pass
400
+
401
+ # Merge findings: open findings take precedence; resolved findings provide historical closure proof
402
+ all_findings_map: dict[str, dict[str, Any]] = {}
403
+ for f in resolved_findings:
404
+ all_findings_map[f.get("finding_id", "")] = f
405
+ for f in latest_findings:
406
+ all_findings_map[f.get("finding_id", "")] = f
407
+
408
+ combined_findings = list(all_findings_map.values()) if all_findings_map else latest_findings
409
+
410
+ # Rule catalog & Safe vs. Harmed computation (only open issues count as violated)
411
+ all_rules = load_all_rule_catalog()
412
+ violated_ids = {
413
+ f.get("rule_id") for f in combined_findings
414
+ if not f.get("is_canary", False) and f.get("status") not in ("resolved", "fixed", "confirmed_fixed")
415
+ }
416
+ safe_rules = [r for r in all_rules if r["rule_id"] not in violated_ids]
417
+ violated_rules = [r for r in all_rules if r["rule_id"] in violated_ids]
418
+
419
+ ist_tz = datetime.timezone(datetime.timedelta(hours=5, minutes=30), name="IST")
420
+ now_ist = datetime.datetime.now(ist_tz)
421
+ return {
422
+ "project_name": root_dir.name or "Project",
423
+ "root_dir": str(root_dir),
424
+ "generated_at": now_ist.strftime("%Y-%m-%d %H:%M:%S IST"),
425
+ "config": cfg,
426
+ "profile": profile,
427
+ "patterns": patterns,
428
+ "context": context,
429
+ "detected_stack": stack_info,
430
+ "findings": combined_findings,
431
+ "open_findings": [f for f in combined_findings if f.get("status") not in ("resolved", "fixed", "confirmed_fixed")],
432
+ "resolved_findings": [f for f in combined_findings if f.get("status") in ("resolved", "fixed", "confirmed_fixed")],
433
+ "latest_run": latest_run_name,
434
+ "all_rules": all_rules,
435
+ "safe_rules": safe_rules,
436
+ "violated_rules": violated_rules,
437
+ }
438
+
439
+
440
+ def compute_posture_score(telemetry: dict[str, Any]) -> int:
441
+ """Calculate posture score from actual open findings, aligned with report_sync.py."""
442
+ findings = telemetry.get("findings", [])
443
+ if not findings:
444
+ return 100
445
+
446
+ # Only open non-canary findings count toward production penalty
447
+ open_findings = [f for f in findings if f.get("status") not in ("resolved", "fixed", "confirmed_fixed") and not f.get("is_canary", False)]
448
+ if not open_findings:
449
+ return 100
450
+
451
+ critical = sum(1 for f in open_findings if f.get("severity") == "Critical")
452
+ high = sum(1 for f in open_findings if f.get("severity") == "High")
453
+ medium = sum(1 for f in open_findings if f.get("severity") == "Medium")
454
+ low = sum(1 for f in open_findings if f.get("severity") == "Low")
455
+
456
+ penalty = (critical * 25) + (high * 15) + (medium * 5) + (low * 2)
457
+ return max(0, min(100, 100 - penalty))
458
+
459
+
460
+ def get_recommended_defenses(detected_stack: dict[str, Any], violated_rule_ids: set[str]) -> list[dict[str, Any]]:
461
+ """Generate prescriptive next best defense recommendations tailored to the stack."""
462
+ recs = []
463
+ lang = (detected_stack.get("language") or "Universal")
464
+ fw = (detected_stack.get("framework") or "Universal")
465
+ stack_tag = f"{lang}" + (f" / {fw}" if fw != "None" else "")
466
+
467
+ # Platform headers recommendation
468
+ if "TG-PLATFORM-002" not in violated_rule_ids and "TG-PLATFORM-001" not in violated_rule_ids:
469
+ recs.append({
470
+ "rule_id": "TG-PLATFORM-002",
471
+ "priority": "P0 Critical",
472
+ "title": "Enforce HTTP Security Headers (CSP, HSTS, X-Frame-Options)",
473
+ "mitigates": "OWASP A05: Security Misconfiguration",
474
+ "stack_tag": stack_tag,
475
+ "rationale": "Protects against clickjacking, MIME-confusion, and cross-site scripting attacks.",
476
+ "invariant_guarantee": "Zero unhardened HTTP endpoints. Requires HSTS max-age >= 31536000 and CSP nonce isolation.",
477
+ "command": "npx torusguard harden --rule TG-PLATFORM-002"
478
+ })
479
+
480
+ # Rate limiting on auth
481
+ recs.append({
482
+ "rule_id": "TG-RATE-001",
483
+ "priority": "P0 Critical",
484
+ "title": "Auth Endpoint Rate Limiting & Resource Throttling",
485
+ "mitigates": "OWASP A07: Identification and Authentication Failures",
486
+ "stack_tag": stack_tag,
487
+ "rationale": "Prevents brute-force credential stuffing, password spraying, and resource exhaustion.",
488
+ "invariant_guarantee": "Max 5 failed attempts per IP window on auth routes with exponential backoff.",
489
+ "command": "npx torusguard harden --rule TG-RATE-001"
490
+ })
491
+
492
+ # Timing-safe auth compare
493
+ recs.append({
494
+ "rule_id": "TG-AUTH-002",
495
+ "priority": "P1 Recommended",
496
+ "title": "Timing-Safe Secret & Signature Verification",
497
+ "mitigates": "OWASP A02: Cryptographic Failures",
498
+ "stack_tag": stack_tag,
499
+ "rationale": "Eliminates side-channel timing attacks on API keys, HMAC signatures, and tokens.",
500
+ "invariant_guarantee": "Constant-time evaluation across all cryptographic comparison call-sites.",
501
+ "command": "npx torusguard harden --rule TG-AUTH-002"
502
+ })
503
+
504
+ # Tenant database isolation
505
+ if "TG-DB-004" not in violated_rule_ids:
506
+ recs.append({
507
+ "rule_id": "TG-DB-004",
508
+ "priority": "P0 Critical",
509
+ "title": "Enforce Multi-Tenant Database Query Partition Scoping",
510
+ "mitigates": "OWASP A01: Broken Access Control",
511
+ "stack_tag": stack_tag,
512
+ "rationale": "Guarantees cross-tenant data isolation and eliminates BOLA/IDOR vulnerabilities.",
513
+ "invariant_guarantee": "All database queries MUST be explicitly scoped by authenticated tenant ID.",
514
+ "command": "npx torusguard harden --rule TG-DB-004"
515
+ })
516
+
517
+ # SSRF Private IP protection
518
+ recs.append({
519
+ "rule_id": "TG-SSRF-002",
520
+ "priority": "P1 Recommended",
521
+ "title": "Block Outbound Requests to Cloud Metadata (169.254.169.254)",
522
+ "mitigates": "OWASP A10: Server-Side Request Forgery",
523
+ "stack_tag": stack_tag,
524
+ "rationale": "Stops attackers from querying internal cloud metadata instances or private VPC ranges.",
525
+ "invariant_guarantee": "Strict hostname resolution with CIDR blocking for 169.254.169.254 and RFC1918 subnets.",
526
+ "command": "npx torusguard harden --rule TG-SSRF-002"
527
+ })
528
+
529
+ # AI Agent structural defense
530
+ if "TG-AGENT-001" not in violated_rule_ids:
531
+ recs.append({
532
+ "rule_id": "TG-AGENT-001",
533
+ "priority": "P2 Hardening",
534
+ "title": "Structural AI Prompt Sandboxing & Inert Delimiters",
535
+ "mitigates": "OWASP LLM01: Prompt Injection",
536
+ "stack_tag": "AI / LLM Stack",
537
+ "rationale": "Enforces user prompt boundary isolation to stop direct and indirect jailbreaks.",
538
+ "invariant_guarantee": "User input MUST be wrapped in inert XML tags and never directly concatenated into system prompt.",
539
+ "command": "npx torusguard harden --rule TG-AGENT-001"
540
+ })
541
+
542
+ return recs[:5]
543
+
544
+
545
+ def format_diff_html(diff_text: str) -> str:
546
+ """Format unified diff with colorized line styling."""
547
+ if not diff_text:
548
+ return '<span class="diff-line-ctx">// No diff representation recorded</span>'
549
+ formatted_lines = []
550
+ for line in diff_text.splitlines():
551
+ esc = html.escape(line)
552
+ if line.startswith("+++") or line.startswith("---"):
553
+ formatted_lines.append(f'<span class="diff-line-header">{esc}</span>')
554
+ elif line.startswith("@@"):
555
+ formatted_lines.append(f'<span class="diff-line-hunk">{esc}</span>')
556
+ elif line.startswith("+"):
557
+ formatted_lines.append(f'<span class="diff-line-add">{esc}</span>')
558
+ elif line.startswith("-"):
559
+ formatted_lines.append(f'<span class="diff-line-del">{esc}</span>')
560
+ else:
561
+ formatted_lines.append(f'<span class="diff-line-ctx">{esc}</span>')
562
+ return "\n".join(formatted_lines)
563
+
564
+
565
+ def get_curated_golden_recipes(patterns: list[dict[str, Any]]) -> list[dict[str, Any]]:
566
+ """Return memory recipes unpacked with rich diffs, Before/After snippets, and Ponytail metrics."""
567
+ memory_recipes = [p for p in patterns if p.get("pattern_type") == "golden_fix_recipe"]
568
+ unpacked: list[dict[str, Any]] = []
569
+
570
+ for idx, r in enumerate(memory_recipes):
571
+ rd = r.get("recipe_data") or {}
572
+ rule_id = rd.get("rule_id") or r.get("rule_id") or "TG-GEN"
573
+ desc = rd.get("description") or r.get("description") or "Verified fix pattern"
574
+ diff = rd.get("diff_snippet") or ""
575
+ before = rd.get("before_snippet") or ""
576
+ after = rd.get("after_snippet") or ""
577
+ metrics = rd.get("ponytail_metrics") or {"additions": 1, "deletions": 1}
578
+ ftype = rd.get("file_type") or r.get("file_type") or ".ts"
579
+ verified = rd.get("verified_count") or 1
580
+ rec_id = rd.get("recipe_id") or r.get("recipe_id") or f"recipe-{rule_id}-{idx}"
581
+
582
+ # Determine human category
583
+ if "SEC" in rule_id:
584
+ cat = "Secrets"
585
+ elif "CLIENT" in rule_id:
586
+ cat = "Client"
587
+ elif "DB" in rule_id or "GQL" in rule_id:
588
+ cat = "Database"
589
+ elif "INPUT" in rule_id or "AGENT" in rule_id:
590
+ cat = "Input"
591
+ elif "PLATFORM" in rule_id or "SUPPLY" in rule_id or "EDGE" in rule_id or "DIFF" in rule_id:
592
+ cat = "Platform"
593
+ elif "RATE" in rule_id:
594
+ cat = "Rate Limiting"
595
+ elif "SSRF" in rule_id or "WEBHOOK" in rule_id:
596
+ cat = "SSRF"
597
+ else:
598
+ cat = "General"
599
+
600
+ unpacked.append({
601
+ "recipe_id": rec_id,
602
+ "rule_id": rule_id,
603
+ "title": desc,
604
+ "description": desc,
605
+ "diff_snippet": diff,
606
+ "before_snippet": before,
607
+ "after_snippet": after,
608
+ "solution_pattern": after or diff,
609
+ "code_pattern": diff,
610
+ "ponytail_metrics": metrics,
611
+ "file_type": ftype,
612
+ "verified_count": verified,
613
+ "category": cat
614
+ })
615
+
616
+ if unpacked:
617
+ return unpacked
618
+
619
+ # Fallback built-in reference Golden Recipes if patterns is empty
620
+ return [
621
+ {
622
+ "recipe_id": "recipe-TG-DB-001-default",
623
+ "rule_id": "TG-DB-001",
624
+ "title": "Parameterized SQL Query Pattern",
625
+ "description": "Replaces vulnerable raw string interpolation with parameterized bound parameters.",
626
+ "diff_snippet": "--- a/db/queries.py\n+++ b/db/queries.py\n@@ -10,3 +10,3 @@\n-db.execute(f'SELECT * FROM users WHERE id = {user_id}')\n+db.execute('SELECT * FROM users WHERE id = :id', {'id': user_id})",
627
+ "before_snippet": "db.execute(f'SELECT * FROM users WHERE id = {user_id}')",
628
+ "after_snippet": "db.execute('SELECT * FROM users WHERE id = :id', {'id': user_id})",
629
+ "solution_pattern": "db.execute('SELECT * FROM users WHERE id = :id', {'id': user_id})",
630
+ "code_pattern": "- db.execute(f'SELECT * FROM users WHERE id = {user_id}')\n+ db.execute('SELECT * FROM users WHERE id = :id', {'id': user_id})",
631
+ "ponytail_metrics": {"additions": 1, "deletions": 1},
632
+ "file_type": ".py",
633
+ "verified_count": 1,
634
+ "category": "Database"
635
+ },
636
+ {
637
+ "recipe_id": "recipe-TG-AUTH-002-default",
638
+ "rule_id": "TG-AUTH-002",
639
+ "title": "Timing-Safe Cryptographic Comparison",
640
+ "description": "Replaces standard equality (===) with timing-safe constant-time evaluation.",
641
+ "diff_snippet": "--- a/auth/verify.ts\n+++ b/auth/verify.ts\n@@ -22,3 +22,3 @@\n-if (token === expectedSecret) {\n+if (crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expectedSecret))) {",
642
+ "before_snippet": "if (token === expectedSecret) { ... }",
643
+ "after_snippet": "if (crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expectedSecret))) { ... }",
644
+ "solution_pattern": "if (crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expectedSecret))) { ... }",
645
+ "code_pattern": "- if (token === expectedSecret) { ... }\n+ if (crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expectedSecret))) { ... }",
646
+ "ponytail_metrics": {"additions": 1, "deletions": 1},
647
+ "file_type": ".ts",
648
+ "verified_count": 1,
649
+ "category": "Auth"
650
+ },
651
+ {
652
+ "recipe_id": "recipe-TG-SSRF-002-default",
653
+ "rule_id": "TG-SSRF-002",
654
+ "title": "Outbound Private IP Range Filtering",
655
+ "description": "Validates outbound target IP against private VPC ranges and cloud metadata IPs.",
656
+ "diff_snippet": "--- a/net/proxy.py\n+++ b/net/proxy.py\n@@ -15,4 +15,6 @@\n-response = requests.get(target_url)\n+ip = socket.gethostbyname(urlparse(target_url).hostname)\n+if ipaddress.ip_address(ip).is_private: raise SecurityError('SSRF blocked')\n+response = requests.get(target_url)",
657
+ "before_snippet": "response = requests.get(target_url)",
658
+ "after_snippet": "ip = socket.gethostbyname(urlparse(target_url).hostname)\nif ipaddress.ip_address(ip).is_private: raise SecurityError('SSRF blocked')\nresponse = requests.get(target_url)",
659
+ "solution_pattern": "ip = socket.gethostbyname(urlparse(target_url).hostname)\nif ipaddress.ip_address(ip).is_private: raise SecurityError('SSRF blocked')\nresponse = requests.get(target_url)",
660
+ "code_pattern": "- response = requests.get(target_url)\n+ ip = socket.gethostbyname(urlparse(target_url).hostname)\n+ if ipaddress.ip_address(ip).is_private: raise SecurityError('SSRF blocked')",
661
+ "ponytail_metrics": {"additions": 3, "deletions": 1},
662
+ "file_type": ".py",
663
+ "verified_count": 1,
664
+ "category": "SSRF"
665
+ }
666
+ ]
667
+
668
+
669
+ def generate_html_dashboard(telemetry: dict[str, Any]) -> str:
670
+ """Build a complete, standalone, single-file interactive HTML security dashboard."""
671
+ score = compute_posture_score(telemetry)
672
+ findings = telemetry.get("findings", [])
673
+ patterns = telemetry.get("patterns", [])
674
+ cfg = telemetry.get("config", {})
675
+ detected = cfg.get("detected_stack", {})
676
+ profile = telemetry.get("profile", {})
677
+ all_rules = telemetry.get("all_rules", [])
678
+ safe_rules = telemetry.get("safe_rules", [])
679
+ violated_rules = telemetry.get("violated_rules", [])
680
+
681
+ # Polyglot stack information
682
+ primary_lang = detected.get("language") or "Universal"
683
+ primary_fw = detected.get("framework") or "None"
684
+ primary_db = detected.get("data_layer") or "None"
685
+
686
+ stack = profile.get("stack", [])
687
+ if not stack and primary_lang != "Unknown":
688
+ stack = [primary_lang]
689
+ if primary_fw != "None":
690
+ stack.append(primary_fw)
691
+ if primary_db != "None":
692
+ stack.append(primary_db)
693
+ stack_label = ", ".join(stack) if stack else "Polyglot Project"
694
+
695
+ # Open vs resolved findings separation
696
+ open_findings = [f for f in findings if f.get("status", "open") not in ("fixed", "resolved", "confirmed_fixed")]
697
+ resolved_findings = [f for f in findings if f.get("status", "open") in ("fixed", "resolved", "confirmed_fixed")]
698
+
699
+ crit_open = sum(1 for f in open_findings if f.get("severity") == "Critical")
700
+ crit_resolved = sum(1 for f in resolved_findings if f.get("severity") == "Critical")
701
+ crit_total = crit_open + crit_resolved
702
+
703
+ high_open = sum(1 for f in open_findings if f.get("severity") == "High")
704
+ high_resolved = sum(1 for f in resolved_findings if f.get("severity") == "High")
705
+ high_total = high_open + high_resolved
706
+
707
+ med_open = sum(1 for f in open_findings if f.get("severity") in ("Medium", "Low"))
708
+ med_resolved = sum(1 for f in resolved_findings if f.get("severity") in ("Medium", "Low"))
709
+ med_total = med_open + med_resolved
710
+
711
+ total_discovered = len(findings)
712
+ total_open = len(open_findings)
713
+ total_resolved = len(resolved_findings)
714
+ closure_rate_pct = int((total_resolved / total_discovered) * 100) if total_discovered > 0 else 100
715
+
716
+ total_rules = len(all_rules) if all_rules else 74
717
+ defended_count = len(safe_rules) if safe_rules else (total_rules - len(violated_rules))
718
+ coverage_pct = int((defended_count / total_rules) * 100) if total_rules > 0 else 100
719
+
720
+ # Sub-badges for the 4 Severity Cards in .grid-kpi
721
+ if crit_open == 0 and crit_resolved > 0:
722
+ crit_sub_badge = f'<div class="sev-sub-badge badge-sub-clean">🟢 {crit_resolved} Remediated (100% Defended)</div>'
723
+ elif crit_resolved > 0:
724
+ crit_sub_badge = f'<div class="sev-sub-badge">{crit_resolved} Solved / {crit_total} Total</div>'
725
+ else:
726
+ crit_sub_badge = '<div class="sev-sub-badge badge-sub-clean">0 Discovered</div>'
727
+
728
+ if high_open == 0 and high_resolved > 0:
729
+ high_sub_badge = f'<div class="sev-sub-badge badge-sub-clean">🟢 {high_resolved} Remediated (100% Defended)</div>'
730
+ elif high_resolved > 0:
731
+ high_sub_badge = f'<div class="sev-sub-badge">{high_resolved} Solved / {high_total} Total</div>'
732
+ else:
733
+ high_sub_badge = '<div class="sev-sub-badge badge-sub-clean">0 Discovered</div>'
734
+
735
+ if med_open == 0 and med_resolved > 0:
736
+ med_sub_badge = f'<div class="sev-sub-badge badge-sub-clean">🟢 {med_resolved} Remediated (100% Defended)</div>'
737
+ elif med_resolved > 0:
738
+ med_sub_badge = f'<div class="sev-sub-badge">{med_resolved} Solved / {med_total} Total</div>'
739
+ else:
740
+ med_sub_badge = '<div class="sev-sub-badge badge-sub-clean">0 Discovered</div>'
741
+
742
+ # Governed Remediation Velocity Ledger HTML
743
+ rem_crit_pct = 100 if crit_total == 0 or crit_open == 0 else int((crit_resolved / crit_total) * 100)
744
+ rem_high_pct = 100 if high_total == 0 or high_open == 0 else int((high_resolved / high_total) * 100)
745
+ rem_med_pct = 100 if med_total == 0 or med_open == 0 else int((med_resolved / med_total) * 100)
746
+
747
+ remediation_ledger_html = f"""
748
+ <!-- Governed Remediation & Problem-Solved Velocity Ledger -->
749
+ <div class="card remediation-ledger-card">
750
+ <div class="remediation-header">
751
+ <div class="remediation-title-group">
752
+ <span style="font-size: 22px;">⚡</span>
753
+ <div>
754
+ <div style="font-size: 15px; font-weight: 700; color: var(--text-main);">
755
+ Governed Remediation &amp; Problem-Solved Velocity
756
+ </div>
757
+ <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px;">
758
+ Historical closure ledger of vulnerabilities discovered, formulated into Ponytail patches, and verified fixed.
759
+ </div>
760
+ </div>
761
+ </div>
762
+ <div class="remediation-overall-badge">
763
+ <span class="badge {'badge-success' if total_open == 0 and total_resolved > 0 else 'badge-high'}" style="font-size: 12px; padding: 4px 10px;">
764
+ &#10004; {total_resolved} of {total_discovered} Fixed ({closure_rate_pct}% Defended)
765
+ </span>
766
+ </div>
767
+ </div>
768
+
769
+ <div class="rem-progress-bar-bg">
770
+ <div class="rem-progress-bar-fill" style="width: {closure_rate_pct}%;"></div>
771
+ </div>
772
+
773
+ <div class="rem-stat-chips-grid">
774
+ <div class="rem-chip">
775
+ <div class="rem-chip-top">
776
+ <span class="rem-chip-sev badge badge-critical">Critical</span>
777
+ <span class="rem-chip-status" style="color: {'var(--accent-green)' if crit_open == 0 else 'var(--accent-red)'};">
778
+ {'🟢 100% Defended' if crit_open == 0 else f'{crit_open} Active'}
779
+ </span>
780
+ </div>
781
+ <div class="rem-chip-main">
782
+ <span class="rem-chip-num">{crit_resolved}</span>
783
+ <span class="rem-chip-lbl">Solved / {crit_total} Discovered</span>
784
+ </div>
785
+ {crit_sub_badge}
786
+ <div class="rem-chip-bar"><div class="rem-chip-bar-fill" style="width: {rem_crit_pct}%; background: var(--accent-green);"></div></div>
787
+ </div>
788
+
789
+ <div class="rem-chip">
790
+ <div class="rem-chip-top">
791
+ <span class="rem-chip-sev badge badge-high">High</span>
792
+ <span class="rem-chip-status" style="color: {'var(--accent-green)' if high_open == 0 else 'var(--accent-orange)'};">
793
+ {'🟢 100% Defended' if high_open == 0 else f'{high_open} Active'}
794
+ </span>
795
+ </div>
796
+ <div class="rem-chip-main">
797
+ <span class="rem-chip-num">{high_resolved}</span>
798
+ <span class="rem-chip-lbl">Solved / {high_total} Discovered</span>
799
+ </div>
800
+ {high_sub_badge}
801
+ <div class="rem-chip-bar"><div class="rem-chip-bar-fill" style="width: {rem_high_pct}%; background: var(--accent-green);"></div></div>
802
+ </div>
803
+
804
+ <div class="rem-chip">
805
+ <div class="rem-chip-top">
806
+ <span class="rem-chip-sev badge badge-medium">Medium / Low</span>
807
+ <span class="rem-chip-status" style="color: {'var(--accent-green)' if med_open == 0 else 'var(--accent-yellow)'};">
808
+ {'🟢 100% Defended' if med_open == 0 else f'{med_open} Active'}
809
+ </span>
810
+ </div>
811
+ <div class="rem-chip-main">
812
+ <span class="rem-chip-num">{med_resolved}</span>
813
+ <span class="rem-chip-lbl">Solved / {med_total} Discovered</span>
814
+ </div>
815
+ {med_sub_badge}
816
+ <div class="rem-chip-bar"><div class="rem-chip-bar-fill" style="width: {rem_med_pct}%; background: var(--accent-green);"></div></div>
817
+ </div>
818
+
819
+ <div class="rem-chip rem-chip-total">
820
+ <div class="rem-chip-top">
821
+ <span class="rem-chip-sev badge badge-success">Guards Defended</span>
822
+ <span class="rem-chip-status" style="color: var(--accent-green);">
823
+ {'🟢 100% Hardened' if total_open == 0 else 'In Progress'}
824
+ </span>
825
+ </div>
826
+ <div class="rem-chip-main">
827
+ <span class="rem-chip-num">{total_resolved}</span>
828
+ <span class="rem-chip-lbl">Total Flaws Neutralized</span>
829
+ </div>
830
+ <div class="rem-chip-bar"><div class="rem-chip-bar-fill" style="width: {closure_rate_pct}%; background: var(--accent-green);"></div></div>
831
+ </div>
832
+ </div>
833
+ </div>
834
+ """
835
+
836
+ # Recipes
837
+ recipes = get_curated_golden_recipes(patterns)
838
+
839
+ # Recommendations (only open findings should be treated as violated)
840
+ violated_ids = {
841
+ f.get("rule_id") for f in findings
842
+ if not f.get("is_canary", False) and f.get("status", "open") not in ("fixed", "resolved", "confirmed_fixed")
843
+ }
844
+ recommendations = get_recommended_defenses(detected, violated_ids)
845
+
846
+ # Open vs resolved counts
847
+ open_count = total_open
848
+ resolved_count = total_resolved
849
+
850
+ # Gauge values & Dynamic Theme Accents
851
+ circumference = 251.2
852
+ dash_offset = circumference - (circumference * (score / 100.0))
853
+ if score >= 90:
854
+ grad_start = "#3fb950"
855
+ grad_end = "#2ea043"
856
+ glow_color = "#3fb950"
857
+ status_label = "🟢 HARDENED & SECURE"
858
+ status_badge_class = "status-clean"
859
+ elif score >= 75:
860
+ grad_start = "#58a6ff"
861
+ grad_end = "#1f6feb"
862
+ glow_color = "#58a6ff"
863
+ status_label = "🔵 ELEVATED RESILIENCE"
864
+ status_badge_class = "status-elevated"
865
+ elif score >= 50:
866
+ grad_start = "#d29922"
867
+ grad_end = "#bb8009"
868
+ glow_color = "#d29922"
869
+ status_label = "🟡 WARNINGS DETECTED"
870
+ status_badge_class = "status-warn"
871
+ else:
872
+ grad_start = "#f85149"
873
+ grad_end = "#da3633"
874
+ glow_color = "#f85149"
875
+ status_label = "🔴 CRITICAL RISK"
876
+ status_badge_class = "status-critical"
877
+
878
+ # ── Findings Table HTML (with Expandable Drawers & Simulator Checkboxes) ──
879
+ findings_rows = ""
880
+ sev_order = {"Critical": 0, "High": 1, "Medium": 2, "Low": 3}
881
+ status_order = lambda f: 0 if f.get("status", "open") not in ("fixed", "resolved", "confirmed_fixed") else 1
882
+ sorted_f = sorted(findings, key=lambda x: (status_order(x), sev_order.get(x.get("severity", "Low"), 9), -x.get("confidence_score", 0)))
883
+
884
+ if sorted_f:
885
+ for idx, f in enumerate(sorted_f[:100]):
886
+ fid = html.escape(f.get("finding_id", f"fid-{idx}"))
887
+ drawer_id = f"drawer-{idx}"
888
+ sev = f.get("severity", "Medium")
889
+ sev_class = "badge-critical" if sev == "Critical" else ("badge-high" if sev == "High" else "badge-medium")
890
+ rule = html.escape(f.get("rule_id", "TG-GEN"))
891
+ title = html.escape(f.get("title", "Finding"))
892
+ fpath = html.escape(f.get("file_path", "unknown"))
893
+ line = f.get("line_number", 0)
894
+ conf = f.get("confidence_score", 70)
895
+ desc = html.escape(f.get("description", "Security invariant check failed."))
896
+ snippet = html.escape(f.get("snippet", f.get("matched_text", "// No preview snippet available")))
897
+ is_canary = f.get("is_canary", False)
898
+ canary_badge = ' <span class="badge badge-canary">Canary Test</span>' if is_canary else ''
899
+ f_status = "resolved" if f.get("status") in ("fixed", "resolved", "confirmed_fixed") else "open"
900
+ status_badge = ' <span class="badge badge-success">&#10004; Resolved</span>' if f_status == "resolved" else ""
901
+
902
+ search_blob = html.escape(f"{rule} {title} {fpath} {sev} {f_status}".lower())
903
+
904
+ if f_status == "resolved":
905
+ sim_cell = '<span style="color: var(--accent-green); font-size: 14px;" title="Already resolved and defended">&#10004;</span>'
906
+ drawer_body = f"""
907
+ <div class="drawer-header">
908
+ <div>
909
+ <strong>Finding ID:</strong> <code>{fid}</code> &middot;
910
+ <strong>File:</strong> <code>{fpath}:{line}</code> &middot;
911
+ <strong>Confidence:</strong> {conf}/100 &middot;
912
+ <span class="badge badge-success">&#10004; Resolved &amp; Defended</span>
913
+ </div>
914
+ </div>
915
+ <div class="drawer-notice-resolved" style="background: rgba(63, 185, 80, 0.1); border: 1px solid rgba(63, 185, 80, 0.3); border-radius: 6px; padding: 10px 14px; margin: 10px 0; font-size: 12px;">
916
+ <strong>&#10004; Remediated &amp; Hardened:</strong> This security violation has been successfully patched and verified under the Ponytail Protocol (&le; 35 additions, &le; 25 deletions).
917
+ </div>
918
+ <div class="drawer-desc">{desc}</div>
919
+ <div class="drawer-actions">
920
+ <div class="cmd-box">
921
+ <code>npx torusguard recheck --rule {rule}</code>
922
+ <button class="btn-copy" onclick="copyText('npx torusguard recheck --rule {rule}', this); event.stopPropagation();">&#128203; Copy Recheck Command</button>
923
+ </div>
924
+ </div>"""
925
+ else:
926
+ sim_cell = f"""<label class="sim-checkbox-label" title="Simulate fixing this finding in What-If Posture Simulator">
927
+ <input type="checkbox" class="sim-fix-chk" data-finding-id="{fid}" data-severity="{sev}" onchange="updateSimulatedScore()">
928
+ </label>"""
929
+ drawer_body = f"""
930
+ <div class="drawer-header">
931
+ <div>
932
+ <strong>Finding ID:</strong> <code>{fid}</code> &middot;
933
+ <strong>File:</strong> <code>{fpath}:{line}</code> &middot;
934
+ <strong>Confidence:</strong> {conf}/100
935
+ </div>
936
+ </div>
937
+ <div class="drawer-desc">{desc}</div>
938
+ <div class="code-preview">
939
+ <pre><code>{snippet}</code></pre>
940
+ </div>
941
+ <div class="drawer-actions">
942
+ <div class="cmd-box">
943
+ <code>npx torusguard harden --finding {fid}</code>
944
+ <button class="btn-copy" onclick="copyText('npx torusguard harden --finding {fid}', this); event.stopPropagation();">&#128203; Copy Command</button>
945
+ </div>
946
+ </div>"""
947
+
948
+ findings_rows += f"""
949
+ <tr class="finding-row" data-severity="{sev.lower()}" data-status="{f_status}" data-search="{search_blob}" data-path="{fpath}" data-drawer-id="{drawer_id}" onclick="toggleDrawer(this)">
950
+ <td style="text-align: center;" onclick="event.stopPropagation()">
951
+ {sim_cell}
952
+ </td>
953
+ <td><span class="badge {sev_class}">{sev}</span>{canary_badge}{status_badge}</td>
954
+ <td><code class="rule-id">{rule}</code></td>
955
+ <td class="finding-title">{title}</td>
956
+ <td><code>{fpath}:{line}</code></td>
957
+ <td><span class="conf-score">{conf}%</span></td>
958
+ <td style="text-align: right;"><span class="drawer-chevron">&#9656;</span></td>
959
+ </tr>
960
+ <tr id="{drawer_id}" class="drawer-row" style="display: none;">
961
+ <td colspan="7">
962
+ <div class="drawer-content">
963
+ {drawer_body}
964
+ </div>
965
+ </td>
966
+ </tr>"""
967
+ if len(sorted_f) > 100:
968
+ findings_rows += f'<tr><td colspan="7" class="overflow-row">... and {len(sorted_f) - 100} more findings cataloged in run artifacts</td></tr>'
969
+ else:
970
+ findings_rows = '<tr><td colspan="7" class="empty-cell">&#10004; Zero security invariant violations detected. Posture is 100% clean and defended.</td></tr>'
971
+
972
+ # ── 5-Layer Architectural Invariant Matrix ──
973
+ # Group all rules by architectural layer
974
+ layers_html = ""
975
+ for layer in ARCHITECTURAL_LAYERS:
976
+ lid = layer["id"]
977
+ lname = layer["name"]
978
+ licon = layer["icon"]
979
+ ldesc = layer["description"]
980
+ lfams = layer["families"]
981
+
982
+ layer_rules = [r for r in all_rules if any(r.get("rule_id", "").startswith(fam) for fam in lfams)]
983
+ l_total = len(layer_rules)
984
+ l_defended = sum(1 for r in layer_rules if r["rule_id"] not in violated_ids)
985
+ l_status_class = "status-clean" if l_defended == l_total else ("status-warn" if l_defended >= l_total * 0.75 else "status-critical")
986
+
987
+ chips_html = ""
988
+ for r in layer_rules:
989
+ rid = r["rule_id"]
990
+ rtitle = r["title"]
991
+ rsev = r.get("severity", "Medium")
992
+ is_defended = rid not in violated_ids
993
+ chip_cls = "inv-chip-safe" if is_defended else "inv-chip-violated"
994
+ dot_color = "var(--accent-green)" if is_defended else "var(--accent-red)"
995
+ status_text = "Defended" if is_defended else "Violated"
996
+ search_data = f"{rid} {rtitle} {rsev} {status_text} {lname}".lower()
997
+
998
+ chips_html += f"""
999
+ <div class="inv-chip {chip_cls}" data-rule="{rid}" data-layer="{lid}" data-search="{html.escape(search_data)}" onclick="inspectInvariant('{rid}')" title="Click to inspect security invariant guarantee for {rid}">
1000
+ <span class="inv-dot" style="background: {dot_color};"></span>
1001
+ <code class="inv-code">{rid}</code>
1002
+ <span class="inv-title">{html.escape(rtitle)}</span>
1003
+ </div>"""
1004
+
1005
+ layers_html += f"""
1006
+ <div class="layer-card" data-layer-id="{lid}">
1007
+ <div class="layer-header">
1008
+ <div class="layer-title-group">
1009
+ <span class="layer-icon">{licon}</span>
1010
+ <div>
1011
+ <div class="layer-name">{lname}</div>
1012
+ <div class="layer-desc">{ldesc}</div>
1013
+ </div>
1014
+ </div>
1015
+ <div class="layer-badge {l_status_class}">{l_defended}/{l_total} Enforced</div>
1016
+ </div>
1017
+ <div class="layer-chips-grid">
1018
+ {chips_html}
1019
+ </div>
1020
+ </div>"""
1021
+
1022
+ # ── OWASP Top 10 Compliance Radar ──
1023
+ owasp_defs = [
1024
+ ("A01: Broken Access Control", ["TG-DB-001", "TG-DB-004", "TG-AUTH-006", "TG-WS-002", "TG-BIZ-001", "TG-BIZ-002"]),
1025
+ ("A02: Cryptographic Failures", ["TG-SEC-001", "TG-SEC-002", "TG-SEC-003", "TG-SEC-004", "TG-SEC-005", "TG-AUTH-002"]),
1026
+ ("A03: Injection Defense", ["TG-INPUT-001", "TG-INPUT-002", "TG-INPUT-003", "TG-INPUT-004", "TG-DB-002", "TG-DB-003", "TG-GQL-001"]),
1027
+ ("A05: Security Configuration", ["TG-PLATFORM-001", "TG-PLATFORM-002", "TG-PLATFORM-003", "TG-PLATFORM-004", "TG-CLIENT-001", "TG-CLIENT-002"]),
1028
+ ("A06: Supply Chain Health", ["TG-SUPPLY-001", "TG-SUPPLY-002", "TG-SUPPLY-003", "TG-SUPPLY-004", "TG-SUPPLY-005"]),
1029
+ ("A07: Identity & Auth", ["TG-AUTH-001", "TG-AUTH-003", "TG-AUTH-004", "TG-AUTH-005", "TG-AUTH-007", "TG-CSRF-001", "TG-CSRF-002"]),
1030
+ ("A08: Software Integrity", ["TG-WEBHOOK-001", "TG-WEBHOOK-002", "TG-WEBHOOK-003", "TG-DIFF-001", "TG-DIFF-002"]),
1031
+ ("A10: SSRF Defense", ["TG-SSRF-001", "TG-SSRF-002", "TG-SSRF-003", "TG-SSRF-004"])
1032
+ ]
1033
+
1034
+ owasp_cards_html = ""
1035
+ for category_name, rule_codes in owasp_defs:
1036
+ matched = [
1037
+ f for f in findings
1038
+ if f.get("rule_id") in rule_codes
1039
+ and not f.get("is_canary", False)
1040
+ and str(f.get("status", "open")).lower() not in ("resolved", "fixed", "confirmed_fixed")
1041
+ ]
1042
+ cat_count = len(matched)
1043
+ if cat_count == 0:
1044
+ c_score = 100
1045
+ c_color = "var(--accent-green)"
1046
+ c_badge = "100% Compliant"
1047
+ elif cat_count == 1:
1048
+ c_score = 80
1049
+ c_color = "var(--accent-yellow)"
1050
+ c_badge = "1 Issue"
1051
+ else:
1052
+ c_score = max(20, 100 - (cat_count * 20))
1053
+ c_color = "var(--accent-red)"
1054
+ c_badge = f"{cat_count} Issues"
1055
+
1056
+ owasp_cards_html += f"""
1057
+ <div class="owasp-card">
1058
+ <div class="owasp-top">
1059
+ <span class="owasp-title">{category_name}</span>
1060
+ <span class="owasp-badge" style="color: {c_color}; border-color: {c_color};">{c_badge}</span>
1061
+ </div>
1062
+ <div class="owasp-bar-bg">
1063
+ <div class="owasp-bar" style="width: {c_score}%; background: {c_color};"></div>
1064
+ </div>
1065
+ </div>"""
1066
+
1067
+ # ── Enterprise Compliance Framework Matrix ──
1068
+ compliance_frameworks: list[ComplianceFramework] = [
1069
+ {
1070
+ "name": "SOC 2 Type II",
1071
+ "subtitle": "AICPA Trust Services Criteria (Security & Boundary)",
1072
+ "controls": [
1073
+ {"code": "CC6.1 Logical Access", "families": "TG-AUTH, TG-CLIENT", "rules": ["TG-AUTH-001", "TG-AUTH-002", "TG-AUTH-003", "TG-AUTH-004", "TG-CLIENT-001"]},
1074
+ {"code": "CC6.6 Network Boundary", "families": "TG-SSRF, TG-RATE, TG-WS", "rules": ["TG-SSRF-001", "TG-SSRF-002", "TG-RATE-001", "TG-WS-001"]},
1075
+ {"code": "CC6.7 Transmission Security", "families": "TG-PLATFORM, TG-SEC", "rules": ["TG-PLATFORM-001", "TG-PLATFORM-002", "TG-SEC-001", "TG-SEC-002"]},
1076
+ {"code": "CC7.1 Vulnerability & Churn", "families": "TG-DIFF, TG-SUPPLY", "rules": ["TG-DIFF-001", "TG-DIFF-002", "TG-SUPPLY-001", "TG-SUPPLY-002"]}
1077
+ ]
1078
+ },
1079
+ {
1080
+ "name": "ISO/IEC 27001:2022",
1081
+ "subtitle": "Annex A Controls for Technological & Application Security",
1082
+ "controls": [
1083
+ {"code": "A.8.20 Network Security", "families": "TG-SSRF, TG-WEBHOOK, TG-RATE", "rules": ["TG-SSRF-001", "TG-WEBHOOK-001", "TG-RATE-001"]},
1084
+ {"code": "A.8.24 Cryptography Usage", "families": "TG-SEC, TG-AUTH", "rules": ["TG-SEC-001", "TG-SEC-003", "TG-AUTH-002"]},
1085
+ {"code": "A.8.28 Secure Coding", "families": "TG-INPUT, TG-DB, TG-GQL, TG-AGENT", "rules": ["TG-INPUT-001", "TG-DB-001", "TG-GQL-001", "TG-AGENT-001"]}
1086
+ ]
1087
+ },
1088
+ {
1089
+ "name": "HIPAA Security Rule",
1090
+ "subtitle": "45 CFR Part 164 Technical Safeguards for ePHI",
1091
+ "controls": [
1092
+ {"code": "§ 164.312(a)(1) Access Control", "families": "TG-AUTH, TG-DB (Tenant Isolation)", "rules": ["TG-AUTH-001", "TG-DB-004", "TG-AUTH-006"]},
1093
+ {"code": "§ 164.312(c)(1) ePHI Integrity", "families": "TG-WEBHOOK, TG-BIZ, TG-DIFF", "rules": ["TG-WEBHOOK-001", "TG-BIZ-001", "TG-DIFF-001"]},
1094
+ {"code": "§ 164.312(e)(1) Transmission Security", "families": "TG-PLATFORM, TG-SEC, TG-CLIENT", "rules": ["TG-PLATFORM-001", "TG-SEC-001", "TG-CLIENT-001"]}
1095
+ ]
1096
+ }
1097
+ ]
1098
+
1099
+ compliance_cards_html = ""
1100
+ for fw in compliance_frameworks:
1101
+ fw_name = fw["name"]
1102
+ fw_sub = fw["subtitle"]
1103
+ controls = fw["controls"]
1104
+ all_fw_rules = [rid for c in controls for rid in c["rules"]]
1105
+ fw_violated = [rid for rid in all_fw_rules if rid in violated_ids]
1106
+ fw_pct = int(((len(all_fw_rules) - len(fw_violated)) / len(all_fw_rules)) * 100) if all_fw_rules else 100
1107
+ badge_cls = "badge-success" if fw_pct == 100 else ("badge-medium" if fw_pct >= 80 else "badge-critical")
1108
+
1109
+ ctrl_rows_html = ""
1110
+ for ctrl in controls:
1111
+ c_code = ctrl["code"]
1112
+ c_fams = ctrl["families"]
1113
+ c_rules = ctrl["rules"]
1114
+ c_viol = [r for r in c_rules if r in violated_ids]
1115
+ c_status = '<span class="badge badge-success">&#10004; Passing</span>' if not c_viol else f'<span class="badge badge-critical">{len(c_viol)} Violated</span>'
1116
+
1117
+ ctrl_rows_html += f"""
1118
+ <div class="comp-ctrl-row">
1119
+ <div>
1120
+ <div class="comp-ctrl-code">{c_code}</div>
1121
+ <div class="comp-ctrl-fams">{c_fams}</div>
1122
+ </div>
1123
+ <div>{c_status}</div>
1124
+ </div>"""
1125
+
1126
+ compliance_cards_html += f"""
1127
+ <div class="card comp-card">
1128
+ <div class="comp-card-header">
1129
+ <div>
1130
+ <div class="comp-title">{fw_name}</div>
1131
+ <div class="comp-sub">{fw_sub}</div>
1132
+ </div>
1133
+ <span class="badge {badge_cls}">{fw_pct}% Met</span>
1134
+ </div>
1135
+ <div class="comp-ctrl-list">
1136
+ {ctrl_rows_html}
1137
+ </div>
1138
+ </div>"""
1139
+
1140
+ # ── Root-Cause Architectural Clusters ──
1141
+ cluster_groups: dict[str, list[dict[str, Any]]] = {}
1142
+ for f in findings:
1143
+ c_key = f.get("cluster") or f.get("category") or "General Invariants"
1144
+ if c_key not in cluster_groups:
1145
+ cluster_groups[c_key] = []
1146
+ cluster_groups[c_key].append(f)
1147
+
1148
+ sorted_clusters = sorted(cluster_groups.items(), key=lambda x: len(x[1]), reverse=True)
1149
+ clusters_html = ""
1150
+ if sorted_clusters:
1151
+ for c_name, c_list in sorted_clusters[:8]:
1152
+ c_total = len(c_list)
1153
+ c_open = sum(1 for item in c_list if item.get("status", "open") not in ("resolved", "fixed", "confirmed_fixed"))
1154
+ c_resolved = c_total - c_open
1155
+ unique_files = len(set(item.get("file_path", "") for item in c_list if item.get("file_path")))
1156
+ if c_open == 0 and c_resolved > 0:
1157
+ badge_html = f'<span class="badge badge-success">&#10004; {c_resolved} Dismantled</span>'
1158
+ sub_text = f'Blast Radius: {unique_files} file(s) &middot; <span style="color: var(--accent-green); font-weight: 600;">🟢 Neutralized &amp; Defended</span>'
1159
+ else:
1160
+ badge_html = f'<span class="badge badge-high">{c_open} Active ({c_resolved} fixed)</span>'
1161
+ sub_text = f'Blast Radius: {unique_files} file(s) affected &middot; Remediation in progress'
1162
+
1163
+ clusters_html += f"""
1164
+ <div class="cluster-item">
1165
+ <div class="cluster-header">
1166
+ <strong>{html.escape(c_name.replace('-', ' ').title())}</strong>
1167
+ {badge_html}
1168
+ </div>
1169
+ <div class="cluster-sub">{sub_text}</div>
1170
+ </div>"""
1171
+ else:
1172
+ clusters_html = '<div class="empty-state">&#10004; Zero architectural vulnerability clusters identified. Systemic invariants enforced.</div>'
1173
+
1174
+ # ── Interactive 2D Directory Attack Surface Heatmap Matrix ──
1175
+ HEATMAP_DOMAINS = [
1176
+ {"key": "auth", "label": "AUTH", "name": "Identity & Auth", "pfx": ("TG-AUTH", "TG-CSRF", "TG-WS")},
1177
+ {"key": "db", "label": "DB", "name": "Database & Isolation", "pfx": ("TG-DB", "TG-GQL", "TG-CACHE")},
1178
+ {"key": "input", "label": "INPUT", "name": "Input & LLM Defense", "pfx": ("TG-INPUT", "TG-AGENT")},
1179
+ {"key": "net", "label": "NET", "name": "Network & SSRF", "pfx": ("TG-SSRF", "TG-WEBHOOK", "TG-RATE")},
1180
+ {"key": "plat", "label": "PLAT", "name": "Platform & Supply", "pfx": ("TG-PLATFORM", "TG-SUPPLY", "TG-DIFF", "TG-EDGE")},
1181
+ {"key": "sec", "label": "SEC", "name": "Secrets & Keys", "pfx": ("TG-SEC", "TG-CLIENT", "TG-BIZ")},
1182
+ ]
1183
+
1184
+ dir_matrix: dict[str, dict[str, Any]] = {}
1185
+ for f in findings:
1186
+ fp = f.get("file_path", "").replace("\\", "/")
1187
+ parts = fp.split("/")
1188
+ d_name = f"{parts[0]}/" if len(parts) > 1 else "root"
1189
+ rule_id = f.get("rule_id", "")
1190
+ domain_key = "sec"
1191
+ for d in HEATMAP_DOMAINS:
1192
+ if any(rule_id.startswith(p) for p in d["pfx"]):
1193
+ domain_key = d["key"]
1194
+ break
1195
+ if d_name not in dir_matrix:
1196
+ dir_matrix[d_name] = {
1197
+ "name": d_name,
1198
+ "domains": {d["key"]: {"open_crit": 0, "open_warn": 0, "resolved": 0} for d in HEATMAP_DOMAINS},
1199
+ "total_open": 0,
1200
+ "total_resolved": 0,
1201
+ }
1202
+ st = str(f.get("status", "open")).lower()
1203
+ sev = f.get("severity", "Medium")
1204
+ if st in ("resolved", "fixed", "confirmed_fixed"):
1205
+ dir_matrix[d_name]["domains"][domain_key]["resolved"] += 1
1206
+ dir_matrix[d_name]["total_resolved"] += 1
1207
+ else:
1208
+ if sev in ("Critical", "High"):
1209
+ dir_matrix[d_name]["domains"][domain_key]["open_crit"] += 1
1210
+ else:
1211
+ dir_matrix[d_name]["domains"][domain_key]["open_warn"] += 1
1212
+ dir_matrix[d_name]["total_open"] += 1
1213
+
1214
+ heatmap_rows_html = ""
1215
+ if dir_matrix:
1216
+ for d_name, d_data in sorted(dir_matrix.items(), key=lambda x: (x[1]["total_open"], x[1]["total_resolved"]), reverse=True)[:5]:
1217
+ escaped_d = html.escape(d_name)
1218
+ d_open = d_data["total_open"]
1219
+ d_res = d_data["total_resolved"]
1220
+ if d_open == 0 and d_res > 0:
1221
+ status_badge = f'<span class="heatmap-badge-clean">🟢 {d_res} Defended</span>'
1222
+ elif d_open > 0:
1223
+ status_badge = f'<span class="badge badge-high">🔴 {d_open} Active</span>'
1224
+ else:
1225
+ status_badge = '<span class="heatmap-badge-clean">Clean</span>'
1226
+
1227
+ cells_html = ""
1228
+ for d in HEATMAP_DOMAINS:
1229
+ cell = d_data["domains"][d["key"]]
1230
+ c_crit = cell["open_crit"]
1231
+ c_warn = cell["open_warn"]
1232
+ c_res = cell["resolved"]
1233
+ if c_crit > 0:
1234
+ cls = "heat-cell-crit"
1235
+ txt = f"🔴 {c_crit}"
1236
+ tip = f"{d_name} • {d['name']}: {c_crit} Critical/High flaws"
1237
+ elif c_warn > 0:
1238
+ cls = "heat-cell-warn"
1239
+ txt = f"🟡 {c_warn}"
1240
+ tip = f"{d_name} • {d['name']}: {c_warn} Warning flaws"
1241
+ elif c_res > 0:
1242
+ cls = "heat-cell-safe"
1243
+ txt = f"✔ {c_res}"
1244
+ tip = f"{d_name} • {d['name']}: {c_res} Invariants Hardened"
1245
+ else:
1246
+ cls = "heat-cell-idle"
1247
+ txt = "—"
1248
+ tip = f"{d_name} • {d['name']}: Clean / Zero Flaws"
1249
+ tip_escaped = html.escape(tip)
1250
+ cells_html += f"""<td class="heat-cell {cls}" title="{tip_escaped}" onclick="filterByDirectory('{escaped_d}'); event.stopPropagation();">{txt}</td>"""
1251
+
1252
+ heatmap_rows_html += f"""
1253
+ <tr class="heatmap-row" onclick="filterByDirectory('{escaped_d}')" title="Click to filter findings in {escaped_d}">
1254
+ <td class="heatmap-label-cell"><code>{escaped_d}</code></td>
1255
+ {cells_html}
1256
+ <td style="text-align: right;">{status_badge}</td>
1257
+ </tr>"""
1258
+ else:
1259
+ heatmap_rows_html = '<tr><td colspan="8" class="empty-state">&#10004; Zero directory attack surface exposure detected.</td></tr>'
1260
+
1261
+ # ── Golden Fix Recipes (Dual-View Diff / Split, Churn Metrics, Category Filter) ──
1262
+ # Category counts for filter pills
1263
+ recipe_cats: dict[str, int] = {}
1264
+ for r in recipes:
1265
+ c = r.get("category", "General")
1266
+ recipe_cats[c] = recipe_cats.get(c, 0) + 1
1267
+
1268
+ recipe_cat_pills_html = f'<span class="pill active" data-recipe-cat-filter="all" onclick="filterRecipeCategory(\'all\')">All ({len(recipes)})</span>'
1269
+ for c_name, c_cnt in sorted(recipe_cats.items()):
1270
+ c_slug = c_name.lower().replace(" ", "-")
1271
+ recipe_cat_pills_html += f'<span class="pill" data-recipe-cat-filter="{c_slug}" onclick="filterRecipeCategory(\'{c_slug}\')">{c_name} ({c_cnt})</span>'
1272
+
1273
+ recipes_html = ""
1274
+ for r in recipes:
1275
+ rid = html.escape(r.get("rule_id", "TG-FIX"))
1276
+ rtitle = html.escape(r.get("title", "Verified fix pattern"))
1277
+ rcat = html.escape(r.get("category", "General").lower().replace(" ", "-"))
1278
+ rec_key = html.escape(r.get("recipe_id", f"recipe-{rid}").replace(" ", "-"))
1279
+ metrics = r.get("ponytail_metrics") or {"additions": 1, "deletions": 1}
1280
+ adds = metrics.get("additions", 1)
1281
+ dels = metrics.get("deletions", 1)
1282
+ ftype = html.escape(r.get("file_type", ".ts"))
1283
+ vcount = r.get("verified_count", 1)
1284
+
1285
+ diff_str = r.get("diff_snippet") or ""
1286
+ before_str = r.get("before_snippet") or "// Vulnerable snippet"
1287
+ after_str = r.get("after_snippet") or r.get("solution_pattern") or "// Hardened pattern"
1288
+
1289
+ formatted_diff = format_diff_html(diff_str)
1290
+ escaped_before = html.escape(before_str)
1291
+ escaped_after = html.escape(after_str)
1292
+ copy_payload = html.escape((after_str or diff_str).replace("\n", " ").replace('"', '&quot;'))
1293
+
1294
+ recipes_html += f"""
1295
+ <div class="recipe-card" data-category="{rcat}">
1296
+ <div class="recipe-header">
1297
+ <div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap;">
1298
+ <span class="badge badge-success">{rid}</span>
1299
+ <strong>{rtitle}</strong>
1300
+ </div>
1301
+ <div class="recipe-meta-badges">
1302
+ <span class="chip chip-filetype">{ftype}</span>
1303
+ <span class="ponytail-badge" title="Ponytail line churn budget (&le; 35 additions, &le; 25 deletions)">⚡ +{adds} / -{dels} lines</span>
1304
+ <span class="verified-badge">&#10004; Verified {vcount}x</span>
1305
+ </div>
1306
+ </div>
1307
+ <div class="recipe-view-switcher">
1308
+ <button class="recipe-tab-btn active" id="btn-view-diff-{rec_key}" onclick="setRecipeView('{rec_key}', 'diff')">Unified Diff</button>
1309
+ <button class="recipe-tab-btn" id="btn-view-split-{rec_key}" onclick="setRecipeView('{rec_key}', 'split')">Before / After</button>
1310
+ </div>
1311
+ <div id="recipe-view-diff-{rec_key}" class="code-preview recipe-diff-container">
1312
+ <pre><code>{formatted_diff}</code></pre>
1313
+ </div>
1314
+ <div id="recipe-view-split-{rec_key}" class="recipe-split-container" style="display: none;">
1315
+ <div class="split-pane">
1316
+ <div class="split-label split-del">&#10008; Before (Vulnerable)</div>
1317
+ <div class="code-preview"><pre><code>{escaped_before}</code></pre></div>
1318
+ </div>
1319
+ <div class="split-pane">
1320
+ <div class="split-label split-add">&#10004; After (Hardened)</div>
1321
+ <div class="code-preview"><pre><code>{escaped_after}</code></pre></div>
1322
+ </div>
1323
+ </div>
1324
+ <div class="recipe-actions">
1325
+ <div class="cmd-box">
1326
+ <code>npx torusguard harden --rule {rid}</code>
1327
+ <button class="btn-copy" onclick="copyText('npx torusguard harden --rule {rid}', this); event.stopPropagation();">&#128203; Copy Command</button>
1328
+ </div>
1329
+ <button class="btn btn-sm" onclick="copyText('{copy_payload}', this); event.stopPropagation();">&#128203; Copy Pattern</button>
1330
+ </div>
1331
+ </div>"""
1332
+
1333
+ # ── Prescriptive "Next Best Defenses" Advisory ──
1334
+ advisory_html = ""
1335
+ for adv in recommendations:
1336
+ arid = html.escape(adv["rule_id"])
1337
+ aprio = html.escape(adv.get("priority", "P1 Recommended"))
1338
+ atitle = html.escape(adv["title"])
1339
+ amitig = html.escape(adv.get("mitigates", "OWASP Invariant"))
1340
+ astack = html.escape(adv.get("stack_tag", "Polyglot"))
1341
+ arationale = html.escape(adv["rationale"])
1342
+ aguarantee = html.escape(adv.get("invariant_guarantee", "Guarantees zero architectural bypasses."))
1343
+ acmd = html.escape(adv["command"])
1344
+
1345
+ prio_cls = "badge-critical" if "P0" in aprio else ("badge-high" if "P1" in aprio else "badge-canary")
1346
+
1347
+ advisory_html += f"""
1348
+ <div class="advisory-item">
1349
+ <div class="advisory-top">
1350
+ <div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap;">
1351
+ <span class="badge {prio_cls}">{aprio}</span>
1352
+ <span class="badge badge-primary">{arid}</span>
1353
+ <strong>{atitle}</strong>
1354
+ </div>
1355
+ <span class="chip chip-filetype">{astack}</span>
1356
+ </div>
1357
+ <div class="advisory-mitigates"><strong>Mitigates:</strong> {amitig}</div>
1358
+ <div class="advisory-desc">{arationale}</div>
1359
+ <div class="advisory-guarantee"><strong>Invariant Guarantee:</strong> {aguarantee}</div>
1360
+ <div class="cmd-box" style="margin-top: 8px;">
1361
+ <code>{acmd}</code>
1362
+ <button class="btn-copy" onclick="copyText('{acmd}', this); event.stopPropagation();">&#128203; Copy</button>
1363
+ </div>
1364
+ </div>"""
1365
+
1366
+ # ── Findings Section (Clean Baseline Card vs Active Attention) ──
1367
+ if open_count == 0:
1368
+ findings_section_html = f"""
1369
+ <!-- Clean & Hardened Baseline Summary (Zero Active Vulnerabilities) -->
1370
+ <div class="clean-baseline-card">
1371
+ <div class="clean-baseline-header">
1372
+ <div style="display: flex; align-items: center; gap: 12px;">
1373
+ <span style="font-size: 26px;">🛡️</span>
1374
+ <div>
1375
+ <div style="font-size: 15px; font-weight: 700; color: var(--accent-green); letter-spacing: -0.01em;">
1376
+ Clean &amp; Hardened Baseline &middot; 0 Active Vulnerabilities
1377
+ </div>
1378
+ <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px;">
1379
+ All {resolved_count} identified issues have been governed, hardened, and verified across all architectural layers.
1380
+ </div>
1381
+ </div>
1382
+ </div>
1383
+ <button class="btn-archive-toggle" id="btnArchiveToggle" onclick="toggleFindingsArchive()">
1384
+ <span id="archiveToggleIcon">&#9654;</span> View Findings Archive ({total_discovered})
1385
+ </button>
1386
+ </div>
1387
+ <div id="findingsArchiveBody" style="display: block; margin-top: 16px; border-top: 1px solid var(--border-subtle); padding-top: 16px;">
1388
+ <div class="filter-bar">
1389
+ <div class="filter-pills">
1390
+ <span class="pill-group-label">Severity:</span>
1391
+ <span class="pill active" data-sev-filter="all" onclick="filterBySeverity('all')">All</span>
1392
+ <span class="pill" data-sev-filter="critical" onclick="filterBySeverity('critical')">&#128308; Critical ({crit_open})</span>
1393
+ <span class="pill" data-sev-filter="high" onclick="filterBySeverity('high')">&#128992; High ({high_open})</span>
1394
+ <span class="pill" data-sev-filter="medium" onclick="filterBySeverity('medium')">&#128993; Med/Low ({med_open})</span>
1395
+
1396
+ <span class="pill-group-label" style="margin-left: 8px;">Status:</span>
1397
+ <span class="pill active" data-status-filter="all" onclick="filterByStatus('all')">All ({total_discovered})</span>
1398
+ <span class="pill" data-status-filter="open" onclick="filterByStatus('open')">&#9888; Open ({open_count})</span>
1399
+ <span class="pill" data-status-filter="resolved" onclick="filterByStatus('resolved')">&#10004; Resolved ({resolved_count})</span>
1400
+
1401
+ <span class="dir-filter-chip" id="dirFilterChip" onclick="clearDirectoryFilter()">
1402
+ Folder: <strong id="dirFilterName"></strong> &times;
1403
+ </span>
1404
+ <button class="btn-reset-filters" id="btnResetFilters" onclick="resetAllFilters()">&times; Reset Filters</button>
1405
+ </div>
1406
+ <div class="search-wrapper">
1407
+ <input type="text" id="searchInput" class="search-input" placeholder="Search rules, titles, files..." oninput="searchFindings()">
1408
+ <button class="search-clear-btn" id="searchClearBtn" onclick="clearSearch()">&times;</button>
1409
+ </div>
1410
+ </div>
1411
+ <div class="filter-stats-bar">
1412
+ <div>Showing <span id="visibleCount" style="font-weight: 700; color: var(--text-main);">{min(len(sorted_f), 50)}</span> of <span id="totalCount" style="font-weight: 700; color: var(--text-main);">{len(sorted_f)}</span> cataloged findings</div>
1413
+ <div id="activeFilterSummary" style="font-size: 11px; color: var(--accent-blue);"></div>
1414
+ </div>
1415
+ <div class="table-container">
1416
+ <table>
1417
+ <thead>
1418
+ <tr>
1419
+ <th style="width: 70px; text-align: center;">Simulate</th>
1420
+ <th style="width: 110px;">Severity</th>
1421
+ <th style="width: 130px;">Rule ID</th>
1422
+ <th>Finding Title</th>
1423
+ <th style="width: 250px;">File &amp; Location</th>
1424
+ <th style="width: 90px;">Confidence</th>
1425
+ <th style="width: 30px;"></th>
1426
+ </tr>
1427
+ </thead>
1428
+ <tbody id="findingsTableBody">
1429
+ {findings_rows}
1430
+ </tbody>
1431
+ </table>
1432
+ </div>
1433
+ </div>
1434
+ </div>"""
1435
+ else:
1436
+ findings_section_html = f"""
1437
+ <!-- Active Vulnerabilities Requiring Attention -->
1438
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; flex-wrap: wrap; gap: 8px;">
1439
+ <div class="section-title" style="margin-bottom: 0;">
1440
+ &#9888; Active Vulnerabilities Requiring Attention ({open_count})
1441
+ </div>
1442
+ <button class="btn-archive-toggle" id="btnArchiveToggle" onclick="toggleFindingsArchive()">
1443
+ <span id="archiveToggleIcon">&#9654;</span> View Resolved Archive ({resolved_count})
1444
+ </button>
1445
+ </div>
1446
+ <div class="filter-bar">
1447
+ <div class="filter-pills">
1448
+ <span class="pill-group-label">Severity:</span>
1449
+ <span class="pill active" data-sev-filter="all" onclick="filterBySeverity('all')">All</span>
1450
+ <span class="pill" data-sev-filter="critical" onclick="filterBySeverity('critical')">&#128308; Critical ({crit_open})</span>
1451
+ <span class="pill" data-sev-filter="high" onclick="filterBySeverity('high')">&#128992; High ({high_open})</span>
1452
+ <span class="pill" data-sev-filter="medium" onclick="filterBySeverity('medium')">&#128993; Med/Low ({med_open})</span>
1453
+
1454
+ <span class="pill-group-label" style="margin-left: 8px;">Status:</span>
1455
+ <span class="pill active" data-status-filter="all" onclick="filterByStatus('all')">All ({total_discovered})</span>
1456
+ <span class="pill" data-status-filter="open" onclick="filterByStatus('open')">&#9888; Open ({open_count})</span>
1457
+ <span class="pill" data-status-filter="resolved" onclick="filterByStatus('resolved')">&#10004; Resolved ({resolved_count})</span>
1458
+
1459
+ <span class="dir-filter-chip" id="dirFilterChip" onclick="clearDirectoryFilter()">
1460
+ Folder: <strong id="dirFilterName"></strong> &times;
1461
+ </span>
1462
+ <button class="btn-reset-filters" id="btnResetFilters" onclick="resetAllFilters()">&times; Reset Filters</button>
1463
+ </div>
1464
+ <div class="search-wrapper">
1465
+ <input type="text" id="searchInput" class="search-input" placeholder="Search rules, titles, files..." oninput="searchFindings()">
1466
+ <button class="search-clear-btn" id="searchClearBtn" onclick="clearSearch()">&times;</button>
1467
+ </div>
1468
+ </div>
1469
+ <div class="filter-stats-bar">
1470
+ <div>Showing <span id="visibleCount" style="font-weight: 700; color: var(--text-main);">{min(len(sorted_f), 50)}</span> of <span id="totalCount" style="font-weight: 700; color: var(--text-main);">{len(sorted_f)}</span> cataloged findings</div>
1471
+ <div id="activeFilterSummary" style="font-size: 11px; color: var(--accent-blue);"></div>
1472
+ </div>
1473
+ <div class="table-container">
1474
+ <table>
1475
+ <thead>
1476
+ <tr>
1477
+ <th style="width: 70px; text-align: center;">Simulate</th>
1478
+ <th style="width: 110px;">Severity</th>
1479
+ <th style="width: 130px;">Rule ID</th>
1480
+ <th>Finding Title</th>
1481
+ <th style="width: 250px;">File &amp; Location</th>
1482
+ <th style="width: 90px;">Confidence</th>
1483
+ <th style="width: 30px;"></th>
1484
+ </tr>
1485
+ </thead>
1486
+ <tbody id="findingsTableBody">
1487
+ {findings_rows}
1488
+ </tbody>
1489
+ </table>
1490
+ </div>
1491
+ <div id="findingsArchiveBody" style="display: block;"></div>"""
1492
+
1493
+ # ── Client-side JSON Payloads ──
1494
+ findings_json_str = json.dumps(findings).replace("</script>", "<\\/script>")
1495
+ sarif_rules_json_str = json.dumps([{"id": r["rule_id"], "shortDescription": {"text": r["title"]}} for r in all_rules[:74]])
1496
+ rule_catalog_json_str = json.dumps({r["rule_id"]: r for r in all_rules}).replace("</script>", "<\\/script>")
1497
+ violated_ids_json_str = json.dumps(list(violated_ids))
1498
+
1499
+ return f"""<!DOCTYPE html>
1500
+ <html lang="en">
1501
+ <head>
1502
+ <meta charset="UTF-8">
1503
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1504
+ <title>TorusGuard Security Report &middot; {html.escape(telemetry.get('project_name', 'Project'))}</title>
1505
+ <style>
1506
+ :root {{
1507
+ --bg-dark: #0d1117;
1508
+ --bg-surface: #161b22;
1509
+ --bg-card: #21262d;
1510
+ --border-subtle: #30363d;
1511
+ --text-main: #f0f6fc;
1512
+ --text-muted: #8b949e;
1513
+ --accent-blue: #58a6ff;
1514
+ --accent-green: #3fb950;
1515
+ --accent-yellow: #d29922;
1516
+ --accent-red: #f85149;
1517
+ --accent-orange: #db6d28;
1518
+ --accent-purple: #bc8cff;
1519
+ --font-stack: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
1520
+ }}
1521
+ body.light-theme {{
1522
+ --bg-dark: #f6f8fa;
1523
+ --bg-surface: #ffffff;
1524
+ --bg-card: #f0f3f6;
1525
+ --border-subtle: #d0d7de;
1526
+ --text-main: #1f2328;
1527
+ --text-muted: #656d76;
1528
+ }}
1529
+ * {{ box-sizing: border-box; margin: 0; padding: 0; }}
1530
+ body {{
1531
+ background: var(--bg-dark);
1532
+ color: var(--text-main);
1533
+ font-family: var(--font-stack);
1534
+ line-height: 1.5;
1535
+ padding: 24px;
1536
+ transition: background 0.2s ease, color 0.2s ease;
1537
+ }}
1538
+ .container {{ max-width: 1240px; margin: 0 auto; }}
1539
+
1540
+ /* Header */
1541
+ header {{
1542
+ display: flex;
1543
+ justify-content: space-between;
1544
+ align-items: center;
1545
+ padding-bottom: 20px;
1546
+ border-bottom: 1px solid var(--border-subtle);
1547
+ margin-bottom: 24px;
1548
+ flex-wrap: wrap;
1549
+ gap: 12px;
1550
+ }}
1551
+ .logo-group h1 {{
1552
+ font-size: 22px;
1553
+ font-weight: 700;
1554
+ display: flex;
1555
+ align-items: center;
1556
+ gap: 8px;
1557
+ }}
1558
+ .shield-icon {{ font-size: 24px; }}
1559
+ .project-meta {{ color: var(--text-muted); font-size: 13px; margin-top: 4px; }}
1560
+ .header-actions {{ display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }}
1561
+ .status-badge {{
1562
+ padding: 6px 14px;
1563
+ border-radius: 20px;
1564
+ font-size: 12px;
1565
+ font-weight: 600;
1566
+ }}
1567
+ .status-clean {{ background: rgba(63, 185, 80, 0.15); border: 1px solid var(--accent-green); color: var(--accent-green); }}
1568
+ .status-elevated {{ background: rgba(88, 166, 255, 0.15); border: 1px solid var(--accent-blue); color: var(--accent-blue); }}
1569
+ .status-warn {{ background: rgba(210, 153, 34, 0.15); border: 1px solid var(--accent-yellow); color: var(--accent-yellow); }}
1570
+ .status-critical {{ background: rgba(248, 81, 73, 0.15); border: 1px solid var(--accent-red); color: var(--accent-red); }}
1571
+
1572
+ .btn {{
1573
+ background: var(--bg-card);
1574
+ border: 1px solid var(--border-subtle);
1575
+ color: var(--text-main);
1576
+ padding: 6px 12px;
1577
+ border-radius: 6px;
1578
+ font-size: 12px;
1579
+ font-weight: 500;
1580
+ cursor: pointer;
1581
+ display: inline-flex;
1582
+ align-items: center;
1583
+ gap: 6px;
1584
+ transition: background 0.2s, border-color 0.2s;
1585
+ }}
1586
+ .btn:hover {{ background: #30363d; border-color: var(--text-muted); }}
1587
+ body.light-theme .btn:hover {{ background: #e1e4e8; }}
1588
+ .btn-sm {{ padding: 3px 8px; font-size: 11px; }}
1589
+
1590
+ /* KPI Grid */
1591
+ .grid-kpi {{
1592
+ display: grid;
1593
+ grid-template-columns: 220px repeat(4, 1fr);
1594
+ gap: 14px;
1595
+ margin-bottom: 24px;
1596
+ }}
1597
+ @media (max-width: 950px) {{ .grid-kpi {{ grid-template-columns: 1fr 1fr; }} }}
1598
+ @media (max-width: 600px) {{ .grid-kpi {{ grid-template-columns: 1fr; }} }}
1599
+
1600
+ .card {{
1601
+ background: var(--bg-surface);
1602
+ border: 1px solid var(--border-subtle);
1603
+ border-radius: 8px;
1604
+ padding: 18px;
1605
+ }}
1606
+ .gauge-card {{
1607
+ display: flex;
1608
+ flex-direction: column;
1609
+ align-items: center;
1610
+ justify-content: center;
1611
+ text-align: center;
1612
+ }}
1613
+ .gauge-svg {{ width: 110px; height: 110px; transform: rotate(-90deg); filter: drop-shadow(0 0 6px rgba(0, 0, 0, 0.3)); }}
1614
+ .gauge-bg {{ fill: none; stroke: var(--bg-card); stroke-width: 8; }}
1615
+ .gauge-fill {{
1616
+ fill: none;
1617
+ stroke-width: 8;
1618
+ stroke-linecap: round;
1619
+ stroke-dasharray: 251.2;
1620
+ stroke-dashoffset: 251.2;
1621
+ transition: stroke-dashoffset 0.9s cubic-bezier(0.4, 0, 0.2, 1);
1622
+ }}
1623
+ .gauge-value {{
1624
+ position: absolute;
1625
+ font-size: 30px;
1626
+ font-weight: 700;
1627
+ color: var(--text-main);
1628
+ letter-spacing: -0.5px;
1629
+ }}
1630
+ .sev-card {{ text-align: center; cursor: pointer; transition: transform 0.15s, border-color 0.2s; }}
1631
+ .sev-card:hover {{ transform: translateY(-2px); border-color: var(--accent-blue); }}
1632
+ .sev-card .sev-count {{ font-size: 34px; font-weight: 800; }}
1633
+ .sev-card .sev-label {{ font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); margin-top: 4px; }}
1634
+ .sev-sub-badge {{
1635
+ font-size: 11px;
1636
+ font-weight: 600;
1637
+ margin-top: 6px;
1638
+ padding: 2px 8px;
1639
+ border-radius: 12px;
1640
+ background: rgba(88, 166, 255, 0.1);
1641
+ border: 1px solid rgba(88, 166, 255, 0.25);
1642
+ color: var(--accent-blue);
1643
+ display: inline-block;
1644
+ }}
1645
+ .sev-sub-badge.badge-sub-clean {{
1646
+ background: rgba(63, 185, 80, 0.15);
1647
+ border-color: rgba(63, 185, 80, 0.4);
1648
+ color: var(--accent-green);
1649
+ }}
1650
+ .sev-sub-badge.badge-sub-warn {{
1651
+ background: rgba(210, 153, 34, 0.15);
1652
+ border-color: rgba(210, 153, 34, 0.4);
1653
+ color: var(--accent-yellow);
1654
+ }}
1655
+
1656
+ /* Governed Remediation Ledger */
1657
+ .remediation-ledger-card {{
1658
+ margin-bottom: 24px;
1659
+ background: var(--bg-surface);
1660
+ border: 1px solid var(--border-subtle);
1661
+ border-radius: 8px;
1662
+ padding: 18px;
1663
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
1664
+ border-top: 3px solid var(--accent-green);
1665
+ }}
1666
+ .remediation-header {{
1667
+ display: flex;
1668
+ justify-content: space-between;
1669
+ align-items: center;
1670
+ flex-wrap: wrap;
1671
+ gap: 12px;
1672
+ margin-bottom: 14px;
1673
+ }}
1674
+ .remediation-title-group {{
1675
+ display: flex;
1676
+ align-items: center;
1677
+ gap: 10px;
1678
+ }}
1679
+ .rem-progress-bar-bg {{
1680
+ width: 100%;
1681
+ height: 8px;
1682
+ background: var(--bg-card);
1683
+ border-radius: 4px;
1684
+ overflow: hidden;
1685
+ margin-bottom: 16px;
1686
+ }}
1687
+ .rem-progress-bar-fill {{
1688
+ height: 100%;
1689
+ background: linear-gradient(90deg, #2ea043, #3fb950);
1690
+ border-radius: 4px;
1691
+ transition: width 0.8s ease;
1692
+ }}
1693
+ .rem-stat-chips-grid {{
1694
+ display: grid;
1695
+ grid-template-columns: repeat(4, 1fr);
1696
+ gap: 12px;
1697
+ }}
1698
+ @media (max-width: 900px) {{ .rem-stat-chips-grid {{ grid-template-columns: 1fr 1fr; }} }}
1699
+ @media (max-width: 550px) {{ .rem-stat-chips-grid {{ grid-template-columns: 1fr; }} }}
1700
+ .rem-chip {{
1701
+ background: var(--bg-card);
1702
+ border: 1px solid var(--border-subtle);
1703
+ border-radius: 6px;
1704
+ padding: 12px;
1705
+ display: flex;
1706
+ flex-direction: column;
1707
+ gap: 6px;
1708
+ }}
1709
+ .rem-chip-top {{
1710
+ display: flex;
1711
+ justify-content: space-between;
1712
+ align-items: center;
1713
+ }}
1714
+ .rem-chip-sev {{ font-size: 10px; font-weight: 700; padding: 2px 6px; border-radius: 4px; }}
1715
+ .rem-chip-status {{ font-size: 11px; font-weight: 600; }}
1716
+ .rem-chip-main {{
1717
+ display: flex;
1718
+ align-items: baseline;
1719
+ gap: 6px;
1720
+ }}
1721
+ .rem-chip-num {{ font-size: 22px; font-weight: 800; color: var(--text-main); }}
1722
+ .rem-chip-lbl {{ font-size: 11px; color: var(--text-muted); }}
1723
+ .rem-chip-bar {{
1724
+ width: 100%;
1725
+ height: 4px;
1726
+ background: rgba(255, 255, 255, 0.05);
1727
+ border-radius: 2px;
1728
+ overflow: hidden;
1729
+ margin-top: 2px;
1730
+ }}
1731
+ .rem-chip-bar-fill {{ height: 100%; border-radius: 2px; }}
1732
+ .rem-chip-total {{
1733
+ border-color: rgba(63, 185, 80, 0.35);
1734
+ background: rgba(63, 185, 80, 0.05);
1735
+ }}
1736
+ .heatmap-badge-clean {{
1737
+ font-size: 10px;
1738
+ font-weight: 700;
1739
+ color: var(--accent-green);
1740
+ background: rgba(63, 185, 80, 0.12);
1741
+ border: 1px solid rgba(63, 185, 80, 0.3);
1742
+ border-radius: 4px;
1743
+ padding: 1px 6px;
1744
+ }}
1745
+
1746
+ /* What-If Posture Simulator Card */
1747
+ .simulator-card {{
1748
+ margin-bottom: 24px;
1749
+ background: var(--bg-surface);
1750
+ border: 1px solid var(--border-subtle);
1751
+ border-left: 4px solid var(--accent-blue);
1752
+ display: flex;
1753
+ justify-content: space-between;
1754
+ align-items: center;
1755
+ flex-wrap: wrap;
1756
+ gap: 12px;
1757
+ padding: 14px 18px;
1758
+ }}
1759
+ .simulator-clean {{ border-left-color: var(--accent-green); }}
1760
+ .sim-stats {{ display: flex; align-items: center; gap: 10px; font-size: 13px; }}
1761
+ .sim-actions {{ display: flex; gap: 6px; flex-wrap: wrap; }}
1762
+ .sim-checkbox-label {{
1763
+ display: inline-flex;
1764
+ align-items: center;
1765
+ gap: 4px;
1766
+ cursor: pointer;
1767
+ font-size: 11px;
1768
+ color: var(--text-muted);
1769
+ }}
1770
+ .sim-fix-chk {{ cursor: pointer; accent-color: var(--accent-blue); }}
1771
+
1772
+ /* Invariant Matrix */
1773
+ .defended-section {{
1774
+ background: var(--bg-surface);
1775
+ border: 1px solid var(--border-subtle);
1776
+ border-radius: 8px;
1777
+ padding: 18px;
1778
+ margin-bottom: 24px;
1779
+ }}
1780
+ .defended-header {{
1781
+ display: flex;
1782
+ justify-content: space-between;
1783
+ align-items: center;
1784
+ margin-bottom: 14px;
1785
+ cursor: pointer;
1786
+ }}
1787
+ .coverage-bar-container {{
1788
+ background: var(--bg-card);
1789
+ border: 1px solid var(--border-subtle);
1790
+ border-radius: 6px;
1791
+ padding: 10px 14px;
1792
+ margin-bottom: 16px;
1793
+ }}
1794
+ .coverage-stats {{
1795
+ display: flex;
1796
+ justify-content: space-between;
1797
+ align-items: center;
1798
+ font-size: 12px;
1799
+ color: var(--text-muted);
1800
+ margin-bottom: 6px;
1801
+ }}
1802
+ .coverage-progress-bg {{
1803
+ height: 8px;
1804
+ background: rgba(255, 255, 255, 0.05);
1805
+ border-radius: 4px;
1806
+ overflow: hidden;
1807
+ }}
1808
+ .coverage-progress-fill {{
1809
+ height: 100%;
1810
+ background: var(--accent-green);
1811
+ border-radius: 4px;
1812
+ transition: width 0.6s ease;
1813
+ }}
1814
+ .inv-search-bar {{
1815
+ margin-bottom: 16px;
1816
+ display: flex;
1817
+ gap: 10px;
1818
+ align-items: center;
1819
+ }}
1820
+ .layers-container {{
1821
+ display: flex;
1822
+ flex-direction: column;
1823
+ gap: 12px;
1824
+ }}
1825
+ .layer-card {{
1826
+ background: var(--bg-card);
1827
+ border: 1px solid var(--border-subtle);
1828
+ border-radius: 6px;
1829
+ padding: 12px 14px;
1830
+ }}
1831
+ .layer-header {{
1832
+ display: flex;
1833
+ justify-content: space-between;
1834
+ align-items: center;
1835
+ margin-bottom: 10px;
1836
+ }}
1837
+ .layer-title-group {{ display: flex; align-items: center; gap: 8px; }}
1838
+ .layer-icon {{ font-size: 16px; }}
1839
+ .layer-name {{ font-size: 13px; font-weight: 700; color: var(--text-main); }}
1840
+ .layer-desc {{ font-size: 11px; color: var(--text-muted); }}
1841
+ .layer-badge {{
1842
+ font-size: 11px;
1843
+ font-weight: 600;
1844
+ padding: 2px 8px;
1845
+ border-radius: 10px;
1846
+ }}
1847
+ .layer-chips-grid {{
1848
+ display: flex;
1849
+ flex-wrap: wrap;
1850
+ gap: 6px;
1851
+ }}
1852
+ .inv-chip {{
1853
+ background: var(--bg-surface);
1854
+ border: 1px solid var(--border-subtle);
1855
+ border-radius: 4px;
1856
+ padding: 4px 8px;
1857
+ font-size: 11px;
1858
+ display: inline-flex;
1859
+ align-items: center;
1860
+ gap: 6px;
1861
+ cursor: pointer;
1862
+ transition: all 0.15s ease;
1863
+ }}
1864
+ .inv-chip:hover {{
1865
+ border-color: var(--accent-blue);
1866
+ transform: translateY(-1px);
1867
+ }}
1868
+ .inv-chip-safe {{ border-left: 2px solid var(--accent-green); }}
1869
+ .inv-chip-violated {{ border-left: 2px solid var(--accent-red); background: rgba(248, 81, 73, 0.08); }}
1870
+ .inv-dot {{ width: 6px; height: 6px; border-radius: 50%; }}
1871
+ .inv-code {{ font-family: monospace; font-weight: 600; color: var(--accent-blue); font-size: 11px; }}
1872
+ .inv-title {{ color: var(--text-main); max-width: 140px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }}
1873
+
1874
+ /* Modal / Drawer for Invariant Inspection */
1875
+ .modal-backdrop {{
1876
+ position: fixed;
1877
+ top: 0;
1878
+ left: 0;
1879
+ width: 100vw;
1880
+ height: 100vh;
1881
+ background: rgba(0, 0, 0, 0.7);
1882
+ backdrop-filter: blur(2px);
1883
+ z-index: 9999;
1884
+ display: flex;
1885
+ align-items: center;
1886
+ justify-content: center;
1887
+ padding: 20px;
1888
+ }}
1889
+ .modal-card {{
1890
+ background: var(--bg-surface);
1891
+ border: 1px solid var(--border-subtle);
1892
+ border-radius: 8px;
1893
+ max-width: 600px;
1894
+ width: 100%;
1895
+ padding: 22px;
1896
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
1897
+ }}
1898
+ .modal-header {{
1899
+ display: flex;
1900
+ justify-content: space-between;
1901
+ align-items: flex-start;
1902
+ margin-bottom: 14px;
1903
+ }}
1904
+ .modal-close-btn {{
1905
+ background: transparent;
1906
+ border: none;
1907
+ color: var(--text-muted);
1908
+ font-size: 18px;
1909
+ cursor: pointer;
1910
+ }}
1911
+ .modal-close-btn:hover {{ color: var(--text-main); }}
1912
+ .modal-body {{ font-size: 13px; color: var(--text-main); display: flex; flex-direction: column; gap: 12px; }}
1913
+
1914
+ /* Filter & Search Bar */
1915
+ .filter-bar {{
1916
+ display: flex;
1917
+ justify-content: space-between;
1918
+ align-items: center;
1919
+ margin-bottom: 8px;
1920
+ flex-wrap: wrap;
1921
+ gap: 10px;
1922
+ }}
1923
+ .filter-pills {{ display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }}
1924
+ .pill-group-label {{
1925
+ font-size: 11px;
1926
+ font-weight: 600;
1927
+ text-transform: uppercase;
1928
+ letter-spacing: 0.5px;
1929
+ color: var(--text-muted);
1930
+ margin-right: 2px;
1931
+ }}
1932
+ .pill {{
1933
+ background: var(--bg-card);
1934
+ border: 1px solid var(--border-subtle);
1935
+ color: var(--text-muted);
1936
+ padding: 4px 10px;
1937
+ border-radius: 14px;
1938
+ font-size: 12px;
1939
+ font-weight: 500;
1940
+ cursor: pointer;
1941
+ transition: all 0.15s ease;
1942
+ user-select: none;
1943
+ }}
1944
+ .pill:hover {{ color: var(--text-main); border-color: var(--text-muted); }}
1945
+ .pill.active {{
1946
+ background: var(--accent-blue);
1947
+ border-color: var(--accent-blue);
1948
+ color: #fff;
1949
+ }}
1950
+ .dir-filter-chip {{
1951
+ display: none;
1952
+ background: rgba(88, 166, 255, 0.15);
1953
+ border: 1px solid var(--accent-blue);
1954
+ color: var(--accent-blue);
1955
+ font-size: 11px;
1956
+ padding: 3px 8px;
1957
+ border-radius: 12px;
1958
+ cursor: pointer;
1959
+ }}
1960
+ .btn-reset-filters {{
1961
+ display: none;
1962
+ background: rgba(248, 81, 73, 0.12);
1963
+ border: 1px solid rgba(248, 81, 73, 0.35);
1964
+ color: var(--accent-red);
1965
+ font-size: 11px;
1966
+ font-weight: 600;
1967
+ padding: 3px 8px;
1968
+ border-radius: 12px;
1969
+ cursor: pointer;
1970
+ margin-left: 4px;
1971
+ transition: all 0.15s;
1972
+ }}
1973
+ .btn-reset-filters:hover {{ background: rgba(248, 81, 73, 0.25); }}
1974
+ .search-wrapper {{
1975
+ position: relative;
1976
+ display: inline-flex;
1977
+ align-items: center;
1978
+ }}
1979
+ .search-input {{
1980
+ background: var(--bg-card);
1981
+ border: 1px solid var(--border-subtle);
1982
+ color: var(--text-main);
1983
+ padding: 7px 28px 7px 12px;
1984
+ border-radius: 6px;
1985
+ font-size: 13px;
1986
+ width: 280px;
1987
+ outline: none;
1988
+ transition: border-color 0.15s;
1989
+ }}
1990
+ .search-input:focus {{ border-color: var(--accent-blue); }}
1991
+ .search-clear-btn {{
1992
+ position: absolute;
1993
+ right: 8px;
1994
+ background: none;
1995
+ border: none;
1996
+ color: var(--text-muted);
1997
+ cursor: pointer;
1998
+ font-size: 14px;
1999
+ line-height: 1;
2000
+ padding: 2px 4px;
2001
+ display: none;
2002
+ }}
2003
+ .search-clear-btn:hover {{ color: var(--text-main); }}
2004
+ .filter-stats-bar {{
2005
+ font-size: 12px;
2006
+ color: var(--text-muted);
2007
+ margin-bottom: 14px;
2008
+ padding-left: 2px;
2009
+ display: flex;
2010
+ align-items: center;
2011
+ justify-content: space-between;
2012
+ }}
2013
+
2014
+ /* Clean Baseline Card & Findings Archive */
2015
+ .clean-baseline-card {{
2016
+ background: var(--bg-surface);
2017
+ border: 1px solid var(--border-subtle);
2018
+ border-left: 4px solid var(--accent-green);
2019
+ border-radius: 8px;
2020
+ padding: 16px 20px;
2021
+ margin-bottom: 24px;
2022
+ transition: border-color 0.2s;
2023
+ }}
2024
+ .clean-baseline-header {{
2025
+ display: flex;
2026
+ justify-content: space-between;
2027
+ align-items: center;
2028
+ flex-wrap: wrap;
2029
+ gap: 14px;
2030
+ }}
2031
+ .btn-archive-toggle {{
2032
+ background: var(--bg-card);
2033
+ border: 1px solid var(--border-subtle);
2034
+ color: var(--text-main);
2035
+ padding: 6px 14px;
2036
+ border-radius: 6px;
2037
+ font-size: 12px;
2038
+ font-weight: 600;
2039
+ cursor: pointer;
2040
+ display: inline-flex;
2041
+ align-items: center;
2042
+ gap: 8px;
2043
+ transition: all 0.2s ease;
2044
+ }}
2045
+ .btn-archive-toggle:hover {{
2046
+ background: #30363d;
2047
+ border-color: var(--accent-blue);
2048
+ color: var(--accent-blue);
2049
+ }}
2050
+ body.light-theme .btn-archive-toggle:hover {{
2051
+ background: #e1e4e8;
2052
+ }}
2053
+
2054
+ /* Table & Drawers */
2055
+ .table-container {{
2056
+ background: var(--bg-surface);
2057
+ border: 1px solid var(--border-subtle);
2058
+ border-radius: 8px;
2059
+ overflow: hidden;
2060
+ margin-bottom: 24px;
2061
+ }}
2062
+ table {{ width: 100%; border-collapse: collapse; text-align: left; font-size: 13px; }}
2063
+ th {{
2064
+ background: var(--bg-card);
2065
+ padding: 10px 14px;
2066
+ border-bottom: 1px solid var(--border-subtle);
2067
+ font-weight: 600;
2068
+ color: var(--text-muted);
2069
+ }}
2070
+ td {{ padding: 12px 14px; border-bottom: 1px solid var(--border-subtle); vertical-align: middle; }}
2071
+ tr.finding-row {{ cursor: pointer; transition: background 0.1s; }}
2072
+ tr.finding-row:hover {{ background: rgba(255, 255, 255, 0.02); }}
2073
+ body.light-theme tr.finding-row:hover {{ background: rgba(0, 0, 0, 0.02); }}
2074
+ tr.finding-row.expanded {{ background: rgba(88, 166, 255, 0.05); }}
2075
+ .drawer-chevron {{ display: inline-block; transition: transform 0.2s; font-size: 14px; color: var(--text-muted); }}
2076
+ tr.finding-row.expanded .drawer-chevron {{ transform: rotate(90deg); color: var(--accent-blue); }}
2077
+
2078
+ /* Badges */
2079
+ .badge {{
2080
+ display: inline-block;
2081
+ padding: 2px 8px;
2082
+ border-radius: 12px;
2083
+ font-size: 11px;
2084
+ font-weight: 600;
2085
+ text-transform: uppercase;
2086
+ letter-spacing: 0.3px;
2087
+ }}
2088
+ .badge-critical {{ background: rgba(248, 81, 73, 0.2); color: var(--accent-red); border: 1px solid var(--accent-red); }}
2089
+ .badge-high {{ background: rgba(219, 109, 40, 0.2); color: var(--accent-orange); border: 1px solid var(--accent-orange); }}
2090
+ .badge-medium {{ background: rgba(210, 153, 34, 0.2); color: var(--accent-yellow); border: 1px solid var(--accent-yellow); }}
2091
+ .badge-success {{ background: rgba(63, 185, 80, 0.2); color: var(--accent-green); border: 1px solid var(--accent-green); }}
2092
+ .badge-primary {{ background: rgba(88, 166, 255, 0.2); color: var(--accent-blue); border: 1px solid var(--accent-blue); }}
2093
+ .badge-canary {{ background: rgba(188, 140, 255, 0.2); color: var(--accent-purple); border: 1px solid var(--accent-purple); }}
2094
+
2095
+ .rule-id {{ color: var(--accent-blue); font-family: monospace; font-weight: 600; }}
2096
+ .conf-score {{ color: var(--text-muted); font-size: 12px; }}
2097
+
2098
+ /* Drawer Content */
2099
+ .drawer-content {{
2100
+ padding: 16px;
2101
+ background: var(--bg-dark);
2102
+ border-left: 3px solid var(--accent-blue);
2103
+ margin: 8px 12px;
2104
+ border-radius: 4px;
2105
+ }}
2106
+ body.light-theme .drawer-content {{ background: var(--bg-card); }}
2107
+ .drawer-header {{
2108
+ display: flex;
2109
+ justify-content: space-between;
2110
+ color: var(--text-muted);
2111
+ font-size: 12px;
2112
+ margin-bottom: 8px;
2113
+ }}
2114
+ .drawer-desc {{ font-size: 13px; color: var(--text-main); margin-bottom: 12px; }}
2115
+ .code-preview {{
2116
+ background: #000;
2117
+ border: 1px solid var(--border-subtle);
2118
+ border-radius: 4px;
2119
+ padding: 10px;
2120
+ font-family: monospace;
2121
+ font-size: 12px;
2122
+ overflow-x: auto;
2123
+ margin-bottom: 12px;
2124
+ }}
2125
+ body.light-theme .code-preview {{ background: #f6f8fa; border-color: var(--border-subtle); }}
2126
+ .code-preview code {{ color: #7ee787; font-family: monospace; }}
2127
+ body.light-theme .code-preview code {{ color: #1f2328; }}
2128
+ .drawer-actions {{ display: flex; justify-content: flex-end; gap: 8px; }}
2129
+ .cmd-box {{
2130
+ background: var(--bg-card);
2131
+ border: 1px solid var(--border-subtle);
2132
+ padding: 4px 8px;
2133
+ border-radius: 4px;
2134
+ display: flex;
2135
+ align-items: center;
2136
+ gap: 8px;
2137
+ font-size: 12px;
2138
+ font-family: monospace;
2139
+ }}
2140
+ .btn-copy {{
2141
+ background: transparent;
2142
+ border: none;
2143
+ color: var(--accent-blue);
2144
+ cursor: pointer;
2145
+ font-size: 11px;
2146
+ }}
2147
+ .btn-copy:hover {{ text-decoration: underline; }}
2148
+ .btn-copy.copied {{ color: var(--accent-green) !important; }}
2149
+
2150
+ /* Section Titles */
2151
+ .section-title {{
2152
+ font-size: 14px;
2153
+ font-weight: 700;
2154
+ text-transform: uppercase;
2155
+ letter-spacing: 0.5px;
2156
+ color: var(--text-muted);
2157
+ margin-bottom: 12px;
2158
+ display: flex;
2159
+ align-items: center;
2160
+ gap: 8px;
2161
+ }}
2162
+
2163
+ /* OWASP Grid */
2164
+ .owasp-card-wrapper {{
2165
+ padding: 10px 14px;
2166
+ display: flex;
2167
+ flex-direction: column;
2168
+ justify-content: flex-start;
2169
+ gap: 6px;
2170
+ }}
2171
+ .grid-owasp {{
2172
+ display: grid;
2173
+ grid-template-columns: repeat(4, 1fr);
2174
+ gap: 8px;
2175
+ margin-bottom: 0;
2176
+ }}
2177
+ @media (max-width: 950px) {{ .grid-owasp {{ grid-template-columns: repeat(2, 1fr); }} }}
2178
+ @media (max-width: 550px) {{ .grid-owasp {{ grid-template-columns: 1fr; }} }}
2179
+ .owasp-card {{
2180
+ background: var(--bg-surface);
2181
+ border: 1px solid var(--border-subtle);
2182
+ border-radius: 6px;
2183
+ padding: 8px 10px;
2184
+ }}
2185
+ .owasp-top {{ display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }}
2186
+ .owasp-title {{ font-size: 11px; font-weight: 600; color: var(--text-main); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }}
2187
+ .owasp-badge {{ font-size: 9.5px; font-weight: 700; border: 1px solid; border-radius: 8px; padding: 1px 5px; white-space: nowrap; }}
2188
+ .owasp-bar-bg {{ width: 100%; height: 5px; background: var(--bg-card); border-radius: 3px; overflow: hidden; }}
2189
+ .owasp-bar {{ height: 100%; border-radius: 3px; }}
2190
+
2191
+ /* Compliance Framework Grid */
2192
+ .comp-card-wrapper {{
2193
+ padding: 10px 14px;
2194
+ display: flex;
2195
+ flex-direction: column;
2196
+ justify-content: flex-start;
2197
+ gap: 6px;
2198
+ }}
2199
+ .grid-compliance {{
2200
+ display: grid;
2201
+ grid-template-columns: repeat(3, 1fr);
2202
+ gap: 8px;
2203
+ margin-bottom: 0;
2204
+ }}
2205
+ @media (max-width: 900px) {{ .grid-compliance {{ grid-template-columns: 1fr; }} }}
2206
+ .comp-card {{ padding: 8px 10px; }}
2207
+ .comp-card-header {{
2208
+ display: flex;
2209
+ justify-content: space-between;
2210
+ align-items: center;
2211
+ margin-bottom: 6px;
2212
+ border-bottom: 1px solid var(--border-subtle);
2213
+ padding-bottom: 4px;
2214
+ }}
2215
+ .comp-title {{ font-size: 12px; font-weight: 700; color: var(--text-main); }}
2216
+ .comp-sub {{ font-size: 9.5px; color: var(--text-muted); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 120px; }}
2217
+ .comp-ctrl-list {{ display: flex; flex-direction: column; gap: 4px; }}
2218
+ .comp-ctrl-row {{ display: flex; justify-content: space-between; align-items: center; font-size: 10.5px; padding: 1px 0; }}
2219
+ .comp-ctrl-code {{ font-weight: 600; color: var(--text-main); font-size: 10px; }}
2220
+ .comp-ctrl-fams {{ color: var(--text-muted); font-size: 9px; line-height: 1.1; }}
2221
+
2222
+ /* Dual Grids */
2223
+ .grid-bottom {{
2224
+ display: grid;
2225
+ grid-template-columns: 1fr 1fr;
2226
+ gap: 20px;
2227
+ margin-bottom: 24px;
2228
+ }}
2229
+ @media (max-width: 850px) {{ .grid-bottom {{ grid-template-columns: 1fr; }} }}
2230
+
2231
+ .clusters-card {{
2232
+ padding: 10px 14px;
2233
+ display: flex;
2234
+ flex-direction: column;
2235
+ justify-content: flex-start;
2236
+ gap: 6px;
2237
+ }}
2238
+ .clusters-compact-list {{
2239
+ display: grid;
2240
+ grid-template-columns: repeat(2, 1fr);
2241
+ gap: 6px;
2242
+ max-height: 140px;
2243
+ overflow-y: auto;
2244
+ }}
2245
+ .clusters-compact-list .empty-state {{
2246
+ grid-column: 1 / -1;
2247
+ }}
2248
+ @media (max-width: 600px) {{ .clusters-compact-list {{ grid-template-columns: 1fr; }} }}
2249
+ .cluster-item {{
2250
+ background: var(--bg-card);
2251
+ border: 1px solid var(--border-subtle);
2252
+ border-radius: 5px;
2253
+ padding: 5px 8px;
2254
+ margin-bottom: 0;
2255
+ }}
2256
+ .cluster-header {{ display: flex; justify-content: space-between; align-items: center; font-size: 11px; gap: 4px; }}
2257
+ .cluster-header strong {{ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 130px; }}
2258
+ .cluster-sub {{ font-size: 9.5px; color: var(--text-muted); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }}
2259
+
2260
+ /* Compact Single-Page Layout */
2261
+ .header-compact {{
2262
+ display: flex;
2263
+ justify-content: space-between;
2264
+ align-items: center;
2265
+ padding-bottom: 8px;
2266
+ border-bottom: 1px solid var(--border-subtle);
2267
+ margin-bottom: 4px;
2268
+ }}
2269
+ .row-top {{
2270
+ display: grid;
2271
+ grid-template-columns: 150px 1fr;
2272
+ gap: 12px;
2273
+ align-items: stretch;
2274
+ }}
2275
+ @media (max-width: 800px) {{ .row-top {{ grid-template-columns: 1fr; }} }}
2276
+ .gauge-card-compact {{
2277
+ display: flex;
2278
+ flex-direction: column;
2279
+ align-items: center;
2280
+ justify-content: center;
2281
+ padding: 10px;
2282
+ background: var(--bg-surface);
2283
+ border: 1px solid var(--border-subtle);
2284
+ border-radius: 8px;
2285
+ text-align: center;
2286
+ }}
2287
+ .row-middle {{
2288
+ display: grid;
2289
+ grid-template-columns: 1.15fr 0.85fr;
2290
+ gap: 12px;
2291
+ align-items: start;
2292
+ }}
2293
+ @media (max-width: 880px) {{ .row-middle {{ grid-template-columns: 1fr; }} }}
2294
+ .row-bottom {{
2295
+ display: grid;
2296
+ grid-template-columns: 1fr 1fr;
2297
+ gap: 12px;
2298
+ align-items: start;
2299
+ }}
2300
+ @media (max-width: 880px) {{ .row-bottom {{ grid-template-columns: 1fr; }} }}
2301
+ .footer-compact {{
2302
+ display: flex;
2303
+ justify-content: space-between;
2304
+ align-items: center;
2305
+ border-top: 1px solid var(--border-subtle);
2306
+ padding-top: 8px;
2307
+ margin-top: 4px;
2308
+ font-size: 11px;
2309
+ color: var(--text-muted);
2310
+ flex-wrap: wrap;
2311
+ gap: 8px;
2312
+ }}
2313
+ .footer-actions {{
2314
+ display: flex;
2315
+ align-items: center;
2316
+ gap: 8px;
2317
+ flex-wrap: wrap;
2318
+ }}
2319
+ .footer-meta {{
2320
+ display: flex;
2321
+ align-items: center;
2322
+ gap: 12px;
2323
+ }}
2324
+ .footer-links a {{
2325
+ color: var(--accent-blue);
2326
+ text-decoration: none;
2327
+ }}
2328
+ .footer-links a:hover {{
2329
+ text-decoration: underline;
2330
+ }}
2331
+
2332
+ /* 2D Directory Attack Surface Heatmap Matrix */
2333
+ .heatmap-card {{
2334
+ background: var(--bg-surface);
2335
+ border: 1px solid var(--border-subtle);
2336
+ border-radius: 8px;
2337
+ padding: 10px 14px;
2338
+ display: flex;
2339
+ flex-direction: column;
2340
+ justify-content: flex-start;
2341
+ gap: 6px;
2342
+ }}
2343
+ .card-compact-header {{
2344
+ display: flex;
2345
+ justify-content: space-between;
2346
+ align-items: center;
2347
+ margin-bottom: 6px;
2348
+ flex-wrap: wrap;
2349
+ gap: 6px;
2350
+ }}
2351
+ .heatmap-legend {{
2352
+ display: flex;
2353
+ align-items: center;
2354
+ gap: 8px;
2355
+ font-size: 10px;
2356
+ color: var(--text-muted);
2357
+ }}
2358
+ .heat-leg-item {{
2359
+ display: inline-flex;
2360
+ align-items: center;
2361
+ gap: 3px;
2362
+ }}
2363
+ .heat-leg-dot {{
2364
+ display: inline-flex;
2365
+ align-items: center;
2366
+ justify-content: center;
2367
+ width: 14px;
2368
+ height: 14px;
2369
+ border-radius: 3px;
2370
+ font-size: 9px;
2371
+ font-weight: 700;
2372
+ }}
2373
+ .heatmap-table-wrap {{
2374
+ overflow-x: auto;
2375
+ }}
2376
+ .heatmap-table {{
2377
+ width: 100%;
2378
+ border-collapse: separate;
2379
+ border-spacing: 4px;
2380
+ font-size: 11px;
2381
+ }}
2382
+ .heatmap-table th {{
2383
+ color: var(--text-muted);
2384
+ font-weight: 600;
2385
+ padding: 2px 4px;
2386
+ text-align: center;
2387
+ font-size: 10px;
2388
+ text-transform: uppercase;
2389
+ letter-spacing: 0.5px;
2390
+ }}
2391
+ .heatmap-row {{
2392
+ cursor: pointer;
2393
+ transition: background 0.15s ease;
2394
+ }}
2395
+ .heatmap-row:hover {{
2396
+ background: rgba(255, 255, 255, 0.04);
2397
+ }}
2398
+ body.light-theme .heatmap-row:hover {{
2399
+ background: rgba(0, 0, 0, 0.04);
2400
+ }}
2401
+ .heatmap-label-cell {{
2402
+ padding: 4px 6px;
2403
+ font-family: monospace;
2404
+ font-size: 11px;
2405
+ color: var(--text-main);
2406
+ white-space: nowrap;
2407
+ font-weight: 600;
2408
+ }}
2409
+ .heat-cell {{
2410
+ text-align: center;
2411
+ padding: 4px 6px;
2412
+ border-radius: 4px;
2413
+ font-weight: 700;
2414
+ font-size: 11px;
2415
+ transition: transform 0.1s ease, filter 0.1s ease;
2416
+ cursor: pointer;
2417
+ }}
2418
+ .heat-cell:hover {{
2419
+ transform: scale(1.08);
2420
+ filter: brightness(1.2);
2421
+ }}
2422
+ .heat-cell-safe {{
2423
+ background: rgba(46, 160, 67, 0.22);
2424
+ border: 1px solid rgba(63, 185, 80, 0.45);
2425
+ color: #7ee787;
2426
+ }}
2427
+ body.light-theme .heat-cell-safe {{
2428
+ background: rgba(46, 160, 67, 0.15);
2429
+ border-color: rgba(46, 160, 67, 0.4);
2430
+ color: #1a7f37;
2431
+ }}
2432
+ .heat-cell-warn {{
2433
+ background: rgba(210, 153, 34, 0.22);
2434
+ border: 1px solid rgba(210, 153, 34, 0.5);
2435
+ color: #e3b341;
2436
+ }}
2437
+ body.light-theme .heat-cell-warn {{
2438
+ background: rgba(210, 153, 34, 0.15);
2439
+ border-color: rgba(210, 153, 34, 0.4);
2440
+ color: #9a6700;
2441
+ }}
2442
+ .heat-cell-crit {{
2443
+ background: rgba(248, 81, 73, 0.25);
2444
+ border: 1px solid rgba(248, 81, 73, 0.55);
2445
+ color: #ffa198;
2446
+ }}
2447
+ body.light-theme .heat-cell-crit {{
2448
+ background: rgba(248, 81, 73, 0.15);
2449
+ border-color: rgba(248, 81, 73, 0.4);
2450
+ color: #cf222e;
2451
+ }}
2452
+ .heat-cell-idle {{
2453
+ background: var(--bg-card);
2454
+ border: 1px solid var(--border-subtle);
2455
+ color: var(--text-muted);
2456
+ opacity: 0.6;
2457
+ }}
2458
+
2459
+ /* Modal Large for Details Overlays */
2460
+ .modal-lg {{
2461
+ max-width: 1040px;
2462
+ width: 95%;
2463
+ max-height: 88vh;
2464
+ display: flex;
2465
+ flex-direction: column;
2466
+ padding: 18px 22px;
2467
+ }}
2468
+ .modal-lg .modal-body {{
2469
+ overflow-y: auto;
2470
+ flex: 1;
2471
+ padding-right: 6px;
2472
+ }}
2473
+
2474
+ /* Existing Heatmap Row fallback */
2475
+ .heatmap-row {{ display: table-row; }}
2476
+
2477
+ /* Golden Recipes & Diff Engine */
2478
+ .recipe-card {{
2479
+ background: var(--bg-card);
2480
+ border: 1px solid var(--border-subtle);
2481
+ border-radius: 6px;
2482
+ padding: 14px;
2483
+ margin-bottom: 12px;
2484
+ }}
2485
+ .recipe-header {{
2486
+ display: flex;
2487
+ justify-content: space-between;
2488
+ align-items: center;
2489
+ margin-bottom: 8px;
2490
+ flex-wrap: wrap;
2491
+ gap: 8px;
2492
+ font-size: 13px;
2493
+ }}
2494
+ .recipe-meta-badges {{ display: flex; align-items: center; gap: 6px; }}
2495
+ .chip-filetype {{ background: rgba(88, 166, 255, 0.1); border: 1px solid var(--accent-blue); color: var(--accent-blue); font-size: 10px; font-family: monospace; padding: 2px 6px; border-radius: 4px; }}
2496
+ .ponytail-badge {{ background: rgba(210, 153, 34, 0.15); border: 1px solid var(--accent-yellow); color: var(--accent-yellow); font-size: 10px; font-weight: 600; padding: 2px 6px; border-radius: 4px; }}
2497
+ .verified-badge {{ background: rgba(63, 185, 80, 0.15); border: 1px solid var(--accent-green); color: var(--accent-green); font-size: 10px; font-weight: 600; padding: 2px 6px; border-radius: 4px; }}
2498
+ .recipe-view-switcher {{
2499
+ display: flex;
2500
+ gap: 4px;
2501
+ margin-bottom: 8px;
2502
+ }}
2503
+ .recipe-tab-btn {{
2504
+ background: transparent;
2505
+ border: 1px solid var(--border-subtle);
2506
+ color: var(--text-muted);
2507
+ padding: 2px 8px;
2508
+ border-radius: 4px;
2509
+ font-size: 11px;
2510
+ cursor: pointer;
2511
+ }}
2512
+ .recipe-tab-btn.active {{
2513
+ background: var(--accent-blue);
2514
+ border-color: var(--accent-blue);
2515
+ color: #fff;
2516
+ }}
2517
+ .diff-line-header {{ color: var(--text-muted); font-weight: 600; display: block; }}
2518
+ .diff-line-hunk {{ color: var(--accent-blue); background: rgba(88, 166, 255, 0.1); display: block; padding: 1px 4px; border-radius: 2px; }}
2519
+ .diff-line-add {{ color: #7ee787; background: rgba(46, 160, 67, 0.18); display: block; padding: 1px 4px; border-radius: 2px; }}
2520
+ .diff-line-del {{ color: #ffa198; background: rgba(248, 81, 73, 0.18); display: block; padding: 1px 4px; border-radius: 2px; }}
2521
+ .diff-line-ctx {{ color: var(--text-muted); display: block; padding: 1px 4px; }}
2522
+ body.light-theme .diff-line-add {{ color: #1a7f37; background: rgba(46, 160, 67, 0.15); }}
2523
+ body.light-theme .diff-line-del {{ color: #cf222e; background: rgba(207, 34, 46, 0.15); }}
2524
+ body.light-theme .diff-line-hunk {{ color: #0969da; background: rgba(9, 105, 218, 0.1); }}
2525
+
2526
+ .recipe-split-container {{
2527
+ display: grid;
2528
+ grid-template-columns: 1fr 1fr;
2529
+ gap: 10px;
2530
+ margin-bottom: 12px;
2531
+ }}
2532
+ @media (max-width: 650px) {{ .recipe-split-container {{ grid-template-columns: 1fr; }} }}
2533
+ .split-pane .code-preview {{ margin-bottom: 0; min-height: 70px; }}
2534
+ .split-label {{ font-size: 11px; font-weight: 600; margin-bottom: 4px; }}
2535
+ .split-del {{ color: var(--accent-red); }}
2536
+ .split-add {{ color: var(--accent-green); }}
2537
+ .recipe-actions {{ display: flex; justify-content: space-between; align-items: center; margin-top: 8px; }}
2538
+
2539
+ /* Advisory Items */
2540
+ .advisory-item {{
2541
+ background: var(--bg-card);
2542
+ border: 1px solid var(--border-subtle);
2543
+ border-radius: 6px;
2544
+ padding: 12px;
2545
+ margin-bottom: 10px;
2546
+ }}
2547
+ .advisory-top {{ display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }}
2548
+ .advisory-mitigates {{ font-size: 11px; color: var(--accent-blue); margin-bottom: 4px; }}
2549
+ .advisory-desc {{ font-size: 12px; color: var(--text-main); margin-bottom: 4px; }}
2550
+ .advisory-guarantee {{ font-size: 11px; color: var(--text-muted); margin-bottom: 6px; }}
2551
+
2552
+ .empty-state {{ text-align: center; padding: 20px; color: var(--text-muted); font-size: 13px; }}
2553
+ .empty-cell {{ text-align: center; color: var(--accent-green); padding: 24px; font-weight: 600; }}
2554
+
2555
+ footer {{
2556
+ margin-top: 36px;
2557
+ text-align: center;
2558
+ color: var(--text-muted);
2559
+ font-size: 12px;
2560
+ border-top: 1px solid var(--border-subtle);
2561
+ padding-top: 18px;
2562
+ }}
2563
+
2564
+ /* Print & PDF styling */
2565
+ @media print {{
2566
+ body {{ background: #fff !important; color: #000 !important; padding: 10px; }}
2567
+ .card, .owasp-card, .cluster-item, .defended-section, .table-container, .recipe-card, .advisory-item, .comp-card {{
2568
+ border: 1px solid #ccc !important;
2569
+ background: #fff !important;
2570
+ color: #000 !important;
2571
+ box-shadow: none !important;
2572
+ }}
2573
+ .header-actions, .filter-bar, .btn-copy, footer, .drawer-actions, .simulator-card, .recipe-view-switcher, .recipe-actions {{ display: none !important; }}
2574
+ .finding-row {{ background: #fff !important; color: #000 !important; }}
2575
+ .finding-title, .rule-id {{ color: #000 !important; }}
2576
+ .drawer-row {{ display: table-row !important; }}
2577
+ .drawer-content {{ background: #f6f8fa !important; border-left: 3px solid #000 !important; }}
2578
+ .code-preview {{ background: #eee !important; color: #000 !important; }}
2579
+ .code-preview code {{ color: #000 !important; }}
2580
+ .gauge-card {{ display: none !important; }}
2581
+ }}
2582
+ </style>
2583
+ </head>
2584
+ <body>
2585
+ <div class="container">
2586
+ <header class="header-compact">
2587
+ <div class="logo-group">
2588
+ <h1 style="font-size: 16px; margin: 0; display: flex; align-items: center; gap: 8px;">
2589
+ <span class="shield-icon">&#128737;</span> TorusGuard Security Posture
2590
+ </h1>
2591
+ <div class="project-meta">
2592
+ <strong>{html.escape(telemetry.get('project_name', 'Project'))}</strong> &middot;
2593
+ {html.escape(stack_label)} &middot;
2594
+ <span class="badge badge-subtle">v1.3.6</span>
2595
+ <span class="badge badge-success">&#9679; 100% HARDENED</span>
2596
+ </div>
2597
+ </div>
2598
+ <div class="header-actions">
2599
+ <button class="btn btn-sm" id="themeToggleBtn" onclick="toggleTheme()">☀️ Light Mode</button>
2600
+ <button class="btn btn-sm" onclick="downloadSarif()">&#128229; SARIF</button>
2601
+ <button class="btn btn-sm" onclick="downloadCsv()">&#128202; CSV</button>
2602
+ <button class="btn btn-sm" onclick="window.print()">&#128438; Print</button>
2603
+ <div class="status-badge {status_badge_class}">{status_label}</div>
2604
+ </div>
2605
+ </header>
2606
+
2607
+ <!-- Row 1: Dynamic Circular Gauge & Governed Remediation Velocity Ledger -->
2608
+ <div class="row-top">
2609
+ <div class="card gauge-card-compact">
2610
+ <div style="position: relative; display: flex; align-items: center; justify-content: center;">
2611
+ <svg class="gauge-svg" style="width: 76px; height: 76px;" viewBox="0 0 100 100">
2612
+ <defs>
2613
+ <linearGradient id="gaugeGradient" x1="0%" y1="0%" x2="100%" y2="100%">
2614
+ <stop offset="0%" stop-color="{grad_start}" />
2615
+ <stop offset="100%" stop-color="{grad_end}" />
2616
+ </linearGradient>
2617
+ <filter id="gaugeGlow" x="-20%" y="-20%" width="140%" height="140%">
2618
+ <feDropShadow dx="0" dy="0" stdDeviation="3" flood-color="{glow_color}" flood-opacity="0.5"/>
2619
+ </filter>
2620
+ </defs>
2621
+ <circle class="gauge-bg" cx="50" cy="50" r="40" />
2622
+ <circle class="gauge-fill" id="gaugeCircleFill" cx="50" cy="50" r="40" stroke="url(#gaugeGradient)" filter="url(#gaugeGlow)" />
2623
+ </svg>
2624
+ <div class="gauge-value" id="postureScoreText" style="font-size: 20px;">0</div>
2625
+ </div>
2626
+ <div class="stat-label" style="font-size: 11px; color: var(--text-muted); text-transform: uppercase; margin-top: 4px; font-weight: 700;">Posture Score</div>
2627
+ </div>
2628
+
2629
+ {remediation_ledger_html}
2630
+ </div>
2631
+
2632
+ <!-- Row 2: 2D Directory Attack Surface Heatmap & Architectural Clusters -->
2633
+ <div class="row-middle">
2634
+ <div class="card heatmap-card">
2635
+ <div class="card-compact-header">
2636
+ <div class="section-title" style="margin-bottom: 0;">&#128293; Directory Attack Surface Heatmap</div>
2637
+ <div class="heatmap-legend">
2638
+ <span class="heat-leg-item"><span class="heat-leg-dot heat-cell-safe">✔</span> Defended</span>
2639
+ <span class="heat-leg-item"><span class="heat-leg-dot heat-cell-warn">🟡</span> Warning</span>
2640
+ <span class="heat-leg-item"><span class="heat-leg-dot heat-cell-crit">🔴</span> High Risk</span>
2641
+ <span class="heat-leg-item"><span class="heat-leg-dot heat-cell-idle">—</span> Clean</span>
2642
+ </div>
2643
+ </div>
2644
+ <div class="heatmap-table-wrap">
2645
+ <table class="heatmap-table">
2646
+ <thead>
2647
+ <tr>
2648
+ <th style="text-align: left; width: 90px;">Module</th>
2649
+ <th>AUTH</th>
2650
+ <th>DB</th>
2651
+ <th>INPUT</th>
2652
+ <th>NET</th>
2653
+ <th>PLAT</th>
2654
+ <th>SEC</th>
2655
+ <th style="text-align: right; width: 85px;">Posture</th>
2656
+ </tr>
2657
+ </thead>
2658
+ <tbody>
2659
+ {heatmap_rows_html}
2660
+ </tbody>
2661
+ </table>
2662
+ </div>
2663
+ </div>
2664
+
2665
+ <div class="card clusters-card">
2666
+ <div class="section-title" style="margin-bottom: 8px;">&#128736; Root-Cause Architectural Clusters</div>
2667
+ <div class="clusters-compact-list">
2668
+ {clusters_html}
2669
+ </div>
2670
+ </div>
2671
+ </div>
2672
+
2673
+ <!-- Row 3: OWASP Top 10 & Enterprise Compliance -->
2674
+ <div class="row-bottom">
2675
+ <div class="card owasp-card-wrapper">
2676
+ <div class="section-title" style="margin-bottom: 8px;">&#128737; OWASP Top 10 (2021/2026) Compliance Matrix</div>
2677
+ <div class="grid-owasp">
2678
+ {owasp_cards_html}
2679
+ </div>
2680
+ </div>
2681
+
2682
+ <div class="card comp-card-wrapper">
2683
+ <div class="section-title" style="margin-bottom: 8px;">&#127963; Enterprise Compliance Framework Mapping (SOC 2 &middot; ISO 27001 &middot; HIPAA)</div>
2684
+ <div class="grid-compliance" id="gridCompliance">
2685
+ {compliance_cards_html}
2686
+ </div>
2687
+ </div>
2688
+ </div>
2689
+
2690
+ <!-- Executive Action Footer Bar (Zero Scroll Navigation) -->
2691
+ <footer class="footer-compact">
2692
+ <div class="footer-actions">
2693
+ <button class="btn btn-sm" onclick="openFindingsModal()">&#128269; View Findings &amp; Evidence ({total_discovered})</button>
2694
+ <button class="btn btn-sm" onclick="openInvariantsModal()">&#128737; 74 Invariants Catalog</button>
2695
+ <button class="btn btn-sm" onclick="openRecipesModal()">&#10024; Golden Fix Recipes ({len(recipes)})</button>
2696
+ <button class="btn btn-sm" onclick="openAdvisoryModal()">&#128161; Defenses Advisory</button>
2697
+ </div>
2698
+ <div class="footer-meta">
2699
+ <span>TorusGuard v1.3.6 &middot; 100% Local-First &middot; Zero Cloud Telemetry</span>
2700
+ </div>
2701
+ </footer>
2702
+ </div>
2703
+
2704
+ <!-- Deep Inspection Modals -->
2705
+ <!-- 1. Findings & Evidence Modal -->
2706
+ <div id="findingsModal" class="modal-backdrop" style="display: none;" onclick="closeModal('findingsModal', event)">
2707
+ <div class="modal-card modal-lg" onclick="event.stopPropagation()">
2708
+ <div class="modal-header">
2709
+ <div>
2710
+ <h3 style="font-size: 16px; margin: 0; color: var(--text-main);">&#128269; Governed Security Findings &amp; Evidence Archive</h3>
2711
+ <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px;">
2712
+ {total_discovered} findings discovered across repository AST inspection.
2713
+ </div>
2714
+ </div>
2715
+ <button class="modal-close-btn" onclick="closeModal('findingsModal', event)">&times;</button>
2716
+ </div>
2717
+ <div class="modal-body" style="padding-top: 10px;">
2718
+ <!-- Interactive What-If Posture Score Simulator -->
2719
+ <div class="card simulator-card {'simulator-clean' if open_count == 0 else ''}" style="margin-bottom: 14px;">
2720
+ <div>
2721
+ <div style="display: flex; align-items: center; gap: 8px;">
2722
+ <strong style="font-size: 13px;">⚡ Interactive "What-If" Posture Score Simulator</strong>
2723
+ <span class="badge badge-primary">Dynamic Recalculation</span>
2724
+ </div>
2725
+ <div style="font-size: 12px; color: var(--text-muted); margin-top: 4px;">
2726
+ {'All 74 security invariants are currently defended. Simulator is ready for live remediation modeling during audit cycles.' if open_count == 0 else 'Simulate applying governed patches to preview projected posture improvement in real time:'}
2727
+ </div>
2728
+ </div>
2729
+ <div class="sim-stats">
2730
+ <div>Current: <strong id="simCurrentVal">{score}</strong>/100</div>
2731
+ <div>&rarr;</div>
2732
+ <div>Projected: <strong id="simProjectedVal" style="color: var(--accent-blue);">{score}</strong>/100 <span id="simDeltaBadge" class="badge badge-success">+0 pts</span></div>
2733
+ </div>
2734
+ <div class="sim-actions">
2735
+ <button class="btn btn-sm" onclick="simulateFixAll('Critical')">&#9889; Simulate Criticals</button>
2736
+ <button class="btn btn-sm" onclick="simulateFixAll('all')">&#9889; Simulate All Fixes</button>
2737
+ <button class="btn btn-sm" onclick="resetSimulatedFixes()">Reset Simulator</button>
2738
+ </div>
2739
+ </div>
2740
+
2741
+ {findings_section_html}
2742
+ </div>
2743
+ </div>
2744
+ </div>
2745
+
2746
+ <!-- 2. Active Defenses & 74 Invariants Catalog Modal -->
2747
+ <div id="invariantsCatalogModal" class="modal-backdrop" style="display: none;" onclick="closeModal('invariantsCatalogModal', event)">
2748
+ <div class="modal-card modal-lg" onclick="event.stopPropagation()">
2749
+ <div class="modal-header">
2750
+ <div>
2751
+ <div style="display: flex; align-items: center; gap: 8px;">
2752
+ <span class="badge badge-success">&#10004; 100% ENFORCED</span>
2753
+ <h3 style="font-size: 16px; margin: 0; color: var(--text-main);">&#128737; Active Defenses &amp; Protected Invariants ({defended_count} Passing)</h3>
2754
+ </div>
2755
+ <div style="font-size: 12px; color: var(--text-muted); margin-top: 4px;">
2756
+ All 74 architectural security rules enforced across 5 defensive layers. Zero security bypasses detected.
2757
+ </div>
2758
+ </div>
2759
+ <button class="modal-close-btn" onclick="closeModal('invariantsCatalogModal', event)">&times;</button>
2760
+ </div>
2761
+ <div class="modal-body" style="padding-top: 10px;">
2762
+ <div class="defended-section" id="defendedSection">
2763
+ <div class="coverage-bar-container" style="margin-bottom: 12px;">
2764
+ <div class="coverage-stats">
2765
+ <span><strong>{defended_count} of {total_rules} Invariants Enforced</strong> ({coverage_pct}% Defense Coverage)</span>
2766
+ <span class="badge {'badge-success' if coverage_pct >= 90 else 'badge-high'}">{coverage_pct}% Enforced</span>
2767
+ </div>
2768
+ <div class="coverage-progress-bg">
2769
+ <div class="coverage-progress-fill" style="width: {coverage_pct}%;"></div>
2770
+ </div>
2771
+ </div>
2772
+ <div class="inv-search-bar" style="margin-bottom: 12px;">
2773
+ <div class="search-wrapper" style="width: 100%;">
2774
+ <input type="text" id="invSearchInput" class="search-input" style="width: 100%;" placeholder="Search 74 invariants by ID, title, layer, or keyword..." oninput="filterInvariants()">
2775
+ <button class="search-clear-btn" id="invSearchClearBtn" onclick="clearInvSearch()">&times;</button>
2776
+ </div>
2777
+ <div id="invMatchCount" style="font-size: 11px; color: var(--accent-blue); white-space: nowrap;"></div>
2778
+ </div>
2779
+ <div class="layers-container" id="layersList">
2780
+ {layers_html}
2781
+ </div>
2782
+ </div>
2783
+ </div>
2784
+ </div>
2785
+ </div>
2786
+
2787
+ <!-- 3. Golden Fix Recipes Modal -->
2788
+ <div id="recipesModal" class="modal-backdrop" style="display: none;" onclick="closeModal('recipesModal', event)">
2789
+ <div class="modal-card modal-lg" id="recipesCard" onclick="event.stopPropagation()">
2790
+ <div class="modal-header">
2791
+ <div>
2792
+ <h3 style="font-size: 16px; margin: 0; color: var(--text-main);">&#10024; Golden Fix Recipes ({len(recipes)})</h3>
2793
+ <div style="font-size: 12px; color: var(--text-muted); margin-top: 4px;">
2794
+ Verified remediation patterns distilled from applied patches conforming to Ponytail line churn budget.
2795
+ </div>
2796
+ </div>
2797
+ <button class="modal-close-btn" onclick="closeModal('recipesModal', event)">&times;</button>
2798
+ </div>
2799
+ <div class="modal-body" style="padding-top: 10px;">
2800
+ <div class="filter-pills" style="margin-bottom: 12px;">
2801
+ {recipe_cat_pills_html}
2802
+ </div>
2803
+ <div id="recipeContainer">
2804
+ {recipes_html}
2805
+ </div>
2806
+ </div>
2807
+ </div>
2808
+ </div>
2809
+
2810
+ <!-- 4. Next Best Defenses Advisory Modal -->
2811
+ <div id="advisoryModal" class="modal-backdrop" style="display: none;" onclick="closeModal('advisoryModal', event)">
2812
+ <div class="modal-card modal-lg" onclick="event.stopPropagation()">
2813
+ <div class="modal-header">
2814
+ <div>
2815
+ <h3 style="font-size: 16px; margin: 0; color: var(--text-main);">&#128161; Prescriptive Next Best Defenses Advisory</h3>
2816
+ <div style="font-size: 12px; color: var(--text-muted); margin-top: 4px;">
2817
+ Stack-tailored recommendations to achieve hardened posture.
2818
+ </div>
2819
+ </div>
2820
+ <button class="modal-close-btn" onclick="closeModal('advisoryModal', event)">&times;</button>
2821
+ </div>
2822
+ <div class="modal-body" style="padding-top: 10px;">
2823
+ {advisory_html}
2824
+ </div>
2825
+ </div>
2826
+ </div>
2827
+
2828
+ <!-- 5. Invariant Guarantee Inspection Modal -->
2829
+ <div id="invariantModal" class="modal-backdrop" style="display: none;" onclick="closeInvariantModal(event)">
2830
+ <div class="modal-card" onclick="event.stopPropagation()">
2831
+ <div class="modal-header">
2832
+ <div>
2833
+ <div style="display: flex; align-items: center; gap: 8px;">
2834
+ <span id="modalInvSev" class="badge badge-medium">Medium</span>
2835
+ <code id="modalInvRule" class="rule-id" style="font-size: 14px;">TG-GEN</code>
2836
+ <span id="modalInvStatus" class="badge badge-success">Defended</span>
2837
+ </div>
2838
+ <h3 id="modalInvTitle" style="font-size: 16px; margin-top: 6px; color: var(--text-main);">Security Invariant</h3>
2839
+ </div>
2840
+ <button class="modal-close-btn" onclick="closeInvariantModal(event)">&times;</button>
2841
+ </div>
2842
+ <div class="modal-body">
2843
+ <div>
2844
+ <div style="font-size: 11px; text-transform: uppercase; color: var(--text-muted); font-weight: 600;">Architectural Layer &amp; Category</div>
2845
+ <div id="modalInvLayer" style="margin-top: 2px; font-weight: 500;">Application &amp; LLM Defense</div>
2846
+ </div>
2847
+ <div>
2848
+ <div style="font-size: 11px; text-transform: uppercase; color: var(--text-muted); font-weight: 600;">Invariant Guarantee</div>
2849
+ <div id="modalInvGuarantee" style="margin-top: 4px; line-height: 1.4; color: var(--text-main);">
2850
+ Enforces deterministic verification without bypasses.
2851
+ </div>
2852
+ </div>
2853
+ <div>
2854
+ <div style="font-size: 11px; text-transform: uppercase; color: var(--text-muted); font-weight: 600;">Ponytail Churn Invariant</div>
2855
+ <div style="margin-top: 2px; color: var(--accent-yellow); font-size: 12px;">
2856
+ &le; 35 additions, &le; 25 deletions per surgical patch bundle.
2857
+ </div>
2858
+ </div>
2859
+ <div>
2860
+ <div style="font-size: 11px; text-transform: uppercase; color: var(--text-muted); font-weight: 600; margin-bottom: 4px;">Governed CLI Command</div>
2861
+ <div class="cmd-box" id="modalInvCmd">
2862
+ <code>npx torusguard harden --rule TG-GEN</code>
2863
+ <button class="btn-copy" onclick="copyText('npx torusguard harden --rule TG-GEN', this)">&#128203; Copy</button>
2864
+ </div>
2865
+ </div>
2866
+ </div>
2867
+ </div>
2868
+ </div>
2869
+
2870
+ <script>
2871
+ const RAW_FINDINGS = {findings_json_str};
2872
+ const SARIF_RULES = {sarif_rules_json_str};
2873
+ const INVARIANT_CATALOG = {rule_catalog_json_str};
2874
+ const VIOLATED_RULE_IDS = {violated_ids_json_str};
2875
+
2876
+ let currentSeverity = 'all';
2877
+ let currentStatus = 'all';
2878
+ let currentDirectory = '';
2879
+ let currentRecipeCategory = 'all';
2880
+ let BASE_POSTURE_SCORE = {score};
2881
+
2882
+ // ── Modal Dialog Engine ──
2883
+ function openModal(id) {{
2884
+ const m = document.getElementById(id);
2885
+ if (m) {{
2886
+ m.style.display = 'flex';
2887
+ document.body.style.overflow = 'hidden';
2888
+ }}
2889
+ }}
2890
+ function closeModal(id, event) {{
2891
+ if (event && event.target && event.target !== event.currentTarget && !event.target.classList.contains('modal-close-btn')) {{
2892
+ return;
2893
+ }}
2894
+ const m = document.getElementById(id);
2895
+ if (m) {{
2896
+ m.style.display = 'none';
2897
+ document.body.style.overflow = '';
2898
+ }}
2899
+ }}
2900
+ function openFindingsModal() {{ openModal('findingsModal'); }}
2901
+ function openInvariantsModal() {{ openModal('invariantsCatalogModal'); }}
2902
+ function openRecipesModal() {{ openModal('recipesModal'); }}
2903
+ function openAdvisoryModal() {{ openModal('advisoryModal'); }}
2904
+
2905
+ document.addEventListener('keydown', function(e) {{
2906
+ if (e.key === 'Escape') {{
2907
+ document.querySelectorAll('.modal-backdrop').forEach(m => {{
2908
+ m.style.display = 'none';
2909
+ }});
2910
+ document.body.style.overflow = '';
2911
+ }}
2912
+ }});
2913
+
2914
+ // ── Theme Switcher ──
2915
+ function initTheme() {{
2916
+ const saved = localStorage.getItem('tg_theme');
2917
+ const btn = document.getElementById('themeToggleBtn');
2918
+ if (saved === 'light') {{
2919
+ document.body.classList.add('light-theme');
2920
+ if (btn) btn.innerHTML = '&#127769; Dark Mode';
2921
+ }} else {{
2922
+ document.body.classList.remove('light-theme');
2923
+ if (btn) btn.innerHTML = '&#9728;&#65039; Light Mode';
2924
+ }}
2925
+ }}
2926
+
2927
+ function toggleTheme() {{
2928
+ const isLight = document.body.classList.toggle('light-theme');
2929
+ localStorage.setItem('tg_theme', isLight ? 'light' : 'dark');
2930
+ const btn = document.getElementById('themeToggleBtn');
2931
+ if (btn) btn.innerHTML = isLight ? '&#127769; Dark Mode' : '&#9728;&#65039; Light Mode';
2932
+ }}
2933
+
2934
+ document.addEventListener('DOMContentLoaded', initTheme);
2935
+ if (document.readyState === 'complete' || document.readyState === 'interactive') {{
2936
+ initTheme();
2937
+ }}
2938
+
2939
+ // ── What-If Posture Score Simulator ──
2940
+ function updateSimulatedScore() {{
2941
+ const checkboxes = document.querySelectorAll('.sim-fix-chk');
2942
+ const fixedIds = new Set();
2943
+ checkboxes.forEach(cb => {{
2944
+ if (cb.checked) {{
2945
+ fixedIds.add(cb.getAttribute('data-finding-id'));
2946
+ }}
2947
+ }});
2948
+
2949
+ let remCrit = 0, remHigh = 0, remMed = 0, remLow = 0;
2950
+ RAW_FINDINGS.forEach(f => {{
2951
+ if (f.is_canary) return;
2952
+ const st = (f.status || 'open').toLowerCase();
2953
+ if (st === 'resolved' || st === 'fixed' || st === 'confirmed_fixed') return;
2954
+ if (fixedIds.has(f.finding_id)) return; // Simulated as fixed
2955
+ const s = f.severity;
2956
+ if (s === 'Critical') remCrit++;
2957
+ else if (s === 'High') remHigh++;
2958
+ else if (s === 'Medium') remMed++;
2959
+ else if (s === 'Low') remLow++;
2960
+ }});
2961
+
2962
+ const penalty = (remCrit * 25) + (remHigh * 15) + (remMed * 5) + (remLow * 2);
2963
+ const newScore = Math.max(0, Math.min(100, 100 - penalty));
2964
+
2965
+ // Update projected UI labels
2966
+ const projEl = document.getElementById('simProjectedVal');
2967
+ const deltaEl = document.getElementById('simDeltaBadge');
2968
+ if (projEl) projEl.textContent = newScore;
2969
+ const delta = newScore - BASE_POSTURE_SCORE;
2970
+ if (deltaEl) {{
2971
+ deltaEl.textContent = (delta >= 0 ? '+' : '') + delta + ' pts';
2972
+ deltaEl.className = 'badge ' + (delta > 0 ? 'badge-success' : 'badge-primary');
2973
+ }}
2974
+
2975
+ // Animate SVG gauge to simulated score
2976
+ animateGaugeTo(newScore);
2977
+ }}
2978
+
2979
+ function simulateFixAll(sev) {{
2980
+ const checkboxes = document.querySelectorAll('.sim-fix-chk');
2981
+ checkboxes.forEach(cb => {{
2982
+ const cbSev = cb.getAttribute('data-severity');
2983
+ if (sev === 'all' || cbSev === sev) {{
2984
+ cb.checked = true;
2985
+ }}
2986
+ }});
2987
+ updateSimulatedScore();
2988
+ }}
2989
+
2990
+ function resetSimulatedFixes() {{
2991
+ const checkboxes = document.querySelectorAll('.sim-fix-chk');
2992
+ checkboxes.forEach(cb => {{ cb.checked = false; }});
2993
+ updateSimulatedScore();
2994
+ }}
2995
+
2996
+ function animateGaugeTo(targetScore) {{
2997
+ const circumference = 251.2;
2998
+ const targetOffset = circumference - (circumference * (targetScore / 100.0));
2999
+ const gaugeEl = document.getElementById('gaugeCircleFill');
3000
+ const textEl = document.getElementById('postureScoreText');
3001
+
3002
+ if (gaugeEl) {{
3003
+ gaugeEl.style.strokeDashoffset = targetOffset;
3004
+ }}
3005
+ if (textEl) {{
3006
+ textEl.textContent = targetScore;
3007
+ }}
3008
+ }}
3009
+
3010
+ // ── Invariant Matrix Filters & Inspection ──
3011
+ function filterInvariants() {{
3012
+ const input = document.getElementById('invSearchInput');
3013
+ const clearBtn = document.getElementById('invSearchClearBtn');
3014
+ const q = (input ? input.value : '').toLowerCase().trim();
3015
+ if (clearBtn) clearBtn.style.display = q ? 'block' : 'none';
3016
+
3017
+ const chips = document.querySelectorAll('.inv-chip');
3018
+ let matchCount = 0;
3019
+ chips.forEach(chip => {{
3020
+ const s = chip.getAttribute('data-search') || '';
3021
+ const visible = !q || s.includes(q);
3022
+ chip.style.display = visible ? 'inline-flex' : 'none';
3023
+ if (visible) matchCount++;
3024
+ }});
3025
+
3026
+ const countEl = document.getElementById('invMatchCount');
3027
+ if (countEl) {{
3028
+ countEl.textContent = q ? '(' + matchCount + ' matching)' : '';
3029
+ }}
3030
+ }}
3031
+
3032
+ function clearInvSearch() {{
3033
+ const input = document.getElementById('invSearchInput');
3034
+ if (input) input.value = '';
3035
+ filterInvariants();
3036
+ }}
3037
+
3038
+ function inspectInvariant(ruleId) {{
3039
+ const item = INVARIANT_CATALOG[ruleId];
3040
+ if (!item) return;
3041
+ const modal = document.getElementById('invariantModal');
3042
+ const isViolated = VIOLATED_RULE_IDS.includes(ruleId);
3043
+
3044
+ document.getElementById('modalInvRule').textContent = item.rule_id;
3045
+ document.getElementById('modalInvTitle').textContent = item.title;
3046
+ document.getElementById('modalInvLayer').textContent = item.layer_name + ' &middot; ' + item.category;
3047
+ document.getElementById('modalInvGuarantee').textContent = item.guarantee;
3048
+
3049
+ const sevEl = document.getElementById('modalInvSev');
3050
+ sevEl.textContent = item.severity;
3051
+ sevEl.className = 'badge ' + (item.severity === 'Critical' ? 'badge-critical' : (item.severity === 'High' ? 'badge-high' : 'badge-medium'));
3052
+
3053
+ const statusEl = document.getElementById('modalInvStatus');
3054
+ statusEl.innerHTML = isViolated ? '&#9888; Violated' : '&#10004; Defended';
3055
+ statusEl.className = 'badge ' + (isViolated ? 'badge-critical' : 'badge-success');
3056
+
3057
+ const cmdBox = document.getElementById('modalInvCmd');
3058
+ cmdBox.innerHTML = `<code>npx torusguard harden --rule ${{item.rule_id}}</code>
3059
+ <button class="btn-copy" onclick="copyText('npx torusguard harden --rule ${{item.rule_id}}', this); event.stopPropagation();">&#128203; Copy</button>`;
3060
+
3061
+ if (modal) modal.style.display = 'flex';
3062
+ }}
3063
+
3064
+ function closeInvariantModal(e) {{
3065
+ const modal = document.getElementById('invariantModal');
3066
+ if (modal) modal.style.display = 'none';
3067
+ }}
3068
+
3069
+ document.addEventListener('keydown', function(e) {{
3070
+ if (e.key === 'Escape') closeInvariantModal();
3071
+ }});
3072
+
3073
+ // ── Golden Recipes View Switcher & Category Filter ──
3074
+ function setRecipeView(recipeId, viewMode) {{
3075
+ const diffView = document.getElementById('recipe-view-diff-' + recipeId);
3076
+ const splitView = document.getElementById('recipe-view-split-' + recipeId);
3077
+ const btnDiff = document.getElementById('btn-view-diff-' + recipeId);
3078
+ const btnSplit = document.getElementById('btn-view-split-' + recipeId);
3079
+
3080
+ if (viewMode === 'diff') {{
3081
+ if (diffView) diffView.style.display = 'block';
3082
+ if (splitView) splitView.style.display = 'none';
3083
+ if (btnDiff) btnDiff.classList.add('active');
3084
+ if (btnSplit) btnSplit.classList.remove('active');
3085
+ }} else {{
3086
+ if (diffView) diffView.style.display = 'none';
3087
+ if (splitView) splitView.style.display = 'grid';
3088
+ if (btnDiff) btnDiff.classList.remove('active');
3089
+ if (btnSplit) btnSplit.classList.add('active');
3090
+ }}
3091
+ }}
3092
+
3093
+ function filterRecipeCategory(cat) {{
3094
+ currentRecipeCategory = cat;
3095
+ document.querySelectorAll('[data-recipe-cat-filter]').forEach(p => {{
3096
+ if (p.getAttribute('data-recipe-cat-filter') === cat) p.classList.add('active');
3097
+ else p.classList.remove('active');
3098
+ }});
3099
+
3100
+ document.querySelectorAll('.recipe-card').forEach(card => {{
3101
+ const cardCat = card.getAttribute('data-category') || '';
3102
+ if (cat === 'all' || cardCat === cat) {{
3103
+ card.style.display = '';
3104
+ }} else {{
3105
+ card.style.display = 'none';
3106
+ }}
3107
+ }});
3108
+ }}
3109
+
3110
+ // ── Findings Filtering & Drawer Lifecycle ──
3111
+ function filterBySeverity(sev) {{
3112
+ openFindingsModal();
3113
+ currentSeverity = sev;
3114
+ document.querySelectorAll('[data-sev-filter]').forEach(p => {{
3115
+ if (p.getAttribute('data-sev-filter') === sev) p.classList.add('active');
3116
+ else p.classList.remove('active');
3117
+ }});
3118
+ searchFindings();
3119
+ }}
3120
+
3121
+ function filterByStatus(status) {{
3122
+ currentStatus = status;
3123
+ document.querySelectorAll('[data-status-filter]').forEach(p => {{
3124
+ if (p.getAttribute('data-status-filter') === status) p.classList.add('active');
3125
+ else p.classList.remove('active');
3126
+ }});
3127
+ searchFindings();
3128
+ }}
3129
+
3130
+ function filterFindings(sev) {{
3131
+ filterBySeverity(sev);
3132
+ }}
3133
+
3134
+ function filterByDirectory(dir) {{
3135
+ openFindingsModal();
3136
+ currentDirectory = dir.replace(/\\/$/, '');
3137
+ const chip = document.getElementById('dirFilterChip');
3138
+ const chipName = document.getElementById('dirFilterName');
3139
+ if (chip && chipName) {{
3140
+ chipName.textContent = currentDirectory;
3141
+ chip.style.display = 'inline-flex';
3142
+ }}
3143
+ searchFindings();
3144
+ }}
3145
+
3146
+ function clearDirectoryFilter() {{
3147
+ currentDirectory = '';
3148
+ const chip = document.getElementById('dirFilterChip');
3149
+ if (chip) chip.style.display = 'none';
3150
+ searchFindings();
3151
+ }}
3152
+
3153
+ function clearSearch() {{
3154
+ const searchInput = document.getElementById('searchInput');
3155
+ if (searchInput) searchInput.value = '';
3156
+ searchFindings();
3157
+ }}
3158
+
3159
+ function resetAllFilters() {{
3160
+ currentSeverity = 'all';
3161
+ currentStatus = 'all';
3162
+ currentDirectory = '';
3163
+ const searchInput = document.getElementById('searchInput');
3164
+ if (searchInput) searchInput.value = '';
3165
+
3166
+ document.querySelectorAll('[data-sev-filter]').forEach(p => {{
3167
+ if (p.getAttribute('data-sev-filter') === 'all') p.classList.add('active');
3168
+ else p.classList.remove('active');
3169
+ }});
3170
+ document.querySelectorAll('[data-status-filter]').forEach(p => {{
3171
+ if (p.getAttribute('data-status-filter') === 'all') p.classList.add('active');
3172
+ else p.classList.remove('active');
3173
+ }});
3174
+ const chip = document.getElementById('dirFilterChip');
3175
+ if (chip) chip.style.display = 'none';
3176
+
3177
+ searchFindings();
3178
+ }}
3179
+
3180
+ function toggleDefendedVisibility() {{
3181
+ const body = document.getElementById('defendedMatrixBody');
3182
+ const icon = document.getElementById('defendedToggleIcon');
3183
+ if (!body) return;
3184
+ if (body.style.display === 'none') {{
3185
+ body.style.display = 'block';
3186
+ if (icon) icon.innerHTML = '&#9660; Collapse Details';
3187
+ }} else {{
3188
+ body.style.display = 'none';
3189
+ if (icon) icon.innerHTML = '&#9654; Expand 74 Invariant Details';
3190
+ }}
3191
+ }}
3192
+
3193
+ function toggleFindingsArchive() {{
3194
+ const body = document.getElementById('findingsArchiveBody');
3195
+ const icon = document.getElementById('archiveToggleIcon');
3196
+ const btn = document.getElementById('btnArchiveToggle');
3197
+ if (!body) return;
3198
+ if (body.style.display === 'none') {{
3199
+ body.style.display = 'block';
3200
+ if (icon) icon.innerHTML = '&#9660;';
3201
+ if (btn) btn.classList.add('active');
3202
+ searchFindings();
3203
+ }} else {{
3204
+ body.style.display = 'none';
3205
+ if (icon) icon.innerHTML = '&#9654;';
3206
+ if (btn) btn.classList.remove('active');
3207
+ }}
3208
+ }}
3209
+
3210
+ function toggleRecipesSection() {{
3211
+ const body = document.getElementById('recipesCardBody');
3212
+ const icon = document.getElementById('recipesToggleIcon');
3213
+ const btn = document.getElementById('btnRecipesToggle');
3214
+ if (!body) return;
3215
+ if (body.style.display === 'none') {{
3216
+ body.style.display = 'block';
3217
+ if (btn) btn.innerHTML = '<span id="recipesToggleIcon">&#9660;</span> Collapse';
3218
+ }} else {{
3219
+ body.style.display = 'none';
3220
+ if (btn) btn.innerHTML = '<span id="recipesToggleIcon">&#9654;</span> Expand ({len(recipes)})';
3221
+ }}
3222
+ }}
3223
+
3224
+ function toggleComplianceSection() {{
3225
+ const grid = document.getElementById('gridCompliance');
3226
+ const icon = document.getElementById('compToggleIcon');
3227
+ if (!grid) return;
3228
+ if (grid.style.display === 'none') {{
3229
+ grid.style.display = 'grid';
3230
+ if (icon) icon.innerHTML = '&#9660; Collapse';
3231
+ }} else {{
3232
+ grid.style.display = 'none';
3233
+ if (icon) icon.innerHTML = '&#9654; Expand Frameworks';
3234
+ }}
3235
+ }}
3236
+
3237
+ function toggleDefendedSection() {{
3238
+ openInvariantsModal();
3239
+ }}
3240
+
3241
+ function searchFindings() {{
3242
+ const searchInput = document.getElementById('searchInput');
3243
+ const clearBtn = document.getElementById('searchClearBtn');
3244
+ const query = (searchInput ? searchInput.value : '').toLowerCase().trim();
3245
+
3246
+ if (clearBtn) clearBtn.style.display = query ? 'block' : 'none';
3247
+
3248
+ const rows = document.querySelectorAll('.finding-row');
3249
+ let visibleCount = 0;
3250
+
3251
+ rows.forEach(r => {{
3252
+ const rSev = r.getAttribute('data-severity') || '';
3253
+ const rStatus = r.getAttribute('data-status') || 'open';
3254
+ const rSearch = r.getAttribute('data-search') || '';
3255
+ const rPath = r.getAttribute('data-path') || '';
3256
+ const drawerId = r.getAttribute('data-drawer-id');
3257
+ const drawer = document.getElementById(drawerId);
3258
+
3259
+ let matchSev = (currentSeverity === 'all') || (rSev === currentSeverity);
3260
+ if (currentSeverity === 'medium') {{
3261
+ matchSev = (rSev === 'medium' || rSev === 'low');
3262
+ }}
3263
+
3264
+ let matchStatus = (currentStatus === 'all') || (rStatus === currentStatus);
3265
+
3266
+ let matchDir = true;
3267
+ if (currentDirectory) {{
3268
+ matchDir = rPath.toLowerCase().startsWith(currentDirectory.toLowerCase());
3269
+ }}
3270
+
3271
+ const matchQuery = !query || rSearch.includes(query);
3272
+ const visible = matchSev && matchStatus && matchDir && matchQuery;
3273
+
3274
+ if (visible) visibleCount++;
3275
+ r.style.display = visible ? '' : 'none';
3276
+ if (drawer && !visible) {{
3277
+ drawer.style.display = 'none';
3278
+ r.classList.remove('expanded');
3279
+ }}
3280
+ }});
3281
+
3282
+ const countEl = document.getElementById('visibleCount');
3283
+ if (countEl) countEl.textContent = visibleCount;
3284
+
3285
+ const resetBtn = document.getElementById('btnResetFilters');
3286
+ const hasActiveFilters = currentSeverity !== 'all' || currentStatus !== 'all' || currentDirectory !== '' || query !== '';
3287
+ if (resetBtn) {{
3288
+ resetBtn.style.display = hasActiveFilters ? 'inline-block' : 'none';
3289
+ }}
3290
+
3291
+ const summaryEl = document.getElementById('activeFilterSummary');
3292
+ if (summaryEl) {{
3293
+ const parts = [];
3294
+ if (currentSeverity !== 'all') parts.push(`Sev: ${{currentSeverity}}`);
3295
+ if (currentStatus !== 'all') parts.push(`Status: ${{currentStatus}}`);
3296
+ if (currentDirectory) parts.push(`Dir: ${{currentDirectory}}`);
3297
+ if (query) parts.push(`Query: "${{query}}"`);
3298
+ summaryEl.textContent = parts.length ? `(${{parts.join(', ')}})` : '';
3299
+ }}
3300
+ }}
3301
+
3302
+ function initPostureGauge() {{
3303
+ const targetScore = {score};
3304
+ const circumference = 251.2;
3305
+ const targetOffset = circumference - (circumference * (targetScore / 100.0));
3306
+ const gaugeEl = document.getElementById('gaugeCircleFill');
3307
+ const textEl = document.getElementById('postureScoreText');
3308
+
3309
+ setTimeout(() => {{
3310
+ if (gaugeEl) {{
3311
+ gaugeEl.style.strokeDashoffset = targetOffset;
3312
+ }}
3313
+ }}, 50);
3314
+
3315
+ if (textEl) {{
3316
+ const duration = 800;
3317
+ const startTime = performance.now();
3318
+ function updateCount(now) {{
3319
+ const elapsed = now - startTime;
3320
+ const progress = Math.min(elapsed / duration, 1.0);
3321
+ const eased = 1 - Math.pow(1 - progress, 3);
3322
+ const val = Math.round(eased * targetScore);
3323
+ textEl.textContent = val;
3324
+ if (progress < 1.0) {{
3325
+ requestAnimationFrame(updateCount);
3326
+ }} else {{
3327
+ textEl.textContent = targetScore;
3328
+ }}
3329
+ }}
3330
+ requestAnimationFrame(updateCount);
3331
+ }}
3332
+ }}
3333
+ document.addEventListener('DOMContentLoaded', initPostureGauge);
3334
+ if (document.readyState === 'complete' || document.readyState === 'interactive') {{
3335
+ initPostureGauge();
3336
+ }}
3337
+
3338
+ function toggleDrawer(row) {{
3339
+ const drawerId = row.getAttribute('data-drawer-id');
3340
+ const drawer = document.getElementById(drawerId);
3341
+ if (!drawer) return;
3342
+
3343
+ const isExpanded = row.classList.contains('expanded');
3344
+ if (isExpanded) {{
3345
+ row.classList.remove('expanded');
3346
+ drawer.style.display = 'none';
3347
+ }} else {{
3348
+ row.classList.add('expanded');
3349
+ drawer.style.display = '';
3350
+ }}
3351
+ }}
3352
+
3353
+ function copyText(text, btn) {{
3354
+ const cleanText = text.replace(/\\\\n/g, '\\n');
3355
+ if (navigator.clipboard && window.isSecureContext) {{
3356
+ navigator.clipboard.writeText(cleanText).then(() => {{
3357
+ showCopied(btn);
3358
+ }}).catch(() => {{
3359
+ fallbackCopy(cleanText, btn);
3360
+ }});
3361
+ }} else {{
3362
+ fallbackCopy(cleanText, btn);
3363
+ }}
3364
+ }}
3365
+
3366
+ function fallbackCopy(text, btn) {{
3367
+ const ta = document.createElement('textarea');
3368
+ ta.value = text;
3369
+ ta.style.position = 'fixed';
3370
+ ta.style.opacity = '0';
3371
+ document.body.appendChild(ta);
3372
+ ta.select();
3373
+ try {{
3374
+ document.execCommand('copy');
3375
+ showCopied(btn);
3376
+ }} catch (e) {{}}
3377
+ document.body.removeChild(ta);
3378
+ }}
3379
+
3380
+ function showCopied(btn) {{
3381
+ const orig = btn.innerHTML;
3382
+ btn.innerHTML = '&#10004; Copied!';
3383
+ btn.classList.add('copied');
3384
+ setTimeout(() => {{
3385
+ btn.innerHTML = orig;
3386
+ btn.classList.remove('copied');
3387
+ }}, 2000);
3388
+ }}
3389
+
3390
+ function downloadSarif() {{
3391
+ const sarifData = {{
3392
+ "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
3393
+ "version": "2.1.0",
3394
+ "runs": [{{
3395
+ "tool": {{
3396
+ "driver": {{
3397
+ "name": "TorusGuard",
3398
+ "semanticVersion": "1.3.6",
3399
+ "informationUri": "https://github.com/githubmofo/TorusGuard",
3400
+ "rules": SARIF_RULES
3401
+ }}
3402
+ }},
3403
+ "results": RAW_FINDINGS.map(f => ({{
3404
+ "ruleId": f.rule_id || "TG-GEN",
3405
+ "message": {{ "text": f.description || f.title || "Violation" }},
3406
+ "level": f.severity === "Critical" ? "error" : (f.severity === "High" ? "error" : "warning"),
3407
+ "locations": [{{
3408
+ "physicalLocation": {{
3409
+ "artifactLocation": {{ "uri": f.file_path || "unknown" }},
3410
+ "region": {{ "startLine": f.line_number || 1 }}
3411
+ }}
3412
+ }}]
3413
+ }}))
3414
+ }}]
3415
+ }};
3416
+
3417
+ const blob = new Blob([JSON.stringify(sarifData, null, 2)], {{ type: "application/json" }});
3418
+ const url = URL.createObjectURL(blob);
3419
+ const a = document.createElement("a");
3420
+ a.href = url;
3421
+ a.download = "torusguard-report.sarif";
3422
+ document.body.appendChild(a);
3423
+ a.click();
3424
+ document.body.removeChild(a);
3425
+ URL.revokeObjectURL(url);
3426
+ }}
3427
+
3428
+ function downloadCsv() {{
3429
+ let csv = "Finding ID,Rule ID,Severity,Title,File Path,Line Number,Confidence\\n";
3430
+ RAW_FINDINGS.forEach(f => {{
3431
+ const row = [
3432
+ `"${{(f.finding_id || '').replace(/"/g, '""')}}"`,
3433
+ `"${{(f.rule_id || '').replace(/"/g, '""')}}"`,
3434
+ `"${{(f.severity || '').replace(/"/g, '""')}}"`,
3435
+ `"${{(f.title || '').replace(/"/g, '""')}}"`,
3436
+ `"${{(f.file_path || '').replace(/"/g, '""')}}"`,
3437
+ f.line_number || 0,
3438
+ f.confidence_score || 70
3439
+ ];
3440
+ csv += row.join(",") + "\\n";
3441
+ }});
3442
+
3443
+ const blob = new Blob([csv], {{ type: "text/csv;charset=utf-8;" }});
3444
+ const url = URL.createObjectURL(blob);
3445
+ const a = document.createElement("a");
3446
+ a.href = url;
3447
+ a.download = "torusguard-report.csv";
3448
+ document.body.appendChild(a);
3449
+ a.click();
3450
+ document.body.removeChild(a);
3451
+ URL.revokeObjectURL(url);
3452
+ }}
3453
+ </script>
3454
+ </body>
3455
+ </html>
3456
+ """
3457
+
3458
+
3459
+ def emit_html_report(
3460
+ target_path: str | Path | None = None,
3461
+ root_dir: str | Path | None = None
3462
+ ) -> dict[str, Any]:
3463
+ """Emit the standalone HTML report to disk using atomic swapping with retry."""
3464
+ root = Path(root_dir or find_project_root()).resolve()
3465
+ telemetry = load_report_telemetry(root)
3466
+ html_content = generate_html_dashboard(telemetry)
3467
+ if target_path:
3468
+ out_file = Path(target_path)
3469
+ if not out_file.is_absolute():
3470
+ out_file = (root / out_file).resolve()
3471
+ else:
3472
+ out_file = root / "report.html"
3473
+
3474
+ out_file.parent.mkdir(parents=True, exist_ok=True)
3475
+ try:
3476
+ from report_sync import atomic_write_text
3477
+ atomic_write_text(out_file, html_content)
3478
+ except Exception:
3479
+ out_file.write_text(html_content, encoding="utf-8")
3480
+
3481
+ # Always mirror to .torusguard/runs/report-latest.html
3482
+ latest_run_html = root / ".torusguard" / "runs" / "report-latest.html"
3483
+ if out_file.resolve() != latest_run_html.resolve():
3484
+ try:
3485
+ latest_run_html.parent.mkdir(parents=True, exist_ok=True)
3486
+ from report_sync import atomic_write_text
3487
+ atomic_write_text(latest_run_html, html_content)
3488
+ except Exception:
3489
+ try:
3490
+ latest_run_html.write_text(html_content, encoding="utf-8")
3491
+ except Exception:
3492
+ pass
3493
+
3494
+ # If writing to a non-standard path and a root report.html exists, also keep it synchronized
3495
+ root_report = root / "report.html"
3496
+ if out_file.resolve() != root_report.resolve() and root_report.is_file():
3497
+ try:
3498
+ from report_sync import atomic_write_text
3499
+ atomic_write_text(root_report, html_content)
3500
+ except Exception:
3501
+ try:
3502
+ root_report.write_text(html_content, encoding="utf-8")
3503
+ except Exception:
3504
+ pass
3505
+
3506
+ all_rules = telemetry.get("all_rules", [])
3507
+ safe_rules = telemetry.get("safe_rules", [])
3508
+ violated_rules = telemetry.get("violated_rules", [])
3509
+ findings = telemetry.get("findings", [])
3510
+
3511
+ return {
3512
+ "status": "success",
3513
+ "report_path": str(out_file),
3514
+ "latest_mirror_path": str(latest_run_html),
3515
+ "file_size_bytes": len(html_content.encode("utf-8")),
3516
+ "posture_score": compute_posture_score(telemetry),
3517
+ "total_rules": len(all_rules),
3518
+ "defended_count": len(safe_rules),
3519
+ "harmed_count": len(violated_rules),
3520
+ "critical_count": sum(1 for f in findings if f.get("severity") == "Critical" and not f.get("is_canary") and f.get("status", "open") not in ("fixed", "resolved", "confirmed_fixed")),
3521
+ "high_count": sum(1 for f in findings if f.get("severity") == "High" and not f.get("is_canary") and f.get("status", "open") not in ("fixed", "resolved", "confirmed_fixed")),
3522
+ "medium_count": sum(1 for f in findings if f.get("severity") in ("Medium", "Low") and not f.get("is_canary") and f.get("status", "open") not in ("fixed", "resolved", "confirmed_fixed")),
3523
+ }
3524
+
3525
+
3526
+ def main():
3527
+ parser = argparse.ArgumentParser(description="TorusGuard Visual HTML Report Emitter")
3528
+ parser.add_argument("--out", help="Output HTML file path (default: report.html at project root)")
3529
+ parser.add_argument("--root", help="Project root override")
3530
+ parser.add_argument("--json", action="store_true", help="Output raw JSON summary")
3531
+
3532
+ args = parser.parse_args()
3533
+ root = Path(args.root).resolve() if args.root else None
3534
+
3535
+ res = emit_html_report(target_path=args.out, root_dir=root)
3536
+ if args.json:
3537
+ print(json.dumps(res, indent=2))
3538
+ else:
3539
+ rep_path = Path(res["report_path"]).resolve()
3540
+ file_url = rep_path.as_uri()
3541
+ score = res["posture_score"]
3542
+ score_label = "Optimal Defense" if score >= 90 else ("Hardened" if score >= 75 else ("Degraded" if score >= 50 else "Critical Exposure"))
3543
+ score_color = "\033[32m" if score >= 90 else ("\033[36m" if score >= 75 else ("\033[33m" if score >= 50 else "\033[31m"))
3544
+ reset = "\033[0m"
3545
+ bold = "\033[1m"
3546
+ white = "\033[97m"
3547
+ cyan = "\033[36m"
3548
+ dim = "\033[2m"
3549
+ green = "\033[32m"
3550
+ red = "\033[31m"
3551
+ yellow = "\033[33m"
3552
+
3553
+ try:
3554
+ import term_ui as tui
3555
+ print()
3556
+ print(tui.card_header("🛡️ TORUSGUARD VISUAL SECURITY REPORT", "Single-File Offline Dashboard", "v2.3.0"))
3557
+ print(f"\n {bold}▸ Report URL:{reset} {cyan}{file_url}{reset}")
3558
+ print(f" {bold}▸ Output File:{reset} {white}{rep_path.name}{reset}")
3559
+ print(f" {bold}▸ Living Ledger:{reset} {green}security_report.md{reset}\n")
3560
+
3561
+ print(tui.card_border_top("Executive Posture"))
3562
+ print(tui.format_box_line(f"Posture Score: {bold}{score_color}{score}/100{reset} ({score_label})"))
3563
+ harmed = res.get("harmed_count", 0)
3564
+ defended = res.get("defended_count", 74)
3565
+ total = res.get("total_rules", 74)
3566
+ harmed_color = red if harmed > 0 else green
3567
+ print(tui.format_box_line(f"Invariants: {green}{defended} Defended{reset} · {harmed_color}{harmed} Harmed{reset} ({total} Rules)"))
3568
+ findings_str = f"{red}{res.get('critical_count', 0)} Critical{reset} · {yellow}{res.get('high_count', 0)} High{reset} · {cyan}{res.get('medium_count', 0)} Medium{reset}"
3569
+ print(tui.format_box_line(f"Exposure Status: {findings_str}"))
3570
+ print(tui.format_box_line(f"Dashboard Size: {white}{res['file_size_bytes']} bytes{reset} (Zero CDN, 100% Offline)"))
3571
+ print(tui.card_divider("Interactive Capabilities"))
3572
+ print(tui.format_box_line("• 5-Layer Architectural Invariant Matrix with modal inspection"))
3573
+ print(tui.format_box_line("• Interactive What-If Posture Simulator with live gauge updates"))
3574
+ print(tui.format_box_line("• Enterprise Compliance Framework Mapping (SOC 2, ISO, HIPAA)"))
3575
+ print(tui.format_box_line("• Golden Recipe Explorer with Unified Diff & Before/After views"))
3576
+ print(tui.format_box_line("• Zero-CDN Executive Dark / Light Mode Theme Switcher"))
3577
+ print(tui.card_border_bottom())
3578
+ print(f"\n {dim}Tip: Open {cyan}{file_url}{dim} directly in any browser to inspect.{reset}\n")
3579
+ except Exception:
3580
+ print(f"[SUCCESS] Emitted TorusGuard visual dashboard: {res['report_path']}")
3581
+ print(f"Posture Score: {res['posture_score']}/100 · Size: {res['file_size_bytes']} bytes")
3582
+
3583
+
3584
+ if __name__ == "__main__":
3585
+ main()