Skip to content

Extending mcp-scan

This guide explains how to extend mcp-scan with custom rules and custom reporters.


Table of Contents


Custom Rules via Configuration

mcp-scan supports custom rules defined in the YAML configuration file. This is the recommended way to extend detection capabilities without modifying source code.

Custom Rule Structure

# .mcp-scan.yaml
rules:
  custom:
    - id: "CUSTOM-001"
      pattern: "regex_pattern"
      severity: high
      confidence: medium
      class: A
      description: "Finding description"
      remediation: "How to fix the problem"
      languages:
        - python
        - javascript

Rule Fields

Field Type Required Description
id string Yes Unique identifier (recommended: CUSTOM-XXX)
pattern string Yes Regular expression for detection
severity string Yes info, low, medium, high, critical
confidence string Yes low, medium, high
class string Yes Vulnerability class (A-N)
description string Yes Problem description
remediation string No Remediation instructions
languages []string No Languages to apply to (empty = all)

Custom Rule Examples

Detect Usage of Deprecated Functions

rules:
  custom:
    - id: "CUSTOM-DEP001"
      pattern: "deprecated_function\\("
      severity: low
      confidence: high
      class: L
      description: "Usage of deprecated function deprecated_function()"
      remediation: "Replace with new_function()"
      languages:
        - python

    - id: "CUSTOM-DEP002"
      pattern: "old_api\\.method\\("
      severity: medium
      confidence: high
      class: L
      description: "Usage of obsolete API old_api.method()"
      remediation: "Migrate to new_api.method()"
      languages:
        - python
        - javascript

Detect Insecure Configurations

rules:
  custom:
    - id: "CUSTOM-CFG001"
      pattern: "debug\\s*=\\s*[Tt]rue"
      severity: medium
      confidence: medium
      class: E
      description: "Debug mode enabled in production"
      remediation: "Disable debug mode in production environments"

    - id: "CUSTOM-CFG002"
      pattern: "verify\\s*=\\s*[Ff]alse"
      severity: high
      confidence: high
      class: F
      description: "SSL verification disabled"
      remediation: "Enable SSL verification with verify=True"
      languages:
        - python

Detect Organization-Specific Patterns

rules:
  custom:
    - id: "ACME-SEC001"
      pattern: "AcmeInternalAPI\\(\\s*api_key\\s*="
      severity: critical
      confidence: high
      class: E
      description: "Acme API key hardcoded in code"
      remediation: "Use environment variable ACME_API_KEY"

    - id: "ACME-SEC002"
      pattern: "connect\\([\"']acme-internal\\."
      severity: high
      confidence: medium
      class: C
      description: "Direct connection to Acme internal infrastructure"
      remediation: "Use the internal API gateway"

Detect Injection Patterns in MCP Tools

rules:
  custom:
    - id: "CUSTOM-MCP001"
      pattern: "@tool\\s*\\n.*\\n.*eval\\("
      severity: critical
      confidence: high
      class: A
      description: "MCP Tool with eval() - possible RCE"
      remediation: "Remove usage of eval() in MCP tool handlers"
      languages:
        - python

    - id: "CUSTOM-MCP002"
      pattern: "@tool\\s*\\n.*\\n.*subprocess\\.call\\("
      severity: critical
      confidence: high
      class: A
      description: "MCP Tool executes commands via subprocess.call()"
      remediation: "Use subprocess.run() with shell=False and validate input"
      languages:
        - python

Using Custom Rules in Code

package main

import (
    "context"
    "fmt"
    "log"
    "os"

    "github.com/mcphub/mcp-scan/pkg/scanner"
    "gopkg.in/yaml.v3"
)

// CustomRule represents a custom rule
type CustomRule struct {
    ID          string   `yaml:"id"`
    Pattern     string   `yaml:"pattern"`
    Severity    string   `yaml:"severity"`
    Confidence  string   `yaml:"confidence"`
    Class       string   `yaml:"class"`
    Description string   `yaml:"description"`
    Remediation string   `yaml:"remediation"`
    Languages   []string `yaml:"languages"`
}

type Config struct {
    Rules struct {
        Custom []CustomRule `yaml:"custom"`
    } `yaml:"rules"`
}

func loadConfig(path string) (*Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, err
    }

    var cfg Config
    if err := yaml.Unmarshal(data, &cfg); err != nil {
        return nil, err
    }

    return &cfg, nil
}

