1
0
Fork 0
llm-ingestion-pipeline-secu.../src/llm_ingestion_guard/okf.py
Kjell Tore Guttormsen 0772dafb70 feat(okf): scan reserved index.md/log.md in mode-b import, not path-reject (review MAJOR #2)
A received OKF bundle MAY legitimately carry index.md (directory listing, read
first under progressive disclosure) and log.md (update history) at any level
(spec §3.1/§6/§7). import_bundle previously hard-rejected those basenames in the
T4 path gate, so a conformant third-party bundle was over-blocked in full
(FAIL_SECURE) — and because the reject fired before scan_concept, index.md's
body (the highest-priority injection surface) was never scanned.

import_bundle now defaults allow_reserved=True: reserved basenames are scanned
as structural files (path-safety checks — traversal / absolute / backslash / .md
— still apply). The shadow-reject (an *upload* masquerading as index.md) is
preserved: the front-end passes allow_reserved=False so a materialized upload
landing on a reserved basename is still refused. That front-end opt-in was
required to keep the shadow-reject once the default flipped (not in the plan's
Filer set; traced from the code).

- okf.py: validate_concept_path/_validate_concept/import_bundle gain the
  keyword; validate_concept_path default stays False (strict standalone).
- tests: +3 (legit index/log admit; injection in index.md body caught;
  okf_version frontmatter admits). Per-concept-iteration test switched to a
  traversal vector; mode-b showcase's index.md surface reframed from
  reserved-name-reject to index.md-body-scan.
- README honest-limits + CLAUDE.md context note the mode-b/upload distinction.

Suite: 341 -> 344 passed. Core invariant intact (dependencies=[]).
2026-07-15 06:43:50 +02:00

595 lines
22 KiB
Python

"""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
from dataclasses import dataclass
from enum import Enum
from .output import scan_output
from .report import Report, Source
from .disposition import Trust, Disposition, Policy, decide
__all__ = [
"parse_frontmatter",
"scan_concept",
"validate_concept_path",
"validate_resource_url",
"trust_for",
"stamp_concept",
"format_log_entry",
"import_bundle",
"extract_link_targets",
"resolve_link",
"link_graph",
"Origin",
"Channel",
"ProvenanceStamp",
"ConceptResult",
"BundleResult",
"LinkGraphResult",
"OKFError",
"OKFFrontmatterError",
"OKFPathError",
"OKFResourceError",
"OKFLinkError",
]
_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."""
class OKFPathError(OKFError):
"""A concept path is unsafe (traversal, absolute, or reserved-name shadow)."""
class OKFResourceError(OKFError):
"""A ``resource`` URL is not on the https allowlist."""
class OKFLinkError(OKFError):
"""A cross-link target is unsafe (dangerous scheme or bundle escape)."""
_URL_SCHEME_RE = re.compile(r"^([A-Za-z][A-Za-z0-9+.\-]*):")
# `index.md` (directory listing) and `log.md` (update history) are reserved by
# the OKF spec and MUST NOT name concept documents — at any directory level.
_RESERVED_BASENAMES = frozenset({"index.md", "log.md"})
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 scan_concept(document, *, source=Source.OUTPUT):
"""Scan every scannable region of one OKF concept, merged into one Report.
T1 — whole-concept scan surface. The body is not the only injectable region:
OKF frontmatter *values* (notably ``description``, which propagates into
``index.md`` and is read first under progressive disclosure), ``tags`` items
and the ``resource`` string are all attacker-controlled and must go through
the same ``scan_output`` path as the body. Findings from all regions are
merged so nothing in the frontmatter escapes the gate.
Frontmatter is parsed with the strict :func:`parse_frontmatter` gate first,
so a parse-safety violation (T2) raises before any scanning.
"""
frontmatter, body = parse_frontmatter(document)
report = Report()
for region in _scannable_regions(frontmatter, body):
report.extend(scan_output(region, source=source).findings)
return report
def _scannable_regions(frontmatter, body):
"""The text regions of a concept that carry attacker-controlled content."""
regions = [body]
for value in frontmatter.values():
if isinstance(value, list):
regions.extend(value)
elif value:
regions.append(value)
return regions
def validate_concept_path(path, *, allow_reserved=False):
"""Validate a bundle-relative concept path and return its concept-ID.
T4 — path / reserved-name gate. The concept-ID is the path with the ``.md``
suffix removed (OKF spec). Rejects, before the path is ever used to write:
- ``..`` traversal at any segment (escape the bundle);
- absolute paths (``/...``) and backslashes (platform-separator ambiguity);
- the reserved basenames ``index.md`` / ``log.md`` (shadow the directory
listing / update log), case-insensitively — a case-insensitive filesystem
lets ``Index.md`` shadow ``index.md``;
- non-``.md`` files (not a concept document).
``allow_reserved`` (default ``False``) keeps this a strict concept-path
validator: a reserved basename is not a concept and is rejected. A mode-b
bundle import passes ``allow_reserved=True`` because a *received* bundle MAY
legitimately carry ``index.md`` / ``log.md`` as structural files — the caller
then scans their body rather than persisting them as concepts. The path-safety
checks (traversal / absolute / backslash / ``.md``) still apply either way.
Raises :class:`OKFPathError` on any of these; returns the concept-ID string.
"""
if not path or not isinstance(path, str):
raise OKFPathError("empty or non-string concept path: %r" % (path,))
if path.startswith("/"):
raise OKFPathError("concept path must be bundle-relative, not absolute: %r" % path)
if "\\" in path:
raise OKFPathError("backslashes are not permitted in a concept path: %r" % path)
segments = path.split("/")
for seg in segments:
if seg == "..":
raise OKFPathError("path traversal ('..') is not permitted: %r" % path)
if seg == "" or seg == ".":
raise OKFPathError("malformed path segment in %r" % path)
basename = segments[-1]
if not allow_reserved and basename.lower() in _RESERVED_BASENAMES:
raise OKFPathError("reserved filename may not name a concept: %r" % basename)
if not basename.lower().endswith(".md"):
raise OKFPathError("a concept document must be a .md file: %r" % path)
return path[: -len(".md")]
def validate_resource_url(url):
"""Validate a concept's ``resource`` URL against the https allowlist (T3).
The OKF format places no constraint on the ``resource`` scheme (verified
against SPEC.md), so this default-deny allowlist is the only gate: it
**rejects** anything that is not ``https`` — ``http``, ``data:``,
``javascript:``, ``file:``, ``blob:``, ``ftp:`` and schemeless/relative
strings — *before commit*. This is reject, not defang: ``neutralize`` renders
dangerous schemes inert for human audit; this refuses to persist them at all.
Returns ``url`` unchanged on success; raises :class:`OKFResourceError`
otherwise.
"""
if not url or not isinstance(url, str):
raise OKFResourceError("empty or non-string resource URL: %r" % (url,))
stripped = url.strip()
if " " in stripped or any(ord(c) < 0x20 for c in stripped):
raise OKFResourceError("resource URL contains whitespace/control chars: %r" % url)
match = _URL_SCHEME_RE.match(stripped)
scheme = match.group(1).lower() if match else None
if scheme != "https":
raise OKFResourceError(
"resource URL must use the https scheme (got %r): %r" % (scheme, url)
)
return url
class Origin(str, Enum):
"""Where the data actually came from (brief §5) — drives trust."""
EXTERNAL = "external"
INTERNAL = "internal"
class Channel(str, Enum):
"""How it was inserted — recorded for the log, but never upgrades trust."""
AUTOMATIC = "automatic"
MANUAL = "manual"
@dataclass(frozen=True)
class ProvenanceStamp:
"""A per-concept provenance record for ``log.md`` (brief §6 T6).
Composes ``Origin`` x ``Channel`` x ``Trust`` x ``Disposition`` — it adds no
new disposition value (brief §8 naming caveat); the disposition is whatever
:func:`decide` returns for the concept's scan under its origin-derived trust.
"""
concept_id: str
origin: Origin
channel: Channel
trust: Trust
disposition: Disposition
def trust_for(origin, channel=None):
"""Map a concept's origin to a :class:`Trust` tier (brief §5).
Trust follows the *origin*, never the insertion *channel*: a manual paste of
external material is still external. The channel is recorded on the stamp for
the audit log but grants no trust discount.
"""
return Trust.TRUSTED if origin is Origin.INTERNAL else Trust.UNTRUSTED
def stamp_concept(concept_id, report, origin, channel):
"""Stamp one scanned concept with its provenance and disposition (T6).
``report`` is the concept's scan (e.g. from :func:`scan_concept`); the
disposition is decided under a policy at the origin-derived trust tier.
"""
trust = trust_for(origin, channel)
decision = decide(report, Policy(trust=trust))
return ProvenanceStamp(concept_id, origin, channel, trust, decision.disposition)
def format_log_entry(stamp, *, timestamp=None):
"""Render a :class:`ProvenanceStamp` as one tab-separated ``log.md`` line.
``timestamp`` is caller-supplied (kept out of the stamp so stamping stays
deterministic and wall-clock-free); when given it is prepended.
"""
fields = [
stamp.concept_id,
stamp.origin.value,
stamp.channel.value,
stamp.trust.value,
stamp.disposition.value,
]
if timestamp is not None:
fields.insert(0, timestamp)
return "\t".join(fields)
# --- T7: bundle-import iterator (mode b) -------------------------------------
# WARN < QUARANTINE_REVIEW < FAIL_SECURE — the aggregate is the most severe.
_DISPOSITION_ORDER = (
Disposition.WARN,
Disposition.QUARANTINE_REVIEW,
Disposition.FAIL_SECURE,
)
@dataclass(frozen=True)
class ConceptResult:
"""The outcome of validating one concept in a bundle.
``error`` is ``None`` on a concept that passed the gates (and then carries a
``stamp``); a non-``None`` ``error`` means a hard reject (bad path, unsafe
frontmatter, or a non-https ``resource``) — ``disposition`` is FAIL_SECURE
and no stamp is produced, so the concept must not be merged.
"""
path: str
concept_id: str | None
disposition: Disposition
stamp: ProvenanceStamp | None
report: Report
error: str | None
@dataclass(frozen=True)
class BundleResult:
"""Per-concept results, the aggregate disposition, and the cross-link graph.
``links`` is the in-import :class:`LinkGraphResult` for the whole bundle
(dangling / rejected / resolved edges), so a mode-b import returns both halves
of the gate together. Whether a dangling or rejected link should block is the
caller's disposition call (design principle 4).
"""
concepts: tuple
disposition: Disposition
links: "LinkGraphResult"
def log(self):
"""The ``log.md`` body — one line per concept, rejected ones marked."""
lines = []
for c in self.concepts:
if c.stamp is not None:
lines.append(format_log_entry(c.stamp))
else:
lines.append("\t".join([c.path, "REJECTED", c.disposition.value, c.error or ""]))
return "\n".join(lines)
def import_bundle(bundle, *, origin=Origin.EXTERNAL, channel=Channel.AUTOMATIC, allow_reserved=True):
"""Validate a received OKF bundle concept-by-concept before merge (mode b).
``bundle`` maps concept path (e.g. ``tables/users.md``) to its raw document
text. Each concept runs the full per-concept gate — path/reserved-name (T4),
frontmatter parse-safety (T2), ``resource`` allowlist (T3), whole-concept
scan (T1) and provenance stamping (T6). A concept that fails a hard gate is
rejected (FAIL_SECURE) and recorded, but iteration continues, so the caller
sees every issue in the bundle, not only the first. The bundle disposition is
the most severe across its concepts.
``allow_reserved`` (default ``True``) reflects that this is the mode-b
*received-bundle* path: ``index.md`` / ``log.md`` are legitimate structural
files (OKF spec §3.1/§6/§7) that MAY appear at any level, so they are scanned
(their body is the highest-priority injection surface) rather than
path-rejected — over-blocking a conformant third-party bundle is itself a
failure mode (brief principle 5). A front-end materialising individual
*uploads* passes ``allow_reserved=False``: there a reserved basename is a
shadow of the directory listing and must be refused.
"""
results = tuple(
_validate_concept(path, bundle[path], origin, channel, allow_reserved=allow_reserved)
for path in sorted(bundle)
)
aggregate = _most_severe(r.disposition for r in results)
return BundleResult(results, aggregate, link_graph(bundle))
def _validate_concept(path, doc, origin, channel, *, allow_reserved=True):
try:
concept_id = validate_concept_path(path, allow_reserved=allow_reserved)
except OKFPathError as exc:
return ConceptResult(path, None, Disposition.FAIL_SECURE, None, Report(), str(exc))
try:
frontmatter, _body = parse_frontmatter(doc)
except OKFFrontmatterError as exc:
return ConceptResult(path, concept_id, Disposition.FAIL_SECURE, None, Report(), str(exc))
resource = frontmatter.get("resource")
if isinstance(resource, str):
try:
validate_resource_url(resource)
except OKFResourceError as exc:
return ConceptResult(path, concept_id, Disposition.FAIL_SECURE, None, Report(), str(exc))
report = scan_concept(doc)
stamp = stamp_concept(concept_id, report, origin, channel)
return ConceptResult(path, concept_id, stamp.disposition, stamp, report, None)
def _most_severe(dispositions):
worst = Disposition.WARN
for disposition in dispositions:
if _DISPOSITION_ORDER.index(disposition) > _DISPOSITION_ORDER.index(worst):
worst = disposition
return worst
# --- T5a / A: cross-link graph (in-import) -----------------------------------
# The persisted cross-run graph (B) that would catch "plant a link now, write
# the poisoned target in a LATER run" (§7.2) is deferred to stream 2, where the
# consumer that owns the corpus decides where the durable graph state lives.
# This in-import graph resolves links within a single bundle merge.
_MD_LINK_RE = re.compile(r"\[[^\]]*\]\(\s*([^)\s]+)")
# Active-content schemes are refused in a link, mirroring the resource gate (T3).
_DANGEROUS_LINK_SCHEMES = frozenset({"javascript", "data", "vbscript", "file", "blob"})
@dataclass(frozen=True)
class LinkGraphResult:
"""In-import cross-link resolution over one bundle.
``dangling`` — ``(from_id, target_concept_id)`` for in-bundle ``.md`` links
whose target concept is **not present** in the bundle: the dormant-injection
signal of §7.2 (a link planted to a not-yet-written concept). ``rejected`` —
``(from_id, target, reason)`` for links refused outright (dangerous scheme or
bundle escape). ``resolved`` — ``(from_id, target_concept_id)`` for links to
concepts present in the bundle.
"""
dangling: tuple
rejected: tuple
resolved: tuple
def extract_link_targets(body):
"""Return the destinations of markdown ``[text](target)`` links in ``body``."""
return _MD_LINK_RE.findall(body)
def resolve_link(target, from_concept_id):
"""Resolve one link target to an in-bundle concept-ID, or reject it (T5a).
Returns the target concept-ID for an in-bundle ``.md`` link (bundle-absolute
``/x.md`` or relative ``./x.md`` / ``../y.md``, resolved against the linking
concept's directory). Returns ``None`` for an external ``http(s)``/other
non-active link (not a concept edge) and for non-``.md`` targets. Raises
:class:`OKFLinkError` for an active-content scheme or a ``..`` escape past the
bundle root.
"""
candidate = target.strip().split("#", 1)[0].split("?", 1)[0]
if not candidate:
return None
scheme_match = _URL_SCHEME_RE.match(candidate)
if scheme_match:
scheme = scheme_match.group(1).lower()
if scheme in _DANGEROUS_LINK_SCHEMES:
raise OKFLinkError("link uses a dangerous scheme %r: %r" % (scheme, target))
return None # external (http/https/mailto/…): not an in-bundle concept edge
if not candidate.endswith(".md"):
return None # not a concept-document link (asset, anchor, …)
if candidate.startswith("/"):
normalized = _normalize_bundle_path(candidate[1:])
else:
from_dir = from_concept_id.rsplit("/", 1)[0] if "/" in from_concept_id else ""
joined = from_dir + "/" + candidate if from_dir else candidate
normalized = _normalize_bundle_path(joined)
return normalized[: -len(".md")]
def link_graph(bundle):
"""Resolve every cross-link in ``bundle`` against the concepts it contains.
``bundle`` maps concept path to document text (as :func:`import_bundle`). Only
the body is scanned for links. See :class:`LinkGraphResult` for the outcome.
"""
present = {p[: -len(".md")] for p in bundle if p.endswith(".md")}
dangling, rejected, resolved = [], [], []
for path in sorted(bundle):
if not path.endswith(".md"):
continue
from_id = path[: -len(".md")]
try:
_frontmatter, body = parse_frontmatter(bundle[path])
except OKFFrontmatterError:
body = bundle[path] # unparseable frontmatter is T2's reject, not ours
for target in extract_link_targets(body):
try:
concept_id = resolve_link(target, from_id)
except OKFLinkError as exc:
rejected.append((from_id, target, str(exc)))
continue
if concept_id is None:
continue
if concept_id in present:
resolved.append((from_id, concept_id))
else:
dangling.append((from_id, concept_id))
return LinkGraphResult(tuple(dangling), tuple(rejected), tuple(resolved))
def _normalize_bundle_path(path):
"""Normalize a ``/``-separated bundle path; raise if it escapes the root."""
parts = []
for segment in path.split("/"):
if segment in ("", "."):
continue
if segment == "..":
if not parts:
raise OKFLinkError("link target escapes the bundle root: %r" % path)
parts.pop()
else:
parts.append(segment)
return "/".join(parts)
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)
)