Files
NeuroSploit/docs/authz-matrix-design.md
T
CyberSecurityUPandClaude Opus 5.5 79685cee49 feat: graph memory in the test loop, wire-bound evidence, authz matrix, report redaction
Harness
- graph_memory: typed attack graph (web + internal network) with Neo4j sync
  over the HTTP API. Graph checkpoints run after recon, validation, each
  chaining round and at the end. They direct agent selection and feed the
  live chains, next moves and dependency todolist into every prompt.
- wire: every reported exchange is checked against recorded flows and
  re-sent through the scope-guarded replay engine. A contradiction means the
  finding cannot stand as confirmed.
- traces: each agent's transcript is shared with the others as a fenced,
  structured digest (endpoints, params, error signatures, versions).
- authz_matrix: role x route authorization matrix validator, with
  identity tiers in creds.
- deliverables: evidence custody, exploit-as-test (honest oracles only),
  detection content and personas, with no credential leakage.
- redact: credentials quoted by agents become $SECRET_n in every
  human-facing report (md/html/json/sarif/typ, findings.md). findings.json
  and the vault keep raw values for re-testing.
- compliance: GDPR replaces LGPD.
- New agents: authorization_matrix, session_lifecycle, api_token_structure,
  exposed_datastore, postgres_superuser_rce, meta/graph_reasoning.

Web console
- New tabs, mounted in both the detail and live views: Attack graph
  (replaces Generative Attack Path Chaining), Chains, Dependencies,
  Traffic, Traces, Deliverables.
- Wire/Status columns, KPIs, CSV export.
- Graph recompute / Neo4j push / Re-test actions, all through the CLI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015v24fuCWxkanS31HDmaDB6
2026-10-11 19:20:29 -03:00

16 KiB
Raw Blame History

Design: Authorization Matrix Engine

Implementation status (2026-10-07)

  • Slice 1 — validator core: DONE. validation.rs: Evidence.{matrix,oracle}, Expect/MatrixProbe/OracleProbe, AuthzMatrixValidator (CWE 862/863/285, split out of IdorValidator), the 403-vs-business-error oracle, the judge override, title routing, 7 unit tests.
  • Slice 2 — identity fixtures: DONE. creds.rs: Identity.{tier,known_id}, Tier, SampleValues, Identity::{tier_kind,expected,is_explicit_anonymous}, Creds::{authz_matrix_ready,highest_identity}, parser for identities/ sample_values + anonymous-preserving retain, 3 unit tests.
  • Slice 3 — engine: DONE. authz_matrix.rs: Route/RouteKind/ RouteInventory (template dedup), run/run_pass (generic over an async send closure — no new dep), send_via (reqwest adapter), scope + budget guards, non-existent-id oracle, 11 unit tests. Registered in lib.rs. Workspace: 420 tests green.
  • Pipeline plumbing: DONE. types.rs: RunConfig.{authz_matrix, creds_file}. pipeline.rs: endpoints_from_recon (parses the recon JSON endpoints/apis), authz_matrix_pass (loads creds, builds inventory, reqwest client, run_pass, 400-request cap), called as step 4b right after validate_with_persist and merged into findings. app/src/main.rs: global --authz-matrix on|off|auto, and --creds now also populates cfg.creds_file. Workspace builds clean; harness 420 tests green. (Pre-existing unrelated repl test /sandbox failure is not touched by this change.)

Usage: neurosploit run <url> --creds creds.yaml --authz-matrix auto, with a creds.yaml carrying the multi-identity block from the creds.rs module doc.


Status: proposal · Target crate: crates/harness · Touches: validation.rs, pipeline.rs, scope.rs/creds.rs, new authz_matrix.rs, types.rs

Problem

Broken authorization is the dominant finding class in real backoffice/business apps, and it is systemic: one missing check repeats across dozens of routes ("read-only role can write", "permission declared but never required", "protected route served to anonymous"). The current engine tests one class at a time, usually with one identity. IdorValidator already proves object-level access (BOLA) from an identity_a/identity_b pair, but there is no engine that sweeps the whole route surface against every identity and decides function-level authorization (BFLA), missing-auth, and read-only-can-write.

This design adds that engine and a deterministic validator for it, reusing the existing Exchange/Evidence/Verdict/CweValidator machinery.

