feat(report): SARIF 2.1.0 export + stronger cross-object reference mining (v4.2.1)

- new sarif module: projects findings to SARIF 2.1.0 (rules deduped by CWE,
  security-severity from graded CVSS, endpoint locations, OWASP/MITRE tags)
- report::write_all/rebuild now emit report.sarif alongside md/json/html/pdf
- `neurosploit sarif <run> [--out]` re-emits on demand; exposed over MCP
- assurance: report.sarif added to the known-artifacts manifest
- chaining doctrine: harvest every object identifier (ids/UUIDs/tokens/emails)
  into a reference pool and substitute across identities/endpoints — the core
  of reliable BOLA/IDOR/mass-assignment discovery
- version 4.2.1; 389 tests passing

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
CyberSecurityUPandClaude Opus 4.8 committed 2026-09-24 12:14:49 -03:00
1 parent e49595b8bf
commit 5ab6451c15
10 files changed
+304 -8

No files matched your search

+21 -4
View File
@@ -1,4 +1,4 @@
<h1 align="center">🧠 NeuroSploit v4.2.0</h1> <h1 align="center">🧠 NeuroSploit v4.2.1</h1>
<p align="center"> <p align="center">
<a href="https://github.com/JoasASantos/NeuroSploit/stargazers"><img src="https://img.shields.io/github/stars/JoasASantos/NeuroSploit?style=for-the-badge&logo=github&color=8b5cf6" alt="Stars"></a> <a href="https://github.com/JoasASantos/NeuroSploit/stargazers"><img src="https://img.shields.io/github/stars/JoasASantos/NeuroSploit?style=for-the-badge&logo=github&color=8b5cf6" alt="Stars"></a>
@@ -8,7 +8,7 @@
</p> </p>
<p align="center"> <p align="center">
<img src="https://img.shields.io/badge/Version-4.2.0-blue?style=flat-square"> <img src="https://img.shields.io/badge/Version-4.2.1-blue?style=flat-square">
<img src="https://img.shields.io/badge/Harness-Rust%20%7C%20tokio-e6b673?style=flat-square"> <img src="https://img.shields.io/badge/Harness-Rust%20%7C%20tokio-e6b673?style=flat-square">
<img src="https://img.shields.io/badge/License-MIT-green?style=flat-square"> <img src="https://img.shields.io/badge/License-MIT-green?style=flat-square">
<img src="https://img.shields.io/badge/MD%20Agents-458-red?style=flat-square"> <img src="https://img.shields.io/badge/MD%20Agents-458-red?style=flat-square">
@@ -52,6 +52,15 @@ Control TUI**.
### Highlights ### Highlights
> **New in v4.2.1** — **SARIF 2.1.0 export**: every run now writes `report.sarif`
> next to the Markdown/JSON/HTML/PDF, and `neurosploit sarif <run>` (re)emits it
> on demand, so findings drop straight into GitHub / Azure DevOps code-scanning
> as severity-coloured, CWE-linked alerts (also exposed over MCP). Plus stronger
> **cross-object reference mining** in the chaining loop — the engine harvests
> every object identifier it sees (ids, UUIDs, tokens, emails) into a reference
> pool and substitutes them across identities and endpoints, the core of
> reliable BOLA / IDOR / mass-assignment discovery.
> **New in v4.2.0** — **binary / APK / IPA testing**: a new `mobile` mode analyses > **New in v4.2.0** — **binary / APK / IPA testing**: a new `mobile` mode analyses
> a local artifact with 12 reverse-engineering skills (static binary triage, > a local artifact with 12 reverse-engineering skills (static binary triage,
> APK/IPA static analysis, RASP & anti-tamper mapping, root/jailbreak, TLS > APK/IPA static analysis, RASP & anti-tamper mapping, root/jailbreak, TLS
@@ -741,6 +750,13 @@ a `.http` file:
neurosploit traffic <run> # flows.jsonl -> traffic.http neurosploit traffic <run> # flows.jsonl -> traffic.http
``` ```
Every run also writes `report.sarif` (SARIF 2.1.0); re-emit it any time for CI
code-scanning:
```bash
neurosploit sarif <run> # findings -> report.sarif (GitHub/Azure code-scanning)
```
--- ---
## 🔌 Run it as an MCP server ## 🔌 Run it as an MCP server
@@ -761,8 +777,8 @@ and authorization are identical to the CLI. See TUTORIAL section 8.
## 📊 How we compare ## 📊 How we compare
A rough, honest capability benchmark against Strix, Shannon, Penligent and the A rough, honest capability benchmark against Shannon, Penligent and other
other open-source agents — including where NeuroSploit is **behind** (no open-source agents — including where NeuroSploit is **behind** (no
container isolation, no real intercepting proxy, no published benchmark run) — container isolation, no real intercepting proxy, no published benchmark run) —
lives in **[BENCHMARK.md](BENCHMARK.md)**. lives in **[BENCHMARK.md](BENCHMARK.md)**.
@@ -958,6 +974,7 @@ Every run writes a self-contained folder `runs/ns-<ts>-<target>/`:
| `exploitation.md` | raw per-agent transcript | | `exploitation.md` | raw per-agent transcript |
| `findings.json` / `findings.md` | validated findings (reuse by other tools/AIs) | | `findings.json` / `findings.md` | validated findings (reuse by other tools/AIs) |
| `report.html`, `report.typ`, `report.pdf` | final report (PDF via the Typst engine) | | `report.html`, `report.typ`, `report.pdf` | final report (PDF via the Typst engine) |
| `report.sarif` | SARIF 2.1.0 results for CI code-scanning ingestion |
A reinforcement-learning reward store (`data/rl_state_rs.json`) biases agent A reinforcement-learning reward store (`data/rl_state_rs.json`) biases agent
selection on future runs. selection on future runs.
+2 -2
View File
@@ -929,7 +929,7 @@ dependencies = [
[[package]] [[package]]
name = "neurosploit" name = "neurosploit"
version = "4.2.0" version = "4.2.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
@@ -946,7 +946,7 @@ dependencies = [
[[package]] [[package]]
name = "neurosploit-harness" name = "neurosploit-harness"
version = "4.2.0" version = "4.2.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
+1 -1
View File
@@ -3,7 +3,7 @@ members = ["crates/harness", "app"]
resolver = "2" resolver = "2"
[workspace.package] [workspace.package]
version = "4.2.0" version = "4.2.1"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
repository = "https://github.com/JoasASantos/NeuroSploit" repository = "https://github.com/JoasASantos/NeuroSploit"
+25
View File
@@ -203,6 +203,15 @@ enum Cmd {
/// Run id or path. /// Run id or path.
run: String, run: String,
}, },
/// Emit SARIF 2.1.0 for a finished run so CI code-scanning (GitHub, Azure
/// DevOps) can ingest the findings as annotated, severity-coloured alerts.
Sarif {
/// Run id (`ns-…`) or a path to the run directory.
run: String,
/// Write to this path instead of the run's `report.sarif`.
#[arg(long = "out")]
out: Option<String>,
},
/// Verify a finished run's audit trail — the hash chain and, with --anchor, /// Verify a finished run's audit trail — the hash chain and, with --anchor,
/// the signed anchors that catch truncation and silent rebuilds. /// the signed anchors that catch truncation and silent rebuilds.
Audit { Audit {
@@ -808,6 +817,7 @@ async fn main() -> anyhow::Result<()> {
} }
Cmd::Audit { run, anchor } => handle_audit(&base, &run, anchor)?, Cmd::Audit { run, anchor } => handle_audit(&base, &run, anchor)?,
Cmd::Traffic { run } => handle_traffic(&base, &run)?, Cmd::Traffic { run } => handle_traffic(&base, &run)?,
Cmd::Sarif { run, out } => handle_sarif(&base, &run, out.as_deref())?,
Cmd::Assurance { run, verify } => handle_assurance(&base, &run, verify)?, Cmd::Assurance { run, verify } => handle_assurance(&base, &run, verify)?,
Cmd::Compliance { run, framework, include_leads } => handle_compliance(&base, &run, &framework, include_leads)?, Cmd::Compliance { run, framework, include_leads } => handle_compliance(&base, &run, &framework, include_leads)?,
Cmd::Poc { run, repeats, apply } => handle_poc(&base, &run, repeats, apply).await?, Cmd::Poc { run, repeats, apply } => handle_poc(&base, &run, repeats, apply).await?,
@@ -1657,6 +1667,21 @@ fn handle_traffic(base: &std::path::Path, run: &str) -> anyhow::Result<()> {
Ok(()) 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)?;
let target = std::fs::read_to_string(dir.join("meta.json"))
.ok()
.and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
.and_then(|v| v.get("target").and_then(|x| x.as_str()).map(String::from))
.unwrap_or_else(|| run.to_string());
let doc = harness::sarif::to_string(&target, &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());
Ok(())
}
fn handle_audit(base: &std::path::Path, run: &str, anchor: bool) -> anyhow::Result<()> { fn handle_audit(base: &std::path::Path, run: &str, anchor: bool) -> anyhow::Result<()> {
let dir = resolve_run(base, run)?; let dir = resolve_run(base, run)?;
let log = harness::audit::AuditLog::open(dir.join("audit.jsonl")); let log = harness::audit::AuditLog::open(dir.join("audit.jsonl"));
+2
View File
@@ -99,6 +99,7 @@ fn tool_list() -> Value {
{ "name": "neurosploit_findings", "description": "Read a finished run's findings as JSON.", "inputSchema": { "type": "object", "properties": { "run": { "type": "string", "description": "Run id or path" } }, "required": ["run"] } }, { "name": "neurosploit_findings", "description": "Read a finished run's findings as JSON.", "inputSchema": { "type": "object", "properties": { "run": { "type": "string", "description": "Run id or path" } }, "required": ["run"] } },
{ "name": "neurosploit_report", "description": "Read a finished run's Markdown report.", "inputSchema": { "type": "object", "properties": { "run": { "type": "string" } }, "required": ["run"] } }, { "name": "neurosploit_report", "description": "Read a finished run's Markdown report.", "inputSchema": { "type": "object", "properties": { "run": { "type": "string" } }, "required": ["run"] } },
{ "name": "neurosploit_rebuild", "description": "Rebuild a run's report artifacts from its findings (no model calls).", "inputSchema": { "type": "object", "properties": { "run": { "type": "string" } }, "required": ["run"] } }, { "name": "neurosploit_rebuild", "description": "Rebuild a run's report artifacts from its findings (no model calls).", "inputSchema": { "type": "object", "properties": { "run": { "type": "string" } }, "required": ["run"] } },
{ "name": "neurosploit_sarif", "description": "Emit SARIF 2.1.0 for a finished run (report.sarif) so CI code-scanning can ingest the findings.", "inputSchema": { "type": "object", "properties": { "run": { "type": "string" } }, "required": ["run"] } },
{ "name": "neurosploit_internal", "description": "Internal-network / Active Directory attack-graph analysis: paths to crown jewels and the choke point to fix first.", "inputSchema": { "type": "object", "properties": { "graph": { "type": "string", "description": "Path to a graph JSON" }, "scaffold": { "type": "string", "description": "Domain to scaffold, e.g. corp.local" }, "from": { "type": "string", "description": "Foothold node id" } } } }, { "name": "neurosploit_internal", "description": "Internal-network / Active Directory attack-graph analysis: paths to crown jewels and the choke point to fix first.", "inputSchema": { "type": "object", "properties": { "graph": { "type": "string", "description": "Path to a graph JSON" }, "scaffold": { "type": "string", "description": "Domain to scaffold, e.g. corp.local" }, "from": { "type": "string", "description": "Foothold node id" } } } },
{ "name": "neurosploit_compliance", "description": "Map a finished run's findings onto PCI-DSS, HIPAA or SOC 2 controls.", "inputSchema": { "type": "object", "properties": { "run": { "type": "string" }, "framework": { "type": "string", "enum": ["pci-dss","hipaa","soc2"] } }, "required": ["run"] } }, { "name": "neurosploit_compliance", "description": "Map a finished run's findings onto PCI-DSS, HIPAA or SOC 2 controls.", "inputSchema": { "type": "object", "properties": { "run": { "type": "string" }, "framework": { "type": "string", "enum": ["pci-dss","hipaa","soc2"] } }, "required": ["run"] } },
{ "name": "neurosploit_container", "description": "Scan an OCI container image (repo:tag / tar / Dockerfile) for vulnerable packages, secrets, misconfig and emit an SBOM.", "inputSchema": { "type": "object", "properties": { "image": { "type": "string" }, "model": { "type": "string" }, "subscription": { "type": "boolean" } }, "required": ["image"] } } { "name": "neurosploit_container", "description": "Scan an OCI container image (repo:tag / tar / Dockerfile) for vulnerable packages, secrets, misconfig and emit an SBOM.", "inputSchema": { "type": "object", "properties": { "image": { "type": "string" }, "model": { "type": "string" }, "subscription": { "type": "boolean" } }, "required": ["image"] } }
@@ -138,6 +139,7 @@ fn handle_call(id: Option<Value>, req: &Value, exe: &std::path::Path) -> Value {
return read_run_file(id, &run, "report.md"); return read_run_file(id, &run, "report.md");
} }
"neurosploit_rebuild" => { let Some(run) = s("run") else { return tool_err(id, "run is required") }; argv.push("rebuild".into()); argv.push(run); } "neurosploit_rebuild" => { let Some(run) = s("run") else { return tool_err(id, "run is required") }; argv.push("rebuild".into()); argv.push(run); }
"neurosploit_sarif" => { let Some(run) = s("run") else { return tool_err(id, "run is required") }; argv.push("sarif".into()); argv.push(run); }
"neurosploit_internal" => { "neurosploit_internal" => {
argv.push("internal".into()); argv.push("internal".into());
if let Some(g) = s("graph") { argv.push("--graph".into()); argv.push(g); } if let Some(g) = s("graph") { argv.push("--graph".into()); argv.push(g); }
@@ -86,6 +86,7 @@ const KNOWN: &[(&str, &str)] = &[
("flows.jsonl", "intercepted request/response flows"), ("flows.jsonl", "intercepted request/response flows"),
("meta.json", "target metadata"), ("meta.json", "target metadata"),
("coverage.md", "what was tested and what was not"), ("coverage.md", "what was tested and what was not"),
("report.sarif", "SARIF 2.1.0 results for CI code-scanning ingestion"),
]; ];
fn hash_file(path: &Path) -> Option<(String, u64)> { fn hash_file(path: &Path) -> Option<(String, u64)> {
+1
View File
@@ -43,6 +43,7 @@ pub mod replay;
pub mod report; pub mod report;
pub mod rl; pub mod rl;
pub mod sandbox; pub mod sandbox;
pub mod sarif;
pub mod scope; pub mod scope;
pub mod taint; pub mod taint;
pub mod transport; pub mod transport;
@@ -607,6 +607,7 @@ const CHAIN_DOCTRINE: &str = "CHAIN THE FOOTHOLD (pivot to deeper, provable impa
· A param that lands in a redirect/`Location` header → ALSO test CRLF/header injection on the SAME param (`%0d%0aX-Injected: pwned`, `%0d%0aSet-Cookie:`): an open redirect and response splitting share the sink, so never stop at the redirect.\n\ · A param that lands in a redirect/`Location` header → ALSO test CRLF/header injection on the SAME param (`%0d%0aX-Injected: pwned`, `%0d%0aSet-Cookie:`): an open redirect and response splitting share the sink, so never stop at the redirect.\n\
- Second-order & preconditions: a payload you STORE (profile/bio/name/review/filename) may only fire on a DIFFERENT page, often a privileged one (e.g. an admin search). Plant the payload, then TRIGGER it from every identity you hold; if the trigger page needs a role you lack, FIRST look for a privesc/IDOR/mass-assign to reach it, and if none exists, report the second-order as a CHAINED lead (payload stored + trigger located, blocked only by authorization) rather than dropping it.\n\ - Second-order & preconditions: a payload you STORE (profile/bio/name/review/filename) may only fire on a DIFFERENT page, often a privileged one (e.g. an admin search). Plant the payload, then TRIGGER it from every identity you hold; if the trigger page needs a role you lack, FIRST look for a privesc/IDOR/mass-assign to reach it, and if none exists, report the second-order as a CHAINED lead (payload stored + trigger located, blocked only by authorization) rather than dropping it.\n\
- Reuse loot relentlessly: every credential/JWT/cookie/API key/host you obtain is input to the next step — carry it forward across modules and try it everywhere it might be accepted.\n\ - Reuse loot relentlessly: every credential/JWT/cookie/API key/host you obtain is input to the next step — carry it forward across modules and try it everywhere it might be accepted.\n\
- Mine object references (this is the BOLA/IDOR engine): from EVERY response harvest each object identifier you see — numeric ids, UUIDs, order/invoice/document/ticket numbers, account/customer ids, filenames, emails, and any signed/opaque token — into a reference pool. Then systematically SUBSTITUTE another principal's identifier into every request that accepts one (path segment, query param, JSON field, header, cookie) and diff the response against your own: another user's data returned under your session IS the proof. Do this ACROSS identities — register/hold ≥2 accounts and cross them — and across endpoints, because an id leaked on one route (a list/search/export) is often the key to an object on another (a detail/update/delete route). Enumerate sequential ids sparingly (a small benign sample, never a mass scrape) to show the pattern, then stop at proof.\n\
- Understand the BUSINESS & LOGIC: reason about what the app is FOR (payments, orders, tenancy, KYC, entitlements) and chain toward business impact — payment/price/coupon abuse, cross-tenant data access, entitlement/limit bypass, workflow/state-machine skips (skip approval/verification steps), race conditions on balance/stock. These compound: each finding updates your model of the app for the next probe.\n\ - Understand the BUSINESS & LOGIC: reason about what the app is FOR (payments, orders, tenancy, KYC, entitlements) and chain toward business impact — payment/price/coupon abuse, cross-tenant data access, entitlement/limit bypass, workflow/state-machine skips (skip approval/verification steps), race conditions on balance/stock. These compound: each finding updates your model of the app for the next probe.\n\
- Stop at proof: demonstrate the impact with the SMALLEST safe step and report the CHAIN end-to-end; never destroy, overwrite, encrypt, mass-exfiltrate, or DoS to 'prove' it.\n\n"; - Stop at proof: demonstrate the impact with the SMALLEST safe step and report the CHAIN end-to-end; never destroy, overwrite, encrypt, mass-exfiltrate, or DoS to 'prove' it.\n\n";
@@ -2558,7 +2559,7 @@ async fn finish(cfg: RunConfig, _lib: &Library, pool: &ModelPool, recon: String,
let _ = tx.send(n).await; let _ = tx.send(n).await;
} }
// Coverage report (Strix-style): what was tested, how, and what was NOT — // Coverage report: what was tested, how, and what was NOT —
// so a reader can see the engagement's reach, not just its findings. // so a reader can see the engagement's reach, not just its findings.
write_coverage(&cfg, &selected, &findings); write_coverage(&cfg, &selected, &findings);
let artifacts = persist(&cfg, &recon, &transcript, &findings); let artifacts = persist(&cfg, &recon, &transcript, &findings);
@@ -810,6 +810,7 @@ pub fn write_all(target: &str, findings: &[Finding], dir: &Path) -> std::io::Res
md.push_str(&pocs_section(dir)); md.push_str(&pocs_section(dir));
std::fs::write(dir.join("report.md"), md)?; std::fs::write(dir.join("report.md"), md)?;
std::fs::write(dir.join("report.json"), json_report(target, findings, &run_id, &meta))?; std::fs::write(dir.join("report.json"), json_report(target, findings, &run_id, &meta))?;
std::fs::write(dir.join("report.sarif"), crate::sarif::to_string(target, findings))?;
let pocs: Vec<String> = std::fs::read_dir(dir.join("pocs")) let pocs: Vec<String> = std::fs::read_dir(dir.join("pocs"))
.map(|rd| rd.filter_map(|e| e.ok()).map(|e| e.file_name().to_string_lossy().to_string()).collect()) .map(|rd| rd.filter_map(|e| e.ok()).map(|e| e.file_name().to_string_lossy().to_string()).collect())
.unwrap_or_default(); .unwrap_or_default();
+248
View File
@@ -0,0 +1,248 @@
//! SARIF 2.1.0 export for a finished run.
//!
//! SARIF (Static Analysis Results Interchange Format) is the format GitHub code
//! scanning, Azure DevOps, and most CI dashboards ingest. Emitting it next to
//! `report.md` lets a NeuroSploit run drop straight into a pipeline: upload
//! `report.sarif` and every finding shows up as an annotated alert, coloured by
//! severity, linked to its CWE, on the exact endpoint it was proven against.
//!
//! This is a pure projection of the findings already on disk — no model calls,
//! no fabrication. It is emitted by [`crate::report::write_all`] and rebuilt by
//! [`crate::report::rebuild`], and can be regenerated on demand from the CLI.
//!
//! We follow the parts of the spec CI actually consumes:
//! - one `run` with a `tool.driver` carrying a de-duplicated `rules` array,
//! - `security-severity` (0.0..10.0) on each rule, which GitHub uses to bucket
//! the alert into critical/high/medium/low,
//! - one `result` per finding with `ruleId`, `level`, a message, and a
//! physical location pointing at the endpoint.
use crate::types::Finding;
use serde_json::{json, Value};
use std::collections::BTreeMap;
const SCHEMA: &str = "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json";
/// SARIF result level. GitHub renders error/warning/note distinctly.
fn level_for(severity: &str) -> &'static str {
match severity.trim().to_ascii_lowercase().as_str() {
"critical" | "high" => "error",
"medium" => "warning",
"low" => "note",
_ => "none",
}
}
/// Numeric CVSS base score parsed from the leading float of the `cvss` field
/// (e.g. "9.8 (AV:N/AC:L/...)" -> 9.8). Falls back to a severity-band midpoint
/// so GitHub still buckets the alert when no vector was graded.
fn security_severity(f: &Finding) -> String {
let parsed = f
.cvss
.trim()
.split(|c: char| !(c.is_ascii_digit() || c == '.'))
.find(|t| !t.is_empty())
.and_then(|t| t.parse::<f64>().ok())
.filter(|v| (0.0..=10.0).contains(v));
let score = parsed.unwrap_or_else(|| match f.severity.trim().to_ascii_lowercase().as_str() {
"critical" => 9.5,
"high" => 7.5,
"medium" => 5.0,
"low" => 2.5,
_ => 0.0,
});
format!("{score:.1}")
}
/// A stable rule id for a finding: prefer the CWE, else the agent class.
fn rule_id(f: &Finding) -> String {
let cwe = f.cwe.trim();
if cwe.is_empty() {
let agent = f.agent.trim();
if agent.is_empty() { "NEUROSPLOIT.finding".to_string() } else { format!("NEUROSPLOIT.{agent}") }
} else if cwe.to_ascii_uppercase().starts_with("CWE-") {
cwe.to_ascii_uppercase()
} else {
format!("CWE-{cwe}")
}
}
/// Help URI for a CWE-style rule id (deep link to the MITRE entry).
fn help_uri(rule_id: &str) -> Option<String> {
let num: String = rule_id.chars().filter(|c| c.is_ascii_digit()).collect();
if rule_id.starts_with("CWE-") && !num.is_empty() {
Some(format!("https://cwe.mitre.org/data/definitions/{num}.html"))
} else {
None
}
}
/// Build the full SARIF 2.1.0 document for a run's findings.
pub fn to_sarif(target: &str, findings: &[Finding]) -> Value {
// De-duplicate rules by id; the first finding of each class defines the rule.
let mut rules: BTreeMap<String, Value> = BTreeMap::new();
for f in findings {
let id = rule_id(f);
rules.entry(id.clone()).or_insert_with(|| {
let mut rule = json!({
"id": id,
"name": if f.cwe.trim().is_empty() { f.agent.clone() } else { f.cwe.clone() },
"shortDescription": { "text": rule_short_desc(f) },
"defaultConfiguration": { "level": level_for(&f.severity) },
"properties": {
"security-severity": security_severity(f),
"tags": rule_tags(f),
}
});
if let Some(uri) = help_uri(&id) {
rule["helpUri"] = json!(uri);
}
rule
});
}
let results: Vec<Value> = findings.iter().map(|f| result_for(f)).collect();
json!({
"$schema": SCHEMA,
"version": "2.1.0",
"runs": [{
"tool": {
"driver": {
"name": "NeuroSploit",
"version": env!("CARGO_PKG_VERSION"),
"informationUri": "https://github.com/CyberSecurityUP/neurosploit-rs",
"rules": rules.into_values().collect::<Vec<_>>(),
}
},
"properties": { "target": target },
"results": results,
}]
})
}
fn rule_short_desc(f: &Finding) -> String {
if !f.cwe.trim().is_empty() {
f.title.clone()
} else {
format!("{} finding", f.agent)
}
}
/// Rule tags CI can facet on: OWASP + MITRE when known, plus "security".
fn rule_tags(f: &Finding) -> Vec<String> {
let mut tags = vec!["security".to_string()];
if !f.owasp.trim().is_empty() { tags.push(f.owasp.clone()); }
if !f.mitre.trim().is_empty() { tags.push(f.mitre.clone()); }
tags
}
fn result_for(f: &Finding) -> Value {
// The endpoint is the closest thing to a physical location for a web
// finding; `location` (param/field/step) refines the message.
let uri = if f.endpoint.trim().is_empty() { "target".to_string() } else { f.endpoint.clone() };
let mut text = f.title.clone();
if !f.location.trim().is_empty() {
text.push_str(&format!(" — {}", f.location));
}
if !f.impact.trim().is_empty() {
text.push_str(&format!("\nImpact: {}", f.impact));
}
let mut props = json!({
"confidence": f.confidence,
"validated": f.validated,
"severity": f.severity,
});
if !f.cvss.trim().is_empty() { props["cvss"] = json!(f.cvss); }
if !f.owasp.trim().is_empty() { props["owasp"] = json!(f.owasp); }
if !f.mitre.trim().is_empty() { props["mitre"] = json!(f.mitre); }
if !f.review_status.trim().is_empty() { props["reviewStatus"] = json!(f.review_status); }
json!({
"ruleId": rule_id(f),
"level": level_for(&f.severity),
"message": { "text": text },
"locations": [{
"physicalLocation": {
"artifactLocation": { "uri": uri }
}
}],
"partialFingerprints": { "neurosploitFindingId": f.id },
"properties": props,
})
}
/// Serialize the SARIF document to pretty JSON.
pub fn to_string(target: &str, findings: &[Finding]) -> String {
serde_json::to_string_pretty(&to_sarif(target, findings)).unwrap_or_else(|_| "{}".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn finding(sev: &str, cwe: &str, cvss: &str) -> Finding {
Finding {
id: "f1".into(),
agent: "web".into(),
title: "SQL injection".into(),
severity: sev.into(),
cwe: cwe.into(),
cvss: cvss.into(),
endpoint: "https://app.example.com/api/login".into(),
..Default::default()
}
}
#[test]
fn levels_map_from_severity() {
assert_eq!(level_for("Critical"), "error");
assert_eq!(level_for("high"), "error");
assert_eq!(level_for("Medium"), "warning");
assert_eq!(level_for("Low"), "note");
assert_eq!(level_for("Info"), "none");
}
#[test]
fn cvss_score_parsed_from_vector_string() {
let f = finding("Critical", "CWE-89", "9.8 (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H)");
assert_eq!(security_severity(&f), "9.8");
}
#[test]
fn cvss_falls_back_to_severity_band() {
let f = finding("High", "CWE-89", "");
assert_eq!(security_severity(&f), "7.5");
}
#[test]
fn rule_id_prefers_cwe() {
assert_eq!(rule_id(&finding("High", "CWE-89", "")), "CWE-89");
assert_eq!(rule_id(&finding("High", "89", "")), "CWE-89");
assert_eq!(rule_id(&finding("High", "", "")), "NEUROSPLOIT.web");
}
#[test]
fn help_uri_deep_links_cwe() {
assert_eq!(help_uri("CWE-89").as_deref(), Some("https://cwe.mitre.org/data/definitions/89.html"));
assert_eq!(help_uri("NEUROSPLOIT.web"), None);
}
#[test]
fn document_is_wellformed_and_dedupes_rules() {
let fs = vec![
finding("Critical", "CWE-89", "9.8"),
finding("High", "CWE-89", "8.1"),
finding("Medium", "CWE-79", "6.1"),
];
let doc = to_sarif("https://app.example.com", &fs);
assert_eq!(doc["version"], "2.1.0");
let rules = doc["runs"][0]["tool"]["driver"]["rules"].as_array().unwrap();
assert_eq!(rules.len(), 2, "two distinct CWEs -> two rules");
let results = doc["runs"][0]["results"].as_array().unwrap();
assert_eq!(results.len(), 3);
assert_eq!(results[0]["ruleId"], "CWE-89");
assert_eq!(results[0]["level"], "error");
}
}