Fix workspace View and Export with beautiful report viewer

This commit is contained in:
2026-01-01 18:10:51 +11:00
parent 04465f8d29
commit 57436da1bf
3 changed files with 628 additions and 37 deletions

View File

@@ -116,22 +116,25 @@ async function loadWorkspaces() {
if (result.workspaces && result.workspaces.length > 0) {
container.innerHTML = result.workspaces
.map(
(ws) => `
(ws) => {
const name = ws.name || ws;
const size = ws.size || '';
const modified = ws.modified || '';
return `
<div class="workspace-item">
<span class="workspace-name">${escapeHtml(ws)}</span>
<div style="flex: 1;">
<span class="workspace-name">${escapeHtml(name)}</span>
${size ? `<span style="color: var(--text-secondary); font-size: 0.8rem; margin-left: 1rem;">${size}</span>` : ''}
${modified ? `<span style="color: var(--text-secondary); font-size: 0.8rem; margin-left: 0.5rem;">| ${modified}</span>` : ''}
</div>
<div class="workspace-actions">
<button class="btn btn-secondary" onclick="viewWorkspace('${escapeHtml(
ws
)}')">View</button>
<button class="btn btn-secondary" onclick="exportWorkspace('${escapeHtml(
ws
)}')">Export</button>
<button class="btn btn-danger" onclick="deleteWorkspace('${escapeHtml(
ws
)}')">Delete</button>
<button class="btn btn-secondary" onclick="viewWorkspace('${escapeHtml(name)}')">View</button>
<button class="btn btn-secondary" onclick="exportWorkspace('${escapeHtml(name)}')">Export</button>
<button class="btn btn-danger" onclick="deleteWorkspace('${escapeHtml(name)}')">Delete</button>
</div>
</div>
`
`;
}
)
.join("");
} else {
@@ -152,8 +155,10 @@ async function viewWorkspace(name) {
);
const result = await response.json();
if (result.reportPath) {
window.open(result.reportPath, "_blank");
if (result.reportUrl) {
window.open(result.reportUrl, "_blank");
} else if (result.error) {
showNotification(result.error, "error");
} else {
showNotification("No report found for this workspace.", "warning");
}
@@ -163,7 +168,7 @@ async function viewWorkspace(name) {
}
async function exportWorkspace(name) {
showNotification("Exporting workspace: " + name, "info");
showNotification("Creating export for: " + name + "...", "info");
try {
const response = await fetch("workspaces.php", {
method: "POST",
@@ -172,10 +177,12 @@ async function exportWorkspace(name) {
});
const result = await response.json();
if (result.success) {
showNotification("Workspace exported: " + result.path, "success");
if (result.success && result.downloadUrl) {
showNotification(`Export ready: ${result.filename} (${result.size})`, "success");
// Trigger download
window.open(result.downloadUrl, "_blank");
} else {
showNotification("Export failed: " + result.error, "error");
showNotification("Export failed: " + (result.error || "Unknown error"), "error");
}
} catch (error) {
showNotification("Export failed.", "error");

489
webui/report.php Normal file
View File

@@ -0,0 +1,489 @@
<?php
/**
* UltyScan Web Interface - Workspace Report Viewer
* Generates a beautiful HTML report for a workspace
*/
$name = preg_replace('/[^a-zA-Z0-9\-\_\.]/', '', $_GET['name'] ?? '');
if (empty($name)) {
die('Invalid workspace name');
}
$workspaceDir = '/usr/share/sniper/loot/workspace/' . $name;
if (!is_dir($workspaceDir)) {
die('Workspace not found');
}
// Check for existing sniper report
$sniperReport = $workspaceDir . '/sniper-report.html';
if (file_exists($sniperReport)) {
// Redirect to the existing report
header('Location: /loot/workspace/' . urlencode($name) . '/sniper-report.html');
exit;
}
// Collect workspace data
$data = [
'name' => $name,
'created' => date('Y-m-d H:i:s', filectime($workspaceDir)),
'modified' => date('Y-m-d H:i:s', filemtime($workspaceDir)),
'hosts' => [],
'ports' => [],
'vulnerabilities' => [],
'screenshots' => [],
'files' => []
];
// Get all files in workspace
function scanWorkspace($dir, $prefix = '')
{
$files = [];
if (is_dir($dir)) {
$items = scandir($dir);
foreach ($items as $item) {
if ($item === '.' || $item === '..') continue;
$path = $dir . '/' . $item;
if (is_dir($path)) {
$files = array_merge($files, scanWorkspace($path, $prefix . $item . '/'));
} else {
$files[] = [
'name' => $item,
'path' => $prefix . $item,
'size' => filesize($path),
'modified' => filemtime($path)
];
}
}
}
return $files;
}
$data['files'] = scanWorkspace($workspaceDir);
// Parse hosts from nmap directory
$nmapDir = $workspaceDir . '/nmap';
if (is_dir($nmapDir)) {
$nmapFiles = glob($nmapDir . '/*.nmap');
foreach ($nmapFiles as $file) {
$content = file_get_contents($file);
// Extract host info
if (preg_match('/Nmap scan report for (.+)/', $content, $matches)) {
$host = trim($matches[1]);
if (!in_array($host, $data['hosts'])) {
$data['hosts'][] = $host;
}
}
// Extract ports
if (preg_match_all('/(\d+)\/(tcp|udp)\s+open\s+(\S+)/', $content, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$data['ports'][] = [
'port' => $match[1],
'protocol' => $match[2],
'service' => $match[3]
];
}
}
}
}
// Get screenshots
$screenshotDir = $workspaceDir . '/screenshots';
if (is_dir($screenshotDir)) {
$screenshots = glob($screenshotDir . '/*.{png,jpg,jpeg,gif}', GLOB_BRACE);
foreach ($screenshots as $ss) {
$data['screenshots'][] = basename($ss);
}
}
// Parse vulnerabilities from output files
$outputDir = $workspaceDir . '/output';
if (is_dir($outputDir)) {
$vulnFiles = glob($outputDir . '/*nuclei*.txt');
foreach ($vulnFiles as $file) {
$lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
if (strpos($line, '[') !== false && strpos($line, ']') !== false) {
$data['vulnerabilities'][] = trim($line);
}
}
}
}
// Unique ports
$data['ports'] = array_unique($data['ports'], SORT_REGULAR);
$data['vulnerabilities'] = array_unique($data['vulnerabilities']);
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>UltyScan Report - <?php echo htmlspecialchars($name); ?></title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-primary: #0a0e17;
--bg-secondary: #111827;
--bg-card: rgba(17, 24, 39, 0.9);
--accent-primary: #3b82f6;
--accent-secondary: #8b5cf6;
--accent-success: #10b981;
--accent-warning: #f59e0b;
--accent-danger: #ef4444;
--text-primary: #f3f4f6;
--text-secondary: #9ca3af;
--border-color: rgba(75, 85, 99, 0.4);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
padding: 2rem;
background-image:
radial-gradient(ellipse at 20% 50%, rgba(59, 130, 246, 0.1) 0%, transparent 50%),
radial-gradient(ellipse at 80% 50%, rgba(139, 92, 246, 0.08) 0%, transparent 50%);
}
.container {
max-width: 1400px;
margin: 0 auto;
}
.header {
text-align: center;
margin-bottom: 3rem;
padding: 2rem;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 16px;
}
.header h1 {
font-size: 2.5rem;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 0.5rem;
}
.header .meta {
color: var(--text-secondary);
font-size: 0.9rem;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 1.5rem;
text-align: center;
}
.stat-card .number {
font-size: 2.5rem;
font-weight: 700;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-success));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.stat-card .label {
color: var(--text-secondary);
margin-top: 0.5rem;
}
.section {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 16px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.section h2 {
font-size: 1.25rem;
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
gap: 0.5rem;
}
.section h2::before {
content: '';
width: 4px;
height: 20px;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
border-radius: 2px;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
th {
background: var(--bg-secondary);
font-weight: 600;
color: var(--text-secondary);
font-size: 0.85rem;
text-transform: uppercase;
}
tr:hover td {
background: rgba(59, 130, 246, 0.05);
}
.tag {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 500;
}
.tag-info {
background: rgba(59, 130, 246, 0.2);
color: #60a5fa;
}
.tag-warning {
background: rgba(245, 158, 11, 0.2);
color: #fbbf24;
}
.tag-danger {
background: rgba(239, 68, 68, 0.2);
color: #f87171;
}
.tag-success {
background: rgba(16, 185, 129, 0.2);
color: #34d399;
}
.vuln-item {
padding: 0.75rem;
margin-bottom: 0.5rem;
background: var(--bg-secondary);
border-radius: 8px;
font-family: monospace;
font-size: 0.85rem;
word-break: break-all;
}
.file-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 0.75rem;
}
.file-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: var(--bg-secondary);
border-radius: 8px;
}
.file-icon {
font-size: 1.5rem;
}
.file-name {
flex: 1;
word-break: break-all;
}
.file-size {
color: var(--text-secondary);
font-size: 0.8rem;
}
.screenshot-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1rem;
}
.screenshot-item {
background: var(--bg-secondary);
border-radius: 8px;
overflow: hidden;
}
.screenshot-item img {
width: 100%;
height: 150px;
object-fit: cover;
}
.screenshot-item .caption {
padding: 0.5rem;
font-size: 0.8rem;
color: var(--text-secondary);
text-align: center;
}
.empty-state {
text-align: center;
padding: 2rem;
color: var(--text-secondary);
}
@media print {
body {
background: white;
color: black;
}
.section {
border: 1px solid #ddd;
}
}
</style>
</head>
<body>
<div class="container">
<header class="header">
<h1>🔍 <?php echo htmlspecialchars($name); ?></h1>
<p class="meta">
Created: <?php echo $data['created']; ?> |
Modified: <?php echo $data['modified']; ?>
</p>
</header>
<div class="stats">
<div class="stat-card">
<div class="number"><?php echo count($data['hosts']); ?></div>
<div class="label">Hosts Discovered</div>
</div>
<div class="stat-card">
<div class="number"><?php echo count($data['ports']); ?></div>
<div class="label">Open Ports</div>
</div>
<div class="stat-card">
<div class="number"><?php echo count($data['vulnerabilities']); ?></div>
<div class="label">Findings</div>
</div>
<div class="stat-card">
<div class="number"><?php echo count($data['files']); ?></div>
<div class="label">Files Generated</div>
</div>
</div>
<?php if (!empty($data['hosts'])): ?>
<div class="section">
<h2>Discovered Hosts</h2>
<table>
<thead>
<tr>
<th>Host</th>
</tr>
</thead>
<tbody>
<?php foreach ($data['hosts'] as $host): ?>
<tr>
<td><?php echo htmlspecialchars($host); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
<?php if (!empty($data['ports'])): ?>
<div class="section">
<h2>Open Ports</h2>
<table>
<thead>
<tr>
<th>Port</th>
<th>Protocol</th>
<th>Service</th>
</tr>
</thead>
<tbody>
<?php foreach ($data['ports'] as $port): ?>
<tr>
<td><span class="tag tag-info"><?php echo htmlspecialchars($port['port']); ?></span></td>
<td><?php echo htmlspecialchars($port['protocol']); ?></td>
<td><?php echo htmlspecialchars($port['service']); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
<?php if (!empty($data['vulnerabilities'])): ?>
<div class="section">
<h2>Vulnerability Findings</h2>
<?php foreach ($data['vulnerabilities'] as $vuln): ?>
<div class="vuln-item"><?php echo htmlspecialchars($vuln); ?></div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if (!empty($data['screenshots'])): ?>
<div class="section">
<h2>Screenshots</h2>
<div class="screenshot-grid">
<?php foreach ($data['screenshots'] as $ss): ?>
<div class="screenshot-item">
<img src="/loot/workspace/<?php echo urlencode($name); ?>/screenshots/<?php echo urlencode($ss); ?>" alt="<?php echo htmlspecialchars($ss); ?>">
<div class="caption"><?php echo htmlspecialchars($ss); ?></div>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<div class="section">
<h2>Generated Files</h2>
<?php if (empty($data['files'])): ?>
<div class="empty-state">No files found in this workspace.</div>
<?php else: ?>
<div class="file-list">
<?php foreach ($data['files'] as $file): ?>
<div class="file-item">
<span class="file-icon">📄</span>
<span class="file-name"><?php echo htmlspecialchars($file['path']); ?></span>
<span class="file-size"><?php echo number_format($file['size'] / 1024, 1); ?> KB</span>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</div>
</body>
</html>

View File

@@ -4,22 +4,32 @@
* UltyScan Web Interface - Workspace Management
*/
header('Content-Type: application/json');
define('WORKSPACE_DIR', '/usr/share/sniper/loot/workspace');
define('SNIPER_PATH', '/usr/share/sniper/sniper');
define('EXPORT_DIR', '/var/www/html/ultyscan/exports');
// Handle GET requests (list, view)
// Create export directory if it doesn't exist
if (!is_dir(EXPORT_DIR)) {
mkdir(EXPORT_DIR, 0755, true);
}
// Handle GET requests (list, view, download)
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$action = $_GET['action'] ?? 'list';
if ($action === 'list') {
header('Content-Type: application/json');
$workspaces = [];
if (is_dir(WORKSPACE_DIR)) {
$dirs = scandir(WORKSPACE_DIR);
foreach ($dirs as $dir) {
if ($dir !== '.' && $dir !== '..' && is_dir(WORKSPACE_DIR . '/' . $dir)) {
$workspaces[] = $dir;
$wsPath = WORKSPACE_DIR . '/' . $dir;
$workspaces[] = [
'name' => $dir,
'created' => date('Y-m-d H:i', filectime($wsPath)),
'modified' => date('Y-m-d H:i', filemtime($wsPath)),
'size' => getDirectorySize($wsPath)
];
}
}
}
@@ -28,25 +38,52 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
}
if ($action === 'view') {
header('Content-Type: application/json');
$name = preg_replace('/[^a-zA-Z0-9\-\_\.]/', '', $_GET['name'] ?? '');
if (empty($name)) {
echo json_encode(['error' => 'Invalid workspace name']);
exit;
}
$reportPath = WORKSPACE_DIR . '/' . $name . '/sniper-report.html';
$wsPath = WORKSPACE_DIR . '/' . $name;
if (!is_dir($wsPath)) {
echo json_encode(['error' => 'Workspace not found']);
exit;
}
// Check for sniper-report.html first
$reportPath = $wsPath . '/sniper-report.html';
if (file_exists($reportPath)) {
// Return relative web path (assuming workspace is web-accessible)
echo json_encode(['reportPath' => '/loot/workspace/' . $name . '/sniper-report.html']);
echo json_encode(['reportUrl' => '/loot/workspace/' . urlencode($name) . '/sniper-report.html']);
} else {
echo json_encode(['reportPath' => null, 'message' => 'No report found']);
// Use our custom report viewer
echo json_encode(['reportUrl' => 'report.php?name=' . urlencode($name)]);
}
exit;
}
if ($action === 'download') {
$name = preg_replace('/[^a-zA-Z0-9\-\_\.]/', '', $_GET['name'] ?? '');
if (empty($name)) {
die('Invalid workspace name');
}
$exportFile = EXPORT_DIR . '/' . $name . '.tar.gz';
if (file_exists($exportFile)) {
header('Content-Type: application/gzip');
header('Content-Disposition: attachment; filename="' . $name . '.tar.gz"');
header('Content-Length: ' . filesize($exportFile));
readfile($exportFile);
exit;
} else {
die('Export file not found. Please export the workspace first.');
}
}
}
// Handle POST requests (delete, export)
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
header('Content-Type: application/json');
$data = json_decode(file_get_contents('php://input'), true);
$action = $data['action'] ?? '';
$name = preg_replace('/[^a-zA-Z0-9\-\_\.]/', '', $data['name'] ?? '');
@@ -56,24 +93,82 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
exit;
}
$wsPath = WORKSPACE_DIR . '/' . $name;
if ($action === 'delete') {
$cmd = SNIPER_PATH . ' -w ' . escapeshellarg($name) . ' -d 2>&1';
// Auto-confirm the deletion
$output = shell_exec("echo 'y' | $cmd");
echo json_encode(['success' => true, 'output' => $output]);
if (is_dir($wsPath)) {
// Delete directory recursively
deleteDirectory($wsPath);
echo json_encode(['success' => true, 'message' => 'Workspace deleted']);
} else {
echo json_encode(['success' => false, 'error' => 'Workspace not found']);
}
exit;
}
if ($action === 'export') {
$cmd = SNIPER_PATH . ' -w ' . escapeshellarg($name) . ' --export 2>&1';
if (!is_dir($wsPath)) {
echo json_encode(['success' => false, 'error' => 'Workspace not found']);
exit;
}
$exportFile = EXPORT_DIR . '/' . $name . '.tar.gz';
// Create tar.gz archive
$cmd = "cd " . escapeshellarg(WORKSPACE_DIR) . " && tar -czf " . escapeshellarg($exportFile) . " " . escapeshellarg($name) . " 2>&1";
$output = shell_exec($cmd);
echo json_encode([
'success' => true,
'path' => '/usr/share/sniper/loot/' . $name . '.tar',
'output' => $output
]);
if (file_exists($exportFile)) {
$size = filesize($exportFile);
echo json_encode([
'success' => true,
'downloadUrl' => 'workspaces.php?action=download&name=' . urlencode($name),
'filename' => $name . '.tar.gz',
'size' => formatBytes($size),
'message' => 'Export created successfully'
]);
} else {
echo json_encode([
'success' => false,
'error' => 'Failed to create export: ' . $output
]);
}
exit;
}
}
echo json_encode(['error' => 'Invalid request']);
// Helper functions
function getDirectorySize($dir)
{
$size = 0;
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS)) as $file) {
$size += $file->getSize();
}
return formatBytes($size);
}
function formatBytes($bytes)
{
if ($bytes >= 1073741824) {
return number_format($bytes / 1073741824, 2) . ' GB';
} elseif ($bytes >= 1048576) {
return number_format($bytes / 1048576, 2) . ' MB';
} elseif ($bytes >= 1024) {
return number_format($bytes / 1024, 2) . ' KB';
} else {
return $bytes . ' B';
}
}
function deleteDirectory($dir)
{
if (!is_dir($dir)) return false;
$files = array_diff(scandir($dir), ['.', '..']);
foreach ($files as $file) {
$path = $dir . '/' . $file;
is_dir($path) ? deleteDirectory($path) : unlink($path);
}
return rmdir($dir);
}