1
0
Fork 0

feat(okf): strict reject-by-default frontmatter parser (T2, TDD, +12)

This commit is contained in:
Kjell Tore Guttormsen 2026-07-06 07:36:14 +02:00
commit 22e65dcec5
2 changed files with 250 additions and 0 deletions

View file

@ -0,0 +1,149 @@
"""OKF adapter — Open Knowledge Format (Google, v0.1) support on top of the core.
Design principle: the format-agnostic core stays ``text -> findings``. This
adapter knows OKF structure (frontmatter, paths, links, ``resource``, bundles)
and feeds scannable text regions into the existing ``sanitize`` / ``scan_output``
/ ``disposition`` machinery. No YAML/format awareness leaks into the core.
T2 — frontmatter parse-safety gate. ``parse_frontmatter`` is a *strict,
reject-by-default* loader for the minimal OKF frontmatter subset: flat
``key: value`` scalars plus block ``- item`` lists. Every construct the
"block anchor/alias DoS + dangerous type coercion" requirement names is refused
*by construction* — you cannot suffer a billion-laughs alias expansion or a
``!!python/object`` coercion if anchors, aliases and explicit tags are rejected
before any value is interpreted. This is the "reject, don't parse-then-sanitize"
philosophy, the frontmatter analogue of the ``resource`` reject-gate (T3).
Deliberately NOT a general YAML parser. A security tool whose thesis is
minimal-dependency should not pull in a full YAML engine whose own features
(anchors, tags, merges) are the attack surface being defended against. Quoted
scalars are kept verbatim (quotes included) rather than unquoted — the value is
still scanned as text downstream, so an injection inside a quoted value is not
lost; richer scalar forms are a future refinement, not a silent parse.
"""
import re
__all__ = ["parse_frontmatter", "OKFError", "OKFFrontmatterError"]
_FENCE = "---"
_KEY_RE = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_-]*$")
# A plain OKF scalar cannot *begin* with a YAML structural indicator. Any value
# starting with one signals an anchor (&), alias (*), explicit tag (!), block
# scalar (|, >), flow collection ([ ] { }), directive (%) or reserved char
# (@ `) — all outside the supported subset and all rejected.
_DANGEROUS_VALUE_STARTS = frozenset("&*!|>[]{}%@`")
class OKFError(Exception):
"""Base class for OKF adapter rejections."""
class OKFFrontmatterError(OKFError):
"""Frontmatter violates the strict, reject-by-default OKF subset."""
def parse_frontmatter(document):
"""Split leading OKF frontmatter from the body and parse it strictly.
Returns ``(frontmatter: dict, body: str)``. A document with no leading
``---`` fence has no frontmatter: ``({}, document)`` is returned unchanged.
Raises ``OKFFrontmatterError`` on an unterminated fence or any construct
outside the minimal flat subset (anchors, aliases, explicit tags, merge
keys, block scalars, flow collections, nested mappings).
"""
lines = document.split("\n")
if not lines or lines[0].strip() != _FENCE:
return {}, document
close_idx = None
for i in range(1, len(lines)):
if lines[i].strip() == _FENCE:
close_idx = i
break
if close_idx is None:
raise OKFFrontmatterError("unterminated frontmatter: no closing '---' fence")
frontmatter = _parse_flat(lines[1:close_idx])
body = "\n".join(lines[close_idx + 1:])
return frontmatter, body
def _parse_flat(fm_lines):
result = {}
i = 0
n = len(fm_lines)
while i < n:
raw = fm_lines[i]
stripped = raw.strip()
if stripped == "" or stripped.startswith("#"):
i += 1
continue
# An indented line with no active list key is a nested structure.
if raw[:1] in (" ", "\t"):
raise OKFFrontmatterError(
"nested mappings are not supported in OKF frontmatter: %r" % raw
)
if stripped.startswith("<<"):
raise OKFFrontmatterError("YAML merge keys are not permitted")
if ":" not in stripped:
raise OKFFrontmatterError("malformed frontmatter line: %r" % raw)
key, _, value = stripped.partition(":")
key = key.strip()
value = value.strip()
if not _KEY_RE.match(key):
raise OKFFrontmatterError("invalid frontmatter key: %r" % key)
if value == "":
items, i = _consume_block_list(fm_lines, i + 1)
result[key] = items if items is not None else ""
continue
_reject_dangerous_value(value)
result[key] = value
i += 1
return result
def _consume_block_list(fm_lines, start):
"""Consume `` - item`` lines following a bare ``key:``.
Returns ``(items, next_index)`` — ``items`` is ``None`` (and ``next_index``
unchanged) when no list item follows, so the caller can treat the key as an
empty scalar and let the next line trip the nested-structure guard.
"""
items = []
i = start
n = len(fm_lines)
while i < n:
raw = fm_lines[i]
stripped = raw.strip()
if stripped == "" or stripped.startswith("#"):
i += 1
continue
if raw[:1] in (" ", "\t") and stripped.startswith("- "):
item = stripped[2:].strip()
_reject_dangerous_value(item)
items.append(item)
i += 1
continue
break
if not items:
return None, start
return items, i
def _reject_dangerous_value(value):
if value and value[0] in _DANGEROUS_VALUE_STARTS:
raise OKFFrontmatterError(
"value begins with a disallowed YAML indicator %r: %r"
% (value[0], value)
)

