secure-vibe-runtime 0.0.0-stage → 7.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -2
- package/dist/convex/index.d.ts +4 -0
- package/dist/convex/index.js +4 -0
- package/dist/convex/redact.d.ts +21 -0
- package/dist/convex/redact.js +34 -0
- package/dist/convex/securityCounts.d.ts +38 -0
- package/dist/convex/securityCounts.js +69 -0
- package/dist/convex/securityLogger.d.ts +67 -0
- package/dist/convex/securityLogger.js +214 -0
- package/dist/convex/securityTables.d.ts +214 -0
- package/dist/convex/securityTables.js +67 -0
- package/dist/csrf.d.ts +18 -0
- package/dist/csrf.js +66 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/secrets.d.ts +28 -0
- package/dist/secrets.js +111 -0
- package/dist/withCsrf.d.ts +22 -0
- package/dist/withCsrf.js +104 -0
- package/dist/withRateLimit.d.ts +23 -0
- package/dist/withRateLimit.js +168 -0
- package/package.json +72 -4
package/README.md
CHANGED
|
@@ -1,3 +1,55 @@
|
|
|
1
|
-
#
|
|
1
|
+
# secure-vibe-runtime
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The runtime security pieces of [Secure Vibe Coding OS](https://github.com/harperaa/secure-vibe-coding-OS) as one package, so every app built from the template gets security fixes by updating a dependency instead of copying files.
|
|
4
|
+
|
|
5
|
+
| Entry point | What it provides |
|
|
6
|
+
|---|---|
|
|
7
|
+
| `secure-vibe-runtime/secrets` | `ensureSecretsLoaded()`, `revalidateSecrets()`: Doppler runtime fetch with retry and a per-instance cache |
|
|
8
|
+
| `secure-vibe-runtime/csrf` | `generateCsrfToken()`, `validateCsrfToken()`, `generateUUID()`: HMAC tokens bound to a session; refuses to run without `CSRF_SECRET` |
|
|
9
|
+
| `secure-vibe-runtime/withCsrf` | `withCsrf(handler)`: route wrapper that validates the token and logs failures |
|
|
10
|
+
| `secure-vibe-runtime/withRateLimit` | `withRateLimit(handler)`: per-instance IP rate limit that fails closed on an unresolvable IP and logs refusals |
|
|
11
|
+
| `secure-vibe-runtime/convex` | `securityTables` (schema fragment), the validators, `logSecurity()` and the exact-counter helpers, `redactIdentifiers()` |
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install secure-vibe-runtime
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Peer dependencies: `next` and `convex`. The template already wires everything below; these notes are for an app that adds the package by hand.
|
|
20
|
+
|
|
21
|
+
### Next.js side
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
// instrumentation.ts
|
|
25
|
+
export async function register() {
|
|
26
|
+
if (process.env.NEXT_RUNTIME !== "nodejs") return;
|
|
27
|
+
const { ensureSecretsLoaded } = await import("secure-vibe-runtime/secrets");
|
|
28
|
+
await ensureSecretsLoaded();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// app/api/something/route.ts
|
|
32
|
+
import { withRateLimit } from "secure-vibe-runtime/withRateLimit";
|
|
33
|
+
import { withCsrf } from "secure-vibe-runtime/withCsrf";
|
|
34
|
+
export const POST = withRateLimit(withCsrf(handler));
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The wrappers write refusals to the Convex security log by calling the app's `security:logSecurityViolation` mutation with `SECURITY_LOG_KEY`. The app owns that mutation and the admin dashboard; see the template's `convex/security.ts`.
|
|
38
|
+
|
|
39
|
+
### Convex side
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
// convex/schema.ts
|
|
43
|
+
import { securityTables } from "secure-vibe-runtime/convex";
|
|
44
|
+
export default defineSchema({ users: ..., ...securityTables });
|
|
45
|
+
|
|
46
|
+
// any mutation
|
|
47
|
+
import { logSecurity } from "secure-vibe-runtime/convex";
|
|
48
|
+
await logSecurity(ctx, user?._id, "unauthorized_access", "high", { endpoint: "..." });
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Every write to `securityEvents` must go through `insertSecurityEvent`, `deleteSecurityEvent`, or `setSecurityEventRead` so the hourly counters stay exact. An app's `MutationCtx` is assignable to the package's `SecurityMutationCtx` without a cast.
|
|
52
|
+
|
|
53
|
+
## Versioning
|
|
54
|
+
|
|
55
|
+
The package version tracks the template release it ships with. Breaking changes to the schema fragment or function contracts are called out in the template's release notes, together with the Convex deploy or rebuild step they need.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redacts identifiers from text before it leaves the system.
|
|
3
|
+
*
|
|
4
|
+
* Used by the background-job example (convex/jobs.ts) as the server-side step
|
|
5
|
+
* that runs BEFORE any outside call: an AI model, a document processor, a
|
|
6
|
+
* third-party API. The browser is never trusted to do this. No Convex imports,
|
|
7
|
+
* so it is unit-tested directly (scripts/redact.test.mjs).
|
|
8
|
+
*
|
|
9
|
+
* What it catches, deliberately narrow and documented:
|
|
10
|
+
* - email addresses
|
|
11
|
+
* - US phone numbers (10 digits with common separators, optional +1)
|
|
12
|
+
* - US Social Security numbers (###-##-####)
|
|
13
|
+
* - record identifiers written as "MRN 123456", "patient id: 42", etc.
|
|
14
|
+
* Anything else your data protection rules require (names, dates of birth,
|
|
15
|
+
* free-text clinical detail) needs its own rule here. A redactor is a
|
|
16
|
+
* floor, not a guarantee; keep humans in the loop for anything regulated.
|
|
17
|
+
*/
|
|
18
|
+
export declare function redactIdentifiers(text: string): {
|
|
19
|
+
text: string;
|
|
20
|
+
redactions: number;
|
|
21
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redacts identifiers from text before it leaves the system.
|
|
3
|
+
*
|
|
4
|
+
* Used by the background-job example (convex/jobs.ts) as the server-side step
|
|
5
|
+
* that runs BEFORE any outside call: an AI model, a document processor, a
|
|
6
|
+
* third-party API. The browser is never trusted to do this. No Convex imports,
|
|
7
|
+
* so it is unit-tested directly (scripts/redact.test.mjs).
|
|
8
|
+
*
|
|
9
|
+
* What it catches, deliberately narrow and documented:
|
|
10
|
+
* - email addresses
|
|
11
|
+
* - US phone numbers (10 digits with common separators, optional +1)
|
|
12
|
+
* - US Social Security numbers (###-##-####)
|
|
13
|
+
* - record identifiers written as "MRN 123456", "patient id: 42", etc.
|
|
14
|
+
* Anything else your data protection rules require (names, dates of birth,
|
|
15
|
+
* free-text clinical detail) needs its own rule here. A redactor is a
|
|
16
|
+
* floor, not a guarantee; keep humans in the loop for anything regulated.
|
|
17
|
+
*/
|
|
18
|
+
const RULES = [
|
|
19
|
+
{ label: "EMAIL", pattern: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g },
|
|
20
|
+
{ label: "SSN", pattern: /\b\d{3}-\d{2}-\d{4}\b/g },
|
|
21
|
+
{ label: "PHONE", pattern: /(?:\+?1[\s.-]?)?\(?\b\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}\b/g },
|
|
22
|
+
{ label: "RECORD_ID", pattern: /\b(?:MRN|patient(?:\s+id)?|record(?:\s+id)?)\s*[:#]?\s*\d{3,}\b/gi },
|
|
23
|
+
];
|
|
24
|
+
export function redactIdentifiers(text) {
|
|
25
|
+
let redactions = 0;
|
|
26
|
+
let out = text;
|
|
27
|
+
for (const { label, pattern } of RULES) {
|
|
28
|
+
out = out.replace(pattern, () => {
|
|
29
|
+
redactions += 1;
|
|
30
|
+
return `[${label}]`;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
return { text: out, redactions };
|
|
34
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for the security log's exact counters. No Convex imports, so
|
|
3
|
+
* this file is unit-tested directly with node:test (see securityCounts.test.ts).
|
|
4
|
+
*
|
|
5
|
+
* Why counters exist: the dashboard's summary tiles used to be computed from
|
|
6
|
+
* the newest SCAN_CEILING rows in the window, so "Total events" was a lower
|
|
7
|
+
* bound under load, not a count. Reading the whole table to count it is the
|
|
8
|
+
* failure mode the bound was added to avoid. Instead every insert, delete and
|
|
9
|
+
* read-state change updates one small bucket document per UTC hour, keyed by
|
|
10
|
+
* "eventType:severity". A 90-day window is then at most ~2,200 small reads,
|
|
11
|
+
* independent of how many events were logged.
|
|
12
|
+
*/
|
|
13
|
+
export declare const HOUR_MS: number;
|
|
14
|
+
/** Start of the UTC hour that contains `timestamp`, in ms. */
|
|
15
|
+
export declare function hourStart(timestamp: number): number;
|
|
16
|
+
export declare function countKey(eventType: string, severity: string): string;
|
|
17
|
+
export declare function parseKey(key: string): {
|
|
18
|
+
eventType: string;
|
|
19
|
+
severity: string;
|
|
20
|
+
};
|
|
21
|
+
/** Add `delta` to `record[key]`, dropping the key when it reaches zero. */
|
|
22
|
+
export declare function bump(record: Record<string, number>, key: string, delta: number): Record<string, number>;
|
|
23
|
+
export type CountBucket = {
|
|
24
|
+
hour: number;
|
|
25
|
+
counts: Record<string, number>;
|
|
26
|
+
unread: Record<string, number>;
|
|
27
|
+
};
|
|
28
|
+
export type SecuritySummary = {
|
|
29
|
+
totalEvents: number;
|
|
30
|
+
unreadCount: number;
|
|
31
|
+
criticalCount: number;
|
|
32
|
+
highCount: number;
|
|
33
|
+
mediumCount: number;
|
|
34
|
+
lowCount: number;
|
|
35
|
+
eventTypeBreakdown: Record<string, number>;
|
|
36
|
+
};
|
|
37
|
+
/** Fold hourly buckets into the shape the dashboard tiles consume. */
|
|
38
|
+
export declare function summarize(buckets: CountBucket[], eventType?: string): SecuritySummary;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for the security log's exact counters. No Convex imports, so
|
|
3
|
+
* this file is unit-tested directly with node:test (see securityCounts.test.ts).
|
|
4
|
+
*
|
|
5
|
+
* Why counters exist: the dashboard's summary tiles used to be computed from
|
|
6
|
+
* the newest SCAN_CEILING rows in the window, so "Total events" was a lower
|
|
7
|
+
* bound under load, not a count. Reading the whole table to count it is the
|
|
8
|
+
* failure mode the bound was added to avoid. Instead every insert, delete and
|
|
9
|
+
* read-state change updates one small bucket document per UTC hour, keyed by
|
|
10
|
+
* "eventType:severity". A 90-day window is then at most ~2,200 small reads,
|
|
11
|
+
* independent of how many events were logged.
|
|
12
|
+
*/
|
|
13
|
+
export const HOUR_MS = 60 * 60 * 1000;
|
|
14
|
+
/** Start of the UTC hour that contains `timestamp`, in ms. */
|
|
15
|
+
export function hourStart(timestamp) {
|
|
16
|
+
return Math.floor(timestamp / HOUR_MS) * HOUR_MS;
|
|
17
|
+
}
|
|
18
|
+
export function countKey(eventType, severity) {
|
|
19
|
+
return `${eventType}:${severity}`;
|
|
20
|
+
}
|
|
21
|
+
export function parseKey(key) {
|
|
22
|
+
const i = key.lastIndexOf(":");
|
|
23
|
+
return { eventType: key.slice(0, i), severity: key.slice(i + 1) };
|
|
24
|
+
}
|
|
25
|
+
/** Add `delta` to `record[key]`, dropping the key when it reaches zero. */
|
|
26
|
+
export function bump(record, key, delta) {
|
|
27
|
+
const next = { ...record };
|
|
28
|
+
const value = (next[key] ?? 0) + delta;
|
|
29
|
+
if (value <= 0)
|
|
30
|
+
delete next[key];
|
|
31
|
+
else
|
|
32
|
+
next[key] = value;
|
|
33
|
+
return next;
|
|
34
|
+
}
|
|
35
|
+
/** Fold hourly buckets into the shape the dashboard tiles consume. */
|
|
36
|
+
export function summarize(buckets, eventType) {
|
|
37
|
+
const summary = {
|
|
38
|
+
totalEvents: 0,
|
|
39
|
+
unreadCount: 0,
|
|
40
|
+
criticalCount: 0,
|
|
41
|
+
highCount: 0,
|
|
42
|
+
mediumCount: 0,
|
|
43
|
+
lowCount: 0,
|
|
44
|
+
eventTypeBreakdown: {},
|
|
45
|
+
};
|
|
46
|
+
for (const bucket of buckets) {
|
|
47
|
+
for (const [key, n] of Object.entries(bucket.counts)) {
|
|
48
|
+
const { eventType: type, severity } = parseKey(key);
|
|
49
|
+
if (eventType !== undefined && type !== eventType)
|
|
50
|
+
continue;
|
|
51
|
+
summary.totalEvents += n;
|
|
52
|
+
summary.eventTypeBreakdown[type] = (summary.eventTypeBreakdown[type] ?? 0) + n;
|
|
53
|
+
if (severity === "critical")
|
|
54
|
+
summary.criticalCount += n;
|
|
55
|
+
else if (severity === "high")
|
|
56
|
+
summary.highCount += n;
|
|
57
|
+
else if (severity === "medium")
|
|
58
|
+
summary.mediumCount += n;
|
|
59
|
+
else if (severity === "low")
|
|
60
|
+
summary.lowCount += n;
|
|
61
|
+
}
|
|
62
|
+
for (const [key, n] of Object.entries(bucket.unread)) {
|
|
63
|
+
if (eventType !== undefined && parseKey(key).eventType !== eventType)
|
|
64
|
+
continue;
|
|
65
|
+
summary.unreadCount += n;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return summary;
|
|
69
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security Logger
|
|
3
|
+
*
|
|
4
|
+
* Unified helper for logging ALL security events.
|
|
5
|
+
* Used within mutations that return results instead of throwing.
|
|
6
|
+
* Direct DB insert ensures events persist (no scheduler, no separate transaction).
|
|
7
|
+
*/
|
|
8
|
+
import { GenericId } from "convex/values";
|
|
9
|
+
import { SecurityEventDoc as SecurityEventRow, SecurityEventType, SecurityMetadata, SecurityMutationCtx as MutationCtx, SecuritySeverity } from "./securityTables";
|
|
10
|
+
export type { SecurityEventType, SecurityMetadata, SecuritySeverity } from "./securityTables";
|
|
11
|
+
type Id<T extends string> = GenericId<T>;
|
|
12
|
+
type Doc<T extends "securityEvents"> = T extends "securityEvents" ? SecurityEventRow : never;
|
|
13
|
+
/**
|
|
14
|
+
* All security event types that can be logged
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Severity levels for security events
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Metadata for security events
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* Log a security event directly to the database.
|
|
24
|
+
*
|
|
25
|
+
* This function is designed to be called from mutations that return
|
|
26
|
+
* result objects instead of throwing. Since the mutation won't throw
|
|
27
|
+
* after logging, the event will persist.
|
|
28
|
+
*
|
|
29
|
+
* @param ctx - Mutation context
|
|
30
|
+
* @param userId - User ID who owns this security event (optional for anonymous violations)
|
|
31
|
+
* @param eventType - Type of security event
|
|
32
|
+
* @param severity - Severity level of the event
|
|
33
|
+
* @param metadata - Additional event metadata
|
|
34
|
+
* @returns The ID of the created security event
|
|
35
|
+
*/
|
|
36
|
+
export declare function logSecurity(ctx: MutationCtx, userId: Id<"users"> | undefined, eventType: SecurityEventType, severity: SecuritySeverity, metadata: SecurityMetadata): Promise<Id<"securityEvents">>;
|
|
37
|
+
type SecurityEventDoc = Omit<Doc<"securityEvents">, "_id" | "_creationTime">;
|
|
38
|
+
/** The in-progress rebuild marker, or null. One tiny read. */
|
|
39
|
+
export declare function activeRebuild(ctx: MutationCtx): Promise<{
|
|
40
|
+
_id: GenericId<"securityCountsRebuild">;
|
|
41
|
+
_creationTime: number;
|
|
42
|
+
runId: string;
|
|
43
|
+
upTo: number;
|
|
44
|
+
startedAt: number;
|
|
45
|
+
} | null>;
|
|
46
|
+
/**
|
|
47
|
+
* Adds `delta` to the hourly bucket for (timestamp, eventType, severity).
|
|
48
|
+
* `unreadDelta` moves the unread counter independently (read-state changes
|
|
49
|
+
* pass count 0). Empty buckets are deleted so retention leaves nothing behind.
|
|
50
|
+
*
|
|
51
|
+
* While a rebuild is running, a delete or read-state change on a row the
|
|
52
|
+
* rebuild has not scanned yet would adjust a counter that does not include
|
|
53
|
+
* that row, and the rebuild would then count the row in its new state: the
|
|
54
|
+
* result is off by one forever. So those adjustments (delta <= 0) on rows at
|
|
55
|
+
* or before the rebuild's upTo are refused until the rebuild finishes. Inserts
|
|
56
|
+
* are unaffected: new rows have timestamps after upTo. The rebuild's own
|
|
57
|
+
* +1 adjustments pass because delta > 0. Callers surface the error; the
|
|
58
|
+
* retention cron checks first and skips the run instead.
|
|
59
|
+
*/
|
|
60
|
+
export declare function adjustSecurityCounts(ctx: MutationCtx, event: {
|
|
61
|
+
timestamp: number;
|
|
62
|
+
eventType: string;
|
|
63
|
+
severity: string;
|
|
64
|
+
}, delta: number, unreadDelta: number): Promise<void>;
|
|
65
|
+
export declare function insertSecurityEvent(ctx: MutationCtx, event: SecurityEventDoc): Promise<Id<"securityEvents">>;
|
|
66
|
+
export declare function deleteSecurityEvent(ctx: MutationCtx, event: Doc<"securityEvents">): Promise<void>;
|
|
67
|
+
export declare function setSecurityEventRead(ctx: MutationCtx, event: Doc<"securityEvents">, isRead: boolean): Promise<void>;
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security Logger
|
|
3
|
+
*
|
|
4
|
+
* Unified helper for logging ALL security events.
|
|
5
|
+
* Used within mutations that return results instead of throwing.
|
|
6
|
+
* Direct DB insert ensures events persist (no scheduler, no separate transaction).
|
|
7
|
+
*/
|
|
8
|
+
import { bump, countKey, hourStart } from "./securityCounts";
|
|
9
|
+
/**
|
|
10
|
+
* SECURITY: Sanitize email for logging - preserves domain but masks local part
|
|
11
|
+
* Example: john.doe@example.com -> j***e@example.com
|
|
12
|
+
*/
|
|
13
|
+
function sanitizeEmailForLog(email) {
|
|
14
|
+
if (!email)
|
|
15
|
+
return undefined;
|
|
16
|
+
const atIndex = email.indexOf('@');
|
|
17
|
+
if (atIndex <= 0)
|
|
18
|
+
return '***@invalid';
|
|
19
|
+
const localPart = email.substring(0, atIndex);
|
|
20
|
+
const domain = email.substring(atIndex);
|
|
21
|
+
// For very short local parts, just mask completely
|
|
22
|
+
if (localPart.length <= 2) {
|
|
23
|
+
return `***${domain}`;
|
|
24
|
+
}
|
|
25
|
+
// Keep first and last character, mask the rest
|
|
26
|
+
return `${localPart[0]}***${localPart[localPart.length - 1]}${domain}`;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* SECURITY: Sanitize name for logging - keeps first character and length indication
|
|
30
|
+
* Example: John Doe -> J*** (8 chars)
|
|
31
|
+
*/
|
|
32
|
+
function sanitizeNameForLog(name) {
|
|
33
|
+
if (!name)
|
|
34
|
+
return undefined;
|
|
35
|
+
const trimmed = name.trim();
|
|
36
|
+
if (trimmed.length === 0)
|
|
37
|
+
return undefined;
|
|
38
|
+
// Keep first character and indicate length
|
|
39
|
+
return `${trimmed[0]}*** (${trimmed.length} chars)`;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* SECURITY: Sanitize user ID for logging - partial redaction
|
|
43
|
+
* Example: usr_123456789 -> usr_1234***
|
|
44
|
+
*/
|
|
45
|
+
function sanitizeIdForLog(id) {
|
|
46
|
+
if (!id)
|
|
47
|
+
return undefined;
|
|
48
|
+
if (id.length <= 8) {
|
|
49
|
+
return `${id.substring(0, 2)}***`;
|
|
50
|
+
}
|
|
51
|
+
// Keep first 8 characters, mask the rest
|
|
52
|
+
return `${id.substring(0, 8)}***`;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Maximum stored length for caller-supplied free-text log fields.
|
|
56
|
+
*
|
|
57
|
+
* 2 KB is far more than any genuine errorMessage or requestPayload excerpt needs
|
|
58
|
+
* for triage, and small enough that even sustained abuse grows the table slowly
|
|
59
|
+
* relative to Convex's per-transaction read ceiling.
|
|
60
|
+
*/
|
|
61
|
+
const MAX_LOG_FIELD_CHARS = 2000;
|
|
62
|
+
/**
|
|
63
|
+
* Cap for every OTHER caller-supplied string (origin, endpoint, IP,
|
|
64
|
+
* fingerprint, action type, and the PII fields before masking). None of these
|
|
65
|
+
* has a legitimate value anywhere near this long; an origin or endpoint over
|
|
66
|
+
* 256 chars is itself evidence of abuse.
|
|
67
|
+
*
|
|
68
|
+
* Worst-case row size is what the dashboard's SCAN_CEILING and the retention
|
|
69
|
+
* cron's PRUNE_BATCH in convex/security.ts are derived from: two 2,000-char
|
|
70
|
+
* fields plus eight 256-char fields is under ~6 KB per row, so 1,000 rows
|
|
71
|
+
* stay under Convex's 8 MiB per-transaction read budget. Change these caps
|
|
72
|
+
* and those batch sizes together.
|
|
73
|
+
*/
|
|
74
|
+
const MAX_LOG_SHORT_FIELD_CHARS = 256;
|
|
75
|
+
/**
|
|
76
|
+
* Caps a caller-supplied string so one caller cannot write unbounded rows.
|
|
77
|
+
*
|
|
78
|
+
* This exists because logSecurityViolation records violations before a session
|
|
79
|
+
* exists, gated only by a shared server-side key. If that key leaks, and
|
|
80
|
+
* without a cap, the audit table is an unbounded write primitive for anyone who
|
|
81
|
+
* knows the Convex deployment URL — and the failure mode is self-concealing, because the first
|
|
82
|
+
* thing that breaks is the dashboard used to notice it. A row of uncapped
|
|
83
|
+
* fields can approach Convex's 1 MiB document limit, and a handful of those
|
|
84
|
+
* exhausts the read budget of any query or cleanup that touches them.
|
|
85
|
+
*
|
|
86
|
+
* Truncation is marked so a reader can tell a clipped value from a short one.
|
|
87
|
+
*/
|
|
88
|
+
function capForLog(value, max = MAX_LOG_FIELD_CHARS) {
|
|
89
|
+
if (!value)
|
|
90
|
+
return undefined;
|
|
91
|
+
if (value.length <= max)
|
|
92
|
+
return value;
|
|
93
|
+
return `${value.substring(0, max)}…[truncated ${value.length - max} chars]`;
|
|
94
|
+
}
|
|
95
|
+
const capShort = (value) => capForLog(value, MAX_LOG_SHORT_FIELD_CHARS);
|
|
96
|
+
/**
|
|
97
|
+
* All security event types that can be logged
|
|
98
|
+
*/
|
|
99
|
+
/**
|
|
100
|
+
* Severity levels for security events
|
|
101
|
+
*/
|
|
102
|
+
/**
|
|
103
|
+
* Metadata for security events
|
|
104
|
+
*/
|
|
105
|
+
/**
|
|
106
|
+
* Log a security event directly to the database.
|
|
107
|
+
*
|
|
108
|
+
* This function is designed to be called from mutations that return
|
|
109
|
+
* result objects instead of throwing. Since the mutation won't throw
|
|
110
|
+
* after logging, the event will persist.
|
|
111
|
+
*
|
|
112
|
+
* @param ctx - Mutation context
|
|
113
|
+
* @param userId - User ID who owns this security event (optional for anonymous violations)
|
|
114
|
+
* @param eventType - Type of security event
|
|
115
|
+
* @param severity - Severity level of the event
|
|
116
|
+
* @param metadata - Additional event metadata
|
|
117
|
+
* @returns The ID of the created security event
|
|
118
|
+
*/
|
|
119
|
+
export async function logSecurity(ctx, userId, eventType, severity, metadata) {
|
|
120
|
+
// SECURITY: Sanitize PII before logging
|
|
121
|
+
// Preserves enough info for security analysis while protecting user privacy
|
|
122
|
+
// Every field is size-capped because logSecurityViolation is gated only by a
|
|
123
|
+
// shared key. If it leaks and without caps, one caller can write arbitrarily large rows
|
|
124
|
+
// and grow the securityEvents table until the dashboard's reads (and the
|
|
125
|
+
// retention cron's deletes) exceed Convex's per-transaction limit —
|
|
126
|
+
// silencing the monitoring it is meant to feed. Truncate rather than
|
|
127
|
+
// reject: a clipped event is still evidence. The two free-text fields get
|
|
128
|
+
// the long cap; everything else the short one.
|
|
129
|
+
const sanitizedMetadata = {
|
|
130
|
+
endpoint: capShort(metadata.endpoint),
|
|
131
|
+
origin: capShort(metadata.origin),
|
|
132
|
+
ipAddress: capShort(metadata.ipAddress), // IPs are needed for security analysis
|
|
133
|
+
fingerprint: capShort(metadata.fingerprint), // Fingerprints are anonymized identifiers
|
|
134
|
+
errorMessage: capForLog(metadata.errorMessage),
|
|
135
|
+
// PII fields are capped first (a masked email still keeps its domain), then sanitized
|
|
136
|
+
endUserEmail: sanitizeEmailForLog(capShort(metadata.endUserEmail)),
|
|
137
|
+
endUserName: sanitizeNameForLog(capShort(metadata.endUserName)),
|
|
138
|
+
endUserId: sanitizeIdForLog(capShort(metadata.endUserId)),
|
|
139
|
+
actionType: capShort(metadata.actionType),
|
|
140
|
+
requestPayload: capForLog(metadata.requestPayload),
|
|
141
|
+
};
|
|
142
|
+
// Direct insert - no scheduler, no separate transaction
|
|
143
|
+
const eventId = await insertSecurityEvent(ctx, {
|
|
144
|
+
userId,
|
|
145
|
+
eventType,
|
|
146
|
+
severity,
|
|
147
|
+
metadata: sanitizedMetadata,
|
|
148
|
+
timestamp: Date.now(),
|
|
149
|
+
isRead: false,
|
|
150
|
+
});
|
|
151
|
+
// Also log to console for debugging (using sanitized metadata)
|
|
152
|
+
console.log(`[SECURITY EVENT] ${severity.toUpperCase()}: ${eventType} | ` +
|
|
153
|
+
`user=${userId} endpoint=${sanitizedMetadata.endpoint} ` +
|
|
154
|
+
(sanitizedMetadata.errorMessage ? `error="${sanitizedMetadata.errorMessage}"` : ""));
|
|
155
|
+
return eventId;
|
|
156
|
+
}
|
|
157
|
+
/** The in-progress rebuild marker, or null. One tiny read. */
|
|
158
|
+
export async function activeRebuild(ctx) {
|
|
159
|
+
return await ctx.db.query("securityCountsRebuild").first();
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Adds `delta` to the hourly bucket for (timestamp, eventType, severity).
|
|
163
|
+
* `unreadDelta` moves the unread counter independently (read-state changes
|
|
164
|
+
* pass count 0). Empty buckets are deleted so retention leaves nothing behind.
|
|
165
|
+
*
|
|
166
|
+
* While a rebuild is running, a delete or read-state change on a row the
|
|
167
|
+
* rebuild has not scanned yet would adjust a counter that does not include
|
|
168
|
+
* that row, and the rebuild would then count the row in its new state: the
|
|
169
|
+
* result is off by one forever. So those adjustments (delta <= 0) on rows at
|
|
170
|
+
* or before the rebuild's upTo are refused until the rebuild finishes. Inserts
|
|
171
|
+
* are unaffected: new rows have timestamps after upTo. The rebuild's own
|
|
172
|
+
* +1 adjustments pass because delta > 0. Callers surface the error; the
|
|
173
|
+
* retention cron checks first and skips the run instead.
|
|
174
|
+
*/
|
|
175
|
+
export async function adjustSecurityCounts(ctx, event, delta, unreadDelta) {
|
|
176
|
+
if (delta <= 0) {
|
|
177
|
+
const rebuild = await activeRebuild(ctx);
|
|
178
|
+
if (rebuild && event.timestamp <= rebuild.upTo) {
|
|
179
|
+
throw new Error("Security log counters are being rebuilt; retry this change in a moment.");
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const hour = hourStart(event.timestamp);
|
|
183
|
+
const key = countKey(event.eventType, event.severity);
|
|
184
|
+
const bucket = await ctx.db
|
|
185
|
+
.query("securityEventCounts")
|
|
186
|
+
.withIndex("byHour", (q) => q.eq("hour", hour))
|
|
187
|
+
.unique();
|
|
188
|
+
const counts = bump(bucket?.counts ?? {}, key, delta);
|
|
189
|
+
const unread = bump(bucket?.unread ?? {}, key, unreadDelta);
|
|
190
|
+
if (bucket) {
|
|
191
|
+
if (Object.keys(counts).length === 0)
|
|
192
|
+
await ctx.db.delete(bucket._id);
|
|
193
|
+
else
|
|
194
|
+
await ctx.db.patch(bucket._id, { counts, unread });
|
|
195
|
+
}
|
|
196
|
+
else if (Object.keys(counts).length > 0) {
|
|
197
|
+
await ctx.db.insert("securityEventCounts", { hour, counts, unread });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
export async function insertSecurityEvent(ctx, event) {
|
|
201
|
+
const id = await ctx.db.insert("securityEvents", event);
|
|
202
|
+
await adjustSecurityCounts(ctx, event, 1, event.isRead ? 0 : 1);
|
|
203
|
+
return id;
|
|
204
|
+
}
|
|
205
|
+
export async function deleteSecurityEvent(ctx, event) {
|
|
206
|
+
await ctx.db.delete(event._id);
|
|
207
|
+
await adjustSecurityCounts(ctx, event, -1, event.isRead ? 0 : -1);
|
|
208
|
+
}
|
|
209
|
+
export async function setSecurityEventRead(ctx, event, isRead) {
|
|
210
|
+
if (event.isRead === isRead)
|
|
211
|
+
return; // idempotent: no double-counting on repeat clicks
|
|
212
|
+
await ctx.db.patch(event._id, { isRead });
|
|
213
|
+
await adjustSecurityCounts(ctx, event, 0, isRead ? -1 : 1);
|
|
214
|
+
}
|