v3.5.3 — Integrations (GitHub · GitLab · Jira)

New harness module `integrations` (+ app commands) wiring NeuroSploit into the
SDLC. Config persists per-project to .neurosploit/integrations.json; secrets are
NEVER stored — only the env-var name is saved, values read from the environment.

GitHub:
- private-repo clone (token injected into the clone URL for whitebox/greybox/tui)
- `neurosploit pr <owner/repo> <n>`: clone the PR head (refs/pull/N/head),
  white-box review, optional `--comment` (PR summary) and `--jira` (cards)
- `neurosploit watch <owner/repo> --branch --interval`: re-review on each new commit
GitLab:
- private-repo clone (oauth2 token) for whitebox/greybox (gitlab.com or self-hosted)
Jira:
- `--jira` on any engagement opens one card per finding (REST /issue, basic auth)

Control:
- `/integrations` (REPL): show · enable/disable · setup jira|gitlab|github
- `neurosploit integrations [show|enable|disable] [github|gitlab|jira]` (CLI)

Docs: README "Integrations" section + new TUTORIAL-INTEGRATION.md (per-tool setup,
scopes, recipes, troubleshooting). Version bumped 3.5.2 → 3.5.3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
CyberSecurityUP
2026-06-27 01:56:49 -03:00
parent ae5bb247a3
commit 64decada3e
17 changed files with 805 additions and 34 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
//! POMDP belief-state world model (v3.5.2).
//! POMDP belief-state world model (v3.5.3).
//!
//! The target is only partially observable, so we don't track booleans — we
//! track a **belief**: a property graph whose nodes (host / service / vuln /
@@ -1,4 +1,4 @@
//! Verification / grounding engine (v3.5.2).
//! Verification / grounding engine (v3.5.3).
//!
//! Hard rule: **no claim enters the world model without a tool receipt** — raw
//! tool output, not the LLM's paraphrase. This is the empirical anti-hallucination
@@ -0,0 +1,199 @@
//! External integrations (v3.5.3): GitHub / GitLab (private repos, PR/MR code
//! review, commit watching) and Jira (open one vulnerability card per finding).
//!
//! Config persists to `<project>/.neurosploit/integrations.json`. **Secrets are
//! never stored** — only the *name* of the env var holding each token is saved;
//! the value is read from the environment at use time.
use crate::types::Finding;
use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Serialize, Deserialize, Clone)]
pub struct GithubCfg {
pub enabled: bool,
pub token_env: String, // e.g. GITHUB_TOKEN (a PAT with `repo` scope for private repos)
pub api: String, // https://api.github.com (or GHE base)
}
impl Default for GithubCfg {
fn default() -> Self { Self { enabled: false, token_env: "GITHUB_TOKEN".into(), api: "https://api.github.com".into() } }
}
#[derive(Serialize, Deserialize, Clone)]
pub struct GitlabCfg {
pub enabled: bool,
pub token_env: String, // GITLAB_TOKEN
pub base: String, // https://gitlab.com (or self-hosted)
}
impl Default for GitlabCfg {
fn default() -> Self { Self { enabled: false, token_env: "GITLAB_TOKEN".into(), base: "https://gitlab.com".into() } }
}
#[derive(Serialize, Deserialize, Clone)]
pub struct JiraCfg {
pub enabled: bool,
pub base_url: String, // https://your-org.atlassian.net
pub email_env: String, // JIRA_EMAIL
pub token_env: String, // JIRA_API_TOKEN
pub project_key: String,
pub issue_type: String, // Bug / Vulnerability / Task
}
impl Default for JiraCfg {
fn default() -> Self {
Self { enabled: false, base_url: String::new(), email_env: "JIRA_EMAIL".into(),
token_env: "JIRA_API_TOKEN".into(), project_key: String::new(), issue_type: "Bug".into() }
}
}
#[derive(Serialize, Deserialize, Clone, Default)]
pub struct Integrations {
pub github: GithubCfg,
pub gitlab: GitlabCfg,
pub jira: JiraCfg,
}
fn env(name: &str) -> Option<String> {
std::env::var(name).ok().filter(|v| !v.trim().is_empty())
}
fn client() -> reqwest::Client {
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.unwrap_or_default()
}
impl Integrations {
pub fn path(dir: &Path) -> std::path::PathBuf { dir.join("integrations.json") }
pub fn load(dir: &Path) -> Self {
std::fs::read_to_string(Self::path(dir))
.ok()
.and_then(|t| serde_json::from_str(&t).ok())
.unwrap_or_default()
}
pub fn save(&self, dir: &Path) -> Result<()> {
std::fs::create_dir_all(dir).ok();
std::fs::write(Self::path(dir), serde_json::to_string_pretty(self)?)?;
Ok(())
}
pub fn github_token(&self) -> Option<String> { env(&self.github.token_env) }
pub fn gitlab_token(&self) -> Option<String> { env(&self.gitlab.token_env) }
/// Inject a token into an https git URL so private repos can be cloned.
/// No-op if the matching integration is off, the token env is unset, or the
/// URL doesn't match the configured host.
pub fn authed_clone_url(&self, url: &str) -> String {
if self.github.enabled {
if let Some(rest) = url.strip_prefix("https://github.com/") {
if let Some(tok) = self.github_token() {
return format!("https://x-access-token:{tok}@github.com/{rest}");
}
}
}
if self.gitlab.enabled {
let host = self.gitlab.base.trim_start_matches("https://").trim_start_matches("http://").trim_end_matches('/');
let prefix = format!("https://{host}/");
if let Some(rest) = url.strip_prefix(&prefix) {
if let Some(tok) = self.gitlab_token() {
return format!("https://oauth2:{tok}@{host}/{rest}");
}
}
}
url.to_string()
}
/// Post a comment on a GitHub PR/issue (`repo` = `owner/name`).
pub async fn github_comment(&self, repo: &str, number: u64, body: &str) -> Result<()> {
let tok = self.github_token().ok_or_else(|| anyhow!("{} not set", self.github.token_env))?;
let url = format!("{}/repos/{}/issues/{}/comments", 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!({ "body": body }))
.send().await?;
if !resp.status().is_success() {
return Err(anyhow!("github comment failed: {} {}", resp.status(), resp.text().await.unwrap_or_default()));
}
Ok(())
}
/// 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);
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 commits API {}: {}", resp.status(), resp.text().await.unwrap_or_default()));
}
let v: serde_json::Value = resp.json().await?;
v["sha"].as_str().map(|s| s.to_string()).ok_or_else(|| anyhow!("no sha in response"))
}
/// Create one Jira issue. Returns the issue key (e.g. SEC-123).
pub async fn jira_card(&self, summary: &str, description: &str) -> Result<String> {
let email = env(&self.jira.email_env).ok_or_else(|| anyhow!("{} not set", self.jira.email_env))?;
let token = env(&self.jira.token_env).ok_or_else(|| anyhow!("{} not set", self.jira.token_env))?;
if self.jira.base_url.is_empty() || self.jira.project_key.is_empty() {
return Err(anyhow!("jira base_url/project_key not configured (run /integrations setup jira)"));
}
let url = format!("{}/rest/api/2/issue", self.jira.base_url.trim_end_matches('/'));
let payload = serde_json::json!({
"fields": {
"project": { "key": self.jira.project_key },
"summary": summary,
"description": description,
"issuetype": { "name": self.jira.issue_type },
}
});
let resp = client().post(&url)
.basic_auth(email, Some(token))
.header("Accept", "application/json")
.json(&payload)
.send().await?;
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if !status.is_success() {
return Err(anyhow!("jira create failed: {} {}", status, text));
}
let v: serde_json::Value = serde_json::from_str(&text)?;
Ok(v["key"].as_str().unwrap_or("?").to_string())
}
/// Open one Jira card per finding. Returns (created keys, errors).
pub async fn jira_cards_for(&self, target: &str, findings: &[Finding]) -> (Vec<String>, Vec<String>) {
let (mut keys, mut errs) = (Vec::new(), Vec::new());
for f in findings {
let summary = format!("[{}] {}{}", f.severity, f.title, target);
let description = format!(
"*Target:* {target}\n*Severity:* {} | *CVSS:* {} | *CWE:* {}\n*Location:* {}\n\n*Impact:*\n{}\n\n*PoC / payload:*\n{{code}}{}{{code}}\n\n*Evidence:*\n{{code}}{}{{code}}\n\n*Remediation:*\n{}\n\n_Filed automatically by NeuroSploit._",
f.severity, f.cvss, f.cwe, f.endpoint, f.impact, f.payload, f.evidence, f.remediation
);
match self.jira_card(&summary, &description).await {
Ok(k) => keys.push(k),
Err(e) => errs.push(format!("{}: {e}", f.title)),
}
}
(keys, errs)
}
/// Human-readable status (for `/integrations` and the CLI).
pub fn status_lines(&self) -> Vec<String> {
let badge = |on: bool, tok: bool| if !on { "off".to_string() }
else if tok { "on ✓ token".to_string() } else { "on ⚠ token env not set".to_string() };
vec![
format!("github : {:<18} (clone private repos · PR review · watch) env={}", badge(self.github.enabled, self.github_token().is_some()), self.github.token_env),
format!("gitlab : {:<18} (clone private repos · MR review) env={}", badge(self.gitlab.enabled, self.gitlab_token().is_some()), self.gitlab.token_env),
format!("jira : {:<18} (open a card per finding) project={} base={}",
badge(self.jira.enabled, env(&self.jira.token_env).is_some()),
if self.jira.project_key.is_empty() { "-" } else { &self.jira.project_key },
if self.jira.base_url.is_empty() { "-" } else { &self.jira.base_url }),
]
}
}
+2 -1
View File
@@ -1,4 +1,4 @@
//! NeuroSploit v3.5.2 harness — a robust multi-model runtime for the
//! NeuroSploit v3.5.3 harness — a robust multi-model runtime for the
//! markdown-driven autonomous pentest engine.
//!
//! The harness loads the `agents_md/` library, drives a *pool* of LLM models
@@ -12,6 +12,7 @@ pub mod belief;
pub mod creds;
pub mod grounding;
pub mod hygiene;
pub mod integrations;
pub mod pomdp;
pub mod models;
pub mod pipeline;
+1 -1
View File
@@ -1,4 +1,4 @@
//! POMDP decision layer (v3.5.2): value-of-information planning + the
//! POMDP decision layer (v3.5.3): value-of-information planning + the
//! anti-hallucination gate.
//!
//! The choice "scan more vs exploit now" is **not** a heuristic here — it falls
+3 -3
View File
@@ -97,9 +97,9 @@ pub fn html(target: &str, findings: &[Finding]) -> String {
h4{{margin:12px 0 3px;font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:#8b5cf6}}\
.b{{color:#8b5cf6;font-weight:800}}</style></head><body>\
<h1><span class=b>NeuroSploit</span> Penetration Test Report</h1>\
<div class=meta>Target: <b>{t}</b> · v3.5.2 Rust harness · multi-model validated</div>\
<div class=meta>Target: <b>{t}</b> · v3.5.3 Rust harness · multi-model validated</div>\
<div>{chips}</div>{graph_block}<h2>Findings ({n})</h2>{body}\
<p class=meta>Authorized testing only. Findings confirmed by multi-model adversarial voting.<br>NeuroSploit v3.5.2 · by <b>Joas A Santos</b> &amp; <b>Red Team Leaders</b></p></body></html>",
<p class=meta>Authorized testing only. Findings confirmed by multi-model adversarial voting.<br>NeuroSploit v3.5.3 · by <b>Joas A Santos</b> &amp; <b>Red Team Leaders</b></p></body></html>",
t = esc(target), chips = chips, n = sorted.len(), body = body, graph_block = graph_block,
)
}
@@ -135,7 +135,7 @@ pub fn typst_report(target: &str, findings: &[Finding], dir: &Path) -> std::io::
let mut data = String::new();
data.push_str(&format!(
"#let meta = (target: {}, run_id: {}, generated: {}, model: {})\n",
tq(target), tq(&run_id), tq("NeuroSploit v3.5.2"), tq("multi-model")
tq(target), tq(&run_id), tq("NeuroSploit v3.5.3"), tq("multi-model")
));
data.push_str("#let findings = (\n");
for f in sorted_findings(findings) {