OWASP's Top 10 for LLM Applications 2026 renumbered the list; only LLM01
and LLM02 kept their meaning. Every finding's `owasp` was written against
2025 with no year, so EchoLeak findings said LLM05, which in 2026 means
Data and Model Poisoning. Row 2 of the 2026 done-criterion counted 95
such sites at 6702d7a (82 old meaning, 13 wrong); it is now 0.
- Producers name the risk, never the number: one table (`_owasp.py`)
owns `LLMnn:2026`. The vendored lexicon stays byte-equal with commons
and gets its year where the finding is made; a commons number whose
meaning moved between editions raises instead of being guessed.
- Mechanical map from the 2025 meaning (verified against the publisher
in session 1): LLM05 -> LLM10:2026, LLM06 -> LLM03:2026,
LLM10 -> LLM06:2026, LLM08 -> LLM09:2026, LLM04 -> LLM05:2026.
The 13 wrong-meaning sites, judged:
- Factual poisoning (PoisonedRAG gap, and the test that follows it):
LLM05:2026. The 2026 text names "RAG Knowledge Base Poisoning" as its
example 6. It said LLM09, which was Misinformation.
- Fail-secure (README row, matrix row): no number. It is the guard's own
failure semantics; no edition describes it. Was LLM10.
- Parser-surface refusals (nested mapping, block scalar, quoted flow
element, off-allowlist key): no number. Refusing a shape the parser
would have to interpret is not resource consumption. Was LLM10. The
anchor and merge-key rows stay LLM06:2026 (alias expansion is DoS).
- `resource` URL and cross-link scheme validation (4): no number. They
validate received bundle metadata; LLM10:2026 is about model output
and says input validation belongs to LLM01. Was LLM05.
- `_raise_case` default (OKF path traversal, absolute path, reserved
name, non-.md, bundle escape): no number. None is prompt injection.
Was LLM01.
A doubtful number would be the misreading the year exists to prevent,
so `Case.owasp` is now Optional and the matrix prints "-".
Self-safety (input caps, ReDoS) keeps LLM06:2026: the 2026 text, read at
the publisher, covers "input flood ... exploiting processing
inefficiencies" and application-side rechunking. The README's LLM09
anchor (Misinformation) is dropped; it was the factual-poisoning row.
The permanent test (tests/test_catalog_numbers.py) fails on an old or
wrong number, on a number without the year outside the vendored
lexicon, and on any finding the coverage matrix drives whose `owasp`
lacks the newest year; "newest" is read from the catalog register. The
census holds its wrong-rules to their counts at the frozen revision
only, so the frozen listing (da1aa0fa...6f) is unchanged.
161 lines
6.4 KiB
Python
161 lines
6.4 KiB
Python
"""Every OWASP LLM Top 10 number carries the newest edition's year — row 2 of the 2026 done-criterion.
|
|
|
|
OWASP's 2026 edition renumbered the list (`docs/PLAN.md` § T.5): only LLM01 and
|
|
LLM02 kept their meaning, so a bare ``LLM05`` written against 2025 now reads as
|
|
Data and Model Poisoning. Row 2 (`docs/PLAN.md` § T.2) is the count of places a
|
|
number is used with an older edition's meaning, or with a meaning no edition gave
|
|
it; it was 95 at ``6702d7a`` and this file holds it at 0.
|
|
|
|
Two guards, because the number reaches a consumer two ways:
|
|
|
|
* TEXT — ``docs/catalog-census.py`` over the tracked files. No site may be
|
|
``old`` or ``wrong``, and none may lack the year (``same``) except the vendored
|
|
lexicon, whose data stays byte-equal with commons and gets its year where the
|
|
finding is made (``lexicon.load_lexicon``).
|
|
* FINDINGS — every finding the coverage matrix drives out of the real guard is
|
|
recorded as it is constructed, and each ``owasp`` is ``None`` or ``LLMnn:<newest>``.
|
|
|
|
"Newest" is read from the catalog register (`docs/threat-2026/catalogs.json`), not
|
|
written here, so the next edition moves one place.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import json
|
|
import re
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from llm_ingestion_guard import report as report_module
|
|
from llm_ingestion_guard.coverage import CORE_CASES, run_matrix
|
|
from llm_ingestion_guard.lexicon import load_lexicon
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
REGISTER = ROOT / "docs" / "threat-2026" / "catalogs.json"
|
|
VENDORED_LEXICON = "src/llm_ingestion_guard/injection_lexicon.json"
|
|
|
|
|
|
def _newest_year() -> str:
|
|
entries = json.loads(REGISTER.read_text(encoding="utf-8"))["entries"]
|
|
(owasp,) = [e for e in entries if e["id"] == "owasp-llm-top10"]
|
|
return owasp["latest"]
|
|
|
|
|
|
def _census():
|
|
spec = importlib.util.spec_from_file_location("catalog_census", ROOT / "docs" / "catalog-census.py")
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
def _in_git_checkout() -> bool:
|
|
try:
|
|
subprocess.run(("git", "-C", str(ROOT), "rev-parse"), check=True, capture_output=True)
|
|
except (OSError, subprocess.CalledProcessError):
|
|
return False
|
|
return True
|
|
|
|
|
|
needs_git = pytest.mark.skipif(not _in_git_checkout(), reason="the census reads tracked files")
|
|
|
|
|
|
@needs_git
|
|
def test_census_counts_the_register_edition_as_current():
|
|
# The census and the register must agree on which edition is newest; if they
|
|
# drift, "current" means a year the register no longer calls latest.
|
|
assert _census().CURRENT_YEAR == _newest_year()
|
|
|
|
|
|
@needs_git
|
|
def test_no_catalog_number_with_an_older_or_no_meaning():
|
|
rows = _census().census(None)
|
|
counted = [r for r in rows if r[0] in ("old", "wrong")]
|
|
assert not counted, "catalog numbers with an older edition's meaning, or none:\n" + "\n".join(
|
|
f" {r[1]}:{r[2]} {r[3]} [{r[0]}] -> {r[6]}" for r in counted)
|
|
|
|
|
|
@needs_git
|
|
def test_no_catalog_number_without_the_year():
|
|
rows = _census().census(None)
|
|
bare = [r for r in rows if r[0] == "same" and r[1] != VENDORED_LEXICON]
|
|
assert not bare, "catalog numbers without the year:\n" + "\n".join(
|
|
f" {r[1]}:{r[2]} {r[3]} -> {r[6]}" for r in bare)
|
|
|
|
|
|
def test_vendored_lexicon_gets_the_year_where_the_finding_is_made():
|
|
year = _newest_year()
|
|
assert {p.owasp for p in load_lexicon()} == {f"LLM01:{year}"}
|
|
|
|
|
|
def test_a_moved_number_from_commons_is_refused_not_guessed():
|
|
# A bare number whose meaning moved between editions cannot be labelled
|
|
# without knowing which edition wrote it; mislabelling it is the defect this
|
|
# file exists for, so it raises instead.
|
|
from llm_ingestion_guard import _owasp
|
|
|
|
assert _owasp.from_commons("LLM02") == f"LLM02:{_newest_year()}"
|
|
assert _owasp.from_commons(f"LLM10:{_newest_year()}") == f"LLM10:{_newest_year()}"
|
|
with pytest.raises(ValueError):
|
|
_owasp.from_commons("LLM05")
|
|
with pytest.raises(ValueError):
|
|
_owasp.from_commons("LLM01:2025")
|
|
|
|
|
|
def test_every_finding_the_matrix_drives_carries_the_newest_year(monkeypatch):
|
|
seen: list[tuple[str, object]] = []
|
|
original = report_module.Finding.__init__
|
|
|
|
def recording_init(self, *args, **kwargs):
|
|
original(self, *args, **kwargs)
|
|
seen.append((self.label, self.owasp))
|
|
|
|
monkeypatch.setattr(report_module.Finding, "__init__", recording_init)
|
|
run_matrix()
|
|
assert len(seen) > 100, f"recorder saw only {len(seen)} findings"
|
|
shape = re.compile(rf"LLM(0[1-9]|10):{_newest_year()}")
|
|
bad = sorted({(label, owasp) for label, owasp in seen
|
|
if owasp is not None and not shape.fullmatch(owasp)})
|
|
assert not bad, f"findings with an un-yeared or older-edition owasp: {bad}"
|
|
|
|
|
|
def test_matrix_rows_carry_the_newest_year_or_no_number():
|
|
shape = re.compile(rf"LLM(0[1-9]|10):{_newest_year()}")
|
|
bad = [(c.group, c.klasse, c.owasp) for c in CORE_CASES
|
|
if c.owasp is not None and not shape.fullmatch(c.owasp)]
|
|
assert not bad, bad
|
|
|
|
|
|
# The 13 sites the census classed `wrong` at 6702d7a, re-judged in session 3.
|
|
# Each class below either has a 2026 entry that describes it, or no entry does,
|
|
# and then it carries no number rather than a doubtful one.
|
|
_NO_CATALOG_ENTRY = (
|
|
"un-scannable artifact fails closed", # the guard's own failure semantics
|
|
"T2 frontmatter nested mapping", # parser-surface reduction
|
|
"T2 frontmatter block scalar",
|
|
"T2 frontmatter flow sequence, quoted element",
|
|
"T2 mapping key off the allowlist",
|
|
"T3 resource non-https (http)", # received metadata, not model output
|
|
"T3 resource data: scheme",
|
|
"T3 resource javascript: scheme",
|
|
"T5a cross-link dangerous scheme",
|
|
"T4 path traversal", # bundle file-system integrity
|
|
"T4 absolute path",
|
|
"T4 reserved filename shadow",
|
|
"T4 non-.md concept",
|
|
"T5a cross-link bundle escape",
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("klasse", _NO_CATALOG_ENTRY)
|
|
def test_rows_no_edition_describes_carry_no_number(klasse):
|
|
(case,) = [c for c in CORE_CASES if c.klasse == klasse]
|
|
assert case.owasp is None
|
|
|
|
|
|
def test_factual_poisoning_gap_is_data_and_model_poisoning():
|
|
# LLM05:2026 names "RAG Knowledge Base Poisoning" (PoisonedRAG) as its example 6;
|
|
# the row used to say LLM09, which in 2025 meant Misinformation.
|
|
(case,) = [c for c in CORE_CASES if c.klasse == "semantic / factual poisoning"]
|
|
assert case.owasp == f"LLM05:{_newest_year()}"
|