diff --git a/agent/internal/security/status.go b/agent/internal/security/status.go index e66480d197..c569bd9e54 100644 --- a/agent/internal/security/status.go +++ b/agent/internal/security/status.go @@ -101,6 +101,11 @@ func defaultDataDir() string { func providerFromName(name string) string { lower := strings.ToLower(strings.TrimSpace(name)) switch { + // Elastic Defend (Elastic Agent / Elastic Endpoint Security) registers with + // Windows Security Center. Match before the broad "defender" case so an + // "Elastic Defender"-style name isn't misread as Microsoft Defender (#2018). + case strings.Contains(lower, "elastic"): + return "elastic_defend" case strings.Contains(lower, "defender"): return "windows_defender" case strings.Contains(lower, "bitdefender"): diff --git a/agent/internal/security/status_provider_test.go b/agent/internal/security/status_provider_test.go new file mode 100644 index 0000000000..8447aaf6b7 --- /dev/null +++ b/agent/internal/security/status_provider_test.go @@ -0,0 +1,31 @@ +package security + +import "testing" + +func TestProviderFromName(t *testing.T) { + cases := []struct { + name string + display string + expected string + }{ + {"microsoft defender", "Windows Defender", "windows_defender"}, + {"sentinelone", "SentinelOne", "sentinelone"}, + {"crowdstrike", "CrowdStrike Falcon", "crowdstrike"}, + {"elastic defend", "Elastic Defend", "elastic_defend"}, + {"elastic endpoint security", "Elastic Endpoint Security", "elastic_defend"}, + {"elastic agent", "Elastic Agent", "elastic_defend"}, + // Locks the elastic-before-defender ordering: an "Elastic Defender"-style + // name must not fall through to the broad "defender" → windows_defender case. + {"elastic defender", "Elastic Defender", "elastic_defend"}, + {"unknown product", "Acme Shield", "other"}, + {"empty", "", "other"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := providerFromName(tc.display); got != tc.expected { + t.Fatalf("providerFromName(%q) = %q, want %q", tc.display, got, tc.expected) + } + }) + } +} diff --git a/apps/api/migrations/2026-07-05-security-provider-elastic-defend.sql b/apps/api/migrations/2026-07-05-security-provider-elastic-defend.sql new file mode 100644 index 0000000000..7409afb232 --- /dev/null +++ b/apps/api/migrations/2026-07-05-security-provider-elastic-defend.sql @@ -0,0 +1,5 @@ +-- Recognize Elastic Defend as a first-class antivirus provider. +-- Previously WSC-reported Elastic agents normalized to 'other', so AV-coverage +-- detection treated Elastic Defend-protected devices as unprotected (#2018). +-- Insert before 'other' to keep the catch-all value last in the enum order. +ALTER TYPE security_provider ADD VALUE IF NOT EXISTS 'elastic_defend' BEFORE 'other'; diff --git a/apps/api/src/db/schema/security.ts b/apps/api/src/db/schema/security.ts index 9ea8c91a1d..2e8863d34b 100644 --- a/apps/api/src/db/schema/security.ts +++ b/apps/api/src/db/schema/security.ts @@ -24,6 +24,7 @@ export const securityProviderEnum = pgEnum('security_provider', [ 'malwarebytes', 'eset', 'kaspersky', + 'elastic_defend', 'other' ]); diff --git a/apps/api/src/routes/agents/helpers.provider.test.ts b/apps/api/src/routes/agents/helpers.provider.test.ts new file mode 100644 index 0000000000..37f2f99f5c --- /dev/null +++ b/apps/api/src/routes/agents/helpers.provider.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from 'vitest'; + +// helpers.ts (and its transitive imports) load the db module at import; stub it +// so this pure-function test doesn't spin up a real pool (green-local/red-CI trap). +vi.mock('../../db', () => ({ + db: {}, + runOutsideDbContext: vi.fn((fn: () => unknown) => fn()), + withDbAccessContext: vi.fn(async (_ctx: unknown, fn: () => Promise) => fn()), + withSystemDbAccessContext: vi.fn(async (fn: () => Promise) => fn()) +})); + +import { normalizeProvider } from './helpers'; +import { securityProviderValues } from './schemas'; +import { providerCatalog } from '../security/schemas'; + +describe('normalizeProvider — Elastic Defend (#2018)', () => { + it('maps Elastic Defend variants to elastic_defend', () => { + expect(normalizeProvider('elastic_defend')).toBe('elastic_defend'); + expect(normalizeProvider('elastic_endpoint')).toBe('elastic_defend'); + expect(normalizeProvider('elastic_agent')).toBe('elastic_defend'); + expect(normalizeProvider('elastic')).toBe('elastic_defend'); + // Case-insensitive, matching the existing provider handling. + expect(normalizeProvider('Elastic_Defend')).toBe('elastic_defend'); + }); + + it('still normalizes known providers and unknowns', () => { + expect(normalizeProvider('crowdstrike')).toBe('crowdstrike'); + expect(normalizeProvider('acme-shield')).toBe('other'); + expect(normalizeProvider(null)).toBe('other'); + }); + + it('keeps the provider sources in sync (dashboard indexes providerCatalog[normalizeProvider(x)])', () => { + // elastic_defend must be an accepted ingest value... + expect(securityProviderValues).toContain('elastic_defend'); + // ...and resolvable in the catalog, or dashboard provider labeling throws. + expect(providerCatalog.elastic_defend).toEqual({ + id: 'elastic_defend', + name: 'Elastic Defend', + vendor: 'Elastic' + }); + }); +}); diff --git a/apps/api/src/routes/agents/helpers.ts b/apps/api/src/routes/agents/helpers.ts index a4b5817040..d4135c100c 100644 --- a/apps/api/src/routes/agents/helpers.ts +++ b/apps/api/src/routes/agents/helpers.ts @@ -217,6 +217,11 @@ export function normalizeProvider(raw: unknown): SecurityProviderValue { return 'eset'; case 'kaspersky': return 'kaspersky'; + case 'elastic_defend': + case 'elastic_endpoint': + case 'elastic_agent': + case 'elastic': + return 'elastic_defend'; default: return 'other'; } diff --git a/apps/api/src/routes/agents/schemas.ts b/apps/api/src/routes/agents/schemas.ts index cd7dd4e470..2b8eb2f526 100644 --- a/apps/api/src/routes/agents/schemas.ts +++ b/apps/api/src/routes/agents/schemas.ts @@ -258,6 +258,7 @@ export const securityProviderValues = [ 'malwarebytes', 'eset', 'kaspersky', + 'elastic_defend', 'other' ] as const; diff --git a/apps/api/src/routes/security/schemas.ts b/apps/api/src/routes/security/schemas.ts index df262859a6..f9b06de8cd 100644 --- a/apps/api/src/routes/security/schemas.ts +++ b/apps/api/src/routes/security/schemas.ts @@ -10,6 +10,7 @@ export const providerCatalog = { malwarebytes: { id: 'malwarebytes', name: 'Malwarebytes', vendor: 'Malwarebytes' }, eset: { id: 'eset', name: 'ESET', vendor: 'ESET' }, kaspersky: { id: 'kaspersky', name: 'Kaspersky', vendor: 'Kaspersky' }, + elastic_defend: { id: 'elastic_defend', name: 'Elastic Defend', vendor: 'Elastic' }, other: { id: 'other', name: 'Other', vendor: 'Other' } } as const; diff --git a/apps/api/src/services/securityComplianceReport.test.ts b/apps/api/src/services/securityComplianceReport.test.ts index 99be3e0e8c..65ff43e0af 100644 --- a/apps/api/src/services/securityComplianceReport.test.ts +++ b/apps/api/src/services/securityComplianceReport.test.ts @@ -85,6 +85,24 @@ describe('generateSecurityCompliancePostureReport', () => { expect((r.summary as any).controls.unprotectedCount).toBe(2); }); + it('counts an Elastic Defend device with RTP on as AV-covered, not unprotected (#2018)', async () => { + mockGeneratorQueries({ + // dev-1: Huntress-managed (#5). dev-2: native Elastic Defend with RTP on. + // dev-3: no security_status row → unprotected. + 3: [ + { deviceId: 'dev-2', provider: 'elastic_defend', realTimeProtection: true, definitionsDate: new Date(), encryptionStatus: 'encrypted', firewallEnabled: true, passwordPolicySummary: { minLength: 12, lockoutThreshold: 5 }, localAdminSummary: { adminCount: 1 } } + ] + }); + const r = await generateSecurityCompliancePostureReport(ORG, { sites: [] }); + const c = (r.summary as any).controls; + // Before the fix, elastic_defend normalized to 'other' → dev-2 would be + // unprotected (anyAv 33%). Recognized as native AV it joins dev-1 in coverage. + expect(c.anyAvCoveragePct).toBe(67); + expect(c.unprotectedCount).toBe(1); + const byHost = Object.fromEntries((r.rows as any[]).map((x) => [x.hostname, x])); + expect(byHost['pc-2'].protection).toMatch(/Elastic Defend/i); + }); + it('computes control percentages from security_status', async () => { mockGeneratorQueries(); const r = await generateSecurityCompliancePostureReport(ORG, {}); diff --git a/apps/api/src/services/securityComplianceReport.ts b/apps/api/src/services/securityComplianceReport.ts index 198694eff3..145220233f 100644 --- a/apps/api/src/services/securityComplianceReport.ts +++ b/apps/api/src/services/securityComplianceReport.ts @@ -69,7 +69,8 @@ function prettyProvider(p: string): string { sophos: 'Sophos', malwarebytes: 'Malwarebytes', eset: 'ESET', - kaspersky: 'Kaspersky' + kaspersky: 'Kaspersky', + elastic_defend: 'Elastic Defend' }; return map[p] ?? p; }