func main() {
    // Load configuration with custom rules
    cfg, err := loadConfig(".mcp-scan.yaml")
    if err != nil {
        log.Printf("Could not load config: %v", err)
    } else {
        fmt.Printf("Custom rules loaded: %d\n", len(cfg.Rules.Custom))
        for _, rule := range cfg.Rules.Custom {
            fmt.Printf("  - %s: %s\n", rule.ID, rule.Description)
        }
    }

    // Scanner will automatically load rules from config file
    s := scanner.New(scanner.DefaultConfig())

    result, err := s.Scan(context.Background(), "./src")
    if err != nil {
        log.Fatal(err)
    }

    // Findings will include matches from custom rules
    for _, f := range result.Findings {
        if f.RuleID[:6] == "CUSTOM" || f.RuleID[:4] == "ACME" {
            fmt.Printf("[CUSTOM] %s: %s\n", f.RuleID, f.Description)
        }
    }
}

Custom Reporters

mcp-scan generates reports in JSON, SARIF, and Evidence Bundle formats. You can create custom reporters by processing the scan result.

Base Reporter Structure

package main

import (
    "context"
    "encoding/json"
    "fmt"
    "io"

    "github.com/mcphub/mcp-scan/pkg/scanner"
)

// Reporter is the interface for custom reporters
type Reporter interface {
    Generate(result *scanner.Result) ([]byte, error)
    Write(result *scanner.Result, w io.Writer) error
}

// BaseReporter provides common functionality
type BaseReporter struct {
    RedactSnippets bool
}

func (r *BaseReporter) redact(s string) string {
    if r.RedactSnippets {
        return "[REDACTED]"
    }
    return s
}

CSV Reporter

package main

import (
    "bytes"
    "context"
    "encoding/csv"
    "fmt"
    "io"
    "log"
    "os"

    "github.com/mcphub/mcp-scan/pkg/scanner"
)

type CSVReporter struct {
    BaseReporter
    IncludeHeaders bool
}

func NewCSVReporter(redact, headers bool) *CSVReporter {
    return &CSVReporter{
        BaseReporter:   BaseReporter{RedactSnippets: redact},
        IncludeHeaders: headers,
    }
}

func (r *CSVReporter) Generate(result *scanner.Result) ([]byte, error) {
    var buf bytes.Buffer
    if err := r.Write(result, &buf); err != nil {
        return nil, err
    }
    return buf.Bytes(), nil
}

func (r *CSVReporter) Write(result *scanner.Result, w io.Writer) error {
    writer := csv.NewWriter(w)
    defer writer.Flush()

    if r.IncludeHeaders {
        headers := []string{
            "ID", "Rule", "Severity", "Confidence", "Class",
            "File", "Line", "Title", "Description",
        }
        if err := writer.Write(headers); err != nil {
            return err
        }
    }

    for _, f := range result.Findings {
        row := []string{
            f.ID,
            f.RuleID,
            string(f.Severity),
            string(f.Confidence),
            string(f.Class),
            f.Location.File,
            fmt.Sprintf("%d", f.Location.StartLine),
            f.Title,
            f.Description,
        }
        if err := writer.Write(row); err != nil {
            return err
        }
    }

    return nil
}

func main() {
    s := scanner.New(scanner.DefaultConfig())
    result, err := s.Scan(context.Background(), "./src")
    if err != nil {
        log.Fatal(err)
    }

    reporter := NewCSVReporter(false, true)

    // Write to file
    f, _ := os.Create("report.csv")
    defer f.Close()
    reporter.Write(result, f)

    fmt.Println("CSV report generated at report.csv")
}

Markdown Reporter

package main

import (
    "bytes"
    "context"
    "fmt"
    "io"
    "log"
    "os"
    "strings"

    "github.com/mcphub/mcp-scan/pkg/scanner"
)

type MarkdownReporter struct {
    BaseReporter
    IncludeSummary   bool
    IncludeMCPSurface bool
}

func NewMarkdownReporter() *MarkdownReporter {
    return &MarkdownReporter{
        IncludeSummary:    true,
        IncludeMCPSurface: true,
    }
}

func (r *MarkdownReporter) Generate(result *scanner.Result) ([]byte, error) {
    var buf bytes.Buffer
    if err := r.Write(result, &buf); err != nil {
        return nil, err
    }
    return buf.Bytes(), nil
}

