Skip to content

fix(security): the shipping build has no SSRF guard on scan targets - #46

Merged
0xmanhnv merged 1 commit into
mainfrom
fix/scan-target-guard-default-build
Aug 3, 2026
Merged

fix(security): the shipping build has no SSRF guard on scan targets#46
0xmanhnv merged 1 commit into
mainfrom
fix/scan-target-guard-default-build

Conversation

@0xmanhnv

@0xmanhnv 0xmanhnv commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

The gap

In the default build — what ships, and what demo-agent runs — a scan
command is handled by core.NewDefaultCommandExecutor from sdk-go:

// sdk-go v0.5.2, pkg/core/command_poller.go:491
scanResult, err := scanner.Scan(ctx, payload.Target, opts)

No validation. And the pinned version has nothing to validate with:

$ ls $GOPATH/pkg/mod/github.com/openctemio/sdk-go@v0.5.2/pkg/
adapters audit chunk client compress connectors core credentials ctis
enrichers errors gitenv handler health metrics mocks options pipeline
platform providers resource retry scanners shared strategy transport
                              ^ no httpsec

$ grep -rc "169.254\|httpsec\|IsPrivate" .../sdk-go@v0.5.2/pkg/
(nothing)

The hardening is on sdk-go main (pkg/httpsec exists there). It is not in
the version agent/go.mod pins.

The agent's own guard does exist and is compiled into every build —
validateScanTarget in vulnscan.go, plus dangerousToolFlags — but it is
reachable only through executor.Router, and:

$ /usr/bin/grep -rn "NewRouter(" --include="*.go" .
./platform.go:171:  router := executor.NewRouter(...)   # //go:build platform

So it is compiled into every build and reachable from none of the shipping ones.

Net: the narrow validate path has been guarded since it landed; scan
which is what actually runs, with targets drawn from assets.name, i.e. from
ingest — has not been.

The fix

Guard at the wrapper that already intercepts validate
(ValidatingCommandExecutor), reusing the same validateScannerTargets the
validate path uses. Both the single target and the targets array the API
writes 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 validateScannerTarget behaviour — the platform build has
always worked this way — but extending the guard's reach extends this with it.
It is pinned by TestScanGuard_RefusesUnresolvableHost rather than left to be
found by whoever's scans stop. RFC1918 targets remain available via
AGENT_ALLOW_PRIVATE_TARGETS; the hard-blocked tier is not openable.

Verified

Check Result
5 guard tests pass
the blocking tests against current main all 5 hard-blocked targets reach the SDK executor
go build ./... and go build -tags platform ./... exit 0
go vet ./... exit 0
go test ./... pass

The failure message on main is the finding stated plainly:

the scan command reached the SDK executor — sdk-go v0.5.2 passes
payload.Target straight to scanner.Scan with no validation

TestScanGuard_AllowsOrdinaryTargets matters as much as the blocking one — a
guard 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

  • Bump sdk-go past v0.5.2 once a tag carrying pkg/httpsec exists — defence
    in 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.
  • The API performs no target validation at all on scan or validate commands
    (SecurityValidator.ValidateCommandPayload has one production caller, the
    pipeline path). The agent should not be the only enforcement point for an
    attacker-influenceable address.

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.
@0xmanhnv
0xmanhnv merged commit 6016432 into main Aug 3, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant