import asyncio
import json
import logging
import os
import time
from collections import namedtuple
from datetime import date, datetime, timedelta
from functools import wraps
from ipaddress import IPv4Network, IPv6Network, ip_network
from pathlib import Path
import jsonschema
from defence360agent.rpc_tools.validate import (
SchemaValidator as SchemaValidatorBase,
)
from defence360agent.rpc_tools.validate import validate
from im360.contracts.config import Webshield
from im360.internals.core import IPSetPort, libipset
from defence360agent.utils.validate import IP
logger = logging.getLogger(__name__)
TODAY = "today"
YESTERDAY = "yesterday"
PortProtoBase = namedtuple("PortProtoBase", ["port", "proto"])
PeriodBase = namedtuple("PeriodBase", ["since", "to"])
class PortProto(PortProtoBase):
def __new__(cls, port, proto):
if proto not in IPSetPort.PROTOS:
raise ValueError("Protocol {} is not supported".format(proto))
if not (IPSetPort.MIN_PORT < port < IPSetPort.MAX_PORT):
raise ValueError("Port {} is incorrect".format(port))
return super().__new__(cls, port, proto)
@classmethod
def fromstring(cls, pp_string):
try:
port, proto = pp_string.split(":")
port = int(port)
return cls(port, proto)
except ValueError as e:
raise ValueError(
"Incorrect port_proto ({}): {}".format(str(e), pp_string)
)
class Period(PeriodBase):
def __new__(cls, since, to):
try:
datetime.fromtimestamp(since), datetime.fromtimestamp(to)
except ValueError as e:
raise ValueError("Incorrect value for period: {}".format(str(e)))
return super().__new__(cls, since, to)
@classmethod
def fromstring(cls, period_string):
now = datetime.now()
seconds_since_midnight = (
now - now.replace(hour=0, minute=0, second=0, microsecond=0)
).total_seconds()
if period_string == TODAY:
since, to = time.time() - seconds_since_midnight, time.time()
elif period_string == YESTERDAY:
from_date = (
time.time()
- seconds_since_midnight
- timedelta(days=1).total_seconds()
)
to_date = time.time() - seconds_since_midnight
since, to = from_date, to_date
else:
period_names = "weeks", "days", "hours", "minutes", "seconds"
try:
val, sfx = int(period_string[:-1]), period_string[-1:]
except (ValueError, IndexError) as e:
raise ValueError(
"Invalid string from period: {} ({})".format(
period_string, str(e)
)
)
if not sfx.endswith(tuple(p_name[0] for p_name in period_names)):
# argparse will handle this exception
raise ValueError("Invalid suffix: {}".format(sfx))
sfx_expanded = next(xp for xp in period_names if sfx == xp[0])
real_args = {sfx_expanded: val}
since, to = (
(datetime.now() - timedelta(**real_args)).timestamp(),
time.time(),
)
return cls(since, to)
class SchemaValidator(SchemaValidatorBase):
MAX_IPSET_TIMEOUT = libipset.IPSET_TIMEOUT_MAX # ipset's maximum ttl
def _normalize_coerce_port_proto(self, value):
if isinstance(value, PortProto):
return value
elif isinstance(value, str):
return PortProto.fromstring(value)
raise ValueError("String or PortProto must be provided")
def _normalize_coerce_ip(self, value):
if isinstance(value, (IPv4Network, IPv6Network)):
return value
elif isinstance(value, str):
return ip_network(value)
def _normalize_coerce_ip_discard_host_bits(self, value):
if isinstance(value, (IPv4Network, IPv6Network)):
return value
elif isinstance(value, str):
return ip_network(value, strict=False)
def _normalize_coerce_period(self, value):
if isinstance(value, Period):
return value
elif isinstance(value, str):
return Period.fromstring(value)
raise ValueError("String or Period must be provided")
def _normalize_coerce_tolower(self, value):
# please, don't try to casefold() instead of lower()
# see https://tools.ietf.org/html/rfc4343
return value.lower()
def _validate_type_port_proto(self, value):
if isinstance(value, PortProto):
return True
return False
def _validate_type_period(self, value):
if isinstance(value, Period):
return True
return False
def _validate_type_ip(self, value):
return isinstance(value, (IPv4Network, IPv6Network))
def _validator_enforce64min_subnet_mask_for_ipv6(self, field, value):
if IP.is_valid_ipv6_network(value):
# 64 - min subnet mask for ipv6 addr
if IPv6Network(value).prefixlen > 64:
self._error(
field, "Supported only ipv6 /64 networks: {}".format(value)
)
def _validator_max_days(self, field, value):
max_days = timedelta.max.days
max_past = date.today() - date(1970, 1, 1)
if value > max_days or timedelta(days=value) > max_past:
self._error(
field,
"Number of days ({}) exceeds maximum value of {}. "
"Please, specify lesser amount of days".format(
value, max_past.days
),
)
def _validator_timestamp(self, field, value):
try:
datetime.fromtimestamp(value)
except ValueError as e:
self._error(
field, "Incorrect timestamp: {} ({})".format(value, str(e))
)
def _validator_expiration(self, field, value):
if not value:
return
expiration_time = value
now = time.time()
if expiration_time <= now:
self._error(
field,
"Expiration contains expired timestamp {}!".format(
time.strftime("%x %X %Z", time.gmtime(expiration_time))
),
)
max_expiration_time = now + self.MAX_IPSET_TIMEOUT
if expiration_time > max_expiration_time:
self._error(
field,
(
"Expiration time {} is too far into the future."
" It is more than {} seconds from now"
).format(expiration_time, self.MAX_IPSET_TIMEOUT),
)
def _validator_webshield_is_enabled(self, field, value):
if not Webshield.ENABLE:
self._error(
field,
"This command is not supported when webshield is disabled",
)
def validate_middleware(validator):
base = Path(os.path.dirname(__file__)) / "../.."
core_schemas = base / "defence360agent/simple_rpc/schema_responses/another"
imav_schemas = base / "imav/simple_rpc/schema_responses/another"
im360_schemas = base / "im360/simple_rpc/schema_responses/another"
def get_file_from_schema_responses_dirs(filename):
core_schema_path = core_schemas.with_name(filename)
if core_schema_path.exists():
return core_schema_path
imav_schema_path = imav_schemas.with_name(filename)
if imav_schema_path.exists():
return imav_schema_path
return im360_schemas.with_name(filename)
def get_response_schema(return_type):
# asserts that return_type does not contain '/'
schema_path = get_file_from_schema_responses_dirs(
return_type + ".json"
)
with schema_path.open("r") as f:
schema = json.load(f)
return schema
async def validate_response(hashable, result):
return_type = validator.schema.get(hashable).get("return_type", None)
if return_type is None:
return
schema = get_response_schema(return_type)
# validation should be performed after
# result gets such format (ui accepts it)
# in some cases result never gets such format
# like test_addmany_invalid_request
target = {"result": "success", "messages": [], "data": result}
try:
jsonschema.validate(target, schema)
except jsonschema.ValidationError as error:
logger.critical(
'Validating %r using schema %r failed with error "%s".',
target,
schema,
error,
exc_info=error,
)
def wrapped(f):
@wraps(f)
async def wrapper(request, *args, **kwargs):
hashable = tuple(request["command"])
request["params"] = validate(
validator, hashable, request["params"]
)
result = await f(request, *args, **kwargs)
# no cpu overhead during rpc request
# since validation is asynchronous
# (only next request may be delayed a little)
# run_until_complete waits until this task will be finished (why?)
# so test will be failed
asyncio.ensure_future(validate_response(hashable, result))
return result
return wrapper
return wrapped
|