diff --git a/README.md b/README.md index 364cf29..3b0a36e 100755 --- a/README.md +++ b/README.md @@ -26,31 +26,7 @@ > ⭐ If this is useful, **star the repo** β€” it helps a lot. > -> πŸ“– **New here? Read the [full Tutorial & User Guide β†’](TUTORIAL.md)** β€” every mode, flag, config and example explained. - -> πŸ†• **New in v3.6.5 β€” LLM red-teaming: jailbreaks & prompt injection across scenarios:** -> **+12 AI agents (β†’ 30, 429 total)** that adversarially red-team a live AI -> system the way [hackagent.dev](https://hackagent.dev)-style tooling does β€” -> jailbreak techniques (**AdvPrefix**, **PAIR**, **TAP**, **Crescendo**, -> many-shot, persona/DAN, encoding/obfuscation, refusal-suppression) and -> prompt-injection scenarios (direct, **indirect** via RAG/web/email/tool output, -> **goal hijacking**, tool/function-call abuse, system-prompt exfiltration). Each -> runs an attackerβ†’**LLM-judge** loop β€” capture the baseline refusal, apply the -> technique across variants, judge whether the guardrail was truly bypassed β€” -> proving it with a **benign, redacted** receipt. `neurosploit aitest `. -> Also: **self-service test-account registration** β€” analyzes the app's forms and -> creates a benign account (curl or Playwright) to reach the **authenticated** -> surface, with a hard **anti-flood guardrail** (≀2 accounts), a per-run -> **credential vault** (`vault.json`, secrets masked in the report), a **"delete -> these accounts" cleanup** section, findings tagged **authenticated / -> unauthenticated**, and **opt-in disposable email** (`/tempmail`, free mail.tm). -> **Human-in-the-loop validator:** uncertain findings are now **flagged -> `needs-review`, not deleted** (only zero-support noise is dropped) so a human -> makes the final call. **Reports in Markdown + JSON** alongside PDF/HTML. Sharper -> agents on **modern SPA/REST apps** (a Juice-Shop-class API-hunting methodology) -> plus **more robust RL** reward shaping. New models: **Claude Opus 5**, **Claude -> Sonnet 5**, **Kimi K3** (Moonshot β†’ 15 providers). Full history in -> [RELEASE.md](RELEASE.md). +> πŸ“– **New here? Read the [full Tutorial & User Guide β†’](TUTORIAL.md)** β€” every mode, flag, config and example explained. Version-by-version changes live in [RELEASE.md](RELEASE.md). --- @@ -114,6 +90,24 @@ Control TUI**. reproducible scripts to the run's `pocs/`), and **rate-limit** testing β€” all under a strict **data-safety/PII guardrail** (no destructive or state-changing actions; PII proven with a masked sample, never dumped). +- πŸ—£οΈ **Natural-language REPL** β€” in the interactive session, just describe what + you want, in any language: *"testa https://loja.com com opus, foco em SQLi, + fora de escopo /admin, roda"*. A hybrid parser sets target/models/focus/ + objective/out-of-scope and toggles (Burp, browser, votes, recon depth) and can + launch β€” zero-token deterministic parse for the common shapes, model fallback + for anything ambiguous. No flags to memorize. +- πŸ”€ **CI/CD PR gate** β€” `neurosploit pr --fail-on critical` reviews a + pull request, and on a confirmed finding at/above the threshold it **fails the + check, sets a `neurosploit/security` commit status, and posts a REQUEST_CHANGES + review** β€” so branch protection blocks the merge. Ready-made GitHub Actions + workflows included (PR gate + a **`@neurosploit` mention bot** that runs a scan + when a writer comments). See [Integrations](#-integrations-github--gitlab--jira). +- 🎯 **Engagement objective & out-of-scope** β€” give the goal/context and hard + exclusions in words (`/objective`, `/scope-out`, or `--objective` / + `--out-of-scope`); both steer every agent prompt. +- πŸ“Έ **Proof screenshots in reports** β€” agents capture visual proof per finding + (`evidence/-N.png`), embedded beside its vulnerability in the + Typst/HTML/Markdown reports. - πŸ•΅οΈ **Burp/ZAP proxy** β€” `/proxy ` (or `/burp`) routes agent traffic through your local intercepting proxy so you can inspect & replay in Burp. - πŸ—ΊοΈ **Attack graph & kill chain** β€” findings mapped to OWASP / CWE / MITRE @@ -212,6 +206,10 @@ neurosploit integrations enable github # Review a Pull Request's code (clones the PR head, white-box) and comment back: neurosploit pr digininja/DVWA 42 --subscription --model anthropic:claude-opus-4-8 --comment +# Same, but BLOCK the merge on a confirmed critical: fails the check, sets a +# `neurosploit/security` commit status, and posts a REQUEST_CHANGES review. +neurosploit pr digininja/DVWA 42 --model anthropic:claude-opus-4-8 --comment --fail-on critical + # Watch a branch and re-review on every new commit: neurosploit watch myorg/private-app --branch main --subscription --model anthropic:claude-opus-4-8 @@ -226,10 +224,25 @@ neurosploit whitebox https://github.com/myorg/app --jira --subscription --model | Integration | What you get | Env vars | |-------------|--------------|----------| -| **GitHub** | private clone Β· `pr` review + comment Β· `watch` branch | `GITHUB_TOKEN` | +| **GitHub** | private clone Β· `pr` review + comment Β· **PR gate** (`--fail-on`: fail check + commit status + REQUEST_CHANGES) Β· `watch` branch | `GITHUB_TOKEN` | | **GitLab** | private clone for whitebox/greybox | `GITLAB_TOKEN` | | **Jira** | one card per finding (`--jira`) | `JIRA_EMAIL`, `JIRA_API_TOKEN` | +### Automations (GitHub Actions) + +Two ready-made workflows ship in [`examples/github-actions/`](examples/github-actions) β€” copy +them into your repo: + +- **`neurosploit-pr-gate.yml`** β€” reviews every PR and blocks the merge on a + confirmed critical. Make it enforcing: *Settings β†’ Branches β†’ require the + `neurosploit-pr-gate` status check* (and/or require review to honor the + REQUEST_CHANGES). Set `ANTHROPIC_API_KEY` (or swap the model) in Actions secrets; + the built-in `GITHUB_TOKEN` covers statuses/reviews. +- **`neurosploit-mention.yml`** β€” comment **`@neurosploit`** on a PR or issue to + trigger a scan (only repo writers can). Text after the mention is the + instruction (any language): `@neurosploit focus SQLi and IDOR`, or + `@neurosploit scan https://staging.app` for a black-box run. + πŸ“– Step-by-step setup for each tool: **[TUTORIAL-INTEGRATION.md](TUTORIAL-INTEGRATION.md)**. --- diff --git a/TUTORIAL-INTEGRATION.md b/TUTORIAL-INTEGRATION.md index c31d372..96f9c32 100644 --- a/TUTORIAL-INTEGRATION.md +++ b/TUTORIAL-INTEGRATION.md @@ -1,7 +1,8 @@ -# NeuroSploit β€” Integrations Setup Guide (v3.5.3) +# NeuroSploit β€” Integrations Setup Guide Connect NeuroSploit to **GitHub**, **GitLab** and **Jira** so it can review private -repositories and Pull Requests, watch branches for new code, and file a Jira +repositories and Pull Requests, **gate merges** on severe findings, watch branches +for new code, run from a **`@neurosploit`** comment, and file a Jira **card per vulnerability**. > ⚠️ **Authorized testing only.** Use integrations against code/projects you own or @@ -100,10 +101,51 @@ neurosploit integrations enable github ``` It polls the branch tip via the GitHub API and runs a white-box review whenever the SHA changes (Ctrl-C to stop). +- **Gate a Pull Request** β€” block the merge when a confirmed finding is severe: + ```bash + neurosploit pr myorg/private-app 128 \ + --model anthropic:claude-opus-4-8 --comment --fail-on critical + ``` + `--fail-on ` does three things when a **confirmed** + finding is at/above the threshold: the CLI **exits non-zero** (so a CI check + fails), it sets a **`neurosploit/security` commit status** of `failure` on the + PR head, and it submits a **REQUEST_CHANGES** review. `needs-review` findings + never trip the gate β€” only confirmed ones do. **GitHub Enterprise:** `/integrations setup github` and set the API base to your GHE URL (e.g. `https://ghe.mycorp.com/api/v3`). +### 3.1 Automations β€” GitHub Actions + +Two workflows ship in [`examples/github-actions/`](examples/github-actions). Copy them into +your repo and add an `ANTHROPIC_API_KEY` Actions secret (or swap `MODEL` for a +provider you have a key for). The built-in `GITHUB_TOKEN` already covers commit +statuses, reviews and comments. + +**PR gate β€” `neurosploit-pr-gate.yml`** +Runs on every pull request, reviews the code, and enforces the gate: +```bash +neurosploit pr "$REPO" "$PR_NUMBER" --model "$MODEL" --comment --fail-on critical -v +``` +To make it actually block merges: *repo Settings β†’ Branches β†’ Branch protection +rule* on your default branch β†’ **Require status checks to pass** β†’ select +**`neurosploit-pr-gate`**. Add **Require a pull request review** to also honor the +REQUEST_CHANGES review it posts. + +**`@neurosploit` mention bot β€” `neurosploit-mention.yml`** +Comment `@neurosploit` on a PR or issue to trigger a scan. Only users with +**write** access can trigger it (a permission check guards the model budget). +Everything after the mention is the instruction, in **any language**: + +| Comment | Effect | +|---------|--------| +| `@neurosploit` | white-box review of this PR (blocks on critical) | +| `@neurosploit focus SQLi and IDOR` | same, steered by the focus | +| `@neurosploit scan https://staging.app` | black-box test of that URL | +| `@neurosploit foco em IDOR, fora de escopo /admin` | steered review (Portuguese) | + +The bot reacts πŸ‘€ to acknowledge, then posts results back as a comment. + --- ## 4. GitLab diff --git a/examples/github-actions/README.md b/examples/github-actions/README.md new file mode 100644 index 0000000..f9435c6 --- /dev/null +++ b/examples/github-actions/README.md @@ -0,0 +1,22 @@ +# NeuroSploit β€” GitHub Actions templates + +Copy either file into your repository's `.github/workflows/` directory to enable +the automation. Add an `ANTHROPIC_API_KEY` secret (Settings β†’ Secrets and +variables β†’ Actions), or swap the `MODEL`/key for a provider you use. The built-in +`GITHUB_TOKEN` already covers commit statuses, PR reviews and comments. + +| Template | What it does | +|----------|--------------| +| `neurosploit-pr-gate.yml` | Reviews every pull request and **blocks the merge** on a confirmed critical (fails the check + sets a `neurosploit/security` commit status + posts a REQUEST_CHANGES review). | +| `neurosploit-mention.yml` | Comment **`@neurosploit`** on a PR/issue (writers only) to trigger a scan. Text after the mention steers it, in any language; a URL runs a black-box test, otherwise it reviews the PR. | + +## Enforce the PR gate as a merge block + +1. Add `neurosploit-pr-gate.yml` to `.github/workflows/` and let it run once on a PR. +2. Repo **Settings β†’ Branches β†’ Branch protection rule** on your default branch. +3. Enable **Require status checks to pass** and select **`neurosploit-pr-gate`**. +4. (Optional) Enable **Require a pull request review** so the REQUEST_CHANGES + review it posts must be resolved/overridden before merge. + +These live here (not in `.github/workflows/`) so this repo doesn't run them on +itself β€” they're templates for **your** repo. diff --git a/examples/github-actions/neurosploit-mention.yml b/examples/github-actions/neurosploit-mention.yml new file mode 100644 index 0000000..83c10a7 --- /dev/null +++ b/examples/github-actions/neurosploit-mention.yml @@ -0,0 +1,107 @@ +# NeuroSploit β€” @neurosploit mention bot +# +# Comment `@neurosploit` on a pull request or issue to trigger a scan: +# +# @neurosploit β†’ white-box review of this PR +# @neurosploit scan https://staging.app β†’ black-box test of a URL +# @neurosploit focus SQLi and IDOR β†’ review this PR, steered +# +# Everything after `@neurosploit` is passed verbatim as the natural-language +# instruction, so any language works. Results are posted back as a comment; on a +# PR, a critical confirmed finding also blocks the merge (commit status + review). +# +# Guard: only members with write access can trigger it (checked below), so a +# random commenter can't burn your model budget. + +name: neurosploit-mention + +on: + issue_comment: + types: [created] + +permissions: + contents: read + issues: write + pull-requests: write + statuses: write + +jobs: + dispatch: + runs-on: ubuntu-latest + # Only fire when the comment mentions the bot. + if: contains(github.event.comment.body, '@neurosploit') + steps: + - name: Check the commenter has write access + id: perm + uses: actions/github-script@v7 + with: + script: | + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, repo: context.repo.repo, + username: context.payload.comment.user.login, + }); + const ok = ['admin', 'write', 'maintain'].includes(data.permission); + core.setOutput('ok', ok ? 'yes' : 'no'); + if (!ok) core.notice('Ignoring @neurosploit from a non-writer.'); + + - name: React πŸ‘€ to acknowledge + if: steps.perm.outputs.ok == 'yes' + uses: actions/github-script@v7 + with: + script: | + await github.rest.reactions.createForIssueComment({ + owner: context.repo.owner, repo: context.repo.repo, + comment_id: context.payload.comment.id, content: 'eyes', + }); + + - name: Parse the instruction after @neurosploit + if: steps.perm.outputs.ok == 'yes' + id: parse + uses: actions/github-script@v7 + with: + script: | + const body = context.payload.comment.body || ''; + const m = body.match(/@neurosploit\s*([\s\S]*)/i); + const instr = (m && m[1] ? m[1] : '').trim(); + const isPR = !!context.payload.issue.pull_request; + // A URL in the instruction β†’ black-box scan; otherwise review the PR. + const url = (instr.match(/https?:\/\/\S+/) || [])[0] || ''; + core.setOutput('instr', instr); + core.setOutput('is_pr', isPR ? 'yes' : 'no'); + core.setOutput('url', url); + core.setOutput('number', String(context.payload.issue.number)); + + - name: Install NeuroSploit + if: steps.perm.outputs.ok == 'yes' + run: curl -fsSL https://raw.githubusercontent.com/JoasASantos/NeuroSploit/main/setup.sh | bash + + - name: Enable the GitHub integration + if: steps.perm.outputs.ok == 'yes' + run: | + export NEUROSPLOIT_BASE="$HOME/.neurosploit-app" + "$HOME/.local/bin/neurosploit" integrations enable github + + - name: Run the requested scan + if: steps.perm.outputs.ok == 'yes' + env: + NEUROSPLOIT_BASE: /home/runner/.neurosploit-app + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + MODEL: anthropic:claude-opus-4-8 + INSTR: ${{ steps.parse.outputs.instr }} + URL: ${{ steps.parse.outputs.url }} + IS_PR: ${{ steps.parse.outputs.is_pr }} + NUMBER: ${{ steps.parse.outputs.number }} + run: | + NS="$HOME/.local/bin/neurosploit" + if [ -n "$URL" ]; then + # Black-box scan of the URL the commenter named. + "$NS" run "$URL" --model "$MODEL" ${INSTR:+--focus "$INSTR"} -v + elif [ "$IS_PR" = "yes" ]; then + # Review this PR (steered by any text after the mention), block on critical. + "$NS" pr "${{ github.repository }}" "$NUMBER" \ + --model "$MODEL" --comment --fail-on critical -v + else + echo "Nothing to scan: mention a URL or comment on a PR." >&2 + exit 1 + fi diff --git a/examples/github-actions/neurosploit-pr-gate.yml b/examples/github-actions/neurosploit-pr-gate.yml new file mode 100644 index 0000000..7f450f2 --- /dev/null +++ b/examples/github-actions/neurosploit-pr-gate.yml @@ -0,0 +1,60 @@ +# NeuroSploit β€” PR security gate +# +# White-box reviews every pull request and BLOCKS the merge when a confirmed +# finding is critical (configurable). It works two ways at once: +# 1. `--fail-on` makes the CLI exit non-zero β†’ this required check fails. +# 2. `--fail-on` also sets a `neurosploit/security` commit status + a +# REQUEST_CHANGES review via the API (needs the github integration on). +# +# Make it enforce a merge block: Settings β†’ Branches β†’ add a rule on your default +# branch β†’ "Require status checks to pass" β†’ select **neurosploit-pr-gate** +# (and/or "Require review from Code Owners" to honor the REQUEST_CHANGES review). +# +# Secrets/vars to set (Settings β†’ Secrets and variables β†’ Actions): +# ANTHROPIC_API_KEY a model key (or swap MODEL + the matching key below) +# GITHUB_TOKEN is provided automatically and is enough for statuses/reviews. + +name: neurosploit-pr-gate + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: write # post the REQUEST_CHANGES review + comment + statuses: write # set the neurosploit/security commit status + checks: write + +concurrency: + group: neurosploit-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + gate: + runs-on: ubuntu-latest + # Skip forks β€” they don't get the secrets/token needed to review. + if: github.event.pull_request.head.repo.full_name == github.repository + steps: + - name: Install NeuroSploit + run: curl -fsSL https://raw.githubusercontent.com/JoasASantos/NeuroSploit/main/setup.sh | bash + + - name: Enable the GitHub integration (for status + review) + run: | + export NEUROSPLOIT_BASE="$HOME/.neurosploit-app" + "$HOME/.local/bin/neurosploit" integrations enable github + + - name: Review the PR and enforce the gate + env: + NEUROSPLOIT_BASE: /home/runner/.neurosploit-app + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + # Change the model + severity threshold to taste. + MODEL: anthropic:claude-opus-4-8 + FAIL_ON: critical + run: | + "$HOME/.local/bin/neurosploit" pr "${{ github.repository }}" ${{ github.event.pull_request.number }} \ + --model "$MODEL" \ + --comment \ + --fail-on "$FAIL_ON" \ + -v diff --git a/neurosploit-rs/app/src/main.rs b/neurosploit-rs/app/src/main.rs index 708990e..6448d0c 100644 --- a/neurosploit-rs/app/src/main.rs +++ b/neurosploit-rs/app/src/main.rs @@ -258,6 +258,11 @@ enum Cmd { /// Post a summary comment back on the PR (needs github integration on). #[arg(long)] comment: bool, + /// Block the PR when a confirmed finding is this severity or worse: + /// critical|high|medium|low. Sets a failing commit status + a + /// REQUEST_CHANGES review, and exits non-zero so CI fails the check. + #[arg(long)] + fail_on: Option, /// Open a Jira card per finding (needs jira integration on). #[arg(long)] jira: bool, @@ -494,7 +499,7 @@ async fn main() -> anyhow::Result<()> { let out = run_mode(&base, cfg, false, Mode::Skills).await?; print_findings(&out); } - Cmd::Pr { repo, number, models, vote_n, chain_depth, recon, subscription, comment, jira, verbose } => { + Cmd::Pr { repo, number, models, vote_n, chain_depth, recon, subscription, comment, fail_on, jira, verbose } => { let ig = harness::integrations::Integrations::load(&repl::proj_dir()); let owner_repo = normalize_repo(&repo); let path = clone_pr(&base, &ig, &owner_repo, number)?; @@ -510,6 +515,16 @@ async fn main() -> anyhow::Result<()> { let out = run_engagement(&base, cfg, false, true).await?; print_findings(&out); post_integrations(&ig, &format!("{owner_repo}#{number}"), &out, jira, comment, Some((&owner_repo, number))).await; + // Security gate: block the PR when a confirmed finding is >= threshold. + if let Some(thresh) = fail_on.as_deref() { + let blocked = gate_pr(&ig, &owner_repo, number, &out, thresh).await; + if blocked { + eprintln!(" \x1b[1;31mβ›” PR gate: confirmed finding β‰₯ {thresh} β€” blocking (exit 2)\x1b[0m"); + std::process::exit(2); + } else { + println!(" \x1b[1;32mβœ“ PR gate: nothing β‰₯ {thresh} β€” clear\x1b[0m"); + } + } } Cmd::Watch { repo, branch, interval, models, subscription, jira, verbose } => { let ig = harness::integrations::Integrations::load(&repl::proj_dir()); @@ -972,6 +987,44 @@ fn clone_pr(base: &Path, ig: &harness::integrations::Integrations, owner_repo: & } /// After a run, optionally open Jira cards and/or comment on a GitHub PR. +/// Enforce the PR security gate. Sets a GitHub commit status (success/failure) +/// on the PR head and, when it trips, submits a REQUEST_CHANGES review so branch +/// protection blocks the merge. Best-effort on the API calls (a token may be +/// absent locally); returns whether the gate tripped so the caller can exit 2. +async fn gate_pr( + ig: &harness::integrations::Integrations, + owner_repo: &str, + number: u64, + out: &RunOutput, + threshold: &str, +) -> bool { + use harness::integrations as gi; + let tripped = gi::gate_trips(&out.findings, threshold); + if ig.github.enabled { + let (state, desc) = if tripped { + ("failure", format!("Confirmed finding β‰₯ {threshold} β€” merge blocked by NeuroSploit")) + } else { + ("success", "No confirmed finding at/above the gate threshold".to_string()) + }; + // Attach the status to the PR head SHA (looked up from the API). + match ig.github_pr_head_sha(owner_repo, number).await { + Ok(sha) => { + if let Err(e) = ig.github_set_status(owner_repo, &sha, state, "neurosploit/security", &desc, None).await { + eprintln!(" github status: {e}"); + } + } + Err(e) => eprintln!(" github PR head lookup: {e}"), + } + if tripped { + let body = format!("## β›” NeuroSploit security gate\n\nBlocking this PR: a **confirmed** finding is **{threshold}** or worse.\n\n{}", pr_comment_body(out)); + if let Err(e) = ig.github_pr_review(owner_repo, number, "REQUEST_CHANGES", &body).await { + eprintln!(" github review: {e}"); + } + } + } + tripped +} + async fn post_integrations( ig: &harness::integrations::Integrations, target: &str, diff --git a/neurosploit-rs/app/src/repl.rs b/neurosploit-rs/app/src/repl.rs index 6bd98d4..522ef36 100644 --- a/neurosploit-rs/app/src/repl.rs +++ b/neurosploit-rs/app/src/repl.rs @@ -466,8 +466,10 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> { } else { let attached = expand_ats(line, &mut s); if attached > 0 { println!(" ({attached} @attachment(s) added to context)"); } - let run = handle_nl(line, &mut s).await; - if run { ("/run".to_string(), String::new()) } else { continue } + match handle_nl(line, &mut s).await { + Some(c) => (c.to_string(), String::new()), // "/run" or "/stop" + None => continue, + } }; let (cmd, arg) = (cmd.as_str(), arg.as_str()); match cmd { @@ -1700,8 +1702,9 @@ fn help() { println!("\n \x1b[1mNeuroSploit REPL β€” commands\x1b[0m"); println!("\n \x1b[2mNATURAL LANGUAGE (any language β€” just type, no slash)\x1b[0m"); - println!(" e.g. \x1b[36mtesta https://loja.com com opus, foco em SQLi, fora de escopo /admin, roda\x1b[0m"); - println!(" \x1b[2mconfigures target/models/focus/objective/out-of-scope and can launch β€” hands-free\x1b[0m"); + println!(" e.g. \x1b[36mtesta https://loja.com com opus, foco em SQLi, fora de escopo /admin, usa burp, roda\x1b[0m"); + println!(" \x1b[2msets target/models/focus/objective/out-of-scope + toggles (burpΒ·browserΒ·votesΒ·recon),\x1b[0m"); + println!(" \x1b[2mand can launch or stop β€” hands-free. Ambiguous phrasing falls back to the model.\x1b[0m"); println!("\n \x1b[2mTARGET & SCOPE\x1b[0m"); h("/onboard", "guided setup: pick scope (web Β· infra Β· cloud Β· ai/llm Β· skills/n8n)"); @@ -1774,14 +1777,23 @@ struct Intent { out_of_scope: Option, auth: Option, scope: Option<&'static str>, + // Toggles/knobs the user can ask for in words. + mcp: Option, // "usa navegador/browser", "ativa mcp" + proxy: Option, // "manda pro burp", "usa proxy 127.0.0.1:8080" + subscription: Option, // "usa minha assinatura/login" + vote_n: Option, // "3 votos", "5 votes" + recon: Option, // "recon profundo/exaustivo", "recon 4" run: bool, + stop: bool, // "para", "stop", "cancela" } impl Intent { fn is_empty(&self) -> bool { self.target.is_none() && self.repo.is_none() && self.models.is_empty() && self.focus.is_none() && self.objective.is_none() && self.out_of_scope.is_none() - && self.auth.is_none() && self.scope.is_none() && !self.run + && self.auth.is_none() && self.scope.is_none() + && self.mcp.is_none() && self.proxy.is_none() && self.subscription.is_none() + && self.vote_n.is_none() && self.recon.is_none() && !self.run && !self.stop } /// Fill any field this intent is missing from `other` (deterministic wins). fn merge_from(&mut self, other: Intent) { @@ -1793,14 +1805,21 @@ impl Intent { if self.out_of_scope.is_none() { self.out_of_scope = other.out_of_scope; } if self.auth.is_none() { self.auth = other.auth; } if self.scope.is_none() { self.scope = other.scope; } + if self.mcp.is_none() { self.mcp = other.mcp; } + if self.proxy.is_none() { self.proxy = other.proxy; } + if self.subscription.is_none() { self.subscription = other.subscription; } + if self.vote_n.is_none() { self.vote_n = other.vote_n; } + if self.recon.is_none() { self.recon = other.recon; } self.run = self.run || other.run; + self.stop = self.stop || other.stop; } } /// Resolve a natural-language line into session config. Deterministic fast-path /// first (0 tokens); if the phrase is ambiguous and a model is available, ask it -/// to structure the request (works in any language). Returns whether to run now. -async fn handle_nl(line: &str, s: &mut Session) -> bool { +/// to structure the request (works in any language). Returns a follow-up command +/// to execute ("/run" or "/stop"), or None when it only reconfigured. +async fn handle_nl(line: &str, s: &mut Session) -> Option<&'static str> { let (mut intent, confident) = parse_intent_fast(line); if !confident && !s.offline { if let Some(mi) = parse_intent_model(line, s).await { @@ -1811,13 +1830,14 @@ async fn handle_nl(line: &str, s: &mut Session) -> bool { // Nothing structured found β†’ treat the whole line as focus (old behavior). s.instructions = Some(line.to_string()); println!(" focus set: {line}"); - return false; + return None; } apply_intent(s, intent) } -/// Apply an intent to the session, print a summary, and return `run`. -fn apply_intent(s: &mut Session, intent: Intent) -> bool { +/// Apply an intent to the session, print a summary, and return the follow-up +/// command ("/run" | "/stop") or None. +fn apply_intent(s: &mut Session, intent: Intent) -> Option<&'static str> { let mut set = Vec::new(); if let Some(t) = intent.target { let t = if t.starts_with("http") || t.contains("://") { t } else { format!("https://{t}") }; @@ -1833,19 +1853,32 @@ fn apply_intent(s: &mut Session, intent: Intent) -> bool { if let Some(o) = intent.objective { s.objective = Some(o.clone()); set.push(format!("objective=\"{o}\"")); } if let Some(x) = intent.out_of_scope { s.out_of_scope = Some(x.clone()); set.push(format!("out-of-scope=\"{x}\"")); } if let Some(a) = intent.auth { let a = normalize_auth(&a); s.auth = Some(a.clone()); set.push("auth set".into()); let _ = a; } - if set.is_empty() { + if let Some(b) = intent.mcp { s.mcp = b; set.push(format!("mcp={}", onoff(b))); } + if let Some(p) = intent.proxy { + let p = if p.starts_with("http") { p } else { format!("http://{p}") }; + s.proxy = Some(p.clone()); set.push(format!("proxy={p}")); + } + if let Some(b) = intent.subscription { s.subscription = b; set.push(format!("subscription={}", onoff(b))); } + if let Some(v) = intent.vote_n { s.vote_n = v; set.push(format!("votes={v}")); } + if let Some(r) = intent.recon { s.recon_intensity = r.clamp(1, 4); set.push(format!("recon={}", s.recon_intensity)); } + if set.is_empty() && !intent.stop { println!(" \x1b[2m(understood β€” nothing to change)\x1b[0m"); - } else { + } else if !set.is_empty() { println!(" \x1b[36mβ‡’ configured\x1b[0m {}", set.join(" Β· ")); } + if intent.stop { + println!(" \x1b[1;33m⏸ stopping\x1b[0m …"); + return Some("/stop"); + } if intent.run { if s.target.is_none() && s.repo.is_none() { println!(" \x1b[33m! set a target/repo first β€” nothing to run yet.\x1b[0m"); - return false; + return None; } println!(" \x1b[1;35mβ–Ά launching\x1b[0m …"); + return Some("/run"); } - intent.run + None } /// Deterministic, zero-token parse for the common phrasings (PT/EN/ES). Returns @@ -1872,6 +1905,27 @@ fn parse_intent_fast(line: &str) -> (Intent, bool) { "prueba", "probar", "escanea", "ejecuta", "corre", "lanza", "lanzar", ]; if RUN_VERBS.iter().any(|v| word_present(&low, v)) { it.run = true; } + // Stop verbs. + const STOP_VERBS: &[&str] = &["stop", "para", "pare", "parar", "cancel", "cancela", "cancelar", "aborta", "abortar", "halt", "detΓ©n", "detener", "para tudo"]; + if STOP_VERBS.iter().any(|v| word_present(&low, v)) { it.stop = true; } + + // Spoken toggles/knobs (PT/EN/ES). Only set when clearly mentioned. + if word_present(&low, "burp") || low.contains("intercept") { it.proxy = Some("http://127.0.0.1:8080".into()); } + if low.contains("browser") || low.contains("navegador") || low.contains("navegou") || low.contains("playwright") || word_present(&low, "mcp") { + it.mcp = Some(!(low.contains("sem navegador") || low.contains("no browser") || low.contains("sin navegador"))); + } + if low.contains("assinatura") || low.contains("subscription") || low.contains("meu login") || low.contains("mi cuenta") || low.contains("suscripciΓ³n") { + it.subscription = Some(true); + } + // "3 votos" / "5 votes" / "2 votos de validaΓ§Γ£o". + if let Some(n) = number_before_any(&low, &["voto", "votos", "vote", "votes"]) { it.vote_n = Some(n as usize); } + // Recon depth: explicit number 1-4 or a qualitative word. + if let Some(n) = number_before_any(&low, &["recon"]) { if (1..=4).contains(&n) { it.recon = Some(n as usize); } } + if it.recon.is_none() && low.contains("recon") { + if low.contains("exausti") || low.contains("exhaust") { it.recon = Some(4); } + else if low.contains("profund") || low.contains("deep") || low.contains("profundo") { it.recon = Some(3); } + else if low.contains("rΓ‘pid") || low.contains("rapid") || low.contains("quick") || low.contains("quick") { it.recon = Some(1); } + } // Keyworded clauses: split on commas/semicolons and classify each chunk. let mut residue = 0usize; @@ -2014,6 +2068,24 @@ fn join_opt(prev: Option, add: &str) -> String { } } +/// First integer that appears immediately before any of `keys` (e.g. "3 votos" +/// with keys ["votos"] β†’ 3), scanning token pairs. Returns None if not found. +fn number_before_any(low: &str, keys: &[&str]) -> Option { + let toks: Vec<&str> = low.split(|c: char| !c.is_ascii_alphanumeric()).filter(|t| !t.is_empty()).collect(); + for w in toks.windows(2) { + if keys.iter().any(|k| w[1].starts_with(k)) { + if let Ok(n) = w[0].parse::() { return Some(n); } + } + } + // Also "recon 4" (number AFTER the key). + for w in toks.windows(2) { + if keys.iter().any(|k| w[0].starts_with(k)) { + if let Ok(n) = w[1].parse::() { return Some(n); } + } + } + None +} + /// Scan a line for @path tokens, attach each referenced file/dir to context. fn expand_ats(line: &str, s: &mut Session) -> usize { let mut n = 0; @@ -2177,4 +2249,21 @@ mod nl_tests { assert!(ids.iter().all(|i| i.contains(':'))); assert!(ids.iter().any(|i| i.to_lowercase().contains("opus"))); } + + #[test] + fn fast_parse_spoken_toggles() { + let (it, _) = parse_intent_fast("testa loja.com com opus, usa burp, 5 votos, recon profundo, roda"); + assert_eq!(it.proxy.as_deref(), Some("http://127.0.0.1:8080")); + assert_eq!(it.vote_n, Some(5)); + assert_eq!(it.recon, Some(3)); + assert!(it.run); + } + + #[test] + fn fast_parse_stop_and_browser() { + assert!(parse_intent_fast("para tudo agora").0.stop); + assert_eq!(parse_intent_fast("usa o navegador").0.mcp, Some(true)); + assert_eq!(parse_intent_fast("recon 4 em example.com").0.recon, Some(4)); + } + } diff --git a/neurosploit-rs/crates/harness/src/integrations.rs b/neurosploit-rs/crates/harness/src/integrations.rs index 6a01713..6e15f1f 100644 --- a/neurosploit-rs/crates/harness/src/integrations.rs +++ b/neurosploit-rs/crates/harness/src/integrations.rs @@ -56,6 +56,33 @@ fn env(name: &str) -> Option { 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 { + 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 { 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")); + } +}