func (r *MarkdownReporter) Write(result *scanner.Result, w io.Writer) error {
    // Title
    fmt.Fprintln(w, "# MCP Security Report")
    fmt.Fprintln(w)

    // Summary
    if r.IncludeSummary {
        fmt.Fprintln(w, "## Summary")
        fmt.Fprintln(w)
        fmt.Fprintf(w, "| Metric | Value |\n")
        fmt.Fprintf(w, "|--------|-------|\n")
        fmt.Fprintf(w, "| Files scanned | %d |\n", result.Manifest.TotalFiles)
        fmt.Fprintf(w, "| Total findings | %d |\n", len(result.Findings))
        fmt.Fprintf(w, "| MSSS Score | %.1f/100 |\n", result.MSSSScore.Total)
        fmt.Fprintf(w, "| MSSS Level | %d |\n", result.MSSSScore.Level)
        fmt.Fprintf(w, "| Duration | %v |\n", result.ScanDuration)
        fmt.Fprintln(w)

        // By severity
        fmt.Fprintln(w, "### By Severity")
        fmt.Fprintln(w)
        fmt.Fprintln(w, "| Severity | Count |")
        fmt.Fprintln(w, "|----------|-------|")
        for sev, count := range result.Summary.BySeverity {
            emoji := severityEmoji(sev)
            fmt.Fprintf(w, "| %s %s | %d |\n", emoji, sev, count)
        }
        fmt.Fprintln(w)
    }

    // MCP Surface
    if r.IncludeMCPSurface && result.MCPSurface != nil {
        fmt.Fprintln(w, "## MCP Surface")
        fmt.Fprintln(w)
        fmt.Fprintf(w, "- **Transport:** %s\n", result.MCPSurface.Transport)
        fmt.Fprintf(w, "- **Tools:** %d\n", len(result.MCPSurface.Tools))
        fmt.Fprintf(w, "- **Resources:** %d\n", len(result.MCPSurface.Resources))
        fmt.Fprintf(w, "- **Prompts:** %d\n", len(result.MCPSurface.Prompts))
        fmt.Fprintln(w)

        if len(result.MCPSurface.Tools) > 0 {
            fmt.Fprintln(w, "### Detected Tools")
            fmt.Fprintln(w)
            for _, tool := range result.MCPSurface.Tools {
                fmt.Fprintf(w, "- **%s**", tool.Name)
                if tool.Description != "" {
                    fmt.Fprintf(w, ": %s", tool.Description)
                }
                fmt.Fprintln(w)
            }
            fmt.Fprintln(w)
        }
    }

    // Findings
    fmt.Fprintln(w, "## Findings")
    fmt.Fprintln(w)

    if len(result.Findings) == 0 {
        fmt.Fprintln(w, "No security findings found.")
        return nil
    }

    // Group by severity
    bySeverity := groupBySeverity(result.Findings)

    severityOrder := []string{"critical", "high", "medium", "low", "info"}
    for _, sev := range severityOrder {
        findings := bySeverity[sev]
        if len(findings) == 0 {
            continue
        }

        fmt.Fprintf(w, "### %s %s (%d)\n\n", severityEmoji(sev), strings.Title(sev), len(findings))

        for _, f := range findings {
            fmt.Fprintf(w, "#### %s - %s\n\n", f.RuleID, f.Title)
            fmt.Fprintf(w, "**Location:** `%s:%d`\n\n", f.Location.File, f.Location.StartLine)
            fmt.Fprintf(w, "%s\n\n", f.Description)

            if f.Evidence.Snippet != "" && !r.RedactSnippets {
                fmt.Fprintln(w, "```")
                fmt.Fprintln(w, f.Evidence.Snippet)
                fmt.Fprintln(w, "```")
                fmt.Fprintln(w)
            }

            if f.Remediation != "" {
                fmt.Fprintf(w, "**Remediation:** %s\n\n", f.Remediation)
            }

            fmt.Fprintln(w, "---")
            fmt.Fprintln(w)
        }
    }

    return nil
}

func severityEmoji(sev string) string {
    switch sev {
    case "critical":
        return "[CRIT]"
    case "high":
        return "[HIGH]"
    case "medium":
        return "[MED]"
    case "low":
        return "[LOW]"
    default:
        return "[INFO]"
    }
}

func groupBySeverity(findings []types.Finding) map[string][]types.Finding {
    groups := make(map[string][]types.Finding)
    for _, f := range findings {
        groups[string(f.Severity)] = append(groups[string(f.Severity)], f)
    }
    return groups
}

