Add quick button to mark unexpected ports as expected

Allow users to add ports to expected list directly from scan results page
instead of navigating through site config pages. The button appears next
to unexpected ports and updates the site IP configuration via the API.

- Add site_id and site_ip_id to scan result data for linking to config
- Add "Mark Expected" button next to unexpected ports in scan detail view
- Implement markPortExpected() JS function to update site IP settings
This commit is contained in:
2025-11-21 15:40:37 -06:00
parent 3058c69c39
commit 9bd2f67150
2 changed files with 115 additions and 4 deletions

View File

@@ -16,7 +16,7 @@ from sqlalchemy.orm import Session, joinedload
from web.models import ( from web.models import (
Scan, ScanSite, ScanIP, ScanPort, ScanService as ScanServiceModel, Scan, ScanSite, ScanIP, ScanPort, ScanService as ScanServiceModel,
ScanCertificate, ScanTLSVersion, Site, ScanSiteAssociation ScanCertificate, ScanTLSVersion, Site, ScanSiteAssociation, SiteIP
) )
from web.utils.pagination import paginate, PaginatedResult from web.utils.pagination import paginate, PaginatedResult
from web.utils.validators import validate_scan_status from web.utils.validators import validate_scan_status
@@ -630,17 +630,47 @@ class ScanService:
def _site_to_dict(self, site: ScanSite) -> Dict[str, Any]: def _site_to_dict(self, site: ScanSite) -> Dict[str, Any]:
"""Convert ScanSite to dictionary.""" """Convert ScanSite to dictionary."""
# Look up the master Site ID from ScanSiteAssociation
master_site_id = None
assoc = (
self.db.query(ScanSiteAssociation)
.filter(
ScanSiteAssociation.scan_id == site.scan_id,
)
.join(Site)
.filter(Site.name == site.site_name)
.first()
)
if assoc:
master_site_id = assoc.site_id
return { return {
'id': site.id, 'id': site.id,
'name': site.site_name, 'name': site.site_name,
'ips': [self._ip_to_dict(ip) for ip in site.ips] 'site_id': master_site_id, # The actual Site ID for config updates
'ips': [self._ip_to_dict(ip, master_site_id) for ip in site.ips]
} }
def _ip_to_dict(self, ip: ScanIP) -> Dict[str, Any]: def _ip_to_dict(self, ip: ScanIP, site_id: Optional[int] = None) -> Dict[str, Any]:
"""Convert ScanIP to dictionary.""" """Convert ScanIP to dictionary."""
# Look up the SiteIP ID for this IP address in the master Site
site_ip_id = None
if site_id:
site_ip = (
self.db.query(SiteIP)
.filter(
SiteIP.site_id == site_id,
SiteIP.ip_address == ip.ip_address
)
.first()
)
if site_ip:
site_ip_id = site_ip.id
return { return {
'id': ip.id, 'id': ip.id,
'address': ip.ip_address, 'address': ip.ip_address,
'site_ip_id': site_ip_id, # The actual SiteIP ID for config updates
'ping_expected': ip.ping_expected, 'ping_expected': ip.ping_expected,
'ping_actual': ip.ping_actual, 'ping_actual': ip.ping_actual,
'ports': [self._port_to_dict(port) for port in ip.ports] 'ports': [self._port_to_dict(port) for port in ip.ports]

View File

@@ -586,6 +586,19 @@
const screenshotPath = service && service.screenshot_path ? service.screenshot_path : null; const screenshotPath = service && service.screenshot_path ? service.screenshot_path : null;
const certificate = service && service.certificates && service.certificates.length > 0 ? service.certificates[0] : null; const certificate = service && service.certificates && service.certificates.length > 0 ? service.certificates[0] : null;
// Build status cell with optional "Mark Expected" button
let statusCell;
if (port.expected) {
statusCell = '<span class="badge badge-good">Expected</span>';
} else {
// Show "Unexpected" badge with "Mark Expected" button if site_id and site_ip_id are available
const canMarkExpected = site.site_id && ip.site_ip_id;
statusCell = `<span class="badge badge-warning">Unexpected</span>`;
if (canMarkExpected) {
statusCell += ` <button class="btn btn-sm btn-outline-success ms-1" onclick="markPortExpected(${site.site_id}, ${ip.site_ip_id}, ${port.port}, '${port.protocol}')" title="Add to expected ports"><i class="bi bi-plus-circle"></i></button>`;
}
}
const row = document.createElement('tr'); const row = document.createElement('tr');
row.classList.add('scan-row'); // Fix white row bug row.classList.add('scan-row'); // Fix white row bug
row.innerHTML = ` row.innerHTML = `
@@ -595,7 +608,7 @@
<td>${service ? service.service_name : '-'}</td> <td>${service ? service.service_name : '-'}</td>
<td>${service ? service.product || '-' : '-'}</td> <td>${service ? service.product || '-' : '-'}</td>
<td class="mono">${service ? service.version || '-' : '-'}</td> <td class="mono">${service ? service.version || '-' : '-'}</td>
<td>${port.expected ? '<span class="badge badge-good">Expected</span>' : '<span class="badge badge-warning">Unexpected</span>'}</td> <td>${statusCell}</td>
<td>${screenshotPath ? `<a href="/output/${screenshotPath.replace(/^\/?(?:app\/)?output\/?/, '')}" target="_blank" class="btn btn-sm btn-outline-primary" title="View Screenshot"><i class="bi bi-image"></i></a>` : '-'}</td> <td>${screenshotPath ? `<a href="/output/${screenshotPath.replace(/^\/?(?:app\/)?output\/?/, '')}" target="_blank" class="btn btn-sm btn-outline-primary" title="View Screenshot"><i class="bi bi-image"></i></a>` : '-'}</td>
<td>${certificate ? `<button class="btn btn-sm btn-outline-info" onclick='showCertificateModal(${JSON.stringify(certificate).replace(/'/g, "&#39;")})' title="View Certificate"><i class="bi bi-shield-lock"></i></button>` : '-'}</td> <td>${certificate ? `<button class="btn btn-sm btn-outline-info" onclick='showCertificateModal(${JSON.stringify(certificate).replace(/'/g, "&#39;")})' title="View Certificate"><i class="bi bi-shield-lock"></i></button>` : '-'}</td>
`; `;
@@ -757,6 +770,74 @@
} }
} }
// Mark a port as expected in the site config
async function markPortExpected(siteId, ipId, portNumber, protocol) {
try {
// First, get the current IP settings - fetch all IPs with high per_page to find the one we need
const getResponse = await fetch(`/api/sites/${siteId}/ips?per_page=200`);
if (!getResponse.ok) {
throw new Error('Failed to get site IPs');
}
const ipsData = await getResponse.json();
// Find the IP in the site
const ipData = ipsData.ips.find(ip => ip.id === ipId);
if (!ipData) {
throw new Error('IP not found in site');
}
// Get current expected ports
let expectedTcpPorts = ipData.expected_tcp_ports || [];
let expectedUdpPorts = ipData.expected_udp_ports || [];
// Add the new port to the appropriate list
if (protocol.toLowerCase() === 'tcp') {
if (!expectedTcpPorts.includes(portNumber)) {
expectedTcpPorts.push(portNumber);
expectedTcpPorts.sort((a, b) => a - b);
}
} else if (protocol.toLowerCase() === 'udp') {
if (!expectedUdpPorts.includes(portNumber)) {
expectedUdpPorts.push(portNumber);
expectedUdpPorts.sort((a, b) => a - b);
}
}
// Update the IP settings
const updateResponse = await fetch(`/api/sites/${siteId}/ips/${ipId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
expected_tcp_ports: expectedTcpPorts,
expected_udp_ports: expectedUdpPorts
})
});
if (!updateResponse.ok) {
let errorMessage = 'Failed to update IP settings';
try {
const errorData = await updateResponse.json();
errorMessage = errorData.message || errorMessage;
} catch (e) {
// Ignore JSON parse errors
}
throw new Error(errorMessage);
}
// Show success message
showAlert('success', `Port ${portNumber}/${protocol.toUpperCase()} added to expected ports for this IP. Refresh the page to see updated status.`);
// Optionally refresh the scan data to show the change
// Note: The scan data itself won't change, but the user knows it's been updated
} catch (error) {
console.error('Error marking port as expected:', error);
showAlert('danger', `Failed to mark port as expected: ${error.message}`);
}
}
// Find previous scan and show compare button // Find previous scan and show compare button
let previousScanId = null; let previousScanId = null;
let currentConfigId = null; let currentConfigId = null;