Commit Graph
7 Commits
Author SHA1 Message Date
Marius CiepluchandClaude Opus 5 eebca3f663 fix(model-grounded-detection): the absence arbiter was silencing its own family
Two bugs, together making 4.1 report "no bug" on a textbook IDOR. Both found by
running it against a real corpus pair rather than a fixture.

1. constraint_params were treated as dischargers. A field named `user_id` is
   what an IDOR is MADE OF -- `filter_by(id=user_id)` with user_id off the
   request is the bug, not the fix. The deleted checker only counted an identity
   constraint whose value's PROVENANCE was the authenticated context, so the
   parameter name alone evidences nothing. This is the mirror of group 2's
   sanitizer bug: a thing that must never count as a discharge, counted.

2. Guard tokens matched as substrings. With `user` in the token list, the string
   "SELECT * FROM users WHERE id = ?" contains `user`, so the query reported
   every operation in the handler as guarded. Matching is word-boundary now.

On threatbyte-api-v1-delete the arbiter previously found three operations, all
"guarded" by evidence like '"SELECT * FROM users WHERE id = ?"' and '(user_id,)',
and returned no verdict at all. After the fix, on the real pairs:

  threatbyte-api-v1-delete     vuln=model_corroborated  fixed=none
  vampi-users-update-password  vuln=model_corroborated  fixed=none

Both distinguished, deterministically, with no model call. Corroborated rather
than entailed is right for these: they are single-handler excerpts with no
guarded sibling to be inconsistent with, which is the asymmetry the rung
distinction exists to express.

identity_sources still discharge; request_sources and permissive_values remain
excluded because they describe the vulnerable shape rather than the fix.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-08 15:10:51 +02:00
Marius CiepluchandClaude Opus 5 d9ceeb6a09 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
2026-09-08 15:00:41 +02:00
Marius CiepluchandClaude Opus 5 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
2026-09-08 14:45:12 +02:00
Marius CiepluchandClaude Opus 5 e127e430ed feat(model-grounded-detection): parameter sources, so the measurement is like-for-like
Only 11 of the 50 injection pairs contain a framework source token at all. The
other 39 receive untrusted input as a FUNCTION PARAMETER, because SVEN-style
pairs are function-level extractions:

    def add_user(username, password):
        os.system("useradd ... -p " + encPass + " " + username)

A framework-anchored source model finds nothing there, which caps entailment on
this slice near 22% by construction -- a property of the source model, not of
the engine. More to the point, the flat-IR baseline this feature is measured
against already counted these (source_kinds: ["parameter"]), so a run without
them is not comparable to the 2.2% it is supposed to beat.

The flag is opt-in and off by default, because the two settings are right in
different places: in a function-level pair the function boundary IS the trust
boundary, while across a whole repository most parameters carry internal
values and treating them all as untrusted is not sound. The corpus harness
opts in; a repository scan does not.

On the pair that exposed this the model now resolves both flows, including one
through an intermediate binding:
    username -> os.system(...)                       2 steps
    password -> crypt.crypt(...) -> encPass -> ...   7 steps

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-08 12:56:13 +02:00
Marius CiepluchandClaude Opus 5 1765e51da1 feat(model-grounded-detection): the safe-shape test, without which the model arbitrates nothing
The first live run produced the finding that shapes this design: taint
reachability alone does not distinguish a fix from a bug. On the canonical SQL
pair the flow request.args["name"] -> name -> execute exists on BOTH sides --

  vuln   db.execute("select ... '" + name + "'")   one argument, interpolated
  fixed  db.execute("select ... = %s", (name,))    two arguments, bound

-- so the model entailed the fixed twin exactly as readily as the vulnerable
one, and arbitrated nothing. What separates them is the SHAPE of the sink call,
which is precisely what safe_shape_sinks preserved from the earlier sanitizer
fix. The query now reports sinkArity and sinkArg0Literal, and a flow ending at
a sink written in its declared-safe form is not a vulnerability.

Two bugs in that shape test, both found only against the real engine:
Joern's `call.argument` includes the RECEIVER at argumentIndex 0, so `db` was
counted as an argument -- making a one-argument interpolated call look like a
two-argument bound one, the exact inversion of the test -- and the "first
argument" whose literalness decides a format-string sink was the receiver
rather than the format. Both fixed with argumentIndexGt(0).