func main() {
    s := scanner.New(scanner.DefaultConfig())
    result, err := s.Scan(context.Background(), "./src")
    if err != nil {
        log.Fatal(err)
    }

    reporter := NewMarkdownReporter()

    f, _ := os.Create("SECURITY_REPORT.md")
    defer f.Close()
    reporter.Write(result, f)

    fmt.Println("Markdown report generated at SECURITY_REPORT.md")
}

XML Reporter (JUnit)

Useful for integration with CI/CD systems expecting JUnit format:

package main

import (
    "context"
    "encoding/xml"
    "fmt"
    "log"
    "os"

    "github.com/mcphub/mcp-scan/pkg/scanner"
)

type JUnitTestSuites struct {
    XMLName    xml.Name         `xml:"testsuites"`
    TestSuites []JUnitTestSuite `xml:"testsuite"`
}

type JUnitTestSuite struct {
    Name     string          `xml:"name,attr"`
    Tests    int             `xml:"tests,attr"`
    Failures int             `xml:"failures,attr"`
    Errors   int             `xml:"errors,attr"`
    Time     float64         `xml:"time,attr"`
    Cases    []JUnitTestCase `xml:"testcase"`
}

type JUnitTestCase struct {
    Name      string        `xml:"name,attr"`
    ClassName string        `xml:"classname,attr"`
    Time      float64       `xml:"time,attr"`
    Failure   *JUnitFailure `xml:"failure,omitempty"`
}

type JUnitFailure struct {
    Message string `xml:"message,attr"`
    Type    string `xml:"type,attr"`
    Content string `xml:",chardata"`
}

type JUnitReporter struct{}

func (r *JUnitReporter) Generate(result *scanner.Result) ([]byte, error) {
    suites := JUnitTestSuites{
        TestSuites: []JUnitTestSuite{
            {
                Name:     "MCP Security Scan",
                Tests:    len(result.Findings) + 1,  // +1 for compliance test
                Failures: len(result.Findings),
                Time:     result.ScanDuration.Seconds(),
            },
        },
    }

    suite := &suites.TestSuites[0]

    // Add compliance case
    complianceCase := JUnitTestCase{
        Name:      "MSSS Compliance",
        ClassName: "security.msss",
        Time:      0,
    }
    if result.MSSSScore.Level < 1 {
        complianceCase.Failure = &JUnitFailure{
            Message: fmt.Sprintf("MSSS Level %d < 1", result.MSSSScore.Level),
            Type:    "ComplianceFailure",
            Content: fmt.Sprintf("Score: %.1f, Level: %d",
                result.MSSSScore.Total, result.MSSSScore.Level),
        }
        suite.Failures++
    }
    suite.Cases = append(suite.Cases, complianceCase)

    // Add findings as test cases
    for _, f := range result.Findings {
        tc := JUnitTestCase{
            Name:      f.RuleID,
            ClassName: fmt.Sprintf("security.%s", f.Class),
            Time:      0,
            Failure: &JUnitFailure{
                Message: f.Title,
                Type:    string(f.Severity),
                Content: fmt.Sprintf("%s\nFile: %s:%d\n%s",
                    f.Description,
                    f.Location.File, f.Location.StartLine,
                    f.Remediation),
            },
        }
        suite.Cases = append(suite.Cases, tc)
    }

    return xml.MarshalIndent(suites, "", "  ")
}

func main() {
    s := scanner.New(scanner.DefaultConfig())
    result, err := s.Scan(context.Background(), "./src")
    if err != nil {
        log.Fatal(err)
    }

    reporter := &JUnitReporter{}
    output, err := reporter.Generate(result)
    if err != nil {
        log.Fatal(err)
    }

    os.WriteFile("junit-results.xml", output, 0644)
    fmt.Println("JUnit report generated at junit-results.xml")
}

Result Processors

You can create processors that transform or enrich scan results.

Enrichment Processor

package main

import (
    "context"
    "fmt"
    "log"
    "strings"

    "github.com/mcphub/mcp-scan/pkg/scanner"
    "github.com/mcphub/mcp-scan/internal/types"
)

// FindingEnricher enriches findings with additional information
type FindingEnricher struct {
    OwnershipMap map[string]string  // file pattern -> owner
    PriorityMap  map[string]int     // rule -> priority
}

