Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ name: Checks

on:
push:
branches: [main, v1]
branches: ["main", "v1", "mcp"]
Comment thread
cuixq marked this conversation as resolved.
pull_request:
# The branches below must be a subset of the branches above
branches: [main, v1]
branches: ["main", "v1", "mcp"]
workflow_dispatch:

concurrency:
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/osv-scanner-unified-action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@ name: OSV-Scanner Scheduled Scan

on:
pull_request:
branches: ["main", "v1"]
branches: ["main", "v1", "mcp"]
schedule:
- cron: "12 12 * * 1"
push:
branches: ["main", "v1"]
branches: ["main", "v1", "mcp"]

# Restrict jobs in this workflow to have no permissions by default; permissions
# should be granted per job as needed using a dedicated `permissions` block
Expand Down
2 changes: 2 additions & 0 deletions cmd/osv-scanner/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (

"github.com/google/osv-scanner/v2/cmd/osv-scanner/fix"
"github.com/google/osv-scanner/v2/cmd/osv-scanner/internal/cmd"
"github.com/google/osv-scanner/v2/cmd/osv-scanner/mcp"
"github.com/google/osv-scanner/v2/cmd/osv-scanner/scan"
"github.com/google/osv-scanner/v2/cmd/osv-scanner/update"
)
Expand All @@ -15,6 +16,7 @@ func main() {
scan.Command,
fix.Command,
update.Command,
mcp.Command,
}),
)
}
222 changes: 222 additions & 0 deletions cmd/osv-scanner/mcp/command.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
// Package mcp implements the `mcp` command for osv-scanner.
package mcp

import (
"context"
_ "embed"
"errors"
"fmt"
"io"
"strings"
"sync"
"time"

"net/http"

"github.com/google/osv-scanner/v2/internal/cmdlogger"
"github.com/google/osv-scanner/v2/internal/output"
"github.com/google/osv-scanner/v2/internal/version"
"github.com/google/osv-scanner/v2/pkg/osvscanner"
"github.com/jedib0t/go-pretty/v6/text"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/ossf/osv-schema/bindings/go/osvschema"
"github.com/urfave/cli/v3"
"osv.dev/bindings/go/osvdev"
)

// vulnCacheMap is a cache of vulnerability details that have been retrieved from the OSV API during normal scanning.
// This avoids unnecessary double queries to the osv.dev API.
// vulnCacheMap: map[string]*osvschema.Vulnerability
var vulnCacheMap = sync.Map{}
Comment thread
another-rex marked this conversation as resolved.

// Command is the entry point for the `mcp` subcommand.
func Command(_, _ io.Writer) *cli.Command {
return &cli.Command{
Name: "experimental-mcp",
Usage: "Run osv-scanner as an MCP service (experimental)",
Description: "Run osv-scanner as an MCP service, speaking the MCP protocol over stdin/stdout.",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "sse",
DefaultText: "localhost:8080",
Value: "localhost:8080",
Usage: "The listening address for the SSE server, e.g. localhost:8080",
},
},
Action: action,
}
}

// scanVulnerableDependenciesInput is the input for the scan_vulnerable_dependencies tool.
type scanVulnerableDependenciesInput struct {
Paths []string `json:"paths" jsonschema:"A list of absolute or relative path to a file or directory to scan."`
IgnoreGlobPatterns []string `json:"ignore_glob_patterns" jsonschema:"A list of glob patterns to ignore when scanning."`
Recursive bool `json:"recursive" jsonschema:"Scans directory recursively"`
}

func action(ctx context.Context, cmd *cli.Command) error {
s := mcp.NewServer(&mcp.Implementation{
Name: "OSV-Scanner", Version: version.OSVVersion,
}, nil)

mcp.AddTool(s, &mcp.Tool{
Name: "scan_vulnerable_dependencies",
Description: "Scans a source directory for vulnerable dependencies." +
" Walks the given directory and uses osv.dev to query for vulnerabilities matching the found dependencies." +
" Use this tool to check that the user's project is not depending on known vulnerable code.",
}, handleScan)

// TODO(another-rex): Ideally both of the following tools would be resources, but gemini-cli does not support those yet.
mcp.AddTool(s, &mcp.Tool{
Name: "get_vulnerability_details",
Description: "Retrieves the full JSON details for a given vulnerability ID.",
}, handleVulnIDRetrieval)

mcp.AddTool(s, &mcp.Tool{
Name: "ignore_vulnerability",
Description: "Provides instructions for writing a config file to exclude vulnerabilities from the scan report.",
}, handleIgnoreVulnerability)

s.AddPrompt(&mcp.Prompt{
Name: "scan_deps",
Description: "Scans your project dependencies for known vulnerabilities.",
}, handleScanDepsPrompt)

// Provide two options, sse on a network port, or stdio.
if cmd.IsSet("sse") {
sseAddr := cmd.String("sse")
cmdlogger.Infof("Starting SSE server on %s", sseAddr)
handler := mcp.NewSSEHandler(func(_ *http.Request) *mcp.Server {
return s
}, nil)
srv := &http.Server{
Addr: sseAddr,
Handler: handler,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
}
if err := srv.ListenAndServe(); err != nil {
cmdlogger.Errorf("mcp error: %s", err)
return err
}
} else {
cmdlogger.SendEverythingToStderr()
cmdlogger.Infof("Starting MCP server on stdio")
if err := s.Run(ctx, &mcp.StdioTransport{}); err != nil {
cmdlogger.Errorf("mcp error: %s", err)
return err
}
}

return nil
}

