Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

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