fix(security): the shipping build has no SSRF guard on scan targets - #46
Merged
Conversation
In the default build a scan command is handled by core.NewDefaultCommandExecutor from sdk-go, which does scanner.Scan(ctx, payload.Target, opts) with no validation of any kind. The pinned sdk-go v0.5.2 has no httpsec package at all — grep for 169.254, httpsec or IsPrivate across its pkg/ returns nothing. The hardening exists on sdk-go main; it is not in the version go.mod pins. The agent's own guarded scanner path does call validateScanTarget, but it lives in vulnscan.go behind executor.Router, and the only NewRouter call site is platform.go under //go:build platform. So the guard has been compiled into every build and reachable from none of the shipping ones. Net effect: the far narrower validate path has been guarded since it landed while scan — which is what actually runs, with targets taken from assets.name, i.e. from ingest — has not been. Guarded at the wrapper that already intercepts validate, using the same validateScannerTargets the validate path uses. Doing it here rather than by bumping sdk-go is deliberate: a dependency bump fixes today and leaves the next downgrade to silently reopen it. This defends whichever version is pinned. Fails closed. The hard-blocked tier (link-local/IMDS, loopback, CGNAT, multicast) is not openable by configuration; RFC1918 stays available through AGENT_ALLOW_PRIVATE_TARGETS, the same opt-in the validate path and the platform build already use. Operators should know one consequence: the guard resolves hostnames and refuses what it cannot resolve, so an internal-only DNS name that used to scan will now be refused unless it resolves for the agent. That is pre-existing behaviour of validateScannerTarget — the platform build has always worked this way — and it is pinned by a test rather than left to be discovered. Five tests. All of the hard-blocked cases reach the SDK executor against current main; the companion test asserts ordinary targets still get through, because a guard that refuses real scans gets reverted and is worse than none.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The gap
In the default build — what ships, and what
demo-agentruns — a scancommand is handled by
core.NewDefaultCommandExecutorfrom sdk-go:No validation. And the pinned version has nothing to validate with:
The hardening is on sdk-go
main(pkg/httpsecexists there). It is not inthe version
agent/go.modpins.The agent's own guard does exist and is compiled into every build —
validateScanTargetinvulnscan.go, plusdangerousToolFlags— but it isreachable only through
executor.Router, and:So it is compiled into every build and reachable from none of the shipping ones.
Net: the narrow
validatepath has been guarded since it landed;scan—which is what actually runs, with targets drawn from
assets.name, i.e. fromingest — has not been.
The fix
Guard at the wrapper that already intercepts
validate(
ValidatingCommandExecutor), reusing the samevalidateScannerTargetsthevalidate path uses. Both the single
targetand thetargetsarray the APIwrites are checked.
Why here rather than bumping sdk-go. A dependency bump fixes today and
leaves the next downgrade — or a fresh module graph resolution — to silently
reopen it. Guarding at the boundary the agent owns means the answer no longer
depends on which sdk-go is pinned. The bump is still worth doing; it is not what
makes this safe.
Fails closed, including on a payload it cannot parse: the whole reason the
guard exists is that what reaches the scanner is influenced by ingested data, so
forwarding an unread payload would defeat it.
One consequence operators must know
The guard resolves hostnames and refuses what it cannot resolve. An
internal-only DNS name that used to scan will be refused unless it resolves for
the agent.
That is pre-existing
validateScannerTargetbehaviour — the platform build hasalways worked this way — but extending the guard's reach extends this with it.
It is pinned by
TestScanGuard_RefusesUnresolvableHostrather than left to befound by whoever's scans stop. RFC1918 targets remain available via
AGENT_ALLOW_PRIVATE_TARGETS; the hard-blocked tier is not openable.Verified
maingo build ./...andgo build -tags platform ./...go vet ./...go test ./...The failure message on
mainis the finding stated plainly:TestScanGuard_AllowsOrdinaryTargetsmatters as much as the blocking one — aguard that refuses real scans gets reverted, which leaves you worse off than
before. It uses IP literals so it does not depend on DNS in CI.
Follow-ups, not in this PR
pkg/httpsecexists — defencein depth, and it fixes the platform build's transitive use too. Today the
newest tag is v0.5.2 and the hardening is only on
main.(
SecurityValidator.ValidateCommandPayloadhas one production caller, thepipeline path). The agent should not be the only enforcement point for an
attacker-influenceable address.