diff --git a/dissect/target/plugins/apps/webserver/apache.py b/dissect/target/plugins/apps/webserver/apache.py index 52705a3212..00939f3e8d 100644 --- a/dissect/target/plugins/apps/webserver/apache.py +++ b/dissect/target/plugins/apps/webserver/apache.py @@ -5,13 +5,14 @@ from datetime import datetime from functools import cached_property from pathlib import Path -from typing import TYPE_CHECKING, NamedTuple +from typing import TYPE_CHECKING from dissect.target.exceptions import FileNotFoundError, UnsupportedPluginError from dissect.target.helpers.certificate import parse_x509 from dissect.target.helpers.fsutil import open_decompress from dissect.target.plugin import OperatingSystem, export from dissect.target.plugins.apps.webserver.webserver import ( + LogFormat, WebserverAccessLogRecord, WebserverCertificateRecord, WebserverErrorLogRecord, @@ -26,11 +27,6 @@ from dissect.target.target import Target -class LogFormat(NamedTuple): - name: str - pattern: re.Pattern - - # e.g. ServerRoot "/etc/httpd" RE_CONFIG_ROOT = re.compile( r""" @@ -154,6 +150,24 @@ class LogFormat(NamedTuple): (?P.*) # The actual log message. """ +RE_FIRST_LINE_OF_REQUEST = r""" + " + ( + - # Malformed requests may result in the value "-" + | + ( + (?P.*?) # The HTTP Method used for the request. + \s + (?P.*?) # The HTTP URI of the request. + \s + ?(?PHTTP\/.*?)? # The request protocol. + ) + | + (?P.*?) # Malformed or invalid requests can contain raw bytes + ) + " +""" + RE_ENV_VAR_IN_STRING = re.compile(r"\$\{(?P[^\"\s$]+)\}", re.VERBOSE) RE_VIRTUALHOST = re.compile(r"^\[^\s:]+)(?:\:(?P\d+))?", re.IGNORECASE) @@ -331,7 +345,7 @@ def _process_conf_file(self, path: Path, seen: set[Path] | None = None) -> None: elif "include" in line_lower: if not (match := RE_CONFIG_INCLUDE.match(line)): - self.target.log.warning("Unable to parse Apache 'Include' configuration in %s: %r", path, line) + self.target.log.debug("Unable to parse Apache 'Include' configuration in %s: %r", path, line) continue location = match.groupdict().get("location") @@ -423,16 +437,20 @@ def access(self) -> Iterator[WebserverAccessLogRecord]: if response_time := log.get("response_time"): response_time = apache_response_time_to_ms(response_time) + ts = datetime.strptime(log["ts"], logformat.timestamp or "%d/%b/%Y:%H:%M:%S %z") # noqa: DTZ007 + if logformat.timestamp and "%z" not in logformat.timestamp: + ts.replace(tzinfo=self.target.datetime.tzinfo) + yield WebserverAccessLogRecord( - ts=datetime.strptime(log["ts"], "%d/%b/%Y:%H:%M:%S %z"), + ts=ts, webserver=self.__namespace__, - remote_user=clean_value(log["remote_user"]), + remote_user=clean_value(log.get("remote_user")), remote_ip=log["remote_ip"], local_ip=clean_value(log.get("local_ip")), method=log["method"], uri=log["uri"], protocol=log["protocol"], - status_code=log["status_code"], + status_code=log.get("status_code"), bytes_sent=clean_value(log["bytes_sent"]) or 0, pid=log.get("pid"), referer=clean_value(log.get("referer")), diff --git a/dissect/target/plugins/apps/webserver/tomcat.py b/dissect/target/plugins/apps/webserver/tomcat.py new file mode 100644 index 0000000000..101fa62d44 --- /dev/null +++ b/dissect/target/plugins/apps/webserver/tomcat.py @@ -0,0 +1,358 @@ +from __future__ import annotations + +import re +from datetime import datetime, timezone +from typing import TYPE_CHECKING + +import defusedxml.ElementTree as ET + +from dissect.target.exceptions import UnsupportedPluginError +from dissect.target.helpers.certificate import parse_x509 +from dissect.target.helpers.fsutil import open_decompress +from dissect.target.plugin import export +from dissect.target.plugins.apps.webserver.webserver import ( + LogFormat, + WebserverAccessLogRecord, + WebserverCertificateRecord, + WebserverHostRecord, + WebserverPlugin, +) + +if TYPE_CHECKING: + from collections.abc import Iterator + from pathlib import Path + + from dissect.target.target import Target + + +# '%h %l %u %t "%r" %s %b' +# Reference: https://tomcat.apache.org/tomcat-9.0-doc/config/valve.html#Access_Logging +LOG_FORMAT_ACCESS_DEFAULT = LogFormat( + "default", + re.compile( + r""" + (?P.*?)\s-\s(?P.*?) + \s + \[(?P\d{2}\/[A-Za-z]{3}\/\d{4}:\d{2}:\d{2}:\d{2}\s(\+|\-)\d{4})\] + \s + \"((?P.*?)\s(?P.*?)\s?(?PHTTP\/.*?)|-)?\" + \s + (?P\d{3}) + \s + (?P-|\d+) + ( + \s + (["](?P(\-)|(.+))["]) + \s + \"(?P.*?)\" + )? + """, + re.VERBOSE, + ), + r"%d/%b/%Y:%H:%M:%S %z", +) + + +class TomcatPlugin(WebserverPlugin): + """Tomcat webserver plugin. + + References: + - https://tomcat.apache.org/ + """ + + __namespace__ = "tomcat" + + INSTALL_PATHS_SYSTEM = ( + # Windows + "/sysvol/Program Files/Apache Software Foundation/Tomcat*", + "/sysvol/Program Files/Tomcat*", + "/sysvol/XAMPP/Tomcat*", + # Linux + "/var/lib/tomcat*", + "/etc/tomcat*", # Handles symlinked conf folders (e.g. /var/lib/tomcat10/conf -> /etc/tomcat10) + "/usr/local/tomcat*", + "/opt/bitnami/tomcat*", + "/opt/tomcat*", + ) + + INSTALL_PATHS_USER = ( + # Windows + "XAMPP/Tomcat*", + ) + + # Relative to install path. Does not parse WEB-INF dirs. + DEFAULT_CONFIG_FILES = ( + "conf/server.xml", + "server.xml", # For symlinked conf folder installs (e.g. /etc/tomcat10) + ) + + DEFAULT_LOG_PATHS = ("/var/log/tomcat*",) + + def __init__(self, target: Target): + super().__init__(target) + + self.installs = list(self.find_installs()) + self.configs = list(self.find_configs()) + self.log_files = list(self.find_log_files()) + + def check_compatible(self) -> None: + if not self.installs and not self.configs and not self.log_files: + raise UnsupportedPluginError("No Tomcat installations found on target") + + def find_installs(self) -> Iterator[Path]: + """Yield found Tomcat installation directories.""" + seen = set() + + for path in self.INSTALL_PATHS_SYSTEM: + base, _, glob = path.rpartition("/") + for dir in self.target.fs.path(base).glob(glob): + if dir.is_dir() and dir not in seen: + seen.add(dir) + yield dir + + for user_details in self.target.user_details.all_with_home(): + for path in self.INSTALL_PATHS_USER: + base, _, glob = path.rpartition("/") # type: ignore + for dir in user_details.home_path.joinpath(base).glob(glob): + if dir.is_dir() and dir not in seen: + seen.add(dir) + yield dir + + def find_configs(self) -> Iterator[tuple[Path, Path]]: + """Yield Tomcat configuration files based on found Tomcat install paths. + + Returns: + Iterator with tuple of the original install path and the configuration file path. + """ + seen: set[Path] = set() + + for install in self.installs: + for conf in self.DEFAULT_CONFIG_FILES: + if (config := install.joinpath(conf)).is_file() and not any(s.samefile(config) for s in seen): + seen.add(config) + yield install, config + + def parse_config(self, path: Path) -> Iterator[dict]: + """Parse the given Tomcat configuration file for host information.""" + config = ET.fromstring(path.read_text()) + + # Within a Service, a Connector defines the listen port, protocol and TLS. + # A Connector can contain multiple Hosts. + for service in config.findall("./Service"): + tls_cert = None + tls_key = None + tls_port = None + http_port = None + + for connector in service.findall("./Connector"): + # Collect the TLS settings for all hosts under this Connector. Does not parse java keystore files. + # https://tomcat.apache.org/tomcat-9.0-doc/ssl-howto.html + if "SSLCertificateFile" in connector.keys(): # noqa: SIM118 + tls_cert = connector.get("SSLCertificateFile") + if "SSLCertificateKeyFile" in connector.keys(): # noqa: SIM118 + tls_key = connector.get("SSLCertificateKeyFile") + + # Collect the TLS settings for hosts for Tomcat version >= 9 + for certificate in connector.findall("./SSLHostConfig/Certificate"): + if "certificateFile" in certificate.keys(): # noqa: SIM118 + tls_cert = certificate.get("certificateFile") + if "certificateKeyFile" in certificate.keys(): # noqa: SIM118 + tls_key = certificate.get("certificateKeyFile") + + if "port" in connector.keys() and ( # noqa: SIM118 + any("certificate" in k.lower() for k in connector.keys()) # noqa: SIM118 + or connector.find("./SSLHostConfig") is not None + ): + tls_port = connector.get("port") + else: + http_port = connector.get("port") + + for host in service.findall("./Engine/Host"): + host_conf = { + "hostname": host.get("name"), + "http_port": http_port, + "tls_port": tls_port, + "base": host.get("appBase"), + "logs": {}, + "tls_cert": tls_cert, + "tls_key": tls_key, + } + + # Collect defined log configuration for this host by iterating for the AccessLogValve. + if (valve := host.find("./Valve[@className='org.apache.catalina.valves.AccessLogValve']")) is not None: + host_conf["logs"] = { + "directory": valve.get("directory"), + "prefix": valve.get("prefix"), + "suffix": valve.get("suffix"), + "pattern": valve.get("pattern"), + } + + yield host_conf + + def find_log_files(self) -> Iterator[Path]: + """Yield Tomcat log files.""" + seen = set() + + # Find log directories from configuration files. + for install, path in self.configs: + for config in self.parse_config(path): + if not (log_config := config.get("logs")): + continue + + dir_str = log_config.get("directory") + + # Duck-type if this is already a complete path + if (log_dir := self.target.fs.path(dir_str)).is_dir() or ( + log_dir := install.joinpath(dir_str) + ).is_dir(): + pass + else: + self.target.log.warning("Unable to infer log directory location for %r in %s", dir_str, path) + continue + + prefix = log_config.get("prefix") + suffix = log_config.get("suffix") + for log in log_dir.glob(f"{prefix}*{suffix}"): + log = log.resolve() + if log not in seen: + seen.add(log) + yield log + + # Find log directories from default (absolute) paths. + for log_str in self.DEFAULT_LOG_PATHS: + base, _, glob = log_str.rpartition("/") + for log_dir in self.target.fs.path(base).glob(glob): + for log in log_dir.glob("*access_log*"): + if log not in seen: + seen.add(log) + yield log + + @export(record=WebserverHostRecord) + def hosts(self) -> Iterator[WebserverHostRecord]: + """Return configured Tomcat hosts in unified ``WebserverHostRecord`` format. + + References: + - https://tomcat.apache.org/tomcat-9.0-doc/config/host.html + """ + for install, config_path in self.configs: + for config in self.parse_config(config_path): + log_config = config.get("logs", {}) + if log_config: + access_log_config = ( + f"{log_config.get('directory')}/{log_config.get('prefix')}*{log_config.get('suffix')}" + ) + else: + access_log_config = None + + tls_cert_path = None + tls_key_path = None + + if (tls_cert := config.get("tls_cert")) and not ( + (tls_cert_path := self.target.fs.path(tls_cert)).is_file() + or (tls_cert_path := install.joinpath(tls_cert)).is_file() + ): + self.target.log.warning("Unable to resolve certificate file location for %r", tls_cert) + + if (tls_key := config.get("tls_key")) and not ( + (tls_key_path := self.target.fs.path(tls_key)).is_file() + or (tls_key_path := install.joinpath(tls_key)).is_file() + ): + self.target.log.warning("Unable to resolve certificate key file location for %r", tls_cert) + + yield WebserverHostRecord( + ts=config_path.lstat().st_mtime, + webserver=self.__namespace__, + server_name=config.get("hostname"), + server_port=config.get("tls_port") if tls_cert_path else config.get("http_port"), + access_log_config=access_log_config, + tls_certificate=tls_cert_path, + tls_key=tls_key_path, + source=config_path, + _target=self.target, + ) + + @export(record=WebserverCertificateRecord) + def certificates(self) -> Iterator[WebserverCertificateRecord]: + """Yield TLS certificates from Tomcat hosts.""" + for host in self.hosts(): + if not host.tls_certificate: + continue + + cert_path = self.target.fs.path(host.tls_certificate) + try: + cert = parse_x509(cert_path) + yield WebserverCertificateRecord( + ts=cert_path.lstat().st_mtime, + webserver=self.__namespace__, + **cert._asdict(), + host=host.server_name, + source=cert_path, + _target=self.target, + ) + except Exception as e: + self.target.log.warning("Unable to parse certificate %s: %s", cert_path, e) + self.target.log.debug("", exc_info=e) + + @export(record=WebserverAccessLogRecord) + def access(self) -> Iterator[WebserverAccessLogRecord]: + """Return contents of Tomcat access log files in unified ``WebserverAccessLogRecord`` format. + + References: + - https://tomcat.apache.org/tomcat-9.0-doc/config/valve.html#Access_logging + """ + for log_file in self.log_files: + for line in open_decompress(log_file, "rt"): + if not (logformat := self.infer_access_log_format(line)): + self.target.log.warning("Could not detect Tomcat format for log line in %s: %r", log_file, line) + continue + + if not (match := logformat.pattern.match(line)): + self.target.log.warning( + "Could not match Tomcat format %s for log line in %s: %r", logformat.name, log_file, line + ) + continue + + log = match.groupdict() + + try: + bytes_sent = log["bytes_sent"].strip("-") or 0 + except ValueError: + bytes_sent = None + + ts = None + ts_fmts = [logformat.timestamp] if not isinstance(logformat.timestamp, list) else logformat.timestamp + + for fmt in ts_fmts: + try: + ts = datetime.strptime(log["ts"], fmt or "%d/%b/%Y:%H:%M:%S %z") # noqa: DTZ007 + break + except ValueError: + pass + + if not ts: + self.target.log.warning( + "Could not match Tomcat timestamp format for log line in %s: %r", log_file, log["ts"] + ) + elif not ts.tzinfo: + ts.replace(tzinfo=self.target.datetime.tzinfo).astimezone(timezone.utc) + + log.pop("ts") + log.pop("bytes_sent") + + # Normalize empty '-' values to None + for key, value in log.items(): + log[key] = None if value == "-" else value + + yield WebserverAccessLogRecord( + ts=ts, + webserver=self.__namespace__, + bytes_sent=bytes_sent, + **log, + source=log_file, + _target=self.target, + ) + + @staticmethod + def infer_access_log_format(line: str) -> LogFormat | None: + """Attempt to infer what LogFormat is used by the log line provided.""" + return LOG_FORMAT_ACCESS_DEFAULT diff --git a/dissect/target/plugins/apps/webserver/webserver.py b/dissect/target/plugins/apps/webserver/webserver.py index a549a24f98..112fa301f2 100644 --- a/dissect/target/plugins/apps/webserver/webserver.py +++ b/dissect/target/plugins/apps/webserver/webserver.py @@ -1,12 +1,13 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, NamedTuple from dissect.target.helpers.certificate import COMMON_CERTIFICATE_FIELDS from dissect.target.helpers.record import TargetRecordDescriptor from dissect.target.plugin import NamespacePlugin, export if TYPE_CHECKING: + import re from collections.abc import Iterator WebserverAccessLogRecord = TargetRecordDescriptor( @@ -75,6 +76,12 @@ ) +class LogFormat(NamedTuple): + name: str + pattern: re.Pattern + timestamp: str | list[str] | None = None + + class WebserverPlugin(NamespacePlugin): __namespace__ = "webserver" diff --git a/tests/_data/plugins/apps/webserver/tomcat/localhost_access_log.2026-01-01.combined.txt b/tests/_data/plugins/apps/webserver/tomcat/localhost_access_log.2026-01-01.combined.txt new file mode 100755 index 0000000000..b19cede599 --- /dev/null +++ b/tests/_data/plugins/apps/webserver/tomcat/localhost_access_log.2026-01-01.combined.txt @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9e18d5210c3ec6cf6acbd7190a79d258aa73ed1334046cb42eba08eca200be1f +size 334 diff --git a/tests/_data/plugins/apps/webserver/tomcat/localhost_access_log.2026-01-01.txt b/tests/_data/plugins/apps/webserver/tomcat/localhost_access_log.2026-01-01.txt new file mode 100755 index 0000000000..71c4569a7c --- /dev/null +++ b/tests/_data/plugins/apps/webserver/tomcat/localhost_access_log.2026-01-01.txt @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ab03981fe39975dec549d81d9eef9f76c77f6088b99403a6c4dc12c4f7d77368 +size 145 diff --git a/tests/_data/plugins/apps/webserver/tomcat/server.xml b/tests/_data/plugins/apps/webserver/tomcat/server.xml new file mode 100755 index 0000000000..bc0e3bf5a8 --- /dev/null +++ b/tests/_data/plugins/apps/webserver/tomcat/server.xml @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a8a8f7f5df3274d2bd8542b7eb3c53ea45d8c8754126e719fe3dcf2ac4c9b4f6 +size 1676 diff --git a/tests/_data/plugins/apps/webserver/tomcat/server_changed_log_file.xml b/tests/_data/plugins/apps/webserver/tomcat/server_changed_log_file.xml new file mode 100755 index 0000000000..d41fbca350 --- /dev/null +++ b/tests/_data/plugins/apps/webserver/tomcat/server_changed_log_file.xml @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:42e69720f09aff69f2b49e34d94a96680ab57eff66b44deef8ee6cf9b897ae66 +size 1678 diff --git a/tests/_data/plugins/apps/webserver/tomcat/server_log_combined.xml b/tests/_data/plugins/apps/webserver/tomcat/server_log_combined.xml new file mode 100755 index 0000000000..30c1cd3778 --- /dev/null +++ b/tests/_data/plugins/apps/webserver/tomcat/server_log_combined.xml @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9dd510670bb3f111c672ce6fce58bdbc59bfce58bb0382b404ac2132a168fd57 +size 1652 diff --git a/tests/_data/plugins/apps/webserver/tomcat/server_tls.xml b/tests/_data/plugins/apps/webserver/tomcat/server_tls.xml new file mode 100755 index 0000000000..c89a53e01c --- /dev/null +++ b/tests/_data/plugins/apps/webserver/tomcat/server_tls.xml @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5206d05aa9061c20f3673e8a85b241d257a65a23e199bae7ecbc447a32602cc3 +size 2165 diff --git a/tests/_data/plugins/apps/webserver/tomcat/server_tls_v8.xml b/tests/_data/plugins/apps/webserver/tomcat/server_tls_v8.xml new file mode 100755 index 0000000000..f5dbbfdf6d --- /dev/null +++ b/tests/_data/plugins/apps/webserver/tomcat/server_tls_v8.xml @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5fb1ab040f914a4acbbb0afb46019d0c49dc331aa6a319e572e9e8aaeb92fb58 +size 1980 diff --git a/tests/plugins/apps/webserver/test_tomcat.py b/tests/plugins/apps/webserver/test_tomcat.py new file mode 100644 index 0000000000..68d0e6dbad --- /dev/null +++ b/tests/plugins/apps/webserver/test_tomcat.py @@ -0,0 +1,241 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import TYPE_CHECKING + +from dissect.target.plugins.apps.webserver.tomcat import TomcatPlugin +from tests._utils import absolute_path + +if TYPE_CHECKING: + from dissect.target.filesystem import VirtualFilesystem + from dissect.target.target import Target + + +def test_unix_logs_default_install(target_unix: Target, fs_unix: VirtualFilesystem) -> None: + """Test if we can parse the Tomcat 10 access logs of a default UNIX install.""" + fs_unix.map_dir("/var/lib/tomcat10/", absolute_path("_data/plugins/apps/webserver/tomcat/tomcat/")) + fs_unix.symlink("/etc/tomcat10/", "/var/lib/tomcat10/conf/") + fs_unix.symlink("/var/log/tomcat10/", "/var/lib/tomcat10/logs/") + + fs_unix.map_file("/etc/tomcat10/server.xml", absolute_path("_data/plugins/apps/webserver/tomcat/server.xml")) + fs_unix.map_file( + "/var/log/tomcat10/localhost_access_log.2026-01-01.txt", + absolute_path("_data/plugins/apps/webserver/tomcat/localhost_access_log.2026-01-01.txt"), + ) + + target_unix.add_plugin(TomcatPlugin) + records = list(target_unix.tomcat.access()) + assert len(records) == 2 + + assert records[0].ts == datetime(2026, 1, 1, 13, 37, 1, tzinfo=timezone.utc) + assert records[0].status_code == 200 + assert records[0].remote_ip == "0:0:0:0:0:0:0:1" + assert records[0].remote_user is None + assert records[0].method == "GET" + assert records[0].uri == "/" + assert records[0].protocol == "HTTP/1.1" + assert records[0].bytes_sent == 1337 + + assert records[1].ts == datetime(2026, 1, 1, 13, 37, 2, tzinfo=timezone.utc) + assert records[1].status_code == 404 + assert records[1].remote_ip == "1.2.3.4" + assert records[1].remote_user is None + assert records[1].method == "GET" + assert records[1].uri == "/foo" + assert records[1].protocol == "HTTP/1.1" + assert records[1].bytes_sent == 1337 + + +def test_unix_logs_combined_format(target_unix: Target, fs_unix: VirtualFilesystem) -> None: + """Test if we can parse the access logs of a Tomcat 10 UNIX install with combined log format.""" + fs_unix.map_file( + "/var/log/tomcat10/localhost_access_log.2026-01-01.txt", + absolute_path("_data/plugins/apps/webserver/tomcat/localhost_access_log.2026-01-01.combined.txt"), + ) + + target_unix.add_plugin(TomcatPlugin) + records = list(target_unix.tomcat.access()) + assert len(records) == 2 + + assert records[0].referer is None + assert records[0].useragent == "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:148.0) Gecko/20100101 Firefox/148.0" + + assert records[1].webserver == "tomcat" + assert not records[1].remote_user + assert records[1].remote_ip == "1.2.3.4" + assert records[1].method == "GET" + assert records[1].uri == "/foo" + assert not records[1].query + assert records[1].protocol == "HTTP/1.1" + assert records[1].status_code == 404 + assert records[1].bytes_sent == 1337 + assert records[1].referer == "http://127.0.0.1/bar" + assert records[1].useragent == "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:148.0) Gecko/20100101 Firefox/148.0" + assert records[1].source == "/var/log/tomcat10/localhost_access_log.2026-01-01.txt" + + +def test_unix_hosts_default_install(target_unix: Target, fs_unix: VirtualFilesystem) -> None: + """Test if we can parse Tomcat 10 UNIX hosts defined in ``server.xml`` files.""" + fs_unix.map_dir("/var/lib/tomcat10/", absolute_path("_data/plugins/apps/webserver/tomcat/tomcat/")) + fs_unix.symlink("/etc/tomcat10/", "/var/lib/tomcat10/conf/") + fs_unix.symlink("/var/log/tomcat10/", "/var/lib/tomcat10/logs/") + + fs_unix.map_file("/etc/tomcat10/server.xml", absolute_path("_data/plugins/apps/webserver/tomcat/server.xml")) + fs_unix.map_file( + "/var/log/tomcat10/localhost_access_log.2026-01-01.txt", + absolute_path("_data/plugins/apps/webserver/tomcat/localhost_access_log.2026-01-01.txt"), + ) + + target_unix.add_plugin(TomcatPlugin) + records = list(target_unix.tomcat.hosts()) + assert len(records) == 1 # Test deduplication of symlinked install dirs. + + assert records[0].webserver == "tomcat" + assert records[0].server_name == "localhost" + assert records[0].server_port == 8080 + assert records[0].access_log_config == "logs/localhost_access_log*.txt" + assert records[0].source == "/var/lib/tomcat10/conf/server.xml" + + +def test_unix_certificates_default_install_v8(target_unix: Target, fs_unix: VirtualFilesystem) -> None: + """Test if we can parse Tomcat version <=8 TLS host certificates.""" + fs_unix.map_dir("/var/lib/tomcat8/", absolute_path("_data/plugins/apps/webserver/tomcat/tomcat/")) + fs_unix.symlink("/etc/tomcat8/", "/var/lib/tomcat8/conf/") + fs_unix.symlink("/var/log/tomcat8/", "/var/lib/tomcat8/logs/") + + fs_unix.map_file("/etc/tomcat8/server.xml", absolute_path("_data/plugins/apps/webserver/tomcat/server_tls_v8.xml")) + fs_unix.map_file( + "/etc/tomcat8/localhost.crt", absolute_path("_data/plugins/apps/webserver/certificates/example.crt") + ) + fs_unix.map_file( + "/etc/tomcat8/localhost.key", absolute_path("_data/plugins/apps/webserver/certificates/example.key") + ) + target_unix.add_plugin(TomcatPlugin) + + records = list(target_unix.tomcat.hosts()) + assert records[0].server_port == 8443 + assert records[0].tls_certificate == "/var/lib/tomcat8/conf/localhost.crt" + assert records[0].tls_key == "/var/lib/tomcat8/conf/localhost.key" + + records = list(target_unix.tomcat.certificates()) + assert len(records) == 1 + + assert records[0].fingerprint.md5 == "a218ac9b6dbdaa8b23658c4d18c1cfc1" + assert records[0].fingerprint.sha1 == "6566d8ebea1feb4eb3d12d9486cddb69e4e9e827" + assert records[0].fingerprint.sha256 == "7221d881743505f13b7bfe854bdf800d7f0cd22d34307ed7157808a295299471" + assert records[0].serial_number == 21067204948278457910649605551283467908287726794 + assert records[0].serial_number_hex == "03b0afa702c33e37fffd40e0c402b2120c1284ca" + assert records[0].issuer_dn == "C=AU,ST=Some-State,O=Internet Widgits Pty Ltd,CN=example.com" + assert records[0].source == "/var/lib/tomcat8/conf/localhost.crt" + + +def test_unix_certificates_default_install_v10(target_unix: Target, fs_unix: VirtualFilesystem) -> None: + """Test if we can parse Tomcat 10 UNIX host certificates.""" + fs_unix.map_dir("/var/lib/tomcat10/", absolute_path("_data/plugins/apps/webserver/tomcat/tomcat/")) + fs_unix.symlink("/etc/tomcat10/", "/var/lib/tomcat10/conf/") + fs_unix.symlink("/var/log/tomcat10/", "/var/lib/tomcat10/logs/") + + fs_unix.map_file("/etc/tomcat10/server.xml", absolute_path("_data/plugins/apps/webserver/tomcat/server_tls.xml")) + fs_unix.map_file( + "/etc/tomcat10/localhost.crt", absolute_path("_data/plugins/apps/webserver/certificates/example.crt") + ) + fs_unix.map_file( + "/etc/tomcat10/localhost.key", absolute_path("_data/plugins/apps/webserver/certificates/example.key") + ) + target_unix.add_plugin(TomcatPlugin) + + records = list(target_unix.tomcat.hosts()) + assert len(records) == 1 + + assert records[0].server_port == 8443 + assert records[0].tls_certificate == "/var/lib/tomcat10/conf/localhost.crt" + assert records[0].tls_key == "/var/lib/tomcat10/conf/localhost.key" + + records = list(target_unix.tomcat.certificates()) + assert len(records) == 1 + + assert records[0].fingerprint.md5 == "a218ac9b6dbdaa8b23658c4d18c1cfc1" + assert records[0].fingerprint.sha1 == "6566d8ebea1feb4eb3d12d9486cddb69e4e9e827" + assert records[0].fingerprint.sha256 == "7221d881743505f13b7bfe854bdf800d7f0cd22d34307ed7157808a295299471" + assert records[0].serial_number == 21067204948278457910649605551283467908287726794 + assert records[0].serial_number_hex == "03b0afa702c33e37fffd40e0c402b2120c1284ca" + assert records[0].issuer_dn == "C=AU,ST=Some-State,O=Internet Widgits Pty Ltd,CN=example.com" + assert records[0].source == "/var/lib/tomcat10/conf/localhost.crt" + + +def test_unix_logs_custom_log_location(target_unix: Target, fs_unix: VirtualFilesystem) -> None: + """Test if we can find and parse a custom Tomcat 10 log location directive.""" + fs_unix.map_dir("/var/lib/tomcat10/", absolute_path("_data/plugins/apps/webserver/tomcat/tomcat/")) + fs_unix.symlink("/etc/tomcat10/", "/var/lib/tomcat10/conf/") + fs_unix.symlink("/var/log/tomcat10/", "/var/lib/tomcat10/logs/") + + fs_unix.map_file( + "/etc/tomcat10/server.xml", absolute_path("_data/plugins/apps/webserver/tomcat/server_changed_log_file.xml") + ) + fs_unix.map_file( + "/var/log/tomcat10/example.com_access_log.2026-01-01.log", + absolute_path("_data/plugins/apps/webserver/tomcat/localhost_access_log.2026-01-01.txt"), + ) + + target_unix.add_plugin(TomcatPlugin) + records = list(target_unix.tomcat.access()) + assert len(records) == 2 + + +def test_unix_logs_custom_log_location_no_install(target_unix: Target, fs_unix: VirtualFilesystem) -> None: + """Test if we can find and parse a custom Tomcat 10 log location without a full Tomcat install.""" + fs_unix.map_file( + "/etc/tomcat10/server.xml", absolute_path("_data/plugins/apps/webserver/tomcat/server_changed_log_file.xml") + ) + fs_unix.map_file( + "/var/log/tomcat10/example.com_access_log.2026-01-01.log", + absolute_path("_data/plugins/apps/webserver/tomcat/localhost_access_log.2026-01-01.txt"), + ) + + target_unix.add_plugin(TomcatPlugin) + records = list(target_unix.tomcat.access()) + assert len(records) == 2 + + +def test_unix_logs_deleted_install(target_unix: Target, fs_unix: VirtualFilesystem) -> None: + """Test if we still find a custom log file if the current ``server.xml`` no longer references to it.""" + fs_unix.map_file("/etc/tomcat10/server.xml", absolute_path("_data/plugins/apps/webserver/tomcat/server.xml")) + fs_unix.map_file( + "/var/log/tomcat10/localhost_access_log.2026-01-01.txt", + absolute_path("_data/plugins/apps/webserver/tomcat/localhost_access_log.2026-01-01.txt"), + ) + target_unix.add_plugin(TomcatPlugin) + + records = list(target_unix.tomcat.access()) + assert len(records) == 2 + + records = list(target_unix.tomcat.hosts()) + assert len(records) == 1 + + assert records[0].webserver == "tomcat" + assert records[0].server_name == "localhost" + assert records[0].server_port == 8080 + assert records[0].access_log_config == "logs/localhost_access_log*.txt" + assert records[0].source == "/etc/tomcat10/server.xml" + + +def test_win_logs_default_install(target_win: Target, fs_win: VirtualFilesystem) -> None: + """Test if we can find and detect a XAMPP Tomcat install on a Windows machine.""" + fs_win.map_file("xampp/tomcat/conf/server.xml", absolute_path("_data/plugins/apps/webserver/tomcat/server.xml")) + fs_win.map_file( + "xampp/tomcat/logs/localhost_access_log.2026-01-01.txt", + absolute_path("_data/plugins/apps/webserver/tomcat/localhost_access_log.2026-01-01.txt"), + ) + target_win.add_plugin(TomcatPlugin) + + records = list(target_win.tomcat.access()) + assert len(records) == 2 + + records = list(target_win.tomcat.hosts()) + assert len(records) == 1 + + assert records[0].webserver == "tomcat" + assert records[0].server_name == "localhost" + assert records[0].server_port == 8080 + assert records[0].access_log_config == "logs/localhost_access_log*.txt" + assert records[0].source == "\\sysvol\\xampp\\tomcat\\conf\\server.xml"