mirror of
https://github.com/selfhosters-cc/container-census.git
synced 2026-04-26 06:59:57 -05:00
Fix module path and add build time display to UI
Backend changes: - Updated go.mod module path from github.com/container-census to github.com/selfhosters-cc to match correct GitHub organization - Updated all import paths across codebase to use new module name - This fixes ldflags injection of BuildTime during compilation - BuildTime now correctly shows in /api/health response Frontend changes: - Added build time badge next to version in header - Shows date and time in compact format (e.g., "🔨 12/11/2025 8:06 PM") - Hover shows full timestamp - Only displays if build_time is not "unknown" The build script already sets BuildTime via ldflags, but it was being ignored because the module path in go.mod didn't match the ldflags path. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,13 @@ Container Census is a multi-host Docker monitoring system written in Go. It cons
|
||||
2. **Agent** (`cmd/agent`): Lightweight agent for remote Docker hosts
|
||||
3. **Telemetry Collector** (`cmd/telemetry-collector`): Analytics aggregation service with PostgreSQL backend
|
||||
|
||||
### Frontend
|
||||
|
||||
**IMPORTANT**: Container Census uses a **Next.js/React** frontend (`web-next/`) as the primary UI. The vanilla JavaScript frontend (`web/`) is **deprecated** and kept only for reference - it will be removed in a future release. All new development should use the Next.js frontend.
|
||||
|
||||
- **Active**: `web-next/` - Next.js 16 with TypeScript and React
|
||||
- **Deprecated**: `web/` - Vanilla JS (reference only, DO NOT USE for new features)
|
||||
|
||||
## Build Instructions
|
||||
|
||||
**IMPORTANT**: When building binaries during development, ALWAYS build to `/tmp/container-census`:
|
||||
|
||||
+13
-5
@@ -23,11 +23,17 @@ COPY . .
|
||||
# Tidy if needed (rarely changes cache)
|
||||
RUN go mod tidy -e
|
||||
|
||||
# Build the binary with proper tags for Alpine
|
||||
RUN CGO_ENABLED=1 GOOS=linux go build -buildvcs=false -tags "sqlite_omit_load_extension" -o census ./cmd/server
|
||||
# Build the binary with proper tags for Alpine and inject build timestamp
|
||||
RUN BUILD_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") && \
|
||||
CGO_ENABLED=1 GOOS=linux go build \
|
||||
-buildvcs=false \
|
||||
-tags "sqlite_omit_load_extension" \
|
||||
-ldflags "-X github.com/selfhosters-cc/container-census/internal/version.BuildTime=${BUILD_TIME}" \
|
||||
-o census \
|
||||
./cmd/server
|
||||
|
||||
# Stage 2: Create minimal runtime image
|
||||
FROM alpine:latest
|
||||
FROM alpine:3.21
|
||||
|
||||
# Build arg for docker group GID (defaults to 999, can be overridden at runtime)
|
||||
ARG DOCKER_GID=999
|
||||
@@ -70,8 +76,10 @@ COPY --from=builder /build/.version ./.version
|
||||
# Copy changelog for "What's New" feature
|
||||
COPY --from=builder /build/CHANGELOG.md ./CHANGELOG.md
|
||||
|
||||
# Copy web frontend
|
||||
COPY --from=builder /build/web ./web
|
||||
# Copy Next.js web frontend (pre-built static export)
|
||||
# To build: cd web-next && npm run build
|
||||
# Output is in web-next/out which gets copied here as ./web
|
||||
COPY --from=builder /build/web-next/out ./web
|
||||
|
||||
# Copy example config
|
||||
COPY --from=builder /build/config/config.yaml.example ./config/config.yaml.example
|
||||
|
||||
+27
-5
@@ -27,13 +27,31 @@ RUN go mod tidy -e
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -buildvcs=false -ldflags="-w -s" -o census-agent ./cmd/agent
|
||||
|
||||
# Stage 2: Create minimal runtime image
|
||||
FROM alpine:latest
|
||||
FROM alpine:3.21
|
||||
|
||||
# Build arg for docker group GID (defaults to 999)
|
||||
ARG DOCKER_GID=999
|
||||
|
||||
# Install ca-certificates for HTTPS
|
||||
RUN apk --no-cache add ca-certificates tzdata
|
||||
# Build arg for optional Trivy installation
|
||||
ARG INSTALL_TRIVY=false
|
||||
ARG TRIVY_VERSION=0.58.1
|
||||
|
||||
# Install ca-certificates for HTTPS and conditionally install Trivy
|
||||
RUN apk --no-cache add ca-certificates tzdata && \
|
||||
if [ "$INSTALL_TRIVY" = "true" ]; then \
|
||||
apk --no-cache add wget && \
|
||||
ARCH=$(uname -m) && \
|
||||
case "$ARCH" in \
|
||||
x86_64) TRIVY_ARCH="64bit" ;; \
|
||||
aarch64) TRIVY_ARCH="ARM64" ;; \
|
||||
armv7l) TRIVY_ARCH="ARM" ;; \
|
||||
*) echo "Unsupported architecture: $ARCH" && exit 1 ;; \
|
||||
esac && \
|
||||
wget -qO- https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_Linux-${TRIVY_ARCH}.tar.gz | tar -xzf - -C /usr/local/bin trivy && \
|
||||
chmod +x /usr/local/bin/trivy && \
|
||||
trivy --version && \
|
||||
apk del wget; \
|
||||
fi
|
||||
|
||||
# Create docker group with host's GID and census user
|
||||
RUN (getent group ${DOCKER_GID} && delgroup $(getent group ${DOCKER_GID} | cut -d: -f1)) || true && \
|
||||
@@ -51,8 +69,12 @@ COPY --from=builder /build/census-agent .
|
||||
# Copy version file
|
||||
COPY --from=builder /build/.version ./.version
|
||||
|
||||
# Create data directory for token persistence
|
||||
RUN mkdir -p /app/data && chown census:census /app/data
|
||||
# Create data directory for token persistence and conditionally create Trivy cache directory
|
||||
RUN mkdir -p /app/data && chown census:census /app/data && \
|
||||
if [ "$INSTALL_TRIVY" = "true" ]; then \
|
||||
mkdir -p /app/data/.trivy && \
|
||||
chown census:census /app/data/.trivy; \
|
||||
fi
|
||||
|
||||
# Switch to non-root user
|
||||
USER census
|
||||
|
||||
@@ -26,7 +26,7 @@ RUN go mod tidy -e
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -buildvcs=false -ldflags="-w -s" -o telemetry-collector ./cmd/telemetry-collector
|
||||
|
||||
# Stage 2: Create minimal runtime image
|
||||
FROM alpine:latest
|
||||
FROM alpine:3.21
|
||||
|
||||
# Install ca-certificates for HTTPS and timezone data
|
||||
RUN apk --no-cache add ca-certificates tzdata
|
||||
|
||||
+39
-7
@@ -7,14 +7,16 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/container-census/container-census/internal/agent"
|
||||
"github.com/container-census/container-census/internal/version"
|
||||
"github.com/selfhosters-cc/container-census/internal/agent"
|
||||
"github.com/selfhosters-cc/container-census/internal/version"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -48,19 +50,29 @@ func main() {
|
||||
// Get version
|
||||
agentVersion := version.Get()
|
||||
|
||||
// Check Trivy availability
|
||||
hasTrivyBool, trivyVer := checkTrivyAvailability()
|
||||
|
||||
// Create agent info
|
||||
agentInfo := agent.Info{
|
||||
Version: agentVersion,
|
||||
Hostname: hostname,
|
||||
OS: runtime.GOOS,
|
||||
Arch: runtime.GOARCH,
|
||||
StartedAt: time.Now(),
|
||||
Version: agentVersion,
|
||||
Hostname: hostname,
|
||||
OS: runtime.GOOS,
|
||||
Arch: runtime.GOARCH,
|
||||
StartedAt: time.Now(),
|
||||
HasTrivy: hasTrivyBool,
|
||||
TrivyVersion: trivyVer,
|
||||
}
|
||||
|
||||
log.Printf("Starting Container Census Agent v%s", agentVersion)
|
||||
log.Printf("Hostname: %s", hostname)
|
||||
log.Printf("OS: %s/%s", runtime.GOOS, runtime.GOARCH)
|
||||
log.Printf("Docker Host: %s", *dockerHost)
|
||||
if hasTrivyBool {
|
||||
log.Printf("Trivy: v%s (vulnerability scanning enabled)", trivyVer)
|
||||
} else {
|
||||
log.Printf("Trivy: not available (vulnerability scanning disabled)")
|
||||
}
|
||||
|
||||
// Create agent server
|
||||
agentServer, err := agent.New(*dockerHost, *apiToken, agentInfo)
|
||||
@@ -192,3 +204,23 @@ func loadOrGenerateToken(tokenFile string) string {
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
// checkTrivyAvailability checks if Trivy is installed and returns version
|
||||
func checkTrivyAvailability() (bool, string) {
|
||||
cmd := exec.Command("trivy", "--version")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// Parse version from output
|
||||
// Expected format: "Version: 0.58.1"
|
||||
lines := strings.Split(string(output), "\n")
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, "Version:") {
|
||||
version := strings.TrimSpace(strings.TrimPrefix(line, "Version:"))
|
||||
return true, version
|
||||
}
|
||||
}
|
||||
return true, "unknown"
|
||||
}
|
||||
|
||||
+19
-19
@@ -13,21 +13,21 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/container-census/container-census/internal/api"
|
||||
"github.com/container-census/container-census/internal/auth"
|
||||
"github.com/container-census/container-census/internal/migration"
|
||||
"github.com/container-census/container-census/internal/models"
|
||||
"github.com/container-census/container-census/internal/notifications"
|
||||
"github.com/container-census/container-census/internal/plugins"
|
||||
"github.com/container-census/container-census/internal/plugins/builtin/graph"
|
||||
"github.com/container-census/container-census/internal/plugins/builtin/npm"
|
||||
"github.com/container-census/container-census/internal/plugins/builtin/security"
|
||||
"github.com/container-census/container-census/internal/registry"
|
||||
"github.com/container-census/container-census/internal/scanner"
|
||||
"github.com/container-census/container-census/internal/storage"
|
||||
"github.com/container-census/container-census/internal/telemetry"
|
||||
"github.com/container-census/container-census/internal/version"
|
||||
"github.com/container-census/container-census/internal/vulnerability"
|
||||
"github.com/selfhosters-cc/container-census/internal/api"
|
||||
"github.com/selfhosters-cc/container-census/internal/auth"
|
||||
"github.com/selfhosters-cc/container-census/internal/migration"
|
||||
"github.com/selfhosters-cc/container-census/internal/models"
|
||||
"github.com/selfhosters-cc/container-census/internal/notifications"
|
||||
"github.com/selfhosters-cc/container-census/internal/plugins"
|
||||
"github.com/selfhosters-cc/container-census/internal/plugins/builtin/graph"
|
||||
"github.com/selfhosters-cc/container-census/internal/plugins/builtin/npm"
|
||||
"github.com/selfhosters-cc/container-census/internal/plugins/builtin/security"
|
||||
"github.com/selfhosters-cc/container-census/internal/registry"
|
||||
"github.com/selfhosters-cc/container-census/internal/scanner"
|
||||
"github.com/selfhosters-cc/container-census/internal/storage"
|
||||
"github.com/selfhosters-cc/container-census/internal/telemetry"
|
||||
"github.com/selfhosters-cc/container-census/internal/version"
|
||||
"github.com/selfhosters-cc/container-census/internal/vulnerability"
|
||||
)
|
||||
|
||||
// Global scan interval that can be updated dynamically
|
||||
@@ -345,7 +345,7 @@ func main() {
|
||||
log.Printf("Loaded vulnerability settings from database (cache_dir: %s)", vulnConfig.GetCacheDir())
|
||||
|
||||
vulnScanner := vulnerability.NewScanner(vulnConfig, db)
|
||||
vulnScheduler := vulnerability.NewScheduler(vulnScanner, vulnConfig)
|
||||
vulnScheduler := vulnerability.NewScheduler(vulnScanner, vulnConfig, db)
|
||||
vulnScheduler.Start()
|
||||
log.Printf("Vulnerability scanner initialized (%d workers, auto-scan: %v)", vulnConfig.GetWorkerPoolSize(), vulnConfig.GetAutoScanNewImages())
|
||||
|
||||
@@ -572,9 +572,9 @@ func queueImagesForScanning(containers []models.Container, hostID int64, db *sto
|
||||
log.Printf("Warning: Failed to update image-container mapping: %v", err)
|
||||
}
|
||||
|
||||
// Queue for scanning (non-blocking)
|
||||
// Note: QueueScan internally checks NeedsScan() and returns nil if already scanned recently
|
||||
if err := vulnerabilitySchedulerGlobal.QueueScan(container.ImageID, container.Image, 0); err != nil {
|
||||
// Queue for scanning with host context (enables routing to agent)
|
||||
// Note: QueueScanWithHost internally checks NeedsScan() and returns nil if already scanned recently
|
||||
if err := vulnerabilitySchedulerGlobal.QueueScanWithHost(container.ImageID, container.Image, hostID, 0); err != nil {
|
||||
log.Printf("Warning: Failed to queue image for scanning: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/container-census/container-census/internal/models"
|
||||
"github.com/container-census/container-census/internal/version"
|
||||
"github.com/selfhosters-cc/container-census/internal/models"
|
||||
"github.com/selfhosters-cc/container-census/internal/version"
|
||||
"github.com/gorilla/mux"
|
||||
_ "github.com/lib/pq" // PostgreSQL driver
|
||||
)
|
||||
@@ -28,7 +28,7 @@ type Config struct {
|
||||
AuthUsername string
|
||||
AuthPassword string
|
||||
StatsAPIKey string // API key for stats endpoints
|
||||
StatsMinInstalls int // Minimum installations for trending stats (default: 10)
|
||||
StatsMinInstalls int // Minimum installations for trending stats (default: 3)
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
@@ -57,7 +57,7 @@ func main() {
|
||||
AuthUsername: getEnv("COLLECTOR_AUTH_USERNAME", ""),
|
||||
AuthPassword: getEnv("COLLECTOR_AUTH_PASSWORD", ""),
|
||||
StatsAPIKey: getEnv("STATS_API_KEY", ""),
|
||||
StatsMinInstalls: getEnvInt("STATS_MIN_INSTALLATIONS", 10),
|
||||
StatsMinInstalls: getEnvInt("STATS_MIN_INSTALLATIONS", 3),
|
||||
}
|
||||
|
||||
if config.AuthEnabled {
|
||||
@@ -2404,9 +2404,9 @@ func (s *Server) handleMoversStats(w http.ResponseWriter, r *http.Request) {
|
||||
previousQuery := `
|
||||
SELECT image, total_count, installation_count
|
||||
FROM image_stats_weekly
|
||||
WHERE week_start = $1
|
||||
WHERE week_start = $1 AND installation_count >= $2
|
||||
`
|
||||
previousRows, err := s.db.Query(previousQuery, previousWeek)
|
||||
previousRows, err := s.db.Query(previousQuery, previousWeek, minInstallations)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Query failed: "+err.Error())
|
||||
return
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
module github.com/container-census/container-census
|
||||
module github.com/selfhosters-cc/container-census
|
||||
|
||||
go 1.25
|
||||
|
||||
|
||||
+139
-1
@@ -1,6 +1,7 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
@@ -9,11 +10,14 @@ import (
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/container-census/container-census/internal/models"
|
||||
"github.com/selfhosters-cc/container-census/internal/models"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
@@ -29,6 +33,8 @@ type Info struct {
|
||||
Arch string `json:"arch"`
|
||||
DockerVersion string `json:"docker_version"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
HasTrivy bool `json:"has_trivy"` // Whether Trivy is available
|
||||
TrivyVersion string `json:"trivy_version"` // Trivy version if available
|
||||
}
|
||||
|
||||
// Agent handles Docker operations on a single host
|
||||
@@ -97,6 +103,11 @@ func (a *Agent) setupRoutes() {
|
||||
|
||||
// Telemetry endpoint
|
||||
api.HandleFunc("/telemetry", a.handleGetTelemetry).Methods("GET")
|
||||
|
||||
// Vulnerability scanning endpoints (if Trivy available)
|
||||
api.HandleFunc("/vulnerabilities/scan", a.handleScanImage).Methods("POST")
|
||||
api.HandleFunc("/vulnerabilities/db-update", a.handleUpdateTrivyDB).Methods("POST")
|
||||
api.HandleFunc("/vulnerabilities/cache-clear", a.handleClearTrivyCache).Methods("POST")
|
||||
}
|
||||
|
||||
// Router returns the configured router
|
||||
@@ -804,6 +815,133 @@ func createDockerClient(dockerHost string) (*client.Client, error) {
|
||||
)
|
||||
}
|
||||
|
||||
// handleScanImage scans an image using local Trivy
|
||||
func (a *Agent) handleScanImage(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.info.HasTrivy {
|
||||
respondError(w, http.StatusNotImplemented, "Trivy not available on this agent")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
ImageID string `json:"image_id"`
|
||||
ImageName string `json:"image_name"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if req.ImageName == "" {
|
||||
respondError(w, http.StatusBadRequest, "image_name is required")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
result, err := a.runTrivyScan(ctx, req.ImageName)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Scan failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// runTrivyScan executes Trivy CLI and returns raw JSON
|
||||
func (a *Agent) runTrivyScan(ctx context.Context, imageRef string) (map[string]interface{}, error) {
|
||||
cacheDir := "/app/data/.trivy"
|
||||
|
||||
args := []string{
|
||||
"image",
|
||||
"--format", "json",
|
||||
"--quiet",
|
||||
"--no-progress",
|
||||
"--cache-dir", cacheDir,
|
||||
"--image-src", "docker",
|
||||
}
|
||||
|
||||
// Skip DB update if DB exists (prevent lock conflicts)
|
||||
dbPath := filepath.Join(cacheDir, "db", "trivy.db")
|
||||
if _, err := os.Stat(dbPath); err == nil {
|
||||
args = append(args, "--skip-db-update", "--skip-java-db-update")
|
||||
}
|
||||
|
||||
args = append(args, imageRef)
|
||||
|
||||
cmd := exec.CommandContext(ctx, "trivy", args...)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
stderrStr := stderr.String()
|
||||
if strings.Contains(stderrStr, "unable to find the specified image") ||
|
||||
strings.Contains(stderrStr, "No such image") {
|
||||
return nil, fmt.Errorf("image not available for scanning")
|
||||
}
|
||||
return nil, fmt.Errorf("trivy command failed: %w (stderr: %s)", err, stderrStr)
|
||||
}
|
||||
|
||||
// Parse JSON and return as generic map
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse trivy output: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// handleUpdateTrivyDB updates the Trivy vulnerability database
|
||||
func (a *Agent) handleUpdateTrivyDB(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.info.HasTrivy {
|
||||
respondError(w, http.StatusNotImplemented, "Trivy not available on this agent")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
cacheDir := "/app/data/.trivy"
|
||||
cmd := exec.CommandContext(ctx, "trivy", "image", "--download-db-only", "--cache-dir", cacheDir)
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to update database: "+string(output))
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]string{
|
||||
"message": "Trivy database updated successfully",
|
||||
"output": string(output),
|
||||
})
|
||||
}
|
||||
|
||||
// handleClearTrivyCache clears the Trivy cache directory
|
||||
func (a *Agent) handleClearTrivyCache(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.info.HasTrivy {
|
||||
respondError(w, http.StatusNotImplemented, "Trivy not available on this agent")
|
||||
return
|
||||
}
|
||||
|
||||
cacheDir := "/app/data/.trivy"
|
||||
|
||||
if err := os.RemoveAll(cacheDir); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to clear cache: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(cacheDir, 0755); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to recreate cache directory: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]string{
|
||||
"message": "Trivy cache cleared successfully",
|
||||
})
|
||||
}
|
||||
|
||||
func respondJSON(w http.ResponseWriter, status int, data interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/container-census/container-census/internal/models"
|
||||
"github.com/selfhosters-cc/container-census/internal/models"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
@@ -30,6 +30,96 @@ func detectHostType(address string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// handleAddHost adds a new host (generic handler that detects type from address)
|
||||
func (s *Server) handleAddHost(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address"`
|
||||
Description string `json:"description"`
|
||||
AgentToken string `json:"agent_token,omitempty"` // Legacy field name
|
||||
APIToken string `json:"api_token,omitempty"` // Preferred field name
|
||||
CollectStats bool `json:"collect_stats"`
|
||||
EnableVulnerabilityScanning bool `json:"enable_vulnerability_scanning"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if req.Name == "" {
|
||||
respondError(w, http.StatusBadRequest, "Name is required")
|
||||
return
|
||||
}
|
||||
if req.Address == "" {
|
||||
respondError(w, http.StatusBadRequest, "Address is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Detect host type from address
|
||||
hostType := detectHostType(req.Address)
|
||||
|
||||
// Use api_token if provided, otherwise fall back to agent_token
|
||||
token := req.APIToken
|
||||
if token == "" {
|
||||
token = req.AgentToken
|
||||
}
|
||||
|
||||
// For agent hosts, require token
|
||||
if hostType == "agent" && token == "" {
|
||||
respondError(w, http.StatusBadRequest, "Agent token is required for agent hosts")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify connectivity based on host type
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
host := models.Host{
|
||||
Name: req.Name,
|
||||
Address: req.Address,
|
||||
Description: req.Description,
|
||||
HostType: hostType,
|
||||
AgentToken: token,
|
||||
AgentStatus: "unknown",
|
||||
Enabled: true,
|
||||
CollectStats: req.CollectStats,
|
||||
EnableVulnerabilityScanning: req.EnableVulnerabilityScanning,
|
||||
}
|
||||
|
||||
// For agent hosts, verify connectivity and authentication
|
||||
if hostType == "agent" {
|
||||
if err := s.verifyAgentConnection(ctx, host); err != nil {
|
||||
respondError(w, http.StatusBadGateway, "Failed to connect to agent: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Also verify authentication
|
||||
if err := s.scanner.VerifyAgentAuth(ctx, host); err != nil {
|
||||
if strings.Contains(err.Error(), "401") || strings.Contains(err.Error(), "authentication failed") {
|
||||
respondError(w, http.StatusUnauthorized, "Agent authentication failed - invalid API token")
|
||||
} else {
|
||||
respondError(w, http.StatusBadGateway, "Failed to verify agent authentication: "+err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
host.AgentStatus = "online"
|
||||
host.LastSeen = time.Now()
|
||||
}
|
||||
|
||||
// Add to database
|
||||
id, err := s.db.AddHost(host)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to add host: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
host.ID = id
|
||||
respondJSON(w, http.StatusCreated, host)
|
||||
}
|
||||
|
||||
// handleAddAgentHost adds a new agent-based host
|
||||
func (s *Server) handleAddAgentHost(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/container-census/container-census/internal/auth"
|
||||
"github.com/selfhosters-cc/container-census/internal/auth"
|
||||
)
|
||||
|
||||
// LoginRequest represents the login request payload
|
||||
|
||||
+263
-12
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -13,15 +14,15 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/container-census/container-census/internal/auth"
|
||||
"github.com/container-census/container-census/internal/models"
|
||||
"github.com/container-census/container-census/internal/notifications"
|
||||
"github.com/container-census/container-census/internal/plugins"
|
||||
"github.com/container-census/container-census/internal/registry"
|
||||
"github.com/container-census/container-census/internal/scanner"
|
||||
"github.com/container-census/container-census/internal/storage"
|
||||
"github.com/container-census/container-census/internal/telemetry"
|
||||
"github.com/container-census/container-census/internal/version"
|
||||
"github.com/selfhosters-cc/container-census/internal/auth"
|
||||
"github.com/selfhosters-cc/container-census/internal/models"
|
||||
"github.com/selfhosters-cc/container-census/internal/notifications"
|
||||
"github.com/selfhosters-cc/container-census/internal/plugins"
|
||||
"github.com/selfhosters-cc/container-census/internal/registry"
|
||||
"github.com/selfhosters-cc/container-census/internal/scanner"
|
||||
"github.com/selfhosters-cc/container-census/internal/storage"
|
||||
"github.com/selfhosters-cc/container-census/internal/telemetry"
|
||||
"github.com/selfhosters-cc/container-census/internal/version"
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
@@ -183,13 +184,22 @@ func (s *Server) setupRoutes() {
|
||||
|
||||
// Host endpoints
|
||||
api.HandleFunc("/hosts", s.handleGetHosts).Methods("GET")
|
||||
api.HandleFunc("/hosts", s.handleAddHost).Methods("POST")
|
||||
|
||||
// Host Trivy management endpoints (must be before /hosts/{id} to avoid route conflicts)
|
||||
api.HandleFunc("/hosts/trivy-summary", s.handleGetTrivySummary).Methods("GET")
|
||||
api.HandleFunc("/hosts/bulk-trivy-update", s.handleBulkTrivyUpdate).Methods("POST")
|
||||
api.HandleFunc("/hosts/agent", s.handleAddAgentHost).Methods("POST")
|
||||
api.HandleFunc("/hosts/agent/test", s.handleTestAgentConnection).Methods("POST")
|
||||
api.HandleFunc("/hosts/agent/{id}/info", s.handleGetAgentInfo).Methods("GET")
|
||||
|
||||
// Host CRUD endpoints (must be after specific routes)
|
||||
api.HandleFunc("/hosts/{id}", s.handleGetHost).Methods("GET")
|
||||
api.HandleFunc("/hosts/{id}", s.handleUpdateHost).Methods("PUT")
|
||||
api.HandleFunc("/hosts/{id}", s.handleDeleteHost).Methods("DELETE")
|
||||
api.HandleFunc("/hosts/{id}/scan", s.handleScanHost).Methods("POST")
|
||||
api.HandleFunc("/hosts/agent", s.handleAddAgentHost).Methods("POST")
|
||||
api.HandleFunc("/hosts/agent/test", s.handleTestAgentConnection).Methods("POST")
|
||||
api.HandleFunc("/hosts/agent/{id}/info", s.handleGetAgentInfo).Methods("GET")
|
||||
api.HandleFunc("/hosts/{id}/trivy-update", s.handleHostTrivyUpdate).Methods("POST")
|
||||
api.HandleFunc("/hosts/{id}/trivy-clear-cache", s.handleHostTrivyClearCache).Methods("POST")
|
||||
|
||||
// Container endpoints
|
||||
api.HandleFunc("/containers", s.handleGetContainers).Methods("GET")
|
||||
@@ -426,6 +436,63 @@ func (s *Server) handleDeleteHost(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusOK, map[string]string{"message": "Host deleted successfully"})
|
||||
}
|
||||
|
||||
func (s *Server) handleGetTrivySummary(w http.ResponseWriter, r *http.Request) {
|
||||
// Get all hosts
|
||||
hosts, err := s.db.GetHosts()
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to get hosts: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
withTrivy := 0
|
||||
withoutTrivy := 0
|
||||
disabled := 0
|
||||
totalAgents := len(hosts)
|
||||
|
||||
// Check local host
|
||||
if s.vulnScanner != nil {
|
||||
withTrivy++ // Local host has Trivy
|
||||
}
|
||||
|
||||
// Check each agent host
|
||||
for _, host := range hosts {
|
||||
if !host.EnableVulnerabilityScanning {
|
||||
disabled++
|
||||
continue
|
||||
}
|
||||
|
||||
// For agent hosts, check if they have Trivy
|
||||
if host.HostType == "agent" {
|
||||
// Try to get agent info
|
||||
if s.scanner != nil {
|
||||
if agentInfo, err := s.scanner.GetAgentInfo(r.Context(), host); err == nil && agentInfo.HasTrivy {
|
||||
withTrivy++
|
||||
} else {
|
||||
withoutTrivy++
|
||||
}
|
||||
} else {
|
||||
withoutTrivy++
|
||||
}
|
||||
} else if host.HostType == "unix" {
|
||||
// Unix hosts use local Trivy
|
||||
if s.vulnScanner != nil {
|
||||
withTrivy++
|
||||
} else {
|
||||
withoutTrivy++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"with_trivy": withTrivy,
|
||||
"without_trivy": withoutTrivy,
|
||||
"disabled": disabled,
|
||||
"total_agents": totalAgents,
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetContainers(w http.ResponseWriter, r *http.Request) {
|
||||
containers, err := s.db.GetLatestContainers()
|
||||
if err != nil {
|
||||
@@ -901,6 +968,7 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
response := map[string]interface{}{
|
||||
"status": "healthy",
|
||||
"version": version.Get(),
|
||||
"build_time": version.GetBuildTime(),
|
||||
"time": time.Now().Format(time.RFC3339),
|
||||
"auth_enabled": s.authConfig.Enabled,
|
||||
}
|
||||
@@ -2239,3 +2307,186 @@ func (s *Server) handlePluginAsset(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
http.ServeFile(w, r, fullPath)
|
||||
}
|
||||
|
||||
// handleBulkTrivyUpdate updates Trivy database on all agents
|
||||
func (s *Server) handleBulkTrivyUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
hosts, err := s.db.GetHosts()
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to list hosts")
|
||||
return
|
||||
}
|
||||
|
||||
updateCount := 0
|
||||
for _, host := range hosts {
|
||||
if !isAgentHost(host.Address) {
|
||||
continue
|
||||
}
|
||||
|
||||
agentInfo, err := s.vulnScanner.GetAgentInfo(r.Context(), &host)
|
||||
if err != nil || !agentInfo.HasTrivy {
|
||||
continue
|
||||
}
|
||||
|
||||
// Launch update asynchronously
|
||||
go func(host models.Host) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
if err := updateAgentTrivyDB(ctx, host); err != nil {
|
||||
log.Printf("Failed to update Trivy DB on %s: %v", host.Name, err)
|
||||
}
|
||||
}(host)
|
||||
|
||||
updateCount++
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"message": "Trivy DB update initiated",
|
||||
"updated": updateCount,
|
||||
})
|
||||
}
|
||||
|
||||
// handleHostTrivyUpdate updates Trivy database on a specific agent
|
||||
func (s *Server) handleHostTrivyUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
hostID, err := strconv.ParseInt(vars["id"], 10, 64)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid host ID")
|
||||
return
|
||||
}
|
||||
|
||||
host, err := s.db.GetHost(hostID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "Host not found")
|
||||
return
|
||||
}
|
||||
|
||||
if !isAgentHost(host.Address) {
|
||||
respondError(w, http.StatusBadRequest, "Host is not an agent")
|
||||
return
|
||||
}
|
||||
|
||||
agentInfo, err := s.vulnScanner.GetAgentInfo(r.Context(), host)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadGateway, "Failed to connect to agent")
|
||||
return
|
||||
}
|
||||
|
||||
if !agentInfo.HasTrivy {
|
||||
respondError(w, http.StatusBadRequest, "Agent does not have Trivy installed")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
if err := updateAgentTrivyDB(ctx, *host); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to update Trivy DB: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]string{"message": "Trivy database updated successfully"})
|
||||
}
|
||||
|
||||
// handleHostTrivyClearCache clears Trivy cache on a specific agent
|
||||
func (s *Server) handleHostTrivyClearCache(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
hostID, err := strconv.ParseInt(vars["id"], 10, 64)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid host ID")
|
||||
return
|
||||
}
|
||||
|
||||
host, err := s.db.GetHost(hostID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "Host not found")
|
||||
return
|
||||
}
|
||||
|
||||
if !isAgentHost(host.Address) {
|
||||
respondError(w, http.StatusBadRequest, "Host is not an agent")
|
||||
return
|
||||
}
|
||||
|
||||
agentInfo, err := s.vulnScanner.GetAgentInfo(r.Context(), host)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadGateway, "Failed to connect to agent")
|
||||
return
|
||||
}
|
||||
|
||||
if !agentInfo.HasTrivy {
|
||||
respondError(w, http.StatusBadRequest, "Agent does not have Trivy installed")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := clearAgentTrivyCache(ctx, *host); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to clear cache: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]string{"message": "Trivy cache cleared successfully"})
|
||||
}
|
||||
|
||||
// updateAgentTrivyDB makes HTTP call to agent to update Trivy database
|
||||
func updateAgentTrivyDB(ctx context.Context, host models.Host) error {
|
||||
url := normalizeAgentURL(host.Address) + "/api/vulnerabilities/db-update"
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("X-API-Token", host.AgentToken)
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Minute}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("agent returned %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// clearAgentTrivyCache makes HTTP call to agent to clear Trivy cache
|
||||
func clearAgentTrivyCache(ctx context.Context, host models.Host) error {
|
||||
url := normalizeAgentURL(host.Address) + "/api/vulnerabilities/cache-clear"
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("X-API-Token", host.AgentToken)
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("agent returned %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isAgentHost checks if a host address is agent-based
|
||||
func isAgentHost(address string) bool {
|
||||
return strings.HasPrefix(address, "agent://") ||
|
||||
strings.HasPrefix(address, "http://") ||
|
||||
strings.HasPrefix(address, "https://")
|
||||
}
|
||||
|
||||
// normalizeAgentURL converts agent:// prefix to http:// and cleans URL
|
||||
func normalizeAgentURL(address string) string {
|
||||
address = strings.TrimPrefix(address, "agent://")
|
||||
if !strings.HasPrefix(address, "http://") && !strings.HasPrefix(address, "https://") {
|
||||
address = "http://" + address
|
||||
}
|
||||
return strings.TrimSuffix(address, "/")
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/container-census/container-census/internal/models"
|
||||
"github.com/selfhosters-cc/container-census/internal/models"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/container-census/container-census/internal/models"
|
||||
"github.com/container-census/container-census/internal/storage"
|
||||
"github.com/selfhosters-cc/container-census/internal/models"
|
||||
"github.com/selfhosters-cc/container-census/internal/storage"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/container-census/container-census/internal/models"
|
||||
"github.com/container-census/container-census/internal/plugins"
|
||||
"github.com/selfhosters-cc/container-census/internal/models"
|
||||
"github.com/selfhosters-cc/container-census/internal/plugins"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/container-census/container-census/internal/migration"
|
||||
"github.com/container-census/container-census/internal/models"
|
||||
"github.com/container-census/container-census/internal/storage"
|
||||
"github.com/selfhosters-cc/container-census/internal/migration"
|
||||
"github.com/selfhosters-cc/container-census/internal/models"
|
||||
"github.com/selfhosters-cc/container-census/internal/storage"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/container-census/container-census/internal/models"
|
||||
"github.com/container-census/container-census/internal/vulnerability"
|
||||
"github.com/selfhosters-cc/container-census/internal/models"
|
||||
"github.com/selfhosters-cc/container-census/internal/vulnerability"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
@@ -20,6 +20,7 @@ type VulnerabilityScanner interface {
|
||||
GetConfig() *vulnerability.Config
|
||||
SetConfig(config *vulnerability.Config)
|
||||
InvalidateCache(imageID string)
|
||||
GetAgentInfo(ctx context.Context, host *models.Host) (*models.AgentInfo, error)
|
||||
}
|
||||
|
||||
// VulnerabilityScheduler interface for the vulnerability scheduler
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/container-census/container-census/internal/models"
|
||||
"github.com/selfhosters-cc/container-census/internal/models"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/container-census/container-census/internal/models"
|
||||
"github.com/container-census/container-census/internal/storage"
|
||||
"github.com/container-census/container-census/internal/vulnerability"
|
||||
"github.com/selfhosters-cc/container-census/internal/models"
|
||||
"github.com/selfhosters-cc/container-census/internal/storage"
|
||||
"github.com/selfhosters-cc/container-census/internal/vulnerability"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/container-census/container-census/internal/config"
|
||||
"github.com/container-census/container-census/internal/models"
|
||||
"github.com/container-census/container-census/internal/storage"
|
||||
"github.com/selfhosters-cc/container-census/internal/config"
|
||||
"github.com/selfhosters-cc/container-census/internal/models"
|
||||
"github.com/selfhosters-cc/container-census/internal/storage"
|
||||
)
|
||||
|
||||
// ImportYAMLConfig imports settings from config.yaml to database (one-time migration)
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/container-census/container-census/internal/models"
|
||||
"github.com/container-census/container-census/internal/storage"
|
||||
"github.com/selfhosters-cc/container-census/internal/models"
|
||||
"github.com/selfhosters-cc/container-census/internal/storage"
|
||||
)
|
||||
|
||||
func TestImportYAMLConfig_FirstRun_WithValidYAML(t *testing.T) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/container-census/container-census/internal/storage"
|
||||
"github.com/selfhosters-cc/container-census/internal/storage"
|
||||
)
|
||||
|
||||
// TestIntegration_FirstRunScenario simulates the complete first-run workflow
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/container-census/container-census/internal/config"
|
||||
"github.com/container-census/container-census/internal/storage"
|
||||
"github.com/selfhosters-cc/container-census/internal/config"
|
||||
"github.com/selfhosters-cc/container-census/internal/storage"
|
||||
)
|
||||
|
||||
// TestServerStartup_FirstRun simulates exactly what happens when the server starts for the first time
|
||||
|
||||
+24
-13
@@ -15,13 +15,14 @@ type Host struct {
|
||||
AgentToken string `json:"agent_token,omitempty"` // API token for agent authentication
|
||||
AgentStatus string `json:"agent_status,omitempty"` // online, offline, unknown
|
||||
AgentVersion string `json:"agent_version,omitempty"` // version of the census agent
|
||||
LastSeen time.Time `json:"last_seen,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CollectStats bool `json:"collect_stats"` // whether to collect CPU/memory stats for this host
|
||||
ContainerCount int `json:"container_count,omitempty"` // total containers on this host
|
||||
RunningCount int `json:"running_count,omitempty"` // running containers on this host
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
LastSeen time.Time `json:"last_seen,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CollectStats bool `json:"collect_stats"` // whether to collect CPU/memory stats for this host
|
||||
EnableVulnerabilityScanning bool `json:"enable_vulnerability_scanning"` // whether to enable vulnerability scanning for this host
|
||||
ContainerCount int `json:"container_count,omitempty"` // total containers on this host
|
||||
RunningCount int `json:"running_count,omitempty"` // running containers on this host
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Container represents a Docker container found on a host
|
||||
@@ -202,6 +203,14 @@ type VulnerabilityConfig struct {
|
||||
MaxQueueSize int `yaml:"max_queue_size"`
|
||||
}
|
||||
|
||||
// TrivyDBMetadata tracks Trivy database version and update status per host
|
||||
type TrivyDBMetadata struct {
|
||||
HostID int64 `json:"host_id"`
|
||||
TrivyVersion string `json:"trivy_version"`
|
||||
DBVersion string `json:"db_version"`
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
}
|
||||
|
||||
// HostConfig contains host configuration
|
||||
type HostConfig struct {
|
||||
Name string `yaml:"name"`
|
||||
@@ -211,12 +220,14 @@ type HostConfig struct {
|
||||
|
||||
// AgentInfo represents agent metadata
|
||||
type AgentInfo struct {
|
||||
Version string `json:"version"`
|
||||
Hostname string `json:"hostname"`
|
||||
OS string `json:"os"`
|
||||
Arch string `json:"arch"`
|
||||
DockerVersion string `json:"docker_version"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
Version string `json:"version"`
|
||||
Hostname string `json:"hostname"`
|
||||
OS string `json:"os"`
|
||||
Arch string `json:"arch"`
|
||||
DockerVersion string `json:"docker_version"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
HasTrivy bool `json:"has_trivy"` // Whether Trivy is available on this agent
|
||||
TrivyVersion string `json:"trivy_version"` // Trivy version if available
|
||||
}
|
||||
|
||||
// AgentRequest wraps requests sent to agents
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/container-census/container-census/internal/models"
|
||||
"github.com/container-census/container-census/internal/storage"
|
||||
"github.com/selfhosters-cc/container-census/internal/models"
|
||||
"github.com/selfhosters-cc/container-census/internal/storage"
|
||||
)
|
||||
|
||||
// BaselineCollector manages baseline statistics for anomaly detection
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/container-census/container-census/internal/models"
|
||||
"github.com/container-census/container-census/internal/storage"
|
||||
"github.com/selfhosters-cc/container-census/internal/models"
|
||||
"github.com/selfhosters-cc/container-census/internal/storage"
|
||||
)
|
||||
|
||||
// setupTestBaseline creates a test baseline collector
|
||||
|
||||
@@ -3,7 +3,7 @@ package channels
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/container-census/container-census/internal/models"
|
||||
"github.com/selfhosters-cc/container-census/internal/models"
|
||||
)
|
||||
|
||||
// Channel represents a notification delivery channel
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/container-census/container-census/internal/models"
|
||||
"github.com/container-census/container-census/internal/storage"
|
||||
"github.com/selfhosters-cc/container-census/internal/models"
|
||||
"github.com/selfhosters-cc/container-census/internal/storage"
|
||||
)
|
||||
|
||||
// InAppChannel implements in-app notifications (writes to notification_log)
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/container-census/container-census/internal/models"
|
||||
"github.com/container-census/container-census/internal/storage"
|
||||
"github.com/selfhosters-cc/container-census/internal/models"
|
||||
"github.com/selfhosters-cc/container-census/internal/storage"
|
||||
)
|
||||
|
||||
// setupTestInAppChannel creates a test in-app channel with database
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/container-census/container-census/internal/models"
|
||||
"github.com/selfhosters-cc/container-census/internal/models"
|
||||
)
|
||||
|
||||
// NtfyChannel implements ntfy.sh notifications
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/container-census/container-census/internal/models"
|
||||
"github.com/selfhosters-cc/container-census/internal/models"
|
||||
)
|
||||
|
||||
// TestNtfyChannel_BasicSend tests basic ntfy notification
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/container-census/container-census/internal/models"
|
||||
"github.com/selfhosters-cc/container-census/internal/models"
|
||||
)
|
||||
|
||||
// WebhookChannel implements webhook notifications
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/container-census/container-census/internal/models"
|
||||
"github.com/selfhosters-cc/container-census/internal/models"
|
||||
)
|
||||
|
||||
// TestWebhookChannel_SuccessfulDelivery tests successful webhook delivery
|
||||
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/container-census/container-census/internal/models"
|
||||
"github.com/container-census/container-census/internal/notifications/channels"
|
||||
"github.com/container-census/container-census/internal/storage"
|
||||
"github.com/selfhosters-cc/container-census/internal/models"
|
||||
"github.com/selfhosters-cc/container-census/internal/notifications/channels"
|
||||
"github.com/selfhosters-cc/container-census/internal/storage"
|
||||
)
|
||||
|
||||
// NotificationService handles all notification logic
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/container-census/container-census/internal/models"
|
||||
"github.com/container-census/container-census/internal/storage"
|
||||
"github.com/selfhosters-cc/container-census/internal/models"
|
||||
"github.com/selfhosters-cc/container-census/internal/storage"
|
||||
)
|
||||
|
||||
// setupTestNotifier creates a test notification service with an in-memory database
|
||||
|
||||
@@ -3,7 +3,7 @@ package graph
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/container-census/container-census/internal/models"
|
||||
"github.com/selfhosters-cc/container-census/internal/models"
|
||||
)
|
||||
|
||||
type GraphNode struct {
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
_ "embed"
|
||||
"net/http"
|
||||
|
||||
"github.com/container-census/container-census/internal/plugins"
|
||||
"github.com/selfhosters-cc/container-census/internal/plugins"
|
||||
)
|
||||
|
||||
//go:embed frontend/bundle.js
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/container-census/container-census/internal/models"
|
||||
"github.com/container-census/container-census/internal/plugins"
|
||||
"github.com/selfhosters-cc/container-census/internal/models"
|
||||
"github.com/selfhosters-cc/container-census/internal/plugins"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -421,26 +421,20 @@ async function loadVulnerabilityScans() {
|
||||
}
|
||||
}
|
||||
|
||||
// Store scans globally for filtering
|
||||
let allVulnerabilityScans = [];
|
||||
|
||||
// Render vulnerability scans table
|
||||
function renderVulnerabilityScans(scans) {
|
||||
const tbody = document.getElementById('securityScansBody');
|
||||
if (!tbody) return;
|
||||
|
||||
// Filter out remote/agent scans (images not available for local scanning)
|
||||
const localScans = scans ? scans.filter(scan => {
|
||||
// Exclude scans that failed because image wasn't available (agent scans)
|
||||
return !(scan.success === false && scan.error && scan.error.includes('image not available'));
|
||||
}) : [];
|
||||
|
||||
// Count filtered scans
|
||||
const filteredCount = scans ? scans.length - localScans.length : 0;
|
||||
|
||||
// Deduplicate by image_id (keep most recent scan)
|
||||
const uniqueScans = [];
|
||||
const seenImages = new Set();
|
||||
|
||||
// Sort by scanned_at descending to get most recent first
|
||||
const sortedScans = [...localScans].sort((a, b) => {
|
||||
const sortedScans = [...(scans || [])].sort((a, b) => {
|
||||
const dateA = new Date(a.scanned_at);
|
||||
const dateB = new Date(b.scanned_at);
|
||||
return dateB - dateA;
|
||||
@@ -453,36 +447,18 @@ function renderVulnerabilityScans(scans) {
|
||||
}
|
||||
}
|
||||
|
||||
// Store scans globally for filtering
|
||||
allVulnerabilityScans = uniqueScans;
|
||||
|
||||
// Populate host filter dropdown
|
||||
populateHostFilter(uniqueScans);
|
||||
|
||||
// Update scan count badge
|
||||
const scanCountBadge = document.getElementById('scanCountBadge');
|
||||
if (scanCountBadge) {
|
||||
scanCountBadge.textContent = `${uniqueScans.length} images`;
|
||||
}
|
||||
|
||||
// Update or create exclusion notice
|
||||
let exclusionNotice = document.getElementById('exclusionNotice');
|
||||
const tableCard = document.querySelector('.security-table-card');
|
||||
|
||||
if (filteredCount > 0) {
|
||||
if (!exclusionNotice) {
|
||||
exclusionNotice = document.createElement('div');
|
||||
exclusionNotice.id = 'exclusionNotice';
|
||||
exclusionNotice.className = 'security-queue-status';
|
||||
exclusionNotice.style.marginBottom = '20px';
|
||||
tableCard.insertBefore(exclusionNotice, tableCard.firstChild);
|
||||
}
|
||||
exclusionNotice.innerHTML = `
|
||||
<div class="queue-status-icon">ℹ️</div>
|
||||
<div class="queue-status-content">
|
||||
<strong>Remote Agent Images:</strong>
|
||||
${filteredCount} images from remote agents are not shown. Agents do not have the ability to scan for vulnerabilities.
|
||||
</div>
|
||||
`;
|
||||
exclusionNotice.style.display = 'flex';
|
||||
} else if (exclusionNotice) {
|
||||
exclusionNotice.style.display = 'none';
|
||||
}
|
||||
|
||||
if (!uniqueScans || uniqueScans.length === 0) {
|
||||
tbody.innerHTML = `<tr><td colspan="9" class="no-data">No vulnerability scans found</td></tr>`;
|
||||
return;
|
||||
@@ -494,21 +470,50 @@ function renderVulnerabilityScans(scans) {
|
||||
const scannedAt = new Date(scan.scanned_at).toLocaleString();
|
||||
|
||||
let statusBadge = '';
|
||||
let severityClass = '';
|
||||
if (!scan.success) {
|
||||
statusBadge = '❌ Failed';
|
||||
severityClass = 'failed';
|
||||
} else if (total === 0) {
|
||||
statusBadge = '✅ Clean';
|
||||
severityClass = 'clean';
|
||||
} else if (counts.critical > 0) {
|
||||
statusBadge = '🚨 Critical';
|
||||
severityClass = 'critical';
|
||||
} else if (counts.high > 0) {
|
||||
statusBadge = '⚠️ High';
|
||||
severityClass = 'high';
|
||||
} else if (counts.medium > 0) {
|
||||
statusBadge = '📊 Issues';
|
||||
severityClass = 'medium';
|
||||
} else {
|
||||
statusBadge = '📊 Issues';
|
||||
severityClass = 'low';
|
||||
}
|
||||
|
||||
// Prepare host IDs and names for data attributes
|
||||
const hostIds = (scan.host_ids || []).join(',');
|
||||
const hostNames = (scan.host_names || []).join(',');
|
||||
|
||||
// Format host names for display (shown below image name)
|
||||
const hostNamesDisplay = (scan.host_names && scan.host_names.length > 0)
|
||||
? `<div style="font-size: 0.85em; color: var(--text-secondary, #666); margin-top: 4px;">
|
||||
${scan.host_names.join(', ')}
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<tr data-image-id="${scan.image_id}" style="cursor: pointer;" onclick="window.viewScanDetails('${scan.image_id}')">
|
||||
<td title="${scan.image_name || scan.image_id}">${scan.image_name || scan.image_id}</td>
|
||||
<tr data-image-id="${scan.image_id}"
|
||||
data-host-ids="${hostIds}"
|
||||
data-host-names="${hostNames}"
|
||||
data-severity="${severityClass}"
|
||||
data-image-name="${(scan.image_name || scan.image_id).toLowerCase()}"
|
||||
style="cursor: pointer;"
|
||||
onclick="window.viewScanDetails('${scan.image_id}')">
|
||||
<td title="${scan.image_name || scan.image_id}">
|
||||
<div>${scan.image_name || scan.image_id}</div>
|
||||
${hostNamesDisplay}
|
||||
</td>
|
||||
<td>${statusBadge}</td>
|
||||
<td>${total}</td>
|
||||
<td>${counts.critical || 0}</td>
|
||||
@@ -523,6 +528,122 @@ function renderVulnerabilityScans(scans) {
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// Setup filter event listeners (only once)
|
||||
setupFilterListeners();
|
||||
}
|
||||
|
||||
// Populate host filter dropdown
|
||||
function populateHostFilter(scans) {
|
||||
const hostFilter = document.getElementById('securityHostFilter');
|
||||
if (!hostFilter) return;
|
||||
|
||||
// Collect all unique hosts
|
||||
const hostsMap = new Map();
|
||||
scans.forEach(scan => {
|
||||
if (scan.host_ids && scan.host_names) {
|
||||
scan.host_ids.forEach((hostId, index) => {
|
||||
const hostName = scan.host_names[index] || `Host ${hostId}`;
|
||||
hostsMap.set(hostId, hostName);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Sort hosts by name
|
||||
const hosts = Array.from(hostsMap.entries()).sort((a, b) => a[1].localeCompare(b[1]));
|
||||
|
||||
// Preserve current selection
|
||||
const currentValue = hostFilter.value;
|
||||
|
||||
// Rebuild dropdown
|
||||
hostFilter.innerHTML = '<option value="">All Hosts</option>' +
|
||||
hosts.map(([id, name]) => `<option value="${id}">${name}</option>`).join('');
|
||||
|
||||
// Restore selection if it still exists
|
||||
if (currentValue && Array.from(hostFilter.options).some(opt => opt.value === currentValue)) {
|
||||
hostFilter.value = currentValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Setup filter event listeners
|
||||
let filtersSetup = false;
|
||||
function setupFilterListeners() {
|
||||
if (filtersSetup) return;
|
||||
filtersSetup = true;
|
||||
|
||||
const hostFilter = document.getElementById('securityHostFilter');
|
||||
const severityFilter = document.getElementById('securitySeverityFilter');
|
||||
const statusFilter = document.getElementById('securityStatusFilter');
|
||||
const searchInput = document.getElementById('securitySearchInput');
|
||||
|
||||
if (hostFilter) hostFilter.addEventListener('change', applyFilters);
|
||||
if (severityFilter) severityFilter.addEventListener('change', applyFilters);
|
||||
if (statusFilter) statusFilter.addEventListener('change', applyFilters);
|
||||
if (searchInput) searchInput.addEventListener('input', applyFilters);
|
||||
}
|
||||
|
||||
// Apply all filters
|
||||
function applyFilters() {
|
||||
const hostFilter = document.getElementById('securityHostFilter')?.value || '';
|
||||
const severityFilter = document.getElementById('securitySeverityFilter')?.value || '';
|
||||
const statusFilter = document.getElementById('securityStatusFilter')?.value || '';
|
||||
const searchText = document.getElementById('securitySearchInput')?.value.toLowerCase() || '';
|
||||
|
||||
const tbody = document.getElementById('securityScansBody');
|
||||
if (!tbody) return;
|
||||
|
||||
const rows = tbody.querySelectorAll('tr[data-image-id]');
|
||||
let visibleCount = 0;
|
||||
|
||||
rows.forEach(row => {
|
||||
let show = true;
|
||||
|
||||
// Host filter
|
||||
if (hostFilter) {
|
||||
const hostIds = row.getAttribute('data-host-ids') || '';
|
||||
if (!hostIds.split(',').includes(hostFilter)) {
|
||||
show = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Severity filter
|
||||
if (severityFilter && show) {
|
||||
const severity = row.getAttribute('data-severity') || '';
|
||||
if (severity !== severityFilter) {
|
||||
show = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Status filter
|
||||
if (statusFilter && show) {
|
||||
const severity = row.getAttribute('data-severity') || '';
|
||||
if (statusFilter === 'scanned' && (severity === 'failed' || severity === '')) {
|
||||
show = false;
|
||||
} else if (statusFilter === 'failed' && severity !== 'failed') {
|
||||
show = false;
|
||||
} else if (statusFilter === 'remote') {
|
||||
// This filter doesn't apply to the table (remote scans are already filtered out)
|
||||
show = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Search filter
|
||||
if (searchText && show) {
|
||||
const imageName = row.getAttribute('data-image-name') || '';
|
||||
if (!imageName.includes(searchText)) {
|
||||
show = false;
|
||||
}
|
||||
}
|
||||
|
||||
row.style.display = show ? '' : 'none';
|
||||
if (show) visibleCount++;
|
||||
});
|
||||
|
||||
// Update count badge
|
||||
const scanCountBadge = document.getElementById('scanCountBadge');
|
||||
if (scanCountBadge) {
|
||||
scanCountBadge.textContent = `${visibleCount} images`;
|
||||
}
|
||||
}
|
||||
|
||||
// View detailed scan results
|
||||
@@ -662,7 +783,16 @@ window.scanAllImages = async function() {
|
||||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
||||
|
||||
const result = await response.json();
|
||||
alert(`${result.images_queued} images queued for scanning`);
|
||||
|
||||
// Build detailed message with per-host breakdown
|
||||
let message = `${result.total_queued} images queued for scanning`;
|
||||
if (result.queued_by_host && Object.keys(result.queued_by_host).length > 0) {
|
||||
const breakdown = Object.entries(result.queued_by_host)
|
||||
.map(([host, count]) => `${host}: ${count}`)
|
||||
.join(', ');
|
||||
message += `\n\n${breakdown}`;
|
||||
}
|
||||
alert(message);
|
||||
|
||||
setTimeout(() => {
|
||||
loadVulnerabilitySummary();
|
||||
@@ -881,6 +1011,21 @@ window.filterSecurityScans = function() {
|
||||
|
||||
// Initialize security tab
|
||||
async function initSecurityTab() {
|
||||
// Attach event listeners to buttons
|
||||
const scanAllBtn = document.getElementById('scanAllImagesBtn');
|
||||
const updateDbBtn = document.getElementById('updateTrivyDBBtn');
|
||||
const settingsBtn = document.getElementById('vulnerabilitySettingsBtn');
|
||||
|
||||
if (scanAllBtn) {
|
||||
scanAllBtn.addEventListener('click', window.scanAllImages);
|
||||
}
|
||||
if (updateDbBtn) {
|
||||
updateDbBtn.addEventListener('click', window.updateTrivyDB);
|
||||
}
|
||||
if (settingsBtn) {
|
||||
settingsBtn.addEventListener('click', window.showSecuritySettings);
|
||||
}
|
||||
|
||||
// Pre-load vulnerability scans
|
||||
await preloadVulnerabilityScans();
|
||||
|
||||
@@ -983,6 +1128,14 @@ window.initSecurityPlugin = function(container, sdk) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="security-queue-status" style="display: flex; background-color: var(--info-bg, #e3f2fd); border-color: var(--info-border, #2196f3);">
|
||||
<div class="queue-status-icon">ℹ️</div>
|
||||
<div class="queue-status-content">
|
||||
<strong>Note:</strong>
|
||||
Only images from hosts with vulnerability scanning enabled are shown here. Agents do not have the ability to scan for vulnerabilities locally.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="security-charts-grid">
|
||||
<div class="security-chart-card">
|
||||
<div class="chart-card-header">
|
||||
@@ -1012,6 +1165,9 @@ window.initSecurityPlugin = function(container, sdk) {
|
||||
<span class="scan-count" id="scanCountBadge">0 scans</span>
|
||||
</div>
|
||||
<div class="security-filters-modern">
|
||||
<select id="securityHostFilter" class="filter-select">
|
||||
<option value="">All Hosts</option>
|
||||
</select>
|
||||
<select id="securitySeverityFilter" class="filter-select">
|
||||
<option value="">All Severities</option>
|
||||
<option value="critical">Critical</option>
|
||||
|
||||
@@ -3,12 +3,15 @@ package security
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/container-census/container-census/internal/models"
|
||||
"github.com/container-census/container-census/internal/vulnerability"
|
||||
"github.com/selfhosters-cc/container-census/internal/models"
|
||||
"github.com/selfhosters-cc/container-census/internal/vulnerability"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
@@ -184,30 +187,90 @@ func (p *SecurityPlugin) handleTriggerScan(w http.ResponseWriter, r *http.Reques
|
||||
})
|
||||
}
|
||||
|
||||
// handleScanAll queues all images for rescanning
|
||||
// handleScanAll queues all images for rescanning across selected hosts
|
||||
func (p *SecurityPlugin) handleScanAll(w http.ResponseWriter, r *http.Request) {
|
||||
if p.vulnScheduler == nil {
|
||||
respondError(w, http.StatusServiceUnavailable, "Vulnerability scanner not available")
|
||||
return
|
||||
}
|
||||
|
||||
// Get all unique images from recent scans
|
||||
scans, err := p.db.GetAllVulnerabilityScans(1000)
|
||||
// Try to parse request body for host IDs
|
||||
var req struct {
|
||||
HostIDs []int64 `json:"host_ids"` // Optional: if empty, scan all enabled hosts
|
||||
}
|
||||
|
||||
// Parse body if present (ignore errors for backward compatibility)
|
||||
if r.Body != nil {
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
}
|
||||
|
||||
// Get all hosts
|
||||
hosts, err := p.db.GetHosts()
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to get scans: "+err.Error())
|
||||
respondError(w, http.StatusInternalServerError, "Failed to get hosts: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
imageMap := make(map[string]string)
|
||||
for _, scan := range scans {
|
||||
imageMap[scan.ImageID] = scan.ImageName
|
||||
// Determine which hosts to scan
|
||||
hostsToScan := make(map[int64]models.Host)
|
||||
if len(req.HostIDs) > 0 {
|
||||
// Scan only selected hosts
|
||||
for _, hostID := range req.HostIDs {
|
||||
// Find and include this host
|
||||
for _, host := range hosts {
|
||||
if host.ID == hostID {
|
||||
hostsToScan[hostID] = host
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Scan all hosts (default behavior)
|
||||
for _, host := range hosts {
|
||||
hostsToScan[host.ID] = host
|
||||
}
|
||||
}
|
||||
|
||||
count := p.vulnScheduler.RescanAll(imageMap)
|
||||
queuedByHost := make(map[string]int)
|
||||
totalQueued := 0
|
||||
|
||||
// Get all containers
|
||||
localContainers, err := p.db.GetLatestContainers()
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to get containers: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Scan each selected host
|
||||
for hostID, host := range hostsToScan {
|
||||
hostCount := 0
|
||||
imagesSeen := make(map[string]bool)
|
||||
|
||||
// Get containers for this host
|
||||
for _, container := range localContainers {
|
||||
if container.HostID == hostID && !imagesSeen[container.ImageID] {
|
||||
imagesSeen[container.ImageID] = true
|
||||
// Use force=true to bypass cache - user explicitly requested scan
|
||||
if err := p.vulnScheduler.QueueScanWithHostForce(container.ImageID, container.Image, hostID, 5); err == nil {
|
||||
hostCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if hostCount > 0 {
|
||||
hostName := host.Name
|
||||
if hostName == "" {
|
||||
hostName = "Local"
|
||||
}
|
||||
queuedByHost[hostName] = hostCount
|
||||
totalQueued += hostCount
|
||||
}
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"message": "Rescan triggered",
|
||||
"images_queued": count,
|
||||
"message": "Multi-host scan triggered",
|
||||
"queued_by_host": queuedByHost,
|
||||
"total_queued": totalQueued,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -222,24 +285,193 @@ func (p *SecurityPlugin) handleGetQueue(w http.ResponseWriter, r *http.Request)
|
||||
respondJSON(w, http.StatusOK, status)
|
||||
}
|
||||
|
||||
// handleUpdateDB triggers an update of the Trivy vulnerability database
|
||||
func (p *SecurityPlugin) handleUpdateDB(w http.ResponseWriter, r *http.Request) {
|
||||
if p.vulnScanner == nil {
|
||||
// handleGetProgress returns detailed scan progress information
|
||||
func (p *SecurityPlugin) handleGetProgress(w http.ResponseWriter, r *http.Request) {
|
||||
if p.vulnScheduler == nil {
|
||||
respondError(w, http.StatusServiceUnavailable, "Vulnerability scanner not available")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
|
||||
defer cancel()
|
||||
status := p.vulnScheduler.GetQueueStatus()
|
||||
|
||||
err := p.vulnScanner.UpdateTrivyDB(ctx)
|
||||
// Transform queue items into current scans format
|
||||
currentScans := make([]map[string]interface{}, 0)
|
||||
for _, item := range status.QueueItems {
|
||||
// Only include items that are likely being processed (first N items where N = active workers)
|
||||
if len(currentScans) < status.ActiveWorkers {
|
||||
currentScans = append(currentScans, map[string]interface{}{
|
||||
"image_id": item.ImageID,
|
||||
"image_name": item.ImageName,
|
||||
"host_id": item.HostID,
|
||||
"host_name": item.HostName,
|
||||
"started_at": item.QueuedAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"in_progress": status.InProgress,
|
||||
"pending": status.Queued,
|
||||
"total": status.InProgress + status.Queued,
|
||||
"current_scans": currentScans,
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// handleGetTrivyStatus returns Trivy status for all hosts
|
||||
func (p *SecurityPlugin) handleGetTrivyStatus(w http.ResponseWriter, r *http.Request) {
|
||||
// Get all hosts
|
||||
hosts, err := p.db.GetHosts()
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to update Trivy database: "+err.Error())
|
||||
respondError(w, http.StatusInternalServerError, "Failed to get hosts: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
type TrivyHostStatus struct {
|
||||
HostID int64 `json:"host_id"`
|
||||
HostName string `json:"host_name"`
|
||||
TrivyVersion string `json:"trivy_version"`
|
||||
DBVersion string `json:"db_version"`
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
HasTrivy bool `json:"has_trivy"`
|
||||
}
|
||||
|
||||
var hostStatuses []TrivyHostStatus
|
||||
|
||||
// Check each host (including unix and agent types)
|
||||
for _, host := range hosts {
|
||||
status := TrivyHostStatus{
|
||||
HostID: host.ID,
|
||||
HostName: host.Name,
|
||||
HasTrivy: false,
|
||||
}
|
||||
|
||||
// Get metadata from database
|
||||
if metadata, err := p.db.GetTrivyDBMetadata(host.ID); err == nil && metadata != nil {
|
||||
status.TrivyVersion = metadata.TrivyVersion
|
||||
status.DBVersion = metadata.DBVersion
|
||||
status.LastUpdated = metadata.LastUpdated
|
||||
}
|
||||
|
||||
// Determine if host has Trivy based on type
|
||||
if host.HostType == "unix" {
|
||||
// Local unix socket - has Trivy if scanner is available
|
||||
status.HasTrivy = p.vulnScanner != nil
|
||||
} else if host.HostType == "agent" && p.vulnScanner != nil {
|
||||
// Agent hosts - query the /info endpoint to check Trivy capability
|
||||
if agentInfo, err := p.vulnScanner.GetAgentInfo(r.Context(), &host); err == nil {
|
||||
status.HasTrivy = agentInfo.HasTrivy
|
||||
if agentInfo.TrivyVersion != "" {
|
||||
status.TrivyVersion = agentInfo.TrivyVersion
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hostStatuses = append(hostStatuses, status)
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"hosts": hostStatuses,
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// handleUpdateDB triggers an update of the Trivy vulnerability database
|
||||
func (p *SecurityPlugin) handleUpdateDB(w http.ResponseWriter, r *http.Request) {
|
||||
// Parse request body for host IDs (optional)
|
||||
type UpdateRequest struct {
|
||||
HostIDs []int64 `json:"host_ids"`
|
||||
}
|
||||
|
||||
var req UpdateRequest
|
||||
if r.Body != nil {
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
}
|
||||
|
||||
// Get all hosts
|
||||
hosts, err := p.db.GetHosts()
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to get hosts: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// If no host IDs specified, update local unix hosts only (default behavior)
|
||||
if len(req.HostIDs) == 0 {
|
||||
for _, h := range hosts {
|
||||
if h.HostType == "unix" {
|
||||
req.HostIDs = append(req.HostIDs, h.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type UpdateResult struct {
|
||||
HostID int64 `json:"host_id"`
|
||||
HostName string `json:"host_name"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
var results []UpdateResult
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
// Build host map for lookups
|
||||
hostMap := make(map[int64]*models.Host)
|
||||
for i := range hosts {
|
||||
hostMap[hosts[i].ID] = &hosts[i]
|
||||
}
|
||||
|
||||
for _, hostID := range req.HostIDs {
|
||||
targetHost := hostMap[hostID]
|
||||
|
||||
result := UpdateResult{
|
||||
HostID: hostID,
|
||||
HostName: "Unknown",
|
||||
}
|
||||
|
||||
if targetHost != nil {
|
||||
result.HostName = targetHost.Name
|
||||
}
|
||||
|
||||
if targetHost == nil {
|
||||
result.Success = false
|
||||
result.Error = "Host not found"
|
||||
} else if targetHost.HostType == "unix" {
|
||||
// Update local Trivy DB (unix socket host)
|
||||
if p.vulnScanner == nil {
|
||||
result.Success = false
|
||||
result.Error = "Local vulnerability scanner not available"
|
||||
} else if err := p.vulnScanner.UpdateTrivyDB(ctx); err != nil {
|
||||
result.Success = false
|
||||
result.Error = err.Error()
|
||||
} else {
|
||||
result.Success = true
|
||||
// Save metadata
|
||||
p.db.SaveTrivyDBMetadata(targetHost.ID, "", "")
|
||||
}
|
||||
} else if targetHost.HostType == "agent" {
|
||||
// Update agent Trivy DB
|
||||
updateErr := updateAgentTrivyDB(ctx, *targetHost)
|
||||
if updateErr != nil {
|
||||
result.Success = false
|
||||
result.Error = updateErr.Error()
|
||||
} else {
|
||||
result.Success = true
|
||||
// Save metadata to track last update time
|
||||
p.db.SaveTrivyDBMetadata(targetHost.ID, "", "")
|
||||
}
|
||||
} else {
|
||||
result.Success = false
|
||||
result.Error = "Not a unix or agent host"
|
||||
}
|
||||
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"message": "Trivy database updated successfully",
|
||||
"results": results,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -333,3 +565,40 @@ func (p *SecurityPlugin) handleClear(w http.ResponseWriter, r *http.Request) {
|
||||
"message": "All vulnerability scans and CVE data deleted",
|
||||
})
|
||||
}
|
||||
|
||||
// updateAgentTrivyDB calls the agent's Trivy DB update endpoint
|
||||
func updateAgentTrivyDB(ctx context.Context, host models.Host) error {
|
||||
// Build agent URL
|
||||
agentURL := host.Address
|
||||
if !strings.HasPrefix(agentURL, "http://") && !strings.HasPrefix(agentURL, "https://") {
|
||||
agentURL = "http://" + agentURL
|
||||
}
|
||||
agentURL = strings.TrimSuffix(agentURL, "/") + "/api/vulnerabilities/db-update"
|
||||
|
||||
// Create request
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", agentURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Add auth token
|
||||
if host.AgentToken != "" {
|
||||
req.Header.Set("X-API-Token", host.AgentToken)
|
||||
}
|
||||
|
||||
// Send request
|
||||
client := &http.Client{Timeout: 10 * time.Minute}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to call agent: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check response
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("agent returned error %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -9,10 +9,10 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/container-census/container-census/internal/plugins"
|
||||
"github.com/container-census/container-census/internal/scanner"
|
||||
"github.com/container-census/container-census/internal/storage"
|
||||
"github.com/container-census/container-census/internal/vulnerability"
|
||||
"github.com/selfhosters-cc/container-census/internal/plugins"
|
||||
"github.com/selfhosters-cc/container-census/internal/scanner"
|
||||
"github.com/selfhosters-cc/container-census/internal/storage"
|
||||
"github.com/selfhosters-cc/container-census/internal/vulnerability"
|
||||
)
|
||||
|
||||
//go:embed frontend/bundle.js
|
||||
@@ -110,7 +110,7 @@ func (p *SecurityPlugin) Start(ctx context.Context) error {
|
||||
p.vulnScanner = vulnerability.NewScanner(vulnConfig, p.db)
|
||||
|
||||
// Initialize vulnerability scheduler
|
||||
p.vulnScheduler = vulnerability.NewScheduler(p.vulnScanner, vulnConfig)
|
||||
p.vulnScheduler = vulnerability.NewScheduler(p.vulnScanner, vulnConfig, p.db)
|
||||
|
||||
// Start scheduler
|
||||
p.vulnScheduler.Start()
|
||||
@@ -149,6 +149,8 @@ func (p *SecurityPlugin) Routes() []plugins.Route {
|
||||
{Path: "/scan/{imageId}", Method: "POST", Handler: p.handleTriggerScan},
|
||||
{Path: "/scan-all", Method: "POST", Handler: p.handleScanAll},
|
||||
{Path: "/queue", Method: "GET", Handler: p.handleGetQueue},
|
||||
{Path: "/progress", Method: "GET", Handler: p.handleGetProgress},
|
||||
{Path: "/trivy-status", Method: "GET", Handler: p.handleGetTrivyStatus},
|
||||
{Path: "/update-db", Method: "POST", Handler: p.handleUpdateDB},
|
||||
{Path: "/settings", Method: "GET", Handler: p.handleGetSettings},
|
||||
{Path: "/settings", Method: "PUT", Handler: p.handleUpdateSettings},
|
||||
@@ -156,65 +158,16 @@ func (p *SecurityPlugin) Routes() []plugins.Route {
|
||||
}
|
||||
}
|
||||
|
||||
// Tab returns the tab definition
|
||||
// Tab returns the tab definition for the Integrations sidebar
|
||||
func (p *SecurityPlugin) Tab() *plugins.TabDefinition {
|
||||
contentHTML := `
|
||||
<div class="security-section-modern">
|
||||
<div class="security-header-modern">
|
||||
<div class="security-title-group">
|
||||
<h2>🛡️ Vulnerability Scanner</h2>
|
||||
<p class="security-subtitle">Monitor and track security vulnerabilities across all container images</p>
|
||||
</div>
|
||||
<div class="security-actions">
|
||||
<button onclick="window.scanAllImages()" class="btn btn-primary">🔄 Scan All Images</button>
|
||||
<button onclick="window.updateTrivyDB()" class="btn btn-secondary">📥 Update Database</button>
|
||||
<button onclick="window.showSecuritySettings()" class="btn btn-secondary">⚙️ Settings</button>
|
||||
<button onclick="window.exportVulnerabilityData()" class="btn btn-secondary">📊 Export</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="vulnerabilitySummary"></div>
|
||||
|
||||
<div class="security-table-card">
|
||||
<div class="security-table-header-modern">
|
||||
<div class="table-title-group">
|
||||
<h3>Vulnerability Scans</h3>
|
||||
<span class="scan-count" id="scanCountBadge">0 scans</span>
|
||||
</div>
|
||||
<div class="security-filters-modern">
|
||||
<input type="text" id="securitySearch" class="search-input" placeholder="🔍 Search images..." oninput="window.filterSecurityScans()">
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table class="vulnerability-table" id="securityScansTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Image Name</th>
|
||||
<th>Status</th>
|
||||
<th>Total Vulnerabilities</th>
|
||||
<th>Severity Breakdown</th>
|
||||
<th>Scanned At</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colspan="5" class="loading">Loading vulnerability scans...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// Return tab definition so it appears in sidebar, but without vanilla JS content
|
||||
// The Next.js page at /integrations/security provides the actual UI
|
||||
return &plugins.TabDefinition{
|
||||
ID: "security",
|
||||
Label: "Security",
|
||||
Icon: "🛡️",
|
||||
Order: 12, // After containers (10), before integrations (14)
|
||||
ContentHTML: contentHTML,
|
||||
ScriptURL: "/bundle.js", // Relative to /api/p/security/
|
||||
InitFunc: "initSecurityPlugin",
|
||||
ID: "security",
|
||||
Label: "Security",
|
||||
Icon: "🛡️",
|
||||
Order: 12, // After containers (10), before other integrations
|
||||
// No ContentHTML or ScriptURL - Next.js handles the UI
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package security
|
||||
|
||||
import "github.com/container-census/container-census/internal/plugins"
|
||||
import "github.com/selfhosters-cc/container-census/internal/plugins"
|
||||
|
||||
// Register registers the security plugin with the plugin manager
|
||||
func Register(manager *plugins.Manager) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/container-census/container-census/internal/models"
|
||||
"github.com/selfhosters-cc/container-census/internal/models"
|
||||
)
|
||||
|
||||
// Plugin represents a Container Census plugin
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/container-census/container-census/internal/models"
|
||||
"github.com/container-census/container-census/internal/storage"
|
||||
"github.com/selfhosters-cc/container-census/internal/models"
|
||||
"github.com/selfhosters-cc/container-census/internal/storage"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/container-census/container-census/internal/models"
|
||||
"github.com/selfhosters-cc/container-census/internal/models"
|
||||
imagetypes "github.com/docker/docker/api/types/image"
|
||||
)
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/container-census/container-census/internal/models"
|
||||
"github.com/selfhosters-cc/container-census/internal/models"
|
||||
containertypes "github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
imagetypes "github.com/docker/docker/api/types/image"
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/container-census/container-census/internal/models"
|
||||
"github.com/selfhosters-cc/container-census/internal/models"
|
||||
)
|
||||
|
||||
// TestCleanupSimple is a minimal test for notification cleanup
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/container-census/container-census/internal/models"
|
||||
"github.com/selfhosters-cc/container-census/internal/models"
|
||||
)
|
||||
|
||||
// TestCleanupOldNotifications tests clearing old notifications
|
||||
|
||||
+45
-10
@@ -9,7 +9,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/container-census/container-census/internal/models"
|
||||
"github.com/selfhosters-cc/container-census/internal/models"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
@@ -329,6 +329,14 @@ func (db *DB) initSchema() error {
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS trivy_db_metadata (
|
||||
host_id INTEGER PRIMARY KEY,
|
||||
trivy_version TEXT,
|
||||
db_version TEXT,
|
||||
last_updated TIMESTAMP,
|
||||
FOREIGN KEY (host_id) REFERENCES hosts(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
@@ -472,6 +480,23 @@ func (db *DB) runMigrations() error {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if enable_vulnerability_scanning column exists in hosts table
|
||||
var enableVulnScanExists int
|
||||
err = db.conn.QueryRow(`
|
||||
SELECT COUNT(*) FROM pragma_table_info('hosts') WHERE name='enable_vulnerability_scanning'
|
||||
`).Scan(&enableVulnScanExists)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if enableVulnScanExists == 0 {
|
||||
if _, err := db.conn.Exec(`ALTER TABLE hosts ADD COLUMN enable_vulnerability_scanning BOOLEAN NOT NULL DEFAULT 1`); err != nil {
|
||||
if !isSQLiteColumnExistsError(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if cpu_percent column exists in containers table (for stats monitoring)
|
||||
var cpuPercentExists int
|
||||
err = db.conn.QueryRow(`
|
||||
@@ -617,14 +642,14 @@ func (db *DB) GetHosts() ([]models.Host, error) {
|
||||
)
|
||||
SELECT
|
||||
h.id, h.name, h.address, h.description, h.host_type, h.agent_token, h.agent_status,
|
||||
h.agent_version, h.last_seen, h.enabled, h.collect_stats, h.created_at, h.updated_at,
|
||||
h.agent_version, h.last_seen, h.enabled, h.collect_stats, h.enable_vulnerability_scanning, h.created_at, h.updated_at,
|
||||
COUNT(c.id) as container_count,
|
||||
COUNT(CASE WHEN c.state = 'running' THEN c.id END) as running_count
|
||||
FROM hosts h
|
||||
LEFT JOIN latest_scan_per_host ls ON h.id = ls.host_id
|
||||
LEFT JOIN containers c ON c.host_id = h.id AND c.scanned_at = ls.max_scanned_at
|
||||
GROUP BY h.id, h.name, h.address, h.description, h.host_type, h.agent_token, h.agent_status,
|
||||
h.agent_version, h.last_seen, h.enabled, h.collect_stats, h.created_at, h.updated_at
|
||||
h.agent_version, h.last_seen, h.enabled, h.collect_stats, h.enable_vulnerability_scanning, h.created_at, h.updated_at
|
||||
ORDER BY h.name
|
||||
`)
|
||||
if err != nil {
|
||||
@@ -637,10 +662,10 @@ func (db *DB) GetHosts() ([]models.Host, error) {
|
||||
var h models.Host
|
||||
var lastSeen sql.NullTime
|
||||
var agentToken, agentStatus, agentVersion sql.NullString
|
||||
var collectStats sql.NullBool
|
||||
var collectStats, enableVulnScanning sql.NullBool
|
||||
var containerCount, runningCount int
|
||||
|
||||
if err := rows.Scan(&h.ID, &h.Name, &h.Address, &h.Description, &h.HostType, &agentToken, &agentStatus, &agentVersion, &lastSeen, &h.Enabled, &collectStats, &h.CreatedAt, &h.UpdatedAt, &containerCount, &runningCount); err != nil {
|
||||
if err := rows.Scan(&h.ID, &h.Name, &h.Address, &h.Description, &h.HostType, &agentToken, &agentStatus, &agentVersion, &lastSeen, &h.Enabled, &collectStats, &enableVulnScanning, &h.CreatedAt, &h.UpdatedAt, &containerCount, &runningCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -661,6 +686,11 @@ func (db *DB) GetHosts() ([]models.Host, error) {
|
||||
} else {
|
||||
h.CollectStats = true // Default to true
|
||||
}
|
||||
if enableVulnScanning.Valid {
|
||||
h.EnableVulnerabilityScanning = enableVulnScanning.Bool
|
||||
} else {
|
||||
h.EnableVulnerabilityScanning = true // Default to true
|
||||
}
|
||||
|
||||
h.ContainerCount = containerCount
|
||||
h.RunningCount = runningCount
|
||||
@@ -676,12 +706,12 @@ func (db *DB) GetHost(id int64) (*models.Host, error) {
|
||||
var h models.Host
|
||||
var lastSeen sql.NullTime
|
||||
var agentToken, agentStatus, agentVersion sql.NullString
|
||||
var collectStats sql.NullBool
|
||||
var collectStats, enableVulnScanning sql.NullBool
|
||||
|
||||
err := db.conn.QueryRow(`
|
||||
SELECT id, name, address, description, host_type, agent_token, agent_status, agent_version, last_seen, enabled, collect_stats, created_at, updated_at
|
||||
SELECT id, name, address, description, host_type, agent_token, agent_status, agent_version, last_seen, enabled, collect_stats, enable_vulnerability_scanning, created_at, updated_at
|
||||
FROM hosts WHERE id = ?
|
||||
`, id).Scan(&h.ID, &h.Name, &h.Address, &h.Description, &h.HostType, &agentToken, &agentStatus, &agentVersion, &lastSeen, &h.Enabled, &collectStats, &h.CreatedAt, &h.UpdatedAt)
|
||||
`, id).Scan(&h.ID, &h.Name, &h.Address, &h.Description, &h.HostType, &agentToken, &agentStatus, &agentVersion, &lastSeen, &h.Enabled, &collectStats, &enableVulnScanning, &h.CreatedAt, &h.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -703,6 +733,11 @@ func (db *DB) GetHost(id int64) (*models.Host, error) {
|
||||
} else {
|
||||
h.CollectStats = true // Default to true
|
||||
}
|
||||
if enableVulnScanning.Valid {
|
||||
h.EnableVulnerabilityScanning = enableVulnScanning.Bool
|
||||
} else {
|
||||
h.EnableVulnerabilityScanning = true // Default to true
|
||||
}
|
||||
|
||||
return &h, nil
|
||||
}
|
||||
@@ -711,9 +746,9 @@ func (db *DB) GetHost(id int64) (*models.Host, error) {
|
||||
func (db *DB) UpdateHost(host models.Host) error {
|
||||
_, err := db.conn.Exec(`
|
||||
UPDATE hosts
|
||||
SET name = ?, address = ?, description = ?, host_type = ?, agent_token = ?, agent_status = ?, agent_version = ?, last_seen = ?, enabled = ?, collect_stats = ?, updated_at = CURRENT_TIMESTAMP
|
||||
SET name = ?, address = ?, description = ?, host_type = ?, agent_token = ?, agent_status = ?, agent_version = ?, last_seen = ?, enabled = ?, collect_stats = ?, enable_vulnerability_scanning = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`, host.Name, host.Address, host.Description, host.HostType, host.AgentToken, host.AgentStatus, host.AgentVersion, host.LastSeen, host.Enabled, host.CollectStats, host.ID)
|
||||
`, host.Name, host.Address, host.Description, host.HostType, host.AgentToken, host.AgentStatus, host.AgentVersion, host.LastSeen, host.Enabled, host.CollectStats, host.EnableVulnerabilityScanning, host.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/container-census/container-census/internal/models"
|
||||
"github.com/selfhosters-cc/container-census/internal/models"
|
||||
)
|
||||
|
||||
// setupTestDB creates an in-memory SQLite database for testing
|
||||
|
||||
@@ -3,7 +3,7 @@ package storage
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/container-census/container-census/internal/models"
|
||||
"github.com/selfhosters-cc/container-census/internal/models"
|
||||
)
|
||||
|
||||
// InitializeDefaultNotifications creates default notification channels and rules if they don't exist
|
||||
|
||||
@@ -3,7 +3,7 @@ package storage
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/container-census/container-census/internal/models"
|
||||
"github.com/selfhosters-cc/container-census/internal/models"
|
||||
)
|
||||
|
||||
// TestInitializeDefaultRules tests that default notification rules are created
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/container-census/container-census/internal/models"
|
||||
"github.com/selfhosters-cc/container-census/internal/models"
|
||||
)
|
||||
|
||||
// Notification operations
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/container-census/container-census/internal/models"
|
||||
"github.com/selfhosters-cc/container-census/internal/models"
|
||||
)
|
||||
|
||||
// TestNotificationChannelCRUD tests Create, Read, Update, Delete for notification channels
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/container-census/container-census/internal/models"
|
||||
"github.com/selfhosters-cc/container-census/internal/models"
|
||||
)
|
||||
|
||||
func TestGetChangesReport(t *testing.T) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/container-census/container-census/internal/models"
|
||||
"github.com/selfhosters-cc/container-census/internal/models"
|
||||
)
|
||||
|
||||
// GetDefaultSettings returns default system settings
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/container-census/container-census/internal/models"
|
||||
"github.com/selfhosters-cc/container-census/internal/models"
|
||||
)
|
||||
|
||||
// TestSQLDatetimeDebug directly tests SQL datetime logic
|
||||
|
||||
@@ -6,7 +6,8 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/container-census/container-census/internal/vulnerability"
|
||||
"github.com/selfhosters-cc/container-census/internal/models"
|
||||
"github.com/selfhosters-cc/container-census/internal/vulnerability"
|
||||
)
|
||||
|
||||
// GetVulnerabilityScan retrieves a vulnerability scan by image ID
|
||||
@@ -324,6 +325,39 @@ func (db *DB) GetAllVulnerabilityScans(limit int) ([]vulnerability.Vulnerability
|
||||
scan.Error = errorText.String
|
||||
}
|
||||
|
||||
// Query host information for this image
|
||||
hostQuery := `
|
||||
SELECT DISTINCT ic.host_id, h.name
|
||||
FROM image_containers ic
|
||||
LEFT JOIN hosts h ON ic.host_id = h.id
|
||||
WHERE ic.image_id = ?
|
||||
ORDER BY h.name
|
||||
`
|
||||
hostRows, err := db.conn.Query(hostQuery, scan.ImageID)
|
||||
if err != nil {
|
||||
// Don't fail the entire query, just log and continue
|
||||
scan.HostIDs = []int{}
|
||||
scan.HostNames = []string{}
|
||||
} else {
|
||||
hostIDs := []int{}
|
||||
hostNames := []string{}
|
||||
for hostRows.Next() {
|
||||
var hostID int
|
||||
var hostName sql.NullString
|
||||
if err := hostRows.Scan(&hostID, &hostName); err == nil {
|
||||
hostIDs = append(hostIDs, hostID)
|
||||
if hostName.Valid {
|
||||
hostNames = append(hostNames, hostName.String)
|
||||
} else {
|
||||
hostNames = append(hostNames, fmt.Sprintf("Host %d", hostID))
|
||||
}
|
||||
}
|
||||
}
|
||||
hostRows.Close()
|
||||
scan.HostIDs = hostIDs
|
||||
scan.HostNames = hostNames
|
||||
}
|
||||
|
||||
scans = append(scans, scan)
|
||||
}
|
||||
|
||||
@@ -496,3 +530,80 @@ func (db *DB) LoadVulnerabilitySettings() (*vulnerability.Config, error) {
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// SaveTrivyDBMetadata saves Trivy database metadata for a host
|
||||
func (db *DB) SaveTrivyDBMetadata(hostID int64, trivyVersion, dbVersion string) error {
|
||||
query := `
|
||||
INSERT OR REPLACE INTO trivy_db_metadata (host_id, trivy_version, db_version, last_updated)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`
|
||||
|
||||
_, err := db.conn.Exec(query, hostID, trivyVersion, dbVersion, time.Now())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save trivy metadata: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetTrivyDBMetadata retrieves Trivy database metadata for a specific host
|
||||
func (db *DB) GetTrivyDBMetadata(hostID int64) (*models.TrivyDBMetadata, error) {
|
||||
query := `
|
||||
SELECT host_id, trivy_version, db_version, last_updated
|
||||
FROM trivy_db_metadata
|
||||
WHERE host_id = ?
|
||||
`
|
||||
|
||||
var metadata models.TrivyDBMetadata
|
||||
err := db.conn.QueryRow(query, hostID).Scan(
|
||||
&metadata.HostID,
|
||||
&metadata.TrivyVersion,
|
||||
&metadata.DBVersion,
|
||||
&metadata.LastUpdated,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get trivy metadata: %w", err)
|
||||
}
|
||||
|
||||
return &metadata, nil
|
||||
}
|
||||
|
||||
// GetAllTrivyDBMetadata retrieves Trivy database metadata for all hosts
|
||||
func (db *DB) GetAllTrivyDBMetadata() ([]models.TrivyDBMetadata, error) {
|
||||
query := `
|
||||
SELECT host_id, trivy_version, db_version, last_updated
|
||||
FROM trivy_db_metadata
|
||||
ORDER BY host_id
|
||||
`
|
||||
|
||||
rows, err := db.conn.Query(query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query trivy metadata: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var metadataList []models.TrivyDBMetadata
|
||||
for rows.Next() {
|
||||
var metadata models.TrivyDBMetadata
|
||||
err := rows.Scan(
|
||||
&metadata.HostID,
|
||||
&metadata.TrivyVersion,
|
||||
&metadata.DBVersion,
|
||||
&metadata.LastUpdated,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan trivy metadata: %w", err)
|
||||
}
|
||||
metadataList = append(metadataList, metadata)
|
||||
}
|
||||
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error iterating trivy metadata: %w", err)
|
||||
}
|
||||
|
||||
return metadataList, nil
|
||||
}
|
||||
|
||||
@@ -11,9 +11,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/container-census/container-census/internal/models"
|
||||
"github.com/container-census/container-census/internal/storage"
|
||||
"github.com/container-census/container-census/internal/version"
|
||||
"github.com/selfhosters-cc/container-census/internal/models"
|
||||
"github.com/selfhosters-cc/container-census/internal/storage"
|
||||
"github.com/selfhosters-cc/container-census/internal/version"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@ import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/container-census/container-census/internal/models"
|
||||
"github.com/container-census/container-census/internal/scanner"
|
||||
"github.com/container-census/container-census/internal/storage"
|
||||
"github.com/selfhosters-cc/container-census/internal/models"
|
||||
"github.com/selfhosters-cc/container-census/internal/scanner"
|
||||
"github.com/selfhosters-cc/container-census/internal/storage"
|
||||
)
|
||||
|
||||
// Scheduler handles periodic telemetry collection and submission
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/container-census/container-census/internal/models"
|
||||
"github.com/selfhosters-cc/container-census/internal/models"
|
||||
)
|
||||
|
||||
// Submitter handles sending telemetry to multiple endpoints
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/container-census/container-census/internal/models"
|
||||
"github.com/selfhosters-cc/container-census/internal/models"
|
||||
)
|
||||
|
||||
// mockDB implements the database interface needed by Submitter
|
||||
|
||||
@@ -14,6 +14,9 @@ import (
|
||||
var (
|
||||
// Version is read from .version file or defaults to "dev"
|
||||
Version string
|
||||
|
||||
// BuildTime is set at compile time using -ldflags
|
||||
BuildTime string = "unknown"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -53,6 +56,11 @@ func Get() string {
|
||||
return Version
|
||||
}
|
||||
|
||||
// GetBuildTime returns the build timestamp
|
||||
func GetBuildTime() string {
|
||||
return BuildTime
|
||||
}
|
||||
|
||||
// GitHubRelease represents the GitHub API response for a release
|
||||
type GitHubRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
|
||||
@@ -39,6 +39,8 @@ type VulnerabilityScan struct {
|
||||
TrivyDBVersion string `json:"trivy_db_version"`
|
||||
TotalVulnerabilities int `json:"total_vulnerabilities"`
|
||||
SeverityCounts SeverityCounts `json:"severity_counts"`
|
||||
HostIDs []int `json:"host_ids,omitempty"`
|
||||
HostNames []string `json:"host_names,omitempty"`
|
||||
}
|
||||
|
||||
// VulnerabilityScanResult is the complete result of scanning an image
|
||||
@@ -59,10 +61,12 @@ type ScanSummary struct {
|
||||
|
||||
// ScanJob represents a queued scan job
|
||||
type ScanJob struct {
|
||||
ImageID string
|
||||
ImageName string
|
||||
Priority int // Higher = more important
|
||||
QueuedAt time.Time
|
||||
ImageID string `json:"image_id"`
|
||||
ImageName string `json:"image_name"`
|
||||
HostID int64 `json:"host_id"` // Host where image is located (for routing to agent)
|
||||
HostName string `json:"host_name"` // Host name for display in UI
|
||||
Priority int `json:"priority"` // Higher = more important
|
||||
QueuedAt time.Time `json:"queued_at"`
|
||||
}
|
||||
|
||||
// ScanQueueStatus provides information about the scan queue
|
||||
|
||||
@@ -5,13 +5,17 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/selfhosters-cc/container-census/internal/models"
|
||||
)
|
||||
|
||||
// Scanner handles vulnerability scanning using Trivy
|
||||
@@ -260,3 +264,109 @@ func getTrivyDBVersion() string {
|
||||
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// GetAgentInfo retrieves agent information including Trivy capability
|
||||
func (s *Scanner) GetAgentInfo(ctx context.Context, host *models.Host) (*models.AgentInfo, error) {
|
||||
agentURL := normalizeAgentURL(host.Address) + "/info"
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", agentURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to agent: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("agent returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var info models.AgentInfo
|
||||
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
// ScanImageOnAgent delegates vulnerability scanning to an agent
|
||||
func (s *Scanner) ScanImageOnAgent(ctx context.Context, host models.Host, imageID, imageName string) (*VulnerabilityScanResult, error) {
|
||||
startTime := time.Now()
|
||||
|
||||
reqBody := map[string]string{
|
||||
"image_id": imageID,
|
||||
"image_name": imageName,
|
||||
}
|
||||
jsonData, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
agentURL := normalizeAgentURL(host.Address) + "/api/vulnerabilities/scan"
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", agentURL, bytes.NewReader(jsonData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("X-API-Token", host.AgentToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Connection", "close") // Disable keep-alive to avoid EOF errors
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Minute,
|
||||
Transport: &http.Transport{
|
||||
DisableKeepAlives: true, // Disable connection reuse to prevent EOF errors
|
||||
},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to agent: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("agent returned %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var trivyResult TrivyResult
|
||||
if err := json.NewDecoder(resp.Body).Decode(&trivyResult); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
vulnerabilities := s.parseTrivyResult(&trivyResult, imageID)
|
||||
severityCounts := CalculateSeverityCounts(vulnerabilities)
|
||||
|
||||
scan := &VulnerabilityScan{
|
||||
ImageID: imageID,
|
||||
ImageName: imageName,
|
||||
ScannedAt: time.Now(),
|
||||
ScanDurationMs: time.Since(startTime).Milliseconds(),
|
||||
Success: true,
|
||||
TotalVulnerabilities: severityCounts.GetTotal(),
|
||||
SeverityCounts: severityCounts,
|
||||
}
|
||||
|
||||
// Save to database
|
||||
if err := s.storage.SaveVulnerabilityScan(scan, vulnerabilities); err != nil {
|
||||
log.Printf("Failed to save agent scan results: %v", err)
|
||||
}
|
||||
|
||||
s.cache.Set(scan, vulnerabilities)
|
||||
|
||||
return &VulnerabilityScanResult{
|
||||
Scan: *scan,
|
||||
Vulnerabilities: vulnerabilities,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// normalizeAgentURL converts agent:// prefix to http:// and cleans URL
|
||||
func normalizeAgentURL(address string) string {
|
||||
address = strings.TrimPrefix(address, "agent://")
|
||||
if !strings.HasPrefix(address, "http://") && !strings.HasPrefix(address, "https://") {
|
||||
address = "http://" + address
|
||||
}
|
||||
return strings.TrimSuffix(address, "/")
|
||||
}
|
||||
|
||||
@@ -3,15 +3,24 @@ package vulnerability
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/selfhosters-cc/container-census/internal/models"
|
||||
)
|
||||
|
||||
// HostStorage defines the interface for retrieving host information
|
||||
type HostStorage interface {
|
||||
GetHost(id int64) (*models.Host, error)
|
||||
}
|
||||
|
||||
// Scheduler manages the vulnerability scanning queue and worker pool
|
||||
type Scheduler struct {
|
||||
scanner *Scanner
|
||||
config *Config
|
||||
storage HostStorage
|
||||
queue chan ScanJob
|
||||
workers []*worker
|
||||
mu sync.RWMutex
|
||||
@@ -30,12 +39,13 @@ type worker struct {
|
||||
}
|
||||
|
||||
// NewScheduler creates a new scan scheduler
|
||||
func NewScheduler(scanner *Scanner, config *Config) *Scheduler {
|
||||
func NewScheduler(scanner *Scanner, config *Config, storage HostStorage) *Scheduler {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
s := &Scheduler{
|
||||
scanner: scanner,
|
||||
config: config,
|
||||
storage: storage,
|
||||
queue: make(chan ScanJob, config.GetMaxQueueSize()),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
@@ -122,6 +132,52 @@ func (s *Scheduler) QueueScan(imageID, imageName string, priority int) error {
|
||||
}
|
||||
}
|
||||
|
||||
// QueueScanWithHost adds a scan job with host context for routing to agents
|
||||
func (s *Scheduler) QueueScanWithHost(imageID, imageName string, hostID int64, priority int) error {
|
||||
return s.queueScanWithHostInternal(imageID, imageName, hostID, priority, false)
|
||||
}
|
||||
|
||||
// QueueScanWithHostForce queues a scan bypassing cache checks (force rescan)
|
||||
func (s *Scheduler) QueueScanWithHostForce(imageID, imageName string, hostID int64, priority int) error {
|
||||
return s.queueScanWithHostInternal(imageID, imageName, hostID, priority, true)
|
||||
}
|
||||
|
||||
func (s *Scheduler) queueScanWithHostInternal(imageID, imageName string, hostID int64, priority int, force bool) error {
|
||||
// Check if scanning is enabled
|
||||
if !s.config.GetEnabled() {
|
||||
return nil // Silently ignore if disabled
|
||||
}
|
||||
|
||||
// Check if image needs scanning (unless force=true)
|
||||
if !force && !s.scanner.NeedsScan(imageID) {
|
||||
return nil // Already scanned recently
|
||||
}
|
||||
|
||||
// Get host name for display
|
||||
hostName := "Unknown"
|
||||
if s.storage != nil {
|
||||
if host, err := s.storage.GetHost(hostID); err == nil && host != nil {
|
||||
hostName = host.Name
|
||||
}
|
||||
}
|
||||
|
||||
job := ScanJob{
|
||||
ImageID: imageID,
|
||||
ImageName: imageName,
|
||||
HostID: hostID, // Include host context for routing
|
||||
HostName: hostName,
|
||||
Priority: priority,
|
||||
QueuedAt: time.Now(),
|
||||
}
|
||||
|
||||
select {
|
||||
case s.queue <- job:
|
||||
return nil
|
||||
default:
|
||||
return nil // Queue is full, silently drop
|
||||
}
|
||||
}
|
||||
|
||||
// QueueScanBlocking adds a scan job and waits if queue is full
|
||||
func (s *Scheduler) QueueScanBlocking(imageID, imageName string, priority int) error {
|
||||
if !s.config.GetEnabled() {
|
||||
@@ -216,13 +272,13 @@ func (w *worker) run() {
|
||||
}
|
||||
}
|
||||
|
||||
// worker.processJob processes a single scan job
|
||||
// worker.processJob processes a single scan job with intelligent routing
|
||||
func (w *worker) processJob(job ScanJob) {
|
||||
atomic.AddInt32(&w.scheduler.inProgressCount, 1)
|
||||
defer atomic.AddInt32(&w.scheduler.inProgressCount, -1)
|
||||
|
||||
// Perform the scan with both image ID and name
|
||||
_, err := w.scheduler.scanner.ScanImage(w.scheduler.ctx, job.ImageID, job.ImageName)
|
||||
// Route scan based on host context
|
||||
_, err := w.routeAndScan(job)
|
||||
if err != nil {
|
||||
// Only log unexpected errors (not "image not available")
|
||||
if err.Error() != "image not available for scanning" {
|
||||
@@ -234,6 +290,63 @@ func (w *worker) processJob(job ScanJob) {
|
||||
}
|
||||
}
|
||||
|
||||
// routeAndScan determines where to scan (agent vs server) and executes
|
||||
func (w *worker) routeAndScan(job ScanJob) (*VulnerabilityScanResult, error) {
|
||||
// No host context = scan locally (backward compatibility for manual scans)
|
||||
if job.HostID == 0 {
|
||||
return w.scheduler.scanner.ScanImage(w.scheduler.ctx, job.ImageID, job.ImageName)
|
||||
}
|
||||
|
||||
// Get host information from database
|
||||
host, err := w.scheduler.storage.GetHost(job.HostID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get host for routing: %v, falling back to local scan", err)
|
||||
return w.scheduler.scanner.ScanImage(w.scheduler.ctx, job.ImageID, job.ImageName)
|
||||
}
|
||||
|
||||
// Check if vulnerability scanning is enabled for this host
|
||||
if !host.EnableVulnerabilityScanning {
|
||||
log.Printf("Vulnerability scanning disabled for host %s, skipping", host.Name)
|
||||
return nil, nil // Skip silently
|
||||
}
|
||||
|
||||
// Check if host is an agent with Trivy capability
|
||||
if w.isAgentHost(host.Address) && host.AgentStatus == "online" {
|
||||
// Get agent capabilities
|
||||
agentInfo, err := w.scheduler.scanner.GetAgentInfo(w.scheduler.ctx, host)
|
||||
if err == nil && agentInfo.HasTrivy {
|
||||
// Route to agent
|
||||
log.Printf("Worker %d: Routing scan for %s to agent %s", w.id, job.ImageName, host.Name)
|
||||
result, err := w.scanOnAgent(job, host)
|
||||
if err != nil {
|
||||
log.Printf("Agent scan failed for %s on %s: %v, falling back to local", job.ImageName, host.Name, err)
|
||||
// Automatic fallback to local scan
|
||||
return w.scheduler.scanner.ScanImage(w.scheduler.ctx, job.ImageID, job.ImageName)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to server-side scan
|
||||
log.Printf("Worker %d: Scanning %s locally (host: %s)", w.id, job.ImageName, host.Name)
|
||||
return w.scheduler.scanner.ScanImage(w.scheduler.ctx, job.ImageID, job.ImageName)
|
||||
}
|
||||
|
||||
// scanOnAgent performs vulnerability scan via agent API
|
||||
func (w *worker) scanOnAgent(job ScanJob, host *models.Host) (*VulnerabilityScanResult, error) {
|
||||
ctx, cancel := context.WithTimeout(w.scheduler.ctx, 10*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
return w.scheduler.scanner.ScanImageOnAgent(ctx, *host, job.ImageID, job.ImageName)
|
||||
}
|
||||
|
||||
// isAgentHost checks if address is agent-based
|
||||
func (w *worker) isAgentHost(address string) bool {
|
||||
return strings.HasPrefix(address, "agent://") ||
|
||||
strings.HasPrefix(address, "http://") ||
|
||||
strings.HasPrefix(address, "https://")
|
||||
}
|
||||
|
||||
// dailyStatsReset resets daily counters at midnight
|
||||
func (s *Scheduler) dailyStatsReset() {
|
||||
ticker := time.NewTicker(1 * time.Hour)
|
||||
|
||||
+153
-7
@@ -383,6 +383,65 @@ save_version "$NEW_VERSION"
|
||||
# Start building
|
||||
print_header "Starting Build Process"
|
||||
|
||||
# Build Next.js frontend (if building server)
|
||||
if [ "$BUILD_SERVER" = true ]; then
|
||||
echo ""
|
||||
read -p "Build Next.js frontend? (Y/n): " build_frontend
|
||||
|
||||
if [[ ! $build_frontend =~ ^[Nn]$ ]]; then
|
||||
print_info "Building Next.js frontend..."
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
if [ ! -d "$PROJECT_ROOT/web-next/node_modules" ]; then
|
||||
print_info "Installing npm dependencies..."
|
||||
(cd "$PROJECT_ROOT/web-next" && npm install)
|
||||
fi
|
||||
|
||||
(cd "$PROJECT_ROOT/web-next" && npm run build)
|
||||
|
||||
print_success "Next.js frontend built successfully!"
|
||||
print_info "Static files available in: $PROJECT_ROOT/web-next/out/"
|
||||
else
|
||||
print_warning "Skipping Next.js frontend build (vanilla JS will be used)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Build Graph Plugin Frontend (if building server)
|
||||
if [ "$BUILD_SERVER" = true ]; then
|
||||
GRAPH_PLUGIN_DIR="$PROJECT_ROOT/internal/plugins/builtin/graph/frontend"
|
||||
if [ -d "$GRAPH_PLUGIN_DIR/src" ]; then
|
||||
print_info "Building Graph Plugin frontend..."
|
||||
|
||||
if [ ! -d "$GRAPH_PLUGIN_DIR/node_modules" ]; then
|
||||
print_info "Installing Graph Plugin npm dependencies..."
|
||||
(cd "$GRAPH_PLUGIN_DIR" && npm install)
|
||||
fi
|
||||
|
||||
(cd "$GRAPH_PLUGIN_DIR" && npm run build)
|
||||
|
||||
print_success "Graph Plugin frontend built successfully!"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Build Security Plugin Frontend (if building server)
|
||||
if [ "$BUILD_SERVER" = true ]; then
|
||||
SECURITY_PLUGIN_DIR="$PROJECT_ROOT/internal/plugins/builtin/security/frontend"
|
||||
if [ -d "$SECURITY_PLUGIN_DIR/src" ]; then
|
||||
print_info "Building Security Plugin frontend..."
|
||||
|
||||
if [ ! -d "$SECURITY_PLUGIN_DIR/node_modules" ]; then
|
||||
print_info "Installing Security Plugin npm dependencies..."
|
||||
(cd "$SECURITY_PLUGIN_DIR" && npm install)
|
||||
fi
|
||||
|
||||
(cd "$SECURITY_PLUGIN_DIR" && npm run build)
|
||||
|
||||
print_success "Security Plugin frontend built successfully!"
|
||||
fi
|
||||
fi
|
||||
|
||||
BUILD_SUCCESS=true
|
||||
|
||||
# Build server
|
||||
@@ -398,13 +457,100 @@ fi
|
||||
|
||||
# Build agent
|
||||
if [ "$BUILD_AGENT" = true ]; then
|
||||
if build_image "census-agent" "Dockerfile.agent" "$NEW_VERSION" "$PLATFORMS"; then
|
||||
if [ "$PUSH_TO_REGISTRY" = true ]; then
|
||||
build_and_push "census-agent" "Dockerfile.agent" "$NEW_VERSION" "$PLATFORMS" "$REGISTRY"
|
||||
fi
|
||||
else
|
||||
BUILD_SUCCESS=false
|
||||
fi
|
||||
print_header "Agent Build Options"
|
||||
echo -e "Which agent variant(s) to build?"
|
||||
echo -e " ${GREEN}1${NC}) Lightweight (no Trivy) - census-agent:latest"
|
||||
echo -e " ${GREEN}2${NC}) With Trivy - census-agent:with-trivy"
|
||||
echo -e " ${GREEN}3${NC}) Both variants"
|
||||
echo ""
|
||||
read -p "Choice [1-3]: " AGENT_VARIANT
|
||||
|
||||
case "$AGENT_VARIANT" in
|
||||
1)
|
||||
print_info "Building lightweight agent (no Trivy)..."
|
||||
if build_image "census-agent" "Dockerfile.agent" "$NEW_VERSION" "$PLATFORMS"; then
|
||||
if [ "$PUSH_TO_REGISTRY" = true ]; then
|
||||
build_and_push "census-agent" "Dockerfile.agent" "$NEW_VERSION" "$PLATFORMS" "$REGISTRY"
|
||||
fi
|
||||
else
|
||||
BUILD_SUCCESS=false
|
||||
fi
|
||||
;;
|
||||
2)
|
||||
print_info "Building agent with Trivy..."
|
||||
# Build with Trivy using --build-arg
|
||||
if docker buildx build \
|
||||
--platform "$PLATFORMS" \
|
||||
--build-arg DOCKER_GID=999 \
|
||||
--build-arg INSTALL_TRIVY=true \
|
||||
-t "census-agent:with-trivy-$NEW_VERSION" \
|
||||
-t "census-agent:with-trivy" \
|
||||
-f "Dockerfile.agent" \
|
||||
$([ $(echo "$PLATFORMS" | tr ',' '\n' | wc -l) -eq 1 ] && echo "--load" || echo "") \
|
||||
--progress=plain \
|
||||
. ; then
|
||||
print_success "census-agent:with-trivy built successfully"
|
||||
if [ "$PUSH_TO_REGISTRY" = true ] && [ -n "$REGISTRY" ]; then
|
||||
docker buildx build \
|
||||
--platform "$PLATFORMS" \
|
||||
--build-arg DOCKER_GID=999 \
|
||||
--build-arg INSTALL_TRIVY=true \
|
||||
-t "$REGISTRY/census-agent:with-trivy-$NEW_VERSION" \
|
||||
-t "$REGISTRY/census-agent:with-trivy" \
|
||||
-f "Dockerfile.agent" \
|
||||
--push \
|
||||
--progress=plain \
|
||||
.
|
||||
print_success "Pushed to $REGISTRY/census-agent:with-trivy"
|
||||
fi
|
||||
else
|
||||
BUILD_SUCCESS=false
|
||||
fi
|
||||
;;
|
||||
3)
|
||||
print_info "Building both agent variants..."
|
||||
# Build lightweight
|
||||
if build_image "census-agent" "Dockerfile.agent" "$NEW_VERSION" "$PLATFORMS"; then
|
||||
if [ "$PUSH_TO_REGISTRY" = true ]; then
|
||||
build_and_push "census-agent" "Dockerfile.agent" "$NEW_VERSION" "$PLATFORMS" "$REGISTRY"
|
||||
fi
|
||||
else
|
||||
BUILD_SUCCESS=false
|
||||
fi
|
||||
|
||||
# Build with Trivy
|
||||
if docker buildx build \
|
||||
--platform "$PLATFORMS" \
|
||||
--build-arg DOCKER_GID=999 \
|
||||
--build-arg INSTALL_TRIVY=true \
|
||||
-t "census-agent:with-trivy-$NEW_VERSION" \
|
||||
-t "census-agent:with-trivy" \
|
||||
-f "Dockerfile.agent" \
|
||||
$([ $(echo "$PLATFORMS" | tr ',' '\n' | wc -l) -eq 1 ] && echo "--load" || echo "") \
|
||||
--progress=plain \
|
||||
. ; then
|
||||
print_success "census-agent:with-trivy built successfully"
|
||||
if [ "$PUSH_TO_REGISTRY" = true ] && [ -n "$REGISTRY" ]; then
|
||||
docker buildx build \
|
||||
--platform "$PLATFORMS" \
|
||||
--build-arg DOCKER_GID=999 \
|
||||
--build-arg INSTALL_TRIVY=true \
|
||||
-t "$REGISTRY/census-agent:with-trivy-$NEW_VERSION" \
|
||||
-t "$REGISTRY/census-agent:with-trivy" \
|
||||
-f "Dockerfile.agent" \
|
||||
--push \
|
||||
--progress=plain \
|
||||
.
|
||||
print_success "Pushed to $REGISTRY/census-agent:with-trivy"
|
||||
fi
|
||||
else
|
||||
BUILD_SUCCESS=false
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
print_error "Invalid choice. Skipping agent build."
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Build telemetry collector
|
||||
|
||||
Executable
+144
@@ -0,0 +1,144 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Deploy Agent Script
|
||||
# Builds agent Docker image and deploys to ubuntu3
|
||||
|
||||
set -e
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Configuration
|
||||
REMOTE_HOST="ubuntu3"
|
||||
REMOTE_COMPOSE_DIR="/opt/docker-compose"
|
||||
IMAGE_NAME="census-agent"
|
||||
DOCKER_GID=$(stat -c '%g' /var/run/docker.sock 2>/dev/null || echo "999")
|
||||
|
||||
# Read version
|
||||
VERSION=$(cat .version 2>/dev/null || echo "dev")
|
||||
|
||||
echo -e "${YELLOW}Building and deploying Census Agent v${VERSION}...${NC}"
|
||||
echo ""
|
||||
|
||||
# Ask which agent variant to build
|
||||
echo -e "${YELLOW}Which agent variant to build?${NC}"
|
||||
echo -e " ${GREEN}1${NC}) Lightweight (no Trivy) - faster, smaller (~20MB)"
|
||||
echo -e " ${GREEN}2${NC}) With Trivy - vulnerability scanning capability (~400MB)"
|
||||
echo ""
|
||||
read -p "Choice [1-2] (default: 1): " AGENT_VARIANT
|
||||
AGENT_VARIANT=${AGENT_VARIANT:-1}
|
||||
|
||||
# Set build arguments and image tags based on choice
|
||||
if [ "$AGENT_VARIANT" = "2" ]; then
|
||||
echo -e "${GREEN}Building agent WITH Trivy...${NC}"
|
||||
BUILD_ARGS="--build-arg DOCKER_GID=${DOCKER_GID} --build-arg INSTALL_TRIVY=true"
|
||||
IMAGE_TAG="with-trivy"
|
||||
else
|
||||
echo -e "${GREEN}Building lightweight agent (no Trivy)...${NC}"
|
||||
BUILD_ARGS="--build-arg DOCKER_GID=${DOCKER_GID}"
|
||||
IMAGE_TAG="latest"
|
||||
fi
|
||||
|
||||
# Step 1: Build the agent Docker image
|
||||
echo ""
|
||||
echo -e "${YELLOW}Step 1/5: Building agent Docker image...${NC}"
|
||||
docker buildx build \
|
||||
--platform linux/amd64 \
|
||||
${BUILD_ARGS} \
|
||||
-f Dockerfile.agent \
|
||||
-t ${IMAGE_NAME}:${VERSION} \
|
||||
-t ${IMAGE_NAME}:${IMAGE_TAG} \
|
||||
--load \
|
||||
.
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo -e "${GREEN}✓ Agent image built successfully${NC}"
|
||||
else
|
||||
echo -e "${RED}✗ Failed to build agent image${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Step 2: Save the image to a tar file
|
||||
echo ""
|
||||
echo -e "${YELLOW}Step 2/5: Saving image to tar file...${NC}"
|
||||
docker save ${IMAGE_NAME}:${IMAGE_TAG} -o /tmp/${IMAGE_NAME}.tar
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo -e "${GREEN}✓ Image saved to /tmp/${IMAGE_NAME}.tar${NC}"
|
||||
else
|
||||
echo -e "${RED}✗ Failed to save image${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Step 3: Copy the tar file to ubuntu3
|
||||
echo ""
|
||||
echo -e "${YELLOW}Step 3/5: Copying image to ${REMOTE_HOST}...${NC}"
|
||||
scp /tmp/${IMAGE_NAME}.tar ${REMOTE_HOST}:/tmp/
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo -e "${GREEN}✓ Image copied to ${REMOTE_HOST}${NC}"
|
||||
else
|
||||
echo -e "${RED}✗ Failed to copy image to ${REMOTE_HOST}${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Step 4: Load the image on ubuntu3 and restart the agent
|
||||
echo ""
|
||||
echo -e "${YELLOW}Step 4/5: Loading image and restarting agent on ${REMOTE_HOST}...${NC}"
|
||||
ssh ${REMOTE_HOST} << EOF
|
||||
echo "Loading image..."
|
||||
docker load -i /tmp/census-agent.tar
|
||||
|
||||
echo "Tagging image for docker-compose..."
|
||||
# Tag the loaded image to match what docker-compose expects
|
||||
docker tag ${IMAGE_NAME}:${IMAGE_TAG} ghcr.io/selfhosters-cc/census-agent:latest
|
||||
|
||||
echo "Recreating agent container with new image..."
|
||||
cd /opt/docker-compose
|
||||
docker compose up -d census-agent
|
||||
|
||||
echo "Cleaning up tar file..."
|
||||
rm /tmp/census-agent.tar
|
||||
|
||||
echo "Waiting for agent to be healthy..."
|
||||
sleep 3
|
||||
|
||||
# Check if agent is running
|
||||
if docker ps | grep -q census-agent; then
|
||||
echo "✓ Agent container is running"
|
||||
else
|
||||
echo "✗ Agent container is not running"
|
||||
exit 1
|
||||
fi
|
||||
EOF
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo -e "${GREEN}✓ Agent restarted successfully on ${REMOTE_HOST}${NC}"
|
||||
else
|
||||
echo -e "${RED}✗ Failed to restart agent on ${REMOTE_HOST}${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Step 5: Cleanup local tar file
|
||||
echo ""
|
||||
echo -e "${YELLOW}Step 5/5: Cleaning up local tar file...${NC}"
|
||||
rm /tmp/${IMAGE_NAME}.tar
|
||||
echo -e "${GREEN}✓ Cleanup complete${NC}"
|
||||
|
||||
# Final status check
|
||||
echo ""
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo -e "${GREEN}Deployment Complete!${NC}"
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo ""
|
||||
echo -e "Agent version: ${YELLOW}${VERSION}${NC}"
|
||||
echo -e "Agent variant: ${YELLOW}${IMAGE_TAG}${NC}"
|
||||
echo -e "Deployed to: ${YELLOW}${REMOTE_HOST}${NC}"
|
||||
echo ""
|
||||
echo -e "You can check the agent status with:"
|
||||
echo -e " ${YELLOW}ssh ${REMOTE_HOST} 'docker ps | grep census-agent'${NC}"
|
||||
echo -e " ${YELLOW}ssh ${REMOTE_HOST} 'docker logs census-agent'${NC}"
|
||||
echo ""
|
||||
+12
-7
@@ -12,7 +12,7 @@ NC='\033[0m' # No Color
|
||||
TEST_MODE=false
|
||||
RESET_DB=false
|
||||
AUTH_ENABLED=false
|
||||
FRONTEND="classic"
|
||||
FRONTEND="nextjs" # Default to Next.js
|
||||
INTERACTIVE=true
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
@@ -33,6 +33,10 @@ while [[ $# -gt 0 ]]; do
|
||||
FRONTEND="nextjs"
|
||||
shift
|
||||
;;
|
||||
--classic)
|
||||
FRONTEND="classic"
|
||||
shift
|
||||
;;
|
||||
--no-interactive|-y)
|
||||
INTERACTIVE=false
|
||||
shift
|
||||
@@ -44,14 +48,15 @@ while [[ $# -gt 0 ]]; do
|
||||
echo " --test Run in test mode (uses config-test.yaml and census-test.db)"
|
||||
echo " --reset-db Reset the test database (only valid with --test)"
|
||||
echo " --auth Enable authentication (username: qwerty, password: qwerty)"
|
||||
echo " --nextjs Use Next.js frontend instead of classic"
|
||||
echo " --nextjs Use Next.js frontend (default)"
|
||||
echo " --classic Use classic vanilla JS frontend"
|
||||
echo " --no-interactive,-y Run without prompts (use default/flag settings)"
|
||||
echo " --help,-h Show this help message"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 # Interactive mode (prompts for all options)"
|
||||
echo " $0 --no-interactive # Non-interactive with defaults (normal mode, no auth, classic UI)"
|
||||
echo " $0 -y --nextjs # Non-interactive with Next.js UI"
|
||||
echo " $0 --no-interactive # Non-interactive with defaults (normal mode, no auth, Next.js UI)"
|
||||
echo " $0 -y --classic # Non-interactive with classic UI"
|
||||
echo " $0 --test --auth --nextjs -y # Test mode, auth enabled, Next.js UI, no prompts"
|
||||
exit 0
|
||||
;;
|
||||
@@ -125,13 +130,13 @@ fi
|
||||
if [ "$INTERACTIVE" = true ]; then
|
||||
echo ""
|
||||
echo "Frontend options:"
|
||||
echo " 1) Classic (vanilla JS) - web/"
|
||||
echo " 2) Next.js (React) - web-next/out/"
|
||||
echo " 1) Next.js (React) - web-next/out/ [default]"
|
||||
echo " 2) Classic (vanilla JS) - web/"
|
||||
read -p "Choose frontend [1]: " -n 1 -r
|
||||
echo
|
||||
|
||||
if [[ $REPLY == "2" ]]; then
|
||||
FRONTEND="nextjs"
|
||||
FRONTEND="classic"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
@@ -68,7 +68,15 @@ fi
|
||||
# Build Go server
|
||||
echo -e "${YELLOW}Building Go server...${NC}"
|
||||
cd "$PROJECT_ROOT"
|
||||
CGO_ENABLED=1 go build -o /tmp/census-server ./cmd/server
|
||||
|
||||
# Get build timestamp in RFC3339 format
|
||||
BUILD_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
# Build with ldflags to inject build time
|
||||
CGO_ENABLED=1 go build \
|
||||
-ldflags "-X github.com/selfhosters-cc/container-census/internal/version.BuildTime=${BUILD_TIME}" \
|
||||
-o /tmp/census-server \
|
||||
./cmd/server
|
||||
|
||||
echo -e "${GREEN}Server built successfully!${NC}"
|
||||
ls -lh /tmp/census-server
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { scanAllImages } from '@/lib/api';
|
||||
import ScanHostSelectionModal from '@/components/ScanHostSelectionModal';
|
||||
import ScanProgressModal from '@/components/ScanProgressModal';
|
||||
import TrivyDatabaseModal from '@/components/TrivyDatabaseModal';
|
||||
import AgentCapabilityBanner from '@/components/AgentCapabilityBanner';
|
||||
import SecurityContent from '@/app/security.old/SecurityContent';
|
||||
|
||||
export default function SecurityIntegrationPage() {
|
||||
const [showScanHostSelection, setShowScanHostSelection] = useState(false);
|
||||
const [showScanProgress, setShowScanProgress] = useState(false);
|
||||
const [showDbModal, setShowDbModal] = useState(false);
|
||||
const [scannedCount, setScannedCount] = useState(0);
|
||||
|
||||
const handleScanAll = () => {
|
||||
setShowScanHostSelection(true);
|
||||
};
|
||||
|
||||
const handleStartScan = async (hostIds: number[]) => {
|
||||
// Open progress modal FIRST, before starting the scan
|
||||
// This ensures the modal is open when scans complete (even if they're instant from cache)
|
||||
setShowScanProgress(true);
|
||||
|
||||
// Then trigger the scan and capture how many images were queued
|
||||
const result = await scanAllImages(hostIds);
|
||||
setScannedCount(result?.total_queued || 0);
|
||||
};
|
||||
|
||||
const handleUpdateDb = () => {
|
||||
setShowDbModal(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Agent Capability Banner */}
|
||||
<AgentCapabilityBanner />
|
||||
|
||||
{/* Security Content */}
|
||||
<SecurityContent
|
||||
onScanAll={handleScanAll}
|
||||
onUpdateDb={handleUpdateDb}
|
||||
/>
|
||||
|
||||
{/* Scan Host Selection Modal */}
|
||||
<ScanHostSelectionModal
|
||||
isOpen={showScanHostSelection}
|
||||
onClose={() => setShowScanHostSelection(false)}
|
||||
onStartScan={handleStartScan}
|
||||
/>
|
||||
|
||||
{/* Scan Progress Modal */}
|
||||
<ScanProgressModal
|
||||
isOpen={showScanProgress}
|
||||
onClose={() => setShowScanProgress(false)}
|
||||
totalQueued={scannedCount}
|
||||
/>
|
||||
|
||||
{/* Trivy Database Update Modal */}
|
||||
<TrivyDatabaseModal
|
||||
isOpen={showDbModal}
|
||||
onClose={() => setShowDbModal(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useMemo, useRef } from 'react';
|
||||
import {
|
||||
getVulnerabilitySummary,
|
||||
getVulnerabilityScans,
|
||||
getVulnerabilityDetails,
|
||||
scanImage,
|
||||
} from '@/lib/api';
|
||||
import type { VulnerabilitySummary, VulnerabilityScan, Vulnerability } from '@/types';
|
||||
|
||||
// Chart.js type declaration
|
||||
declare const Chart: {
|
||||
new (ctx: CanvasRenderingContext2D, config: unknown): {
|
||||
destroy: () => void;
|
||||
update: () => void;
|
||||
};
|
||||
};
|
||||
|
||||
function SeverityBadge({ severity, count }: { severity: string; count: number }) {
|
||||
const colors: Record<string, string> = {
|
||||
critical: 'bg-[#ff1744] text-white',
|
||||
high: 'bg-[#ff9800] text-white',
|
||||
medium: 'bg-[#ffc107] text-black',
|
||||
low: 'bg-[#4caf50] text-white',
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`px-2 py-0.5 text-xs rounded font-medium ${colors[severity] || 'bg-gray-500 text-white'}`}>
|
||||
{count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value, icon, color = 'text-[var(--text-primary)]' }: { label: string; value: number | string; icon: string; color?: string }) {
|
||||
return (
|
||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span>{icon}</span>
|
||||
<span className="text-sm text-[var(--text-tertiary)]">{label}</span>
|
||||
</div>
|
||||
<div className={`text-3xl font-bold ${color}`}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface VulnerabilityDetailsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
imageId: string;
|
||||
imageName: string;
|
||||
}
|
||||
|
||||
function VulnerabilityDetailsModal({ isOpen, onClose, imageId, imageName }: VulnerabilityDetailsModalProps) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [scan, setScan] = useState<VulnerabilityScan | null>(null);
|
||||
const [vulnerabilities, setVulnerabilities] = useState<Vulnerability[]>([]);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [severityFilter, setSeverityFilter] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && imageId) {
|
||||
setLoading(true);
|
||||
getVulnerabilityDetails(imageId)
|
||||
.then(data => {
|
||||
setScan(data.scan);
|
||||
setVulnerabilities(data.vulnerabilities || []);
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
}, [isOpen, imageId]);
|
||||
|
||||
const filteredVulns = useMemo(() => {
|
||||
return vulnerabilities.filter(v => {
|
||||
const matchesSearch = filter === '' ||
|
||||
v.vulnerability_id.toLowerCase().includes(filter.toLowerCase()) ||
|
||||
v.pkg_name.toLowerCase().includes(filter.toLowerCase()) ||
|
||||
(v.title || '').toLowerCase().includes(filter.toLowerCase());
|
||||
const matchesSeverity = severityFilter === '' || v.severity.toLowerCase() === severityFilter.toLowerCase();
|
||||
return matchesSearch && matchesSeverity;
|
||||
});
|
||||
}, [vulnerabilities, filter, severityFilter]);
|
||||
|
||||
// Compute counts from vulnerabilities array (most accurate)
|
||||
const counts = useMemo(() => {
|
||||
const c = { critical: 0, high: 0, medium: 0, low: 0, total: 0 };
|
||||
vulnerabilities.forEach(v => {
|
||||
c.total++;
|
||||
const sev = v.severity.toLowerCase();
|
||||
if (sev === 'critical') c.critical++;
|
||||
else if (sev === 'high') c.high++;
|
||||
else if (sev === 'medium') c.medium++;
|
||||
else if (sev === 'low') c.low++;
|
||||
});
|
||||
return c;
|
||||
}, [vulnerabilities]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg w-full max-w-4xl max-h-[80vh] flex flex-col">
|
||||
<div className="p-4 border-b border-[var(--border)] flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">Vulnerability Details</h2>
|
||||
<div className="text-sm text-[var(--text-tertiary)]">{imageName}</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-2xl hover:opacity-70">×</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex-1 flex items-center justify-center p-8">
|
||||
<div className="text-[var(--text-tertiary)]">Loading...</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Summary with severity badges */}
|
||||
<div className="p-4 border-b border-[var(--border)] flex flex-wrap gap-3 items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-[var(--text-tertiary)]">Critical:</span>
|
||||
<SeverityBadge severity="critical" count={counts.critical} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-[var(--text-tertiary)]">High:</span>
|
||||
<SeverityBadge severity="high" count={counts.high} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-[var(--text-tertiary)]">Medium:</span>
|
||||
<SeverityBadge severity="medium" count={counts.medium} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-[var(--text-tertiary)]">Low:</span>
|
||||
<SeverityBadge severity="low" count={counts.low} />
|
||||
</div>
|
||||
<span className="text-sm text-[var(--text-tertiary)] ml-4">
|
||||
Total: <strong>{counts.total}</strong>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="p-4 border-b border-[var(--border)] flex gap-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search CVE, package, title..."
|
||||
value={filter}
|
||||
onChange={e => setFilter(e.target.value)}
|
||||
className="flex-1 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-3 py-2 text-sm focus:outline-none focus:border-[var(--accent)]"
|
||||
/>
|
||||
<select
|
||||
value={severityFilter}
|
||||
onChange={e => setSeverityFilter(e.target.value)}
|
||||
className="bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-3 py-2 text-sm focus:outline-none focus:border-[var(--accent)]"
|
||||
>
|
||||
<option value="">All Severities</option>
|
||||
<option value="critical">Critical</option>
|
||||
<option value="high">High</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="low">Low</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Vulnerabilities List */}
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{filteredVulns.length === 0 ? (
|
||||
<div className="text-center py-8 text-[var(--text-tertiary)]">
|
||||
{vulnerabilities.length === 0 ? 'No vulnerabilities found' : 'No matching vulnerabilities'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filteredVulns.map((v, idx) => (
|
||||
<div key={idx} className="bg-[var(--bg-tertiary)] rounded p-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<SeverityBadge severity={v.severity.toLowerCase()} count={1} />
|
||||
<code className="text-sm font-medium">{v.vulnerability_id}</code>
|
||||
</div>
|
||||
<div className="text-sm mb-1">{v.title || 'No title'}</div>
|
||||
<div className="text-xs text-[var(--text-tertiary)]">
|
||||
Package: {v.pkg_name} ({v.installed_version})
|
||||
{v.fixed_version && <span> → Fix: {v.fixed_version}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SecurityContentProps {
|
||||
onScanAll: () => void;
|
||||
onUpdateDb: () => void;
|
||||
}
|
||||
|
||||
export default function SecurityContent({ onScanAll, onUpdateDb }: SecurityContentProps) {
|
||||
const [summary, setSummary] = useState<VulnerabilitySummary | null>(null);
|
||||
const [scans, setScans] = useState<VulnerabilityScan[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [severityFilter, setSeverityFilter] = useState('');
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [detailsModal, setDetailsModal] = useState<{ imageId: string; imageName: string } | null>(null);
|
||||
|
||||
const severityChartRef = useRef<HTMLCanvasElement>(null);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const severityChartInstance = useRef<any>(null);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [summaryData, scansData] = await Promise.all([
|
||||
getVulnerabilitySummary().catch(() => null),
|
||||
getVulnerabilityScans(1000).catch(() => []),
|
||||
]);
|
||||
setSummary(summaryData);
|
||||
setScans(scansData);
|
||||
} catch (error) {
|
||||
console.error('Failed to load security data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
|
||||
// Load Chart.js from CDN
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/chart.js@4.4.0';
|
||||
script.async = true;
|
||||
document.body.appendChild(script);
|
||||
|
||||
const interval = setInterval(loadData, 30000);
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
if (script.parentNode) script.parentNode.removeChild(script);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Helper to get severity count from scan (handles both nested and flat formats)
|
||||
const getSeverityCount = (scan: VulnerabilityScan, severity: 'critical' | 'high' | 'medium' | 'low'): number => {
|
||||
// Try nested severity_counts first (current API format)
|
||||
if (scan.severity_counts && typeof scan.severity_counts === 'object') {
|
||||
return scan.severity_counts[severity] || 0;
|
||||
}
|
||||
// Fallback to flat fields (legacy format)
|
||||
const legacyField = `${severity}_count` as keyof VulnerabilityScan;
|
||||
return (scan[legacyField] as number) || 0;
|
||||
};
|
||||
|
||||
// Compute stats from scans (most reliable)
|
||||
const stats = useMemo(() => {
|
||||
let critical = 0, high = 0, medium = 0, low = 0, atRisk = 0;
|
||||
scans.forEach(scan => {
|
||||
if (scan.success) {
|
||||
critical += getSeverityCount(scan, 'critical');
|
||||
high += getSeverityCount(scan, 'high');
|
||||
medium += getSeverityCount(scan, 'medium');
|
||||
low += getSeverityCount(scan, 'low');
|
||||
if (scan.total_vulnerabilities > 0) atRisk++;
|
||||
}
|
||||
});
|
||||
return {
|
||||
total: scans.filter(s => s.success).length,
|
||||
critical,
|
||||
high,
|
||||
medium,
|
||||
low,
|
||||
atRisk,
|
||||
};
|
||||
}, [scans]);
|
||||
|
||||
// Render severity distribution chart
|
||||
useEffect(() => {
|
||||
if (loading || !severityChartRef.current || typeof Chart === 'undefined') return;
|
||||
|
||||
if (severityChartInstance.current) {
|
||||
severityChartInstance.current.destroy();
|
||||
}
|
||||
|
||||
const ctx = severityChartRef.current.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
severityChartInstance.current = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['Critical', 'High', 'Medium', 'Low'],
|
||||
datasets: [{
|
||||
data: [stats.critical, stats.high, stats.medium, stats.low],
|
||||
backgroundColor: ['#ff1744', '#ff9800', '#ffc107', '#4caf50'],
|
||||
borderWidth: 0,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: { color: '#94a3b8', padding: 15 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (severityChartInstance.current) {
|
||||
severityChartInstance.current.destroy();
|
||||
}
|
||||
};
|
||||
}, [loading, stats]);
|
||||
|
||||
const filteredScans = useMemo(() => {
|
||||
return scans.filter(scan => {
|
||||
const matchesSearch = searchTerm === '' ||
|
||||
scan.image_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
scan.image_id.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
let matchesStatus = true;
|
||||
if (statusFilter === 'scanned') {
|
||||
matchesStatus = scan.success === true;
|
||||
} else if (statusFilter === 'remote') {
|
||||
matchesStatus = scan.success !== true && Boolean(scan.error?.includes('not available') || scan.error?.includes('remote'));
|
||||
} else if (statusFilter === 'failed') {
|
||||
matchesStatus = scan.success !== true && !scan.error?.includes('not available');
|
||||
}
|
||||
|
||||
let matchesSeverity = true;
|
||||
if (severityFilter === 'critical') {
|
||||
matchesSeverity = getSeverityCount(scan, 'critical') > 0;
|
||||
} else if (severityFilter === 'high') {
|
||||
matchesSeverity = getSeverityCount(scan, 'high') > 0;
|
||||
} else if (severityFilter === 'medium') {
|
||||
matchesSeverity = getSeverityCount(scan, 'medium') > 0;
|
||||
} else if (severityFilter === 'low') {
|
||||
matchesSeverity = getSeverityCount(scan, 'low') > 0;
|
||||
} else if (severityFilter === 'clean') {
|
||||
matchesSeverity = scan.total_vulnerabilities === 0 && scan.success === true;
|
||||
}
|
||||
|
||||
return matchesSearch && matchesStatus && matchesSeverity;
|
||||
});
|
||||
}, [scans, searchTerm, statusFilter, severityFilter]);
|
||||
|
||||
const handleScanImage = async (imageId: string) => {
|
||||
try {
|
||||
await scanImage(imageId);
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed to scan image:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Determine status badge for a scan
|
||||
const getStatusBadge = (scan: VulnerabilityScan) => {
|
||||
if (!scan.success) {
|
||||
const isRemote = scan.error?.includes('not available') || scan.error?.includes('remote');
|
||||
if (isRemote) {
|
||||
return <span className="px-2 py-1 text-xs rounded bg-[var(--info)] text-white">🌐 Remote</span>;
|
||||
}
|
||||
return <span className="px-2 py-1 text-xs rounded bg-[var(--danger)] text-white" title={scan.error}>⚠️ Failed</span>;
|
||||
}
|
||||
if (scan.total_vulnerabilities === 0) {
|
||||
return <span className="px-2 py-1 text-xs rounded bg-[var(--success)] text-white">✓ Clean</span>;
|
||||
}
|
||||
if (getSeverityCount(scan, 'critical') > 0) {
|
||||
return <span className="px-2 py-1 text-xs rounded bg-[#ff1744] text-white">🚨 Critical</span>;
|
||||
}
|
||||
if (getSeverityCount(scan, 'high') > 0) {
|
||||
return <span className="px-2 py-1 text-xs rounded bg-[#ff9800] text-white">⚠️ High</span>;
|
||||
}
|
||||
return <span className="px-2 py-1 text-xs rounded bg-[#ffc107] text-black">⚡ Vuln</span>;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-[var(--text-tertiary)]">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">🛡️ Security</h1>
|
||||
<p className="text-sm text-[var(--text-tertiary)]">Monitor and track security vulnerabilities across all container images</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onScanAll}
|
||||
disabled={actionLoading}
|
||||
className="px-4 py-2 text-sm bg-[var(--accent)] text-white rounded hover:opacity-80 transition-opacity disabled:opacity-50"
|
||||
>
|
||||
{actionLoading ? '...' : '🔍 Scan All'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onUpdateDb}
|
||||
disabled={actionLoading}
|
||||
className="px-4 py-2 text-sm border border-[var(--border)] rounded hover:bg-[var(--bg-tertiary)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
{actionLoading ? '...' : '⬇️ Update DB'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<StatCard icon="📊" label="Scanned Images" value={stats.total} />
|
||||
<StatCard icon="🚨" label="Critical" value={stats.critical} color={stats.critical > 0 ? 'text-[#ff1744]' : ''} />
|
||||
<StatCard icon="⚠️" label="High" value={stats.high} color={stats.high > 0 ? 'text-[#ff9800]' : ''} />
|
||||
<StatCard icon="🛡️" label="At Risk Images" value={stats.atRisk} color={stats.atRisk > 0 ? 'text-[var(--warning)]' : ''} />
|
||||
</div>
|
||||
|
||||
{/* Charts Row */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Severity Distribution Chart */}
|
||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg p-4">
|
||||
<h3 className="text-lg font-medium mb-2">Severity Distribution</h3>
|
||||
<p className="text-xs text-[var(--text-tertiary)] mb-4">Current vulnerability breakdown</p>
|
||||
<div className="h-64">
|
||||
<canvas ref={severityChartRef}></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Severity Counts */}
|
||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg p-4">
|
||||
<h3 className="text-lg font-medium mb-2">Vulnerability Counts</h3>
|
||||
<p className="text-xs text-[var(--text-tertiary)] mb-4">Total vulnerabilities by severity</p>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-3 bg-[var(--bg-tertiary)] rounded">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full bg-[#ff1744]"></span>
|
||||
<span>Critical</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-[#ff1744]">{stats.critical}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 bg-[var(--bg-tertiary)] rounded">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full bg-[#ff9800]"></span>
|
||||
<span>High</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-[#ff9800]">{stats.high}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 bg-[var(--bg-tertiary)] rounded">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full bg-[#ffc107]"></span>
|
||||
<span>Medium</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-[#ffc107]">{stats.medium}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 bg-[var(--bg-tertiary)] rounded">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full bg-[#4caf50]"></span>
|
||||
<span>Low</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-[#4caf50]">{stats.low}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Queue Status */}
|
||||
{summary?.queue_status && (summary.queue_status.in_progress > 0 || summary.queue_status.pending > 0) && (
|
||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg p-4 flex items-center gap-4">
|
||||
<span className="text-xl">⏳</span>
|
||||
<span className="text-sm">
|
||||
<strong>Scanning in progress:</strong> {summary.queue_status.in_progress} scanning, {summary.queue_status.pending} queued
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="🔍 Search images..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="flex-1 min-w-[200px] bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg px-4 py-2 text-[var(--text-primary)] placeholder-[var(--text-tertiary)] focus:outline-none focus:border-[var(--accent)]"
|
||||
/>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg px-4 py-2 text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="scanned">Scanned Only</option>
|
||||
<option value="remote">Remote Only</option>
|
||||
<option value="failed">Failed Only</option>
|
||||
</select>
|
||||
<select
|
||||
value={severityFilter}
|
||||
onChange={(e) => setSeverityFilter(e.target.value)}
|
||||
className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg px-4 py-2 text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
|
||||
>
|
||||
<option value="">All Severities</option>
|
||||
<option value="critical">Critical</option>
|
||||
<option value="high">High</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="clean">Clean</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Scans Table */}
|
||||
{filteredScans.length === 0 ? (
|
||||
<div className="text-center py-12 text-[var(--text-tertiary)]">
|
||||
No vulnerability scans found
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-[var(--border)] flex items-center justify-between">
|
||||
<h3 className="font-medium">Vulnerability Scans</h3>
|
||||
<span className="text-sm text-[var(--text-tertiary)]">{filteredScans.length} scans</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-[var(--bg-tertiary)]">
|
||||
<th className="text-left px-4 py-3 text-sm font-medium">Image</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium">Status</th>
|
||||
<th className="text-center px-4 py-3 text-sm font-medium">Critical</th>
|
||||
<th className="text-center px-4 py-3 text-sm font-medium">High</th>
|
||||
<th className="text-center px-4 py-3 text-sm font-medium">Medium</th>
|
||||
<th className="text-center px-4 py-3 text-sm font-medium">Low</th>
|
||||
<th className="text-center px-4 py-3 text-sm font-medium">Total</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium">Last Scan</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredScans.map(scan => (
|
||||
<tr
|
||||
key={scan.image_id}
|
||||
className="border-t border-[var(--border)] hover:bg-[var(--bg-tertiary)] cursor-pointer"
|
||||
onClick={() => setDetailsModal({ imageId: scan.image_id, imageName: scan.image_name })}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<code className="text-sm">{scan.image_name}</code>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{getStatusBadge(scan)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<SeverityBadge severity="critical" count={getSeverityCount(scan, 'critical')} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<SeverityBadge severity="high" count={getSeverityCount(scan, 'high')} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<SeverityBadge severity="medium" count={getSeverityCount(scan, 'medium')} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<SeverityBadge severity="low" count={getSeverityCount(scan, 'low')} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center font-medium">{scan.total_vulnerabilities}</td>
|
||||
<td className="px-4 py-3 text-sm text-[var(--text-tertiary)]">
|
||||
{new Date(scan.scanned_at).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleScanImage(scan.image_id);
|
||||
}}
|
||||
className="px-2 py-1 text-sm rounded hover:bg-[var(--bg-secondary)] transition-colors"
|
||||
title="Rescan"
|
||||
>
|
||||
🔄
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Details Modal */}
|
||||
{detailsModal && (
|
||||
<VulnerabilityDetailsModal
|
||||
isOpen={!!detailsModal}
|
||||
onClose={() => setDetailsModal(null)}
|
||||
imageId={detailsModal.imageId}
|
||||
imageName={detailsModal.imageName}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,622 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useMemo, useRef } from 'react';
|
||||
import {
|
||||
getVulnerabilitySummary,
|
||||
getVulnerabilityScans,
|
||||
getVulnerabilityDetails,
|
||||
scanImage,
|
||||
scanAllImages,
|
||||
updateVulnerabilityDb,
|
||||
} from '@/lib/api';
|
||||
import type { VulnerabilitySummary, VulnerabilityScan, Vulnerability } from '@/types';
|
||||
|
||||
// Chart.js type declaration
|
||||
declare const Chart: {
|
||||
new (ctx: CanvasRenderingContext2D, config: unknown): {
|
||||
destroy: () => void;
|
||||
update: () => void;
|
||||
};
|
||||
};
|
||||
|
||||
function SeverityBadge({ severity, count }: { severity: string; count: number }) {
|
||||
const colors: Record<string, string> = {
|
||||
critical: 'bg-[#ff1744] text-white',
|
||||
high: 'bg-[#ff9800] text-white',
|
||||
medium: 'bg-[#ffc107] text-black',
|
||||
low: 'bg-[#4caf50] text-white',
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`px-2 py-0.5 text-xs rounded font-medium ${colors[severity] || 'bg-gray-500 text-white'}`}>
|
||||
{count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value, icon, color = 'text-[var(--text-primary)]' }: { label: string; value: number | string; icon: string; color?: string }) {
|
||||
return (
|
||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span>{icon}</span>
|
||||
<span className="text-sm text-[var(--text-tertiary)]">{label}</span>
|
||||
</div>
|
||||
<div className={`text-3xl font-bold ${color}`}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface VulnerabilityDetailsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
imageId: string;
|
||||
imageName: string;
|
||||
}
|
||||
|
||||
function VulnerabilityDetailsModal({ isOpen, onClose, imageId, imageName }: VulnerabilityDetailsModalProps) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [scan, setScan] = useState<VulnerabilityScan | null>(null);
|
||||
const [vulnerabilities, setVulnerabilities] = useState<Vulnerability[]>([]);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [severityFilter, setSeverityFilter] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && imageId) {
|
||||
setLoading(true);
|
||||
getVulnerabilityDetails(imageId)
|
||||
.then(data => {
|
||||
setScan(data.scan);
|
||||
setVulnerabilities(data.vulnerabilities || []);
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
}, [isOpen, imageId]);
|
||||
|
||||
const filteredVulns = useMemo(() => {
|
||||
return vulnerabilities.filter(v => {
|
||||
const matchesSearch = filter === '' ||
|
||||
v.vulnerability_id.toLowerCase().includes(filter.toLowerCase()) ||
|
||||
v.pkg_name.toLowerCase().includes(filter.toLowerCase()) ||
|
||||
(v.title || '').toLowerCase().includes(filter.toLowerCase());
|
||||
const matchesSeverity = severityFilter === '' || v.severity.toLowerCase() === severityFilter.toLowerCase();
|
||||
return matchesSearch && matchesSeverity;
|
||||
});
|
||||
}, [vulnerabilities, filter, severityFilter]);
|
||||
|
||||
// Compute counts from vulnerabilities array (most accurate)
|
||||
const counts = useMemo(() => {
|
||||
const c = { critical: 0, high: 0, medium: 0, low: 0, total: 0 };
|
||||
vulnerabilities.forEach(v => {
|
||||
c.total++;
|
||||
const sev = v.severity.toLowerCase();
|
||||
if (sev === 'critical') c.critical++;
|
||||
else if (sev === 'high') c.high++;
|
||||
else if (sev === 'medium') c.medium++;
|
||||
else if (sev === 'low') c.low++;
|
||||
});
|
||||
return c;
|
||||
}, [vulnerabilities]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg w-full max-w-4xl max-h-[80vh] flex flex-col">
|
||||
<div className="p-4 border-b border-[var(--border)] flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">Vulnerability Details</h2>
|
||||
<div className="text-sm text-[var(--text-tertiary)]">{imageName}</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-2xl hover:opacity-70">×</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex-1 flex items-center justify-center p-8">
|
||||
<div className="text-[var(--text-tertiary)]">Loading...</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Summary with severity badges */}
|
||||
<div className="p-4 border-b border-[var(--border)] flex flex-wrap gap-3 items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-[var(--text-tertiary)]">Critical:</span>
|
||||
<SeverityBadge severity="critical" count={counts.critical} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-[var(--text-tertiary)]">High:</span>
|
||||
<SeverityBadge severity="high" count={counts.high} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-[var(--text-tertiary)]">Medium:</span>
|
||||
<SeverityBadge severity="medium" count={counts.medium} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-[var(--text-tertiary)]">Low:</span>
|
||||
<SeverityBadge severity="low" count={counts.low} />
|
||||
</div>
|
||||
<span className="text-sm text-[var(--text-tertiary)] ml-4">
|
||||
Total: <strong>{counts.total}</strong>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="p-4 border-b border-[var(--border)] flex gap-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search CVE, package, title..."
|
||||
value={filter}
|
||||
onChange={e => setFilter(e.target.value)}
|
||||
className="flex-1 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-3 py-2 text-sm focus:outline-none focus:border-[var(--accent)]"
|
||||
/>
|
||||
<select
|
||||
value={severityFilter}
|
||||
onChange={e => setSeverityFilter(e.target.value)}
|
||||
className="bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-3 py-2 text-sm focus:outline-none focus:border-[var(--accent)]"
|
||||
>
|
||||
<option value="">All Severities</option>
|
||||
<option value="critical">Critical</option>
|
||||
<option value="high">High</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="low">Low</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Vulnerabilities List */}
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{filteredVulns.length === 0 ? (
|
||||
<div className="text-center py-8 text-[var(--text-tertiary)]">
|
||||
{vulnerabilities.length === 0 ? 'No vulnerabilities found' : 'No matching vulnerabilities'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filteredVulns.map((v, idx) => (
|
||||
<div key={idx} className="bg-[var(--bg-tertiary)] rounded p-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<SeverityBadge severity={v.severity.toLowerCase()} count={1} />
|
||||
<code className="text-sm font-medium">{v.vulnerability_id}</code>
|
||||
</div>
|
||||
<div className="text-sm mb-1">{v.title || 'No title'}</div>
|
||||
<div className="text-xs text-[var(--text-tertiary)]">
|
||||
Package: {v.pkg_name} ({v.installed_version})
|
||||
{v.fixed_version && <span> → Fix: {v.fixed_version}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { useState } from 'react';
|
||||
import { scanAllImages } from '@/lib/api';
|
||||
import ScanProgressModal from '@/components/ScanProgressModal';
|
||||
import TrivyDatabaseModal from '@/components/TrivyDatabaseModal';
|
||||
import AgentCapabilityBanner from '@/components/AgentCapabilityBanner';
|
||||
import SecurityContent from './SecurityContent';
|
||||
|
||||
export default function SecurityPage() {
|
||||
const [summary, setSummary] = useState<VulnerabilitySummary | null>(null);
|
||||
const [scans, setScans] = useState<VulnerabilityScan[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [severityFilter, setSeverityFilter] = useState('');
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [detailsModal, setDetailsModal] = useState<{ imageId: string; imageName: string } | null>(null);
|
||||
|
||||
const severityChartRef = useRef<HTMLCanvasElement>(null);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const severityChartInstance = useRef<any>(null);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [summaryData, scansData] = await Promise.all([
|
||||
getVulnerabilitySummary().catch(() => null),
|
||||
getVulnerabilityScans(1000).catch(() => []),
|
||||
]);
|
||||
setSummary(summaryData);
|
||||
setScans(scansData);
|
||||
} catch (error) {
|
||||
console.error('Failed to load security data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
|
||||
// Load Chart.js from CDN
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/chart.js@4.4.0';
|
||||
script.async = true;
|
||||
document.body.appendChild(script);
|
||||
|
||||
const interval = setInterval(loadData, 30000);
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
if (script.parentNode) script.parentNode.removeChild(script);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Helper to get severity count from scan (handles both nested and flat formats)
|
||||
const getSeverityCount = (scan: VulnerabilityScan, severity: 'critical' | 'high' | 'medium' | 'low'): number => {
|
||||
// Try nested severity_counts first (current API format)
|
||||
if (scan.severity_counts && typeof scan.severity_counts === 'object') {
|
||||
return scan.severity_counts[severity] || 0;
|
||||
}
|
||||
// Fallback to flat fields (legacy format)
|
||||
const legacyField = `${severity}_count` as keyof VulnerabilityScan;
|
||||
return (scan[legacyField] as number) || 0;
|
||||
};
|
||||
|
||||
// Compute stats from scans (most reliable)
|
||||
const stats = useMemo(() => {
|
||||
let critical = 0, high = 0, medium = 0, low = 0, atRisk = 0;
|
||||
scans.forEach(scan => {
|
||||
if (scan.success) {
|
||||
critical += getSeverityCount(scan, 'critical');
|
||||
high += getSeverityCount(scan, 'high');
|
||||
medium += getSeverityCount(scan, 'medium');
|
||||
low += getSeverityCount(scan, 'low');
|
||||
if (scan.total_vulnerabilities > 0) atRisk++;
|
||||
}
|
||||
});
|
||||
return {
|
||||
total: scans.filter(s => s.success).length,
|
||||
critical,
|
||||
high,
|
||||
medium,
|
||||
low,
|
||||
atRisk,
|
||||
};
|
||||
}, [scans]);
|
||||
|
||||
// Render severity distribution chart
|
||||
useEffect(() => {
|
||||
if (loading || !severityChartRef.current || typeof Chart === 'undefined') return;
|
||||
|
||||
if (severityChartInstance.current) {
|
||||
severityChartInstance.current.destroy();
|
||||
}
|
||||
|
||||
const ctx = severityChartRef.current.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
severityChartInstance.current = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['Critical', 'High', 'Medium', 'Low'],
|
||||
datasets: [{
|
||||
data: [stats.critical, stats.high, stats.medium, stats.low],
|
||||
backgroundColor: ['#ff1744', '#ff9800', '#ffc107', '#4caf50'],
|
||||
borderWidth: 0,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: { color: '#94a3b8', padding: 15 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (severityChartInstance.current) {
|
||||
severityChartInstance.current.destroy();
|
||||
}
|
||||
};
|
||||
}, [loading, stats]);
|
||||
|
||||
const filteredScans = useMemo(() => {
|
||||
return scans.filter(scan => {
|
||||
const matchesSearch = searchTerm === '' ||
|
||||
scan.image_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
scan.image_id.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
let matchesStatus = true;
|
||||
if (statusFilter === 'scanned') {
|
||||
matchesStatus = scan.success === true;
|
||||
} else if (statusFilter === 'remote') {
|
||||
matchesStatus = scan.success !== true && Boolean(scan.error?.includes('not available') || scan.error?.includes('remote'));
|
||||
} else if (statusFilter === 'failed') {
|
||||
matchesStatus = scan.success !== true && !scan.error?.includes('not available');
|
||||
}
|
||||
|
||||
let matchesSeverity = true;
|
||||
if (severityFilter === 'critical') {
|
||||
matchesSeverity = getSeverityCount(scan, 'critical') > 0;
|
||||
} else if (severityFilter === 'high') {
|
||||
matchesSeverity = getSeverityCount(scan, 'high') > 0;
|
||||
} else if (severityFilter === 'medium') {
|
||||
matchesSeverity = getSeverityCount(scan, 'medium') > 0;
|
||||
} else if (severityFilter === 'low') {
|
||||
matchesSeverity = getSeverityCount(scan, 'low') > 0;
|
||||
} else if (severityFilter === 'clean') {
|
||||
matchesSeverity = scan.total_vulnerabilities === 0 && scan.success === true;
|
||||
}
|
||||
|
||||
return matchesSearch && matchesStatus && matchesSeverity;
|
||||
});
|
||||
}, [scans, searchTerm, statusFilter, severityFilter]);
|
||||
const [showScanProgress, setShowScanProgress] = useState(false);
|
||||
const [showDbModal, setShowDbModal] = useState(false);
|
||||
|
||||
const handleScanAll = async () => {
|
||||
setActionLoading(true);
|
||||
setShowScanProgress(true);
|
||||
try {
|
||||
await scanAllImages();
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed to scan all images:', error);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
setShowScanProgress(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateDb = async () => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await updateVulnerabilityDb();
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed to update vulnerability database:', error);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
const handleUpdateDb = () => {
|
||||
setShowDbModal(true);
|
||||
};
|
||||
|
||||
const handleScanImage = async (imageId: string) => {
|
||||
try {
|
||||
await scanImage(imageId);
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed to scan image:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Determine status badge for a scan
|
||||
const getStatusBadge = (scan: VulnerabilityScan) => {
|
||||
if (!scan.success) {
|
||||
const isRemote = scan.error?.includes('not available') || scan.error?.includes('remote');
|
||||
if (isRemote) {
|
||||
return <span className="px-2 py-1 text-xs rounded bg-[var(--info)] text-white">🌐 Remote</span>;
|
||||
}
|
||||
return <span className="px-2 py-1 text-xs rounded bg-[var(--danger)] text-white" title={scan.error}>⚠️ Failed</span>;
|
||||
}
|
||||
if (scan.total_vulnerabilities === 0) {
|
||||
return <span className="px-2 py-1 text-xs rounded bg-[var(--success)] text-white">✓ Clean</span>;
|
||||
}
|
||||
if (getSeverityCount(scan, 'critical') > 0) {
|
||||
return <span className="px-2 py-1 text-xs rounded bg-[#ff1744] text-white">🚨 Critical</span>;
|
||||
}
|
||||
if (getSeverityCount(scan, 'high') > 0) {
|
||||
return <span className="px-2 py-1 text-xs rounded bg-[#ff9800] text-white">⚠️ High</span>;
|
||||
}
|
||||
return <span className="px-2 py-1 text-xs rounded bg-[#ffc107] text-black">⚡ Vuln</span>;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-[var(--text-tertiary)]">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">🛡️ Security</h1>
|
||||
<p className="text-sm text-[var(--text-tertiary)]">Monitor and track security vulnerabilities across all container images</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleScanAll}
|
||||
disabled={actionLoading}
|
||||
className="px-4 py-2 text-sm bg-[var(--accent)] text-white rounded hover:opacity-80 transition-opacity disabled:opacity-50"
|
||||
>
|
||||
{actionLoading ? '...' : '🔍 Scan All'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleUpdateDb}
|
||||
disabled={actionLoading}
|
||||
className="px-4 py-2 text-sm border border-[var(--border)] rounded hover:bg-[var(--bg-tertiary)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
{actionLoading ? '...' : '⬇️ Update DB'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Agent Capability Banner */}
|
||||
<AgentCapabilityBanner />
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<StatCard icon="📊" label="Scanned Images" value={stats.total} />
|
||||
<StatCard icon="🚨" label="Critical" value={stats.critical} color={stats.critical > 0 ? 'text-[#ff1744]' : ''} />
|
||||
<StatCard icon="⚠️" label="High" value={stats.high} color={stats.high > 0 ? 'text-[#ff9800]' : ''} />
|
||||
<StatCard icon="🛡️" label="At Risk Images" value={stats.atRisk} color={stats.atRisk > 0 ? 'text-[var(--warning)]' : ''} />
|
||||
</div>
|
||||
{/* Security Content */}
|
||||
<SecurityContent
|
||||
onScanAll={handleScanAll}
|
||||
onUpdateDb={handleUpdateDb}
|
||||
/>
|
||||
|
||||
{/* Charts Row */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Severity Distribution Chart */}
|
||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg p-4">
|
||||
<h3 className="text-lg font-medium mb-2">Severity Distribution</h3>
|
||||
<p className="text-xs text-[var(--text-tertiary)] mb-4">Current vulnerability breakdown</p>
|
||||
<div className="h-64">
|
||||
<canvas ref={severityChartRef}></canvas>
|
||||
</div>
|
||||
</div>
|
||||
{/* Scan Progress Modal */}
|
||||
<ScanProgressModal
|
||||
isOpen={showScanProgress}
|
||||
onClose={() => setShowScanProgress(false)}
|
||||
/>
|
||||
|
||||
{/* Severity Counts */}
|
||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg p-4">
|
||||
<h3 className="text-lg font-medium mb-2">Vulnerability Counts</h3>
|
||||
<p className="text-xs text-[var(--text-tertiary)] mb-4">Total vulnerabilities by severity</p>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-3 bg-[var(--bg-tertiary)] rounded">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full bg-[#ff1744]"></span>
|
||||
<span>Critical</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-[#ff1744]">{stats.critical}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 bg-[var(--bg-tertiary)] rounded">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full bg-[#ff9800]"></span>
|
||||
<span>High</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-[#ff9800]">{stats.high}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 bg-[var(--bg-tertiary)] rounded">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full bg-[#ffc107]"></span>
|
||||
<span>Medium</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-[#ffc107]">{stats.medium}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 bg-[var(--bg-tertiary)] rounded">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full bg-[#4caf50]"></span>
|
||||
<span>Low</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-[#4caf50]">{stats.low}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Queue Status */}
|
||||
{summary?.queue_status && (summary.queue_status.in_progress > 0 || summary.queue_status.pending > 0) && (
|
||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg p-4 flex items-center gap-4">
|
||||
<span className="text-xl">⏳</span>
|
||||
<span className="text-sm">
|
||||
<strong>Scanning in progress:</strong> {summary.queue_status.in_progress} scanning, {summary.queue_status.pending} queued
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="🔍 Search images..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="flex-1 min-w-[200px] bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg px-4 py-2 text-[var(--text-primary)] placeholder-[var(--text-tertiary)] focus:outline-none focus:border-[var(--accent)]"
|
||||
/>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg px-4 py-2 text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="scanned">Scanned Only</option>
|
||||
<option value="remote">Remote Only</option>
|
||||
<option value="failed">Failed Only</option>
|
||||
</select>
|
||||
<select
|
||||
value={severityFilter}
|
||||
onChange={(e) => setSeverityFilter(e.target.value)}
|
||||
className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg px-4 py-2 text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
|
||||
>
|
||||
<option value="">All Severities</option>
|
||||
<option value="critical">Critical</option>
|
||||
<option value="high">High</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="clean">Clean</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Scans Table */}
|
||||
{filteredScans.length === 0 ? (
|
||||
<div className="text-center py-12 text-[var(--text-tertiary)]">
|
||||
No vulnerability scans found
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-[var(--border)] flex items-center justify-between">
|
||||
<h3 className="font-medium">Vulnerability Scans</h3>
|
||||
<span className="text-sm text-[var(--text-tertiary)]">{filteredScans.length} scans</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-[var(--bg-tertiary)]">
|
||||
<th className="text-left px-4 py-3 text-sm font-medium">Image</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium">Status</th>
|
||||
<th className="text-center px-4 py-3 text-sm font-medium">Critical</th>
|
||||
<th className="text-center px-4 py-3 text-sm font-medium">High</th>
|
||||
<th className="text-center px-4 py-3 text-sm font-medium">Medium</th>
|
||||
<th className="text-center px-4 py-3 text-sm font-medium">Low</th>
|
||||
<th className="text-center px-4 py-3 text-sm font-medium">Total</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium">Last Scan</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredScans.map(scan => (
|
||||
<tr
|
||||
key={scan.image_id}
|
||||
className="border-t border-[var(--border)] hover:bg-[var(--bg-tertiary)] cursor-pointer"
|
||||
onClick={() => setDetailsModal({ imageId: scan.image_id, imageName: scan.image_name })}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<code className="text-sm">{scan.image_name}</code>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{getStatusBadge(scan)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<SeverityBadge severity="critical" count={getSeverityCount(scan, 'critical')} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<SeverityBadge severity="high" count={getSeverityCount(scan, 'high')} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<SeverityBadge severity="medium" count={getSeverityCount(scan, 'medium')} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<SeverityBadge severity="low" count={getSeverityCount(scan, 'low')} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center font-medium">{scan.total_vulnerabilities}</td>
|
||||
<td className="px-4 py-3 text-sm text-[var(--text-tertiary)]">
|
||||
{new Date(scan.scanned_at).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleScanImage(scan.image_id);
|
||||
}}
|
||||
className="px-2 py-1 text-sm rounded hover:bg-[var(--bg-secondary)] transition-colors"
|
||||
title="Rescan"
|
||||
>
|
||||
🔄
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Details Modal */}
|
||||
{detailsModal && (
|
||||
<VulnerabilityDetailsModal
|
||||
isOpen={!!detailsModal}
|
||||
onClose={() => setDetailsModal(null)}
|
||||
imageId={detailsModal.imageId}
|
||||
imageName={detailsModal.imageName}
|
||||
/>
|
||||
)}
|
||||
{/* Trivy Database Update Modal */}
|
||||
<TrivyDatabaseModal
|
||||
isOpen={showDbModal}
|
||||
onClose={() => setShowDbModal(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { getTrivySummary } from '@/lib/api';
|
||||
import type { TrivySummary } from '@/types';
|
||||
|
||||
const BANNER_DISMISSED_KEY = 'agent-capability-banner-dismissed';
|
||||
|
||||
export default function AgentCapabilityBanner() {
|
||||
const [summary, setSummary] = useState<TrivySummary | null>(null);
|
||||
const [isDismissed, setIsDismissed] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
// Check if banner was dismissed
|
||||
const dismissed = localStorage.getItem(BANNER_DISMISSED_KEY);
|
||||
if (dismissed === 'true') {
|
||||
setIsDismissed(true);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
loadSummary();
|
||||
}, []);
|
||||
|
||||
const loadSummary = async () => {
|
||||
try {
|
||||
const data = await getTrivySummary();
|
||||
setSummary(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load Trivy summary:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
localStorage.setItem(BANNER_DISMISSED_KEY, 'true');
|
||||
setIsDismissed(true);
|
||||
};
|
||||
|
||||
const handleNavigate = () => {
|
||||
router.push('/hosts');
|
||||
};
|
||||
|
||||
// Don't show if loading, dismissed, or no issues
|
||||
if (loading || isDismissed || !summary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only show if there are hosts without Trivy or disabled
|
||||
const hasIssues = summary.without_trivy > 0 || summary.disabled > 0;
|
||||
if (!hasIssues) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border-l-4 border-yellow-400 dark:border-yellow-600 p-4 mb-6">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-yellow-400 dark:text-yellow-500" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3 flex-1">
|
||||
<div className="text-sm text-yellow-700 dark:text-yellow-300">
|
||||
<p className="font-medium">Agent Vulnerability Scanning Status</p>
|
||||
<p className="mt-1">
|
||||
{summary.with_trivy > 0 && (
|
||||
<span className="font-semibold text-green-700 dark:text-green-400">
|
||||
{summary.with_trivy} host{summary.with_trivy !== 1 ? 's' : ''} with Trivy
|
||||
</span>
|
||||
)}
|
||||
{summary.with_trivy > 0 && (summary.without_trivy > 0 || summary.disabled > 0) && ', '}
|
||||
{summary.without_trivy > 0 && (
|
||||
<span className="font-semibold text-yellow-800 dark:text-yellow-400">
|
||||
{summary.without_trivy} without Trivy
|
||||
</span>
|
||||
)}
|
||||
{summary.without_trivy > 0 && summary.disabled > 0 && ', '}
|
||||
{summary.disabled > 0 && (
|
||||
<span className="font-semibold text-gray-700 dark:text-gray-400">
|
||||
{summary.disabled} disabled
|
||||
</span>
|
||||
)}
|
||||
.{' '}
|
||||
<button
|
||||
onClick={handleNavigate}
|
||||
className="underline hover:text-yellow-900 dark:hover:text-yellow-200"
|
||||
>
|
||||
Click to manage hosts
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto pl-3">
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="inline-flex rounded-md text-yellow-400 hover:text-yellow-500 focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:ring-offset-2"
|
||||
>
|
||||
<span className="sr-only">Dismiss</span>
|
||||
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getTrivyStatus } from '@/lib/api';
|
||||
import type { TrivyHostStatus } from '@/types';
|
||||
|
||||
interface ScanHostSelectionModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onStartScan: (hostIds: number[]) => Promise<void>;
|
||||
}
|
||||
|
||||
export default function ScanHostSelectionModal({ isOpen, onClose, onStartScan }: ScanHostSelectionModalProps) {
|
||||
const [hosts, setHosts] = useState<TrivyHostStatus[]>([]);
|
||||
const [selectedHostIds, setSelectedHostIds] = useState<number[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadHosts();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const loadHosts = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getTrivyStatus();
|
||||
setHosts(data.hosts);
|
||||
// Pre-select hosts that have Trivy installed
|
||||
setSelectedHostIds(data.hosts.filter(h => h.has_trivy).map(h => h.host_id));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load hosts');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleHost = (hostId: number) => {
|
||||
setSelectedHostIds(prev =>
|
||||
prev.includes(hostId)
|
||||
? prev.filter(id => id !== hostId)
|
||||
: [...prev, hostId]
|
||||
);
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
setSelectedHostIds(hosts.filter(h => h.has_trivy).map(h => h.host_id));
|
||||
};
|
||||
|
||||
const handleDeselectAll = () => {
|
||||
setSelectedHostIds([]);
|
||||
};
|
||||
|
||||
const handleStart = async () => {
|
||||
if (selectedHostIds.length === 0) {
|
||||
setError('Please select at least one host');
|
||||
return;
|
||||
}
|
||||
|
||||
setStarting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Close the modal first, THEN start the scan
|
||||
// This ensures the progress modal opens before scans complete
|
||||
onClose();
|
||||
await onStartScan(selectedHostIds);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to start scan');
|
||||
} finally {
|
||||
setStarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 max-w-3xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
Select Hosts to Scan
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 rounded text-red-700 dark:text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">Loading hosts...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-4 flex justify-between items-center">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Select hosts to scan their container images for vulnerabilities
|
||||
</p>
|
||||
<div className="space-x-2">
|
||||
<button
|
||||
onClick={handleSelectAll}
|
||||
className="text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
Select All
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeselectAll}
|
||||
className="text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
Deselect All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 mb-6 max-h-80 overflow-y-auto">
|
||||
{hosts.map(host => (
|
||||
<div
|
||||
key={host.host_id}
|
||||
className={`p-4 border rounded-lg ${
|
||||
host.has_trivy
|
||||
? 'bg-white dark:bg-gray-700 border-gray-200 dark:border-gray-600'
|
||||
: 'bg-gray-50 dark:bg-gray-800 border-gray-200 dark:border-gray-700 opacity-60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedHostIds.includes(host.host_id)}
|
||||
onChange={() => handleToggleHost(host.host_id)}
|
||||
disabled={!host.has_trivy}
|
||||
className="mt-1 mr-3 h-4 w-4 text-blue-600 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium text-gray-900 dark:text-white">
|
||||
{host.host_name}
|
||||
</h3>
|
||||
{!host.has_trivy && (
|
||||
<span className="text-xs px-2 py-1 bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 rounded">
|
||||
No Trivy
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{host.has_trivy ? (
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div className="flex items-center space-x-4">
|
||||
<span>Trivy: {host.trivy_version || 'Unknown'}</span>
|
||||
<span>DB: {host.db_version || 'Unknown'}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-500">
|
||||
Trivy not available on this host - cannot scan for vulnerabilities
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={starting}
|
||||
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleStart}
|
||||
disabled={starting || selectedHostIds.length === 0}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 flex items-center"
|
||||
>
|
||||
{starting && (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
)}
|
||||
{starting ? 'Starting...' : `Scan ${selectedHostIds.length} Host${selectedHostIds.length !== 1 ? 's' : ''}`}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getScanProgress } from '@/lib/api';
|
||||
import type { ScanProgress } from '@/types';
|
||||
|
||||
interface ScanProgressModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onComplete?: () => void;
|
||||
totalQueued?: number; // Number of images that were queued for scanning
|
||||
}
|
||||
|
||||
export default function ScanProgressModal({ isOpen, onClose, onComplete, totalQueued }: ScanProgressModalProps) {
|
||||
const [progress, setProgress] = useState<ScanProgress | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [startTime] = useState(Date.now());
|
||||
const [hasSeenActivity, setHasSeenActivity] = useState(false);
|
||||
const [completedAt, setCompletedAt] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
// Reset state when modal closes
|
||||
setProgress(null);
|
||||
setError(null);
|
||||
setHasSeenActivity(false);
|
||||
setCompletedAt(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let pollInterval: NodeJS.Timeout;
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const data = await getScanProgress();
|
||||
setProgress(data);
|
||||
setError(null);
|
||||
|
||||
// Track if we've seen any activity
|
||||
// Consider it activity if there's anything in the queue OR if images were queued OR if this is the first poll
|
||||
// (scans might complete so fast they're done before we poll)
|
||||
if (data.total > 0 || data.in_progress > 0 || data.pending > 0) {
|
||||
setHasSeenActivity(true);
|
||||
} else if (!hasSeenActivity && (progress === null || (totalQueued && totalQueued > 0))) {
|
||||
// First poll came back empty but we know images were queued
|
||||
// Scans completed very quickly (likely from cache)
|
||||
setHasSeenActivity(true);
|
||||
}
|
||||
|
||||
// Check if complete
|
||||
const isComplete = data.total === 0 && data.in_progress === 0 && data.pending === 0;
|
||||
|
||||
if (isComplete && hasSeenActivity && !completedAt) {
|
||||
// Mark completion time
|
||||
setCompletedAt(Date.now());
|
||||
if (onComplete) onComplete();
|
||||
}
|
||||
|
||||
// Auto-close 3 seconds after completion (only if we saw activity)
|
||||
if (completedAt && Date.now() - completedAt > 3000) {
|
||||
onClose();
|
||||
}
|
||||
|
||||
// Auto-close after 5 minutes
|
||||
if (Date.now() - startTime > 5 * 60 * 1000) {
|
||||
onClose();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch progress');
|
||||
}
|
||||
};
|
||||
|
||||
// Initial poll
|
||||
poll();
|
||||
|
||||
// Poll every 2 seconds
|
||||
pollInterval = setInterval(poll, 2000);
|
||||
|
||||
return () => {
|
||||
if (pollInterval) clearInterval(pollInterval);
|
||||
};
|
||||
}, [isOpen, onClose, onComplete, startTime, hasSeenActivity, completedAt]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 max-w-2xl w-full mx-4">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
Scan Progress
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 rounded text-red-700 dark:text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{progress && (
|
||||
<>
|
||||
{/* Overall Progress */}
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
<span>
|
||||
{progress.in_progress} scanning, {progress.pending} queued
|
||||
</span>
|
||||
<span>
|
||||
{progress.total > 0 ? `${progress.in_progress + progress.pending} remaining` : 'Complete'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2.5">
|
||||
<div
|
||||
className="bg-blue-600 h-2.5 rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width: progress.total > 0 ? `${((progress.in_progress / progress.total) * 100)}%` : '100%'
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Scans */}
|
||||
{progress.current_scans && progress.current_scans.length > 0 ? (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
|
||||
Currently Scanning
|
||||
</h3>
|
||||
<div className="space-y-2 max-h-60 overflow-y-auto">
|
||||
{progress.current_scans.map((scan, idx) => (
|
||||
<div
|
||||
key={`${scan.image_id}-${idx}`}
|
||||
className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700 rounded-lg"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
{scan.image_name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
on {scan.host_name}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : progress.total === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
{hasSeenActivity ? (
|
||||
<>
|
||||
<svg className="mx-auto h-12 w-12 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
All scans complete!
|
||||
</p>
|
||||
{totalQueued && totalQueued > 0 && (
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-500">
|
||||
{totalQueued} image{totalQueued !== 1 ? 's' : ''} scanned
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
No images to scan
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-500">
|
||||
Make sure you have containers running with images to scan
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<p className="text-sm">Waiting for scans to start...</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!progress && !error && (
|
||||
<div className="text-center py-8">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">Loading...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getTrivyStatus, updateVulnerabilityDb } from '@/lib/api';
|
||||
import type { TrivyHostStatus, UpdateDBResponse } from '@/types';
|
||||
|
||||
interface TrivyDatabaseModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
export default function TrivyDatabaseModal({ isOpen, onClose, onComplete }: TrivyDatabaseModalProps) {
|
||||
const [hosts, setHosts] = useState<TrivyHostStatus[]>([]);
|
||||
const [selectedHostIds, setSelectedHostIds] = useState<number[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [updating, setUpdating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [results, setResults] = useState<UpdateDBResponse | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadTrivyStatus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const loadTrivyStatus = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getTrivyStatus();
|
||||
setHosts(data.hosts);
|
||||
// Pre-select hosts that have Trivy
|
||||
setSelectedHostIds(data.hosts.filter(h => h.has_trivy).map(h => h.host_id));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load Trivy status');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleHost = (hostId: number) => {
|
||||
setSelectedHostIds(prev =>
|
||||
prev.includes(hostId)
|
||||
? prev.filter(id => id !== hostId)
|
||||
: [...prev, hostId]
|
||||
);
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
setSelectedHostIds(hosts.filter(h => h.has_trivy).map(h => h.host_id));
|
||||
};
|
||||
|
||||
const handleDeselectAll = () => {
|
||||
setSelectedHostIds([]);
|
||||
};
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (selectedHostIds.length === 0) {
|
||||
setError('Please select at least one host');
|
||||
return;
|
||||
}
|
||||
|
||||
setUpdating(true);
|
||||
setError(null);
|
||||
setResults(null);
|
||||
|
||||
try {
|
||||
const data = await updateVulnerabilityDb(selectedHostIds);
|
||||
setResults(data);
|
||||
|
||||
// Refresh status after update
|
||||
await loadTrivyStatus();
|
||||
|
||||
if (onComplete) {
|
||||
onComplete();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update database');
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
if (!dateStr) return 'Never';
|
||||
const date = new Date(dateStr);
|
||||
if (isNaN(date.getTime())) return 'Never';
|
||||
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffHours < 1) return 'Less than 1 hour ago';
|
||||
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
|
||||
if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
|
||||
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 max-w-3xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
Update Trivy Database
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 rounded text-red-700 dark:text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{results && (
|
||||
<div className="mb-4 p-4 bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded">
|
||||
<h3 className="font-semibold text-blue-900 dark:text-blue-100 mb-2">Update Results</h3>
|
||||
<div className="space-y-1 text-sm">
|
||||
{results.results.map((result, idx) => (
|
||||
<div key={idx} className="flex items-center">
|
||||
{result.success ? (
|
||||
<svg className="w-4 h-4 text-green-600 dark:text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4 text-red-600 dark:text-red-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)}
|
||||
<span className="text-blue-800 dark:text-blue-200">
|
||||
{result.host_name}: {result.success ? 'Updated' : result.error || 'Failed'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">Loading hosts...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-4 flex justify-between items-center">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Select hosts to update their Trivy vulnerability databases
|
||||
</p>
|
||||
<div className="space-x-2">
|
||||
<button
|
||||
onClick={handleSelectAll}
|
||||
className="text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
Select All
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeselectAll}
|
||||
className="text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
Deselect All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 mb-6 max-h-80 overflow-y-auto">
|
||||
{hosts.map(host => (
|
||||
<div
|
||||
key={host.host_id}
|
||||
className={`p-4 border rounded-lg ${
|
||||
host.has_trivy
|
||||
? 'bg-white dark:bg-gray-700 border-gray-200 dark:border-gray-600'
|
||||
: 'bg-gray-50 dark:bg-gray-800 border-gray-200 dark:border-gray-700 opacity-60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedHostIds.includes(host.host_id)}
|
||||
onChange={() => handleToggleHost(host.host_id)}
|
||||
disabled={!host.has_trivy}
|
||||
className="mt-1 mr-3 h-4 w-4 text-blue-600 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium text-gray-900 dark:text-white">
|
||||
{host.host_name}
|
||||
</h3>
|
||||
{!host.has_trivy && (
|
||||
<span className="text-xs px-2 py-1 bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 rounded">
|
||||
No Trivy
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{host.has_trivy ? (
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div className="flex items-center space-x-4">
|
||||
<span>Version: {host.trivy_version || 'Unknown'}</span>
|
||||
<span>DB: {host.db_version || 'Unknown'}</span>
|
||||
</div>
|
||||
<div className="text-xs mt-1">
|
||||
Last updated: {formatDate(host.last_updated)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-500">
|
||||
Trivy not available on this host
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={updating}
|
||||
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleUpdate}
|
||||
disabled={updating || selectedHostIds.length === 0}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 flex items-center"
|
||||
>
|
||||
{updating && (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
)}
|
||||
{updating ? 'Updating...' : `Update ${selectedHostIds.length} Host${selectedHostIds.length !== 1 ? 's' : ''}`}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -104,20 +104,33 @@ export default function Header({ onScan, onTelemetry }: HeaderProps) {
|
||||
<span>Census</span>
|
||||
</Link>
|
||||
{health && (
|
||||
<span className="text-xs px-2 py-1 rounded bg-[var(--bg-tertiary)] text-[var(--text-tertiary)]">
|
||||
{health.update_available ? (
|
||||
<a
|
||||
href={health.release_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[var(--accent)] hover:underline"
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="text-xs px-2 py-1 rounded bg-[var(--bg-tertiary)] text-[var(--text-tertiary)]"
|
||||
title="Current version"
|
||||
>
|
||||
{health.update_available ? (
|
||||
<a
|
||||
href={health.release_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[var(--accent)] hover:underline"
|
||||
>
|
||||
v{health.version} → v{health.latest_version} ⬆️
|
||||
</a>
|
||||
) : (
|
||||
`v${health.version}`
|
||||
)}
|
||||
</span>
|
||||
{health.build_time && health.build_time !== 'unknown' && (
|
||||
<span
|
||||
className="text-xs px-2 py-1 rounded bg-[var(--bg-tertiary)] text-[var(--text-tertiary)]"
|
||||
title={`Built: ${new Date(health.build_time).toLocaleString()}`}
|
||||
>
|
||||
v{health.version} → v{health.latest_version} ⬆️
|
||||
</a>
|
||||
) : (
|
||||
`v${health.version}`
|
||||
🔨 {new Date(health.build_time).toLocaleDateString()} {new Date(health.build_time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
+18
-4
@@ -67,6 +67,10 @@ export const deleteHost = (id: number) =>
|
||||
fetchApi<void>(`/hosts/${id}`, { method: 'DELETE' });
|
||||
export const scanHost = (id: number) =>
|
||||
fetchApi<void>(`/hosts/${id}/scan`, { method: 'POST' });
|
||||
export const getTrivySummary = () =>
|
||||
fetchApi<import('@/types').TrivySummary>('/hosts/trivy-summary');
|
||||
export const getAgentInfo = (hostId: number) =>
|
||||
fetchApi<{ has_trivy: boolean; trivy_version?: string }>(`/hosts/agent/${hostId}/info`);
|
||||
|
||||
// Images
|
||||
export const getImages = () => fetchApi<import('@/types').Image[]>('/images');
|
||||
@@ -82,10 +86,20 @@ export const getVulnerabilityDetails = (imageId: string) =>
|
||||
);
|
||||
export const scanImage = (imageId: string) =>
|
||||
fetchApi<void>(`/p/security/scan/${encodeURIComponent(imageId)}`, { method: 'POST' });
|
||||
export const scanAllImages = () =>
|
||||
fetchApi<void>('/p/security/scan-all', { method: 'POST' });
|
||||
export const updateVulnerabilityDb = () =>
|
||||
fetchApi<void>('/p/security/update-db', { method: 'POST' });
|
||||
export const scanAllImages = (hostIds?: number[]) =>
|
||||
fetchApi<import('@/types').ScanAllResponse>('/p/security/scan-all', {
|
||||
method: 'POST',
|
||||
body: hostIds ? JSON.stringify({ host_ids: hostIds }) : undefined,
|
||||
});
|
||||
export const getScanProgress = () =>
|
||||
fetchApi<import('@/types').ScanProgress>('/p/security/progress');
|
||||
export const getTrivyStatus = () =>
|
||||
fetchApi<import('@/types').TrivyStatusResponse>('/p/security/trivy-status');
|
||||
export const updateVulnerabilityDb = (hostIds?: number[]) =>
|
||||
fetchApi<import('@/types').UpdateDBResponse>('/p/security/update-db', {
|
||||
method: 'POST',
|
||||
body: hostIds ? JSON.stringify({ host_ids: hostIds }) : undefined,
|
||||
});
|
||||
export const getVulnerabilitySettings = () =>
|
||||
fetchApi<Record<string, string>>('/p/security/settings');
|
||||
export const updateVulnerabilitySettings = (settings: Record<string, string>) =>
|
||||
|
||||
@@ -45,6 +45,7 @@ export interface Host {
|
||||
api_token?: string;
|
||||
description?: string;
|
||||
collect_stats: boolean;
|
||||
enable_vulnerability_scanning?: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
last_seen?: string;
|
||||
@@ -231,6 +232,7 @@ export interface Badge {
|
||||
export interface HealthStatus {
|
||||
status: string;
|
||||
version: string;
|
||||
build_time?: string;
|
||||
latest_version?: string;
|
||||
update_available?: boolean;
|
||||
release_url?: string;
|
||||
@@ -295,3 +297,52 @@ export interface ContainerLifecycleEvent {
|
||||
description?: string;
|
||||
restart_count?: number;
|
||||
}
|
||||
|
||||
// Security Plugin Types
|
||||
export interface ScanProgress {
|
||||
in_progress: number;
|
||||
pending: number;
|
||||
total: number;
|
||||
current_scans: Array<{
|
||||
image_id: string;
|
||||
image_name: string;
|
||||
host_id: number;
|
||||
host_name: string;
|
||||
started_at: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface TrivyHostStatus {
|
||||
host_id: number;
|
||||
host_name: string;
|
||||
trivy_version: string;
|
||||
db_version: string;
|
||||
last_updated: string;
|
||||
has_trivy: boolean;
|
||||
}
|
||||
|
||||
export interface TrivyStatusResponse {
|
||||
hosts: TrivyHostStatus[];
|
||||
}
|
||||
|
||||
export interface TrivySummary {
|
||||
with_trivy: number;
|
||||
without_trivy: number;
|
||||
disabled: number;
|
||||
total_agents: number;
|
||||
}
|
||||
|
||||
export interface ScanAllResponse {
|
||||
message: string;
|
||||
queued_by_host: Record<string, number>;
|
||||
total_queued: number;
|
||||
}
|
||||
|
||||
export interface UpdateDBResponse {
|
||||
results: Array<{
|
||||
host_id: number;
|
||||
host_name: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
+10
-3
@@ -793,11 +793,18 @@ async function loadVersion() {
|
||||
const badge = document.getElementById('versionBadge');
|
||||
|
||||
if (data.version) {
|
||||
// Format build time for display
|
||||
let buildTimeText = '';
|
||||
if (data.build_time && data.build_time !== 'unknown') {
|
||||
const buildDate = new Date(data.build_time);
|
||||
buildTimeText = `\nBuilt: ${buildDate.toLocaleString()}`;
|
||||
}
|
||||
|
||||
if (data.update_available && data.latest_version) {
|
||||
// Show update indicator
|
||||
badge.innerHTML = `v${data.version} → v${data.latest_version} <span style="font-size: 1.2em;">⬆️</span>`;
|
||||
badge.style.cursor = 'pointer';
|
||||
badge.title = 'Click to view update';
|
||||
badge.title = `Click to view update${buildTimeText}`;
|
||||
badge.onclick = () => {
|
||||
if (data.release_url) {
|
||||
window.open(data.release_url, '_blank');
|
||||
@@ -811,7 +818,7 @@ async function loadVersion() {
|
||||
// No update available
|
||||
badge.textContent = 'v' + data.version;
|
||||
badge.style.cursor = 'default';
|
||||
badge.title = 'Current version';
|
||||
badge.title = `Current version${buildTimeText}`;
|
||||
badge.onclick = null;
|
||||
}
|
||||
}
|
||||
@@ -1754,7 +1761,7 @@ async function loadHottestImages(days) {
|
||||
|
||||
// Update subtitle
|
||||
document.getElementById('hottestSubtitle').textContent =
|
||||
`Based on ${data.total_installations.toLocaleString()} installations over the last ${data.period_days} days`;
|
||||
`Based on ${data.total_installations.toLocaleString()} active installations over the last ${data.period_days} days`;
|
||||
|
||||
// Create charts
|
||||
createHottestCharts(data);
|
||||
|
||||
+129
-2
@@ -863,11 +863,18 @@ async function loadVersion() {
|
||||
const badge = document.getElementById('versionBadge');
|
||||
|
||||
if (data.version) {
|
||||
// Format build time for display
|
||||
let buildTimeText = '';
|
||||
if (data.build_time && data.build_time !== 'unknown') {
|
||||
const buildDate = new Date(data.build_time);
|
||||
buildTimeText = `\nBuilt: ${buildDate.toLocaleString()}`;
|
||||
}
|
||||
|
||||
if (data.update_available && data.latest_version) {
|
||||
// Show update indicator
|
||||
badge.innerHTML = `v${data.version} → v${data.latest_version} <span style="font-size: 1.2em;">⬆️</span>`;
|
||||
badge.style.cursor = 'pointer';
|
||||
badge.title = 'Click to view update';
|
||||
badge.title = `Click to view update${buildTimeText}`;
|
||||
badge.onclick = () => {
|
||||
if (data.release_url) {
|
||||
window.open(data.release_url, '_blank');
|
||||
@@ -881,7 +888,7 @@ async function loadVersion() {
|
||||
// No update available
|
||||
badge.textContent = 'v' + data.version;
|
||||
badge.style.cursor = 'default';
|
||||
badge.title = 'Current version';
|
||||
badge.title = `Current version${buildTimeText}`;
|
||||
badge.onclick = null;
|
||||
}
|
||||
}
|
||||
@@ -1964,6 +1971,22 @@ function renderHosts(hostsData) {
|
||||
? '<span class="badge badge-success" style="cursor: pointer;" onclick="toggleStatsCollection(' + host.id + ', false)" title="Click to disable stats collection">✓ Enabled</span>'
|
||||
: '<span class="badge badge-secondary" style="cursor: pointer;" onclick="toggleStatsCollection(' + host.id + ', true)" title="Click to enable stats collection">Disabled</span>';
|
||||
|
||||
const vulnScanningBadge = host.enable_vulnerability_scanning
|
||||
? '<span class="badge badge-success" style="cursor: pointer;" onclick="toggleVulnScanning(' + host.id + ', false)" title="Click to disable vulnerability scanning">✓ Enabled</span>'
|
||||
: '<span class="badge badge-secondary" style="cursor: pointer;" onclick="toggleVulnScanning(' + host.id + ', true)" title="Click to enable vulnerability scanning">Disabled</span>';
|
||||
|
||||
// Show Trivy actions only for agent hosts with Trivy capability
|
||||
const hasTrivyActions = host.host_type === 'agent' && host.agent_status === 'online';
|
||||
const trivyActions = hasTrivyActions ? `
|
||||
<div class="dropdown" style="display: inline-block;">
|
||||
<button class="btn-icon" onclick="toggleHostActionsDropdown(${host.id})" title="Trivy Actions">⚙️</button>
|
||||
<div id="hostActions${host.id}" class="dropdown-menu" style="display: none;">
|
||||
<a class="dropdown-item" onclick="updateHostTrivyDB(${host.id})">Update Trivy DB</a>
|
||||
<a class="dropdown-item" onclick="clearHostTrivyCache(${host.id})">Clear Trivy Cache</a>
|
||||
</div>
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td><strong>${escapeHtml(host.name)}</strong></td>
|
||||
@@ -1971,6 +1994,7 @@ function renderHosts(hostsData) {
|
||||
<td><code>${escapeHtml(host.address)}</code></td>
|
||||
<td>${statusBadge}</td>
|
||||
<td>${statsCollectionBadge}</td>
|
||||
<td>${vulnScanningBadge}</td>
|
||||
<td>${escapeHtml(host.description || '-')}</td>
|
||||
<td class="time-ago">${lastSeen}</td>
|
||||
<td class="actions">
|
||||
@@ -1978,6 +2002,7 @@ function renderHosts(hostsData) {
|
||||
? `<button class="btn-icon btn-warning" onclick="toggleHost(${host.id}, false)" title="Disable">⏸</button>`
|
||||
: `<button class="btn-icon btn-success" onclick="toggleHost(${host.id}, true)" title="Enable">▶</button>`
|
||||
}
|
||||
${trivyActions}
|
||||
<button class="btn-icon btn-delete" onclick="deleteHost(${host.id}, '${escapeAttr(host.name)}')" title="Delete">🗑</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -2031,6 +2056,108 @@ async function toggleStatsCollection(hostId, enable) {
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleVulnScanning(hostId, enable) {
|
||||
try {
|
||||
const host = hosts.find(h => h.id === hostId);
|
||||
if (!host) return;
|
||||
|
||||
const response = await fetch(`/api/hosts/${hostId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...host, enable_vulnerability_scanning: enable })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showNotification(`Vulnerability scanning ${enable ? 'enabled' : 'disabled'} successfully`, 'success');
|
||||
loadData();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showNotification('Error: ' + (error.error || 'Failed to update host'), 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showNotification('Error: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function toggleHostActionsDropdown(hostId) {
|
||||
const dropdown = document.getElementById(`hostActions${hostId}`);
|
||||
if (dropdown) {
|
||||
dropdown.style.display = dropdown.style.display === 'none' ? 'block' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function updateHostTrivyDB(hostId) {
|
||||
const host = hosts.find(h => h.id === hostId);
|
||||
if (!host) return;
|
||||
|
||||
showNotification(`Updating Trivy database on ${host.name}...`, 'info');
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`/api/hosts/${hostId}/trivy-update`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
showNotification(result.message || 'Trivy database updated successfully', 'success');
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showNotification(`Failed: ${error.error || 'Unknown error'}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showNotification('Error: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function clearHostTrivyCache(hostId) {
|
||||
const host = hosts.find(h => h.id === hostId);
|
||||
if (!host) return;
|
||||
|
||||
if (!confirm(`Clear Trivy cache on ${host.name}?\n\nNext scan will download the database again (~500MB).`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`/api/hosts/${hostId}/trivy-clear-cache`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showNotification('Trivy cache cleared successfully', 'success');
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showNotification(`Failed: ${error.error || 'Unknown error'}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showNotification('Error: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function updateAllAgentTrivyDBs() {
|
||||
if (!confirm('Update Trivy databases on all agents? This may take several minutes.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
showNotification('Updating Trivy databases on all agents...', 'info');
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth('/api/hosts/bulk-trivy-update', {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
showNotification(`Trivy DB update initiated on ${result.updated} agent(s)`, 'success');
|
||||
setTimeout(loadHosts, 2000);
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showNotification(`Failed: ${error.error || 'Unknown error'}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showNotification('Error: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteHost(hostId, hostName) {
|
||||
if (!confirm(`Are you sure you want to delete host "${hostName}"?\n\nThis will remove all associated container history.`)) {
|
||||
return;
|
||||
|
||||
+6
-2
@@ -664,7 +664,10 @@
|
||||
<div class="hosts-section">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||||
<h2 style="margin: 0;">Configured Hosts</h2>
|
||||
<button id="addAgentBtn" class="btn btn-success">+ Add Agent Host</button>
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<button class="btn btn-secondary" onclick="updateAllAgentTrivyDBs()">Update All Agents' Trivy DBs</button>
|
||||
<button id="addAgentBtn" class="btn btn-success">+ Add Agent Host</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="hostsTable" class="table-container">
|
||||
<table>
|
||||
@@ -675,6 +678,7 @@
|
||||
<th>Address</th>
|
||||
<th>Status</th>
|
||||
<th>Stats Collection</th>
|
||||
<th>Vuln Scanning</th>
|
||||
<th>Description</th>
|
||||
<th>Last Seen</th>
|
||||
<th>Actions</th>
|
||||
@@ -682,7 +686,7 @@
|
||||
</thead>
|
||||
<tbody id="hostsBody">
|
||||
<tr>
|
||||
<td colspan="8" class="loading">Loading...</td>
|
||||
<td colspan="9" class="loading">Loading...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
Reference in New Issue
Block a user