mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 03:49:26 +00:00
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) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
This commit is contained in:
co-authored by
Claude Opus 5
parent
fec67253f3
commit
d9ceeb6a09
@@ -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"
|
||||
|
||||
@@ -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---")
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user