Skip to content

Commit

Permalink
Add target state and rule scope to Santa metrics
Browse files Browse the repository at this point in the history
  • Loading branch information
np5 committed Dec 9, 2024
1 parent 79361f1 commit e4185b4
Show file tree
Hide file tree
Showing 3 changed files with 139 additions and 44 deletions.
107 changes: 81 additions & 26 deletions tests/santa/test_metrics_views.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
from unittest.mock import call, patch
from unittest.mock import call, patch, Mock
from django.urls import reverse
from django.test import TestCase
from prometheus_client.parser import text_string_to_metric_families
from zentral.contrib.santa.models import Rule, Target
from zentral.contrib.santa.models import Rule, Target, TargetState
from zentral.conf import settings
from .utils import (force_ballot, force_configuration, force_enrolled_machine, force_realm_user, force_rule,
force_target, force_target_counter)
force_target, force_target_counter, force_target_state)


class SantaMetricsViewsTestCase(TestCase):
Expand Down Expand Up @@ -123,15 +123,19 @@ def test_enrolled_machines_unknown_mode(self, warning, connection):

def test_rules(self):
rules = {}
for target_type, policy, is_voting_rule in (
(Target.Type.BINARY, Rule.Policy.ALLOWLIST, False),
(Target.Type.BUNDLE, Rule.Policy.BLOCKLIST, False),
(Target.Type.CDHASH, Rule.Policy.ALLOWLIST_COMPILER, False),
(Target.Type.CERTIFICATE, Rule.Policy.SILENT_BLOCKLIST, False),
(Target.Type.TEAM_ID, Rule.Policy.ALLOWLIST, True),
(Target.Type.SIGNING_ID, Rule.Policy.BLOCKLIST, False),
for target_type, policy, is_voting_rule, pus, sns, epus, esns in (
(Target.Type.BINARY, Rule.Policy.ALLOWLIST, False, [], [], [], []),
(Target.Type.BUNDLE, Rule.Policy.BLOCKLIST, False, ["1"], [], [], []),
(Target.Type.CDHASH, Rule.Policy.ALLOWLIST_COMPILER, False, [], ["2"], [], []),
(Target.Type.CERTIFICATE, Rule.Policy.SILENT_BLOCKLIST, False, [], [], ["3"], []),
(Target.Type.TEAM_ID, Rule.Policy.ALLOWLIST, True, [], [], [], ["4"]),
(Target.Type.SIGNING_ID, Rule.Policy.BLOCKLIST, False, [], [], [], []),
):
rule = force_rule(target_type=target_type, policy=policy)
rule = force_rule(target_type=target_type, policy=policy,
primary_users=pus,
serial_numbers=sns,
excluded_primary_users=epus,
excluded_serial_numbers=esns)
rules[str(rule.configuration.pk)] = rule
response = self._make_authenticated_request()
for family in text_string_to_metric_families(response.content.decode("utf-8")):
Expand All @@ -145,6 +149,14 @@ def test_rules(self):
self.assertEqual(sample.labels["policy"], rule.policy.name)
self.assertEqual(sample.labels["target_type"], rule.target.type)
self.assertEqual(sample.labels["voting"], str(rule.is_voting_rule).lower())
self.assertEqual(sample.labels["users"],
str(len(rule.primary_users) > 0).lower())
self.assertEqual(sample.labels["machines"],
str(len(rule.serial_numbers) > 0).lower())
self.assertEqual(sample.labels["excluded_users"],
str(len(rule.excluded_primary_users) > 0).lower())
self.assertEqual(sample.labels["excluded_machines"],
str(len(rule.excluded_serial_numbers) > 0).lower())
break
else:
raise AssertionError("could not find expected metric family")
Expand All @@ -153,11 +165,19 @@ def test_rules(self):
@patch("zentral.contrib.santa.metrics_views.connection")
@patch("zentral.contrib.santa.metrics_views.logger.error")
def test_rules_unknown_policy(self, warning, connection):
mocked_fetchall = connection.cursor.return_value.__enter__.return_value.fetchall
mocked_fetchall.side_effect = [
mocked_cursor = connection.cursor.return_value.__enter__.return_value
mocked_cursor.description = []
for name in (
"cfg_pk", "ruleset", "target_type", "policy", "is_voting_rule",
"users", "machines", "excluded_users", "excluded_machines", "count",
):
col = Mock()
col.name = name
mocked_cursor.description.append(col)
mocked_cursor.fetchall.side_effect = [
[], # 1st call for the configurations info
[], # 2nd call for the enrolled machines gauge
[(1, None, "BUNDLE", 42, False, 1)], # 3rd call with unknown policy
[(1, None, "BUNDLE", 42, False, True, False, False, False, 1)], # 3rd call with unknown policy
[], # 4th call for the targets gauge
[], # 5th call for the votes gauge
]
Expand All @@ -172,18 +192,18 @@ def test_rules_unknown_policy(self, warning, connection):
self.assertEqual(family_count, 1)
self.assertEqual(sample_count, 0)
warning.assert_called_once_with("Unknown rule policy: %s", 42)
self.assertEqual(mocked_fetchall.mock_calls, [call() for _ in range(5)])
self.assertEqual(mocked_cursor.fetchall.mock_calls, [call() for _ in range(5)])

def test_targets(self):
target_counters = {}
for target_type, blocked_count, collected_count, executed_count, is_rule in (
(Target.Type.BINARY, 11, 0, 0, True),
(Target.Type.BUNDLE, 11, 22, 0, False),
(Target.Type.CDHASH, 11, 22, 33, False),
(Target.Type.CERTIFICATE, 1, 0, 0, False),
(Target.Type.METABUNDLE, 4, 5, 6, False),
(Target.Type.TEAM_ID, 1, 2, 0, False),
(Target.Type.SIGNING_ID, 1, 2, 3, True),
for target_type, blocked_count, collected_count, executed_count, is_rule, state in (
(Target.Type.BINARY, 11, 0, 0, True, TargetState.State.UNTRUSTED),
(Target.Type.BUNDLE, 11, 22, 0, False, TargetState.State.BANNED),
(Target.Type.CDHASH, 11, 22, 33, False, TargetState.State.SUSPECT),
(Target.Type.CERTIFICATE, 1, 0, 0, False, TargetState.State.PARTIALLY_ALLOWLISTED),
(Target.Type.METABUNDLE, 4, 5, 6, False, TargetState.State.GLOBALLY_ALLOWLISTED),
(Target.Type.TEAM_ID, 1, 2, 0, False, TargetState.State.GLOBALLY_ALLOWLISTED),
(Target.Type.SIGNING_ID, 1, 2, 3, True, TargetState.State.GLOBALLY_ALLOWLISTED),
):
target_counter = force_target_counter(
target_type,
Expand All @@ -192,12 +212,14 @@ def test_targets(self):
executed_count=executed_count,
is_rule=is_rule,
)
force_target_state(target_counter.configuration, target_counter.target, state)
target_counters.setdefault(str(target_counter.configuration.pk), {})[target_counter.target.type] = {
"total": 1,
"blocked_total": blocked_count,
"collected_total": collected_count,
"executed_total": executed_count,
"rules_total": 1 if is_rule else 0
"rules_total": 1 if is_rule else 0,
"state": state.name,
}
response = self._make_authenticated_request()
family_count = 0
Expand All @@ -211,12 +233,13 @@ def test_targets(self):
sample_count = 0
for sample in family.samples:
sample_count += 1
target_counter = target_counters[sample.labels["cfg_pk"]].get(sample.labels["type"], {})
self.assertEqual(sample.labels["state"], target_counter.get("state", TargetState.State.UNTRUSTED.name))
self.assertEqual(
sample.value,
# the expected value is stored when creating the counters.
# for the missing counters, we have 1 target total and 0 other totals.
(target_counters[sample.labels["cfg_pk"]].get(sample.labels["type"], {})
.get(total_key, 1 if total_key == "total" else 0))
target_counter.get(total_key, 1 if total_key == "total" else 0)
)
self.assertEqual(sample_count, 7 * 7) # 7 configs, 7 types
self.assertEqual(family_count, 5)
Expand All @@ -225,6 +248,38 @@ def test_targets(self):
{"total", "blocked_total", "collected_total", "executed_total", "rules_total"}
)

@patch("zentral.contrib.santa.metrics_views.connection")
@patch("zentral.contrib.santa.metrics_views.logger.error")
def test_targets_unknown_state(self, warning, connection):
mocked_cursor = connection.cursor.return_value.__enter__.return_value
mocked_cursor.description = []
for name in (
"target_type", "cfg_pk", "state",
"total", "blocked_total", "collected_total", "executed_total", "rules_total"
):
col = Mock()
col.name = name
mocked_cursor.description.append(col)
mocked_cursor.fetchall.side_effect = [
[], # 1st call for the configurations info
[], # 2nd call for the enrolled machines gauge
[], # 3rd call for the rules gauge
[("BUNDLE", 1, -2, 1, 0, 0, 0, 0, 0)], # 4th call with unknown state
[], # 5th call for the votes gauge
]
response = self._make_authenticated_request()
family_count = 0
sample_count = 0
for family in text_string_to_metric_families(response.content.decode("utf-8")):
if not family.name.startswith("zentral_santa_targets_"):
continue
family_count += 1
sample_count += len(family.samples)
self.assertEqual(family_count, 5)
self.assertEqual(sample_count, 0)
self.assertEqual(warning.mock_calls, [call("Unknown target state: %s", -2) for _ in range(5)])
self.assertEqual(mocked_cursor.fetchall.mock_calls, [call() for _ in range(5)])

def test_votes(self):
target = force_target()
realm, realm_user = force_realm_user()
Expand Down
23 changes: 21 additions & 2 deletions tests/santa/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from zentral.contrib.santa.events import (_commit_files, _create_bundle_binaries, _create_missing_bundles,
_update_targets)
from zentral.contrib.santa.models import (Ballot, Configuration, EnrolledMachine, Enrollment,
Rule, Target, TargetCounter,
Rule, Target, TargetCounter, TargetState,
Vote, VotingGroup)
from zentral.contrib.santa.utils import update_metabundles

Expand Down Expand Up @@ -206,6 +206,17 @@ def force_target_counter(target_type, blocked_count=0, collected_count=0, execut
)


# target state


def force_target_state(configuration=None, target=None, state=None):
return TargetState.objects.create(
configuration=configuration or force_configuration(),
target=target or force_target(),
state=state or TargetState.State.UNTRUSTED,
)


# rule


Expand All @@ -215,6 +226,10 @@ def force_rule(
configuration=None,
policy=Rule.Policy.BLOCKLIST,
is_voting_rule=False,
primary_users=None,
serial_numbers=None,
excluded_primary_users=None,
excluded_serial_numbers=None,
):
target = force_target(target_type, target_identifier)
if configuration is None:
Expand All @@ -223,7 +238,11 @@ def force_rule(
configuration=configuration,
target=target,
policy=policy,
is_voting_rule=is_voting_rule
is_voting_rule=is_voting_rule,
primary_users=primary_users or [],
serial_numbers=serial_numbers or [],
excluded_primary_users=excluded_primary_users or [],
excluded_serial_numbers=excluded_serial_numbers or [],
)


Expand Down
53 changes: 37 additions & 16 deletions zentral/contrib/santa/metrics_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from django.db import connection
from prometheus_client import Gauge
from zentral.utils.prometheus import BasePrometheusMetricsView
from .models import Configuration, Rule
from .models import Configuration, Rule, TargetState


logger = logging.getLogger("zentral.contrib.santa.metrics_views")
Expand Down Expand Up @@ -55,29 +55,43 @@ def add_enrolled_machines_gauge(self):

def add_rules_gauge(self):
g = Gauge('zentral_santa_rules_total', 'Zentral Santa Rules',
['cfg_pk', 'ruleset', 'target_type', 'policy', 'voting'], registry=self.registry)
['cfg_pk', 'ruleset', 'target_type', 'policy', 'voting',
'users', 'machines', 'excluded_users', 'excluded_machines'],
registry=self.registry)
query = (
"select r.configuration_id, s.name, t.type, r.policy, r.is_voting_rule, count(*) "
"select r.configuration_id cfg_pk, s.name ruleset, t.type target_type, r.policy, r.is_voting_rule,"
"cardinality(primary_users) > 0 users,"
"cardinality(serial_numbers) > 0 machines,"
"cardinality(excluded_primary_users) > 0 excluded_users,"
"cardinality(excluded_serial_numbers) > 0 excluded_machines,"
"count(*) "
"from santa_rule as r "
"left join santa_ruleset as s on (r.ruleset_id = s.id) "
"join santa_target as t on (r.target_id = t.id) "
"group by r.configuration_id, s.name, t.type, r.policy, r.is_voting_rule"
"group by r.configuration_id, s.name, t.type, r.policy, r.is_voting_rule,"
"users, machines, excluded_users, excluded_machines"
)
with connection.cursor() as cursor:
cursor.execute(query)
for cfg_pk, ruleset, target_type, policy, is_voting_rule, count in cursor.fetchall():
columns = [c.name for c in cursor.description]
for result in cursor.fetchall():
result_d = dict(zip(columns, result))
try:
policy_label = Rule.Policy(policy).name
policy_label = Rule.Policy(result_d["policy"]).name
except ValueError:
logger.error("Unknown rule policy: %s", policy)
logger.error("Unknown rule policy: %s", result_d["policy"])
continue
g.labels(
cfg_pk=cfg_pk,
ruleset=ruleset if ruleset else "_",
target_type=target_type,
cfg_pk=result_d["cfg_pk"],
ruleset=result_d["ruleset"] if result_d["ruleset"] else "_",
target_type=result_d["target_type"],
policy=policy_label,
voting=str(is_voting_rule).lower(),
).set(count)
voting=str(result_d["is_voting_rule"]).lower(),
users=str(result_d["users"]).lower(),
machines=str(result_d["machines"]).lower(),
excluded_users=str(result_d["excluded_users"]).lower(),
excluded_machines=str(result_d["excluded_machines"]).lower(),
).set(result_d["count"])

