Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-09-16 07:42:05 -04:00
pkg-proxy/internal/handler/scanfetch.go

89 lines
3.6 KiB
Go
Raw Permalink Normal View History

Add pre-cache artifact scanning hook (trivy/ClamAV/Wiz/custom) (#298) Runs fetched artifacts through pluggable external scanners after they're staged in storage but before they're committed to the cache DB, so a block verdict deletes the object instead of ever exposing it to a client. Scanners pull the staged bytes themselves via a short-lived HMAC-signed internal route rather than the proxy pushing bytes to them, keeping the mechanism storage-backend-agnostic and avoiding uploading potentially huge artifacts through the proxy's own egress. Hardening baked in from the start: the internal scan-fetch route is gated both at router-mount time and in the handler so it's inert whenever scanning is disabled or unsigned; the signing key is mandatory whenever scanning is enabled, enforced directly in scanner.NewGroup rather than relying on callers to invoke config validation; the scan call and the delete-on-block cleanup both run on a context detached from the client's, so a client disconnecting mid-scan can't be mistaken for a scanner failure, doesn't cause a legitimate artifact to be deleted, and doesn't leave a genuinely blocked artifact's bytes orphaned in storage; and scanner infrastructure errors (connection failures, internal hostnames) are never forwarded verbatim to anonymous clients, only a generic message. The scan-error metric also correctly distinguishes a scanner's own timeout from being cancelled because a sibling scanner already decided the verdict. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 05:06:44 -04:00
package handler
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"time"
)
// scanFetchURL builds a short-lived, HMAC-signed URL for the internal
// /_internal/scan-fetch route, so an external scanner can pull the exact
// bytes staged at path without going through cooldown or the scan hook
// itself. This is generated the same way for every storage backend: it
// never depends on Storage.SignedURL, which not every backend implements.
func (p *Proxy) scanFetchURL(path string, ttl time.Duration) string {
exp := time.Now().Add(ttl).Unix()
return fmt.Sprintf("%s/_internal/scan-fetch?path=%s&exp=%d&sig=%s",
p.ScanFetchBaseURL, url.QueryEscape(path), exp, hmacHex(p.ScanSigningKey, path, exp))
}
func hmacHex(key []byte, path string, exp int64) string {
mac := hmac.New(sha256.New, key)
_, _ = fmt.Fprintf(mac, "%s|%d", path, exp)
return hex.EncodeToString(mac.Sum(nil))
}
// ServeScanFetch streams a storage object to a caller presenting a valid
// short-lived HMAC token, so external scanners can pull a staged artifact
// without going through cooldown or the scan hook themselves. This handler
// never calls GetOrFetchArtifact/fetchAndCache/storeArtifact — the
// separation from the normal request path is structural, not a
// conditional bypass flag.
//
// This route exists only for scanners configured under ScanningConfig; the
// URL is minted by scanFetchURL and passed as fetch_url in the scan notify
// request. It is not part of the public API and should be restricted to
// internal-network access at the ingress/network-policy layer — the HMAC
// scoping (one object, short TTL) limits what a leaked token can do, but
// isn't a substitute for network restriction.
//
// @Summary Fetch a staged artifact for scanning
// @Description Streams the exact bytes staged in storage for a pre-cache security scan.
// @Description Requires a short-lived HMAC-signed token minted by the proxy itself and
// @Description delivered via the fetch_url field of the scan notify request (see the
// @Description Artifact Scanning section of docs/configuration.md). Not part of the
// @Description public API; restrict access to the scanner network at the ingress layer.
// @Tags scanning
// @Produce application/octet-stream
// @Param path query string true "Storage path of the staged artifact"
// @Param exp query int true "Token expiry, Unix seconds"
// @Param sig query string true "HMAC-SHA256 signature over the string path|exp"
// @Success 200 {file} file
// @Failure 403 {string} string "invalid, expired, or tampered token"
// @Failure 404 {string} string "object not found in storage, or scanning is not configured"
// @Router /_internal/scan-fetch [get]
func (p *Proxy) ServeScanFetch(w http.ResponseWriter, r *http.Request) {
if p.Scanners == nil || !p.Scanners.Enabled() || len(p.ScanSigningKey) == 0 {
http.Error(w, "not found", http.StatusNotFound)
return
}
path := r.URL.Query().Get("path")
exp, err := strconv.ParseInt(r.URL.Query().Get("exp"), 10, 64)
if err != nil || containsPathTraversal(path) || time.Now().Unix() > exp {
http.Error(w, "invalid or expired token", http.StatusForbidden)
return
}
want := hmacHex(p.ScanSigningKey, path, exp)
if !hmac.Equal([]byte(r.URL.Query().Get("sig")), []byte(want)) {
http.Error(w, "invalid signature", http.StatusForbidden)
return
}
reader, err := p.Storage.Open(r.Context(), path)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
defer func() { _ = reader.Close() }()
w.Header().Set("Content-Type", "application/octet-stream")
_, _ = io.Copy(w, reader)
}