Skip to content
Merged
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
5 changes: 5 additions & 0 deletions agent/internal/security/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand Down
31 changes: 31 additions & 0 deletions agent/internal/security/status_provider_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
Original file line number Diff line number Diff line change
@@ -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';
1 change: 1 addition & 0 deletions apps/api/src/db/schema/security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const securityProviderEnum = pgEnum('security_provider', [
'malwarebytes',
'eset',
'kaspersky',
'elastic_defend',
'other'
]);

Expand Down
42 changes: 42 additions & 0 deletions apps/api/src/routes/agents/helpers.provider.test.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>) => fn()),
withSystemDbAccessContext: vi.fn(async (fn: () => Promise<unknown>) => 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'
});
});
});
5 changes: 5 additions & 0 deletions apps/api/src/routes/agents/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/routes/agents/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ export const securityProviderValues = [
'malwarebytes',
'eset',
'kaspersky',
'elastic_defend',
'other'
] as const;

Expand Down
1 change: 1 addition & 0 deletions apps/api/src/routes/security/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
18 changes: 18 additions & 0 deletions apps/api/src/services/securityComplianceReport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {});
Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/services/securityComplianceReport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Loading