def add_targets_gauges(self):
totals = ("total", "blocked_total", "executed_total", "collected_total", "rules_total")
Expand All @@ -87,33 +101,40 @@ def add_targets_gauges(self):
gauges[total] = Gauge(
f'zentral_santa_targets_{total}',
f'Zentral Santa Targets {total_for_display}'.strip(),
["cfg_pk", "type"],
["cfg_pk", "type", "state"],
registry=self.registry,
)
query = (
"with target_config_product as ("
" select t.id target_id, t.type target_type, c.id cfg_pk"
" from santa_target t, santa_configuration c"
")"
"select tcp.target_type, tcp.cfg_pk, count(*) total, "
"select tcp.target_type, tcp.cfg_pk, coalesce(ts.state, 0) state, count(*) total, "
"coalesce(sum(tc.blocked_count), 0) as blocked_total,"
"coalesce(sum(tc.collected_count), 0) as collected_total,"
"coalesce(sum(tc.executed_count), 0) as executed_total,"
"sum(case when r.id is null then 0 else 1 end) rules_total "
"from target_config_product tcp "
"left join santa_targetstate ts on (ts.target_id = tcp.target_id and ts.configuration_id = tcp.cfg_pk) "
"left join santa_targetcounter tc on (tc.target_id = tcp.target_id and tc.configuration_id = tcp.cfg_pk) "
"left join santa_rule r on (r.target_id = tcp.target_id and r.configuration_id = tcp.cfg_pk) "
"group by tcp.target_type, tcp.cfg_pk"
"group by tcp.target_type, tcp.cfg_pk, ts.state"
)
with connection.cursor() as cursor:
cursor.execute(query)
columns = [c.name for c in cursor.description]
for result in cursor.fetchall():
result_d = dict(zip(columns, result))
for total in totals:
try:
state = TargetState.State(result_d["state"]).name
except ValueError:
logger.error("Unknown target state: %s", result_d["state"])
continue
gauges[total].labels(
cfg_pk=result_d["cfg_pk"],
type=result_d["target_type"]
type=result_d["target_type"],
state=state,
).set(result_d[total])

def add_votes_gauge(self):
Expand Down

0 comments on commit e4185b4

Please sign in to comment.