func NewFindingEnricher() *FindingEnricher {
    return &FindingEnricher{
        OwnershipMap: map[string]string{
            "auth/":     "security-team@example.com",
            "payments/": "payments-team@example.com",
            "api/":      "backend-team@example.com",
        },
        PriorityMap: map[string]int{
            "MCP-A003": 1,  // RCE - highest priority
            "MCP-E001": 2,  // Secrets
            "MCP-C002": 3,  // SSRF
        },
    }
}

type EnrichedFinding struct {
    types.Finding
    Owner    string `json:"owner"`
    Priority int    `json:"priority"`
    Tags     []string `json:"tags"`
}

func (e *FindingEnricher) Enrich(findings []types.Finding) []EnrichedFinding {
    enriched := make([]EnrichedFinding, len(findings))

    for i, f := range findings {
        ef := EnrichedFinding{
            Finding:  f,
            Priority: 99, // Default
        }

        // Assign owner
        for pattern, owner := range e.OwnershipMap {
            if strings.Contains(f.Location.File, pattern) {
                ef.Owner = owner
                break
            }
        }

        // Assign priority
        if priority, ok := e.PriorityMap[f.RuleID]; ok {
            ef.Priority = priority
        }

        // Add tags
        ef.Tags = e.generateTags(f)

        enriched[i] = ef
    }

    return enriched
}

func (e *FindingEnricher) generateTags(f types.Finding) []string {
    var tags []string

    // Tag by severity
    tags = append(tags, "severity:"+string(f.Severity))

    // Tag by class
    tags = append(tags, "class:"+string(f.Class))

    // Tag if in MCP context
    if f.MCPContext != nil {
        tags = append(tags, "mcp-tool")
        if f.MCPContext.ToolName != "" {
            tags = append(tags, "tool:"+f.MCPContext.ToolName)
        }
    }

    // Tag if has taint trace
    if f.Trace != nil {
        tags = append(tags, "taint-flow")
    }

    return tags
}

func main() {
    s := scanner.New(scanner.DefaultConfig())
    result, err := s.Scan(context.Background(), "./src")
    if err != nil {
        log.Fatal(err)
    }

    enricher := NewFindingEnricher()
    enrichedFindings := enricher.Enrich(result.Findings)

    for _, ef := range enrichedFindings {
        fmt.Printf("[P%d] %s - %s\n", ef.Priority, ef.RuleID, ef.Title)
        fmt.Printf("  Owner: %s\n", ef.Owner)
        fmt.Printf("  Tags: %v\n", ef.Tags)
    }
}

Advanced Filtering Processor

package main

import (
    "context"
    "fmt"
    "log"
    "regexp"

    "github.com/mcphub/mcp-scan/pkg/scanner"
    "github.com/mcphub/mcp-scan/internal/types"
)

// FindingFilter allows advanced filtering of findings
type FindingFilter struct {
    MinSeverity     types.Severity
    MinConfidence   types.Confidence
    IncludeClasses  []types.VulnClass
    ExcludeClasses  []types.VulnClass
    IncludeRules    []string
    ExcludeRules    []string
    FilePatterns    []*regexp.Regexp
    ExcludePatterns []*regexp.Regexp
    OnlyMCPContext  bool
    OnlyTaintFlow   bool
}

func (f *FindingFilter) Filter(findings []types.Finding) []types.Finding {
    var filtered []types.Finding

    for _, finding := range findings {
        if f.shouldInclude(finding) {
            filtered = append(filtered, finding)
        }
    }

    return filtered
}

func (f *FindingFilter) shouldInclude(finding types.Finding) bool {
    // Filter by minimum severity
    if f.MinSeverity != "" && finding.Severity.Level() < f.MinSeverity.Level() {
        return false
    }

    // Filter by minimum confidence
    if f.MinConfidence != "" {
        confLevel := map[types.Confidence]int{
            types.ConfidenceLow:    1,
            types.ConfidenceMedium: 2,
            types.ConfidenceHigh:   3,
        }
        if confLevel[finding.Confidence] < confLevel[f.MinConfidence] {
            return false
        }
    }

    // Filter by included classes
    if len(f.IncludeClasses) > 0 {
        included := false
        for _, class := range f.IncludeClasses {
            if finding.Class == class {
                included = true
                break
            }
        }
        if !included {
            return false
        }
    }

    // Filter by excluded classes
    for _, class := range f.ExcludeClasses {
        if finding.Class == class {
            return false
        }
    }

    // Filter by excluded rules
    for _, rule := range f.ExcludeRules {
        if finding.RuleID == rule {
            return false
        }
    }

    // Filter by file patterns
    if len(f.FilePatterns) > 0 {
        matched := false
        for _, pattern := range f.FilePatterns {
            if pattern.MatchString(finding.Location.File) {
                matched = true
                break
            }
        }
        if !matched {
            return false
        }
    }

    // Filter by excluded patterns
    for _, pattern := range f.ExcludePatterns {
        if pattern.MatchString(finding.Location.File) {
            return false
        }
    }

    // Filter MCP context only
    if f.OnlyMCPContext && finding.MCPContext == nil {
        return false
    }

    // Filter taint flow only
    if f.OnlyTaintFlow && finding.Trace == nil {
        return false
    }

    return true
}

