diff --git a/backend/secuscan/plugin_validator.py b/backend/secuscan/plugin_validator.py index 0a9fe955b..c5ccd85cc 100644 --- a/backend/secuscan/plugin_validator.py +++ b/backend/secuscan/plugin_validator.py @@ -235,6 +235,14 @@ def _check_fields(self, data: dict, result: ValidationResult) -> None: f"{prefix}.options", f"Field '{fid}' is type '{ftype}' and must have a non-empty 'options' list", ) + elif isinstance(options, list): + for j, opt in enumerate(options): + opt_val = opt.get("value") if isinstance(opt, dict) else opt + if not isinstance(opt_val, str) or not opt_val.strip(): + result.add( + f"{prefix}.options[{j}]", + f"Field '{fid}' option at index {j} must be a non-empty string or object with non-empty 'value'", + ) if not f.get("help"): result.add_warning( @@ -322,10 +330,10 @@ def _check_validation_block(self, data: dict, result: ValidationResult) -> None: "Must contain at least two field ids", ) - for field_id in mutually_exclusive: - if field_id not in field_ids: + for j, field_id in enumerate(mutually_exclusive): + if not isinstance(field_id, str) or field_id not in field_ids: result.add( - f"{prefix}.mutually_exclusive", + f"{prefix}.mutually_exclusive[{j}]", f"Unknown field '{field_id}'", ) @@ -365,6 +373,13 @@ def _check_dependencies(self, data: dict, result: ValidationResult) -> None: python_packages = deps.get("python_packages") if python_packages is not None and not isinstance(python_packages, list): result.add("dependencies.python_packages", "Must be a list of strings") + elif isinstance(python_packages, list): + for i, pkg in enumerate(python_packages): + if not isinstance(pkg, str) or not pkg.strip(): + result.add( + f"dependencies.python_packages[{i}]", + "Each python package dependency must be a non-empty string", + ) def _check_custom_parser(self, data: dict, result: ValidationResult) -> None: output = data.get("output") 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/fixtures/plugins/nested_invalid_plugin/metadata.json b/testing/backend/unit/fixtures/plugins/nested_invalid_plugin/metadata.json new file mode 100644 index 000000000..b2812ce8f --- /dev/null +++ b/testing/backend/unit/fixtures/plugins/nested_invalid_plugin/metadata.json @@ -0,0 +1,60 @@ +{ + "id": "nested_invalid_plugin", + "name": "Nested Invalid Plugin Fixture", + "description": "Fixture with validation failures inside nested objects and lists.", + "version": "1.0.0", + "category": "utils", + "icon": "bug", + "engine": { + "type": "spaceship" + }, + "command_template": [ + "run", + "{mode}" + ], + "fields": [ + { + "id": "mode", + "label": "Mode", + "type": "select", + "help": "Select execution mode", + "options": [ + "fast", + "" + ] + }, + { + "id": "target", + "label": "Target Host", + "type": "text", + "help": "Target IP or domain" + } + ], + "output": { + "parser": "telepathy" + }, + "safety": { + "level": "apocalyptic", + "requires_consent": true + }, + "validation": { + "mode_rule": { + "required": "yes", + "mutually_exclusive": [ + "mode", + "ghost_field" + ] + } + }, + "dependencies": { + "binaries": [ + "nmap", + "" + ], + "python_packages": [ + "", + "requests" + ] + }, + "checksum": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +} diff --git a/testing/backend/unit/test_plugin_validator.py b/testing/backend/unit/test_plugin_validator.py index e153d972e..2710d421c 100644 --- a/testing/backend/unit/test_plugin_validator.py +++ b/testing/backend/unit/test_plugin_validator.py @@ -32,6 +32,7 @@ FIXTURES_DIR = Path(__file__).resolve().parent / "fixtures" / "plugins" VALID_FIXTURE = FIXTURES_DIR / "valid_plugin" INVALID_FIXTURE = FIXTURES_DIR / "invalid_plugin" +NESTED_INVALID_FIXTURE = FIXTURES_DIR / "nested_invalid_plugin" # --------------------------------------------------------------------------- @@ -795,3 +796,78 @@ def test_sandbox_timeout_terminates_hanging_parser(self): ) assert "timed out" in str(exc_info.value) + + +# =========================================================================== +# Nested object & array validation paths (#2164) +# =========================================================================== + + +class TestNestedObjectValidationPaths: + def test_nested_invalid_fixture_fails(self): + result = validate_one_plugin(NESTED_INVALID_FIXTURE) + assert not result.valid, "Nested invalid fixture should fail validation" + + def test_nested_invalid_fixture_reports_engine_type_path(self): + result = validate_one_plugin(NESTED_INVALID_FIXTURE) + assert "engine.type" in _error_paths(result) + + def test_nested_invalid_fixture_reports_safety_level_path(self): + result = validate_one_plugin(NESTED_INVALID_FIXTURE) + assert "safety.level" in _error_paths(result) + + def test_nested_invalid_fixture_reports_safety_consent_message_path(self): + result = validate_one_plugin(NESTED_INVALID_FIXTURE) + assert "safety.consent_message" in _error_paths(result) + + def test_nested_invalid_fixture_reports_output_parser_path(self): + result = validate_one_plugin(NESTED_INVALID_FIXTURE) + assert "output.parser" in _error_paths(result) + + def test_nested_invalid_fixture_reports_field_options_indexed_path(self): + result = validate_one_plugin(NESTED_INVALID_FIXTURE) + assert "fields[0].options[1]" in _error_paths(result) + + def test_nested_invalid_fixture_reports_dependencies_binaries_indexed_path(self): + result = validate_one_plugin(NESTED_INVALID_FIXTURE) + assert "dependencies.binaries[1]" in _error_paths(result) + + def test_nested_invalid_fixture_reports_dependencies_python_packages_indexed_path(self): + result = validate_one_plugin(NESTED_INVALID_FIXTURE) + assert "dependencies.python_packages[0]" in _error_paths(result) + + def test_nested_invalid_fixture_reports_validation_required_path(self): + result = validate_one_plugin(NESTED_INVALID_FIXTURE) + assert "validation.mode_rule.required" in _error_paths(result) + + def test_nested_invalid_fixture_reports_validation_mutually_exclusive_indexed_path(self): + result = validate_one_plugin(NESTED_INVALID_FIXTURE) + assert "validation.mode_rule.mutually_exclusive[1]" in _error_paths(result) + + def test_valid_nested_objects_accepted(self, tmp_path): + data = _minimal_valid() + data["dependencies"] = { + "binaries": ["ping"], + "python_packages": ["pytest"], + } + data["validation"] = { + "target_count": { + "required": True, + "mutually_exclusive": ["target", "count"], + } + } + plugin_dir = _write_metadata(tmp_path, data) + result = validate_one_plugin(plugin_dir) + + nested_paths = { + "engine.type", + "safety.level", + "output.parser", + "dependencies.binaries[0]", + "dependencies.python_packages[0]", + "validation.target_count.required", + "validation.target_count.mutually_exclusive[0]", + "validation.target_count.mutually_exclusive[1]", + } + reported = _error_paths(result) + assert not (reported & nested_paths), f"Unexpected errors on valid nested paths: {reported & nested_paths}"