Core idea

  recon ──▶ RouteInventory ─┐
                            ├─▶ AuthzMatrix.run() ─▶ Finding{ evidence.matrix } ─▶ judge()/apply() ─▶ report
  creds.yaml ─▶ IdentitySet ┘                                                         │
                                                              AuthzMatrixValidator ◀──┘

The engine produces ordinary Findings carrying a new Evidence.matrix; they flow through the same validation::judge path as every other finding, so CVSS grading, voting reconciliation, scope withholding and the assurance bundle all work unchanged.

1. Data model (validation.rs + types.rs)

Extend Evidence (all #[serde(default)], fully backward compatible):

pub struct Evidence {
    // … existing fields (baseline, attack, repeats, marker, identity_a/b, …)
    /// One probe per identity for a single route+method.
    #[serde(default)]
    pub matrix: Vec<MatrixProbe>,
    /// The same route+method as a should-be-denied identity, probed with a
    /// well-formed NON-EXISTENT id (the 403-vs-business-error oracle).
    #[serde(default)]
    pub oracle: Option<OracleProbe>,
}

/// Privilege tier an identity is expected to have on a route.
#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Expect { Allow, Deny }

pub struct MatrixProbe {
    pub identity: String,     // "admin" | "readonly" | "tenantB" | "anonymous"
    pub expect: Expect,       // what policy SAYS should happen
    pub exchange: Exchange,   // what actually happened (reuses Exchange)
}

pub struct OracleProbe {
    /// Denied identity calling a WRITE route with a non-existent well-formed id.
    pub denied: Exchange,
    /// Same call, same non-existent id, as the privileged identity — the
    /// control: both should hit the SAME business error if authz is absent.
    pub privileged: Exchange,
}

Exchange already carries identity, status, body, headers — no change.

2. Validator (validation.rs)

CWE reassignment (keeps the disjointness test green)

The test at validation.rs:1895 asserts every validator owns ≥1 CWE and no two share one. Today IdorValidator owns 639/862/863/284/285/566/425 (verified at validation.rs:366). Split by semantics:

Validator CWEs Scope
IdorValidator (narrow) 639, 284, 566, 425 object-level / BOLA (identity B reads A's object), forced browsing
AuthzMatrixValidator (new) 862, 863, 285 function-level / missing-auth / improper-authorization

862 = Missing Authorization, 863 = Incorrect Authorization, 285 = Improper Authorization — all function/decision level, correctly the matrix engine's. Object-level 639/284 and forced-browsing 425/566 stay with Idor. Update validator_for title routing: move "broken access control", "bola" stays→Idor; add "bfla", "function-level", "missing authorization", "read-only", "authorization matrix" → AuthzMatrixValidator.

Dispatch override

In judge/apply, before CWE/title routing, add:

if ev.map_or(false, |e| !e.matrix.is_empty() || e.oracle.is_some()) {
    return AuthzMatrixValidator.validate(f, ev.unwrap());
}

So any finding that arrives with matrix/oracle evidence is judged by the matrix rule regardless of how the agent labeled it.

Verdict logic

impl CweValidator for AuthzMatrixValidator {
    fn name(&self) -> &'static str { "authz-matrix" }
    fn cwes(&self) -> &'static [&'static str] { &["862","863","285"] }

    fn validate(&self, _f: &Finding, ev: &Evidence) -> Verdict {
        // (A) Oracle path — non-destructive proof for write routes.
        if let Some(o) = &ev.oracle {
            if is_auth_denied(&o.denied) {                 // 401/403
                return Verdict::Rejected("authorization enforced — denied identity got 401/403".into());
            }
            if looks_like_login_or_public(&o.denied) {
                return Verdict::Rejected("response is a login page / public view, not the protected handler".into());
            }
            // Denied identity reached business logic iff it got the SAME
            // business-level outcome as the privileged control for a
            // non-existent id (e.g. both "record not found"/422/409, or both 2xx).
            if reached_business_logic(&o.denied) && same_business_outcome(&o.denied, &o.privileged) {
                return Verdict::Confirmed(format!(
                    "write route reached business logic as a should-be-denied identity \
                     (non-existent id → {} like the privileged control, not 403)", o.denied.status));
            }
            return Verdict::NeedsReview("ambiguous — generic 5xx/WAF/empty; cannot tell authz from error".into());
        }

        // (B) Matrix path — read/data routes across identities.
        let denied_but_served: Vec<&MatrixProbe> = ev.matrix.iter()
            .filter(|p| p.expect == Expect::Deny)
            .filter(|p| p.exchange.is_success()
                     && !looks_like_login_or_public(&p.exchange)
                     && !is_empty_or_own_data(&p.exchange))
            .collect();

        if ev.matrix.iter().filter(|p| p.expect == Expect::Deny).count() == 0 {
            return Verdict::NeedsReview("no should-be-denied identity in the matrix — nothing to prove".into());
        }
        match denied_but_served.first() {
            Some(p) => Verdict::Confirmed(format!(
                "identity '{}' (expected deny) was served the protected resource ({}, {} bytes)",
                p.identity, p.exchange.status, p.exchange.len())),
            None => Verdict::Rejected("every should-be-denied identity was correctly refused".into()),
        }
    }
}

Helper predicates (new, small, unit-tested):

  • is_auth_denied(x) → x.status == 401 || 403.
  • reached_business_logic(x) → x.is_success() OR a business-error status (404/409/422/400) whose body carries a business signature (not found/invalid state/does not exist), NOT an auth signature (forbidden/unauthorized/access denied).
  • same_business_outcome(a, b) → same status class AND same business-error signature (so the denied call got identical treatment to the privileged one).
  • looks_like_login_or_public(x) → body matches a login form / redirect to /login / equals the recorded unauthenticated baseline.
  • is_empty_or_own_data(x) → empty body, or body contains only the denied identity's own identifier (so it is not cross-boundary).

These guards encode the FP rules already in the agent markdown: a 200 login page, a response equal to the public view, a 403, or own-data-only is not a finding; a write 200 without read-back persistence is needs-review.

3. Identity fixtures (scope.rs/creds.rs)

Extend the creds.yaml schema (already loaded by the creds parser) with an identities block and seed values, so the engine has multiple role sessions and valid domain inputs to pass business validation:

identities:
  - label: admin        # free-form, used in evidence
    role: admin
    auth: { header: "Authorization", value: "Bearer <admin-token>" }
    tier: high
  - label: readonly
    role: viewer
    auth: { header: "Authorization", value: "Bearer <viewer-token>" }
    tier: readonly        # expected Deny on write routes
  - label: tenantB
    role: user
    auth: { cookie: "session=<other-tenant>" }
    tier: tenant
  - label: anonymous
    auth: {}              # no credential; expected Deny on protected routes
    tier: none

sample_values:            # valid domain inputs to get past format gates
  nfe_key: "35200114200166000187550010000000071000000017"
  cnpj:    "11222333000181"
  known_ids: ["6531aa…", "6531bb…"]   # real ids per identity for BOLA

New types:

pub struct Identity { pub label: String, pub role: String, pub tier: Tier,
                      pub auth: AuthMaterial }
pub enum Tier { High, ReadOnly, Tenant, None }
pub enum AuthMaterial { Header{name:String,value:String}, Cookie(String), Anonymous }
pub struct IdentitySet { pub identities: Vec<Identity>, pub samples: SampleValues }

IdentitySet::expected(route, identity) returns Expect: a write/decision route expects Deny for ReadOnly/None and for Tenant on another tenant's object; a protected read route expects Deny for None. This is the policy the matrix diffs against. The engine never needs the app's real policy table — "anonymous and read-only should not write" is the universal prior, and any Allow observed where Deny was expected is the finding.

4. Engine (authz_matrix.rs, new)

pub struct Route { pub method: String, pub path: String, pub kind: RouteKind }
pub enum RouteKind { Read, Write }   // Write = state-changing (POST/PUT/PATCH/DELETE, or a decision verb)

pub struct RouteInventory { pub routes: Vec<Route> }
impl RouteInventory {
    /// Aggregate from everything recon already produced.
    pub fn build(recon: &Recon, ids: &IdentitySet, http: &Http) -> Self { … }
    //   sources: OpenAPI/Swagger JSON, GraphQL introspection, JS-bundle route
    //   mining, an AUTHENTICATED crawl per identity (the readonly/admin crawls
    //   reveal backoffice routes anon never sees), and sensitive-path guessing.
}

pub struct MatrixResult { pub findings: Vec<(Finding, Evidence)> }

pub async fn run(inv: &RouteInventory, ids: &IdentitySet, http: &Http,
                 scope: &Scope, budget: &Budget) -> MatrixResult {
    for route in dedupe_templates(&inv.routes) {           // collapse /x/{id} variants
        scope.check(&route)?;                              // existing guard: never out of scope
        let privileged = replay(route, ids.highest(), http).await;
        let mut ev = Evidence::default();
        for id in ids.iter().filter(|i| i.tier != Tier::High) {
            let expect = ids.expected(route, id);
            match route.kind {
                RouteKind::Read => {
                    let x = replay_read(route, id, &ids.samples, http).await;
                    ev.matrix.push(MatrixProbe{ identity:id.label.clone(), expect, exchange:x });
                }
                RouteKind::Write if expect == Expect::Deny => {
                    // 403-vs-business-error oracle: non-existent well-formed id,
                    // NEVER a real object → no mutation of real data.
                    let denied     = replay_write_nonexistent(route, id, &ids.samples, http).await;
                    let privileged = replay_write_nonexistent(route, ids.highest(), &ids.samples, http).await;
                    ev.oracle = Some(OracleProbe{ denied, privileged });
                }
                _ => {}
            }
            budget.tick()?;                                // reasoning-budget governor
        }
        if ev.matrix.iter().any(|p| p.expect==Expect::Deny) || ev.oracle.is_some() {
            findings.push((synth_finding(route, &privileged), ev));
        }
    }
}

Safety built in: writes only ever use a non-existent well-formed id, so the oracle proves reachability without mutating real objects; scope.check (the existing scope.rs guard) still gates every request; the budget governor caps routes × identities; dedupe_templates collapses /users/{id} so the matrix is O(routes) not O(objects).

5. Pipeline wiring (pipeline.rs)

Add an engine pass after recon + agent selection, before/alongside the exploit agents (it is a deterministic engine, not an LLM agent, so it is cheap and reproducible):

if identities.len() >= 2 || cfg.authz_matrix {           // auto when ≥2 roles, or --authz-matrix
    let inv = RouteInventory::build(&recon, &identities, &http);
    let res = authz_matrix::run(&inv, &identities, &http, &scope, &budget).await;
    for (mut f, ev) in res.findings {
        let verdict = validation::apply(&mut f, Some(&ev));   // deterministic judge
        emit_finding(f, ev, verdict).await;                  // same sink as agents
    }
}
  • Flag: --authz-matrix on|off|auto (auto = run when ≥2 identities loaded), mirroring --typesafe.
  • The synthesized findings carry owasp = "A01:2025-Broken Access Control", cwe = "862"/"863"/"639", stage = "privesc", and feed the attack graph as weakness nodes on the affected endpoints, so the "systemic" pattern (one module, many routes) is visible as a cluster.

6. Tests

  • Keep validation.rs:1895 disjointness green after the CWE split (add a case: Idor owns 639/284, AuthzMatrix owns 862/863/285, no overlap).
  • Unit-test AuthzMatrixValidator:
    • oracle: denied 403 → Rejected; denied business-404 matching privileged → Confirmed; denied generic-500 → NeedsReview; denied login-page → Rejected.
    • matrix: readonly 200 real body → Confirmed; anonymous 401 → Rejected; denied-but-empty → Rejected; denied-but-own-data → Rejected; no-deny-identity → NeedsReview.
  • Golden run: a seeded app with a read-only role that can write; assert one Confirmed per vulnerable write route and zero on the enforced ones.

7. Why this fits NeuroSploit's invariants

  • Deterministic and model-independent: the verdict is computed from status/body signatures, never a vote, so it holds across backends (the benchmark property).
  • Absent evidence is never a pass: no deny-identity probe → needs-review.
  • Non-destructive: the oracle uses non-existent ids; scope guard unchanged.
  • Reuses Exchange/Evidence/Verdict/judge/report/attack-graph end to end.

Effort estimate

  • Evidence fields + types: ~0.5 day.
  • AuthzMatrixValidator + helpers + tests: ~1 day.
  • IdentitySet/creds schema: ~1 day.
  • RouteInventory (reusing recon outputs): ~2–3 days (the crawl-per-identity is the real work).
  • authz_matrix::run + pipeline wiring + flag: ~1–2 days.

The validator and data model (the deterministic core) are the smallest and highest-leverage slice and can ship first against agent-emitted matrix evidence, before the engine crawls routes on its own.