Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions capabilities/web-security/agents/web-security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
10 changes: 10 additions & 0 deletions capabilities/web-security/capability.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
237 changes: 237 additions & 0 deletions capabilities/web-security/mcp/jira.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading