Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions backend/secuscan/plugin_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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}'",
)

Expand Down Expand Up @@ -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")
Expand Down
27 changes: 4 additions & 23 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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"
}
76 changes: 76 additions & 0 deletions testing/backend/unit/test_plugin_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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}"
Loading