trace-sec 2.1.12 → 2.1.14

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.
@@ -1,135 +1,216 @@
1
- """FastAPI framework adapter for endpoint and attack-surface discovery."""
2
-
3
- import re
4
- from typing import List, Optional
5
- from trace_engine.parsing.parser import ParsedFile
6
- from trace_engine.framework.base import FrameworkAdapter, Endpoint, EndpointParameter
7
- from trace_engine.parsing.locations import SourceLocation
8
-
9
- HTTP_METHODS = {"get", "post", "put", "delete", "patch", "options", "head"}
10
-
11
-
12
- class FastAPIFrameworkAdapter(FrameworkAdapter):
13
- """Detects and extracts FastAPI/Starlette routes and auth gates."""
14
-
15
- def can_handle(self, parsed_file: ParsedFile) -> bool:
16
- if parsed_file.language != "python":
17
- return False
18
- for imp in parsed_file.imports:
19
- if "fastapi" in imp.module.lower() or "starlette" in imp.module.lower():
20
- return True
21
- return False
22
-
23
- def extract_endpoints(self, parsed_file: ParsedFile, content: str) -> List[Endpoint]:
24
- endpoints: List[Endpoint] = []
25
- ep_count = 0
26
-
27
- # Also search decorators in parsed functions
28
- for fn in parsed_file.functions:
29
- for dec in fn.decorators:
30
- dec_text = dec.raw_text.strip()
31
- # Pattern: @app.get('/path' ...) or @router.post('/path' ...)
32
- match = re.search(
33
- r"""@?(?:[a-zA-Z0-9_]+)\.(get|post|put|delete|patch)\s*\(\s*["']([^"']+)["']""",
34
- dec_text,
35
- re.IGNORECASE,
36
- )
37
- if match:
38
- ep_count += 1
39
- method = match.group(1).upper()
40
- path = match.group(2)
41
- ep_id = f"ep_{parsed_file.file_path.replace('/', '_').replace('.', '_')}_{fn.name}_{method}"
42
-
43
- # Detect path parameters: {param_name}
44
- path_params = re.findall(r"\{([a-zA-Z0-9_]+)\}", path)
45
- params: List[EndpointParameter] = [
46
- EndpointParameter(name=p, location="path", required=True)
47
- for p in path_params
48
- ]
49
-
50
- # Detect function params from AST
51
- for p in fn.parameters:
52
- if p.name not in ("self", "cls") and p.name not in path_params:
53
- params.append(
54
- EndpointParameter(
55
- name=p.name,
56
- location="query" if method == "GET" else "body",
57
- param_type=p.type_annotation or "string",
58
- )
59
- )
60
-
61
- # Analyze auth dependencies in decorator or args
62
- auth_required = False
63
- roles = []
64
- fn_body = fn.body_text or ""
65
-
66
- auth_indicators = [
67
- "current_user",
68
- "auth",
69
- "token",
70
- "security",
71
- "jwt",
72
- "oauth",
73
- "credentials",
74
- "verify_token",
75
- "api_key",
76
- ]
77
- for p in fn.parameters:
78
- p_ann = (p.type_annotation or "").lower()
79
- p_def = (p.default_value or "").lower()
80
- if any(ind in p.name.lower() or ind in p_ann or ind in p_def for ind in auth_indicators):
81
- auth_required = True
82
- if "admin" in p.name.lower() or "admin" in p_ann or "admin" in p_def:
83
- roles.append("admin")
84
-
85
- if "require_admin" in dec_text.lower() or "admin" in path.lower():
86
- roles.append("admin")
87
- if "current_user" in dec_text.lower() or "depends(get_current_user" in dec_text.lower():
88
- auth_required = True
89
-
90
- # Object identifier flag (e.g. /items/{id})
91
- has_obj_id = any(
92
- p in ("id", "user_id", "order_id", "product_id", "item_id")
93
- for p in path_params
94
- )
95
-
96
- # State changing
97
- state_changing = method in ("POST", "PUT", "DELETE", "PATCH")
98
-
99
- # Database access check
100
- db_access = any(
101
- term in fn_body.lower()
102
- for term in ("db.", "session.", "cursor.", "repository", "query(", ".filter(", "orders", "database", "find")
103
- )
104
-
105
- # Outbound network (SSRF indicator)
106
- ext_network = any(
107
- term in fn_body.lower()
108
- for term in ("requests.", "httpx.", "urllib.", "aiohttp.", "fetch(")
109
- )
110
-
111
- # Sensitive data
112
- sensitive = any(
113
- term in path.lower() or term in fn_body.lower()
114
- for term in ("password", "secret", "token", "credit", "card", "user", "order", "refund", "admin")
115
- )
116
-
117
- endpoints.append(
118
- Endpoint(
119
- id=ep_id,
120
- method=method,
121
- path=path,
122
- handler_name=fn.qualified_name,
123
- auth_required=auth_required,
124
- roles=list(set(roles)),
125
- parameters=params,
126
- database_access=db_access,
127
- object_identifier=has_obj_id,
128
- state_changing=state_changing,
129
- external_network=ext_network,
130
- sensitive_data=sensitive,
131
- source=fn.location,
132
- )
133
- )
134
-
135
- return endpoints
1
+ """FastAPI framework adapter for endpoint and attack-surface discovery."""
2
+
3
+ import re
4
+ from typing import List, Optional
5
+ from trace_engine.parsing.parser import ParsedFile
6
+ from trace_engine.framework.base import FrameworkAdapter, Endpoint, EndpointParameter
7
+ from trace_engine.parsing.locations import SourceLocation
8
+
9
+ HTTP_METHODS = {"get", "post", "put", "delete", "patch", "options", "head", "websocket"}
10
+
11
+
12
+ class FastAPIFrameworkAdapter(FrameworkAdapter):
13
+ """Detects and extracts FastAPI/Starlette routes, WebSockets, static mounts, and auth gates."""
14
+
15
+ def can_handle(self, parsed_file: ParsedFile) -> bool:
16
+ if parsed_file.language != "python":
17
+ return False
18
+ for imp in parsed_file.imports:
19
+ if "fastapi" in imp.module.lower() or "starlette" in imp.module.lower():
20
+ return True
21
+ return False
22
+
23
+ def extract_endpoints(self, parsed_file: ParsedFile, content: str) -> List[Endpoint]:
24
+ endpoints: List[Endpoint] = []
25
+ existing_keys = set()
26
+
27
+ # 1. Search decorators on parsed AST functions
28
+ for fn in parsed_file.functions:
29
+ for dec in fn.decorators:
30
+ dec_text = dec.raw_text.strip()
31
+ # Pattern: @app.get('/path' ...), @router.post('/path' ...), @app.websocket('/ws/...')
32
+ match = re.search(
33
+ r"""@?(?:[a-zA-Z0-9_]+)\.(get|post|put|delete|patch|websocket|api_route)\s*\(\s*["']([^"']+)["']""",
34
+ dec_text,
35
+ re.IGNORECASE,
36
+ )
37
+ if match:
38
+ raw_verb = match.group(1).upper()
39
+ method = "WEBSOCKET" if raw_verb == "WEBSOCKET" else ("GET" if raw_verb == "API_ROUTE" else raw_verb)
40
+ path = match.group(2)
41
+ key = (method, path)
42
+ if key in existing_keys:
43
+ continue
44
+ existing_keys.add(key)
45
+
46
+ ep_id = f"ep_{parsed_file.file_path.replace('/', '_').replace('.', '_')}_{fn.name}_{method}"
47
+
48
+ # Detect path parameters: {param_name}
49
+ path_params = re.findall(r"\{([a-zA-Z0-9_]+)\}", path)
50
+ params: List[EndpointParameter] = [
51
+ EndpointParameter(name=p, location="path", required=True)
52
+ for p in path_params
53
+ ]
54
+
55
+ # Detect function params from AST
56
+ for p in fn.parameters:
57
+ if p.name not in ("self", "cls") and p.name not in path_params:
58
+ params.append(
59
+ EndpointParameter(
60
+ name=p.name,
61
+ location="query" if method in ("GET", "WEBSOCKET") else "body",
62
+ param_type=p.type_annotation or "string",
63
+ )
64
+ )
65
+
66
+ # Analyze auth dependencies in decorator or args
67
+ auth_required = False
68
+ roles = []
69
+ fn_body = fn.body_text or ""
70
+
71
+ auth_indicators = [
72
+ "current_user",
73
+ "auth",
74
+ "token",
75
+ "security",
76
+ "jwt",
77
+ "oauth",
78
+ "credentials",
79
+ "verify_token",
80
+ "api_key",
81
+ "require_admin",
82
+ "get_current_user",
83
+ ]
84
+ for p in fn.parameters:
85
+ p_ann = (p.type_annotation or "").lower()
86
+ p_def = (p.default_value or "").lower()
87
+ if any(ind in p.name.lower() or ind in p_ann or ind in p_def for ind in auth_indicators):
88
+ auth_required = True
89
+ if "admin" in p.name.lower() or "admin" in p_ann or "admin" in p_def:
90
+ roles.append("admin")
91
+
92
+ if "require_admin" in dec_text.lower() or "admin" in path.lower():
93
+ roles.append("admin")
94
+ if "current_user" in dec_text.lower() or "depends(" in dec_text.lower():
95
+ auth_required = True
96
+
97
+ # Object identifier flag: e.g. /items/{id}, /athlete/{athlete_id}, /coach/{coach_id}
98
+ has_obj_id = any(
99
+ p.lower() == "id" or p.lower().endswith("_id") or p.lower().endswith("id")
100
+ for p in path_params
101
+ )
102
+
103
+ # State changing
104
+ state_changing = method in ("POST", "PUT", "DELETE", "PATCH")
105
+
106
+ # Database access check
107
+ db_access = any(
108
+ term in fn_body.lower()
109
+ for term in ("db.", "session.", "cursor.", "repository", "query(", ".filter(", "database", "models.")
110
+ )
111
+
112
+ # Outbound network (SSRF indicator)
113
+ ext_network = any(
114
+ term in fn_body.lower()
115
+ for term in ("requests.", "httpx.", "urllib.", "aiohttp.", "fetch(")
116
+ )
117
+
118
+ # Sensitive data
119
+ sensitive = any(
120
+ term in path.lower() or term in fn_body.lower()
121
+ for term in ("password", "secret", "token", "credit", "card", "user", "order", "refund", "admin", "coach", "athlete", "story", "session")
122
+ )
123
+
124
+ endpoints.append(
125
+ Endpoint(
126
+ id=ep_id,
127
+ method=method,
128
+ path=path,
129
+ handler_name=fn.qualified_name,
130
+ auth_required=auth_required,
131
+ roles=list(set(roles)),
132
+ parameters=params,
133
+ database_access=db_access,
134
+ object_identifier=has_obj_id,
135
+ state_changing=state_changing,
136
+ external_network=ext_network,
137
+ sensitive_data=sensitive,
138
+ source=fn.location,
139
+ )
140
+ )
141
+
142
+ # 2. Detect StaticFiles mounts e.g. app.mount("/session_videos", StaticFiles(directory="session_videos"), name="session_videos")
143
+ mount_pattern = re.compile(
144
+ r"""(?:app|router)\.mount\s*\(\s*["']([^"']+)["']\s*,\s*StaticFiles\s*\(\s*(?:directory\s*=\s*)?["']([^"']+)["']""",
145
+ re.IGNORECASE,
146
+ )
147
+ for match in mount_pattern.finditer(content):
148
+ mount_path = match.group(1).rstrip("/")
149
+ directory = match.group(2)
150
+ line_no = content[:match.start()].count("\n") + 1
151
+ ep_id = f"ep_{parsed_file.file_path.replace('/', '_').replace('.', '_')}_mount_{directory}_GET"
152
+ key = ("GET", f"{mount_path}/{{filepath}}")
153
+ if key not in existing_keys:
154
+ existing_keys.add(key)
155
+ endpoints.append(
156
+ Endpoint(
157
+ id=ep_id,
158
+ method="GET",
159
+ path=f"{mount_path}/{{filepath}}",
160
+ handler_name=f"StaticFiles({directory})",
161
+ auth_required=False,
162
+ roles=[],
163
+ parameters=[
164
+ EndpointParameter(name="filepath", location="path", required=True)
165
+ ],
166
+ database_access=False,
167
+ object_identifier=True,
168
+ state_changing=False,
169
+ external_network=False,
170
+ sensitive_data=True,
171
+ source=SourceLocation(file=parsed_file.file_path, line_start=line_no, line_end=line_no),
172
+ )
173
+ )
174
+
175
+ # 3. Direct regex fallback across raw content to catch routes missed by AST function traversal
176
+ raw_route_pat = re.compile(
177
+ r"""@(?:app|router|api_router)\.(get|post|put|delete|patch|websocket)\s*\(\s*["']([^"']+)["']""",
178
+ re.IGNORECASE,
179
+ )
180
+ for match in raw_route_pat.finditer(content):
181
+ raw_verb = match.group(1).upper()
182
+ method = "WEBSOCKET" if raw_verb == "WEBSOCKET" else raw_verb
183
+ path = match.group(2)
184
+ key = (method, path)
185
+ if key in existing_keys:
186
+ continue
187
+ existing_keys.add(key)
188
+
189
+ line_no = content[:match.start()].count("\n") + 1
190
+ path_params = re.findall(r"\{([a-zA-Z0-9_]+)\}", path)
191
+ has_obj_id = any(
192
+ p.lower() == "id" or p.lower().endswith("_id") or p.lower().endswith("id")
193
+ for p in path_params
194
+ )
195
+ endpoints.append(
196
+ Endpoint(
197
+ id=f"ep_{parsed_file.file_path.replace('/', '_').replace('.', '_')}_L{line_no}_{method}",
198
+ method=method,
199
+ path=path,
200
+ handler_name=f"route_L{line_no}",
201
+ auth_required=False,
202
+ roles=["admin"] if "admin" in path.lower() else [],
203
+ parameters=[
204
+ EndpointParameter(name=p, location="path", required=True)
205
+ for p in path_params
206
+ ],
207
+ database_access=True,
208
+ object_identifier=has_obj_id,
209
+ state_changing=method in ("POST", "PUT", "DELETE", "PATCH"),
210
+ external_network=False,
211
+ sensitive_data="admin" in path.lower() or "user" in path.lower() or "session" in path.lower() or "story" in path.lower(),
212
+ source=SourceLocation(file=parsed_file.file_path, line_start=line_no, line_end=line_no),
213
+ )
214
+ )
215
+
216
+ return endpoints
@@ -624,53 +624,57 @@ def run_interactive_story() -> None:
624
624
  )
