mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-17 20:19:29 +00:00
ec3d1457fbd2c6c7a9420a33dfba6589bf38d2da
15
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
ec3d1457fb |
fix(rules): Node vm is an eval sink; a package name is not a sink token
mongo-express CVE-2019-10758 passes the request body to vm.runInNewContext. No
injection sink named that call, so the flow was answered and missed. runInNewContext,
runInContext, runInThisContext and compileFunction are now CWE-95 sinks.
Once the file's questions completed, the fixed pin showed a second defect. Every call
on `require('mongodb-query-parser')` has that package name as its full name. The
bounded-token match treats `-` as a boundary, so the name matched the SQL sink
`query`. In a full name, `-` belongs to a package name, so it no longer separates
tokens.
Exploratory (retune after protocol-v1): mongo-express vulnerable pin now detected at
lib/bson.js:60.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
|
||
|
|
ece4473c2b |
fix(scan): a hook callback's parameters are the registry's arguments, not the request's
The core-hook-argument class from the second PMPro adjudication: pmpro_membership_history_profile_fields($user) on `edit_user_profile` was a route entry, so its parameters were attacker input -- and $user is the WP_User WordPress loaded; its ID became an SQL injection. A callback registered in a string-keyed registry is handed its arguments by the matching apply call, not by the request. The mapper now records the registration call on the entry (`registered_by`), regions carry it, and the dispatch fact says which registrations DO hand a callback request-borne arguments (`request_arguments`; WordPress: add_shortcode, whose $atts are post content). Every other registered callback keeps its call depth but its parameters stop being sources; request data that reaches an apply call's arguments is still followed by the hook half of the taint query. REST routes are not registry entries and keep the parameter rule. Controls: the fact field is validated against `register`; a hook callback is not input, a shortcode and a plain route are. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |
||
|
|
7b8ff3cf44 |
fix(taint): a filter returns its value argument; a source is the access, not a call around it
The apply_filters class from the second PMPro adjudication (2 of 3
remaining false positives): `apply_filters('pmpro_random_code', $code,
$this)` returned whatever $this carried, and a report's `$type` reached SQL
only as an extra filter argument. Two causes, both fixed generally:
- The dispatch fact now says what an apply call RETURNS: `returns` names
the calls whose result is the dispatched value and `value_arg` its
position (WordPress: apply_filters/apply_filters_ref_array return
argument 2; do_action returns nothing). A path through such a call must
ENTER by that argument -- the first of the run of the call's arguments
before it, because the engine hops between sibling arguments
($_GET["c"] -> md5("x") -> apply_filters). Loaded and validated as data;
the query knows no framework.
- A call was a source when the pattern appeared anywhere in its text, so
`apply_filters(..., $_GET["c"])` and `sanitize_text_field($_POST[..])`
were sources themselves -- the "a token matches the thing it names"
invariant. A call is now a source when the pattern names its callee,
receiver or the access it is; the inner access stays a source.
Controls: dispatch facts load/validate; a real-Joern case with request
data as filter context (quiet) and as the filtered value (found). 1,453
host and 18 query tests pass.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
|
||
|
|
2c5fba55ef |
fix(semantic-facts): reach the deserialization form WordPress actually writes
Across four WordPress plugins and 1,164 PHP files there is exactly one deserialization site, and
it is spelled maybe_unserialize. The bare token never reached it, and correctly so: the sink
matcher is word-bounded and the character before unserialize inside maybe_unserialize is a word
character. A token a repository never spells is a family that can never answer, which is what the
2026-09-19 census recorded for this one. maybe_unserialize is a real entry point rather than a
safe wrapper: it calls unserialize whenever the string looks serialized.
Also adds the prototype control. That family had never established anything on any subject, and
its only census report was the word `set` inside a comment. No repository available to this
project contains the mechanism: the only first-party matches on the Node subject are
app.set("view engine", "html") and its sibling, Express settings whose arguments are literals. So
the control demonstrates what the family can do, on a merge of the request body into an object,
and asserts the Express setting is not reported alongside it.
Corrects the PHP record under task 13.2. joernio/joern#6281, this project's own report, is a PHP
`global` inside a closure producing a node with two AST parents, which made the dataflow overlay
throw and cost the whole graph. It was filed against 4.0.623 and fixed by #6270 in v4.0.625,
which the image has pinned since before any measurement recorded here. Verified today on the real
trigger rather than on a transcription of the repro: PMPro's includes/fields.php, the file the
original bisection found, builds and applies the default overlays cleanly on 4.0.625 and on
4.0.631 alike. What remains under that task is a runtime failure and must not be re-diagnosed as
the graph bug.
1,417 tests pass, eighteen skipped.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
|
||
|
|
f31ef862a7 |
fix(output-encoding): ask about the server half, and say what the family cannot decide
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
|
||
|
|
57f5a3f0dc |
fix(semantic-facts): give untrusted destination the clients a Node server calls
The family census recorded this family completing 44 questions on NodeGoat, finding the open redirect, and having no way to ask about the repository's documented SSRF: needle.get on a query-string URL at app/routes/research.js:16. A thin vocabulary is a silent false negative. One data change, no query change. The declared set now covers axios, got, needle, request, superagent, unirest, undici and fetch as bare module tokens, plus http.get, http.request, https.get and https.request dotted. Bare is the documented call form of each module and covers its verb methods too, since a token matches word-bounded against the callee and axios is bounded inside axios.get; axios.get and axios.post are dropped as subsumed rather than unwanted. http and https stay dotted because their namespace also holds createServer, and a bare token would have claimed every server a repository starts. The request is now reported at app/routes/research.js:16, model_entailed, witness `req.query.url + req.query.symbol -> needle.get(url, ...)`. This family goes from one finding to two on NodeGoat, every other family is unchanged, and 299 of 299 questions still complete. Measured on that graph, needle matches exactly one call and the eleven other clients match none in first-party or vendored code, so NodeGoat shows the gap closing and cannot show those clients' noise. A control asserts both halves. A handler calling axios.get with a literal and needle.get with req.query.url reports the tainted client alone, and it reads the shipped vocabulary rather than a copy, so removing the client fails it. The CWE-601 redirect entry is untouched: a Location-header redirect is value-shaped and stays outside what this family asks. 1,411 tests pass, eleven skipped; seven 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 |
||
|
|
6ba2b06353 | feat(pre-push-safety-net): exclude vendor inputs and partition frontend coverage | ||
|
|
b821ac6e4e |
feat(facts): the [[layout]] fact -- vendored trees are out, test paths are not product
Two decisions the maintainer made, as rows rather than constants. A vendored tree (vendor/, node_modules/, third_party/) is somebody else's unit: out of the targets in preprocess and out of the graph through the frontend's --exclude, which the scan hands to the backend because the CPG seam imports only the standard library. A test path (tests/, test-*.php, *Test.php) stays in scope -- a test that reaches a sink still names the sink -- but its regions are not shipped and order after the product's. Measured before the row: 207 of WP Statistics's 357 PHP files were vendored, and 14 of the 67 regions ahead of MW WP Form's CVE were test files. The position benchmark applies both, so it measures the order the scan spends. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG |
||
|
|
9cce5a3181 |
feat(cpg): give the files a frontend refuses a CPG of their own
A dropped file is dropped from the ONLY graph there is, so every question
about it has no answer -- which is how a 637-file plugin became a scan of
nothing. But those files build perfectly well alone: the failure is
per-invocation, not per-file. So a file the main build refuses is excluded
from it, given a second CPG, and both are queried with rows concatenated per
request id.
pmpro two-file slice before: cpg_empty, 0 findings
after: 18 entailed incl. CVE-2023-23488 at :936,
reported with cpg_sharded
What this does NOT buy is a flow that crosses the shard boundary: a source in
the excluded file whose sink is in the rest of the tree is invisible to every
question asked, and no merging of results recovers it. That is why the split
is REPORTED rather than quietly served as if it were whole.
The excluded-file tree is symlinks at the original relative paths. A region
asks about `classes/class.memberorder.php` and the queries match a filename by
suffix, so a flattened copy would cheerfully answer about a file nobody asked
about.
Reports gain "What could not be analysed", rendering cpg_sharded, cpg_empty,
files_unparsed, query_failed and budget_exhausted in the reader's terms. A
degradation is a fact about coverage, not a warning to file away: silence in
the coverage table means "no fact table covered this", and silence about an
empty or split graph means something else entirely.
Also removes the WordPress vocabulary from the query, which was the other half
of this request. `apply_filters` was a hardcoded Set in taint.sc and
`add_action|add_filter|add_shortcode` a hardcoded alternation in mapping.py.
Both are now a `[[dispatch]]` fact:
[[dispatch]]
id = "wordpress_hooks"
register = ["add_action", "add_filter", "add_shortcode"]
apply = ["apply_filters", ..., "do_action", ...]
The shape is general -- a registry keyed by a string literal at both ends, as
Django signals, jQuery events and Symfony's dispatcher all are -- so a second
framework is a row in a TOML rather than an edit to a query. `dispatch_apply`
is empty for every language that declares none, and the hook half then does
nothing at all rather than looking for names that mean nothing there.
848 tests pass; gates byte-identical. wpstatistics 3/2 with the CVE at :225 on
the vulnerable side only, mwwpform 1/0, pmpro 18/1, VAmPI 4 unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
|
||
|
|
922ed84053 |
feat(repos): a WordPress plugin with a verified CVE, and the WordPress DB API
contributor-scan 5.1's detection half. Paid Memberships Pro 2.9.7 pinned at
1a900be3, carrying CVE-2023-23488 -- unauthenticated SQL injection through
the `code` parameter of the /pmpro/v1/order REST route.
**Verified in the code, not taken from the advisory.** At this commit
classes/class.memberorder.php:936 reads
$id = $wpdb->get_var("SELECT id FROM $wpdb->pmpro_membership_orders
WHERE code = '" . $code . "' LIMIT 1");
and tag 2.9.8 replaces it with `$wpdb->prepare( "... code = %s ...", $code )`.
637 PHP files and 117k lines, so it is also the first PHP checkout large
enough to say anything about an envelope.
**WordPress never calls mysqli; it goes through $wpdb**, so the facts could
not have found this. Added qualified: `$wpdb->get_var`, `->get_results`,
`->get_row`, `->get_col`, `->query`, `->replace`. Qualified because a bare
`get_var` or `query` matches any method of that name anywhere -- the
generic-name trap that made `query.get` match every `.get(` in a repository
this morning. `->insert`, `->update` and `->delete` are deliberately absent:
they take arrays and WordPress prepares them itself.
**And the thing that nearly went wrong.** The obvious next step when
writing PHP facts is to add WordPress's sanitizers -- sanitize_text_field,
esc_html, esc_attr. Doing so would have made this CVE invisible, because
its handler calls `sanitize_text_field( $params['code'] )` and then
concatenates the result into a query. sanitize_text_field is a real
sanitizer that does nothing whatsoever about SQL injection.
`TaintSpec.sanitizers` is one flat list per language whose comment states
the assumption outright -- "a sanitizer that breaks one flow breaks it
whatever the sink's family" -- and PHP falsifies it. So only SQL-specific
cleansers are listed, which leaves XSS under-sanitized instead, and the
real fix is recorded as task 5.6: a discharge counts for the obligation it
discharges. That is task 2.6's finding arriving in the taint family, where
the dominance arbiter already reads kind from the facts and the taint spec
has no equivalent.
The end-to-end scan of this checkout is not yet reported: the first run
returned 1577 regions with 0 scanned, and the CPG builds standalone at
4.3MB with no errors, so the two do not yet agree. Numbers when they do.
Gates byte-identical.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
|
||
|
|
61811512a8 |
feat(facts): PHP semantic facts, validated against a real PHP CPG
contributor-scan 5.1, the first step toward the v0.1 WordPress target. Written against a PHP CPG rather than against PHP source, because the two do not look alike. Verified with joern-parse 4.0.623 / php2cpg on a probe: $_GET["name"] -> call `<operator>.indexAccess`, CODE $_GET["name"] mysqli_query($conn, $sql) -> call NAMED mysqli_query echo $_GET["msg"] -> call NAMED echo So sources match on code and sinks match on name, and every entry the probe did not exercise is marked unverified in the file -- `include` and `require` among them, which is PHP's most characteristic path bug and the first thing to check on a real checkout. Five families derive (injection, output_encoding, path, deserialization, untrusted_destination) with no sink that is its own sanitizer -- asserted by a test, because that bug class has now appeared three times in three matchers. `php` is added to the region model's languages, without which the facts would have reached nothing. The arbiter decides 4 of 4 on the probe: the SQL concat entails, its `mysqli_real_escape_string` twin drops to corroborated, `echo` XSS entails and `system` command injection entails. **A whole scan finds 2 of those 3, and the gap is the finding.** PHP files yield FILE-LEVEL regions because no PHP entry points are mapped, and the taint arbiter returns one verdict per region per family -- so a file-level region reports at most one injection finding however many it holds. `system` won on flow length and the SQL injection went unreported. That is the mirror image of task 2.9: that one is many regions collapsing to one site, this is many sites collapsing to one verdict, and it bites hardest exactly where PHP needs it not to, since a WordPress file is large and has no decorator for the mapper to find. Recorded as task 5.4 with WordPress's registration model (add_action, add_filter, admin-ajax, REST routes), which the mapper already handles in principle for other frameworks. A 13-line probe is not a WordPress result. What is established is that the seam works end to end for PHP; everything about scale and coverage is unmeasured, and there are still zero PHP pairs in a corpus of 263 python, 351 c_cpp, 47 javascript and 49 java. Gates byte-identical. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG |
||
|
|
fcb12e116a |
feat(model-grounded-detection): Java web sinks, and the post-closure-fix ceiling
Java shipped with injection, deserialization and crypto sinks only, so the path and output_encoding families had nothing routing to them by CWE and any Java pair in either was unreadable -- the gap hutool-cve-2018-17297 reported as "no path spec for java". Adds the JDK filesystem entry points, the servlet response writers, the outbound/redirect APIs, and the ESAPI / canonical-path sanitizers the OWASP suite itself uses. Documented APIs, provenance per entry. Post-closure-fix ceiling: 41.3% entailed (38/92) and 26.1% distinguished, up from 39.1% / 23.9%. path went 0/8 to 1/8 with its first distinguished pair; injection 29->31 entailed and 18->19 distinguished. Two points -- real, and the mechanism is understood, but the remaining gaps are data rather than machinery. Carrying a correction: the measurement harness routed config_secrets through TAINT rather than the constant-abstraction arm, so its 0/10 in this artifact measures the wrong arbiter and did so in both full runs. The harness is fixed; that family's real number is the earlier targeted re-measure, and this artifact's config row should not be read. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG |
||
|
|
d651455d23 |
fix(model-grounded-detection): closure scoping, and the three bugs it uncovered
The group-4 finding: JavaScript is callback-heavy, so the method containing a
sink routinely is not the labeled function. `fs.readFileSync` inside the
callback passed to `fs.stat` has enclosing method `<lambda>0`, and filtering on
`sinkMethod == function` dropped it -- the whole path family measured 0/8.
Scope is now decided by line-range containment within the same file
(astParentFullName is empty for lambdas, so nesting is not readable there), and
reported as `inLabeledScope`; the Python side trusts that flag and keeps the
name comparison only as a fallback, so an engine that cannot report scope never
has its sinks silently treated as scoped. Parameter sources stay bound to the
labeled function's OWN parameters -- a callback's `err`/`stats` are not
attacker input.
Fixing the scope exposed three further bugs, each of which would have raised
the number while making the answer untrue:
* Shortest-flow-wins picked the wrong witness. With closures in scope the
same sink has flows from `req.url...` (10 steps, the vulnerability), `res`
(3) and `this` (7). A flow whose source matches the source model now
outranks a shorter one from a bare captured name; shortest still breaks
ties, so the order stays total and the verdict deterministic.
* The JS fact file had NO sanitizers at all, so no JavaScript fix of any
family could ever be recognised. Added the documented path-normalisation
and HTML-escaping APIs, with provenance.
* Adding them regressed the witness, because `resolveUrl` CONTAINS `resolve`
and the substring test marked the vulnerable flow sanitized. This is the
same bug class already fixed in dominance.sc and not carried across -- the
exact fix-the-instance-miss-the-class failure. taint.sc now matches on word
boundaries too.
And the principled rule the fix turns on: parameter sources are a FALLBACK,
never an override. When a modelled source reaches the sink, those flows are the
evidence; otherwise a captured `res` reaching a sanitized sink reports the fix
as still vulnerable on a flow that names no attacker input.
Also recorded: req.url was NOT wrong as a diagnosis, it was untestable in
isolation -- the scoping bug masked it. Both were needed. On the pair that
motivated all of this:
vuln model_entailed req.url.split('?')[0] -> readFileSync(possibleFilename)
fixed model_corroborated same flow, sanitized by path.normalize
distinguished: true
700 passing, mypy/ruff clean, gates byte-identical.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
|
||
|
|
fec67253f3 |
feat(model-grounded-detection): 4.1 guard dominance, 4.2 the missing sink facts
4.1 is the arbiter for the class no crash oracle reaches. An access-control bug
is the ABSENCE of a guard: no flow to follow, no crash to reproduce. What the
model can establish is a consistency violation -- an obligated operation no
discharging guard governs, in a file where its siblings are governed by one.
"Governed" turned out not to be one relation, and asking pure CFG dominance of
the operation gets two of three shapes wrong. Measured against the real engine:
identity constraint the guard is INSIDE the operation's arguments
filter_by(owner_id = current_user.id)
identity branch abort(403).controlledBy == [note.owner_id != current_user.id]
-- the check runs AFTER the fetch, so it never dominates
the operation, but it controls whether the value escapes
dominating guard a discharger-naming call that dominates the operation
That is why the query uses controlledBy rather than dominatedBy for shape 2.
Two bugs found by running it, both the same class as the sanitizer bug in group
2 -- part of a fact flattened, the part carrying the signal dropped:
* dominance_specs used only `calls` and `decorators`, omitting
identity_sources (current_user, request.user) and constraint_params
(owner_id, user_id) -- which is how an identity constraint is actually
written. The arbiter reported "no guard" on handlers that plainly had one.
request_sources and permissive_values stay excluded: they describe the
vulnerable shape, and including them would let the arbiter read a bug as
its own fix.
* matching operations by code containment yielded four or five desugared rows
per operation (the bare `tmp1.filter_by` receiver among them, carrying none
of the guards the real call has), so a guarded operation could be flagged.
Matching is by call name now.
Live, deterministic, zero model calls:
leaky -> model_entailed no discharging guard, while 2 sibling handlers are guarded
edit_note -> no finding (ownership check controls the denial)
list_notes -> no finding (identity constraint in the query)
4.2 authors the sink facts the go/no-go measured as its largest miss class (11
of 23): Django ORM raw SQL, and the four families that previously had no fact
routing to them by CWE at all -- path, output_encoding, untrusted_destination,
prototype. Each entry is a documented dangerous API of the named library with
its provenance recorded inline, and rule_ids left empty because a sink fact is
not a detection rule. Flagged for maintainer review as data, given the standing
"no LLM-authored patterns" constraint. The exact-set assertion written in task
1.1 to hold this gap open is updated rather than deleted, so it still fails if a
family ever loses its seed.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
|
||
|
|
1ae41eea19 |
feat(propose-adjudicate-prove): overlay adjudicates inventory before prove
Regex inventory stays the proposer. MAP attaches promote/demote/unadjudicated/coverage from intra-file taint (tree-sitter when a grammar parses, Python ast as last resort). Deep sandboxes promotions only, ordered by the existing heuristic ranker plus optional OpenRouter mechanism recall. The SAST pair slice scores overlay outcomes; the stage-1 detection gate and local pair gate stay inventory-only. |