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
12 changes: 11 additions & 1 deletion apps/extension/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,17 @@ Safari's `webRequest` support is limited, so header checks may show Unknown ther

## Account features

The **Account** tab signs in with Supabase and shows usage and alerts. The build reads `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_ANON_KEY` (and `NEXT_PUBLIC_APP_URL`, default `https://threatcrush.com`) from the environment or `apps/.env*`. Without the Supabase values, sign-in reports that it isn't configured; page checks still work.
The **Account** tab signs in with Supabase and shows your organization's detections. The build reads `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_ANON_KEY` (and `NEXT_PUBLIC_APP_URL`, default `https://threatcrush.com`) from the environment or `apps/.env*`. Without the Supabase values, sign-in reports that it isn't configured; page checks still work.

### Detection alerts

While you are signed in, the background worker polls `GET /api/orgs/:id/detections?status=new` for your current organization (the one selected on the website, else your only or most recently joined one) every *Event check interval* minutes (Options, default 5) and whenever the popup's Account tab opens.

- **Popup:** the number of detections still marked *new*, how many of them are high or critical, and the five newest. *View all* and *Alerts* open `/org/<slug>/detections`.
- **Notifications:** one browser notification per poll for high and critical detections that weren't in the previous poll (at most one notification per organization on screen; the next alert replaces it). Clicking it opens the detections page. Turn it off with Options → Account alerts. The first poll after signing in, or after switching organization, only records what already exists, so installing the extension doesn't replay old alerts.
- **Toolbar:** the tooltip reads "ThreatCrush: N new detections in <org>". The badge is not used for this: it belongs to page checks, set per tab for the page on screen, and one badge meaning two things would be ambiguous.

New detections are found by comparing detection ids with the previous poll, not by time: `detected_at` comes from the daemon and can be older than the upload (a spooled event replayed after an outage). Signed out, it makes no requests; with no organization, it only lists your organizations. If the API can't be reached, the popup keeps the last known numbers and nothing is notified.

## Structure

Expand Down
221 changes: 221 additions & 0 deletions apps/extension/__tests__/detection-alerts.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const ORG = { id: 'org-1', slug: 'acme', name: 'Acme' };

const detection = (id, severity, title = `Detection ${id}`) => ({
id,
severity,
title,
status: 'new',
detected_at: '2026-09-25T12:00:00Z',
});

let store;
let api;

function fakeChrome() {
store = {};
return {
storage: {
local: {
get: vi.fn(async (key) => (key in store ? { [key]: store[key] } : {})),
set: vi.fn(async (items) => Object.assign(store, items)),
remove: vi.fn(async (key) => {
delete store[key];
}),
},
},
notifications: { create: vi.fn(async () => 'id'), onClicked: { addListener: vi.fn() }, clear: vi.fn() },
action: { setTitle: vi.fn(async () => {}), setBadgeText: vi.fn(async () => {}) },
tabs: { create: vi.fn() },
runtime: { getURL: (path) => `chrome-extension://test/${path}` },
};
}

/** Load the module with the API returning `pages` in order, one per poll. */
async function load({ token = 'jwt', organizations = [ORG], pages = [] } = {}) {
const queue = [...pages];
api = {
getAuthToken: vi.fn(async () => token),
getProfile: vi.fn(async () => ({ profile: { current_org_id: null } })),
listOrganizations: vi.fn(async () => ({ organizations })),
listDetections: vi.fn(async () => {
const next = queue.shift();
if (next instanceof Error) throw next;
return next;
}),
};
vi.doMock('../src/lib/api.js', () => api);
return import('../src/background/detections.js');
}

beforeEach(() => {
vi.resetModules();
global.chrome = fakeChrome();
vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.spyOn(console, 'error').mockImplementation(() => {});
});

afterEach(() => {
vi.doUnmock('../src/lib/api.js');
vi.restoreAllMocks();
});

