diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index c447221..7d40018 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -11,6 +11,8 @@ on: paths: - 'data/servers.json' - 'README.md' + - 'scripts/**' + - 'tests/**' schedule: # Run weekly security scans on Mondays at 06:00 UTC - cron: '0 6 * * 1' @@ -46,6 +48,9 @@ jobs: - name: Validate data run: python scripts/validate.py + - name: Test security scanner + run: python -m unittest discover -s tests + security-scan: name: Automated Security Scanning runs-on: ubuntu-latest diff --git a/README.md b/README.md index a9d11fa..087d070 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ Find **secure MCP servers** for your agentic AI applications with confidence. Mo | Server | Version | Security Status | Description | |--------|---------|----------------|-------------| | [Anthropic Computer Use](https://github.com/anthropics/anthropic-computer-use) | 0.1.0 | ⏳ Awaiting Scan | Desktop automation with screen capture and input control | +| [Xquik MCP Server](https://github.com/Xquik-dev/x-twitter-scraper) | 2.6.4 | ⏳ Awaiting Scan | Remote MCP server for authenticated X data workflows | --- @@ -527,6 +528,5 @@ _Click on server scores above to jump to detailed security breakdowns:_ - diff --git a/data/servers.json b/data/servers.json index 93e970c..4154fd1 100644 --- a/data/servers.json +++ b/data/servers.json @@ -1067,6 +1067,57 @@ "iam", "enterprise" ] + }, + { + "name": "Xquik MCP Server", + "slug": "xquik", + "repository": "https://github.com/Xquik-dev/x-twitter-scraper", + "category": "under-review", + "description": "Remote MCP server for authenticated X data workflows", + "maintainer": { + "name": "Xquik-dev", + "type": "organization", + "contact": "https://github.com/Xquik-dev" + }, + "versions": [ + { + "version": "2.6.4", + "release_date": "2026-08-12", + "security_status": "under-review", + "is_recommended": false, + "security_scan": { + "scan_date": "2026-08-12T18:05:02Z", + "scanner_version": "pending", + "static_analysis": { + "status": "not-applicable", + "details": "Awaiting project security validation", + "score": 50 + }, + "dependency_scan": { + "status": "not-applicable", + "details": "Awaiting project security validation", + "score": 50 + }, + "tool_poisoning_check": { + "status": "not-applicable", + "details": "Awaiting project security validation", + "score": 50 + }, + "overall_score": 50 + }, + "vulnerabilities": [] + } + ], + "mcp_protocol_versions": [ + "2026-07-28" + ], + "tags": [ + "x", + "twitter", + "social-data", + "remote-mcp", + "api" + ] } ] -} \ No newline at end of file +} diff --git a/scripts/security-scanner.py b/scripts/security-scanner.py index 6d596d1..9d1e4bb 100644 --- a/scripts/security-scanner.py +++ b/scripts/security-scanner.py @@ -232,6 +232,7 @@ def _run_mcp_scan(self, repo_path: str) -> Dict: return self._basic_tool_poisoning_check(repo_path) try: + successful_scan_count = 0 # Run mcp-scan on found configuration files with enhanced options for config_file in mcp_config_files: cmd = ['uvx', 'mcp-scan@latest', 'scan', '--json', '--local-only', '--verbose', config_file] @@ -242,8 +243,9 @@ def _run_mcp_scan(self, repo_path: str) -> Dict: scan_output = json.loads(result.stdout) # Parse mcp-scan results with severity breakdown - if 'results' in scan_output: + if isinstance(scan_output.get('results'), list): config_results = scan_output['results'] + successful_scan_count += 1 issues_found = 0 critical_issues = 0 high_issues = 0 @@ -276,6 +278,11 @@ def _run_mcp_scan(self, repo_path: str) -> Dict: } results['issues_found'] += issues_found + else: + logger.warning(f"MCP-scan returned an unexpected payload for {config_file}") + results['scan_results'][config_file] = { + 'error': 'Scan output did not contain a results list' + } except json.JSONDecodeError: logger.warning(f"Could not parse mcp-scan output for {config_file}") @@ -290,6 +297,19 @@ def _run_mcp_scan(self, repo_path: str) -> Dict: results['scan_results'][config_file] = { 'error': f'Scan failed: {error_msg}' } + + failed_scan_count = len(mcp_config_files) - successful_scan_count + if successful_scan_count == 0: + fallback = self._basic_tool_poisoning_check(repo_path) + fallback['details'] = ( + f'MCP-scan failed for all {failed_scan_count} configuration file(s); ' + f"{fallback['details']}" + ) + fallback['scan_results'] = results['scan_results'] + if fallback['status'] == 'pass': + fallback['status'] = 'warning' + fallback['score'] = min(fallback['score'], 70) + return fallback # Calculate severity-weighted score total_critical = sum(config.get('critical_issues', 0) for config in results['scan_results'].values() if isinstance(config, dict)) @@ -326,6 +346,12 @@ def _run_mcp_scan(self, repo_path: str) -> Dict: 'details': f'MCP-scan found only low-severity issues ({total_issues} total)', 'score': max(80, score) }) + + if failed_scan_count: + results['details'] += f'; {failed_scan_count} configuration scan(s) failed' + if results['status'] == 'pass': + results['status'] = 'warning' + results['score'] = min(results['score'], 75) except subprocess.TimeoutExpired: results.update({ @@ -1132,4 +1158,4 @@ def main(): if __name__ == '__main__': - main() \ No newline at end of file + main() diff --git a/tests/test_security_scanner.py b/tests/test_security_scanner.py new file mode 100644 index 0000000..b7e60fe --- /dev/null +++ b/tests/test_security_scanner.py @@ -0,0 +1,56 @@ +import importlib.util +import json +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + + +MODULE_PATH = Path(__file__).parents[1] / "scripts" / "security-scanner.py" +SPEC = importlib.util.spec_from_file_location("security_scanner", MODULE_PATH) +SECURITY_SCANNER = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(SECURITY_SCANNER) + + +class McpScanFailureTest(unittest.TestCase): + def test_all_failed_scans_use_basic_fallback(self): + with tempfile.TemporaryDirectory() as directory: + config_path = Path(directory) / "mcp.json" + config_path.write_text('{"mcpServers": {}}', encoding="utf-8") + failed = subprocess.CompletedProcess([], 2, "", "unsupported option") + + with patch.object(SECURITY_SCANNER.subprocess, "run", return_value=failed): + result = SECURITY_SCANNER.SecurityScanner()._run_mcp_scan(directory) + + self.assertEqual(result["status"], "warning") + self.assertEqual(result["score"], 70) + self.assertIn("failed for all 1 configuration file(s)", result["details"]) + self.assertIn(str(config_path), result["scan_results"]) + + def test_partial_scan_failure_cannot_report_full_pass(self): + with tempfile.TemporaryDirectory() as directory: + first_config = Path(directory) / "mcp.json" + second_config = Path(directory) / "mcp_config.json" + first_config.write_text('{"mcpServers": {}}', encoding="utf-8") + second_config.write_text('{"mcpServers": {}}', encoding="utf-8") + succeeded = subprocess.CompletedProcess( + [], 0, json.dumps({"results": []}), "" + ) + failed = subprocess.CompletedProcess([], 2, "", "scan failed") + + with patch.object( + SECURITY_SCANNER.subprocess, + "run", + side_effect=[succeeded, failed], + ): + result = SECURITY_SCANNER.SecurityScanner()._run_mcp_scan(directory) + + self.assertEqual(result["status"], "warning") + self.assertEqual(result["score"], 75) + self.assertEqual(result["issues_found"], 0) + self.assertIn("1 configuration scan(s) failed", result["details"]) + + +if __name__ == "__main__": + unittest.main()