Skip to content

Commit 11cb85f

Browse files
committed
🐛 fix(mappers): iterate all matching outcomes instead of first only
1 parent baf053a commit 11cb85f

15 files changed

Lines changed: 500 additions & 101 deletions

File tree

external-import/google-secops-siem-incidents/src/google_secops_siem_incidents/converter_to_stix.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414

1515
class ConverterToStix:
16-
"""Convert Google SecOps Chronicle alerts into flat lists of STIX 2.1 objects."""
16+
"""Convert Google SecOps alerts into flat lists of STIX 2.1 objects."""
1717

1818
def __init__(
1919
self,
@@ -31,10 +31,10 @@ def __init__(
3131
self.tlp_marking = TLPMarking(level=TLPLevel(tlp_level)).to_stix2_object()
3232

3333
def convert_rule_alert(self, alert: Alert, rule_metadata: RuleMetadata) -> list:
34-
"""Convert a single Chronicle alert into a flat list of STIX objects.
34+
"""Convert a single alert into a flat list of STIX objects.
3535
3636
Args:
37-
alert: The Chronicle detection alert.
37+
alert: The detection alert.
3838
rule_metadata: Metadata for the rule that triggered the alert.
3939
4040
Returns:
@@ -44,7 +44,7 @@ def convert_rule_alert(self, alert: Alert, rule_metadata: RuleMetadata) -> list:
4444
alert, rule_metadata, author=self.author, tlp_marking=self.tlp_marking
4545
)
4646

47-
hostname = map_hostname(
47+
hostnames = map_hostname(
4848
alert.outcomes, author=self.author, tlp_marking=self.tlp_marking
4949
)
5050
ips = map_ip_addresses(
@@ -58,8 +58,7 @@ def convert_rule_alert(self, alert: Alert, rule_metadata: RuleMetadata) -> list:
5858
)
5959

6060
observables: list = []
61-
if hostname:
62-
observables.append(hostname)
61+
observables.extend(hostnames)
6362
observables.extend(ips)
6463
observables.extend(users)
6564
observables.extend(files)
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
"""Mapper modules for converting Chronicle alert data to connectors_sdk models."""
1+
"""Mapper modules for converting alert data to connectors_sdk models."""

external-import/google-secops-siem-incidents/src/google_secops_siem_incidents/mappers/_utils.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,16 @@ def find_outcome(outcomes: list[Outcome], name: str) -> Outcome | None:
1717
if o.name == name:
1818
return o
1919
return None
20+
21+
22+
def find_all_outcomes(outcomes: list[Outcome], name: str) -> list[Outcome]:
23+
"""Return all outcomes matching name.
24+
25+
Args:
26+
outcomes: List of alert outcomes to search.
27+
name: Outcome name to match.
28+
29+
Returns:
30+
List of matching Outcome objects (may be empty).
31+
"""
32+
return [o for o in outcomes if o.name == name]

external-import/google-secops-siem-incidents/src/google_secops_siem_incidents/mappers/file_mapper.py

Lines changed: 24 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
"""Map Chronicle alert outcomes to File observables."""
1+
"""Map alert outcomes to File observables."""
22

33
from typing import Any
44

55
from connectors_sdk.models import File
66
from connectors_sdk.models.enums import HashAlgorithm
7-
from google_secops_siem_incidents.mappers._utils import find_outcome
7+
8+
from google_secops_siem_incidents.mappers._utils import find_all_outcomes
89
from google_secops_siem_incidents.models.rule_alert_response import Outcome
910

1011

@@ -46,25 +47,27 @@ def map_files(
4647

4748
result = []
4849
for path_name, sha256_name in pairs:
49-
path_outcome = find_outcome(outcomes, path_name)
50-
if path_outcome is None or not path_outcome.string_val:
51-
continue
52-
53-
path = path_outcome.string_val
54-
name = _basename(path)
55-
56-
sha256_outcome = find_outcome(outcomes, sha256_name)
57-
hashes = None
58-
if sha256_outcome and sha256_outcome.string_val:
59-
hashes = {HashAlgorithm.SHA256: sha256_outcome.string_val}
60-
61-
result.append(
62-
File(
63-
name=name,
64-
hashes=hashes,
65-
author=author,
66-
markings=[tlp_marking],
50+
path_outcomes = find_all_outcomes(outcomes, path_name)
51+
sha256_outcomes = find_all_outcomes(outcomes, sha256_name)
52+
53+
for i, path_outcome in enumerate(path_outcomes):
54+
if not path_outcome.string_val:
55+
continue
56+
57+
path = path_outcome.string_val
58+
name = _basename(path)
59+
60+
hashes = None
61+
if i < len(sha256_outcomes) and sha256_outcomes[i].string_val:
62+
hashes = {HashAlgorithm.SHA256: sha256_outcomes[i].string_val}
63+
64+
result.append(
65+
File(
66+
name=name,
67+
hashes=hashes,
68+
author=author,
69+
markings=[tlp_marking],
70+
)
6771
)
68-
)
6972

7073
return result
Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
"""Map Chronicle alert outcomes to a Hostname observable."""
1+
"""Map alert outcomes to Hostname observables."""
22

33
from typing import Any
44

55
from connectors_sdk.models import Hostname
6-
from google_secops_siem_incidents.mappers._utils import find_outcome
6+
7+
from google_secops_siem_incidents.mappers._utils import find_all_outcomes
78
from google_secops_siem_incidents.models.rule_alert_response import Outcome
89

910

@@ -12,22 +13,25 @@ def map_hostname(
1213
*,
1314
author: Any,
1415
tlp_marking: Any,
15-
) -> Hostname | None:
16-
"""Extract a Hostname observable from the principal_hostname alert outcome.
16+
) -> list[Hostname]:
17+
"""Extract Hostname observables from all principal_hostname alert outcomes.
1718
1819
Args:
1920
outcomes: List of alert outcomes to inspect.
2021
author: STIX author identity object.
2122
tlp_marking: TLP marking definition object.
2223
2324
Returns:
24-
Hostname observable, or None if the outcome is absent.
25+
List of Hostname observables (may be empty).
2526
"""
26-
outcome = find_outcome(outcomes, "principal_hostname")
27-
if outcome is None or not outcome.string_val:
28-
return None
29-
return Hostname(
30-
value=outcome.string_val,
31-
author=author,
32-
markings=[tlp_marking],
33-
)
27+
result = []
28+
for outcome in find_all_outcomes(outcomes, "principal_hostname"):
29+
if outcome.string_val:
30+
result.append(
31+
Hostname(
32+
value=outcome.string_val,
33+
author=author,
34+
markings=[tlp_marking],
35+
)
36+
)
37+
return result

external-import/google-secops-siem-incidents/src/google_secops_siem_incidents/mappers/incident_mapper.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Map a Chronicle Alert + RuleMetadata to a connectors_sdk Incident."""
1+
"""Map a Alert + RuleMetadata to a connectors_sdk Incident."""
22

33
from datetime import datetime
44
from typing import Any
@@ -16,10 +16,10 @@ def map_incident(
1616
author: Any,
1717
tlp_marking: Any,
1818
) -> Incident:
19-
"""Map a Chronicle alert and rule metadata to a connectors_sdk Incident.
19+
"""Map a alert and rule metadata to a connectors_sdk Incident.
2020
2121
Args:
22-
alert: The Chronicle detection alert.
22+
alert: The detection alert.
2323
rule_metadata: Rule metadata associated with the alert.
2424
author: STIX author identity object.
2525
tlp_marking: TLP marking definition object.
Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
"""Map Chronicle alert outcomes to IPv4 or IPv6 address observables."""
1+
"""Map alert outcomes to IPv4 or IPv6 address observables."""
22

33
from typing import Any
44

55
from connectors_sdk.models import IPV4Address, IPV6Address
6-
from google_secops_siem_incidents.mappers._utils import find_outcome
6+
7+
from google_secops_siem_incidents.mappers._utils import find_all_outcomes, find_outcome
78
from google_secops_siem_incidents.models.rule_alert_response import Outcome
89

910

@@ -13,7 +14,7 @@ def map_ip_addresses(
1314
author: Any,
1415
tlp_marking: Any,
1516
) -> list[IPV4Address | IPV6Address]:
16-
"""Extract IPv4 or IPv6 address observables from the principal_ip alert outcome.
17+
"""Extract IPv4 or IPv6 address observables from all principal_ip alert outcomes.
1718
1819
Args:
1920
outcomes: List of alert outcomes to inspect.
@@ -23,23 +24,26 @@ def map_ip_addresses(
2324
Returns:
2425
List of IPv4 or IPv6 address observables (may be empty).
2526
"""
26-
ip_outcome = find_outcome(outcomes, "principal_ip")
27-
if ip_outcome is None or ip_outcome.string_seq is None:
28-
return []
29-
30-
ips = ip_outcome.string_seq.string_vals
31-
if not ips:
27+
ip_outcomes = find_all_outcomes(outcomes, "principal_ip")
28+
if not ip_outcomes:
3229
return []
3330

3431
ipv6_outcome = find_outcome(outcomes, "SourceIsIpv6")
3532
is_ipv6 = ipv6_outcome is not None and ipv6_outcome.string_val == "true"
3633

3734
result = []
38-
for ip in ips:
39-
if not ip or not ip.strip():
35+
for ip_outcome in ip_outcomes:
36+
if ip_outcome.string_seq is None:
4037
continue
41-
if is_ipv6:
42-
result.append(IPV6Address(value=ip, author=author, markings=[tlp_marking]))
43-
else:
44-
result.append(IPV4Address(value=ip, author=author, markings=[tlp_marking]))
38+
for ip in ip_outcome.string_seq.string_vals:
39+
if not ip or not ip.strip():
40+
continue
41+
if is_ipv6:
42+
result.append(
43+
IPV6Address(value=ip, author=author, markings=[tlp_marking])
44+
)
45+
else:
46+
result.append(
47+
IPV4Address(value=ip, author=author, markings=[tlp_marking])
48+
)
4549
return result

external-import/google-secops-siem-incidents/src/google_secops_siem_incidents/mappers/user_account_mapper.py

Lines changed: 12 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
"""Map Chronicle alert outcomes to UserAccount observables."""
1+
"""Map alert outcomes to UserAccount observables."""
22

33
from typing import Any
44

55
from connectors_sdk.models import UserAccount
66
from connectors_sdk.models.enums import AccountType
7-
from google_secops_siem_incidents.mappers._utils import find_outcome
7+
8+
from google_secops_siem_incidents.mappers._utils import find_all_outcomes
89
from google_secops_siem_incidents.models.rule_alert_response import Outcome
910

1011

@@ -30,7 +31,7 @@ def map_user_accounts(
3031
author: Any,
3132
tlp_marking: Any,
3233
) -> list[UserAccount]:
33-
"""Extract deduplicated UserAccount observables from principal and target user outcomes.
34+
"""Extract deduplicated UserAccount observables from all principal and target user outcomes.
3435
3536
Args:
3637
outcomes: List of alert outcomes to inspect.
@@ -40,23 +41,14 @@ def map_user_accounts(
4041
Returns:
4142
Deduplicated list of UserAccount observables.
4243
"""
43-
principal_outcome = find_outcome(outcomes, "principal_user_userid")
44-
target_outcome = find_outcome(outcomes, "target_user_userid")
45-
46-
principal_ids = (
47-
principal_outcome.string_seq.string_vals
48-
if principal_outcome and principal_outcome.string_seq
49-
else []
50-
)
51-
target_ids = (
52-
target_outcome.string_seq.string_vals
53-
if target_outcome and target_outcome.string_seq
54-
else []
55-
)
56-
57-
unique_ids = list(
58-
dict.fromkeys(uid for uid in principal_ids + target_ids if uid and uid.strip())
59-
)
44+
all_ids: list[str] = []
45+
for outcome in find_all_outcomes(
46+
outcomes, "principal_user_userid"
47+
) + find_all_outcomes(outcomes, "target_user_userid"):
48+
if outcome.string_seq:
49+
all_ids.extend(outcome.string_seq.string_vals)
50+
51+
unique_ids = list(dict.fromkeys(uid for uid in all_ids if uid and uid.strip()))
6052

6153
return [
6254
UserAccount(

external-import/google-secops-siem-incidents/tests/test_connector_e2e.py

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
RulePropertiesFactory,
1919
make_hostname_outcomes,
2020
make_ip_outcomes,
21+
make_multi_hostname_outcomes,
2122
)
2223

2324
# =====================
@@ -41,26 +42,26 @@ def expected_full_run_log_messages() -> list[str]:
4142
"[CONNECTOR] Run started - {'start_time':",
4243
"[CONNECTOR] Batch fetched - {'batch_num': 1, 'rule_alerts': 1, 'alerts': 2}",
4344
(
44-
"[CONNECTOR] Batch converted to STIX - {'batch_num': 1, 'stix_count': '10 (~10 unique)',"
45-
" 'type_summary': 'hostname: 2, incident: 2, ipv4-addr: 2, relationship: 4'}"
45+
"[CONNECTOR] Batch converted to STIX - {'batch_num': 1, 'stix_count': '14 (~14 unique)',"
46+
" 'type_summary': 'hostname: 3, incident: 2, ipv4-addr: 3, relationship: 6'}"
4647
),
4748
(
4849
"[CONNECTOR] Bundle sent - {'batch_num': 1, 'work_id': 'work-id-123',"
49-
" 'stix_count': '12 (~12 unique)',"
50-
" 'type_summary': 'hostname: 2, identity: 1, incident: 2, ipv4-addr: 2, marking-definition: 1, relationship: 4'}"
50+
" 'stix_count': '16 (~16 unique)',"
51+
" 'type_summary': 'hostname: 3, identity: 1, incident: 2, ipv4-addr: 3, marking-definition: 1, relationship: 6'}"
5152
),
5253
"[CONNECTOR] Batch fetched - {'batch_num': 2, 'rule_alerts': 1, 'alerts': 2}",
5354
(
54-
"[CONNECTOR] Batch converted to STIX - {'batch_num': 2, 'stix_count': '10 (~10 unique)',"
55-
" 'type_summary': 'hostname: 2, incident: 2, ipv4-addr: 2, relationship: 4'}"
55+
"[CONNECTOR] Batch converted to STIX - {'batch_num': 2, 'stix_count': '14 (~14 unique)',"
56+
" 'type_summary': 'hostname: 3, incident: 2, ipv4-addr: 3, relationship: 6'}"
5657
),
5758
(
5859
"[CONNECTOR] Bundle sent - {'batch_num': 2, 'work_id': 'work-id-123',"
59-
" 'stix_count': '12 (~12 unique)',"
60-
" 'type_summary': 'hostname: 2, identity: 1, incident: 2, ipv4-addr: 2, marking-definition: 1, relationship: 4'}"
60+
" 'stix_count': '16 (~16 unique)',"
61+
" 'type_summary': 'hostname: 3, identity: 1, incident: 2, ipv4-addr: 3, marking-definition: 1, relationship: 6'}"
6162
),
6263
"[CONNECTOR] State updated - {'total_batches': 2, 'last_alert_timestamp':",
63-
"[CONNECTOR] Run completed - {'total_batches': 2, 'total_alerts': 4, 'total_stix_objects': '20 (~16 unique)',",
64+
"[CONNECTOR] Run completed - {'total_batches': 2, 'total_alerts': 4, 'total_stix_objects': '28 (~22 unique)',",
6465
]
6566

6667

@@ -71,7 +72,7 @@ def expected_resume_run_log_messages() -> list[str]:
7172
"[CONNECTOR] Run started - {'start_time': '2024-03-01T12:00:00+00:00',",
7273
"[CONNECTOR] Batch fetched - {'batch_num': 1, 'rule_alerts': 1, 'alerts': 2}",
7374
"[CONNECTOR] Batch fetched - {'batch_num': 2, 'rule_alerts': 1, 'alerts': 2}",
74-
"[CONNECTOR] Run completed - {'total_batches': 2, 'total_alerts': 4, 'total_stix_objects': '20 (~16 unique)',",
75+
"[CONNECTOR] Run completed - {'total_batches': 2, 'total_alerts': 4, 'total_stix_objects': '28 (~22 unique)',",
7576
]
7677

7778

@@ -242,12 +243,12 @@ def _build_batch(detection_ts: str) -> RuleAlertResponse:
242243
"""Build a RuleAlertResponse with 2 alerts sharing the same detection_timestamp."""
243244
alert1 = AlertFactory.build(
244245
fields=[AlertFieldFactory.build(name="ip", string_val="10.0.0.1")],
245-
outcomes=make_hostname_outcomes("host1.local") + make_ip_outcomes(["10.0.0.1"]),
246+
outcomes=make_multi_hostname_outcomes(["host1a.local", "host1b.local"]) + make_ip_outcomes(["10.0.0.1", "10.0.0.2"]),
246247
detection_timestamp=detection_ts,
247248
)
248249
alert2 = AlertFactory.build(
249-
fields=[AlertFieldFactory.build(name="ip", string_val="10.0.0.2")],
250-
outcomes=make_hostname_outcomes("host2.local") + make_ip_outcomes(["10.0.0.2"]),
250+
fields=[AlertFieldFactory.build(name="ip", string_val="10.0.0.3")],
251+
outcomes=make_hostname_outcomes("host2.local") + make_ip_outcomes(["10.0.0.3"]),
251252
detection_timestamp=detection_ts,
252253
)
253254
rule_alert = RuleAlertFactory.build(

0 commit comments

Comments
 (0)