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=[]).
This commit is contained in:
parent
4d53765c63
commit
0772dafb70
6 changed files with 105 additions and 18 deletions
|
|
@ -13,8 +13,11 @@ Lexikon-seed: `injection-patterns.mjs` fra `llm-security`-pluginen.
|
|||
|
||||
Repoet er på **v0.2 (alpha)**: stdlib-kjernen er bygget og testet (12 moduler +
|
||||
topp-nivå wiring, showcase + korpus), inkl. OKF-adapter og aktivt-innhold-
|
||||
detektor (EchoLeak-klassen) i output-gaten. Start med `docs/BRIEF.md` for design,
|
||||
`README.md` for bruk, `docs/PLAN.md` for byggerekkefølgen.
|
||||
detektor (EchoLeak-klassen) i output-gaten. Mode-b `import_bundle` skanner
|
||||
reserverte strukturfiler (`index.md`/`log.md`) i mottatte bundles i stedet for å
|
||||
path-avvise dem; upload-front-end beholder shadow-reject (`allow_reserved=False`).
|
||||
Start med `docs/BRIEF.md` for design, `README.md` for bruk, `docs/PLAN.md` for
|
||||
byggerekkefølgen.
|
||||
|
||||
## Konvensjoner
|
||||
|
||||
|
|
|
|||
|
|
@ -116,6 +116,12 @@ that a green scan means safe content:
|
|||
the payload is planted later, when that target is written. A scanner that sees
|
||||
one document at a time cannot catch it; it needs cross-write graph re-scan
|
||||
(tracked for the OKF adapter).
|
||||
- **OKF reserved files (`index.md` / `log.md`).** In a *received* bundle these are
|
||||
legitimate structure (directory listing, update log), so mode-b `import_bundle`
|
||||
scans their body and frontmatter — an injection planted in a directory listing
|
||||
is caught — rather than path-rejecting the whole conformant bundle. The upload
|
||||
front-end keeps the opposite rule: an individual upload landing on a reserved
|
||||
basename is a shadow of the listing and is refused (`allow_reserved=False`).
|
||||
- **A document that *describes* attacks is a false positive.** Content whose
|
||||
legitimate purpose is to document prompt-injection payloads (security notes,
|
||||
this project's own corpus) trips carrier-strip / fail-secure. At the text layer
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ def _scannable_regions(frontmatter, body):
|
|||
return regions
|
||||
|
||||
|
||||
def validate_concept_path(path):
|
||||
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``
|
||||
|
|
@ -164,6 +164,13 @@ def validate_concept_path(path):
|
|||
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):
|
||||
|
|
@ -181,7 +188,7 @@ def validate_concept_path(path):
|
|||
raise OKFPathError("malformed path segment in %r" % path)
|
||||
|
||||
basename = segments[-1]
|
||||
if basename.lower() in _RESERVED_BASENAMES:
|
||||
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)
|
||||
|
|
@ -338,7 +345,7 @@ class BundleResult:
|
|||
return "\n".join(lines)
|
||||
|
||||
|
||||
def import_bundle(bundle, *, origin=Origin.EXTERNAL, channel=Channel.AUTOMATIC):
|
||||
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
|
||||
|
|
@ -348,18 +355,27 @@ def import_bundle(bundle, *, origin=Origin.EXTERNAL, channel=Channel.AUTOMATIC):
|
|||
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)
|
||||
_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):
|
||||
def _validate_concept(path, doc, origin, channel, *, allow_reserved=True):
|
||||
try:
|
||||
concept_id = validate_concept_path(path)
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -351,6 +351,12 @@ def receive(
|
|||
REJECT regardless of the guard's aggregate — the guard never saw that drop.
|
||||
"""
|
||||
extracted = extract_inbox(paths, max_entry_bytes=max_entry_bytes, max_total_bytes=max_total_bytes)
|
||||
result = import_bundle(extracted.bundle, origin=Origin.EXTERNAL, channel=Channel.AUTOMATIC)
|
||||
# allow_reserved=False: these are individually-materialized *uploads*, so an
|
||||
# upload landing on the reserved index.md/log.md is a shadow of the directory
|
||||
# listing and is refused (T4). A received third-party bundle, by contrast,
|
||||
# carries those as legitimate structural files (import_bundle's default).
|
||||
result = import_bundle(
|
||||
extracted.bundle, origin=Origin.EXTERNAL, channel=Channel.AUTOMATIC, allow_reserved=False
|
||||
)
|
||||
verdict = "REJECT" if extracted.rejected else _VERDICT[result.disposition]
|
||||
return extracted, result, verdict
|
||||
|
|
|
|||
|
|
@ -333,11 +333,12 @@ def test_import_bundle_all_clean_warns():
|
|||
|
||||
|
||||
def test_import_bundle_iterates_per_concept_not_whole_unit():
|
||||
# a reserved-name concept is rejected, but the good concept is still validated
|
||||
result = import_bundle({"index.md": _CLEAN_A, "tables/users.md": _CLEAN_B})
|
||||
# a hard-rejected concept (path traversal) is FAIL_SECURE, but the good
|
||||
# concept is still validated — iteration does not stop at the first reject.
|
||||
result = import_bundle({"../escape.md": _CLEAN_A, "tables/users.md": _CLEAN_B})
|
||||
by_path = {c.path: c for c in result.concepts}
|
||||
assert by_path["index.md"].disposition is Disposition.FAIL_SECURE
|
||||
assert by_path["index.md"].error is not None
|
||||
assert by_path["../escape.md"].disposition is Disposition.FAIL_SECURE
|
||||
assert by_path["../escape.md"].error is not None
|
||||
assert by_path["tables/users.md"].error is None
|
||||
assert by_path["tables/users.md"].disposition is Disposition.WARN
|
||||
|
||||
|
|
@ -384,6 +385,58 @@ def test_import_bundle_records_origin_channel_on_stamp():
|
|||
assert stamp.channel is Channel.MANUAL
|
||||
|
||||
|
||||
# --- A2: reserved structural files (index.md / log.md) in a received bundle ---
|
||||
# OKF spec §3.1/§6/§7: index.md (directory listing, read FIRST under progressive
|
||||
# disclosure) and log.md (update history) are legitimate structural files a
|
||||
# received bundle MAY carry at any level — not concepts, but attacker-controlled
|
||||
# text. In a mode-b import (the default), import_bundle scans their body (the
|
||||
# highest-priority injection surface) instead of path-rejecting the whole bundle.
|
||||
# The shadow-reject — an *upload* masquerading as index.md — stays in the
|
||||
# front-end/upload context (allow_reserved=False), tested in
|
||||
# test_okf_inbox_uploads.py.
|
||||
|
||||
_CLEAN_INDEX = (
|
||||
"---\ntype: table\ndescription: A directory listing.\n---\nA clean listing body.\n"
|
||||
)
|
||||
_CLEAN_LOG = "---\ntype: table\n---\nA clean change-log entry.\n"
|
||||
|
||||
|
||||
def test_legit_index_and_log_admit():
|
||||
result = import_bundle(
|
||||
{"index.md": _CLEAN_INDEX, "log.md": _CLEAN_LOG, "tables/users.md": _CLEAN_A}
|
||||
)
|
||||
by_path = {c.path: c for c in result.concepts}
|
||||
assert by_path["index.md"].error is None # scanned, not path-rejected
|
||||
assert by_path["log.md"].error is None
|
||||
assert result.disposition is Disposition.WARN # a clean structural bundle admits
|
||||
|
||||
|
||||
def test_injection_in_index_body_is_caught():
|
||||
# The coverage hole A2 closes: index.md's body was never scanned (path-rejected
|
||||
# first). Now an injection planted in the directory listing is caught.
|
||||
poisoned_index = "---\ntype: table\n---\n" + _INJECTION + "\n"
|
||||
result = import_bundle({"index.md": poisoned_index, "tables/users.md": _CLEAN_A})
|
||||
idx = {c.path: c for c in result.concepts}["index.md"]
|
||||
assert idx.error is None # scanned, not path-rejected
|
||||
assert any(f.label == "override:ignore-previous" for f in idx.report.findings)
|
||||
assert result.disposition in (Disposition.QUARANTINE_REVIEW, Disposition.FAIL_SECURE)
|
||||
|
||||
|
||||
def test_index_with_okf_version_frontmatter_admits():
|
||||
# Risk (review): okf_version frontmatter is legal only in the bundle-root
|
||||
# index.md. Scanning its body must parse the frontmatter without the strict
|
||||
# T2 gate tripping on that legitimate key.
|
||||
result = import_bundle(
|
||||
{
|
||||
"index.md": "---\nokf_version: 0.1\n---\n# Concept listing\n",
|
||||
"tables/users.md": "---\ntype: table\n---\nA clean users table.\n",
|
||||
}
|
||||
)
|
||||
by_path = {c.path: c for c in result.concepts}
|
||||
assert by_path["index.md"].error is None
|
||||
assert result.disposition is Disposition.WARN
|
||||
|
||||
|
||||
# --- T5a/A: cross-link extraction, target validation, in-import resolution ----
|
||||
# OKF links are markdown `.md` paths, bundle-absolute (`/x.md`, recommended) or
|
||||
# relative (`./x.md`); verified against SPEC.md. In-import graph only (A); the
|
||||
|
|
|
|||
|
|
@ -3,8 +3,9 @@
|
|||
The OKF analogue of ``tests/test_showcase.py``: one realistic *received external
|
||||
OKF bundle* that plants one attack per OKF surface at once — a body injection, a
|
||||
frontmatter-``description`` injection, a non-``https`` ``resource:``, a
|
||||
path-traversal concept key, a reserved-name shadow, a dangerous frontmatter
|
||||
value, a dangerous-scheme cross-link, a dangling cross-link, and a
|
||||
path-traversal concept key, an injection in a reserved ``index.md`` body, a
|
||||
dangerous frontmatter value, a dangerous-scheme cross-link, a dangling
|
||||
cross-link, and a
|
||||
homoglyph-obfuscated body injection — run through the public ``okf`` surface
|
||||
exactly as an "upload inbox" consumer would compose it. Every planted surface is
|
||||
caught or rejected and the aggregate disposition fails secure.
|
||||
|
|
@ -73,8 +74,10 @@ def _poisoned_bundle() -> dict:
|
|||
"dangerous-frontmatter.md": "---\ntype: &anchor table\n---\nbody\n",
|
||||
# T4 — a path-traversal concept key escapes the bundle root.
|
||||
"../escape.md": "---\ntype: table\n---\nbody\n",
|
||||
# T4 — a reserved basename shadows the directory listing.
|
||||
"index.md": "---\ntype: table\ndescription: Listing.\n---\nbody\n",
|
||||
# A2 — index.md is a legitimate structural file in a *received* bundle
|
||||
# (not path-rejected in mode-b), but its body IS scanned: an injection
|
||||
# planted in the directory listing is caught, not silently admitted.
|
||||
"index.md": "---\ntype: table\ndescription: Listing.\n---\n" + _INJECTION + "\n",
|
||||
# LLM05 — the zero-click EchoLeak primitive: an auto-fetched markdown
|
||||
# image URL that exfiltrates the moment the concept is rendered.
|
||||
"echoleak.md": (
|
||||
|
|
@ -124,7 +127,7 @@ def _surface_checks(result: BundleResult) -> dict:
|
|||
desc = by_path.get("frontmatter-desc.md")
|
||||
return {
|
||||
"T4:path-traversal": rejected("../escape.md"),
|
||||
"T4:reserved-name": rejected("index.md"),
|
||||
"A2:index-body-scanned": caught("index.md", "override:ignore-previous"),
|
||||
"T3:resource": rejected("bad-resource.md"),
|
||||
"T2:frontmatter": rejected("dangerous-frontmatter.md"),
|
||||
"T1:body-injection": caught("body-injection.md", "override:ignore-previous"),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue