Complete RedFlag codebase with two major security audit implementations.
== A-1: Ed25519 Key Rotation Support ==
Server:
- SignCommand sets SignedAt timestamp and KeyID on every signature
- signing_keys database table (migration 020) for multi-key rotation
- InitializePrimaryKey registers active key at startup
- /api/v1/public-keys endpoint for rotation-aware agents
- SigningKeyQueries for key lifecycle management
Agent:
- Key-ID-aware verification via CheckKeyRotation
- FetchAndCacheAllActiveKeys for rotation pre-caching
- Cache metadata with TTL and staleness fallback
- SecurityLogger events for key rotation and command signing
== A-2: Replay Attack Fixes (F-1 through F-7) ==
F-5 CRITICAL - RetryCommand now signs via signAndCreateCommand
F-1 HIGH - v3 format: "{agent_id}:{cmd_id}:{type}:{hash}:{ts}"
F-7 HIGH - Migration 026: expires_at column with partial index
F-6 HIGH - GetPendingCommands/GetStuckCommands filter by expires_at
F-2 HIGH - Agent-side executedIDs dedup map with cleanup
F-4 HIGH - commandMaxAge reduced from 24h to 4h
F-3 CRITICAL - Old-format commands rejected after 48h via CreatedAt
Verification fixes: migration idempotency (ETHOS #4), log format
compliance (ETHOS #1), stale comments updated.
All 24 tests passing. Docker --no-cache build verified.
See docs/ for full audit reports and deviation log (DEV-001 to DEV-019).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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)
|
|
}
|
|
}
|
|
}
|