Skip to content

Commit 87eb420

Browse files
authored
Merge pull request #20 from calebevans/xml
fix(xml): add escaping, root element, and pretty printing
2 parents b8868a3 + dbe5266 commit 87eb420

6 files changed

Lines changed: 101 additions & 34 deletions

File tree

docs/architecture.md

Lines changed: 34 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -301,33 +301,44 @@ Merged block: lines 10-30 (score=max(0.15, 0.18, 0.12) = 0.18)
301301

302302
### 6. Output Formatting
303303

304-
**Generates structured XML output with metadata.**
304+
**Generates pretty-printed, structured XML output with metadata.**
305305

306306
```xml
307-
<block lines="581-600" score="0.1746">
308-
[Sun Dec 04 07:18:00 2005] [error] mod_jk child workerEnv in error state 6
309-
[Sun Dec 04 07:18:00 2005] [notice] workerEnv.init() ok /etc/httpd/conf/workers2.properties
310-
[Sun Dec 04 07:18:00 2005] [error] mod_jk child workerEnv in error state 7
311-
[Sun Dec 04 07:45:45 2005] [error] [client 63.13.186.196] Directory index forbidden by rule: /var/www/html/
312-
[Sun Dec 04 08:54:17 2005] [error] [client 147.31.138.75] Directory index forbidden by rule: /var/www/html/
313-
[Sun Dec 04 09:35:12 2005] [error] [client 207.203.80.15] Directory index forbidden by rule: /var/www/html/
314-
[Sun Dec 04 10:53:30 2005] [error] [client 218.76.139.20] Directory index forbidden by rule: /var/www/html/
315-
[Sun Dec 04 11:11:07 2005] [error] [client 24.147.151.74] Directory index forbidden by rule: /var/www/html/
316-
[Sun Dec 04 11:33:18 2005] [error] [client 211.141.93.88] Directory index forbidden by rule: /var/www/html/
317-
[Sun Dec 04 11:42:43 2005] [error] [client 216.127.124.16] Directory index forbidden by rule: /var/www/html/
318-
[Sun Dec 04 12:33:13 2005] [error] [client 208.51.151.210] Directory index forbidden by rule: /var/www/html/
319-
[Sun Dec 04 13:32:32 2005] [error] [client 65.68.235.27] Directory index forbidden by rule: /var/www/html/
320-
[Sun Dec 04 14:29:00 2005] [error] [client 4.245.93.87] Directory index forbidden by rule: /var/www/html/
321-
[Sun Dec 04 15:18:36 2005] [error] [client 67.154.58.130] Directory index forbidden by rule: /var/www/html/
322-
[Sun Dec 04 15:59:01 2005] [error] [client 24.83.37.136] Directory index forbidden by rule: /var/www/html/
323-
[Sun Dec 04 16:24:03 2005] [notice] jk2_init() Found child 1219 in scoreboard slot 6
324-
[Sun Dec 04 16:24:05 2005] [error] [client 58.225.62.140] Directory index forbidden by rule: /var/www/html/
325-
[Sun Dec 04 16:24:06 2005] [notice] workerEnv.init() ok /etc/httpd/conf/workers2.properties
326-
[Sun Dec 04 16:24:06 2005] [error] mod_jk child workerEnv in error state 6
327-
[Sun Dec 04 16:31:07 2005] [notice] jk2_init() Found child 1248 in scoreboard slot 7
328-
</block>
307+
<?xml version="1.0" encoding="UTF-8"?>
308+
<anomalies>
309+
310+
<block lines="581-600" score="0.1746">
311+
[Sun Dec 04 07:18:00 2005] [error] mod_jk child workerEnv in error state 6
312+
[Sun Dec 04 07:18:00 2005] [notice] workerEnv.init() ok /etc/httpd/conf/workers2.properties
313+
[Sun Dec 04 07:18:00 2005] [error] mod_jk child workerEnv in error state 7
314+
[Sun Dec 04 07:45:45 2005] [error] [client 63.13.186.196] Directory index forbidden by rule: /var/www/html/
315+
[Sun Dec 04 08:54:17 2005] [error] [client 147.31.138.75] Directory index forbidden by rule: /var/www/html/
316+
[Sun Dec 04 09:35:12 2005] [error] [client 207.203.80.15] Directory index forbidden by rule: /var/www/html/
317+
[Sun Dec 04 10:53:30 2005] [error] [client 218.76.139.20] Directory index forbidden by rule: /var/www/html/
318+
[Sun Dec 04 11:11:07 2005] [error] [client 24.147.151.74] Directory index forbidden by rule: /var/www/html/
319+
[Sun Dec 04 11:33:18 2005] [error] [client 211.141.93.88] Directory index forbidden by rule: /var/www/html/
320+
[Sun Dec 04 11:42:43 2005] [error] [client 216.127.124.16] Directory index forbidden by rule: /var/www/html/
321+
[Sun Dec 04 12:33:13 2005] [error] [client 208.51.151.210] Directory index forbidden by rule: /var/www/html/
322+
[Sun Dec 04 13:32:32 2005] [error] [client 65.68.235.27] Directory index forbidden by rule: /var/www/html/
323+
[Sun Dec 04 14:29:00 2005] [error] [client 4.245.93.87] Directory index forbidden by rule: /var/www/html/
324+
[Sun Dec 04 15:18:36 2005] [error] [client 67.154.58.130] Directory index forbidden by rule: /var/www/html/
325+
[Sun Dec 04 15:59:01 2005] [error] [client 24.83.37.136] Directory index forbidden by rule: /var/www/html/
326+
[Sun Dec 04 16:24:03 2005] [notice] jk2_init() Found child 1219 in scoreboard slot 6
327+
[Sun Dec 04 16:24:05 2005] [error] [client 58.225.62.140] Directory index forbidden by rule: /var/www/html/
328+
[Sun Dec 04 16:24:06 2005] [notice] workerEnv.init() ok /etc/httpd/conf/workers2.properties
329+
[Sun Dec 04 16:24:06 2005] [error] mod_jk child workerEnv in error state 6
330+
[Sun Dec 04 16:31:07 2005] [notice] jk2_init() Found child 1248 in scoreboard slot 7
331+
</block>
332+
333+
</anomalies>
329334
```
330335

