diff --git a/capabilities/web-security/agents/web-security.md b/capabilities/web-security/agents/web-security.md index ce5c16a..fedb6a7 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. +- **jira**: Create internal Jira remediation tickets from validated findings. Run `jira_health` first to verify credentials. Use `jira_get_create_metadata` before creating issues when the project or issue type is uncertain. Use `jira_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 `JIRA_BASE_URL`, `JIRA_EMAIL`, and `JIRA_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`. diff --git a/capabilities/web-security/capability.yaml b/capabilities/web-security/capability.yaml index 6cfff3f..b7f74b1 100644 --- a/capabilities/web-security/capability.yaml +++ b/capabilities/web-security/capability.yaml @@ -35,6 +35,16 @@ mcp: - "run" - "${CAPABILITY_ROOT}/mcp/hackerone.py" init_timeout: 60 + jira: + command: "uv" + args: + - "run" + - "${CAPABILITY_ROOT}/mcp/jira.py" + env: + JIRA_BASE_URL: "${JIRA_BASE_URL:-}" + JIRA_EMAIL: "${JIRA_EMAIL:-}" + JIRA_API_TOKEN: "${JIRA_API_TOKEN:-}" + init_timeout: 60 github: command: "uv" args: diff --git a/capabilities/web-security/mcp/jira.py b/capabilities/web-security/mcp/jira.py new file mode 100644 index 0000000..8715e4f --- /dev/null +++ b/capabilities/web-security/mcp/jira.py @@ -0,0 +1,237 @@ +#!/usr/bin/env -S uv run +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "fastmcp>=2.0", +# "httpx>=0.28", +# ] +# /// +"""Jira Cloud issue tools for web-security report export. + +Auth: HTTP Basic via JIRA_BASE_URL, JIRA_EMAIL, and JIRA_API_TOKEN. +Use these tools only after a web-security finding has passed validation. +""" + +from __future__ import annotations + +import base64 +import os +from typing import Annotated, Any + +import httpx +from fastmcp import FastMCP + +MAX_OUTPUT_CHARS = 30_000 + +mcp = FastMCP("jira") + + +class _JiraClient: + """Lazy Jira Cloud API client.""" + + def __init__(self) -> None: + self._client: httpx.AsyncClient | None = None + + def _settings(self) -> tuple[str, str, str]: + base_url = os.environ.get("JIRA_BASE_URL", "").strip().rstrip("/") + email = os.environ.get("JIRA_EMAIL", "").strip() + token = os.environ.get("JIRA_API_TOKEN", "").strip() + + missing = [ + name + for name, value in ( + ("JIRA_BASE_URL", base_url), + ("JIRA_EMAIL", email), + ("JIRA_API_TOKEN", token), + ) + if not value + ] + if missing: + raise RuntimeError( + "Jira credentials not configured. " + f"Set {', '.join(missing)} environment variables." + ) + return base_url, email, token + + async def get(self) -> httpx.AsyncClient: + if self._client is not None: + return self._client + + base_url, email, token = self._settings() + auth = base64.b64encode(f"{email}:{token}".encode()).decode() + self._client = httpx.AsyncClient( + base_url=base_url, + timeout=30.0, + follow_redirects=True, + headers={ + "Authorization": f"Basic {auth}", + "Accept": "application/json", + "Content-Type": "application/json", + }, + ) + return self._client + + +_jira = _JiraClient() + + +def _raise_for_jira(resp: httpx.Response, action: str) -> None: + if 200 <= resp.status_code < 300: + return + detail = resp.text[:1000] + raise RuntimeError(f"Jira {action} failed: HTTP {resp.status_code}: {detail}") + + +def _adf_text(text: str) -> dict[str, Any]: + """Convert plain text or Markdown-ish text into simple Jira ADF.""" + content: list[dict[str, Any]] = [] + for block in text.split("\n\n"): + lines = block.splitlines() or [""] + paragraph_content: list[dict[str, Any]] = [] + for index, line in enumerate(lines): + if line: + paragraph_content.append({"type": "text", "text": line}) + if index < len(lines) - 1: + paragraph_content.append({"type": "hardBreak"}) + content.append({"type": "paragraph", "content": paragraph_content}) + + if not content: + content.append({"type": "paragraph", "content": []}) + + return {"type": "doc", "version": 1, "content": content} + + +def _issue_summary(issue: dict[str, Any]) -> str: + key = issue.get("key") or issue.get("id") or "?" + fields = issue.get("fields") or {} + summary = fields.get("summary") or issue.get("summary") or "?" + status = (fields.get("status") or {}).get("name", "?") + priority = (fields.get("priority") or {}).get("name", "?") + return f"{key}\t{status}\t{priority}\t{summary}" + + +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]" + + +@mcp.tool +async def jira_health() -> str: + """Check Jira API connectivity and show the authenticated user.""" + client = await _jira.get() + resp = await client.get("/rest/api/3/myself") + _raise_for_jira(resp, "health check") + data = resp.json() + return ( + "Connected to Jira\n" + f" Account ID: {data.get('accountId', '?')}\n" + f" Display: {data.get('displayName', '?')}\n" + f" Email: {data.get('emailAddress', '?')}" + ) + + +@mcp.tool +async def jira_get_create_metadata( + project_key: Annotated[str, "Jira project key, e.g. ENG"], +) -> str: + """List issue types available for creating issues in a Jira project.""" + client = await _jira.get() + resp = await client.get(f"/rest/api/3/issue/createmeta/{project_key}/issuetypes") + _raise_for_jira(resp, "create metadata lookup") + issue_types = resp.json().get("issueTypes", []) + if not issue_types: + return f"No issue types found for project {project_key}." + + lines = [f"Creatable issue types for {project_key}:"] + for issue_type in issue_types: + name = issue_type.get("name", "?") + issue_type_id = issue_type.get("id", "?") + description = issue_type.get("description", "") + line = f" {issue_type_id}\t{name}" + if description: + line += f"\t{description[:120]}" + lines.append(line) + return "\n".join(lines) + + +@mcp.tool +async def jira_create_issue( + project_key: Annotated[str, "Jira project key, e.g. ENG"], + issue_type: Annotated[str, "Jira issue type name, e.g. Bug or Task"], + summary: Annotated[str, "Issue summary/title"], + description: Annotated[str, "Validated finding or report body"], + priority: Annotated[str | None, "Optional Jira priority name"] = None, + labels: Annotated[list[str] | None, "Optional Jira labels"] = None, + assignee_account_id: Annotated[ + str | None, + "Optional Jira account ID to assign the issue to", + ] = None, + components: Annotated[list[str] | None, "Optional component names"] = None, +) -> str: + """Create a Jira issue from a validated web-security finding.""" + fields: dict[str, Any] = { + "project": {"key": project_key}, + "issuetype": {"name": issue_type}, + "summary": summary, + "description": _adf_text(description), + } + if priority: + fields["priority"] = {"name": priority} + if labels: + fields["labels"] = labels + if assignee_account_id: + fields["assignee"] = {"accountId": assignee_account_id} + if components: + fields["components"] = [{"name": name} for name in components] + + client = await _jira.get() + resp = await client.post("/rest/api/3/issue", json={"fields": fields}) + _raise_for_jira(resp, "issue create") + data = resp.json() + key = data.get("key", "?") + url = os.environ.get("JIRA_BASE_URL", "").strip().rstrip("/") + return f"Created Jira issue {key}: {url}/browse/{key}" + + +@mcp.tool +async def jira_get_issue( + issue_key: Annotated[str, "Jira issue key, e.g. ENG-123"], +) -> str: + """Get a Jira issue summary and description.""" + client = await _jira.get() + resp = await client.get(f"/rest/api/3/issue/{issue_key}") + _raise_for_jira(resp, "issue fetch") + data = resp.json() + fields = data.get("fields") or {} + description = fields.get("description") + return _truncate( + "\n".join( + [ + _issue_summary(data), + "", + "--- Description ADF ---", + str(description or ""), + ] + ) + ) + + +@mcp.tool +async def jira_add_comment( + issue_key: Annotated[str, "Jira issue key, e.g. ENG-123"], + body: Annotated[str, "Comment text to add to the issue"], +) -> str: + """Add a comment to an existing Jira issue.""" + client = await _jira.get() + resp = await client.post( + f"/rest/api/3/issue/{issue_key}/comment", + json={"body": _adf_text(body)}, + ) + _raise_for_jira(resp, "comment create") + data = resp.json() + return f"Added comment {data.get('id', '?')} to Jira issue {issue_key}." + + +if __name__ == "__main__": + mcp.run() diff --git a/capabilities/web-security/tests/test_jira_mcp.py b/capabilities/web-security/tests/test_jira_mcp.py new file mode 100644 index 0000000..7160820 --- /dev/null +++ b/capabilities/web-security/tests/test_jira_mcp.py @@ -0,0 +1,230 @@ +"""Tests for the Jira MCP server.""" + +from __future__ import annotations + +import base64 +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 jira.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" / "jira.py" +SPEC = importlib.util.spec_from_file_location("jira_mcp", MODULE_PATH) +assert SPEC and SPEC.loader +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + +_JiraClient = MODULE._JiraClient + + +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://example.atlassian.net/test"), + } + if json_data is not None: + kwargs["json"] = json_data + elif text is not None: + kwargs["text"] = text + return httpx.Response(**kwargs) + + +class TestJiraClient: + def test_settings_missing_credentials(self) -> None: + client = _JiraClient() + with patch.dict("os.environ", {}, clear=True): + with pytest.raises(RuntimeError, match="JIRA_BASE_URL"): + client._settings() + + def test_settings_strips_base_url(self) -> None: + client = _JiraClient() + env = { + "JIRA_BASE_URL": "https://example.atlassian.net/", + "JIRA_EMAIL": "user@example.com", + "JIRA_API_TOKEN": "token", + } + with patch.dict("os.environ", env, clear=True): + assert client._settings() == ( + "https://example.atlassian.net", + "user@example.com", + "token", + ) + + @pytest.mark.asyncio + async def test_get_builds_basic_auth_client(self) -> None: + client = _JiraClient() + env = { + "JIRA_BASE_URL": "https://example.atlassian.net", + "JIRA_EMAIL": "user@example.com", + "JIRA_API_TOKEN": "token", + } + with patch.dict("os.environ", env, clear=True): + http_client = await client.get() + + expected = base64.b64encode(b"user@example.com:token").decode() + assert http_client.base_url == "https://example.atlassian.net" + assert http_client.headers["Authorization"] == f"Basic {expected}" + + +class TestHelpers: + def test_adf_text_builds_paragraphs_and_breaks(self) -> None: + adf = MODULE._adf_text("line one\nline two\n\nline three") + + assert adf["type"] == "doc" + assert adf["version"] == 1 + assert len(adf["content"]) == 2 + first = adf["content"][0]["content"] + assert first[0] == {"type": "text", "text": "line one"} + assert first[1] == {"type": "hardBreak"} + assert first[2] == {"type": "text", "text": "line two"} + + def test_raise_for_jira_includes_status_and_body(self) -> None: + resp = _mock_response(status_code=403, text="nope") + with pytest.raises(RuntimeError, match="HTTP 403: nope"): + MODULE._raise_for_jira(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={ + "accountId": "abc123", + "displayName": "Security Bot", + "emailAddress": "sec@example.com", + } + ) + + with patch.object(MODULE._jira, "get", return_value=mock_client): + result = await MODULE.jira_health() + + assert "Connected to Jira" in result + assert "abc123" in result + assert "Security Bot" in result + mock_client.get.assert_called_once_with("/rest/api/3/myself") + + @pytest.mark.asyncio + async def test_get_create_metadata_formats_issue_types(self) -> None: + mock_client = AsyncMock() + mock_client.get.return_value = _mock_response( + json_data={ + "issueTypes": [ + {"id": "10001", "name": "Bug", "description": "Bug report"}, + {"id": "10002", "name": "Task"}, + ] + } + ) + + with patch.object(MODULE._jira, "get", return_value=mock_client): + result = await MODULE.jira_get_create_metadata("ENG") + + assert "Creatable issue types for ENG" in result + assert "10001\tBug" in result + assert "10002\tTask" in result + mock_client.get.assert_called_once_with( + "/rest/api/3/issue/createmeta/ENG/issuetypes" + ) + + @pytest.mark.asyncio + async def test_create_issue_posts_expected_fields(self) -> None: + mock_client = AsyncMock() + mock_client.post.return_value = _mock_response( + status_code=201, + json_data={"key": "ENG-123"}, + ) + env = {"JIRA_BASE_URL": "https://example.atlassian.net"} + + with ( + patch.object(MODULE._jira, "get", return_value=mock_client), + patch.dict("os.environ", env, clear=True), + ): + result = await MODULE.jira_create_issue( + project_key="ENG", + issue_type="Bug", + summary="Stored XSS in comments", + description="Validated report body", + priority="High", + labels=["web-security", "validated"], + assignee_account_id="acct-1", + components=["AppSec"], + ) + + assert result == ( + "Created Jira issue ENG-123: " + "https://example.atlassian.net/browse/ENG-123" + ) + _, kwargs = mock_client.post.call_args + fields = kwargs["json"]["fields"] + assert fields["project"] == {"key": "ENG"} + assert fields["issuetype"] == {"name": "Bug"} + assert fields["priority"] == {"name": "High"} + assert fields["labels"] == ["web-security", "validated"] + assert fields["assignee"] == {"accountId": "acct-1"} + assert fields["components"] == [{"name": "AppSec"}] + assert fields["description"]["type"] == "doc" + + @pytest.mark.asyncio + async def test_get_issue_returns_summary(self) -> None: + mock_client = AsyncMock() + mock_client.get.return_value = _mock_response( + json_data={ + "key": "ENG-123", + "fields": { + "summary": "Stored XSS", + "status": {"name": "Todo"}, + "priority": {"name": "High"}, + "description": {"type": "doc"}, + }, + } + ) + + with patch.object(MODULE._jira, "get", return_value=mock_client): + result = await MODULE.jira_get_issue("ENG-123") + + assert "ENG-123\tTodo\tHigh\tStored XSS" in result + assert "--- Description ADF ---" in result + + @pytest.mark.asyncio + async def test_add_comment_posts_adf_body(self) -> None: + mock_client = AsyncMock() + mock_client.post.return_value = _mock_response(json_data={"id": "10000"}) + + with patch.object(MODULE._jira, "get", return_value=mock_client): + result = await MODULE.jira_add_comment("ENG-123", "new evidence") + + assert result == "Added comment 10000 to Jira issue ENG-123." + mock_client.post.assert_called_once() + _, kwargs = mock_client.post.call_args + assert kwargs["json"]["body"]["type"] == "doc"