Add IP address search feature with global search box
- Add API endpoint GET /api/scans/by-ip/{ip_address} to retrieve
last 10 scans containing a specific IP
- Add ScanService.get_scans_by_ip() method with ScanIP join query
- Add search box to global navigation header
- Create dedicated search results page at /search/ip
- Update API documentation with new endpoint
This commit is contained in:
@@ -281,6 +281,45 @@ def get_scan_status(scan_id):
|
|||||||
}), 500
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/by-ip/<ip_address>', methods=['GET'])
|
||||||
|
@api_auth_required
|
||||||
|
def get_scans_by_ip(ip_address):
|
||||||
|
"""
|
||||||
|
Get last 10 scans containing a specific IP address.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ip_address: IP address to search for
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON response with list of scans containing the IP
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get scans from service
|
||||||
|
scan_service = ScanService(current_app.db_session)
|
||||||
|
scans = scan_service.get_scans_by_ip(ip_address)
|
||||||
|
|
||||||
|
logger.info(f"Retrieved {len(scans)} scans for IP: {ip_address}")
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'ip_address': ip_address,
|
||||||
|
'scans': scans,
|
||||||
|
'count': len(scans)
|
||||||
|
})
|
||||||
|
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
logger.error(f"Database error retrieving scans for IP {ip_address}: {str(e)}")
|
||||||
|
return jsonify({
|
||||||
|
'error': 'Database error',
|
||||||
|
'message': 'Failed to retrieve scans'
|
||||||
|
}), 500
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected error retrieving scans for IP {ip_address}: {str(e)}", exc_info=True)
|
||||||
|
return jsonify({
|
||||||
|
'error': 'Internal server error',
|
||||||
|
'message': 'An unexpected error occurred'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/<int:scan_id1>/compare/<int:scan_id2>', methods=['GET'])
|
@bp.route('/<int:scan_id1>/compare/<int:scan_id2>', methods=['GET'])
|
||||||
@api_auth_required
|
@api_auth_required
|
||||||
def compare_scans(scan_id1, scan_id2):
|
def compare_scans(scan_id1, scan_id2):
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Provides dashboard and scan viewing pages.
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from flask import Blueprint, current_app, redirect, render_template, send_from_directory, url_for
|
from flask import Blueprint, current_app, redirect, render_template, request, send_from_directory, url_for
|
||||||
|
|
||||||
from web.auth.decorators import login_required
|
from web.auth.decorators import login_required
|
||||||
|
|
||||||
@@ -83,6 +83,19 @@ def compare_scans(scan_id1, scan_id2):
|
|||||||
return render_template('scan_compare.html', scan_id1=scan_id1, scan_id2=scan_id2)
|
return render_template('scan_compare.html', scan_id1=scan_id1, scan_id2=scan_id2)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/search/ip')
|
||||||
|
@login_required
|
||||||
|
def search_ip():
|
||||||
|
"""
|
||||||
|
IP search results page - shows scans containing a specific IP address.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Rendered search results template
|
||||||
|
"""
|
||||||
|
ip_address = request.args.get('ip', '').strip()
|
||||||
|
return render_template('ip_search_results.html', ip_address=ip_address)
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/schedules')
|
@bp.route('/schedules')
|
||||||
@login_required
|
@login_required
|
||||||
def schedules():
|
def schedules():
|
||||||
|
|||||||
@@ -260,6 +260,29 @@ class ScanService:
|
|||||||
|
|
||||||
return status_info
|
return status_info
|
||||||
|
|
||||||
|
def get_scans_by_ip(self, ip_address: str, limit: int = 10) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get the last N scans containing a specific IP address.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ip_address: IP address to search for
|
||||||
|
limit: Maximum number of scans to return (default: 10)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of scan summary dictionaries, most recent first
|
||||||
|
"""
|
||||||
|
scans = (
|
||||||
|
self.db.query(Scan)
|
||||||
|
.join(ScanIP, Scan.id == ScanIP.scan_id)
|
||||||
|
.filter(ScanIP.ip_address == ip_address)
|
||||||
|
.filter(Scan.status == 'completed')
|
||||||
|
.order_by(Scan.timestamp.desc())
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
return [self._scan_to_summary_dict(scan) for scan in scans]
|
||||||
|
|
||||||
def cleanup_orphaned_scans(self) -> int:
|
def cleanup_orphaned_scans(self) -> int:
|
||||||
"""
|
"""
|
||||||
Clean up orphaned scans that are stuck in 'running' status.
|
Clean up orphaned scans that are stuck in 'running' status.
|
||||||
|
|||||||
@@ -76,6 +76,13 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<form class="d-flex me-3" action="{{ url_for('main.search_ip') }}" method="GET">
|
||||||
|
<input class="form-control form-control-sm me-2" type="search" name="ip"
|
||||||
|
placeholder="Search IP..." aria-label="Search IP" style="width: 150px;">
|
||||||
|
<button class="btn btn-outline-primary btn-sm" type="submit">
|
||||||
|
<i class="bi bi-search"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
<ul class="navbar-nav">
|
<ul class="navbar-nav">
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if request.endpoint == 'main.help' %}active{% endif %}"
|
<a class="nav-link {% if request.endpoint == 'main.help' %}active{% endif %}"
|
||||||
|
|||||||
175
app/web/templates/ip_search_results.html
Normal file
175
app/web/templates/ip_search_results.html
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Search Results for {{ ip_address }} - SneakyScanner{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row mt-4">
|
||||||
|
<div class="col-12 d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1>
|
||||||
|
<i class="bi bi-search"></i>
|
||||||
|
Search Results
|
||||||
|
{% if ip_address %}
|
||||||
|
<small class="text-muted">for {{ ip_address }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</h1>
|
||||||
|
<a href="{{ url_for('main.scans') }}" class="btn btn-secondary">
|
||||||
|
<i class="bi bi-arrow-left"></i> Back to Scans
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not ip_address %}
|
||||||
|
<!-- No IP provided -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body text-center py-5">
|
||||||
|
<i class="bi bi-exclamation-circle text-warning" style="font-size: 3rem;"></i>
|
||||||
|
<h4 class="mt-3">No IP Address Provided</h4>
|
||||||
|
<p class="text-muted">Please enter an IP address in the search box to find related scans.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<!-- Results Table -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Last 10 Scans Containing {{ ip_address }}</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="results-loading" class="text-center py-5">
|
||||||
|
<div class="spinner-border" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 text-muted">Searching for scans...</p>
|
||||||
|
</div>
|
||||||
|
<div id="results-error" class="alert alert-danger" style="display: none;"></div>
|
||||||
|
<div id="results-empty" class="text-center py-5 text-muted" style="display: none;">
|
||||||
|
<i class="bi bi-search" style="font-size: 3rem;"></i>
|
||||||
|
<h5 class="mt-3">No Scans Found</h5>
|
||||||
|
<p>No completed scans contain the IP address <strong>{{ ip_address }}</strong>.</p>
|
||||||
|
</div>
|
||||||
|
<div id="results-table-container" style="display: none;">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 80px;">ID</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th style="width: 200px;">Timestamp</th>
|
||||||
|
<th style="width: 100px;">Duration</th>
|
||||||
|
<th style="width: 120px;">Status</th>
|
||||||
|
<th style="width: 100px;">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="results-tbody">
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="text-muted mt-3">
|
||||||
|
Found <span id="result-count">0</span> scan(s) containing this IP address.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
const ipAddress = "{{ ip_address | e }}";
|
||||||
|
|
||||||
|
// Load results when page loads
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
if (ipAddress) {
|
||||||
|
loadResults();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load search results from API
|
||||||
|
async function loadResults() {
|
||||||
|
const loadingEl = document.getElementById('results-loading');
|
||||||
|
const errorEl = document.getElementById('results-error');
|
||||||
|
const emptyEl = document.getElementById('results-empty');
|
||||||
|
const tableEl = document.getElementById('results-table-container');
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
loadingEl.style.display = 'block';
|
||||||
|
errorEl.style.display = 'none';
|
||||||
|
emptyEl.style.display = 'none';
|
||||||
|
tableEl.style.display = 'none';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/scans/by-ip/${encodeURIComponent(ipAddress)}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to search for scans');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const scans = data.scans || [];
|
||||||
|
|
||||||
|
loadingEl.style.display = 'none';
|
||||||
|
|
||||||
|
if (scans.length === 0) {
|
||||||
|
emptyEl.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
tableEl.style.display = 'block';
|
||||||
|
renderResultsTable(scans);
|
||||||
|
document.getElementById('result-count').textContent = data.count;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error searching for scans:', error);
|
||||||
|
loadingEl.style.display = 'none';
|
||||||
|
errorEl.textContent = 'Failed to search for scans. Please try again.';
|
||||||
|
errorEl.style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render results table
|
||||||
|
function renderResultsTable(scans) {
|
||||||
|
const tbody = document.getElementById('results-tbody');
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
|
||||||
|
scans.forEach(scan => {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.classList.add('scan-row');
|
||||||
|
|
||||||
|
// Format timestamp
|
||||||
|
const timestamp = new Date(scan.timestamp).toLocaleString();
|
||||||
|
|
||||||
|
// Format duration
|
||||||
|
const duration = scan.duration ? `${scan.duration.toFixed(1)}s` : '-';
|
||||||
|
|
||||||
|
// Status badge
|
||||||
|
let statusBadge = '';
|
||||||
|
if (scan.status === 'completed') {
|
||||||
|
statusBadge = '<span class="badge badge-success">Completed</span>';
|
||||||
|
} else if (scan.status === 'running') {
|
||||||
|
statusBadge = '<span class="badge badge-info">Running</span>';
|
||||||
|
} else if (scan.status === 'failed') {
|
||||||
|
statusBadge = '<span class="badge badge-danger">Failed</span>';
|
||||||
|
} else {
|
||||||
|
statusBadge = `<span class="badge badge-info">${scan.status}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
row.innerHTML = `
|
||||||
|
<td class="mono">${scan.id}</td>
|
||||||
|
<td>${scan.title || 'Untitled Scan'}</td>
|
||||||
|
<td class="text-muted">${timestamp}</td>
|
||||||
|
<td class="mono">${duration}</td>
|
||||||
|
<td>${statusBadge}</td>
|
||||||
|
<td>
|
||||||
|
<a href="/scans/${scan.id}" class="btn btn-sm btn-secondary">View</a>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
|
||||||
|
tbody.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -989,6 +989,56 @@ curl -X DELETE http://localhost:5000/api/scans/42 \
|
|||||||
-b cookies.txt
|
-b cookies.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Get Scans by IP
|
||||||
|
|
||||||
|
Get the last 10 scans containing a specific IP address.
|
||||||
|
|
||||||
|
**Endpoint:** `GET /api/scans/by-ip/{ip_address}`
|
||||||
|
|
||||||
|
**Authentication:** Required
|
||||||
|
|
||||||
|
**Path Parameters:**
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
|-----------|------|----------|-------------|
|
||||||
|
| `ip_address` | string | Yes | IP address to search for |
|
||||||
|
|
||||||
|
**Success Response (200 OK):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ip_address": "192.168.1.10",
|
||||||
|
"scans": [
|
||||||
|
{
|
||||||
|
"id": 42,
|
||||||
|
"timestamp": "2025-11-14T10:30:00Z",
|
||||||
|
"duration": 125.5,
|
||||||
|
"status": "completed",
|
||||||
|
"title": "Production Network Scan",
|
||||||
|
"config_id": 1,
|
||||||
|
"triggered_by": "manual",
|
||||||
|
"created_at": "2025-11-14T10:30:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 38,
|
||||||
|
"timestamp": "2025-11-13T10:30:00Z",
|
||||||
|
"duration": 98.2,
|
||||||
|
"status": "completed",
|
||||||
|
"title": "Production Network Scan",
|
||||||
|
"config_id": 1,
|
||||||
|
"triggered_by": "scheduled",
|
||||||
|
"created_at": "2025-11-13T10:30:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"count": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage Example:**
|
||||||
|
```bash
|
||||||
|
curl -X GET http://localhost:5000/api/scans/by-ip/192.168.1.10 \
|
||||||
|
-b cookies.txt
|
||||||
|
```
|
||||||
|
|
||||||
### Compare Scans
|
### Compare Scans
|
||||||
|
|
||||||
Compare two scans to identify differences in ports, services, and certificates.
|
Compare two scans to identify differences in ports, services, and certificates.
|
||||||
|
|||||||
Reference in New Issue
Block a user