Files
Open_Ultra_SAST/.kiro/specs/access-control-prover/research.md
T

16 KiB

Research & Design Decisions

Summary

  • Feature: access-control-prover
  • Discovery Scope: Extension of authorization-obligations (checker, facts, dominance) and the hunter's per-change context; gap analysis against the code on 2026-10-14 (commit 9feb8f1).
  • Key Findings:
    • The checker already takes paths and entries and already decides reached-ness and along-path dominance (semantic/obligations/check.py::_reached, dominance.py::_dominates_along); nothing supplies paths today, so every operation outside an entry handler is invisible to it.
    • The entry-point mapper (mapping.py) recognises Flask, Django, Express, Laravel routes, WordPress hooks, Next and Remix route modules partially, and nothing for Java: no Stapler doXxx web method, no Servlet or Jetty handler, no JAX-RS resource. Seventeen of the pool's 23 fixes are Stapler web methods.
    • The hunter's Context (hunt/producers.py) caches product files, texts, file IR, a call index and the repository's entry points per change; source.callers_of gives one hop of callers from the index. A bounded upward walk from a function to an entry point is a loop over that, no new index.
    • Enforcement outside the function fits the existing Discharge scopes router and decorator, which OrderDominance.dominates accepts without a line, through the checker's extra_witnesses parameter. No schema change to the facts loader is needed for them; the facts files gain operation rows only.
    • benchmarks/valid/metrics.py scores replay-shaped records with benchmarks.unseen.score; the hunter arm (benchmarks/valid/hunter_arm.py) writes that shape. A prover arm writing the same shape is scored by the same code, so its recall and false alarms per 100 stand beside the hunter arms.
    • The pool's fixes by shape: 17 Jenkins web methods (doTestConnection, doFill...Items, doCheck..., doValidate..., doChangeSubmit, doNotifyCommit, doPython, persistValue, create) missing checkPermission; one Jetty service without authentication (CWE-306); one Tornado terminal websocket open without authentication (CWE-306); one Laravel Livewire store without authorization (CWE-285); one Symfony controller action (CWE-284); one Remix action deactivating a schedule by id without an ownership check (CWE-639); one Flarum JavaScript extension (CWE-863, out of reach for this prover: client-side permission gating, counted as uncovered).

Research Log

Which checker parameters exist and what they expect

  • Context: avoid a second checker.
  • Sources Consulted: src/openultrasast/semantic/obligations/check.py (check_obligations, _reached, _relevant), dominance.py (OrderDominance, _dominates_along, _hop_index), the approved design of authorization-obligations (Dominance, Checker, EntryPoints sections).
  • Findings: paths is a sequence of objects with a hops attribute; each hop is "path::function" or a (node, line) pair; a path reaches an operation when its last hop equals f"{op.path}::{op.function}". With no paths, only operations inside entry handlers (entries) are reached. A witness of scope hop on an earlier hop dominates along the path. Witness scopes decorator and router dominate without a line.
  • Implications: the prover produces PathRecord(hops=(...)) objects ending at the operation's function and passes them as paths; it produces router and decorator witnesses as extra_witnesses; it needs no change to the checker's logic. One gap: the checker builds operations from the IRs it is given, so the prover passes the IR of every file on a path, not only the touched file.

Where entry points come from

  • Sources Consulted: src/openultrasast/mapping.py (analyze_entry_points, _entry, the per-framework recognisers around lines 260-930), EntryPointRecord fields.
  • Findings: recognisers are code, one block per framework, each calling _entry(target, start, end, handler, name, kind, access, trust_boundary, evidence, conditions, ...). No Java recogniser exists. frameworks.toml is a lexicon for priors and stratification, not for entry recognition.
  • Implications: add recognisers in mapping.py following the existing block pattern (Stapler, Jetty, Symfony attributes, Livewire, Tornado, Remix exports where missing). Each names its evidence.

How a rule arm is scored

  • Sources Consulted: benchmarks/valid/metrics.py (summarize, scoring_records), benchmarks/unseen/score.py (catches, location_match, false_alarm, coverage), benchmarks/valid/hunter_arm.py (run, record writing), src/openultrasast/hunt/metric.py::search_outcome.
  • Findings: a record needs id, kind, repo, family, the change's path/function_lines (copied from the pool), findings with path, line, family, and coverage fields the scorer reads; a push is caught when a finding of the change's family lands within the tolerance of a verified location.
  • Implications: the prover arm copies the hunter arm's record envelope and instrument fields (bytes read per changed file, head verified), writes per-change files, and calls benchmarks.valid.metrics.summarize.

