Add audit logging
This commit is contained in:
66
src/sshecret/audit.py
Normal file
66
src/sshecret/audit.py
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
"""Audit setup."""
|
||||||
|
|
||||||
|
import enum
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
from pythonjsonlogger.json import JsonFormatter
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
from .constants import AUDIT_LOG_NAME
|
||||||
|
|
||||||
|
AUDIT_LOG = logging.getLogger(AUDIT_LOG_NAME)
|
||||||
|
|
||||||
|
|
||||||
|
class AuditMessageType(enum.StrEnum):
|
||||||
|
"""Audit Message Type."""
|
||||||
|
|
||||||
|
ACCESS = enum.auto() # Someone accessed something
|
||||||
|
SECURITY = enum.auto() # A message related to security
|
||||||
|
INFORMATIONAL = enum.auto() # other informational messages
|
||||||
|
|
||||||
|
|
||||||
|
class AuditMessage(BaseModel):
|
||||||
|
"""Audit message."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(use_enum_values=True)
|
||||||
|
|
||||||
|
type: AuditMessageType
|
||||||
|
message: str
|
||||||
|
client_name: str | None = None
|
||||||
|
source_address: str | None = None
|
||||||
|
secret_name: str | None = None
|
||||||
|
details: str | None = None
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
"""Stringify object as JSON."""
|
||||||
|
return self.model_dump_json()
|
||||||
|
|
||||||
|
|
||||||
|
def audit_message(
|
||||||
|
message: str,
|
||||||
|
audit_type: AuditMessageType | str | None = None,
|
||||||
|
client_name: str | None = None,
|
||||||
|
secret_name: str | None = None,
|
||||||
|
source_address: str | None = None,
|
||||||
|
details: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Create an audit message."""
|
||||||
|
if not audit_type:
|
||||||
|
audit_type = AuditMessageType.INFORMATIONAL
|
||||||
|
|
||||||
|
if audit_type not in list(AuditMessageType):
|
||||||
|
audit_type = AuditMessageType.INFORMATIONAL
|
||||||
|
|
||||||
|
audit_message = AuditMessage(
|
||||||
|
type=audit_type,
|
||||||
|
message=message,
|
||||||
|
client_name=client_name,
|
||||||
|
source_address=source_address,
|
||||||
|
secret_name=secret_name,
|
||||||
|
details=details,
|
||||||
|
)
|
||||||
|
|
||||||
|
AUDIT_LOG.info(audit_message.model_dump(exclude_none=True))
|
||||||
@ -13,3 +13,5 @@ ERROR_SOURCE_IP_NOT_ALLOWED = (
|
|||||||
|
|
||||||
RSA_PUBLIC_EXPONENT = 65537
|
RSA_PUBLIC_EXPONENT = 65537
|
||||||
RSA_KEY_SIZE = 2048
|
RSA_KEY_SIZE = 2048
|
||||||
|
|
||||||
|
AUDIT_LOG_NAME = "AUDIT"
|
||||||
|
|||||||
@ -10,9 +10,12 @@ import tempfile
|
|||||||
import threading
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .server.async_server import start_server
|
from pythonjsonlogger.json import JsonFormatter
|
||||||
|
|
||||||
|
from .server import start_server
|
||||||
from sshecret.backends import FileTableBackend
|
from sshecret.backends import FileTableBackend
|
||||||
from .utils import create_client_file, add_secret_to_client_file
|
from .utils import create_client_file, add_secret_to_client_file
|
||||||
|
from .constants import AUDIT_LOG_NAME
|
||||||
|
|
||||||
|
|
||||||
def thread_id_filter(record: logging.LogRecord) -> logging.LogRecord:
|
def thread_id_filter(record: logging.LogRecord) -> logging.LogRecord:
|
||||||
@ -31,6 +34,12 @@ handler.setFormatter(formatter)
|
|||||||
LOG.addHandler(handler)
|
LOG.addHandler(handler)
|
||||||
LOG.setLevel(logging.DEBUG)
|
LOG.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
AUDIT_LOG = logging.getLogger(AUDIT_LOG_NAME)
|
||||||
|
audit_formatter = JsonFormatter()
|
||||||
|
audit_handler = logging.StreamHandler()
|
||||||
|
audit_handler.setFormatter(audit_formatter)
|
||||||
|
AUDIT_LOG.addHandler(audit_handler)
|
||||||
|
|
||||||
|
|
||||||
@click.group()
|
@click.group()
|
||||||
def cli() -> None:
|
def cli() -> None:
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
"""Sshecret server module."""
|
"""Sshecret server module."""
|
||||||
|
|
||||||
from .server import SshKeyServer
|
from .async_server import AsshyncServer, start_server
|
||||||
|
|
||||||
__all__ = ["SshKeyServer"]
|
__all__ = ["AsshyncServer", "start_server"]
|
||||||
|
|||||||
@ -8,6 +8,7 @@ from typing import override
|
|||||||
import asyncssh
|
import asyncssh
|
||||||
|
|
||||||
from sshecret import constants
|
from sshecret import constants
|
||||||
|
from sshecret.audit import audit_message
|
||||||
from sshecret.types import ClientSpecification, BaseClientBackend
|
from sshecret.types import ClientSpecification, BaseClientBackend
|
||||||
from sshecret.crypto import create_private_rsa_key
|
from sshecret.crypto import create_private_rsa_key
|
||||||
|
|
||||||
@ -17,26 +18,31 @@ LOG = logging.getLogger(__name__)
|
|||||||
|
|
||||||
def handle_client(process: asyncssh.SSHServerProcess[str]) -> None:
|
def handle_client(process: asyncssh.SSHServerProcess[str]) -> None:
|
||||||
"""Handle client."""
|
"""Handle client."""
|
||||||
|
remote_ip = process.get_extra_info("peername")[0]
|
||||||
client_found = process.get_extra_info("client_allowed", False)
|
client_found = process.get_extra_info("client_allowed", False)
|
||||||
if not client_found:
|
if not client_found:
|
||||||
process.stderr.write(constants.ERROR_UKNOWN_CLIENT_OR_SECRET + "\n")
|
process.stderr.write(constants.ERROR_UKNOWN_CLIENT_OR_SECRET + "\n")
|
||||||
|
audit_message("Unknown connection", source_address=remote_ip)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
return
|
return
|
||||||
|
|
||||||
client_allowed = process.get_extra_info("client_allowed", False)
|
client_allowed = process.get_extra_info("client_allowed", False)
|
||||||
if not client_allowed:
|
if not client_allowed:
|
||||||
|
audit_message("Not permitted", "SECURITY", source_address=remote_ip)
|
||||||
process.stderr.write(constants.ERROR_SOURCE_IP_NOT_ALLOWED + "\n")
|
process.stderr.write(constants.ERROR_SOURCE_IP_NOT_ALLOWED + "\n")
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
return
|
return
|
||||||
|
|
||||||
client = process.get_extra_info("client")
|
client = process.get_extra_info("client")
|
||||||
if not client:
|
if not client:
|
||||||
|
audit_message("Unknown client", source_address=remote_ip)
|
||||||
process.stderr.write(constants.ERROR_UKNOWN_CLIENT_OR_SECRET + "\n")
|
process.stderr.write(constants.ERROR_UKNOWN_CLIENT_OR_SECRET + "\n")
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
return
|
return
|
||||||
|
|
||||||
secret_name = process.command
|
secret_name = process.command
|
||||||
if not secret_name:
|
if not secret_name:
|
||||||
|
audit_message("No secret specified", source_address=remote_ip, client_name=client.name)
|
||||||
process.stderr.write(constants.ERROR_UKNOWN_CLIENT_OR_SECRET + "\n")
|
process.stderr.write(constants.ERROR_UKNOWN_CLIENT_OR_SECRET + "\n")
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
return
|
return
|
||||||
@ -45,12 +51,14 @@ def handle_client(process: asyncssh.SSHServerProcess[str]) -> None:
|
|||||||
"Client %s successfully connected. Fetching secret %s", client.name, secret_name
|
"Client %s successfully connected. Fetching secret %s", client.name, secret_name
|
||||||
)
|
)
|
||||||
|
|
||||||
|
audit_message(f"Requested secret", client_name=client.name, secret_name=secret_name, source_address=remote_ip)
|
||||||
secret = client.secrets.get(secret_name)
|
secret = client.secrets.get(secret_name)
|
||||||
if not secret:
|
if not secret:
|
||||||
process.stderr.write(constants.ERROR_UKNOWN_CLIENT_OR_SECRET + "\n")
|
process.stderr.write(constants.ERROR_UKNOWN_CLIENT_OR_SECRET + "\n")
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
audit_message("Accessed secret", client.name, secret_name, source_address=remote_ip)
|
||||||
process.stdout.write(secret)
|
process.stdout.write(secret)
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
|
|
||||||
@ -66,6 +74,8 @@ class AsshyncServer(asyncssh.SSHServer):
|
|||||||
@override
|
@override
|
||||||
def connection_made(self, conn: asyncssh.SSHServerConnection) -> None:
|
def connection_made(self, conn: asyncssh.SSHServerConnection) -> None:
|
||||||
"""Handle incoming connection."""
|
"""Handle incoming connection."""
|
||||||
|
peername = conn.get_extra_info("peername")
|
||||||
|
LOG.debug("Connection established from %r", peername)
|
||||||
self._conn = conn
|
self._conn = conn
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -81,6 +91,7 @@ class AsshyncServer(asyncssh.SSHServer):
|
|||||||
LOG.debug("Remote_IP: %r", remote_ip)
|
LOG.debug("Remote_IP: %r", remote_ip)
|
||||||
assert isinstance(remote_ip, str)
|
assert isinstance(remote_ip, str)
|
||||||
if self.check_connection_allowed(client, remote_ip):
|
if self.check_connection_allowed(client, remote_ip):
|
||||||
|
audit_message("Authentication requested", "ACCESS", client_name=client.name, source_address=remote_ip)
|
||||||
self._conn.set_extra_info(client_allowed=True)
|
self._conn.set_extra_info(client_allowed=True)
|
||||||
self._conn.set_extra_info(client=client)
|
self._conn.set_extra_info(client=client)
|
||||||
|
|
||||||
@ -101,10 +112,12 @@ class AsshyncServer(asyncssh.SSHServer):
|
|||||||
"""Check if client is allowed to request secrets."""
|
"""Check if client is allowed to request secrets."""
|
||||||
LOG.debug("Checking if client is allowed to log in from %s", source)
|
LOG.debug("Checking if client is allowed to log in from %s", source)
|
||||||
if isinstance(client.allowed_ips, str) and client.allowed_ips == "*":
|
if isinstance(client.allowed_ips, str) and client.allowed_ips == "*":
|
||||||
|
audit_message("Permitting login", "SECURITY", client_name=client.name, source_address=source)
|
||||||
LOG.debug("Client has no restrictions on source IP address. Permitting.")
|
LOG.debug("Client has no restrictions on source IP address. Permitting.")
|
||||||
return True
|
return True
|
||||||
if isinstance(client.allowed_ips, str) and "/" not in client.allowed_ips:
|
if isinstance(client.allowed_ips, str) and "/" not in client.allowed_ips:
|
||||||
if source == client.allowed_ips:
|
if source == client.allowed_ips:
|
||||||
|
audit_message("Permitting login", "SECURITY", client_name=client.name, source_address=source)
|
||||||
LOG.debug("Client IP matches permitted address")
|
LOG.debug("Client IP matches permitted address")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@ -113,6 +126,7 @@ class AsshyncServer(asyncssh.SSHServer):
|
|||||||
client.name,
|
client.name,
|
||||||
source,
|
source,
|
||||||
)
|
)
|
||||||
|
audit_message("REJECTED. Invalid address", "SECURITY", client_name=client.name, source_address=source)
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user