mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 03:49:26 +00:00
docs(authorization-obligations): approve requirements; design and research
Requirements approved by the maintainer in session. Design: closed obligation facts (operations and dischargers), obligation shapes as a second family in the mechanism store, sibling-set consistency and an optional declared policy as the policy sources, a Dominance protocol with an order-based default so the checker works function-local before reachability and guard dominance land, findings at suspicion carrying consistency_violation / declared_policy_violation / function_local as data, reports, manifest and ranking fields, handler_context harvest mode, leave-one-out per obligation kind, and an obligations lever with pending policy clauses under the existing gate. All 44 acceptance criteria traced. 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
a87755830a
commit
27626d3a07
@@ -0,0 +1,424 @@
|
||||
# Design Document
|
||||
|
||||
## Overview
|
||||
|
||||
**Purpose.** Give OpenUltraSAST a second detection regime beside data flow: an operation that carries an obligation (a protected read or write, a privileged action, a security-relevant setting) is reported when an entry point reaches it and nothing on the way discharges the obligation (a path guard, an identity constraint bound from the authenticated context, an ownership check, a non-permissive value, validated input). The application's own policy says which obligations exist: the consistency of sibling handlers, and optionally a declared policy file.
|
||||
|
||||
**Users.** AppSec engineers get absence findings that name the missing discharger, the evidence for the obligation, and the known fix. Maintainers get a leave-one-out number per obligation kind and an improve lever that admits obligation shapes under the existing gate. Project owners may declare their policy in a small versioned file.
|
||||
|
||||
**Impact.** New `semantic/obligations/` package (facts, shapes, siblings, declared policy, dominance protocol, checker). One more MAP proposer. Obligation rows in the existing mechanism store (second shape family). New harvest mode and one additive label field in the corpus. Reports, manifest and ranking gain obligation fields. No change to dispositions, the evidence ladder, taint, facts for flows, the call graph, or any gate.
|
||||
|
||||
### Goals
|
||||
|
||||
- Absence pairs in `vibe-py` and `agent-vfc` move from 0 detected to a measured leave-one-out baseline per obligation kind.
|
||||
- Every obligation finding states obligation, missing discharger, evidence (siblings or policy clause) and known fix.
|
||||
- Works today in function-local mode; becomes path-aware when reachability lands; becomes nesting-aware when guard dominance lands, without caller changes.
|
||||
- Policy is never authored by a model.
|
||||
|
||||
### Non-Goals
|
||||
|
||||
- Flow taint, sink facts, walker, dispositions, ladder rungs.
|
||||
- Call graph, summaries, path records, dominance IR (owned upstream).
|
||||
- Sandbox proof of absence bugs.
|
||||
- LLM-authored policy or shapes; any merge gate; vendoring unlicensed code.
|
||||
|
||||
## Boundary Commitments
|
||||
|
||||
### This Spec Owns
|
||||
|
||||
- Obligation facts data and loader (`obligations.toml`, `OperationFact`, `DischargerFact`, closed kinds).
|
||||
- Obligation shape derivation, family tag, dedupe key, export into the mechanism store.
|
||||
- Sibling sets, discharge detection per handler, consistency anomalies.
|
||||
- Declared policy schema, loader, version hash, pending clauses.
|
||||
- The `Dominance` protocol and its order-based default implementation.
|
||||
- The obligation checker (path-aware and function-local), its findings, tags and labels.
|
||||
- Obligation fields in markdown/SARIF, the manifest `obligations` block, ranking weight for obligation findings.
|
||||
- Corpus: `handler_context` harvest mode, `obligation` label field, vocabulary additions, leave-one-out per obligation kind.
|
||||
- The `obligations` lever (shape admit/retract, pending policy clauses) reusing the mechanisms lever machinery.
|
||||
|
||||
### Out of Boundary
|
||||
|
||||
- `semantic/taint.py`, `semantic/facts.py` (flow facts), `semantic/cst.py`, `semantic/ir.py`, `overlay.py` dispositions and `ALLOWED_EVIDENCE`.
|
||||
- `semantic/callgraph.py`, `summaries.py`, `paths.py` (`reachability-flow-model`); nesting-aware dominance (`guard-dominance-regime`).
|
||||
- Pair scorer rules beyond one additive detection rule for obligation-labeled rows; catalog schema beyond the `obligation` field.
|
||||
- Sink-shape variant search and its lever family (`corpus-seeded-mechanisms`), reused unchanged.
|
||||
- Rule and CWE policy levers, ledger format.
|
||||
|
||||
### Allowed Dependencies
|
||||
|
||||
- `FileIR`, `parse_file`, `named_ranges_from_ir`, `variants.source_kind_of_name` (read), `SemanticFacts` sources (read, for request-input provenance).
|
||||
- `EntryPointRecord` from `mapping.analyze_entry_points` (read).
|
||||
- `PathRecord` from `semantic/paths.py` when present (read, optional).
|
||||
- `MechanismStore`, `append_from_pair`-style writer, `MechanismEdit`, `apply_mechanism_edits`, `StoreSnapshot`, `evaluate_mechanism_profiles`, `profile_regressions`, journal.
|
||||
- `PairCase`, `load_pair_catalog`, selectors, `loo.score_pair_with_store` pattern, harvest library.
|
||||
- Reports, manifest writer, `StaticFinding`, rank.
|
||||
|
||||
### Revalidation Triggers
|
||||
|
||||
- Any obligation finding above `suspicion` without sandbox agreement.
|
||||
- A shape or policy clause authored by a model or written by the tool.
|
||||
- Obligation kinds or discharger kinds outside the closed sets appearing in facts, shapes or policy.
|
||||
- `Dominance` protocol signature change (both upstream engine specs implement it).
|
||||
- `PathRecord` shape change upstream (the checker's path consumer must be re-checked).
|
||||
- Consistency findings that demote or suppress anything.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Existing Architecture Analysis
|
||||
|
||||
MAP already runs three proposers over one parse: inventory findings adjudicated by the overlay, the tool hunter, and variant search. Each produces `StaticFinding`s at or below `static_corroboration`, merges with overlay records where a call site coincides, and is counted in the manifest. The entry-point mapper runs before MAP and yields routes with an access level. The mechanism store is append-only with deterministic ids and a folding loader; the improve loop has a bounded lever contract with byte-for-byte revert. This design adds a fourth proposer and a second shape family, following those patterns exactly; the only new concept is the `Dominance` protocol, introduced because the spec that owns real dominance is not written yet.
|
||||
|
||||
### Architecture Pattern & Boundary Map
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph facts[Closed data]
|
||||
OF[obligations.toml: operation + discharger facts]
|
||||
DP[declared policy file: resources, routes, identity source, roles]
|
||||
end
|
||||
subgraph corpus[Corpus]
|
||||
PAIRS[trusted absence pairs, handler_context excerpts]
|
||||
end
|
||||
PAIRS --> DERIVE[derive_obligation: shape family obligation]
|
||||
DERIVE --> STORE[mechanisms.jsonl rows with family=obligation]
|
||||
OF --> OPS[find_operations over FileIR]
|
||||
EP[EntryPointRecord: routes, access level] --> SIB[sibling sets]
|
||||
OPS --> SIB
|
||||
OF --> DIS[discharges per handler]
|
||||
SIB --> CONS[consistency anomalies]
|
||||
DP --> CHECK[obligation checker]
|
||||
CONS --> CHECK
|
||||
PATHS[PathRecord optional] --> DOM[Dominance protocol: OrderDominance default]
|
||||
DOM --> CHECK
|
||||
STORE --> CHECK
|
||||
CHECK --> FIND[StaticFinding suspicion + obligation labels]
|
||||
FIND --> REP[reports, manifest, rank]
|
||||
PAIRS --> LOO[leave-one-out per obligation kind]
|
||||
STORE -.-> LOO
|
||||
LOO --> LEVER[obligations lever: admit/retract shapes, pending policy clauses]
|
||||
LEVER --> STORE
|
||||
```
|
||||
|
||||
**Architecture Integration**
|
||||
|
||||
- Selected pattern: closed facts plus learned shapes plus recovered policy, evaluated by a proposer that never claims proof.
|
||||
- Domain boundaries: facts/shapes (semantic data), siblings/policy (application intent), checker (evaluation), reports/rank/lever (consumers). No component writes another's data except the lever writing the store through the existing contract.
|
||||
- Existing patterns preserved: append-only store with deterministic ids; MAP proposer at `suspicion`; lever with validator, snapshot, per-profile clause; leave-one-out as the objective.
|
||||
- New component rationale: `Dominance` protocol decouples the checker from the two upstream engine specs so obligations are useful in function-local mode today.
|
||||
- Dependency direction: obligation facts → shapes → siblings/policy → checker → cli/reports/rank → loo → lever. `obligations/` never imports the overlay, the scorer, or `improve`.
|
||||
|
||||
### Technology Stack
|
||||
|
||||
| Layer | Choice | Role | Notes |
|
||||
|-------|--------|------|-------|
|
||||
| Facts | TOML data next to the flow facts | operation and discharger kinds | closed loader, separate file so `facts.py` stays untouched |
|
||||
| Shapes | stdlib dataclasses over `FileIR` | derive, key, dict round-trip | second family in the mechanism store |
|
||||
| Policy | TOML file in the scanned project's calibration dir | declared resources and routes | closed schema, version hash in manifest |
|
||||
| Evaluation | MAP proposer | findings at `suspicion` | function-local without paths |
|
||||
| Corpus | harvest mode + label field | teach and score absence pairs | additive |
|
||||
| Improve | existing lever machinery | admit/retract, pending clauses | same gate |
|
||||
|
||||
## File Structure Plan
|
||||
|
||||
```
|
||||
src/openultrasast/
|
||||
├── semantic/obligations/
|
||||
│ ├── __init__.py # public surface: load_obligation_facts, derive_obligation, check_obligations, ObligationFinding
|
||||
│ ├── facts.py # OperationFact, DischargerFact, OPERATION_KINDS, DISCHARGER_KINDS, PROVENANCE_KINDS, load_obligation_facts(dir)
|
||||
│ ├── shapes.py # ObligationShape (family="obligation"), derive_obligation(vuln_ir, fixed_ir, label, facts, texts), classify_discharger
|
||||
│ ├── operations.py # find_operations(ir, facts) -> Operation sites; find_discharges(ir, facts, ranges) -> Discharge witnesses
|
||||
│ ├── siblings.py # SiblingSet grouping from EntryPointRecord + operations; consistency_anomalies(sets, min_siblings)
|
||||
│ ├── policy.py # DeclaredPolicy schema + load_declared_policy(path) (closed; rejects unknown fields); version hash
|
||||
│ ├── dominance.py # Dominance protocol; OrderDominance default (decorators, router middleware, statement order, hop order)
|
||||
│ └── check.py # check_obligations(...) -> ObligationResult(findings, discharges, sibling_sets, degradations); findings_to_static
|
||||
├── data/facts/obligations.toml # closed operation/discharger facts per language (python, javascript, typescript)
|
||||
├── cli.py # MAP: obligation check after variant search; manifest `obligations`; `mechanisms export` also writes obligation shapes
|
||||
├── config.py # [obligations] enabled=true, min_siblings=3, policy_path=".openultrasast/obligations.toml"
|
||||
├── reports.py # obligation lines in markdown, `obligation_*` SARIF properties
|
||||
├── rank.py # obligation ranking weight: sensitivity x label, below any sandbox-proven finding
|
||||
├── semantic/seed.py # pair_lessons yields obligation shapes for obligation-labeled rows (same exporter)
|
||||
├── semantic/loo.py # score_pair_with_store handles family=obligation via check_obligations; per obligation kind grouping
|
||||
├── improve/validator.py # validate_mechanism dispatches on shape family; PolicyClauseEdit (pending)
|
||||
├── improve/evolve.py # propose_mechanism_edits covers obligation rows; pending clause journaling
|
||||
├── pairs.py # ExpectedFinding.obligation (additive); obligation-labeled rows count an obligation finding inside the function as detection
|
||||
benchmarks/pairs/
|
||||
├── harvest.py # mode "handler_context": function + decorators + registration lines
|
||||
├── mechanisms.toml # vocabulary additions: unconstrained_protected_read, unconstrained_protected_write, unguarded_privileged_action
|
||||
tests/
|
||||
├── test_obligation_facts.py, test_obligation_shapes.py, test_obligation_siblings.py, test_obligation_policy.py,
|
||||
├── test_obligation_check.py (function-local + path-aware with a stub PathRecord), test_obligation_reports.py,
|
||||
├── test_obligation_corpus.py (harvest mode, label, LOO per kind), test_obligation_lever.py
|
||||
```
|
||||
|
||||
### Modified Files
|
||||
|
||||
- `src/openultrasast/cli.py` — `_check_obligations` after `_search_variants` in MAP (standard/deep); manifest `obligations` block; `mechanisms export` writes obligation rows too.
|
||||
- `src/openultrasast/pairs.py` — `ExpectedFinding.obligation: str | None`; `_detections`-side additive rule: for a row with `obligation`, a finding with tag `obligation:<kind>` inside the labeled function counts.
|
||||
- `src/openultrasast/semantic/mechanisms.py` — no schema change; `corpus_mechanisms()` unchanged; a helper `obligation_mechanisms(records)` selects `shape["family"] == "obligation"` (added in `obligations/shapes.py`, not in the store module).
|
||||
- `src/openultrasast/reports.py`, `rank.py`, `config.py`, `improve/*`, `semantic/seed.py`, `semantic/loo.py`, `benchmarks/pairs/harvest.py`, `benchmarks/pairs/mechanisms.toml` as listed above.
|
||||
|
||||
## System Flows
|
||||
|
||||
### Evaluation in MAP
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[parsed files + EntryPointRecords] --> B[find_operations per handler]
|
||||
B --> C[find_discharges per handler]
|
||||
A --> D[sibling sets by resource x router]
|
||||
C --> D
|
||||
D --> E[consistency anomalies >= min_siblings]
|
||||
P[PathRecords if present] --> F[Dominance.dominates witness, operation]
|
||||
C --> F
|
||||
G[declared policy if present] --> H[obligation checker]
|
||||
E --> H
|
||||
F --> H
|
||||
H --> I[ObligationFinding: kind, discharger, provenance, evidence label, siblings/clause, known fix]
|
||||
I --> J[StaticFinding suspicion; tags obligation:*, discharger:*, obligation_evidence:*]
|
||||
```
|
||||
|
||||
Gating: the checker emits a finding only when (1) the operation is reached — from an entry point along a `PathRecord`, or, without paths, when the operation sits in a handler that is itself an entry point — and (2) no discharger of the required kind dominates, and (3) the obligation is justified by a sibling set (`consistency_violation`), a policy clause (`declared_policy_violation`) or, in degraded mode without either, by the operation fact alone (`function_local`). A declared public route suppresses path-guard obligations for that route only.
|
||||
|
||||
### Teaching and measuring
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
T[trusted absence pair] --> V[vuln + fixed FileIR, handler_context texts]
|
||||
V --> W[operation in labeled function]
|
||||
V --> X[discharger added on fixed side + provenance of bound value]
|
||||
W --> Y[ObligationShape: operation kind, discharger kind, provenance, resource token class]
|
||||
X --> Y
|
||||
Y --> Z[store row family=obligation, deterministic id]
|
||||
Z --> L[leave-one-out: check_obligations on held-out excerpts with the other pairs' shapes]
|
||||
L --> M[per obligation kind: recall, silence, Youden; found_by]
|
||||
```
|
||||
|
||||
### Lever
|
||||
|
||||
Admit/retract of obligation shape rows follows the mechanisms lever exactly (validator dispatches on `family`; same snapshot, tombstone, per-profile clause via `evaluate_mechanism_profiles` extended to run the checker for obligation rows). A policy clause proposal (`PolicyClauseEdit`) is journaled as `pending` with its sibling evidence and is never read by a scan until a human copies it into the policy file; accepting is a human edit of the file, not a loop action.
|
||||
|
||||
## Requirements Traceability
|
||||
|
||||
| Requirement | Summary | Components | Flows |
|
||||
|-------------|---------|------------|-------|
|
||||
| 1.1–1.5 | closed operation/discharger facts, loader rejects unknown, no detection outside facts | ObligationFacts | — |
|
||||
| 2.1–2.5 | shapes from trusted pairs, tiers, text-free, dedupe with provenance, skip reasons | ObligationShapes, Export | teaching |
|
||||
| 3.1–3.6 | sibling sets, min count, route access evidence, never demote, fixed-twin leak rate | Siblings, Checker | evaluation |
|
||||
| 4.1–4.5 | declared policy: load, reject unknown, declared violation, public routes, never written by the tool | DeclaredPolicy, Checker, Lever | evaluation, lever |
|
||||
| 5.1–5.5 | path-aware, discharge recorded, function_local degraded mode, identity provenance, standard/deep only | Dominance, Checker, cli | evaluation |
|
||||
| 6.1–6.5 | one label, reports, hunter intent, ranking weight, manifest block | Checker, reports, rank, IntentAdjudication | — |
|
||||
| 7.1–7.5 | handler_context harvest, obligation label, vocabulary, LOO per kind, roadmap | harvest, pairs, mechanisms.toml, loo | teaching |
|
||||
| 8.1–8.5 | obligations lever, closed validator, gate unchanged, pending clauses, journal | Lever | lever |
|
||||
| 9.1–9.3 | offline tests, gates unchanged, extra-free green | tests | — |
|
||||
|
||||
## Components and Interfaces
|
||||
|
||||
| Component | Domain | Intent | Req | Dependencies | Contract |
|
||||
|-----------|--------|--------|-----|--------------|----------|
|
||||
| ObligationFacts | semantic data | closed kinds and their call/attribute matches | 1 | TOML | State |
|
||||
| ObligationShapes | semantic | derive, key, round-trip, export | 2, 7 | FileIR, facts, variants helpers, store | Service |
|
||||
| Operations | semantic | operation sites and discharge witnesses in a file | 3, 5 | FileIR, facts | Service |
|
||||
| Siblings | intent | group handlers, find anomalies | 3 | EntryPointRecord, Operations | Service |
|
||||
| DeclaredPolicy | intent | load and validate policy, version | 4 | TOML | State |
|
||||
| Dominance | evaluation | does a witness dominate an operation | 5 | PathRecord optional | Protocol |
|
||||
| Checker | evaluation | findings with labels and evidence | 3–6 | all above | Service |
|
||||
| IntentAdjudication | hunter | record intent on a finding | 6.3 | hunter client (optional) | Service |
|
||||
| Corpus | corpus | harvest mode, label, LOO per kind | 7 | harvest lib, pairs, loo | Batch |
|
||||
| Lever | improve | admit/retract shapes, pending clauses | 8 | validator, evolve, store | Batch |
|
||||
|
||||
### ObligationFacts
|
||||
|
||||
```python
|
||||
OPERATION_KINDS = ("protected_read", "protected_write", "privileged_action", "security_setting")
|
||||
DISCHARGER_KINDS = ("path_guard", "identity_constraint", "ownership_check", "non_permissive_value", "validated_input")
|
||||
PROVENANCE_KINDS = ("authenticated_context", "request_input", "constant", "unknown")
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OperationFact:
|
||||
id: str
|
||||
kind: str # OPERATION_KINDS
|
||||
language: str
|
||||
calls: tuple[str, ...] # trailing-name matches, same rule as sink facts
|
||||
resource_arg: int | None # argument or receiver position that names the resource (model, table, path)
|
||||
requires: tuple[str, ...] # DISCHARGER_KINDS that discharge this operation
|
||||
sensitivity: str # low | medium | high (default weight for ranking)
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DischargerFact:
|
||||
id: str
|
||||
kind: str # DISCHARGER_KINDS
|
||||
language: str
|
||||
calls: tuple[str, ...] = () # guard calls / decorators / setters
|
||||
decorators: tuple[str, ...] = ()
|
||||
identity_sources: tuple[str, ...] = () # text patterns bound from the authenticated context (request.user, g.user, token payload)
|
||||
constraint_params: tuple[str, ...] = () # keyword or field names that constrain by identity (user, owner, user_id, tenant)
|
||||
|
||||
def load_obligation_facts(directory: Path | None = None) -> ObligationFacts: ...
|
||||
```
|
||||
|
||||
Invariants: any kind outside the closed sets raises `ObligationFactsError` naming the entry; `for_language(language)` scoping like `SemanticFacts`.
|
||||
|
||||
### ObligationShapes
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class ObligationShape:
|
||||
language: str
|
||||
operation_kind: str
|
||||
discharger_kind: str
|
||||
provenance: str # PROVENANCE_KINDS: what the fix binds the discharger to
|
||||
resource_class: str # "owned" | "global" | "setting" (from the operation fact's resource_arg and the added constraint)
|
||||
mechanism: str # closed vocabulary id from the label
|
||||
family: str = "obligation"
|
||||
|
||||
def key(self) -> str: ... # text-free; family prefix so it never collides with sink shapes
|
||||
|
||||
def derive_obligation(vuln: FileIR, fixed: FileIR, *, function: str, obligation: str, mechanism: str, facts: ObligationFacts, flow_facts: SemanticFacts, vuln_text: str, fixed_text: str) -> ObligationShape | None: ...
|
||||
def classify_discharger(vuln: FileIR, fixed: FileIR, *, function: str, facts: ObligationFacts, texts: tuple[str, str]) -> tuple[str, str] | None: ... # (discharger kind, provenance) added on the fixed side
|
||||
```
|
||||
|
||||
Preconditions: both sides parse; the labeled function exists on both. Postconditions: None when no operation of a required kind is in the labeled function or no discharger was added; the shape carries no text. Stored through the existing writer with `shape = to_dict()`; id from `key()`.
|
||||
|
||||
### Operations and Siblings
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class Operation:
|
||||
path: str; line: int; function: str; kind: str; fact_id: str; resource: str | None # resource token, identifier only
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Discharge:
|
||||
path: str; line: int | None; function: str; kind: str; fact_id: str; provenance: str; scope: str # scope: decorator | router | statement | hop
|
||||
|
||||
def find_operations(ir: FileIR, facts: ObligationFacts, ranges) -> tuple[Operation, ...]: ...
|
||||
def find_discharges(ir: FileIR, facts: ObligationFacts, flow_facts: SemanticFacts, ranges, entry: EntryPointRecord | None) -> tuple[Discharge, ...]: ...
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SiblingSet:
|
||||
key: tuple[str, str] # (router or module, resource token)
|
||||
handlers: tuple[str, ...] # "path::function"
|
||||
discharging: dict[str, tuple[str, ...]] # discharger kind -> handlers that discharge it
|
||||
|
||||
def sibling_sets(entries: Sequence[EntryPointRecord], operations, discharges) -> tuple[SiblingSet, ...]: ...
|
||||
def consistency_anomalies(sets, *, min_siblings: int) -> tuple[Anomaly, ...]: ... # handler, kind, siblings that discharge
|
||||
```
|
||||
|
||||
Invariants: a set with fewer than `min_siblings` members yields no anomaly and is counted as under-populated; anomalies never touch existing findings.
|
||||
|
||||
### DeclaredPolicy
|
||||
|
||||
```toml
|
||||
# .openultrasast/obligations.toml (project-owned; the tool never writes it)
|
||||
version = 1
|
||||
identity_source = "request.user" # closed: one of the discharger facts' identity_sources or "token"
|
||||
[[resource]]
|
||||
name = "book" # resource token as seen in operations
|
||||
sensitivity = "high" # low | medium | high
|
||||
identity_field = "user_id" # constraint param that ties the resource to its owner
|
||||
[[route]]
|
||||
path = "/health"
|
||||
access = "public" # public | authenticated | role
|
||||
roles = []
|
||||
```
|
||||
|
||||
`load_declared_policy(path) -> DeclaredPolicy | None`; unknown field or value → `PolicyError` naming it; the scan continues without a policy and records `{"stage": "obligations", "reason": "policy_invalid", "detail"}`. `DeclaredPolicy.version_hash` goes to the manifest.
|
||||
|
||||
### Dominance
|
||||
|
||||
```python
|
||||
class Dominance(Protocol):
|
||||
def dominates(self, witness: Discharge, operation: Operation, path: PathRecord | None) -> bool: ...
|
||||
|
||||
class OrderDominance:
|
||||
"""Default: decorator and router-middleware witnesses dominate the handler; a statement witness dominates when it
|
||||
precedes the operation in the same function and the function has an early exit between them or the witness is a
|
||||
guard call; along a PathRecord, a witness on an earlier hop dominates."""
|
||||
```
|
||||
|
||||
`guard-dominance-regime` provides a nesting-aware implementation behind the same protocol; the checker takes the implementation as a parameter.
|
||||
|
||||
### Checker
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class ObligationFinding:
|
||||
operation: Operation
|
||||
missing: str # discharger kind
|
||||
provenance: str | None # when an identity constraint exists but binds request input
|
||||
label: str # consistency_violation | declared_policy_violation | function_local
|
||||
evidence: tuple[str, ...] # sibling handler ids or the policy clause
|
||||
known_fix: str | None # mechanism record id whose shape matches (operation kind, discharger kind)
|
||||
intent: str | None = None # hunter adjudication: public | protected | unknown
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ObligationResult:
|
||||
findings: tuple[ObligationFinding, ...]
|
||||
discharges: tuple[Discharge, ...]
|
||||
sibling_sets: tuple[SiblingSet, ...]
|
||||
degradations: tuple[dict[str, object], ...]
|
||||
|
||||
def check_obligations(*, irs, entries, facts, flow_facts, policy, paths, dominance, store_shapes, min_siblings) -> ObligationResult: ...
|
||||
def findings_to_static(result) -> list[StaticFinding]: ... # id "obligation:<kind>:<path>:<line>", evidence suspicion, tags
|
||||
```
|
||||
|
||||
Rules: reached = a `PathRecord` ends at the operation, or (no paths) the operation's function is an entry point; label precedence declared > consistency > function_local; a declared public route suppresses `path_guard` obligations for that route; an operation with a matching store shape gains `known_fix`; a discharge that binds `request_input` where `authenticated_context` is required is reported with `provenance = "request_input"`.
|
||||
|
||||
### IntentAdjudication
|
||||
|
||||
When a hunter client is available (the same resolution as `pairs --hunter`), the checker asks one closed question per finding with a route: "Is this route meant to be public?" with the handler text and siblings as context; the answer is recorded as `intent` and in the rationale. It never changes the label or the evidence level; when the client is absent a degradation `intent_adjudication_unavailable` is recorded once.
|
||||
|
||||
### Reports, manifest, rank
|
||||
|
||||
Markdown per finding: `- Obligation: <kind> on <resource>`, `- Missing discharger: <kind> (<provenance>)`, `- Evidence: <label> — <siblings | clause>`, `- Known fix: <guard shape> learned from <pairs>`, `- Intent: <adjudication>`; a `## Obligations` section lists sibling sets and the policy version. SARIF properties `obligation_kind`, `obligation_missing`, `obligation_label`, `obligation_evidence`, `obligation_known_fix`, `obligation_intent`. Manifest `obligations = {sibling_sets, under_populated, operations, findings_by_label, policy_version | null, degradations}`. Rank: `ranking_priority = base(sensitivity) x weight(label)` with `declared > consistency > function_local`, capped strictly below the lowest sandbox-proven finding in the run.
|
||||
|
||||
### Corpus and leave-one-out
|
||||
|
||||
Harvest mode `handler_context`: the labeled function plus its decorators and the registration statements that name it (router `.get(...)`/`.post(...)` calls, `add_url_rule`, middleware attachment) as one excerpt per side. Catalog row `obligation = "<OPERATION_KIND>"` (additive); vocabulary gains `unconstrained_protected_read`, `unconstrained_protected_write`, `unguarded_privileged_action` (existing `missing_auth_guard`, `identity_from_request_body`, `permissive_default` stay). `loo.score_pair_with_store` runs `check_obligations` on both sides for obligation rows; grouping adds `per_obligation_kind`; `found_by` names the shape record that supplied `known_fix`.
|
||||
|
||||
### Lever
|
||||
|
||||
`validate_mechanism` dispatches on `shape["family"]`: obligation rows check `operation_kind`, `discharger_kind`, `provenance`, `resource_class` against the closed sets. `PolicyClauseEdit(kind="resource"|"route", fields, evidence, rationale)` is validated against the policy schema, journaled under `pending_policy_clauses`, and never applied by the loop. `propose_mechanism_edits` treats obligation rows like sink rows (admit when a trial store recovers a missed holdout absence pair without leaking; retract leakers); `evaluate_mechanism_profiles` runs the checker for obligation rows.
|
||||
|
||||
## Data Models
|
||||
|
||||
- Store row: existing `Mechanism` with `shape = {family: "obligation", language, operation_kind, discharger_kind, provenance, resource_class, mechanism}`; `guard` field carries `discharger_kind` for report continuity.
|
||||
- Facts: `obligations.toml` with `[[operation]]` and `[[discharger]]` tables per language; `version`.
|
||||
- Policy: as above; `version_hash = sha1(normalized file)`.
|
||||
- Manifest: `obligations` block; `loo.json` gains `per_obligation_kind`.
|
||||
- Journal: `pending_policy_clauses: [{clause, evidence, round, rationale}]`.
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Condition | Response |
|
||||
|-----------|----------|
|
||||
| Facts entry outside closed sets | `ObligationFactsError` naming the entry; loader fails loud (data bug) |
|
||||
| Policy file invalid | `policy_invalid` degradation; scan continues without policy |
|
||||
| No path records | function-local mode; `function_local` labels; degradation per language once |
|
||||
| Language without grammar | skipped with `obligations_language_unsupported` |
|
||||
| Sibling set under minimum | counted under-populated, no finding |
|
||||
| Hunter unavailable | `intent_adjudication_unavailable`, findings unchanged |
|
||||
| Store row with malformed obligation shape | skipped with `obligations_store_row_invalid` |
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- Unit: facts loader rejects an unknown kind (1.4); `derive_obligation` on the vampi pair yields `protected_read` / `identity_constraint` / `authenticated_context` and a text-free key (2.1, 2.3); a pair whose fix adds no discharger is skipped with a reason (2.5); `sibling_sets` groups three handlers on `/books` and `consistency_anomalies` flags the unconstrained one with the two siblings as evidence, and stays silent under `min_siblings` (3.2, 3.3); a public route among authenticated siblings is a missing `path_guard` with the access classification as evidence (3.4); policy loader rejects an unknown field and a declared public route suppresses the guard obligation but not the identity one (4.2, 4.4); `OrderDominance` with a stub `PathRecord` accepts a guard on an earlier hop and rejects one after the operation (5.1, 5.2); no paths → `function_local` plus degradation (5.3); identity bound from `request.args` → `provenance = request_input` (5.4).
|
||||
- Integration: standard scan on a fixture with three handlers emits one `obligation:` finding at `suspicion` with tags and report lines, manifest block present, absent in quick (5.5, 6.1, 6.2, 6.5); ranking places it below a sandbox-proven finding (6.4).
|
||||
- Corpus: `handler_context` excerpt keeps decorators and registration (7.1); an obligation-labeled catalog row counts an obligation finding inside the function (7.2); LOO on a three-pair toy slice reports per obligation kind and `found_by` (7.4).
|
||||
- Lever: admission of an obligation shape that recovers a holdout absence pair is accepted; one that fires on a fixed twin is rejected with byte-identical store; a `PolicyClauseEdit` lands in `pending_policy_clauses` and no scan reads it (8.3, 8.4).
|
||||
- Gates: detection, map and local pair gates unchanged; extra-free suite green with semantic tests skipping (9.2, 9.3).
|
||||
|
||||
## Security Considerations
|
||||
|
||||
Shapes and policy carry identifiers only; the policy file is project-owned and never written by the tool; the hunter receives redacted handler text and can only annotate. Obligation findings never execute anything and never reach the sandbox candidate set.
|
||||
|
||||
## Performance
|
||||
|
||||
Operations and discharges are found in the same parse the overlay uses; sibling grouping is linear in handlers; the checker is O(operations x witnesses) per handler and O(paths) with path records; bounded by the MAP file budget.
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
1. Facts, shapes, exporter and vocabulary; measure how many absence pairs teach.
|
||||
2. Operations, discharges, siblings, policy, order dominance, checker in function-local mode; MAP wiring behind `[obligations].enabled`; reports and manifest.
|
||||
3. Corpus harvest mode and label; leave-one-out per kind; roadmap baseline.
|
||||
4. Lever and pending clauses.
|
||||
5. When `reachability-flow-model` lands: pass `PathRecord`s to the checker (no interface change). When `guard-dominance-regime` lands: swap the `Dominance` implementation.
|
||||
@@ -0,0 +1,88 @@
|
||||
# Research & Design Decisions
|
||||
|
||||
## Summary
|
||||
|
||||
- **Feature**: `authorization-obligations`
|
||||
- **Discovery Scope**: Extension of the semantic layer (facts, shapes, store, MAP proposers), the entry-point mapper, reports, the pair corpus, leave-one-out and the improve lever; complex integration because it consumes two unimplemented upstream specs.
|
||||
- **Key Findings**:
|
||||
- The absence pairs are real and structured. `vampi-books-get-by-title` has identical code on both sides except a constraint `user=user` bound from `token_validator(request.headers.get('Authorization'))['sub']`; the vulnerable branch queries `Book` by title alone. The difference is the provenance of a constraint, not a flow. 15 of 29 `agent-vfc` rows and 6 of the 19 non-teaching `vibe-py` pairs carry absence labels (`missing_auth_guard`, `permissive_default`, `identity_from_request_body`, `secret_in_client`, `validation_strength`).
|
||||
- The entry-point mapper already classifies every route as `public`, `authenticated` or `role-restricted` from its decorators (`mapping._python_route_access`), records the decorators as `access_evidence`, and lists `conditions` (`if` tests that look like checks). That is the sibling detector's upstream for path guards and needs no change.
|
||||
- `FileIR` binds carry `value_text`, `names` and `call_name`; `variants.source_kind_of_name` already traces a name through binds to a fact source, a parameter or a container read. Provenance of a constraint's value (authenticated context versus request input versus constant) is the same trace with one more source class.
|
||||
- `MechanismStore` rows carry a free `shape` dict and deterministic ids from `Shape.key()`; a second shape family fits without schema change. `MechanismEdit`, `apply_mechanism_edits`, `StoreSnapshot`, the tombstone and `evaluate_mechanism_profiles` are reusable as-is for a second lever family.
|
||||
- `reachability-flow-model` (unapproved) defines `PathRecord(entry, hops, sink, sink_line, sink_node, incomplete_at, ...)` and `compose(graph, summaries, entry_nodes, max_depth)`. Obligations need paths from an entry point to an *operation*, which is a sink-shaped target; the same `compose` works when obligated operations are passed as the sink set. `guard-dominance-regime` has no spec directory yet; only the roadmap line exists. Dominance must therefore be consumed through a narrow interface this design defines and the two upstream specs later satisfy.
|
||||
- The evidence ladder is closed: `OverlayRecord` allows `static_corroboration` and `suspicion`; proof rungs are forbidden outside the sandbox. Obligation findings are `StaticFinding`s at `suspicion` with the obligation label carried as data, so no ladder change is needed and Req 6.1 holds by construction.
|
||||
- The pair scorer counts promote and coverage-with-source records as detections; an obligation finding has no source or sink. Detection for obligation-labeled rows must be an additive rule keyed on the obligation label (allowed by the corpus spec's "one additive label field").
|
||||
|
||||
## Research Log
|
||||
|
||||
### What the absence pairs actually differ by
|
||||
|
||||
- **Context**: Decide whether absence bugs can be a shape family at all.
|
||||
- **Sources Consulted**: `benchmarks/pairs/vibe-py` excerpts for `missing_auth_guard` and `permissive_default` rows; `benchmarks/pairs/agent-vfc/recipes.toml` mechanisms; `mechanisms.toml` definitions.
|
||||
- **Findings**: three recurring differences: (a) a constraint added to a data access whose value comes from the authenticated context; (b) a guard call or decorator added before the operation (early return on a failed token check, `login_required`); (c) a literal changed from permissive to restrictive (`CORS(app)` → origins list, `DEBUG=True` → `False`, RLS enabled). A fourth, rarer: identity read from the body replaced by identity from the token (same as (a) seen from the other side).
|
||||
- **Implications**: discharger kinds `identity_constraint`, `path_guard`, `ownership_check`, `non_permissive_value`, `validated_input`; the value provenance is the discriminating feature for (a) and (d).
|
||||
|
||||
### Where the policy comes from
|
||||
|
||||
- **Context**: Absence is relative to intent; the tool must not invent intent.
|
||||
- **Sources Consulted**: `mapping.analyze_entry_points` (routes, access level, evidence), Real-Vuln repositories (many handlers per resource), Clearwing's sibling-comparison practice.
|
||||
- **Findings**: in every Real-Vuln web app the handlers on one resource share a router and a model; the vulnerable handler is the odd one out. Route access classification already exists. A declared policy is the only way to say "this route is meant to be public"; the hunter can only guess it.
|
||||
- **Implications**: consistency first (no declaration needed, measurable now), declared policy second (stronger rung), hunter last (records intent, never raises).
|
||||
|
||||
### Dominance before guard-dominance-regime exists
|
||||
|
||||
- **Context**: Req 5 needs "a discharger dominates the path"; the spec that owns dominance is not written.
|
||||
- **Findings**: within one handler, the existing `FunctionIR` order of binds and calls gives statement order; a guard call followed by an early return before the operation line is dominance in the common case; decorators dominate the whole handler; middleware attached to the router dominates every route under it. Across calls, `PathRecord.hops` gives the sequence of nodes; a discharger on any hop before the operation node dominates the path when the hop's function returns early on failure.
|
||||
- **Implications**: define `Dominance` as a narrow protocol (`dominates(witness, operation) -> bool`) with a built-in `OrderDominance` implementation (statement order in the handler, decorators, router middleware, hop order) that `guard-dominance-regime` may replace with nesting-aware dominance. The degraded mode (`function_local`) is `OrderDominance` without path records.
|
||||
|
||||
### Store and lever reuse
|
||||
|
||||
- **Context**: Avoid a second store and a second lever machinery.
|
||||
- **Findings**: `Mechanism.shape` is a dict validated where used; `corpus_mechanism_id` hashes `shape.key()`; `validate_mechanism` checks guard and source kinds against `variants` constants. A second family needs its own closed kinds check and a `family` field in the shape dict; the tombstone, snapshot and profile clause need nothing.
|
||||
- **Implications**: `ObligationShape.to_dict()` carries `family = "obligation"`; the lever validator dispatches on `family`; `search_tree` ignores obligation rows (they are not sink shapes) and the obligation checker ignores sink rows.
|
||||
|
||||
## Architecture Pattern Evaluation
|
||||
|
||||
| Option | Description | Strengths | Risks / Limitations | Notes |
|
||||
|--------|-------------|-----------|---------------------|-------|
|
||||
| Absence as a taint sink ("sink with a missing sanitizer") | Treat protected access as a sink needing an identity "sanitizer" | Reuses taint | Wrong semantics: the constraint is not a sanitizer of a flow; unconstrained access has no tainted argument | Rejected |
|
||||
| Model-authored authorization rules | LLM writes per-project rules | Flexible | Violates no-model-authored-patterns; unverifiable | Rejected |
|
||||
| Obligations with dischargers, policy from consistency plus declaration, path-aware | Closed facts, learned shapes, sibling anomaly, optional declared policy, dominance protocol | Zero-dep, measurable leave-one-out, honest rungs, grows with corpus | Coarse resource grouping; dominance approximated until the guard spec lands | **Selected** |
|
||||
| Framework-specific authorization linters | Per-framework hard-coded checks | High precision | Regex growth, no learning, no policy | Rejected |
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### Decision: Obligation findings are `suspicion` with a data label, not new ladder rungs
|
||||
|
||||
- **Context**: Req 6.1 names three labels; the ladder is closed and owned elsewhere.
|
||||
- **Selected Approach**: `StaticFinding.evidence_level = "suspicion"`, tags `obligation:<kind>`, `discharger:<kind>`, `obligation_evidence:<label>`; reports render the label. Dispositions and `ALLOWED_EVIDENCE` unchanged.
|
||||
- **Trade-offs**: SARIF consumers see `suspicion` plus properties; honest and non-invasive.
|
||||
|
||||
### Decision: Dominance is a protocol with an order-based default
|
||||
|
||||
- **Context**: `guard-dominance-regime` is unwritten; this spec cannot wait for it to be useful.
|
||||
- **Selected Approach**: `Dominance` protocol; `OrderDominance` default (decorator, router middleware, statement order before the operation, hop order along a path). The guard spec replaces it without touching callers.
|
||||
- **Trade-offs**: order-based dominance misses guards inside conditionals; recorded as the known coarseness and measured on the fixed twins.
|
||||
|
||||
### Decision: Policy is never written by the tool
|
||||
|
||||
- **Selected Approach**: `declared policy` is a hand-maintained file with a closed schema; the lever may only *propose* a clause, journaled as pending until a human accepts; scans never read pending clauses.
|
||||
|
||||
### Decision: Resource identity for sibling grouping
|
||||
|
||||
- **Selected Approach**: a handler's resource is the trailing segment of its route path with parameters stripped, joined with the operation's target token (model, table, collection name from the operation fact match); router or module is the second key. Coarse but deterministic and text-free.
|
||||
|
||||
## Risks & Mitigations
|
||||
|
||||
- Sibling grouping over-merges unrelated handlers → minimum sibling count, both keys required, siblings listed in the finding for review.
|
||||
- Consistency leaks on legitimately public handlers → declared public routes suppress path-guard violations; leak rate on fixed twins reported per slice; the lever's holdout gate rejects shapes that leak.
|
||||
- Order dominance misses conditional guards → `function_local` and `consistency_violation` labels stay below proof; guard spec replaces the default.
|
||||
- Corpus excerpts lack registration context → new `handler_context` harvest mode; pointer pairs fetch whole files anyway.
|
||||
- Two unimplemented upstream specs → this design degrades to function-local mode without path records, so tasks 1–6 are implementable before reachability lands; only path-aware evaluation waits.
|
||||
|
||||
## References
|
||||
|
||||
- `.kiro/specs/reachability-flow-model/design.md` (PathRecord, compose)
|
||||
- `.kiro/specs/corpus-seeded-mechanisms/design.md` (store, lever, LOO)
|
||||
- `src/openultrasast/mapping.py` (`EntryPointRecord`, `_python_route_access`)
|
||||
- `src/openultrasast/semantic/variants.py` (`source_kind_of_name`, `classify_guard`)
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"feature_name": "authorization-obligations",
|
||||
"created_at": "2026-09-05T17:00:00Z",
|
||||
"updated_at": "2026-09-05T17:00:00Z",
|
||||
"updated_at": "2026-09-05T18:00:00Z",
|
||||
"language": "en",
|
||||
"phase": "requirements-generated",
|
||||
"phase": "design-generated",
|
||||
"approvals": {
|
||||
"requirements": {
|
||||
"generated": true,
|
||||
"approved": false
|
||||
"approved": true
|
||||
},
|
||||
"design": {
|
||||
"generated": false,
|
||||
"generated": true,
|
||||
"approved": false
|
||||
},
|
||||
"tasks": {
|
||||
@@ -23,5 +23,6 @@
|
||||
"reachability-flow-model",
|
||||
"guard-dominance-regime",
|
||||
"corpus-seeded-mechanisms"
|
||||
]
|
||||
],
|
||||
"approval_note": "2026-09-05 requirements approved by the maintainer in session ('approve the requirements and run the design phase')"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user