101
tests/test_okf.py Normal file
View file

@ -0,0 +1,101 @@
"""Tests for the OKF adapter (v0.2 stream 1).
The adapter sits *on top of* the format-agnostic core: the core stays
`text -> findings`; the adapter knows OKF structure and feeds scannable text
regions into the existing machinery. No YAML/format awareness leaks into core.
T2 — frontmatter parse-safety gate. A *strict, reject-by-default* loader for the
minimal OKF frontmatter subset (flat `key: value` scalars + block `- item`
lists). Every construct the "block anchor/alias DoS + dangerous type coercion"
requirement names is a hard reject, by construction — you cannot suffer a
billion-laughs expansion if anchors are refused before parsing.
OKF spec facts used here (verified against okf/SPEC.md, 2026-07-06):
- `type` is the only REQUIRED frontmatter key; `title`/`description`/`resource`/
`tags`/`timestamp` are recommended; producers MAY add arbitrary keys.
- frontmatter is minimal by design — a flat block of scalars plus a `tags` list.
"""
import pytest
from llm_ingestion_guard.okf import parse_frontmatter, OKFFrontmatterError
# --- happy path: split + parse the minimal flat subset -----------------------
def test_splits_frontmatter_from_body():
doc = "---\ntype: table\ntitle: Users\n---\nThe users table body.\n"
frontmatter, body = parse_frontmatter(doc)
assert frontmatter == {"type": "table", "title": "Users"}
assert body == "The users table body.\n"
def test_no_frontmatter_returns_empty_and_full_body():
doc = "Just a body with no frontmatter fence.\n"
frontmatter, body = parse_frontmatter(doc)
assert frontmatter == {}
assert body == doc
def test_parses_block_tags_list():
doc = "---\ntype: table\ntags:\n - pii\n - customers\n---\nbody\n"
frontmatter, body = parse_frontmatter(doc)
assert frontmatter == {"type": "table", "tags": ["pii", "customers"]}
def test_blank_and_comment_lines_are_ignored():
doc = "---\ntype: table\n# a comment\n\ntitle: Users\n---\nbody\n"
frontmatter, _ = parse_frontmatter(doc)
assert frontmatter == {"type": "table", "title": "Users"}
# --- reject-by-default: the dangerous YAML constructs ------------------------
def test_rejects_anchor():
doc = "---\ntype: &a table\n---\nbody\n"
with pytest.raises(OKFFrontmatterError):
parse_frontmatter(doc)
def test_rejects_alias():
doc = "---\ntype: table\ntitle: *a\n---\nbody\n"
with pytest.raises(OKFFrontmatterError):
parse_frontmatter(doc)
def test_rejects_explicit_tag_type_coercion():
# the classic PyYAML RCE shape
doc = "---\ntype: !!python/object/apply:os.system ['id']\n---\nbody\n"
with pytest.raises(OKFFrontmatterError):
parse_frontmatter(doc)
def test_rejects_merge_key():
doc = "---\ntype: table\n<<: *base\n---\nbody\n"
with pytest.raises(OKFFrontmatterError):
parse_frontmatter(doc)
def test_rejects_block_scalar():
doc = "---\ntype: table\ndescription: |\n multi\n line\n---\nbody\n"
with pytest.raises(OKFFrontmatterError):
parse_frontmatter(doc)
def test_rejects_nested_mapping():
doc = "---\ntype: table\nmeta:\n nested: value\n---\nbody\n"
with pytest.raises(OKFFrontmatterError):
parse_frontmatter(doc)
def test_rejects_unterminated_frontmatter():
doc = "---\ntype: table\ntitle: Users\n" # no closing fence
with pytest.raises(OKFFrontmatterError):
parse_frontmatter(doc)
def test_rejects_flow_collection():
# inline flow collections are outside the supported subset -> reject, don't
# silently mis-parse the bracket string as a scalar.
doc = "---\ntype: table\ntags: [pii, customers]\n---\nbody\n"
with pytest.raises(OKFFrontmatterError):
parse_frontmatter(doc)