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 @@
   Coverage
   Docker
   Artifact Hub
-  Version
+  Version
   License
   Status
 
 
 
-> **🌊 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.

{/if} @@ -341,7 +341,7 @@
Exporting... - {exportProgress.current.toLocaleString()} / {exportProgress.total.toLocaleString()} + {exportProgress.current.toLocaleString('en-US')} / {exportProgress.total.toLocaleString('en-US')}
@@ -364,7 +364,7 @@ Exporting... {:else} - Export {exportLimit.toLocaleString()} Logs + Export {exportLimit.toLocaleString('en-US')} Logs {/if} diff --git a/packages/frontend/src/lib/components/Footer.svelte b/packages/frontend/src/lib/components/Footer.svelte index 4ce73582..2df9517a 100644 --- a/packages/frontend/src/lib/components/Footer.svelte +++ b/packages/frontend/src/lib/components/Footer.svelte @@ -1,7 +1,7 @@ diff --git a/packages/frontend/src/lib/components/LogContextDialog.svelte b/packages/frontend/src/lib/components/LogContextDialog.svelte index fbd8ab62..ddca78ca 100644 --- a/packages/frontend/src/lib/components/LogContextDialog.svelte +++ b/packages/frontend/src/lib/components/LogContextDialog.svelte @@ -5,8 +5,11 @@ import Spinner from '$lib/components/Spinner.svelte'; import { ExceptionDetailsDialog } from '$lib/components/exceptions'; import BreadcrumbTimeline from '$lib/components/BreadcrumbTimeline.svelte'; + import { copyToClipboard } from '$lib/utils/clipboard'; import AlertTriangle from '@lucide/svelte/icons/alert-triangle'; import ListTree from '@lucide/svelte/icons/list-tree'; + import Copy from '@lucide/svelte/icons/copy'; + import Check from '@lucide/svelte/icons/check'; interface LogEntry { id?: string; @@ -51,6 +54,18 @@ return level === 'error' || level === 'critical'; } + // Copy-to-clipboard feedback, keyed per metadata block + let copiedKey = $state(null); + async function copyMetadata(key: string, metadata: Record) { + const ok = await copyToClipboard(JSON.stringify(metadata, null, 2)); + if (ok) { + copiedKey = key; + setTimeout(() => { + if (copiedKey === key) copiedKey = null; + }, 2000); + } + } + function openExceptionDialog() { exceptionDialogOpen = true; } @@ -100,7 +115,7 @@ } function formatTime(timestamp: string): string { - return new Date(timestamp).toLocaleString(); + return new Date(timestamp).toLocaleString('en-US'); } function getLevelColor(level: string): string { @@ -121,6 +136,30 @@ } +{#snippet metadataBlock(metadata: Record, key: string)} +
+ + View metadata + +
+ +
{JSON.stringify(metadata, null, 2)}
+
+
+{/snippet} + !isOpen && onClose()}> @@ -140,7 +179,7 @@ {error} {:else if contextLogs} -
+
{#if contextLogs.before.length > 0}
← {contextLogs.before.length} log(s) before @@ -163,12 +202,7 @@

{log.message}

{#if log.metadata && Object.keys(log.metadata).length > 0} -
- - View metadata - -
{JSON.stringify(log.metadata, null, 2)}
-
+ {@render metadataBlock(log.metadata, `before-${log.id ?? log.time}`)} {/if}
{/each} @@ -195,12 +229,7 @@

{selectedLog.message}

{#if selectedLog.metadata && Object.keys(selectedLog.metadata).length > 0} -
- - View metadata - -
{JSON.stringify(selectedLog.metadata, null, 2)}
-
+ {@render metadataBlock(selectedLog.metadata, 'selected')} {/if} {#if isErrorLevel(selectedLog.level) && selectedLog.id && organizationId}
@@ -227,7 +256,7 @@ {breadcrumbsOpen ? '▾' : '▸'} {#if breadcrumbsOpen} -
+

{log.message}

{#if log.metadata && Object.keys(log.metadata).length > 0} -
- - View metadata - -
{JSON.stringify(log.metadata, null, 2)}
-
+ {@render metadataBlock(log.metadata, `after-${log.id ?? log.time}`)} {/if}
{/each} diff --git a/packages/frontend/src/lib/components/ServiceMap.svelte b/packages/frontend/src/lib/components/ServiceMap.svelte index 79c894b4..371b33a6 100644 --- a/packages/frontend/src/lib/components/ServiceMap.svelte +++ b/packages/frontend/src/lib/components/ServiceMap.svelte @@ -4,6 +4,7 @@ import type { ServiceDependencies, EnrichedServiceDependencies } from "$lib/api/traces"; import { themeStore } from "$lib/stores/theme"; import { getEChartsTheme, getTooltipStyle } from "$lib/utils/echarts-theme"; + import { escapeHtml } from "$lib/utils/html"; interface Props { dependencies: ServiceDependencies | EnrichedServiceDependencies; @@ -138,7 +139,7 @@ formatter: (params: any) => { if (params.dataType === "node") { const node = dependencies.nodes.find((n) => n.name === params.name); - let html = `${params.name}
Calls: ${params.value}`; + let html = `${escapeHtml(params.name)}
Calls: ${params.value}`; if (node && isEnrichedNode(node)) { html += `
Error rate: ${(node.errorRate * 100).toFixed(1)}%`; html += `
Avg latency: ${formatLatency(node.avgLatencyMs)}`; @@ -148,7 +149,7 @@ const edge = dependencies.edges.find( (e) => e.source === params.data.source && e.target === params.data.target ); - let html = `${params.data.source} → ${params.data.target}
Calls: ${params.data.value}`; + let html = `${escapeHtml(params.data.source)} → ${escapeHtml(params.data.target)}
Calls: ${params.data.value}`; if (edge && isEnrichedEdge(edge) && edge.type === 'log_correlation') { html += `
(log correlation)`; } diff --git a/packages/frontend/src/lib/components/SigmaTreeMultiSelect.svelte b/packages/frontend/src/lib/components/SigmaTreeMultiSelect.svelte index b575d78f..ef5401e0 100644 --- a/packages/frontend/src/lib/components/SigmaTreeMultiSelect.svelte +++ b/packages/frontend/src/lib/components/SigmaTreeMultiSelect.svelte @@ -141,7 +141,7 @@ {#if totalSelected > 0}
- {totalSelected.toLocaleString()} item{totalSelected === 1 ? '' : 's'} selected + {totalSelected.toLocaleString('en-US')} item{totalSelected === 1 ? '' : 's'} selected
`; for (const p of params) { html += `
`; html += `${p.marker} ${p.seriesName}`; - html += `${Number(p.value).toLocaleString()}`; + html += `${Number(p.value).toLocaleString('en-US')}`; html += `
`; } return html; @@ -75,7 +75,7 @@ if (val % 1 !== 0) return ''; if (val >= 1000000) return `${(val / 1000000).toFixed(1)}M`; if (val >= 1000) return `${(val / 1000).toFixed(0)}k`; - return val.toLocaleString(); + return val.toLocaleString('en-US'); }, }, }, diff --git a/packages/frontend/src/lib/components/alerts/preview/PreviewSamples.svelte b/packages/frontend/src/lib/components/alerts/preview/PreviewSamples.svelte index 319335b7..a04204e6 100644 --- a/packages/frontend/src/lib/components/alerts/preview/PreviewSamples.svelte +++ b/packages/frontend/src/lib/components/alerts/preview/PreviewSamples.svelte @@ -44,7 +44,7 @@ function formatTime(dateStr: string): string { const d = new Date(dateStr); - return d.toLocaleString(undefined, { + return d.toLocaleString('en-US', { month: "short", day: "numeric", hour: "2-digit", @@ -55,7 +55,7 @@ function formatLogTime(dateStr: string): string { const d = new Date(dateStr); - return d.toLocaleTimeString(undefined, { + return d.toLocaleTimeString('en-US', { hour: "2-digit", minute: "2-digit", second: "2-digit", @@ -141,7 +141,7 @@ {#if incidents.length > 5}

- Showing 5 of {incidents.length.toLocaleString()} incidents + Showing 5 of {incidents.length.toLocaleString('en-US')} incidents

{/if} {/if} diff --git a/packages/frontend/src/lib/components/alerts/preview/PreviewSummary.svelte b/packages/frontend/src/lib/components/alerts/preview/PreviewSummary.svelte index 4a43b0ec..3c64e0d2 100644 --- a/packages/frontend/src/lib/components/alerts/preview/PreviewSummary.svelte +++ b/packages/frontend/src/lib/components/alerts/preview/PreviewSummary.svelte @@ -75,7 +75,7 @@
{#if totalIncidents > 0} - {totalIncidents.toLocaleString()} + {totalIncidents.toLocaleString('en-US')} incident{totalIncidents !== 1 ? "s" : ""} in the last {rangeDays} day{rangeDays !== 1 ? "s" : ""} diff --git a/packages/frontend/src/lib/components/alerts/preview/PreviewTimeline.svelte b/packages/frontend/src/lib/components/alerts/preview/PreviewTimeline.svelte index 62fc60f2..45418e11 100644 --- a/packages/frontend/src/lib/components/alerts/preview/PreviewTimeline.svelte +++ b/packages/frontend/src/lib/components/alerts/preview/PreviewTimeline.svelte @@ -78,7 +78,7 @@ // Format times for x-axis const times = dataPoints.map((d) => - d.time.toLocaleString(undefined, { + d.time.toLocaleString('en-US', { month: "short", day: "numeric", hour: "2-digit", @@ -139,7 +139,7 @@ ...axisStyle.axisLabel, formatter: (val: number) => { if (val % 1 !== 0) return ''; - return val.toLocaleString(); + return val.toLocaleString('en-US'); } }, }, @@ -229,8 +229,8 @@ class="h-[200px] md:h-[250px] w-full" >

- {incidents.length.toLocaleString()} incident{incidents.length !== 1 ? "s" : ""} detected - - dashed line shows threshold ({threshold.toLocaleString()} logs) + {incidents.length.toLocaleString('en-US')} incident{incidents.length !== 1 ? "s" : ""} detected + - dashed line shows threshold ({threshold.toLocaleString('en-US')} logs)

{/if}
diff --git a/packages/frontend/src/lib/components/custom-dashboards/panels/ActivityOverviewPanel.svelte b/packages/frontend/src/lib/components/custom-dashboards/panels/ActivityOverviewPanel.svelte index 45bd0d41..fe5a5d04 100644 --- a/packages/frontend/src/lib/components/custom-dashboards/panels/ActivityOverviewPanel.svelte +++ b/packages/frontend/src/lib/components/custom-dashboards/panels/ActivityOverviewPanel.svelte @@ -56,9 +56,9 @@ function formatTimeLabel(time: string, bucket: 'hour' | 'day'): string { const d = new Date(time); if (bucket === 'day') { - return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); } - return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false }); + return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }); } function buildOption(): echarts.EChartsOption { diff --git a/packages/frontend/src/lib/components/custom-dashboards/panels/DetectionEventsPanel.svelte b/packages/frontend/src/lib/components/custom-dashboards/panels/DetectionEventsPanel.svelte index ed2917ed..01ea5cf7 100644 --- a/packages/frontend/src/lib/components/custom-dashboards/panels/DetectionEventsPanel.svelte +++ b/packages/frontend/src/lib/components/custom-dashboards/panels/DetectionEventsPanel.svelte @@ -29,7 +29,7 @@ const typed = $derived(data as DetectionEventsData | null); function fmtTime(t: string): string { - return new Date(t).toLocaleString(undefined, { + return new Date(t).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', diff --git a/packages/frontend/src/lib/components/custom-dashboards/panels/LiveLogStreamPanel.svelte b/packages/frontend/src/lib/components/custom-dashboards/panels/LiveLogStreamPanel.svelte index ab44b6a4..55bbf40b 100644 --- a/packages/frontend/src/lib/components/custom-dashboards/panels/LiveLogStreamPanel.svelte +++ b/packages/frontend/src/lib/components/custom-dashboards/panels/LiveLogStreamPanel.svelte @@ -26,7 +26,7 @@ const typed = $derived(data as LiveLogStreamSnapshot | null); function formatTime(time: string): string { - return new Date(time).toLocaleTimeString(undefined, { + return new Date(time).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', diff --git a/packages/frontend/src/lib/components/custom-dashboards/panels/MetricChartPanel.svelte b/packages/frontend/src/lib/components/custom-dashboards/panels/MetricChartPanel.svelte index bc480f6d..e4011f43 100644 --- a/packages/frontend/src/lib/components/custom-dashboards/panels/MetricChartPanel.svelte +++ b/packages/frontend/src/lib/components/custom-dashboards/panels/MetricChartPanel.svelte @@ -31,7 +31,7 @@ const typed = $derived(data as MetricChartData | null); function fmtTime(t: string): string { - return new Date(t).toLocaleTimeString(undefined, { + return new Date(t).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false, diff --git a/packages/frontend/src/lib/components/custom-dashboards/panels/TimeSeriesPanel.svelte b/packages/frontend/src/lib/components/custom-dashboards/panels/TimeSeriesPanel.svelte index b138e616..6e6fae32 100644 --- a/packages/frontend/src/lib/components/custom-dashboards/panels/TimeSeriesPanel.svelte +++ b/packages/frontend/src/lib/components/custom-dashboards/panels/TimeSeriesPanel.svelte @@ -37,7 +37,7 @@ const typedData = $derived(data as TimeSeriesPanelData | null); function formatTimeLabel(time: string): string { - return new Date(time).toLocaleTimeString(undefined, { + return new Date(time).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false, diff --git a/packages/frontend/src/lib/components/custom-dashboards/panels/TopNTablePanel.svelte b/packages/frontend/src/lib/components/custom-dashboards/panels/TopNTablePanel.svelte index e7b71ec3..25d66c78 100644 --- a/packages/frontend/src/lib/components/custom-dashboards/panels/TopNTablePanel.svelte +++ b/packages/frontend/src/lib/components/custom-dashboards/panels/TopNTablePanel.svelte @@ -38,7 +38,7 @@

{row.key}

- {row.count.toLocaleString()} + {row.count.toLocaleString('en-US')} {config.dimension === 'service' ? 'logs' : 'occurrences'}

diff --git a/packages/frontend/src/lib/components/custom-dashboards/panels/TraceLatencyPanel.svelte b/packages/frontend/src/lib/components/custom-dashboards/panels/TraceLatencyPanel.svelte index cc8f281f..9d4b36c5 100644 --- a/packages/frontend/src/lib/components/custom-dashboards/panels/TraceLatencyPanel.svelte +++ b/packages/frontend/src/lib/components/custom-dashboards/panels/TraceLatencyPanel.svelte @@ -37,7 +37,7 @@ const typed = $derived(data as TraceLatencyData | null); function fmtTime(t: string): string { - return new Date(t).toLocaleTimeString(undefined, { + return new Date(t).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false, diff --git a/packages/frontend/src/lib/components/custom-dashboards/panels/TraceVolumePanel.svelte b/packages/frontend/src/lib/components/custom-dashboards/panels/TraceVolumePanel.svelte index 04d821ca..7596f7fc 100644 --- a/packages/frontend/src/lib/components/custom-dashboards/panels/TraceVolumePanel.svelte +++ b/packages/frontend/src/lib/components/custom-dashboards/panels/TraceVolumePanel.svelte @@ -33,9 +33,9 @@ function formatTimeLabel(time: string, bucket: 'hour' | 'day'): string { const d = new Date(time); if (bucket === 'day') { - return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); } - return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false }); + return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }); } function buildOption(): echarts.EChartsOption { diff --git a/packages/frontend/src/lib/components/dashboard/LogsChart.svelte b/packages/frontend/src/lib/components/dashboard/LogsChart.svelte index 589b7b6a..914a9174 100644 --- a/packages/frontend/src/lib/components/dashboard/LogsChart.svelte +++ b/packages/frontend/src/lib/components/dashboard/LogsChart.svelte @@ -30,7 +30,7 @@ let chart: echarts.ECharts | null = null; function formatTimeLabel(time: string): string { - return new Date(time).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false }); + return new Date(time).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }); } function buildEventSeries(): echarts.SeriesOption[] { @@ -150,7 +150,7 @@ ...axisStyle.axisLabel, formatter: (value: number) => { if (value % 1 !== 0) return ''; - return value.toLocaleString(); + return value.toLocaleString('en-US'); } } }, diff --git a/packages/frontend/src/lib/components/dashboard/TopServicesWidget.svelte b/packages/frontend/src/lib/components/dashboard/TopServicesWidget.svelte index 0315de57..09f32821 100644 --- a/packages/frontend/src/lib/components/dashboard/TopServicesWidget.svelte +++ b/packages/frontend/src/lib/components/dashboard/TopServicesWidget.svelte @@ -38,7 +38,7 @@

{service.name}

-

{service.count.toLocaleString()} logs

+

{service.count.toLocaleString('en-US')} logs

{service.percentage.toFixed(2)}% diff --git a/packages/frontend/src/lib/components/exceptions/ErrorGroupCard.svelte b/packages/frontend/src/lib/components/exceptions/ErrorGroupCard.svelte index 8df76d5b..804ce747 100644 --- a/packages/frontend/src/lib/components/exceptions/ErrorGroupCard.svelte +++ b/packages/frontend/src/lib/components/exceptions/ErrorGroupCard.svelte @@ -18,7 +18,7 @@ function formatDate(dateStr: string | Date): string { const date = typeof dateStr === 'string' ? new Date(dateStr) : dateStr; - return date.toLocaleDateString(undefined, { + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', diff --git a/packages/frontend/src/lib/components/metrics/MetricCard.svelte b/packages/frontend/src/lib/components/metrics/MetricCard.svelte index eecc27c9..65642416 100644 --- a/packages/frontend/src/lib/components/metrics/MetricCard.svelte +++ b/packages/frontend/src/lib/components/metrics/MetricCard.svelte @@ -75,7 +75,7 @@ const tooltipStyle = getTooltipStyle(); const buckets = timeseries.timeseries.map(p => { const d = typeof p.bucket === 'string' ? new Date(p.bucket) : p.bucket; - return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false }); + return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }); }); const values = timeseries.timeseries.map(p => p.value); diff --git a/packages/frontend/src/lib/components/metrics/SignalChart.svelte b/packages/frontend/src/lib/components/metrics/SignalChart.svelte index 0f740692..aa304747 100644 --- a/packages/frontend/src/lib/components/metrics/SignalChart.svelte +++ b/packages/frontend/src/lib/components/metrics/SignalChart.svelte @@ -117,7 +117,7 @@ boundaryGap: false, data: allBuckets.map(b => { const d = new Date(b); - return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false }); + return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }); }), ...axisStyle, }, diff --git a/packages/frontend/src/lib/components/onboarding/steps/FirstLogStep.svelte b/packages/frontend/src/lib/components/onboarding/steps/FirstLogStep.svelte index 40059a7b..fb8a5a73 100644 --- a/packages/frontend/src/lib/components/onboarding/steps/FirstLogStep.svelte +++ b/packages/frontend/src/lib/components/onboarding/steps/FirstLogStep.svelte @@ -221,7 +221,7 @@
Time: - {new Date(receivedLog.time).toLocaleString()} + {new Date(receivedLog.time).toLocaleString('en-US')}
Level: diff --git a/packages/frontend/src/lib/components/search/ColumnConfigMenu.svelte b/packages/frontend/src/lib/components/search/ColumnConfigMenu.svelte index 85025d09..bcdecbb7 100644 --- a/packages/frontend/src/lib/components/search/ColumnConfigMenu.svelte +++ b/packages/frontend/src/lib/components/search/ColumnConfigMenu.svelte @@ -58,13 +58,13 @@

Metadata columns

-

Add metadata keys to show as extra columns in the table.

+

Add metadata keys to show as extra columns in the table. Use dot notation to reach nested values (e.g. sdk.name).

0) { const date = new Date(data?.[p[0].dataIndex]?.timestamp || ''); - return `${date.toLocaleString(undefined)}
${p[0].value} detections`; + return `${date.toLocaleString('en-US')}
${p[0].value} detections`; } return ''; }, @@ -77,7 +77,7 @@ ...axisStyle.axisLabel, formatter: (value: number) => { if (value % 1 !== 0) return ''; - return value.toLocaleString(); + return value.toLocaleString('en-US'); } } }, series: [ diff --git a/packages/frontend/src/lib/components/siem/enrichment/IpReputationCard.svelte b/packages/frontend/src/lib/components/siem/enrichment/IpReputationCard.svelte index 72c6227e..cc42cb29 100644 --- a/packages/frontend/src/lib/components/siem/enrichment/IpReputationCard.svelte +++ b/packages/frontend/src/lib/components/siem/enrichment/IpReputationCard.svelte @@ -74,7 +74,7 @@ if (diffMins < 60) return `${diffMins}m ago`; if (diffHours < 24) return `${diffHours}h ago`; if (diffDays < 7) return `${diffDays}d ago`; - return date.toLocaleDateString(); + return date.toLocaleDateString('en-US'); } function toggleExpand(ip: string) { diff --git a/packages/frontend/src/lib/components/siem/incidents/DetectionEventsList.svelte b/packages/frontend/src/lib/components/siem/incidents/DetectionEventsList.svelte index 781e2a34..814eda1e 100644 --- a/packages/frontend/src/lib/components/siem/incidents/DetectionEventsList.svelte +++ b/packages/frontend/src/lib/components/siem/incidents/DetectionEventsList.svelte @@ -39,7 +39,7 @@ function formatTime(dateStr: string | Date): string { const date = typeof dateStr === 'string' ? new Date(dateStr) : dateStr; - return date.toLocaleTimeString(undefined, { + return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', @@ -48,7 +48,7 @@ function formatDate(dateStr: string | Date): string { const date = typeof dateStr === 'string' ? new Date(dateStr) : dateStr; - return date.toLocaleDateString(undefined, { + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', }); @@ -83,7 +83,7 @@ Detection Events - ({detections.length.toLocaleString()}) + ({detections.length.toLocaleString('en-US')}) diff --git a/packages/frontend/src/lib/components/siem/incidents/IncidentCard.svelte b/packages/frontend/src/lib/components/siem/incidents/IncidentCard.svelte index 9b3a490c..655a36bb 100644 --- a/packages/frontend/src/lib/components/siem/incidents/IncidentCard.svelte +++ b/packages/frontend/src/lib/components/siem/incidents/IncidentCard.svelte @@ -20,7 +20,7 @@ function formatDate(dateStr: string | Date): string { const date = typeof dateStr === 'string' ? new Date(dateStr) : dateStr; - return date.toLocaleDateString(undefined, { + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', diff --git a/packages/frontend/src/lib/components/siem/incidents/IncidentCommentsThread.svelte b/packages/frontend/src/lib/components/siem/incidents/IncidentCommentsThread.svelte index 97f3422d..0fe77011 100644 --- a/packages/frontend/src/lib/components/siem/incidents/IncidentCommentsThread.svelte +++ b/packages/frontend/src/lib/components/siem/incidents/IncidentCommentsThread.svelte @@ -25,7 +25,7 @@ function formatDate(dateStr: string | Date): string { const date = typeof dateStr === 'string' ? new Date(dateStr) : dateStr; - return date.toLocaleDateString(undefined, { + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', diff --git a/packages/frontend/src/lib/components/siem/incidents/IncidentHistoryTimeline.svelte b/packages/frontend/src/lib/components/siem/incidents/IncidentHistoryTimeline.svelte index ec524a86..24ab2124 100644 --- a/packages/frontend/src/lib/components/siem/incidents/IncidentHistoryTimeline.svelte +++ b/packages/frontend/src/lib/components/siem/incidents/IncidentHistoryTimeline.svelte @@ -65,7 +65,7 @@ function formatDate(dateStr: string | Date): string { const date = typeof dateStr === 'string' ? new Date(dateStr) : dateStr; - return date.toLocaleDateString(undefined, { + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', diff --git a/packages/frontend/src/lib/utils/datetime.ts b/packages/frontend/src/lib/utils/datetime.ts index d57c8d8e..9b39f4e7 100644 --- a/packages/frontend/src/lib/utils/datetime.ts +++ b/packages/frontend/src/lib/utils/datetime.ts @@ -30,7 +30,7 @@ export function formatDateTimeLong(date: Date | string): string { return 'Invalid date'; } - return dateObj.toLocaleString(undefined, { + return dateObj.toLocaleString('en-US', { year: 'numeric', month: 'short', day: 'numeric', diff --git a/packages/frontend/src/lib/utils/html.test.ts b/packages/frontend/src/lib/utils/html.test.ts new file mode 100644 index 00000000..aee82f76 --- /dev/null +++ b/packages/frontend/src/lib/utils/html.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import { escapeHtml } from './html'; + +describe('escapeHtml', () => { + it('escapes the five HTML-significant characters', () => { + expect(escapeHtml('&')).toBe('&'); + expect(escapeHtml('<')).toBe('<'); + expect(escapeHtml('>')).toBe('>'); + expect(escapeHtml('"')).toBe('"'); + expect(escapeHtml("'")).toBe('''); + }); + + it('neutralizes an XSS payload smuggled through a service name', () => { + const payload = ''; + const escaped = escapeHtml(payload); + expect(escaped).not.toContain(' { + it('accepts a normal in-app path', () => { + expect(isSafeInternalPath('/dashboard')).toBe(true); + expect(isSafeInternalPath('/dashboard/projects/1?tab=x')).toBe(true); + expect(isSafeInternalPath('/')).toBe(true); + }); + + it('rejects empty / nullish values', () => { + expect(isSafeInternalPath(null)).toBe(false); + expect(isSafeInternalPath(undefined)).toBe(false); + expect(isSafeInternalPath('')).toBe(false); + }); + + it('rejects absolute URLs to other origins', () => { + expect(isSafeInternalPath('https://evil.com')).toBe(false); + expect(isSafeInternalPath('http://evil.com/path')).toBe(false); + }); + + it('rejects protocol-relative URLs (//evil.com)', () => { + expect(isSafeInternalPath('//evil.com')).toBe(false); + expect(isSafeInternalPath('//evil.com/path')).toBe(false); + }); + + it('rejects backslash-smuggled protocol-relative URLs (/\\evil.com)', () => { + expect(isSafeInternalPath('/\\evil.com')).toBe(false); + }); + + it('rejects values not anchored at the site root', () => { + expect(isSafeInternalPath('dashboard')).toBe(false); + expect(isSafeInternalPath(' /dashboard')).toBe(false); + expect(isSafeInternalPath('javascript:alert(1)')).toBe(false); + }); +}); + +describe('safeRedirect', () => { + it('returns the path when safe', () => { + expect(safeRedirect('/dashboard/projects')).toBe('/dashboard/projects'); + }); + + it('falls back to /dashboard for unsafe or missing values', () => { + expect(safeRedirect(null)).toBe('/dashboard'); + expect(safeRedirect('//evil.com')).toBe('/dashboard'); + expect(safeRedirect('https://evil.com')).toBe('/dashboard'); + }); + + it('honors a custom fallback', () => { + expect(safeRedirect('//evil.com', '/onboarding')).toBe('/onboarding'); + }); +}); diff --git a/packages/frontend/src/lib/utils/redirect.ts b/packages/frontend/src/lib/utils/redirect.ts new file mode 100644 index 00000000..365f322f --- /dev/null +++ b/packages/frontend/src/lib/utils/redirect.ts @@ -0,0 +1,25 @@ +/** + * Guard against open-redirect via a user-supplied `redirect` query parameter. + * + * A value is only safe to navigate to if it is anchored at the site root and + * cannot be coerced into a cross-origin destination. We require it to start with + * a single "/" and reject protocol-relative forms ("//evil.com") including the + * backslash variant ("/\\evil.com") that some browsers normalize to "//". + */ +export function isSafeInternalPath(path: string | null | undefined): path is string { + if (!path) return false; + if (path[0] !== '/') return false; // must be relative to the site root + // Block "//evil.com" and "/\evil.com" (protocol-relative / browser-normalized). + if (path[1] === '/' || path[1] === '\\') return false; + return true; +} + +/** + * Return `path` if it is a safe in-app destination, otherwise `fallback`. + */ +export function safeRedirect( + path: string | null | undefined, + fallback = '/dashboard', +): string { + return isSafeInternalPath(path) ? path : fallback; +} diff --git a/packages/frontend/src/lib/utils/siem.ts b/packages/frontend/src/lib/utils/siem.ts index f6a2127f..ff547711 100644 --- a/packages/frontend/src/lib/utils/siem.ts +++ b/packages/frontend/src/lib/utils/siem.ts @@ -6,6 +6,7 @@ import { formatMitreTactic, formatMitreTechnique, } from '@logtide/shared'; +import { escapeHtml } from './html'; export { getSeverityColor, getSeverityLabel }; @@ -207,15 +208,6 @@ export async function exportIncidentToPdf(data: PdfExportData): Promise { }; } -function escapeHtml(text: string): string { - return text - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} - function formatHistoryAction(entry: IncidentHistoryEntry): string { switch (entry.action) { case 'created': diff --git a/packages/frontend/src/routes/dashboard/admin/+page.svelte b/packages/frontend/src/routes/dashboard/admin/+page.svelte index 2b9eb031..9f574a51 100644 --- a/packages/frontend/src/routes/dashboard/admin/+page.svelte +++ b/packages/frontend/src/routes/dashboard/admin/+page.svelte @@ -223,7 +223,7 @@
- {lastRefreshed.toLocaleTimeString()} + {lastRefreshed.toLocaleTimeString('en-US')}
{/if} diff --git a/packages/frontend/src/routes/dashboard/admin/organizations/+page.svelte b/packages/frontend/src/routes/dashboard/admin/organizations/+page.svelte index cc55087a..8634e861 100644 --- a/packages/frontend/src/routes/dashboard/admin/organizations/+page.svelte +++ b/packages/frontend/src/routes/dashboard/admin/organizations/+page.svelte @@ -109,7 +109,7 @@ function formatDate(dateString: string | Date) { const date = typeof dateString === 'string' ? new Date(dateString) : dateString; - return date.toLocaleString(); + return date.toLocaleString('en-US'); } onMount(() => { diff --git a/packages/frontend/src/routes/dashboard/admin/organizations/[id]/+page.svelte b/packages/frontend/src/routes/dashboard/admin/organizations/[id]/+page.svelte index 2a68e3bc..17b0e394 100644 --- a/packages/frontend/src/routes/dashboard/admin/organizations/[id]/+page.svelte +++ b/packages/frontend/src/routes/dashboard/admin/organizations/[id]/+page.svelte @@ -203,7 +203,7 @@ } function formatDate(dateString: string) { - return new Date(dateString).toLocaleString(); + return new Date(dateString).toLocaleString('en-US'); } function loadAll() { diff --git a/packages/frontend/src/routes/dashboard/admin/projects/+page.svelte b/packages/frontend/src/routes/dashboard/admin/projects/+page.svelte index 16ef0e65..7f9e4e04 100644 --- a/packages/frontend/src/routes/dashboard/admin/projects/+page.svelte +++ b/packages/frontend/src/routes/dashboard/admin/projects/+page.svelte @@ -100,7 +100,7 @@ function formatDate(dateStr: string | Date) { const date = typeof dateStr === 'string' ? new Date(dateStr) : dateStr; - return date.toLocaleDateString(undefined, { + return date.toLocaleDateString('en-US', { year: "numeric", month: "short", day: "numeric", diff --git a/packages/frontend/src/routes/dashboard/admin/projects/[id]/+page.svelte b/packages/frontend/src/routes/dashboard/admin/projects/[id]/+page.svelte index 258b0588..9a48205c 100644 --- a/packages/frontend/src/routes/dashboard/admin/projects/[id]/+page.svelte +++ b/packages/frontend/src/routes/dashboard/admin/projects/[id]/+page.svelte @@ -71,7 +71,7 @@ } function formatDate(date: string) { - return new Date(date).toLocaleString(undefined, { + return new Date(date).toLocaleString('en-US', { month: "short", day: "numeric", hour: "2-digit", diff --git a/packages/frontend/src/routes/dashboard/admin/system-health/+page.svelte b/packages/frontend/src/routes/dashboard/admin/system-health/+page.svelte index 6c4a9d0b..8024150c 100644 --- a/packages/frontend/src/routes/dashboard/admin/system-health/+page.svelte +++ b/packages/frontend/src/routes/dashboard/admin/system-health/+page.svelte @@ -183,7 +183,7 @@
- {lastRefreshed.toLocaleTimeString()} + {lastRefreshed.toLocaleTimeString('en-US')} +
+ {#if log.traceId} + + {/if} + +

{log.message}

@@ -452,7 +469,7 @@ Loading... {:else} - Load More ({(logsTotal - logs.length).toLocaleString()} remaining) + Load More ({(logsTotal - logs.length).toLocaleString('en-US')} remaining) {/if}
diff --git a/packages/frontend/src/routes/dashboard/monitoring/+page.svelte b/packages/frontend/src/routes/dashboard/monitoring/+page.svelte index 25c849dd..c5a2ba27 100644 --- a/packages/frontend/src/routes/dashboard/monitoring/+page.svelte +++ b/packages/frontend/src/routes/dashboard/monitoring/+page.svelte @@ -675,7 +675,7 @@ onclick={() => (monitorStatusFilter = 'all')} >

Total

-

{monitorSummary.total.toLocaleString()}

+

{monitorSummary.total.toLocaleString('en-US')}

@@ -980,7 +980,7 @@ {monitor.status?.lastCheckedAt - ? new Date(monitor.status.lastCheckedAt).toLocaleString() + ? new Date(monitor.status.lastCheckedAt).toLocaleString('en-US') : '-'} @@ -1116,7 +1116,7 @@ -

{new Date(incident.createdAt).toLocaleString()}

+

{new Date(incident.createdAt).toLocaleString('en-US')}

{/each} @@ -1236,7 +1236,7 @@

{m.description}

{/if}

- {new Date(m.scheduledStart).toLocaleString()} - {new Date(m.scheduledEnd).toLocaleString()} + {new Date(m.scheduledStart).toLocaleString('en-US')} - {new Date(m.scheduledEnd).toLocaleString('en-US')}

{#if m.autoUpdateStatus}

Monitor alerts suppressed

diff --git a/packages/frontend/src/routes/dashboard/monitoring/[id]/+page.svelte b/packages/frontend/src/routes/dashboard/monitoring/[id]/+page.svelte index 96d26018..934830e2 100644 --- a/packages/frontend/src/routes/dashboard/monitoring/[id]/+page.svelte +++ b/packages/frontend/src/routes/dashboard/monitoring/[id]/+page.svelte @@ -81,7 +81,7 @@ function formatDate(d: string | null | undefined) { if (!d) return '-'; - return new Date(d).toLocaleString(); + return new Date(d).toLocaleString('en-US'); } function formatResponseTime(ms: number | null | undefined) { @@ -185,7 +185,7 @@

30-day uptime

- {recentUptime[0]?.bucket ? new Date(recentUptime[0].bucket).toLocaleDateString() : ''} – today + {recentUptime[0]?.bucket ? new Date(recentUptime[0].bucket).toLocaleDateString('en-US') : ''} – today
@@ -193,7 +193,7 @@
{/each}
@@ -309,7 +309,7 @@ {/if} - {new Date(result.time).toLocaleString()} + {new Date(result.time).toLocaleString('en-US')} {result.status === 'up' ? 'Up' : 'Down'} diff --git a/packages/frontend/src/routes/dashboard/projects/[id]/overview/+page.svelte b/packages/frontend/src/routes/dashboard/projects/[id]/overview/+page.svelte index 2e04fa6b..9777dd28 100644 --- a/packages/frontend/src/routes/dashboard/projects/[id]/overview/+page.svelte +++ b/packages/frontend/src/routes/dashboard/projects/[id]/overview/+page.svelte @@ -4,9 +4,11 @@ import { browser } from '$app/environment'; import { currentOrganization } from '$lib/stores/organization'; import { dashboardAPI } from '$lib/api/dashboard'; - import type { DashboardStats, TimeseriesDataPoint, TopService, RecentError, TimelineEvent } from '$lib/api/dashboard'; + import type { DashboardStats, TopService, RecentError, ActivityOverviewData } from '$lib/api/dashboard'; + import type { ActivityOverviewConfig } from '@logtide/shared'; import StatsCard from '$lib/components/dashboard/StatsCard.svelte'; - import LogsChart from '$lib/components/dashboard/LogsChart.svelte'; + import ActivityOverviewPanel from '$lib/components/custom-dashboards/panels/ActivityOverviewPanel.svelte'; + import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card'; import TopServicesWidget from '$lib/components/dashboard/TopServicesWidget.svelte'; import RecentErrorsWidget from '$lib/components/dashboard/RecentErrorsWidget.svelte'; import Spinner from '$lib/components/Spinner.svelte'; @@ -19,14 +21,22 @@ const projectId = $derived(page.params.id); let stats = $state(null); - let chartData = $state([]); + let activity = $state(null); let topServices = $state([]); let recentErrors = $state([]); - let timelineEvents = $state([]); let loading = $state(true); let error = $state(''); let lastLoadedKey = $state(null); + const activityConfig = $derived({ + type: 'activity_overview', + title: 'Activity Overview', + source: 'mixed', + projectId: projectId ?? null, + timeRange: '24h', + series: ['logs', 'log_errors', 'spans', 'span_errors', 'detections', 'alerts'], + }); + async function loadDashboard() { if (!$currentOrganization || !projectId) return; @@ -35,29 +45,26 @@ try { const orgId = $currentOrganization.id; - const [statsData, timeseriesData, servicesData, errorsData, eventsData] = await Promise.all([ + const [statsData, activityData, servicesData, errorsData] = await Promise.all([ dashboardAPI.getStats(orgId, projectId), - dashboardAPI.getTimeseries(orgId, projectId), + dashboardAPI.getActivityOverview(orgId, projectId), dashboardAPI.getTopServices(orgId, projectId), dashboardAPI.getRecentErrors(orgId, projectId), - dashboardAPI.getTimelineEvents(orgId, projectId).catch(() => []), ]); stats = statsData; - chartData = timeseriesData; + activity = activityData; topServices = servicesData; recentErrors = errorsData; - timelineEvents = eventsData; lastLoadedKey = `${orgId}-${projectId}`; } catch (e) { console.error('Failed to load project dashboard:', e); error = e instanceof Error ? e.message : 'Failed to load project dashboard'; stats = null; - chartData = []; + activity = null; topServices = []; recentErrors = []; - timelineEvents = []; } finally { loading = false; } @@ -66,10 +73,9 @@ $effect(() => { if (!browser || !$currentOrganization || !projectId) { stats = null; - chartData = []; + activity = null; topServices = []; recentErrors = []; - timelineEvents = []; lastLoadedKey = null; return; } @@ -91,11 +97,23 @@ return throughput.toFixed(1) + '/s'; } + const hasActivity = $derived( + (activity?.series ?? []).some( + (p) => + p.logs > 0 || + p.log_errors > 0 || + p.spans > 0 || + p.span_errors > 0 || + p.detections > 0 || + p.alerts > 0 + ) + ); + let isEmpty = $derived( stats !== null && stats.totalLogsToday.value === 0 && stats.activeServices.value === 0 && - chartData.length === 0 + !hasActivity ); function getLast24HoursParams(): string { @@ -120,21 +138,6 @@ goto(`/dashboard/search?${getLast24HoursParams()}`); } - function handleChartClick(params: { seriesName: string; time: string; value: number }) { - const levelMap: Record = { - 'Errors': 'error', - 'Warnings': 'warn', - 'Info': 'info' - }; - const level = levelMap[params.seriesName]; - const clickedTime = new Date(params.time); - const from = new Date(clickedTime.getTime() - 30 * 60 * 1000); - const to = new Date(clickedTime.getTime() + 30 * 60 * 1000); - const timeParams = `from=${from.toISOString()}&to=${to.toISOString()}`; - const levelParam = level ? `&level=${level}` : ''; - goto(`/dashboard/search?${timeParams}${levelParam}&project=${projectId}`); - } - function handleServiceClick(service: TopService) { goto(`/dashboard/search?service=${encodeURIComponent(service.name)}&${getLast24HoursParams()}`); } @@ -229,13 +232,22 @@ /> - {#if chartData.length > 0} - - {:else} -
- No log data available for the last 24 hours -
- {/if} + + + Activity Overview (Last 24 Hours) + + + {#if hasActivity} +
+ +
+ {:else} +
+ No activity in the last 24 hours +
+ {/if} +
+
diff --git a/packages/frontend/src/routes/dashboard/projects/[id]/sessions/+page.svelte b/packages/frontend/src/routes/dashboard/projects/[id]/sessions/+page.svelte index 2b655d8b..dc057e6e 100644 --- a/packages/frontend/src/routes/dashboard/projects/[id]/sessions/+page.svelte +++ b/packages/frontend/src/routes/dashboard/projects/[id]/sessions/+page.svelte @@ -103,7 +103,7 @@ } function formatTimestamp(iso: string) { - return new Date(iso).toLocaleString(undefined, { + return new Date(iso).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', diff --git a/packages/frontend/src/routes/dashboard/projects/[id]/sessions/[sessionId]/+page.svelte b/packages/frontend/src/routes/dashboard/projects/[id]/sessions/[sessionId]/+page.svelte index 197fbab7..96ae8066 100644 --- a/packages/frontend/src/routes/dashboard/projects/[id]/sessions/[sessionId]/+page.svelte +++ b/packages/frontend/src/routes/dashboard/projects/[id]/sessions/[sessionId]/+page.svelte @@ -215,7 +215,7 @@ } function formatTime(iso: string): string { - return new Date(iso).toLocaleString(undefined, { + return new Date(iso).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', @@ -226,7 +226,7 @@ } function formatTimestamp(ts: number): string { - return new Date(ts).toLocaleTimeString(undefined, { + return new Date(ts).toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', diff --git a/packages/frontend/src/routes/dashboard/search/+page.svelte b/packages/frontend/src/routes/dashboard/search/+page.svelte index b43b795c..bb0f5c99 100644 --- a/packages/frontend/src/routes/dashboard/search/+page.svelte +++ b/packages/frontend/src/routes/dashboard/search/+page.svelte @@ -58,6 +58,13 @@ import Table2 from "@lucide/svelte/icons/table-2"; import WrapText from "@lucide/svelte/icons/wrap-text"; import Clock from "@lucide/svelte/icons/clock"; + import ArrowUpRight from "@lucide/svelte/icons/arrow-up-right"; + import Filter from "@lucide/svelte/icons/filter"; + import Copy from "@lucide/svelte/icons/copy"; + import Check from "@lucide/svelte/icons/check"; + import ListTree from "@lucide/svelte/icons/list-tree"; + import { copyToClipboard } from "$lib/utils/clipboard"; + import BreadcrumbTimeline from "$lib/components/BreadcrumbTimeline.svelte"; interface LogEntry { id?: string; @@ -800,6 +807,53 @@ expandedRows = newSet; } + // Per-row breadcrumbs expand state + let expandedBreadcrumbs = $state(new Set()); + function toggleBreadcrumbs(index: number) { + const newSet = new Set(expandedBreadcrumbs); + if (newSet.has(index)) { + newSet.delete(index); + } else { + newSet.add(index); + } + expandedBreadcrumbs = newSet; + } + + type Breadcrumb = { + type: string; + category?: string; + message: string; + level?: string; + timestamp: number; + data?: Record; + }; + function getBreadcrumbs(log: LogEntry): Breadcrumb[] { + const bc = (log.metadata as Record | undefined)?.breadcrumbs; + return Array.isArray(bc) ? (bc as Breadcrumb[]) : []; + } + + // Resolve a metadata column value, supporting dot-notation paths into nested + // objects (e.g. "sdk.name"). Exact top-level keys win first, so flat keys that + // literally contain dots (e.g. "debug.trace_id") still resolve correctly. + function resolveMetadataPath( + metadata: Record | undefined, + path: string, + ): unknown { + if (!metadata) return undefined; + if (Object.prototype.hasOwnProperty.call(metadata, path)) return metadata[path]; + let current: any = metadata; + for (const part of path.split(".")) { + if (current === null || typeof current !== "object") return undefined; + current = current[part]; + } + return current; + } + + function formatMetadataCell(value: unknown): string { + if (typeof value === "object") return JSON.stringify(value); + return String(value); + } + function openContextDialog(log: LogEntry) { selectedLogForContext = log; contextDialogOpen = true; @@ -892,6 +946,18 @@ return level === 'error' || level === 'critical'; } + // Copy-to-clipboard feedback for metadata blocks, keyed per row + let copiedMetaKey = $state(null); + async function copyMetadata(key: string, metadata: unknown) { + const ok = await copyToClipboard(JSON.stringify(metadata, null, 2)); + if (ok) { + copiedMetaKey = key; + setTimeout(() => { + if (copiedMetaKey === key) copiedMetaKey = null; + }, 2000); + } + } + function getLevelColor(level: LogEntry["level"]): string { switch (level) { case "critical": @@ -1823,9 +1889,15 @@ >{log.message} {#each customColumns as col (col)} - - {#if log.metadata && log.metadata[col] !== undefined && log.metadata[col] !== null} - {String(log.metadata[col])} + {@const cellValue = resolveMetadataPath(log.metadata, col)} + + {#if cellValue !== undefined && cellValue !== null} + {formatMetadataCell(cellValue)} {:else} - {/if} @@ -1864,41 +1936,55 @@
{#if log.traceId} -
+
Trace ID: - {#if log.projectId} - View Trace → + {log.traceId} + + + {:else} + {/if}
{/if} {#if log.sessionId} -
+
Session ID:
{/if} @@ -1924,14 +2010,30 @@
{/if} {#if log.metadata} + {@const metaKey = `meta-${log.id ?? globalIndex}`}
Metadata: -
-
{JSON.stringify(
-                                    log.metadata,
-                                    null,
-                                    2,
-                                  )}
+
+ +
+
{JSON.stringify(
+                                      log.metadata,
+                                      null,
+                                      2,
+                                    )}
+
{/if} @@ -1948,6 +2050,25 @@
{/if} + {#if getBreadcrumbs(log).length > 0} + {@const crumbs = getBreadcrumbs(log)} +
+ + {#if expandedBreadcrumbs.has(globalIndex)} +
+ +
+ {/if} +
+ {/if}
@@ -1961,7 +2082,7 @@
{#if totalLogs > 0} - Showing {((currentPage - 1) * pageSize + 1).toLocaleString()} to {Math.min(currentPage * pageSize, totalLogs).toLocaleString()} of {totalLogs.toLocaleString()} logs + Showing {((currentPage - 1) * pageSize + 1).toLocaleString('en-US')} to {Math.min(currentPage * pageSize, totalLogs).toLocaleString('en-US')} of {totalLogs.toLocaleString('en-US')} logs {:else} Showing {(currentPage - 1) * pageSize + 1} to {(currentPage - 1) * pageSize + logs.length} logs {/if} @@ -2070,7 +2191,7 @@
{:else} - Page {currentPage.toLocaleString()} + Page {currentPage.toLocaleString('en-US')} {/if}