Skip to content

Commit b733135

Browse files
Fix parser and export security controls (#115)
Co-authored-by: Johnny Wilson Dougherty <192861341+JohnnyWilson-Portfolio@users.noreply.github.com>
1 parent f43460c commit b733135

14 files changed

Lines changed: 165 additions & 32 deletions

File tree

‎docs/parsers.md‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@ domain-validation are a separate step (`fd.clean`), so parsing and rules stay de
3131
### FHIR R4 JSON
3232

3333
`fd.parse_domain(source, format="fhir")` accepts a **Bundle**, a single resource, a list
34-
of resources, a JSON string, or a file path, and flattens five resource types into frames
35-
whose columns line up with the healthcare validators:
34+
of resources, a JSON string, or a `pathlib.Path`, and flattens five resource types into
35+
frames whose columns line up with the healthcare validators:
3636

3737
```python
3838
result = fd.parse_domain(bundle_json, format="fhir")
@@ -64,9 +64,10 @@ patients = fd.clean_domain_file(
6464
)
6565
```
6666

67-
`fd.parse_domain` accepts a **path, raw text/bytes, or a file-like object**. Malformed
68-
input is recorded in `ParseResult.warnings` rather than raising, so a partial message is
69-
still usable.
67+
`fd.parse_domain` accepts **raw text/bytes, a `pathlib.Path`, or a file-like object**.
68+
String values are treated as raw content; use `clean_domain_file("admit.hl7", ...)` or
69+
pass a `Path` when you want filesystem input. Malformed input is recorded in
70+
`ParseResult.warnings` rather than raising, so a partial message is still usable.
7071

7172
!!! note "Honest scope"
7273
Parsers are structural readers for the common parts of each format — HL7 MSH/PID/PV1/OBX,

‎src/freshdata/api.py‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1012,9 +1012,11 @@ def profile(
10121012
def parse_domain(source: Any, *, format: str) -> ParseResult: # noqa: A002
10131013
"""Parse a raw message or file into DataFrames using the named *format* parser.
10141014
1015-
*source* may be a filesystem path, the raw text/bytes content, or a file-like
1016-
object. *format* is a registered parser name (``"hl7v2"``, ``"gpx"``, ``"sdmx"``,
1017-
``"edifact"`` — see :func:`freshdata.parsers.available`).
1015+
*source* may be raw text/bytes content, a file-like object, or a
1016+
:class:`pathlib.Path` for filesystem input. String values are treated as content;
1017+
use :func:`clean_domain_file` for the convenience file-path workflow. *format* is a
1018+
registered parser name (``"hl7v2"``, ``"gpx"``, ``"sdmx"``, ``"edifact"`` — see
1019+
:func:`freshdata.parsers.available`).
10181020
10191021
Returns a :class:`~freshdata.parsers.ParseResult` carrying the parsed frames,
10201022
a suggested domain, metadata, and any audit warnings.
@@ -1044,7 +1046,8 @@ def clean_domain_file(
10441046
through :func:`clean` with *domain* and any extra cleaning keyword arguments. When a
10451047
parser yields several non-empty frames, pass ``frame=`` to pick one.
10461048
"""
1047-
result = parse_domain(path, format=format)
1049+
source = Path(path) if isinstance(path, str) and Path(path).exists() else path
1050+
result = parse_domain(source, format=format)
10481051
if domain is None:
10491052
# suggested_domain is advisory metadata, not an instruction to clean.
10501053
return result

‎src/freshdata/integrations/dbt/__init__.py‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
import logging
2121
import os
2222
from dataclasses import dataclass
23-
from pathlib import Path
23+
from pathlib import Path, PurePath
2424
from typing import TYPE_CHECKING, Any
2525

2626
from .._core import OnLowScore, TrustGateError, TrustGateResult, evaluate_trust_gate
@@ -41,6 +41,20 @@
4141
)
4242

4343

44+
def _validate_audit_table_name(table: str) -> str:
45+
path = PurePath(table)
46+
if (
47+
not table
48+
or table in {".", ".."}
49+
or path.is_absolute()
50+
or path.name != table
51+
or "/" in table
52+
or "\\" in table
53+
):
54+
raise ValueError(f"{table!r} is not a safe dbt model name for audit output")
55+
return table
56+
57+
4458
def _read_table(conn_str: str, schema: str | None, table: str) -> pd.DataFrame:
4559
"""Read ``schema.table`` from ``conn_str`` into a DataFrame."""
4660
import pandas as pd
@@ -89,6 +103,7 @@ def _split_table(self) -> tuple[str | None, str]:
89103
return None, self.model_name
90104

91105
def _write_audit(self, table: str, result: TrustGateResult) -> Path:
106+
table = _validate_audit_table_name(table)
92107
out_dir = Path(self.output_dir) # type: ignore[arg-type]
93108
out_dir.mkdir(parents=True, exist_ok=True)
94109
path = out_dir / f"{table}_audit.json"
@@ -103,6 +118,8 @@ def run(self) -> TrustGateResult:
103118
"No warehouse connection: pass conn_str or set FRESHDATA_WAREHOUSE_CONN."
104119
)
105120
schema, table = self._split_table()
121+
if self.output_dir:
122+
_validate_audit_table_name(table)
106123
df = _read_table(conn, schema, table)
107124
_, result = evaluate_trust_gate(
108125
df,

‎src/freshdata/integrations/exceptions.py‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,14 @@ def _infer_format(path: str) -> str:
138138
return "csv"
139139

140140

141+
def _quote_duckdb_identifier(identifier: str) -> str:
142+
if not isinstance(identifier, str) or not identifier:
143+
raise ValueError("DuckDB table_name must be a non-empty string")
144+
if "\x00" in identifier:
145+
raise ValueError("DuckDB table_name must not contain NUL bytes")
146+
return f'"{identifier.replace(chr(34), chr(34) * 2)}"'
147+
148+
141149
def write_exception_table(
142150
table: pd.DataFrame,
143151
path: str,
@@ -175,7 +183,7 @@ def write_exception_table(
175183
try:
176184
con.register("_freshdata_exceptions", table)
177185
con.execute(
178-
f'CREATE OR REPLACE TABLE "{table_name}" AS '
186+
f"CREATE OR REPLACE TABLE {_quote_duckdb_identifier(table_name)} AS "
179187
"SELECT * FROM _freshdata_exceptions"
180188
)
181189
finally:

‎src/freshdata/parsers/base.py‎

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,15 @@
1010
from __future__ import annotations
1111

1212
import io
13-
import os
1413
from abc import ABC, abstractmethod
1514
from dataclasses import dataclass, field
1615
from pathlib import Path
1716
from typing import Any
1817

1918
import pandas as pd
2019

20+
_MAX_XML_BYTES = 10 * 1024 * 1024
21+
2122

2223
@dataclass
2324
class ParseResult:
@@ -76,10 +77,10 @@ class Parser(ABC):
7677

7778
@abstractmethod
7879
def parse(self, source: Any) -> ParseResult:
79-
"""Parse *source* (path, text, bytes, or file-like) into a :class:`ParseResult`."""
80+
"""Parse *source* (Path, text, bytes, or file-like) into a :class:`ParseResult`."""
8081

8182
def read_text(self, source: Any, *, encoding: str = "utf-8") -> str:
82-
"""Read *source* into text, accepting a path, str content, bytes, or file-like."""
83+
"""Read *source* into text, accepting a Path, str content, bytes, or file-like."""
8384
if isinstance(source, (bytes, bytearray)):
8485
return bytes(source).decode(encoding)
8586
if hasattr(source, "read"):
@@ -88,11 +89,6 @@ def read_text(self, source: Any, *, encoding: str = "utf-8") -> str:
8889
if isinstance(source, Path):
8990
return source.read_text(encoding=encoding)
9091
if isinstance(source, str):
91-
# A short string that names an existing file is treated as a path;
92-
# otherwise it is treated as the content itself.
93-
if (len(source) < 4096 and "\n" not in source and "\r" not in source
94-
and os.path.exists(source)):
95-
return Path(source).read_text(encoding=encoding)
9692
return source
9793
raise TypeError(f"cannot read a {type(source).__name__} source")
9894

@@ -104,8 +100,29 @@ def open_binary(self, source: Any) -> io.BufferedIOBase | io.BytesIO:
104100
data = source.read()
105101
return io.BytesIO(data if isinstance(data, (bytes, bytearray))
106102
else str(data).encode("utf-8"))
107-
if isinstance(source, (str, Path)) and os.path.exists(str(source)):
103+
if isinstance(source, Path):
108104
return open(source, "rb") # noqa: SIM115 - caller consumes immediately
109-
if isinstance(source, (str, Path)):
105+
if isinstance(source, str):
110106
return io.BytesIO(str(source).encode("utf-8"))
111107
raise TypeError(f"cannot open a {type(source).__name__} source")
108+
109+
def open_safe_xml_binary(
110+
self,
111+
source: Any,
112+
*,
113+
max_bytes: int = _MAX_XML_BYTES,
114+
) -> io.BytesIO:
115+
"""Return bounded XML bytes with DTD/entity declarations rejected."""
116+
stream = self.open_binary(source)
117+
try:
118+
data = stream.read(max_bytes + 1)
119+
finally:
120+
if hasattr(stream, "close"):
121+
stream.close()
122+
123+
if len(data) > max_bytes:
124+
raise ValueError(f"XML input exceeds {max_bytes} bytes")
125+
lowered = data.lower()
126+
if b"<!doctype" in lowered or b"<!entity" in lowered:
127+
raise ValueError("XML DTD/entity declarations are not allowed")
128+
return io.BytesIO(data)

‎src/freshdata/parsers/fhir.py‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
"""FHIR R4 JSON parser.
22
33
Parses a FHIR R4 Bundle, a single resource, a list of resources, a JSON string, or a
4-
file path into flattened DataFrames keyed by resource: ``patient``, ``observation``,
5-
``encounter``, ``condition``, ``medication_request``. The flattened columns line up with
6-
the healthcare domain pack's resource validators, so a parsed frame can go straight into
7-
``fd.clean(frame, domain="healthcare")``.
4+
``pathlib.Path`` into flattened DataFrames keyed by resource: ``patient``,
5+
``observation``, ``encounter``, ``condition``, ``medication_request``. The flattened
6+
columns line up with the healthcare domain pack's resource validators, so a parsed frame
7+
can go straight into ``fd.clean(frame, domain="healthcare")``.
88
99
Only predictable R4 fields are flattened; resource types the parser does not handle are
1010
counted and surfaced in :attr:`ParseResult.warnings` rather than dropped silently.
@@ -195,7 +195,7 @@ def parse(self, source: Any) -> ParseResult:
195195
)
196196

197197
def _load_json(self, source: Any) -> Any:
198-
"""Return parsed JSON, accepting a dict/list directly or a path/text/bytes."""
198+
"""Return parsed JSON, accepting a dict/list directly or a Path/text/bytes."""
199199
if isinstance(source, (dict, list)):
200200
return source
201201
return json.loads(self.read_text(source))

‎src/freshdata/parsers/gpx.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,18 @@ def parse(self, source: Any) -> ParseResult:
3434
warnings: list[str] = []
3535
rows: dict[str, list[dict[str, Any]]] = {v: [] for v in _POINT_KIND.values()}
3636

37-
stream = self.open_binary(source)
37+
stream = None
3838
try:
39+
stream = self.open_safe_xml_binary(source)
3940
root = ET.parse(stream).getroot() # noqa: S314 - GPX files are local, trusted
41+
except ValueError as exc:
42+
return ParseResult(self.format, {v: pd.DataFrame() for v in _POINT_KIND.values()},
43+
self.suggested_domain, {}, [f"unsafe GPX XML: {exc}"])
4044
except ET.ParseError as exc:
4145
return ParseResult(self.format, {v: pd.DataFrame() for v in _POINT_KIND.values()},
4246
self.suggested_domain, {}, [f"invalid GPX XML: {exc}"])
4347
finally:
44-
if hasattr(stream, "close"):
48+
if stream is not None and hasattr(stream, "close"):
4549
stream.close()
4650

4751
def _point(elem: ET.Element, kind: str, **extra: Any) -> None:

‎src/freshdata/parsers/sdmx.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,18 @@ class SDMXParser(Parser):
3333

3434
def parse(self, source: Any) -> ParseResult:
3535
warnings: list[str] = []
36-
stream = self.open_binary(source)
36+
stream = None
3737
try:
38+
stream = self.open_safe_xml_binary(source)
3839
root = ET.parse(stream).getroot() # noqa: S314 - local trusted SDMX files
40+
except ValueError as exc:
41+
return ParseResult(self.format, {"observations": pd.DataFrame()},
42+
None, {}, [f"unsafe SDMX XML: {exc} (audit only)"])
3943
except ET.ParseError as exc:
4044
return ParseResult(self.format, {"observations": pd.DataFrame()},
4145
None, {}, [f"invalid SDMX XML: {exc} (audit only)"])
4246
finally:
43-
if hasattr(stream, "close"):
47+
if stream is not None and hasattr(stream, "close"):
4448
stream.close()
4549

4650
rows: list[dict[str, Any]] = []

‎tests/parsers/test_fhir.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ def test_json_string_and_path_inputs(tmp_path):
124124
assert fd.parse_domain(text, format="fhir").metadata["total_resources"] == 6
125125
p = tmp_path / "bundle.json"
126126
p.write_text(text)
127-
assert fd.parse_domain(str(p), format="fhir").metadata["total_resources"] == 6
127+
assert fd.parse_domain(p, format="fhir").metadata["total_resources"] == 6
128128

129129

130130
def test_invalid_json_warns_not_raises():

‎tests/parsers/test_gpx.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,17 @@ def test_malformed_xml_returns_warning_not_exception():
6060
assert any("invalid GPX XML" in w for w in result.warnings)
6161

6262

63+
def test_doctype_entities_are_rejected():
64+
entity_gpx = """<!DOCTYPE gpx [<!ENTITY x "expanded">]>
65+
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1">
66+
<wpt lat="40.0" lon="-73.0"><name>&x;</name></wpt>
67+
</gpx>"""
68+
result = fd.parse_domain(entity_gpx, format="gpx")
69+
70+
assert all(df.empty for df in result.frames.values())
71+
assert any("unsafe GPX XML" in w for w in result.warnings)
72+
73+
6374
def test_empty_gpx_warns():
6475
result = fd.parse_domain('<gpx xmlns="http://www.topografix.com/GPX/1/1"/>', format="gpx")
6576
assert any("no waypoints" in w for w in result.warnings)

0 commit comments

Comments
 (0)