Skip to content
Open
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 backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ python-multipart>=0.0.9
xhtml2pdf>=0.2.17
aiosqlite>=0.20.0
python-whois>=0.9.4
httpx>=0.27.0
115 changes: 115 additions & 0 deletions backend/secuscan/integrations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import httpx
import logging
from typing import Dict, Any
from .models import Finding

logger = logging.getLogger(__name__)

async def create_jira_ticket(finding: Finding, config: Dict[str, str]) -> Dict[str, Any]:
url = config.get("jiraUrl", "").rstrip("/")
email = config.get("jiraEmail")
token = config.get("jiraToken")
project_key = config.get("jiraProject")

if not all([url, email, token, project_key]):
raise ValueError("Missing Jira configuration parameters")

api_url = f"{url}/rest/api/2/issue"

description = f"""
*Target:* {finding.target}
*Severity:* {finding.severity}
*Category:* {finding.category}
*Discovered At:* {finding.discovered_at}

*Description:*
{finding.description}

*Remediation:*
{finding.remediation}
"""
if finding.cve:
description += f"\n*CVE:* {finding.cve}"

payload = {
"fields": {
"project": {
"key": project_key
},
"summary": f"[{finding.severity.upper()}] {finding.title}",
"description": description,
"issuetype": {
"name": "Bug"
}
}
}

async with httpx.AsyncClient() as client:
response = await client.post(
api_url,
json=payload,
auth=(email, token),
headers={"Content-Type": "application/json"}
)

if response.status_code >= 400:
logger.error(f"Jira API error: {response.text}")
raise Exception(f"Failed to create Jira ticket: {response.status_code} {response.text}")

data = response.json()
return {
"ticket_id": data.get("key"),
"ticket_url": f"{url}/browse/{data.get('key')}"
}


async def create_github_issue(finding: Finding, config: Dict[str, str]) -> Dict[str, Any]:
token = config.get("githubToken")
repo = config.get("githubRepo")

if not all([token, repo]):
raise ValueError("Missing GitHub configuration parameters")

api_url = f"https://api.github.com/repos/{repo}/issues"

body = f"""
**Target:** `{finding.target}`
**Severity:** {finding.severity.upper()}
**Category:** {finding.category}
**Discovered At:** {finding.discovered_at}

### Description
{finding.description}

### Remediation
{finding.remediation}
"""
if finding.cve:
body += f"\n**CVE:** {finding.cve}"

payload = {
"title": f"[{finding.severity.upper()}] {finding.title}",
"body": body,
"labels": ["bug", "security"]
}

async with httpx.AsyncClient() as client:
response = await client.post(
api_url,
json=payload,
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github.v3+json",
"X-GitHub-Api-Version": "2022-11-28"
}
)

if response.status_code >= 400:
logger.error(f"GitHub API error: {response.text}")
raise Exception(f"Failed to create GitHub issue: {response.status_code} {response.text}")

data = response.json()
return {
"ticket_id": str(data.get("number")),
"ticket_url": data.get("html_url")
}
14 changes: 13 additions & 1 deletion backend/secuscan/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,4 +181,16 @@ class ErrorResponse(BaseModel):

class BulkDeleteRequest(RootModel[Annotated[List[str], Field(max_length=MAX_BULK_DELETE)]]):
"""Accepts a JSON array of task IDs directly. Max 500 per request."""
pass
pass


class TicketCreateRequest(BaseModel):
"""Request to create a ticket in an external integration"""
provider: str # "jira" or "github"
finding: Finding


class TicketResponse(BaseModel):
"""Response after creating a ticket"""
ticket_id: str
ticket_url: str
36 changes: 35 additions & 1 deletion backend/secuscan/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,10 @@ def build_report_filename(task: Dict[str, Any], extension: str) -> str:
from .cache import get_cache
from .models import (
TaskCreateRequest, TaskResponse, TaskResult,
PluginListResponse, ErrorResponse, BulkDeleteRequest
PluginListResponse, ErrorResponse, BulkDeleteRequest,
TicketCreateRequest, TicketResponse
)
from .integrations import create_jira_ticket, create_github_issue
from .config import settings
from .database import get_db
from .plugins import get_plugin_manager, init_plugins
Expand Down Expand Up @@ -1251,3 +1253,35 @@ async def get_assets():
rows = await db.fetchall("SELECT DISTINCT target FROM tasks UNION SELECT DISTINCT target FROM findings")
assets = [{"id": str(uuid.uuid4()), "name": row["target"]} for row in rows]
return {"assets": assets}


@router.post("/integrations/ticket", response_model=TicketResponse)
async def create_ticket(request: TicketCreateRequest):
"""Create a ticket in an external issue tracker"""
db = await get_db()
crypto = VaultCrypto(settings.resolved_vault_key)

