feat: PR security gate, @neurosploit bot, richer NL REPL (#39)

GitHub automation
- integrations: github_set_status (commit status), github_pr_review
  (REQUEST_CHANGES/APPROVE), github_pr_head_sha, and a shared severity
  gate (severity_rank / worst_confirmed_rank / gate_trips — confirmed
  findings only).
- `neurosploit pr --fail-on <critical|high|medium|low>`: on a confirmed
  finding at/above the threshold, sets a failing `neurosploit/security`
  commit status, posts a REQUEST_CHANGES review, and exits 2 so a CI
  check fails — branch protection then blocks the merge.
- Two ready GitHub Actions: neurosploit-pr-gate.yml (review + block every
  PR) and neurosploit-mention.yml (writers comment @neurosploit <text> to
  trigger a scan; any language; URL → black-box, else PR review).

Natural-language REPL
- Intent now also parses spoken toggles/knobs across PT/EN/ES: Burp/proxy,
  browser/MCP, subscription, "N votos/votes", recon depth (number or
  quick/deep/exhaustive), plus stop verbs. handle_nl returns the follow-up
  command (/run or /stop).

Docs: README trimmed to features (version changelog stays in RELEASE.md),
new automations documented in README + TUTORIAL-INTEGRATION.

Tests: gate (3), NL toggles/stop (added). All green.


Claude-Session: https://claude.ai/code/session_018BGLy4j5qsqqid6CoovowC

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Joas A Santos
2026-08-02 19:12:18 -03:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 21a62c95e5
commit 3786d7c559
8 changed files with 544 additions and 43 deletions
@@ -56,6 +56,33 @@ fn env(name: &str) -> Option<String> {
std::env::var(name).ok().filter(|v| !v.trim().is_empty())
}
/// Severity as a numeric rank (Critical=4 … Info=0) for gate comparisons.
pub fn severity_rank(s: &str) -> u8 {
match s.trim().to_ascii_lowercase().as_str() {
"critical" => 4, "high" => 3, "medium" => 2, "low" => 1, _ => 0,
}
}
/// The single worst confirmed severity across findings (skips needs-review), as
/// a rank. 0 when nothing confirmed.
pub fn worst_confirmed_rank(findings: &[Finding]) -> u8 {
findings.iter()
.filter(|f| f.review_status != "needs-review")
.map(|f| severity_rank(&f.severity))
.max().unwrap_or(0)
}
/// Does any CONFIRMED finding meet/exceed `threshold` (a severity word)? This is
/// the CI gate: true → the PR should be blocked. An unknown threshold disables
/// the gate (returns false).
pub fn gate_trips(findings: &[Finding], threshold: &str) -> bool {
let t = severity_rank(threshold);
if t == 0 && !matches!(threshold.trim().to_ascii_lowercase().as_str(), "low" | "info") {
return false; // unknown threshold word → no gate
}
worst_confirmed_rank(findings) >= t
}
fn client() -> reqwest::Client {
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
@@ -121,6 +148,63 @@ impl Integrations {
Ok(())
}
/// Set a GitHub commit status (Checks-style) so branch protection can BLOCK a
/// merge on a failing state. `state` ∈ success|failure|error|pending. `context`
/// names the check (e.g. "neurosploit/security"). Requires a token with
/// `repo:status` (or `statuses:write` on fine-grained PATs).
pub async fn github_set_status(&self, repo: &str, sha: &str, state: &str,
context: &str, description: &str, target_url: Option<&str>) -> Result<()> {
let tok = self.github_token().ok_or_else(|| anyhow!("{} not set", self.github.token_env))?;
let url = format!("{}/repos/{}/statuses/{}", self.github.api.trim_end_matches('/'), repo, sha);
// GitHub caps status description at 140 chars.
let desc: String = description.chars().take(140).collect();
let mut body = serde_json::json!({ "state": state, "context": context, "description": desc });
if let Some(u) = target_url { body["target_url"] = serde_json::json!(u); }
let resp = client().post(&url)
.header("User-Agent", "NeuroSploit")
.header("Accept", "application/vnd.github+json")
.bearer_auth(tok)
.json(&body)
.send().await?;
if !resp.status().is_success() {
return Err(anyhow!("github status failed: {} {}", resp.status(), resp.text().await.unwrap_or_default()));
}
Ok(())
}
/// Submit a PR review. `event` ∈ APPROVE | REQUEST_CHANGES | COMMENT. Used to
/// REQUEST_CHANGES when critical/high findings land — combined with a "require
/// review" branch rule, this blocks the merge until a human overrides.
pub async fn github_pr_review(&self, repo: &str, number: u64, event: &str, body: &str) -> Result<()> {
let tok = self.github_token().ok_or_else(|| anyhow!("{} not set", self.github.token_env))?;
let url = format!("{}/repos/{}/pulls/{}/reviews", self.github.api.trim_end_matches('/'), repo, number);
let resp = client().post(&url)
.header("User-Agent", "NeuroSploit")
.header("Accept", "application/vnd.github+json")
.bearer_auth(tok)
.json(&serde_json::json!({ "event": event, "body": body }))
.send().await?;
if !resp.status().is_success() {
return Err(anyhow!("github review failed: {} {}", resp.status(), resp.text().await.unwrap_or_default()));
}
Ok(())
}
/// Head commit SHA of a PR (needed to attach a commit status to the PR tip).
pub async fn github_pr_head_sha(&self, repo: &str, number: u64) -> Result<String> {
let url = format!("{}/repos/{}/pulls/{}", self.github.api.trim_end_matches('/'), repo, number);
let mut req = client().get(&url)
.header("User-Agent", "NeuroSploit")
.header("Accept", "application/vnd.github+json");
if let Some(t) = self.github_token() { req = req.bearer_auth(t); }
let resp = req.send().await?;
if !resp.status().is_success() {
return Err(anyhow!("github PR API {}: {}", resp.status(), resp.text().await.unwrap_or_default()));
}
let v: serde_json::Value = resp.json().await?;
v["head"]["sha"].as_str().map(|s| s.to_string()).ok_or_else(|| anyhow!("no head.sha in response"))
}
/// Latest commit SHA of a branch via the GitHub API (for `watch`).
pub async fn github_latest_sha(&self, repo: &str, branch: &str) -> Result<String> {
let url = format!("{}/repos/{}/commits/{}", self.github.api.trim_end_matches('/'), repo, branch);
@@ -197,3 +281,34 @@ impl Integrations {
]
}
}
#[cfg(test)]
mod gate_tests {
use super::*;
use crate::types::Finding;
fn f(sev: &str, status: &str) -> Finding {
Finding { severity: sev.into(), review_status: status.into(), ..Default::default() }
}
#[test]
fn gate_blocks_on_threshold_and_above() {
let fs = vec![f("High", "confirmed"), f("Low", "confirmed")];
assert!(gate_trips(&fs, "high"));
assert!(gate_trips(&fs, "medium"));
assert!(!gate_trips(&fs, "critical"));
}
#[test]
fn gate_ignores_needs_review() {
let fs = vec![f("Critical", "needs-review")];
assert!(!gate_trips(&fs, "critical"));
assert_eq!(worst_confirmed_rank(&fs), 0);
}
#[test]
fn unknown_threshold_disables_gate() {
let fs = vec![f("Critical", "confirmed")];
assert!(!gate_trips(&fs, "banana"));
}
}