Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 20 additions & 4 deletions src/analyzers/dmarc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,15 +207,31 @@ export async function analyzeDmarc(
message: "Policy is set to none (monitoring only, no enforcement)",
learnAnchor: learnAnchorHref(LEARN_ANCHORS.dmarcPolicyNone),
});
} else {
validations.push({
status: "fail",
message: `Unrecognized policy value p=${tags.p} — must be one of reject, quarantine, or none (RFC 7489 §6.3)`,
});
}

// sp= check
if (tags.sp) {
const spLower = tags.sp.toLowerCase();
validations.push({
status: "pass",
message: "Subdomain policy explicitly set",
});
if (
spLower === "reject" ||
spLower === "quarantine" ||
spLower === "none"
) {
validations.push({
status: "pass",
message: "Subdomain policy explicitly set",
});
} else {
validations.push({
status: "fail",
message: `Unrecognized subdomain policy value sp=${tags.sp} — must be one of reject, quarantine, or none (RFC 7489 §6.3)`,
});
}
// sp=none overrides stronger parent policy — subdomains lose enforcement
if (
spLower === "none" &&
Expand Down
84 changes: 84 additions & 0 deletions test/dmarc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,90 @@ describe("analyzeDmarc — sp=none weakness", () => {
});
});

describe("analyzeDmarc — unrecognized policy values (#738)", () => {
it("fails on an unrecognized p= value instead of silently passing", async () => {
mockQueryTxt.mockResolvedValueOnce({
entries: ["v=DMARC1; p=Quarntine; rua=mailto:r@mydomain.com"],
raw: "v=DMARC1; p=Quarntine; rua=mailto:r@mydomain.com",
});

const result = await analyzeDmarc("mydomain.com");
expect(result.status).toBe("fail");
expect(
result.validations.some(
(v) =>
v.status === "fail" &&
v.message.includes("Unrecognized policy value") &&
v.message.includes("p=Quarntine"),
),
).toBe(true);
});

it("fails on an unrecognized sp= value instead of reporting it as explicitly set", async () => {
mockQueryTxt.mockResolvedValueOnce({
entries: ["v=DMARC1; p=reject; sp=Rejectt; rua=mailto:r@mydomain.com"],
raw: "v=DMARC1; p=reject; sp=Rejectt; rua=mailto:r@mydomain.com",
});

const result = await analyzeDmarc("mydomain.com");
expect(result.status).toBe("fail");
expect(
result.validations.some(
(v) =>
v.status === "fail" &&
v.message.includes("Unrecognized subdomain policy value") &&
v.message.includes("sp=Rejectt"),
),
).toBe(true);
expect(
result.validations.some(
(v) => v.message === "Subdomain policy explicitly set",
),
).toBe(false);
});

it("still passes p=REJECT (case-insensitive) with no unrecognized-value fail", async () => {
mockQueryTxt.mockResolvedValueOnce({
entries: ["v=DMARC1; p=REJECT; rua=mailto:r@mydomain.com"],
raw: "v=DMARC1; p=REJECT; rua=mailto:r@mydomain.com",
});

const result = await analyzeDmarc("mydomain.com");
expect(
result.validations.some((v) =>
v.message.includes("Unrecognized policy value"),
),
).toBe(false);
expect(
result.validations.some(
(v) =>
v.status === "pass" && v.message.includes("Policy is set to reject"),
),
).toBe(true);
});

it("still reports sp=none as explicitly set with no unrecognized-value fail", async () => {
mockQueryTxt.mockResolvedValueOnce({
entries: ["v=DMARC1; p=reject; sp=none; rua=mailto:r@mydomain.com"],
raw: "v=DMARC1; p=reject; sp=none; rua=mailto:r@mydomain.com",
});

const result = await analyzeDmarc("mydomain.com");
expect(
result.validations.some((v) =>
v.message.includes("Unrecognized subdomain policy value"),
),
).toBe(false);
expect(
result.validations.some(
(v) =>
v.status === "pass" &&
v.message === "Subdomain policy explicitly set",
),
).toBe(true);
});
});

describe("analyzeDmarc — multiple records", () => {
it("fails with permerror when more than one DMARC record is published (RFC 7489 §6.6.3)", async () => {
mockQueryTxt.mockResolvedValueOnce({
Expand Down
17 changes: 17 additions & 0 deletions test/scoring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,23 @@ describe("computeGrade", () => {
expect(grade).toBe("F");
});

it("returns F (never the fallback C arm) for a p= value analyzeDmarc could not parse (#738)", () => {
const breakdown = computeGradeBreakdown({
dmarc: makeDmarc({
status: "fail",
tags: { v: "DMARC1", p: "Quarntine" },
}),
spf: makeSpf(),
dkim: makeDkim(),
bimi: makeBimi(),
mta_sts: makeMtaSts(),
});
expect(breakdown.grade).toBe("F");
expect(breakdown.tierReason).not.toBe(
"Fallback — quarantine-level enforcement",
);
});

// ── D tier (missing auth) ───────────────────────────────────

it("returns D when quarantine but missing SPF", () => {
Expand Down
Loading