Skip to content

Commit 8879733

Browse files
committed
[ctm360] Avoid empty breached-credential Indicator pattern
In breached_credentials_to_stix, `indicator_value = email or username` is truthy when an email is present but invalid (no "@") and the username is empty. The else-branch then built the user-account pattern/name from the empty username, producing `[user-account:account_login = '']` — an empty, constant pattern that collapses unrelated records onto a single `Indicator.generate_id(pattern)` id and breaks ingestion expectations. Fall back to `username or email` for the account-login pattern and name (mirroring the UserAccount `account_login` already built above), so the pattern is never empty when an Indicator is created. Add regression tests asserting the non-empty pattern and that distinct invalid emails map to distinct Indicators.
1 parent dd55ae0 commit 8879733

2 files changed

Lines changed: 35 additions & 2 deletions

File tree

external-import/ctm360-cyberblindspot-feed/src/connector/converter_to_stix.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -330,11 +330,18 @@ def breached_credentials_to_stix(self, creds: list) -> list:
330330
pattern = f"[email-addr:value = '{self._escape_stix_value(email)}']"
331331
indicator_name = f"Breached credential: {email}"
332332
else:
333+
# Fall back to the email when there is no username (the email
334+
# may be present but lack an "@"). indicator_value is truthy
335+
# here, so username-or-email is guaranteed non-empty and the
336+
# pattern is never an empty/constant string — an empty pattern
337+
# would collapse unrelated records onto one Indicator id. This
338+
# mirrors the UserAccount account_login below.
339+
account_login = username or email
333340
pattern = (
334341
"[user-account:account_login = "
335-
f"'{self._escape_stix_value(username)}']"
342+
f"'{self._escape_stix_value(account_login)}']"
336343
)
337-
indicator_name = f"Breached credential: {username}"
344+
indicator_name = f"Breached credential: {account_login}"
338345

339346
# Derive the Indicator id from its STIX pattern via the pycti
340347
# generator so the same credential pattern de-duplicates across

external-import/ctm360-cyberblindspot-feed/tests/test_connector/test_converter_to_stix.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,32 @@ def test_no_identifiers_skips_indicator(self, converter):
443443
assert "user-account" in types
444444
assert "note" in types
445445

446+
def test_invalid_email_without_username_uses_nonempty_pattern(self, converter):
447+
# An email present but invalid (no "@") with no username must not yield
448+
# an empty user-account pattern (which would collapse unrelated records
449+
# onto a single constant Indicator id). It falls back to the email value
450+
# and stays consistent with the UserAccount account_login.
451+
objects = converter.breached_credentials_to_stix(
452+
[{"id": "C4", "email": "not-an-email"}]
453+
)
454+
indicators = [o for o in objects if o.type == "indicator"]
455+
assert indicators
456+
assert indicators[0].pattern == "[user-account:account_login = 'not-an-email']"
457+
user_accounts = [o for o in objects if o.type == "user-account"]
458+
assert user_accounts[0].account_login == "not-an-email"
459+
460+
def test_invalid_email_indicator_ids_are_distinct(self, converter):
461+
# Two records with different invalid emails (and no username) must map to
462+
# distinct Indicators rather than collapsing onto one empty-pattern id.
463+
objects = converter.breached_credentials_to_stix(
464+
[
465+
{"id": "C5", "email": "bad-one"},
466+
{"id": "C6", "email": "bad-two"},
467+
]
468+
)
469+
indicator_ids = {o.id for o in objects if o.type == "indicator"}
470+
assert len(indicator_ids) == 2
471+
446472

447473
class TestCardLeaksToStix:
448474
def test_card_leak_note(self, converter):

0 commit comments

Comments
 (0)