diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5a17a7bc..19381def 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,44 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [Unreleased]
+
+## [1.0.3] - 2026-06-26
+
+A security-focused release. It resolves a batch of privately reported issues (coordinated disclosure via KIberblick.de): cross-tenant read on the dashboard API endpoints, stored XSS via OTLP `service.name` in the service map, an open redirect on the auth-free login/register path, a DNS-rebinding gap in the SSRF guard's HTTP path, a first-admin bootstrap promotion race, and a capability-limit check-then-act race; trace span attributes are now PII-masked as well, and `service.name` is sanitized at ingestion as defense in depth. No database migrations; drop-in upgrade. Alongside the security work: two correctness follow-ups from the multi-engine bug-hunt sweep (issue #255): Sigma detection now honors full SigmaHQ field-modifier chains, and the service-map p95 is a true window percentile on every storage engine. The storage-layer change was validated against real ClickHouse, MongoDB and TimescaleDB. This line also fixes two operational bugs: a Redis memory leak where completed/failed BullMQ jobs were never evicted, and a nightly SigmaHQ sync that re-imported the whole catalog as enabled and auto-created alert rules. Plus a few frontend touch-ups: theme-aware trace/session IDs in the log detail, per-occurrence trace links on the error page, metadata copy buttons, a breadcrumbs timeline and nested metadata columns in log search.
+
+### Security
+- **Cross-tenant read on the dashboard API endpoints (fixed)**: the five dashboard endpoints (`/api/v1/dashboard/stats`, `/timeseries`, `/top-services`, `/timeline-events`, `/recent-errors`) plus the newer `/activity-overview` took `organizationId` from the query string and only ran the organization-membership check behind `if (request.user?.id)`, which is set for session auth only. For API-key auth `requireFullAccess` lets any non-write key through without setting `request.user`, so the membership check was skipped and the org was read from the attacker-supplied query rather than the key's bound `request.organizationId`; when `projectId` was omitted the project-in-org check was skipped too. A holder of any full-access API key (bound to org A) could read another organization's dashboard data by passing that org's id. All six handlers now route through a shared `resolveDashboardScope` that, for API-key auth, requires the requested org to match the key's bound org and the requested project to match (defaulting to the key's bound project when omitted), mirroring `resolveQueryProjectId` which already protects the query and traces routes. Session auth keeps the org-membership and project-in-org checks. Reported privately via KIberblick.de
+- **Stored XSS via OTLP `service.name` in the service map (fixed)**: `service.name` from ingested traces passed through `sanitizeForPostgres`, which only strips null bytes, so `< > " '` survived into `span.service_name` and were served verbatim by the service-map API. In `ServiceMap.svelte` the ECharts `tooltip.formatter` returned a raw HTML string built from `params.name` / `params.data.source` / `params.data.target`, and ECharts renders tooltip output as HTML, so a `service.name` like `` executed in the browser of any operator who opened the project's service map and hovered the node or edge. User-derived tooltip values are now HTML-escaped via a shared `escapeHtml` util (also adopted by the SIEM HTML report builder, replacing its private copy). Stored data is left raw on purpose (escaping at the sink, not the store, avoids double-encoding and keeps the JSON API correct). Reported privately via KIberblick.de
+- **Open redirect on auth-free login/register (fixed)**: in `authMode === 'none'` deployments the login and register pages forwarded the user-supplied `redirect` query parameter via `goto(redirectUrl)` with no validation, while the normal submit path already checked it. The check now lives in a shared `isSafeInternalPath`/`safeRedirect` helper used by both the auth-free and normal paths on both pages; it requires a single-leading-slash path and rejects protocol-relative forms including the backslash variant (`/\evil.com`) that some browsers normalize to `//`. Reported privately via KIberblick.de
+- **SSRF guard now pins the validated IP (DNS rebinding hardening)**: `safeFetch` resolved and validated the target host, then let `fetch()` re-resolve at connect time, leaving a resolve-then-connect window where a hostname could rebind to an internal address between check and connect (the TCP monitor path already pinned, the HTTP path did not). The HTTP(S) path now connects through a per-request undici dispatcher whose lookup is pinned to the already-validated address, so the socket reaches the exact IP that passed validation; TLS SNI and certificate validation still use the original hostname. Reported privately via KIberblick.de
+- **First-admin bootstrap race (fixed)**: `createUser` decided the automatic first-admin promotion with a non-atomic `hasAnyAdmin()` check followed by a separate insert, so concurrent registrations in the zero-admin window could each observe "no admin yet" and all be promoted. The check-then-insert now runs inside a transaction holding a Postgres advisory lock, so at most one registration wins the promotion. Only reachable before the first admin exists (and closed entirely when `INITIAL_ADMIN_*` is set). Reported privately via KIberblick.de
+- **Capability limit check-then-act race (fixed)**: resource-creating routes (api keys, custom dashboards, alert rules, sigma rule import/enable, notification channels) ran `COUNT -> assertWithinLimit -> insert` without serialization, so parallel requests could each read a count under the limit and then all insert, exceeding a configured finite cap (a quota bypass, not a tenant boundary; the OSS default has no finite limits). The count+create now runs through a shared `withLimitLock` helper that takes a per-organization, per-capability transaction-scoped advisory lock, so concurrent creators of the same resource type serialize and the cap holds. Reported privately via KIberblick.de
+- **Defense-in-depth on OTLP `service.name` at ingestion**: complementing the service-map output-encoding fix above, ingested `service.name` (for logs, spans and metrics) is now run through a shared `sanitizeServiceName` that strips control characters (C0/DEL/C1, including null bytes) and caps the length, while deliberately preserving otherwise legitimate characters (escaping still happens at each sink). This keeps a raw payload from resurfacing through a sink that is added later or forgets to encode. Suggested by KIberblick.de
+- **PII masking now also covers trace span attributes**: masking was wired only into log ingestion, so trace spans were stored with their attributes verbatim. Spans routinely carry `http.request_body` / `http.response_body` (plaintext credentials, JWTs), `net.peer.ip` and user agents, all persisted unmasked. `tracesService.ingestSpans` now runs the same org/project masking rules over each span's `attributes`, `resourceAttributes` and event/link attributes before storage, and drops (fail-closed) any span whose masking throws. Because request/response bodies are opaque stringified JSON that field-name rules can't see into, those body attributes are deep-masked (parse the JSON, mask `password`/`token`/email inside, re-serialize) with full redaction as a fallback when the value is not parseable JSON. Metric attributes are not yet masked (tracked separately)
+
+### Added
+- **Per-occurrence trace links on the error detail page**: each log in an error group's Logs tab now shows a "View Trace" action when that log carries a trace context, opening the existing trace timeline. The error-group logs endpoint (`GET /api/v1/error-groups/:id/logs`) now surfaces the `traceId` it already loaded from storage and previously discarded; no schema change, no migration
+- **Copy buttons on metadata blocks**: the log search expanded detail and the Log Context dialog now have a one-click copy on each metadata block (with copied feedback), so a log's metadata JSON can be grabbed without selecting it by hand
+- **Breadcrumbs timeline in the log search detail**: when a log carries `metadata.breadcrumbs`, the expanded row now renders a collapsible "Breadcrumbs (N)" timeline (the same `BreadcrumbTimeline` view already used in the Log Context dialog) instead of leaving them buried in the raw metadata JSON
+- **Nested metadata columns**: custom metadata columns in log search now accept dot-notation paths (e.g. `sdk.name`) to read into nested objects. Exact top-level keys still win first, so flat keys that contain dots (e.g. `debug.trace_id`) keep resolving; object/array values render as compact JSON, and the full value is available on hover
+
+### Fixed
+- **Admin usage page returned 403 for organizations the admin wasn't a member of**: the metering endpoints (`/usage`, `/usage/breakdown`, `/usage/storage`, `/usage/capabilities`) gated solely on org membership, so the platform Admin > Usage page (which lists every organization) got "Forbidden" whenever a selected org wasn't one the admin personally belonged to. Platform admins (`is_admin`) now bypass the membership check on these read endpoints; the queries stay filtered by the requested `organizationId`, so tenant scoping is unchanged
+- **Trace volume / trace latency dashboard panels were empty on ClickHouse and MongoDB**: both panel fetchers read span data straight from the Postgres `spans` hypertable and its continuous aggregates and short-circuited to an empty series when `reservoir.getEngineType() !== 'timescale'`, so on ClickHouse/MongoDB deployments (where spans live in those engines) the panels returned no data. Added a multi-engine `reservoir.getSpanTimeseries` (time-bucketed span volume + true window p50/p95/p99 from raw spans: `percentile_cont` on TimescaleDB, `quantile` on ClickHouse, `$percentile` on MongoDB) and switched both fetchers to it. The ClickHouse and MongoDB paths mirror the validated `getServiceHealthStats` percentile approach
+- **UI dates and numbers no longer follow the machine locale**: across the frontend many `toLocaleDateString`/`toLocaleTimeString`/`toLocaleString` calls were made with no locale (or `undefined`), so on a non-English host they rendered localized weekdays/months (e.g. "mercoledi") and localized number grouping. All user-facing date/time/number formatting is now pinned to `en-US` (the project convention), so the UI reads the same regardless of the server/browser locale. Swept 51 files
+- **Error detail trend bars were invisible**: the occurrence-trend chart on the error detail page rendered only the weekday labels and no bars. The bars used a percentage `height` whose parent column had no definite height (`items-end` left the columns sized to content), so the percentage collapsed to zero. The columns now take full height with the bar anchored in a flex track, so the bars render (a small baseline is kept for non-zero days)
+- **Redis memory leak: completed/failed jobs were never evicted**: the BullMQ queue adapter defined sane `removeOnComplete`/`removeOnFail` cleanup defaults on the queue, but its `add()` then passed `removeOnComplete: undefined` / `removeOnFail: undefined` on every job. BullMQ merges per-job options over the queue defaults with `Object.assign`, which copies the `undefined` keys and so wiped the cleanup config, making BullMQ retain every completed and failed job hash (and its full payload) in Redis forever. With the high-volume ingestion jobs (`sigma-detection`, `log-pipeline`, `exception-parsing`) carrying whole log batches, Redis grew unbounded (multi-GB) while the dashboard still showed 0 waiting / 0 failed. `add()` now omits those keys unless the caller sets them, so the queue-level retention (keep 100 completed/1h, 50 failed/24h) applies
+- **Nightly SigmaHQ sync re-imported the entire catalog and auto-created alert rules**: the 2:30 AM cron called the sync with no rule selection, falling into the "fetch ALL rules" path that pulled the whole SigmaHQ catalog (~2000+ rules) and inserted them all as `enabled = true` (the `sigma_rules.enabled` column defaults to true and the insert never set it), so an org that had enabled 5-6 rules woke up with thousands active. The same cron passed `autoCreateAlerts: true`, which inserted an `alert_rules` row per synced Sigma rule, so Sigma rules appeared to "turn into" alert rules overnight. The cron now syncs only the rules the org already imported (by `sigmahq_path`) to refresh their detection content, and never auto-creates alert rules; Sigma rules stay independent. (Does not retroactively clean rules/alerts already created; a one-off cleanup is tracked separately)
+- **Log Context dialog no longer overflows on wide content**: a wide metadata `
` or breadcrumb entry stretched the whole dialog (the grid children had `min-width: auto`); the content now stays within the dialog and the wide block scrolls on its own axis
+- **Sigma compound field-modifier chains were silently truncated**: the matcher split a field key like `CommandLine|utf16le|base64offset|contains` on `|` but kept only the first modifier, so any rule using a transform-plus-comparator chain (or a PowerShell `-enc` style `utf16le|base64offset|contains`) matched incorrectly. The whole chain is now parsed and applied in order: transforms (`base64`, `base64offset`, `utf16le`/`utf16`/`utf16be`/`wide`, `windash`) rewrite the pattern, then the final comparator runs. Transforms follow the canonical SigmaHQ model (the pattern is encoded, e.g. the field is checked for `base64(value)`), which is what real SigmaHQ rules are authored against. Added `cidr` and numeric `gt`/`gte`/`lt`/`lte` comparators while reworking the parser
+- **Sigma `|all` modifier had the wrong semantics**: it was implemented as "all whitespace-split words present in any order" rather than the SigmaHQ list quantifier. `|all` now flips the default OR over a value list into AND (every list element must match), and composes with modifier chains (e.g. `cmd|base64|contains|all`)
+
+### Changed
+- **Project overview now shows an Activity Overview instead of a logs-only timeline**: the project overview page (`/dashboard/projects/:id/overview`) replaced the "Logs Timeline (Last 24 Hours)" chart (log levels only) with the multi-signal Activity Overview, plotting logs, log errors, spans, span errors, detections and alerts over the same 24h window (toggle individual series from the legend). It reuses the existing custom-dashboard `activity_overview` fetcher via a new `GET /api/v1/dashboard/activity-overview` endpoint (org-membership + project-in-org scoped); no new storage or migration
+- **Service-map p95 is now a true window percentile across all engines**: the service dependency map previously reported `MAX(duration_p95_ms)` from the per-bucket spans continuous aggregate, which overestimates (a p95 is not derivable by combining per-bucket p95s) and was only ever produced on TimescaleDB. Per-service health stats now come from a new `reservoir.getServiceHealthStats` computed directly from raw spans over the requested window on every engine: `percentile_cont` on TimescaleDB, `quantile(0.95)` on ClickHouse, and `$percentile` on MongoDB (approximate t-digest, Mongo 7.0+). ClickHouse and MongoDB service maps now carry real call/error/latency/p95 figures where they previously had none. The `spans_hourly_stats` / `spans_daily_stats` aggregates are unchanged and still back the dashboards
+- **Trace and session IDs in the log search detail are theme-aware**: the expanded log row rendered them as hardcoded light-mode pills (`bg-purple-100` / `bg-teal-100`) that looked washed out in dark mode. The trace ID is now a link that opens the trace timeline (primary accent) with a separate filter button, and the session ID is a dark-safe filter button; both derive their colors from the design tokens
+
## [1.0.2] - 2026-06-22
A frontend correctness and security release from a comprehensive multi-agent frontend bug hunt (UI, logic, reactivity, leaks and security), plus a hardening of how the browser authenticates the live-streaming endpoints. The headline item is single-use stream tickets: the session token no longer travels in WebSocket/SSE URLs (where reverse proxies log it). One additive database migration (`049_stream_tickets`); otherwise a drop-in upgrade.
diff --git a/README.md b/README.md
index 8c8ae6cd..35290eb9 100644
--- a/README.md
+++ b/README.md
@@ -16,14 +16,14 @@
-
+
-> **🌊 LogTide 1.0.2 (public beta):** unified **Logs, Traces & Metrics** with a built-in **SIEM**, multi-engine storage (TimescaleDB / ClickHouse / MongoDB), uptime monitoring, parsing pipelines, and custom dashboards.
+> **🌊 LogTide 1.0.3 (public beta):** unified **Logs, Traces & Metrics** with a built-in **SIEM**, multi-engine storage (TimescaleDB / ClickHouse / MongoDB), uptime monitoring, parsing pipelines, and custom dashboards.
---
@@ -124,7 +124,7 @@ We host it for you. Perfect for testing. [**Sign up at logtide.dev**](https://lo
---
-## ✨ Core Features (v1.0.2)
+## ✨ Core Features (v1.0.3)
### Monitoring, Pipelines & Dashboards
* 🩺 **Uptime Monitoring & Status Pages:** HTTP/TCP/heartbeat monitors with configurable thresholds, auto-created SIEM incidents on failure, scheduled maintenances, and public Uptime-Kuma-style status pages per project.
diff --git a/SECURITY.md b/SECURITY.md
index 853e8e4d..e34a6b99 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -37,6 +37,7 @@ We thank the following researchers for responsibly disclosing security issues:
- **Bertie** — cross-tenant authorization gaps in project-scoped routes (alert preview/creation, monitor creation, source map list/delete) and SSRF / internal port-scanning via HTTP/TCP monitors and webhook delivery. Fixed in 0.9.6.
- **tonghuaroot** — SSRF in the alert/Sigma webhook delivery path, which still used the bypassable inline filter instead of the centralized `safeFetch` guard (incomplete-fix sibling-gap of the 0.9.6 hardening). Fixed in 0.9.7. (GHSA-7v53-pw6r-99vj)
+- **KIberblick.de** ([kiberblick.de](https://kiberblick.de)) — cross-tenant read on the dashboard API endpoints (organization taken from the attacker-supplied query string under API-key auth) and stored XSS via OTLP `service.name` in the service map; plus an open redirect on the auth-free login/register path, a DNS-rebinding gap in the SSRF guard's HTTP path, a first-admin bootstrap promotion race, and a capability-limit check-then-act race. Fixed in 1.0.3.
## Supported Versions
diff --git a/package.json b/package.json
index 00232a3a..8748606e 100644
--- a/package.json
+++ b/package.json
@@ -1,13 +1,13 @@
{
"name": "logtide",
- "version": "1.0.2",
+ "version": "1.0.3",
"private": true,
"description": "LogTide - Self-hosted log management platform",
"author": "LogTide Team",
"license": "AGPL-3.0",
"pnpm": {
"overrides": {
- "esbuild": ">=0.28.1",
+ "esbuild": ">=0.25.0 <0.26.0",
"shell-quote": ">=1.8.4",
"form-data": ">=4.0.6",
"vite": ">=6.4.3",
diff --git a/packages/backend/package.json b/packages/backend/package.json
index 05198cc3..ad30355a 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "@logtide/backend",
- "version": "1.0.2",
+ "version": "1.0.3",
"private": true,
"description": "LogTide Backend API",
"type": "module",
@@ -70,6 +70,7 @@
"safe-regex2": "^5.0.0",
"source-map": "^0.7.6",
"tsx": "^4.21.0",
+ "undici": "^7.28.0",
"zod": "^3.25.76"
},
"devDependencies": {
diff --git a/packages/backend/src/capabilities/index.ts b/packages/backend/src/capabilities/index.ts
index cc6ed7f5..455d1468 100644
--- a/packages/backend/src/capabilities/index.ts
+++ b/packages/backend/src/capabilities/index.ts
@@ -32,3 +32,4 @@ export {
export { quotaFlagCache } from './quota-cache.js';
export { QuotaEvaluator } from './quota-evaluator.js';
+export { withLimitLock } from './limit-lock.js';
diff --git a/packages/backend/src/capabilities/limit-lock.ts b/packages/backend/src/capabilities/limit-lock.ts
new file mode 100644
index 00000000..279cbcf4
--- /dev/null
+++ b/packages/backend/src/capabilities/limit-lock.ts
@@ -0,0 +1,73 @@
+import { sql } from 'kysely';
+import { db } from '../database/connection.js';
+
+// Namespace for the (int4, int4) Postgres advisory lock keyspace used by
+// capability limit enforcement, kept distinct from other advisory locks.
+const CAP_LOCK_NAMESPACE = 0x4c54; // 'LT'
+
+/** Deterministic 32-bit signed hash (FNV-1a) for advisory lock keys. */
+function hash32(input: string): number {
+ let h = 2166136261;
+ for (let i = 0; i < input.length; i++) {
+ h ^= input.charCodeAt(i);
+ h = Math.imul(h, 16777619);
+ }
+ return h | 0; // coerce to signed int32 for pg_advisory_xact_lock(int4, int4)
+}
+
+// In-process serialization tails, keyed by org+capability. This bounds the
+// number of callers that block on the DB advisory lock to ONE per key per
+// process, so concurrent requests don't each hold a transaction connection
+// while waiting (which would exhaust the pool).
+const localTails = new Map>();
+
+async function runExclusiveInProcess(key: string, fn: () => Promise): Promise {
+ const prevTail = localTails.get(key) ?? Promise.resolve();
+ let release!: () => void;
+ const tail = new Promise((resolve) => {
+ release = resolve;
+ });
+ localTails.set(key, tail);
+ await prevTail; // wait for the previous holder of this key
+ try {
+ return await fn();
+ } finally {
+ release();
+ // Drop the entry once we are the last in line, to keep the map bounded.
+ if (localTails.get(key) === tail) localTails.delete(key);
+ }
+}
+
+/**
+ * Serialize a "count current usage -> assert under limit -> create" sequence
+ * against concurrent callers for the same (organization, capability).
+ *
+ * The enforcement pattern (COUNT -> assertWithinLimit -> insert) is a
+ * check-then-act: without serialization, parallel requests can each read a count
+ * below the limit and then all insert, pushing usage past a finite cap.
+ *
+ * Serialization happens at two levels: an in-process mutex per org+capability
+ * (so within one backend instance only one such create runs at a time, and
+ * waiters do not hold a database connection while queued), wrapping a
+ * transaction-scoped Postgres advisory lock on the same key (so the guarantee
+ * also holds across multiple backend instances sharing the database). The
+ * advisory lock is released automatically on commit/rollback.
+ *
+ * Different organizations and different capabilities use distinct keys and do
+ * not contend. When no finite limit is configured (the OSS default) the only
+ * added cost is one advisory lock/unlock round-trip.
+ */
+export async function withLimitLock(
+ organizationId: string,
+ capabilityKey: string,
+ fn: () => Promise,
+): Promise {
+ const key = `${organizationId}:${capabilityKey}`;
+ const key2 = hash32(key);
+ return runExclusiveInProcess(key, () =>
+ db.transaction().execute(async (trx) => {
+ await sql`SELECT pg_advisory_xact_lock(${CAP_LOCK_NAMESPACE}, ${key2})`.execute(trx);
+ return fn();
+ }),
+ );
+}
diff --git a/packages/backend/src/modules/alerts/routes.ts b/packages/backend/src/modules/alerts/routes.ts
index 302294cd..add2cbfe 100644
--- a/packages/backend/src/modules/alerts/routes.ts
+++ b/packages/backend/src/modules/alerts/routes.ts
@@ -8,7 +8,7 @@ import { OrganizationsService } from '../organizations/service.js';
import { projectsService } from '../projects/service.js';
import { notificationChannelsService } from '../notification-channels/index.js';
import { auditLogService } from '../audit-log/index.js';
-import { assertWithinLimit } from '../../capabilities/index.js';
+import { assertWithinLimit, withLimitLock } from '../../capabilities/index.js';
const organizationsService = new OrganizationsService();
@@ -184,26 +184,27 @@ export async function alertsRoutes(fastify: FastifyInstance) {
// Session-auth requests don't populate request.organizationId in the ALS
// context, so we establish a scoped system context with the org from the
// validated body. Same pattern as otlp/trace-routes.ts.
- // Note: count -> insert is not atomic; a concurrent create can briefly
- // exceed the cap by one. Acceptable for user-initiated rule creation.
- await context.runAsSystem('alerts:create-limit-check', async () => {
- await context.with({ organizationId: body.organizationId }, async () => {
- const currentRuleCount = await alertsService.countAlertRules(body.organizationId);
- await assertWithinLimit('alerts.max_rules', currentRuleCount);
- });
- });
-
+ // The count -> insert is serialized per org via withLimitLock so concurrent
+ // creates can't race past the cap.
const { channelIds, alertType, baselineType, deviationMultiplier, minBaselineValue, cooldownMinutes, sustainedMinutes, metadataFilters, ...alertData } = body;
- const alertRule = await alertsService.createAlertRule({
- ...alertData,
- alertType: alertType || 'threshold',
- baselineType: baselineType || null,
- deviationMultiplier: deviationMultiplier ?? null,
- minBaselineValue: minBaselineValue ?? null,
- cooldownMinutes: cooldownMinutes ?? null,
- sustainedMinutes: sustainedMinutes ?? null,
- emailRecipients: alertData.emailRecipients || [],
- metadataFilters: metadataFilters ?? [],
+ const alertRule = await withLimitLock(body.organizationId, 'alerts.max_rules', async () => {
+ await context.runAsSystem('alerts:create-limit-check', async () => {
+ await context.with({ organizationId: body.organizationId }, async () => {
+ const currentRuleCount = await alertsService.countAlertRules(body.organizationId);
+ await assertWithinLimit('alerts.max_rules', currentRuleCount);
+ });
+ });
+ return alertsService.createAlertRule({
+ ...alertData,
+ alertType: alertType || 'threshold',
+ baselineType: baselineType || null,
+ deviationMultiplier: deviationMultiplier ?? null,
+ minBaselineValue: minBaselineValue ?? null,
+ cooldownMinutes: cooldownMinutes ?? null,
+ sustainedMinutes: sustainedMinutes ?? null,
+ emailRecipients: alertData.emailRecipients || [],
+ metadataFilters: metadataFilters ?? [],
+ });
});
// Associate channels with the alert rule
diff --git a/packages/backend/src/modules/api-keys/routes.ts b/packages/backend/src/modules/api-keys/routes.ts
index 8d1d256f..7d50b8fb 100644
--- a/packages/backend/src/modules/api-keys/routes.ts
+++ b/packages/backend/src/modules/api-keys/routes.ts
@@ -6,7 +6,7 @@ import { apiKeysService } from './service.js';
import { authenticate } from '../auth/middleware.js';
import { projectsService } from '../projects/service.js';
import { auditLogService } from '../audit-log/index.js';
-import { assertWithinLimit } from '../../capabilities/index.js';
+import { assertWithinLimit, withLimitLock } from '../../capabilities/index.js';
import { CapabilityError } from '../../capabilities/errors.js';
const createApiKeySchema = z.object({
@@ -72,18 +72,20 @@ export async function apiKeysRoutes(fastify: FastifyInstance) {
});
}
- await context.runAsSystem('apikeys:create-limit-check', async () => {
- await context.with({ organizationId: project.organizationId }, async () => {
- const count = await apiKeysService.countKeysForOrg(project.organizationId);
- await assertWithinLimit('apikeys.max', count);
+ const result = await withLimitLock(project.organizationId, 'apikeys.max', async () => {
+ await context.runAsSystem('apikeys:create-limit-check', async () => {
+ await context.with({ organizationId: project.organizationId }, async () => {
+ const count = await apiKeysService.countKeysForOrg(project.organizationId);
+ await assertWithinLimit('apikeys.max', count);
+ });
});
- });
- const result = await apiKeysService.createApiKey({
- projectId,
- name: body.name,
- type: body.type,
- allowedOrigins: body.allowedOrigins ?? null,
+ return apiKeysService.createApiKey({
+ projectId,
+ name: body.name,
+ type: body.type,
+ allowedOrigins: body.allowedOrigins ?? null,
+ });
});
await auditLogService.record({
diff --git a/packages/backend/src/modules/custom-dashboards/panel-data-service.ts b/packages/backend/src/modules/custom-dashboards/panel-data-service.ts
index 83b45d1e..612e5f7a 100644
--- a/packages/backend/src/modules/custom-dashboards/panel-data-service.ts
+++ b/packages/backend/src/modules/custom-dashboards/panel-data-service.ts
@@ -540,51 +540,34 @@ const traceLatencyFetcher: PanelDataSource
? [config.projectId]
: await resolveProjectIdsForOrg(ctx.organizationId);
- if (projectIds.length === 0 || reservoir.getEngineType() !== 'timescale') {
+ if (projectIds.length === 0) {
return { series: [], serviceName: config.serviceName };
}
const now = new Date();
const rangeMs = timeRangeToMs(config.timeRange);
const from = new Date(now.getTime() - rangeMs);
- // Use hourly aggregate for ranges <= 48h, daily otherwise.
- const useHourly = rangeMs <= 48 * 60 * 60 * 1000;
- const table = useHourly ? 'spans_hourly_stats' : 'spans_daily_stats';
-
- let query = db
- .selectFrom(table)
- .select([
- 'bucket',
- sql`SUM(span_count)`.as('span_count'),
- sql`MAX(duration_p50_ms)`.as('p50'),
- sql`MAX(duration_p95_ms)`.as('p95'),
- sql`MAX(duration_p99_ms)`.as('p99'),
- sql`CASE WHEN SUM(span_count) > 0
- THEN SUM(COALESCE(error_count, 0))::float / SUM(span_count)
- ELSE 0 END`.as('error_rate'),
- ])
- .where('project_id', 'in', projectIds)
- .where('bucket', '>=', from)
- .where('bucket', '<=', now);
-
- if (config.serviceName) {
- query = query.where('service_name', '=', config.serviceName);
- }
+ const bucket: 'hour' | 'day' = rangeMs <= 48 * 60 * 60 * 1000 ? 'hour' : 'day';
- const rows = await query
- .groupBy('bucket')
- .orderBy('bucket', 'asc')
- .execute();
+ // Multi-engine: true window percentiles from raw spans on every storage
+ // engine (Timescale percentile_cont / ClickHouse quantile / Mongo $percentile).
+ const rows = await reservoir.getSpanTimeseries({
+ projectIds,
+ from,
+ to: now,
+ bucket,
+ serviceName: config.serviceName ?? undefined,
+ });
return {
serviceName: config.serviceName,
series: rows.map((r) => ({
- time: new Date(r.bucket as unknown as string).toISOString(),
- p50: r.p50 != null ? Number(r.p50) : null,
- p95: r.p95 != null ? Number(r.p95) : null,
- p99: r.p99 != null ? Number(r.p99) : null,
- spanCount: Number(r.span_count ?? 0),
- errorRate: Number(r.error_rate ?? 0),
+ time: r.time.toISOString(),
+ p50: r.p50,
+ p95: r.p95,
+ p99: r.p99,
+ spanCount: r.spanCount,
+ errorRate: r.spanCount > 0 ? r.errorCount / r.spanCount : 0,
})),
};
},
@@ -600,7 +583,7 @@ const traceVolumeFetcher: PanelDataSource`SUM(span_count)`.as('span_count'),
- sql`SUM(COALESCE(error_count, 0))`.as('error_count'),
- ])
- .where('project_id', 'in', projectIds)
- .where('bucket', '>=', from)
- .where('bucket', '<=', now);
- if (config.serviceName) {
- caggQuery = caggQuery.where('service_name', '=', config.serviceName);
- }
- const caggRows = await caggQuery
- .groupBy('bucket')
- .orderBy('bucket', 'asc')
- .execute()
- .catch(
- () =>
- [] as Array<{
- bucket: unknown;
- span_count: string;
- error_count: string;
- }>,
- );
-
- let rows: Array<{ bucket: unknown; span_count: string; error_count: string }> =
- caggRows;
- if (rows.length === 0) {
- const rawTrunc =
- bucket === 'hour'
- ? sql`date_trunc('hour', start_time)`
- : sql`date_trunc('day', start_time)`;
- let rawQuery = db
- .selectFrom('spans')
- .select([
- rawTrunc.as('bucket'),
- sql`COUNT(*)`.as('span_count'),
- sql`SUM(CASE WHEN status_code = 'ERROR' THEN 1 ELSE 0 END)`.as(
- 'error_count',
- ),
- ])
- .where('project_id', 'in', projectIds)
- .where('start_time', '>=', from)
- .where('start_time', '<=', now);
- if (config.serviceName) {
- rawQuery = rawQuery.where('service_name', '=', config.serviceName);
- }
- rows = await rawQuery
- .groupBy(rawTrunc)
- .orderBy(rawTrunc, 'asc')
- .execute()
- .catch(
- () =>
- [] as Array<{
- bucket: unknown;
- span_count: string;
- error_count: string;
- }>,
- );
- }
+ // Multi-engine span volume straight from raw spans via the reservoir
+ // abstraction (works on Timescale, ClickHouse and MongoDB alike).
+ const rows = await reservoir.getSpanTimeseries({
+ projectIds,
+ from,
+ to: now,
+ bucket,
+ serviceName: config.serviceName ?? undefined,
+ });
return {
series: rows.map((r) => ({
- time: new Date(r.bucket as unknown as string).toISOString(),
- total: Number(r.span_count ?? 0),
- errors: Number(r.error_count ?? 0),
+ time: r.time.toISOString(),
+ total: r.spanCount,
+ errors: r.errorCount,
})),
serviceName: config.serviceName,
timeRange: config.timeRange,
diff --git a/packages/backend/src/modules/custom-dashboards/routes.ts b/packages/backend/src/modules/custom-dashboards/routes.ts
index 6d57278b..778d587d 100644
--- a/packages/backend/src/modules/custom-dashboards/routes.ts
+++ b/packages/backend/src/modules/custom-dashboards/routes.ts
@@ -6,7 +6,7 @@ import { customDashboardsService } from './service.js';
import { panelInstanceSchema } from './panel-registry.js';
import { fetchPanelData } from './panel-data-service.js';
import { context } from '@logtide/shared/context';
-import { assertWithinLimit } from '../../capabilities/index.js';
+import { assertWithinLimit, withLimitLock } from '../../capabilities/index.js';
import { CapabilityError } from '../../capabilities/errors.js';
import { auditLogService } from '../audit-log/service.js';
@@ -92,24 +92,26 @@ export async function customDashboardsRoutes(fastify: FastifyInstance) {
return reply.status(403).send({ error: 'Forbidden' });
}
- await context.runAsSystem('dashboards:create-limit-check', async () => {
- await context.with({ organizationId: body.organizationId }, async () => {
- const count = await customDashboardsService.countForOrg(body.organizationId);
- await assertWithinLimit('dashboards.max_custom', count);
+ const dashboard = await withLimitLock(body.organizationId, 'dashboards.max_custom', async () => {
+ await context.runAsSystem('dashboards:create-limit-check', async () => {
+ await context.with({ organizationId: body.organizationId }, async () => {
+ const count = await customDashboardsService.countForOrg(body.organizationId);
+ await assertWithinLimit('dashboards.max_custom', count);
+ });
});
- });
- const dashboard = await customDashboardsService.create(
- {
- organizationId: body.organizationId,
- projectId: body.projectId ?? null,
- name: body.name,
- description: body.description ?? null,
- isPersonal: body.isPersonal,
- panels: body.panels,
- },
- request.user.id
- );
+ return customDashboardsService.create(
+ {
+ organizationId: body.organizationId,
+ projectId: body.projectId ?? null,
+ name: body.name,
+ description: body.description ?? null,
+ isPersonal: body.isPersonal,
+ panels: body.panels,
+ },
+ request.user.id
+ );
+ });
await auditLogService.record({
action: 'dashboard.created',
@@ -140,18 +142,20 @@ export async function customDashboardsRoutes(fastify: FastifyInstance) {
return reply.status(403).send({ error: 'Forbidden' });
}
- await context.runAsSystem('dashboards:create-limit-check', async () => {
- await context.with({ organizationId: body.organizationId }, async () => {
- const count = await customDashboardsService.countForOrg(body.organizationId);
- await assertWithinLimit('dashboards.max_custom', count);
+ const dashboard = await withLimitLock(body.organizationId, 'dashboards.max_custom', async () => {
+ await context.runAsSystem('dashboards:create-limit-check', async () => {
+ await context.with({ organizationId: body.organizationId }, async () => {
+ const count = await customDashboardsService.countForOrg(body.organizationId);
+ await assertWithinLimit('dashboards.max_custom', count);
+ });
});
- });
- const dashboard = await customDashboardsService.importYaml(
- body.yaml,
- body.organizationId,
- request.user.id
- );
+ return customDashboardsService.importYaml(
+ body.yaml,
+ body.organizationId,
+ request.user.id
+ );
+ });
await auditLogService.record({
action: 'dashboard.imported',
diff --git a/packages/backend/src/modules/dashboard/routes.ts b/packages/backend/src/modules/dashboard/routes.ts
index 4b6a0387..1b66f534 100644
--- a/packages/backend/src/modules/dashboard/routes.ts
+++ b/packages/backend/src/modules/dashboard/routes.ts
@@ -2,6 +2,17 @@ import type { FastifyPluginAsync } from 'fastify';
import { dashboardService } from './service.js';
import { db } from '../../database/index.js';
import { requireFullAccess } from '../auth/guards.js';
+import { fetchPanelData } from '../custom-dashboards/panel-data-service.js';
+import type { ActivityOverviewConfig, ActivityOverviewSeries } from '@logtide/shared';
+
+const ACTIVITY_OVERVIEW_SERIES: ActivityOverviewSeries[] = [
+ 'logs',
+ 'log_errors',
+ 'spans',
+ 'span_errors',
+ 'detections',
+ 'alerts',
+];
async function verifyOrganizationAccess(organizationId: string, userId: string): Promise {
@@ -26,6 +37,76 @@ async function verifyProjectBelongsToOrg(projectId: string, organizationId: stri
return !!result;
}
+/** Returned when access was denied and a response has already been sent. */
+const SCOPE_DENIED = Symbol('dashboard-scope-denied');
+
+/**
+ * Resolve and authorize the tenant scope for a dashboard request.
+ *
+ * Session auth (request.user set): the user must be a member of the requested
+ * organization; a provided projectId must belong to that organization.
+ *
+ * API-key auth (no user; request.organizationId/projectId bound by the auth
+ * plugin): the requested organizationId MUST match the key's bound organization
+ * and a provided projectId MUST match the key's bound project. When projectId is
+ * omitted it defaults to the key's bound project, so a project-scoped key can
+ * never read org-wide or cross-org data. This mirrors resolveQueryProjectId,
+ * which already protects the query and traces routes.
+ *
+ * Returns the effective projectId to scope on (string | undefined), or
+ * SCOPE_DENIED if a 403/404 was already sent.
+ */
+async function resolveDashboardScope(
+ request: any,
+ reply: any,
+ organizationId: string,
+ projectId?: string,
+): Promise {
+ // Session-based auth: org-wide membership check.
+ if (request.user?.id) {
+ const hasAccess = await verifyOrganizationAccess(organizationId, request.user.id);
+ if (!hasAccess) {
+ reply.code(403).send({
+ error: 'Access denied - you are not a member of this organization',
+ });
+ return SCOPE_DENIED;
+ }
+
+ if (projectId) {
+ const belongsToOrg = await verifyProjectBelongsToOrg(projectId, organizationId);
+ if (!belongsToOrg) {
+ reply.code(404).send({ error: 'Project not found in this organization' });
+ return SCOPE_DENIED;
+ }
+ }
+
+ return projectId;
+ }
+
+ // API-key auth: the key is bound to a single org/project by the auth plugin.
+ // Enforce that the requested org/project match the key's bound values.
+ const boundOrg = request.organizationId;
+ const boundProject = request.projectId;
+
+ if (boundOrg && organizationId !== boundOrg) {
+ reply.code(403).send({
+ error: 'Access denied - API key is not bound to this organization',
+ });
+ return SCOPE_DENIED;
+ }
+
+ if (boundProject && projectId && projectId !== boundProject) {
+ reply.code(403).send({
+ error: 'Access denied - API key is not bound to this project',
+ });
+ return SCOPE_DENIED;
+ }
+
+ // Default to the key's bound project so a project-scoped key cannot read
+ // org-wide data (mirrors resolveQueryProjectId).
+ return projectId ?? boundProject;
+}
+
const dashboardRoutes: FastifyPluginAsync = async (fastify) => {
// GET /api/v1/dashboard/stats - Get dashboard statistics
fastify.get('/api/v1/dashboard/stats', {
@@ -52,26 +133,10 @@ const dashboardRoutes: FastifyPluginAsync = async (fastify) => {
});
}
- // SECURITY: Verify user is member of this organization (if using session auth)
- if (request.user?.id) {
- const hasAccess = await verifyOrganizationAccess(organizationId, request.user.id);
-
- if (!hasAccess) {
- return reply.code(403).send({
- error: 'Access denied - you are not a member of this organization',
- });
- }
- }
+ const scope = await resolveDashboardScope(request, reply, organizationId, projectId);
+ if (scope === SCOPE_DENIED) return;
- // Verify project belongs to org if specified
- if (projectId) {
- const belongsToOrg = await verifyProjectBelongsToOrg(projectId, organizationId);
- if (!belongsToOrg) {
- return reply.code(404).send({ error: 'Project not found in this organization' });
- }
- }
-
- const stats = await dashboardService.getStats(organizationId, projectId);
+ const stats = await dashboardService.getStats(organizationId, scope);
return stats;
},
});
@@ -101,26 +166,60 @@ const dashboardRoutes: FastifyPluginAsync = async (fastify) => {
});
}
- // SECURITY: Verify user is member of this organization (if using session auth)
- if (request.user?.id) {
- const hasAccess = await verifyOrganizationAccess(organizationId, request.user.id);
+ const scope = await resolveDashboardScope(request, reply, organizationId, projectId);
+ if (scope === SCOPE_DENIED) return;
- if (!hasAccess) {
- return reply.code(403).send({
- error: 'Access denied - you are not a member of this organization',
- });
- }
- }
+ const timeseries = await dashboardService.getTimeseries(organizationId, scope);
+ return { timeseries };
+ },
+ });
- if (projectId) {
- const belongsToOrg = await verifyProjectBelongsToOrg(projectId, organizationId);
- if (!belongsToOrg) {
- return reply.code(404).send({ error: 'Project not found in this organization' });
- }
+ // GET /api/v1/dashboard/activity-overview - Multi-signal activity timeline
+ // (logs, spans, detections, alerts). Reuses the custom-dashboard panel fetcher.
+ fastify.get('/api/v1/dashboard/activity-overview', {
+ schema: {
+ description: 'Get multi-signal activity overview timeline for organization or project',
+ tags: ['dashboard'],
+ querystring: {
+ type: 'object',
+ properties: {
+ organizationId: { type: 'string', format: 'uuid' },
+ projectId: { type: 'string', format: 'uuid' },
+ timeRange: { type: 'string', enum: ['24h', '7d', '30d'] },
+ },
+ required: ['organizationId'],
+ },
+ },
+ handler: async (request: any, reply) => {
+ if (!await requireFullAccess(request, reply)) return;
+
+ const { organizationId, projectId, timeRange } = request.query as {
+ organizationId: string;
+ projectId?: string;
+ timeRange?: '24h' | '7d' | '30d';
+ };
+
+ if (!organizationId) {
+ return reply.code(400).send({ error: 'organizationId is required' });
}
- const timeseries = await dashboardService.getTimeseries(organizationId, projectId);
- return { timeseries };
+ const scope = await resolveDashboardScope(request, reply, organizationId, projectId);
+ if (scope === SCOPE_DENIED) return;
+
+ const config: ActivityOverviewConfig = {
+ type: 'activity_overview',
+ title: 'Activity Overview',
+ source: 'mixed',
+ projectId: scope ?? null,
+ timeRange: timeRange ?? '24h',
+ series: ACTIVITY_OVERVIEW_SERIES,
+ };
+
+ const data = await fetchPanelData(config, {
+ organizationId,
+ userId: request.user?.id ?? '',
+ });
+ return data;
},
});
@@ -150,25 +249,10 @@ const dashboardRoutes: FastifyPluginAsync = async (fastify) => {
});
}
- // SECURITY: Verify user is member of this organization (if using session auth)
- if (request.user?.id) {
- const hasAccess = await verifyOrganizationAccess(organizationId, request.user.id);
-
- if (!hasAccess) {
- return reply.code(403).send({
- error: 'Access denied - you are not a member of this organization',
- });
- }
- }
-
- if (projectId) {
- const belongsToOrg = await verifyProjectBelongsToOrg(projectId, organizationId);
- if (!belongsToOrg) {
- return reply.code(404).send({ error: 'Project not found in this organization' });
- }
- }
+ const scope = await resolveDashboardScope(request, reply, organizationId, projectId);
+ if (scope === SCOPE_DENIED) return;
- const services = await dashboardService.getTopServices(organizationId, limit || 5, projectId);
+ const services = await dashboardService.getTopServices(organizationId, limit || 5, scope);
return { services };
},
});
@@ -198,24 +282,10 @@ const dashboardRoutes: FastifyPluginAsync = async (fastify) => {
});
}
- if (request.user?.id) {
- const hasAccess = await verifyOrganizationAccess(organizationId, request.user.id);
-
- if (!hasAccess) {
- return reply.code(403).send({
- error: 'Access denied - you are not a member of this organization',
- });
- }
- }
-
- if (projectId) {
- const belongsToOrg = await verifyProjectBelongsToOrg(projectId, organizationId);
- if (!belongsToOrg) {
- return reply.code(404).send({ error: 'Project not found in this organization' });
- }
- }
+ const scope = await resolveDashboardScope(request, reply, organizationId, projectId);
+ if (scope === SCOPE_DENIED) return;
- const events = await dashboardService.getTimelineEvents(organizationId, projectId);
+ const events = await dashboardService.getTimelineEvents(organizationId, scope);
return { events };
},
});
@@ -245,25 +315,10 @@ const dashboardRoutes: FastifyPluginAsync = async (fastify) => {
});
}
- // SECURITY: Verify user is member of this organization (if using session auth)
- if (request.user?.id) {
- const hasAccess = await verifyOrganizationAccess(organizationId, request.user.id);
-
- if (!hasAccess) {
- return reply.code(403).send({
- error: 'Access denied - you are not a member of this organization',
- });
- }
- }
-
- if (projectId) {
- const belongsToOrg = await verifyProjectBelongsToOrg(projectId, organizationId);
- if (!belongsToOrg) {
- return reply.code(404).send({ error: 'Project not found in this organization' });
- }
- }
+ const scope = await resolveDashboardScope(request, reply, organizationId, projectId);
+ if (scope === SCOPE_DENIED) return;
- const errors = await dashboardService.getRecentErrors(organizationId, projectId);
+ const errors = await dashboardService.getRecentErrors(organizationId, scope);
return { errors };
},
});
diff --git a/packages/backend/src/modules/exceptions/service.ts b/packages/backend/src/modules/exceptions/service.ts
index 7fcb399d..8dd693c2 100644
--- a/packages/backend/src/modules/exceptions/service.ts
+++ b/packages/backend/src/modules/exceptions/service.ts
@@ -506,7 +506,7 @@ export class ExceptionService {
occurrenceCount: number;
limit?: number;
offset?: number;
- }): Promise<{ logs: Array<{ id: string; time: Date; service: string; message: string; metadata?: Record }>; total: number }> {
+ }): Promise<{ logs: Array<{ id: string; time: Date; service: string; message: string; traceId?: string; metadata?: Record }>; total: number }> {
const limit = params.limit || 10;
const offset = params.offset || 0;
@@ -553,15 +553,16 @@ export class ExceptionService {
).flat();
// Build lookup map and return in the same order
- const logMap = new Map(storedLogs.map((l: { id: string; time: Date; service: string; message: string; metadata?: any }) => [l.id, l]));
+ const logMap = new Map(storedLogs.map((l: { id: string; time: Date; service: string; message: string; traceId?: string; metadata?: any }) => [l.id, l]));
const logs = logIds
.map(id => logMap.get(id))
- .filter((l): l is { id: string; time: Date; service: string; message: string; metadata?: any } => Boolean(l))
+ .filter((l): l is { id: string; time: Date; service: string; message: string; traceId?: string; metadata?: any } => Boolean(l))
.map(l => ({
id: l.id,
time: l.time,
service: l.service,
message: l.message,
+ traceId: l.traceId,
metadata: l.metadata,
}));
diff --git a/packages/backend/src/modules/metering/routes.ts b/packages/backend/src/modules/metering/routes.ts
index eba1906e..8f6fe6b8 100644
--- a/packages/backend/src/modules/metering/routes.ts
+++ b/packages/backend/src/modules/metering/routes.ts
@@ -32,13 +32,24 @@ async function checkMembership(userId: string, orgId: string): Promise
return orgs.some((o) => o.id === orgId);
}
+// Usage data is org-scoped, but platform admins (the admin usage page) need to
+// read any org's metering, so they bypass the membership check. The underlying
+// queries stay filtered by the requested organizationId.
+async function canAccessOrg(
+ user: { id: string; is_admin?: boolean },
+ orgId: string,
+): Promise {
+ if (user.is_admin) return true;
+ return checkMembership(user.id, orgId);
+}
+
export async function usageRoutes(fastify: FastifyInstance) {
fastify.addHook('onRequest', authenticate);
fastify.get('/', async (request: any, reply) => {
try {
const q = usageQuerySchema.parse(request.query);
- if (!(await checkMembership(request.user.id, q.organizationId))) {
+ if (!(await canAccessOrg(request.user, q.organizationId))) {
return reply.status(403).send({ error: 'Forbidden' });
}
@@ -64,7 +75,7 @@ export async function usageRoutes(fastify: FastifyInstance) {
fastify.get('/breakdown', async (request: any, reply) => {
try {
const q = breakdownQuerySchema.parse(request.query);
- if (!(await checkMembership(request.user.id, q.organizationId))) {
+ if (!(await canAccessOrg(request.user, q.organizationId))) {
return reply.status(403).send({ error: 'Forbidden' });
}
@@ -89,7 +100,7 @@ export async function usageRoutes(fastify: FastifyInstance) {
fastify.get('/storage', async (request: any, reply) => {
try {
const q = breakdownQuerySchema.parse(request.query);
- if (!(await checkMembership(request.user.id, q.organizationId))) {
+ if (!(await canAccessOrg(request.user, q.organizationId))) {
return reply.status(403).send({ error: 'Forbidden' });
}
@@ -114,7 +125,7 @@ export async function usageRoutes(fastify: FastifyInstance) {
fastify.get('/capabilities', async (request: any, reply) => {
try {
const q = orgOnlyQuerySchema.parse(request.query);
- if (!(await checkMembership(request.user.id, q.organizationId))) {
+ if (!(await canAccessOrg(request.user, q.organizationId))) {
return reply.status(403).send({ error: 'Forbidden' });
}
diff --git a/packages/backend/src/modules/notification-channels/routes.ts b/packages/backend/src/modules/notification-channels/routes.ts
index 3213ad46..4bac2739 100644
--- a/packages/backend/src/modules/notification-channels/routes.ts
+++ b/packages/backend/src/modules/notification-channels/routes.ts
@@ -9,7 +9,7 @@ import { authenticate } from '../auth/middleware.js';
import { OrganizationsService } from '../organizations/service.js';
import type { NotificationEventType } from '@logtide/shared';
import { context } from '@logtide/shared/context';
-import { assertWithinLimit } from '../../capabilities/index.js';
+import { assertWithinLimit, withLimitLock } from '../../capabilities/index.js';
import { CapabilityError } from '../../capabilities/errors.js';
import { auditLogService } from '../audit-log/service.js';
@@ -173,18 +173,20 @@ export async function notificationChannelsRoutes(fastify: FastifyInstance) {
return reply.status(403).send({ error: 'Only admins can create notification channels' });
}
- await context.runAsSystem('channels:create-limit-check', async () => {
- await context.with({ organizationId }, async () => {
- const count = await notificationChannelsService.countChannels(organizationId);
- await assertWithinLimit('notifications.max_channels', count);
+ const channel = await withLimitLock(organizationId, 'notifications.max_channels', async () => {
+ await context.runAsSystem('channels:create-limit-check', async () => {
+ await context.with({ organizationId }, async () => {
+ const count = await notificationChannelsService.countChannels(organizationId);
+ await assertWithinLimit('notifications.max_channels', count);
+ });
});
- });
- const channel = await notificationChannelsService.createChannel(
- organizationId,
- body,
- request.user.id
- );
+ return notificationChannelsService.createChannel(
+ organizationId,
+ body,
+ request.user.id
+ );
+ });
await auditLogService.record({
action: 'channel.created',
diff --git a/packages/backend/src/modules/otlp/trace-transformer.ts b/packages/backend/src/modules/otlp/trace-transformer.ts
index 383c4d79..d31a862f 100644
--- a/packages/backend/src/modules/otlp/trace-transformer.ts
+++ b/packages/backend/src/modules/otlp/trace-transformer.ts
@@ -7,7 +7,7 @@
*/
import type { SpanKind, SpanStatusCode } from '../../database/types.js';
-import { attributesToRecord, sanitizeForPostgres, type OtlpKeyValue } from './transformer.js';
+import { attributesToRecord, sanitizeForPostgres, sanitizeServiceName, type OtlpKeyValue } from './transformer.js';
import { isGzipCompressed, decompressGzip } from './parser.js';
import { createRequire } from 'module';
@@ -332,7 +332,7 @@ export function extractServiceName(attributes?: OtlpKeyValue[]): string {
const serviceAttr = attributes.find((attr) => attr.key === 'service.name');
if (serviceAttr?.value?.stringValue) {
- return sanitizeForPostgres(serviceAttr.value.stringValue);
+ return sanitizeServiceName(serviceAttr.value.stringValue);
}
return 'unknown';
diff --git a/packages/backend/src/modules/otlp/transformer.ts b/packages/backend/src/modules/otlp/transformer.ts
index d37d07fb..705214c0 100644
--- a/packages/backend/src/modules/otlp/transformer.ts
+++ b/packages/backend/src/modules/otlp/transformer.ts
@@ -218,6 +218,27 @@ export function sanitizeForPostgres(str: string): string {
return str.replace(/\x00/g, '');
}
+/**
+ * Maximum stored length for an OTLP service name. Service names are short
+ * identifiers; anything longer is almost certainly abuse or a bug.
+ */
+export const MAX_SERVICE_NAME_LENGTH = 255;
+
+/**
+ * Defense-in-depth sanitization for OTLP service.name before it is stored and
+ * later rendered (dashboards, service map). Strips control characters (C0, DEL,
+ * C1) including null bytes, and caps the length. It deliberately does NOT strip
+ * otherwise legitimate characters (such as < > " '): those are neutralized by
+ * output-encoding at each sink, and stripping them here would corrupt the value
+ * for the JSON API and other consumers. Returns 'unknown' if nothing usable
+ * remains.
+ */
+export function sanitizeServiceName(raw: string): string {
+ // eslint-disable-next-line no-control-regex
+ const cleaned = raw.replace(/[\x00-\x1f\x7f-\x9f]/g, '').slice(0, MAX_SERVICE_NAME_LENGTH);
+ return cleaned.trim().length > 0 ? cleaned : 'unknown';
+}
+
/**
* Extract service name from resource attributes.
* Falls back to 'unknown' if not found.
@@ -227,7 +248,7 @@ export function extractServiceName(attributes?: OtlpKeyValue[]): string {
const serviceAttr = attributes.find((attr) => attr.key === 'service.name');
if (serviceAttr?.value?.stringValue) {
- return sanitizeForPostgres(serviceAttr.value.stringValue);
+ return sanitizeServiceName(serviceAttr.value.stringValue);
}
return 'unknown';
diff --git a/packages/backend/src/modules/pii-masking/service.ts b/packages/backend/src/modules/pii-masking/service.ts
index e85edf2e..fc8e0add 100644
--- a/packages/backend/src/modules/pii-masking/service.ts
+++ b/packages/backend/src/modules/pii-masking/service.ts
@@ -18,6 +18,22 @@ import {
} from './built-in-rules.js';
import isSafeRegex from 'safe-regex2';
+// Span attribute keys that carry full request/response payloads. Their values
+// are opaque (often a stringified JSON body) so field-name rules can't see the
+// `password`/`token` keys inside. We deep-mask them: parse the JSON and run the
+// rules over the parsed object, falling back to full redaction when the value
+// is not parseable JSON.
+const BODY_ATTRIBUTE_KEYS = new Set([
+ 'http.request_body',
+ 'http.response_body',
+ 'http.request.body',
+ 'http.response.body',
+ 'request_body',
+ 'response_body',
+]);
+
+const BODY_REDACTION_LABEL = '[REDACTED]';
+
// ============================================================================
// Types
// ============================================================================
@@ -48,6 +64,17 @@ interface CacheEntry {
expiresAt: number;
}
+/**
+ * Minimal span shape masked in place by maskSpanBatch. Structurally compatible
+ * with the reservoir span record (we only touch the attribute bags).
+ */
+export interface SpanMaskInput {
+ attributes?: Record;
+ resourceAttributes?: Record;
+ events?: Array>;
+ links?: Array>;
+}
+
export interface PiiRuleInput {
name: string;
displayName: string;
@@ -377,6 +404,104 @@ export class PiiMaskingService {
return failed;
}
+ /**
+ * Mask a batch of spans in place (attributes, resource attributes, and the
+ * attributes on each event/link). Mirrors maskLogBatch: returns the indices
+ * of spans whose masking FAILED so the caller can drop them (fail-closed).
+ * If rule compilation fails, every index is returned.
+ */
+ async maskSpanBatch(
+ spans: SpanMaskInput[],
+ organizationId: string,
+ projectId: string
+ ): Promise {
+ let ruleSet: CompiledRuleSet;
+ try {
+ ruleSet = await this.getCompiledRules(organizationId, projectId);
+ } catch (err) {
+ console.error('[PII] Failed to compile masking rules, failing span batch closed:', err);
+ return spans.map((_, i) => i);
+ }
+
+ // Fast path: no enabled rules
+ if (ruleSet.contentRules.length === 0 && ruleSet.fieldRules.length === 0) {
+ return [];
+ }
+
+ const failed: number[] = [];
+ for (let i = 0; i < spans.length; i++) {
+ const span = spans[i];
+ try {
+ if (span.attributes && typeof span.attributes === 'object') {
+ this.maskSpanAttributes(span.attributes, ruleSet);
+ }
+ if (span.resourceAttributes && typeof span.resourceAttributes === 'object') {
+ this.maskSpanAttributes(span.resourceAttributes, ruleSet);
+ }
+ for (const entry of [...(span.events ?? []), ...(span.links ?? [])]) {
+ const attrs = entry && typeof entry === 'object' ? entry.attributes : undefined;
+ if (attrs && typeof attrs === 'object') {
+ this.maskSpanAttributes(attrs as Record, ruleSet);
+ }
+ }
+ } catch {
+ failed.push(i);
+ }
+ }
+ return failed;
+ }
+
+ /**
+ * Mask a flat span-attributes object in place. Runs the standard field-name +
+ * content rules, but first deep-masks any value that is a stringified JSON
+ * object/array (so credentials/tokens nested inside request/response bodies
+ * are caught), redacting known body attributes wholesale when not parseable.
+ */
+ private maskSpanAttributes(attrs: Record, ruleSet: CompiledRuleSet): void {
+ for (const key of Object.keys(attrs)) {
+ const value = attrs[key];
+ if (typeof value !== 'string') continue;
+
+ const deep = this.maskJsonStringValue(value, ruleSet);
+ if (deep.handled) {
+ attrs[key] = deep.value;
+ } else if (BODY_ATTRIBUTE_KEYS.has(key.toLowerCase())) {
+ // A body attribute that isn't parseable JSON: redact it entirely rather
+ // than risk leaking credentials the content rules can't see.
+ attrs[key] = BODY_REDACTION_LABEL;
+ }
+ // Other plain strings are left for the content rules in maskObject below.
+ }
+
+ // Field-name redaction + content masking over the whole attributes object.
+ this.maskObject(attrs, ruleSet, '', false);
+ }
+
+ /**
+ * If `value` is a stringified JSON object/array, parse it, mask the parsed
+ * structure (field-name + content rules), and return the re-stringified
+ * result. Returns handled=false for non-JSON strings.
+ */
+ private maskJsonStringValue(
+ value: string,
+ ruleSet: CompiledRuleSet
+ ): { handled: boolean; value: string } {
+ const trimmed = value.trim();
+ if (trimmed.length < 2 || (trimmed[0] !== '{' && trimmed[0] !== '[')) {
+ return { handled: false, value };
+ }
+ try {
+ const parsed = JSON.parse(trimmed);
+ if (parsed && typeof parsed === 'object') {
+ this.maskObject(parsed as Record, ruleSet, '', false);
+ return { handled: true, value: JSON.stringify(parsed) };
+ }
+ } catch {
+ // not valid JSON
+ }
+ return { handled: false, value };
+ }
+
/**
* Test masking on sample data (for the UI test panel).
*/
diff --git a/packages/backend/src/modules/sigma/field-matcher.ts b/packages/backend/src/modules/sigma/field-matcher.ts
index ead226bc..70f7b4ec 100644
--- a/packages/backend/src/modules/sigma/field-matcher.ts
+++ b/packages/backend/src/modules/sigma/field-matcher.ts
@@ -1,22 +1,62 @@
/**
- * SigmaFieldMatcher - Field matching with wildcards and modifiers
+ * SigmaFieldMatcher - Field matching with wildcards and modifier chains
*
- * Supports:
- * - Wildcards: * (any characters), ? (single character)
- * - Modifiers: contains, startswith, endswith, base64, re (regex)
- * - Case-insensitive matching
+ * Implements the SigmaHQ field-modifier model:
+ * - Transforms (rewrite the pattern, applied left to right): base64,
+ * base64offset, utf16le/utf16/utf16be/wide, windash
+ * - Comparators (final match operator): contains, startswith, endswith, re,
+ * cidr, gt, gte, lt, lte, exists. Default (none) is equals-with-wildcards.
+ * - Quantifier: all (over a value list, flips the default OR into AND)
+ *
+ * Whole modifier chains are honored (e.g. CommandLine|utf16le|base64offset|contains),
+ * not just the first modifier.
*/
-export type FieldModifier = 'contains' | 'startswith' | 'endswith' | 'base64' | 're' | 'all' | 'base64offset' | 'exists';
+export type FieldModifier =
+ | 'contains'
+ | 'startswith'
+ | 'endswith'
+ | 'base64'
+ | 'base64offset'
+ | 're'
+ | 'cidr'
+ | 'gt'
+ | 'gte'
+ | 'lt'
+ | 'lte'
+ | 'all'
+ | 'exists'
+ | 'utf16le'
+ | 'utf16'
+ | 'utf16be'
+ | 'wide'
+ | 'windash';
export interface FieldMatchOptions {
caseSensitive?: boolean;
modifier?: FieldModifier;
}
+type Comparator = 'contains' | 'startswith' | 'endswith' | 're' | 'cidr' | 'gt' | 'gte' | 'lt' | 'lte';
+
+const TRANSFORMS = new Set(['base64', 'base64offset', 'utf16le', 'utf16', 'utf16be', 'wide', 'windash']);
+const COMPARATORS = new Set(['contains', 'startswith', 'endswith', 're', 'cidr', 'gt', 'gte', 'lt', 'lte']);
+
+// Windows dash variants for the |windash modifier (ASCII hyphen, slash, and the
+// unicode dashes accepted by Windows command parsers).
+const WINDASH_CHARS = ['-', '/', '–', '—', '―'];
+
+// Warn at most once per unknown modifier token so a malformed rule never throws
+// and never silently disappears.
+const warnedModifiers = new Set();
+
+/** Intermediate pattern representation while running the transform chain. */
+type Candidate = { text: string } | { bytes: Buffer };
+
export class SigmaFieldMatcher {
/**
- * Match a field value against a pattern with optional modifiers
+ * Match a field value against a pattern with an optional single modifier.
+ * Kept for backward compatibility; chains are driven from matchSelection.
*/
static match(
fieldValue: any,
@@ -25,71 +65,201 @@ export class SigmaFieldMatcher {
): boolean {
const { caseSensitive = false, modifier } = options;
- // Handle null/undefined field values
if (fieldValue === null || fieldValue === undefined) {
return false;
}
- // Convert field value to string for matching
- const valueStr = String(fieldValue);
-
// Handle arrays in pattern (OR logic - match if ANY pattern matches)
if (Array.isArray(pattern)) {
return pattern.some((p) => this.match(fieldValue, p, options));
}
- // Convert pattern to string
- const patternStr = String(pattern);
-
- // Apply modifier-specific matching
if (modifier) {
- return this.matchWithModifier(valueStr, patternStr, modifier, caseSensitive);
+ return this.applyModifierChain(fieldValue, pattern, [modifier], caseSensitive);
}
- // Default: exact match with optional wildcards
- return this.matchWithWildcards(valueStr, patternStr, caseSensitive);
+ return this.matchWithWildcards(String(fieldValue), String(pattern), caseSensitive);
}
/**
- * Match with field modifiers
+ * Match a Sigma selection block against log data.
+ *
+ * @param logData - Log entry data (flattened object)
+ * @param selection - Sigma selection block (field: value pairs)
+ * @param caseSensitive - Case-sensitive matching
+ * @returns true if ALL fields in selection match (AND logic)
*/
- private static matchWithModifier(
- value: string,
- pattern: string,
- modifier: FieldModifier,
+ static matchSelection(
+ logData: Record,
+ selection: Record,
+ caseSensitive: boolean = false
+ ): boolean {
+ if (!selection || Object.keys(selection).length === 0) {
+ return false;
+ }
+
+ return Object.entries(selection).every(([field, pattern]) => {
+ const { fieldName, modifiers } = this.parseFieldWithModifier(field);
+ const fieldValue = this.getNestedField(logData, fieldName);
+
+ // |exists is a presence check, not a value match.
+ if (modifiers.includes('exists')) {
+ const exists = fieldValue !== null && fieldValue !== undefined;
+ return pattern === true ? exists : !exists;
+ }
+
+ const requireAll = modifiers.includes('all');
+ const chain = modifiers.filter((m) => m !== 'all');
+
+ if (Array.isArray(pattern)) {
+ // |all flips the default OR over a value list into AND.
+ return requireAll
+ ? pattern.every((p) => this.applyModifierChain(fieldValue, p, chain, caseSensitive))
+ : pattern.some((p) => this.applyModifierChain(fieldValue, p, chain, caseSensitive));
+ }
+
+ return this.applyModifierChain(fieldValue, pattern, chain, caseSensitive);
+ });
+ }
+
+ /**
+ * Apply an ordered chain of modifiers (transforms + a final comparator) to a
+ * single scalar pattern.
+ */
+ private static applyModifierChain(
+ fieldValue: any,
+ pattern: any,
+ modifiers: string[],
caseSensitive: boolean
): boolean {
- const compareValue = caseSensitive ? value : value.toLowerCase();
- const comparePattern = caseSensitive ? pattern : pattern.toLowerCase();
+ if (fieldValue === null || fieldValue === undefined) {
+ return false;
+ }
- switch (modifier) {
- case 'contains':
- return compareValue.includes(comparePattern);
+ const transforms: string[] = [];
+ let comparator: Comparator | undefined;
+
+ for (const m of modifiers) {
+ if (TRANSFORMS.has(m)) {
+ transforms.push(m);
+ } else if (COMPARATORS.has(m as Comparator)) {
+ comparator = m as Comparator; // last comparator wins
+ } else if (m !== 'exists' && m !== 'all') {
+ if (!warnedModifiers.has(m)) {
+ warnedModifiers.add(m);
+ console.warn(`[SigmaFieldMatcher] Unknown field modifier ignored: ${m}`);
+ }
+ }
+ }
- case 'startswith':
- return compareValue.startsWith(comparePattern);
+ const candidates = this.expandTransforms(String(pattern), transforms);
- case 'endswith':
- return compareValue.endsWith(comparePattern);
+ // base64/base64offset are substring transforms in practice: imply contains
+ // when no explicit comparator follows them.
+ const hasEncoding = transforms.includes('base64') || transforms.includes('base64offset');
+ const cmp = comparator ?? (hasEncoding ? 'contains' : undefined);
- case 'base64':
- return this.matchBase64(value, pattern, caseSensitive);
+ return candidates.some((c) => this.matchComparator(fieldValue, c, cmp, caseSensitive));
+ }
- case 'base64offset':
- return this.matchBase64Offset(value, pattern, caseSensitive);
+ /**
+ * Run the pattern through the ordered transform list, fanning out into the set
+ * of candidate strings that the comparator should be tested against.
+ */
+ private static expandTransforms(pattern: string, transforms: string[]): string[] {
+ let items: Candidate[] = [{ text: pattern }];
+
+ for (const t of transforms) {
+ const next: Candidate[] = [];
+ for (const item of items) {
+ const asText = 'text' in item ? item.text : item.bytes.toString('latin1');
+
+ switch (t) {
+ case 'utf16le':
+ case 'utf16': // treated as utf16le for matching purposes
+ case 'wide':
+ next.push({ bytes: Buffer.from(asText, 'utf16le') });
+ break;
+ case 'utf16be':
+ next.push({ bytes: this.toUtf16be(asText) });
+ break;
+ case 'windash':
+ for (const variant of this.windashVariants(asText)) {
+ next.push({ text: variant });
+ }
+ break;
+ case 'base64': {
+ const buf = 'bytes' in item ? item.bytes : Buffer.from(item.text, 'utf8');
+ next.push({ text: buf.toString('base64') });
+ break;
+ }
+ case 'base64offset': {
+ const buf = 'bytes' in item ? item.bytes : Buffer.from(item.text, 'utf8');
+ for (const v of this.base64Offsets(buf)) {
+ next.push({ text: v });
+ }
+ break;
+ }
+ default:
+ next.push(item);
+ }
+ }
+ items = next;
+ }
- case 're':
- return this.matchRegex(value, pattern, caseSensitive);
+ return items.map((i) => ('text' in i ? i.text : i.bytes.toString('latin1')));
+ }
- case 'all':
- // 'all' modifier means match all words in any order
- return this.matchAllWords(value, pattern, caseSensitive);
+ /** Apply a single comparator between the field value and a candidate pattern. */
+ private static matchComparator(
+ fieldValue: any,
+ candidate: string,
+ comparator: Comparator | undefined,
+ caseSensitive: boolean
+ ): boolean {
+ const valueStr = String(fieldValue);
+ switch (comparator) {
+ case undefined:
+ return this.matchWithWildcards(valueStr, candidate, caseSensitive);
+ case 'contains':
+ case 'startswith':
+ case 'endswith':
+ return this.matchStringOp(valueStr, candidate, comparator, caseSensitive);
+ case 're':
+ return this.matchRegex(valueStr, candidate, caseSensitive);
+ case 'cidr':
+ return this.matchCidr(valueStr, candidate);
+ case 'gt':
+ case 'gte':
+ case 'lt':
+ case 'lte':
+ return this.matchNumeric(fieldValue, candidate, comparator);
default:
return false;
}
}
+ /** contains / startswith / endswith */
+ private static matchStringOp(
+ value: string,
+ pattern: string,
+ op: 'contains' | 'startswith' | 'endswith',
+ caseSensitive: boolean
+ ): boolean {
+ const v = caseSensitive ? value : value.toLowerCase();
+ const p = caseSensitive ? pattern : pattern.toLowerCase();
+
+ switch (op) {
+ case 'contains':
+ return v.includes(p);
+ case 'startswith':
+ return v.startsWith(p);
+ case 'endswith':
+ return v.endsWith(p);
+ }
+ }
+
/**
* Match with wildcards (* and ?)
*/
@@ -98,15 +268,13 @@ export class SigmaFieldMatcher {
pattern: string,
caseSensitive: boolean
): boolean {
- const compareValue = caseSensitive ? value : value.toLowerCase();
const comparePattern = caseSensitive ? pattern : pattern.toLowerCase();
+ const compareValue = caseSensitive ? value : value.toLowerCase();
- // If no wildcards, do exact match
if (!comparePattern.includes('*') && !comparePattern.includes('?')) {
return compareValue === comparePattern;
}
- // Convert wildcard pattern to regex
const regexPattern = this.wildcardToRegex(comparePattern);
const regex = new RegExp(`^${regexPattern}$`, caseSensitive ? '' : 'i');
@@ -119,57 +287,8 @@ export class SigmaFieldMatcher {
private static wildcardToRegex(pattern: string): string {
return pattern
.replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape regex special chars
- .replace(/\*/g, '.*') // * → .*
- .replace(/\?/g, '.'); // ? → .
- }
-
- /**
- * Match base64-encoded values
- */
- private static matchBase64(
- value: string,
- pattern: string,
- caseSensitive: boolean
- ): boolean {
- try {
- // Decode base64 value
- const decoded = Buffer.from(value, 'base64').toString('utf-8');
- const compareValue = caseSensitive ? decoded : decoded.toLowerCase();
- const comparePattern = caseSensitive ? pattern : pattern.toLowerCase();
-
- return compareValue.includes(comparePattern);
- } catch (error) {
- // Invalid base64, no match
- return false;
- }
- }
-
- /**
- * Match base64-encoded values at any offset
- */
- private static matchBase64Offset(
- value: string,
- pattern: string,
- caseSensitive: boolean
- ): boolean {
- // Try matching at different offsets (0, 1, 2 bytes)
- for (let offset = 0; offset < 3; offset++) {
- try {
- const paddedValue = '='.repeat(offset) + value;
- const decoded = Buffer.from(paddedValue, 'base64').toString('utf-8');
- const compareValue = caseSensitive ? decoded : decoded.toLowerCase();
- const comparePattern = caseSensitive ? pattern : pattern.toLowerCase();
-
- if (compareValue.includes(comparePattern)) {
- return true;
- }
- } catch {
- // Invalid base64 at this offset, try next
- continue;
- }
- }
-
- return false;
+ .replace(/\*/g, '.*') // * -> .*
+ .replace(/\?/g, '.'); // ? -> .
}
/**
@@ -185,98 +304,125 @@ export class SigmaFieldMatcher {
const regex = new RegExp(pattern, flags);
return regex.test(value);
} catch (error) {
- // Invalid regex, no match
console.warn(`[SigmaFieldMatcher] Invalid regex pattern: ${pattern}`, error);
return false;
}
}
- /**
- * Match all words in any order
- */
- private static matchAllWords(
- value: string,
- pattern: string,
- caseSensitive: boolean
- ): boolean {
- const compareValue = caseSensitive ? value : value.toLowerCase();
- const words = (caseSensitive ? pattern : pattern.toLowerCase()).split(/\s+/);
+ /** IPv4 CIDR membership test. Non-IPv4 input or malformed CIDR -> no match. */
+ private static matchCidr(value: string, cidr: string): boolean {
+ const slash = cidr.indexOf('/');
+ if (slash === -1) {
+ // Bare address: treat as /32 equality.
+ const ip = this.ipv4ToInt(value);
+ const range = this.ipv4ToInt(cidr);
+ return ip !== null && range !== null && ip === range;
+ }
- return words.every((word) => compareValue.includes(word));
+ const range = this.ipv4ToInt(cidr.slice(0, slash));
+ const bits = Number(cidr.slice(slash + 1));
+ const ip = this.ipv4ToInt(value);
+ if (ip === null || range === null || !Number.isInteger(bits) || bits < 0 || bits > 32) {
+ return false;
+ }
+
+ const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
+ return (ip & mask) === (range & mask);
}
- /**
- * Match a Sigma selection block against log data
- *
- * @param logData - Log entry data (flattened object)
- * @param selection - Sigma selection block (field: value pairs)
- * @param caseSensitive - Case-sensitive matching
- * @returns true if ALL fields in selection match (AND logic)
- */
- static matchSelection(
- logData: Record,
- selection: Record,
- caseSensitive: boolean = false
- ): boolean {
- // Empty selection matches nothing
- if (!selection || Object.keys(selection).length === 0) {
- return false;
+ private static ipv4ToInt(ip: string): number | null {
+ const parts = ip.trim().split('.');
+ if (parts.length !== 4) return null;
+ let result = 0;
+ for (const part of parts) {
+ if (!/^\d{1,3}$/.test(part)) return null;
+ const n = Number(part);
+ if (n > 255) return null;
+ result = (result << 8) | n;
}
+ return result >>> 0;
+ }
- // ALL fields must match (AND logic within a selection)
- return Object.entries(selection).every(([field, pattern]) => {
- // Parse field modifiers (e.g., "fieldname|contains", "fieldname|re")
- const { fieldName, modifier } = this.parseFieldWithModifier(field);
+ /** Numeric comparison (gt/gte/lt/lte). Non-numeric input -> no match. */
+ private static matchNumeric(value: any, pattern: string, op: 'gt' | 'gte' | 'lt' | 'lte'): boolean {
+ const a = typeof value === 'number' ? value : Number(value);
+ const b = Number(pattern);
+ if (Number.isNaN(a) || Number.isNaN(b)) return false;
+
+ switch (op) {
+ case 'gt':
+ return a > b;
+ case 'gte':
+ return a >= b;
+ case 'lt':
+ return a < b;
+ case 'lte':
+ return a <= b;
+ }
+ }
- // Get field value from log (support nested fields with dot notation)
- const fieldValue = this.getNestedField(logData, fieldName);
+ /** Encode a string as UTF-16BE bytes. */
+ private static toUtf16be(text: string): Buffer {
+ const le = Buffer.from(text, 'utf16le');
+ const be = Buffer.alloc(le.length);
+ for (let i = 0; i < le.length; i += 2) {
+ be[i] = le[i + 1];
+ be[i + 1] = le[i];
+ }
+ return be;
+ }
- // Handle |exists modifier specially
- if (modifier === 'exists') {
- const exists = fieldValue !== null && fieldValue !== undefined;
- // pattern should be true/false
- return pattern === true ? exists : !exists;
- }
+ /** Replace every ASCII hyphen with each Windows dash variant. */
+ private static windashVariants(pattern: string): string[] {
+ if (!pattern.includes('-')) return [pattern];
+ return WINDASH_CHARS.map((c) => pattern.split('-').join(c));
+ }
- // Match with modifier
- return this.match(fieldValue, pattern, { caseSensitive, modifier });
- });
+ /**
+ * SigmaHQ base64offset: produce the three encodings covering the possible
+ * byte alignments of the pattern inside a larger base64 blob.
+ */
+ private static base64Offsets(buf: Buffer): string[] {
+ const startOffsets = [0, 2, 3];
+ const endOffsets: Array = [null, -3, -2];
+ const results: string[] = [];
+
+ for (let i = 0; i < 3; i++) {
+ const prefixed = Buffer.concat([Buffer.alloc(i, 0x20), buf]);
+ const encoded = prefixed.toString('base64');
+ const start = startOffsets[i];
+ const end = endOffsets[i];
+ results.push(end === null ? encoded.slice(start) : encoded.slice(start, end));
+ }
+
+ return results;
}
/**
- * Parse field name with optional modifier
- * Example: "CommandLine|contains" → { fieldName: "CommandLine", modifier: "contains" }
+ * Parse a field name with its (possibly chained) modifiers.
+ * Example: "CommandLine|utf16le|base64offset|contains" ->
+ * { fieldName: "CommandLine", modifiers: ["utf16le", "base64offset", "contains"] }
*/
private static parseFieldWithModifier(field: string): {
fieldName: string;
- modifier?: FieldModifier;
+ modifiers: string[];
} {
const parts = field.split('|');
-
- if (parts.length === 1) {
- return { fieldName: parts[0] };
- }
-
- const fieldName = parts[0];
- const modifier = parts[1] as FieldModifier;
-
- return { fieldName, modifier };
+ return { fieldName: parts[0], modifiers: parts.slice(1) };
}
/**
* Get nested field value using dot notation
- * Example: "metadata.user.id" → logData.metadata.user.id
+ * Example: "metadata.user.id" -> logData.metadata.user.id
*/
private static getNestedField(
obj: Record,
path: string
): any {
- // Support both dot notation and direct access
if (path in obj) {
return obj[path];
}
- // Try nested access
const parts = path.split('.');
let current: any = obj;
@@ -284,7 +430,6 @@ export class SigmaFieldMatcher {
if (current === null || current === undefined) {
return undefined;
}
-
current = current[part];
}
diff --git a/packages/backend/src/modules/sigma/routes.ts b/packages/backend/src/modules/sigma/routes.ts
index e98a8340..b0002c89 100644
--- a/packages/backend/src/modules/sigma/routes.ts
+++ b/packages/backend/src/modules/sigma/routes.ts
@@ -8,7 +8,7 @@ import { OrganizationsService } from '../organizations/service.js';
import { notificationChannelsService } from '../notification-channels/index.js';
import { auditLogService } from '../audit-log/service.js';
import { context } from '@logtide/shared/context';
-import { assertWithinLimit } from '../../capabilities/index.js';
+import { assertWithinLimit, withLimitLock } from '../../capabilities/index.js';
import { CapabilityError } from '../../capabilities/errors.js';
const sigmaService = new SigmaService();
@@ -93,15 +93,18 @@ export async function sigmaRoutes(fastify: FastifyInstance) {
});
}
- // Cap on enabled sigma rules (#214 follow-up, WS2)
- await context.runAsSystem('sigma:import-limit-check', async () => {
- await context.with({ organizationId: body.organizationId }, async () => {
- const activeCount = await sigmaService.countActiveRules(body.organizationId);
- await assertWithinLimit('sigma.max_active_rules', activeCount);
+ // Cap on enabled sigma rules (#214 follow-up, WS2). Serialize the
+ // count + import per org so concurrent imports can't race past the cap.
+ const result = await withLimitLock(body.organizationId, 'sigma.max_active_rules', async () => {
+ await context.runAsSystem('sigma:import-limit-check', async () => {
+ await context.with({ organizationId: body.organizationId }, async () => {
+ const activeCount = await sigmaService.countActiveRules(body.organizationId);
+ await assertWithinLimit('sigma.max_active_rules', activeCount);
+ });
});
- });
- const result = await sigmaService.importSigmaRule(importData);
+ return sigmaService.importSigmaRule(importData);
+ });
// Associate channels with the sigma rule if import was successful
if (result.sigmaRule && channelIds && channelIds.length > 0) {
@@ -321,22 +324,31 @@ export async function sigmaRoutes(fastify: FastifyInstance) {
if (!existingRule) {
return reply.code(404).send({ error: 'Sigma rule not found' });
}
- // Cap on enabled sigma rules - only check when actually transitioning disabled -> enabled
+ // Cap on enabled sigma rules - only check when actually transitioning
+ // disabled -> enabled. Serialize the count + enable per org so two
+ // concurrent enables can't race past the cap.
if (!existingRule.enabled) {
- await context.runAsSystem('sigma:enable-limit-check', async () => {
- await context.with({ organizationId: body.organizationId }, async () => {
- const activeCount = await sigmaService.countActiveRules(body.organizationId);
- await assertWithinLimit('sigma.max_active_rules', activeCount);
+ rule = await withLimitLock(body.organizationId, 'sigma.max_active_rules', async () => {
+ await context.runAsSystem('sigma:enable-limit-check', async () => {
+ await context.with({ organizationId: body.organizationId }, async () => {
+ const activeCount = await sigmaService.countActiveRules(body.organizationId);
+ await assertWithinLimit('sigma.max_active_rules', activeCount);
+ });
});
+ // Inside the `body.enabled === true` branch: enable explicitly.
+ return sigmaService.toggleSigmaRule(params.id, body.organizationId, true);
});
}
}
- rule = await sigmaService.toggleSigmaRule(
- params.id,
- body.organizationId,
- body.enabled
- );
+ // For non-enable-transition cases (disable, or already enabled) toggle now.
+ if (!rule) {
+ rule = await sigmaService.toggleSigmaRule(
+ params.id,
+ body.organizationId,
+ body.enabled
+ );
+ }
if (!rule) {
return reply.code(404).send({ error: 'Sigma rule not found' });
diff --git a/packages/backend/src/modules/traces/service.ts b/packages/backend/src/modules/traces/service.ts
index 32072ea1..243a40e4 100644
--- a/packages/backend/src/modules/traces/service.ts
+++ b/packages/backend/src/modules/traces/service.ts
@@ -3,6 +3,7 @@ import { pool } from '../../database/connection.js';
import { reservoir } from '../../database/reservoir.js';
import { projectsService } from '../projects/service.js';
import { recordSpanIngestion } from '../metering/index.js';
+import { piiMaskingService } from '../pii-masking/service.js';
import type { TransformedSpan, AggregatedTrace } from '../otlp/trace-transformer.js';
import type {
SpanRecord as ReservoirSpanRecord,
@@ -124,7 +125,24 @@ export class TracesService {
resourceAttributes: span.resource_attributes || undefined,
}));
- const result = await reservoir.ingestSpans(reservoirSpans);
+ // PII masking (fail-closed): mask span attributes in place and drop any
+ // span whose masking fails, so unmasked attributes (request/response bodies,
+ // tokens, IPs) never reach storage. Mirrors the log ingestion path.
+ const failedIdx = await piiMaskingService.maskSpanBatch(
+ reservoirSpans,
+ organizationId,
+ projectId
+ );
+ let spansToStore = reservoirSpans;
+ if (failedIdx.length > 0) {
+ const failedSet = new Set(failedIdx);
+ spansToStore = reservoirSpans.filter((_, i) => !failedSet.has(i));
+ console.warn(
+ `[Traces] PII masking failed for ${failedIdx.length} span(s); dropped pre-storage`
+ );
+ }
+
+ const result = await reservoir.ingestSpans(spansToStore);
// Metering: record ingested span count (fire-and-forget; activates
// the tracing.max_spans_monthly quota in the capability system).
@@ -213,7 +231,7 @@ export class TracesService {
const results = await Promise.allSettled([
reservoir.getServiceDependencies(projectId, effectiveFrom, effectiveTo),
- this.getServiceHealthStats(projectId, effectiveFrom, effectiveTo, rangeHours),
+ this.getServiceHealthStats(projectId, effectiveFrom, effectiveTo),
includeLogCorrelation
? this.getLogCoOccurrenceEdges(projectId, effectiveFrom, effectiveTo)
: Promise.resolve([]),
@@ -298,48 +316,19 @@ export class TracesService {
projectId: string,
from: Date,
to: Date,
- rangeHours: number,
): Promise {
- if (reservoir.getEngineType() !== 'timescale') {
- return [];
- }
-
- const { sql } = await import('kysely');
- const table = rangeHours <= 48 ? 'spans_hourly_stats' as const : 'spans_daily_stats' as const;
-
- const result = await db
- .selectFrom(table)
- .select([
- 'service_name',
- ])
- .select([
- db.fn.sum('span_count').as('total_calls'),
- db.fn.sum('error_count').as('total_errors'),
- // Weighted average: SUM(avg * count) / SUM(count)
- sql`CASE WHEN SUM(span_count) > 0
- THEN SUM(COALESCE(duration_avg_ms, 0) * span_count) / SUM(span_count)
- ELSE 0 END`.as('avg_latency_ms'),
- // APPROXIMATION: this is the max of the per-bucket p95s, not a true window
- // p95 (the hourly/daily aggregate stores only a per-bucket p95, which is
- // not mergeable). It is an upper-bound estimate; a true p95 would require a
- // mergeable quantile sketch (t-digest) in the continuous aggregate.
- db.fn.max('duration_p95_ms').as('p95_latency_ms'),
- ])
- .where('project_id', '=', projectId)
- .where('bucket', '>=', from)
- .where('bucket', '<=', to)
- .groupBy('service_name')
- .execute();
-
- return result.map((r) => ({
- service_name: r.service_name,
- total_calls: Number(r.total_calls ?? 0),
- total_errors: Number(r.total_errors ?? 0),
- error_rate: Number(r.total_calls) > 0
- ? Number(r.total_errors) / Number(r.total_calls)
- : 0,
- avg_latency_ms: Number(r.avg_latency_ms ?? 0),
- p95_latency_ms: r.p95_latency_ms != null ? Number(r.p95_latency_ms) : null,
+ // True window p95 computed directly from raw spans by the storage engine
+ // (percentile_cont / quantile / $percentile), not a max of per-bucket p95s
+ // from a continuous aggregate. Works across every reservoir engine.
+ const stats = await reservoir.getServiceHealthStats(projectId, from, to);
+
+ return stats.map((s) => ({
+ service_name: s.serviceName,
+ total_calls: s.totalCalls,
+ total_errors: s.totalErrors,
+ error_rate: s.totalCalls > 0 ? s.totalErrors / s.totalCalls : 0,
+ avg_latency_ms: s.avgLatencyMs,
+ p95_latency_ms: s.p95LatencyMs,
}));
}
diff --git a/packages/backend/src/modules/users/service.ts b/packages/backend/src/modules/users/service.ts
index aec00c16..3dc1f36b 100644
--- a/packages/backend/src/modules/users/service.ts
+++ b/packages/backend/src/modules/users/service.ts
@@ -1,11 +1,17 @@
import bcrypt from 'bcrypt';
import crypto from 'crypto';
+import { sql } from 'kysely';
import { db } from '../../database/connection.js';
import { CacheManager, CACHE_TTL } from '../../utils/cache.js';
const SALT_ROUNDS = 10;
const SESSION_DURATION_DAYS = 30;
+// Constant key for the Postgres transaction-scoped advisory lock that
+// serializes the first-admin promotion decision across concurrent
+// registrations (see createUser).
+const FIRST_ADMIN_ADVISORY_LOCK = 947163001;
+
export interface CreateUserInput {
email: string;
password: string;
@@ -98,26 +104,39 @@ export class UsersService {
throw new Error('User with this email already exists');
}
- // Hash the password
+ // Hash the password (outside the lock below - bcrypt is intentionally slow)
const passwordHash = await this.hashPassword(input.password);
- // Promote first user to admin if no admin exists yet
- const shouldBeAdmin = !(await this.hasAnyAdmin());
- if (shouldBeAdmin) {
- console.log(`[Users] No admin exists yet. Promoting ${email} to admin on registration.`);
- }
+ // Decide first-admin promotion and insert atomically. Without serialization,
+ // two concurrent registrations in the zero-admin bootstrap window could each
+ // observe "no admin yet" and both be promoted. A transaction-scoped advisory
+ // lock makes the check-then-insert atomic, so at most one registration wins
+ // the promotion. The lock is released automatically on commit/rollback.
+ const user = await db.transaction().execute(async (trx) => {
+ await sql`SELECT pg_advisory_xact_lock(${FIRST_ADMIN_ADVISORY_LOCK})`.execute(trx);
- // Insert the user
- const user = await db
- .insertInto('users')
- .values({
- email,
- password_hash: passwordHash,
- name: input.name,
- is_admin: shouldBeAdmin,
- })
- .returning(['id', 'email', 'name', 'is_admin', 'disabled', 'created_at', 'last_login'])
- .executeTakeFirstOrThrow();
+ const adminRow = await trx
+ .selectFrom('users')
+ .select('id')
+ .where('is_admin', '=', true)
+ .executeTakeFirst();
+
+ const shouldBeAdmin = !adminRow;
+ if (shouldBeAdmin) {
+ console.log(`[Users] No admin exists yet. Promoting ${email} to admin on registration.`);
+ }
+
+ return trx
+ .insertInto('users')
+ .values({
+ email,
+ password_hash: passwordHash,
+ name: input.name,
+ is_admin: shouldBeAdmin,
+ })
+ .returning(['id', 'email', 'name', 'is_admin', 'disabled', 'created_at', 'last_login'])
+ .executeTakeFirstOrThrow();
+ });
return {
id: user.id,
diff --git a/packages/backend/src/queue/adapters/bullmq-adapter.ts b/packages/backend/src/queue/adapters/bullmq-adapter.ts
index e899d1b9..0a57ce9b 100644
--- a/packages/backend/src/queue/adapters/bullmq-adapter.ts
+++ b/packages/backend/src/queue/adapters/bullmq-adapter.ts
@@ -31,6 +31,36 @@ const DEFAULT_JOB_OPTIONS = {
},
};
+/**
+ * Build the per-job options passed to BullMQ's queue.add().
+ *
+ * CRITICAL: only include `removeOnComplete` / `removeOnFail` when the caller
+ * explicitly provides them. BullMQ merges per-job options OVER the queue's
+ * `defaultJobOptions` with Object.assign, which copies keys whose value is
+ * `undefined`. So passing `removeOnComplete: undefined` would clobber the
+ * DEFAULT_JOB_OPTIONS cleanup config, leaving BullMQ to keep every completed
+ * and failed job hash in Redis forever (the memory-leak root cause).
+ */
+export function buildBullJobOptions(options?: IJobOptions): Record {
+ const jobOptions: Record = {
+ delay: options?.delay,
+ // Default to 3 attempts to match the graphile adapter. Without this BullMQ
+ // would default to 1 (no retries), so the same job retried differently
+ // depending on the configured queue backend.
+ attempts: options?.maxAttempts ?? 3,
+ priority: options?.priority,
+ jobId: options?.jobKey,
+ repeat: options?.repeat,
+ };
+ if (options?.removeOnComplete !== undefined) {
+ jobOptions.removeOnComplete = options.removeOnComplete;
+ }
+ if (options?.removeOnFail !== undefined) {
+ jobOptions.removeOnFail = options.removeOnFail;
+ }
+ return jobOptions;
+}
+
/**
* Convert BullMQ Job to unified IJob interface
*/
@@ -62,18 +92,7 @@ export class BullMQQueueAdapter implements IQueueAdapter, ICronR
async add(jobName: string, data: T, options?: IJobOptions): Promise> {
const payload = attachContextToPayload(data);
- const bullJob = await (this.queue as any).add(jobName, payload, {
- delay: options?.delay,
- // Default to 3 attempts to match the graphile adapter. Without this BullMQ
- // would default to 1 (no retries), so the same job retried differently
- // depending on the configured queue backend.
- attempts: options?.maxAttempts ?? 3,
- priority: options?.priority,
- jobId: options?.jobKey,
- repeat: options?.repeat,
- removeOnComplete: options?.removeOnComplete,
- removeOnFail: options?.removeOnFail,
- });
+ const bullJob = await (this.queue as any).add(jobName, payload, buildBullJobOptions(options));
return {
id: bullJob.id || '',
diff --git a/packages/backend/src/tests/modules/capabilities/apikeys-limit.test.ts b/packages/backend/src/tests/modules/capabilities/apikeys-limit.test.ts
index 11dededb..b2ce4a9a 100644
--- a/packages/backend/src/tests/modules/capabilities/apikeys-limit.test.ts
+++ b/packages/backend/src/tests/modules/capabilities/apikeys-limit.test.ts
@@ -137,6 +137,43 @@ describe('apikeys.max enforcement', () => {
expect(res.statusCode).toBe(201);
});
+ // Case 5: concurrent creates must not race past a finite limit
+ it('serializes concurrent creates so the limit is never exceeded (race)', async () => {
+ // createTestContext already inserted 1 key; limit 2 leaves room for exactly 1 more.
+ await db
+ .insertInto('organization_entitlements')
+ .values({ organization_id: orgId, capability: 'apikeys.max', enabled: null, limit_value: 2 })
+ .execute();
+ capabilities.invalidate(orgId);
+
+ const attempts = 6;
+ const results = await Promise.all(
+ Array.from({ length: attempts }, (_, i) =>
+ app.inject({
+ method: 'POST',
+ url: `/api/v1/projects/${projectId}/api-keys`,
+ headers: { Authorization: `Bearer ${token}` },
+ payload: { name: `Race Key ${i}` },
+ })
+ )
+ );
+
+ const created = results.filter((r) => r.statusCode === 201).length;
+ const blocked = results.filter((r) => r.statusCode === 403).length;
+
+ expect(created).toBe(1);
+ expect(blocked).toBe(attempts - 1);
+
+ // Hard invariant: the org never ends up over its configured limit.
+ const total = await db
+ .selectFrom('api_keys')
+ .innerJoin('projects', 'projects.id', 'api_keys.project_id')
+ .select((eb) => eb.fn.countAll().as('c'))
+ .where('projects.organization_id', '=', orgId)
+ .executeTakeFirstOrThrow();
+ expect(Number(total.c)).toBe(2);
+ });
+
// Case 4: org isolation => org A at limit does not block org B
it('does not block org B when org A is at the limit', async () => {
await db
diff --git a/packages/backend/src/tests/modules/dashboard/routes.test.ts b/packages/backend/src/tests/modules/dashboard/routes.test.ts
index 5c6b3ba5..c045a10f 100644
--- a/packages/backend/src/tests/modules/dashboard/routes.test.ts
+++ b/packages/backend/src/tests/modules/dashboard/routes.test.ts
@@ -2,7 +2,14 @@ import { describe, it, expect, beforeEach, afterAll, beforeAll } from 'vitest';
import Fastify, { FastifyInstance } from 'fastify';
import { db } from '../../../database/index.js';
import dashboardRoutes from '../../../modules/dashboard/routes.js';
-import { createTestContext, createTestLog } from '../../helpers/factories.js';
+import {
+ createTestContext,
+ createTestLog,
+ createTestUser,
+ createTestOrganization,
+ createTestProject,
+ createTestApiKey,
+} from '../../helpers/factories.js';
import crypto from 'crypto';
async function createTestSession(userId: string) {
@@ -34,6 +41,25 @@ describe('Dashboard Routes', () => {
// but rely on request.user being set. We register the routes and
// add a mock auth hook that sets request.user from the session.
app.addHook('onRequest', async (request: any) => {
+ // API-key auth: mirror the real auth plugin - bind project/org from the
+ // key, do NOT set request.user.
+ const apiKey = request.headers['x-api-key'];
+ if (apiKey) {
+ const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex');
+ const row = await db
+ .selectFrom('api_keys')
+ .innerJoin('projects', 'projects.id', 'api_keys.project_id')
+ .select(['api_keys.project_id', 'api_keys.type', 'projects.organization_id'])
+ .where('api_keys.key_hash', '=', keyHash)
+ .executeTakeFirst();
+ if (row) {
+ request.projectId = row.project_id;
+ request.organizationId = row.organization_id;
+ request.apiKeyType = row.type;
+ }
+ return;
+ }
+
const authHeader = request.headers.authorization;
if (!authHeader) return;
@@ -491,4 +517,103 @@ describe('Dashboard Routes', () => {
expect(res.statusCode).toBe(404);
});
});
+
+ // =========================================================================
+ // API-key tenant isolation (regression for cross-tenant dashboard access)
+ //
+ // A full-access API key is PROJECT-scoped. It must only read its own org's
+ // (and project's) dashboard data, never another organization's, regardless
+ // of the organizationId/projectId passed in the query string.
+ // =========================================================================
+ describe('API-key tenant isolation', () => {
+ const ENDPOINTS = [
+ '/api/v1/dashboard/stats',
+ '/api/v1/dashboard/timeseries',
+ '/api/v1/dashboard/top-services',
+ '/api/v1/dashboard/timeline-events',
+ '/api/v1/dashboard/recent-errors',
+ '/api/v1/dashboard/activity-overview',
+ ];
+
+ async function buildOtherOrg() {
+ const owner = await createTestUser();
+ const org = await createTestOrganization({ ownerId: owner.id });
+ const project = await createTestProject({ organizationId: org.id, userId: owner.id });
+ const apiKey = await createTestApiKey({ projectId: project.id });
+ return { org, project, apiKey };
+ }
+
+ it('rejects a key bound to org A reading org B with 403 on every endpoint', async () => {
+ // testOrganization is org A; its key:
+ const orgAKey = await createTestApiKey({ projectId: testProject.id });
+ const other = await buildOtherOrg(); // org B
+
+ for (const url of ENDPOINTS) {
+ const res = await app.inject({
+ method: 'GET',
+ url: `${url}?organizationId=${other.org.id}`,
+ headers: { 'x-api-key': orgAKey.plainKey },
+ });
+ expect(res.statusCode, `${url} should be 403`).toBe(403);
+ }
+ });
+
+ it('rejects a key reading another org even when its own projectId is passed', async () => {
+ const orgAKey = await createTestApiKey({ projectId: testProject.id });
+ const other = await buildOtherOrg();
+
+ const res = await app.inject({
+ method: 'GET',
+ url: `/api/v1/dashboard/stats?organizationId=${other.org.id}&projectId=${testProject.id}`,
+ headers: { 'x-api-key': orgAKey.plainKey },
+ });
+ expect(res.statusCode).toBe(403);
+ });
+
+ it('rejects a key passing a foreign projectId within its own org with 403', async () => {
+ const orgAKey = await createTestApiKey({ projectId: testProject.id });
+ const other = await buildOtherOrg();
+
+ const res = await app.inject({
+ method: 'GET',
+ url: `/api/v1/dashboard/stats?organizationId=${testOrganization.id}&projectId=${other.project.id}`,
+ headers: { 'x-api-key': orgAKey.plainKey },
+ });
+ expect(res.statusCode).toBe(403);
+ });
+
+ it('allows a key to read its own organization', async () => {
+ const orgAKey = await createTestApiKey({ projectId: testProject.id });
+
+ const res = await app.inject({
+ method: 'GET',
+ url: `/api/v1/dashboard/stats?organizationId=${testOrganization.id}`,
+ headers: { 'x-api-key': orgAKey.plainKey },
+ });
+ expect(res.statusCode).toBe(200);
+ });
+
+ it('scopes a key to its own project: only its project data is counted', async () => {
+ // Second project in the same org with its own logs.
+ const otherProject = await createTestProject({
+ organizationId: testOrganization.id,
+ userId: testUser.id,
+ });
+ await createTestLog({ projectId: otherProject.id, level: 'info' });
+ await createTestLog({ projectId: testProject.id, level: 'info' });
+
+ const orgAKey = await createTestApiKey({ projectId: testProject.id });
+
+ // No projectId in query: must default to the key's bound project,
+ // so only the single log in testProject is counted (not both).
+ const res = await app.inject({
+ method: 'GET',
+ url: `/api/v1/dashboard/stats?organizationId=${testOrganization.id}`,
+ headers: { 'x-api-key': orgAKey.plainKey },
+ });
+ expect(res.statusCode).toBe(200);
+ const body = JSON.parse(res.payload);
+ expect(body.totalLogsToday.value).toBe(1);
+ });
+ });
});
diff --git a/packages/backend/src/tests/modules/metering/routes.test.ts b/packages/backend/src/tests/modules/metering/routes.test.ts
index 2aef292c..3e6e0012 100644
--- a/packages/backend/src/tests/modules/metering/routes.test.ts
+++ b/packages/backend/src/tests/modules/metering/routes.test.ts
@@ -70,6 +70,28 @@ describe('GET /api/v1/usage', () => {
expect(res.statusCode).toBe(403);
});
+ it('allows a platform admin to read another org usage', async () => {
+ // Admin belongs to a different org and is NOT a member of `orgId`.
+ const adminCtx = await createTestContext();
+ await db
+ .updateTable('users')
+ .set({ is_admin: true })
+ .where('id', '=', adminCtx.user.id)
+ .execute();
+ const adminToken = await createTestSession(adminCtx.user.id);
+
+ const res = await app.inject({
+ method: 'GET',
+ url: `/api/v1/usage?organizationId=${orgId}&from=2026-06-01T00:00:00Z&to=2026-06-02T00:00:00Z&groupBy=type`,
+ headers: { Authorization: `Bearer ${adminToken}` },
+ });
+
+ expect(res.statusCode).toBe(200);
+ const body = JSON.parse(res.payload);
+ const byType = Object.fromEntries(body.usage.map((r: any) => [r.type, r.quantity]));
+ expect(byType['logs.ingested.events']).toBe(10);
+ });
+
it('returns 400 on a missing required param', async () => {
const res = await app.inject({
method: 'GET',
diff --git a/packages/backend/src/tests/modules/otlp/transformer.test.ts b/packages/backend/src/tests/modules/otlp/transformer.test.ts
index a30af39b..3076bfa4 100644
--- a/packages/backend/src/tests/modules/otlp/transformer.test.ts
+++ b/packages/backend/src/tests/modules/otlp/transformer.test.ts
@@ -3,6 +3,8 @@ import {
transformOtlpToLogTide,
transformLogRecord,
extractServiceName,
+ sanitizeServiceName,
+ MAX_SERVICE_NAME_LENGTH,
nanosToIso,
normalizeTraceId,
extractMessage,
@@ -464,6 +466,60 @@ describe('OTLP Transformer', () => {
expect(extractServiceName(attrs)).toBe('unknown');
});
+
+ it('strips control characters from service.name (defense-in-depth)', () => {
+ const attrs: OtlpKeyValue[] = [
+ { key: 'service.name', value: { stringValue: 'api\x00-svc\x07\x1bx' } },
+ ];
+
+ expect(extractServiceName(attrs)).toBe('api-svcx');
+ });
+
+ it('caps an over-long service.name', () => {
+ const attrs: OtlpKeyValue[] = [
+ { key: 'service.name', value: { stringValue: 'a'.repeat(1000) } },
+ ];
+
+ expect(extractServiceName(attrs)).toHaveLength(MAX_SERVICE_NAME_LENGTH);
+ });
+
+ it('preserves legitimate punctuation (escaping happens at the sink)', () => {
+ const attrs: OtlpKeyValue[] = [
+ { key: 'service.name', value: { stringValue: '' } },
+ ];
+
+ // Not stripped here: the value stays raw and is HTML-escaped at render time.
+ expect(extractServiceName(attrs)).toBe('');
+ });
+
+ it('falls back to "unknown" when only control characters remain', () => {
+ const attrs: OtlpKeyValue[] = [
+ { key: 'service.name', value: { stringValue: '\x00\x01\x02' } },
+ ];
+
+ expect(extractServiceName(attrs)).toBe('unknown');
+ });
+ });
+
+ describe('sanitizeServiceName', () => {
+ it('passes through a normal name', () => {
+ expect(sanitizeServiceName('payment-service')).toBe('payment-service');
+ });
+
+ it('removes null bytes and other control characters', () => {
+ expect(sanitizeServiceName('a\x00b\x1fc\x7fd')).toBe('abcd');
+ });
+
+ it('caps length at MAX_SERVICE_NAME_LENGTH', () => {
+ expect(sanitizeServiceName('x'.repeat(MAX_SERVICE_NAME_LENGTH + 50))).toHaveLength(
+ MAX_SERVICE_NAME_LENGTH,
+ );
+ });
+
+ it('returns "unknown" when empty after cleaning', () => {
+ expect(sanitizeServiceName('\x00\x01')).toBe('unknown');
+ expect(sanitizeServiceName('')).toBe('unknown');
+ });
});
describe('nanosToIso', () => {
diff --git a/packages/backend/src/tests/modules/pii-masking/service.test.ts b/packages/backend/src/tests/modules/pii-masking/service.test.ts
index aa188924..8ae9e7c0 100644
--- a/packages/backend/src/tests/modules/pii-masking/service.test.ts
+++ b/packages/backend/src/tests/modules/pii-masking/service.test.ts
@@ -1054,4 +1054,116 @@ describe('PiiMaskingService', () => {
expect(logs[0].message).toBe('test message here');
});
});
+
+ // =========================================================================
+ // maskSpanBatch (trace span attributes)
+ // =========================================================================
+
+ describe('maskSpanBatch', () => {
+ async function enableBodyMaskingRules() {
+ await service.createRule(organizationId, {
+ name: 'email',
+ displayName: 'Email',
+ patternType: 'builtin',
+ action: 'mask',
+ enabled: true,
+ });
+ await service.createRule(organizationId, {
+ name: 'sensitive_fields',
+ displayName: 'Sensitive Fields',
+ patternType: 'builtin',
+ action: 'redact',
+ enabled: true,
+ });
+ }
+
+ it('does nothing when no rules are enabled', async () => {
+ const spans = [{ attributes: { 'http.request_body': '{"password":"secret123"}' } }];
+ const failed = await service.maskSpanBatch(spans, organizationId, projectId);
+ expect(failed).toEqual([]);
+ expect(spans[0].attributes['http.request_body']).toBe('{"password":"secret123"}');
+ });
+
+ it('deep-masks credentials inside a stringified JSON request body', async () => {
+ await enableBodyMaskingRules();
+
+ const spans = [
+ {
+ attributes: {
+ 'http.method': 'POST',
+ 'http.request_body':
+ '{"email":"giuseppe@solture.it","password":"Polliog80!"}',
+ },
+ },
+ ];
+
+ const failed = await service.maskSpanBatch(spans, organizationId, projectId);
+ expect(failed).toEqual([]);
+
+ const body = spans[0].attributes['http.request_body'] as string;
+ // Credentials gone
+ expect(body).not.toContain('Polliog80!');
+ expect(body).not.toContain('giuseppe@solture.it');
+ // password redacted by field rule, structure preserved (still valid JSON)
+ const parsed = JSON.parse(body);
+ expect(parsed.password).toBe('[REDACTED]');
+ expect(parsed.email).not.toContain('giuseppe@solture.it');
+ // untouched attribute survives
+ expect(spans[0].attributes['http.method']).toBe('POST');
+ });
+
+ it('redacts a body attribute that is not parseable JSON', async () => {
+ await enableBodyMaskingRules();
+
+ const spans = [{ attributes: { 'http.response_body': 'token=abc.def.ghi raw text' } }];
+ await service.maskSpanBatch(spans, organizationId, projectId);
+ expect(spans[0].attributes['http.response_body']).toBe('[REDACTED]');
+ });
+
+ it('masks IPs and emails in plain attributes via content rules', async () => {
+ await service.createRule(organizationId, {
+ name: 'ip_address',
+ displayName: 'IP',
+ patternType: 'builtin',
+ action: 'redact',
+ enabled: true,
+ });
+ await service.createRule(organizationId, {
+ name: 'email',
+ displayName: 'Email',
+ patternType: 'builtin',
+ action: 'mask',
+ enabled: true,
+ });
+
+ const spans = [
+ { attributes: { 'net.peer.ip': '10.0.1.16', 'enduser.id': 'a@b.com' } },
+ ];
+ await service.maskSpanBatch(spans, organizationId, projectId);
+ expect(spans[0].attributes['net.peer.ip']).not.toBe('10.0.1.16');
+ expect(spans[0].attributes['enduser.id']).not.toBe('a@b.com');
+ });
+
+ it('masks event and link attributes too', async () => {
+ await enableBodyMaskingRules();
+
+ const spans = [
+ {
+ attributes: {},
+ events: [{ name: 'login', attributes: { password: 'hunter2' } }],
+ links: [{ attributes: { 'http.request_body': '{"token":"xyz"}' } }],
+ },
+ ];
+ await service.maskSpanBatch(spans, organizationId, projectId);
+ expect(spans[0].events[0].attributes.password).toBe('[REDACTED]');
+ expect(spans[0].links[0].attributes['http.request_body'] as string).not.toContain('xyz');
+ });
+
+ it('masks resource attributes', async () => {
+ await enableBodyMaskingRules();
+ const spans = [{ resourceAttributes: { password: 'p' } }];
+ await service.maskSpanBatch(spans, organizationId, projectId);
+ expect(spans[0].resourceAttributes.password).toBe('[REDACTED]');
+ });
+ });
});
diff --git a/packages/backend/src/tests/modules/sigma/field-matcher.test.ts b/packages/backend/src/tests/modules/sigma/field-matcher.test.ts
index aba11be6..59e948ad 100644
--- a/packages/backend/src/tests/modules/sigma/field-matcher.test.ts
+++ b/packages/backend/src/tests/modules/sigma/field-matcher.test.ts
@@ -135,23 +135,129 @@ describe('Sigma Field Matcher', () => {
});
});
- describe('Modifier: base64', () => {
- it('should match base64-encoded content', () => {
- const base64 = Buffer.from('malicious code').toString('base64');
- expect(SigmaFieldMatcher.match(base64, 'malicious', { modifier: 'base64' })).toBe(true);
- expect(SigmaFieldMatcher.match(base64, 'benign', { modifier: 'base64' })).toBe(false);
+ describe('Modifier: base64 (SigmaHQ encode-pattern semantics)', () => {
+ // SigmaHQ: the pattern is base64-encoded and that encoding is matched
+ // against the field value (NOT: decode the field). A lone base64 modifier
+ // implies a substring (contains) match, as it is always used in practice.
+ it('should match when the field contains base64(pattern)', () => {
+ const enc = Buffer.from('malicious').toString('base64');
+ expect(SigmaFieldMatcher.match(`prefix ${enc} suffix`, 'malicious', { modifier: 'base64' })).toBe(true);
+ expect(SigmaFieldMatcher.match('plain text no encoding', 'malicious', { modifier: 'base64' })).toBe(false);
+ });
+
+ it('should support base64|contains chains via matchSelection', () => {
+ const enc = Buffer.from('whoami').toString('base64'); // d2hvYW1p
+ expect(
+ SigmaFieldMatcher.matchSelection({ cmd: `powershell ${enc} extra` }, { 'cmd|base64|contains': 'whoami' }),
+ ).toBe(true);
+ expect(
+ SigmaFieldMatcher.matchSelection({ cmd: 'powershell whoami extra' }, { 'cmd|base64|contains': 'whoami' }),
+ ).toBe(false);
+ });
+ });
+
+ describe('Modifier: all (SigmaHQ list quantifier)', () => {
+ // SigmaHQ: |all flips the default OR over a value list into AND.
+ it('should require every list element to match (AND) with |all', () => {
+ expect(
+ SigmaFieldMatcher.matchSelection({ cmd: 'foo bar baz' }, { 'cmd|contains|all': ['foo', 'baz'] }),
+ ).toBe(true);
+ expect(
+ SigmaFieldMatcher.matchSelection({ cmd: 'foo bar' }, { 'cmd|contains|all': ['foo', 'baz'] }),
+ ).toBe(false);
});
- it('should handle invalid base64 gracefully', () => {
- expect(SigmaFieldMatcher.match('not-base64!!!', 'test', { modifier: 'base64' })).toBe(false);
+ it('should keep OR semantics over a list without |all', () => {
+ expect(
+ SigmaFieldMatcher.matchSelection({ cmd: 'foo only' }, { 'cmd|contains': ['foo', 'baz'] }),
+ ).toBe(true);
});
});
- describe('Modifier: all (all words)', () => {
- it('should match if all words present in any order', () => {
- expect(SigmaFieldMatcher.match('hello world test', 'hello test', { modifier: 'all' })).toBe(true);
- expect(SigmaFieldMatcher.match('test hello world', 'hello test', { modifier: 'all' })).toBe(true);
- expect(SigmaFieldMatcher.match('hello world', 'hello test', { modifier: 'all' })).toBe(false);
+ describe('Compound modifiers (SigmaHQ spec)', () => {
+ it('should match base64offset|contains regardless of byte alignment', () => {
+ // A real base64 blob in a field; the secret must be found at any of
+ // the 3 base64 alignment offsets.
+ const blob = Buffer.from('powershell -enc whoami extra payload').toString('base64');
+ expect(
+ SigmaFieldMatcher.matchSelection({ cmd: blob }, { 'cmd|base64offset|contains': 'whoami' }),
+ ).toBe(true);
+ expect(
+ SigmaFieldMatcher.matchSelection({ cmd: blob }, { 'cmd|base64offset|contains': 'notthere' }),
+ ).toBe(false);
+ });
+
+ it('should match utf16le|base64offset|contains (PowerShell -enc style)', () => {
+ const enc = Buffer.from('whoami', 'utf16le').toString('base64');
+ expect(
+ SigmaFieldMatcher.matchSelection(
+ { cmd: `powershell -enc ${enc}` },
+ { 'cmd|utf16le|base64offset|contains': 'whoami' },
+ ),
+ ).toBe(true);
+ });
+
+ it('should treat wide as an alias of utf16le', () => {
+ const enc = Buffer.from('whoami', 'utf16le').toString('base64');
+ expect(
+ SigmaFieldMatcher.matchSelection(
+ { cmd: `x ${enc} y` },
+ { 'cmd|wide|base64offset|contains': 'whoami' },
+ ),
+ ).toBe(true);
+ });
+
+ it('should expand windash dash variants', () => {
+ expect(
+ SigmaFieldMatcher.matchSelection({ cmd: 'program /e /s' }, { 'cmd|windash|contains': '-e' }),
+ ).toBe(true);
+ // without windash the literal dash is required and not present
+ expect(
+ SigmaFieldMatcher.matchSelection({ cmd: 'program /e /s' }, { 'cmd|contains': '-e' }),
+ ).toBe(false);
+ });
+
+ it('should match IPv4 cidr ranges', () => {
+ expect(SigmaFieldMatcher.matchSelection({ src: '192.168.1.50' }, { 'src|cidr': '192.168.1.0/24' })).toBe(true);
+ expect(SigmaFieldMatcher.matchSelection({ src: '192.168.2.50' }, { 'src|cidr': '192.168.1.0/24' })).toBe(false);
+ expect(SigmaFieldMatcher.matchSelection({ src: '10.0.0.5' }, { 'src|cidr': '10.0.0.0/8' })).toBe(true);
+ });
+
+ it('should support numeric comparators gt/gte/lt/lte', () => {
+ expect(SigmaFieldMatcher.matchSelection({ n: 10 }, { 'n|gt': 5 })).toBe(true);
+ expect(SigmaFieldMatcher.matchSelection({ n: 10 }, { 'n|gt': 10 })).toBe(false);
+ expect(SigmaFieldMatcher.matchSelection({ n: 10 }, { 'n|gte': 10 })).toBe(true);
+ expect(SigmaFieldMatcher.matchSelection({ n: 10 }, { 'n|lt': 20 })).toBe(true);
+ expect(SigmaFieldMatcher.matchSelection({ n: 10 }, { 'n|lte': 10 })).toBe(true);
+ expect(SigmaFieldMatcher.matchSelection({ n: 'notnum' }, { 'n|gt': 5 })).toBe(false);
+ });
+
+ it('should not drop the comparator in a transform+comparator chain', () => {
+ const enc = Buffer.from('cmd.exe').toString('base64');
+ // endswith comparator must be honored after the base64 transform
+ expect(
+ SigmaFieldMatcher.matchSelection({ p: `junk${enc}` }, { 'p|base64|endswith': 'cmd.exe' }),
+ ).toBe(true);
+ expect(
+ SigmaFieldMatcher.matchSelection({ p: `${enc}junk` }, { 'p|base64|endswith': 'cmd.exe' }),
+ ).toBe(false);
+ });
+
+ it('should combine |all with a transform+comparator chain', () => {
+ const a = Buffer.from('alpha').toString('base64');
+ const b = Buffer.from('omega').toString('base64');
+ expect(
+ SigmaFieldMatcher.matchSelection(
+ { cmd: `x ${a} y ${b} z` },
+ { 'cmd|base64|contains|all': ['alpha', 'omega'] },
+ ),
+ ).toBe(true);
+ expect(
+ SigmaFieldMatcher.matchSelection(
+ { cmd: `x ${a} y z` },
+ { 'cmd|base64|contains|all': ['alpha', 'omega'] },
+ ),
+ ).toBe(false);
});
});
diff --git a/packages/backend/src/tests/modules/sigma/sync-service.test.ts b/packages/backend/src/tests/modules/sigma/sync-service.test.ts
index 0d9d40a7..640a951d 100644
--- a/packages/backend/src/tests/modules/sigma/sync-service.test.ts
+++ b/packages/backend/src/tests/modules/sigma/sync-service.test.ts
@@ -413,5 +413,38 @@ describe('SigmaSyncService - extra methods', () => {
expect(result.imported).toBe(1);
});
+
+ it('does NOT create an alert rule when autoCreateAlerts=false (sigma rules are independent)', async () => {
+ const { sigmahqClient } = await import('../../../modules/sigma/github-client.js');
+ (sigmahqClient.fetchRulesByCategory as ReturnType).mockResolvedValueOnce([
+ { path: 'rules/linux/noalert.yml', name: 'noalert.yml', category: 'linux', downloadUrl: 'http://x', sha: 'sha1' },
+ ]);
+ (sigmahqClient.fetchRule as ReturnType).mockResolvedValue(VALID_YAML);
+
+ const result = await service.syncFromSigmaHQ({
+ organizationId: orgId,
+ selection: { categories: ['linux'] },
+ autoCreateAlerts: false,
+ });
+
+ expect(result.imported).toBe(1);
+
+ // No alert_rules row should be created for this org (the cron path that
+ // passed autoCreateAlerts:true used to spawn an alert rule per sigma rule).
+ const alerts = await db
+ .selectFrom('alert_rules')
+ .select('id')
+ .where('organization_id', '=', orgId)
+ .execute();
+ expect(alerts).toHaveLength(0);
+
+ // ...and the imported sigma rule must not be linked to one.
+ const rule = await db
+ .selectFrom('sigma_rules')
+ .select('alert_rule_id')
+ .where('sigmahq_path', '=', 'rules/linux/noalert.yml')
+ .executeTakeFirst();
+ expect(rule?.alert_rule_id).toBeNull();
+ });
});
});
diff --git a/packages/backend/src/tests/modules/traces/service.test.ts b/packages/backend/src/tests/modules/traces/service.test.ts
index efbfdb0f..2666a506 100644
--- a/packages/backend/src/tests/modules/traces/service.test.ts
+++ b/packages/backend/src/tests/modules/traces/service.test.ts
@@ -998,17 +998,30 @@ describe('TracesService', () => {
expect(result.edges).toBeDefined();
});
- it('should set default values when health stats are empty', async () => {
+ it('should compute a true window p95 and avg latency from raw spans', async () => {
const traceId = crypto.randomBytes(16).toString('hex');
const now = new Date();
+ // Parent in svc-health (duration 100), plus a second svc-health span
+ // (duration 300) so avg/p95 are computed over the window, not defaulted.
const parentSpan = await createTestSpan({
projectId: context.project.id,
organizationId: context.organization.id,
traceId,
spanId: 'health-parent',
- serviceName: 'svc-no-health',
+ serviceName: 'svc-health',
startTime: now,
+ durationMs: 100,
+ });
+
+ await createTestSpan({
+ projectId: context.project.id,
+ organizationId: context.organization.id,
+ traceId,
+ spanId: 'health-extra',
+ serviceName: 'svc-health',
+ startTime: new Date(now.getTime() + 5),
+ durationMs: 300,
});
await createTestSpan({
@@ -1016,7 +1029,7 @@ describe('TracesService', () => {
organizationId: context.organization.id,
traceId,
parentSpanId: parentSpan.span_id,
- serviceName: 'svc-no-health-child',
+ serviceName: 'svc-health-child',
startTime: new Date(now.getTime() + 10),
});
@@ -1026,13 +1039,12 @@ describe('TracesService', () => {
new Date(now.getTime() + 5000),
);
- // Health stats won't be populated (continuous aggregates not refreshed in tests)
- // So defaults should be applied
- for (const node of result.nodes) {
- expect(node.errorRate).toBe(0);
- expect(node.avgLatencyMs).toBe(0);
- expect(node.p95LatencyMs).toBeNull();
- }
+ const node = result.nodes.find((n) => n.name === 'svc-health');
+ expect(node).toBeDefined();
+ // avg of 100 and 300 is 200; p95 is a true window percentile, not null.
+ expect(node!.avgLatencyMs).toBeGreaterThan(0);
+ expect(node!.p95LatencyMs).not.toBeNull();
+ expect(node!.p95LatencyMs!).toBeGreaterThanOrEqual(100);
});
it('should handle multiple independent trace dependencies', async () => {
@@ -1123,10 +1135,13 @@ describe('TracesService', () => {
expect(nodeA).toBeDefined();
expect(nodeA?.callCount).toBe(0);
expect(nodeA?.totalCalls).toBe(0);
+ // No spans for log-only services, so health stats default to null p95.
+ expect(nodeA?.p95LatencyMs).toBeNull();
expect(nodeB).toBeDefined();
expect(nodeB?.callCount).toBe(0);
expect(nodeB?.totalCalls).toBe(0);
+ expect(nodeB?.p95LatencyMs).toBeNull();
});
it('should not add log edges below threshold (< 2 co-occurrences)', async () => {
diff --git a/packages/backend/src/tests/modules/users/users-service.test.ts b/packages/backend/src/tests/modules/users/users-service.test.ts
index 868a5e55..35481494 100644
--- a/packages/backend/src/tests/modules/users/users-service.test.ts
+++ b/packages/backend/src/tests/modules/users/users-service.test.ts
@@ -130,6 +130,33 @@ describe('UsersService', () => {
expect(second.is_admin).toBe(false);
});
+ it('promotes at most one admin under concurrent first-time registrations (race)', async () => {
+ // Fire several registrations at once against an empty (zero-admin)
+ // users table. Without serialization each would observe "no admin
+ // yet" and all be promoted to admin.
+ const N = 5;
+ const users = await Promise.all(
+ Array.from({ length: N }, (_, i) =>
+ usersService.createUser({
+ email: `race-${i}@example.com`,
+ password: 'password123',
+ name: `Race User ${i}`,
+ })
+ )
+ );
+
+ const admins = users.filter((u) => u.is_admin);
+ expect(admins).toHaveLength(1);
+
+ // Confirm at the storage layer too.
+ const adminCount = await db
+ .selectFrom('users')
+ .select((eb) => eb.fn.countAll().as('c'))
+ .where('is_admin', '=', true)
+ .executeTakeFirstOrThrow();
+ expect(Number(adminCount.c)).toBe(1);
+ });
+
it('should throw error for duplicate email', async () => {
await usersService.createUser({
email: 'duplicate@example.com',
diff --git a/packages/backend/src/tests/queue/bullmq-job-options.test.ts b/packages/backend/src/tests/queue/bullmq-job-options.test.ts
new file mode 100644
index 00000000..5366a1c9
--- /dev/null
+++ b/packages/backend/src/tests/queue/bullmq-job-options.test.ts
@@ -0,0 +1,44 @@
+import { describe, it, expect } from 'vitest';
+import { buildBullJobOptions } from '../../queue/adapters/bullmq-adapter.js';
+
+/**
+ * Regression test for the Redis memory leak: the BullMQ adapter must NOT pass
+ * `removeOnComplete`/`removeOnFail` when the caller omits them, otherwise the
+ * `undefined` values clobber the queue-level DEFAULT_JOB_OPTIONS during BullMQ's
+ * Object.assign merge and disable job cleanup (completed/failed job hashes pile
+ * up in Redis forever).
+ */
+describe('buildBullJobOptions', () => {
+ it('omits removeOnComplete/removeOnFail when no options are given, so queue defaults survive', () => {
+ const opts = buildBullJobOptions();
+
+ // The keys must be ABSENT (not present-with-undefined), so Object.assign over
+ // defaultJobOptions keeps the cleanup config.
+ expect('removeOnComplete' in opts).toBe(false);
+ expect('removeOnFail' in opts).toBe(false);
+ });
+
+ it('omits removeOnComplete/removeOnFail when options object lacks them', () => {
+ const opts = buildBullJobOptions({ maxAttempts: 5, priority: 2 });
+
+ expect('removeOnComplete' in opts).toBe(false);
+ expect('removeOnFail' in opts).toBe(false);
+ expect(opts.attempts).toBe(5);
+ expect(opts.priority).toBe(2);
+ });
+
+ it('defaults attempts to 3 to match the graphile adapter', () => {
+ expect(buildBullJobOptions().attempts).toBe(3);
+ });
+
+ it('passes through removeOnComplete/removeOnFail when explicitly provided', () => {
+ const opts = buildBullJobOptions({ removeOnComplete: false, removeOnFail: true });
+
+ expect(opts.removeOnComplete).toBe(false);
+ expect(opts.removeOnFail).toBe(true);
+ });
+
+ it('maps jobKey to jobId', () => {
+ expect(buildBullJobOptions({ jobKey: 'abc' }).jobId).toBe('abc');
+ });
+});
diff --git a/packages/backend/src/tests/utils/ssrf-guard.test.ts b/packages/backend/src/tests/utils/ssrf-guard.test.ts
index 9d59b65e..e3bcc51f 100644
--- a/packages/backend/src/tests/utils/ssrf-guard.test.ts
+++ b/packages/backend/src/tests/utils/ssrf-guard.test.ts
@@ -4,6 +4,8 @@ import {
resolveAndValidateHost,
assertHttpTargetAllowed,
safeFetch,
+ createPinnedLookup,
+ __setSafeFetchImpl,
SsrfBlockedError,
} from '../../utils/ssrf-guard.js';
@@ -92,8 +94,31 @@ describe('assertHttpTargetAllowed', () => {
});
});
+describe('createPinnedLookup (DNS rebinding protection)', () => {
+ it('always returns the pinned address regardless of the hostname asked', () => {
+ const lookup = createPinnedLookup('93.184.216.34');
+ let got: { address: string; family?: number } | undefined;
+ lookup('evil.example.com', {}, (_err, address, family) => {
+ got = { address: address as string, family };
+ });
+ expect(got).toEqual({ address: '93.184.216.34', family: 4 });
+ });
+
+ it('supports the { all: true } array form with the correct family', () => {
+ const lookup = createPinnedLookup('2606:4700:4700::1111');
+ let got: unknown;
+ lookup('evil.example.com', { all: true }, (_err, list) => {
+ got = list;
+ });
+ expect(got).toEqual([{ address: '2606:4700:4700::1111', family: 6 }]);
+ });
+});
+
describe('safeFetch redirect revalidation', () => {
- afterEach(() => vi.restoreAllMocks());
+ afterEach(() => {
+ vi.restoreAllMocks();
+ __setSafeFetchImpl(null);
+ });
function res(status: number, location?: string) {
return {
@@ -108,16 +133,26 @@ describe('safeFetch redirect revalidation', () => {
.fn()
.mockResolvedValueOnce(res(302, 'http://1.1.1.1/next'))
.mockResolvedValueOnce(res(200));
- global.fetch = fetchMock as any;
+ __setSafeFetchImpl(fetchMock as any);
const result = await safeFetch('http://8.8.8.8/start', {}, { allowPrivate: false });
expect(result.status).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
+ it('passes a pinning dispatcher to the underlying fetch', async () => {
+ const fetchMock = vi.fn().mockResolvedValueOnce(res(200));
+ __setSafeFetchImpl(fetchMock as any);
+
+ await safeFetch('http://8.8.8.8/start', {}, { allowPrivate: false });
+ const init = fetchMock.mock.calls[0][1];
+ expect(init.dispatcher).toBeDefined();
+ expect(init.redirect).toBe('manual');
+ });
+
it('blocks a redirect that points at an internal address', async () => {
const fetchMock = vi.fn().mockResolvedValueOnce(res(302, 'http://169.254.169.254/latest/meta-data'));
- global.fetch = fetchMock as any;
+ __setSafeFetchImpl(fetchMock as any);
await expect(safeFetch('http://8.8.8.8/start', {}, { allowPrivate: false })).rejects.toBeInstanceOf(
SsrfBlockedError
@@ -126,7 +161,7 @@ describe('safeFetch redirect revalidation', () => {
it('stops after too many redirects', async () => {
const fetchMock = vi.fn().mockResolvedValue(res(302, 'http://1.1.1.1/loop'));
- global.fetch = fetchMock as any;
+ __setSafeFetchImpl(fetchMock as any);
await expect(safeFetch('http://8.8.8.8/start', {}, { allowPrivate: false, maxRedirects: 2 })).rejects.toBeInstanceOf(
SsrfBlockedError
diff --git a/packages/backend/src/utils/internal-logger.ts b/packages/backend/src/utils/internal-logger.ts
index 26c300f0..63886fcf 100644
--- a/packages/backend/src/utils/internal-logger.ts
+++ b/packages/backend/src/utils/internal-logger.ts
@@ -58,7 +58,7 @@ export async function initializeInternalLogging(): Promise {
dsn,
service: process.env.SERVICE_NAME || 'logtide-backend',
environment: process.env.NODE_ENV || 'development',
- release: process.env.npm_package_version || '1.0.2',
+ release: process.env.npm_package_version || '1.0.3',
batchSize: 5, // Smaller batch for internal logs to see them faster
flushInterval: 5000,
maxBufferSize: 1000,
diff --git a/packages/backend/src/utils/ssrf-guard.ts b/packages/backend/src/utils/ssrf-guard.ts
index 1315eb81..a3f91e50 100644
--- a/packages/backend/src/utils/ssrf-guard.ts
+++ b/packages/backend/src/utils/ssrf-guard.ts
@@ -15,6 +15,7 @@
import { lookup } from 'dns/promises';
import { isIP } from 'net';
+import { Agent, fetch as undiciFetch } from 'undici';
export class SsrfBlockedError extends Error {
constructor(message: string) {
@@ -139,14 +140,49 @@ interface SafeFetchOptions {
maxRedirects?: number;
}
+/**
+ * Build a DNS lookup function that always resolves to the already-validated
+ * `address`, regardless of the hostname asked. Handed to an undici Agent so the
+ * socket connects to the exact IP we checked, closing the resolve-then-connect
+ * (DNS rebinding) window. TLS SNI and certificate validation still use the
+ * original hostname; only the destination IP is pinned.
+ */
+export function createPinnedLookup(address: string) {
+ const family = isIP(address); // 4 or 6 (0 is rejected upstream as not-an-IP)
+ return (
+ _hostname: string,
+ options: { all?: boolean } | undefined,
+ callback: (
+ err: NodeJS.ErrnoException | null,
+ address: string | Array<{ address: string; family: number }>,
+ family?: number,
+ ) => void,
+ ): void => {
+ if (options && options.all) {
+ callback(null, [{ address, family }]);
+ } else {
+ callback(null, address, family);
+ }
+ };
+}
+
+// Fetch implementation seam. Defaults to undici's fetch so we can attach a
+// per-request dispatcher; overridable in tests via __setSafeFetchImpl.
+type FetchImpl = (url: string | URL, init: Record) => Promise;
+let fetchImpl: FetchImpl = undiciFetch as unknown as FetchImpl;
+
+/** @internal Test hook to stub the underlying fetch. Pass null to reset. */
+export function __setSafeFetchImpl(fn: FetchImpl | null): void {
+ fetchImpl = fn ?? (undiciFetch as unknown as FetchImpl);
+}
+
/**
* fetch() wrapper that validates the target before connecting and revalidates
* every redirect hop instead of blindly following them. Each hop is resolved
- * and checked, which closes redirect-to-internal bypasses.
- *
- * Note: there is a small resolve-then-connect window (DNS rebinding) that this
- * does not fully pin for HTTPS without a custom dispatcher; the validation here
- * addresses the reported direct-target and redirect bypasses.
+ * and checked, and the connection is PINNED to the validated IP via a custom
+ * undici dispatcher, which closes the resolve-then-connect (DNS rebinding)
+ * window for both HTTP and HTTPS. TLS still uses the original hostname for SNI
+ * and certificate validation.
*/
export async function safeFetch(
rawUrl: string,
@@ -166,9 +202,22 @@ export async function safeFetch(
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new SsrfBlockedError('Only http and https targets are allowed');
}
- await resolveAndValidateHost(url.hostname, options.allowPrivate);
+ const addresses = await resolveAndValidateHost(url.hostname, options.allowPrivate);
- const response = await fetch(url, { ...init, redirect: 'manual' });
+ // Pin the socket to the validated address so a hostname that re-resolves to
+ // an internal IP between validation and connect cannot be reached.
+ const dispatcher = new Agent({ connect: { lookup: createPinnedLookup(addresses[0]) } });
+
+ let response: Response;
+ try {
+ response = await fetchImpl(url, { ...init, redirect: 'manual', dispatcher });
+ } catch (err) {
+ void dispatcher.close().catch(() => {});
+ throw err;
+ }
+ // Gracefully close the per-hop dispatcher once the request completes; this
+ // does not abort the body the caller is about to read.
+ void dispatcher.close().catch(() => {});
const isRedirect = response.status >= 300 && response.status < 400;
const location = isRedirect ? response.headers?.get('location') ?? null : null;
diff --git a/packages/backend/src/worker.ts b/packages/backend/src/worker.ts
index 3f47d691..c29c8924 100644
--- a/packages/backend/src/worker.ts
+++ b/packages/backend/src/worker.ts
@@ -641,9 +641,29 @@ async function syncSigmaRules() {
for (const org of orgs) {
try {
+ // Only re-sync the rules this org already imported, to refresh their
+ // detection content/commit from upstream. Do NOT fetch the whole
+ // SigmaHQ catalog (that path imports and enables thousands of new
+ // rules), and do NOT auto-create alert rules: Sigma rules are
+ // independent from alert rules.
+ const existingRules = await db
+ .selectFrom('sigma_rules')
+ .select('sigmahq_path')
+ .where('organization_id', '=', org.organization_id)
+ .where('sigmahq_path', 'is not', null)
+ .execute();
+ const rulePaths = existingRules
+ .map((r) => r.sigmahq_path)
+ .filter((p): p is string => Boolean(p));
+
+ if (rulePaths.length === 0) {
+ continue;
+ }
+
const result = await sigmaSyncService.syncFromSigmaHQ({
organizationId: org.organization_id,
- autoCreateAlerts: true,
+ selection: { rules: rulePaths },
+ autoCreateAlerts: false,
onLimitExceeded: 'skip-new',
});
diff --git a/packages/frontend/package.json b/packages/frontend/package.json
index 07c68938..a2e3f1de 100644
--- a/packages/frontend/package.json
+++ b/packages/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "@logtide/frontend",
- "version": "1.0.2",
+ "version": "1.0.3",
"private": true,
"description": "LogTide Frontend Dashboard",
"type": "module",
diff --git a/packages/frontend/src/hooks.client.ts b/packages/frontend/src/hooks.client.ts
index f8f923d9..b2fb82e1 100644
--- a/packages/frontend/src/hooks.client.ts
+++ b/packages/frontend/src/hooks.client.ts
@@ -9,7 +9,7 @@ if (dsn) {
dsn,
service: 'logtide-frontend-client',
environment: env.PUBLIC_NODE_ENV || 'production',
- release: env.PUBLIC_APP_VERSION || '1.0.2',
+ release: env.PUBLIC_APP_VERSION || '1.0.3',
debug: env.PUBLIC_NODE_ENV === 'development',
browser: {
// Core Web Vitals (LCP, INP, CLS, TTFB)
diff --git a/packages/frontend/src/hooks.server.ts b/packages/frontend/src/hooks.server.ts
index 31b94ef5..86bb0559 100644
--- a/packages/frontend/src/hooks.server.ts
+++ b/packages/frontend/src/hooks.server.ts
@@ -82,7 +82,7 @@ export const handle = dsn
dsn,
service: 'logtide-frontend',
environment: privateEnv?.NODE_ENV || 'production',
- release: process.env.npm_package_version || '1.0.2', }) as unknown as Handle,
+ release: process.env.npm_package_version || '1.0.3', }) as unknown as Handle,
requestLogHandle,
configHandle
)
diff --git a/packages/frontend/src/lib/api/dashboard.ts b/packages/frontend/src/lib/api/dashboard.ts
index 7284a4e4..8fa838db 100644
--- a/packages/frontend/src/lib/api/dashboard.ts
+++ b/packages/frontend/src/lib/api/dashboard.ts
@@ -53,6 +53,21 @@ export interface TimelineEvent {
detectionsBySeverity: { critical: number; high: number; medium: number; low: number };
}
+export interface ActivityOverviewData {
+ series: Array<{
+ time: string;
+ logs: number;
+ log_errors: number;
+ spans: number;
+ span_errors: number;
+ detections: number;
+ alerts: number;
+ }>;
+ timeRange: string;
+ bucket: 'hour' | 'day';
+ enabled: string[];
+}
+
export class DashboardAPI {
constructor(private getToken: () => string | null) {}
@@ -108,6 +123,30 @@ export class DashboardAPI {
return data.timeseries;
}
+ async getActivityOverview(
+ organizationId: string,
+ projectId?: string,
+ timeRange: '24h' | '7d' | '30d' = '24h'
+ ): Promise {
+ const params = new URLSearchParams();
+ params.append('organizationId', organizationId);
+ if (projectId) params.append('projectId', projectId);
+ params.append('timeRange', timeRange);
+
+ const url = `${getApiUrl()}/api/v1/dashboard/activity-overview?${params.toString()}`;
+
+ const response = await fetch(url, {
+ method: 'GET',
+ headers: this.getHeaders(),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch activity overview: ${response.statusText}`);
+ }
+
+ return (await response.json()) as ActivityOverviewData;
+ }
+
async getTopServices(organizationId: string, projectId?: string): Promise {
const params = new URLSearchParams();
params.append('organizationId', organizationId);
diff --git a/packages/frontend/src/lib/api/exceptions.ts b/packages/frontend/src/lib/api/exceptions.ts
index b1065380..82319db7 100644
--- a/packages/frontend/src/lib/api/exceptions.ts
+++ b/packages/frontend/src/lib/api/exceptions.ts
@@ -26,6 +26,7 @@ export interface ErrorGroupLog {
time: string | Date;
service: string;
message: string;
+ traceId?: string;
metadata?: Record;
}
diff --git a/packages/frontend/src/lib/components/BreadcrumbTimeline.svelte b/packages/frontend/src/lib/components/BreadcrumbTimeline.svelte
index d4a4be16..f1190a6a 100644
--- a/packages/frontend/src/lib/components/BreadcrumbTimeline.svelte
+++ b/packages/frontend/src/lib/components/BreadcrumbTimeline.svelte
@@ -50,7 +50,7 @@
}
function formatAbsoluteTime(timestamp: number): string {
- return new Date(timestamp).toLocaleTimeString(undefined, {
+ return new Date(timestamp).toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
diff --git a/packages/frontend/src/lib/components/CorrelationTimelineDialog.svelte b/packages/frontend/src/lib/components/CorrelationTimelineDialog.svelte
index 7b1ce558..0775b051 100644
--- a/packages/frontend/src/lib/components/CorrelationTimelineDialog.svelte
+++ b/packages/frontend/src/lib/components/CorrelationTimelineDialog.svelte
@@ -67,13 +67,13 @@
}
function formatTime(timestamp: string): string {
- return new Date(timestamp).toLocaleString();
+ return new Date(timestamp).toLocaleString('en-US');
}
function formatShortTime(timestamp: string): string {
const date = new Date(timestamp);
// Include milliseconds manually since fractionalSecondDigits may not be supported
- const timeStr = date.toLocaleTimeString(undefined, {
+ const timeStr = date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
diff --git a/packages/frontend/src/lib/components/DetectionPackDialog.svelte b/packages/frontend/src/lib/components/DetectionPackDialog.svelte
index 51ab73cc..21656184 100644
--- a/packages/frontend/src/lib/components/DetectionPackDialog.svelte
+++ b/packages/frontend/src/lib/components/DetectionPackDialog.svelte
@@ -255,7 +255,7 @@
{pack.category}
- {pack.rules.length.toLocaleString()} Sigma rules
+ {pack.rules.length.toLocaleString('en-US')} Sigma rules
{#if pack.enabled && pack.generatedRulesCount > 0}
diff --git a/packages/frontend/src/lib/components/DetectionPacksGalleryDialog.svelte b/packages/frontend/src/lib/components/DetectionPacksGalleryDialog.svelte
index c46df9c5..2b1f094e 100644
--- a/packages/frontend/src/lib/components/DetectionPacksGalleryDialog.svelte
+++ b/packages/frontend/src/lib/components/DetectionPacksGalleryDialog.svelte
@@ -228,7 +228,7 @@
{pack.category}
- {pack.rules.length.toLocaleString()} rules
+ {pack.rules.length.toLocaleString('en-US')} rules
diff --git a/packages/frontend/src/lib/components/ExportLogsDialog.svelte b/packages/frontend/src/lib/components/ExportLogsDialog.svelte
index 1dd1d930..20f02979 100644
--- a/packages/frontend/src/lib/components/ExportLogsDialog.svelte
+++ b/packages/frontend/src/lib/components/ExportLogsDialog.svelte
@@ -74,7 +74,7 @@
const toDate = to ? new Date(to) : null;
const formatDate = (d: Date) => {
- return d.toLocaleString(undefined, {
+ return d.toLocaleString('en-US', {
month: "short",
day: "numeric",
hour: "2-digit",
@@ -124,7 +124,7 @@
async function handleExport() {
if (exportLimit < 1 || exportLimit > maxExportable) {
- toastStore.error(`Please enter a number between 1 and ${maxExportable.toLocaleString()}`);
+ toastStore.error(`Please enter a number between 1 and ${maxExportable.toLocaleString('en-US')}`);
return;
}
@@ -179,7 +179,7 @@
downloadFile(csv, filename, "text/csv");
}
- toastStore.success(`Exported ${allLogs.length.toLocaleString()} logs successfully`);
+ toastStore.success(`Exported ${allLogs.length.toLocaleString('en-US')} logs successfully`);
open = false;
} catch (error) {
console.error("Export failed:", error);
@@ -246,7 +246,7 @@
{/if}
Total Matching:
- {totalLogs.toLocaleString()} logs
+ {totalLogs.toLocaleString('en-US')} logs
@@ -265,12 +265,12 @@
class="w-32"
/>
- / {maxExportable.toLocaleString()} max
+ / {maxExportable.toLocaleString('en-US')} max
{#if totalLogs > MAX_EXPORT_LOGS}
- Maximum export limit is {MAX_EXPORT_LOGS.toLocaleString()} logs.
+ Maximum export limit is {MAX_EXPORT_LOGS.toLocaleString('en-US')} logs.
Use time filters to narrow down your results.
`;
html += `${p.marker} ${p.seriesName}`;
- html += `${Number(p.value).toLocaleString()}`;
+ html += `${Number(p.value).toLocaleString('en-US')}`;
html += `