diff --git a/.bandit-test.yml b/.bandit-test.yml index 9aa53cbd..bd69c522 100644 --- a/.bandit-test.yml +++ b/.bandit-test.yml @@ -1 +1 @@ -skips: ['B101', 'B105', 'B106', 'B404'] \ No newline at end of file +skips: ['B101', 'B104', 'B105', 'B106', 'B404'] diff --git a/docs/configuration.rst b/docs/configuration.rst index 98aea927..4568ce60 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -69,7 +69,7 @@ Build related configuration. Group name can refer to architecture, e.g. ``build: * ``makepkg_flags`` - additional flags passed to ``makepkg`` command, space separated list of strings, optional. * ``makechrootpkg_flags`` - additional flags passed to ``makechrootpkg`` command, space separated list of strings, optional. * ``triggers`` - list of ``ahriman.core.triggers.Trigger`` class implementation (e.g. ``ahriman.core.report.ReportTrigger ahriman.core.upload.UploadTrigger``) which will be loaded and run at the end of processing, space separated list of strings, optional. You can also specify triggers by their paths, e.g. ``/usr/lib/python3.10/site-packages/ahriman/core/report/report.py.ReportTrigger``. Triggers are run in the order of mention. -* ``vcs_allowed_age`` - maximal age in seconds of the VCS packages before their version will be updated with its remote source, int, optional, default ``0``. +* ``vcs_allowed_age`` - maximal age in seconds of the VCS packages before their version will be updated with its remote source, int, optional, default ``604800``. ``repository`` group -------------------- diff --git a/src/ahriman/application/handlers/validate.py b/src/ahriman/application/handlers/validate.py index c78bf900..788f3fcd 100644 --- a/src/ahriman/application/handlers/validate.py +++ b/src/ahriman/application/handlers/validate.py @@ -52,7 +52,7 @@ class Validate(Handler): unsafe(bool): if set no user check will be performed before path creation """ schema = Validate.schema(architecture, configuration) - validator = Validator(instance=configuration, schema=schema) + validator = Validator(configuration=configuration, schema=schema) if validator.validate(configuration.dump()): return # no errors found diff --git a/src/ahriman/core/configuration/schema.py b/src/ahriman/core/configuration/schema.py index 202ec369..3ca3a422 100644 --- a/src/ahriman/core/configuration/schema.py +++ b/src/ahriman/core/configuration/schema.py @@ -64,6 +64,7 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = { "mirror": { "type": "string", "required": True, + "is_url": [], }, "repositories": { "type": "list", @@ -111,10 +112,13 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = { }, "cookie_secret_key": { "type": "string", + "minlength": 32, + "maxlength": 64, # we cannot verify maxlength, because base64 representation might be longer than bytes }, "max_age": { "type": "integer", "coerce": "integer", + "min": 0, }, "oauth_provider": { "type": "string", @@ -162,6 +166,7 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = { "vcs_allowed_age": { "type": "integer", "coerce": "integer", + "min": 0, }, }, }, @@ -204,6 +209,7 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = { "schema": { "address": { "type": "string", + "is_url": ["http", "https"], }, "debug": { "type": "boolean", @@ -220,9 +226,11 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = { }, "host": { "type": "string", + "is_ip_address": ["localhost"], }, "index_url": { "type": "string", + "is_url": ["http", "https"], }, "password": { "type": "string", @@ -258,44 +266,4 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = { }, }, }, - "remote-pull": { - "type": "dict", - "schema": { - "target": { - "type": "list", - "coerce": "list", - "schema": {"type": "string"}, - }, - }, - }, - "remote-push": { - "type": "dict", - "schema": { - "target": { - "type": "list", - "coerce": "list", - "schema": {"type": "string"}, - }, - }, - }, - "report": { - "type": "dict", - "schema": { - "target": { - "type": "list", - "coerce": "list", - "schema": {"type": "string"}, - }, - }, - }, - "upload": { - "type": "dict", - "schema": { - "target": { - "type": "list", - "coerce": "list", - "schema": {"type": "string"}, - }, - }, - }, } diff --git a/src/ahriman/core/configuration/validator.py b/src/ahriman/core/configuration/validator.py index 1f895119..d4e764cb 100644 --- a/src/ahriman/core/configuration/validator.py +++ b/src/ahriman/core/configuration/validator.py @@ -17,9 +17,12 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . # +import ipaddress + from cerberus import TypeDefinition, Validator as RootValidator # type: ignore from pathlib import Path from typing import Any, List +from urllib.parse import urlparse from ahriman.core.configuration import Configuration @@ -29,7 +32,7 @@ class Validator(RootValidator): # type: ignore class which defines custom validation methods for the service configuration Attributes: - instance(Configuration): configuration instance + configuration(Configuration): configuration instance """ types_mapping = RootValidator.types_mapping.copy() @@ -40,12 +43,12 @@ class Validator(RootValidator): # type: ignore default constructor Args: - instance(Configuration): configuration instance used for extraction + configuration(Configuration): configuration instance used for extraction *args(Any): positional arguments to be passed to base validator **kwargs(): keyword arguments to be passed to base validator """ RootValidator.__init__(self, *args, **kwargs) - self.instance: Configuration = kwargs["instance"] + self.configuration: Configuration = kwargs["configuration"] def _normalize_coerce_absolute_path(self, value: str) -> Path: """ @@ -57,7 +60,7 @@ class Validator(RootValidator): # type: ignore Returns: Path: value converted to path instance according to configuration rules """ - converted: Path = self.instance.converters["path"](value) + converted: Path = self.configuration.converters["path"](value) return converted def _normalize_coerce_boolean(self, value: str) -> bool: @@ -71,7 +74,7 @@ class Validator(RootValidator): # type: ignore bool: value converted to boolean according to configuration rules """ # pylint: disable=protected-access - converted: bool = self.instance._convert_to_boolean(value) # type: ignore + converted: bool = self.configuration._convert_to_boolean(value) # type: ignore return converted def _normalize_coerce_integer(self, value: str) -> int: @@ -97,9 +100,50 @@ class Validator(RootValidator): # type: ignore Returns: List[str]: value converted to string list instance according to configuration rules """ - converted: List[str] = self.instance.converters["list"](value) + converted: List[str] = self.configuration.converters["list"](value) return converted + def _validate_is_ip_address(self, constraint: List[str], field: str, value: str) -> None: + """ + check if the specified value is valid ip address + + Args: + constraint(List[str]): optional list of allowed special words (e.g. ``localhost``) + field(str): field name to be checked + value(Path): value to be checked + + Examples: + The rule's arguments are validated against this schema: + {"type": "list", "schema": {"type": "string"}} + """ + if value in constraint: + return + try: + ipaddress.ip_address(value) + except ValueError: + self._error(field, f"Value {value} must be valid IP address") + + def _validate_is_url(self, constraint: List[str], field: str, value: str) -> None: + """ + check if the specified value is a valid url + + Args: + constraint(List[str]): optional list of supported schemas. If empty, no schema validation will be performed + field(str): field name to be checked + value(str): value to be checked + + Examples: + The rule's arguments are validated against this schema: + {"type": "list", "schema": {"type": "string"}} + """ + url = urlparse(value) # it probably will never rise exceptions on parse + if not url.scheme: + self._error(field, f"Url scheme is not set for {value}") + if not url.netloc and url.scheme not in ("file",): + self._error(field, f"Location must be set for url {value} of scheme {url.scheme}") + if constraint and url.scheme not in constraint: + self._error(field, f"Url {value} scheme must be one of {constraint}") + def _validate_path_exists(self, constraint: bool, field: str, value: Path) -> None: """ check if paths exists diff --git a/src/ahriman/core/gitremote/remote_pull_trigger.py b/src/ahriman/core/gitremote/remote_pull_trigger.py index 0e97c29e..ed757dd5 100644 --- a/src/ahriman/core/gitremote/remote_pull_trigger.py +++ b/src/ahriman/core/gitremote/remote_pull_trigger.py @@ -33,6 +33,16 @@ class RemotePullTrigger(Trigger): """ CONFIGURATION_SCHEMA = { + "remote-pull": { + "type": "dict", + "schema": { + "target": { + "type": "list", + "coerce": "list", + "schema": {"type": "string"}, + }, + }, + }, "gitremote": { "type": "dict", "schema": { diff --git a/src/ahriman/core/gitremote/remote_push_trigger.py b/src/ahriman/core/gitremote/remote_push_trigger.py index b2d89420..159adccb 100644 --- a/src/ahriman/core/gitremote/remote_push_trigger.py +++ b/src/ahriman/core/gitremote/remote_push_trigger.py @@ -38,6 +38,16 @@ class RemotePushTrigger(Trigger): """ CONFIGURATION_SCHEMA = { + "remote-push": { + "type": "dict", + "schema": { + "target": { + "type": "list", + "coerce": "list", + "schema": {"type": "string"}, + }, + }, + }, "gitremote": { "type": "dict", "schema": { diff --git a/src/ahriman/core/report/report_trigger.py b/src/ahriman/core/report/report_trigger.py index d93c7aa2..d9c37bbf 100644 --- a/src/ahriman/core/report/report_trigger.py +++ b/src/ahriman/core/report/report_trigger.py @@ -35,6 +35,16 @@ class ReportTrigger(Trigger): """ CONFIGURATION_SCHEMA = { + "report": { + "type": "dict", + "schema": { + "target": { + "type": "list", + "coerce": "list", + "schema": {"type": "string"}, + }, + }, + }, "console": { "type": "dict", "schema": { @@ -62,6 +72,7 @@ class ReportTrigger(Trigger): }, "homepage": { "type": "string", + "is_url": ["http", "https"], }, "host": { "type": "string", @@ -70,6 +81,7 @@ class ReportTrigger(Trigger): "link_path": { "type": "string", "required": True, + "is_url": [], }, "no_empty_report": { "type": "boolean", @@ -82,6 +94,8 @@ class ReportTrigger(Trigger): "type": "integer", "coerce": "integer", "required": True, + "min": 0, + "max": 65535, }, "receivers": { "type": "list", @@ -118,10 +132,12 @@ class ReportTrigger(Trigger): }, "homepage": { "type": "string", + "is_url": ["http", "https"], }, "link_path": { "type": "string", "required": True, + "is_url": [], }, "path": { "type": "path", @@ -153,10 +169,12 @@ class ReportTrigger(Trigger): }, "homepage": { "type": "string", + "is_url": ["http", "https"], }, "link_path": { "type": "string", "required": True, + "is_url": [], }, "template_path": { "type": "path", @@ -171,6 +189,7 @@ class ReportTrigger(Trigger): "timeout": { "type": "integer", "coerce": "integer", + "min": 0, }, }, }, diff --git a/src/ahriman/core/upload/upload_trigger.py b/src/ahriman/core/upload/upload_trigger.py index e0dddcb7..88450722 100644 --- a/src/ahriman/core/upload/upload_trigger.py +++ b/src/ahriman/core/upload/upload_trigger.py @@ -35,6 +35,16 @@ class UploadTrigger(Trigger): """ CONFIGURATION_SCHEMA = { + "upload": { + "type": "dict", + "schema": { + "target": { + "type": "list", + "coerce": "list", + "schema": {"type": "string"}, + }, + }, + }, "github": { "type": "dict", "schema": { @@ -57,6 +67,7 @@ class UploadTrigger(Trigger): "timeout": { "type": "integer", "coerce": "integer", + "min": 0, }, "username": { "type": "string", @@ -101,6 +112,7 @@ class UploadTrigger(Trigger): "chunk_size": { "type": "integer", "coerce": "integer", + "min": 0, }, "region": { "type": "string", diff --git a/tests/ahriman/application/handlers/test_handler_validate.py b/tests/ahriman/application/handlers/test_handler_validate.py index 57bb10fc..a488bd90 100644 --- a/tests/ahriman/application/handlers/test_handler_validate.py +++ b/tests/ahriman/application/handlers/test_handler_validate.py @@ -62,9 +62,11 @@ def test_schema(configuration: Configuration) -> None: assert schema.pop("email") assert schema.pop("github") assert schema.pop("html") + assert schema.pop("report") assert schema.pop("rsync") assert schema.pop("s3") assert schema.pop("telegram") + assert schema.pop("upload") assert schema == CONFIGURATION_SCHEMA diff --git a/tests/ahriman/core/configuration/conftest.py b/tests/ahriman/core/configuration/conftest.py index 4cbc47b6..aa01c904 100644 --- a/tests/ahriman/core/configuration/conftest.py +++ b/tests/ahriman/core/configuration/conftest.py @@ -16,4 +16,4 @@ def validator(configuration: Configuration) -> Validator: Returns: Validator: validator test instance """ - return Validator(instance=configuration, schema=CONFIGURATION_SCHEMA) + return Validator(configuration=configuration, schema=CONFIGURATION_SCHEMA) diff --git a/tests/ahriman/core/configuration/test_validator.py b/tests/ahriman/core/configuration/test_validator.py index 3ff52daf..db4bb34e 100644 --- a/tests/ahriman/core/configuration/test_validator.py +++ b/tests/ahriman/core/configuration/test_validator.py @@ -1,6 +1,6 @@ from pathlib import Path from pytest_mock import MockerFixture -from unittest.mock import MagicMock +from unittest.mock import MagicMock, call as MockCall from ahriman.core.configuration.validator import Validator @@ -18,7 +18,7 @@ def test_normalize_coerce_absolute_path(validator: Validator) -> None: must convert string value to path by using configuration converters """ convert_mock = MagicMock() - validator.instance.converters["path"] = convert_mock + validator.configuration.converters["path"] = convert_mock validator._normalize_coerce_absolute_path("value") convert_mock.assert_called_once_with("value") @@ -46,12 +46,56 @@ def test_normalize_coerce_list(validator: Validator) -> None: must convert string value to list by using configuration converters """ convert_mock = MagicMock() - validator.instance.converters["list"] = convert_mock + validator.configuration.converters["list"] = convert_mock validator._normalize_coerce_list("value") convert_mock.assert_called_once_with("value") +def test_validate_is_ip_address(validator: Validator, mocker: MockerFixture) -> None: + """ + must validate addresses correctly + """ + error_mock = mocker.patch("ahriman.core.configuration.validator.Validator._error") + + validator._validate_is_ip_address(["localhost"], "field", "localhost") + validator._validate_is_ip_address([], "field", "localhost") + + validator._validate_is_ip_address([], "field", "127.0.0.1") + validator._validate_is_ip_address([], "field", "::") + validator._validate_is_ip_address([], "field", "0.0.0.0") + + validator._validate_is_ip_address([], "field", "random string") + + error_mock.assert_has_calls([ + MockCall("field", "Value localhost must be valid IP address"), + MockCall("field", "Value random string must be valid IP address"), + ]) + + +def test_validate_is_url(validator: Validator, mocker: MockerFixture) -> None: + """ + must validate url correctly + """ + error_mock = mocker.patch("ahriman.core.configuration.validator.Validator._error") + + validator._validate_is_url([], "field", "http://example.com") + validator._validate_is_url([], "field", "https://example.com") + validator._validate_is_url([], "field", "file:///tmp") + + validator._validate_is_url(["http", "https"], "field", "file:///tmp") + + validator._validate_is_url([], "field", "http:///path") + + validator._validate_is_url([], "field", "random string") + + error_mock.assert_has_calls([ + MockCall("field", "Url file:///tmp scheme must be one of ['http', 'https']"), + MockCall("field", "Location must be set for url http:///path of scheme http"), + MockCall("field", "Url scheme is not set for random string"), + ]) + + def test_validate_path_exists(validator: Validator, mocker: MockerFixture) -> None: """ must validate that paths exists @@ -67,4 +111,6 @@ def test_validate_path_exists(validator: Validator, mocker: MockerFixture) -> No mocker.patch("pathlib.Path.exists", return_value=True) validator._validate_path_exists(True, "field", Path("3")) - error_mock.assert_called_once_with("field", "Path 2 must exist") + error_mock.assert_has_calls([ + MockCall("field", "Path 2 must exist"), + ])