tova 0.10.3 → 0.11.12
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/bin/tova.js +93 -6604
- package/package.json +1 -1
- package/src/analyzer/analyzer.js +3 -812
- package/src/analyzer/auth-analyzer.js +123 -0
- package/src/analyzer/cli-analyzer.js +91 -0
- package/src/analyzer/concurrency-analyzer.js +126 -0
- package/src/analyzer/edge-analyzer.js +215 -0
- package/src/analyzer/security-analyzer.js +297 -0
- package/src/cli/build.js +1044 -0
- package/src/cli/check.js +154 -0
- package/src/cli/compile.js +815 -0
- package/src/cli/completions.js +192 -0
- package/src/cli/deploy.js +15 -0
- package/src/cli/dev.js +612 -0
- package/src/cli/doctor.js +130 -0
- package/src/cli/format.js +52 -0
- package/src/cli/info.js +72 -0
- package/src/cli/migrate.js +386 -0
- package/src/cli/new.js +1435 -0
- package/src/cli/package.js +452 -0
- package/src/cli/repl.js +437 -0
- package/src/cli/run.js +137 -0
- package/src/cli/test.js +255 -0
- package/src/cli/upgrade.js +261 -0
- package/src/cli/utils.js +190 -0
- package/src/codegen/codegen.js +51 -17
- package/src/registry/plugins/auth-plugin.js +13 -2
- package/src/registry/plugins/cli-plugin.js +13 -2
- package/src/registry/plugins/concurrency-plugin.js +4 -0
- package/src/registry/plugins/edge-plugin.js +13 -2
- package/src/registry/plugins/security-plugin.js +13 -2
- package/src/version.js +1 -1
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// Security-specific analyzer methods for the Tova language
|
|
2
|
+
// Extracted from analyzer.js for lazy loading — only loaded when security { } blocks are encountered.
|
|
3
|
+
|
|
4
|
+
export function installSecurityAnalyzer(AnalyzerClass) {
|
|
5
|
+
if (AnalyzerClass.prototype._securityAnalyzerInstalled) return;
|
|
6
|
+
AnalyzerClass.prototype._securityAnalyzerInstalled = true;
|
|
7
|
+
|
|
8
|
+
AnalyzerClass.prototype.visitSecurityBlock = function(node) {
|
|
9
|
+
// Per-block: only check for duplicate role names within this block
|
|
10
|
+
const localRoles = new Set();
|
|
11
|
+
for (const stmt of node.body) {
|
|
12
|
+
if (stmt.type === 'SecurityRoleDeclaration') {
|
|
13
|
+
if (localRoles.has(stmt.name)) {
|
|
14
|
+
this.warnings.push({
|
|
15
|
+
message: `Duplicate role definition: '${stmt.name}'`,
|
|
16
|
+
loc: stmt.loc,
|
|
17
|
+
code: 'W_DUPLICATE_ROLE',
|
|
18
|
+
category: 'security',
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
localRoles.add(stmt.name);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
AnalyzerClass.prototype._validateSecurityCrossBlock = function() {
|
|
27
|
+
// W_NO_SECURITY_BLOCK: server/edge block without security block
|
|
28
|
+
const hasServerOrEdge = this.ast.body.some(n => n.type === 'ServerBlock' || n.type === 'EdgeBlock');
|
|
29
|
+
const hasSecurityBlock = this.ast.body.some(n => n.type === 'SecurityBlock');
|
|
30
|
+
if (hasServerOrEdge && !hasSecurityBlock) {
|
|
31
|
+
const block = this.ast.body.find(n => n.type === 'ServerBlock' || n.type === 'EdgeBlock');
|
|
32
|
+
this.warnings.push({
|
|
33
|
+
message: 'Server/edge block defined without a security block — consider adding security { ... } for auth, CORS, and CSRF protection',
|
|
34
|
+
loc: block.loc,
|
|
35
|
+
code: 'W_NO_SECURITY_BLOCK',
|
|
36
|
+
category: 'security',
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Collect ALL security declarations across ALL security blocks in the AST
|
|
41
|
+
const allRoles = new Set();
|
|
42
|
+
const allProtects = [];
|
|
43
|
+
const allSensitives = [];
|
|
44
|
+
let hasAuth = false;
|
|
45
|
+
let hasProtect = false;
|
|
46
|
+
let authDecl = null;
|
|
47
|
+
let corsDecl = null;
|
|
48
|
+
let rateLimitDecl = null;
|
|
49
|
+
let csrfDecl = null;
|
|
50
|
+
|
|
51
|
+
// Check for top-level AuthBlock (independent auth block, not security sub-block)
|
|
52
|
+
for (const node of this.ast.body) {
|
|
53
|
+
if (node.type === 'AuthBlock') { hasAuth = true; authDecl = node; break; }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const roleDecls = []; // track all role declarations for cross-block duplicate detection
|
|
57
|
+
for (const node of this.ast.body) {
|
|
58
|
+
if (node.type !== 'SecurityBlock') continue;
|
|
59
|
+
for (const stmt of node.body) {
|
|
60
|
+
if (stmt.type === 'SecurityRoleDeclaration') {
|
|
61
|
+
roleDecls.push(stmt);
|
|
62
|
+
allRoles.add(stmt.name);
|
|
63
|
+
} else if (stmt.type === 'SecurityProtectDeclaration') {
|
|
64
|
+
allProtects.push(stmt);
|
|
65
|
+
hasProtect = true;
|
|
66
|
+
} else if (stmt.type === 'SecuritySensitiveDeclaration') {
|
|
67
|
+
allSensitives.push(stmt);
|
|
68
|
+
} else if (stmt.type === 'SecurityAuthDeclaration') {
|
|
69
|
+
hasAuth = true;
|
|
70
|
+
authDecl = stmt;
|
|
71
|
+
} else if (stmt.type === 'SecurityCorsDeclaration') {
|
|
72
|
+
corsDecl = stmt;
|
|
73
|
+
} else if (stmt.type === 'SecurityRateLimitDeclaration') {
|
|
74
|
+
rateLimitDecl = stmt;
|
|
75
|
+
} else if (stmt.type === 'SecurityCsrfDeclaration') {
|
|
76
|
+
csrfDecl = stmt;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// W_DUPLICATE_ROLE across blocks: detect roles with same name in different security blocks
|
|
82
|
+
const seenRoleNames = new Map(); // name -> first declaration
|
|
83
|
+
for (const decl of roleDecls) {
|
|
84
|
+
const prev = seenRoleNames.get(decl.name);
|
|
85
|
+
if (prev && prev.loc !== decl.loc) {
|
|
86
|
+
// Only warn if this is from a different block (same-block dupes handled by visitSecurityBlock)
|
|
87
|
+
const prevInSameBlock = this.ast.body.some(b =>
|
|
88
|
+
b.type === 'SecurityBlock' && b.body.includes(prev) && b.body.includes(decl)
|
|
89
|
+
);
|
|
90
|
+
if (!prevInSameBlock) {
|
|
91
|
+
this.warnings.push({
|
|
92
|
+
message: `Role '${decl.name}' is defined in multiple security blocks — later definition overwrites earlier one`,
|
|
93
|
+
loc: decl.loc,
|
|
94
|
+
code: 'W_DUPLICATE_ROLE',
|
|
95
|
+
category: 'security',
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
seenRoleNames.set(decl.name, decl);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// W_UNKNOWN_AUTH_TYPE — validate auth type is a known value
|
|
103
|
+
if (authDecl && authDecl.authType) {
|
|
104
|
+
const validAuthTypes = ['jwt', 'api_key'];
|
|
105
|
+
if (!validAuthTypes.includes(authDecl.authType)) {
|
|
106
|
+
this.warnings.push({
|
|
107
|
+
message: `Unknown auth type '${authDecl.authType}' — supported types are: ${validAuthTypes.join(', ')}`,
|
|
108
|
+
loc: authDecl.loc,
|
|
109
|
+
code: 'W_UNKNOWN_AUTH_TYPE',
|
|
110
|
+
category: 'security',
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Fix 2: W_HARDCODED_SECRET — warn if auth secret is a string literal
|
|
116
|
+
// Only check SecurityAuthDeclaration (which has .config), not top-level AuthBlock
|
|
117
|
+
if (authDecl && authDecl.config && authDecl.config.secret) {
|
|
118
|
+
const secretNode = authDecl.config.secret;
|
|
119
|
+
if (secretNode.type === 'StringLiteral') {
|
|
120
|
+
this.warnings.push({
|
|
121
|
+
message: 'Auth secret is hardcoded as a string literal — use env("SECRET_NAME") instead',
|
|
122
|
+
loc: authDecl.loc,
|
|
123
|
+
code: 'W_HARDCODED_SECRET',
|
|
124
|
+
category: 'security',
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Fix 7: W_CORS_WILDCARD — warn if cors origins contains "*"
|
|
130
|
+
if (corsDecl && corsDecl.config.origins) {
|
|
131
|
+
const originsNode = corsDecl.config.origins;
|
|
132
|
+
if (originsNode.elements) {
|
|
133
|
+
for (const elem of originsNode.elements) {
|
|
134
|
+
if (elem.type === 'StringLiteral' && elem.value === '*') {
|
|
135
|
+
this.warnings.push({
|
|
136
|
+
message: 'CORS origins contains wildcard "*" — consider restricting to specific origins',
|
|
137
|
+
loc: corsDecl.loc,
|
|
138
|
+
code: 'W_CORS_WILDCARD',
|
|
139
|
+
category: 'security',
|
|
140
|
+
});
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// W_INVALID_RATE_LIMIT — validate rate limit max/window are positive numbers
|
|
148
|
+
const _rlNumericValue = (node) => {
|
|
149
|
+
if (!node) return null;
|
|
150
|
+
if (node.type === 'NumberLiteral') return node.value;
|
|
151
|
+
if (node.type === 'UnaryExpression' && node.operator === '-' && node.operand && node.operand.type === 'NumberLiteral') return -node.operand.value;
|
|
152
|
+
return null;
|
|
153
|
+
};
|
|
154
|
+
if (rateLimitDecl && rateLimitDecl.config) {
|
|
155
|
+
const rlMaxVal = _rlNumericValue(rateLimitDecl.config.max);
|
|
156
|
+
const rlWindowVal = _rlNumericValue(rateLimitDecl.config.window);
|
|
157
|
+
if (rlMaxVal !== null && rlMaxVal <= 0) {
|
|
158
|
+
this.warnings.push({
|
|
159
|
+
message: `Rate limit max must be a positive number, got ${rlMaxVal}`,
|
|
160
|
+
loc: rateLimitDecl.loc,
|
|
161
|
+
code: 'W_INVALID_RATE_LIMIT',
|
|
162
|
+
category: 'security',
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
if (rlWindowVal !== null && rlWindowVal <= 0) {
|
|
166
|
+
this.warnings.push({
|
|
167
|
+
message: `Rate limit window must be a positive number, got ${rlWindowVal}`,
|
|
168
|
+
loc: rateLimitDecl.loc,
|
|
169
|
+
code: 'W_INVALID_RATE_LIMIT',
|
|
170
|
+
category: 'security',
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// W_CSRF_DISABLED — warn when CSRF is explicitly disabled
|
|
176
|
+
if (csrfDecl && csrfDecl.config && csrfDecl.config.enabled) {
|
|
177
|
+
const enabledNode = csrfDecl.config.enabled;
|
|
178
|
+
if ((enabledNode.type === 'BooleanLiteral' && enabledNode.value === false) ||
|
|
179
|
+
(enabledNode.type === 'Identifier' && enabledNode.name === 'false')) {
|
|
180
|
+
this.warnings.push({
|
|
181
|
+
message: 'CSRF protection is explicitly disabled — this increases vulnerability to cross-site request forgery attacks',
|
|
182
|
+
loc: csrfDecl.loc,
|
|
183
|
+
code: 'W_CSRF_DISABLED',
|
|
184
|
+
category: 'security',
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// W_LOCALSTORAGE_TOKEN — warn when auth uses default localStorage storage (XSS-vulnerable)
|
|
190
|
+
if (authDecl && authDecl.authType === 'jwt' && authDecl.config) {
|
|
191
|
+
const storageNode = authDecl.config.storage;
|
|
192
|
+
const isCookieAuth = storageNode && storageNode.type === 'StringLiteral' && storageNode.value === 'cookie';
|
|
193
|
+
if (!isCookieAuth) {
|
|
194
|
+
this.warnings.push({
|
|
195
|
+
message: 'Auth tokens stored in localStorage are vulnerable to XSS attacks — consider using storage: "cookie" for HttpOnly cookie storage',
|
|
196
|
+
loc: authDecl.loc,
|
|
197
|
+
code: 'W_LOCALSTORAGE_TOKEN',
|
|
198
|
+
category: 'security',
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Fix 5: W_INMEMORY_RATELIMIT — warn that rate limiting is in-memory only
|
|
204
|
+
if (rateLimitDecl) {
|
|
205
|
+
this.warnings.push({
|
|
206
|
+
message: 'Rate limiting uses in-memory storage — not shared across server instances. Consider an external store for production multi-instance deployments',
|
|
207
|
+
loc: rateLimitDecl.loc,
|
|
208
|
+
code: 'W_INMEMORY_RATELIMIT',
|
|
209
|
+
category: 'security',
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Fix 6: W_NO_AUTH_RATELIMIT — warn when auth exists but no rate limiting protects against brute-force
|
|
214
|
+
if (hasAuth && !rateLimitDecl) {
|
|
215
|
+
const hasAuthRateLimit = allProtects.some(p => p.config && p.config.rate_limit);
|
|
216
|
+
if (!hasAuthRateLimit) {
|
|
217
|
+
this.warnings.push({
|
|
218
|
+
message: 'Auth is configured without rate limiting — consider adding rate_limit to protect against brute-force attacks',
|
|
219
|
+
loc: authDecl.loc,
|
|
220
|
+
code: 'W_NO_AUTH_RATELIMIT',
|
|
221
|
+
category: 'security',
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Fix 7: W_HASH_NOT_ENFORCED — warn when sensitive declares hash but it's not auto-enforced
|
|
227
|
+
for (const s of allSensitives) {
|
|
228
|
+
if (s.config && s.config.hash) {
|
|
229
|
+
const hashVal = s.config.hash.value || s.config.hash;
|
|
230
|
+
this.warnings.push({
|
|
231
|
+
message: `sensitive ${s.typeName}.${s.fieldName} declares hash: "${hashVal}" but hashing is not automatically enforced — use hash_password() in your write handlers`,
|
|
232
|
+
loc: s.loc,
|
|
233
|
+
code: 'W_HASH_NOT_ENFORCED',
|
|
234
|
+
category: 'security',
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// No security blocks → nothing to validate (but allow auth/cors checks above)
|
|
240
|
+
if (!hasProtect && allSensitives.length === 0) return;
|
|
241
|
+
|
|
242
|
+
// W_PROTECT_WITHOUT_AUTH: protect rules exist but no auth configured
|
|
243
|
+
if (hasProtect && !hasAuth) {
|
|
244
|
+
// Find first protect for location
|
|
245
|
+
this.warnings.push({
|
|
246
|
+
message: 'Route protection rules exist but no auth is configured — all protected routes will be inaccessible',
|
|
247
|
+
loc: allProtects[0].loc,
|
|
248
|
+
code: 'W_PROTECT_WITHOUT_AUTH',
|
|
249
|
+
category: 'security',
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// W_UNDEFINED_ROLE: protect rules reference roles not defined anywhere
|
|
254
|
+
for (const protect of allProtects) {
|
|
255
|
+
const requireExpr = protect.config.require;
|
|
256
|
+
if (!requireExpr) {
|
|
257
|
+
// W_PROTECT_NO_REQUIRE: protect rule has no require key
|
|
258
|
+
this.warnings.push({
|
|
259
|
+
message: `Protect rule for "${protect.pattern}" has no 'require' — route is unprotected`,
|
|
260
|
+
loc: protect.loc,
|
|
261
|
+
code: 'W_PROTECT_NO_REQUIRE',
|
|
262
|
+
category: 'security',
|
|
263
|
+
});
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
if (requireExpr.type === 'Identifier' && requireExpr.name !== 'authenticated') {
|
|
267
|
+
if (!allRoles.has(requireExpr.name)) {
|
|
268
|
+
this.warnings.push({
|
|
269
|
+
message: `Protect rule references undefined role '${requireExpr.name}'`,
|
|
270
|
+
loc: protect.loc,
|
|
271
|
+
code: 'W_UNDEFINED_ROLE',
|
|
272
|
+
category: 'security',
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// W_UNDEFINED_ROLE: sensitive visible_to references roles not defined anywhere
|
|
279
|
+
for (const sensitive of allSensitives) {
|
|
280
|
+
const visibleTo = sensitive.config.visible_to;
|
|
281
|
+
if (visibleTo && (visibleTo.type === 'ArrayExpression' || visibleTo.type === 'ArrayLiteral')) {
|
|
282
|
+
for (const elem of visibleTo.elements) {
|
|
283
|
+
if (elem.type === 'Identifier' && elem.name !== 'self') {
|
|
284
|
+
if (!allRoles.has(elem.name)) {
|
|
285
|
+
this.warnings.push({
|
|
286
|
+
message: `Sensitive field '${sensitive.typeName}.${sensitive.fieldName}' visible_to references undefined role '${elem.name}'`,
|
|
287
|
+
loc: sensitive.loc,
|
|
288
|
+
code: 'W_UNDEFINED_ROLE',
|
|
289
|
+
category: 'security',
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
}
|