Files
ppf/static/mitm.js

704 lines
23 KiB
JavaScript

/**
* MITM Certificate Search Interface
* Provides search functionality for SSL MITM certificate data
*/
(function() {
'use strict';
// Theme toggle (shared with dashboard)
var themes = ['dark', 'muted-dark', 'light'];
function getTheme() {
if (document.documentElement.classList.contains('light')) return 'light';
if (document.documentElement.classList.contains('muted-dark')) return 'muted-dark';
return 'dark';
}
function setTheme(theme) {
document.documentElement.classList.remove('light', 'muted-dark');
if (theme === 'light') document.documentElement.classList.add('light');
else if (theme === 'muted-dark') document.documentElement.classList.add('muted-dark');
try { localStorage.setItem('ppf-theme', theme); } catch(e) {}
}
function initTheme() {
var saved = null;
try { saved = localStorage.getItem('ppf-theme'); } catch(e) {}
if (saved && themes.indexOf(saved) !== -1) {
setTheme(saved);
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) {
setTheme('light');
}
var btn = document.getElementById('themeToggle');
if (btn) {
btn.addEventListener('click', function() {
var current = getTheme();
var idx = themes.indexOf(current);
var next = themes[(idx + 1) % themes.length];
setTheme(next);
});
}
}
document.addEventListener('DOMContentLoaded', initTheme);
// DOM elements
var searchInput = document.getElementById('searchInput');
var searchClear = document.getElementById('searchClear');
var suggestions = document.getElementById('suggestions');
var resultsContainer = document.getElementById('resultsContainer');
var resultsEmpty = document.getElementById('resultsEmpty');
var resultsLoading = document.getElementById('resultsLoading');
var resultsList = document.getElementById('resultsList');
var resultsContent = document.getElementById('resultsContent');
var resultsCount = document.getElementById('resultsCount');
var noDataState = document.getElementById('noDataState');
// Stats elements
var totalDetections = document.getElementById('totalDetections');
var uniqueCerts = document.getElementById('uniqueCerts');
var uniqueOrgs = document.getElementById('uniqueOrgs');
var uniqueProxies = document.getElementById('uniqueProxies');
// State
var searchTimeout = null;
var currentData = null;
var suggestionIndex = -1;
// Search field definitions for autocomplete
var searchFields = [
{ prefix: 'org:', desc: 'organization name', icon: '🏢' },
{ prefix: 'issuer:', desc: 'certificate issuer', icon: '📄' },
{ prefix: 'cn:', desc: 'common name', icon: '🔗' },
{ prefix: 'proxy:', desc: 'proxy IP address', icon: '🖥' },
{ prefix: 'fp:', desc: 'fingerprint', icon: '🔑' },
{ prefix: 'serial:', desc: 'serial number', icon: '🔢' },
{ prefix: 'expires:', desc: 'expiration year', icon: '📅' },
{ prefix: 'expired:', desc: 'yes/no', icon: '⚠' }
];
/**
* Escape HTML to prevent XSS
*/
function escapeHtml(str) {
if (str === null || str === undefined) return '';
var div = document.createElement('div');
div.textContent = String(str);
return div.innerHTML;
}
/**
* Sanitize search query - remove potentially dangerous characters
*/
function sanitizeQuery(query) {
if (!query) return '';
// Allow alphanumeric, spaces, common punctuation for searches
// Remove control characters and dangerous patterns
return String(query)
.replace(/[\x00-\x1f\x7f]/g, '') // Control characters
.replace(/<[^>]*>/g, '') // HTML tags
.trim()
.substring(0, 200); // Limit length
}
/**
* Format number with comma separators
*/
function formatNumber(n) {
if (n === null || n === undefined || n === '-') return '-';
return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
/**
* Format timestamp to readable date
*/
function formatDate(ts) {
if (!ts) return '-';
// Handle ASN.1 time format (YYYYMMDDhhmmssZ)
if (typeof ts === 'string' && ts.length >= 14) {
var y = ts.substring(0, 4);
var m = ts.substring(4, 6);
var d = ts.substring(6, 8);
return y + '-' + m + '-' + d;
}
// Handle Unix timestamp
if (typeof ts === 'number') {
var date = new Date(ts * 1000);
return date.toISOString().split('T')[0];
}
return String(ts);
}
/**
* Check if certificate is expired
*/
function isExpired(notAfter) {
if (!notAfter || notAfter.length < 14) return false;
var expDate = new Date(
parseInt(notAfter.substring(0, 4)),
parseInt(notAfter.substring(4, 6)) - 1,
parseInt(notAfter.substring(6, 8))
);
return expDate < new Date();
}
/**
* Load initial stats
*/
function loadStats() {
fetch('/api/mitm')
.then(function(r) { return r.json(); })
.then(function(data) {
currentData = data;
// Update summary stats
totalDetections.textContent = formatNumber(data.total_detections || 0);
uniqueCerts.textContent = formatNumber(data.unique_certs || 0);
uniqueProxies.textContent = formatNumber(data.unique_proxies || 0);
// Count unique orgs from top_organizations
var orgCount = (data.top_organizations || []).length;
uniqueOrgs.textContent = formatNumber(orgCount);
// Show no data state if empty
if (!data.certificates || data.certificates.length === 0) {
document.getElementById('statsSummary').style.display = 'none';
document.querySelector('.search-box').style.display = 'none';
document.getElementById('helpBox').style.display = 'none';
resultsContainer.style.display = 'none';
noDataState.style.display = 'block';
}
})
.catch(function(err) {
console.error('Failed to load MITM stats:', err);
});
}
/**
* Parse search query into structured filters
*/
function parseQuery(query) {
var filters = {
org: null,
issuer: null,
cn: null,
proxy: null,
fp: null,
serial: null,
expires: null,
expired: null,
text: []
};
if (!query) return filters;
// Match field:value patterns and quoted strings
var parts = query.match(/(\w+:"[^"]+"|"[^"]+"|[\w:.]+)/g) || [];
for (var i = 0; i < parts.length; i++) {
var part = parts[i];
var colonIdx = part.indexOf(':');
if (colonIdx > 0 && colonIdx < part.length - 1) {
var field = part.substring(0, colonIdx).toLowerCase();
var value = part.substring(colonIdx + 1).replace(/^"|"$/g, '');
if (filters.hasOwnProperty(field)) {
filters[field] = value;
} else {
filters.text.push(part);
}
} else {
// Plain text search
filters.text.push(part.replace(/^"|"$/g, ''));
}
}
return filters;
}
/**
* Check if certificate matches filters
*/
function matchesCert(cert, filters) {
// Organization filter
if (filters.org) {
var org = (cert.subject_o || '').toLowerCase();
if (org.indexOf(filters.org.toLowerCase()) === -1) return false;
}
// Issuer filter
if (filters.issuer) {
var issuer = (cert.issuer_cn || cert.issuer_o || '').toLowerCase();
if (issuer.indexOf(filters.issuer.toLowerCase()) === -1) return false;
}
// Common name filter
if (filters.cn) {
var cn = (cert.subject_cn || '').toLowerCase();
if (cn.indexOf(filters.cn.toLowerCase()) === -1) return false;
}
// Proxy filter
if (filters.proxy) {
var proxies = cert.proxies || [];
var found = false;
for (var i = 0; i < proxies.length; i++) {
if (proxies[i].indexOf(filters.proxy) !== -1) {
found = true;
break;
}
}
if (!found) return false;
}
// Fingerprint filter
if (filters.fp) {
var fp = (cert.fingerprint || cert.fingerprint_full || '').toLowerCase();
if (fp.indexOf(filters.fp.toLowerCase()) === -1) return false;
}
// Serial filter
if (filters.serial) {
var serial = (cert.serial || '').toLowerCase();
if (serial.indexOf(filters.serial.toLowerCase()) === -1) return false;
}
// Expiration year filter
if (filters.expires) {
var notAfter = cert.not_after || '';
if (notAfter.indexOf(filters.expires) === -1) return false;
}
// Expired filter
if (filters.expired) {
var wantExpired = filters.expired.toLowerCase() === 'yes';
var certExpired = isExpired(cert.not_after);
if (wantExpired !== certExpired) return false;
}
// Text search (searches all fields)
if (filters.text.length > 0) {
var searchable = [
cert.subject_cn || '',
cert.subject_o || '',
cert.issuer_cn || '',
cert.issuer_o || '',
cert.fingerprint || '',
cert.serial || ''
].join(' ').toLowerCase();
for (var j = 0; j < filters.text.length; j++) {
if (searchable.indexOf(filters.text[j].toLowerCase()) === -1) {
return false;
}
}
}
return true;
}
/**
* Perform search on current data
*/
function performSearch(query) {
query = sanitizeQuery(query);
if (!query) {
showEmpty();
return;
}
if (!currentData || !currentData.certificates) {
showEmpty();
return;
}
showLoading();
// Use setTimeout to not block UI
setTimeout(function() {
var filters = parseQuery(query);
var results = [];
var certs = currentData.certificates || [];
for (var i = 0; i < certs.length; i++) {
if (matchesCert(certs[i], filters)) {
results.push(certs[i]);
}
}
showResults(results, query);
}, 50);
}
/**
* Show empty state
*/
function showEmpty() {
resultsEmpty.style.display = 'block';
resultsLoading.style.display = 'none';
resultsList.style.display = 'none';
}
/**
* Show loading state
*/
function showLoading() {
resultsEmpty.style.display = 'none';
resultsLoading.style.display = 'block';
resultsList.style.display = 'none';
}
/**
* Render search results
*/
function showResults(results, query) {
resultsEmpty.style.display = 'none';
resultsLoading.style.display = 'none';
resultsList.style.display = 'block';
resultsCount.textContent = formatNumber(results.length);
if (results.length === 0) {
resultsContent.innerHTML =
'<div class="results-empty">' +
'<div class="results-empty-icon">&#128269;</div>' +
'<div class="results-empty-text">No certificates match your search</div>' +
'<div class="results-empty-hint">Try adjusting your filters or search terms</div>' +
'</div>';
return;
}
var html = '';
for (var i = 0; i < results.length; i++) {
html += renderCertCard(results[i]);
}
resultsContent.innerHTML = html;
}
/**
* Render a single certificate card
*/
function renderCertCard(cert) {
var expired = isExpired(cert.not_after);
var proxies = cert.proxies || [];
var html = '<div class="result-card">';
html += '<div class="result-header">';
html += '<div class="result-title">';
html += '<span class="result-badge cert">Certificate</span>';
html += escapeHtml(cert.subject_cn || cert.subject_o || 'Unknown');
html += '</div>';
html += '<div class="result-meta">';
if (expired) {
html += '<span class="red">Expired</span> &middot; ';
}
html += 'Seen ' + escapeHtml(String(cert.count || 1)) + ' time(s)';
html += '</div>';
html += '</div>';
html += '<div class="result-body">';
// Subject
html += '<div class="result-field">';
html += '<div class="result-field-label">Subject (CN)</div>';
html += '<div class="result-field-value">' + escapeHtml(cert.subject_cn || '-') + '</div>';
html += '</div>';
// Organization
html += '<div class="result-field">';
html += '<div class="result-field-label">Organization</div>';
html += '<div class="result-field-value">' + escapeHtml(cert.subject_o || '-') + '</div>';
html += '</div>';
// Issuer
html += '<div class="result-field">';
html += '<div class="result-field-label">Issuer (CN)</div>';
html += '<div class="result-field-value">' + escapeHtml(cert.issuer_cn || '-') + '</div>';
html += '</div>';
// Issuer Org
html += '<div class="result-field">';
html += '<div class="result-field-label">Issuer Org</div>';
html += '<div class="result-field-value">' + escapeHtml(cert.issuer_o || '-') + '</div>';
html += '</div>';
// Fingerprint
html += '<div class="result-field">';
html += '<div class="result-field-label">Fingerprint</div>';
html += '<div class="result-field-value highlight">' + escapeHtml(cert.fingerprint || cert.fingerprint_full || '-') + '</div>';
html += '</div>';
// Serial
html += '<div class="result-field">';
html += '<div class="result-field-label">Serial</div>';
html += '<div class="result-field-value">' + escapeHtml(cert.serial || '-') + '</div>';
html += '</div>';
// Valid From
html += '<div class="result-field">';
html += '<div class="result-field-label">Valid From</div>';
html += '<div class="result-field-value">' + escapeHtml(formatDate(cert.not_before)) + '</div>';
html += '</div>';
// Valid Until
html += '<div class="result-field">';
html += '<div class="result-field-label">Valid Until</div>';
html += '<div class="result-field-value' + (expired ? ' red' : '') + '">' + escapeHtml(formatDate(cert.not_after)) + '</div>';
html += '</div>';
html += '</div>';
// Proxies list
if (proxies.length > 0) {
html += '<div class="result-proxies">';
html += '<div class="result-proxies-title">Proxies using this certificate (' + proxies.length + ')</div>';
html += '<div class="proxy-tags">';
for (var i = 0; i < Math.min(proxies.length, 10); i++) {
html += '<span class="proxy-tag">' + escapeHtml(proxies[i]) + '</span>';
}
if (proxies.length > 10) {
html += '<span class="proxy-tag">+' + (proxies.length - 10) + ' more</span>';
}
html += '</div>';
html += '</div>';
}
html += '</div>';
return html;
}
/**
* Show autocomplete suggestions
*/
function showSuggestions(query) {
query = sanitizeQuery(query);
if (!query) {
hideSuggestions();
return;
}
var html = '';
// Check if user is typing a field prefix
var lastWord = query.split(/\s+/).pop() || '';
var colonIdx = lastWord.indexOf(':');
if (colonIdx === -1) {
// Show matching field suggestions
var matchingFields = [];
for (var i = 0; i < searchFields.length; i++) {
if (searchFields[i].prefix.toLowerCase().indexOf(lastWord.toLowerCase()) === 0) {
matchingFields.push(searchFields[i]);
}
}
if (matchingFields.length > 0) {
html += '<div class="suggestion-group">';
html += '<div class="suggestion-header">Field Filters</div>';
for (var j = 0; j < matchingFields.length; j++) {
var field = matchingFields[j];
html += '<div class="suggestion-item" data-value="' + escapeHtml(field.prefix) + '">';
html += '<span class="suggestion-icon">' + field.icon + '</span>';
html += '<span class="suggestion-text"><code>' + escapeHtml(field.prefix) + '</code></span>';
html += '<span class="suggestion-desc">' + escapeHtml(field.desc) + '</span>';
html += '</div>';
}
html += '</div>';
}
}
// Add quick value suggestions from data
if (currentData && colonIdx > 0) {
var fieldName = lastWord.substring(0, colonIdx);
var valuePart = lastWord.substring(colonIdx + 1).toLowerCase();
var valueSuggestions = getValueSuggestions(fieldName, valuePart);
if (valueSuggestions.length > 0) {
html += '<div class="suggestion-group">';
html += '<div class="suggestion-header">Matching Values</div>';
for (var k = 0; k < Math.min(valueSuggestions.length, 5); k++) {
var val = valueSuggestions[k];
html += '<div class="suggestion-item" data-value="' + escapeHtml(fieldName + ':' + val) + '">';
html += '<span class="suggestion-icon">&#8594;</span>';
html += '<span class="suggestion-text">' + escapeHtml(val) + '</span>';
html += '</div>';
}
html += '</div>';
}
}
if (html) {
suggestions.innerHTML = html;
suggestions.classList.add('visible');
suggestionIndex = -1;
} else {
hideSuggestions();
}
}
/**
* Get value suggestions for a field
*/
function getValueSuggestions(field, partial) {
if (!currentData) return [];
var values = [];
field = field.toLowerCase();
if (field === 'org') {
var orgs = currentData.top_organizations || [];
for (var i = 0; i < orgs.length; i++) {
var orgName = orgs[i].name || '';
if (partial === '' || orgName.toLowerCase().indexOf(partial) !== -1) {
values.push(orgName);
}
}
} else if (field === 'issuer') {
var issuers = currentData.top_issuers || [];
for (var j = 0; j < issuers.length; j++) {
var issuerName = issuers[j].name || '';
if (partial === '' || issuerName.toLowerCase().indexOf(partial) !== -1) {
values.push(issuerName);
}
}
} else if (field === 'expired') {
values = ['yes', 'no'];
}
return values;
}
/**
* Hide suggestions dropdown
*/
function hideSuggestions() {
suggestions.classList.remove('visible');
suggestionIndex = -1;
}
/**
* Apply suggestion to search input
*/
function applySuggestion(value) {
var query = searchInput.value;
var parts = query.split(/\s+/);
parts[parts.length - 1] = value;
searchInput.value = parts.join(' ');
searchInput.focus();
hideSuggestions();
// Trigger search if value is complete
if (value.indexOf(':') !== -1) {
handleSearch();
}
}
/**
* Handle search input
*/
function handleSearch() {
var query = searchInput.value;
// Show/hide clear button
if (query) {
searchClear.classList.add('visible');
} else {
searchClear.classList.remove('visible');
}
// Debounce search
if (searchTimeout) {
clearTimeout(searchTimeout);
}
searchTimeout = setTimeout(function() {
performSearch(query);
}, 200);
// Show suggestions immediately
showSuggestions(query);
}
/**
* Clear search
*/
function clearSearch() {
searchInput.value = '';
searchClear.classList.remove('visible');
hideSuggestions();
showEmpty();
searchInput.focus();
}
/**
* Toggle help box
*/
window.toggleHelp = function() {
var helpBox = document.getElementById('helpBox');
helpBox.classList.toggle('collapsed');
};
/**
* Keyboard navigation for suggestions
*/
function handleKeydown(e) {
var items = suggestions.querySelectorAll('.suggestion-item');
if (!items.length) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
suggestionIndex = Math.min(suggestionIndex + 1, items.length - 1);
updateSuggestionHighlight(items);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
suggestionIndex = Math.max(suggestionIndex - 1, 0);
updateSuggestionHighlight(items);
} else if (e.key === 'Enter' && suggestionIndex >= 0) {
e.preventDefault();
var value = items[suggestionIndex].getAttribute('data-value');
if (value) applySuggestion(value);
} else if (e.key === 'Escape') {
hideSuggestions();
}
}
/**
* Update suggestion highlight
*/
function updateSuggestionHighlight(items) {
for (var i = 0; i < items.length; i++) {
if (i === suggestionIndex) {
items[i].classList.add('active');
} else {
items[i].classList.remove('active');
}
}
}
// Event listeners
searchInput.addEventListener('input', handleSearch);
searchInput.addEventListener('keydown', handleKeydown);
searchInput.addEventListener('focus', function() {
if (searchInput.value) showSuggestions(searchInput.value);
});
searchClear.addEventListener('click', clearSearch);
// Click on suggestion
suggestions.addEventListener('click', function(e) {
var item = e.target.closest('.suggestion-item');
if (item) {
var value = item.getAttribute('data-value');
if (value) applySuggestion(value);
}
});
// Click outside to hide suggestions
document.addEventListener('click', function(e) {
if (!e.target.closest('.search-box')) {
hideSuggestions();
}
});
// Initialize
loadStats();
})();