mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-10-14 11:41:06 +02:00
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
This commit is contained in:
1 parent
f279817cfe
commit
79685cee49
29 files changed
+8052
-483
No files matched your search
@@ -0,0 +1,354 @@
|
||||
# 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 `Finding`s 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):
|
||||
|
||||
```rust
|
||||
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:
|
||||
|
||||
```rust
|
||||
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
|
||||
|
||||
```rust
|
||||
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:
|
||||
|
||||
```yaml
|
||||
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:
|
||||
|
||||
```rust
|
||||
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)
|
||||
|
||||
```rust
|
||||
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):
|
||||
|
||||
```rust
|
||||
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.
|
||||
```
|
||||
@@ -0,0 +1,152 @@
|
||||
# Graph memory & attack-path reasoning (Neo4j)
|
||||
|
||||
NeuroSploit keeps three graphs: the **knowledge graph** (what was learned about a
|
||||
target, across runs), the **internal graph** (hosts, identities, credentials, the
|
||||
AD credential loop) and the **chain links** (which finding hands which capability
|
||||
to which). `graph_memory.rs` projects all three into one typed schema, reasons
|
||||
over it, and — when configured — MERGEs it into Neo4j and reads accumulated paths
|
||||
back.
|
||||
|
||||
It works without Neo4j. Every run writes `graph/` next to the report:
|
||||
|
||||
| file | what |
|
||||
|---|---|
|
||||
| `graph/attack-chains.md` | chains in the 11-field analyst format, plus next moves |
|
||||
| `graph/graph.cypher` | idempotent MERGE script: `cypher-shell -a <uri> -u <user> -p <pw> -f graph.cypher` |
|
||||
| `graph/graph.json` | nodes/edges as JSON |
|
||||
| `graph/queries.cypher` | ready-made analyst queries |
|
||||
|
||||
## The graph is a phase of the test
|
||||
|
||||
The pipeline runs a **graph checkpoint** at each stage, in every mode (black-box,
|
||||
grey-box, host/infra, container, mobile, AI):
|
||||
|
||||
| checkpoint | what goes in | what it changes |
|
||||
|---|---|---|
|
||||
| `recon` | recon surface (in-scope hosts, services, endpoints, tech, secret fingerprints) + this target's cross-run memory + saved internal graph + Neo4j paths | black-box selection adds **graph-directed agents** (e.g. SSRF → `ssrf_cloud`, authz/identities → `authorization_matrix`, yielded secret → `session_lifecycle`, PostgreSQL+SQLi → `postgres_superuser_rce`, datastore tech → `exposed_datastore`) |
|
||||
| `validated` | the validated findings | every chaining agent sees the new chains |
|
||||
| `chain round N` | each round's validated findings | footholds on the most valuable chains are expanded first |
|
||||
| `final` | final findings (before secrets are masked) | Neo4j sync, report |
|
||||
|
||||
Each checkpoint publishes a **live context** that `operator_directives` adds to
|
||||
every exploit and chaining prompt: the surface, the top chains with their status,
|
||||
the next moves, and the graph discipline (report obtained credentials in
|
||||
`secret`/`account`, dependencies in `chains_from`, unproven steps as hypotheses).
|
||||
It also writes `graph/live-chains.md` and `graph/live.json` so the operator can
|
||||
watch the graph evolve, and MERGEs the run's slice into Neo4j when configured.
|
||||
|
||||
**Dependency todolist** (`graph/todo.md`). Every unconfirmed chain is laid out
|
||||
step by step: `done` when the graph holds evidence for the step, `ready` when every
|
||||
earlier step is done, `blocked` otherwise. The graph *is* the persistent list, so
|
||||
it survives rounds and restarts. The chaining rounds dispatch `ready` steps first,
|
||||
one per foothold, each with the exact step to perform and an instruction not to
|
||||
jump ahead; a step already attempted is not re-sent.
|
||||
|
||||
**Shared traces** (`traces/<agent>.md`). Each agent's transcript is written as it
|
||||
finishes. Every prompt carries a structured digest of the other agents' traces
|
||||
(endpoint paths, parameter names, error signatures, product versions, block
|
||||
signals — never raw lines), fenced as untrusted data, plus the directory to
|
||||
`grep` for full context.
|
||||
|
||||
**Wire-bound evidence** (always on, before validation). Each exchange an agent
|
||||
reports is checked against recorded traffic (when the recording interceptor ran)
|
||||
and re-sent by the harness itself through the scope-guarded replay engine
|
||||
(non-mutating verbs only; identities only when their credentials were recorded).
|
||||
When the wire reproduces the account, the harness's observation replaces it and
|
||||
the validators judge real bytes (`[wire-verified]`). When it does not, the
|
||||
disagreement is recorded and the finding cannot stand as confirmed. What cannot be
|
||||
checked stays as reported and is counted as unverifiable.
|
||||
|
||||
Cross-run memory is scoped to the target: only the component reachable from this
|
||||
target's asset node, through target-specific nodes, is used — another
|
||||
engagement's history never enters this one's prompts.
|
||||
|
||||
## Using it
|
||||
|
||||
```bash
|
||||
# During a run: chaining rounds get the graph's chains and next moves, and the
|
||||
# result is pushed to Neo4j at the end.
|
||||
export NEO4J_USER=neo4j NEO4J_PASSWORD='…'
|
||||
neurosploit run https://app.example --neo4j bolt://localhost:7687
|
||||
|
||||
# After a run (offline, or with --push to MERGE + read back accumulated paths)
|
||||
neurosploit graph <run-id|dir> [--internal internal-graph.json] [--push] [--top 10]
|
||||
|
||||
# Internal network: save the AD/host graph into the run, then reason over both
|
||||
neurosploit internal --graph ad.json --from <node> --save runs/<id>/internal-graph.json
|
||||
neurosploit graph <run-id>
|
||||
```
|
||||
|
||||
Configuration: `--neo4j <uri>` or `NEO4J_URI` (`bolt://`, `neo4j://`, `neo4j+s://`
|
||||
are mapped to the HTTP transactional endpoint on 7474/7473; `http(s)://` is used
|
||||
as is). Credentials only from `NEO4J_USER` (or `NEO4J_USERNAME`),
|
||||
`NEO4J_PASSWORD`, `NEO4J_DATABASE` — never from flags, never written to disk.
|
||||
|
||||
## Schema
|
||||
|
||||
Labels: Target, Asset, Host, IPAddress, Domain, Service, Endpoint, Technology,
|
||||
Vulnerability, Finding, Evidence, Credential, Identity, Role, Permission, Secret,
|
||||
ExploitAttempt, ValidationResult, AttackStep, AttackPath, BusinessImpact, Control,
|
||||
Mitigation.
|
||||
|
||||
Relationships: HAS_ASSET, RESOLVES_TO, EXPOSES, RUNS, HOSTS, USES_TECH,
|
||||
HAS_ENDPOINT, HAS_VULNERABILITY, HAS_FINDING, SUPPORTED_BY, OBSERVED_IN, REQUIRES,
|
||||
ENABLES, YIELDS, CAN_ACCESS, HAS_PERMISSION, ESCALATES_TO, PIVOTS_TO, DEPENDS_ON,
|
||||
VALIDATED_BY, MITIGATED_BY, IMPACTS, PART_OF_PATH.
|
||||
|
||||
Stable keys (MERGE): Host = target + host; Service = host + port + proto; Endpoint =
|
||||
method + normalized URL (ids collapsed to `{id}`, query dropped); Vulnerability =
|
||||
CWE/type + endpoint; Finding = target + type + host + evidence hash; Evidence =
|
||||
hash of the observed exchange (write-once); Credential/Secret = salted fingerprint.
|
||||
|
||||
How web and internal meet: a finding whose evidence demonstrates code execution or
|
||||
internal reach gets `AttackStep -CAN_ACCESS-> Host`. The internal graph's node for
|
||||
the same host shares the key, so its credentials and pivots continue the path.
|
||||
|
||||
## Reasoning rules (enforced in code)
|
||||
|
||||
- **Local vs chain severity.** A chain is only scored above its findings' local
|
||||
severity when it is `confirmed` — every finding confirmed by the validation
|
||||
engine and every edge evidenced. Otherwise it keeps the local severity and states
|
||||
the severity it would have once validated (`High — Critical once validated`).
|
||||
- **Impact must be demonstrated.** An `IMPACTS` edge is confirmed only when the
|
||||
evidence reached that rung: code execution needs a nonce in output or an
|
||||
out-of-band callback; sensitive-data exposure needs sensitive data actually read.
|
||||
"Could lead to RCE" stays `needs_validation`.
|
||||
- **Derived links are hypotheses.** Capability-matched links between findings are
|
||||
`needs_validation`; cross-run memory is never more than `needs_review`.
|
||||
- **Status.** `confirmed` / `needs_validation` / `hypothesis` (rests on an
|
||||
unconfirmed finding, an unproven node, or history).
|
||||
- **Next moves.** The first unproven edge of the most valuable chains, yielded
|
||||
credentials not yet tried against in-scope services (lockout-aware), code
|
||||
execution not yet connected to the internal network, SSRF without a recorded
|
||||
internal target. These feed the attack-chaining rounds, and footholds on the
|
||||
most valuable chains are expanded first.
|
||||
|
||||
## Data minimisation
|
||||
|
||||
No raw secret, token, cookie, password, response body or model narrative becomes a
|
||||
graph property. Secrets are fingerprints salted per installation
|
||||
(`~/.neurosploit/graph-salt`, or `NEUROSPLOIT_GRAPH_SALT` — share one value across a
|
||||
team that shares a Neo4j so credential reuse still correlates). Identities that
|
||||
look like personal data are masked (`a***@corp.test`). Endpoints lose their query
|
||||
strings. Internal credential nodes are named from the node id and kind, never from
|
||||
the label. Labels and relationship types come from closed enums; all values are
|
||||
statement parameters.
|
||||
|
||||
## Queries
|
||||
|
||||
See `graph/queries.cypher`. Examples:
|
||||
|
||||
```cypher
|
||||
// Hypotheses still worth validating, best first
|
||||
MATCH (p:AttackPath) WHERE p.status IN ['needs_validation','hypothesis']
|
||||
RETURN p ORDER BY p.risk_score DESC;
|
||||
|
||||
// Credentials that reach privileged identities (internal)
|
||||
MATCH path = (c)-[:CAN_ACCESS|HAS_PERMISSION|ESCALATES_TO*1..6]->(r)
|
||||
WHERE (c:Credential OR c:Secret) AND (r:Role OR r:Identity) AND r.privileged = true
|
||||
RETURN path;
|
||||
```
|
||||
|
||||
The LLM-facing version of these rules is `agents_md/meta/graph_reasoning.md`.
|
||||
Reference in new issue
Block a user