625
625
 
626
626
  # =========================================================================
627
- # STEP 7: Autonomous AST Self-Healing Opportunity
627
+ # STEP 7: AI Coding Agent Handoff & Export Options
628
628
  # =========================================================================
629
- if findings:
630
- console.print(f"\n [{DIM_GREEN}]{'─' * 76}[/{DIM_GREEN}]")
631
- console.print(f" [{GREEN}]◆ AUTONOMOUS AST SELF-HEALING OPPORTUNITY:[/{GREEN}]")
632
- console.print(f" [{WHITE}]TRACE can synthesize surgical AST patches for detected flaws in {project_dir.name}.[/{WHITE}]")
633
- console.print(f" [{DIM_WHITE}]Includes transactional rollback protection and verification oracle testing.[/{DIM_WHITE}]\n")
634
-
635
- heal_ans = ask_input(
636
- f" [{ORANGE}]›[/{ORANGE}] [{BOLD_WHITE}]Autonomously remediate verified vulnerabilities now? (y/n)[/{BOLD_WHITE}]",
637
- choices=["y", "n", "yes", "no"],
638
- default="n",
639
- )
640
- should_heal = heal_ans.lower() in ("y", "yes")
641
-
642
- if should_heal:
643
- from trace_engine.harness.engine import AgentHarness
644
- with console.status(f" [{GREEN}]Autonomous Harness:[/{GREEN}] [{WHITE}]Refactoring AST nodes & running verification...[/{WHITE}]", spinner="dots"):
645
- harness = AgentHarness(project_dir, target_url=target_url)
646
- report = harness.run_self_healing_loop()
647
- print_harness_report(report)
648
-
649
- # =========================================================================
650
- # STEP 8: Post-Audit Export
651
- # =========================================================================
652
- console.print(f"\n [{BOLD_WHITE}]Export Options:[/{BOLD_WHITE}]")
653
- console.print(f" [{ORANGE}][1][/{ORANGE}] [{BOLD_WHITE}]Export OASIS SARIF v2.1.0[/{BOLD_WHITE}] [{DIM_WHITE}](GitHub Code Scanning / CI Integration)[/{DIM_WHITE}]")
654
- console.print(f" [{ORANGE}][2][/{ORANGE}] [{BOLD_WHITE}]Export Markdown Report[/{BOLD_WHITE}] [{DIM_WHITE}](Executive summary document)[/{DIM_WHITE}]")
655
- console.print(f" [{ORANGE}][3][/{ORANGE}] [{BOLD_WHITE}]Finish Session[/{BOLD_WHITE}]\n")
656
-
657
- post_choice = ask_input(
658
- f" [{ORANGE}]›[/{ORANGE}] [{BOLD_WHITE}]Select export option[/{BOLD_WHITE}]",
659
- choices=["1", "2", "3"],
660
- default="1",
629
+ console.print(f"\n [{DIM_GREEN}]{'─' * 76}[/{DIM_GREEN}]")
630
+ console.print(f" [{GREEN}]◆ TRACE AUDIT COMPLETE — AI AGENT & REMEDIATION HANDOFF:[/{GREEN}]")
631
+ console.print(f" [{WHITE}]TRACE has generated precise root causes and concrete remediation plans for all {len(findings)} findings.[/{WHITE}]")
632
+ console.print(f" [{DIM_WHITE}]Executive report automatically saved to:[/{DIM_WHITE}] [{BOLD_WHITE}]{project_dir / 'findings.md'}[/{BOLD_WHITE}]\n")
633
+
634
+ console.print(f" [{BOLD_WHITE}]Next Action / Integration Options:[/{BOLD_WHITE}]")
635
+ console.print(f" [{ORANGE}][1][/{ORANGE}] [{BOLD_WHITE}]Inspect Detailed Root Cause & Exploit Mechanics in Terminal[/{BOLD_WHITE}]")
636
+ console.print(f" [{ORANGE}][2][/{ORANGE}] [{BOLD_WHITE}]Launch TRACE MCP Server[/{BOLD_WHITE}] [{DIM_WHITE}](Connect Claude Code / Cursor as skill)[/{DIM_WHITE}]")
637
+ console.print(f" [{ORANGE}][3][/{ORANGE}] [{BOLD_WHITE}]Export OASIS SARIF v2.1.0[/{BOLD_WHITE}] [{DIM_WHITE}](GitHub Code Scanning / CI)[/{DIM_WHITE}]")
638
+ console.print(f" [{ORANGE}][4][/{ORANGE}] [{BOLD_WHITE}]Finish Session[/{BOLD_WHITE}]\n")
639
+
640
+ handoff_choice = ask_input(
641
+ f" [{ORANGE}]›[/{ORANGE}] [{BOLD_WHITE}]Select action[/{BOLD_WHITE}]",
642
+ choices=["1", "2", "3", "4"],
643
+ default="4",
661
644
  show_default=True,
662
645
  )
663
646
 
664
- if post_choice == "1":
647
+ if handoff_choice == "1":
648
+ while True:
649
+ console.print(f"\n [{BOLD_WHITE}]Available Findings to Inspect:[/{BOLD_WHITE}]")
650
+ for f in findings:
651
+ action_text = f.short_action or f.title
652
+ console.print(f" [{GREEN}]• {f.id:<14}[/{GREEN}] [{WHITE}]{f.endpoint:<28}[/{WHITE}] [{DIM_WHITE}]{action_text}[/{DIM_WHITE}]")
653
+ f_id = ask_input(f"\n [{ORANGE}]›[/{ORANGE}] [{BOLD_WHITE}]Enter Finding ID to inspect (or 'back')[/{BOLD_WHITE}]", default="back")
654
+ if f_id.lower() in ("back", "exit", "q"):
655
+ break
656
+ matched = next((f for f in findings if f.id.lower() == f_id.lower()), None)
657
+ if matched:
658
+ console.print()
659
+ print_finding_detail(matched)
660
+ else:
661
+ console.print(f" [{ORANGE}]Finding '{f_id}' not found.[/{ORANGE}]")
662
+
663
+ elif handoff_choice == "2":
664
+ console.print(f"\n [{GREEN}]Starting TRACE MCP Server on port 8765...[/{GREEN}]")
665
+ console.print(f" [{DIM_WHITE}]Connect Claude Code or Cursor to: [bold white]http://127.0.0.1:8765/sse[/bold white][/{DIM_WHITE}]")
666
+ console.print(f" [{DIM_WHITE}]Press Ctrl+C to stop the MCP server when finished.[/{DIM_WHITE}]\n")
667
+ from trace_engine.mcp.server import run_server
668
+ try:
669
+ run_server(host="127.0.0.1", port=8765)
670
+ except KeyboardInterrupt:
671
+ console.print(f"\n [{GREEN}]✓ TRACE MCP Server stopped.[/{GREEN}]")
672
+
673
+ elif handoff_choice == "3":
665
674
  sarif_file = project_dir / "trace-results.sarif"
666
675
  sarif_data = generate_sarif_report(findings, project_name=project_dir.name, workspace_root=str(project_dir))
667
676
  import json
668
677
  sarif_file.write_text(json.dumps(sarif_data, indent=2), encoding="utf-8")
669
678
  console.print(f" [{GREEN}]✓ Exported SARIF v2.1.0:[/{GREEN}] [{WHITE}]{sarif_file}[/{WHITE}]\n")
670
- elif post_choice == "2":
671
- md_file = project_dir / "TRACE_SECURITY_REPORT.md"
672
- md_content = generate_markdown_report(findings, project_name=project_dir.name)
673
- md_file.write_text(md_content, encoding="utf-8")
674
- console.print(f" [{GREEN}]✓ Exported Markdown Report:[/{GREEN}] [{WHITE}]{md_file}[/{WHITE}]\n")
675
679
 
676
680
  console.print(f" [{GREEN}]✓ TRACE Audit Session Complete. System Verified.[/{GREEN}]\n")
@@ -218,6 +218,8 @@ class TraceMCPServer:
218
218
  "severity": f.severity.value,
219
219
  "confidence": f.confidence.value,
220
220
  "endpoint": f.endpoint,
221
+ "root_cause": f.root_cause,
222
+ "short_action": f.short_action,
221
223
  "remediation": f.remediation,
222
224
  "source": str(f.source_location) if f.source_location else None,
223
225
  }
