llm

package
v0.8.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 7, 2026 License: MIT Imports: 17 Imported by: 0

Documentation

Overview

Package llm asks a chat model two questions about a CVE that the deterministic analysis has already decided is genuinely present: whether it is plausibly exploitable in context (Assess), and which checkable identifiers the advisory text names (Mine).

Both are optional and advisory. Assess never sets a status -- it attaches an opinion to a finding that already has one. Mine produces nothing but candidate strings, and the caller must validate them against something it can observe before any of them supports a conclusion; an unvalidatable hint is indistinguishable from a hallucination, so validation is what makes a wrong answer inert rather than dangerous. That validation deliberately lives with the caller that has the facts to do it, not here.

Which model answers is the user's choice, via Config: an OpenAI-compatible endpoint, a local server speaking the same format, or a CLI already installed on the machine. Nothing here is tied to a provider, and nothing needs to be -- because no answer from any of them can set a status, the choice trades rationale quality and cost, not correctness.

Index

Constants

View Source
const DefaultMinInterval = 0

DefaultMinInterval is the default minimum spacing between requests: none.

This was once a second, to stay under GitHub Models' burst limit. A general endpoint has no such limit, and the ones that do rate-limit say so with a 429 and a Retry-After that the retry loop already honors. Slowing every scan down for a provider the user may not be using is the wrong default. Override with VEXSCAN_LLM_MIN_INTERVAL (a Go duration such as "2s").

View Source
const DefaultModel = "gpt-4o"

DefaultModel is the OpenAI-compatible model id used when none is given. It is a bare name rather than a provider-qualified one because that is what every endpoint except a router expects; OpenRouter and friends want "vendor/model" and the user supplies it.

Variables

View Source
var ErrNoProvider = errors.New(`no LLM provider configured, and there is no default (GitHub Models, which used to be one, has been retired). Choose one:

  an OpenAI-compatible endpoint
    export VEXSCAN_LLM_ENDPOINT=https://api.openai.com/v1/chat/completions
    export VEXSCAN_LLM_TOKEN=sk-...            # or set OPENAI_API_KEY

  a model on this machine, via Ollama
    export VEXSCAN_LLM_ENDPOINT=http://localhost:11434/v1/chat/completions
    export VEXSCAN_LLM_MODEL=llama3.1          # no token needed

  a CLI already installed and logged in
    export VEXSCAN_LLM_COMMAND='claude -p'

or pass --llm-endpoint / --llm-command`)

ErrNoProvider is returned when --llm is on and nothing says who to ask.

It is a paragraph rather than a sentence because it is the error every existing user hits exactly once, on the day their working command line stops working, and the useful thing to tell them is not what went wrong but which three things they can type instead.

Functions

This section is empty.

Types

type Client

type Client struct {
	Transport Transport

	// MinInterval is the minimum spacing enforced between outgoing requests.
	MinInterval time.Duration
	// contains filtered or unexported fields
}

Client asks a model the package's two questions, once each per distinct input.

func NewClient

func NewClient(cfg Config) (*Client, error)

NewClient builds a Client for a provider.

func NewClientWithTransport

func NewClientWithTransport(tr Transport) *Client

NewClientWithTransport wraps a Transport the caller built itself.

func (*Client) Assess

func (c *Client) Assess(ctx context.Context, r Request) (*Verdict, error)

Assess returns the model's verdict for a single CVE. Identical requests (same CVE, module, version, packages and reachability) are served from an in-memory cache, so image-mode scans that link the same CVE into many binaries only pay for one API call.

func (*Client) Describe

func (c *Client) Describe() string

Describe names the provider, for a log line saying who is being asked.

func (*Client) Mine

func (c *Client) Mine(ctx context.Context, r MineRequest) (*Hints, error)

Mine extracts the identifiers an advisory names. Results are cached per advisory, because one advisory routinely applies to several packages and to several images in the same run.

type CommandTransport

type CommandTransport struct {
	// Args is the command and its arguments, already split. Args[0] is looked
	// up on PATH.
	Args []string
}

CommandTransport runs a locally installed CLI once per question, writing the prompt to its standard input and reading the reply from its standard output.

This is for the tools people already have logged in -- claude, llm, a wrapper script around something in-house -- where the alternative is provisioning an API key for a machine that already has working credentials.

It is the weakest of the transports and the trade is worth stating. There is no structured-output mode to ask for, so the reply is whatever the CLI decided to print and parseVerdict has to find the JSON in it; there are no rate-limit headers, so a provider that wants us to slow down can only say so by failing; and a CLI that has not been authenticated fails per call rather than once at startup. None of that can produce a wrong conclusion -- the model's output is an overlay everywhere it is used, and mined symbols are validated against the artifact before they matter -- so the cost is verdicts that do not arrive, which is visible, rather than verdicts that are wrong.

func NewCommandTransport

func NewCommandTransport(command string) (*CommandTransport, error)

NewCommandTransport splits a command line and returns a transport for it.

The split is done here rather than by a shell on purpose. Passing the string to "sh -c" would make the caller's quoting into shell syntax -- a model name containing a space or a dollar sign would become the user's problem, and an environment variable in the string would expand at a moment nobody chose. This handles the one thing a command line actually needs, which is quoting arguments that contain spaces.

func (*CommandTransport) Chat

func (t *CommandTransport) Chat(ctx context.Context, system, user string) (string, error)

Chat runs the command with the prompt on stdin.

