diff --git a/backend/secuscan/plugins.py b/backend/secuscan/plugins.py index 343d4300e..58ae207a1 100644 --- a/backend/secuscan/plugins.py +++ b/backend/secuscan/plugins.py @@ -102,6 +102,7 @@ class PluginManager: def __init__(self, plugins_dir: str): self.plugins_dir = Path(plugins_dir) self.plugins: Dict[str, PluginMetadata] = {} + self.plugin_locations: Dict[str, Path] = {} def _scan_plugin_dirs(self) -> List[Path]: """Scan the plugins directory for plugin directories.""" @@ -123,6 +124,8 @@ async def load_plugins(self) -> int: Returns: Number of successfully loaded plugins """ + self.plugins.clear() + self.plugin_locations.clear() plugin_dirs = self._scan_plugin_dirs() loaded = 0 @@ -135,9 +138,19 @@ async def load_plugins(self) -> int: try: plugin_meta = await self._load_plugin_metadata(metadata_file) + # Check for duplicate plugin identifier + if plugin_meta.id in self.plugins: + existing_loc = self.plugin_locations.get(plugin_meta.id) + logger.error( + f"Duplicate plugin identifier '{plugin_meta.id}' found in {plugin_dir} " + f"(conflicts with existing plugin at {existing_loc})" + ) + continue + # Validate plugin if await self._validate_plugin(plugin_meta, plugin_dir): self.plugins[plugin_meta.id] = plugin_meta + self.plugin_locations[plugin_meta.id] = plugin_dir loaded += 1 logger.info(f"✓ Loaded plugin: {plugin_meta.name} v{plugin_meta.version}") else: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 57e6d80e2..43aa273af 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -115,7 +115,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -464,7 +463,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -488,7 +486,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -498,7 +495,6 @@ "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", "license": "MIT", - "peer": true, "dependencies": { "@emotion/memoize": "^0.9.0" } @@ -1524,7 +1520,6 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -1752,7 +1747,6 @@ "integrity": "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.21.0" } @@ -1783,7 +1777,6 @@ "integrity": "sha512-WeqMfGJLGuLCqHGYRGHxnKrXcTitc6L/nBUWfWPcTarG3t9PsquqUMuVeXZeca+mglY4Vo5GZjCi0A3Or2lnxA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -1795,7 +1788,6 @@ "integrity": "sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/react": "*" } @@ -2132,7 +2124,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -3272,7 +3263,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -3289,7 +3279,6 @@ "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssstyle": "^4.1.0", "data-urls": "^5.0.0", @@ -3540,9 +3529,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", @@ -3763,7 +3752,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", @@ -3973,7 +3961,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -3986,7 +3973,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -4016,7 +4002,6 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -4147,8 +4132,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -4649,7 +4633,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -4843,7 +4826,6 @@ "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -4937,7 +4919,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, diff --git a/frontend/package.json b/frontend/package.json index 8c8d0ea0b..211c72975 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -55,6 +55,7 @@ "react-router": "^6.30.4", "dompurify": "^3.4.10", "@babel/core": "^7.29.7", - "undici": "^8.10.0" + "undici": "^8.10.0", + "nanoid": "^3.3.18" } } diff --git a/testing/backend/unit/test_plugins.py b/testing/backend/unit/test_plugins.py index 70a559b3d..187efde96 100644 --- a/testing/backend/unit/test_plugins.py +++ b/testing/backend/unit/test_plugins.py @@ -1,4 +1,6 @@ import asyncio +import json +import logging from pathlib import Path import pytest @@ -475,3 +477,60 @@ def test_plugin_build_command_allows_legitimate_targets(setup_test_environment): ) assert command is not None assert "https://example.com" in command + + +def test_plugin_loader_duplicate_identifier_diagnostics(tmp_path, caplog): + """Loader must log error diagnostics identifying both conflicting plugin locations when duplicate IDs exist.""" + dir1 = tmp_path / "plugin_alpha" + dir1.mkdir() + dir2 = tmp_path / "plugin_beta" + dir2.mkdir() + + meta1 = { + "id": "duplicate_scanner", + "name": "Alpha Scanner", + "description": "First plugin instance", + "version": "1.0.0", + "category": "web", + "icon": "shield", + "engine": {"type": "cli", "binary": "echo"}, + "command_template": ["echo", "test"], + "fields": [], + "presets": {}, + "output": {"parser": "text"}, + "safety": {"level": "safe"}, + } + + meta2 = { + "id": "duplicate_scanner", + "name": "Beta Scanner", + "description": "Second plugin instance with duplicate identifier", + "version": "2.0.0", + "category": "web", + "icon": "shield", + "engine": {"type": "cli", "binary": "echo"}, + "command_template": ["echo", "test"], + "fields": [], + "presets": {}, + "output": {"parser": "text"}, + "safety": {"level": "safe"}, + } + + (dir1 / "metadata.json").write_text(json.dumps(meta1), encoding="utf-8") + (dir2 / "metadata.json").write_text(json.dumps(meta2), encoding="utf-8") + + manager = PluginManager(str(tmp_path)) + with caplog.at_level(logging.ERROR): + loaded = asyncio.run(manager.load_plugins()) + + # Only 1 plugin should be loaded successfully (the duplicate is skipped) + assert loaded == 1 + loaded_plugin = manager.get_plugin("duplicate_scanner") + assert loaded_plugin is not None + assert loaded_plugin.name == "Alpha Scanner" + + # Verify that an error log was captured identifying the duplicate ID and BOTH locations + error_logs = [rec.message for rec in caplog.records if rec.levelno == logging.ERROR] + assert any("duplicate_scanner" in msg for msg in error_logs) + assert any(str(dir1) in msg or dir1.name in msg for msg in error_logs) + assert any(str(dir2) in msg or dir2.name in msg for msg in error_logs)