func handleScan(_ context.Context, _ *mcp.CallToolRequest, input *scanVulnerableDependenciesInput) (*mcp.CallToolResult, any, error) {
statsCollector := fileOpenedLogger{}

action := osvscanner.ScannerActions{
DirectoryPaths: input.Paths,
ScanLicensesSummary: false,
ExperimentalScannerActions: osvscanner.ExperimentalScannerActions{
StatsCollector: &statsCollector,
},
CallAnalysisStates: map[string]bool{
"go": true,
},
Recursive: input.Recursive,
}

//nolint:contextcheck // passing the context in would be a breaking change
scanResults, err := osvscanner.DoScan(action)
Comment thread
another-rex marked this conversation as resolved.
if err != nil && !errors.Is(err, osvscanner.ErrVulnerabilitiesFound) {
return nil, nil, fmt.Errorf("failed to run scanner: %w", err)
}

for _, vuln := range scanResults.Flatten() {
vulnCacheMap.Store(vuln.Vulnerability.ID, &vuln.Vulnerability)
}

if err == nil {
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: "No issues found"},
},
}, nil, nil
}

buf := strings.Builder{}

for _, s := range statsCollector.collectedLines {
buf.WriteString(s + "\n")
}

text.DisableColors()
output.PrintVerticalResults(&scanResults, &buf, false)

return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: buf.String()},
},
}, nil, nil
}

// getVulnerabilityDetailsInput is the input for the get_vulnerability_details tool.
type getVulnerabilityDetailsInput struct {
VulnID string `json:"vuln_id" jsonschema:"The OSV vulnerability ID to retrieve details for."`
}

func handleVulnIDRetrieval(ctx context.Context, _ *mcp.CallToolRequest, input *getVulnerabilityDetailsInput) (*mcp.CallToolResult, *osvschema.Vulnerability, error) {
vulnAny, found := vulnCacheMap.Load(input.VulnID)
vuln := vulnAny.(*osvschema.Vulnerability)
if !found {
var err error
vuln, err = osvdev.DefaultClient().GetVulnByID(ctx, input.VulnID)
if err != nil {
return nil, nil, fmt.Errorf("vulnerability with ID %s not found: %w", input.VulnID, err)
}

vulnCacheMap.Store(input.VulnID, vuln)
}

return &mcp.CallToolResult{}, vuln, nil
}

// ignoreVulnerabilityInput is a placeholder to enable the tool call,
// as it seems like go-sdk mcp does not support a tool call with no arguments.
type ignoreVulnerabilityInput struct {
// Extra field is needed as a placeholder to prevent the llm from erroring when calling the tool
Verbose bool `json:"verbose" jsonschema:"ignore this parameter"`
Comment thread
another-rex marked this conversation as resolved.
}

//go:embed configuration-instructions.md
var configInstructions string

// handleIgnoreVulnerability does not perform any actual actions, but instead provides the instructions of how
// to write an ignore file to the LLM using this tool, so that it can correctly write the ignore file.
func handleIgnoreVulnerability(_ context.Context, _ *mcp.CallToolRequest, _ *ignoreVulnerabilityInput) (*mcp.CallToolResult, any, error) {
Comment thread
another-rex marked this conversation as resolved.
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: configInstructions},
},
}, nil, nil
}

// scanDepsPrompt is the prompt that is sent to the AI model when the scan_deps prompt is requested.
//
//go:embed scan-deps-prompt.md
var scanDepsPrompt string

