a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
314 lines
8.7 KiB
Go
314 lines
8.7 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
|
|
mcp "github.com/mark3labs/mcp-go/mcp"
|
|
|
|
"github.com/zzet/gortex/internal/astquery"
|
|
"github.com/zzet/gortex/internal/review"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// analyze kind=sast / analyze kind=hygiene
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// Bandit-parity SAST surface. Fan every detector whose Category is
|
|
// "sast" (resp. "hygiene") out across the indexed file set, aggregate
|
|
// rows by detector + severity + CWE, return a structured payload
|
|
// suitable for SARIF / DefectDojo / GitHub Code Scanning consumers.
|
|
//
|
|
// Filters mirror the unsafe_patterns surface (language / detector /
|
|
// severity / path_prefix / limit / exclude_tests) plus three new
|
|
// ones that only make sense for the larger SAST catalog:
|
|
//
|
|
// - cwe — comma-separated subset (e.g. "CWE-78,CWE-89").
|
|
// - tag — comma-separated subset against Detector.Tags
|
|
// (e.g. "injection,xss"). Lets the agent run "every
|
|
// injection-class rule" without enumerating names.
|
|
// - kinds_only — when true, return only the per-category breakdown
|
|
// (rows omitted). Useful for the "what's my SAST
|
|
// surface look like" snapshot without paying row bytes.
|
|
|
|
type sastRow struct {
|
|
Detector string `json:"detector"`
|
|
Severity string `json:"severity"`
|
|
Category string `json:"category"`
|
|
CWE string `json:"cwe,omitempty"`
|
|
OWASP string `json:"owasp,omitempty"`
|
|
Tags []string `json:"tags,omitempty"`
|
|
Language string `json:"language"`
|
|
File string `json:"file"`
|
|
Line int `json:"line"`
|
|
Symbol string `json:"symbol,omitempty"`
|
|
Text string `json:"text,omitempty"`
|
|
}
|
|
|
|
type sastSummary struct {
|
|
Detector string `json:"detector"`
|
|
Severity string `json:"severity"`
|
|
CWE string `json:"cwe,omitempty"`
|
|
Count int `json:"count"`
|
|
}
|
|
|
|
type sastCWEBucket struct {
|
|
CWE string `json:"cwe"`
|
|
Count int `json:"count"`
|
|
}
|
|
|
|
// handleAnalyzeSAST runs the category bundle (sast or hygiene).
|
|
func (s *Server) handleAnalyzeSAST(ctx context.Context, req mcp.CallToolRequest, kind string) (*mcp.CallToolResult, error) {
|
|
args := req.GetArguments()
|
|
languageFilter := parseCSVSet(stringArg(args, "language"))
|
|
detectorFilter := parseCSVSet(stringArg(args, "detector"))
|
|
severityFilter := parseCSVSet(stringArg(args, "severity"))
|
|
cweFilter := parseCSVSet(stringArg(args, "cwe"))
|
|
tagFilter := parseCSVSet(stringArg(args, "tag"))
|
|
pathPrefix := strings.TrimSpace(stringArg(args, "path_prefix"))
|
|
limit := intArg(args, "limit", 200)
|
|
excludeTests, excludeTestsSet := boolArg(args, "exclude_tests")
|
|
kindsOnly := false
|
|
if v, ok := args["kinds_only"].(bool); ok {
|
|
kindsOnly = v
|
|
}
|
|
|
|
allowedRepos, err := s.resolveRepoFilter(ctx, req)
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
targets, err := s.buildASTTargets("", pathPrefix, allowedRepos)
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
fileSymbols := s.buildFileSymbolIndex(targets)
|
|
lookup := func(graphPath string, line int) (string, string) {
|
|
idx := fileSymbols[graphPath]
|
|
if idx == nil {
|
|
return "", ""
|
|
}
|
|
return idx.find(line)
|
|
}
|
|
|
|
bundle := astquery.DetectorsByCategory(kind)
|
|
if len(bundle) == 0 {
|
|
return mcp.NewToolResultError(fmt.Sprintf("analyze %s: no detectors registered for category %q", kind, kind)), nil
|
|
}
|
|
|
|
// Reject unknown detector names early — same UX as unsafe_patterns.
|
|
if len(detectorFilter) > 0 {
|
|
known := make(map[string]struct{}, len(bundle))
|
|
for _, d := range bundle {
|
|
known[d.Name] = struct{}{}
|
|
}
|
|
var unknown []string
|
|
for n := range detectorFilter {
|
|
if _, ok := known[n]; !ok {
|
|
unknown = append(unknown, n)
|
|
}
|
|
}
|
|
if len(unknown) > 0 {
|
|
sort.Strings(unknown)
|
|
return mcp.NewToolResultError(fmt.Sprintf(
|
|
"analyze %s: unknown detector(s) %s for this category",
|
|
kind, strings.Join(unknown, ","),
|
|
)), nil
|
|
}
|
|
}
|
|
|
|
rows := make([]sastRow, 0, 64)
|
|
summary := make(map[string]*sastSummary, len(bundle))
|
|
cweBuckets := make(map[string]*sastCWEBucket, len(bundle))
|
|
var errsAcc []string
|
|
|
|
// detMeta carries per-detector taxonomy (category / CWE / OWASP /
|
|
// tags) keyed by name so row + rollup metadata survives the
|
|
// graph-grounding post-pass, which works on the flat Match list.
|
|
detMeta := make(map[string]*astquery.Detector, len(bundle))
|
|
var collected []astquery.Match
|
|
|
|
for _, d := range bundle {
|
|
if len(detectorFilter) > 0 {
|
|
if _, ok := detectorFilter[d.Name]; !ok {
|
|
continue
|
|
}
|
|
}
|
|
if len(cweFilter) > 0 {
|
|
if _, ok := cweFilter[strings.ToLower(d.CWE)]; !ok {
|
|
continue
|
|
}
|
|
}
|
|
if len(tagFilter) > 0 {
|
|
has := false
|
|
for _, t := range d.Tags {
|
|
if _, ok := tagFilter[strings.ToLower(t)]; ok {
|
|
has = true
|
|
break
|
|
}
|
|
}
|
|
if !has {
|
|
continue
|
|
}
|
|
}
|
|
|
|
detMeta[d.Name] = d
|
|
|
|
opts := astquery.Options{
|
|
Detector: d.Name,
|
|
Targets: targets,
|
|
SymbolLookup: lookup,
|
|
Resolver: astquery.DefaultLanguageResolver,
|
|
Limit: 5000,
|
|
}
|
|
if excludeTestsSet {
|
|
opts.ExcludeTests = excludeTests
|
|
} else {
|
|
opts.ExcludeTests = true
|
|
}
|
|
|
|
res, runErr := astquery.Run(ctx, opts)
|
|
if runErr != nil {
|
|
errsAcc = append(errsAcc, fmt.Sprintf("%s: %v", d.Name, runErr))
|
|
continue
|
|
}
|
|
errsAcc = append(errsAcc, res.Errors...)
|
|
|
|
// Ensure a summary entry exists even when grounding later
|
|
// removes every row for this detector (Count stays 0 and the
|
|
// entry is dropped in the rollup pass).
|
|
if summary[d.Name] == nil {
|
|
summary[d.Name] = &sastSummary{Detector: d.Name, Severity: d.Severity, CWE: d.CWE}
|
|
}
|
|
|
|
for _, m := range res.Matches {
|
|
if len(languageFilter) > 0 {
|
|
if _, ok := languageFilter[strings.ToLower(m.Language)]; !ok {
|
|
continue
|
|
}
|
|
}
|
|
if len(severityFilter) > 0 {
|
|
if _, ok := severityFilter[strings.ToLower(m.Severity)]; !ok {
|
|
continue
|
|
}
|
|
}
|
|
collected = append(collected, m)
|
|
}
|
|
}
|
|
|
|
// Graph-grounding post-pass — the load-bearing FP-reduction step.
|
|
// The review detectors emit undecidable-from-AST-alone rows (N+1
|
|
// query-in-loop, check-then-act) optimistically; here, one layer
|
|
// up from the engine where s.graph is reachable, we drop the rows
|
|
// the resolved call / loop metadata refutes. Only the review
|
|
// bundle is grounded; sast / hygiene / domain pass through.
|
|
if kind == "review" {
|
|
collected = review.GroundReviewMatches(s.graph, collected)
|
|
}
|
|
|
|
for _, m := range collected {
|
|
d := detMeta[m.Detector]
|
|
if d == nil {
|
|
continue
|
|
}
|
|
entry := summary[d.Name]
|
|
if entry == nil {
|
|
entry = &sastSummary{Detector: d.Name, Severity: d.Severity, CWE: d.CWE}
|
|
summary[d.Name] = entry
|
|
}
|
|
rows = append(rows, sastRow{
|
|
Detector: m.Detector,
|
|
Severity: m.Severity,
|
|
Category: d.Category,
|
|
CWE: d.CWE,
|
|
OWASP: d.OWASP,
|
|
Tags: append([]string(nil), d.Tags...),
|
|
Language: m.Language,
|
|
File: m.File,
|
|
Line: m.Line,
|
|
Symbol: m.SymbolID,
|
|
Text: m.Text,
|
|
})
|
|
entry.Count++
|
|
if d.CWE != "" {
|
|
b := cweBuckets[d.CWE]
|
|
if b == nil {
|
|
b = &sastCWEBucket{CWE: d.CWE}
|
|
cweBuckets[d.CWE] = b
|
|
}
|
|
b.Count++
|
|
}
|
|
}
|
|
|
|
sort.Slice(rows, func(i, j int) bool {
|
|
ri, rj := severityRank(rows[i].Severity), severityRank(rows[j].Severity)
|
|
if ri != rj {
|
|
return ri > rj
|
|
}
|
|
if rows[i].Detector != rows[j].Detector {
|
|
return rows[i].Detector < rows[j].Detector
|
|
}
|
|
if rows[i].File != rows[j].File {
|
|
return rows[i].File < rows[j].File
|
|
}
|
|
return rows[i].Line < rows[j].Line
|
|
})
|
|
|
|
totalRows := len(rows)
|
|
truncated := false
|
|
if limit > 0 && len(rows) > limit {
|
|
rows = rows[:limit]
|
|
truncated = true
|
|
}
|
|
|
|
summaries := make([]sastSummary, 0, len(summary))
|
|
for _, entry := range summary {
|
|
if entry.Count == 0 {
|
|
continue
|
|
}
|
|
summaries = append(summaries, *entry)
|
|
}
|
|
sort.Slice(summaries, func(i, j int) bool {
|
|
ri, rj := severityRank(summaries[i].Severity), severityRank(summaries[j].Severity)
|
|
if ri != rj {
|
|
return ri > rj
|
|
}
|
|
if summaries[i].Count != summaries[j].Count {
|
|
return summaries[i].Count > summaries[j].Count
|
|
}
|
|
return summaries[i].Detector < summaries[j].Detector
|
|
})
|
|
|
|
cweRollup := make([]sastCWEBucket, 0, len(cweBuckets))
|
|
for _, b := range cweBuckets {
|
|
cweRollup = append(cweRollup, *b)
|
|
}
|
|
sort.Slice(cweRollup, func(i, j int) bool {
|
|
if cweRollup[i].Count != cweRollup[j].Count {
|
|
return cweRollup[i].Count > cweRollup[j].Count
|
|
}
|
|
return cweRollup[i].CWE < cweRollup[j].CWE
|
|
})
|
|
|
|
resp := map[string]any{
|
|
"summary": summaries,
|
|
"cwes": cweRollup,
|
|
"total": totalRows,
|
|
"truncated": truncated,
|
|
"category": kind,
|
|
}
|
|
if !kindsOnly {
|
|
resp["matches"] = rows
|
|
}
|
|
if truncated {
|
|
resp["limit"] = limit
|
|
}
|
|
if len(errsAcc) > 0 {
|
|
resp["errors"] = errsAcc
|
|
}
|
|
return s.respondJSONOrTOON(ctx, req, resp)
|
|
}
|