@@ -239,6 +241,8 @@ class TraceMCPServer:
239
241
  "endpoint": finding.endpoint,
240
242
  "source": str(finding.source_location),
241
243
  "attack_path": finding.attack_path,
244
+ "root_cause": finding.root_cause,
245
+ "short_action": finding.short_action,
242
246
  "static_evidence": finding.static_evidence,
243
247
  "runtime_evidence": finding.runtime_evidence,
244
248
  "correlation_notes": finding.correlation_notes,
@@ -10,16 +10,18 @@ def generate_markdown_report(findings: List[Finding], project_name: str = "Appli
10
10
  f"# TRACE Security Assessment Report — {project_name}",
11
11
  "Threat Reconnaissance & Attack-path Correlation Engine",
12
12
  "",
13
- "## Summary",
14
- f"- Total Correlated Findings: **{len(findings)}**",
13
+ "## Executive Summary",
14
+ f"- **Target Application:** `{project_name}`",
15
+ f"- **Total Correlated Findings:** **{len(findings)}**",
15
16
  "",
16
- "| ID | Severity | Confidence | Category | Endpoint | Title |",
17
+ "| ID | Severity | Confidence | Category | Endpoint | Remediation Action |",
17
18
  "|---|---|---|---|---|---|",
18
19
  ]