func main() {
    s := scanner.New(scanner.DefaultConfig())
    result, err := s.Scan(context.Background(), "./src")
    if err != nil {
        log.Fatal(err)
    }

    // Create filter: only critical/high in MCP tools
    filter := &FindingFilter{
        MinSeverity:    types.SeverityHigh,
        OnlyMCPContext: true,
        ExcludeRules:   []string{"MCP-E002"},  // Exclude specific rule
    }

    filtered := filter.Filter(result.Findings)

    fmt.Printf("Original: %d, Filtered: %d\n",
        len(result.Findings), len(filtered))

    for _, f := range filtered {
        fmt.Printf("[%s] %s in tool %s\n",
            f.Severity, f.RuleID, f.MCPContext.ToolName)
    }
}

Integration with External Systems

Export to Jira

package main

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "log"
    "net/http"
    "os"

    "github.com/mcphub/mcp-scan/pkg/scanner"
    "github.com/mcphub/mcp-scan/internal/types"
)

type JiraClient struct {
    BaseURL  string
    Username string
    Token    string
    Project  string
}

type JiraIssue struct {
    Fields JiraFields `json:"fields"`
}

type JiraFields struct {
    Project   JiraProject `json:"project"`
    Summary   string      `json:"summary"`
    Description string    `json:"description"`
    IssueType JiraType    `json:"issuetype"`
    Priority  JiraPriority `json:"priority,omitempty"`
    Labels    []string    `json:"labels,omitempty"`
}

type JiraProject struct {
    Key string `json:"key"`
}

type JiraType struct {
    Name string `json:"name"`
}

type JiraPriority struct {
    Name string `json:"name"`
}

func (c *JiraClient) CreateIssue(finding types.Finding) error {
    issue := JiraIssue{
        Fields: JiraFields{
            Project:   JiraProject{Key: c.Project},
            Summary:   fmt.Sprintf("[%s] %s - %s", finding.Severity, finding.RuleID, finding.Title),
            Description: c.formatDescription(finding),
            IssueType: JiraType{Name: "Bug"},
            Priority:  c.mapPriority(finding.Severity),
            Labels:    []string{"security", "mcp-scan", string(finding.Class)},
        },
    }

    payload, _ := json.Marshal(issue)

    req, err := http.NewRequest("POST", c.BaseURL+"/rest/api/2/issue", bytes.NewReader(payload))
    if err != nil {
        return err
    }

    req.SetBasicAuth(c.Username, c.Token)
    req.Header.Set("Content-Type", "application/json")

    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return err
    }
    defer resp.Body.Close()

    if resp.StatusCode >= 400 {
        return fmt.Errorf("jira API error: %s", resp.Status)
    }

    return nil
}

func (c *JiraClient) formatDescription(f types.Finding) string {
    desc := fmt.Sprintf(`
h2. Security Finding

*Rule:* %s
*Severity:* %s
*Confidence:* %s
*Class:* %s

h3. Description
%s

h3. Location
* File: %s
* Line: %d

`, f.RuleID, f.Severity, f.Confidence, f.Class, f.Description, f.Location.File, f.Location.StartLine)

    if f.Remediation != "" {
        desc += fmt.Sprintf(`
h3. Remediation
%s
`, f.Remediation)
    }

    if f.Evidence.Snippet != "" {
        desc += fmt.Sprintf(`
h3. Code
{code}
%s
{code}
`, f.Evidence.Snippet)
    }

    return desc
}