The prompt goes to stdin rather than onto the command line for two reasons. It can be several kilobytes of advisory prose, which is close enough to the argument-length limit to matter; and everything on a command line is visible in the process table to every user on the machine, which is the wrong place for the contents of an image someone is triaging.

func (*CommandTransport) Describe

func (t *CommandTransport) Describe() string

type Config

type Config struct {
	// Endpoint is an OpenAI-compatible chat/completions URL.
	Endpoint string
	// Model is the model id to send. Ignored when Command is set: a CLI
	// chooses its own model, or takes one in its own arguments.
	Model string
	// Token is the bearer credential for Endpoint. Empty is valid and means
	// no Authorization header, which is what the local servers want.
	Token string
	// Command is a locally installed CLI to run instead of calling an
	// endpoint. See CommandTransport for what this costs.
	Command string
}

Config says which model to ask and how to reach it.

Exactly one of Endpoint and Command must be set. There is no default for either, and that is the point: the tool used to have one, GitHub Models, which was free with a token most users already had. It was retired, and a scanner that silently picked a replacement -- or silently stopped asking -- would be reporting an absence of exploitability opinions that looks exactly like a set of findings nothing had an opinion about.

func ConfigFrom

func ConfigFrom(endpoint, model, command string) Config

ConfigFrom resolves a provider from explicit values and the environment, explicit values first.

The token is env-only and has no flag on purpose: everything on a command line is readable in the process table by every user on the machine, and a credential is the one thing here that must not be.

type HTTPTransport

type HTTPTransport struct {
	HTTP     *http.Client
	Endpoint string
	Model    string

	// Token is sent as a bearer credential when set. It is optional because
	// the local servers do not want one, and sending "Bearer " with nothing
	// after it makes some of them reject the request.
	Token string
}

HTTPTransport talks to any endpoint that speaks OpenAI's chat/completions wire format.

That is deliberately almost everyone: OpenAI, Anthropic's compatibility endpoint, Azure AI Foundry, OpenRouter, Together, Groq, and the local servers -- Ollama, vLLM, llama.cpp -- which is why this transport has no notion of a provider. It sends a system message, a user message and temperature 0, and reads one string back. Anything a provider offers beyond that, this package does not need.

func (*HTTPTransport) Chat

func (t *HTTPTransport) Chat(ctx context.Context, system, user string) (string, error)

Chat sends one system/user exchange and returns the reply text.

func (*HTTPTransport) Describe

func (t *HTTPTransport) Describe() string

type Hints

type Hints struct {
	// Symbols are function or variable names the advisory says are vulnerable
	// ("SSL_free_buffers", "png_handle_iCCP").
	Symbols []string `json:"symbols"`
	// Sonames are shared-library names the advisory names ("libssl.so.3").
	Sonames []string `json:"sonames"`
	// Modules are importable module paths the advisory names, for the
	// language ecosystems: a dotted Python path ("yaml.cyaml") or an npm
	// subpath. Only the language mining prompts ask for these, so they are
	// empty for an OS advisory.
	Modules []string `json:"modules"`
	// Files are paths or file names the advisory names.
	Files []string `json:"files"`
	// Note is the model's own account of what it did, kept for the record so a
	// reader can see whether an empty result meant "the advisory names nothing"
	// or "the model declined".
	Note string `json:"note"`
}

Hints are the identifiers a model claims an advisory names.

Every field is a *candidate*. Nothing here is a fact until the caller has matched it against something in the artifact: a hint that cannot be checked is indistinguishable from one that was invented, and the two must therefore be treated the same way. See the validation in the ospkg plugin.

func (*Hints) Empty

func (h *Hints) Empty() bool

Empty reports whether the hints carry nothing checkable.

type MineRequest

type MineRequest struct {
	// ID is the advisory identifier, for the cache key and the prompt.
	ID string
	// Ecosystem is the plugin the advisory was resolved for ("os").
	Ecosystem string
	// Package is the affected package name, so the model can tell which of the
	// identifiers in a multi-package advisory belong to the one being checked.
	Package string
	// Summary and Details are the advisory text, verbatim from OSV.
	Summary string
	Details string
}

MineRequest asks the model to extract checkable identifiers from one advisory's prose.

type Request

type Request struct {
	// Ecosystem is the plugin that produced the finding ("golang", "os"). It
	// selects the system prompt and is part of the cache key: the same CVE
	// against a Go module and against an OS package are different questions.
	Ecosystem string

	CVE       string
	Module    string
	Version   string
	Packages  []string
	Binary    string
	Reachable string // "linked" | "reachable" | "unknown"
}

Request describes one CVE to assess in the context of a specific location.

type Transport

type Transport interface {
	// Chat sends the system and user messages and returns the reply text.
	Chat(ctx context.Context, system, user string) (string, error)

	// Describe names the provider for log lines and error messages, without
	// any credential in it.
	Describe() string
}

Transport carries one system/user exchange to a model and returns its reply text. It exists so the provider is a configuration choice rather than a compile-time one: the questions this package asks are small and the answers are short JSON, which every chat model can do, so nothing above this interface should know or care where the model runs.

Implementations must not retry. Whether another attempt is worth making is decided in chat, from the error: an implementation that also retried would multiply the two policies together.

type Verdict

type Verdict struct {
	Exploitable string `json:"exploitable"` // "likely" | "unlikely" | "unknown"
	Confidence  string `json:"confidence"`  // "low" | "medium" | "high"
	Rationale   string `json:"rationale"`
}

Verdict is the model's structured assessment.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL