From 134c44749e93f91b35b77252cf837088a608d3fc Mon Sep 17 00:00:00 2001 From: igor-soldev Date: Wed, 10 Jun 2026 09:10:25 +0200 Subject: [PATCH] feat: Add support for monitoring of InfraScan projects --- .gitignore | 5 + app.py | 351 +++++++++++++++++++ cli.py | 9 + data/monitored_projects.json | 1 + docs/PIPELINE_INTEGRATION.md | 27 ++ static/app.js | 13 +- static/style.css | 170 ++++++++++ templates/index.html | 44 ++- templates/report.html | 34 +- templates/supported_projects.html | 544 ++++++++++++++++++++++++++++++ 10 files changed, 1163 insertions(+), 35 deletions(-) create mode 100644 data/monitored_projects.json create mode 100644 templates/supported_projects.html diff --git a/.gitignore b/.gitignore index 093f9b6..e0eba67 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,8 @@ env/ # Environment files .env +# InfraScan runtime and local artifacts +scan_results/ +data/feedback.json +data/subscribers.json + diff --git a/app.py b/app.py index feec67a..69be750 100644 --- a/app.py +++ b/app.py @@ -26,12 +26,19 @@ app.config['DATA_DIR'] = os.path.join(os.getcwd(), 'data') app.config['FEEDBACK_FILE'] = os.path.join(app.config['DATA_DIR'], 'feedback.json') app.config['SUBSCRIBERS_FILE'] = os.path.join(app.config['DATA_DIR'], 'subscribers.json') +app.config['MONITORED_PROJECTS_FILE'] = os.path.join(app.config['DATA_DIR'], 'monitored_projects.json') +app.config['SHOW_GRADES_PUBLICLY'] = os.getenv('SHOW_GRADES_PUBLICLY', 'True').lower() in ('true', '1', 'yes') # Create directories if they don't exist os.makedirs(app.config['RESULTS_DIR'], exist_ok=True) os.makedirs(app.config['DATA_DIR'], exist_ok=True) app.config['SLACK_WEBHOOK_URL'] = os.getenv('SLACK_WEBHOOK_URL', '') +# Ensure monitored projects config exists +if not os.path.exists(app.config['MONITORED_PROJECTS_FILE']): + with open(app.config['MONITORED_PROJECTS_FILE'], 'w') as f: + json.dump([], f) + # Cache busting - changes on each deployment/restart STATIC_VERSION = str(int(time.time())) @@ -66,6 +73,43 @@ def build_share_url(result_id: str, req, metadata=None) -> str: return f"/{scan_path}" + +def load_monitored_projects(): + try: + with open(app.config['MONITORED_PROJECTS_FILE'], 'r') as f: + items = json.load(f) or [] + except Exception: + return [] + + monitored = [] + for item in items: + if isinstance(item, str): + monitored.append({ + 'repo_url': item, + 'branch': 'main', + 'scanner': 'comprehensive', + 'is_private': False, + }) + elif isinstance(item, dict) and item.get('repo_url'): + monitored.append({ + 'repo_url': item['repo_url'], + 'branch': item.get('branch', 'main'), + 'scanner': item.get('scanner', 'comprehensive'), + 'is_private': item.get('is_private', False), + }) + return monitored + + +def save_scan_result(report_dict): + if 'metadata' not in report_dict or report_dict['metadata'] is None: + report_dict['metadata'] = {} + result_id = str(uuid.uuid4()) + file_path = os.path.join(app.config['RESULTS_DIR'], f"{result_id}.json") + with open(file_path, 'w') as f: + json.dump(report_dict, f) + return result_id + + def send_slack_notification(message: str) -> None: webhook_url = get_slack_webhook_url() if not webhook_url: @@ -353,6 +397,7 @@ def clone_repo(): report_dict['metadata'].update({ 'repository_url': repo_url, 'repository_name': repo_name, + 'scan_source': 'web_app', 'scan_timestamp': scan_timestamp, 'is_private': is_private, @@ -445,6 +490,87 @@ def clone_repo(): shutil.rmtree(temp_dir, ignore_errors=True) +def scan_repository(repo_url, branch='main', scanner_type='comprehensive', is_private=False, scan_source='monitored_scan'): + temp_dir = tempfile.mkdtemp() + try: + Repo.clone_from(repo_url, temp_dir, branch=branch, depth=1) + except Exception as e: + shutil.rmtree(temp_dir, ignore_errors=True) + raise RuntimeError(f"Unable to clone repository: {e}") + + try: + results, resource_count, recommendations = scan_directory(temp_dir, scanner_type=scanner_type, framework='smart') + report_generator = ReportGenerator() + report = report_generator.generate_report( + findings=results, + resource_count=resource_count, + scanner_type=scanner_type, + extra_recommendations=recommendations + ) + + repo_name = repo_url.rstrip('/').split('/')[-1] if '/' in repo_url else repo_url + scan_timestamp = datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC') + + report_dict = report.to_dict() + report_dict['metadata'] = report_dict.get('metadata', {}) + report_dict['metadata'].update({ + 'repository_url': repo_url, + 'repository_name': repo_name, + 'scan_source': scan_source, + 'scan_timestamp': scan_timestamp, + 'is_private': is_private, + 'branch': branch + }) + + report_dict['results'] = results + report_dict['summary'] = { + 'total': len(results), + 'unique_rules': report.metrics.get('unique_rules_triggered', 0), + 'scanner_used': scanner_type + } + + scan_id = save_scan_result(report_dict) + return scan_id, report_dict + finally: + shutil.rmtree(temp_dir, ignore_errors=True) + + +@app.route('/api/scans/monitored/refresh', methods=['POST']) +def refresh_monitored_scans(): + monitored = load_monitored_projects() + if not monitored: + return jsonify({'error': 'No monitored repositories configured.', 'monitored_projects': []}), 400 + + refresh_results = [] + for project in monitored: + try: + scan_id, report_dict = scan_repository( + project['repo_url'], + branch=project.get('branch', 'main'), + scanner_type=project.get('scanner', 'comprehensive'), + is_private=project.get('is_private', False), + scan_source='monitored_project' + ) + refresh_results.append({ + 'repo_url': project['repo_url'], + 'scan_id': scan_id, + 'status': 'ok', + 'grade': report_dict.get('overall', {}).get('letter', '?'), + 'scan_timestamp': report_dict['metadata'].get('scan_timestamp') + }) + except Exception as e: + refresh_results.append({ + 'repo_url': project['repo_url'], + 'status': 'error', + 'message': str(e) + }) + + return jsonify({ + 'results': refresh_results, + 'refreshed_at': datetime.datetime.now(datetime.timezone.utc).isoformat() + }) + + @app.route('/api/results/save', methods=['POST']) def save_results(): data = request.get_json() @@ -573,6 +699,231 @@ def get_recent_scans(): # Return only the 500 most recent return jsonify({'scans': scans[:500]}) + +def normalize_repository_url(repo_url): + import re + if not repo_url: + return repo_url + + url = repo_url.strip() + if url.startswith('git@'): + match = re.match(r'^git@([^:]+):(.+)$', url) + if match: + host = match.group(1) + path = match.group(2) + path = path.rstrip('/') + if path.endswith('.git'): + path = path[:-4] + return f'https://{host}/{path}' + + if '://' not in url: + url = f'https://{url}' + + parsed = urlparse(url) + path = parsed.path.rstrip('/') + if path.endswith('.git'): + path = path[:-4] + + return f'{parsed.scheme}://{parsed.netloc}{path}' + + +def extract_project_name(repo_url): + import re + if not repo_url: + return 'Unknown' + + url = repo_url.strip() + + # Normalize SSH-style URLs to a path-like string + if url.startswith('git@') or '://git@' in url: + if '://' in url: + url = url.split('://', 1)[1] + url = url.replace(':', '/') + + try: + parsed = urlparse(url if '://' in url else f'https://{url}') + path = parsed.path.strip('/') + except Exception: + path = url.strip('/') + + if path.endswith('.git'): + path = path[:-4] + + parts = [p for p in path.split('/') if p] + if parts: + # Use repo name (last segment) when available + return parts[-1] + + # Fallback: remove common host segments and return last remaining piece + parts = [p for p in re.split(r'[:/]', url) if p and p.lower() not in ['github.com', 'gitlab.com', 'bitbucket.org', 'https', 'http', 'git']] + return parts[-1] if parts else 'Unknown' + + +def get_display_name(proj_name): + if not proj_name: + return 'Unknown' + + if proj_name.islower(): + return proj_name.replace('-', ' ').replace('_', ' ').title() + + if '-' in proj_name or '_' in proj_name: + import re + parts = re.split(r'[-_]', proj_name) + return ' '.join(part.capitalize() if part.islower() else part for part in parts) + + return proj_name + + +@app.route('/supported-projects') +def supported_projects(): + """Render the Supported Projects page.""" + return render_template('supported_projects.html') + + +@app.route('/api/scans/supported-projects', methods=['GET']) +def get_supported_projects(): + """Return an aggregated list of infrastructure projects using InfraScan in the last 12 months.""" + results_dir = app.config['RESULTS_DIR'] + projects_map = {} + + try: + files = [f for f in os.listdir(results_dir) if f.endswith('.json')] + except FileNotFoundError: + files = [] + + now = datetime.datetime.now(datetime.timezone.utc) + twelve_months_ago = now - datetime.timedelta(days=365) + + 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', {}) or {} + repo_url = metadata.get('repository_url') + scan_timestamp = metadata.get('scan_timestamp') + is_private = metadata.get('is_private', False) + + # Skip entries without essential data or private scans + if not repo_url or not scan_timestamp or is_private: + continue + + # Parse scan_timestamp + scan_dt = None + clean_ts = scan_timestamp.replace(' UTC', '').split('.')[0] + for fmt in ('%Y-%m-%d %H:%M:%S', '%Y-%m-%dT%H:%M:%SZ', '%Y-%m-%d'): + try: + scan_dt = datetime.datetime.strptime(clean_ts, fmt).replace(tzinfo=datetime.timezone.utc) + break + except ValueError: + continue + + if not scan_dt: + continue + + proj_name = extract_project_name(repo_url) + proj_key = proj_name.lower() + + # Check rolling 12-month window + in_window = scan_dt >= twelve_months_ago + + latest_scan_letter = data.get('overall', {}).get('letter') if data.get('overall') else None + latest_scan_pct = data.get('overall', {}).get('percentage') if data.get('overall') else None + latest_scan_source = metadata.get('scan_source') or 'unknown' + + normalized_repo_url = normalize_repository_url(repo_url) + if proj_key not in projects_map: + projects_map[proj_key] = { + 'raw_name': proj_name, + 'repository_url': normalized_repo_url, + 'scans_in_window': 0, + 'latest_scan_dt': scan_dt, + 'latest_scan_letter': latest_scan_letter, + 'latest_scan_pct': latest_scan_pct, + 'latest_scan_source': latest_scan_source, + 'web_scans': 0, + 'github_actions_scans': 0, + 'other_scans': 0, + 'pct_sum': 0, + 'pct_count': 0, + } + else: + if scan_dt > projects_map[proj_key]['latest_scan_dt']: + projects_map[proj_key]['latest_scan_dt'] = scan_dt + projects_map[proj_key]['latest_scan_letter'] = latest_scan_letter + projects_map[proj_key]['latest_scan_pct'] = latest_scan_pct + projects_map[proj_key]['latest_scan_source'] = latest_scan_source + projects_map[proj_key]['repository_url'] = normalized_repo_url + + if in_window: + projects_map[proj_key]['scans_in_window'] += 1 + if latest_scan_source == 'github_actions': + projects_map[proj_key]['github_actions_scans'] += 1 + elif latest_scan_source == 'web_app': + projects_map[proj_key]['web_scans'] += 1 + else: + projects_map[proj_key]['other_scans'] += 1 + pct = latest_scan_pct + if pct is not None: + try: + projects_map[proj_key]['pct_sum'] += float(pct) + projects_map[proj_key]['pct_count'] += 1 + except (TypeError, ValueError): + pass + + except Exception as e: + print(f"Error reading scan file {filename}: {e}") + continue + + def pct_to_letter(pct): + if pct >= 90: return 'A' + if pct >= 75: return 'B' + if pct >= 60: return 'C' + if pct >= 45: return 'D' + return 'F' + + projects_list = [] + for key, info in projects_map.items(): + if info['scans_in_window'] > 0: + latest_grade = info.get('latest_scan_letter') + if not latest_grade and info.get('latest_scan_pct') is not None: + try: + latest_grade = pct_to_letter(float(info['latest_scan_pct'])) + except (TypeError, ValueError): + latest_grade = None + if not latest_grade: + latest_grade = '?' + projects_list.append({ + 'project_name': get_display_name(info['raw_name']), + 'repository_url': info.get('repository_url'), + 'scan_count': info['scans_in_window'], + 'latest_scan': info['latest_scan_dt'].strftime('%Y-%m-%d'), + 'latest_scan_source': info.get('latest_scan_source', 'unknown'), + 'web_scans': info.get('web_scans', 0), + 'github_actions_scans': info.get('github_actions_scans', 0), + 'other_scans': info.get('other_scans', 0), + 'grade': latest_grade + }) + + # Sort descending by scan count, then latest scan date descending, then alphabetically by project name + projects_list.sort(key=lambda p: p['project_name'].lower()) + projects_list.sort(key=lambda p: p['latest_scan'], reverse=True) + projects_list.sort(key=lambda p: p['scan_count'], reverse=True) + + # Filter grade output if grade visibility is disabled + show_grades = app.config['SHOW_GRADES_PUBLICLY'] + for p in projects_list: + if not show_grades: + p['grade'] = None + + return jsonify({ + 'projects': projects_list, + 'show_grades': show_grades + }) + + @app.route('/api/feedback', methods=['POST']) def submit_feedback(): data = request.get_json() diff --git a/cli.py b/cli.py index 2d7a3f5..ad60039 100755 --- a/cli.py +++ b/cli.py @@ -317,6 +317,15 @@ def main(): 'total': len(results), 'scanner_used': args.scanner } + report_dict['metadata'] = report_dict.get('metadata', {}) + gh_ctx = build_gh_actions_context() + if gh_ctx['repo'] or gh_ctx['workflow'] or gh_ctx['run_url']: + report_dict['metadata'].update({ + 'scan_source': 'github_actions', + 'github_actions': gh_ctx, + }) + if gh_ctx['repo'] and 'repository_url' not in report_dict['metadata']: + report_dict['metadata']['repository_url'] = f"https://github.com/{gh_ctx['repo']}" # Output Results to file/stdout if args.out: diff --git a/data/monitored_projects.json b/data/monitored_projects.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/data/monitored_projects.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/docs/PIPELINE_INTEGRATION.md b/docs/PIPELINE_INTEGRATION.md index f928215..8dfbe0b 100644 --- a/docs/PIPELINE_INTEGRATION.md +++ b/docs/PIPELINE_INTEGRATION.md @@ -97,6 +97,33 @@ jobs: path: report.html ``` +## Daily monitoring of GitHub Actions projects +If you want InfraScan to collect grades from a known set of repositories every day, add those repository URLs to `data/monitored_projects.json` and trigger the new refresh endpoint once per day. + +`data/monitored_projects.json` supports either a list of repository URLs or an array of objects with optional branch/scanner configuration: + +```json +[ + "https://github.com/soldevelo/InfraScan", + { + "repo_url": "https://github.com/example/repo", + "branch": "main", + "scanner": "comprehensive", + "is_private": false + } +] +``` + +Then call the refresh endpoint to fetch the latest scan results for those repos and store them in the web app: + +```bash +curl -X POST https://your-infrascan.example.com/api/scans/monitored/refresh +``` + +This allows the web app to aggregate the latest grades and scan timestamps from configured repositories. The app still needs the list of repos because it cannot automatically discover every project using InfraScan from GitHub Actions alone. + +> Note: to let the InfraScan web app track GitHub Actions scans and their grades, the workflow can also emit JSON output with metadata and make it available to the app (for example by writing it into the app `scan_results` directory or posting it to `/api/results/save`). + ## 💡 Pro Tips * **Console Visibility:** InfraScan v1.0.4+ prints a colored summary directly to the terminal. You don't always need to download the HTML report to see what's wrong. * **Selective Scanning:** For large monorepos, use `-f` or `--include` to scan only the modified directories or files. This speeds up the scan and reduces noise from unrelated projects. diff --git a/static/app.js b/static/app.js index 32176b1..7e51900 100644 --- a/static/app.js +++ b/static/app.js @@ -327,8 +327,9 @@ function initApp() { // Tab Switching tabs.forEach(tab => { - tab.addEventListener('click', () => { + tab.addEventListener('click', (e) => { const targetTab = tab.dataset.tab; + if (!targetTab) return; // Allow normal link navigation for non-tab buttons tabs.forEach(t => t.classList.remove('active')); tabContents.forEach(c => c.classList.remove('active')); @@ -1645,6 +1646,16 @@ function initApp() { } }; } + + // Check for tab query parameter on page load + const urlParams = new URLSearchParams(window.location.search); + const tabParam = urlParams.get('tab'); + if (tabParam) { + const targetTabBtn = document.querySelector(`.tab-btn[data-tab="${tabParam}"]`); + if (targetTabBtn) { + targetTabBtn.click(); + } + } } if (document.readyState === 'loading') { diff --git a/static/style.css b/static/style.css index dd2aebe..edf558c 100644 --- a/static/style.css +++ b/static/style.css @@ -279,6 +279,11 @@ header { font-weight: 500; transition: all 0.2s; border-bottom: 2px solid transparent; + text-decoration: none; + display: inline-flex; + align-items: center; + justify-content: center; + text-align: center; } .tab-btn:hover { @@ -3166,4 +3171,169 @@ textarea:focus { padding-top: 0.5rem; border-top: 1px solid #e2e8f0; } +} + +/* ========================================================================== + Supported Projects Styles + ========================================================================== */ + +/* Table Layout and Container */ +.projects-table-container { + overflow-x: auto; + border-radius: 0.75rem; + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.01); + margin-top: 1.5rem; + margin-bottom: 1.5rem; +} + +.projects-table { + width: 100%; + border-collapse: collapse; + text-align: left; + font-size: 0.95rem; +} + +.projects-table th { + background: rgba(15, 23, 42, 0.6); + color: var(--text-muted); + font-weight: 600; + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--border); + text-transform: uppercase; + font-size: 0.8rem; + letter-spacing: 0.05em; +} + +.projects-table td { + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--border); + color: var(--text-main); + vertical-align: middle; +} + +.projects-table tbody tr { + transition: background-color 0.2s ease; +} + +.projects-table tbody tr:hover { + background-color: rgba(255, 255, 255, 0.02); +} + +.projects-table tbody tr:last-child td { + border-bottom: none; +} + +/* Project Name & Link styling */ +.project-name-cell { + font-weight: 600; + color: var(--text-main); + display: flex; + align-items: center; + gap: 0.5rem; +} + +.project-link { + color: var(--primary); + text-decoration: none; + transition: color 0.2s; +} + +.project-link:hover { + color: var(--primary-hover); + text-decoration: underline; +} + +/* Grade badges */ +.grade-badge-cell { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.25rem; + height: 2.25rem; + border-radius: 50%; + font-weight: 700; + font-size: 1rem; + background-color: rgba(255, 255, 255, 0.1); + color: var(--text-main); +} + +.grade-badge-cell.A { + background-color: rgba(16, 185, 129, 0.15); + color: #34d399; + border: 1px solid rgba(16, 185, 129, 0.3); +} + +.grade-badge-cell.B { + background-color: rgba(16, 185, 129, 0.1); + color: #a7f3d0; + border: 1px solid rgba(16, 185, 129, 0.2); +} + +.grade-badge-cell.C { + background-color: rgba(245, 158, 11, 0.15); + color: #fbbf24; + border: 1px solid rgba(245, 158, 11, 0.3); +} + +.grade-badge-cell.D { + background-color: rgba(239, 68, 68, 0.15); + color: #fca5a5; + border: 1px solid rgba(239, 68, 68, 0.3); +} + +.grade-badge-cell.F { + background-color: rgba(239, 68, 68, 0.2); + color: #f87171; + border: 1px solid rgba(239, 68, 68, 0.4); +} + +.grade-badge-cell.unknown { + background-color: rgba(148, 163, 184, 0.1); + color: var(--text-muted); + border: 1px solid rgba(148, 163, 184, 0.2); +} + +/* Supported Projects Header & Filters */ +.supported-projects-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + margin-bottom: 1.5rem; + flex-wrap: wrap; +} + +.projects-search-container { + position: relative; + max-width: 320px; + width: 100%; +} + +.projects-search-input { + width: 100%; + padding: 0.6rem 1rem 0.6rem 2.25rem; + background-color: rgba(15, 23, 42, 0.6); + border: 1px solid var(--border); + border-radius: 0.5rem; + color: var(--text-main); + font-size: 0.9rem; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +.projects-search-input:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.1); + background-color: rgba(15, 23, 42, 0.8); +} + +.projects-search-icon { + position: absolute; + left: 0.75rem; + top: 50%; + transform: translateY(-50%); + color: var(--text-muted); + font-size: 0.85rem; + pointer-events: none; } \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index 58a5587..ebaf302 100644 --- a/templates/index.html +++ b/templates/index.html @@ -15,16 +15,14 @@ - + - + @@ -142,6 +140,7 @@

