test coverage improvements; mTLS enforcement; agent broke :(
This commit is contained in:
1 parent
921b760f80
commit
31b726b439
43 files changed
+3343
-80
No files matched your search
@@ -1,26 +1,85 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import timedelta
|
||||
from datetime import datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from urllib.parse import quote
|
||||
from unittest.mock import patch
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.x509.oid import NameOID
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from guardian.shortcuts import assign_perm
|
||||
from ninja.errors import HttpError
|
||||
|
||||
from apps.servers.models import Server, ServerAuditLog, ServerLogSource
|
||||
from keywarden.api.security import hash_agent_token
|
||||
from apps.access.models import AccessRequest
|
||||
from apps.keys.models import SSHKey
|
||||
from apps.keys.models import SSHCertificate, SSHCertificateAuthority, parse_public_key
|
||||
from apps.servers.models import AgentCertificateAuthority, Server, ServerAuditLog, ServerLogSource
|
||||
from keywarden.api.routers import agent as agent_router
|
||||
|
||||
|
||||
class AgentLogIngestionTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.server = Server.objects.create(display_name="web-01", hostname="web-01.example.test")
|
||||
self.agent_token = "test-agent-token"
|
||||
self.server.agent_api_token_hash = hash_agent_token(self.agent_token)
|
||||
self.server.save(update_fields=["agent_api_token_hash"])
|
||||
self.agent_auth = f"Bearer {self.agent_token}"
|
||||
self._configure_agent_mtls(self.server)
|
||||
|
||||
def _configure_agent_mtls(self, server: Server) -> None:
|
||||
ca_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
ca_subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Keywarden Test Agent CA")])
|
||||
now = datetime.utcnow()
|
||||
ca_cert = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(ca_subject)
|
||||
.issuer_name(ca_subject)
|
||||
.public_key(ca_key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(now - timedelta(minutes=5))
|
||||
.not_valid_after(now + timedelta(days=365))
|
||||
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
|
||||
.sign(ca_key, hashes.SHA256())
|
||||
)
|
||||
ca_pem = ca_cert.public_bytes(serialization.Encoding.PEM).decode("utf-8")
|
||||
ca_key_pem = ca_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
).decode("utf-8")
|
||||
AgentCertificateAuthority.objects.create(
|
||||
name="Agent Test CA",
|
||||
cert_pem=ca_pem,
|
||||
key_pem=ca_key_pem,
|
||||
is_active=True,
|
||||
)
|
||||
agent_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
agent_cert = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "agent.example.test")]))
|
||||
.issuer_name(ca_cert.subject)
|
||||
.public_key(agent_key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(now - timedelta(minutes=5))
|
||||
.not_valid_after(now + timedelta(days=90))
|
||||
.sign(ca_key, hashes.SHA256())
|
||||
)
|
||||
cert_pem = agent_cert.public_bytes(serialization.Encoding.PEM).decode("utf-8")
|
||||
fingerprint = agent_cert.fingerprint(hashes.SHA256()).hex()
|
||||
serial = format(agent_cert.serial_number, "x")
|
||||
server.agent_cert_fingerprint = fingerprint
|
||||
server.agent_cert_serial = serial
|
||||
server.save(update_fields=["agent_cert_fingerprint", "agent_cert_serial"])
|
||||
self.agent_headers = {
|
||||
"HTTP_X_KEYWARDEN_TLS_CLIENT_VERIFY": "SUCCESS",
|
||||
"HTTP_X_KEYWARDEN_TLS_CLIENT_CERT": quote(cert_pem, safe=""),
|
||||
"HTTP_X_KEYWARDEN_TLS_CLIENT_FINGERPRINT": fingerprint,
|
||||
}
|
||||
|
||||
def test_ingest_logs_persists_events(self) -> None:
|
||||
payload = [
|
||||
@@ -50,7 +109,7 @@ class AgentLogIngestionTests(TestCase):
|
||||
f"/api/v1/agent/servers/{self.server.id}/logs",
|
||||
data=json.dumps(payload),
|
||||
content_type="application/json",
|
||||
HTTP_AUTHORIZATION=self.agent_auth,
|
||||
**self.agent_headers,
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
@@ -74,11 +133,11 @@ class AgentLogIngestionTests(TestCase):
|
||||
"/api/v1/agent/servers/999999/logs",
|
||||
data=json.dumps([]),
|
||||
content_type="application/json",
|
||||
HTTP_AUTHORIZATION=self.agent_auth,
|
||||
**self.agent_headers,
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_ingest_logs_requires_agent_token(self) -> None:
|
||||
def test_ingest_logs_requires_agent_mtls(self) -> None:
|
||||
response = self.client.post(
|
||||
f"/api/v1/agent/servers/{self.server.id}/logs",
|
||||
data=json.dumps([]),
|
||||
@@ -86,15 +145,13 @@ class AgentLogIngestionTests(TestCase):
|
||||
)
|
||||
self.assertEqual(response.status_code, 401)
|
||||
|
||||
def test_ingest_logs_rejects_token_for_different_server(self) -> None:
|
||||
def test_ingest_logs_rejects_mtls_cert_for_different_server(self) -> None:
|
||||
other_server = Server.objects.create(display_name="web-02", hostname="web-02.example.test")
|
||||
other_server.agent_api_token_hash = hash_agent_token("other-server-token")
|
||||
other_server.save(update_fields=["agent_api_token_hash"])
|
||||
response = self.client.post(
|
||||
f"/api/v1/agent/servers/{self.server.id}/logs",
|
||||
f"/api/v1/agent/servers/{other_server.id}/logs",
|
||||
data=json.dumps([]),
|
||||
content_type="application/json",
|
||||
HTTP_AUTHORIZATION="Bearer other-server-token",
|
||||
**self.agent_headers,
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
@@ -129,7 +186,7 @@ class AgentLogIngestionTests(TestCase):
|
||||
)
|
||||
response = self.client.get(
|
||||
f"/api/v1/agent/servers/{self.server.id}/log-config",
|
||||
HTTP_AUTHORIZATION=self.agent_auth,
|
||||
**self.agent_headers,
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()
|
||||
@@ -145,7 +202,7 @@ class AgentLogIngestionTests(TestCase):
|
||||
def test_log_config_returns_empty_when_no_sources(self) -> None:
|
||||
response = self.client.get(
|
||||
f"/api/v1/agent/servers/{self.server.id}/log-config",
|
||||
HTTP_AUTHORIZATION=self.agent_auth,
|
||||
**self.agent_headers,
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json(), [])
|
||||
@@ -219,7 +276,7 @@ class ServerAuditViewTests(TestCase):
|
||||
)
|
||||
|
||||
def test_audit_view_applies_filters(self) -> None:
|
||||
self.client.login(username="auditor", password="pass12345")
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(
|
||||
reverse("servers:audit", args=[self.server.id]),
|
||||
{
|
||||
@@ -234,8 +291,416 @@ class ServerAuditViewTests(TestCase):
|
||||
self.assertEqual(len(logs), 1)
|
||||
self.assertEqual(logs[0].event_type, "ssh.login.success")
|
||||
|
||||
|
||||
class AgentRouterHelpersTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
User = get_user_model()
|
||||
self.user = User.objects.create_user(
|
||||
username="helper",
|
||||
email="helper@example.com",
|
||||
password="pass12345",
|
||||
)
|
||||
self.server = Server.objects.create(display_name="helper-01", hostname="helper-01.example.test")
|
||||
assign_perm("view_server", self.user, self.server)
|
||||
|
||||
now = timezone.now()
|
||||
ServerAuditLog.objects.create(
|
||||
server=self.server,
|
||||
event_at=now - timedelta(minutes=5),
|
||||
source_kind="service",
|
||||
source_name="sshd.service",
|
||||
category="access",
|
||||
event_type="ssh.login.success",
|
||||
username="alice",
|
||||
source_ip="203.0.113.10",
|
||||
message="Accepted publickey for alice",
|
||||
)
|
||||
ServerAuditLog.objects.create(
|
||||
server=self.server,
|
||||
event_at=now - timedelta(minutes=4),
|
||||
source_kind="service",
|
||||
source_name="sshd.service",
|
||||
category="access",
|
||||
event_type="ssh.login.fail",
|
||||
username="bob",
|
||||
source_ip="198.51.100.5",
|
||||
message="Failed password for bob",
|
||||
)
|
||||
ServerAuditLog.objects.create(
|
||||
server=self.server,
|
||||
event_at=now - timedelta(minutes=3),
|
||||
source_kind="file",
|
||||
source_name="/var/log/auth.log",
|
||||
category="network",
|
||||
event_type="network",
|
||||
message="Interface eth0 changed",
|
||||
)
|
||||
ServerAuditLog.objects.create(
|
||||
server=self.server,
|
||||
event_at=now - timedelta(minutes=2),
|
||||
source_kind="file",
|
||||
source_name="/var/log/nginx/access.log",
|
||||
category="access",
|
||||
event_type="http.access",
|
||||
source_ip="203.0.113.10",
|
||||
message='192.0.2.40 - - [15/Apr/2026:23:57:44 +0000] "GET /api/v1/agent/servers/6/log-config HTTP/2.0" 200 682 "-"',
|
||||
fields={"nginx.status": "200"},
|
||||
)
|
||||
ServerAuditLog.objects.create(
|
||||
server=self.server,
|
||||
event_at=now - timedelta(minutes=1),
|
||||
source_kind="file",
|
||||
source_name="/var/log/nginx/access.log",
|
||||
category="access",
|
||||
event_type="http.access",
|
||||
source_ip="198.51.100.5",
|
||||
message='192.0.2.41 - - [15/Apr/2026:23:57:45 +0000] "GET /private HTTP/2.0" 401 245 "-"',
|
||||
fields={"nginx.status": "401"},
|
||||
)
|
||||
|
||||
def _build_ca_material(self):
|
||||
ca_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Keywarden Test Agent CA")])
|
||||
now = datetime.utcnow()
|
||||
ca_cert = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(subject)
|
||||
.issuer_name(subject)
|
||||
.public_key(ca_key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(now - timedelta(minutes=5))
|
||||
.not_valid_after(now + timedelta(days=365))
|
||||
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
|
||||
.sign(ca_key, hashes.SHA256())
|
||||
)
|
||||
ca_pem = ca_cert.public_bytes(serialization.Encoding.PEM).decode("utf-8")
|
||||
key_pem = ca_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
).decode("utf-8")
|
||||
return ca_cert, ca_key, ca_pem, key_pem
|
||||
|
||||
def _build_csr(self, common_name: str = "agent.example.test"):
|
||||
agent_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
csr = (
|
||||
x509.CertificateSigningRequestBuilder()
|
||||
.subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, common_name)]))
|
||||
.sign(agent_key, hashes.SHA256())
|
||||
)
|
||||
return csr
|
||||
|
||||
def test_ip_and_truncation_helpers(self) -> None:
|
||||
self.assertEqual(agent_router._normalize_ip("203.0.113.10", 4), "203.0.113.10")
|
||||
self.assertEqual(agent_router._normalize_ip("2001:db8::10", 6), "2001:db8::10")
|
||||
self.assertIsNone(agent_router._normalize_ip("not-ip", 4))
|
||||
self.assertEqual(agent_router._normalize_ip_any(" 203.0.113.11 "), "203.0.113.11")
|
||||
self.assertIsNone(agent_router._normalize_ip_any("bad-value"))
|
||||
self.assertEqual(agent_router._truncate(" abc ", 3), "abc")
|
||||
self.assertEqual(agent_router._truncate(None, 5, default=" xyz "), "xyz")
|
||||
|
||||
def test_parse_event_at_and_fields_coercion(self) -> None:
|
||||
fallback = datetime(2026, 4, 17, 12, 0, 0)
|
||||
parsed = agent_router._parse_event_at("2026-04-17T12:34:56Z", fallback)
|
||||
self.assertEqual(parsed.isoformat(), "2026-04-17T12:34:56+00:00")
|
||||
fallback_parsed = agent_router._parse_event_at("not-a-time", fallback)
|
||||
self.assertTrue(timezone.is_aware(fallback_parsed))
|
||||
coerced = agent_router._coerce_fields({"A": 1, "B": True, "": "skip"})
|
||||
self.assertEqual(coerced, {"A": "1", "B": "True"})
|
||||
self.assertEqual(agent_router._coerce_fields(None), {})
|
||||
|
||||
def test_source_kind_name_and_match_cleaning_helpers(self) -> None:
|
||||
self.assertEqual(agent_router._normalize_source_kind("service", None, None), "service")
|
||||
self.assertEqual(agent_router._normalize_source_kind(None, "sshd.service", None), "service")
|
||||
self.assertEqual(agent_router._normalize_source_kind(None, None, {"_TRANSPORT": "journal"}), "journal")
|
||||
self.assertEqual(agent_router._normalize_source_kind(None, None, {"file_path": "/var/log/auth.log"}), "file")
|
||||
self.assertEqual(agent_router._normalize_source_kind(None, None, None), "")
|
||||
|
||||
self.assertEqual(agent_router._normalize_source_name(" sshd ", "x", {}), "sshd")
|
||||
self.assertEqual(agent_router._normalize_source_name(None, "sudo.service", {}), "sudo.service")
|
||||
self.assertEqual(agent_router._normalize_source_name(None, None, {"_SYSTEMD_UNIT": "ssh.service"}), "ssh.service")
|
||||
self.assertEqual(agent_router._normalize_source_name(None, None, {"file_path": "/var/log/auth.log"}), "/var/log/auth.log")
|
||||
|
||||
clean = agent_router._clean_source_matches({"_TRANSPORT": [" kernel ", ""], "UNIT": " sshd.service "})
|
||||
self.assertEqual(clean, {"_TRANSPORT": ["kernel"], "UNIT": ["sshd.service"]})
|
||||
self.assertEqual(agent_router._clean_source_matches(None), {})
|
||||
|
||||
def test_default_category_and_event_type_helpers(self) -> None:
|
||||
journal = SimpleNamespace(
|
||||
kind=ServerLogSource.Kind.JOURNAL,
|
||||
service_unit="",
|
||||
include_matches={"_TRANSPORT": ["kernel"]},
|
||||
)
|
||||
service_ssh = SimpleNamespace(
|
||||
kind=ServerLogSource.Kind.SERVICE,
|
||||
service_unit="sshd.service",
|
||||
include_matches={},
|
||||
)
|
||||
service_sudo = SimpleNamespace(
|
||||
kind=ServerLogSource.Kind.SERVICE,
|
||||
service_unit="sudo.service",
|
||||
include_matches={},
|
||||
)
|
||||
file_source = SimpleNamespace(
|
||||
kind=ServerLogSource.Kind.FILE,
|
||||
service_unit="",
|
||||
include_matches={},
|
||||
)
|
||||
|
||||
self.assertEqual(agent_router._default_category_for_source(journal), "system")
|
||||
self.assertEqual(agent_router._default_category_for_source(service_ssh), "access")
|
||||
self.assertEqual(agent_router._default_category_for_source(service_sudo), "auth")
|
||||
self.assertEqual(agent_router._default_event_type_for_source(journal), "kernel")
|
||||
self.assertEqual(agent_router._default_event_type_for_source(service_ssh), "ssh")
|
||||
self.assertEqual(agent_router._default_event_type_for_source(service_sudo), "auth")
|
||||
self.assertEqual(agent_router._default_event_type_for_source(file_source), "file.line")
|
||||
|
||||
def test_agent_access_guards_and_token_generation(self) -> None:
|
||||
with self.assertRaises(HttpError) as unauth:
|
||||
agent_router._require_agent_access(SimpleNamespace(auth=None), self.server.id)
|
||||
self.assertEqual(unauth.exception.status_code, 401)
|
||||
|
||||
with self.assertRaises(HttpError) as forbidden:
|
||||
agent_router._require_agent_access(
|
||||
SimpleNamespace(auth=agent_router.AgentPrincipal(server_id=999, mode="mtls")),
|
||||
self.server.id,
|
||||
)
|
||||
self.assertEqual(forbidden.exception.status_code, 403)
|
||||
|
||||
agent_router._require_agent_access(
|
||||
SimpleNamespace(auth=agent_router.AgentPrincipal(server_id=self.server.id, mode="mtls")),
|
||||
self.server.id,
|
||||
)
|
||||
self.assertNotEqual(agent_router._issue_agent_api_token(), agent_router._issue_agent_api_token())
|
||||
|
||||
def test_server_lookup_key_map_and_account_sync_helpers(self) -> None:
|
||||
found = agent_router._get_server_or_404(self.server.id)
|
||||
self.assertEqual(found.id, self.server.id)
|
||||
with self.assertRaises(HttpError) as not_found:
|
||||
agent_router._get_server_or_404(999999)
|
||||
self.assertEqual(not_found.exception.status_code, 404)
|
||||
|
||||
key_active = SSHKey.objects.create(
|
||||
user=self.user,
|
||||
name="active",
|
||||
public_key="ssh-ed25519 AAAA",
|
||||
key_type="ssh-ed25519",
|
||||
fingerprint="fp-active",
|
||||
is_active=True,
|
||||
revoked_at=None,
|
||||
)
|
||||
SSHKey.objects.create(
|
||||
user=self.user,
|
||||
name="revoked",
|
||||
public_key="ssh-ed25519 BBBB",
|
||||
key_type="ssh-ed25519",
|
||||
fingerprint="fp-revoked",
|
||||
is_active=True,
|
||||
revoked_at=timezone.now(),
|
||||
)
|
||||
key_map = agent_router._key_map_for_users([self.user])
|
||||
self.assertIn(self.user.id, key_map)
|
||||
self.assertEqual([item.id for item in key_map[self.user.id]], [key_active.id])
|
||||
|
||||
payload = [agent_router.AccountSyncIn(user_id=self.user.id, system_username="helper_1", present=True)]
|
||||
agent_router._update_server_accounts(self.server, payload)
|
||||
account = self.server.accounts.get(user=self.user)
|
||||
self.assertEqual(account.system_username, "helper_1")
|
||||
self.assertTrue(account.is_present)
|
||||
|
||||
def test_csr_and_agent_ca_helpers(self) -> None:
|
||||
with self.assertRaises(HttpError) as bad_csr:
|
||||
agent_router._load_csr("not a csr")
|
||||
self.assertEqual(bad_csr.exception.status_code, 422)
|
||||
|
||||
with patch("keywarden.api.routers.agent.x509.load_pem_x509_csr", return_value=SimpleNamespace(is_signature_valid=False)):
|
||||
with self.assertRaises(HttpError) as bad_sig:
|
||||
agent_router._load_csr("dummy")
|
||||
self.assertEqual(bad_sig.exception.status_code, 422)
|
||||
|
||||
ca_cert, ca_key, ca_pem, key_pem = self._build_ca_material()
|
||||
ca_record = agent_router.AgentCertificateAuthority.objects.create(
|
||||
name="Test Agent CA",
|
||||
cert_pem=ca_pem,
|
||||
key_pem=key_pem,
|
||||
is_active=True,
|
||||
)
|
||||
loaded_cert, loaded_key, loaded_pem = agent_router._load_agent_ca()
|
||||
self.assertEqual(loaded_pem, ca_record.cert_pem)
|
||||
self.assertIsNotNone(loaded_cert)
|
||||
self.assertIsNotNone(loaded_key)
|
||||
|
||||
csr = self._build_csr("agent-1.example.test")
|
||||
with patch("keywarden.api.routers.agent._load_agent_ca", return_value=(ca_cert, ca_key, ca_pem)):
|
||||
cert_pem, returned_ca_pem, fingerprint, serial = agent_router._issue_client_cert(
|
||||
csr=csr,
|
||||
host="agent-1.example.test",
|
||||
server_id=self.server.id,
|
||||
)
|
||||
self.assertIn("BEGIN CERTIFICATE", cert_pem)
|
||||
self.assertEqual(returned_ca_pem, ca_pem)
|
||||
self.assertTrue(fingerprint)
|
||||
self.assertTrue(serial)
|
||||
|
||||
|
||||
class KeysModelHelpersTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
User = get_user_model()
|
||||
self.user = User.objects.create_user(
|
||||
username="keys-user",
|
||||
email="keys-user@example.com",
|
||||
password="pass12345",
|
||||
)
|
||||
self.server = Server.objects.create(display_name="keys-server", hostname="keys-server.example.test")
|
||||
assign_perm("view_server", self.user, self.server)
|
||||
now = timezone.now()
|
||||
ServerAuditLog.objects.create(
|
||||
server=self.server,
|
||||
event_at=now - timedelta(minutes=5),
|
||||
source_kind="service",
|
||||
source_name="sshd.service",
|
||||
category="access",
|
||||
event_type="ssh.login.success",
|
||||
username="alice",
|
||||
source_ip="203.0.113.10",
|
||||
message="Accepted publickey for alice",
|
||||
)
|
||||
ServerAuditLog.objects.create(
|
||||
server=self.server,
|
||||
event_at=now - timedelta(minutes=4),
|
||||
source_kind="service",
|
||||
source_name="sshd.service",
|
||||
category="access",
|
||||
event_type="ssh.login.fail",
|
||||
username="bob",
|
||||
source_ip="198.51.100.5",
|
||||
message="Failed password for bob",
|
||||
)
|
||||
ServerAuditLog.objects.create(
|
||||
server=self.server,
|
||||
event_at=now - timedelta(minutes=3),
|
||||
source_kind="file",
|
||||
source_name="/var/log/auth.log",
|
||||
category="network",
|
||||
event_type="network",
|
||||
message="Interface eth0 changed",
|
||||
)
|
||||
ServerAuditLog.objects.create(
|
||||
server=self.server,
|
||||
event_at=now - timedelta(minutes=2),
|
||||
source_kind="file",
|
||||
source_name="/var/log/nginx/access.log",
|
||||
category="access",
|
||||
event_type="http.access",
|
||||
source_ip="203.0.113.10",
|
||||
message='192.0.2.40 - - [15/Apr/2026:23:57:44 +0000] "GET /api/v1/agent/servers/6/log-config HTTP/2.0" 200 682 "-"',
|
||||
fields={"nginx.status": "200"},
|
||||
)
|
||||
ServerAuditLog.objects.create(
|
||||
server=self.server,
|
||||
event_at=now - timedelta(minutes=1),
|
||||
source_kind="file",
|
||||
source_name="/var/log/nginx/access.log",
|
||||
category="access",
|
||||
event_type="http.access",
|
||||
source_ip="198.51.100.5",
|
||||
message='192.0.2.41 - - [15/Apr/2026:23:57:45 +0000] "GET /private HTTP/2.0" 401 245 "-"',
|
||||
fields={"nginx.status": "401"},
|
||||
)
|
||||
|
||||
def test_parse_public_key_valid_and_invalid(self) -> None:
|
||||
key_type, key_b64, fingerprint = parse_public_key("ssh-ed25519 AAAA comment")
|
||||
self.assertEqual(key_type, "ssh-ed25519")
|
||||
self.assertEqual(key_b64, "AAAA")
|
||||
self.assertTrue(fingerprint.startswith("SHA256:"))
|
||||
with self.assertRaises(ValidationError):
|
||||
parse_public_key("invalid-key")
|
||||
|
||||
def test_sshkey_set_public_key_and_revoke(self) -> None:
|
||||
key = SSHKey.objects.create(
|
||||
user=self.user,
|
||||
name="laptop",
|
||||
public_key="ssh-ed25519 AAAA",
|
||||
key_type="ssh-ed25519",
|
||||
fingerprint="fp-laptop",
|
||||
is_active=True,
|
||||
)
|
||||
key.set_public_key("ssh-ed25519 AAAA comment-to-drop")
|
||||
self.assertEqual(key.public_key, "ssh-ed25519 AAAA")
|
||||
now = timezone.now()
|
||||
cert = SSHCertificate.objects.create(
|
||||
key=key,
|
||||
user=self.user,
|
||||
certificate="ssh-ed25519-cert-v01 AAAA",
|
||||
serial=42,
|
||||
principals=["keys_user"],
|
||||
valid_after=now - timedelta(minutes=1),
|
||||
valid_before=now + timedelta(days=1),
|
||||
is_active=True,
|
||||
)
|
||||
key.revoke()
|
||||
key.save(update_fields=["is_active", "revoked_at"])
|
||||
key.refresh_from_db()
|
||||
cert.refresh_from_db()
|
||||
self.assertFalse(key.is_active)
|
||||
self.assertIsNotNone(key.revoked_at)
|
||||
self.assertFalse(cert.is_active)
|
||||
self.assertIsNotNone(cert.revoked_at)
|
||||
|
||||
def test_ssh_certificate_authority_ensure_material_paths(self) -> None:
|
||||
ca = SSHCertificateAuthority(
|
||||
name="Existing CA",
|
||||
public_key="ssh-ed25519 AAAA",
|
||||
private_key="-----BEGIN OPENSSH PRIVATE KEY-----\nPRIVATE\n-----END OPENSSH PRIVATE KEY-----\n",
|
||||
fingerprint="",
|
||||
)
|
||||
ca.ensure_material()
|
||||
self.assertTrue(ca.fingerprint.startswith("SHA256:"))
|
||||
self.assertTrue(ca.private_key.startswith("file://"))
|
||||
|
||||
generated = SSHCertificateAuthority(name="Generated CA")
|
||||
|
||||
def fake_run(cmd, check, capture_output):
|
||||
key_path = cmd[cmd.index("-f") + 1]
|
||||
with open(key_path, "w", encoding="utf-8") as handle:
|
||||
handle.write("PRIVATE-KEY\n")
|
||||
with open(key_path + ".pub", "w", encoding="utf-8") as handle:
|
||||
handle.write("ssh-ed25519 AAAA generated-ca\n")
|
||||
return SimpleNamespace(returncode=0)
|
||||
|
||||
with patch("apps.keys.models.subprocess.run", side_effect=fake_run):
|
||||
generated.ensure_material()
|
||||
self.assertTrue(generated.private_key)
|
||||
self.assertEqual(generated.public_key, "ssh-ed25519 AAAA generated-ca")
|
||||
self.assertTrue(generated.fingerprint.startswith("SHA256:"))
|
||||
|
||||
def test_ssh_certificate_revoke(self) -> None:
|
||||
key = SSHKey.objects.create(
|
||||
user=self.user,
|
||||
name="desktop",
|
||||
public_key="ssh-ed25519 AAAA",
|
||||
key_type="ssh-ed25519",
|
||||
fingerprint="fp-desktop",
|
||||
is_active=True,
|
||||
)
|
||||
cert = SSHCertificate.objects.create(
|
||||
key=key,
|
||||
user=self.user,
|
||||
certificate="ssh-ed25519-cert-v01 AAAA",
|
||||
serial=77,
|
||||
principals=["keys_user"],
|
||||
valid_after=timezone.now() - timedelta(minutes=1),
|
||||
valid_before=timezone.now() + timedelta(days=1),
|
||||
is_active=True,
|
||||
)
|
||||
cert.revoke()
|
||||
self.assertFalse(cert.is_active)
|
||||
self.assertIsNotNone(cert.revoked_at)
|
||||
|
||||
def test_audit_panel_view_supports_source_filters(self) -> None:
|
||||
self.client.login(username="auditor", password="pass12345")
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(
|
||||
reverse("servers:audit_panel", args=[self.server.id]),
|
||||
{
|
||||
@@ -245,10 +710,11 @@ class ServerAuditViewTests(TestCase):
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "/var/log/auth.log")
|
||||
self.assertNotContains(response, "ssh.login.success")
|
||||
self.assertContains(response, "Interface eth0 changed")
|
||||
self.assertNotContains(response, "Accepted publickey for alice")
|
||||
|
||||
def test_audit_view_exposes_chart_context(self) -> None:
|
||||
self.client.login(username="auditor", password="pass12345")
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(
|
||||
reverse("servers:audit", args=[self.server.id]),
|
||||
{"view": "charts"},
|
||||
@@ -272,7 +738,7 @@ class ServerAuditViewTests(TestCase):
|
||||
self.assertEqual(charts["http_status_codes"]["implicit_total"], 1)
|
||||
|
||||
def test_audit_view_drilldown_filters_logs_for_http_status(self) -> None:
|
||||
self.client.login(username="auditor", password="pass12345")
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(
|
||||
reverse("servers:audit", args=[self.server.id]),
|
||||
{"view": "logs", "http_status": "401"},
|
||||
@@ -284,7 +750,7 @@ class ServerAuditViewTests(TestCase):
|
||||
self.assertIn(" 401 ", logs[0].message)
|
||||
|
||||
def test_audit_view_drilldown_filters_logs_for_login_outcome(self) -> None:
|
||||
self.client.login(username="auditor", password="pass12345")
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(
|
||||
reverse("servers:audit", args=[self.server.id]),
|
||||
{"view": "logs", "login_outcome": "success"},
|
||||
|
||||
Reference in new issue
Block a user