mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 11:49:28 +00:00
The census recorded this family completing 44 questions on a Node application and finding
nothing, which is what a client-only vocabulary must do there: all six declared sinks were DOM
sinks. NodeGoat's cross-site scripting is server-side.
Decision: the family asks about a value reaching a rendering call, and states in its own
declaration that it cannot decide template-mediated escaping. Whether res.render is safe depends
on the engine's autoescape setting and on the template's markup, neither of which is at the
call, so res.render is not a sink and the family's limits field says so. A new CWE-79 sink
covers res.write, res.end, res.send and the response.* spellings; res.json stays out because an
application/json body is not a document.
Template auto-escaping is claimed where its evidence is, as a configuration defect:
swig.setDefaults, nunjucks.configure and marked.setOptions are security settings now.
It establishes a finding: app/routes/research.js:25, model_entailed,
`req.query.url + req.query.symbol -> res.write(body)`. The handler fetches a URL the caller
chooses and writes the response body into a text/html response, so the document served from
this origin is the attacker's. The record states how coarse the step is: the path from url to
body runs through the client's callback parameter, which the graph treats as reachable from the
call's arguments, so the conclusion rests on the call's semantics rather than on what the graph
established. Five of seven families now establish findings.
Four defects were found on the way, three of them false positives this change would otherwise
have shipped.
- The permissive-value set was shared across every setting, while a boolean's polarity belongs
to the setting: true leaves the door open in cors({origin: true}) and closes it in
swig.setDefaults({autoescape: true}). Adding the template engines would have reported the fix
as the defect on the line that offers it. ConfigSpec now carries per-setting values, and
Jinja2's Environment moved out of Python's shared list for the same latent reason.
- config.sc matched a dotted setting on its trailing segment alone, so marked.setOptions would
have claimed an editor's setOptions. dominance.sc already required the qualifier.
- taint.sc asked its flow query of every argument including the receiver at index 0. On a
response sink the receiver is the response object, so res.end() -- which takes no data -- was
reported exactly like the res.write(body) two lines above it. Flows now use the real
arguments, as arity already did.
- The arbiter sorted a file's settings by line number as TEXT, so line 126 preceded line 78 and
which setting a file reported depended on the spelling of its line numbers.
Template escaping disabled at server.js:135 is detected, measured model_entailed against the
real query and the real arbiter, and still not reported: the arbiter returns one verdict per
question and that file has an earlier permissive setting. Task 14.6 carries it rather than
hiding it.
1,415 tests pass, thirteen skipped; nine Joern-gated query tests pass in the image.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
217 lines
8.9 KiB
Python
217 lines
8.9 KiB
Python
"""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")
|
|
|
|
|
|
# --- framework coverage and the weak-algorithm arm --------------------------------------------------------
|
|
#
|
|
# The settings table was Flask/FastAPI-shaped (CORS, set_cookie, app.run) while the corpus contains aiohttp
|
|
# and Django. Separately, config_secrets spans three abstractions and only one was wired: a permissive
|
|
# setting VALUE. Weak algorithm CHOICE (CWE-326/327) has its data already -- `weak_literals` on the hashlib
|
|
# sink fact -- and nothing read it.
|
|
|
|
|
|
def test_the_settings_table_covers_the_frameworks_in_the_corpus() -> None:
|
|
from openultrasast.model.specs import config_specs
|
|
|
|
settings = set(config_specs(language="python")["config_secrets"].settings)
|
|
for framework, call in (
|
|
("aiohttp", "session_setup"),
|
|
("aiohttp", "EncryptedCookieStorage"),
|
|
("django", "SECURE_SSL_REDIRECT"),
|
|
("flask", "set_cookie"),
|
|
):
|
|
assert call in settings, f"{framework}'s {call} is not a modelled security setting"
|
|
|
|
|
|
def test_a_weak_algorithm_is_entailed_by_its_literal() -> None:
|
|
"""CWE-327: the danger is the algorithm NAMED, not a permissive flag."""
|
|
from openultrasast.model.config_value import verdict
|
|
from openultrasast.model.ladder import Rung
|
|
from openultrasast.model.specs import config_specs
|
|
|
|
spec = config_specs(language="python")["config_secrets"]
|
|
rows = [{"setting": 'hashlib.new("md5")', "line": "4", "method": "digest", "literalArgs": ['"md5"'], "args": ['"md5"']}]
|
|
answer = verdict(_cpg(rows), spec, function="digest")
|
|
assert answer is not None and answer.rung is Rung.ENTAILED
|
|
assert "md5" in answer.witness.lower()
|
|
|
|
|
|
def test_a_strong_algorithm_yields_no_finding() -> None:
|
|
from openultrasast.model.config_value import verdict
|
|
from openultrasast.model.specs import config_specs
|
|
|
|
spec = config_specs(language="python")["config_secrets"]
|
|
rows = [{"setting": 'hashlib.new("sha256")', "line": "4", "method": "digest", "literalArgs": ['"sha256"'], "args": ['"sha256"']}]
|
|
assert verdict(_cpg(rows), spec, function="digest") is None
|
|
|
|
|
|
def _javascript_spec(): # type: ignore[no-untyped-def]
|
|
"""The SHIPPED JavaScript spec, so these read the facts rather than a copy of them."""
|
|
from openultrasast.model.specs import config_specs
|
|
|
|
return config_specs(language="javascript")["config_secrets"]
|
|
|
|
|
|
def test_disabling_template_escaping_is_entailed() -> None:
|
|
"""The setting NodeGoat's cross-site scripting turns off, claimed where the evidence is: the value."""
|
|
from openultrasast.model.config_value import verdict
|
|
from openultrasast.model.ladder import Rung
|
|
|
|
rows = [
|
|
{
|
|
"setting": "swig.setDefaults({ autoescape: false })",
|
|
"line": "135",
|
|
"method": "<module>",
|
|
"literalArgs": ["false"],
|
|
"args": ["{ autoescape: false }"],
|
|
}
|
|
]
|
|
answer = verdict(_cpg(rows), _javascript_spec(), function="<module>")
|
|
assert answer is not None and answer.rung is Rung.ENTAILED
|
|
assert "swig.setDefaults" in answer.witness and "false" in answer.witness
|
|
|
|
|
|
def test_enabling_template_escaping_is_not_a_finding() -> None:
|
|
"""The fix must not be the defect.
|
|
|
|
`true` is in the general permissive set, because `cors({origin: true})` is how a permissive CORS policy is
|
|
written. Escaping reverses that polarity, and with one shared set this repaired line would have been
|
|
reported on exactly the code that repairs it -- the commented-out `autoescape: true` two lines below
|
|
NodeGoat's defect.
|
|
"""
|
|
from openultrasast.model.config_value import verdict
|
|
|
|
rows = [
|
|
{
|
|
"setting": "swig.setDefaults({ autoescape: true })",
|
|
"line": "135",
|
|
"method": "<module>",
|
|
"literalArgs": ["true"],
|
|
"args": ["{ autoescape: true }"],
|
|
}
|
|
]
|
|
assert verdict(_cpg(rows), _javascript_spec(), function="<module>") is None
|
|
|
|
|
|
def test_a_setting_whose_polarity_is_unchanged_still_reports_true() -> None:
|
|
"""The control for the other direction: scoping must not quiet the settings it was not about."""
|
|
from openultrasast.model.config_value import verdict
|
|
from openultrasast.model.ladder import Rung
|
|
|
|
rows = [
|
|
{
|
|
"setting": "cors({ origin: true })",
|
|
"line": "12",
|
|
"method": "createApp",
|
|
"literalArgs": ["true"],
|
|
"args": ["{ origin: true }"],
|
|
}
|
|
]
|
|
answer = verdict(_cpg(rows), _javascript_spec(), function="createApp")
|
|
assert answer is not None and answer.rung is Rung.ENTAILED
|
|
|
|
|
|
def test_the_first_setting_in_a_file_is_the_one_reported() -> None:
|
|
"""One verdict is returned per question, so which row comes first decides what a file reports.
|
|
|
|
The line sorted as TEXT, so line 126 came before line 78 and a file's reported setting depended on the
|
|
spelling of its line numbers. NodeGoat has a permissive session at line 78 and template escaping disabled
|
|
at line 135, and which one surfaced flipped on that comparison.
|
|
"""
|
|
from openultrasast.model.config_value import verdict
|
|
|
|
rows = [
|
|
{"setting": "swig.setDefaults({ autoescape: false })", "line": "135", "method": "m", "literalArgs": ["false"], "args": []},
|
|
{"setting": "cors({ origin: true })", "line": "78", "method": "m", "literalArgs": ["true"], "args": []},
|
|
]
|
|
answer = verdict(_cpg(rows), _javascript_spec(), function="m")
|
|
assert answer is not None and "line 78" in answer.witness, answer.witness
|