GitHub Ready

+ Supported Projects
@@ -234,12 +233,18 @@

🕒 Recent Scans

June 2, 2026
@@ -248,11 +253,16 @@

🕒 Recent Scans

May 13, 2026
@@ -400,7 +410,7 @@

🤖 GitHub Actions

run: | docker run --rm \ -v ${{ github.workspace }}:/scan \ - soldevelo/infrascan:v1.0.5 \ + soldevelo/infrascan:v1.0.7 \ --fail-on high_critical"> Copy @@ -411,7 +421,7 @@

🤖 GitHub Actions

run: | docker run --rm \ -v ${{ github.workspace }}:/scan \ - soldevelo/infrascan:v1.0.5 \ + soldevelo/infrascan:v1.0.7 \ --fail-on high_critical
{% endraw %} diff --git a/templates/report.html b/templates/report.html index 5b04224..38ecbdf 100644 --- a/templates/report.html +++ b/templates/report.html @@ -15,16 +15,14 @@ - + - + @@ -69,7 +67,7 @@ } @@ -143,6 +141,7 @@

GitHub Ready

+ Supported Projects
@@ -235,7 +234,8 @@

🕒 Recent Scans

May 13, 2026
@@ -383,7 +383,7 @@

🤖 GitHub Actions

run: | docker run --rm \ -v ${{ github.workspace }}:/scan \ - soldevelo/infrascan:v1.0.5 \ + soldevelo/infrascan:v1.0.7 \ --fail-on high_critical"> Copy @@ -394,7 +394,7 @@

