diff --git a/benchmarks/measurements/2026-09-08-module-audit.json b/benchmarks/measurements/2026-09-08-module-audit.json index 5b6b2a0..3906752 100644 --- a/benchmarks/measurements/2026-09-08-module-audit.json +++ b/benchmarks/measurements/2026-09-08-module-audit.json @@ -1,13 +1,13 @@ { "counts": { - "load_bearing": 80, + "load_bearing": 81, "standalone_capability": 7 }, - "generated": "2026-09-08 (regenerated after group 3 added the ladder and the judge)", + "generated": "2026-09-08 (regenerated after group 4 added the dominance arbiter)", "kept_with_a_lever_stripped": { "improve/evolve.py + improve/validator.py": "the design's Removed list named these whole, but each is a mix: the mechanism lever (removed) and the deterministic rule-status improve loop that `ousast improve` still runs from benchmark evidence, which has no LLM in it and is not what this feature supersedes. Deleting the files would have taken a retained capability with them." }, - "modules": 87, + "modules": 88, "note": "Classification of every source module after the group-1 subtraction. load_bearing = reachable from a gate, the CLI, or named by a surviving requirement; standalone_capability = its own entry point or protocol, deliberately off the scan path; orphaned = no importer, no entry point, no surviving requirement -- removed, never carried.", "orphaned": [], "removed_in_this_pass": { @@ -128,6 +128,7 @@ "classification": "load_bearing", "importers": [ "cpg.backend", + "model.dominance", "model.judge", "model.taint" ], @@ -137,6 +138,7 @@ { "classification": "load_bearing", "importers": [ + "model.dominance", "model.judge", "model.taint" ], @@ -324,6 +326,7 @@ "importers": [ "cli", "model.candidates", + "model.dominance", "model.judge", "model.specs", "model.taint" @@ -345,6 +348,12 @@ "module": "model.candidates", "reason": "reachable from gate, map_gate, pair_gate or the CLI" }, + { + "classification": "load_bearing", + "importers": [], + "module": "model.dominance", + "reason": "model-grounded-detection Req 6.2: guard dominance, the arbiter for bugs that never crash" + }, { "classification": "load_bearing", "importers": [], @@ -360,6 +369,7 @@ { "classification": "load_bearing", "importers": [ + "model.dominance", "model.judge", "model.taint" ], @@ -369,6 +379,7 @@ { "classification": "load_bearing", "importers": [ + "model.dominance", "model.judge", "model.taint" ], diff --git a/src/openultrasast/cpg/queries/dominance.sc b/src/openultrasast/cpg/queries/dominance.sc new file mode 100644 index 0000000..f930d21 --- /dev/null +++ b/src/openultrasast/cpg/queries/dominance.sc @@ -0,0 +1,71 @@ +// Guard dominance for the absence families (model-grounded-detection Req 6.2). +// +// An access-control bug is the ABSENCE of a guard: there is no flow to follow and no crash to reproduce. +// What the model can establish is a consistency violation -- an obligated operation that no discharging guard +// governs, in a file where its siblings are governed by one. +// +// "Governed" is not one relation, because a guard discharges an obligation in three different shapes, and +// asking pure CFG dominance of the operation gets two of them wrong: +// +// 1. identity constraint the guard is INSIDE the operation's own arguments +// Note.query.filter_by(owner_id = current_user.id) +// 2. identity branch the method branches on identity, and the denial is controlled by that condition +// note = ...filter_by(id=nid); if note.owner_id != current_user.id: abort(403) +// (measured: abort(403).controlledBy == [note.owner_id != current_user.id]) +// 3. dominating guard a call naming a discharger that dominates the operation -- a check before it +// +// Shape 2 is why `controlledBy` is used rather than `dominatedBy`: the ownership check runs AFTER the fetch, +// so it never dominates the operation, but it does control whether the value escapes. +// +// Parameters: cpgFile, operations, dischargers, function (optional; reported, never used to filter, because +// the sibling comparison needs every operation in the CPG). +// +// Output: a fenced JSON array of {operation, opLine, opMethod, dominatingGuards}. + +@main def exec(cpgFile: String, operations: String, dischargers: String, function: String = "") = { + importCpg(cpgFile) + + def split(raw: String): List[String] = raw.split(",").map(_.trim).filter(_.nonEmpty).toList + + val opNames = split(operations) + val guardTokens = split(dischargers) + + def mentionsGuard(code: String): Boolean = guardTokens.exists(g => code.contains(g)) + + // Match by CALL NAME, never by code containment: the frontend desugars `Note.query.filter_by(x).first()` + // into a chain of temporaries, and matching on code yields four or five rows for one operation -- some of + // them the bare `tmp1.filter_by` receiver, which carries none of the guards the real call does. A `leaky` + // verdict computed over those extra rows would flag a guarded operation. A dotted spec (`query.get`) + // matches on its trailing segment, which is how the call is named in the graph. + def matchesOperation(name: String): Boolean = + opNames.exists(n => name == n || name == n.split("\\.").last) + + val opCalls = cpg.call.filter(c => matchesOperation(c.name)).l + + val rows = opCalls.map { op => + val method = op.method + + // 1. identity constraint: a discharger inside the operation's own arguments. + val inArguments = op.argument.code.l.filter(mentionsGuard) + + // 2. identity branch: a control structure in this method whose condition names a discharger. The denial + // it guards need not dominate the operation -- it controls whether the fetched value escapes. + val inCondition = method.controlStructure.condition.code.l.filter(mentionsGuard) + + // 3. dominating guard: a call naming a discharger that actually dominates the operation. + val dominating = op.dominatedBy.isCall.code.l.filter(mentionsGuard) + + val guards = (inArguments ++ inCondition ++ dominating).distinct.map(_.take(120)) + + ujson.Obj( + "operation" -> op.code.take(200), + "opLine" -> op.lineNumber.getOrElse(-1).toString, + "opMethod" -> method.name, + "dominatingGuards" -> ujson.Arr(guards.map(ujson.Str(_)): _*) + ) + } + + println("---OUSAST-CPG-BEGIN---") + println(ujson.write(ujson.Arr(rows: _*))) + println("---OUSAST-CPG-END---") +} diff --git a/src/openultrasast/model/audit.py b/src/openultrasast/model/audit.py index c790415..f1a7852 100644 --- a/src/openultrasast/model/audit.py +++ b/src/openultrasast/model/audit.py @@ -34,6 +34,7 @@ SPEC_OWNED = { "model.ladder": "model-grounded-detection Req 5: the evidence ladder every finding carries", "model.taint": "model-grounded-detection Req 6.1: taint reachability over the CPG, the flow-family arbiter", "model.judge": "model-grounded-detection Req 8: one bounded question, checked against the model", + "model.dominance": "model-grounded-detection Req 6.2: guard dominance, the arbiter for bugs that never crash", "cpg": "model-grounded-detection Req 4: the CPG seam package", "cpg.backend": "model-grounded-detection Req 4.1/4.5: the single Joern subprocess boundary", "cpg.capability": "model-grounded-detection Req 4.2/4.3: the engine probe that degrades to suspicion", diff --git a/src/openultrasast/model/dominance.py b/src/openultrasast/model/dominance.py new file mode 100644 index 0000000..4d104e7 --- /dev/null +++ b/src/openultrasast/model/dominance.py @@ -0,0 +1,88 @@ +"""Guard dominance: the arbiter for the bugs that never crash (model-grounded-detection, Req 6.2). + +An access-control bug is the *absence* of a guard. There is no flow to follow and no crash to reproduce, so +neither taint reachability nor an execution oracle reaches this class at all — which is precisely the gap +Clearwing's crash ladder leaves open and this project exists to close. + +What the model *can* establish is a consistency violation: an obligated operation that no discharging guard +governs, in a file where its siblings are governed by one. That asymmetry is the evidence, and it is +deterministic — no model call, and the same verdict on every run over one CPG. + +The rungs are deliberately asymmetric: + + ENTAILED unguarded here, guarded on a sibling — the file contradicts itself, and the model can say so + CORROBORATED unguarded here, and no sibling is guarded either — the obligation is real but there is + nothing to be inconsistent *with*. A whole module may be unauthenticated by design, and + calling that entailed would assert a policy the model cannot see. + (none) guarded, or no obligated operation at all +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence + +from ..cpg.backend import CpgResult +from .ladder import Rung, Verdict +from .specs import DominanceSpec + + +def verdict(cpg: CpgResult, spec: DominanceSpec, *, function: str = "") -> Verdict | None: + """The verdict for the obligated operation in ``function``, or ``None`` when there is nothing to say.""" + rows = cpg.run( + "dominance", + {"operations": spec.operations, "dischargers": spec.dischargers, "function": function}, + ) + operations = _operations(rows) + if not operations: + return None + + here = [row for row in operations if not function or row["opMethod"] == function] + if not here: + return None + unguarded = [row for row in here if not row["guards"]] + if not unguarded: + return None # every operation in this function is governed; nothing to report + + # Siblings are the obligated operations *elsewhere* in the graph. Sorted so the witness is stable. + siblings = sorted( + (row for row in operations if row["opMethod"] not in {r["opMethod"] for r in here}), + key=lambda row: (row["opMethod"], row["opLine"]), + ) + # Count guarded sibling *methods*, not rows: one method can hold several obligated operations, and + # naming it twice makes the witness read as more corroboration than the file actually contains. + guarded_methods = sorted({str(row["opMethod"]) for row in siblings if row["guards"]}) + target = min(unguarded, key=lambda row: (row["opMethod"], row["opLine"])) + + if guarded_methods: + witness = ( + f"{target['opMethod']} line {target['opLine']}: no discharging guard, " + f"while {len(guarded_methods)} sibling handler(s) are guarded " + f"({', '.join(guarded_methods[:3])})" + ) + return Verdict(rung=Rung.ENTAILED, family=spec.family, witness=witness) + + witness = f"{target['opMethod']} line {target['opLine']}: obligated operation with no discharging guard, and no guarded sibling" + return Verdict(rung=Rung.CORROBORATED, family=spec.family, witness=witness) + + +def _operations(rows: object) -> list[dict[str, object]]: + """The query's rows. A non-list (an engine failure) yields nothing, never a false absence.""" + if not isinstance(rows, Sequence) or isinstance(rows, (str, bytes)): + return [] + kept: list[dict[str, object]] = [] + for row in rows: + if not isinstance(row, Mapping): + continue + guards = row.get("dominatingGuards") + kept.append( + { + "operation": str(row.get("operation", "")), + "opLine": str(row.get("opLine", "")), + "opMethod": str(row.get("opMethod", "")), + "guards": [str(g) for g in guards] if isinstance(guards, Sequence) and not isinstance(guards, str) else [], + } + ) + return kept + + +__all__ = ["verdict"] diff --git a/src/openultrasast/model/specs.py b/src/openultrasast/model/specs.py index 6a31e7b..516d6fe 100644 --- a/src/openultrasast/model/specs.py +++ b/src/openultrasast/model/specs.py @@ -104,8 +104,16 @@ class TaintSpec: class DominanceSpec: """What an absence family's guard-dominates-operation query matches on, for one language. - ``dischargers`` are the guards that discharge an obligation; the query asks whether one of them dominates - the operation on every path, which is the arbiter for the classes that never crash. + ``dischargers`` are every token that can evidence a discharge, drawn from four fields of a discharger + fact and deliberately not from the other two: + + calls, decorators an explicit guard: ``login_required``, ``check_owner`` + identity_sources the authenticated context: ``current_user``, ``request.user`` + constraint_params a field that constrains by identity: ``owner_id``, ``user_id`` + + ``request_sources`` and ``permissive_values`` are excluded because they describe the NEGATIVE case -- a + value that comes from the request discharges nothing, and a permissive literal is the bug rather than the + guard. Including them would let the absence arbiter read a vulnerability as its own fix. """ family: str @@ -167,6 +175,8 @@ def dominance_specs(*, language: str, facts: ObligationFacts | None = None) -> M sorted( {call for fact in scoped.dischargers for call in fact.calls} | {decorator for fact in scoped.dischargers for decorator in fact.decorators} + | {source for fact in scoped.dischargers for source in fact.identity_sources} + | {param for fact in scoped.dischargers for param in fact.constraint_params} ) ) return {"access_control": DominanceSpec(family="access_control", language=language, operations=operations, dischargers=dischargers)} diff --git a/src/openultrasast/ruleset/semantic/javascript.toml b/src/openultrasast/ruleset/semantic/javascript.toml index f146fcd..f5280a8 100644 --- a/src/openultrasast/ruleset/semantic/javascript.toml +++ b/src/openultrasast/ruleset/semantic/javascript.toml @@ -27,3 +27,41 @@ rule_ids = ["js-sql-injection"] id = "parameterized_query" calls = ["query", "execute"] parameterized = true + +# --- model-grounded-detection task 4.2 ------------------------------------------------------------------- +# See the note in python.toml: documented dangerous APIs, provenance per entry, reviewed as data. + +[[sink]] +# CWE-1321. Prototype pollution reaches the object graph through a recursive merge or an unchecked +# dynamic key. These are the documented merge helpers plus the assignment forms. +id = "prototype.merge" +cwe = "CWE-1321" +calls = ["merge", "mergeDeep", "extend", "defaultsDeep", "Object.assign", "set", "setWith"] +rule_ids = [] + +[[sink]] +# CWE-79. The documented "trusted HTML" sinks in the DOM and the major frameworks. +id = "html.unescaped" +cwe = "CWE-79" +calls = ["innerHTML", "outerHTML", "insertAdjacentHTML", "document.write", "dangerouslySetInnerHTML", "v-html"] +rule_ids = [] + +[[sink]] +# CWE-22. Node filesystem entry points that accept a caller-supplied path. +id = "path.open" +cwe = "CWE-22" +calls = ["readFile", "readFileSync", "createReadStream", "sendFile", "unlink", "unlinkSync"] +rule_ids = [] + +[[sink]] +# CWE-918 / CWE-601: outbound requests and redirects to a caller-supplied destination. +id = "outbound.request" +cwe = "CWE-918" +calls = ["axios.get", "axios.post", "fetch", "request", "got", "http.get", "https.get"] +rule_ids = [] + +[[sink]] +id = "redirect" +cwe = "CWE-601" +calls = ["redirect", "location.assign", "location.replace"] +rule_ids = [] diff --git a/src/openultrasast/ruleset/semantic/python.toml b/src/openultrasast/ruleset/semantic/python.toml index be20752..34038f6 100644 --- a/src/openultrasast/ruleset/semantic/python.toml +++ b/src/openultrasast/ruleset/semantic/python.toml @@ -71,3 +71,49 @@ calls = ["yaml.safe_load", "safe_load"] id = "parameterized_execute" calls = ["execute", "executemany"] parameterized = true + +# --- model-grounded-detection task 4.2 ------------------------------------------------------------------- +# Sinks for the four families that previously had no fact routing to them by CWE, plus the ORM raw-SQL forms +# the Joern go/no-go measured as misses (11 of 23). Every entry below is a documented dangerous API of the +# named library, not an inferred pattern; the provenance is on each entry so a maintainer can review the data +# as data. `rule_ids` is left empty where no pattern rule exists: these facts feed the model layer, and a +# sink fact is not a detection rule. + +[[sink]] +# CWE-89. Django's documented raw-SQL escape hatches (docs: "Performing raw SQL queries") and the +# SQLAlchemy/peewee text forms. These were the single largest miss class in the go/no-go. +id = "orm.raw" +cwe = "CWE-89" +calls = ["objects.raw", "raw", "RawSQL", "extra", "text", "from_statement"] +rule_ids = [] + +[[sink]] +# CWE-22. Filesystem entry points that accept a caller-supplied path: the stdlib openers plus Flask's +# send_file/send_from_directory and Django's FileResponse. +id = "path.open" +cwe = "CWE-22" +calls = ["open", "send_file", "send_from_directory", "FileResponse", "os.remove", "os.unlink", "shutil.copy", "shutil.move", "shutil.rmtree"] +rule_ids = [] + +[[sink]] +# CWE-79. The documented "this string is trusted HTML" escapes: Jinja/Flask render_template_string and +# Markup, Django's mark_safe and format_html. Rendering a template *file* is not a sink; rendering a +# caller-supplied *string* is. +id = "html.unescaped" +cwe = "CWE-79" +calls = ["render_template_string", "Markup", "mark_safe", "format_html", "HttpResponse"] +rule_ids = [] + +[[sink]] +# CWE-918 (SSRF) and CWE-601 (open redirect): outbound requests and redirects to a caller-supplied +# destination. requests/urllib/httpx are the documented HTTP clients; redirect is Flask's and Django's. +id = "outbound.request" +cwe = "CWE-918" +calls = ["requests.get", "requests.post", "requests.request", "urlopen", "urlretrieve", "httpx.get", "httpx.post"] +rule_ids = [] + +[[sink]] +id = "redirect" +cwe = "CWE-601" +calls = ["redirect", "HttpResponseRedirect", "url_for"] +rule_ids = [] diff --git a/tests/test_model_dominance.py b/tests/test_model_dominance.py new file mode 100644 index 0000000..7c2296a --- /dev/null +++ b/tests/test_model_dominance.py @@ -0,0 +1,117 @@ +"""model-grounded-detection task 4.1: guard dominance, the arbiter for bugs that never crash (Req 6.2). + +An access-control bug is the *absence* of a guard, so there is no flow to follow and no crash to reproduce. +What the model can establish is a consistency violation: an obligated operation that no discharging guard +dominates, in a file where its siblings are dominated by one. That asymmetry is the evidence. + +This replaces the line-order heuristic in `semantic/obligations/dominance.py` ("the guard's line is before the +operation's line, with an early exit between") with real control-flow dominance over the CFG. +""" + +from __future__ import annotations + +from pathlib import Path + + +def _spec(): # type: ignore[no-untyped-def] + from openultrasast.model.specs import DominanceSpec + + return DominanceSpec( + family="access_control", + language="python", + operations=("filter_by", "query.get", "session.delete"), + dischargers=("login_required", "current_user", "check_permission"), + ) + + +def _cpg(rows): # type: ignore[no-untyped-def] + from openultrasast.cpg.backend import CpgResult + + return CpgResult(cpg_path=Path("cpg.bin"), run=lambda q, p: rows) + + +def _op(method: str, guards: list[str], line: int = 10) -> dict[str, object]: + return {"operation": "Note.query.filter_by(id=nid)", "opLine": str(line), "opMethod": method, + "dominatingGuards": guards} + + +def test_an_unguarded_operation_among_guarded_siblings_is_entailed() -> None: + """The consistency violation: every sibling checks the owner, this one does not.""" + from openultrasast.model.dominance import verdict + from openultrasast.model.ladder import Rung + + rows = [_op("leaky", []), _op("list_notes", ["current_user"]), _op("edit_note", ["current_user"])] + answer = verdict(_cpg(rows), _spec(), function="leaky") + assert answer is not None and answer.rung is Rung.ENTAILED + assert answer.family == "access_control" + assert "leaky" in answer.witness and "2 sibling" in answer.witness, "the witness names the operation and its siblings" + + +def test_a_method_with_two_operations_counts_once_as_a_sibling() -> None: + """One handler holding two obligated operations is one piece of corroboration, not two.""" + from openultrasast.model.dominance import verdict + + rows = [_op("leaky", []), _op("listing", ["current_user"], line=3), _op("listing", ["current_user"], line=4)] + answer = verdict(_cpg(rows), _spec(), function="leaky") + assert answer is not None and "1 sibling" in answer.witness + assert answer.witness.count("listing") == 1 + + +def test_a_guarded_operation_yields_no_finding() -> None: + from openultrasast.model.dominance import verdict + + rows = [_op("safe", ["current_user"]), _op("other", ["current_user"])] + assert verdict(_cpg(rows), _spec(), function="safe") is None + + +def test_an_unguarded_operation_with_no_guarded_sibling_is_only_corroborated() -> None: + """Nothing to be inconsistent with: the whole module may be unauthenticated by design, and saying + `entailed` there would assert a policy the model cannot see.""" + from openultrasast.model.dominance import verdict + from openultrasast.model.ladder import Rung + + rows = [_op("a", []), _op("b", [])] + answer = verdict(_cpg(rows), _spec(), function="a") + assert answer is not None and answer.rung is Rung.CORROBORATED + + +def test_no_obligated_operation_yields_no_verdict() -> None: + from openultrasast.model.dominance import verdict + + assert verdict(_cpg([]), _spec(), function="whatever") is None + + +def test_a_failed_query_is_not_read_as_an_absence_of_operations() -> None: + """`None` from the engine means "could not decide", never "there is nothing here".""" + from openultrasast.cpg.backend import CpgResult + from openultrasast.model.dominance import verdict + + broken = CpgResult(cpg_path=Path("c.bin"), run=lambda q, p: None) + assert verdict(broken, _spec(), function="a") is None + + +def test_the_verdict_is_deterministic_over_one_cpg() -> None: + """Req 6.4, for the absence arbiter as well as the flow one.""" + from openultrasast.model.dominance import verdict + + rows = [_op("leaky", []), _op("z", ["current_user"]), _op("a", ["check_permission"])] + cpg = _cpg(rows) + assert verdict(cpg, _spec(), function="leaky") == verdict(cpg, _spec(), function="leaky") + + +def test_the_spec_drives_the_query_parameters() -> None: + from openultrasast.cpg.backend import CpgResult + from openultrasast.model.dominance import verdict + + seen: dict[str, object] = {} + + def run(query: str, params: dict[str, object]) -> object: + seen["query"] = query + seen.update(params) + return [] + + verdict(CpgResult(cpg_path=Path("c.bin"), run=run), _spec(), function="f") + assert seen["query"] == "dominance" + assert seen["operations"] == ("filter_by", "query.get", "session.delete") + assert seen["dischargers"] == ("login_required", "current_user", "check_permission") + assert seen["function"] == "f" diff --git a/tests/test_model_specs.py b/tests/test_model_specs.py index a4f523c..e380afa 100644 --- a/tests/test_model_specs.py +++ b/tests/test_model_specs.py @@ -36,10 +36,9 @@ def test_the_guard_classifier_still_recognises_each_kind() -> None: def test_the_seeded_families_and_the_named_gaps_are_both_exact() -> None: """What the retained fact data can seed, and what it provably cannot — Req 9.2, a gap is *named*. - `path`, `output_encoding`, `untrusted_destination` and `prototype` have no sink in `ruleset/semantic` - whose CWE routes to them, which is the same hole the entailment ceiling diagnosed ("closed literal sink - table: misses per-family sinks"). Task 4.2 authors those sinks and must update this test when it does — - the set is asserted exactly so a gap can neither appear nor close silently. + Task 4.2 closed the gap this test was written to hold open: `path`, `output_encoding`, + `untrusted_destination` and `prototype` now have sink facts routing to them by CWE. The set is still + asserted exactly, so a family can neither lose its seed nor gain one without this record moving. """ from openultrasast.model.specs import dominance_specs, taint_specs from openultrasast.model.taxonomy import load_families @@ -50,11 +49,12 @@ def test_the_seeded_families_and_the_named_gaps_are_both_exact() -> None: seeded |= {spec.family for spec in taint_specs(language=language).values()} seeded |= {spec.family for spec in dominance_specs(language=language).values()} deferred = {"unknown", "memory"} # the declined bucket and the deferred execution tier - assert seeded == {"injection", "deserialization", "config_secrets", "access_control", "memory"} + assert seeded == { + "injection", "deserialization", "config_secrets", "access_control", + "path", "output_encoding", "untrusted_destination", "prototype", "memory", + } gaps = {family.id for family in taxonomy.families} - seeded - deferred - assert gaps == {"path", "output_encoding", "untrusted_destination", "prototype"}, ( - "a family lost or gained a seed without this record being updated" - ) + assert gaps == set(), f"a family lost its seed: {sorted(gaps)}" def test_the_injection_taint_spec_carries_sources_sinks_and_sanitizers() -> None: @@ -126,3 +126,25 @@ def test_safe_shape_sinks_are_kept_for_the_shape_test() -> None: assert "execute" not in spec.sanitizers c_memory = taint_specs(language="c").get("memory") assert c_memory is not None and "printf" in c_memory.safe_shape_sinks + + +def test_dischargers_carry_identity_and_constraint_tokens_but_not_the_negative_fields() -> None: + """A discharger fact spreads across six fields and two of them mean the opposite. + + `identity_sources` (current_user, request.user) and `constraint_params` (owner_id, user_id) are how an + identity constraint is actually written, and omitting them left the absence arbiter reporting "no guard" + on a handler that plainly had one. `request_sources` and `permissive_values` are excluded because they + describe the vulnerable shape, not the fix -- including them would let the arbiter read a bug as its fix. + """ + from openultrasast.model.specs import dominance_specs + from openultrasast.semantic.obligations import load_obligation_facts + + spec = dominance_specs(language="python")["access_control"] + assert "current_user" in spec.dischargers and "owner_id" in spec.dischargers + assert "login_required" in spec.dischargers # decorators still present + + facts = load_obligation_facts().for_language("python") + negative = {p for fact in facts.dischargers for p in fact.request_sources} | { + v for fact in facts.dischargers for v in fact.permissive_values + } + assert not (negative & set(spec.dischargers)), "the negative fields must never read as a discharge"