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>
97 lines
2.9 KiB
Go
97 lines
2.9 KiB
Go
package scanner
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
)
|
|
|
|
// HTTPScanner adapts an external HTTP scanning service to the Scanner
|
|
// interface. It POSTs a small JSON notification describing the staged
|
|
// artifact, including a signed fetch URL; the external service is
|
|
// responsible for GETting that URL itself, running the real scan against
|
|
// those bytes, and replying with a verdict before the request's deadline.
|
|
//
|
|
// Request body:
|
|
//
|
|
// {
|
|
// "ecosystem": "npm", "name": "left-pad", "version": "1.0.0",
|
|
// "filename": "left-pad-1.0.0.tgz", "purl": "pkg:npm/left-pad@1.0.0",
|
|
// "content_type": "application/octet-stream", "size": 1234,
|
|
// "fetch_url": "https://proxy.internal/_internal/scan-fetch?..."
|
|
// }
|
|
//
|
|
// Response body:
|
|
//
|
|
// {
|
|
// "allowed": true, "reason": "",
|
|
// "findings": [{"severity": "high", "title": "...", "description": "..."}]
|
|
// }
|
|
//
|
|
// Any compliant adapter — a trivy wrapper, a clamav-rest bridge, a Wiz
|
|
// connector, or an in-house service — need only implement this contract.
|
|
type HTTPScanner struct {
|
|
name string
|
|
url string
|
|
headers map[string]string
|
|
client *http.Client
|
|
}
|
|
|
|
// NewHTTPScanner creates an HTTPScanner named name that notifies url of
|
|
// staged artifacts, attaching headers to every request (e.g. for auth).
|
|
// If client is nil, http.DefaultClient is used.
|
|
func NewHTTPScanner(name, url string, headers map[string]string, client *http.Client) *HTTPScanner {
|
|
if client == nil {
|
|
client = http.DefaultClient
|
|
}
|
|
return &HTTPScanner{name: name, url: url, headers: headers, client: client}
|
|
}
|
|
|
|
// Name returns the scanner's configured name.
|
|
func (s *HTTPScanner) Name() string { return s.name }
|
|
|
|
type httpScanResponse struct {
|
|
Allowed bool `json:"allowed"`
|
|
Reason string `json:"reason"`
|
|
Findings []Finding `json:"findings"`
|
|
}
|
|
|
|
// Scan notifies the configured URL of req and waits for a verdict.
|
|
func (s *HTTPScanner) Scan(ctx context.Context, req Request) (Result, error) {
|
|
body, err := json.Marshal(req)
|
|
if err != nil {
|
|
return Result{}, fmt.Errorf("marshal scan request: %w", err)
|
|
}
|
|
|
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, s.url, bytes.NewReader(body))
|
|
if err != nil {
|
|
return Result{}, fmt.Errorf("build scan request: %w", err)
|
|
}
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
for k, v := range s.headers {
|
|
httpReq.Header.Set(k, v)
|
|
}
|
|
|
|
resp, err := s.client.Do(httpReq)
|
|
if err != nil {
|
|
return Result{}, fmt.Errorf("calling scanner %q: %w", s.name, err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return Result{}, fmt.Errorf("scanner %q returned status %d", s.name, resp.StatusCode)
|
|
}
|
|
|
|
var out httpScanResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
|
return Result{}, fmt.Errorf("decoding scanner %q response: %w", s.name, err)
|
|
}
|
|
|
|
return Result{
|
|
Allowed: out.Allowed,
|
|
Reason: out.Reason,
|
|
Findings: out.Findings,
|
|
}, nil
|
|
}
|