336+
**Output structure:**
337+
- Valid XML with proper declaration and root element
338+
- Pretty-printed with indentation for readability
339+
- XML special characters (`&`, `<`, `>`) are automatically escaped
340+
- Each `<block>` contains metadata and original log content
341+
331342
**Metadata included:**
332343
- **Line range**: References back to original file
333344
- **Anomaly score**: Quantifies unusualness

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "cordon"
3-
version = "0.3.2"
3+
version = "0.3.3"
44
description = "Semantic anomaly detection for system log files"
55
readme = "README.md"
66
requires-python = ">=3.10"

src/cordon/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from cordon.core.types import AnalysisResult, MergedBlock, ScoredWindow, TextWindow
33
from cordon.pipeline import SemanticLogAnalyzer
44

5-
__version__ = "0.3.2"
5+
__version__ = "0.3.3"
66

77
__all__ = [
88
"SemanticLogAnalyzer",

src/cordon/postprocess/formatter.py

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from collections.abc import Sequence
22
from pathlib import Path
3+
from xml.sax.saxutils import escape
34

45
from cordon.core.types import MergedBlock
56

@@ -26,10 +27,10 @@ def format_blocks(self, merged_blocks: Sequence[MergedBlock], original_file: Pat
2627
Formatted string with XML tags and original content
2728
"""
2829
if not merged_blocks:
29-
return ""
30+
return '<?xml version="1.0" encoding="UTF-8"?>\n<anomalies></anomalies>'
3031

3132
# merged blocks are sorted by start_line from the merger
32-
output_parts = []
33+
output_parts = ['<?xml version="1.0" encoding="UTF-8"?>', "<anomalies>", ""]
3334
block_idx = 0
3435
current_line = 1
3536

@@ -51,18 +52,31 @@ def format_blocks(self, merged_blocks: Sequence[MergedBlock], original_file: Pat
5152
content_lines.append(next_line)
5253
current_line += 1
5354

54-
# format the block
55+
# format the block with indentation
5556
tag = (
56-
f'<block lines="{block.start_line}-{block.end_line}" '
57+
f' <block lines="{block.start_line}-{block.end_line}" '
5758
f'score="{block.max_score:.4f}">'
5859
)
5960
content = "".join(content_lines)
60-
output_parts.append(f"{tag}\n{content}</block>")
61+
# Escape XML special characters to ensure valid XML output
62+
escaped_content = escape(content)
63+
64+
# Indent each line of content for pretty printing
65+
# Preserve whitespace-only lines from original content
66+
indented_content = "\n".join(
67+
" " + line if line else line for line in escaped_content.splitlines()
68+
)
69+
70+
output_parts.append(f"{tag}\n{indented_content}\n </block>")
71+
output_parts.append("") # blank line between blocks
6172

6273
# move to next block
6374
block_idx += 1
6475

6576
current_line += 1
6677

67-
# join blocks with double newline separator
68-
return "\n\n".join(output_parts)
78+
# close the root element
79+
output_parts.append("</anomalies>")
80+
81+
# join with newlines
82+
return "\n".join(output_parts)

tests/test_integration.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,9 @@ def test_analyze_empty_log(self) -> None:
5656
assert result.total_windows == 0
5757
assert result.significant_windows == 0
5858
assert result.merged_blocks == 0
59-
assert result.output == ""
59+
assert (
60+
result.output == '<?xml version="1.0" encoding="UTF-8"?>\n<anomalies></anomalies>'
61+
)
6062
finally:
6163
temp_path.unlink()
6264

tests/test_postprocess.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,9 @@ def test_format_single_block(self) -> None:
136136
formatter = OutputFormatter()
137137
output = formatter.format_blocks(blocks, temp_path)
138138

139+
assert '<?xml version="1.0" encoding="UTF-8"?>' in output
140+
assert "<anomalies>" in output
141+
assert "</anomalies>" in output
139142
assert '<block lines="1-2" score="0.8000">' in output
140143
assert "line 1\n" in output
141144
assert "line 2\n" in output
@@ -161,6 +164,9 @@ def test_format_multiple_blocks(self) -> None:
161164
formatter = OutputFormatter()
162165
output = formatter.format_blocks(blocks, temp_path)
163166

167+
assert '<?xml version="1.0" encoding="UTF-8"?>' in output
168+
assert "<anomalies>" in output
169+
assert "</anomalies>" in output
164170
assert '<block lines="1-2" score="0.8000">' in output
165171
assert '<block lines="5-7" score="0.9000">' in output
166172
assert output.count("</block>") == 2
@@ -181,6 +187,40 @@ def test_format_empty_blocks(self) -> None:
181187
formatter = OutputFormatter()
182188
output = formatter.format_blocks(blocks, temp_path)
183189

184-
assert output == ""
190+
assert output == '<?xml version="1.0" encoding="UTF-8"?>\n<anomalies></anomalies>'
191+
finally:
192+
temp_path.unlink()
193+
194+
def test_format_escapes_xml_special_chars(self) -> None:
195+
"""Test that XML special characters are properly escaped."""
196+
with NamedTemporaryFile(mode="w", delete=False, suffix=".log") as f:
197+
f.write("command: test |& tee file.txt\n")
198+
f.write("error: x < y && z > 10\n")
199+
f.write("message: \"quoted\" & 'single'\n")
200+
temp_path = Path(f.name)
201+
202+
try:
203+
from cordon.core.types import MergedBlock
204+
205+
blocks = [MergedBlock(start_line=1, end_line=3, original_windows=(0,), max_score=0.8)]
206+
207+
formatter = OutputFormatter()
208+
output = formatter.format_blocks(blocks, temp_path)
209+
210+
# Verify XML structure
211+
assert '<?xml version="1.0" encoding="UTF-8"?>' in output
212+
assert "<anomalies>" in output
213+
assert "</anomalies>" in output
214+
# Verify XML special characters are escaped (& < > must be escaped in text content)
215+
assert "&amp;" in output
216+
assert "&lt;" in output
217+
assert "&gt;" in output
218+
# Verify the content is properly escaped
219+
assert "command: test |&amp; tee file.txt" in output
220+
assert "error: x &lt; y &amp;&amp; z &gt; 10" in output
221+
# Verify raw special characters are not present where they should be escaped
222+
assert "|& tee" not in output # & should be escaped
223+
assert "x < y" not in output # < should be escaped
224+
assert "z > 10" not in output # > should be escaped
185225
finally:
186226
temp_path.unlink()

0 commit comments

Comments
 (0)