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")
}