describe('checkDetections', () => {
it('counts new detections without notifying for those that existed at the first poll', async () => {
const { checkDetections } = await load({
pages: [{ detections: [detection('a', 'critical'), detection('b', 'low')], total: 2 }],
});

const result = await checkDetections();

expect(result).toMatchObject({ signedIn: true, org: ORG, newCount: 2, urgentCount: 1 });
expect(api.listDetections).toHaveBeenCalledWith('org-1', expect.objectContaining({ status: 'new' }));
expect(chrome.notifications.create).not.toHaveBeenCalled();
});

it('notifies only for high/critical detections that appeared since the previous poll', async () => {
const { checkDetections } = await load({
pages: [
{ detections: [detection('a', 'critical')], total: 1 },
{
detections: [
detection('d', 'medium'),
detection('c', 'high', 'SSH brute force from 203.0.113.9'),
detection('a', 'critical'),
],
total: 3,
},
{ detections: [detection('c', 'high'), detection('a', 'critical')], total: 2 },
],
});

await checkDetections();
const second = await checkDetections();

expect(second.newCount).toBe(3);
expect(chrome.notifications.create).toHaveBeenCalledTimes(1);
const [id, options] = chrome.notifications.create.mock.calls[0];
expect(id).toBe('threatcrush-detections:acme');
expect(options.message).toBe('SSH brute force from 203.0.113.9');

// Nothing unseen on the third poll (one detection was acknowledged meanwhile).
const third = await checkDetections();
expect(third.newCount).toBe(2);
expect(chrome.notifications.create).toHaveBeenCalledTimes(1);
});

it('does not notify for low/medium/info detections', async () => {
const { checkDetections } = await load({
pages: [
{ detections: [], total: 0 },
{ detections: [detection('x', 'medium'), detection('y', 'low'), detection('z', 'info')], total: 3 },
],
});

await checkDetections();
const result = await checkDetections();

expect(result.newCount).toBe(3);
expect(result.urgentCount).toBe(0);
expect(chrome.notifications.create).not.toHaveBeenCalled();
});

it('respects the notifications setting', async () => {
const { checkDetections } = await load({
pages: [{ detections: [], total: 0 }, { detections: [detection('a', 'critical')], total: 1 }],
});
store.notificationsEnabled = false;

await checkDetections();
await checkDetections();

expect(chrome.notifications.create).not.toHaveBeenCalled();
});

it('re-baselines instead of notifying when the current org changes', async () => {
const other = { id: 'org-2', slug: 'other', name: 'Other' };
const { checkDetections } = await load({
organizations: [ORG, other],
pages: [{ detections: [], total: 0 }, { detections: [detection('a', 'critical')], total: 1 }],
});

await checkDetections();
api.getProfile.mockResolvedValue({ profile: { current_org_id: 'org-2' } });
const result = await checkDetections();

expect(result.org).toEqual(other);
expect(chrome.notifications.create).not.toHaveBeenCalled();
});

it('is a quiet no-op when signed out', async () => {
const { checkDetections } = await load({ token: null });
store.detectionAlerts = { org: ORG, newCount: 4, seenIds: ['a'] };

const result = await checkDetections();

expect(result).toMatchObject({ signedIn: false, org: null, newCount: 0 });
expect(api.listOrganizations).not.toHaveBeenCalled();
expect(store.detectionAlerts).toBeUndefined();
expect(chrome.notifications.create).not.toHaveBeenCalled();
expect(console.warn).not.toHaveBeenCalled();
expect(console.error).not.toHaveBeenCalled();
});

it('reports no org and fetches no detections when the user has no organization', async () => {
const { checkDetections } = await load({ organizations: [] });

const result = await checkDetections();

expect(result).toMatchObject({ signedIn: true, org: null, newCount: 0 });
expect(api.listDetections).not.toHaveBeenCalled();
expect(console.warn).not.toHaveBeenCalled();
expect(console.error).not.toHaveBeenCalled();
});

it('keeps the last known state and does not notify when the API fails', async () => {
const { checkDetections } = await load({
pages: [
{ detections: [detection('a', 'high')], total: 1 },
new Error('API error: 502'),
{ detections: [detection('a', 'high')], total: 1 },
],
});

await checkDetections();
const failed = await checkDetections();

expect(failed).toMatchObject({ org: ORG, newCount: 1, error: expect.any(String) });
expect(store.detectionAlerts.seenIds).toEqual(['a']);

// Recovery: the detection seen before the outage does not notify.
await checkDetections();
expect(chrome.notifications.create).not.toHaveBeenCalled();
});

it('shares one poll between concurrent callers so an alert fires once', async () => {
const { checkDetections } = await load({
pages: [{ detections: [], total: 0 }, { detections: [detection('a', 'critical')], total: 1 }],
});
await checkDetections();

await Promise.all([checkDetections(), checkDetections()]);

expect(api.listDetections).toHaveBeenCalledTimes(2);
expect(chrome.notifications.create).toHaveBeenCalledTimes(1);
});
});

describe('notification click', () => {
it("opens the org's detections page", async () => {
const { registerDetectionAlerts } = await load();
registerDetectionAlerts();
const [onClick] = chrome.notifications.onClicked.addListener.mock.calls[0];

onClick('threatcrush-detections:acme');
onClick('some-other-notification');

expect(chrome.tabs.create).toHaveBeenCalledTimes(1);
expect(chrome.tabs.create).toHaveBeenCalledWith({ url: 'https://threatcrush.com/org/acme/detections' });
});
});
Binary file modified apps/extension/public/icons/icon-128.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified apps/extension/public/icons/icon-16.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified apps/extension/public/icons/icon-32.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified apps/extension/public/icons/icon-48.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading