diff --git a/boostsec/registry_validator/errors.py b/boostsec/registry_validator/errors.py index 398b0c5..f4b6321 100644 --- a/boostsec/registry_validator/errors.py +++ b/boostsec/registry_validator/errors.py @@ -10,6 +10,14 @@ def format_validation_error(e: dict[str, Any]) -> str: return f"{loc} is a required property" elif error_type == "value_error.extra": return f"Additional properties are not allowed ({loc} was unexpected)" + elif error_type == "type_error.enum": + permitted = [str(enum.value) for enum in e["ctx"]["enum_values"]] + return ( + f"{loc} has an invalid value; permitted values are: {', '.join(permitted)}" + ) + elif error_type == "value_error.list.min_items": + min_items = e["ctx"]["limit_value"] + return f"{loc}: at least {min_items} item is required" else: msg = e.get("msg", "unknown error") return f"{loc}: {msg}" diff --git a/boostsec/registry_validator/schema.py b/boostsec/registry_validator/schema.py index b27c675..e58f6e7 100644 --- a/boostsec/registry_validator/schema.py +++ b/boostsec/registry_validator/schema.py @@ -1,15 +1,28 @@ """Scanners & rules definition schemas.""" import os +from enum import Enum from typing import Any, Optional from pydantic import AnyHttpUrl, BaseModel, Field, validator +class ScanType(str, Enum): + """Security types that a scanner can claim it produces.""" + + CICD = "cicd" + METADATA = "metadata" + SAST = "sast" + SBOM = "sbom" + SCA = "sca" + SCA_CONTAINER = "sca_container" + + class ModuleBaseSchema(BaseModel): """Base for scanner modules.""" name: str namespace: str + scan_types: list[ScanType] = Field(..., min_items=1) class ModuleConfigSchema(BaseModel): diff --git a/boostsec/registry_validator/testing/factories.py b/boostsec/registry_validator/testing/factories.py index 949a6ad..f95cbdc 100644 --- a/boostsec/registry_validator/testing/factories.py +++ b/boostsec/registry_validator/testing/factories.py @@ -8,6 +8,7 @@ ModuleSchema, RuleSchema, RulesDbSchema, + ScanType, ServerSideModuleSchema, ) @@ -16,12 +17,14 @@ class ModuleSchemaFactory(ModelFactory[ModuleSchema]): """Factory.""" __model__ = ModuleSchema + scan_types = Use(lambda: cast(list[ScanType], [ScanType.SAST])) class ServerSideModuleSchemaFactory(ModelFactory[ServerSideModuleSchema]): """Factory.""" __model__ = ServerSideModuleSchema + scan_types = Use(lambda: cast(list[ScanType], [ScanType.SAST])) class RuleSchemaFactory(ModelFactory[RuleSchema]): diff --git a/tests/integration/samples/scanners/boostsecurityio/simple-scanner/module.yaml b/tests/integration/samples/scanners/boostsecurityio/simple-scanner/module.yaml index 348e45b..807aa44 100644 --- a/tests/integration/samples/scanners/boostsecurityio/simple-scanner/module.yaml +++ b/tests/integration/samples/scanners/boostsecurityio/simple-scanner/module.yaml @@ -3,6 +3,8 @@ api_version: 1.0 id: simple-scanner name: Simple Scanner namespace: boostsecurityio/simple-scanner +scan_types: + - sast config: support_diff_scan: true diff --git a/tests/integration/samples/scanners/invalids/duplicate-a/module.yaml b/tests/integration/samples/scanners/invalids/duplicate-a/module.yaml index 0e287ff..254edea 100644 --- a/tests/integration/samples/scanners/invalids/duplicate-a/module.yaml +++ b/tests/integration/samples/scanners/invalids/duplicate-a/module.yaml @@ -4,6 +4,8 @@ api_version: 1.0 id: duplicate-module name: Duplicate Module namespace: invalids/duplicate-module +scan_types: + - sast config: support_diff_scan: true diff --git a/tests/integration/samples/scanners/invalids/duplicate-b/module.yaml b/tests/integration/samples/scanners/invalids/duplicate-b/module.yaml index 0e287ff..254edea 100644 --- a/tests/integration/samples/scanners/invalids/duplicate-b/module.yaml +++ b/tests/integration/samples/scanners/invalids/duplicate-b/module.yaml @@ -4,6 +4,8 @@ api_version: 1.0 id: duplicate-module name: Duplicate Module namespace: invalids/duplicate-module +scan_types: + - sast config: support_diff_scan: true diff --git a/tests/integration/samples/scanners/invalids/empty-rules/module.yaml b/tests/integration/samples/scanners/invalids/empty-rules/module.yaml index d6a095a..e0a5f87 100644 --- a/tests/integration/samples/scanners/invalids/empty-rules/module.yaml +++ b/tests/integration/samples/scanners/invalids/empty-rules/module.yaml @@ -4,6 +4,8 @@ api_version: 1.0 id: empty-rules name: Empty Rules namespace: invalids/empty-rules +scan_types: + - sast config: support_diff_scan: true diff --git a/tests/integration/samples/scanners/invalids/missing-namespace/module.yaml b/tests/integration/samples/scanners/invalids/missing-namespace/module.yaml index cef177b..183b510 100644 --- a/tests/integration/samples/scanners/invalids/missing-namespace/module.yaml +++ b/tests/integration/samples/scanners/invalids/missing-namespace/module.yaml @@ -3,6 +3,8 @@ api_version: 1.0 id: missing-namespace name: Missing Namespace +scan_types: + - sast config: support_diff_scan: true diff --git a/tests/integration/samples/server-side-scanners/boostsecurityio/simple-scanner/module.yaml b/tests/integration/samples/server-side-scanners/boostsecurityio/simple-scanner/module.yaml index bc6cab5..34ff66a 100644 --- a/tests/integration/samples/server-side-scanners/boostsecurityio/simple-scanner/module.yaml +++ b/tests/integration/samples/server-side-scanners/boostsecurityio/simple-scanner/module.yaml @@ -1,2 +1,4 @@ name: Simple Scanner namespace: boostsecurityio/simple-scanner +scan_types: + - sast diff --git a/tests/integration/samples/server-side-scanners/invalids/duplicate-a/module.yaml b/tests/integration/samples/server-side-scanners/invalids/duplicate-a/module.yaml index aa1da80..0170966 100644 --- a/tests/integration/samples/server-side-scanners/invalids/duplicate-a/module.yaml +++ b/tests/integration/samples/server-side-scanners/invalids/duplicate-a/module.yaml @@ -1,3 +1,4 @@ name: Duplicate Module namespace: invalids/duplicate-module - +scan_types: + - sast diff --git a/tests/integration/samples/server-side-scanners/invalids/duplicate-b/module.yaml b/tests/integration/samples/server-side-scanners/invalids/duplicate-b/module.yaml index aa1da80..e05b002 100644 --- a/tests/integration/samples/server-side-scanners/invalids/duplicate-b/module.yaml +++ b/tests/integration/samples/server-side-scanners/invalids/duplicate-b/module.yaml @@ -1,3 +1,5 @@ name: Duplicate Module namespace: invalids/duplicate-module +scan_types: + - sast diff --git a/tests/integration/samples/server-side-scanners/invalids/empty-rules/module.yaml b/tests/integration/samples/server-side-scanners/invalids/empty-rules/module.yaml index dfa1d49..71e0311 100644 --- a/tests/integration/samples/server-side-scanners/invalids/empty-rules/module.yaml +++ b/tests/integration/samples/server-side-scanners/invalids/empty-rules/module.yaml @@ -1,2 +1,4 @@ name: Empty Rules namespace: invalids/empty-rules +scan_types: + - sast diff --git a/tests/integration/samples/server-side-scanners/invalids/missing-namespace/module.yaml b/tests/integration/samples/server-side-scanners/invalids/missing-namespace/module.yaml index 905b08c..591ab6d 100644 --- a/tests/integration/samples/server-side-scanners/invalids/missing-namespace/module.yaml +++ b/tests/integration/samples/server-side-scanners/invalids/missing-namespace/module.yaml @@ -1 +1,3 @@ name: Missing Namespace +scan_types: + - sast diff --git a/tests/unit/scanner/test_validate_namespaces.py b/tests/unit/scanner/test_validate_namespaces.py index 1333987..d511d99 100644 --- a/tests/unit/scanner/test_validate_namespaces.py +++ b/tests/unit/scanner/test_validate_namespaces.py @@ -1,14 +1,22 @@ """Test.""" from functools import partial from pathlib import Path +from typing import Any from uuid import uuid4 import pytest import yaml from faker import Faker -from boostsec.registry_validator.schema import ModuleSchema -from boostsec.registry_validator.testing.factories import ModuleSchemaFactory +from boostsec.registry_validator.schema import ( + ModuleSchema, + ScanType, + ServerSideModuleSchema, +) +from boostsec.registry_validator.testing.factories import ( + ModuleSchemaFactory, + ScannerNamespaceFactory, +) from boostsec.registry_validator.validate_namespaces import ( find_module_yaml, find_rules_realm_namespace, @@ -21,7 +29,7 @@ faker = Faker() -def _create_module_yaml(tmp_path: Path, namespace: str = "") -> None: +def _create_module_yaml(tmp_path: Path, namespace: str = "", **extra: Any) -> None: """Create a module.yaml file.""" modules_path = tmp_path / uuid4().hex modules_path.mkdir() @@ -34,6 +42,8 @@ def _create_module_yaml(tmp_path: Path, namespace: str = "") -> None: "name": "Example Sarif Scanner", "config": {"support_diff_scan": True}, "steps": ["step"], + "scan_types": ["sast"], + **extra, } if namespace: module_obj["namespace"] = namespace @@ -116,7 +126,7 @@ def test_validate_unique_namespace( ( ModuleSchemaFactory.batch(2), [faker.pystr(), faker.pystr()], - ModuleSchemaFactory.batch(1), + ScannerNamespaceFactory.batch(1), True, "", ), @@ -137,7 +147,7 @@ def test_validate_unique_namespace( ( [], [], - ModuleSchemaFactory.batch(2, namespace="a"), + ScannerNamespaceFactory.batch(2, namespace="a"), False, "ERROR: namespaces are not unique, duplicate: a\n", ), @@ -151,7 +161,7 @@ def test_validate_unique_namespace( ( ModuleSchemaFactory.batch(1, namespace="a"), [], - ModuleSchemaFactory.batch(1, namespace="a"), + ScannerNamespaceFactory.batch(1, namespace="a"), False, "ERROR: namespaces are not unique, duplicate: a\n", ), @@ -161,7 +171,7 @@ def test_validate_namespaces( capfd: pytest.CaptureFixture[str], modules: list[ModuleSchema], rules_realms: list[str], - server_modules: list[ModuleSchema], + server_modules: list[ServerSideModuleSchema], unique: bool, expected: str, ) -> None: @@ -188,3 +198,31 @@ def test_validate_namespaces_without_namespace( [validate_module_yaml_schema(module) for module in modules_path] out, _ = capfd.readouterr() assert "module.yaml is invalid: namespace is a required property" in out + + +@pytest.mark.parametrize( + ("scan_types", "expected"), + [ + ([], "module.yaml is invalid: scan_types: at least 1 item is required"), + ( + ["unknown"], + ( + "module.yaml is invalid: scan_types.0 has an invalid value; " + f"permitted values are: {', '.join(ScanType)}" + ), + ), + ], +) +def test_validate_module_invalid_scan_types( + tmp_path: Path, + capfd: pytest.CaptureFixture[str], + scan_types: list[str], + expected: str, +) -> None: + """Test should reject invalid scan types & print helpful error.""" + _create_module_yaml(tmp_path, namespace="a", scan_types=scan_types) + modules_path = find_module_yaml(tmp_path) + with pytest.raises(SystemExit): + [validate_module_yaml_schema(module) for module in modules_path] + out, _ = capfd.readouterr() + assert expected in out diff --git a/tests/unit/test_errors.py b/tests/unit/test_errors.py index 9eb6a45..37c47c2 100644 --- a/tests/unit/test_errors.py +++ b/tests/unit/test_errors.py @@ -1,5 +1,6 @@ """Errors unit tests.""" +from enum import Enum from typing import Any import pytest @@ -7,6 +8,14 @@ from boostsec.registry_validator.errors import format_validation_error +class DummyEnum(str, Enum): + """Dummy enum for testing.""" + + A = "a" + B = "b" + C = "c" + + @pytest.mark.parametrize( ("error", "expected"), [ @@ -18,6 +27,22 @@ {"loc": ["field"], "type": "value_error.extra"}, "Additional properties are not allowed (field was unexpected)", ), + ( + { + "loc": ["field", "0"], + "type": "type_error.enum", + "ctx": {"enum_values": list(DummyEnum)}, + }, + "field.0 has an invalid value; permitted values are: a, b, c", + ), + ( + { + "loc": ["field"], + "type": "value_error.list.min_items", + "ctx": {"limit_value": 1}, + }, + "field: at least 1 item is required", + ), ( {"loc": ["field"], "type": "not-handled"}, "field: unknown error", diff --git a/tests/unit/test_schema.py b/tests/unit/test_schema.py index 94e4553..0f12e9e 100644 --- a/tests/unit/test_schema.py +++ b/tests/unit/test_schema.py @@ -1,16 +1,33 @@ """Test for scanners & rules schemas.""" +from typing import Optional + import pytest from _pytest.monkeypatch import MonkeyPatch from pydantic import ValidationError +from boostsec.registry_validator.schema import ScanType from boostsec.registry_validator.testing.factories import ( + ModuleSchemaFactory, RuleSchemaFactory, RulesDbSchemaFactory, ) +def test_validate_scan_types() -> None: + """Test all scan types can be parsed.""" + module = ModuleSchemaFactory.build(scan_types=[t.value for t in ScanType]) + assert module.scan_types == list(ScanType) + + +@pytest.mark.parametrize("scan_types", [None, ["unknown"]]) +def test_validate_invalid_scan_types(scan_types: Optional[list[str]]) -> None: + """Should reject invalid types or if types are missing.""" + with pytest.raises(ValidationError): + ModuleSchemaFactory.build(scan_types=scan_types) + + def test_validate_rule_name_with_valid_name() -> None: """Test that each rule name matches its id.""" RulesDbSchemaFactory.build(