Skip to content

feat(m365): add defender_domain_dmarc_records_published check#11936

Open
Rishi943 wants to merge 2 commits into
prowler-cloud:masterfrom
Rishi943:feat/defender-dmarc-records-published
Open

feat(m365): add defender_domain_dmarc_records_published check#11936
Rishi943 wants to merge 2 commits into
prowler-cloud:masterfrom
Rishi943:feat/defender-dmarc-records-published

Conversation

@Rishi943

@Rishi943 Rishi943 commented Jul 10, 2026

Copy link
Copy Markdown

Context

Fix #11800

CIS Microsoft 365 Benchmark 2.1.10 / RFC 7489: every accepted Exchange Online domain should publish a DMARC TXT record at _dmarc.<domain> with an enforcing policy. Without it (or with p=none) attackers can spoof the domain for phishing/BEC.

Description

New check defender_domain_dmarc_records_published (M365 provider, defender service, severity medium):

  • The Defender service fetches the verified-domain list from Microsoft Graph (client.domains.get(), same endpoint used by AdminCenter's password-policy path) and performs a DNS TXT lookup at _dmarc.<domain> via dnspython (already a pinned dependency), storing results in a new DomainDmarcConfiguration model.
  • The check parses the p= tag (split on ;, case-insensitive, whitespace-tolerant): p=quarantine/p=reject PASS; p=none, missing record, or a malformed record (not v=DMARC1 / no p= tag) FAIL, each with a distinct status_extended.

Design note: Graph's serviceConfigurationRecords (used by the earlier SPF PR #7724) never contains DMARC records — Microsoft does not provision one — so a real DNS lookup is the only way to evaluate this, matching the issue's spec ("DNS TXT record at _dmarc.<domain>"). The event-loop bootstrapping follows the current admincenter_service.py pattern.

Includes metadata, Google-style docstrings, List[CheckReportM365] return annotation, service tests + 7 check tests, and a changelog fragment.

Steps to review

  1. defender_service.py: _get_domain_dmarc_configurations() (Graph domain list, verified domains only) and _get_dmarc_txt_record() (dnspython TXT lookup, returns the first v=DMARC1 record or None; NXDOMAIN/NoAnswer/Timeout treated as no record).
  2. Check logic in defender_domain_dmarc_records_published.py_get_dmarc_policy is a small tag parser, no regex.
  3. Run tests: pytest tests/providers/m365/services/defender -p no:randomly — 110 passed (7 new check tests: no domains / reject / quarantine / none / missing / malformed / valid-without-p; plus service-level tests for the Graph fetch and DNS lookup, all mocked — no live DNS in tests).

Checklist

Community Checklist

SDK/CLI

  • Are there new checks included in this PR? Yes
    • Permissions: uses the Graph domain-list read the provider already performs elsewhere (AdminCenter) plus outbound DNS resolution; no new Graph API permissions required.

License

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

Summary by CodeRabbit

  • New Features

    • Added an M365 Defender check to verify that verified domains publish DMARC TXT records.
    • Reports pass when enforcement is set to quarantine or reject, and fail when p=none, DMARC is missing, or the policy is malformed/unsupported.
    • Includes updated remediation guidance and references to Microsoft and RFC documentation.
  • Tests

    • Expanded coverage for no configured domains, pagination handling, DNS TXT lookup outcomes, and enforcement-policy parsing (reject/quarantine/none, missing, malformed, and missing p= tag).

Verify each accepted Exchange Online domain publishes a DMARC TXT
record at _dmarc.<domain> with an enforcing policy (p=quarantine or
p=reject); missing, malformed, and p=none records FAIL. Domains come
from Microsoft Graph and the record from a dnspython TXT lookup
(dnspython is already a pinned dependency).

Fixes prowler-cloud#11800

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Rishi943 Rishi943 requested a review from a team as a code owner July 10, 2026 00:02
@github-actions github-actions Bot added provider/m365 Issues/PRs related with the M365 provider metadata-review community Opened by the Community labels Jul 10, 2026
@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds an M365 Defender check that retrieves DMARC records for verified Exchange Online domains, evaluates enforcement policies, reports per-domain results, and includes metadata plus unit tests for DNS, pagination, and policy scenarios.

Changes

M365 DMARC enforcement check

Layer / File(s) Summary
DMARC domain discovery and DNS retrieval
prowler/providers/m365/services/defender/defender_service.py
Defender retrieves verified domains from Microsoft Graph, resolves _dmarc.<domain> TXT records, handles DNS failures, and stores typed DMARC configurations.
DMARC policy evaluation and check metadata
prowler/providers/m365/services/defender/defender_domain_dmarc_records_published/*, prowler/changelog.d/*
The new check parses DMARC policies, returns per-domain PASS or FAIL reports, and adds metadata and changelog content.
DMARC retrieval and policy test coverage
tests/providers/m365/services/defender/defender_domain_dmarc_records_published/*, tests/providers/m365/services/defender/m365_defender_service_test.py
Tests cover pagination, verified-domain filtering, enforcing, monitoring-only, missing, malformed, and DNS lookup scenarios.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Defender
  participant MicrosoftGraph
  participant DNSResolver
  participant DMARCCheck
  participant CheckReportM365
  Defender->>MicrosoftGraph: query verified domains
  MicrosoftGraph-->>Defender: paginated domain data
  Defender->>DNSResolver: resolve _dmarc.<domain> TXT
  DNSResolver-->>Defender: DMARC TXT record or lookup failure
  Defender-->>DMARCCheck: domain_dmarc_configurations
  DMARCCheck->>DMARCCheck: parse DMARC enforcement policy
  DMARCCheck->>CheckReportM365: create PASS or FAIL report
Loading

Suggested reviewers: danibarranqueroo, HugoPBrito

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 28.57% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the new M365 Defender DMARC check.
Description check ✅ Passed The description includes context, summary, review steps, checklist items, and license, matching the template well.
Linked Issues check ✅ Passed The implementation matches #11800: DMARC checks for all Exchange Online domains with pass/fail rules, medium severity, and DNS lookup behavior.
Out of Scope Changes check ✅ Passed The changes stay focused on the new DMARC check, its service support, tests, metadata, and changelog entry.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

No Conflicts

No conflict markers, and the branch merges cleanly into its base.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@prowler/providers/m365/services/defender/defender_service.py`:
- Around line 669-696: Update _get_domain_dmarc_configurations and the DMARC
lookup helper to use dnspython’s asynchronous resolver instead of synchronous
dns.resolver.resolve. Make the helper async, create concurrent lookup tasks for
verified domains, and await them with asyncio.gather while preserving each
domain’s DomainDmarcConfiguration and existing error handling.
- Around line 683-684: The verified-domain retrieval in the Defender service
only processes the first Graph page. Update the logic around the domains fetch
and iteration to follow each page via odata_next_link (or use Graph’s
PageIterator), aggregating or processing every returned domain before completing
the DMARC check.
- Around line 88-115: Replace the manual event-loop setup and cleanup in the
Defender service initializer with a direct
asyncio.run(self._get_domain_dmarc_configurations()) call. Remove the
created_loop tracking, loop creation, closed-loop check, running-loop check, and
run_until_complete logic while preserving assignment to
self.domain_dmarc_configurations.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: a095c5a9-3213-4bbb-8fbe-f58a9a08c036

📥 Commits

Reviewing files that changed from the base of the PR and between 1ee4de1 and 1ae7b2f.

📒 Files selected for processing (7)
  • prowler/changelog.d/add-defender-domain-dmarc-records-published-check.added.md
  • prowler/providers/m365/services/defender/defender_domain_dmarc_records_published/__init__.py
  • prowler/providers/m365/services/defender/defender_domain_dmarc_records_published/defender_domain_dmarc_records_published.metadata.json
  • prowler/providers/m365/services/defender/defender_domain_dmarc_records_published/defender_domain_dmarc_records_published.py
  • prowler/providers/m365/services/defender/defender_service.py
  • tests/providers/m365/services/defender/defender_domain_dmarc_records_published/defender_domain_dmarc_records_published_test.py
  • tests/providers/m365/services/defender/m365_defender_service_test.py

Comment thread prowler/providers/m365/services/defender/defender_service.py
Comment thread prowler/providers/m365/services/defender/defender_service.py
Comment thread prowler/providers/m365/services/defender/defender_service.py Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/providers/m365/services/defender/m365_defender_service_test.py`:
- Around line 621-665: Add an assertion verifying that
domains_with_url_builder.get was awaited exactly once, alongside the existing
pagination assertions in
test_defender__get_domain_dmarc_configurations_handles_pagination, to confirm
the second page was fetched.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: edb2205f-ea70-4d9e-b790-e85201267e79

📥 Commits

Reviewing files that changed from the base of the PR and between 1ae7b2f and da3777e.

📒 Files selected for processing (2)
  • prowler/providers/m365/services/defender/defender_service.py
  • tests/providers/m365/services/defender/m365_defender_service_test.py

Comment on lines +621 to +665
def test_defender__get_domain_dmarc_configurations_handles_pagination():
defender_service = Defender.__new__(Defender)

domains_page_one = [
SimpleNamespace(id="domain1.com", is_verified=True),
SimpleNamespace(id="unverified.com", is_verified=False),
]
domains_page_two = [
SimpleNamespace(id="domain2.com", is_verified=True),
]

domains_response_page_one = SimpleNamespace(
value=domains_page_one,
odata_next_link="next-link",
)
domains_response_page_two = SimpleNamespace(
value=domains_page_two, odata_next_link=None
)

domains_with_url_builder = SimpleNamespace(
get=AsyncMock(return_value=domains_response_page_two)
)
with_url_mock = MagicMock(return_value=domains_with_url_builder)

domains_builder = SimpleNamespace(
get=AsyncMock(return_value=domains_response_page_one),
with_url=with_url_mock,
)

defender_service.client = SimpleNamespace(domains=domains_builder)

with mock.patch(
"prowler.providers.m365.services.defender.defender_service.Defender._get_dmarc_txt_record",
new=AsyncMock(return_value="v=DMARC1; p=reject"),
):
domain_dmarc_configurations = asyncio.run(
defender_service._get_domain_dmarc_configurations()
)

assert set(domain_dmarc_configurations) == {"domain1.com", "domain2.com"}
assert (
domain_dmarc_configurations["domain1.com"].dmarc_record == "v=DMARC1; p=reject"
)
assert domains_builder.get.await_count == 1
with_url_mock.assert_called_once_with("next-link")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add assertion for second-page get() call in pagination test.

The test verifies domains_builder.get.await_count == 1 and with_url_mock.assert_called_once_with("next-link"), but doesn't explicitly assert that the second-page fetch (domains_with_url_builder.get) was awaited. Adding assert domains_with_url_builder.get.await_count == 1 would close the loop on verifying both pages were fetched.

✨ Optional improvement
     assert domains_builder.get.await_count == 1
     with_url_mock.assert_called_once_with("next-link")
+    assert domains_with_url_builder.get.await_count == 1
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def test_defender__get_domain_dmarc_configurations_handles_pagination():
defender_service = Defender.__new__(Defender)
domains_page_one = [
SimpleNamespace(id="domain1.com", is_verified=True),
SimpleNamespace(id="unverified.com", is_verified=False),
]
domains_page_two = [
SimpleNamespace(id="domain2.com", is_verified=True),
]
domains_response_page_one = SimpleNamespace(
value=domains_page_one,
odata_next_link="next-link",
)
domains_response_page_two = SimpleNamespace(
value=domains_page_two, odata_next_link=None
)
domains_with_url_builder = SimpleNamespace(
get=AsyncMock(return_value=domains_response_page_two)
)
with_url_mock = MagicMock(return_value=domains_with_url_builder)
domains_builder = SimpleNamespace(
get=AsyncMock(return_value=domains_response_page_one),
with_url=with_url_mock,
)
defender_service.client = SimpleNamespace(domains=domains_builder)
with mock.patch(
"prowler.providers.m365.services.defender.defender_service.Defender._get_dmarc_txt_record",
new=AsyncMock(return_value="v=DMARC1; p=reject"),
):
domain_dmarc_configurations = asyncio.run(
defender_service._get_domain_dmarc_configurations()
)
assert set(domain_dmarc_configurations) == {"domain1.com", "domain2.com"}
assert (
domain_dmarc_configurations["domain1.com"].dmarc_record == "v=DMARC1; p=reject"
)
assert domains_builder.get.await_count == 1
with_url_mock.assert_called_once_with("next-link")
def test_defender__get_domain_dmarc_configurations_handles_pagination():
defender_service = Defender.__new__(Defender)
domains_page_one = [
SimpleNamespace(id="domain1.com", is_verified=True),
SimpleNamespace(id="unverified.com", is_verified=False),
]
domains_page_two = [
SimpleNamespace(id="domain2.com", is_verified=True),
]
domains_response_page_one = SimpleNamespace(
value=domains_page_one,
odata_next_link="next-link",
)
domains_response_page_two = SimpleNamespace(
value=domains_page_two, odata_next_link=None
)
domains_with_url_builder = SimpleNamespace(
get=AsyncMock(return_value=domains_response_page_two)
)
with_url_mock = MagicMock(return_value=domains_with_url_builder)
domains_builder = SimpleNamespace(
get=AsyncMock(return_value=domains_response_page_one),
with_url=with_url_mock,
)
defender_service.client = SimpleNamespace(domains=domains_builder)
with mock.patch(
"prowler.providers.m365.services.defender.defender_service.Defender._get_dmarc_txt_record",
new=AsyncMock(return_value="v=DMARC1; p=reject"),
):
domain_dmarc_configurations = asyncio.run(
defender_service._get_domain_dmarc_configurations()
)
assert set(domain_dmarc_configurations) == {"domain1.com", "domain2.com"}
assert (
domain_dmarc_configurations["domain1.com"].dmarc_record == "v=DMARC1; p=reject"
)
assert domains_builder.get.await_count == 1
with_url_mock.assert_called_once_with("next-link")
assert domains_with_url_builder.get.await_count == 1
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/providers/m365/services/defender/m365_defender_service_test.py` around
lines 621 - 665, Add an assertion verifying that domains_with_url_builder.get
was awaited exactly once, alongside the existing pagination assertions in
test_defender__get_domain_dmarc_configurations_handles_pagination, to confirm
the second page was fetched.

- Follow odata_next_link when listing domains so tenants with multiple
  pages are fully covered
- Resolve DMARC TXT records concurrently with dns.asyncresolver +
  asyncio.gather instead of sequential blocking lookups
- Add pagination test mirroring admincenter service test style

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

community Opened by the Community metadata-review new-check provider/m365 Issues/PRs related with the M365 provider

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[New Check]: Ensure DMARC records are published for all Exchange Online domains

2 participants