From c4d2ecd2df0eddd62bfe53ce5767c0170ac65cb0 Mon Sep 17 00:00:00 2001 From: Igor Olszewski Date: Tue, 26 May 2026 10:40:12 +0200 Subject: [PATCH] feat: implement persistent report routing with semantic URLs and dynamic sitemap generation --- app.py | 124 ++++++-- static/app.js | 56 +++- static/sitemap.xml | 8 - templates/report.html | 654 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 804 insertions(+), 38 deletions(-) delete mode 100644 static/sitemap.xml create mode 100644 templates/report.html diff --git a/app.py b/app.py index e9e9927..130f774 100644 --- a/app.py +++ b/app.py @@ -50,23 +50,20 @@ def inject_global_vars(): def get_slack_webhook_url() -> str: return os.getenv('SLACK_WEBHOOK_URL', '').strip() -def build_share_url(result_id: str, req) -> str: - referer = req.headers.get('Referer') if req else None - if referer: - parsed = urlparse(referer) - origin = f"{parsed.scheme}://{parsed.netloc}" if parsed.scheme and parsed.netloc else "" - path = parsed.path or "" - if origin: - return f"{origin}{path}?scan_id={result_id}" - - origin = req.headers.get('Origin') if req else None - if origin: - return f"{origin}/?scan_id={result_id}" +def build_share_url(result_id: str, req, metadata=None) -> str: + clean_repo = "report" + if metadata and 'repository_name' in metadata: + import re + clean_repo = re.sub(r'[^a-z0-9]+', '-', metadata['repository_name'].lower()).strip('-') + if not clean_repo: + clean_repo = "report" + + scan_path = f"report/{clean_repo}-{result_id}" if req and req.host_url: - return f"{req.host_url.rstrip('/')}/?scan_id={result_id}" + return f"{req.host_url.rstrip('/')}/{scan_path}" - return result_id + return f"/{scan_path}" def send_slack_notification(message: str) -> None: webhook_url = get_slack_webhook_url() @@ -86,13 +83,106 @@ def send_slack_notification(message: str) -> None: @app.route('/') def index(): + scan_id = request.args.get('scan_id') + if scan_id: + import re + match = re.search(r'([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$', scan_id.lower()) + real_uuid = match.group(1) if match else scan_id + + file_path = os.path.join(app.config['RESULTS_DIR'], f"{real_uuid}.json") + if os.path.exists(file_path): + try: + with open(file_path, 'r') as f: + data = json.load(f) + metadata = data.get('metadata', {}) + clean_repo = "report" + if 'repository_name' in metadata: + clean_repo = re.sub(r'[^a-z0-9]+', '-', metadata['repository_name'].lower()).strip('-') or "report" + return redirect(url_for('report_view', scan_id=f"{clean_repo}-{real_uuid}"), code=301) + except Exception: + pass + return redirect(url_for('report_view', scan_id=scan_id), code=301) + return render_template('index.html') @app.route('/robots.txt') -@app.route('/sitemap.xml') def static_from_root(): return send_from_directory(app.static_folder, request.path[1:]) +@app.route('/sitemap.xml') +def sitemap(): + results_dir = app.config['RESULTS_DIR'] + try: + files = [f for f in os.listdir(results_dir) if f.endswith('.json')] + except FileNotFoundError: + files = [] + + urls = [] + host_url = request.host_url.rstrip('/') + urls.append(f"{host_url}/") + + import re + for filename in files: + file_path = os.path.join(results_dir, filename) + try: + with open(file_path, 'r') as f: + data = json.load(f) + metadata = data.get('metadata', {}) + if not metadata.get('is_private', False): + real_uuid = filename.replace('.json', '') + clean_repo = "report" + if 'repository_name' in metadata: + clean_repo = re.sub(r'[^a-z0-9]+', '-', metadata['repository_name'].lower()).strip('-') or "report" + urls.append(f"{host_url}/report/{clean_repo}-{real_uuid}") + except Exception: + continue + + xml = '\n' + xml += '\n' + for url in urls: + xml += f' {url}\n' + xml += '' + + return app.response_class(xml, mimetype='application/xml') + +@app.route('/report/') +def report_view(scan_id): + import re + match = re.search(r'([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$', scan_id.lower()) + real_uuid = match.group(1) if match else scan_id + + if '..' in real_uuid or '/' in real_uuid or '\\' in real_uuid or not real_uuid.replace('-', '').isalnum(): + return "Invalid scan ID", 400 + + file_path = os.path.join(app.config['RESULTS_DIR'], f"{real_uuid}.json") + if not os.path.exists(file_path): + return "Report not found", 404 + + try: + with open(file_path, 'r') as f: + data = json.load(f) + except Exception: + return "Error reading report", 500 + + metadata = data.get('metadata', {}) + clean_repo = "report" + if 'repository_name' in metadata: + clean_repo = re.sub(r'[^a-z0-9]+', '-', metadata['repository_name'].lower()).strip('-') or "report" + canonical_scan_id = f"{clean_repo}-{real_uuid}" + + return render_template('report.html', + data=data, + report_json=json.dumps(data).replace('<', '\\u003c').replace('>', '\\u003e').replace('&', '\\u0026').replace("'", '\\u0027'), + current_scan_id=canonical_scan_id, + metadata=metadata, + summary=data.get('summary', {}), + grade_report={ + 'overall': data.get('overall', {}), + 'cost': data.get('cost', {}), + 'security': data.get('security', {}), + 'container': data.get('container', {}), + }) + @app.route('/api/scanner/status') def scanner_status(): """Return information about available scanners.""" @@ -370,7 +460,7 @@ def save_results(): repo_url = metadata.get('repository_url', 'unknown') - share_url = build_share_url(result_id, request) + share_url = build_share_url(result_id, request, metadata) slack_message = ( "🔗 InfraScan results shared | " @@ -379,7 +469,7 @@ def save_results(): ) send_slack_notification(slack_message) - return jsonify({'id': result_id}) + return jsonify({'id': result_id, 'share_url': share_url}) @app.route('/api/results/', methods=['GET']) def get_results(scan_id): diff --git a/static/app.js b/static/app.js index 08ec42f..cfce403 100644 --- a/static/app.js +++ b/static/app.js @@ -73,13 +73,20 @@ function initApp() { if (!data) return; - // Hide all web app specific UI parts - if (scanInputContainer) scanInputContainer.style.display = 'none'; - if (document.querySelector('.tabs')) document.querySelector('.tabs').style.display = 'none'; - if (landingInfo) landingInfo.style.display = 'none'; - document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); - if (newsletterModal) newsletterModal.style.display = 'none'; - if (feedbackModal) feedbackModal.style.display = 'none'; + const isHostedReport = window.location.pathname.startsWith('/report/'); + + if (!isHostedReport) { + // Hide all web app specific UI parts for standalone CLI + if (scanInputContainer) scanInputContainer.style.display = 'none'; + if (document.querySelector('.tabs')) document.querySelector('.tabs').style.display = 'none'; + if (landingInfo) landingInfo.style.display = 'none'; + document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); + if (newsletterModal) newsletterModal.style.display = 'none'; + if (feedbackModal) feedbackModal.style.display = 'none'; + } else { + if (scanInputContainer) scanInputContainer.classList.add('hidden'); + if (landingInfo) landingInfo.classList.add('collapsed'); + } const gradeReport = { overall: data.overall, @@ -100,8 +107,19 @@ function initApp() { setupPdfExport(); // Hide elements that don't make sense in standalone report - if (newScanBtn) newScanBtn.style.display = 'none'; - if (shareBtn) shareBtn.style.display = 'none'; + if (!isHostedReport) { + if (newScanBtn) newScanBtn.style.display = 'none'; + if (shareBtn) shareBtn.style.display = 'none'; + } else { + // We need currentScanId to be populated for sharing to work + const pathParts = window.location.pathname.split('/'); + const lastPart = pathParts[pathParts.length - 1]; + // Extract UUID from the last part (e.g. clean-repo-name-uuid) + const uuidMatch = lastPart.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i); + if (uuidMatch) { + currentScanId = uuidMatch[1]; + } + } // Ensure container is correctly styled for full-width report if (mainContainer) mainContainer.classList.add('expanded'); @@ -114,7 +132,7 @@ function initApp() { loadSharedResults(); // Trigger Newsletter Modal after 3 seconds if not already closed - if (!localStorage.getItem('newsletter_closed') && !window.location.search.includes('scan_id')) { + if (!localStorage.getItem('newsletter_closed') && !window.location.search.includes('scan_id') && !window.location.pathname.startsWith('/report/')) { setTimeout(() => { if (newsletterModal) newsletterModal.classList.remove('hidden'); }, 3000); @@ -422,7 +440,11 @@ function initApp() { try { if (currentScanId) { - const shareUrl = `${window.location.origin}${window.location.pathname}?scan_id=${currentScanId}`; + let cleanRepo = "report"; + if (currentMetadata && currentMetadata.repository_name) { + cleanRepo = currentMetadata.repository_name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || "report"; + } + const shareUrl = `${window.location.origin}/report/${cleanRepo}-${currentScanId}`; shareUrlInput.value = shareUrl; shareLinkContainer.classList.remove('hidden'); shareBtn.textContent = 'Results Shared'; @@ -454,7 +476,7 @@ function initApp() { if (!response.ok) throw new Error(data.error || `Server error (${response.status})`); currentScanId = data.id; - const shareUrl = `${window.location.origin}${window.location.pathname}?scan_id=${data.id}`; + const shareUrl = data.share_url || `${window.location.origin}/report/${data.id}`; shareUrlInput.value = shareUrl; shareLinkContainer.classList.remove('hidden'); shareBtn.textContent = 'Results Shared'; @@ -477,6 +499,10 @@ function initApp() { // New Scan Button if (newScanBtn) { newScanBtn.addEventListener('click', () => { + if (window.location.pathname.startsWith('/report/')) { + window.location.href = '/'; + return; + } resultsArea.classList.add('hidden'); if (scanInputContainer) scanInputContainer.classList.remove('hidden'); if (landingInfo) landingInfo.classList.remove('collapsed'); @@ -837,7 +863,11 @@ function initApp() { const recipientBadge = ''; - const viewUrl = `${window.location.origin}${window.location.pathname}?scan_id=${scan.id}`; + let cleanRepo = "report"; + if (scan.repository_name) { + cleanRepo = scan.repository_name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || "report"; + } + const viewUrl = `${window.location.origin}/report/${cleanRepo}-${scan.id}`; return `
diff --git a/static/sitemap.xml b/static/sitemap.xml deleted file mode 100644 index d13f630..0000000 --- a/static/sitemap.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - https://infrascan.soldevelo.com/ - weekly - 1.0 - - diff --git a/templates/report.html b/templates/report.html new file mode 100644 index 0000000..f54f2f3 --- /dev/null +++ b/templates/report.html @@ -0,0 +1,654 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + InfraScan Report for {{ metadata.repository_name }} + + {% if google_tag_id %} + + + + {% endif %} + + + + + + + + + + + + + + +
+
+ +

Open Source Infrastructure Auditor by SolDevelo

+ +
+
+
💰
+

Reduce Costs

+

Identify expensive mistakes like oversized instances, unmanaged disks, or missing lifecycle + rules.

+ + Learn how we help + +
+
+
🛡️
+

Fix Security

+

Scan for open ports, unencrypted storage, and risky IAM policies before deploying to production. +

+ + Our security approach + +
+
+
+

GitHub Ready

+

Just paste your repository URL. No complex setup or cloud credentials required for the initial + scan.

+ + Get expert advice + +
+
+
+ +
+
+ + + + +
+ +
+
+
+ + + +
+ + + + +
+ + + Comprehensive scans for AWS cost antipatterns, IaC + security issues (Checkov), and container vulnerabilities. +
+ +
+
+ +
+ Private Mode + Scan won't appear in the "Recent Scans" list +
+
+
+ + + + +
+ Supported: + Terraform + AWS + Kubernetes +
+
+
+ +
+
+

🕒 Recent Scans

+

History of the latest infrastructure scans performed by InfraScan. +

+
+ + +
+ + +
+ +
+
+
+
+ v1.0.6 + May 13, 2026 +
+
    +
  • PDF Export: Reports can now be exported as a print-ready PDF directly from the browser — ideal for sharing with compliance and security teams.
  • +
+
+
+
+ v1.0.5 + April 10, 2026 +
+
    +
  • Kubernetes Manifest Scanning: Added support for scanning Kubernetes + manifest files.
  • +
  • CI/CD Integration Examples: Added examples for GitHub Actions and + GitLab CI/CD.
  • +
+
+
+
+ v1.0.4 + April 7, 2026 +
+
    +
  • Beautiful Colored CLI Summary: Integrated rich terminal formatting and + colors for findings directly in CI/CD logs.
  • +
  • Grading Overview: Real-time A-F grades for cost and security displayed + directly in logs.
  • +
  • CI/CD Optimization: Added display limits to prevent log flooding and + ensured text summary is always visible.
  • +
  • Improved Report Reliability: Enhanced standalone HTML generation using + robust regex injection.
  • +
+
+
+
+ v1.0.3 + March 12, 2026 +
+
    +
  • Added option to scan in CI/CD using InfraScan CLI
  • +
+
+
+
+ v1.0.2 + March 3, 2026 +
+
    +
  • Added **Private Mode** toggle: scans can now be performed without appearing in the + public "Recent Scans" history
  • +
  • Implemented **Pagination** for "Recent Scans" (5 scans per page) for improved navigation + and history management
  • +
  • Enhanced privacy controls: private scans are still accessible via their unique share + links
  • +
  • Optimized recent scans loading performance with frontend data slicing
  • +
+
+
+
+ v1.0.1 + February 4, 2026 +
+
    +
  • Enhanced "One-Pager" dashboard with dynamic container expansion (up to 1400px)
  • +
  • Two-column optimized layout: side-by-side Cost and Security findings
  • +
  • Branding refresh: Integrated SolDevelo logo and detailed service value propositions
  • +
  • Intelligent UX: Automatic feedback request triggered by report engagement (scroll-based) +
  • +
  • Added "Scroll to Top" functionality for easier navigation of long reports
  • +
  • Improved result persistence when switching between application tabs
  • +
  • User-friendly terminology: rebranded technical terms to "Cost" and "Security" focus
  • +
  • Fixed feedback modal accessibility and closing logic
  • +
+
+
+
+ v1.0.0 + January 21, 2026 +
+
    +
  • Advanced dual-engine analysis: Real-time pattern matching and deep security inspection +
  • +
  • Comprehensive rule set covering cost, security, and compliance best practices
  • +
  • Multi-tier scanning: Rapid assessment or deep infrastructure audit
  • +
  • Enterprise features: Budget tracking, spot instance optimization, and S3 lifecycle + management
  • +
  • Intelligent repository analysis with automated risk grouping
  • +
  • Professional dashboard with severity-based prioritization
  • +
  • Optimized for modern AWS and Terraform architectures
  • +
+
+
+
+ +
+
+
+
Official CLI
+

🚀 Local & CI/CD Usage

+

Experience the full power of InfraScan on your terms. Audit private infrastructure securely, + integrate into your DevOps pipelines, and generate professional reports locally.

+
+ +
+ +
+

🏠 Run Locally with Docker

+

Perfect for private projects. Your code stays on your machine, scanned by a + self-contained environment.

+ +
+
+
+
+
+
+
+
Terminal / Bash
+ +
+
docker run --rm -v $(pwd):/scan soldevelo/infrascan:latest
+
+

💡 Mounts your current directory to /scan and provides + immediate CLI feedback.

+
+ + +
+ {% raw %} +

🤖 GitHub Actions

+

Stop vulnerabilities before they reach production. Seamlessly integrate InfraScan as a + gatekeeper in your PRs.

+ +
+
+
+
+
+
+
+
.github/workflows/infrascan.yml
+ +
+
steps:
+  - uses: actions/checkout@v4
+  - name: Run InfraScan Audit
+    run: |
+      docker run --rm \
+        -v ${{ github.workspace }}:/scan \
+        soldevelo/infrascan:v1.0.5 \
+        --fail-on high_critical
+
+ {% endraw %} +
+ + +
+

📊 Professional HTML Reports

+

Generate beautiful, shareable HTML audit reports directly from the command line.

+ +
+
+
+
+
+
+
+
Generate audit.html
+ +
+
docker run --rm -v $(pwd):/scan soldevelo/infrascan \
+  --format html --out /scan/audit.html
+
+
+
+ + +
+
+ + + + +
+ +
+ + +

InfraScan v1.0.6 © 2026 SolDevelo. Advanced Infrastructure Auditor.

+

This tool is Open Source – + contributions are welcome! + + GitHub stars + +

+ +

Made with ♥ by + + . +

+ +

Designed & Built by the SolDevelo Cloud + Engineering Team

+
+ + +
+ + + + + + + + +
+ + + + + + \ No newline at end of file