mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-08-14 13:40:23 +02:00
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:
co-authored by
Claude Opus 4.8
parent
21a62c95e5
commit
3786d7c559
@@ -26,31 +26,7 @@
|
|||||||
|
|
||||||
> ⭐ If this is useful, **star the repo** — it helps a lot.
|
> ⭐ 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 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).
|
||||||
|
|
||||||
> 🆕 **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 <ai-url>`.
|
|
||||||
> 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).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -114,6 +90,24 @@ Control TUI**.
|
|||||||
reproducible scripts to the run's `pocs/`), and **rate-limit** testing — all
|
reproducible scripts to the run's `pocs/`), and **rate-limit** testing — all
|
||||||
under a strict **data-safety/PII guardrail** (no destructive or state-changing
|
under a strict **data-safety/PII guardrail** (no destructive or state-changing
|
||||||
actions; PII proven with a masked sample, never dumped).
|
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 <repo> <n> --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/<finding-id>-N.png`), embedded beside its vulnerability in the
|
||||||
|
Typst/HTML/Markdown reports.
|
||||||
- 🕵️ **Burp/ZAP proxy** — `/proxy <url>` (or `/burp`) routes agent traffic
|
- 🕵️ **Burp/ZAP proxy** — `/proxy <url>` (or `/burp`) routes agent traffic
|
||||||
through your local intercepting proxy so you can inspect & replay in Burp.
|
through your local intercepting proxy so you can inspect & replay in Burp.
|
||||||
- 🗺️ **Attack graph & kill chain** — findings mapped to OWASP / CWE / MITRE
|
- 🗺️ **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:
|
# 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
|
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:
|
# Watch a branch and re-review on every new commit:
|
||||||
neurosploit watch myorg/private-app --branch main --subscription --model anthropic:claude-opus-4-8
|
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 |
|
| 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` |
|
| **GitLab** | private clone for whitebox/greybox | `GITLAB_TOKEN` |
|
||||||
| **Jira** | one card per finding (`--jira`) | `JIRA_EMAIL`, `JIRA_API_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)**.
|
📖 Step-by-step setup for each tool: **[TUTORIAL-INTEGRATION.md](TUTORIAL-INTEGRATION.md)**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+44
-2
@@ -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
|
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**.
|
**card per vulnerability**.
|
||||||
|
|
||||||
> ⚠️ **Authorized testing only.** Use integrations against code/projects you own or
|
> ⚠️ **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
|
It polls the branch tip via the GitHub API and runs a white-box review whenever
|
||||||
the SHA changes (Ctrl-C to stop).
|
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 <critical|high|medium|low>` 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
|
**GitHub Enterprise:** `/integrations setup github` and set the API base to your
|
||||||
GHE URL (e.g. `https://ghe.mycorp.com/api/v3`).
|
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
|
## 4. GitLab
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -258,6 +258,11 @@ enum Cmd {
|
|||||||
/// Post a summary comment back on the PR (needs github integration on).
|
/// Post a summary comment back on the PR (needs github integration on).
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
comment: bool,
|
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<String>,
|
||||||
/// Open a Jira card per finding (needs jira integration on).
|
/// Open a Jira card per finding (needs jira integration on).
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
jira: bool,
|
jira: bool,
|
||||||
@@ -494,7 +499,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let out = run_mode(&base, cfg, false, Mode::Skills).await?;
|
let out = run_mode(&base, cfg, false, Mode::Skills).await?;
|
||||||
print_findings(&out);
|
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 ig = harness::integrations::Integrations::load(&repl::proj_dir());
|
||||||
let owner_repo = normalize_repo(&repo);
|
let owner_repo = normalize_repo(&repo);
|
||||||
let path = clone_pr(&base, &ig, &owner_repo, number)?;
|
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?;
|
let out = run_engagement(&base, cfg, false, true).await?;
|
||||||
print_findings(&out);
|
print_findings(&out);
|
||||||
post_integrations(&ig, &format!("{owner_repo}#{number}"), &out, jira, comment, Some((&owner_repo, number))).await;
|
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 } => {
|
Cmd::Watch { repo, branch, interval, models, subscription, jira, verbose } => {
|
||||||
let ig = harness::integrations::Integrations::load(&repl::proj_dir());
|
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.
|
/// 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(
|
async fn post_integrations(
|
||||||
ig: &harness::integrations::Integrations,
|
ig: &harness::integrations::Integrations,
|
||||||
target: &str,
|
target: &str,
|
||||||
|
|||||||
+103
-14
@@ -466,8 +466,10 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> {
|
|||||||
} else {
|
} else {
|
||||||
let attached = expand_ats(line, &mut s);
|
let attached = expand_ats(line, &mut s);
|
||||||
if attached > 0 { println!(" ({attached} @attachment(s) added to context)"); }
|
if attached > 0 { println!(" ({attached} @attachment(s) added to context)"); }
|
||||||
let run = handle_nl(line, &mut s).await;
|
match handle_nl(line, &mut s).await {
|
||||||
if run { ("/run".to_string(), String::new()) } else { continue }
|
Some(c) => (c.to_string(), String::new()), // "/run" or "/stop"
|
||||||
|
None => continue,
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let (cmd, arg) = (cmd.as_str(), arg.as_str());
|
let (cmd, arg) = (cmd.as_str(), arg.as_str());
|
||||||
match cmd {
|
match cmd {
|
||||||
@@ -1700,8 +1702,9 @@ fn help() {
|
|||||||
println!("\n \x1b[1mNeuroSploit REPL — commands\x1b[0m");
|
println!("\n \x1b[1mNeuroSploit REPL — commands\x1b[0m");
|
||||||
|
|
||||||
println!("\n \x1b[2mNATURAL LANGUAGE (any language — just type, no slash)\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!(" e.g. \x1b[36mtesta https://loja.com com opus, foco em SQLi, fora de escopo /admin, usa burp, roda\x1b[0m");
|
||||||
println!(" \x1b[2mconfigures target/models/focus/objective/out-of-scope and can launch — hands-free\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");
|
println!("\n \x1b[2mTARGET & SCOPE\x1b[0m");
|
||||||
h("/onboard", "guided setup: pick scope (web · infra · cloud · ai/llm · skills/n8n)");
|
h("/onboard", "guided setup: pick scope (web · infra · cloud · ai/llm · skills/n8n)");
|
||||||
@@ -1774,14 +1777,23 @@ struct Intent {
|
|||||||
out_of_scope: Option<String>,
|
out_of_scope: Option<String>,
|
||||||
auth: Option<String>,
|
auth: Option<String>,
|
||||||
scope: Option<&'static str>,
|
scope: Option<&'static str>,
|
||||||
|
// Toggles/knobs the user can ask for in words.
|
||||||
|
mcp: Option<bool>, // "usa navegador/browser", "ativa mcp"
|
||||||
|
proxy: Option<String>, // "manda pro burp", "usa proxy 127.0.0.1:8080"
|
||||||
|
subscription: Option<bool>, // "usa minha assinatura/login"
|
||||||
|
vote_n: Option<usize>, // "3 votos", "5 votes"
|
||||||
|
recon: Option<usize>, // "recon profundo/exaustivo", "recon 4"
|
||||||
run: bool,
|
run: bool,
|
||||||
|
stop: bool, // "para", "stop", "cancela"
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Intent {
|
impl Intent {
|
||||||
fn is_empty(&self) -> bool {
|
fn is_empty(&self) -> bool {
|
||||||
self.target.is_none() && self.repo.is_none() && self.models.is_empty()
|
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.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).
|
/// Fill any field this intent is missing from `other` (deterministic wins).
|
||||||
fn merge_from(&mut self, other: Intent) {
|
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.out_of_scope.is_none() { self.out_of_scope = other.out_of_scope; }
|
||||||
if self.auth.is_none() { self.auth = other.auth; }
|
if self.auth.is_none() { self.auth = other.auth; }
|
||||||
if self.scope.is_none() { self.scope = other.scope; }
|
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.run = self.run || other.run;
|
||||||
|
self.stop = self.stop || other.stop;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve a natural-language line into session config. Deterministic fast-path
|
/// 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
|
/// 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.
|
/// to structure the request (works in any language). Returns a follow-up command
|
||||||
async fn handle_nl(line: &str, s: &mut Session) -> bool {
|
/// 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);
|
let (mut intent, confident) = parse_intent_fast(line);
|
||||||
if !confident && !s.offline {
|
if !confident && !s.offline {
|
||||||
if let Some(mi) = parse_intent_model(line, s).await {
|
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).
|
// Nothing structured found → treat the whole line as focus (old behavior).
|
||||||
s.instructions = Some(line.to_string());
|
s.instructions = Some(line.to_string());
|
||||||
println!(" focus set: {line}");
|
println!(" focus set: {line}");
|
||||||
return false;
|
return None;
|
||||||
}
|
}
|
||||||
apply_intent(s, intent)
|
apply_intent(s, intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply an intent to the session, print a summary, and return `run`.
|
/// Apply an intent to the session, print a summary, and return the follow-up
|
||||||
fn apply_intent(s: &mut Session, intent: Intent) -> bool {
|
/// command ("/run" | "/stop") or None.
|
||||||
|
fn apply_intent(s: &mut Session, intent: Intent) -> Option<&'static str> {
|
||||||
let mut set = Vec::new();
|
let mut set = Vec::new();
|
||||||
if let Some(t) = intent.target {
|
if let Some(t) = intent.target {
|
||||||
let t = if t.starts_with("http") || t.contains("://") { t } else { format!("https://{t}") };
|
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(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(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 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");
|
println!(" \x1b[2m(understood — nothing to change)\x1b[0m");
|
||||||
} else {
|
} else if !set.is_empty() {
|
||||||
println!(" \x1b[36m⇢ configured\x1b[0m {}", set.join(" · "));
|
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 intent.run {
|
||||||
if s.target.is_none() && s.repo.is_none() {
|
if s.target.is_none() && s.repo.is_none() {
|
||||||
println!(" \x1b[33m! set a target/repo first — nothing to run yet.\x1b[0m");
|
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 …");
|
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
|
/// 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",
|
"prueba", "probar", "escanea", "ejecuta", "corre", "lanza", "lanzar",
|
||||||
];
|
];
|
||||||
if RUN_VERBS.iter().any(|v| word_present(&low, v)) { it.run = true; }
|
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.
|
// Keyworded clauses: split on commas/semicolons and classify each chunk.
|
||||||
let mut residue = 0usize;
|
let mut residue = 0usize;
|
||||||
@@ -2014,6 +2068,24 @@ fn join_opt(prev: Option<String>, 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<u64> {
|
||||||
|
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::<u64>() { 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::<u64>() { return Some(n); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Scan a line for @path tokens, attach each referenced file/dir to context.
|
/// Scan a line for @path tokens, attach each referenced file/dir to context.
|
||||||
fn expand_ats(line: &str, s: &mut Session) -> usize {
|
fn expand_ats(line: &str, s: &mut Session) -> usize {
|
||||||
let mut n = 0;
|
let mut n = 0;
|
||||||
@@ -2177,4 +2249,21 @@ mod nl_tests {
|
|||||||
assert!(ids.iter().all(|i| i.contains(':')));
|
assert!(ids.iter().all(|i| i.contains(':')));
|
||||||
assert!(ids.iter().any(|i| i.to_lowercase().contains("opus")));
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,33 @@ fn env(name: &str) -> Option<String> {
|
|||||||
std::env::var(name).ok().filter(|v| !v.trim().is_empty())
|
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 {
|
fn client() -> reqwest::Client {
|
||||||
reqwest::Client::builder()
|
reqwest::Client::builder()
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
@@ -121,6 +148,63 @@ impl Integrations {
|
|||||||
Ok(())
|
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`).
|
/// 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> {
|
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 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user