func handleScanDepsPrompt(_ context.Context, _ *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) {
return &mcp.GetPromptResult{
Description: "Dependency vulnerability analysis",
Messages: []*mcp.PromptMessage{
{
Role: "assistant",
Content: &mcp.TextContent{
Text: scanDepsPrompt,
},
},
},
}, nil
}
43 changes: 43 additions & 0 deletions cmd/osv-scanner/mcp/configuration-instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
layout: page
permalink: /configuration/
nav_order: 5
---

# Configuration

To configure scanning, place an osv-scanner.toml file in the scanned file's directory. This does not propagate to child directories.

**Example:**

```
/Cargo.lock
/osv-scanner.toml (1)
/child-dir/go.mod
/child-dir/osv-scanner.toml (2)
/child-dir/nested-dir/package-lock.json
```

`osv-scanner.toml (1)` will only apply to `Cargo.lock`, `osv-scanner.toml (2)` will only apply to `go.mod`, and no config will apply to `package-lock.json`.

To override `osv-scanner.toml` files, pass the `--config=/path/to/config.toml` flag with the path to the configuration you want to apply instead, this will apply `config.toml` to all files parsed, and ignore `osv-scanner.toml` in all directories.

## Ignore vulnerabilities by ID

To ignore a vulnerability, enter the ID under the `IgnoreVulns` key. Optionally, add an expiry date or reason.

### Example

```toml
[[IgnoredVulns]]
id = "GO-2022-0968"
# ignoreUntil = 2022-11-09 # Optional exception expiry date
reason = "No ssh servers are connected to or hosted in Go lang"

[[IgnoredVulns]]
id = "GO-2022-1059"
# ignoreUntil = 2022-11-09 # Optional exception expiry date
reason = "No external http servers are written in Go lang."
```

Ignoring a vulnerability will also ignore vulnerabilities that are considered aliases of that vulnerability.
19 changes: 19 additions & 0 deletions cmd/osv-scanner/mcp/scan-deps-prompt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
You are a highly skilled senior security analyst.
Your primary task is to conduct a security audit of the vulnerabilities in the dependencies of this project.
Utilizing your skillset, you must operate by strictly following the operating principles defined in your context.

**Step 1: Perform initial scan**

Use the scan_vulnerable_dependencies with recursive on the project, always use the absolute path.
This will return a report of all the relevant lockfiles and all vulnerable dependencies in those files.

**Step 2: Analyse the report**

Go through the report and determine the relevant project lockfiles (ignoring lockfiles in test directories),
and prioritise which vulnerability to fix based on the description and severity.
If more information is needed about a vulnerability, use get_vulnerability_details.

**Step 3: Prioritisation**

Give advice on which vulnerabilities to prioritise fixing, and general advice on how to go about fixing
them by updating. Don't try to automatically update for the user without input.
33 changes: 33 additions & 0 deletions cmd/osv-scanner/mcp/stats.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package mcp

import (
"fmt"
"path/filepath"

"github.com/google/osv-scalibr/stats"
"github.com/google/osv-scanner/v2/internal/output"
)

type fileOpenedLogger struct {
stats.NoopCollector

collectedLines []string
}

var _ stats.Collector = &fileOpenedLogger{}

func (c *fileOpenedLogger) AfterExtractorRun(_ string, extractorstats *stats.AfterExtractorStats) {
if extractorstats.Error != nil { // Don't log scanned if error occurred
return
}

pkgsFound := len(extractorstats.Inventory.Packages)

c.collectedLines = append(c.collectedLines,
fmt.Sprintf(
"Scanned %s file and found %d %s",
filepath.Join(extractorstats.Root, extractorstats.Path),
pkgsFound,
output.Form(pkgsFound, "package", "packages"),
))
}
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ require (
github.com/google/osv-scalibr v0.3.7-0.20251023161426-90e9ac9cc1b3
github.com/ianlancetaylor/demangle v0.0.0-20250628045327-2d64ad6b7ec5
github.com/jedib0t/go-pretty/v6 v6.6.8
github.com/modelcontextprotocol/go-sdk v1.0.0
github.com/muesli/reflow v0.3.0
github.com/opencontainers/go-digest v1.0.0
github.com/ossf/osv-schema/bindings/go v0.0.0-20251012234424-434020c6442f
Expand Down Expand Up @@ -116,6 +117,7 @@ require (
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/go-containerregistry v0.20.6 // indirect
github.com/google/jsonschema-go v0.3.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
Expand Down Expand Up @@ -183,6 +185,7 @@ require (
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
github.com/xhit/go-str2duration/v2 v2.1.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
github.com/yuin/goldmark v1.7.12 // indirect
github.com/yuin/goldmark-emoji v1.0.6 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
Expand Down
Loading