From afe3169e37a1c42b77ea40212a430997ba803151 Mon Sep 17 00:00:00 2001 From: bluza Date: Tue, 25 Aug 2026 14:02:59 +0800 Subject: [PATCH 1/6] fix(bugbash): tighten is_suspicious logic and add missing domain reporting --- .../domain_tools/actions/BulkEnrichDomains.py | 36 ++++-- .../actions/BulkEnrichDomains.yaml | 5 + .../domain_tools/actions/EnrichDomainRisk.py | 21 ++- .../actions/EnrichDomainRisk.yaml | 4 +- .../domain_tools/actions/GetDomainProfile.py | 20 ++- .../domain_tools/core/DomainToolsManager.py | 2 +- .../domain_tools/core/DomainToolsParser.py | 122 ++++++++++++++++-- .../partner/domain_tools/core/constants.py | 1 + .../partner/domain_tools/core/datamodels.py | 90 ++++++++++--- 9 files changed, 247 insertions(+), 54 deletions(-) diff --git a/content/response_integrations/third_party/partner/domain_tools/actions/BulkEnrichDomains.py b/content/response_integrations/third_party/partner/domain_tools/actions/BulkEnrichDomains.py index 8cfaf268e7..e4a553cdb7 100644 --- a/content/response_integrations/third_party/partner/domain_tools/actions/BulkEnrichDomains.py +++ b/content/response_integrations/third_party/partner/domain_tools/actions/BulkEnrichDomains.py @@ -61,6 +61,9 @@ def main() -> None: include_young: bool = extract_action_param( siemplify, param_name="Include Young Domains", input_type=bool, default_value=True ) + merge_with_entities: bool = extract_action_param( + siemplify, param_name="Merge With Case Entities", input_type=bool, default_value=False + ) status: int = EXECUTION_STATE_COMPLETED output_message: str = "" @@ -74,15 +77,20 @@ def main() -> None: siemplify_logger=siemplify.LOGGER, ) - if domains_param: - domains = [d.strip() for d in domains_param.split(",") if d.strip()] + manual = [d.strip() for d in domains_param.split(",") if d.strip()] if domains_param else [] + entity_domains = [ + domain + for entity in siemplify.target_entities + if entity.entity_type in SUPPORTED_ENTITY_TYPES + if (domain := extract_domain_from_string(entity.identifier)) + ] + + if manual and merge_with_entities: + domains = list(dict.fromkeys(manual + entity_domains)) + elif manual: + domains = manual else: - domains = list({ - domain - for entity in siemplify.target_entities - if entity.entity_type in SUPPORTED_ENTITY_TYPES - if (domain := extract_domain_from_string(entity.identifier)) - }) + domains = list(dict.fromkeys(entity_domains)) if not domains: output_message = "No domains provided or found in scope." @@ -98,14 +106,20 @@ def main() -> None: enriched_results = dt_manager.enrich_domains_with_risk(domains) - summary: dict[str, int] = { + enriched_domain_set = {e.domain for e in enriched_results} + missing_domains = [d for d in domains if d not in enriched_domain_set] + + summary: dict[str, Any] = { "total_domains": len(enriched_results), "high_risk_count": 0, "medium_risk_count": 0, "suspicious_count": 0, "young_domain_count": 0, "low_risk_count": 0, + "missing_count": len(missing_domains), } + if missing_domains: + summary["missing_domains"] = missing_domains domains_output: list[dict[str, Any]] = [] for enriched in enriched_results: @@ -140,6 +154,10 @@ def main() -> None: f"{summary['young_domain_count']} young domains, " f"{summary['low_risk_count']} low risk." ) + if missing_domains: + output_message += ( + f"\nNot returned by API ({len(missing_domains)}): {', '.join(missing_domains)}" + ) except Exception as err: output_message = f"Error running action: {str(err)}" diff --git a/content/response_integrations/third_party/partner/domain_tools/actions/BulkEnrichDomains.yaml b/content/response_integrations/third_party/partner/domain_tools/actions/BulkEnrichDomains.yaml index bd9cbfbed3..809594a4b5 100644 --- a/content/response_integrations/third_party/partner/domain_tools/actions/BulkEnrichDomains.yaml +++ b/content/response_integrations/third_party/partner/domain_tools/actions/BulkEnrichDomains.yaml @@ -12,6 +12,11 @@ parameters: type: boolean description: Whether to include young domains (< 30 days old) in the output table. is_mandatory: false +- name: Merge With Case Entities + default_value: 'false' + type: boolean + description: When enabled, merges the manual Domains list with in-scope case entities. Has no effect if Domains is empty. + is_mandatory: false dynamic_results_metadata: - result_example_path: resources/BulkEnrichDomains_JsonResult_example.json result_name: JsonResult diff --git a/content/response_integrations/third_party/partner/domain_tools/actions/EnrichDomainRisk.py b/content/response_integrations/third_party/partner/domain_tools/actions/EnrichDomainRisk.py index e271310d08..164a3ebba1 100644 --- a/content/response_integrations/third_party/partner/domain_tools/actions/EnrichDomainRisk.py +++ b/content/response_integrations/third_party/partner/domain_tools/actions/EnrichDomainRisk.py @@ -105,8 +105,8 @@ def main() -> None: entity.additional_properties.update(prefixed) entity.is_enriched = True - if enriched.overall_risk_score >= risk_threshold or enriched.is_young_domain: - entity.is_suspicious = True + entity.is_suspicious = enriched.overall_risk_score >= risk_threshold + enriched.is_suspicious = entity.is_suspicious success_entities.append(entity) except Exception as e: @@ -116,9 +116,16 @@ def main() -> None: ) siemplify.LOGGER.exception(e) + enriched_domains = {e.domain for e in enriched_results} + missing_domains = [d for d in extracted_domains if d not in enriched_domains] + if success_entities: siemplify.update_entities(success_entities) + + if missing_domains: + json_results.append({"Entity": "MissingDomains", "EntityResult": {"missing_domains": missing_domains}}) siemplify.result.add_result_json(json_results) + csv_table_results = [ enriched.to_table_data() for enriched in enriched_results @@ -129,18 +136,20 @@ def main() -> None: "Domain Risk Enrichment", construct_csv(csv_table_results) ) - suspicious_count = sum( - 1 for e in success_entities if e.is_suspicious - ) + suspicious_count = sum(1 for e in success_entities if e.is_suspicious) output_message = ( f"Successfully enriched {len(success_entities)} domain(s). " - f"{suspicious_count} marked as suspicious." + f"{suspicious_count} marked as suspicious based on the given threshold of {risk_threshold}. \n" ) if failed_entities: output_message += ( f"\nFailed to enrich: {', '.join(str(e.identifier) for e in failed_entities)}" ) + if missing_domains: + output_message += ( + f"\nNot returned by API: {', '.join(missing_domains)}" + ) else: output_message = "No entities were enriched." result_value = False diff --git a/content/response_integrations/third_party/partner/domain_tools/actions/EnrichDomainRisk.yaml b/content/response_integrations/third_party/partner/domain_tools/actions/EnrichDomainRisk.yaml index 962fad0217..0edf02163f 100644 --- a/content/response_integrations/third_party/partner/domain_tools/actions/EnrichDomainRisk.yaml +++ b/content/response_integrations/third_party/partner/domain_tools/actions/EnrichDomainRisk.yaml @@ -5,8 +5,8 @@ parameters: - name: Risk Threshold default_value: '70' type: string - description: Risk score above which a domain entity is marked as suspicious. Defaults to 70. - is_mandatory: false + description: Domains scoring at or above this value are marked suspicious. Cannot be set higher than 20 (suspicious category floor) — any domain scoring >= 20 is always flagged regardless of this value. + is_mandatory: true dynamic_results_metadata: - result_example_path: resources/EnrichDomainRisk_JsonResult_example.json result_name: JsonResult diff --git a/content/response_integrations/third_party/partner/domain_tools/actions/GetDomainProfile.py b/content/response_integrations/third_party/partner/domain_tools/actions/GetDomainProfile.py index 9d23173fc9..e242f4313b 100644 --- a/content/response_integrations/third_party/partner/domain_tools/actions/GetDomainProfile.py +++ b/content/response_integrations/third_party/partner/domain_tools/actions/GetDomainProfile.py @@ -55,6 +55,7 @@ def main() -> None: csv_rows: list[dict] = [] success_entities: list = [] failed_entities: list = [] + missing_domains: list[str] = [] target_entities = [ entity @@ -78,11 +79,16 @@ def main() -> None: iris_models = dt_manager.investigate_domains(domains=[domain]) iris_model = iris_models[0] if iris_models else None - create_date = iris_model.registration.create_date if iris_model else None + if not iris_model: + missing_domains.append(domain) + continue + + create_date_raw = iris_model.registration.create_date if iris_model else None + create_date_str = create_date_raw.get("value") if isinstance(create_date_raw, dict) else create_date_raw domain_age_days: int | None = None - if create_date: + if create_date_str: try: - created = datetime.strptime(create_date[:10], "%Y-%m-%d") + created = datetime.strptime(create_date_str[:10], "%Y-%m-%d") domain_age_days = (datetime.now() - created).days except ValueError: pass @@ -111,7 +117,7 @@ def main() -> None: "email_domains": iris_model.identity.email_domains if iris_model else [], }, "registration": { - "create_date": create_date, + "create_date": create_date_raw, "expiration_date": iris_model.registration.expiration_date if iris_model else None, "domain_status": iris_model.registration.domain_status if iris_model else False, "registrar_status": iris_model.registration.registrar_status if iris_model else [], @@ -143,6 +149,8 @@ def main() -> None: if success_entities: siemplify.update_entities(success_entities) + if missing_domains: + json_results.append({"Entity": "MissingDomains", "EntityResult": {"missing_domains": missing_domains}}) siemplify.result.add_result_json(json_results) if csv_rows: siemplify.result.add_data_table("Domain Profile", construct_csv(csv_rows)) @@ -153,6 +161,10 @@ def main() -> None: output_message += ( f"\nFailed to profile: {', '.join(str(e.identifier) for e in failed_entities)}" ) + if missing_domains: + output_message += ( + f"\nNot returned by API: {', '.join(missing_domains)}" + ) else: output_message = "No domain profiles could be built." result_value = False diff --git a/content/response_integrations/third_party/partner/domain_tools/core/DomainToolsManager.py b/content/response_integrations/third_party/partner/domain_tools/core/DomainToolsManager.py index 8518328829..2ef5fcbff6 100644 --- a/content/response_integrations/third_party/partner/domain_tools/core/DomainToolsManager.py +++ b/content/response_integrations/third_party/partner/domain_tools/core/DomainToolsManager.py @@ -159,7 +159,7 @@ def get_parsed_domain_rdap(self, domain: str) -> ParsedDomainRDAPModel: def get_whois_history(self, domain: str): try: - self._check_license("parsed-domain-rdap") + self._check_license("whois-history") response = self._api.whois_history(query=domain).response() return self.parser.parse_whois_history(raw_data=response) except NotFoundException: diff --git a/content/response_integrations/third_party/partner/domain_tools/core/DomainToolsParser.py b/content/response_integrations/third_party/partner/domain_tools/core/DomainToolsParser.py index 1f91e93744..e0801bd66a 100644 --- a/content/response_integrations/third_party/partner/domain_tools/core/DomainToolsParser.py +++ b/content/response_integrations/third_party/partner/domain_tools/core/DomainToolsParser.py @@ -29,6 +29,13 @@ def _safe_get_value(self, data: dict, key: str) -> str: val = data.get(key) return val.get("value", "") if isinstance(val, dict) else (val or "") + def _safe_get_dict(self, data: dict, key: str) -> dict | None: + """Returns the full {value, count} dict if value is non-empty, else None.""" + val = data.get(key) + if not isinstance(val, dict): + return None + return val if val.get("value") else None + def _to_list_dict(self, data: dict, key: str) -> list[dict]: """Ensures tracking codes are always a list of dicts.""" val = data.get(key) @@ -57,7 +64,9 @@ def parse_iris_data(self, raw_data: dict[str, Any]) -> IrisInvestigateModel: risk_details = get_domain_risk_score_details(risk_raw) ips = raw_data.get("ip") or [] - ip_cc = ips[0].get("country_code", {}).get("value", "") if ips else "" + first_ip = ips[0] if ips else {} + ip_cc = self._safe_get_dict(first_ip, "country_code") + isp = self._safe_get_dict(first_ip, "isp") registrant_contact = self._parse_iris_contact(raw_data.get("registrant_contact", {})) admin_contact = self._parse_iris_contact(raw_data.get("admin_contact", {})) @@ -67,9 +76,9 @@ def parse_iris_data(self, raw_data: dict[str, Any]) -> IrisInvestigateModel: return IrisInvestigateModel( name=str(raw_data.get("domain", "")), last_enriched=datetime.now().strftime("%Y-%m-%d"), - website_title=self._safe_get_value(raw_data, "website_title"), - first_seen=self._safe_get_value(raw_data, "first_seen"), - server_type=self._safe_get_value(raw_data, "server_type"), + website_title=self._safe_get_dict(raw_data, "website_title"), + first_seen=self._safe_get_dict(raw_data, "first_seen"), + server_type=self._safe_get_dict(raw_data, "server_type"), analytics=Analytics( overall_risk_score=risk_details.get("overall_risk_score", 0), proximity_risk_score=risk_details.get("proximity_risk_score", 0), @@ -98,13 +107,13 @@ def parse_iris_data(self, raw_data: dict[str, Any]) -> IrisInvestigateModel: tags=raw_data.get("tags") or [], ), identity=Identity( - registrant_name=self._safe_get_value(raw_data, "registrant_name"), - registrant_org=self._safe_get_value(raw_data, "registrant_org"), - registrar=raw_data.get("registrar"), + registrant_name=self._safe_get_dict(raw_data, "registrant_name"), + registrant_org=self._safe_get_dict(raw_data, "registrant_org"), + registrar=self._safe_get_dict(raw_data, "registrar"), soa_email=raw_data.get("soa_email") or [], ssl_email=raw_data.get("ssl_email") or [], email_domains=[ - e.get("value") for e in (raw_data.get("email_domain") or []) if e.get("value") + e for e in (raw_data.get("email_domain") or []) if e.get("value") ], additional_whois_emails=raw_data.get("additional_whois_email") or [], registrant_contact=registrant_contact, @@ -115,21 +124,44 @@ def parse_iris_data(self, raw_data: dict[str, Any]) -> IrisInvestigateModel: registration=Registration( registrar_status=raw_data.get("registrar_status") or [], domain_status=raw_data.get("active") or False, - create_date=self._safe_get_value(raw_data, "create_date"), - expiration_date=self._safe_get_value(raw_data, "expiration_date"), + create_date=self._safe_get_dict(raw_data, "create_date"), + expiration_date=self._safe_get_dict(raw_data, "expiration_date"), ), hosting=Hosting( ip_addresses=ips, ip_country_code=ip_cc, + isp=isp, mx_servers=raw_data.get("mx") or [], spf_info=raw_data.get("spf_info") or [], name_servers=raw_data.get("name_server") or [], ssl_certificates=raw_data.get("ssl_info") or [], - redirects_to=raw_data.get("redirect") or [], - redirect_domain=raw_data.get("redirect_domain") or [], + redirects_to=self._safe_get_dict(raw_data, "redirect"), + redirect_domain=self._safe_get_dict(raw_data, "redirect_domain"), ), ) + def _parse_iris_enrich_contact(self, contact_data: dict) -> Contact | None: + """Parse an iris_enrich contact where each field is wrapped in {value: ...}.""" + if not contact_data: + return None + def _v(key: str) -> str | None: + raw = contact_data.get(key, {}) + v = raw.get("value", "") if isinstance(raw, dict) else (raw or "") + return v or None + emails = contact_data.get("email") or [] + email_str = ", ".join(e.get("value", "") for e in emails if e.get("value")) or None + return Contact( + name=_v("name"), + org=_v("org"), + email=email_str, + phone=_v("phone"), + street=_v("street"), + city=_v("city"), + state=_v("state"), + postal=_v("postal"), + country=_v("country"), + ) + def parse_iris_enrich_data(self, domain: str, raw_data: dict[str, Any]) -> EnrichedDomainSummary: """Parse a single iris_enrich result into an EnrichedDomainSummary.""" risk_raw = raw_data.get("domain_risk") or {} @@ -159,6 +191,40 @@ def parse_iris_enrich_data(self, domain: str, raw_data: dict[str, Any]) -> Enric iris_link = f'https://iris.domaintools.com/investigate/search/?q=domain:"{domain}"' + website_title = self._safe_get_value(raw_data, "website_title") or None + server_type = self._safe_get_value(raw_data, "server_type") or None + first_seen = self._safe_get_value(raw_data, "first_seen") or None + redirect = self._safe_get_value(raw_data, "redirect") or None + redirect_domain = self._safe_get_value(raw_data, "redirect_domain") or None + registrant_name = self._safe_get_value(raw_data, "registrant_name") or None + registrar = self._safe_get_value(raw_data, "registrar") or None + registrar_status = raw_data.get("registrar_status") or [] + spf_info = raw_data.get("spf_info") or None + ssl_info = raw_data.get("ssl_info") or [] + tld = raw_data.get("tld") or None + expiration_date = self._safe_get_value(raw_data, "expiration_date") or None + active = bool(raw_data.get("active")) + whois_url = raw_data.get("whois_url") or None + pr_raw = raw_data.get("popularity_rank") + popularity_rank = int(pr_raw) if pr_raw and str(pr_raw).isdigit() else None + alexa = str(raw_data.get("alexa")) if raw_data.get("alexa") else None + adsense = self._safe_get_value(raw_data, "adsense") or None + google_analytics = self._safe_get_value(raw_data, "google_analytics") or None + ga4 = raw_data.get("ga4") or [] + gtm_codes = raw_data.get("gtm_codes") or [] + fb_codes = raw_data.get("fb_codes") or [] + hotjar_codes = raw_data.get("hotjar_codes") or [] + baidu_codes = raw_data.get("baidu_codes") or [] + yandex_codes = raw_data.get("yandex_codes") or [] + matomo_codes = raw_data.get("matomo_codes") or [] + statcounter_project_codes = raw_data.get("statcounter_project_codes") or [] + statcounter_security_codes = raw_data.get("statcounter_security_codes") or [] + registrant_contact = self._parse_iris_enrich_contact(raw_data.get("registrant_contact") or {}) + admin_contact = self._parse_iris_enrich_contact(raw_data.get("admin_contact") or {}) + technical_contact = self._parse_iris_enrich_contact(raw_data.get("technical_contact") or {}) + billing_contact = self._parse_iris_enrich_contact(raw_data.get("billing_contact") or {}) + tags = raw_data.get("tags") or [] + threats_raw = risk_details.get("threat_profile_threats", "") evidence_raw = risk_details.get("threat_profile_evidence", "") threats = ( @@ -187,8 +253,40 @@ def parse_iris_enrich_data(self, domain: str, raw_data: dict[str, Any]) -> Enric domain_age_days=domain_age_days, is_young_domain=(risk_category == "young_domain"), registrant_org=registrant_org, + registrant_name=registrant_name, + registrar=registrar, + registrar_status=registrar_status, ip_country_code=ip_cc, iris_investigate_link=iris_link, + website_title=website_title, + server_type=server_type, + first_seen=first_seen, + redirect=redirect, + redirect_domain=redirect_domain, + expiration_date=expiration_date, + active=active, + whois_url=whois_url, + popularity_rank=popularity_rank, + alexa=alexa, + spf_info=spf_info, + ssl_info=ssl_info, + tld=tld, + adsense=adsense, + google_analytics=google_analytics, + ga4=ga4, + gtm_codes=gtm_codes, + fb_codes=fb_codes, + hotjar_codes=hotjar_codes, + baidu_codes=baidu_codes, + yandex_codes=yandex_codes, + matomo_codes=matomo_codes, + statcounter_project_codes=statcounter_project_codes, + statcounter_security_codes=statcounter_security_codes, + registrant_contact=registrant_contact, + admin_contact=admin_contact, + technical_contact=technical_contact, + billing_contact=billing_contact, + tags=tags, ) def parse_domain_rdap_data(self, raw_data: dict[str, Any]) -> ParsedDomainRDAPModel: diff --git a/content/response_integrations/third_party/partner/domain_tools/core/constants.py b/content/response_integrations/third_party/partner/domain_tools/core/constants.py index 6e95bfeb5f..0c1cf64236 100644 --- a/content/response_integrations/third_party/partner/domain_tools/core/constants.py +++ b/content/response_integrations/third_party/partner/domain_tools/core/constants.py @@ -33,3 +33,4 @@ LICENSE_IRIS_ENRICH = "iris-enrich" LICENSE_IRIS_INVESTIGATE = "iris-investigate" LICENSE_PARSED_DOMAIN_RDAP = "parsed-domain-rdap" +LICENSE_WHOIS_HISTORY = "whois-history" diff --git a/content/response_integrations/third_party/partner/domain_tools/core/datamodels.py b/content/response_integrations/third_party/partner/domain_tools/core/datamodels.py index 3739a00002..9ef8039d52 100644 --- a/content/response_integrations/third_party/partner/domain_tools/core/datamodels.py +++ b/content/response_integrations/third_party/partner/domain_tools/core/datamodels.py @@ -64,12 +64,12 @@ class Contact: @dataclass(slots=True) class Identity: - registrant_name: str | None = None - registrant_org: str | None = None - registrar: str | None = None + registrant_name: dict | None = None + registrant_org: dict | None = None + registrar: dict | None = None soa_email: list[str] = field(default_factory=list) ssl_email: list[str] = field(default_factory=list) - email_domains: list[str] = field(default_factory=list) + email_domains: list[dict] = field(default_factory=list) additional_whois_emails: list[str] = field(default_factory=list) registrant_contact: Contact | None = None admin_contact: Contact | None = None @@ -81,20 +81,21 @@ class Identity: class Registration: registrar_status: list[str] = field(default_factory=list) domain_status: bool = False - create_date: str | None = None - expiration_date: str | None = None + create_date: dict | None = None + expiration_date: dict | None = None @dataclass(slots=True) class Hosting: ip_addresses: list[dict] = field(default_factory=list) - ip_country_code: str = "" + ip_country_code: dict | None = None + isp: dict | None = None mx_servers: list[dict] = field(default_factory=list) spf_info: list[str] = field(default_factory=list) name_servers: list[dict] = field(default_factory=list) ssl_certificates: list[dict] = field(default_factory=list) - redirects_to: list[str] = field(default_factory=list) - redirect_domain: list[str] = field(default_factory=list) + redirects_to: dict | None = None + redirect_domain: dict | None = None @dataclass(frozen=True, slots=True) @@ -105,9 +106,9 @@ class IrisInvestigateModel(DTBaseModel): identity: Identity registration: Registration hosting: Hosting - website_title: str = "" - first_seen: str = "" - server_type: str = "" + website_title: dict | None = None + first_seen: dict | None = None + server_type: dict | None = None def to_table_data(self) -> dict[str, Any]: """Returns a simplified summary dict for UI tables (csv).""" @@ -132,9 +133,9 @@ def to_table_data(self) -> dict[str, Any]: for t in self.analytics.tags ) if self.analytics.tags else "N/A", # Identity - "Registrant Name": self.identity.registrant_name, - "Registrant Org": self.identity.registrant_org, - "Registrar": self.identity.registrar, + "Registrant Name": self.identity.registrant_name.get("value") if isinstance(self.identity.registrant_name, dict) else self.identity.registrant_name, + "Registrant Org": self.identity.registrant_org.get("value") if isinstance(self.identity.registrant_org, dict) else self.identity.registrant_org, + "Registrar": self.identity.registrar.get("value") if isinstance(self.identity.registrar, dict) else self.identity.registrar, "SOA Email": self._format_list_value( "ema", [{"value": e} for e in self.identity.soa_email] ), @@ -142,14 +143,14 @@ def to_table_data(self) -> dict[str, Any]: "ssl.em", [{"value": e} for e in self.identity.ssl_email] ), # Registration - "Create Date": self.registration.create_date, - "Expiration Date": self.registration.expiration_date, + "Create Date": self.registration.create_date.get("value") if isinstance(self.registration.create_date, dict) else self.registration.create_date, + "Expiration Date": self.registration.expiration_date.get("value") if isinstance(self.registration.expiration_date, dict) else self.registration.expiration_date, "Domain Status": self.registration.domain_status, # hosting "IP Addresses": self._format_ips(self.hosting.ip_addresses), - "IP Country Code": self.hosting.ip_country_code, - "Website Title": self.website_title, - "Server Type": self.server_type, + "IP Country Code": self.hosting.ip_country_code.get("value") if isinstance(self.hosting.ip_country_code, dict) else self.hosting.ip_country_code, + "Website Title": self.website_title.get("value") if isinstance(self.website_title, dict) else self.website_title, + "Server Type": self.server_type.get("value") if isinstance(self.server_type, dict) else self.server_type, "Popularity": self.analytics.popularity_rank, } @@ -330,24 +331,73 @@ class EnrichedDomainSummary(DTBaseModel): create_date: str | None = None domain_age_days: int | None = None is_young_domain: bool = False + is_suspicious: bool = False registrant_org: str | None = None + registrant_name: str | None = None + registrar: str | None = None + registrar_status: list[str] = field(default_factory=list) ip_country_code: str = "" iris_investigate_link: str = "" + website_title: str | None = None + server_type: str | None = None + first_seen: str | None = None + expiration_date: str | None = None + active: bool = False + whois_url: str | None = None + popularity_rank: int | None = None + alexa: str | None = None + redirect: str | None = None + redirect_domain: str | None = None + spf_info: str | None = None + ssl_info: list[dict] = field(default_factory=list) + tld: str | None = None + adsense: str | None = None + google_analytics: str | None = None + ga4: list[dict] = field(default_factory=list) + gtm_codes: list[dict] = field(default_factory=list) + fb_codes: list[dict] = field(default_factory=list) + hotjar_codes: list[dict] = field(default_factory=list) + baidu_codes: list[dict] = field(default_factory=list) + yandex_codes: list[dict] = field(default_factory=list) + matomo_codes: list[dict] = field(default_factory=list) + statcounter_project_codes: list[dict] = field(default_factory=list) + statcounter_security_codes: list[dict] = field(default_factory=list) + registrant_contact: Contact | None = None + admin_contact: Contact | None = None + technical_contact: Contact | None = None + billing_contact: Contact | None = None + tags: list[dict] = field(default_factory=list) def to_table_data(self) -> dict[str, Any]: return { "Domain": self.domain, + "TLD": self.tld or "N/A", + "Active": self.active, "Risk Category": self.risk_category, + "Is Suspicious": self.is_suspicious, "Overall Risk Score": self.overall_risk_score, "Proximity Risk Score": self.proximity_risk_score, "Threat Profile Score": self.threat_profile_risk_score, "Threats": ", ".join(self.threat_profile_threats) if self.threat_profile_threats else "N/A", "Evidence": ", ".join(self.threat_profile_evidence) if self.threat_profile_evidence else "N/A", "Create Date": self.create_date or "N/A", + "Expiration Date": self.expiration_date or "N/A", "Domain Age (days)": self.domain_age_days if self.domain_age_days is not None else "N/A", "Young Domain": self.is_young_domain, + "Registrant Name": self.registrant_name or "N/A", "Registrant Org": self.registrant_org or "N/A", + "Registrar": self.registrar or "N/A", + "Registrar Status": ", ".join(self.registrar_status) if self.registrar_status else "N/A", "IP Country": self.ip_country_code or "N/A", + "Website Title": self.website_title or "N/A", + "Server Type": self.server_type or "N/A", + "First Seen": self.first_seen or "N/A", + "Redirect": self.redirect or "N/A", + "Redirect Domain": self.redirect_domain or "N/A", + "Popularity Rank": self.popularity_rank if self.popularity_rank is not None else "N/A", + "SPF Info": self.spf_info or "N/A", + "WHOIS URL": self.whois_url or "N/A", + "Tags": ", ".join(t.get("label", "") for t in self.tags if t.get("label")) or "N/A", "Iris Link": self.iris_investigate_link, } From 2633547873927bfe6e6c6dfe02db7329d07386e8 Mon Sep 17 00:00:00 2001 From: bluza Date: Tue, 25 Aug 2026 14:17:02 +0800 Subject: [PATCH 2/6] fix failing test case --- .../third_party/partner/domain_tools/release_notes.yaml | 5 +++-- .../tests/test_actions/test_enrich_domain_risk.py | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/content/response_integrations/third_party/partner/domain_tools/release_notes.yaml b/content/response_integrations/third_party/partner/domain_tools/release_notes.yaml index 3bba5b4ab4..3ed3597355 100644 --- a/content/response_integrations/third_party/partner/domain_tools/release_notes.yaml +++ b/content/response_integrations/third_party/partner/domain_tools/release_notes.yaml @@ -103,10 +103,11 @@ publish_time: '2026-07-17' ticket_number: '' - description: Added BulkEnrichDomains, EnrichDomainRisk, and GetDomainProfile actions - with risk scoring, domain profiling, and HTML widgets. Added domain enrichment playbook. + with risk scoring, domain profiling, and expanded API field mapping. Added domain + enrichment playbook. version: 14.0 item_name: DomainTools item_type: Integration - publish_time: '2026-08-18' + publish_time: '2026-08-25' ticket_number: '' new: false diff --git a/content/response_integrations/third_party/partner/domain_tools/tests/test_actions/test_enrich_domain_risk.py b/content/response_integrations/third_party/partner/domain_tools/tests/test_actions/test_enrich_domain_risk.py index c38d92aca6..f46d55dddf 100644 --- a/content/response_integrations/third_party/partner/domain_tools/tests/test_actions/test_enrich_domain_risk.py +++ b/content/response_integrations/third_party/partner/domain_tools/tests/test_actions/test_enrich_domain_risk.py @@ -59,15 +59,16 @@ def test_enrich_success( integration_config_file_path=CONFIG_PATH, entities=[{"identifier": "newdomain.com", "entity_type": "DOMAIN", "additional_properties": {}}], ) - def test_young_domain_marked_suspicious( + def test_young_domain_not_suspicious_below_threshold( self, action_output: MockActionOutput, dt_manager: MockDomainToolsManager ): + # score 10 is below default threshold (70) — young domain age alone does not flag suspicious dt_manager.set_enrich_domains_response([YOUNG_DOMAIN_RESULT]) EnrichDomainRisk.main() assert action_output.results.execution_state == ExecutionState.COMPLETED - assert "1 marked as suspicious" in action_output.results.output_message + assert "0 marked as suspicious" in action_output.results.output_message @set_metadata(integration_config_file_path=CONFIG_PATH, entities=[]) def test_no_entities( From c6ac41a2b90ac357fb6cc12db325b57a9a028fb3 Mon Sep 17 00:00:00 2001 From: bluza Date: Tue, 25 Aug 2026 14:32:32 +0800 Subject: [PATCH 3/6] fix code linting issue --- .../domain_tools/core/DomainToolsParser.py | 1 + .../partner/domain_tools/core/datamodels.py | 20 +++++++++++-------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/content/response_integrations/third_party/partner/domain_tools/core/DomainToolsParser.py b/content/response_integrations/third_party/partner/domain_tools/core/DomainToolsParser.py index e0801bd66a..ceb494cbb8 100644 --- a/content/response_integrations/third_party/partner/domain_tools/core/DomainToolsParser.py +++ b/content/response_integrations/third_party/partner/domain_tools/core/DomainToolsParser.py @@ -144,6 +144,7 @@ def _parse_iris_enrich_contact(self, contact_data: dict) -> Contact | None: """Parse an iris_enrich contact where each field is wrapped in {value: ...}.""" if not contact_data: return None + def _v(key: str) -> str | None: raw = contact_data.get(key, {}) v = raw.get("value", "") if isinstance(raw, dict) else (raw or "") diff --git a/content/response_integrations/third_party/partner/domain_tools/core/datamodels.py b/content/response_integrations/third_party/partner/domain_tools/core/datamodels.py index 9ef8039d52..b1bc32f4f3 100644 --- a/content/response_integrations/third_party/partner/domain_tools/core/datamodels.py +++ b/content/response_integrations/third_party/partner/domain_tools/core/datamodels.py @@ -110,6 +110,10 @@ class IrisInvestigateModel(DTBaseModel): first_seen: dict | None = None server_type: dict | None = None + @staticmethod + def _v(d: dict | None) -> str | None: + return d.get("value") if isinstance(d, dict) else d + def to_table_data(self) -> dict[str, Any]: """Returns a simplified summary dict for UI tables (csv).""" return { @@ -133,9 +137,9 @@ def to_table_data(self) -> dict[str, Any]: for t in self.analytics.tags ) if self.analytics.tags else "N/A", # Identity - "Registrant Name": self.identity.registrant_name.get("value") if isinstance(self.identity.registrant_name, dict) else self.identity.registrant_name, - "Registrant Org": self.identity.registrant_org.get("value") if isinstance(self.identity.registrant_org, dict) else self.identity.registrant_org, - "Registrar": self.identity.registrar.get("value") if isinstance(self.identity.registrar, dict) else self.identity.registrar, + "Registrant Name": self._v(self.identity.registrant_name), + "Registrant Org": self._v(self.identity.registrant_org), + "Registrar": self._v(self.identity.registrar), "SOA Email": self._format_list_value( "ema", [{"value": e} for e in self.identity.soa_email] ), @@ -143,14 +147,14 @@ def to_table_data(self) -> dict[str, Any]: "ssl.em", [{"value": e} for e in self.identity.ssl_email] ), # Registration - "Create Date": self.registration.create_date.get("value") if isinstance(self.registration.create_date, dict) else self.registration.create_date, - "Expiration Date": self.registration.expiration_date.get("value") if isinstance(self.registration.expiration_date, dict) else self.registration.expiration_date, + "Create Date": self._v(self.registration.create_date), + "Expiration Date": self._v(self.registration.expiration_date), "Domain Status": self.registration.domain_status, # hosting "IP Addresses": self._format_ips(self.hosting.ip_addresses), - "IP Country Code": self.hosting.ip_country_code.get("value") if isinstance(self.hosting.ip_country_code, dict) else self.hosting.ip_country_code, - "Website Title": self.website_title.get("value") if isinstance(self.website_title, dict) else self.website_title, - "Server Type": self.server_type.get("value") if isinstance(self.server_type, dict) else self.server_type, + "IP Country Code": self._v(self.hosting.ip_country_code), + "Website Title": self._v(self.website_title), + "Server Type": self._v(self.server_type), "Popularity": self.analytics.popularity_rank, } From 5b7d37330c149c4821d07b302f1f4476112f4d11 Mon Sep 17 00:00:00 2001 From: bluza Date: Tue, 25 Aug 2026 14:33:42 +0800 Subject: [PATCH 4/6] update func naming --- .../partner/domain_tools/core/datamodels.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/content/response_integrations/third_party/partner/domain_tools/core/datamodels.py b/content/response_integrations/third_party/partner/domain_tools/core/datamodels.py index b1bc32f4f3..29934bf0aa 100644 --- a/content/response_integrations/third_party/partner/domain_tools/core/datamodels.py +++ b/content/response_integrations/third_party/partner/domain_tools/core/datamodels.py @@ -111,7 +111,7 @@ class IrisInvestigateModel(DTBaseModel): server_type: dict | None = None @staticmethod - def _v(d: dict | None) -> str | None: + def _extract_value(d: dict | None) -> str | None: return d.get("value") if isinstance(d, dict) else d def to_table_data(self) -> dict[str, Any]: @@ -137,9 +137,9 @@ def to_table_data(self) -> dict[str, Any]: for t in self.analytics.tags ) if self.analytics.tags else "N/A", # Identity - "Registrant Name": self._v(self.identity.registrant_name), - "Registrant Org": self._v(self.identity.registrant_org), - "Registrar": self._v(self.identity.registrar), + "Registrant Name": self._extract_value(self.identity.registrant_name), + "Registrant Org": self._extract_value(self.identity.registrant_org), + "Registrar": self._extract_value(self.identity.registrar), "SOA Email": self._format_list_value( "ema", [{"value": e} for e in self.identity.soa_email] ), @@ -147,14 +147,14 @@ def to_table_data(self) -> dict[str, Any]: "ssl.em", [{"value": e} for e in self.identity.ssl_email] ), # Registration - "Create Date": self._v(self.registration.create_date), - "Expiration Date": self._v(self.registration.expiration_date), + "Create Date": self._extract_value(self.registration.create_date), + "Expiration Date": self._extract_value(self.registration.expiration_date), "Domain Status": self.registration.domain_status, # hosting "IP Addresses": self._format_ips(self.hosting.ip_addresses), - "IP Country Code": self._v(self.hosting.ip_country_code), - "Website Title": self._v(self.website_title), - "Server Type": self._v(self.server_type), + "IP Country Code": self._extract_value(self.hosting.ip_country_code), + "Website Title": self._extract_value(self.website_title), + "Server Type": self._extract_value(self.server_type), "Popularity": self.analytics.popularity_rank, } From 8fb2faeabcb9a480931a5292c56ab9cc6ce63857 Mon Sep 17 00:00:00 2001 From: bluza Date: Tue, 25 Aug 2026 21:19:41 +0800 Subject: [PATCH 5/6] update bulk enrich docstring to 100 instead of 500 --- .../partner/domain_tools/actions/BulkEnrichDomains.py | 2 +- .../partner/domain_tools/actions/BulkEnrichDomains.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/content/response_integrations/third_party/partner/domain_tools/actions/BulkEnrichDomains.py b/content/response_integrations/third_party/partner/domain_tools/actions/BulkEnrichDomains.py index e4a553cdb7..92f1800c46 100644 --- a/content/response_integrations/third_party/partner/domain_tools/actions/BulkEnrichDomains.py +++ b/content/response_integrations/third_party/partner/domain_tools/actions/BulkEnrichDomains.py @@ -1,7 +1,7 @@ """ Action script for DomainTools - Bulk Enrich Domains. -Batch-enriches up to 500 domains and returns aggregate statistics +Batch-enriches up to 100 domains and returns aggregate statistics by risk category, plus per-domain enrichment details. """ diff --git a/content/response_integrations/third_party/partner/domain_tools/actions/BulkEnrichDomains.yaml b/content/response_integrations/third_party/partner/domain_tools/actions/BulkEnrichDomains.yaml index 809594a4b5..28d4429696 100644 --- a/content/response_integrations/third_party/partner/domain_tools/actions/BulkEnrichDomains.yaml +++ b/content/response_integrations/third_party/partner/domain_tools/actions/BulkEnrichDomains.yaml @@ -1,5 +1,5 @@ name: Bulk Enrich Domains -description: Batch-enriches up to 500 domains with DomainTools risk data and returns aggregate statistics by risk category (high_risk, medium_risk, suspicious, young_domain, low_risk). Falls back to in-scope case entities if no explicit domain list is provided. +description: Batch-enriches up to 100 domains with DomainTools risk data and returns aggregate statistics by risk category (high_risk, medium_risk, suspicious, young_domain, low_risk). Falls back to in-scope case entities if no explicit domain list is provided. integration_identifier: DomainTools parameters: - name: Domains From 9977d651a1bc5710ba10ff291e452e9a10e0fcea Mon Sep 17 00:00:00 2001 From: bluza Date: Tue, 25 Aug 2026 21:46:22 +0800 Subject: [PATCH 6/6] fix logger warning --- .../partner/domain_tools/actions/BulkEnrichDomains.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/response_integrations/third_party/partner/domain_tools/actions/BulkEnrichDomains.py b/content/response_integrations/third_party/partner/domain_tools/actions/BulkEnrichDomains.py index 92f1800c46..54792c49ec 100644 --- a/content/response_integrations/third_party/partner/domain_tools/actions/BulkEnrichDomains.py +++ b/content/response_integrations/third_party/partner/domain_tools/actions/BulkEnrichDomains.py @@ -99,7 +99,7 @@ def main() -> None: return if len(domains) > BULK_ENRICH_MAX_DOMAINS: - siemplify.LOGGER.warning( + siemplify.LOGGER.warn( f"Domain count {len(domains)} exceeds max {BULK_ENRICH_MAX_DOMAINS}. Truncating." ) domains = domains[:BULK_ENRICH_MAX_DOMAINS]