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
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema fragments and validators for the security log.
|
|
3
|
+
*
|
|
4
|
+
* Apps spread `securityTables` into their own defineSchema() so the three
|
|
5
|
+
* tables, their indexes and their validators are owned by this package: an
|
|
6
|
+
* index or field change ships as a package update followed by the app's
|
|
7
|
+
* normal `npx convex deploy`, not as a hand-edited schema in every app.
|
|
8
|
+
*
|
|
9
|
+
* `securityEvents.userId` references a `users` table the app defines; Convex
|
|
10
|
+
* validates that reference when the app's schema is pushed.
|
|
11
|
+
*/
|
|
12
|
+
import { DataModelFromSchemaDefinition, GenericMutationCtx, GenericQueryCtx, DocumentByName } from "convex/server";
|
|
13
|
+
import { Infer } from "convex/values";
|
|
14
|
+
export declare const eventTypeValidator: import("convex/values").VUnion<"csrf_validation_failed" | "rate_limit_exceeded" | "origin_mismatch" | "invalid_api_key" | "fingerprint_change" | "suspicious_activity" | "jwt_validation_failed" | "unauthorized_access" | "input_validation_failed" | "replay_detected" | "not_found_enumeration" | "jwt_algorithm_attack" | "tenant_isolation_attack" | "jwt_replay_attack" | "xss_attempt" | "fingerprint_manipulation" | "http_origin_blocked" | "prompt_injection_attempt" | "ai_response_validation_failed", [import("convex/values").VLiteral<"origin_mismatch", "required">, import("convex/values").VLiteral<"rate_limit_exceeded", "required">, import("convex/values").VLiteral<"invalid_api_key", "required">, import("convex/values").VLiteral<"fingerprint_change", "required">, import("convex/values").VLiteral<"suspicious_activity", "required">, import("convex/values").VLiteral<"jwt_validation_failed", "required">, import("convex/values").VLiteral<"unauthorized_access", "required">, import("convex/values").VLiteral<"input_validation_failed", "required">, import("convex/values").VLiteral<"replay_detected", "required">, import("convex/values").VLiteral<"not_found_enumeration", "required">, import("convex/values").VLiteral<"jwt_algorithm_attack", "required">, import("convex/values").VLiteral<"tenant_isolation_attack", "required">, import("convex/values").VLiteral<"jwt_replay_attack", "required">, import("convex/values").VLiteral<"xss_attempt", "required">, import("convex/values").VLiteral<"fingerprint_manipulation", "required">, import("convex/values").VLiteral<"http_origin_blocked", "required">, import("convex/values").VLiteral<"prompt_injection_attempt", "required">, import("convex/values").VLiteral<"ai_response_validation_failed", "required">, import("convex/values").VLiteral<"csrf_validation_failed", "required">], "required", never>;
|
|
15
|
+
export declare const severityValidator: import("convex/values").VUnion<"critical" | "medium" | "low" | "high", [import("convex/values").VLiteral<"low", "required">, import("convex/values").VLiteral<"medium", "required">, import("convex/values").VLiteral<"high", "required">, import("convex/values").VLiteral<"critical", "required">], "required", never>;
|
|
16
|
+
export declare const metadataValidator: import("convex/values").VObject<{
|
|
17
|
+
origin?: string | undefined;
|
|
18
|
+
endpoint?: string | undefined;
|
|
19
|
+
ipAddress?: string | undefined;
|
|
20
|
+
errorMessage?: string | undefined;
|
|
21
|
+
fingerprint?: string | undefined;
|
|
22
|
+
endUserEmail?: string | undefined;
|
|
23
|
+
endUserName?: string | undefined;
|
|
24
|
+
endUserId?: string | undefined;
|
|
25
|
+
actionType?: string | undefined;
|
|
26
|
+
requestPayload?: string | undefined;
|
|
27
|
+
}, {
|
|
28
|
+
origin: import("convex/values").VString<string | undefined, "optional">;
|
|
29
|
+
ipAddress: import("convex/values").VString<string | undefined, "optional">;
|
|
30
|
+
fingerprint: import("convex/values").VString<string | undefined, "optional">;
|
|
31
|
+
endpoint: import("convex/values").VString<string | undefined, "optional">;
|
|
32
|
+
errorMessage: import("convex/values").VString<string | undefined, "optional">;
|
|
33
|
+
endUserEmail: import("convex/values").VString<string | undefined, "optional">;
|
|
34
|
+
endUserName: import("convex/values").VString<string | undefined, "optional">;
|
|
35
|
+
endUserId: import("convex/values").VString<string | undefined, "optional">;
|
|
36
|
+
actionType: import("convex/values").VString<string | undefined, "optional">;
|
|
37
|
+
requestPayload: import("convex/values").VString<string | undefined, "optional">;
|
|
38
|
+
}, "required", "origin" | "endpoint" | "ipAddress" | "errorMessage" | "fingerprint" | "endUserEmail" | "endUserName" | "endUserId" | "actionType" | "requestPayload">;
|
|
39
|
+
export type SecurityEventType = Infer<typeof eventTypeValidator>;
|
|
40
|
+
export type SecuritySeverity = Infer<typeof severityValidator>;
|
|
41
|
+
export type SecurityMetadata = Infer<typeof metadataValidator>;
|
|
42
|
+
export declare const securityTables: {
|
|
43
|
+
securityEvents: import("convex/server").TableDefinition<import("convex/values").VObject<{
|
|
44
|
+
userId?: import("convex/values").GenericId<"users"> | undefined;
|
|
45
|
+
eventType: "csrf_validation_failed" | "rate_limit_exceeded" | "origin_mismatch" | "invalid_api_key" | "fingerprint_change" | "suspicious_activity" | "jwt_validation_failed" | "unauthorized_access" | "input_validation_failed" | "replay_detected" | "not_found_enumeration" | "jwt_algorithm_attack" | "tenant_isolation_attack" | "jwt_replay_attack" | "xss_attempt" | "fingerprint_manipulation" | "http_origin_blocked" | "prompt_injection_attempt" | "ai_response_validation_failed";
|
|
46
|
+
severity: "critical" | "medium" | "low" | "high";
|
|
47
|
+
metadata: {
|
|
48
|
+
origin?: string | undefined;
|
|
49
|
+
endpoint?: string | undefined;
|
|
50
|
+
ipAddress?: string | undefined;
|
|
51
|
+
errorMessage?: string | undefined;
|
|
52
|
+
fingerprint?: string | undefined;
|
|
53
|
+
endUserEmail?: string | undefined;
|
|
54
|
+
endUserName?: string | undefined;
|
|
55
|
+
endUserId?: string | undefined;
|
|
56
|
+
actionType?: string | undefined;
|
|
57
|
+
requestPayload?: string | undefined;
|
|
58
|
+
};
|
|
59
|
+
timestamp: number;
|
|
60
|
+
isRead: boolean;
|
|
61
|
+
}, {
|
|
62
|
+
userId: import("convex/values").VId<import("convex/values").GenericId<"users"> | undefined, "optional">;
|
|
63
|
+
eventType: import("convex/values").VUnion<"csrf_validation_failed" | "rate_limit_exceeded" | "origin_mismatch" | "invalid_api_key" | "fingerprint_change" | "suspicious_activity" | "jwt_validation_failed" | "unauthorized_access" | "input_validation_failed" | "replay_detected" | "not_found_enumeration" | "jwt_algorithm_attack" | "tenant_isolation_attack" | "jwt_replay_attack" | "xss_attempt" | "fingerprint_manipulation" | "http_origin_blocked" | "prompt_injection_attempt" | "ai_response_validation_failed", [import("convex/values").VLiteral<"origin_mismatch", "required">, import("convex/values").VLiteral<"rate_limit_exceeded", "required">, import("convex/values").VLiteral<"invalid_api_key", "required">, import("convex/values").VLiteral<"fingerprint_change", "required">, import("convex/values").VLiteral<"suspicious_activity", "required">, import("convex/values").VLiteral<"jwt_validation_failed", "required">, import("convex/values").VLiteral<"unauthorized_access", "required">, import("convex/values").VLiteral<"input_validation_failed", "required">, import("convex/values").VLiteral<"replay_detected", "required">, import("convex/values").VLiteral<"not_found_enumeration", "required">, import("convex/values").VLiteral<"jwt_algorithm_attack", "required">, import("convex/values").VLiteral<"tenant_isolation_attack", "required">, import("convex/values").VLiteral<"jwt_replay_attack", "required">, import("convex/values").VLiteral<"xss_attempt", "required">, import("convex/values").VLiteral<"fingerprint_manipulation", "required">, import("convex/values").VLiteral<"http_origin_blocked", "required">, import("convex/values").VLiteral<"prompt_injection_attempt", "required">, import("convex/values").VLiteral<"ai_response_validation_failed", "required">, import("convex/values").VLiteral<"csrf_validation_failed", "required">], "required", never>;
|
|
64
|
+
severity: import("convex/values").VUnion<"critical" | "medium" | "low" | "high", [import("convex/values").VLiteral<"low", "required">, import("convex/values").VLiteral<"medium", "required">, import("convex/values").VLiteral<"high", "required">, import("convex/values").VLiteral<"critical", "required">], "required", never>;
|
|
65
|
+
metadata: import("convex/values").VObject<{
|
|
66
|
+
origin?: string | undefined;
|
|
67
|
+
endpoint?: string | undefined;
|
|
68
|
+
ipAddress?: string | undefined;
|
|
69
|
+
errorMessage?: string | undefined;
|
|
70
|
+
fingerprint?: string | undefined;
|
|
71
|
+
endUserEmail?: string | undefined;
|
|
72
|
+
endUserName?: string | undefined;
|
|
73
|
+
endUserId?: string | undefined;
|
|
74
|
+
actionType?: string | undefined;
|
|
75
|
+
requestPayload?: string | undefined;
|
|
76
|
+
}, {
|
|
77
|
+
origin: import("convex/values").VString<string | undefined, "optional">;
|
|
78
|
+
ipAddress: import("convex/values").VString<string | undefined, "optional">;
|
|
79
|
+
fingerprint: import("convex/values").VString<string | undefined, "optional">;
|
|
80
|
+
endpoint: import("convex/values").VString<string | undefined, "optional">;
|
|
81
|
+
errorMessage: import("convex/values").VString<string | undefined, "optional">;
|
|
82
|
+
endUserEmail: import("convex/values").VString<string | undefined, "optional">;
|
|
83
|
+
endUserName: import("convex/values").VString<string | undefined, "optional">;
|
|
84
|
+
endUserId: import("convex/values").VString<string | undefined, "optional">;
|
|
85
|
+
actionType: import("convex/values").VString<string | undefined, "optional">;
|
|
86
|
+
requestPayload: import("convex/values").VString<string | undefined, "optional">;
|
|
87
|
+
}, "required", "origin" | "endpoint" | "ipAddress" | "errorMessage" | "fingerprint" | "endUserEmail" | "endUserName" | "endUserId" | "actionType" | "requestPayload">;
|
|
88
|
+
timestamp: import("convex/values").VFloat64<number, "required">;
|
|
89
|
+
isRead: import("convex/values").VBoolean<boolean, "required">;
|
|
90
|
+
}, "required", "eventType" | "severity" | "metadata" | "userId" | "timestamp" | "isRead" | "metadata.origin" | "metadata.endpoint" | "metadata.ipAddress" | "metadata.errorMessage" | "metadata.fingerprint" | "metadata.endUserEmail" | "metadata.endUserName" | "metadata.endUserId" | "metadata.actionType" | "metadata.requestPayload">, {
|
|
91
|
+
byUser: ["userId", "timestamp", "_creationTime"];
|
|
92
|
+
bySeverity: ["userId", "severity", "timestamp", "_creationTime"];
|
|
93
|
+
byUnread: ["userId", "isRead", "timestamp", "_creationTime"];
|
|
94
|
+
byTimestamp: ["timestamp", "_creationTime"];
|
|
95
|
+
byTypeTime: ["eventType", "timestamp", "_creationTime"];
|
|
96
|
+
bySeverityTime: ["severity", "timestamp", "_creationTime"];
|
|
97
|
+
byReadTime: ["isRead", "timestamp", "_creationTime"];
|
|
98
|
+
byTypeSeverityTime: ["eventType", "severity", "timestamp", "_creationTime"];
|
|
99
|
+
byTypeReadTime: ["eventType", "isRead", "timestamp", "_creationTime"];
|
|
100
|
+
bySeverityReadTime: ["severity", "isRead", "timestamp", "_creationTime"];
|
|
101
|
+
byTypeSeverityReadTime: ["eventType", "severity", "isRead", "timestamp", "_creationTime"];
|
|
102
|
+
}, {}, {}>;
|
|
103
|
+
securityEventCounts: import("convex/server").TableDefinition<import("convex/values").VObject<{
|
|
104
|
+
hour: number;
|
|
105
|
+
counts: Record<string, number>;
|
|
106
|
+
unread: Record<string, number>;
|
|
107
|
+
}, {
|
|
108
|
+
hour: import("convex/values").VFloat64<number, "required">;
|
|
109
|
+
counts: import("convex/values").VRecord<Record<string, number>, import("convex/values").VString<string, "required">, import("convex/values").VFloat64<number, "required">, "required", string>;
|
|
110
|
+
unread: import("convex/values").VRecord<Record<string, number>, import("convex/values").VString<string, "required">, import("convex/values").VFloat64<number, "required">, "required", string>;
|
|
111
|
+
}, "required", "hour" | "counts" | "unread" | `counts.${string}` | `unread.${string}`>, {
|
|
112
|
+
byHour: ["hour", "_creationTime"];
|
|
113
|
+
}, {}, {}>;
|
|
114
|
+
securityCountsRebuild: import("convex/server").TableDefinition<import("convex/values").VObject<{
|
|
115
|
+
runId: string;
|
|
116
|
+
upTo: number;
|
|
117
|
+
startedAt: number;
|
|
118
|
+
}, {
|
|
119
|
+
runId: import("convex/values").VString<string, "required">;
|
|
120
|
+
upTo: import("convex/values").VFloat64<number, "required">;
|
|
121
|
+
startedAt: import("convex/values").VFloat64<number, "required">;
|
|
122
|
+
}, "required", "runId" | "upTo" | "startedAt">, {}, {}, {}>;
|
|
123
|
+
};
|
|
124
|
+
/** The three tables as a standalone schema; apps spread `securityTables` instead. */
|
|
125
|
+
export declare const securitySchema: import("convex/server").SchemaDefinition<{
|
|
126
|
+
securityEvents: import("convex/server").TableDefinition<import("convex/values").VObject<{
|
|
127
|
+
userId?: import("convex/values").GenericId<"users"> | undefined;
|
|
128
|
+
eventType: "csrf_validation_failed" | "rate_limit_exceeded" | "origin_mismatch" | "invalid_api_key" | "fingerprint_change" | "suspicious_activity" | "jwt_validation_failed" | "unauthorized_access" | "input_validation_failed" | "replay_detected" | "not_found_enumeration" | "jwt_algorithm_attack" | "tenant_isolation_attack" | "jwt_replay_attack" | "xss_attempt" | "fingerprint_manipulation" | "http_origin_blocked" | "prompt_injection_attempt" | "ai_response_validation_failed";
|
|
129
|
+
severity: "critical" | "medium" | "low" | "high";
|
|
130
|
+
metadata: {
|
|
131
|
+
origin?: string | undefined;
|
|
132
|
+
endpoint?: string | undefined;
|
|
133
|
+
ipAddress?: string | undefined;
|
|
134
|
+
errorMessage?: string | undefined;
|
|
135
|
+
fingerprint?: string | undefined;
|
|
136
|
+
endUserEmail?: string | undefined;
|
|
137
|
+
endUserName?: string | undefined;
|
|
138
|
+
endUserId?: string | undefined;
|
|
139
|
+
actionType?: string | undefined;
|
|
140
|
+
requestPayload?: string | undefined;
|
|
141
|
+
};
|
|
142
|
+
timestamp: number;
|
|
143
|
+
isRead: boolean;
|
|
144
|
+
}, {
|
|
145
|
+
userId: import("convex/values").VId<import("convex/values").GenericId<"users"> | undefined, "optional">;
|
|
146
|
+
eventType: import("convex/values").VUnion<"csrf_validation_failed" | "rate_limit_exceeded" | "origin_mismatch" | "invalid_api_key" | "fingerprint_change" | "suspicious_activity" | "jwt_validation_failed" | "unauthorized_access" | "input_validation_failed" | "replay_detected" | "not_found_enumeration" | "jwt_algorithm_attack" | "tenant_isolation_attack" | "jwt_replay_attack" | "xss_attempt" | "fingerprint_manipulation" | "http_origin_blocked" | "prompt_injection_attempt" | "ai_response_validation_failed", [import("convex/values").VLiteral<"origin_mismatch", "required">, import("convex/values").VLiteral<"rate_limit_exceeded", "required">, import("convex/values").VLiteral<"invalid_api_key", "required">, import("convex/values").VLiteral<"fingerprint_change", "required">, import("convex/values").VLiteral<"suspicious_activity", "required">, import("convex/values").VLiteral<"jwt_validation_failed", "required">, import("convex/values").VLiteral<"unauthorized_access", "required">, import("convex/values").VLiteral<"input_validation_failed", "required">, import("convex/values").VLiteral<"replay_detected", "required">, import("convex/values").VLiteral<"not_found_enumeration", "required">, import("convex/values").VLiteral<"jwt_algorithm_attack", "required">, import("convex/values").VLiteral<"tenant_isolation_attack", "required">, import("convex/values").VLiteral<"jwt_replay_attack", "required">, import("convex/values").VLiteral<"xss_attempt", "required">, import("convex/values").VLiteral<"fingerprint_manipulation", "required">, import("convex/values").VLiteral<"http_origin_blocked", "required">, import("convex/values").VLiteral<"prompt_injection_attempt", "required">, import("convex/values").VLiteral<"ai_response_validation_failed", "required">, import("convex/values").VLiteral<"csrf_validation_failed", "required">], "required", never>;
|
|
147
|
+
severity: import("convex/values").VUnion<"critical" | "medium" | "low" | "high", [import("convex/values").VLiteral<"low", "required">, import("convex/values").VLiteral<"medium", "required">, import("convex/values").VLiteral<"high", "required">, import("convex/values").VLiteral<"critical", "required">], "required", never>;
|
|
148
|
+
metadata: import("convex/values").VObject<{
|
|
149
|
+
origin?: string | undefined;
|
|
150
|
+
endpoint?: string | undefined;
|
|
151
|
+
ipAddress?: string | undefined;
|
|
152
|
+
errorMessage?: string | undefined;
|
|
153
|
+
fingerprint?: string | undefined;
|
|
154
|
+
endUserEmail?: string | undefined;
|
|
155
|
+
endUserName?: string | undefined;
|
|
156
|
+
endUserId?: string | undefined;
|
|
157
|
+
actionType?: string | undefined;
|
|
158
|
+
requestPayload?: string | undefined;
|
|
159
|
+
}, {
|
|
160
|
+
origin: import("convex/values").VString<string | undefined, "optional">;
|
|
161
|
+
ipAddress: import("convex/values").VString<string | undefined, "optional">;
|
|
162
|
+
fingerprint: import("convex/values").VString<string | undefined, "optional">;
|
|
163
|
+
endpoint: import("convex/values").VString<string | undefined, "optional">;
|
|
164
|
+
errorMessage: import("convex/values").VString<string | undefined, "optional">;
|
|
165
|
+
endUserEmail: import("convex/values").VString<string | undefined, "optional">;
|
|
166
|
+
endUserName: import("convex/values").VString<string | undefined, "optional">;
|
|
167
|
+
endUserId: import("convex/values").VString<string | undefined, "optional">;
|
|
168
|
+
actionType: import("convex/values").VString<string | undefined, "optional">;
|
|
169
|
+
requestPayload: import("convex/values").VString<string | undefined, "optional">;
|
|
170
|
+
}, "required", "origin" | "endpoint" | "ipAddress" | "errorMessage" | "fingerprint" | "endUserEmail" | "endUserName" | "endUserId" | "actionType" | "requestPayload">;
|
|
171
|
+
timestamp: import("convex/values").VFloat64<number, "required">;
|
|
172
|
+
isRead: import("convex/values").VBoolean<boolean, "required">;
|
|
173
|
+
}, "required", "eventType" | "severity" | "metadata" | "userId" | "timestamp" | "isRead" | "metadata.origin" | "metadata.endpoint" | "metadata.ipAddress" | "metadata.errorMessage" | "metadata.fingerprint" | "metadata.endUserEmail" | "metadata.endUserName" | "metadata.endUserId" | "metadata.actionType" | "metadata.requestPayload">, {
|
|
174
|
+
byUser: ["userId", "timestamp", "_creationTime"];
|
|
175
|
+
bySeverity: ["userId", "severity", "timestamp", "_creationTime"];
|
|
176
|
+
byUnread: ["userId", "isRead", "timestamp", "_creationTime"];
|
|
177
|
+
byTimestamp: ["timestamp", "_creationTime"];
|
|
178
|
+
byTypeTime: ["eventType", "timestamp", "_creationTime"];
|
|
179
|
+
bySeverityTime: ["severity", "timestamp", "_creationTime"];
|
|
180
|
+
byReadTime: ["isRead", "timestamp", "_creationTime"];
|
|
181
|
+
byTypeSeverityTime: ["eventType", "severity", "timestamp", "_creationTime"];
|
|
182
|
+
byTypeReadTime: ["eventType", "isRead", "timestamp", "_creationTime"];
|
|
183
|
+
bySeverityReadTime: ["severity", "isRead", "timestamp", "_creationTime"];
|
|
184
|
+
byTypeSeverityReadTime: ["eventType", "severity", "isRead", "timestamp", "_creationTime"];
|
|
185
|
+
}, {}, {}>;
|
|
186
|
+
securityEventCounts: import("convex/server").TableDefinition<import("convex/values").VObject<{
|
|
187
|
+
hour: number;
|
|
188
|
+
counts: Record<string, number>;
|
|
189
|
+
unread: Record<string, number>;
|
|
190
|
+
}, {
|
|
191
|
+
hour: import("convex/values").VFloat64<number, "required">;
|
|
192
|
+
counts: import("convex/values").VRecord<Record<string, number>, import("convex/values").VString<string, "required">, import("convex/values").VFloat64<number, "required">, "required", string>;
|
|
193
|
+
unread: import("convex/values").VRecord<Record<string, number>, import("convex/values").VString<string, "required">, import("convex/values").VFloat64<number, "required">, "required", string>;
|
|
194
|
+
}, "required", "hour" | "counts" | "unread" | `counts.${string}` | `unread.${string}`>, {
|
|
195
|
+
byHour: ["hour", "_creationTime"];
|
|
196
|
+
}, {}, {}>;
|
|
197
|
+
securityCountsRebuild: import("convex/server").TableDefinition<import("convex/values").VObject<{
|
|
198
|
+
runId: string;
|
|
199
|
+
upTo: number;
|
|
200
|
+
startedAt: number;
|
|
201
|
+
}, {
|
|
202
|
+
runId: import("convex/values").VString<string, "required">;
|
|
203
|
+
upTo: import("convex/values").VFloat64<number, "required">;
|
|
204
|
+
startedAt: import("convex/values").VFloat64<number, "required">;
|
|
205
|
+
}, "required", "runId" | "upTo" | "startedAt">, {}, {}, {}>;
|
|
206
|
+
}, true>;
|
|
207
|
+
export type SecurityDataModel = DataModelFromSchemaDefinition<typeof securitySchema>;
|
|
208
|
+
/**
|
|
209
|
+
* An app's MutationCtx/QueryCtx is assignable to these because its data model
|
|
210
|
+
* is a superset of SecurityDataModel (verified with tsc; no cast needed).
|
|
211
|
+
*/
|
|
212
|
+
export type SecurityMutationCtx = GenericMutationCtx<SecurityDataModel>;
|
|
213
|
+
export type SecurityQueryCtx = GenericQueryCtx<SecurityDataModel>;
|
|
214
|
+
export type SecurityEventDoc = DocumentByName<SecurityDataModel, "securityEvents">;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema fragments and validators for the security log.
|
|
3
|
+
*
|
|
4
|
+
* Apps spread `securityTables` into their own defineSchema() so the three
|
|
5
|
+
* tables, their indexes and their validators are owned by this package: an
|
|
6
|
+
* index or field change ships as a package update followed by the app's
|
|
7
|
+
* normal `npx convex deploy`, not as a hand-edited schema in every app.
|
|
8
|
+
*
|
|
9
|
+
* `securityEvents.userId` references a `users` table the app defines; Convex
|
|
10
|
+
* validates that reference when the app's schema is pushed.
|
|
11
|
+
*/
|
|
12
|
+
import { defineSchema, defineTable, } from "convex/server";
|
|
13
|
+
import { v } from "convex/values";
|
|
14
|
+
export const eventTypeValidator = v.union(v.literal("origin_mismatch"), v.literal("rate_limit_exceeded"), v.literal("invalid_api_key"), v.literal("fingerprint_change"), v.literal("suspicious_activity"), v.literal("jwt_validation_failed"), v.literal("unauthorized_access"), v.literal("input_validation_failed"), v.literal("replay_detected"), v.literal("not_found_enumeration"), v.literal("jwt_algorithm_attack"), v.literal("tenant_isolation_attack"), v.literal("jwt_replay_attack"), v.literal("xss_attempt"), v.literal("fingerprint_manipulation"), v.literal("http_origin_blocked"), v.literal("prompt_injection_attempt"), v.literal("ai_response_validation_failed"), v.literal("csrf_validation_failed"));
|
|
15
|
+
export const severityValidator = v.union(v.literal("low"), v.literal("medium"), v.literal("high"), v.literal("critical"));
|
|
16
|
+
export const metadataValidator = v.object({
|
|
17
|
+
origin: v.optional(v.string()),
|
|
18
|
+
ipAddress: v.optional(v.string()),
|
|
19
|
+
fingerprint: v.optional(v.string()),
|
|
20
|
+
endpoint: v.optional(v.string()),
|
|
21
|
+
errorMessage: v.optional(v.string()),
|
|
22
|
+
endUserEmail: v.optional(v.string()),
|
|
23
|
+
endUserName: v.optional(v.string()),
|
|
24
|
+
endUserId: v.optional(v.string()),
|
|
25
|
+
actionType: v.optional(v.string()),
|
|
26
|
+
requestPayload: v.optional(v.string()),
|
|
27
|
+
});
|
|
28
|
+
export const securityTables = {
|
|
29
|
+
securityEvents: defineTable({
|
|
30
|
+
userId: v.optional(v.id("users")),
|
|
31
|
+
eventType: eventTypeValidator,
|
|
32
|
+
severity: severityValidator,
|
|
33
|
+
metadata: metadataValidator,
|
|
34
|
+
timestamp: v.number(),
|
|
35
|
+
isRead: v.boolean(),
|
|
36
|
+
})
|
|
37
|
+
.index("byUser", ["userId", "timestamp"])
|
|
38
|
+
.index("bySeverity", ["userId", "severity", "timestamp"])
|
|
39
|
+
.index("byUnread", ["userId", "isRead", "timestamp"])
|
|
40
|
+
// Cross-user, time-ordered access. The three indexes above all lead with
|
|
41
|
+
// userId, so none of them can serve the admin dashboard (all users) or the
|
|
42
|
+
// retention cron (all rows older than a cutoff) without a table scan.
|
|
43
|
+
.index("byTimestamp", ["timestamp"])
|
|
44
|
+
// One index per filter combination the admin feed offers (event type,
|
|
45
|
+
// severity, open/closed), each ending in timestamp, so every filtered
|
|
46
|
+
// view is a single index range read one page at a time. Nothing is
|
|
47
|
+
// filtered in JavaScript, so no over-read window is needed.
|
|
48
|
+
.index("byTypeTime", ["eventType", "timestamp"])
|
|
49
|
+
.index("bySeverityTime", ["severity", "timestamp"])
|
|
50
|
+
.index("byReadTime", ["isRead", "timestamp"])
|
|
51
|
+
.index("byTypeSeverityTime", ["eventType", "severity", "timestamp"])
|
|
52
|
+
.index("byTypeReadTime", ["eventType", "isRead", "timestamp"])
|
|
53
|
+
.index("bySeverityReadTime", ["severity", "isRead", "timestamp"])
|
|
54
|
+
.index("byTypeSeverityReadTime", ["eventType", "severity", "isRead", "timestamp"]),
|
|
55
|
+
securityEventCounts: defineTable({
|
|
56
|
+
hour: v.number(),
|
|
57
|
+
counts: v.record(v.string(), v.number()),
|
|
58
|
+
unread: v.record(v.string(), v.number()),
|
|
59
|
+
}).index("byHour", ["hour"]),
|
|
60
|
+
securityCountsRebuild: defineTable({
|
|
61
|
+
runId: v.string(),
|
|
62
|
+
upTo: v.number(),
|
|
63
|
+
startedAt: v.number(),
|
|
64
|
+
}),
|
|
65
|
+
};
|
|
66
|
+
/** The three tables as a standalone schema; apps spread `securityTables` instead. */
|
|
67
|
+
export const securitySchema = defineSchema(securityTables);
|
package/dist/csrf.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generates a CSRF token bound to a session ID using HMAC-SHA256
|
|
3
|
+
* @param sessionId - The session ID to bind the token to
|
|
4
|
+
* @returns The generated CSRF token (HMAC digest)
|
|
5
|
+
*/
|
|
6
|
+
export declare function generateCsrfToken(sessionId: string): Promise<string>;
|
|
7
|
+
/**
|
|
8
|
+
* Validates a CSRF token against the stored token
|
|
9
|
+
* @param sentToken - Token received from the client
|
|
10
|
+
* @param storedToken - Token stored in the cookie
|
|
11
|
+
* @returns True if tokens match, false otherwise
|
|
12
|
+
*/
|
|
13
|
+
export declare function validateCsrfToken(sentToken: string, storedToken: string): boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Generates a UUID for session identification
|
|
16
|
+
* @returns A v4 UUID string
|
|
17
|
+
*/
|
|
18
|
+
export declare function generateUUID(): string;
|
package/dist/csrf.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
2
|
+
import CryptoJS from 'crypto-js';
|
|
3
|
+
/**
|
|
4
|
+
* Reads CSRF_SECRET, or throws.
|
|
5
|
+
*
|
|
6
|
+
* There is deliberately no fallback value. A previous version used
|
|
7
|
+
* `process.env.CSRF_SECRET || 'default-secret'`, which meant a deployment that
|
|
8
|
+
* forgot to set the variable still produced tokens — signed with a constant
|
|
9
|
+
* that is public in this repository, and therefore forgeable by anyone. The
|
|
10
|
+
* failure was invisible: tokens validated, the flow worked, and CSRF protection
|
|
11
|
+
* was simply absent.
|
|
12
|
+
*
|
|
13
|
+
* Failing loudly at first use is the correct trade. `/install` and
|
|
14
|
+
* `/deploy-to-*` both provision CSRF_SECRET, so reaching this throw means the
|
|
15
|
+
* environment is genuinely misconfigured.
|
|
16
|
+
*/
|
|
17
|
+
function csrfSecret() {
|
|
18
|
+
const secret = process.env.CSRF_SECRET;
|
|
19
|
+
if (!secret || secret.length < 32) {
|
|
20
|
+
throw new Error('CSRF_SECRET is not set (or is shorter than 32 chars). Refusing to sign CSRF ' +
|
|
21
|
+
'tokens with a weak or absent key — that would silently disable CSRF protection. ' +
|
|
22
|
+
'Set CSRF_SECRET in Doppler (or .env.local in legacy mode) and redeploy.');
|
|
23
|
+
}
|
|
24
|
+
return secret;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Generates a CSRF token bound to a session ID using HMAC-SHA256
|
|
28
|
+
* @param sessionId - The session ID to bind the token to
|
|
29
|
+
* @returns The generated CSRF token (HMAC digest)
|
|
30
|
+
*/
|
|
31
|
+
export async function generateCsrfToken(sessionId) {
|
|
32
|
+
// Read the secret OUTSIDE the try. A missing secret is a configuration fault
|
|
33
|
+
// that must propagate, not get swallowed and rethrown as a generic failure.
|
|
34
|
+
const secret = csrfSecret();
|
|
35
|
+
try {
|
|
36
|
+
const token = CryptoJS.lib.WordArray.random(32).toString(CryptoJS.enc.Hex);
|
|
37
|
+
const hmac = CryptoJS.HmacSHA256(token + sessionId, secret).toString(CryptoJS.enc.Hex);
|
|
38
|
+
return hmac;
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
console.error('CSRF token generation error:', err.message);
|
|
42
|
+
throw new Error('Failed to generate CSRF token');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Validates a CSRF token against the stored token
|
|
47
|
+
* @param sentToken - Token received from the client
|
|
48
|
+
* @param storedToken - Token stored in the cookie
|
|
49
|
+
* @returns True if tokens match, false otherwise
|
|
50
|
+
*/
|
|
51
|
+
export function validateCsrfToken(sentToken, storedToken) {
|
|
52
|
+
try {
|
|
53
|
+
return sentToken === storedToken;
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
console.error('CSRF validation error:', err.message);
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Generates a UUID for session identification
|
|
62
|
+
* @returns A v4 UUID string
|
|
63
|
+
*/
|
|
64
|
+
export function generateUUID() {
|
|
65
|
+
return uuidv4();
|
|
66
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime secrets fetcher for Vercel + Doppler.
|
|
3
|
+
*
|
|
4
|
+
* On cold start, calls Doppler's API with a service token to fetch all secrets
|
|
5
|
+
* for the deployment's config and writes them into `process.env` so the rest of
|
|
6
|
+
* the app (Clerk SDK, lib/csrf.ts, etc.) reads them transparently.
|
|
7
|
+
*
|
|
8
|
+
* Local dev path: when DOPPLER_TOKEN is unset (developers run via
|
|
9
|
+
* `doppler run -- next dev`), this is a no-op — values are already in
|
|
10
|
+
* process.env via the doppler CLI wrapper.
|
|
11
|
+
*
|
|
12
|
+
* Cache: a single in-flight Promise is memoized in module scope. Vercel
|
|
13
|
+
* Fluid Compute reuses function instances across requests, so the fetch
|
|
14
|
+
* cost (~100–300ms) is paid once per instance lifetime, not per request.
|
|
15
|
+
*
|
|
16
|
+
* Cache invalidation: app/api/revalidate-secrets calls revalidateSecrets()
|
|
17
|
+
* to drop the cache and force a refetch — used by /rotate after a key change.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Ensure secrets are loaded into process.env before the caller proceeds.
|
|
21
|
+
* Safe to call from many places — fetch happens at most once per instance.
|
|
22
|
+
*/
|
|
23
|
+
export declare function ensureSecretsLoaded(): Promise<void>;
|
|
24
|
+
/**
|
|
25
|
+
* Drop the in-memory cache and force a refetch on the next call.
|
|
26
|
+
* Used by /api/revalidate-secrets after rotation.
|
|
27
|
+
*/
|
|
28
|
+
export declare function revalidateSecrets(): void;
|
package/dist/secrets.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime secrets fetcher for Vercel + Doppler.
|
|
3
|
+
*
|
|
4
|
+
* On cold start, calls Doppler's API with a service token to fetch all secrets
|
|
5
|
+
* for the deployment's config and writes them into `process.env` so the rest of
|
|
6
|
+
* the app (Clerk SDK, lib/csrf.ts, etc.) reads them transparently.
|
|
7
|
+
*
|
|
8
|
+
* Local dev path: when DOPPLER_TOKEN is unset (developers run via
|
|
9
|
+
* `doppler run -- next dev`), this is a no-op — values are already in
|
|
10
|
+
* process.env via the doppler CLI wrapper.
|
|
11
|
+
*
|
|
12
|
+
* Cache: a single in-flight Promise is memoized in module scope. Vercel
|
|
13
|
+
* Fluid Compute reuses function instances across requests, so the fetch
|
|
14
|
+
* cost (~100–300ms) is paid once per instance lifetime, not per request.
|
|
15
|
+
*
|
|
16
|
+
* Cache invalidation: app/api/revalidate-secrets calls revalidateSecrets()
|
|
17
|
+
* to drop the cache and force a refetch — used by /rotate after a key change.
|
|
18
|
+
*/
|
|
19
|
+
const DOPPLER_API_URL = 'https://api.doppler.com/v3/configs/config/secrets/download?format=json';
|
|
20
|
+
// Retry tuning: transient network blips between Vercel egress and Doppler's
|
|
21
|
+
// edge last seconds, not milliseconds. Backoff totals ~3.75s across attempts
|
|
22
|
+
// (250/500/1000/2000ms) so a brief blip becomes a slower cold start, not a 500.
|
|
23
|
+
const MAX_ATTEMPTS = 5;
|
|
24
|
+
const BASE_BACKOFF_MS = 250;
|
|
25
|
+
const PER_ATTEMPT_TIMEOUT_MS = 5_000;
|
|
26
|
+
let secretsPromise = null;
|
|
27
|
+
/**
|
|
28
|
+
* Flatten an error for logging. Undici wraps the real network failure
|
|
29
|
+
* (DNS, TLS, connection reset) in `err.cause`, so a bare `err.message`
|
|
30
|
+
* is often just "fetch failed" — walk the cause chain to keep the detail.
|
|
31
|
+
*/
|
|
32
|
+
function describeError(err) {
|
|
33
|
+
const parts = [];
|
|
34
|
+
let current = err;
|
|
35
|
+
while (current instanceof Error) {
|
|
36
|
+
const code = current.code;
|
|
37
|
+
parts.push(`${current.name}: ${current.message}${code ? ` (${code})` : ''}`);
|
|
38
|
+
current = current.cause;
|
|
39
|
+
}
|
|
40
|
+
if (current !== undefined && current !== null)
|
|
41
|
+
parts.push(String(current));
|
|
42
|
+
return parts.join(' <- ') || String(err);
|
|
43
|
+
}
|
|
44
|
+
async function fetchFromDoppler(token) {
|
|
45
|
+
let lastError;
|
|
46
|
+
const started = Date.now();
|
|
47
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
48
|
+
const attemptStarted = Date.now();
|
|
49
|
+
try {
|
|
50
|
+
const res = await fetch(DOPPLER_API_URL, {
|
|
51
|
+
method: 'GET',
|
|
52
|
+
headers: {
|
|
53
|
+
Authorization: `Bearer ${token}`,
|
|
54
|
+
Accept: 'application/json',
|
|
55
|
+
},
|
|
56
|
+
signal: AbortSignal.timeout(PER_ATTEMPT_TIMEOUT_MS),
|
|
57
|
+
});
|
|
58
|
+
if (!res.ok) {
|
|
59
|
+
throw new Error(`Doppler API returned ${res.status} ${res.statusText}`);
|
|
60
|
+
}
|
|
61
|
+
const json = (await res.json());
|
|
62
|
+
if (attempt > 1) {
|
|
63
|
+
console.warn(`[secrets] Doppler fetch recovered on attempt ${attempt}/${MAX_ATTEMPTS} after ${Date.now() - started}ms`);
|
|
64
|
+
}
|
|
65
|
+
return json;
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
lastError = err;
|
|
69
|
+
console.warn(`[secrets] Doppler fetch attempt ${attempt}/${MAX_ATTEMPTS} failed after ${Date.now() - attemptStarted}ms: ${describeError(err)}`);
|
|
70
|
+
if (attempt < MAX_ATTEMPTS) {
|
|
71
|
+
await new Promise((r) => setTimeout(r, BASE_BACKOFF_MS * 2 ** (attempt - 1)));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
console.error(`[secrets] Doppler fetch exhausted ${MAX_ATTEMPTS} attempts over ${Date.now() - started}ms; last error: ${describeError(lastError)}`);
|
|
76
|
+
throw new Error(`Doppler fetch failed after ${MAX_ATTEMPTS} attempts: ${describeError(lastError)}`);
|
|
77
|
+
}
|
|
78
|
+
async function applySecrets() {
|
|
79
|
+
const token = process.env.DOPPLER_TOKEN;
|
|
80
|
+
if (!token) {
|
|
81
|
+
// Local dev or legacy mode — process.env already populated upstream.
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const secrets = await fetchFromDoppler(token);
|
|
85
|
+
for (const [key, value] of Object.entries(secrets)) {
|
|
86
|
+
if (typeof value === 'string') {
|
|
87
|
+
process.env[key] = value;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Ensure secrets are loaded into process.env before the caller proceeds.
|
|
93
|
+
* Safe to call from many places — fetch happens at most once per instance.
|
|
94
|
+
*/
|
|
95
|
+
export function ensureSecretsLoaded() {
|
|
96
|
+
if (!secretsPromise) {
|
|
97
|
+
secretsPromise = applySecrets().catch((err) => {
|
|
98
|
+
// Reset on failure so the next call retries instead of caching the error.
|
|
99
|
+
secretsPromise = null;
|
|
100
|
+
throw err;
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return secretsPromise;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Drop the in-memory cache and force a refetch on the next call.
|
|
107
|
+
* Used by /api/revalidate-secrets after rotation.
|
|
108
|
+
*/
|
|
109
|
+
export function revalidateSecrets() {
|
|
110
|
+
secretsPromise = null;
|
|
111
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
/**
|
|
3
|
+
* Higher-order function that wraps API route handlers with CSRF protection
|
|
4
|
+
* Validates the CSRF token from the request header against the stored cookie
|
|
5
|
+
* Tokens are single-use and cleared after validation
|
|
6
|
+
*
|
|
7
|
+
* @param handler - The API route handler to wrap
|
|
8
|
+
* @returns A wrapped handler with CSRF protection
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```typescript
|
|
12
|
+
* import { withCsrf } from '@/lib/withCsrf';
|
|
13
|
+
*
|
|
14
|
+
* async function loginHandler(request: NextRequest) {
|
|
15
|
+
* // Your login logic here
|
|
16
|
+
* return NextResponse.json({ success: true });
|
|
17
|
+
* }
|
|
18
|
+
*
|
|
19
|
+
* export const POST = withCsrf(loginHandler);
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export declare function withCsrf(handler: (request: NextRequest) => Promise<NextResponse>): (request: NextRequest) => Promise<NextResponse<unknown>>;
|