mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 03:49:26 +00:00
feat(model): carried with provenance -- source-fed promotes a tier, parameter-fed only orders
Structural carried (v3) measured: pmpro 391 -> 69, wpstatistics 504 -> 172, mwwpform 513 -> 68 -- and tier 4 swollen to 555 and 569 regions, because "assigned from a parameter" was folded in with "assigned from a source" and every constructor does the former. The query now reports carriedKind; the vector promotes on "source" and gives "parameter" a within-tier weight of 0.75. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
ce49bd877c
commit
d1f37abadd
@@ -159,8 +159,27 @@ A phase that cannot show that is not an improvement, it is a story.
|
||||
regions; evidence pass 68 → 84 s) -- the first repository under the gate. vampi unchanged at 16. pmpro's
|
||||
v2 pass hit the scan's 300 s default query ceiling (v1 took 255 s; the join facts on its 1,040
|
||||
sink-bearing pairs cost more than the 45 s of headroom) and answered nothing, which the benchmark
|
||||
reported as `tiers={}` and the static order; the benchmark now sets its own ceiling and pmpro is
|
||||
re-measured.
|
||||
reported as `tiers={}` and the static order; the benchmark now sets its own ceiling (1,800 s). WP Statistics's v2 pass went the same way (v1 156 s,
|
||||
v2 past 300 s), so both are re-measured under it.
|
||||
|
||||
**Then the flow form was the wall, not the ceiling:** pmpro's v2 pass did not finish in 1,800 s either
|
||||
(the arbiter's `carried` is a `reachableByFlows` per candidate assignment, and 1,040 sink-bearing pairs
|
||||
of them). So `carried` in evidence mode became STRUCTURE (v3): a field the region reads is assigned, in
|
||||
its file, from a source expression, from a parameter of the assigning method, or from a same-file
|
||||
method that reads a source; a hook applied in scope has a registered callback holding one of those.
|
||||
Milliseconds again (pmpro 266 s, wpstatistics 168 s, mwwpform 69 s):
|
||||
|
||||
| repository | static | v1 (no carried) | v2 (flow carried) | v3 (structural) | tier 4 under v3 |
|
||||
|---|---|---|---|---|---|
|
||||
| pmpro | 391 | 116 | timed out | **69** | 555 |
|
||||
| wpstatistics | 504 | 223 | timed out | **172** | 569 |
|
||||
| mwwpform | 513 | 292 | **30** | 68 | 84 |
|
||||
| vampi | 16 | 16 | 16 | 16 | 1 |
|
||||
|
||||
The proxy is too loose: folding "assigned from a parameter" in with "assigned from a source" put 555
|
||||
and 569 regions in tier 4 -- every constructor assigns a field from a parameter -- and the CVEs sit in
|
||||
those tiers on score alone. v4 splits the provenance: source-fed (or fed by a same-file method that
|
||||
reads a source) promotes to tier 4; parameter-fed only orders within a tier (weight +0.75). Measuring.
|
||||
|
||||
- **Phase 3 — the LLM ranker.** Same evidence, ordered by a model. Gate: beats phase 2 leave-one-repository-out.
|
||||
If it does not, phase 2 ships and phase 3 does not.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -71,9 +71,9 @@
|
||||
val sinkCandidatesMemo = scala.collection.mutable.Map.empty[String, List[io.shiftleft.codepropertygraph.generated.nodes.Call]]
|
||||
val reachableMemo = scala.collection.mutable.Map.empty[(String, String, Int), Set[String]]
|
||||
val methodSourceMemo = scala.collection.mutable.Map.empty[String, Boolean]
|
||||
val fedFieldsMemo = scala.collection.mutable.Map.empty[String, Set[String]]
|
||||
val fedFieldsMemo = scala.collection.mutable.Map.empty[String, Map[String, String]]
|
||||
val sourceMethodsMemo = scala.collection.mutable.Map.empty[String, Set[String]]
|
||||
val callbackFedMemo = scala.collection.mutable.Map.empty[String, Boolean]
|
||||
val callbackFedMemo = scala.collection.mutable.Map.empty[String, String]
|
||||
|
||||
def rowsFor(
|
||||
sourcesS: String,
|
||||
@@ -543,9 +543,16 @@
|
||||
// applied in scope have a registered callback whose body holds a source or such a field read (WP
|
||||
// Statistics's `set_current_page`). Memoised per file and per callback for the batch. It orders; the
|
||||
// arbiter still decides with the flow.
|
||||
def prefixFed(code: String, fed: Set[String]): Boolean = {
|
||||
// Provenance matters: "source" (assigned from a source expression, or from a same-file method that
|
||||
// reads one) is the strong form and promotes a tier; "parameter" (assigned from a parameter of the
|
||||
// assigning method -- every constructor does this) is weak and only orders within one. Measured: with
|
||||
// the two folded together tier 4 held 555 of pmpro's regions and 569 of WP Statistics's.
|
||||
def strongest(kinds: Iterable[String]): String =
|
||||
if (kinds.exists(_ == "source")) "source" else if (kinds.exists(_ == "parameter")) "parameter" else ""
|
||||
|
||||
def prefixKind(code: String, fed: Map[String, String]): String = {
|
||||
val parts = code.split("->").map(_.trim).filter(_.nonEmpty)
|
||||
parts.length >= 2 && (2 to parts.length).exists(n => fed.contains(parts.take(n).mkString("->")))
|
||||
if (parts.length < 2) "" else strongest((2 to parts.length).flatMap(n => fed.get(parts.take(n).mkString("->"))))
|
||||
}
|
||||
|
||||
// Methods of a file whose body reads a source: one level of "assigned from a call". WP Statistics writes
|
||||
@@ -556,50 +563,56 @@
|
||||
methodsIn(fileName).filter(m => m.ast.isCall.exists(c => sourcePatterns.exists(p => c.code.contains(p)))).map(_.name).toSet
|
||||
)
|
||||
|
||||
def fedFields(fileName: String): Set[String] =
|
||||
def fedFields(fileName: String): Map[String, String] =
|
||||
fedFieldsMemo.getOrElseUpdate(
|
||||
fileName,
|
||||
methodsIn(fileName).flatMap { m =>
|
||||
val params = m.parameter.name.l.filterNot(_ == "this").toSet
|
||||
fileName, {
|
||||
val readers = sourceMethods(fileName)
|
||||
m.ast.isCall.nameExact(ASSIGNMENT).l.flatMap { assignment =>
|
||||
val args = assignment.argument.l
|
||||
if (args.size < 2) None
|
||||
else
|
||||
args.head match {
|
||||
case target: io.shiftleft.codepropertygraph.generated.nodes.Call if target.name == FIELD_ACCESS =>
|
||||
val rhs = args(1)
|
||||
val fromParameter = rhs match {
|
||||
case id: io.shiftleft.codepropertygraph.generated.nodes.Identifier => params.contains(id.name)
|
||||
case _ => false
|
||||
}
|
||||
val fromReader = readers.nonEmpty && rhs.ast.isCall.name.l.exists(readers.contains)
|
||||
if (fromParameter || fromReader || sourcePatterns.exists(p => rhs.code.contains(p))) Some(target.code.trim) else None
|
||||
case _ => None
|
||||
}
|
||||
}
|
||||
}.toSet
|
||||
)
|
||||
|
||||
def fieldCarriedStructurally: Boolean =
|
||||
cpg.call.nameExact(FIELD_ACCESS).filter(c => inScope(c.method)).l.exists(r => prefixFed(r.code.trim, fedFields(r.method.filename)))
|
||||
|
||||
def callbackFed(callback: String): Boolean =
|
||||
callbackFedMemo.getOrElseUpdate(
|
||||
callback,
|
||||
cpg.method.nameExact(callback).l.exists { m =>
|
||||
m.ast.isCall.exists(c => sourcePatterns.exists(p => c.code.contains(p))) || {
|
||||
val fed = fedFields(m.filename)
|
||||
fed.nonEmpty && m.ast.isCall.nameExact(FIELD_ACCESS).code.l.exists(code => prefixFed(code.trim, fed))
|
||||
}
|
||||
methodsIn(fileName).flatMap { m =>
|
||||
val params = m.parameter.name.l.filterNot(_ == "this").toSet
|
||||
m.ast.isCall.nameExact(ASSIGNMENT).l.flatMap { assignment =>
|
||||
val args = assignment.argument.l
|
||||
if (args.size < 2) None
|
||||
else
|
||||
args.head match {
|
||||
case target: io.shiftleft.codepropertygraph.generated.nodes.Call if target.name == FIELD_ACCESS =>
|
||||
val rhs = args(1)
|
||||
val fromParameter = rhs match {
|
||||
case id: io.shiftleft.codepropertygraph.generated.nodes.Identifier => params.contains(id.name)
|
||||
case _ => false
|
||||
}
|
||||
val fromSource = sourcePatterns.exists(p => rhs.code.contains(p)) || (readers.nonEmpty && rhs.ast.isCall.name.l.exists(readers.contains))
|
||||
if (fromSource) Some(target.code.trim -> "source")
|
||||
else if (fromParameter) Some(target.code.trim -> "parameter")
|
||||
else None
|
||||
case _ => None
|
||||
}
|
||||
}
|
||||
}.groupBy(_._1).map { case (field, kinds) => field -> strongest(kinds.map(_._2)) }
|
||||
}
|
||||
)
|
||||
|
||||
def hookCarriedStructurally: Boolean =
|
||||
hookTable.nonEmpty && hookApply.nonEmpty && cpg.call.filter(c => hookApply.contains(c.name)).filter(c => inScope(c.method)).l.exists { call =>
|
||||
val hook = call.argument.l.headOption.map(a => unquote(a.code)).getOrElse("")
|
||||
hookTable.getOrElse(hook, Nil).exists(callbackFed)
|
||||
}
|
||||
def fieldCarriedKind: String =
|
||||
strongest(cpg.call.nameExact(FIELD_ACCESS).filter(c => inScope(c.method)).l.map(r => prefixKind(r.code.trim, fedFields(r.method.filename))))
|
||||
|
||||
def callbackFedKind(callback: String): String =
|
||||
callbackFedMemo.getOrElseUpdate(
|
||||
callback,
|
||||
strongest(cpg.method.nameExact(callback).l.map { m =>
|
||||
if (m.ast.isCall.exists(c => sourcePatterns.exists(p => c.code.contains(p)))) "source"
|
||||
else {
|
||||
val fed = fedFields(m.filename)
|
||||
if (fed.isEmpty) "" else strongest(m.ast.isCall.nameExact(FIELD_ACCESS).code.l.map(code => prefixKind(code.trim, fed)))
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
def hookCarriedKind: String =
|
||||
if (hookTable.isEmpty || hookApply.isEmpty) ""
|
||||
else
|
||||
strongest(cpg.call.filter(c => hookApply.contains(c.name)).filter(c => inScope(c.method)).l.flatMap { call =>
|
||||
val hook = call.argument.l.headOption.map(a => unquote(a.code)).getOrElse("")
|
||||
hookTable.getOrElse(hook, Nil).map(callbackFedKind)
|
||||
})
|
||||
|
||||
// ---- EVIDENCE MODE: the same question without the dataflow (flow-aware-ranking, phase 0) --------------
|
||||
//
|
||||
@@ -630,13 +643,15 @@
|
||||
// flow form was a wall. It puts `_delete_files()` (fed by `$this->attachments`) and `record()` (fed by
|
||||
// a filter) where their sources say, instead of one tier below every region with a `$_GET` in its own
|
||||
// body. Asked only where there is a sink to carry to, so a plugin's 21,000 tier-0 pairs pay nothing.
|
||||
val carried = sinkList.nonEmpty && familyInRepo && (fieldCarriedStructurally || hookCarriedStructurally)
|
||||
val carriedKind = if (sinkList.nonEmpty && familyInRepo) strongest(List(fieldCarriedKind, hookCarriedKind)) else ""
|
||||
val carried = carriedKind.nonEmpty
|
||||
val summary = ujson.Obj(
|
||||
"kind" -> "summary",
|
||||
"sinks" -> sinkList.size,
|
||||
"sourceLocal" -> sourceLocal,
|
||||
"sourceNear" -> sourceNear,
|
||||
"carried" -> carried,
|
||||
"carriedKind" -> carriedKind,
|
||||
"familyInRepo" -> familyInRepo
|
||||
)
|
||||
val perSink = sinkList.map { sink =>
|
||||
|
||||
@@ -28,6 +28,7 @@ WEIGHTS: dict[str, float] = {
|
||||
"cleansed_sink": -0.5, # per sink call that is cleansed on the call or bound, up to CLEANSED_SINK_CAP
|
||||
"source_local": 2.0, # a modelled source in the region's own body
|
||||
"carried": 1.5, # stage one of the two-stage join says a field or dispatch value arrives tainted
|
||||
"carried_weak": 0.75, # a field assigned from a parameter of its method: every constructor does it
|
||||
"source_near": 1.0, # a modelled source within the arbiter's callDepth
|
||||
"entry": 0.5, # parameters untrusted by contract
|
||||
"access_declared_public": 1.0,
|
||||
@@ -63,7 +64,8 @@ class Evidence:
|
||||
source_near: bool = False # a modelled source within the arbiter's callDepth
|
||||
entry: bool = False # parameters untrusted by contract
|
||||
access_declared_public: bool = False # declared, not inferred -- "no decorator found" is not "public"
|
||||
carried: bool | None = None # stage one of the two-stage join; None where it was not computed
|
||||
carried: bool | None = None # stage one of the two-stage join, the STRONG form; None where it was not computed
|
||||
carried_weak: bool = False # the weak form: a field assigned from a parameter of its method. Orders, never promotes.
|
||||
bound_names: tuple[str, ...] = field(default=()) # sink names the shape test treats as bound at arity >= 2
|
||||
|
||||
@property
|
||||
@@ -108,6 +110,7 @@ class Evidence:
|
||||
+ w["cleansed_sink"] * min(sum(1 for s in self.sinks if s.cleansed_on_call or self.is_bound(s)), CLEANSED_SINK_CAP)
|
||||
+ (w["source_local"] if self.source_local else 0.0)
|
||||
+ (w["carried"] if self.carried else 0.0)
|
||||
+ (w["carried_weak"] if self.carried_weak and not self.carried else 0.0)
|
||||
+ (w["source_near"] if self.source_near else 0.0)
|
||||
+ (w["entry"] if self.entry else 0.0)
|
||||
+ (w["access_declared_public"] if self.access_declared_public else 0.0)
|
||||
@@ -158,7 +161,10 @@ def evidence_from_rows(
|
||||
access_declared_public=access_declared_public,
|
||||
# The query's own answer when it gave one (phase 2: stage one of the two-stage join, reused);
|
||||
# the caller's otherwise; None where neither knows, which is not False.
|
||||
carried=_as_bool(summary["carried"]) if "carried" in summary else carried,
|
||||
carried=(str(summary.get("carriedKind", "")) == "source" or (_as_bool(summary["carried"]) and "carriedKind" not in summary))
|
||||
if "carried" in summary
|
||||
else carried,
|
||||
carried_weak=str(summary.get("carriedKind", "")) == "parameter",
|
||||
bound_names=tuple(bound_names),
|
||||
)
|
||||
|
||||
|
||||
@@ -145,3 +145,33 @@ def test_the_query_may_answer_carried_itself() -> None:
|
||||
assert silent is not None and silent.carried is None and silent.tier == TIER_OPEN
|
||||
told = evidence_from_rows([{"kind": "summary", "sinks": 1, "sourceNear": True, "familyInRepo": True}, sink], carried=True)
|
||||
assert told is not None and told.carried is True
|
||||
|
||||
|
||||
def test_carried_provenance_promotes_only_when_source_fed() -> None:
|
||||
"""A field fed from a source (or from a same-file method that reads one) is the strong `carried` and
|
||||
promotes to tier 4; a field fed from a parameter of its method is the weak form, orders within the
|
||||
tier, and never promotes -- every constructor assigns a field from a parameter."""
|
||||
from openultrasast.model.evidence import TIER_OPEN, TIER_OPEN_PUBLIC, WEIGHTS, evidence_from_rows
|
||||
|
||||
sink = {
|
||||
"kind": "sink",
|
||||
"sink": "unlink($f)",
|
||||
"sinkLine": "259",
|
||||
"sinkMethod": "_delete_files",
|
||||
"sinkArity": 1,
|
||||
"sinkArg0Literal": False,
|
||||
"cleansedOnCall": False,
|
||||
}
|
||||
strong = evidence_from_rows(
|
||||
[{"kind": "summary", "sinks": 1, "sourceNear": True, "carried": True, "carriedKind": "source", "familyInRepo": True}, sink]
|
||||
)
|
||||
weak = evidence_from_rows(
|
||||
[{"kind": "summary", "sinks": 1, "sourceNear": True, "carried": True, "carriedKind": "parameter", "familyInRepo": True}, sink]
|
||||
)
|
||||
none = evidence_from_rows(
|
||||
[{"kind": "summary", "sinks": 1, "sourceNear": True, "carried": False, "carriedKind": "", "familyInRepo": True}, sink]
|
||||
)
|
||||
assert strong is not None and strong.tier == TIER_OPEN_PUBLIC and strong.carried is True
|
||||
assert weak is not None and weak.tier == TIER_OPEN and weak.carried is False and weak.carried_weak
|
||||
assert none is not None and none.tier == TIER_OPEN and not none.carried_weak
|
||||
assert weak.score - none.score == WEIGHTS["carried_weak"]
|
||||
|
||||
Reference in New Issue
Block a user