mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-09-30 21:19:49 +02:00
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:
1 parent
e49595b8bf
commit
5ab6451c15
10 files changed
+304
-8
No files matched your search
@@ -1,4 +1,4 @@
|
||||
<h1 align="center">🧠 NeuroSploit v4.2.0</h1>
|
||||
<h1 align="center">🧠 NeuroSploit v4.2.1</h1>
|
||||
|
||||
<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>
|
||||
@@ -8,7 +8,7 @@
|
||||
</p>
|
||||
|
||||
<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/License-MIT-green?style=flat-square">
|
||||
<img src="https://img.shields.io/badge/MD%20Agents-458-red?style=flat-square">
|
||||
@@ -52,6 +52,15 @@ Control TUI**.
|
||||
|
||||
### 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
|
||||
> a local artifact with 12 reverse-engineering skills (static binary triage,
|
||||
> 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
|
||||
```
|
||||
|
||||
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
|
||||
@@ -761,8 +777,8 @@ and authorization are identical to the CLI. See TUTORIAL section 8.
|
||||
|
||||
## 📊 How we compare
|
||||
|
||||
A rough, honest capability benchmark against Strix, Shannon, Penligent and the
|
||||
other open-source agents — including where NeuroSploit is **behind** (no
|
||||
A rough, honest capability benchmark against Shannon, Penligent and other
|
||||
open-source agents — including where NeuroSploit is **behind** (no
|
||||
container isolation, no real intercepting proxy, no published benchmark run) —
|
||||
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 |
|
||||
| `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.sarif` | SARIF 2.1.0 results for CI code-scanning ingestion |
|
||||
|
||||
A reinforcement-learning reward store (`data/rl_state_rs.json`) biases agent
|
||||
selection on future runs.
|
||||
|
||||
Generated
+2
-2
@@ -929,7 +929,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "neurosploit"
|
||||
version = "4.2.0"
|
||||
version = "4.2.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -946,7 +946,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "neurosploit-harness"
|
||||
version = "4.2.0"
|
||||
version = "4.2.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
||||
@@ -3,7 +3,7 @@ members = ["crates/harness", "app"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "4.2.0"
|
||||
version = "4.2.1"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/JoasASantos/NeuroSploit"
|
||||
|
||||
@@ -203,6 +203,15 @@ enum Cmd {
|
||||
/// Run id or path.
|
||||
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,
|
||||
/// the signed anchors that catch truncation and silent rebuilds.
|
||||
Audit {
|
||||
@@ -808,6 +817,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::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?,
|
||||
@@ -1657,6 +1667,21 @@ fn handle_traffic(base: &std::path::Path, run: &str) -> anyhow::Result<()> {
|
||||
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<()> {
|
||||
let dir = resolve_run(base, run)?;
|
||||
let log = harness::audit::AuditLog::open(dir.join("audit.jsonl"));
|
||||
|
||||
@@ -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_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_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_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"] } }
|
||||
@@ -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");
|
||||
}
|
||||
"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" => {
|
||||
argv.push("internal".into());
|
||||
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"),
|
||||
("meta.json", "target metadata"),
|
||||
("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)> {
|
||||
|
||||
@@ -43,6 +43,7 @@ pub mod replay;
|
||||
pub mod report;
|
||||
pub mod rl;
|
||||
pub mod sandbox;
|
||||
pub mod sarif;
|
||||
pub mod scope;
|
||||
pub mod taint;
|
||||
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\
|
||||
- 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\
|
||||
- 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\
|
||||
- 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;
|
||||
}
|
||||
|
||||
// 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.
|
||||
write_coverage(&cfg, &selected, &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));
|
||||
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.sarif"), crate::sarif::to_string(target, findings))?;
|
||||
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())
|
||||
.unwrap_or_default();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user