emfuzzer 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
emfuzzer/__init__.py ADDED
@@ -0,0 +1,62 @@
1
+ # Copyright (c) 2025 Warsaw University of Technology
2
+ # This file is licensed under the MIT License.
3
+ # See the LICENSE.txt file in the root of the repository for full details.
4
+
5
+ """
6
+ Main module of the application.
7
+ """
8
+
9
+ import json
10
+ import logging
11
+
12
+ from .arguments import Arguments
13
+ from .case import Case
14
+ from .config import Config
15
+ from .context import Context
16
+ from .delay import Delay
17
+ from .injector import Injector
18
+ from .results import Results
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ def run(args: Arguments, config: Config) -> int:
24
+ results = Results(config)
25
+
26
+ with Context(config) as context:
27
+ case = Case.from_config(context=context, results=results)
28
+
29
+ injector = Injector.from_config(results=results, context=context)
30
+
31
+ delay_between_cases = Delay.from_config(
32
+ "delays", "between_cases", config=config
33
+ )
34
+ delay_before_inject = Delay.from_config(
35
+ "delays", "before_inject", config=config
36
+ )
37
+
38
+ for path in args.data:
39
+ logger.info(f"Opening {path}")
40
+ with path.open("rb") as file:
41
+ data = file.read()
42
+ if len(data) == 0:
43
+ logger.warning(f"No data found, skipping {path}")
44
+ continue
45
+
46
+ case_name = str(path)
47
+ results.add_key(case_name)
48
+
49
+ with case.execute(case_name):
50
+ delay_before_inject.wait()
51
+ injector.inject(case_name, data)
52
+
53
+ delay_between_cases.wait()
54
+
55
+ results.finish()
56
+ logger.info(f"Results:\n {results.summary()}")
57
+
58
+ with open(args.output_prefix + ".json", "w", encoding="utf-8") as f:
59
+ json.dump(results.to_dict(), f, indent=2)
60
+ f.write("\n")
61
+
62
+ return results.total_errors()
emfuzzer/__main__.py ADDED
@@ -0,0 +1,92 @@
1
+ # Copyright (c) 2025 Warsaw University of Technology
2
+ # This file is licensed under the MIT License.
3
+ # See the LICENSE.txt file in the root of the repository for full details.
4
+
5
+ """
6
+ Main entry point to the application.
7
+ """
8
+
9
+ import argparse
10
+ import logging
11
+ import sys
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+
15
+ from . import run
16
+ from .arguments import Arguments
17
+ from .config import Config
18
+ from .version import VERSION
19
+
20
+
21
+ def __parse_data(parser: argparse.ArgumentParser, data: list[str]) -> list[Path]:
22
+ result = [Path(f) for f in data]
23
+ for f in result:
24
+ if not f.is_file():
25
+ parser.error(f"Specified path is not a file: {f}")
26
+ if len(result) != len(set(result)):
27
+ parser.error("Non-unique file names as inputs - results would be inconsistent")
28
+ return result
29
+
30
+
31
+ def __setup_logger(prefix: str) -> None:
32
+ log_format = "%(asctime)s [%(levelname)8s](%(name)20s): %(message)s"
33
+ logging.basicConfig(
34
+ level=logging.DEBUG,
35
+ format=log_format,
36
+ )
37
+
38
+ root_logger = logging.getLogger()
39
+ handler = logging.FileHandler(f"{prefix}.log")
40
+ handler.setFormatter(root_logger.handlers[0].formatter)
41
+ logging.getLogger().addHandler(handler)
42
+
43
+ root_logger.info(f"Started instance ({VERSION})")
44
+
45
+
46
+ def parse_args() -> Arguments:
47
+ parser = argparse.ArgumentParser(
48
+ prog="emfuzzer",
49
+ description="Fuzzing experiments orchestrator for embedded",
50
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
51
+ )
52
+ parser.add_argument(
53
+ "data",
54
+ nargs="+",
55
+ help="list of files containing binary data to send to the target",
56
+ )
57
+ parser.add_argument(
58
+ "--output-prefix",
59
+ help="prefix to be used for saving output (logs, reports, etc.)",
60
+ default="emfuzzer",
61
+ type=str,
62
+ )
63
+ parser.add_argument(
64
+ "--config",
65
+ help="path to the configuration file",
66
+ default="default-config.json",
67
+ type=Path,
68
+ )
69
+ parser.add_argument(
70
+ "--version",
71
+ action="version",
72
+ version=VERSION,
73
+ )
74
+
75
+ args = parser.parse_args()
76
+
77
+ args.data = __parse_data(parser, args.data)
78
+ args.output_prefix += f"-{datetime.now():%Y%m%d-%H%M%S}"
79
+
80
+ return args
81
+
82
+
83
+ def main() -> int:
84
+ args = parse_args()
85
+
86
+ __setup_logger(args.output_prefix)
87
+
88
+ return run(args, Config.from_file(args.config))
89
+
90
+
91
+ if __name__ == "__main__":
92
+ sys.exit(main())
emfuzzer/arguments.py ADDED
@@ -0,0 +1,16 @@
1
+ # Copyright (c) 2025 Warsaw University of Technology
2
+ # This file is licensed under the MIT License.
3
+ # See the LICENSE.txt file in the root of the repository for full details.
4
+
5
+ """
6
+ Module representing command line arguments.
7
+ """
8
+
9
+ from pathlib import Path
10
+ from typing import Protocol
11
+
12
+
13
+ class Arguments(Protocol): # pylint: disable=too-few-public-methods
14
+ data: list[Path]
15
+ output_prefix: str
16
+ config: Path
emfuzzer/case.py ADDED
@@ -0,0 +1,43 @@
1
+ # Copyright (c) 2025 Warsaw University of Technology
2
+ # This file is licensed under the MIT License.
3
+ # See the LICENSE.txt file in the root of the repository for full details.
4
+
5
+ """
6
+ Module representing "case" - an instance of the experiment execution.
7
+ """
8
+
9
+ from contextlib import contextmanager
10
+ from typing import Iterator, Self
11
+
12
+ from .context import Context
13
+ from .results import Results
14
+ from .subtasks import SubTasks
15
+
16
+
17
+ class Case:
18
+
19
+ def __init__(self, setups: SubTasks, monitoring: SubTasks, checks: SubTasks):
20
+ self._setups = setups
21
+ self._monitoring = monitoring
22
+ self._checks = checks
23
+
24
+ @contextmanager
25
+ def execute(self, case_name: str) -> Iterator[None]:
26
+ self._setups.execute_for(case_name)
27
+ with self._monitoring.monitor(case_name):
28
+ yield
29
+ self._checks.execute_for(case_name)
30
+
31
+ @classmethod
32
+ def from_config(cls, context: Context, results: Results) -> Self:
33
+ return cls(
34
+ setups=SubTasks.from_config(
35
+ "case", "setups", results=results, context=context
36
+ ),
37
+ checks=SubTasks.from_config(
38
+ "case", "checks", results=results, context=context
39
+ ),
40
+ monitoring=SubTasks.from_config(
41
+ "case", "monitoring", results=results, context=context
42
+ ),
43
+ )
@@ -0,0 +1,109 @@
1
+ # Copyright (c) 2025 Warsaw University of Technology
2
+ # This file is licensed under the MIT License.
3
+ # See the LICENSE.txt file in the root of the repository for full details.
4
+
5
+ """
6
+ CoAP - Constrained Application Protocol support module.
7
+ """
8
+
9
+ from enum import StrEnum, auto
10
+ from typing import Self
11
+
12
+ from ..config import Config
13
+ from ..context import Context
14
+ from ..delay import Delay
15
+ from ..injector.subtask import TypedInjectionSubTask
16
+ from ..io import IOLoop, SendQueue
17
+ from ..io.net import NetworkAddress
18
+ from ..io.sockets import UdpClientSocket
19
+ from ..subtasks.subtask import SubTask, TypedSubTask
20
+ from .validator import Validator
21
+
22
+
23
+ class CoapMonitorResult(StrEnum):
24
+ SUCCESS = auto()
25
+ UNEXPECTED_MESSAGE_RECEIVED = auto()
26
+
27
+
28
+ class CoapMonitor(TypedSubTask[CoapMonitorResult]):
29
+ # pylint: disable=too-many-arguments,too-many-positional-arguments
30
+ def __init__(
31
+ self,
32
+ name: str,
33
+ io: IOLoop,
34
+ target: NetworkAddress,
35
+ response_timeout: float,
36
+ observation_timeout: float,
37
+ ):
38
+ super().__init__(name)
39
+ self._io = io
40
+ self._target = target
41
+ self._response_timeout = response_timeout
42
+ self._delay = Delay(observation_timeout, name + ".observation")
43
+
44
+ self._validator: Validator | None = None
45
+ self._socket: UdpClientSocket | None = None
46
+ self._queue: SendQueue[tuple[NetworkAddress, bytes]] | None = None
47
+
48
+ def start(self) -> CoapMonitorResult | SubTask.StartedType:
49
+ self._queue = self._io.make_queue(tuple[NetworkAddress, bytes])
50
+ self._validator = Validator(self._target, self._response_timeout)
51
+ self._socket = UdpClientSocket(
52
+ self.name() + ".udp", self._queue, self._validator
53
+ )
54
+ self._io.register(self._socket)
55
+ return SubTask.STARTED
56
+
57
+ def finish(self) -> CoapMonitorResult:
58
+ assert self._socket
59
+ assert self._validator
60
+ self._delay.wait()
61
+ self._io.close(self._socket)
62
+ return (
63
+ CoapMonitorResult.SUCCESS
64
+ if self._validator.unexpected_messages == 0
65
+ else CoapMonitorResult.UNEXPECTED_MESSAGE_RECEIVED
66
+ )
67
+
68
+ def result_type(self) -> type[CoapMonitorResult]:
69
+ return CoapMonitorResult
70
+
71
+ def send(self, data: bytes) -> None:
72
+ assert self._queue is not None
73
+ self._queue.put((self._target, data))
74
+
75
+ def wait_for_response(self) -> Validator.Result:
76
+ assert self._validator
77
+ return self._validator.wait_for_result()
78
+
79
+ @classmethod
80
+ def from_config(cls, name: str, config: Config, context: Context) -> Self:
81
+ result = cls(
82
+ name=name,
83
+ target=NetworkAddress.from_config(config.section("target")),
84
+ response_timeout=config.get_float("response_timeout"),
85
+ observation_timeout=config.get_float("observation_timeout"),
86
+ io=context.worker(IOLoop),
87
+ )
88
+ context.register_data(name, result)
89
+ return result
90
+
91
+
92
+ class CoapInjector(TypedInjectionSubTask[Validator.Result]):
93
+ def __init__(self, name: str, monitor: CoapMonitor):
94
+ super().__init__(name)
95
+ self._monitor = monitor
96
+
97
+ def inject(self, data: bytes) -> Validator.Result:
98
+ self._monitor.send(data)
99
+ return self._monitor.wait_for_response()
100
+
101
+ def result_type(self) -> type[Validator.Result]:
102
+ return Validator.Result
103
+
104
+ @classmethod
105
+ def from_config(cls, name: str, config: Config, context: Context) -> Self:
106
+ return cls(
107
+ name=name,
108
+ monitor=context.data(CoapMonitor, config.get_str("monitor")),
109
+ )
emfuzzer/coap/code.py ADDED
@@ -0,0 +1,98 @@
1
+ # Copyright (c) 2025 Warsaw University of Technology
2
+ # This file is licensed under the MIT License.
3
+ # See the LICENSE.txt file in the root of the repository for full details.
4
+
5
+ """
6
+ CoAP - protocol codes etc.
7
+ """
8
+
9
+
10
+ def decode_code(octet: int) -> tuple[int, int]:
11
+ clazz = (octet & 0b11100000) >> 5
12
+ code = octet & 0xB00011111
13
+ return clazz, code
14
+
15
+
16
+ def code_short_string(code: tuple[int, int]) -> str:
17
+ return f"{code[0]}.{code[1]:02}"
18
+
19
+
20
+ __MESSAGES: dict[int, tuple[str, dict[int, str]]] = {
21
+ 1: (
22
+ "REQUEST",
23
+ {
24
+ 0: "EMPTY",
25
+ 1: "GET",
26
+ 2: "POST",
27
+ 3: "PUT",
28
+ 4: "DELETE",
29
+ 5: "FETCH",
30
+ 6: "PATCH",
31
+ 7: "iPATCH",
32
+ },
33
+ ),
34
+ 2: (
35
+ "SUCCESS",
36
+ {
37
+ 1: "Created",
38
+ 2: "Deleted",
39
+ 3: "Valid",
40
+ 4: "Changed",
41
+ 5: "Content",
42
+ 31: "Continue",
43
+ },
44
+ ),
45
+ 4: (
46
+ "CLIENT ERROR",
47
+ {
48
+ 0: "Bad Request",
49
+ 1: "Unauthorized",
50
+ 2: "Bad Option",
51
+ 3: "Forbidden",
52
+ 4: "Not Found",
53
+ 5: "Method Not Allowed",
54
+ 6: "Not Acceptable",
55
+ 8: "Request Entity Incomplete",
56
+ 9: "Conflict",
57
+ 12: "Precondition Failed",
58
+ 13: "Request Entity Too Large",
59
+ 15: "Unsupported Content-Format",
60
+ },
61
+ ),
62
+ 5: (
63
+ "SERVER ERROR",
64
+ {
65
+ 0: "Internal server error",
66
+ 1: "Not implemented",
67
+ 2: "Bad gateway",
68
+ 3: "Service unavailable",
69
+ 4: "Gateway timeout",
70
+ 5: "Proxying not supported",
71
+ },
72
+ ),
73
+ 7: (
74
+ "SIGNALING",
75
+ {
76
+ 0: "Unassigned",
77
+ 1: "CSM",
78
+ 2: "Ping",
79
+ 3: "Pong",
80
+ 4: "Release",
81
+ 5: "Abort",
82
+ },
83
+ ),
84
+ }
85
+
86
+
87
+ def code_message(code: tuple[int, int]) -> str:
88
+ class_message, code_dict = __MESSAGES.get(code[0], ("UNKNOWN", {}))
89
+ message = code_dict.get(code[1], "-unknown-")
90
+ return f"{class_message}: {message}"
91
+
92
+
93
+ def code_to_string(code: tuple[int, int]) -> str:
94
+ return f"{code_short_string(code)} ({code_message(code)})"
95
+
96
+
97
+ def code_reports_success(code: tuple[int, int]) -> bool:
98
+ return code[0] == 2
@@ -0,0 +1,91 @@
1
+ # Copyright (c) 2025 Warsaw University of Technology
2
+ # This file is licensed under the MIT License.
3
+ # See the LICENSE.txt file in the root of the repository for full details.
4
+
5
+ """
6
+ CoAP - communication validator.
7
+ """
8
+
9
+ import logging
10
+ import threading
11
+ from enum import StrEnum, auto
12
+
13
+ from ..io.net import NetworkAddress, NetworkObserver
14
+ from .code import code_reports_success, code_to_string, decode_code
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class Validator(NetworkObserver):
20
+
21
+ class Result(StrEnum):
22
+ SUCCESS = auto()
23
+ UNKNOWN = auto()
24
+ UNEXPECTED_ORIGIN = auto()
25
+ MESSAGE_TOO_SHORT = auto()
26
+ OPERATION_FAILURE = auto()
27
+ TIMEOUT = auto()
28
+
29
+ def __init__(self, expected_ip: NetworkAddress, timeout: float):
30
+ self.expected_ip = expected_ip
31
+ self.timeout = timeout
32
+
33
+ self.cond = threading.Condition()
34
+ self.expecting = False
35
+ self.result: Validator.Result = self.Result.UNKNOWN
36
+
37
+ self.unexpected_messages = 0
38
+
39
+ def on_read(self, address: NetworkAddress, data: bytes) -> None:
40
+ with self.cond:
41
+ if not self.expecting:
42
+ self.__unexpected_message()
43
+ return
44
+ self.expecting = False
45
+ self.result = self.check_message(address, data)
46
+ self.cond.notify()
47
+
48
+ def check_message(self, address: NetworkAddress, data: bytes) -> Result:
49
+ if address != self.expected_ip:
50
+ logger.warning(
51
+ f"Message received from unexpected origin: {address} vs {self.expected_ip}"
52
+ )
53
+ return self.Result.UNEXPECTED_ORIGIN
54
+
55
+ if len(data) < 2:
56
+ logger.warning("Too short message")
57
+ return self.Result.MESSAGE_TOO_SHORT
58
+
59
+ code = decode_code(data[1])
60
+
61
+ logger.info(f"Received {code_to_string(code)}")
62
+
63
+ if not code_reports_success(code):
64
+ logger.warning("Operation reported as failed")
65
+ return self.Result.OPERATION_FAILURE
66
+
67
+ return self.Result.SUCCESS
68
+
69
+ def on_write(self, address: NetworkAddress, data: bytes) -> None:
70
+ with self.cond:
71
+ self.expecting = True
72
+ self.result = self.Result.UNKNOWN
73
+
74
+ def wait_for_result(self) -> Result:
75
+ with self.cond:
76
+ if not self.cond.wait_for(
77
+ lambda: self.result != self.Result.UNKNOWN, timeout=self.timeout
78
+ ):
79
+ self.expecting = False
80
+ logger.warning("Operation timed out")
81
+ return self.Result.TIMEOUT
82
+ result = self.result
83
+ self.result = self.Result.UNKNOWN
84
+ return result
85
+
86
+ def extra_stats(self) -> dict[str, int]:
87
+ return {"unexpected_messages": self.unexpected_messages}
88
+
89
+ def __unexpected_message(self) -> None:
90
+ logger.warning("Message unexpected at this stage")
91
+ self.unexpected_messages += 1
emfuzzer/config.py ADDED
@@ -0,0 +1,86 @@
1
+ # Copyright (c) 2025 Warsaw University of Technology
2
+ # This file is licensed under the MIT License.
3
+ # See the LICENSE.txt file in the root of the repository for full details.
4
+
5
+ """
6
+ Module for loading application configuration.
7
+ """
8
+
9
+ import json
10
+ from pathlib import Path
11
+ from typing import Any, Self, cast
12
+
13
+
14
+ class Config:
15
+
16
+ def __init__(self, obj: dict[str, Any]):
17
+ self._obj = obj
18
+
19
+ def section(self, path: str, *subpath: str) -> Self:
20
+ subsection = cast(dict[str, Any], self._obj[path])
21
+ config = self.__class__(subsection)
22
+ if subpath:
23
+ try:
24
+ config.section(*subpath)
25
+ except KeyError:
26
+ raise KeyError(path, *subpath) from None
27
+ return config
28
+
29
+ def _get_value(self, path: str, *subpath: str) -> Any:
30
+ if subpath:
31
+ try:
32
+ # pylint: disable=protected-access
33
+ return self.section(path)._get_value(*subpath)
34
+ except KeyError:
35
+ raise KeyError(path, *subpath) from None
36
+ return self._obj[path]
37
+
38
+ def get_int(self, path: str, *subpath: str) -> int:
39
+ value = self._get_value(path, *subpath)
40
+ if not isinstance(value, int):
41
+ raise TypeError("not an int", path, *subpath)
42
+ return value
43
+
44
+ def get_float(self, path: str, *subpath: str) -> float:
45
+ value = self._get_value(path, *subpath)
46
+ if type(value) not in (int, float):
47
+ raise TypeError("not an float", path, *subpath)
48
+ return float(value)
49
+
50
+ def get_bool(self, path: str, *subpath: str) -> bool:
51
+ value = self._get_value(path, *subpath)
52
+ if type(value) not in (int, bool):
53
+ raise TypeError("not an bool", path, *subpath)
54
+ return bool(value)
55
+
56
+ def get_str(self, path: str, *subpath: str) -> str:
57
+ value = self._get_value(path, *subpath)
58
+ if not isinstance(value, str):
59
+ raise TypeError("not an str", path, *subpath)
60
+ return value
61
+
62
+ def get_config_list(self, path: str, *subpath: str) -> list[Self]:
63
+ value = self._get_value(path, *subpath)
64
+ if not isinstance(value, list):
65
+ raise TypeError("not an list", path, *subpath)
66
+ if any(not isinstance(x, dict) for x in value):
67
+ raise TypeError("not all elements are dict", path, *subpath)
68
+ return [self.__class__(v) for v in value]
69
+
70
+ def get_str_list(self, path: str, *subpath: str) -> list[str]:
71
+ value = self._get_value(path, *subpath)
72
+ if not isinstance(value, list):
73
+ raise TypeError("not an list", path, *subpath)
74
+ if any(not isinstance(x, str) for x in value):
75
+ raise TypeError("not all elements are str", path, *subpath)
76
+ return value
77
+
78
+ def to_dict(self) -> dict[str, Any]:
79
+ return self._obj
80
+
81
+ @classmethod
82
+ def from_file(cls, path: Path) -> Self:
83
+ with path.open() as file:
84
+ config = cls(json.load(file))
85
+ config._obj["__path__"] = str(path)
86
+ return config
emfuzzer/context.py ADDED
@@ -0,0 +1,74 @@
1
+ # Copyright (c) 2025 Warsaw University of Technology
2
+ # This file is licensed under the MIT License.
3
+ # See the LICENSE.txt file in the root of the repository for full details.
4
+
5
+ """
6
+ Module representing context of the experiment.
7
+ """
8
+
9
+ from abc import ABC, abstractmethod
10
+ from types import TracebackType
11
+ from typing import Self, cast
12
+
13
+ from .config import Config
14
+
15
+
16
+ class Worker(ABC):
17
+ @abstractmethod
18
+ def start(self) -> None: ...
19
+
20
+ @abstractmethod
21
+ def stop(self) -> None: ...
22
+
23
+
24
+ class Context:
25
+
26
+ def __init__(self, config: Config) -> None:
27
+ self._workers: dict[type[Worker], Worker] = {}
28
+ self._data: dict[str, object] = {}
29
+ self._config = config
30
+
31
+ @property
32
+ def config_root(self) -> Config:
33
+ return self._config
34
+
35
+ def worker[T: Worker](self, worker: type[T]) -> T:
36
+ if instance := self._workers.get(worker):
37
+ return cast(T, instance)
38
+
39
+ instance = worker()
40
+ instance.start()
41
+
42
+ self._workers[worker] = instance
43
+
44
+ return instance
45
+
46
+ def teardown(self) -> None:
47
+ for w in self._workers.values():
48
+ w.stop()
49
+
50
+ self._workers.clear()
51
+
52
+ def register_data(self, name: str, item: object) -> None:
53
+ if name in self._data:
54
+ raise RuntimeError(f"Data already registered: '{name}'")
55
+
56
+ self._data[name] = item
57
+
58
+ def data[T](self, data_type: type[T], name: str) -> T:
59
+ if item := self._data.get(name):
60
+ if isinstance(item, data_type):
61
+ return item
62
+ raise RuntimeError(f"Invalid data type for: '{name}'")
63
+ raise RuntimeError(f"Unknown data: '{name}'")
64
+
65
+ def __enter__(self) -> Self:
66
+ return self
67
+
68
+ def __exit__(
69
+ self,
70
+ exc_type: type[BaseException] | None,
71
+ exc_value: BaseException | None,
72
+ exc_traceback: TracebackType | None,
73
+ ) -> None:
74
+ self.teardown()