Budget

  • Findings: the second screen's feature table took 53 minutes for 483 changes with every producer, 7 seconds per change on average and 117 seconds for the slowest seatunnel change; the caller index is the dominant cost and is already cached per change. A depth-3 upward walk adds a few index lookups per function.
  • Implications: the 120-second bound of Requirement 6 is realistic; the runner measures it per change.

Architecture Pattern Evaluation

Option Description Strengths Risks / Limitations Notes
Extend the checker's inputs (chosen) Supply paths, entries and witnesses to check_obligations from the hunter's context One checker, one dominance rule, facts stay closed data Order dominance is textual inside a function; nesting-aware dominance (guard-dominance-regime) never existed Honest for "check present before operation"; the Jenkins shape
New prover module with its own graph Build a call graph and CFG per change Precise dominance Weeks of work, duplicates the checker, no CFG for Java or PHP in the tree Rejected for this increment
Joern engine Dataflow and call graph from the engine Precise 225 s per batch, pilot produced no findings, no PHP in the budget Rejected as the gate; usable later as a proof backend

Design Decisions

Decision: paths from the caller index, not a call graph

  • Context: Requirement 3.
  • Alternatives Considered: a per-change call graph over all product files; the engine's call graph.
  • Selected Approach: an upward walk from the function through source.callers_of on the cached index, bounded by depth and fan-out, stopping at the first entry point of each chain.
  • Rationale: the index exists, is cached per change, and the pool's shapes are shallow (the operation is in the web method or one helper below it).
  • Trade-offs: name-based caller resolution conflates same-named functions across files; chains are kept per file but a caller of save in another class may be a false hop. Accepted and visible in the evidence.
  • Follow-up: measure how many positives are reached at depth 1, 2 and 3 on the pool.

Decision: framework enforcement as router and decorator witnesses

  • Context: Requirement 2.
  • Selected Approach: a small recogniser per framework reads route registrations and attributes and emits Discharge witnesses with scope router or decorator for the handlers they cover; passed through extra_witnesses.
  • Rationale: the dominance rule already accepts those scopes; the facts files stay about calls.
  • Trade-offs: coverage is as wide as the recognisers; an unrecognised middleware name means a finding on a guarded route. Every recogniser lists its names in the facts header and the coverage statement counts routes with unrecognised middleware.

Decision: an opt-in premise in the checker, not a synthesized policy

  • Context: Codex's first attempt stopped on a real conflict: with path records and neither a declared clause nor a sibling norm the checker returns no finding by design (gating 3), so the prover's core verdict could not be emitted without a change.
  • Alternatives Considered: synthesize a DeclaredPolicy in memory marking every recognised entry authenticated (dishonest: the finding would read "route declared authenticated" when nothing was declared); a second checker in the prover (duplicates the rules); an opt-in keyword and a distinct label (chosen).
  • Selected Approach: assume_entries_protected=False keyword, label unguarded_path, evidence naming the premise. Default behaviour and tests unchanged.
  • Trade-offs: the label makes the premise visible in every finding and in the static tags; consumers that grouped by label need the new value (revalidation trigger for authorization-obligations).

Decision: no model call inside the prover

  • Context: Requirement 4.4; the maintainer's direction after the engine pilot: the model's job is the trust boundary question the static side cannot answer, asked after a static result exists.
  • Selected Approach: the prover is deterministic; the intent adjudication hook of the checker stays off in the arm; a later increment may wire it as a separate, measured arm.

Risks & Mitigations

  • Jenkins web methods whose protected operation is an outbound connection test with stored secrets (doTestConnection) call no listed operation name — mitigate by adding operation rows for credential lookups and HTTP client calls used in those handlers, checked in the clones, and by counting the handlers the facts do not cover.
  • Textual order dominance inside a function misses a check in an else branch — accepted for this increment; recorded as a degradation when a witness exists but does not dominate.
  • Same-name conflation in callers — chains kept per file; evidence names the hop files.
  • Over-wide entry recognition (every doXxx) raises false alarms on helper methods — the Stapler recogniser requires a public method on an exposed type or a Stapler-typed parameter; the pool's ordinary pushes measure the rate.

References


Design amendment synthesis -- 2026-10-29

Context: gates 1-3 (measurements 2026-10-28-access-control-{prover,judge,ablation}) found one cross-cutting lever -- interprocedural resolution -- behind both the 8 recall misses (B: operations never linked; C: coarse guards discharge) and the kept false alarms (guards present but unmodeled, name-collision resolution, base-class projection). Requirements amendment [2026-10-29] (AC 1.9, 2.9, 2.10, 3.7-3.10, 5.6, 7.3); this section records the code survey behind the design amendment (same date tag).

