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 +
|
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-
|
topp-nivå wiring, showcase + korpus), inkl. OKF-adapter og aktivt-innhold-
|
||||||
detektor (EchoLeak-klassen) i output-gaten. Start med `docs/BRIEF.md` for design,
|
detektor (EchoLeak-klassen) i output-gaten. Mode-b `import_bundle` skanner
|
||||||
`README.md` for bruk, `docs/PLAN.md` for byggerekkefølgen.
|
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
|
## 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
|
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
|
one document at a time cannot catch it; it needs cross-write graph re-scan
|
||||||
(tracked for the OKF adapter).
|
(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
|
- **A document that *describes* attacks is a false positive.** Content whose
|
||||||
legitimate purpose is to document prompt-injection payloads (security notes,
|
legitimate purpose is to document prompt-injection payloads (security notes,
|
||||||
this project's own corpus) trips carrier-strip / fail-secure. At the text layer
|
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
|
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.
|
"""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``
|
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``;
|
lets ``Index.md`` shadow ``index.md``;
|
||||||
- non-``.md`` files (not a concept document).
|
- 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.
|
Raises :class:`OKFPathError` on any of these; returns the concept-ID string.
|
||||||
"""
|
"""
|
||||||
if not path or not isinstance(path, str):
|
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)
|
raise OKFPathError("malformed path segment in %r" % path)
|
||||||
|
|
||||||
basename = segments[-1]
|
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)
|
raise OKFPathError("reserved filename may not name a concept: %r" % basename)
|
||||||
if not basename.lower().endswith(".md"):
|
if not basename.lower().endswith(".md"):
|
||||||
raise OKFPathError("a concept document must be a .md file: %r" % path)
|
raise OKFPathError("a concept document must be a .md file: %r" % path)
|
||||||
|
|
@ -338,7 +345,7 @@ class BundleResult:
|
||||||
return "\n".join(lines)
|
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).
|
"""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
|
``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
|
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
|
sees every issue in the bundle, not only the first. The bundle disposition is
|
||||||
the most severe across its concepts.
|
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(
|
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)
|
for path in sorted(bundle)
|
||||||
)
|
)
|
||||||
aggregate = _most_severe(r.disposition for r in results)
|
aggregate = _most_severe(r.disposition for r in results)
|
||||||
return BundleResult(results, aggregate, link_graph(bundle))
|
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:
|
try:
|
||||||
concept_id = validate_concept_path(path)
|
concept_id = validate_concept_path(path, allow_reserved=allow_reserved)
|
||||||
except OKFPathError as exc:
|
except OKFPathError as exc:
|
||||||
return ConceptResult(path, None, Disposition.FAIL_SECURE, None, Report(), str(exc))
|
return ConceptResult(path, None, Disposition.FAIL_SECURE, None, Report(), str(exc))
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -351,6 +351,12 @@ def receive(
|
||||||
REJECT regardless of the guard's aggregate — the guard never saw that drop.
|
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)
|
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]
|
verdict = "REJECT" if extracted.rejected else _VERDICT[result.disposition]
|
||||||
return extracted, result, verdict
|
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():
|
def test_import_bundle_iterates_per_concept_not_whole_unit():
|
||||||
# a reserved-name concept is rejected, but the good concept is still validated
|
# a hard-rejected concept (path traversal) is FAIL_SECURE, but the good
|
||||||
result = import_bundle({"index.md": _CLEAN_A, "tables/users.md": _CLEAN_B})
|
# 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}
|
by_path = {c.path: c for c in result.concepts}
|
||||||
assert by_path["index.md"].disposition is Disposition.FAIL_SECURE
|
assert by_path["../escape.md"].disposition is Disposition.FAIL_SECURE
|
||||||
assert by_path["index.md"].error is not None
|
assert by_path["../escape.md"].error is not None
|
||||||
assert by_path["tables/users.md"].error is None
|
assert by_path["tables/users.md"].error is None
|
||||||
assert by_path["tables/users.md"].disposition is Disposition.WARN
|
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
|
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 ----
|
# --- T5a/A: cross-link extraction, target validation, in-import resolution ----
|
||||||
# OKF links are markdown `.md` paths, bundle-absolute (`/x.md`, recommended) or
|
# 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
|
# 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
|
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
|
OKF bundle* that plants one attack per OKF surface at once — a body injection, a
|
||||||
frontmatter-``description`` injection, a non-``https`` ``resource:``, a
|
frontmatter-``description`` injection, a non-``https`` ``resource:``, a
|
||||||
path-traversal concept key, a reserved-name shadow, a dangerous frontmatter
|
path-traversal concept key, an injection in a reserved ``index.md`` body, a
|
||||||
value, a dangerous-scheme cross-link, a dangling cross-link, and 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
|
homoglyph-obfuscated body injection — run through the public ``okf`` surface
|
||||||
exactly as an "upload inbox" consumer would compose it. Every planted surface is
|
exactly as an "upload inbox" consumer would compose it. Every planted surface is
|
||||||
caught or rejected and the aggregate disposition fails secure.
|
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",
|
"dangerous-frontmatter.md": "---\ntype: &anchor table\n---\nbody\n",
|
||||||
# T4 — a path-traversal concept key escapes the bundle root.
|
# T4 — a path-traversal concept key escapes the bundle root.
|
||||||
"../escape.md": "---\ntype: table\n---\nbody\n",
|
"../escape.md": "---\ntype: table\n---\nbody\n",
|
||||||
# T4 — a reserved basename shadows the directory listing.
|
# A2 — index.md is a legitimate structural file in a *received* bundle
|
||||||
"index.md": "---\ntype: table\ndescription: Listing.\n---\nbody\n",
|
# (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
|
# LLM05 — the zero-click EchoLeak primitive: an auto-fetched markdown
|
||||||
# image URL that exfiltrates the moment the concept is rendered.
|
# image URL that exfiltrates the moment the concept is rendered.
|
||||||
"echoleak.md": (
|
"echoleak.md": (
|
||||||
|
|
@ -124,7 +127,7 @@ def _surface_checks(result: BundleResult) -> dict:
|
||||||
desc = by_path.get("frontmatter-desc.md")
|
desc = by_path.get("frontmatter-desc.md")
|
||||||
return {
|
return {
|
||||||
"T4:path-traversal": rejected("../escape.md"),
|
"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"),
|
"T3:resource": rejected("bad-resource.md"),
|
||||||
"T2:frontmatter": rejected("dangerous-frontmatter.md"),
|
"T2:frontmatter": rejected("dangerous-frontmatter.md"),
|
||||||
"T1:body-injection": caught("body-injection.md", "override:ignore-previous"),
|
"T1:body-injection": caught("body-injection.md", "override:ignore-previous"),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue