ce7cb702218693d08ea6d83464d2457c9250027c
This commit is contained in:
commit
34c5f8963c
1 changed files with 145336 additions and 0 deletions
400
services/mirrored-image/controller/registry.go
Normal file
400
services/mirrored-image/controller/registry.go
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const oauthUser = "$oauthtoken"
|
||||
|
||||
var challengeRule = regexp.MustCompile(`([A-Za-z]+)="([^"]*)"`)
|
||||
|
||||
type quayReader struct {
|
||||
api *url.URL
|
||||
registry string
|
||||
client *http.Client
|
||||
candidates string
|
||||
mirror string
|
||||
}
|
||||
|
||||
type manifestDoc struct {
|
||||
Manifests json.RawMessage `json:"manifests"`
|
||||
Layers []descriptor `json:"layers"`
|
||||
}
|
||||
|
||||
type descriptor struct {
|
||||
Digest string `json:"digest"`
|
||||
}
|
||||
|
||||
type securityDoc struct {
|
||||
Status string `json:"status"`
|
||||
Data *securityData `json:"data"`
|
||||
}
|
||||
|
||||
type securityData struct {
|
||||
Layer *securityLayer `json:"Layer"`
|
||||
Inventory *scanInventory `json:"Inventory"`
|
||||
}
|
||||
|
||||
type securityLayer struct {
|
||||
Name string `json:"Name"`
|
||||
Features *[]securityFeature `json:"Features"`
|
||||
}
|
||||
|
||||
type securityFeature struct {
|
||||
Name string `json:"Name"`
|
||||
Version string `json:"Version"`
|
||||
AddedBy string `json:"AddedBy"`
|
||||
Vulnerabilities []vulnerability `json:"Vulnerabilities"`
|
||||
}
|
||||
|
||||
type vulnerability struct {
|
||||
Severity string `json:"Severity"`
|
||||
}
|
||||
|
||||
func loadRegistry() (*quayReader, error) {
|
||||
registry := os.Getenv("REGISTRY")
|
||||
if registry == "" {
|
||||
return nil, errors.New("REGISTRY is required")
|
||||
}
|
||||
apiValue := os.Getenv("QUAY_API")
|
||||
if apiValue == "" {
|
||||
return nil, errors.New("QUAY_API is required")
|
||||
}
|
||||
api, err := url.Parse(apiValue)
|
||||
if err != nil || api.Host == "" || (api.Scheme != "http" && api.Scheme != "https") {
|
||||
return nil, errors.New("QUAY_API must be an HTTP or HTTPS origin")
|
||||
}
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
if api.Scheme == "https" {
|
||||
roots, err := x509.SystemCertPool()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load system roots: %w", err)
|
||||
}
|
||||
certificate, err := os.ReadFile(valueOrDefault("REGISTRY_CA", "/registry-ca/ca.crt"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read registry CA: %w", err)
|
||||
}
|
||||
if !roots.AppendCertsFromPEM(certificate) {
|
||||
return nil, errors.New("registry CA contains no certificate")
|
||||
}
|
||||
transport.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12, RootCAs: roots}
|
||||
}
|
||||
candidates, err := readSecret(valueOrDefault("CANDIDATES_TOKEN", "/candidates-reader/token"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mirror, err := readSecret(valueOrDefault("MIRROR_TOKEN", "/mirror-reader/token"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &quayReader{
|
||||
api: api,
|
||||
registry: registry,
|
||||
client: &http.Client{Timeout: 20 * time.Second, Transport: transport},
|
||||
candidates: candidates,
|
||||
mirror: mirror,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func readSecret(path string) (string, error) {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
value := strings.TrimSpace(string(content))
|
||||
if value == "" {
|
||||
return "", fmt.Errorf("secret %s is empty", path)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (q *quayReader) scan(ctx context.Context, repository, digest, threshold string) (string, error) {
|
||||
content, found, err := q.manifest(ctx, repository, digest)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !found {
|
||||
return scanPending, nil
|
||||
}
|
||||
if fmt.Sprintf("sha256:%x", sha256.Sum256(content)) != digest {
|
||||
return "", errors.New("candidate digest does not match manifest")
|
||||
}
|
||||
digests, err := scanDigests(content, digest)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
limit, err := severityRank(threshold)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, child := range digests {
|
||||
result, found, err := q.security(ctx, repository, child)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !found || result.Status == "queued" || result.Status == "scanning" || result.Status == "unsupported" {
|
||||
return scanPending, nil
|
||||
}
|
||||
if result.Status != "scanned" {
|
||||
return "", fmt.Errorf("clair returned unusable status %q for %s", result.Status, child)
|
||||
}
|
||||
if result.Data == nil || result.Data.Layer == nil || result.Data.Layer.Features == nil {
|
||||
return "", fmt.Errorf("clair scan data is unavailable for %s", child)
|
||||
}
|
||||
manifest := content
|
||||
if child != digest {
|
||||
manifest, found, err = q.manifest(ctx, repository, child)
|
||||
if err != nil || !found {
|
||||
return scanPending, err
|
||||
}
|
||||
}
|
||||
if fmt.Sprintf("sha256:%x", sha256.Sum256(manifest)) != child {
|
||||
return "", errors.New("child digest does not match manifest")
|
||||
}
|
||||
var document manifestDoc
|
||||
if err := json.Unmarshal(manifest, &document); err != nil {
|
||||
return "", errors.New("candidate manifest is invalid")
|
||||
}
|
||||
if len(document.Manifests) != 0 || len(document.Layers) == 0 {
|
||||
return scanRejected, nil
|
||||
}
|
||||
layers := map[string]bool{}
|
||||
for _, layer := range document.Layers {
|
||||
if !digestRule.MatchString(layer.Digest) {
|
||||
return "", errors.New("candidate layer digest is invalid")
|
||||
}
|
||||
layers[layer.Digest] = true
|
||||
}
|
||||
if err := checkInventory(result.Data, child, layers); err != nil {
|
||||
return scanRejected, nil
|
||||
}
|
||||
for _, feature := range *result.Data.Layer.Features {
|
||||
for _, finding := range feature.Vulnerabilities {
|
||||
rank, err := severityRank(finding.Severity)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if rank == 0 || rank >= limit {
|
||||
return scanRejected, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return scanAccepted, nil
|
||||
}
|
||||
|
||||
func scanDigests(content []byte, digest string) ([]string, error) {
|
||||
var manifest manifestDoc
|
||||
if err := json.Unmarshal(content, &manifest); err != nil {
|
||||
return nil, fmt.Errorf("decode candidate manifest: %w", err)
|
||||
}
|
||||
if len(manifest.Manifests) == 0 {
|
||||
return []string{digest}, nil
|
||||
}
|
||||
var children []descriptor
|
||||
if err := json.Unmarshal(manifest.Manifests, &children); err != nil {
|
||||
return nil, fmt.Errorf("decode candidate index: %w", err)
|
||||
}
|
||||
if len(children) == 0 {
|
||||
return nil, errors.New("candidate index contains no manifests")
|
||||
}
|
||||
digests := make([]string, 0, len(children))
|
||||
for _, child := range children {
|
||||
if !digestRule.MatchString(child.Digest) {
|
||||
return nil, fmt.Errorf("candidate index contains invalid digest %q", child.Digest)
|
||||
}
|
||||
digests = append(digests, child.Digest)
|
||||
}
|
||||
return digests, nil
|
||||
}
|
||||
|
||||
func severityRank(value string) (int, error) {
|
||||
switch strings.ToLower(value) {
|
||||
case "unknown":
|
||||
return 0, nil
|
||||
case "negligible":
|
||||
return 1, nil
|
||||
case "low":
|
||||
return 2, nil
|
||||
case "medium":
|
||||
return 3, nil
|
||||
case "high":
|
||||
return 4, nil
|
||||
case "critical":
|
||||
return 5, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("unknown Clair severity %q", value)
|
||||
}
|
||||
}
|
||||
|
||||
func (q *quayReader) exists(ctx context.Context, repository, digest string) (bool, error) {
|
||||
_, found, err := q.manifest(ctx, repository, digest)
|
||||
return found, err
|
||||
}
|
||||
|
||||
func (q *quayReader) physicallyPresent(ctx context.Context, repository, digest string) (bool, error) {
|
||||
response, err := q.apiGet(ctx, repository, digest, "/security?vulnerabilities=false")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer func() { _ = response.Body.Close() }()
|
||||
switch response.StatusCode {
|
||||
case http.StatusOK:
|
||||
return true, nil
|
||||
case http.StatusNotFound:
|
||||
return false, nil
|
||||
default:
|
||||
return false, fmt.Errorf("quay physical manifest lookup failed with HTTP %d", response.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func (q *quayReader) security(ctx context.Context, repository, digest string) (securityDoc, bool, error) {
|
||||
response, err := q.apiGet(ctx, repository, digest, "/security?vulnerabilities=true")
|
||||
if err != nil {
|
||||
return securityDoc{}, false, err
|
||||
}
|
||||
defer func() { _ = response.Body.Close() }()
|
||||
if response.StatusCode == http.StatusNotFound {
|
||||
return securityDoc{}, false, nil
|
||||
}
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return securityDoc{}, false, fmt.Errorf("quay security lookup failed with HTTP %d", response.StatusCode)
|
||||
}
|
||||
var result securityDoc
|
||||
if err := json.NewDecoder(io.LimitReader(response.Body, 8<<20)).Decode(&result); err != nil {
|
||||
return securityDoc{}, false, fmt.Errorf("decode Quay security result: %w", err)
|
||||
}
|
||||
return result, true, nil
|
||||
}
|
||||
|
||||
func (q *quayReader) apiGet(ctx context.Context, repository, digest, suffix string) (*http.Response, error) {
|
||||
endpoint := *q.api
|
||||
endpoint.Path = strings.TrimRight(endpoint.Path, "/") + "/api/v1/repository/" + repository + "/manifest/" + digest
|
||||
endpoint.RawQuery = ""
|
||||
if queryIndex := strings.IndexByte(suffix, '?'); queryIndex >= 0 {
|
||||
endpoint.Path += suffix[:queryIndex]
|
||||
endpoint.RawQuery = suffix[queryIndex+1:]
|
||||
} else {
|
||||
endpoint.Path += suffix
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request.Header.Set("Authorization", "Bearer "+q.token(repository))
|
||||
return q.client.Do(request)
|
||||
}
|
||||
|
||||
func (q *quayReader) manifest(ctx context.Context, repository, digest string) ([]byte, bool, error) {
|
||||
endpoint := fmt.Sprintf("%s://%s/v2/%s/manifests/%s", q.api.Scheme, q.registry, repository, digest)
|
||||
send := func(authorization string) (*http.Response, error) {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request.Header.Set("Accept", strings.Join([]string{
|
||||
"application/vnd.oci.image.index.v1+json",
|
||||
"application/vnd.oci.image.manifest.v1+json",
|
||||
"application/vnd.docker.distribution.manifest.list.v2+json",
|
||||
"application/vnd.docker.distribution.manifest.v2+json",
|
||||
}, ", "))
|
||||
if authorization != "" {
|
||||
request.Header.Set("Authorization", authorization)
|
||||
}
|
||||
return q.client.Do(request)
|
||||
}
|
||||
response, err := send("")
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if response.StatusCode == http.StatusUnauthorized {
|
||||
challenge := response.Header.Get("WWW-Authenticate")
|
||||
if err := response.Body.Close(); err != nil {
|
||||
return nil, false, fmt.Errorf("close registry challenge: %w", err)
|
||||
}
|
||||
token, err := q.bearerToken(ctx, challenge, repository)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
response, err = send("Bearer " + token)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
}
|
||||
defer func() { _ = response.Body.Close() }()
|
||||
if response.StatusCode == http.StatusNotFound {
|
||||
return nil, false, nil
|
||||
}
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return nil, false, fmt.Errorf("registry manifest lookup failed with HTTP %d", response.StatusCode)
|
||||
}
|
||||
content, err := io.ReadAll(io.LimitReader(response.Body, 8<<20))
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("read candidate manifest: %w", err)
|
||||
}
|
||||
return content, true, nil
|
||||
}
|
||||
|
||||
func (q *quayReader) bearerToken(ctx context.Context, challenge, repository string) (string, error) {
|
||||
values := make(map[string]string)
|
||||
for _, match := range challengeRule.FindAllStringSubmatch(challenge, -1) {
|
||||
values[strings.ToLower(match[1])] = match[2]
|
||||
}
|
||||
realm, err := url.Parse(values["realm"])
|
||||
if err != nil || realm.Host == "" {
|
||||
return "", errors.New("registry challenge omitted a valid realm")
|
||||
}
|
||||
query := realm.Query()
|
||||
query.Set("service", values["service"])
|
||||
query.Set("scope", values["scope"])
|
||||
query.Set("account", oauthUser)
|
||||
realm.RawQuery = query.Encode()
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, realm.String(), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
request.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(oauthUser+":"+q.token(repository))))
|
||||
response, err := q.client.Do(request)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() { _ = response.Body.Close() }()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("registry authentication failed with HTTP %d", response.StatusCode)
|
||||
}
|
||||
var result struct {
|
||||
Token string `json:"token"`
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&result); err != nil {
|
||||
return "", fmt.Errorf("decode registry token: %w", err)
|
||||
}
|
||||
if result.Token != "" {
|
||||
return result.Token, nil
|
||||
}
|
||||
if result.AccessToken != "" {
|
||||
return result.AccessToken, nil
|
||||
}
|
||||
return "", errors.New("registry authentication returned no token")
|
||||
}
|
||||
|
||||
func (q *quayReader) token(repository string) string {
|
||||
if strings.HasPrefix(repository, "candidates/") {
|
||||
return q.candidates
|
||||
}
|
||||
return q.mirror
|
||||
}
|
||||
Loading…
Reference in a new issue