nightly merge into beta #7
@@ -281,6 +281,45 @@ def get_scan_status(scan_id):
|
||||
}), 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'])
|
||||
@api_auth_required
|
||||
def compare_scans(scan_id1, scan_id2):
|
||||
|
||||
@@ -7,7 +7,7 @@ Provides dashboard and scan viewing pages.
|
||||
import logging
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@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')
|
||||
@login_required
|
||||
def schedules():
|
||||
|
||||
@@ -260,6 +260,29 @@ class ScanService:
|
||||
|
||||
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:
|
||||
"""
|
||||
Clean up orphaned scans that are stuck in 'running' status.
|
||||
|
||||
@@ -76,6 +76,13 @@
|
||||
</ul>
|
||||
</li>
|
||||
</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">
|
||||
<li class="nav-item">
|
||||
<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
|
||||
```
|
||||
|
||||
### 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 two scans to identify differences in ports, services, and certificates.
|
||||
|
||||
Reference in New Issue
Block a user