refactor(SecurityService): update absolute count ceilings for new projects to improve spam detection
This commit is contained in:
@@ -50,22 +50,13 @@ const SECURITY_THRESHOLDS = {
|
||||
MIN_COMPLAINTS_FOR_CRITICAL: 5,
|
||||
MIN_COMPLAINTS_FOR_WARNING: 3,
|
||||
|
||||
// === Absolute count ceilings ===
|
||||
// These trigger regardless of rate — catches high-volume spammers who dilute their bounce rate
|
||||
// 24-hour absolute ceilings
|
||||
BOUNCE_24H_CEILING_WARNING: 50,
|
||||
BOUNCE_24H_CEILING_CRITICAL: 100,
|
||||
COMPLAINT_24H_CEILING_WARNING: 10,
|
||||
COMPLAINT_24H_CEILING_CRITICAL: 25,
|
||||
|
||||
// 7-day absolute ceilings
|
||||
BOUNCE_7DAY_CEILING_WARNING: 200,
|
||||
BOUNCE_7DAY_CEILING_CRITICAL: 500,
|
||||
COMPLAINT_7DAY_CEILING_WARNING: 30,
|
||||
COMPLAINT_7DAY_CEILING_CRITICAL: 75,
|
||||
|
||||
// === New project thresholds (projects < 30 days old) ===
|
||||
// Legitimate senders ramp up gradually; spammers blast immediately
|
||||
// === Absolute count ceilings (new projects only) ===
|
||||
// These trigger regardless of rate — catches new accounts blasting emails
|
||||
// before their bounce rate has caught up. Established projects rely on
|
||||
// rate-based checks only, since high absolute counts at high volume
|
||||
// (e.g. 100 bounces out of 10K) don't indicate abuse.
|
||||
//
|
||||
// Legitimate senders ramp up gradually; spammers blast immediately.
|
||||
NEW_PROJECT_AGE_DAYS: 30,
|
||||
NEW_PROJECT_BOUNCE_24H_CEILING_WARNING: 10,
|
||||
NEW_PROJECT_BOUNCE_24H_CEILING_CRITICAL: 25,
|
||||
@@ -516,82 +507,54 @@ export class SecurityService {
|
||||
const violations: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Pick absolute count ceilings based on project age
|
||||
const bounceCeilings = isNewProject
|
||||
? {
|
||||
ceiling24hWarning: SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_24H_CEILING_WARNING,
|
||||
ceiling24hCritical: SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_24H_CEILING_CRITICAL,
|
||||
ceiling7dWarning: SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_7DAY_CEILING_WARNING,
|
||||
ceiling7dCritical: SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_7DAY_CEILING_CRITICAL,
|
||||
}
|
||||
: {
|
||||
ceiling24hWarning: SECURITY_THRESHOLDS.BOUNCE_24H_CEILING_WARNING,
|
||||
ceiling24hCritical: SECURITY_THRESHOLDS.BOUNCE_24H_CEILING_CRITICAL,
|
||||
ceiling7dWarning: SECURITY_THRESHOLDS.BOUNCE_7DAY_CEILING_WARNING,
|
||||
ceiling7dCritical: SECURITY_THRESHOLDS.BOUNCE_7DAY_CEILING_CRITICAL,
|
||||
};
|
||||
// === Absolute count ceiling checks (new projects only, rate-independent) ===
|
||||
// Catches new accounts blasting emails before their bounce rate catches up.
|
||||
// Established projects skip these — high absolute counts at high volume
|
||||
// (e.g. 100 bounces out of 10K) don't indicate abuse; rate checks handle them.
|
||||
if (isNewProject) {
|
||||
// 24-hour bounce ceilings
|
||||
if (twentyFourHour.bounces >= SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_24H_CEILING_CRITICAL) {
|
||||
violations.push(
|
||||
`24-hour bounce count (new project) (${twentyFourHour.bounces} bounces) exceeds critical ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_24H_CEILING_CRITICAL})`,
|
||||
);
|
||||
} else if (twentyFourHour.bounces >= SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_24H_CEILING_WARNING) {
|
||||
warnings.push(
|
||||
`24-hour bounce count (new project) (${twentyFourHour.bounces} bounces) exceeds warning ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_24H_CEILING_WARNING})`,
|
||||
);
|
||||
}
|
||||
|
||||
const complaintCeilings = isNewProject
|
||||
? {
|
||||
ceiling24hWarning: SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_24H_CEILING_WARNING,
|
||||
ceiling24hCritical: SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_24H_CEILING_CRITICAL,
|
||||
ceiling7dWarning: SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_7DAY_CEILING_WARNING,
|
||||
ceiling7dCritical: SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_7DAY_CEILING_CRITICAL,
|
||||
}
|
||||
: {
|
||||
ceiling24hWarning: SECURITY_THRESHOLDS.COMPLAINT_24H_CEILING_WARNING,
|
||||
ceiling24hCritical: SECURITY_THRESHOLDS.COMPLAINT_24H_CEILING_CRITICAL,
|
||||
ceiling7dWarning: SECURITY_THRESHOLDS.COMPLAINT_7DAY_CEILING_WARNING,
|
||||
ceiling7dCritical: SECURITY_THRESHOLDS.COMPLAINT_7DAY_CEILING_CRITICAL,
|
||||
};
|
||||
// 7-day bounce ceilings
|
||||
if (sevenDay.bounces >= SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_7DAY_CEILING_CRITICAL) {
|
||||
violations.push(
|
||||
`7-day bounce count (new project) (${sevenDay.bounces} bounces) exceeds critical ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_7DAY_CEILING_CRITICAL})`,
|
||||
);
|
||||
} else if (sevenDay.bounces >= SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_7DAY_CEILING_WARNING) {
|
||||
warnings.push(
|
||||
`7-day bounce count (new project) (${sevenDay.bounces} bounces) exceeds warning ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_7DAY_CEILING_WARNING})`,
|
||||
);
|
||||
}
|
||||
|
||||
const projectLabel = isNewProject ? ' (new project)' : '';
|
||||
// 24-hour complaint ceilings
|
||||
if (twentyFourHour.complaints >= SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_24H_CEILING_CRITICAL) {
|
||||
violations.push(
|
||||
`24-hour complaint count (new project) (${twentyFourHour.complaints} complaints) exceeds critical ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_24H_CEILING_CRITICAL})`,
|
||||
);
|
||||
} else if (twentyFourHour.complaints >= SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_24H_CEILING_WARNING) {
|
||||
warnings.push(
|
||||
`24-hour complaint count (new project) (${twentyFourHour.complaints} complaints) exceeds warning ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_24H_CEILING_WARNING})`,
|
||||
);
|
||||
}
|
||||
|
||||
// === Absolute count ceiling checks (rate-independent) ===
|
||||
// These catch high-volume spammers who dilute their bounce rate by blasting emails
|
||||
|
||||
// 24-hour bounce ceilings
|
||||
if (twentyFourHour.bounces >= bounceCeilings.ceiling24hCritical) {
|
||||
violations.push(
|
||||
`24-hour bounce count${projectLabel} (${twentyFourHour.bounces} bounces) exceeds critical ceiling (${bounceCeilings.ceiling24hCritical})`,
|
||||
);
|
||||
} else if (twentyFourHour.bounces >= bounceCeilings.ceiling24hWarning) {
|
||||
warnings.push(
|
||||
`24-hour bounce count${projectLabel} (${twentyFourHour.bounces} bounces) exceeds warning ceiling (${bounceCeilings.ceiling24hWarning})`,
|
||||
);
|
||||
}
|
||||
|
||||
// 7-day bounce ceilings
|
||||
if (sevenDay.bounces >= bounceCeilings.ceiling7dCritical) {
|
||||
violations.push(
|
||||
`7-day bounce count${projectLabel} (${sevenDay.bounces} bounces) exceeds critical ceiling (${bounceCeilings.ceiling7dCritical})`,
|
||||
);
|
||||
} else if (sevenDay.bounces >= bounceCeilings.ceiling7dWarning) {
|
||||
warnings.push(
|
||||
`7-day bounce count${projectLabel} (${sevenDay.bounces} bounces) exceeds warning ceiling (${bounceCeilings.ceiling7dWarning})`,
|
||||
);
|
||||
}
|
||||
|
||||
// 24-hour complaint ceilings
|
||||
if (twentyFourHour.complaints >= complaintCeilings.ceiling24hCritical) {
|
||||
violations.push(
|
||||
`24-hour complaint count${projectLabel} (${twentyFourHour.complaints} complaints) exceeds critical ceiling (${complaintCeilings.ceiling24hCritical})`,
|
||||
);
|
||||
} else if (twentyFourHour.complaints >= complaintCeilings.ceiling24hWarning) {
|
||||
warnings.push(
|
||||
`24-hour complaint count${projectLabel} (${twentyFourHour.complaints} complaints) exceeds warning ceiling (${complaintCeilings.ceiling24hWarning})`,
|
||||
);
|
||||
}
|
||||
|
||||
// 7-day complaint ceilings
|
||||
if (sevenDay.complaints >= complaintCeilings.ceiling7dCritical) {
|
||||
violations.push(
|
||||
`7-day complaint count${projectLabel} (${sevenDay.complaints} complaints) exceeds critical ceiling (${complaintCeilings.ceiling7dCritical})`,
|
||||
);
|
||||
} else if (sevenDay.complaints >= complaintCeilings.ceiling7dWarning) {
|
||||
warnings.push(
|
||||
`7-day complaint count${projectLabel} (${sevenDay.complaints} complaints) exceeds warning ceiling (${complaintCeilings.ceiling7dWarning})`,
|
||||
);
|
||||
// 7-day complaint ceilings
|
||||
if (sevenDay.complaints >= SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_7DAY_CEILING_CRITICAL) {
|
||||
violations.push(
|
||||
`7-day complaint count (new project) (${sevenDay.complaints} complaints) exceeds critical ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_7DAY_CEILING_CRITICAL})`,
|
||||
);
|
||||
} else if (sevenDay.complaints >= SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_7DAY_CEILING_WARNING) {
|
||||
warnings.push(
|
||||
`7-day complaint count (new project) (${sevenDay.complaints} complaints) exceeds warning ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_7DAY_CEILING_WARNING})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// === Rate-based checks (existing logic) ===
|
||||
|
||||
@@ -109,14 +109,12 @@ describe('SecurityService', () => {
|
||||
await createEmails(50, {bouncedCount: 10});
|
||||
|
||||
const status = await SecurityService.getSecurityStatus(projectId);
|
||||
// Rate-based check doesn't trigger, but absolute count ceiling might
|
||||
// With 10 bounces in 24h, this is below the 50-bounce ceiling for established projects
|
||||
expect(status.violations).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Absolute count ceilings (established projects)', () => {
|
||||
// Age the project past the new-project window so standard ceilings apply
|
||||
describe('Established projects skip absolute ceilings', () => {
|
||||
// Age the project past the new-project window
|
||||
beforeEach(async () => {
|
||||
const oldDate = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000);
|
||||
await prisma.project.update({
|
||||
@@ -125,45 +123,28 @@ describe('SecurityService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should trigger critical when 24-hour bounce count exceeds ceiling', async () => {
|
||||
// 20,000 emails, 101 bounces = 0.5% rate (well below rate threshold)
|
||||
// But 101 bounces > 100 (24h critical ceiling for established projects)
|
||||
await createEmails(20000, {bouncedCount: 101});
|
||||
|
||||
const status = await SecurityService.getSecurityStatus(projectId);
|
||||
expect(status.shouldDisable).toBe(true);
|
||||
expect(status.violations.some(v => v.includes('24-hour bounce count'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should trigger warning when 24-hour bounce count exceeds warning ceiling', async () => {
|
||||
// 10,000 emails, 51 bounces = 0.51% (below rate threshold)
|
||||
// But 51 > 50 (24h warning ceiling), below 100 critical
|
||||
await createEmails(10000, {bouncedCount: 51});
|
||||
|
||||
const status = await SecurityService.getSecurityStatus(projectId);
|
||||
expect(status.isHealthy).toBe(true); // warnings don't make it unhealthy
|
||||
expect(status.warnings.some(w => w.includes('24-hour bounce count'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should trigger critical when 24-hour complaint count exceeds ceiling', async () => {
|
||||
// 20,000 emails, 26 complaints = 0.13% (below complaint rate critical of 0.15%)
|
||||
// But 26 > 25 (24h complaint critical ceiling)
|
||||
await createEmails(20000, {complainedCount: 26});
|
||||
|
||||
const status = await SecurityService.getSecurityStatus(projectId);
|
||||
expect(status.shouldDisable).toBe(true);
|
||||
expect(status.violations.some(v => v.includes('24-hour complaint count'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should NOT trigger ceiling when bounce count is below ceiling', async () => {
|
||||
// 20,000 emails, 40 bounces = below 50 warning ceiling for established projects
|
||||
await createEmails(20000, {bouncedCount: 40});
|
||||
it('should NOT trigger on high absolute bounce count when rate is healthy', async () => {
|
||||
// 20,000 emails, 200 bounces = 1% rate (well below rate threshold)
|
||||
// Established projects rely solely on rates — high absolute counts at
|
||||
// high volume don't indicate abuse.
|
||||
await createEmails(20000, {bouncedCount: 200});
|
||||
|
||||
const status = await SecurityService.getSecurityStatus(projectId);
|
||||
expect(status.isHealthy).toBe(true);
|
||||
expect(status.shouldDisable).toBe(false);
|
||||
expect(status.violations).toHaveLength(0);
|
||||
expect(status.warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should NOT trigger on high absolute complaint count when rate is healthy', async () => {
|
||||
// 20,000 emails, 50 complaints = 0.25% (above warning 0.075%, would have
|
||||
// tripped old absolute ceiling). With rate-only enforcement and rate
|
||||
// above warning but below critical, this is a warning, not a violation.
|
||||
await createEmails(20000, {complainedCount: 50});
|
||||
|
||||
const status = await SecurityService.getSecurityStatus(projectId);
|
||||
expect(status.shouldDisable).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('New project stricter thresholds', () => {
|
||||
@@ -178,7 +159,7 @@ describe('SecurityService', () => {
|
||||
expect(status.violations.some(v => v.includes('new project'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should apply standard ceilings for projects over 30 days old', async () => {
|
||||
it('should NOT apply absolute ceilings for projects over 30 days old', async () => {
|
||||
// Age the project to 31 days
|
||||
const oldDate = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000);
|
||||
await prisma.project.update({
|
||||
@@ -186,15 +167,14 @@ describe('SecurityService', () => {
|
||||
data: {createdAt: oldDate},
|
||||
});
|
||||
|
||||
// 10,000 emails, 26 bounces (above 25 new project ceiling, below 50 standard warning ceiling)
|
||||
// 10,000 emails, 26 bounces — would trip new-project ceiling, but
|
||||
// established projects skip ceilings entirely (rate is 0.26%, healthy).
|
||||
await createEmails(10000, {bouncedCount: 26});
|
||||
|
||||
const status = await SecurityService.getSecurityStatus(projectId);
|
||||
expect(status.isNewProject).toBe(false);
|
||||
// 26 is below the 50-bounce 24h warning ceiling for established projects
|
||||
expect(status.warnings.some(w => w.includes('24-hour bounce count'))).toBe(false);
|
||||
// And below the 100-bounce 24h critical ceiling
|
||||
expect(status.violations.some(v => v.includes('24-hour bounce count'))).toBe(false);
|
||||
expect(status.warnings.some(w => w.includes('bounce count'))).toBe(false);
|
||||
expect(status.violations.some(v => v.includes('bounce count'))).toBe(false);
|
||||
});
|
||||
|
||||
it('should catch new project blasting emails with delayed bounces', async () => {
|
||||
@@ -212,8 +192,8 @@ describe('SecurityService', () => {
|
||||
|
||||
describe('checkAndEnforceSecurityLimits', () => {
|
||||
it('should disable project when critical thresholds are exceeded', async () => {
|
||||
// Create enough bounces to trigger critical
|
||||
await createEmails(20000, {bouncedCount: 101});
|
||||
// New project, 20K emails with 30 bounces — exceeds new project 24h critical ceiling
|
||||
await createEmails(20000, {bouncedCount: 30});
|
||||
|
||||
await SecurityService.checkAndEnforceSecurityLimits(projectId);
|
||||
|
||||
@@ -225,13 +205,13 @@ describe('SecurityService', () => {
|
||||
});
|
||||
|
||||
it('should NOT disable project when only warnings exist', async () => {
|
||||
// 10,000 emails, 51 bounces (above warning but below critical for established project)
|
||||
// Established project, 200 emails, 12 bounces = 6% (above 5% warning, below 10% critical)
|
||||
const oldDate = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000);
|
||||
await prisma.project.update({
|
||||
where: {id: projectId},
|
||||
data: {createdAt: oldDate},
|
||||
});
|
||||
await createEmails(10000, {bouncedCount: 51});
|
||||
await createEmails(200, {bouncedCount: 12});
|
||||
|
||||
await SecurityService.checkAndEnforceSecurityLimits(projectId);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user