From d9ceeb6a0956a5331590a343e9c17ac7f4a0dfa0 Mon Sep 17 00:00:00 2001 From: Marius Ciepluch <11855163+norandom@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:00:41 +0200 Subject: [PATCH] feat(model-grounded-detection): 4.2 constant abstraction, the third arbiter form A configuration bug has no flow to follow and no guard to dominate. CORS(app, origins="*") is dangerous because of the value it is SET TO, so the arbiter evaluates the setting's arguments against the closed permissive set in the obligation facts -- the third form beside taint reachability and guard dominance, and the one Req 6.3 names. ENTAILED a security setting is passed a literal in the permissive set CORROBORATED the argument is computed, so constant abstraction cannot decide it -- that residual is the judge's, never silently "safe" (none) every literal is outside the set, or the call is not a setting Two Joern node-shape bugs, both found by running it and both of the same class as group 2's receiver bug -- the graph's indexing is not what the source text suggests: * a keyword argument (`origins="*"`) is an assignment-shaped node, so isLiteral is false on the argument itself and the literal sits in its subtree; literals are collected from the argument's AST now. * Joern indexes the receiver at 0, positional arguments from 1, and NAMED arguments at -1. Settings are configured overwhelmingly by keyword, so argumentIndexGt(0) -- correct when counting arity for the taint shape test -- dropped exactly the arguments that matter and every keyword-configured setting read as "computed". Live, deterministic, zero model calls: create_app -> model_entailed CORS(app, origins = "*"): permissive literal "*" safe_app -> no finding (origins = "https://app.example") All three arbiter forms now work against a real CPG: taint reachability for the flow families, guard dominance for absence, constant abstraction for config. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG --- .../measurements/2026-09-08-module-audit.json | 17 +++- src/openultrasast/cpg/queries/config.sc | 44 +++++++++++ src/openultrasast/model/audit.py | 1 + src/openultrasast/model/config_value.py | 77 ++++++++++++++++++ src/openultrasast/model/specs.py | 31 ++++++++ tests/test_model_config_value.py | 78 +++++++++++++++++++ 6 files changed, 245 insertions(+), 3 deletions(-) create mode 100644 src/openultrasast/cpg/queries/config.sc create mode 100644 src/openultrasast/model/config_value.py create mode 100644 tests/test_model_config_value.py diff --git a/benchmarks/measurements/2026-09-08-module-audit.json b/benchmarks/measurements/2026-09-08-module-audit.json index 3906752..7e5faff 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": 81, + "load_bearing": 82, "standalone_capability": 7 }, - "generated": "2026-09-08 (regenerated after group 4 added the dominance arbiter)", + "generated": "2026-09-08 (regenerated after group 4: dominance and constant abstraction)", "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": 88, + "modules": 89, "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.config_value", "model.dominance", "model.judge", "model.taint" @@ -138,6 +139,7 @@ { "classification": "load_bearing", "importers": [ + "model.config_value", "model.dominance", "model.judge", "model.taint" @@ -326,6 +328,7 @@ "importers": [ "cli", "model.candidates", + "model.config_value", "model.dominance", "model.judge", "model.specs", @@ -348,6 +351,12 @@ "module": "model.candidates", "reason": "reachable from gate, map_gate, pair_gate or the CLI" }, + { + "classification": "load_bearing", + "importers": [], + "module": "model.config_value", + "reason": "model-grounded-detection Req 6.3: constant abstraction for the configuration families" + }, { "classification": "load_bearing", "importers": [], @@ -369,6 +378,7 @@ { "classification": "load_bearing", "importers": [ + "model.config_value", "model.dominance", "model.judge", "model.taint" @@ -379,6 +389,7 @@ { "classification": "load_bearing", "importers": [ + "model.config_value", "model.dominance", "model.judge", "model.taint" diff --git a/src/openultrasast/cpg/queries/config.sc b/src/openultrasast/cpg/queries/config.sc new file mode 100644 index 0000000..20947a0 --- /dev/null +++ b/src/openultrasast/cpg/queries/config.sc @@ -0,0 +1,44 @@ +// Security settings and the literal values they are given (model-grounded-detection Req 6.3). +// +// A configuration bug has no flow and no guard: `CORS(app, origins="*")` is dangerous because of the value. +// This query reports each setting call with its arguments split into literals (which the model can evaluate) +// and non-literals (which it cannot -- those become `corroborated`, never silently safe). +// +// Parameters: cpgFile, settings, function (optional). +// Output: fenced JSON array of {setting, line, method, args, literalArgs}. + +@main def exec(cpgFile: String, settings: String, function: String = "") = { + importCpg(cpgFile) + + def split(raw: String): List[String] = raw.split(",").map(_.trim).filter(_.nonEmpty).toList + val names = split(settings) + + def matches(name: String): Boolean = names.exists(n => name == n || name == n.split("\\.").last) + + val calls = { + val all = cpg.call.filter(c => matches(c.name)).l + if (function.isEmpty) all else all.filter(_.method.name == function) + } + + val rows = calls.map { call => + // Joern indexes the receiver at 0, positional arguments from 1, and NAMED arguments at -1. A setting is + // configured overwhelmingly by keyword (`origins="*"`, `secure=False`), so filtering `argumentIndexGt(0)` + // -- correct when counting arity for the taint shape test -- drops exactly the arguments that matter here + // and every keyword-configured setting reads as "computed". + val args = call.argument.filter(_.argumentIndex != 0).l + ujson.Obj( + "setting" -> call.code.take(200), + "line" -> call.lineNumber.getOrElse(-1).toString, + "method" -> call.method.name, + "args" -> ujson.Arr(args.map(a => ujson.Str(a.code.take(80))): _*), + // A keyword argument (`origins = "*"`) is an assignment-shaped node, so `isLiteral` is false on the + // argument itself and the literal sits inside it. Collect literals from the argument's SUBTREE, or + // every keyword-configured setting reads as "computed" and nothing is ever evaluated. + "literalArgs" -> ujson.Arr(args.flatMap(a => a.ast.isLiteral.code.l).distinct.map(c => ujson.Str(c.take(80))): _*) + ) + } + + 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 f1a7852..b6a5d76 100644 --- a/src/openultrasast/model/audit.py +++ b/src/openultrasast/model/audit.py @@ -35,6 +35,7 @@ SPEC_OWNED = { "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", + "model.config_value": "model-grounded-detection Req 6.3: constant abstraction for the configuration families", "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/config_value.py b/src/openultrasast/model/config_value.py new file mode 100644 index 0000000..f39c70a --- /dev/null +++ b/src/openultrasast/model/config_value.py @@ -0,0 +1,77 @@ +"""Constant abstraction for the configuration families (model-grounded-detection, Req 6.3). + +The third arbiter form, and the simplest. A configuration bug has no flow to follow and no guard to dominate: +``CORS(app, origins="*")`` is dangerous because of the value it is *set to*. So the model evaluates the +setting's arguments against the closed permissive set from the obligation facts. + + ENTAILED a security setting is passed a literal in the permissive set — the model can read the value + CORROBORATED the setting's argument is computed, so constant abstraction cannot decide it; whether the + computed value is permissive is the residual the judge asks about + (none) every literal argument is outside the permissive set, or the call is not a known setting +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence + +from ..cpg.backend import CpgResult +from .ladder import Rung, Verdict +from .specs import ConfigSpec + + +def verdict(cpg: CpgResult, spec: ConfigSpec, *, function: str = "") -> Verdict | None: + """The verdict for a security setting in ``function``, or ``None`` when nothing is established.""" + rows = cpg.run("config", {"settings": spec.settings, "function": function}) + settings = _settings(rows, spec, function=function) + if not settings: + return None + + permissive = {value.strip().strip("'\"").lower() for value in spec.permissive} + for row in settings: + for literal in row["literals"]: + if str(literal).strip().strip("'\"").lower() in permissive: + return Verdict( + rung=Rung.ENTAILED, + family=spec.family, + witness=f"{row['setting']} (line {row['line']}): permissive literal {literal}", + ) + + # A setting whose value the model could not read. Not safe, not decided. + computed = [row for row in settings if not row["literals"]] + if computed: + row = computed[0] + return Verdict( + rung=Rung.CORROBORATED, + family=spec.family, + witness=f"{row['setting']} (line {row['line']}): value is computed, not a literal the model can evaluate", + ) + return None + + +def _settings(rows: object, spec: ConfigSpec, *, function: str) -> list[dict[str, object]]: + """Rows for calls this spec actually names. A non-list (an engine failure) yields nothing.""" + 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 + setting = str(row.get("setting", "")) + if not any(name in setting for name in spec.settings): + continue # a permissive literal passed to something that is not a security setting is not a bug + if function and str(row.get("method", "")) != function: + continue + literals = row.get("literalArgs") + kept.append( + { + "setting": setting[:200], + "line": str(row.get("line", "")), + "method": str(row.get("method", "")), + "literals": [str(v) for v in literals] if isinstance(literals, Sequence) and not isinstance(literals, str) else [], + } + ) + # Deterministic order: the first row decides, so it must not depend on engine ordering. + return sorted(kept, key=lambda row: (str(row["method"]), str(row["line"]), str(row["setting"]))) + + +__all__ = ["verdict"] diff --git a/src/openultrasast/model/specs.py b/src/openultrasast/model/specs.py index 516d6fe..b58568d 100644 --- a/src/openultrasast/model/specs.py +++ b/src/openultrasast/model/specs.py @@ -122,6 +122,35 @@ class DominanceSpec: dischargers: tuple[str, ...] +@dataclass(frozen=True) +class ConfigSpec: + """What a configuration family's constant-abstraction query matches on, for one language. + + A config bug has no flow and no guard: ``CORS(app, origins="*")`` is dangerous because of the value it is + *set to*, not because anything reaches it. So the arbiter evaluates the argument against a closed + permissive set — the third form beside taint reachability and guard dominance. + """ + + family: str + language: str + settings: tuple[str, ...] + permissive: tuple[str, ...] + + +def config_specs(*, language: str, facts: ObligationFacts | None = None) -> Mapping[str, ConfigSpec]: + """One ``ConfigSpec`` per configuration family, from the obligation facts' security-setting operations. + + ``permissive_values`` on a ``non_permissive_value`` discharger is the closed set of literals that leave a + setting open — the one place in the facts where a value, rather than a call, is the evidence. + """ + scoped = (facts if facts is not None else load_obligation_facts()).for_language(language) + settings = tuple(sorted({call for fact in scoped.operations if fact.kind == "security_setting" for call in fact.calls})) + permissive = tuple(sorted({value for fact in scoped.dischargers for value in fact.permissive_values})) + if not settings or not permissive: + return {} + return {"config_secrets": ConfigSpec(family="config_secrets", language=language, settings=settings, permissive=permissive)} + + def taint_specs( *, language: str, @@ -184,6 +213,8 @@ def dominance_specs(*, language: str, facts: ObligationFacts | None = None) -> M __all__ = [ "GUARD_KINDS", + "ConfigSpec", + "config_specs", "GUARD_PATTERNS", "DominanceSpec", "TaintSpec", diff --git a/tests/test_model_config_value.py b/tests/test_model_config_value.py new file mode 100644 index 0000000..84544d5 --- /dev/null +++ b/tests/test_model_config_value.py @@ -0,0 +1,78 @@ +"""model-grounded-detection task 4.2 / Req 6.3: the configuration arm is constant abstraction, not taint. + +A config_secrets bug has no flow and no guard. `CORS(app, origins="*")` is not dangerous because untrusted +input reaches it — nothing reaches it — but because the value it is *set to* is permissive. So the arbiter is +abstract-value evaluation of the argument against the closed permissive set, which is the third form the +design names alongside taint reachability and guard dominance. +""" + +from __future__ import annotations + +from pathlib import Path + + +def _spec(): # type: ignore[no-untyped-def] + from openultrasast.model.specs import ConfigSpec + + return ConfigSpec(family="config_secrets", language="python", + settings=("CORS", "set_cookie", "app.run"), + permissive=("'*'", '"*"', "True", "0.0.0.0")) + + +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 test_a_permissive_literal_is_entailed() -> None: + from openultrasast.model.config_value import verdict + from openultrasast.model.ladder import Rung + + rows = [{"setting": 'CORS(app, origins="*")', "line": "3", "method": "create_app", + "literalArgs": ['"*"'], "args": ["app", '"*"']}] + answer = verdict(_cpg(rows), _spec(), function="create_app") + assert answer is not None and answer.rung is Rung.ENTAILED + assert "*" in answer.witness and "CORS" in answer.witness + + +def test_a_restrictive_literal_yields_no_finding() -> None: + from openultrasast.model.config_value import verdict + + rows = [{"setting": 'CORS(app, origins="https://app.example")', "line": "3", "method": "create_app", + "literalArgs": ['"https://app.example"'], "args": ["app", '"https://app.example"']}] + assert verdict(_cpg(rows), _spec(), function="create_app") is None + + +def test_a_non_literal_argument_is_corroborated_not_entailed() -> None: + """The value is computed, so constant abstraction cannot decide it — that residual is the LLM's.""" + from openultrasast.model.config_value import verdict + from openultrasast.model.ladder import Rung + + rows = [{"setting": "CORS(app, origins=configured_origins)", "line": "3", "method": "create_app", + "literalArgs": [], "args": ["app", "configured_origins"]}] + answer = verdict(_cpg(rows), _spec(), function="create_app") + assert answer is not None and answer.rung is Rung.CORROBORATED + + +def test_a_setting_that_is_not_in_the_spec_is_ignored() -> None: + from openultrasast.model.config_value import verdict + + rows = [{"setting": 'print("*")', "line": "1", "method": "create_app", "literalArgs": ['"*"'], "args": ['"*"']}] + assert verdict(_cpg(rows), _spec(), function="create_app") is None + + +def test_a_failed_query_is_not_read_as_a_safe_configuration() -> None: + from openultrasast.cpg.backend import CpgResult + from openultrasast.model.config_value import verdict + + assert verdict(CpgResult(cpg_path=Path("c.bin"), run=lambda q, p: None), _spec(), function="f") is None + + +def test_the_verdict_is_deterministic() -> None: + from openultrasast.model.config_value import verdict + + rows = [{"setting": 'set_cookie("s", secure=False)', "line": "9", "method": "login", + "literalArgs": ["True", "0.0.0.0"], "args": ["x"]}] + cpg = _cpg(rows) + assert verdict(cpg, _spec(), function="login") == verdict(cpg, _spec(), function="login")