config = {}
if request.provider == "jira":
keys = ["jiraUrl", "jiraEmail", "jiraToken", "jiraProject"]
elif request.provider == "github":
keys = ["githubToken", "githubRepo"]
else:
raise HTTPException(status_code=400, detail="Unsupported provider")

for key in keys:
row = await db.fetchone("SELECT encrypted_value FROM credential_vault WHERE name = ?", (key,))
if not row:
raise HTTPException(status_code=400, detail=f"Missing integration configuration: {key}")
config[key] = crypto.decrypt(row["encrypted_value"])

try:
if request.provider == "jira":
result = await create_jira_ticket(request.finding, config)
return TicketResponse(**result)
elif request.provider == "github":
result = await create_github_issue(request.finding, config)
return TicketResponse(**result)
except Exception as e:
logger.exception("Ticket creation failed")
raise HTTPException(status_code=500, detail=str(e))
31 changes: 31 additions & 0 deletions frontend/src/__tests__/Findings.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, it, expect, vi } from 'vitest'
import { createTicket } from '../api'

// Mock the global request function by mocking the module
vi.mock('../api', async () => {
const actual = await vi.importActual('../api')
return {
...actual,
createTicket: vi.fn().mockResolvedValue({ ticket_id: 'TEST-123', ticket_url: 'http://example.com/TEST-123' }),
upsertVaultSecret: vi.fn().mockResolvedValue({}),
}
})

describe('Export Flow', () => {
it('calls createTicket without config object (backend handles secrets)', async () => {
const finding = {
id: '123',
title: 'Test Finding',
severity: 'high',
status: 'new'
}

// Call the exported mocked function directly to verify it works without config
await createTicket('jira', finding)

expect(createTicket).toHaveBeenCalledWith('jira', finding)
// We expect it to be called with exactly 2 arguments (provider, finding),
// ensuring no credentials object is passed from the frontend.
expect(createTicket).toHaveBeenCalledTimes(1)
})
})
16 changes: 16 additions & 0 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,3 +294,19 @@ export function deleteWorkflow(workflowId: string): Promise<{ deleted: boolean }
method: 'DELETE',
})
}

export function createTicket(provider: string, finding: any): Promise<{ ticket_id: string; ticket_url: string }> {
return request<{ ticket_id: string; ticket_url: string }>('/integrations/ticket', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider, finding }),
})
}

export function upsertVaultSecret(name: string, payload: Record<string, string>) {
return request(`/vault/${name}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
}
34 changes: 32 additions & 2 deletions frontend/src/pages/Findings.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import React, { useEffect, useMemo, useState } from 'react'
import { motion } from 'framer-motion'
import { getFindings } from '../api'
import { getFindings, createTicket } from '../api'
import { formatLocaleDate, parseDateSafe, getCurrentTimeZone } from '../utils/date'
type RiskFactor = {
import { useToast } from '../components/ToastContext'

export interface RiskFactor {
factor: string
label: string
value: string | number
Expand Down Expand Up @@ -106,6 +108,7 @@ const filterControlClass =
type SortMode = 'severity' | 'newest' | 'oldest' | 'target'

export default function Findings() {
const { addToast } = useToast()
const [findings, setFindings] = useState<Finding[]>([])
const [loading, setLoading] = useState(true)
const [searchQuery, setSearchQuery] = useState('')
Expand Down Expand Up @@ -331,6 +334,17 @@ export default function Findings() {
}
}

async function exportToTracker(finding: Finding & { status: FindingStatus }, provider: 'jira' | 'github') {
try {
addToast(`Exporting to ${provider.toUpperCase()}...`, 'info')
const result = await createTicket(provider, finding)
addToast(`Successfully created ${provider.toUpperCase()} ticket: ${result.ticket_id}`, 'success')
window.open(result.ticket_url, '_blank', 'noopener,noreferrer')
} catch (error: any) {
addToast(`Failed to create ${provider.toUpperCase()} ticket: ${error.message}`, 'error')
}
}

function renderFindingRow(finding: Finding & { severity: string; status: FindingStatus }) {
const isSelected = selectedFinding?.id === finding.id
const cfg = severityConfig[finding.severity]
Expand Down Expand Up @@ -775,6 +789,22 @@ export default function Findings() {
{copiedFindingId === selectedFinding.id ? 'Copied' : 'Copy Brief'}
</button>
</div>
<div className="grid gap-2 sm:grid-cols-2 mt-2">
<button
type="button"
onClick={() => exportToTracker(selectedFinding, 'jira')}
className="border border-[#0052CC]/25 bg-[#0052CC]/10 px-4 py-3 text-[10px] font-black uppercase tracking-[0.18em] text-[#2684FF]"
>
Export to Jira
</button>
<button
type="button"
onClick={() => exportToTracker(selectedFinding, 'github')}
className="border border-white/25 bg-white/10 px-4 py-3 text-[10px] font-black uppercase tracking-[0.18em] text-white"
>
Export to GitHub
</button>
</div>
</div>
</div>
) : (
Expand Down
Loading
Loading