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
287 lines
8.5 KiB
Go
287 lines
8.5 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/config"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// analyze kind=named
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// Named query bundles — reusable, named selections over the bundled
|
|
// structural-detector library. A bundle picks detectors by tag or by
|
|
// name; running it (`analyze kind=named name=<q>`) fans every selected
|
|
// detector across the codebase and aggregates the matches, exactly
|
|
// like analyze kind=sast does for a whole category.
|
|
//
|
|
// Ten bundles ship built-in (the common SAST classes); a project adds
|
|
// its own via the `.gortex.yaml::queries` block. With no name argument
|
|
// the tool lists every available bundle.
|
|
|
|
// SetNamedQueries installs the config-defined `queries:` bundles.
|
|
// Called by the server / daemon entrypoint right after NewServer.
|
|
func (s *Server) SetNamedQueries(q []config.NamedQuery) {
|
|
s.namedQueries = q
|
|
}
|
|
|
|
// builtinNamedQueries are the shipped bundles — curated tag
|
|
// selections over the bundled SAST / hygiene detector catalog.
|
|
func builtinNamedQueries() []config.NamedQuery {
|
|
return []config.NamedQuery{
|
|
{Name: "sql-injection", Description: "SQL injection — tainted input reaching a query", Tags: []string{"sqli", "injection"}},
|
|
{Name: "command-injection", Description: "OS-command and dynamic-code injection", Tags: []string{"command-injection", "code-injection"}},
|
|
{Name: "hardcoded-secrets", Description: "Hardcoded credentials, API keys, and tokens", Tags: []string{"secrets", "credentials"}},
|
|
{Name: "weak-crypto", Description: "Weak hashes, ciphers, and transport protocols", Tags: []string{"crypto", "weak-hash", "weak-cipher", "weak-protocol"}},
|
|
{Name: "xss", Description: "Cross-site scripting sinks", Tags: []string{"xss"}},
|
|
{Name: "unsafe-deserialization", Description: "Unsafe deserialization of untrusted data", Tags: []string{"deserialization"}},
|
|
{Name: "path-traversal", Description: "Path traversal and unsanitised file access", Tags: []string{"path-traversal"}},
|
|
{Name: "ssrf", Description: "Server-side request forgery", Tags: []string{"ssrf"}},
|
|
{Name: "xxe", Description: "XML external-entity processing", Tags: []string{"xxe", "xml"}},
|
|
{Name: "debug-leftovers", Description: "Debug code left on production paths", Tags: []string{"debug-leftover", "debug"}},
|
|
}
|
|
}
|
|
|
|
// resolvedNamedQueries merges the built-in bundles with the
|
|
// config-defined ones; a config bundle overrides a built-in of the
|
|
// same name.
|
|
func (s *Server) resolvedNamedQueries() map[string]config.NamedQuery {
|
|
out := make(map[string]config.NamedQuery)
|
|
for _, q := range builtinNamedQueries() {
|
|
out[q.Name] = q
|
|
}
|
|
for _, q := range s.namedQueries {
|
|
if name := strings.TrimSpace(q.Name); name != "" {
|
|
out[name] = q
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *Server) handleAnalyzeNamed(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
args := req.GetArguments()
|
|
name := strings.TrimSpace(stringArg(args, "name"))
|
|
queries := s.resolvedNamedQueries()
|
|
|
|
if name == "" {
|
|
type queryInfo struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
Detectors int `json:"detectors"`
|
|
}
|
|
infos := make([]queryInfo, 0, len(queries))
|
|
for _, q := range queries {
|
|
infos = append(infos, queryInfo{
|
|
Name: q.Name,
|
|
Description: q.Description,
|
|
Detectors: len(selectBundleDetectors(q)),
|
|
})
|
|
}
|
|
sort.Slice(infos, func(i, j int) bool { return infos[i].Name < infos[j].Name })
|
|
return s.respondJSONOrTOON(ctx, req, map[string]any{
|
|
"queries": infos,
|
|
"total": len(infos),
|
|
})
|
|
}
|
|
|
|
q, ok := queries[name]
|
|
if !ok {
|
|
avail := make([]string, 0, len(queries))
|
|
for n := range queries {
|
|
avail = append(avail, n)
|
|
}
|
|
sort.Strings(avail)
|
|
return mcp.NewToolResultError(fmt.Sprintf(
|
|
"unknown named query %q (available: %s)", name, strings.Join(avail, ", "))), nil
|
|
}
|
|
|
|
detectors := selectBundleDetectors(q)
|
|
if len(detectors) == 0 {
|
|
return mcp.NewToolResultError(fmt.Sprintf(
|
|
"named query %q selects no detectors — check its tags / detectors", name)), nil
|
|
}
|
|
|
|
languageFilter := parseCSVSet(stringArg(args, "language"))
|
|
severityFilter := parseCSVSet(stringArg(args, "severity"))
|
|
pathPrefix := strings.TrimSpace(stringArg(args, "path_prefix"))
|
|
limit := intArg(args, "limit", 200)
|
|
excludeTests, excludeTestsSet := boolArg(args, "exclude_tests")
|
|
|
|
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)
|
|
}
|
|
|
|
rows := make([]sastRow, 0, 64)
|
|
summary := make(map[string]*sastSummary, len(detectors))
|
|
var errsAcc []string
|
|
for _, d := range detectors {
|
|
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...)
|
|
entry := summary[d.Name]
|
|
if entry == nil {
|
|
entry = &sastSummary{Detector: d.Name, Severity: d.Severity, CWE: d.CWE}
|
|
summary[d.Name] = entry
|
|
}
|
|
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
|
|
}
|
|
}
|
|
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++
|
|
}
|
|
}
|
|
|
|
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 _, e := range summary {
|
|
if e.Count > 0 {
|
|
summaries = append(summaries, *e)
|
|
}
|
|
}
|
|
sort.Slice(summaries, func(i, j int) bool {
|
|
if summaries[i].Count != summaries[j].Count {
|
|
return summaries[i].Count > summaries[j].Count
|
|
}
|
|
return summaries[i].Detector < summaries[j].Detector
|
|
})
|
|
|
|
resp := map[string]any{
|
|
"query": name,
|
|
"summary": summaries,
|
|
"matches": rows,
|
|
"total": totalRows,
|
|
"truncated": truncated,
|
|
"detectors": len(detectors),
|
|
}
|
|
if truncated {
|
|
resp["limit"] = limit
|
|
}
|
|
if len(errsAcc) > 0 {
|
|
resp["errors"] = errsAcc
|
|
}
|
|
return s.respondJSONOrTOON(ctx, req, resp)
|
|
}
|
|
|
|
// selectBundleDetectors resolves a named query to the detector set it
|
|
// runs — every detector carrying one of its Tags or named in
|
|
// Detectors, optionally floored by Severity.
|
|
func selectBundleDetectors(q config.NamedQuery) []*astquery.Detector {
|
|
tagSet := make(map[string]bool)
|
|
for _, t := range q.Tags {
|
|
if t = strings.ToLower(strings.TrimSpace(t)); t != "" {
|
|
tagSet[t] = true
|
|
}
|
|
}
|
|
nameSet := make(map[string]bool)
|
|
for _, n := range q.Detectors {
|
|
if n = strings.TrimSpace(n); n != "" {
|
|
nameSet[n] = true
|
|
}
|
|
}
|
|
floor := 0
|
|
if strings.TrimSpace(q.Severity) != "" {
|
|
floor = severityRank(q.Severity)
|
|
}
|
|
|
|
var out []*astquery.Detector
|
|
for _, dn := range astquery.ListDetectors() {
|
|
d := astquery.LookupDetector(dn)
|
|
if d == nil {
|
|
continue
|
|
}
|
|
matched := nameSet[d.Name]
|
|
if !matched {
|
|
for _, t := range d.Tags {
|
|
if tagSet[strings.ToLower(t)] {
|
|
matched = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if !matched {
|
|
continue
|
|
}
|
|
if floor > 0 && severityRank(d.Severity) < floor {
|
|
continue
|
|
}
|
|
out = append(out, d)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
|
return out
|
|
}
|