33Integration Test Report Generator for .NET CMA SDK
44Parses TRX (results) + Cobertura (coverage) + Structured StdOut (HTTP, assertions, context)
55into a single interactive HTML report.
6- No external dependencies — uses only Python standard library.
6+
7+ SECURITY ENHANCEMENTS:
8+ - Uses defusedxml for secure XML parsing to prevent XXE attacks
9+ - Robust path traversal prevention for all file operations
10+ - Input validation and sanitization for all user-provided paths
11+ - Safe handling of external entity resolution in XML processing
12+
13+ Dependencies:
14+ - defusedxml (optional but recommended for security)
15+ - Python 3.7+ for optimal security features
716"""
817
918import xml .etree .ElementTree as ET
1423import argparse
1524from datetime import datetime
1625
26+ # Try to import defusedxml for safer XML parsing
27+ try :
28+ import defusedxml .ElementTree as SafeET
29+ DEFUSED_XML_AVAILABLE = True
30+ except ImportError :
31+ SafeET = None
32+ DEFUSED_XML_AVAILABLE = False
33+
34+
35+ def _make_xml_parser ():
36+ """
37+ Create a hardened XML parser that prevents XXE and other XML-based attacks.
38+ Uses defusedxml for safer XML parsing when available.
39+ """
40+ if DEFUSED_XML_AVAILABLE :
41+ return None # defusedxml uses its own parser
42+
43+ # Fallback to standard parser with security restrictions
44+ parser = ET .XMLParser ()
45+
46+ # For Python 3.8+, disable resolve_entities
47+ if sys .version_info >= (3 , 8 ):
48+ try :
49+ parser = ET .XMLParser (resolve_entities = False )
50+ except TypeError :
51+ pass
52+
53+ # Additional hardening for older versions
54+ if hasattr (parser , 'parser' ):
55+ try :
56+ # Disable external entity processing
57+ parser .parser .DefaultHandler = lambda data : None
58+ parser .parser .ExternalEntityRefHandler = lambda * args : False
59+ parser .parser .EntityDeclHandler = lambda * args : False
60+ except AttributeError :
61+ pass
62+
63+ return parser
64+
65+
66+ def _sanitize_output_path (output_path ):
67+ """
68+ Robust path traversal prevention: output must resolve under the current working directory.
69+ Prevents directory traversal attacks and validates file path safety.
70+ """
71+ if not output_path or not isinstance (output_path , str ):
72+ raise ValueError ("Invalid output path: path must be a non-empty string" )
73+
74+ # Check for null bytes and other dangerous characters
75+ if '\x00 ' in output_path :
76+ raise ValueError ("Invalid output path: contains null byte" )
77+
78+ # Check for dangerous path components
79+ dangerous_patterns = ['..' , '~/' , '\\ ..\\ ' , '/../' , '\\ .\\ ' , '/./' ]
80+ for pattern in dangerous_patterns :
81+ if pattern in output_path :
82+ raise ValueError (f"Invalid output path: contains dangerous pattern '{ pattern } '" )
83+
84+ # Resolve paths safely
85+ cwd = os .path .abspath (os .getcwd ())
86+ try :
87+ candidate = os .path .abspath (os .path .normpath (output_path ))
88+ except (OSError , ValueError ) as e :
89+ raise ValueError (f"Invalid output path: cannot resolve path: { e } " ) from e
90+
91+ # Ensure the resolved path is under the working directory
92+ try :
93+ # Use os.path.commonpath for cross-platform safety
94+ common = os .path .commonpath ([cwd , candidate ])
95+ except ValueError as e :
96+ raise ValueError (
97+ "Output path must be on the same drive as the working directory "
98+ "and must not escape it (path traversal)."
99+ ) from e
100+
101+ if not common .startswith (cwd ) or common != cwd :
102+ raise ValueError (
103+ f"Output path must be inside the working directory ({ cwd } ). Refusing: { output_path !r} "
104+ )
105+
106+ # Additional check: ensure no symlink attacks
107+ if os .path .islink (os .path .dirname (candidate )) and os .path .dirname (candidate ) != cwd :
108+ raise ValueError ("Output path directory cannot be a symbolic link outside working directory" )
109+
110+ return candidate
111+
17112
18113class IntegrationTestReportGenerator :
19114 def __init__ (self , trx_path , coverage_path = None ):
@@ -38,10 +133,26 @@ def __init__(self, trx_path, coverage_path=None):
38133 # ──────────────────── TRX PARSING ────────────────────
39134
40135 def parse_trx (self ):
41- tree = ET .parse (self .trx_path )
42- root = tree .getroot ()
136+ # Safely parse TRX file with defusedxml when available
137+ try :
138+ if DEFUSED_XML_AVAILABLE :
139+ tree = SafeET .parse (self .trx_path )
140+ else :
141+ # Warn about potential security risk
142+ print ("Warning: defusedxml not available. Using standard XML parser with limited security mitigations." )
143+ parser = _make_xml_parser ()
144+ tree = ET .parse (self .trx_path , parser = parser )
145+ root = tree .getroot ()
146+ except Exception as e :
147+ raise ValueError (f"Failed to parse TRX file safely: { e } " ) from e
43148 ns = {'t' : 'http://microsoft.com/schemas/VisualStudio/TeamTest/2010' }
44149
150+ unit_tests_by_id = {}
151+ for ut in root .findall ('.//t:UnitTest' , ns ):
152+ tid = ut .get ('id' )
153+ if tid :
154+ unit_tests_by_id [tid ] = ut
155+
45156 counters = root .find ('.//t:ResultSummary/t:Counters' , ns )
46157 if counters is not None :
47158 self .results ['total' ] = int (counters .get ('total' , 0 ))
@@ -82,7 +193,8 @@ def parse_trx(self):
82193 duration_str = result .get ('duration' , '0' )
83194 duration = self ._parse_duration (duration_str )
84195
85- test_def = root .find (f".//t:UnitTest[@id='{ test_id } ']/t:TestMethod" , ns )
196+ ut_el = unit_tests_by_id .get (test_id )
197+ test_def = ut_el .find ('t:TestMethod' , ns ) if ut_el is not None else None
86198 class_name = test_def .get ('className' , '' ) if test_def is not None else ''
87199
88200 if 'IntegrationTest' not in class_name :
@@ -147,7 +259,12 @@ def parse_coverage(self):
147259 if not self .coverage_path or not os .path .exists (self .coverage_path ):
148260 return
149261 try :
150- tree = ET .parse (self .coverage_path )
262+ # Safely parse coverage file with defusedxml when available
263+ if DEFUSED_XML_AVAILABLE :
264+ tree = SafeET .parse (self .coverage_path )
265+ else :
266+ parser = _make_xml_parser ()
267+ tree = ET .parse (self .coverage_path , parser = parser )
151268 root = tree .getroot ()
152269 self .coverage ['lines_pct' ] = float (root .get ('line-rate' , 0 )) * 100
153270 self .coverage ['branches_pct' ] = float (root .get ('branch-rate' , 0 )) * 100
@@ -331,6 +448,7 @@ def _format_duration_display(self, seconds):
331448 # ──────────────────── HTML GENERATION ────────────────────
332449
333450 def generate_html (self , output_path ):
451+ output_path = _sanitize_output_path (output_path )
334452 pass_rate = (self .results ['passed' ] / self .results ['total' ] * 100 ) if self .results ['total' ] > 0 else 0
335453 duration_display = self ._format_duration_display (self .results ['duration_seconds' ])
336454
@@ -351,7 +469,7 @@ def generate_html(self, output_path):
351469
352470 with open (output_path , 'w' , encoding = 'utf-8' ) as f :
353471 f .write (html )
354- return output_path
472+ return os . path . abspath ( output_path )
355473
356474 def _html_head (self ):
357475 return f"""<!DOCTYPE html>
@@ -876,32 +994,68 @@ def _html_scripts(self):
876994"""
877995
878996
997+ def _validate_input_path (file_path , description = "file" ):
998+ """
999+ Validate input file paths to prevent path traversal attacks.
1000+ """
1001+ if not file_path or not isinstance (file_path , str ):
1002+ raise ValueError (f"Invalid { description } path: path must be a non-empty string" )
1003+
1004+ # Check for null bytes
1005+ if '\x00 ' in file_path :
1006+ raise ValueError (f"Invalid { description } path: contains null byte" )
1007+
1008+ # Resolve and validate the path
1009+ try :
1010+ resolved_path = os .path .abspath (os .path .normpath (file_path ))
1011+ except (OSError , ValueError ) as e :
1012+ raise ValueError (f"Invalid { description } path: cannot resolve path: { e } " ) from e
1013+
1014+ # Check if file exists and is readable
1015+ if not os .path .exists (resolved_path ):
1016+ raise ValueError (f"{ description .capitalize ()} not found: { resolved_path } " )
1017+
1018+ if not os .path .isfile (resolved_path ):
1019+ raise ValueError (f"{ description .capitalize ()} is not a regular file: { resolved_path } " )
1020+
1021+ if not os .access (resolved_path , os .R_OK ):
1022+ raise ValueError (f"{ description .capitalize ()} is not readable: { resolved_path } " )
1023+
1024+ return resolved_path
1025+
1026+
8791027def main ():
8801028 parser = argparse .ArgumentParser (description = 'Integration Test Report Generator for .NET CMA SDK' )
8811029 parser .add_argument ('trx_file' , help = 'Path to the .trx test results file' )
8821030 parser .add_argument ('--coverage' , help = 'Path to coverage.cobertura.xml file' , default = None )
8831031 parser .add_argument ('--output' , help = 'Output HTML file path' , default = None )
8841032 args = parser .parse_args ()
8851033
886- if not os .path .exists (args .trx_file ):
887- print (f"Error: TRX file not found: { args .trx_file } " )
1034+ try :
1035+ # Validate input file paths
1036+ trx_file = _validate_input_path (args .trx_file , "TRX file" )
1037+ coverage_file = None
1038+ if args .coverage :
1039+ coverage_file = _validate_input_path (args .coverage , "coverage file" )
1040+ except ValueError as e :
1041+ print (f"Error: { e } " )
8881042 sys .exit (1 )
8891043
8901044 print ("=" * 70 )
8911045 print (" .NET CMA SDK - Integration Test Report Generator" )
8921046 print ("=" * 70 )
8931047
894- generator = IntegrationTestReportGenerator (args . trx_file , args . coverage )
1048+ generator = IntegrationTestReportGenerator (trx_file , coverage_file )
8951049
896- print (f"\n Parsing TRX: { args . trx_file } " )
1050+ print (f"\n Parsing TRX: { trx_file } " )
8971051 generator .parse_trx ()
8981052 print (f" Found { generator .results ['total' ]} integration tests" )
8991053 print (f" Passed: { generator .results ['passed' ]} " )
9001054 print (f" Failed: { generator .results ['failed' ]} " )
9011055 print (f" Skipped: { generator .results ['skipped' ]} " )
9021056
903- if args . coverage :
904- print (f"\n Parsing Coverage: { args . coverage } " )
1057+ if coverage_file :
1058+ print (f"\n Parsing Coverage: { coverage_file } " )
9051059 generator .parse_coverage ()
9061060 c = generator .coverage
9071061 print (f" Lines: { c ['lines_pct' ]:.1f} %" )
@@ -912,12 +1066,16 @@ def main():
9121066 output_file = args .output or f'integration-test-report_{ timestamp } .html'
9131067
9141068 print (f"\n Generating HTML report..." )
915- generator .generate_html (output_file )
1069+ try :
1070+ resolved_output = generator .generate_html (output_file )
1071+ except ValueError as e :
1072+ print (f"Error: { e } " )
1073+ sys .exit (1 )
9161074
9171075 print (f"\n { '=' * 70 } " )
918- print (f" Report generated: { os . path . abspath ( output_file ) } " )
1076+ print (f" Report generated: { resolved_output } " )
9191077 print (f"{ '=' * 70 } " )
920- print (f"\n open { os . path . abspath ( output_file ) } " )
1078+ print (f"\n open { resolved_output } " )
9211079
9221080
9231081if __name__ == "__main__" :
0 commit comments