🤖 GitHub Actions

run: | docker run --rm \ -v ${{ github.workspace }}:/scan \ - soldevelo/infrascan:v1.0.5 \ + soldevelo/infrascan:v1.0.7 \ --fail-on high_critical
{% endraw %} @@ -507,36 +507,36 @@

InfraScan Report for {{ metadata.repository_name }}

Scan Date: {{ metadata.scan_timestamp }}

Overall Grade: {{ grade_report.overall.letter }} ({{ grade_report.overall.percentage }}%)

Total Issues: {{ summary.total }}

- +

Cost Optimization

Grade: {{ grade_report.cost.letter }}

IaC Security

Grade: {{ grade_report.security.letter }}

Container Security

Grade: {{ grade_report.container.letter }}

Recommendations

diff --git a/templates/supported_projects.html b/templates/supported_projects.html new file mode 100644 index 0000000..ba9c2d9 --- /dev/null +++ b/templates/supported_projects.html @@ -0,0 +1,544 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Supported Projects | InfraScan - Infrastructure Auditor Adoption Dashboard + + {% 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 + +
+
+
+ +
+ + +
+
+
+

💼 Supported Projects

+

Infrastructure projects actively audited by InfraScan in the last 12 months.

+
+
+ 🔍 + +
+
+ +
+
+

Loading projects list...

+
+ + + + + + +
+
+ +
+ + +

InfraScan v1.0.7 © 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

+
+
+ + + + + +
+ + + + +