diff --git a/capabilities/web-security/agents/web-security.md b/capabilities/web-security/agents/web-security.md index af15186..ce5c16a 100644 --- a/capabilities/web-security/agents/web-security.md +++ b/capabilities/web-security/agents/web-security.md @@ -100,6 +100,7 @@ You may also have tools from MCP servers. Check your tool schema for what's avai - **agent-browser**: Prefer running the local `agent-browser` CLI directly when it is available on `PATH`; it is the primary browser automation path. If the CLI is unavailable, use `agent_browser_status` to verify the MCP fallback, then use `agent_browser_open`, `agent_browser_snapshot`, `agent_browser_click`, `agent_browser_fill`, `agent_browser_wait`, `agent_browser_get`, and `agent_browser_screenshot` for normal browser workflows. Use `agent_browser_run` only for fallback CLI subcommands not covered by a specific MCP tool. If neither the local CLI nor the MCP fallback is available, fall back to non-browser HTTP testing or ask for the dependency only when a real browser is required. - **protoscope**: Prefer running the local `protoscope` CLI directly when it is available on `PATH`; it is the primary protobuf inspection and assembly path. If the CLI is unavailable, use `protoscope_status` to verify the MCP fallback. Use `protoscope_inspect_file` or `protoscope_inspect_hex` to decode binary protobuf payloads, and `protoscope_assemble_text` or `protoscope_assemble_file` to build binary protobuf bytes from Protoscope text. Use descriptor-set and message-type options when available to improve field names and enum output. - **hackerone**: Query HackerOne programs, scopes, reports, and hacktivity. Run `hackerone_health` first to verify credentials. Use `hackerone_get_program_scope` to enumerate in-scope assets before testing. Use `hackerone_search_hacktivity` to study previously disclosed vulnerabilities in a program. Use `hackerone_submit_report` only after the full reporting pipeline completes (assess_confidence → report-preflight → exploit-verifier → report-writer). Requires `H1_USERNAME` and `H1_API_TOKEN` env vars. +- **github**: Create GitHub remediation issues from validated findings. Run `github_health` first to verify credentials. Use `github_list_labels` before creating issues when label names are uncertain. Use `github_create_issue` only after the full reporting pipeline completes; include the validated report body, severity/priority labels, and links to Dreadnode evidence or artifacts. Do not post sensitive exploit details to public repositories unless the user explicitly confirms that disclosure is intended. Requires `GITHUB_TOKEN` with Issues write permission. - **linear**: Create internal Linear remediation issues from validated findings. Run `linear_health` first to verify credentials. Use `linear_list_teams` to find the team UUID before creating issues. Use `linear_create_issue` only after the full reporting pipeline completes; include the validated report body, severity/priority mapping, and links to Dreadnode evidence or artifacts. Requires `LINEAR_API_KEY` or `LINEAR_ACCESS_TOKEN`. Scan and tool output is input to your OODA loop, not a deliverable. When a scan completes, orient on the results, prioritize leads by exploitability, load relevant skills, and begin active exploitation immediately. A completed scan is the start of your work, not the end. diff --git a/capabilities/web-security/capability.yaml b/capabilities/web-security/capability.yaml index 5c55ff2..6cfff3f 100644 --- a/capabilities/web-security/capability.yaml +++ b/capabilities/web-security/capability.yaml @@ -35,11 +35,24 @@ mcp: - "run" - "${CAPABILITY_ROOT}/mcp/hackerone.py" init_timeout: 60 + github: + command: "uv" + args: + - "run" + - "${CAPABILITY_ROOT}/mcp/github.py" + env: + GITHUB_TOKEN: "${GITHUB_TOKEN:-}" + GITHUB_API_URL: "${GITHUB_API_URL:-https://api.github.com}" + init_timeout: 60 linear: command: "uv" args: - "run" - "${CAPABILITY_ROOT}/mcp/linear.py" + env: + LINEAR_API_KEY: "${LINEAR_API_KEY:-}" + LINEAR_ACCESS_TOKEN: "${LINEAR_ACCESS_TOKEN:-}" + LINEAR_API_URL: "${LINEAR_API_URL:-https://api.linear.app/graphql}" init_timeout: 60 agent-browser: command: "uv" diff --git a/capabilities/web-security/mcp/github.py b/capabilities/web-security/mcp/github.py new file mode 100644 index 0000000..daccc5d --- /dev/null +++ b/capabilities/web-security/mcp/github.py @@ -0,0 +1,208 @@ +#!/usr/bin/env -S uv run +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "fastmcp>=2.0", +# "httpx>=0.28", +# ] +# /// +"""GitHub issue tools for web-security report export. + +Auth: GITHUB_TOKEN with Issues write permission for target repositories. +Use these tools only after a web-security finding has passed validation, +and avoid posting sensitive exploit detail to public repositories unless the +user explicitly confirms that disclosure is intended. +""" + +from __future__ import annotations + +import os +from typing import Annotated, Any + +import httpx +from fastmcp import FastMCP + +_DEFAULT_API_URL = "https://api.github.com" +_API_VERSION = "2022-11-28" +MAX_OUTPUT_CHARS = 30_000 + +mcp = FastMCP("github") + + +class _GitHubClient: + """Lazy GitHub REST API client.""" + + def __init__(self) -> None: + self._client: httpx.AsyncClient | None = None + + def _settings(self) -> tuple[str, str]: + api_url = os.environ.get("GITHUB_API_URL", _DEFAULT_API_URL).strip().rstrip("/") + token = os.environ.get("GITHUB_TOKEN", "").strip() + if not token: + raise RuntimeError( + "GitHub credentials not configured. Set GITHUB_TOKEN with " + "Issues write permission for the target repository." + ) + return api_url, token + + async def get(self) -> httpx.AsyncClient: + if self._client is not None: + return self._client + + api_url, token = self._settings() + self._client = httpx.AsyncClient( + base_url=api_url, + timeout=30.0, + follow_redirects=True, + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": _API_VERSION, + }, + ) + return self._client + + +_github = _GitHubClient() + + +def _raise_for_github(resp: httpx.Response, action: str) -> None: + if 200 <= resp.status_code < 300: + return + detail = resp.text[:1000] + raise RuntimeError(f"GitHub {action} failed: HTTP {resp.status_code}: {detail}") + + +def _truncate(value: str, limit: int = MAX_OUTPUT_CHARS) -> str: + if len(value) <= limit: + return value + return value[:limit] + f"\n... [TRUNCATED: {len(value)} chars total]" + + +def _drop_empty(value: dict[str, Any]) -> dict[str, Any]: + return { + key: item + for key, item in value.items() + if item is not None and item != "" and item != [] and item != {} + } + + +def _issue_line(issue: dict[str, Any]) -> str: + number = issue.get("number", "?") + state = issue.get("state", "?") + title = issue.get("title", "?") + url = issue.get("html_url", "") + return f"#{number}\t{state}\t{title}\t{url}" + + +@mcp.tool +async def github_health() -> str: + """Check GitHub API connectivity and show the authenticated user.""" + client = await _github.get() + resp = await client.get("/user") + _raise_for_github(resp, "health check") + data = resp.json() + return ( + "Connected to GitHub\n" + f" Login: {data.get('login', '?')}\n" + f" ID: {data.get('id', '?')}\n" + f" URL: {data.get('html_url', '?')}" + ) + + +@mcp.tool +async def github_list_labels( + owner: Annotated[str, "Repository owner or organization"], + repo: Annotated[str, "Repository name"], + per_page: Annotated[int, "Maximum labels to return"] = 100, +) -> str: + """List labels for a GitHub repository.""" + client = await _github.get() + resp = await client.get( + f"/repos/{owner}/{repo}/labels", + params={"per_page": min(per_page, 100)}, + ) + _raise_for_github(resp, "label list") + labels = resp.json() + if not labels: + return f"No labels found for {owner}/{repo}." + + lines = [f"Labels for {owner}/{repo}:"] + for label in labels: + description = label.get("description") or "" + line = f" {label.get('name', '?')}" + if description: + line += f"\t{description[:120]}" + lines.append(line) + return "\n".join(lines) + + +@mcp.tool +async def github_create_issue( + owner: Annotated[str, "Repository owner or organization"], + repo: Annotated[str, "Repository name"], + title: Annotated[str, "Issue title"], + body: Annotated[str, "Validated finding or report body in Markdown"], + labels: Annotated[list[str] | None, "Optional label names"] = None, + assignees: Annotated[list[str] | None, "Optional GitHub usernames"] = None, + milestone: Annotated[int | None, "Optional milestone number"] = None, +) -> str: + """Create a GitHub issue from a validated web-security finding.""" + payload = _drop_empty( + { + "title": title, + "body": body, + "labels": labels, + "assignees": assignees, + "milestone": milestone, + } + ) + client = await _github.get() + resp = await client.post(f"/repos/{owner}/{repo}/issues", json=payload) + _raise_for_github(resp, "issue create") + issue = resp.json() + return f"Created GitHub issue {_issue_line(issue)}" + + +@mcp.tool +async def github_get_issue( + owner: Annotated[str, "Repository owner or organization"], + repo: Annotated[str, "Repository name"], + issue_number: Annotated[int, "Issue number"], +) -> str: + """Get a GitHub issue summary and body.""" + client = await _github.get() + resp = await client.get(f"/repos/{owner}/{repo}/issues/{issue_number}") + _raise_for_github(resp, "issue fetch") + issue = resp.json() + lines = [ + _issue_line(issue), + f"Author: {(issue.get('user') or {}).get('login', '?')}", + f"Labels: {', '.join(label.get('name', '?') for label in issue.get('labels', []))}", + "", + "--- Body ---", + issue.get("body") or "", + ] + return _truncate("\n".join(lines)) + + +@mcp.tool +async def github_add_comment( + owner: Annotated[str, "Repository owner or organization"], + repo: Annotated[str, "Repository name"], + issue_number: Annotated[int, "Issue number"], + body: Annotated[str, "Markdown comment body"], +) -> str: + """Add a comment to an existing GitHub issue.""" + client = await _github.get() + resp = await client.post( + f"/repos/{owner}/{repo}/issues/{issue_number}/comments", + json={"body": body}, + ) + _raise_for_github(resp, "comment create") + comment = resp.json() + return f"Added GitHub comment {comment.get('id', '?')}: {comment.get('html_url', '')}".strip() + + +if __name__ == "__main__": + mcp.run() diff --git a/capabilities/web-security/tests/test_github_mcp.py b/capabilities/web-security/tests/test_github_mcp.py new file mode 100644 index 0000000..5884f7c --- /dev/null +++ b/capabilities/web-security/tests/test_github_mcp.py @@ -0,0 +1,224 @@ +"""Tests for the GitHub MCP server.""" + +from __future__ import annotations + +import importlib.util +import sys +import types +from pathlib import Path +from unittest.mock import AsyncMock, patch + +import httpx +import pytest + + +def _install_fastmcp_stub() -> None: + """Install a minimal fastmcp stub so github.py can be imported.""" + fastmcp = types.ModuleType("fastmcp") + + class _FastMCP: + def __init__(self, name: str) -> None: + self.name = name + self._tools: dict[str, object] = {} + + def tool(self, fn): + self._tools[fn.__name__] = fn + return fn + + def run(self, **kwargs) -> None: + pass + + setattr(fastmcp, "FastMCP", _FastMCP) + sys.modules["fastmcp"] = fastmcp + + +_install_fastmcp_stub() + +MODULE_PATH = Path(__file__).resolve().parent.parent / "mcp" / "github.py" +SPEC = importlib.util.spec_from_file_location("github_mcp", MODULE_PATH) +assert SPEC and SPEC.loader +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + +_GitHubClient = MODULE._GitHubClient + + +def _mock_response( + status_code: int = 200, + json_data: object = None, + text: str | None = None, +) -> httpx.Response: + kwargs: dict = { + "status_code": status_code, + "request": httpx.Request("GET", "https://api.github.com/test"), + } + if json_data is not None: + kwargs["json"] = json_data + elif text is not None: + kwargs["text"] = text + return httpx.Response(**kwargs) + + +def _issue() -> dict: + return { + "number": 123, + "state": "open", + "title": "Stored XSS", + "html_url": "https://github.com/dreadnode/example/issues/123", + "body": "Validated report", + "user": {"login": "security-bot"}, + "labels": [{"name": "security"}, {"name": "high"}], + } + + +class TestGitHubClient: + def test_settings_requires_token(self) -> None: + client = _GitHubClient() + with patch.dict("os.environ", {}, clear=True): + with pytest.raises(RuntimeError, match="GITHUB_TOKEN"): + client._settings() + + def test_settings_strips_api_url(self) -> None: + client = _GitHubClient() + env = { + "GITHUB_API_URL": "https://github.example.com/api/v3/", + "GITHUB_TOKEN": "tok", + } + with patch.dict("os.environ", env, clear=True): + assert client._settings() == ("https://github.example.com/api/v3", "tok") + + @pytest.mark.asyncio + async def test_get_builds_bearer_client(self) -> None: + client = _GitHubClient() + with patch.dict("os.environ", {"GITHUB_TOKEN": "tok"}, clear=True): + http_client = await client.get() + + assert http_client.base_url == "https://api.github.com" + assert http_client.headers["Authorization"] == "Bearer tok" + assert http_client.headers["X-GitHub-Api-Version"] == "2022-11-28" + + +class TestHelpers: + def test_drop_empty_preserves_zero_and_false(self) -> None: + result = MODULE._drop_empty( + { + "empty": "", + "none": None, + "list": [], + "dict": {}, + "zero": 0, + "false": False, + "value": "x", + } + ) + assert result == {"zero": 0, "false": False, "value": "x"} + + def test_raise_for_github_includes_status_and_body(self) -> None: + resp = _mock_response(status_code=403, text="denied") + with pytest.raises(RuntimeError, match="HTTP 403: denied"): + MODULE._raise_for_github(resp, "test") + + +class TestTools: + @pytest.mark.asyncio + async def test_health_success(self) -> None: + mock_client = AsyncMock() + mock_client.get.return_value = _mock_response( + json_data={ + "login": "security-bot", + "id": 1, + "html_url": "https://github.com/security-bot", + } + ) + + with patch.object(MODULE._github, "get", return_value=mock_client): + result = await MODULE.github_health() + + assert "Connected to GitHub" in result + assert "security-bot" in result + mock_client.get.assert_called_once_with("/user") + + @pytest.mark.asyncio + async def test_list_labels_formats_rows(self) -> None: + mock_client = AsyncMock() + mock_client.get.return_value = _mock_response( + json_data=[ + {"name": "security", "description": "Security issue"}, + {"name": "high", "description": None}, + ] + ) + + with patch.object(MODULE._github, "get", return_value=mock_client): + result = await MODULE.github_list_labels("dreadnode", "example") + + assert "Labels for dreadnode/example" in result + assert "security\tSecurity issue" in result + assert "high" in result + + @pytest.mark.asyncio + async def test_create_issue_posts_expected_payload(self) -> None: + mock_client = AsyncMock() + mock_client.post.return_value = _mock_response( + status_code=201, json_data=_issue() + ) + + with patch.object(MODULE._github, "get", return_value=mock_client): + result = await MODULE.github_create_issue( + owner="dreadnode", + repo="example", + title="Stored XSS", + body="Validated report", + labels=["security", "high"], + assignees=["security-bot"], + milestone=1, + ) + + assert "Created GitHub issue #123\topen\tStored XSS" in result + mock_client.post.assert_called_once_with( + "/repos/dreadnode/example/issues", + json={ + "title": "Stored XSS", + "body": "Validated report", + "labels": ["security", "high"], + "assignees": ["security-bot"], + "milestone": 1, + }, + ) + + @pytest.mark.asyncio + async def test_get_issue_returns_body(self) -> None: + mock_client = AsyncMock() + mock_client.get.return_value = _mock_response(json_data=_issue()) + + with patch.object(MODULE._github, "get", return_value=mock_client): + result = await MODULE.github_get_issue("dreadnode", "example", 123) + + assert "#123\topen\tStored XSS" in result + assert "Author: security-bot" in result + assert "Labels: security, high" in result + assert "Validated report" in result + + @pytest.mark.asyncio + async def test_add_comment_posts_body(self) -> None: + mock_client = AsyncMock() + mock_client.post.return_value = _mock_response( + status_code=201, + json_data={ + "id": 99, + "html_url": "https://github.com/dreadnode/example/issues/123#comment-99", + }, + ) + + with patch.object(MODULE._github, "get", return_value=mock_client): + result = await MODULE.github_add_comment( + "dreadnode", + "example", + 123, + "new evidence", + ) + + assert "Added GitHub comment 99" in result + mock_client.post.assert_called_once_with( + "/repos/dreadnode/example/issues/123/comments", + json={"body": "new evidence"}, + )