mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-20 02:14:18 +00:00
369 lines
11 KiB
Python
369 lines
11 KiB
Python
import errno
|
|
import logging
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from config.settings import sentry as sentry_settings
|
|
from config.settings.sentry import before_send, errno_fingerprint
|
|
|
|
|
|
def test_initialize_sentry_skips_without_dsn():
|
|
with (
|
|
patch.object(sentry_settings.env, "str", return_value=""),
|
|
patch.object(sentry_settings.sentry_sdk, "init") as mock_init,
|
|
):
|
|
sentry_settings.initialize_sentry()
|
|
|
|
mock_init.assert_not_called()
|
|
|
|
|
|
def test_initialize_sentry_uses_configured_dsn():
|
|
sentry_dsn = "https://fake-public-key@sentry.example.invalid/1"
|
|
|
|
with (
|
|
patch.object(sentry_settings.env, "str", return_value=sentry_dsn),
|
|
patch.object(sentry_settings.sentry_sdk, "init") as mock_init,
|
|
):
|
|
sentry_settings.initialize_sentry()
|
|
|
|
assert mock_init.call_args.kwargs["dsn"] == sentry_dsn
|
|
assert mock_init.call_args.kwargs["before_send"] is sentry_settings.before_send
|
|
|
|
|
|
def test_initialize_sentry_sends_no_personal_data():
|
|
with (
|
|
patch.object(
|
|
sentry_settings.env,
|
|
"str",
|
|
return_value="https://fake-public-key@sentry.example.invalid/1",
|
|
),
|
|
patch.object(sentry_settings.sentry_sdk, "init") as mock_init,
|
|
):
|
|
sentry_settings.initialize_sentry()
|
|
|
|
assert mock_init.call_args.kwargs["send_default_pii"] is False
|
|
assert mock_init.call_args.kwargs["max_request_body_size"] == "never"
|
|
|
|
|
|
def _make_log_record(msg, level=logging.ERROR, name="test", args=None):
|
|
"""Build a real LogRecord so getMessage() works like in production."""
|
|
record = logging.LogRecord(
|
|
name=name,
|
|
level=level,
|
|
pathname="",
|
|
lineno=0,
|
|
msg=msg,
|
|
args=args,
|
|
exc_info=None,
|
|
)
|
|
return record
|
|
|
|
|
|
def test_before_send_ignores_log_with_ignored_exception():
|
|
"""Test that before_send ignores logs containing ignored exceptions."""
|
|
log_record = _make_log_record("Provider kubernetes is not connected")
|
|
|
|
hint = {"log_record": log_record}
|
|
event = MagicMock()
|
|
|
|
result = before_send(event, hint)
|
|
|
|
# Assert that the event was dropped (None returned)
|
|
assert result is None
|
|
|
|
|
|
def test_before_send_ignores_exception_with_ignored_exception():
|
|
"""Test that before_send ignores exceptions containing ignored exceptions."""
|
|
exc_info = (Exception, Exception("Provider kubernetes is not connected"), None)
|
|
|
|
hint = {"exc_info": exc_info}
|
|
|
|
event = MagicMock()
|
|
|
|
result = before_send(event, hint)
|
|
|
|
# Assert that the event was dropped (None returned)
|
|
assert result is None
|
|
|
|
|
|
def test_before_send_passes_through_non_ignored_log():
|
|
"""Test that before_send passes through logs that don't contain ignored exceptions."""
|
|
log_record = _make_log_record("Some other error message")
|
|
|
|
hint = {"log_record": log_record}
|
|
event = MagicMock()
|
|
|
|
result = before_send(event, hint)
|
|
|
|
# Assert that the event was passed through
|
|
assert result == event
|
|
|
|
|
|
def test_before_send_ignores_cartography_missing_temporary_database_log():
|
|
log_record = _make_log_record(
|
|
msg="Cartography job failed with %s for database %s",
|
|
name="cartography.graph.job",
|
|
args=(
|
|
"Neo.ClientError.Database.DatabaseNotFound",
|
|
"db-tmp-scan-12345678",
|
|
),
|
|
)
|
|
|
|
event = MagicMock()
|
|
|
|
assert before_send(event, {"log_record": log_record}) is None
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("logger_name", "message"),
|
|
[
|
|
(
|
|
"cartography.graph.job.worker",
|
|
"Neo.ClientError.Database.DatabaseNotFound for db-tmp-scan-12345678",
|
|
),
|
|
(
|
|
"cartography.graph.job",
|
|
"DatabaseNotFound for db-tmp-scan-12345678",
|
|
),
|
|
(
|
|
"cartography.graph.job",
|
|
"Neo.ClientError.Database.DatabaseNotFound for db-tenant-12345678",
|
|
),
|
|
],
|
|
)
|
|
def test_before_send_passes_through_similar_cartography_logs(logger_name, message):
|
|
log_record = _make_log_record(msg=message, name=logger_name)
|
|
event = MagicMock()
|
|
|
|
assert before_send(event, {"log_record": log_record}) is event
|
|
|
|
|
|
def test_before_send_passes_through_non_ignored_exception():
|
|
"""Test that before_send passes through exceptions that don't contain ignored exceptions."""
|
|
exc_info = (Exception, Exception("Some other error message"), None)
|
|
|
|
hint = {"exc_info": exc_info}
|
|
|
|
event = MagicMock()
|
|
|
|
result = before_send(event, hint)
|
|
|
|
# Assert that the event was passed through
|
|
assert result == event
|
|
|
|
|
|
def test_before_send_handles_warning_level():
|
|
"""Test that before_send handles warning level logs."""
|
|
log_record = _make_log_record(
|
|
"Provider kubernetes is not connected", level=logging.WARNING
|
|
)
|
|
|
|
hint = {"log_record": log_record}
|
|
event = MagicMock()
|
|
|
|
result = before_send(event, hint)
|
|
|
|
# Assert that the event was dropped (None returned)
|
|
assert result is None
|
|
|
|
|
|
def test_before_send_ignores_neo4j_defunct_connection():
|
|
"""Test that before_send drops neo4j.io defunct connection logs.
|
|
|
|
The Neo4j driver logs transient connection errors at ERROR level
|
|
before RetryableSession retries them. These are noise.
|
|
|
|
The driver uses %s formatting, so "defunct" is in the args, not
|
|
in the template. This test mirrors the real LogRecord structure.
|
|
"""
|
|
log_record = _make_log_record(
|
|
msg="[#%04X] _: <CONNECTION> error: %s: %r",
|
|
name="neo4j.io",
|
|
args=(
|
|
0xE5CC,
|
|
"Failed to read from defunct connection "
|
|
"IPv4Address(('cloud-neo4j.prowler.com', 7687))",
|
|
ConnectionResetError(104, "Connection reset by peer"),
|
|
),
|
|
)
|
|
|
|
hint = {"log_record": log_record}
|
|
event = MagicMock()
|
|
|
|
assert before_send(event, hint) is None
|
|
|
|
|
|
def test_before_send_passes_non_defunct_neo4j_log():
|
|
"""Test that before_send passes through neo4j.io logs that are not about defunct connections."""
|
|
log_record = _make_log_record(
|
|
msg="Some other neo4j transport error",
|
|
name="neo4j.io",
|
|
)
|
|
|
|
hint = {"log_record": log_record}
|
|
event = MagicMock()
|
|
|
|
assert before_send(event, hint) == event
|
|
|
|
|
|
def _filesystem_hint(exception, msg="Error generating output directory"):
|
|
"""Build the hint the logging integration sends for a filesystem failure."""
|
|
exc_info = (type(exception), exception, exception.__traceback__)
|
|
log_record = _make_log_record(msg)
|
|
log_record.exc_info = exc_info
|
|
setattr(
|
|
log_record,
|
|
sentry_settings.ERROR_CATEGORY_ATTRIBUTE,
|
|
sentry_settings.FILESYSTEM_ERROR_CATEGORY,
|
|
)
|
|
return {"log_record": log_record, "exc_info": exc_info}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("error_number", "message", "expected_suffix"),
|
|
[
|
|
(errno.ENOSPC, "No space left on device", "errno:ENOSPC"),
|
|
(errno.ENOENT, "No such file or directory", "errno:ENOENT"),
|
|
(errno.EACCES, "Permission denied", "errno:EACCES"),
|
|
],
|
|
)
|
|
def test_before_send_fingerprints_oserror_by_errno(
|
|
error_number, message, expected_suffix
|
|
):
|
|
"""Filesystem failures raised from the same call site must not be merged."""
|
|
event = {}
|
|
|
|
result = before_send(event, _filesystem_hint(OSError(error_number, message)))
|
|
|
|
assert result is event
|
|
assert event["fingerprint"] == ["{{ default }}", expected_suffix]
|
|
|
|
|
|
def test_before_send_fingerprints_differ_per_errno():
|
|
"""ENOSPC and ENOENT from the same call site produce different issues."""
|
|
enospc_event = {}
|
|
enoent_event = {}
|
|
|
|
before_send(
|
|
enospc_event, _filesystem_hint(OSError(errno.ENOSPC, "No space left on device"))
|
|
)
|
|
before_send(
|
|
enoent_event,
|
|
_filesystem_hint(OSError(errno.ENOENT, "No such file or directory")),
|
|
)
|
|
|
|
assert enospc_event["fingerprint"] != enoent_event["fingerprint"]
|
|
|
|
|
|
def test_before_send_fingerprints_wrapped_oserror():
|
|
"""The errno is found even when the OSError is wrapped by another error."""
|
|
try:
|
|
try:
|
|
raise OSError(errno.ENOSPC, "No space left on device")
|
|
except OSError as os_error:
|
|
raise RuntimeError("Error generating output directory") from os_error
|
|
except RuntimeError as wrapper:
|
|
event = {}
|
|
before_send(event, _filesystem_hint(wrapper))
|
|
|
|
assert event["fingerprint"] == ["{{ default }}", "errno:ENOSPC"]
|
|
|
|
|
|
def test_before_send_does_not_fingerprint_non_oserror():
|
|
"""Non-filesystem exceptions keep Sentry's default grouping."""
|
|
event = {}
|
|
|
|
result = before_send(event, _filesystem_hint(ValueError("boom")))
|
|
|
|
assert result is event
|
|
assert "fingerprint" not in event
|
|
|
|
|
|
def test_before_send_does_not_fingerprint_unrelated_oserror_log():
|
|
"""Only records declaring the filesystem category opt into the errno grouping."""
|
|
exception = OSError(errno.ENOSPC, "No space left on device")
|
|
log_record = _make_log_record("Unrelated failure")
|
|
exc_info = (OSError, exception, None)
|
|
log_record.exc_info = exc_info
|
|
event = {}
|
|
|
|
result = before_send(event, {"log_record": log_record, "exc_info": exc_info})
|
|
|
|
assert result is event
|
|
assert "fingerprint" not in event
|
|
|
|
|
|
def test_before_send_does_not_fingerprint_exception_events():
|
|
"""Exception events without a log record keep Sentry's default grouping."""
|
|
event = {}
|
|
|
|
result = before_send(
|
|
event,
|
|
{"exc_info": (OSError, OSError(errno.ENOSPC, "No space left on device"), None)},
|
|
)
|
|
|
|
assert result is event
|
|
assert "fingerprint" not in event
|
|
|
|
|
|
@pytest.mark.parametrize("fingerprint", [["scope-fingerprint"], []])
|
|
def test_before_send_keeps_existing_fingerprint(fingerprint):
|
|
"""A fingerprint set by a scope or an integration is never overwritten."""
|
|
expected_fingerprint = fingerprint.copy()
|
|
event = {"fingerprint": fingerprint}
|
|
|
|
before_send(
|
|
event, _filesystem_hint(OSError(errno.ENOSPC, "No space left on device"))
|
|
)
|
|
|
|
assert event["fingerprint"] == expected_fingerprint
|
|
|
|
|
|
def test_before_send_ignores_suppressed_context():
|
|
"""`raise ... from None` hides the context, so it must not group the event."""
|
|
try:
|
|
try:
|
|
raise OSError(errno.ENOSPC, "No space left on device")
|
|
except OSError:
|
|
raise RuntimeError("Error generating output directory") from None
|
|
except RuntimeError as wrapper:
|
|
event = {}
|
|
before_send(event, _filesystem_hint(wrapper))
|
|
|
|
assert "fingerprint" not in event
|
|
|
|
|
|
def test_errno_fingerprint_follows_implicit_context():
|
|
"""An implicit `raise` during handling still exposes the original errno."""
|
|
try:
|
|
try:
|
|
raise OSError(errno.EACCES, "Permission denied")
|
|
except OSError:
|
|
raise RuntimeError("Error generating output directory")
|
|
except RuntimeError as wrapper:
|
|
assert errno_fingerprint(wrapper) == "errno:EACCES"
|
|
|
|
|
|
def test_before_send_does_not_fingerprint_oserror_without_errno():
|
|
"""An OSError without errno has nothing to split the issue by."""
|
|
event = {}
|
|
|
|
before_send(event, _filesystem_hint(OSError("no errno here")))
|
|
|
|
assert "fingerprint" not in event
|
|
|
|
|
|
def test_errno_fingerprint_uses_raw_number_for_unknown_errno():
|
|
"""Unmapped errno values still split the issue instead of being dropped."""
|
|
assert errno_fingerprint(OSError(9999, "unknown")) == "errno:9999"
|
|
|
|
|
|
def test_errno_fingerprint_stops_on_self_referencing_chain():
|
|
"""A cyclic exception chain must not hang the fingerprint lookup."""
|
|
first = ValueError("first")
|
|
second = ValueError("second")
|
|
first.__cause__ = second
|
|
second.__cause__ = first
|
|
|
|
assert errno_fingerprint(first) is None
|