Skip to content

Commit

Permalink
BST-6121: Validate scan types (#41)
Browse files Browse the repository at this point in the history
Scanners now needs to define a scan types which needs to be one of the
predefined possible values. A scanner can have multiple, but at least
one is required.
  • Loading branch information
ledo01 authored Jun 21, 2023
1 parent adb9ae6 commit 46e7400
Show file tree
Hide file tree
Showing 16 changed files with 131 additions and 8 deletions.
8 changes: 8 additions & 0 deletions boostsec/registry_validator/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
13 changes: 13 additions & 0 deletions boostsec/registry_validator/schema.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down
3 changes: 3 additions & 0 deletions boostsec/registry_validator/testing/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
ModuleSchema,
RuleSchema,
RulesDbSchema,
ScanType,
ServerSideModuleSchema,
)

Expand All @@ -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]):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ api_version: 1.0

id: missing-namespace
name: Missing Namespace
scan_types:
- sast

config:
support_diff_scan: true
Expand Down
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
name: Simple Scanner
namespace: boostsecurityio/simple-scanner
scan_types:
- sast
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
name: Duplicate Module
namespace: invalids/duplicate-module

scan_types:
- sast
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
name: Duplicate Module
namespace: invalids/duplicate-module
scan_types:
- sast

Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
name: Empty Rules
namespace: invalids/empty-rules
scan_types:
- sast
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
name: Missing Namespace
scan_types:
- sast
52 changes: 45 additions & 7 deletions tests/unit/scanner/test_validate_namespaces.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -116,7 +126,7 @@ def test_validate_unique_namespace(
(
ModuleSchemaFactory.batch(2),
[faker.pystr(), faker.pystr()],
ModuleSchemaFactory.batch(1),
ScannerNamespaceFactory.batch(1),
True,
"",
),
Expand All @@ -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",
),
Expand All @@ -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",
),
Expand All @@ -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:
Expand All @@ -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
25 changes: 25 additions & 0 deletions tests/unit/test_errors.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
"""Errors unit tests."""

from enum import Enum
from typing import Any

import pytest

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"),
[
Expand All @@ -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",
Expand Down
17 changes: 17 additions & 0 deletions tests/unit/test_schema.py
Original file line number Diff line number Diff line change
@@ -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(
Expand Down

0 comments on commit 46e7400

Please sign in to comment.