19
20
 
20
21
  for f in findings:
22
+ action_summary = f.short_action or f.title
21
23
  lines.append(
22
- f"| {f.id} | {f.severity.value} | {f.confidence.value} | {f.category} | `{f.endpoint}` | {f.title} |"
24
+ f"| `{f.id}` | **{f.severity.value}** | {f.confidence.value} | {f.category} | `{f.endpoint}` | {action_summary} |"
23
25
  )
24
26
 
25
27
  lines.extend(["", "---", "", "## Finding Details", ""])
@@ -33,9 +35,13 @@ def generate_markdown_report(findings: List[Finding], project_name: str = "Appli
33
35
  if f.source_location:
34
36
  lines.append(f"- **Source:** `{f.source_location}`")
35
37
  lines.append("")
36
-
38
+
39
+ if f.root_cause:
40
+ lines.append("#### Root Cause & Exploit Mechanics")
41
+ lines.append(f"{f.root_cause}\n")
42
+
37
43
  if f.attack_path:
38
- lines.append("#### Attack Path Hops")
44
+ lines.append("#### Attack Path Hops (APM Traversal)")
39
45
  for hop in f.attack_path:
40
46
  lines.append(f"1. `{hop}`")
41
47
  lines.append("")
@@ -58,7 +64,8 @@ def generate_markdown_report(findings: List[Finding], project_name: str = "Appli
58
64
  lines.append(f"{idx}. {step}")
59
65
  lines.append("")
60
66
 
61
- lines.append(f"#### Remediation Guidance\n{f.remediation}\n")
67
+ lines.append("#### Concrete Remediation Plan")
68
+ lines.append(f"{f.remediation}\n")
62
69
  lines.append("---")
63
70
  lines.append("")
64
71
 
@@ -220,6 +220,10 @@ def print_finding_detail(f: Finding) -> None:
220
220
  prefix = " +- "
221
221
  content.append(f"{prefix}{hop}\n", style="dim white")
222
222
 
223
+ if f.root_cause:
224
+ content.append("\nRoot Cause & Exploit Mechanics:\n", style="bold green")
225
+ content.append(f" {f.root_cause}\n", style="bright_white")
226
+
223
227
  if f.static_evidence:
224
228
  content.append("\nStatic Code Evidence:\n", style="bold green")
225
229
  for ev in f.static_evidence:
@@ -235,8 +239,10 @@ def print_finding_detail(f: Finding) -> None:
235
239
  for idx, step in enumerate(f.reproduction_steps, 1):
236
240
  content.append(f" {idx}. {step}\n", style="white")
237
241
 
238
- content.append("\nRemediation Guidance:\n", style="bold green")
239
- content.append(f" {f.remediation}\n", style="dim white")
242
+ content.append("\nConcrete Remediation Action Plan:\n", style="bold green")
243
+ for line in f.remediation.split("\n"):
244
+ if line.strip():
245
+ content.append(f" {line}\n", style="white")
240
246
 
241
247
  console.print(
242
248
  Panel(
@@ -351,7 +357,7 @@ def print_test_all_report(
351
357
  for idx, f in enumerate(sorted_findings, 1):
352
358
  prio = priority_labels.get(f.severity, str(f.severity.value))
353
359
  loc_str = str(f.source_location) if f.source_location else "-"
354
- remediation_snippet = f.remediation.strip().split(".")[0] + "." if f.remediation else "Apply strict authorization check."
360
+ remediation_snippet = f.short_action or (f.remediation.strip().split(".")[0] + "." if f.remediation else "Apply strict authorization check.")
355
361
 
356
362
  table.add_row(
357
363
  f"#{idx}",