diff --git a/CLAUDE.md b/CLAUDE.md index cb332f7..4a295e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,6 +10,16 @@ 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 +## Build Instructions + +**IMPORTANT**: When building binaries during development, ALWAYS build to `/tmp/container-census`: + +```bash +CGO_ENABLED=1 go build -o /tmp/container-census ./cmd/server +``` + +This ensures a consistent location for testing and prevents confusion with multiple build locations. + ## Build and Development Commands ### Prerequisites diff --git a/internal/api/vulnerabilities.go b/internal/api/vulnerabilities.go index 43955ab..6738ddc 100644 --- a/internal/api/vulnerabilities.go +++ b/internal/api/vulnerabilities.go @@ -15,7 +15,7 @@ import ( // VulnerabilityScanner interface for the vulnerability scanner type VulnerabilityScanner interface { GetCachedScan(imageID string) (*vulnerability.VulnerabilityScan, error) - ScanImage(ctx context.Context, imageRef string) (*vulnerability.VulnerabilityScanResult, error) + ScanImage(ctx context.Context, imageID string, imageName string) (*vulnerability.VulnerabilityScanResult, error) UpdateTrivyDB(ctx context.Context) error GetConfig() *vulnerability.Config SetConfig(config *vulnerability.Config) diff --git a/internal/vulnerability/scanner.go b/internal/vulnerability/scanner.go index a570123..fc1295d 100644 --- a/internal/vulnerability/scanner.go +++ b/internal/vulnerability/scanner.go @@ -31,26 +31,27 @@ func NewScanner(config *Config, storage VulnerabilityStorage) *Scanner { } // ScanImage scans an image for vulnerabilities using Trivy -func (s *Scanner) ScanImage(ctx context.Context, imageRef string) (*VulnerabilityScanResult, error) { +// imageID should be the SHA256 image ID, imageName should be the image reference +func (s *Scanner) ScanImage(ctx context.Context, imageID string, imageName string) (*VulnerabilityScanResult, error) { if !s.config.GetEnabled() { return nil, fmt.Errorf("vulnerability scanning is disabled") } startTime := time.Now() - log.Printf("Starting vulnerability scan for image: %s", imageRef) + log.Printf("Starting vulnerability scan for image: %s (ID: %s)", imageName, imageID) // Create context with timeout scanCtx, cancel := context.WithTimeout(ctx, s.config.GetScanTimeout()) defer cancel() - // Run Trivy scan - trivyResult, err := s.runTrivy(scanCtx, imageRef) + // Run Trivy scan using the image name + trivyResult, err := s.runTrivy(scanCtx, imageName) if err != nil { scanDuration := time.Since(startTime).Milliseconds() - // Save failed scan + // Save failed scan with the actual image ID failedScan := &VulnerabilityScan{ - ImageID: extractImageID(imageRef), - ImageName: imageRef, + ImageID: imageID, + ImageName: imageName, ScannedAt: time.Now(), ScanDurationMs: scanDuration, Success: false, @@ -61,14 +62,14 @@ func (s *Scanner) ScanImage(ctx context.Context, imageRef string) (*Vulnerabilit } // Parse results - vulnerabilities := s.parseTrivyResult(trivyResult, imageRef) + vulnerabilities := s.parseTrivyResult(trivyResult, imageID) severityCounts := CalculateSeverityCounts(vulnerabilities) scanDuration := time.Since(startTime).Milliseconds() - // Create scan record + // Create scan record with the actual image ID scan := &VulnerabilityScan{ - ImageID: extractImageID(imageRef), - ImageName: imageRef, + ImageID: imageID, + ImageName: imageName, ScannedAt: time.Now(), ScanDurationMs: scanDuration, Success: true, @@ -84,7 +85,7 @@ func (s *Scanner) ScanImage(ctx context.Context, imageRef string) (*Vulnerabilit } log.Printf("Vulnerability scan completed for %s: %d vulnerabilities found (%d critical, %d high) in %dms", - imageRef, severityCounts.GetTotal(), severityCounts.Critical, severityCounts.High, scanDuration) + imageName, severityCounts.GetTotal(), severityCounts.Critical, severityCounts.High, scanDuration) return &VulnerabilityScanResult{ Scan: *scan, @@ -149,9 +150,8 @@ func (s *Scanner) runTrivy(ctx context.Context, imageRef string) (*TrivyResult, } // parseTrivyResult converts Trivy output to our vulnerability format -func (s *Scanner) parseTrivyResult(trivyResult *TrivyResult, imageRef string) []Vulnerability { +func (s *Scanner) parseTrivyResult(trivyResult *TrivyResult, imageID string) []Vulnerability { vulnerabilities := make([]Vulnerability, 0) - imageID := extractImageID(imageRef) for _, result := range trivyResult.Results { if result.Vulnerabilities == nil { diff --git a/internal/vulnerability/scheduler.go b/internal/vulnerability/scheduler.go index 1d85111..16bc807 100644 --- a/internal/vulnerability/scheduler.go +++ b/internal/vulnerability/scheduler.go @@ -200,8 +200,8 @@ func (w *worker) processJob(job ScanJob) { atomic.AddInt32(&w.scheduler.inProgressCount, 1) defer atomic.AddInt32(&w.scheduler.inProgressCount, -1) - // Perform the scan - _, err := w.scheduler.scanner.ScanImage(w.scheduler.ctx, job.ImageName) + // Perform the scan with both image ID and name + _, err := w.scheduler.scanner.ScanImage(w.scheduler.ctx, job.ImageID, job.ImageName) if err != nil { // Only log unexpected errors (not "image not available") if err.Error() != "image not available for scanning" { diff --git a/scripts/run.local.sh b/scripts/run.local.sh index c37bc28..b7e2afa 100755 --- a/scripts/run.local.sh +++ b/scripts/run.local.sh @@ -1,2 +1,2 @@ #!/bin/bash -SERVER_PORT=3000 CONFIG_PATH=/opt/docker-compose/census-server/census/config/config.yaml AUTH_ENABLED=false DATABASE_PATH=/opt/docker-compose/census-server/census/server/census.db /tmp/census-1.3.25 \ No newline at end of file +SERVER_PORT=3000 CONFIG_PATH=/opt/docker-compose/census-server/census/config/config.yaml AUTH_ENABLED=false DATABASE_PATH=/opt/docker-compose/census-server/census/server/census.db /tmp/container-census \ No newline at end of file diff --git a/scripts/server-build.sh b/scripts/server-build.sh index 4ab04ea..801ab30 100755 --- a/scripts/server-build.sh +++ b/scripts/server-build.sh @@ -1,2 +1,4 @@ #!/bin/bash -CGO_ENABLED=1 go build -o container-census cmd/server/main.go \ No newline at end of file +# CGO_ENABLED=1 go build -o container-census cmd/server/main.go + +CGO_ENABLED=1 go build -o /tmp/container-census ./cmd/server && ls -lh /tmp/container-census diff --git a/web/app.js b/web/app.js index 9ccce90..108f7d2 100644 --- a/web/app.js +++ b/web/app.js @@ -16,6 +16,10 @@ let vulnerabilityCache = {}; // Cache vulnerability data by imageID let vulnerabilityScansMap = {}; // Pre-loaded map of all scans to avoid 404s let vulnerabilitySummary = null; // Cache overall summary +// Auth credentials (empty if auth is disabled) +let authUsername = ''; +let authPassword = ''; + // Initialize document.addEventListener('DOMContentLoaded', () => { setupEventListeners(); @@ -416,6 +420,7 @@ function setupEventListeners() { document.getElementById('vulnerabilitySettingsBtn')?.addEventListener('click', openVulnerabilitySettingsModal); document.getElementById('securitySearchInput')?.addEventListener('input', filterSecurityScans); document.getElementById('securitySeverityFilter')?.addEventListener('change', filterSecurityScans); + document.getElementById('securityStatusFilter')?.addEventListener('change', filterSecurityScans); // Vulnerability settings modal const vulnSettingsForm = document.getElementById('vulnerabilitySettingsForm'); @@ -4639,7 +4644,11 @@ async function getVulnerabilityScan(imageID) { // Pre-load all vulnerability scans to avoid 404 requests async function preloadVulnerabilityScans() { try { - const response = await fetch('/api/vulnerabilities/scans?limit=1000'); + const response = await fetch('/api/vulnerabilities/scans?limit=1000', { + headers: { + 'Authorization': 'Basic ' + btoa(authUsername + ':' + authPassword) + } + }); if (response.ok) { const scans = await response.json(); // Build a map of imageID -> scan data @@ -4733,7 +4742,7 @@ function getVulnerabilityBadgeHTML(scan) { // Add vulnerability badge to container card (called asynchronously) async function addVulnerabilityBadge(containerElement, imageID) { const scan = await getVulnerabilityScan(imageID); - const badgeHTML = getVulnerabilityBadgeHTML(scan); + const badgeHTML = getVulnerabilityBadgeHTML(scan, imageID); // Find the image row in the container card const imageRow = containerElement.querySelector('.detail-value.image-value'); @@ -4741,7 +4750,16 @@ async function addVulnerabilityBadge(containerElement, imageID) { // Add badge after the image name const badgeContainer = document.createElement('span'); badgeContainer.innerHTML = badgeHTML; - imageRow.parentElement.appendChild(badgeContainer.firstChild); + const badge = badgeContainer.firstChild; + + // Make badge clickable if it has vulnerabilities + if (scan && scan.scan && scan.scan.success) { + const imageName = imageRow.textContent.trim(); + badge.style.cursor = 'pointer'; + badge.onclick = () => viewVulnerabilityDetails(imageID, imageName); + } + + imageRow.parentElement.appendChild(badge); } } @@ -4795,13 +4813,13 @@ async function loadSecurityTab() { allVulnerabilityScans = scans || []; // Update summary cards - updateSecuritySummaryCards(summary); + updateSecuritySummaryCards(summary, allVulnerabilityScans); // Render security chart renderSecurityChart(summary); // Render vulnerability trends chart - renderVulnerabilityTrendsChart(); + renderVulnerabilityTrendsChart(allVulnerabilityScans); // Update queue status updateQueueStatus(summary?.queue_status); @@ -4864,7 +4882,7 @@ async function loadAllVulnerabilityScans() { } // Update security summary cards -function updateSecuritySummaryCards(summary) { +function updateSecuritySummaryCards(summary, scans) { if (!summary) { document.getElementById('totalScannedImages').textContent = '-'; document.getElementById('totalCriticalVulns').textContent = '-'; @@ -4875,7 +4893,12 @@ function updateSecuritySummaryCards(summary) { // Handle both wrapped (summary.summary) and direct summary objects const s = summary.summary || summary; - document.getElementById('totalScannedImages').textContent = s.total_images_scanned || 0; + const totalScans = scans ? scans.length : 0; + const uniqueImages = s.total_images_scanned || 0; + + // Show both unique images and total scans for clarity + const displayText = totalScans > 0 ? `${uniqueImages} (${totalScans} scans)` : `${uniqueImages}`; + document.getElementById('totalScannedImages').textContent = displayText; document.getElementById('totalCriticalVulns').textContent = s.severity_counts?.critical || 0; document.getElementById('totalHighVulns').textContent = s.severity_counts?.high || 0; document.getElementById('atRiskImages').textContent = s.images_with_vulnerabilities || 0; @@ -4941,22 +4964,17 @@ function renderSecurityChart(summary) { let trendsChart = null; // Render vulnerability trends chart -async function renderVulnerabilityTrendsChart() { +function renderVulnerabilityTrendsChart(scans) { const ctx = document.getElementById('vulnerabilityTrendsChart'); if (!ctx) return; try { - // Fetch all scans - const response = await fetch('/api/vulnerabilities/scans?limit=1000', { - headers: { 'Authorization': 'Basic ' + btoa(authUsername + ':' + authPassword) } - }); - - if (!response.ok) { - throw new Error('Failed to fetch scans'); + // Use provided scans data + if (!scans || scans.length === 0) { + console.log('No scan data available for trends chart'); + return; } - const scans = await response.json(); - // Group scans by date (last 30 days) and calculate aggregates const now = new Date(); const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); @@ -5168,6 +5186,7 @@ function updateQueueStatus(queueStatus) { function filterSecurityScans() { const searchTerm = document.getElementById('securitySearchInput')?.value.toLowerCase() || ''; const severityFilter = document.getElementById('securitySeverityFilter')?.value || ''; + const statusFilter = document.getElementById('securityStatusFilter')?.value || ''; const filtered = allVulnerabilityScans.filter(scan => { const matchesSearch = searchTerm === '' || @@ -5177,7 +5196,7 @@ function filterSecurityScans() { let matchesSeverity = true; if (severityFilter) { if (severityFilter === 'clean') { - matchesSeverity = scan.total_vulnerabilities === 0; + matchesSeverity = scan.total_vulnerabilities === 0 && scan.success; } else if (severityFilter === 'critical') { matchesSeverity = (scan.severity_counts?.critical || 0) > 0; } else if (severityFilter === 'high') { @@ -5189,7 +5208,19 @@ function filterSecurityScans() { } } - return matchesSearch && matchesSeverity; + let matchesStatus = true; + if (statusFilter) { + const error = scan.error || ''; + if (statusFilter === 'scanned') { + matchesStatus = scan.success; + } else if (statusFilter === 'remote') { + matchesStatus = !scan.success && (error.includes('image not available for scanning') || error.includes('not available')); + } else if (statusFilter === 'failed') { + matchesStatus = !scan.success && !(error.includes('image not available for scanning') || error.includes('not available')); + } + } + + return matchesSearch && matchesSeverity && matchesStatus; }); renderSecurityScansTable(filtered); @@ -5201,7 +5232,7 @@ function renderSecurityScansTable(scans) { if (!tbody) return; if (scans.length === 0) { - tbody.innerHTML = 'No scans found'; + tbody.innerHTML = 'No scans found'; return; } @@ -5214,9 +5245,28 @@ function renderSecurityScansTable(scans) { const low = counts.low || 0; const scannedTime = formatTimeAgo(new Date(scan.scanned_at)); + // Determine status badge + let statusBadge = ''; + if (!scan.success) { + const error = scan.error || ''; + if (error.includes('image not available for scanning') || error.includes('not available')) { + statusBadge = '🌐 Remote'; + } else { + statusBadge = '⚠️ Failed'; + } + } else if (total === 0) { + statusBadge = '✓ Clean'; + } else if (critical > 0) { + statusBadge = '🚨 Critical'; + } else if (high > 0) { + statusBadge = '⚠️ High'; + } else { + statusBadge = '⚡ Vuln'; + } + // Check if this image is currently being scanned - const isScanning = scanningImages.has(scan.image_id); - const rescanBtnClass = isScanning ? 'btn btn-sm btn-secondary' : 'btn btn-sm btn-secondary'; + const isScanning = scan.image_id && scanningImages.has(scan.image_id); + const rescanBtnClass = 'btn btn-sm btn-secondary'; const rescanBtnDisabled = isScanning ? 'disabled' : ''; const rescanBtnText = isScanning ? '⏳ Scanning...' : '🔄 Rescan'; @@ -5229,6 +5279,7 @@ function renderSecurityScansTable(scans) { return ` ${escapeHtml(scan.image_name)} + ${statusBadge} ${total} ${critical} ${high} @@ -5251,7 +5302,10 @@ function renderSecurityScansTable(scans) { // Trigger scan for all images async function scanAllImages() { try { - const response = await fetch('/api/vulnerabilities/scan-all', { method: 'POST' }); + const response = await fetch('/api/vulnerabilities/scan-all', { + method: 'POST', + headers: { 'Authorization': 'Basic ' + btoa(authUsername + ':' + authPassword) } + }); if (response.ok) { const data = await response.json(); showNotification(`Queued ${data.images_queued} images for scanning`, 'success'); @@ -5270,7 +5324,10 @@ async function scanAllImages() { // Trigger scan for a specific image async function rescanImage(imageID, imageName) { try { - const response = await fetch(`/api/vulnerabilities/scan/${encodeURIComponent(imageID)}`, { method: 'POST' }); + const response = await fetch(`/api/vulnerabilities/scan/${encodeURIComponent(imageID)}`, { + method: 'POST', + headers: { 'Authorization': 'Basic ' + btoa(authUsername + ':' + authPassword) } + }); if (response.ok) { showNotification(`Queued ${imageName} for scanning`, 'success'); // Just update the queue status, don't reload the entire table @@ -5291,7 +5348,10 @@ async function rescanImage(imageID, imageName) { async function updateTrivyDB() { try { showNotification('Updating Trivy database... This may take a few minutes.', 'info'); - const response = await fetch('/api/vulnerabilities/update-db', { method: 'POST' }); + const response = await fetch('/api/vulnerabilities/update-db', { + method: 'POST', + headers: { 'Authorization': 'Basic ' + btoa(authUsername + ':' + authPassword) } + }); if (response.ok) { showNotification('Trivy database updated successfully', 'success'); } else { @@ -5311,7 +5371,11 @@ async function viewVulnerabilityDetails(imageID, imageName) { document.getElementById('vulnDetailsContent').innerHTML = '
Loading vulnerabilities...
'; try { - const response = await fetch(`/api/vulnerabilities/image/${encodeURIComponent(imageID)}`); + const response = await fetch(`/api/vulnerabilities/image/${encodeURIComponent(imageID)}`, { + headers: { + 'Authorization': 'Basic ' + btoa(authUsername + ':' + authPassword) + } + }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } @@ -5423,7 +5487,7 @@ async function openVulnerabilitySettingsModal() { if (response.ok) { currentVulnerabilitySettings = await response.json(); populateVulnerabilitySettingsForm(currentVulnerabilitySettings); - document.getElementById('vulnerabilitySettingsModal').classList.add('active'); + document.getElementById('vulnerabilitySettingsModal').classList.add('show'); } else { showNotification('Failed to load vulnerability settings', 'error'); } @@ -5435,7 +5499,7 @@ async function openVulnerabilitySettingsModal() { // Close vulnerability settings modal function closeVulnerabilitySettingsModal() { - document.getElementById('vulnerabilitySettingsModal').classList.remove('active'); + document.getElementById('vulnerabilitySettingsModal').classList.remove('show'); } // Populate vulnerability settings form diff --git a/web/index.html b/web/index.html index d39ecd7..f28aa2f 100644 --- a/web/index.html +++ b/web/index.html @@ -354,6 +354,12 @@ + @@ -362,6 +368,7 @@ Image Name + Status Total Critical High diff --git a/web/styles.css b/web/styles.css index 41d9c8c..9b7de77 100644 --- a/web/styles.css +++ b/web/styles.css @@ -748,6 +748,10 @@ tbody tr:hover { .modal-header h3 { margin: 0; color: #333; + max-width: calc(100% - 50px); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .close-btn { @@ -759,6 +763,7 @@ tbody tr:hover { padding: 0; width: 30px; height: 30px; + flex-shrink: 0; display: flex; align-items: center; justify-content: center; @@ -5377,6 +5382,7 @@ header { .security-table-modern { width: 100%; border-collapse: collapse; + table-layout: fixed; } .security-table-modern thead { @@ -5413,9 +5419,44 @@ header { font-weight: 500; } -.security-table-modern tbody td:last-child { +/* Fixed column widths for security table */ +.security-table-modern th:nth-child(1), +.security-table-modern td:nth-child(1) { width: 30%; overflow: hidden; text-overflow: ellipsis; } /* Image Name */ + +.security-table-modern th:nth-child(2), +.security-table-modern td:nth-child(2) { width: 10%; text-align: center; } /* Status */ + +.security-table-modern th:nth-child(3), +.security-table-modern td:nth-child(3) { width: 6%; text-align: center; } /* Total */ + +.security-table-modern th:nth-child(4), +.security-table-modern td:nth-child(4) { width: 6%; text-align: center; } /* Critical */ + +.security-table-modern th:nth-child(5), +.security-table-modern td:nth-child(5) { width: 6%; text-align: center; } /* High */ + +.security-table-modern th:nth-child(6), +.security-table-modern td:nth-child(6) { width: 6%; text-align: center; } /* Medium */ + +.security-table-modern th:nth-child(7), +.security-table-modern td:nth-child(7) { width: 6%; text-align: center; } /* Low */ + +.security-table-modern th:nth-child(8), +.security-table-modern td:nth-child(8) { width: 12%; } /* Scanned */ + +.security-table-modern th:nth-child(9), +.security-table-modern td:nth-child(9) { + width: 200px; + min-width: 200px; + max-width: 200px; white-space: nowrap; - min-width: 250px; + text-align: center; +} /* Actions - FIXED WIDTH */ + +.security-table-modern tbody td:last-child .btn { + margin: 0 2px; + padding: 4px 8px; + font-size: 12px; } /* Responsive adjustments */