Research Log: resolution seams (verified against current code)

  • Caller index keys on bare function name only: src/openultrasast/source.py:447 callers_of (regex over CodeLine indexes); CallSite(number, name) at source.py:22 carries no receiver. The prover adapter hunt/prover.py:161-172 re-qualifies hits to file::enclosing_fn by IR span containment.
  • Tree-sitter IR drops receiver text: semantic/ir.py:21-29 CallSite (callee is trailing name only; semantic/cst.py:220 _callee_name strips $obj->method / x.y.z); receiver must be captured in cst.py:203 _call and added to ir.CallSite (python-ast path ir.py:100+ likewise).
  • Target enumeration is one-hop trailing-name join: hunt/prover.py:174-185 (call_names from touched functions' IR calls; any (path,name) with that name and operations). Second receiver-blind seam.
  • Guards on hops: Hop.guards_before_call populated only at semantic/obligations/trace.py:224-231 (serialization); the verdict path uses semantic/obligations/check.py:312-323 _on_caller_hop, which today re-judges only path_guard/ownership_check (router/decorator => True; other kinds => False). Dominance order rule dominance.py:52-60; CFG deferral cfg_dominance.py:63-65.
  • Discharge has no resource identity: semantic/obligations/operations.py:35-44 (detail is identifier-only). The only resource token today is Operation.resource (operations.py:31, _resource_token at 229-243: receiver first segment, SQL table, or resource_arg). Suppression loop check.py:117-122; relevance check.py:326-327; scope matching is (path, function) equality (operations.py:124, dominance.py:38).
  • Enforcement witnesses: semantic/obligations/enforcement.py:18 covers Tornado decorators, Symfony IsGranted/Security/access_control, Laravel middleware + authorizeResource; called per language at hunt/prover.py:223-227. In-function recognizer _call_witnesses (operations.py:163-183) matches discharger rows by trailing name -- denyAccessUnlessGranted/isGranted already match existing rows (ruleset/obligations/php.toml:68-78); voteOnAttribute needs no row (interface method, trailing-name match). Jenkins guard rows exist (java.toml:68-76).
  • Mapping: _jetty_entries (mapping.py:562) anchors at method declarations only; call-site registration precedent exists (_python_registered_routes mapping.py:945-1015, WordPress _PHP_REGISTRATION mapping.py:657-680 with registered_by field on EntryPointRecord, mapping.py:53-74); java dispatch at mapping.py:185-198; framework tag table entry_framework mapping.py:472-488.
  • prover_arm refusal seams: per-file zero-byte loop benchmarks/valid/prover_arm.py:86-113; stale-anchor validation belongs in _prove between lines 114 and 125 (before record.update(findings=...)), mirrored in summarize() (184-187); type-name-only catch at _prove 129-133.
  • Checker boundary: authorization-obligations owns check.py/facts.py/operations.py/paths.py/dominance.py. The design's third extension (resource field + resource_bound keyword + all-kind _on_caller_hop) touches operations.py (dataclass field) and check.py (keyword + relevance compare), default-off. Other check_obligations callers (cli.py, hunt/producers.py, pairs.py) keep unchanged defaults.

Decisions

  1. New module semantic/obligations/resolution.py + additive CallSite.receiver (captured in cst.py) rather than widening the source.py index: the index is shared with hunt search; a per-change class table keeps resolution local, cacheable on the Context, and testable in isolation. Bare-name fallback + receiver_unresolved coverage counter keeps dynamic languages (TS/PHP dynamic receivers) working as today.
  2. Resource binding as checker keyword (resource_bound) with emission-side tokens rather than prover-side filtering: the suppression decision is per (witness, operation) pair inside check_obligations; filtering outside would need a restructured call. Default-off keeps authorization-obligations tests untouched.
  3. Programmatic registration follows the existing call-site registration pattern (python routes, WordPress) with class-literal resolution through resolution.class_table -- one owner for class resolution, reused by the mapping recogniser and the callers adapter.

Risks

  • Dynamic receivers (TS this.handler.get, PHP $container->get(...)) stay name-keyed; the counter makes the fallback visible instead of silent. If the pool shows large receiver_unresolved rates, the fact-proposal lane (deferred injection spec) is the designated place to grow type facts, not this spec.
  • down_depth widens target enumeration; the fan-out bound and the 120 s budget cap it. Per-repository seconds-per-change in the runner summary will show any cost.