mirror of
https://github.com/git-pkgs/proxy.git
synced 2026-09-16 07:42:05 -04:00
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>
89 lines
3.6 KiB
Go
89 lines
3.6 KiB
Go
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)
|
|
}
|