🚩 Private development - version retention only ✅ Complete web dashboard (React + TypeScript + TailwindCSS) ✅ Production-ready server backend (Go + Gin + PostgreSQL) ✅ Linux agent with APT + Docker scanning + local CLI tools ✅ JWT authentication and REST API ✅ Update discovery and approval workflow 🚧 Status: Alpha software - active development 📦 Purpose: Version retention during development ⚠️ Not for public use or deployment
260 lines
7.3 KiB
Go
260 lines
7.3 KiB
Go
package scanner
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// RegistryClient handles communication with Docker registries (Docker Hub and custom registries)
|
|
type RegistryClient struct {
|
|
httpClient *http.Client
|
|
cache *manifestCache
|
|
}
|
|
|
|
// manifestCache stores registry responses to avoid hitting rate limits
|
|
type manifestCache struct {
|
|
mu sync.RWMutex
|
|
entries map[string]*cacheEntry
|
|
}
|
|
|
|
type cacheEntry struct {
|
|
digest string
|
|
expiresAt time.Time
|
|
}
|
|
|
|
// ManifestResponse represents the response from a Docker Registry API v2 manifest request
|
|
type ManifestResponse struct {
|
|
SchemaVersion int `json:"schemaVersion"`
|
|
MediaType string `json:"mediaType"`
|
|
Config struct {
|
|
Digest string `json:"digest"`
|
|
} `json:"config"`
|
|
}
|
|
|
|
// DockerHubTokenResponse represents the authentication token response from Docker Hub
|
|
type DockerHubTokenResponse struct {
|
|
Token string `json:"token"`
|
|
AccessToken string `json:"access_token"`
|
|
ExpiresIn int `json:"expires_in"`
|
|
IssuedAt time.Time `json:"issued_at"`
|
|
}
|
|
|
|
// NewRegistryClient creates a new registry client with caching
|
|
func NewRegistryClient() *RegistryClient {
|
|
return &RegistryClient{
|
|
httpClient: &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
},
|
|
cache: &manifestCache{
|
|
entries: make(map[string]*cacheEntry),
|
|
},
|
|
}
|
|
}
|
|
|
|
// GetRemoteDigest fetches the digest of a remote image from the registry
|
|
// Returns the digest string (e.g., "sha256:abc123...") or an error
|
|
func (c *RegistryClient) GetRemoteDigest(ctx context.Context, imageName, tag string) (string, error) {
|
|
// Parse image name to determine registry and repository
|
|
registry, repository := parseImageName(imageName)
|
|
|
|
// Check cache first
|
|
cacheKey := fmt.Sprintf("%s/%s:%s", registry, repository, tag)
|
|
if digest := c.cache.get(cacheKey); digest != "" {
|
|
return digest, nil
|
|
}
|
|
|
|
// Get authentication token (if needed)
|
|
token, err := c.getAuthToken(ctx, registry, repository)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to get auth token: %w", err)
|
|
}
|
|
|
|
// Fetch manifest from registry
|
|
digest, err := c.fetchManifestDigest(ctx, registry, repository, tag, token)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to fetch manifest: %w", err)
|
|
}
|
|
|
|
// Cache the result (5 minute TTL to avoid hammering registries)
|
|
c.cache.set(cacheKey, digest, 5*time.Minute)
|
|
|
|
return digest, nil
|
|
}
|
|
|
|
// parseImageName splits an image name into registry and repository
|
|
// Examples:
|
|
// - "nginx" -> ("registry-1.docker.io", "library/nginx")
|
|
// - "myuser/myimage" -> ("registry-1.docker.io", "myuser/myimage")
|
|
// - "gcr.io/myproject/myimage" -> ("gcr.io", "myproject/myimage")
|
|
func parseImageName(imageName string) (registry, repository string) {
|
|
parts := strings.Split(imageName, "/")
|
|
|
|
// Check if first part looks like a domain (contains . or :)
|
|
if len(parts) >= 2 && (strings.Contains(parts[0], ".") || strings.Contains(parts[0], ":")) {
|
|
// Custom registry: gcr.io/myproject/myimage
|
|
registry = parts[0]
|
|
repository = strings.Join(parts[1:], "/")
|
|
} else if len(parts) == 1 {
|
|
// Official image: nginx -> library/nginx
|
|
registry = "registry-1.docker.io"
|
|
repository = "library/" + parts[0]
|
|
} else {
|
|
// User image: myuser/myimage
|
|
registry = "registry-1.docker.io"
|
|
repository = imageName
|
|
}
|
|
|
|
return registry, repository
|
|
}
|
|
|
|
// getAuthToken obtains an authentication token for the registry
|
|
// For Docker Hub, uses the token authentication flow
|
|
// For other registries, may need different auth mechanisms (TODO: implement)
|
|
func (c *RegistryClient) getAuthToken(ctx context.Context, registry, repository string) (string, error) {
|
|
// Docker Hub token authentication
|
|
if registry == "registry-1.docker.io" {
|
|
return c.getDockerHubToken(ctx, repository)
|
|
}
|
|
|
|
// For other registries, we'll try unauthenticated first
|
|
// TODO: Support authentication for private registries (basic auth, bearer tokens, etc.)
|
|
return "", nil
|
|
}
|
|
|
|
// getDockerHubToken obtains a token from Docker Hub's authentication service
|
|
func (c *RegistryClient) getDockerHubToken(ctx context.Context, repository string) (string, error) {
|
|
authURL := fmt.Sprintf(
|
|
"https://auth.docker.io/token?service=registry.docker.io&scope=repository:%s:pull",
|
|
repository,
|
|
)
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "GET", authURL, nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return "", fmt.Errorf("auth request failed with status %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var tokenResp DockerHubTokenResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
|
|
return "", fmt.Errorf("failed to decode token response: %w", err)
|
|
}
|
|
|
|
// Docker Hub can return either 'token' or 'access_token'
|
|
if tokenResp.Token != "" {
|
|
return tokenResp.Token, nil
|
|
}
|
|
return tokenResp.AccessToken, nil
|
|
}
|
|
|
|
// fetchManifestDigest fetches the manifest from the registry and extracts the digest
|
|
func (c *RegistryClient) fetchManifestDigest(ctx context.Context, registry, repository, tag, token string) (string, error) {
|
|
// Build manifest URL
|
|
manifestURL := fmt.Sprintf("https://%s/v2/%s/manifests/%s", registry, repository, tag)
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "GET", manifestURL, nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Set required headers
|
|
req.Header.Set("Accept", "application/vnd.docker.distribution.manifest.v2+json")
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode == http.StatusTooManyRequests {
|
|
return "", fmt.Errorf("rate limited by registry (429 Too Many Requests)")
|
|
}
|
|
|
|
if resp.StatusCode == http.StatusUnauthorized {
|
|
return "", fmt.Errorf("unauthorized: authentication failed for %s/%s:%s", registry, repository, tag)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return "", fmt.Errorf("manifest request failed with status %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
// Try to get digest from Docker-Content-Digest header first (faster)
|
|
if digest := resp.Header.Get("Docker-Content-Digest"); digest != "" {
|
|
return digest, nil
|
|
}
|
|
|
|
// Fallback: parse manifest and extract config digest
|
|
var manifest ManifestResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&manifest); err != nil {
|
|
return "", fmt.Errorf("failed to decode manifest: %w", err)
|
|
}
|
|
|
|
if manifest.Config.Digest == "" {
|
|
return "", fmt.Errorf("manifest does not contain a config digest")
|
|
}
|
|
|
|
return manifest.Config.Digest, nil
|
|
}
|
|
|
|
// manifestCache methods
|
|
|
|
func (mc *manifestCache) get(key string) string {
|
|
mc.mu.RLock()
|
|
defer mc.mu.RUnlock()
|
|
|
|
entry, exists := mc.entries[key]
|
|
if !exists {
|
|
return ""
|
|
}
|
|
|
|
if time.Now().After(entry.expiresAt) {
|
|
// Entry expired
|
|
delete(mc.entries, key)
|
|
return ""
|
|
}
|
|
|
|
return entry.digest
|
|
}
|
|
|
|
func (mc *manifestCache) set(key, digest string, ttl time.Duration) {
|
|
mc.mu.Lock()
|
|
defer mc.mu.Unlock()
|
|
|
|
mc.entries[key] = &cacheEntry{
|
|
digest: digest,
|
|
expiresAt: time.Now().Add(ttl),
|
|
}
|
|
}
|
|
|
|
// cleanupExpired removes expired entries from the cache (called periodically)
|
|
func (mc *manifestCache) cleanupExpired() {
|
|
mc.mu.Lock()
|
|
defer mc.mu.Unlock()
|
|
|
|
now := time.Now()
|
|
for key, entry := range mc.entries {
|
|
if now.After(entry.expiresAt) {
|
|
delete(mc.entries, key)
|
|
}
|
|
}
|
|
}
|