The rule is narrow on purpose: only sinks named in safe_shape_sinks are subject
to it (os.system(x) is not safe merely because it takes one argument), and a
row carrying no shape fields is judged on the flow alone -- an unknown shape
must never be read as a safe one.

End to end on the pair, deterministic and with no model call:
  vuln  -> model_entailed  request.args["name"] -> db.execute(...) (line 4)
  fixed -> None

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-08 12:51:42 +02:00
Marius CiepluchandClaude Opus 5 f59e0f5b5a fix(model-grounded-detection): two modelling bugs the first live Joern run exposed
Both were found by running the real engine, and neither was visible offline.

1. A sink was its own sanitizer. The shipped facts mark some sanitizers with a
   *shape* qualifier -- parameterized ("execute(sql, params) binds rather than
   interpolates") and literal_format_arg ("printf with a constant format string
   is safe"). These describe a safe FORM OF THE SINK CALL, not a call that
   cleans a value on the path. Flattening them into the sanitizer list made
   `execute` both the injection sink and its own sanitizer, so every SQL flow
   would have reported as already-clean and nothing could ever be entailed.
   Fixed as a class, not an instance: any fact carrying a shape qualifier is
   excluded from `sanitizers` and kept in `safe_shape_sinks` for the shape test
   task 4.2 will model properly. Fixing only the Python `parameterized` case
   would have left C's entire printf family -- which the generalised test then
   caught immediately.

2. labeled_family lost its deterministic derivation in group 1. The deleted
   classifier resolved a pair's family by declared label, else obligation, else
   mechanism, else CWE, and only asked an LLM when every link failed -- which
   for a labeled corpus was never. Group 1 reduced this to the declared label
   alone, which silently emptied every family whose pairs are labeled by CWE:
   the injection slice went to zero pairs. The chain is restored, still with no
   model call, and the family census again matches the committed entailment
   ceiling exactly (injection 50, memory 176, access_control 8, ...).

Also: the Joern seam now runs in the CPG's own scratch directory, because
`joern --script` writes a `workspace/` beside the working directory and would
otherwise leave one inside the repository being analysed.

The taint query itself was corrected against the live engine: the sink is the
call's ARGUMENTS (data flows into arguments, not into the call node) and
reachableByFlows is called on the sink with the source. With that, the
three-hop chain our flat IR could not resolve is found:
  request.args["cmd"] -> cmd -> "echo " + cmd -> full -> os.system(full)

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-08 12:45:42 +02:00
Marius CiepluchandClaude Opus 5 2b973dc0db feat(model-grounded-detection): tasks 2.1-2.2, the Joern seam and the taint verdict
The whole engine integration is one subprocess boundary: joern-parse builds a
cpg.bin, joern --script runs a shipped CPGQL query that prints its JSON between
fence markers, because Joern's stdout carries a banner and a REPL prompt around
whatever a script emits. Nothing is imported in-process, so the core install
keeps dependencies = [] -- a test walks the seam's AST to assert it imports
only the standard library and names no JVM binding.

Fail closed everywhere. A build or query that errors, times out, or prints
something unparseable yields no verdict and the caller reports suspicion. The
taint verdict distinguishes this from absence deliberately: a query returning
None means "the engine could not decide", which is not "there is no flow", and
conflating them is how a missing engine silently becomes a clean bill of health.

The verdict is where the ladder earns its keep. An unsanitized source->sink
path is model_entailed -- the graph establishes it and the LLM's claim is no
longer load-bearing. A path a sanitizer may break is model_corroborated: the
flow is real, but whether the sanitizer suffices is the semantic residual the
LLM answers, checked against this verdict rather than averaged over runs. The
chosen flow is picked by a total order (shortest, then lexicographic), so two
runs over one CPG cannot disagree -- Req 6.4 asserted directly.

The query itself is the part our flat IR could not do: reachableByFlows walks
the PDG, so `q = "..." + request.args["x"]; execute(q)` is found, which is
exactly the one-hop limit that held measured entailment at 2.2%.

Capability detection reuses semantic/engines.joern_available rather than
growing a second notion of "installed". The audit manifest is regenerated; the
new modules are named by Req 4/5/6 rather than counted as orphans.

Task 2.3, the go/no-go measurement, needs Joern installed and has not run.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-08 12:23:40 +02:00