func (c *JiraClient) mapPriority(sev types.Severity) JiraPriority {
    priorities := map[types.Severity]string{
        types.SeverityCritical: "Highest",
        types.SeverityHigh:     "High",
        types.SeverityMedium:   "Medium",
        types.SeverityLow:      "Low",
        types.SeverityInfo:     "Lowest",
    }
    return JiraPriority{Name: priorities[sev]}
}

func main() {
    jira := &JiraClient{
        BaseURL:  os.Getenv("JIRA_URL"),
        Username: os.Getenv("JIRA_USERNAME"),
        Token:    os.Getenv("JIRA_TOKEN"),
        Project:  os.Getenv("JIRA_PROJECT"),
    }

    s := scanner.New(scanner.DefaultConfig())
    result, err := s.Scan(context.Background(), "./src")
    if err != nil {
        log.Fatal(err)
    }

    // Create issues only for critical and high
    for _, f := range result.Findings {
        if f.Severity == types.SeverityCritical || f.Severity == types.SeverityHigh {
            if err := jira.CreateIssue(f); err != nil {
                log.Printf("Error creating issue for %s: %v", f.RuleID, err)
            } else {
                fmt.Printf("Issue created for %s\n", f.RuleID)
            }
        }
    }
}

Generic Webhook

package main

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "log"
    "net/http"
    "os"
    "time"

    "github.com/mcphub/mcp-scan/pkg/scanner"
)

type WebhookPayload struct {
    EventType  string             `json:"event_type"`
    Timestamp  time.Time          `json:"timestamp"`
    Project    string             `json:"project"`
    Branch     string             `json:"branch"`
    Commit     string             `json:"commit"`
    Summary    WebhookSummary     `json:"summary"`
    Compliance WebhookCompliance  `json:"compliance"`
    Findings   []WebhookFinding   `json:"findings,omitempty"`
}

type WebhookSummary struct {
    TotalFiles    int            `json:"total_files"`
    TotalFindings int            `json:"total_findings"`
    BySeverity    map[string]int `json:"by_severity"`
    Duration      string         `json:"duration"`
}

type WebhookCompliance struct {
    Score     float64 `json:"score"`
    Level     int     `json:"level"`
    Compliant bool    `json:"compliant"`
}

type WebhookFinding struct {
    ID       string `json:"id"`
    Rule     string `json:"rule"`
    Severity string `json:"severity"`
    Title    string `json:"title"`
    File     string `json:"file"`
    Line     int    `json:"line"`
}

func sendWebhook(url string, result *scanner.Result) error {
    payload := WebhookPayload{
        EventType:  "scan_completed",
        Timestamp:  time.Now().UTC(),
        Project:    os.Getenv("PROJECT_NAME"),
        Branch:     os.Getenv("GIT_BRANCH"),
        Commit:     os.Getenv("GIT_COMMIT"),
        Summary: WebhookSummary{
            TotalFiles:    result.Manifest.TotalFiles,
            TotalFindings: len(result.Findings),
            BySeverity:    result.Summary.BySeverity,
            Duration:      result.ScanDuration.String(),
        },
        Compliance: WebhookCompliance{
            Score:     result.MSSSScore.Total,
            Level:     result.MSSSScore.Level,
            Compliant: result.MSSSScore.Compliant,
        },
    }

    // Include top 10 most severe findings
    for i, f := range result.Findings {
        if i >= 10 {
            break
        }
        payload.Findings = append(payload.Findings, WebhookFinding{
            ID:       f.ID,
            Rule:     f.RuleID,
            Severity: string(f.Severity),
            Title:    f.Title,
            File:     f.Location.File,
            Line:     f.Location.StartLine,
        })
    }

    data, _ := json.Marshal(payload)

    resp, err := http.Post(url, "application/json", bytes.NewReader(data))
    if err != nil {
        return err
    }
    defer resp.Body.Close()

    if resp.StatusCode >= 400 {
        return fmt.Errorf("webhook error: %s", resp.Status)
    }

    return nil
}

func main() {
    webhookURL := os.Getenv("WEBHOOK_URL")
    if webhookURL == "" {
        log.Fatal("WEBHOOK_URL not configured")
    }

    s := scanner.New(scanner.DefaultConfig())
    result, err := s.Scan(context.Background(), "./src")
    if err != nil {
        log.Fatal(err)
    }

    if err := sendWebhook(webhookURL, result); err != nil {
        log.Fatalf("Error sending webhook: %v", err)
    }

    fmt.Println("Webhook sent successfully")
}