mirror of
https://github.com/selfhosters-cc/container-census.git
synced 2026-01-28 09:28:30 -06:00
More progress on security scans
This commit is contained in:
10
CLAUDE.md
10
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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
|
||||
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
|
||||
@@ -1,2 +1,4 @@
|
||||
#!/bin/bash
|
||||
CGO_ENABLED=1 go build -o container-census cmd/server/main.go
|
||||
# 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
|
||||
|
||||
120
web/app.js
120
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 = '<tr><td colspan="8" class="loading">No scans found</td></tr>';
|
||||
tbody.innerHTML = '<tr><td colspan="9" class="loading">No scans found</td></tr>';
|
||||
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 = '<span class="vulnerability-badge remote" title="Remote image - not available for scanning">🌐 Remote</span>';
|
||||
} else {
|
||||
statusBadge = '<span class="vulnerability-badge not-scanned" title="Scan failed">⚠️ Failed</span>';
|
||||
}
|
||||
} else if (total === 0) {
|
||||
statusBadge = '<span class="vulnerability-badge clean" title="No vulnerabilities found">✓ Clean</span>';
|
||||
} else if (critical > 0) {
|
||||
statusBadge = '<span class="vulnerability-badge critical" title="Has critical vulnerabilities">🚨 Critical</span>';
|
||||
} else if (high > 0) {
|
||||
statusBadge = '<span class="vulnerability-badge high" title="Has high vulnerabilities">⚠️ High</span>';
|
||||
} else {
|
||||
statusBadge = '<span class="vulnerability-badge medium" title="Has vulnerabilities">⚡ Vuln</span>';
|
||||
}
|
||||
|
||||
// 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 `
|
||||
<tr class="${rowClass}">
|
||||
<td><code>${escapeHtml(scan.image_name)}</code></td>
|
||||
<td>${statusBadge}</td>
|
||||
<td>${total}</td>
|
||||
<td><span class="severity-badge critical">${critical}</span></td>
|
||||
<td><span class="severity-badge high">${high}</span></td>
|
||||
@@ -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 = '<div class="loading">Loading vulnerabilities...</div>';
|
||||
|
||||
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
|
||||
|
||||
@@ -354,6 +354,12 @@
|
||||
<option value="low">Low</option>
|
||||
<option value="clean">Clean</option>
|
||||
</select>
|
||||
<select id="securityStatusFilter" class="filter-select">
|
||||
<option value="">All Status</option>
|
||||
<option value="scanned">Scanned Only</option>
|
||||
<option value="remote">Remote Only</option>
|
||||
<option value="failed">Failed Only</option>
|
||||
</select>
|
||||
<input type="text" id="securitySearchInput" class="search-input" placeholder="🔍 Search images...">
|
||||
</div>
|
||||
</div>
|
||||
@@ -362,6 +368,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Image Name</th>
|
||||
<th>Status</th>
|
||||
<th>Total</th>
|
||||
<th>Critical</th>
|
||||
<th>High</th>
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user