tova 0.4.7 → 0.5.1
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 +40 -7
- package/package.json +1 -1
- package/src/analyzer/analyzer.js +356 -60
- package/src/codegen/cli-codegen.js +386 -0
- package/src/codegen/client-codegen.js +12 -1
- package/src/codegen/codegen.js +77 -21
- package/src/codegen/security-codegen.js +408 -0
- package/src/codegen/server-codegen.js +302 -34
- package/src/parser/ast.js +30 -0
- package/src/parser/cli-ast.js +35 -0
- package/src/parser/cli-parser.js +140 -0
- package/src/parser/parser.js +25 -34
- package/src/parser/security-ast.js +95 -0
- package/src/parser/security-parser.js +299 -0
- package/src/registry/block-registry.js +56 -0
- package/src/registry/plugins/bench-plugin.js +23 -0
- package/src/registry/plugins/cli-plugin.js +24 -0
- package/src/registry/plugins/client-plugin.js +30 -0
- package/src/registry/plugins/data-plugin.js +21 -0
- package/src/registry/plugins/security-plugin.js +27 -0
- package/src/registry/plugins/server-plugin.js +46 -0
- package/src/registry/plugins/shared-plugin.js +17 -0
- package/src/registry/plugins/test-plugin.js +23 -0
- package/src/registry/register-all.js +23 -0
- package/src/stdlib/inline.js +175 -0
- package/src/version.js +1 -1
package/bin/tova.js
CHANGED
|
@@ -36,9 +36,9 @@ const color = {
|
|
|
36
36
|
};
|
|
37
37
|
|
|
38
38
|
const HELP = `
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
╔╦╗╔═╗╦ ╦╔═╗
|
|
40
|
+
║ ║ ║╚╗╔╝╠═╣
|
|
41
|
+
╩ ╚═╝ ╚╝ ╩ ╩ v${VERSION}
|
|
42
42
|
|
|
43
43
|
Created by Enoch Kujem Abassey
|
|
44
44
|
A modern full-stack language that transpiles to JavaScript
|
|
@@ -601,6 +601,18 @@ async function runFile(filePath, options = {}) {
|
|
|
601
601
|
const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
|
|
602
602
|
const stdlib = getRunStdlib();
|
|
603
603
|
|
|
604
|
+
// CLI mode: execute the cli code directly
|
|
605
|
+
if (output.isCli) {
|
|
606
|
+
let code = stdlib + '\n' + output.cli;
|
|
607
|
+
code = code.replace(/^export /gm, '');
|
|
608
|
+
// Override process.argv for cli dispatch
|
|
609
|
+
const scriptArgs = options.scriptArgs || [];
|
|
610
|
+
code = `process.argv = ["node", ${JSON.stringify(resolved)}, ...${JSON.stringify(scriptArgs)}];\n` + code;
|
|
611
|
+
const fn = new AsyncFunction('__tova_args', '__tova_filename', '__tova_dirname', code);
|
|
612
|
+
await fn(scriptArgs, resolved, dirname(resolved));
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
|
|
604
616
|
// Compile .tova dependencies and inline them
|
|
605
617
|
let depCode = '';
|
|
606
618
|
if (hasTovaImports) {
|
|
@@ -766,8 +778,29 @@ async function buildProject(args) {
|
|
|
766
778
|
const outSubDir = dirname(join(outDir, outBaseName));
|
|
767
779
|
if (outSubDir !== outDir) mkdirSync(outSubDir, { recursive: true });
|
|
768
780
|
|
|
781
|
+
// CLI files: write single executable <name>.js with shebang
|
|
782
|
+
if (output.isCli) {
|
|
783
|
+
if (output.cli && output.cli.trim()) {
|
|
784
|
+
const cliPath = join(outDir, `${outBaseName}.js`);
|
|
785
|
+
const shebang = '#!/usr/bin/env node\n';
|
|
786
|
+
writeFileSync(cliPath, shebang + output.cli);
|
|
787
|
+
try { chmodSync(cliPath, 0o755); } catch (e) { /* ignore on Windows */ }
|
|
788
|
+
if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', cliPath)} [cli]${timing}`);
|
|
789
|
+
}
|
|
790
|
+
if (!noCache) {
|
|
791
|
+
const outputPaths = {};
|
|
792
|
+
if (output.cli && output.cli.trim()) outputPaths.cli = join(outDir, `${outBaseName}.js`);
|
|
793
|
+
if (single) {
|
|
794
|
+
const absFile = files[0];
|
|
795
|
+
const sourceContent = readFileSync(absFile, 'utf-8');
|
|
796
|
+
buildCache.set(absFile, sourceContent, outputPaths);
|
|
797
|
+
} else {
|
|
798
|
+
buildCache.setGroup(`dir:${dir}`, files, outputPaths);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
}
|
|
769
802
|
// Module files: write single <name>.js (not .shared.js)
|
|
770
|
-
if (output.isModule) {
|
|
803
|
+
else if (output.isModule) {
|
|
771
804
|
if (output.shared && output.shared.trim()) {
|
|
772
805
|
const modulePath = join(outDir, `${outBaseName}.js`);
|
|
773
806
|
writeFileSync(modulePath, generateSourceMap(output.shared, modulePath));
|
|
@@ -4655,9 +4688,9 @@ async function infoCommand() {
|
|
|
4655
4688
|
const config = resolveConfig(process.cwd());
|
|
4656
4689
|
const hasTOML = config._source === 'tova.toml';
|
|
4657
4690
|
|
|
4658
|
-
console.log(`\n
|
|
4659
|
-
console.log(`
|
|
4660
|
-
console.log(`
|
|
4691
|
+
console.log(`\n ╔╦╗╔═╗╦ ╦╔═╗`);
|
|
4692
|
+
console.log(` ║ ║ ║╚╗╔╝╠═╣`);
|
|
4693
|
+
console.log(` ╩ ╚═╝ ╚╝ ╩ ╩ v${VERSION}\n`);
|
|
4661
4694
|
|
|
4662
4695
|
// Bun version
|
|
4663
4696
|
let bunVersion = 'not found';
|
package/package.json
CHANGED
package/src/analyzer/analyzer.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { Scope, Symbol } from './scope.js';
|
|
2
2
|
import { PIPE_TARGET } from '../parser/ast.js';
|
|
3
3
|
import { BUILTIN_NAMES } from '../stdlib/inline.js';
|
|
4
|
-
import {
|
|
5
|
-
import { installClientAnalyzer } from './client-analyzer.js';
|
|
4
|
+
import { BlockRegistry } from '../registry/register-all.js';
|
|
6
5
|
import {
|
|
7
6
|
Type, PrimitiveType, NilType, AnyType, UnknownType,
|
|
8
7
|
ArrayType, TupleType, FunctionType, RecordType, ADTType,
|
|
@@ -327,17 +326,18 @@ export class Analyzer {
|
|
|
327
326
|
}
|
|
328
327
|
|
|
329
328
|
analyze() {
|
|
330
|
-
// Pre-pass
|
|
331
|
-
const
|
|
332
|
-
|
|
333
|
-
installServerAnalyzer(Analyzer);
|
|
334
|
-
this.serverBlockFunctions = collectServerBlockFunctions(this.ast);
|
|
335
|
-
} else {
|
|
336
|
-
this.serverBlockFunctions = new Map();
|
|
329
|
+
// Pre-pass hooks (e.g., server block function collection for RPC validation)
|
|
330
|
+
for (const plugin of BlockRegistry.all()) {
|
|
331
|
+
if (plugin.analyzer?.prePass) plugin.analyzer.prePass(this);
|
|
337
332
|
}
|
|
338
333
|
|
|
339
334
|
this.visitProgram(this.ast);
|
|
340
335
|
|
|
336
|
+
// Post-visit cross-block validation
|
|
337
|
+
for (const plugin of BlockRegistry.all()) {
|
|
338
|
+
if (plugin.analyzer?.crossBlockValidate) plugin.analyzer.crossBlockValidate(this);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
341
|
// Check for unused variables/imports (#9)
|
|
342
342
|
this._collectAllScopes(this.globalScope);
|
|
343
343
|
this._checkUnusedSymbols();
|
|
@@ -729,43 +729,14 @@ export class Analyzer {
|
|
|
729
729
|
visitNode(node) {
|
|
730
730
|
if (!node) return;
|
|
731
731
|
|
|
732
|
+
// Single registry lookup: returns plugin, NOOP sentinel, or null
|
|
733
|
+
const entry = BlockRegistry.getByAstType(node.type);
|
|
734
|
+
if (entry) {
|
|
735
|
+
if (entry === BlockRegistry.NOOP) return;
|
|
736
|
+
if (entry.analyzer?.visit) return entry.analyzer.visit(this, node);
|
|
737
|
+
}
|
|
738
|
+
|
|
732
739
|
switch (node.type) {
|
|
733
|
-
case 'ServerBlock':
|
|
734
|
-
case 'RouteDeclaration':
|
|
735
|
-
case 'MiddlewareDeclaration':
|
|
736
|
-
case 'HealthCheckDeclaration':
|
|
737
|
-
case 'CorsDeclaration':
|
|
738
|
-
case 'ErrorHandlerDeclaration':
|
|
739
|
-
case 'WebSocketDeclaration':
|
|
740
|
-
case 'StaticDeclaration':
|
|
741
|
-
case 'DiscoverDeclaration':
|
|
742
|
-
case 'AuthDeclaration':
|
|
743
|
-
case 'MaxBodyDeclaration':
|
|
744
|
-
case 'RouteGroupDeclaration':
|
|
745
|
-
case 'RateLimitDeclaration':
|
|
746
|
-
case 'LifecycleHookDeclaration':
|
|
747
|
-
case 'SubscribeDeclaration':
|
|
748
|
-
case 'EnvDeclaration':
|
|
749
|
-
case 'ScheduleDeclaration':
|
|
750
|
-
case 'UploadDeclaration':
|
|
751
|
-
case 'SessionDeclaration':
|
|
752
|
-
case 'DbDeclaration':
|
|
753
|
-
case 'TlsDeclaration':
|
|
754
|
-
case 'CompressionDeclaration':
|
|
755
|
-
case 'BackgroundJobDeclaration':
|
|
756
|
-
case 'CacheDeclaration':
|
|
757
|
-
case 'SseDeclaration':
|
|
758
|
-
case 'ModelDeclaration':
|
|
759
|
-
return this._visitServerNode(node);
|
|
760
|
-
case 'AiConfigDeclaration': return; // handled at block level
|
|
761
|
-
case 'ClientBlock':
|
|
762
|
-
case 'StateDeclaration':
|
|
763
|
-
case 'ComputedDeclaration':
|
|
764
|
-
case 'EffectDeclaration':
|
|
765
|
-
case 'ComponentDeclaration':
|
|
766
|
-
case 'StoreDeclaration':
|
|
767
|
-
return this._visitClientNode(node);
|
|
768
|
-
case 'SharedBlock': return this.visitSharedBlock(node);
|
|
769
740
|
case 'Assignment': return this.visitAssignment(node);
|
|
770
741
|
case 'VarDeclaration': return this.visitVarDeclaration(node);
|
|
771
742
|
case 'LetDestructure': return this.visitLetDestructure(node);
|
|
@@ -787,14 +758,7 @@ export class Analyzer {
|
|
|
787
758
|
case 'ContinueStatement': return this.visitContinueStatement(node);
|
|
788
759
|
case 'GuardStatement': return this.visitGuardStatement(node);
|
|
789
760
|
case 'InterfaceDeclaration': return this.visitInterfaceDeclaration(node);
|
|
790
|
-
case 'DataBlock': return this.visitDataBlock(node);
|
|
791
|
-
case 'SourceDeclaration': return;
|
|
792
|
-
case 'PipelineDeclaration': return;
|
|
793
|
-
case 'ValidateBlock': return;
|
|
794
|
-
case 'RefreshPolicy': return;
|
|
795
761
|
case 'RefinementType': return;
|
|
796
|
-
case 'TestBlock': return this.visitTestBlock(node);
|
|
797
|
-
case 'BenchBlock': return this.visitTestBlock(node);
|
|
798
762
|
case 'ComponentStyleBlock': return; // raw CSS — no analysis needed
|
|
799
763
|
case 'ImplDeclaration': return this.visitImplDeclaration(node);
|
|
800
764
|
case 'TraitDeclaration': return this.visitTraitDeclaration(node);
|
|
@@ -808,19 +772,14 @@ export class Analyzer {
|
|
|
808
772
|
}
|
|
809
773
|
|
|
810
774
|
_visitServerNode(node) {
|
|
811
|
-
if (!Analyzer.prototype._serverAnalyzerInstalled) {
|
|
812
|
-
installServerAnalyzer(Analyzer);
|
|
813
|
-
}
|
|
814
775
|
const methodName = 'visit' + node.type;
|
|
815
776
|
return this[methodName](node);
|
|
816
777
|
}
|
|
817
778
|
|
|
818
779
|
_visitClientNode(node) {
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
const methodName = 'visit' + node.type;
|
|
823
|
-
return this[methodName](node);
|
|
780
|
+
// Ensure client analyzer is installed (may be called from visitExpression for JSX)
|
|
781
|
+
const plugin = BlockRegistry.get('client');
|
|
782
|
+
return plugin.analyzer.visit(this, node);
|
|
824
783
|
}
|
|
825
784
|
|
|
826
785
|
visitExpression(node) {
|
|
@@ -991,6 +950,343 @@ export class Analyzer {
|
|
|
991
950
|
}
|
|
992
951
|
}
|
|
993
952
|
|
|
953
|
+
visitSecurityBlock(node) {
|
|
954
|
+
// Per-block: only check for duplicate role names within this block
|
|
955
|
+
const localRoles = new Set();
|
|
956
|
+
for (const stmt of node.body) {
|
|
957
|
+
if (stmt.type === 'SecurityRoleDeclaration') {
|
|
958
|
+
if (localRoles.has(stmt.name)) {
|
|
959
|
+
this.warnings.push({
|
|
960
|
+
message: `Duplicate role definition: '${stmt.name}'`,
|
|
961
|
+
loc: stmt.loc,
|
|
962
|
+
code: 'W_DUPLICATE_ROLE',
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
localRoles.add(stmt.name);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
visitCliBlock(node) {
|
|
971
|
+
const validKeys = new Set(['name', 'version', 'description']);
|
|
972
|
+
|
|
973
|
+
// Validate config keys
|
|
974
|
+
for (const field of node.config) {
|
|
975
|
+
if (!validKeys.has(field.key)) {
|
|
976
|
+
this.warnings.push({
|
|
977
|
+
message: `Unknown cli config key '${field.key}' — valid keys are: ${[...validKeys].join(', ')}`,
|
|
978
|
+
loc: field.loc,
|
|
979
|
+
code: 'W_UNKNOWN_CLI_CONFIG',
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// Validate commands
|
|
985
|
+
const commandNames = new Set();
|
|
986
|
+
for (const cmd of node.commands) {
|
|
987
|
+
// Duplicate command names
|
|
988
|
+
if (commandNames.has(cmd.name)) {
|
|
989
|
+
this.warnings.push({
|
|
990
|
+
message: `Duplicate cli command '${cmd.name}'`,
|
|
991
|
+
loc: cmd.loc,
|
|
992
|
+
code: 'W_DUPLICATE_CLI_COMMAND',
|
|
993
|
+
});
|
|
994
|
+
}
|
|
995
|
+
commandNames.add(cmd.name);
|
|
996
|
+
|
|
997
|
+
// Check for positional args after flags
|
|
998
|
+
let seenFlag = false;
|
|
999
|
+
for (const param of cmd.params) {
|
|
1000
|
+
if (param.isFlag) {
|
|
1001
|
+
seenFlag = true;
|
|
1002
|
+
} else if (seenFlag) {
|
|
1003
|
+
this.warnings.push({
|
|
1004
|
+
message: `Positional argument '${param.name}' after flag in command '${cmd.name}' — positionals should come before flags`,
|
|
1005
|
+
loc: param.loc,
|
|
1006
|
+
code: 'W_POSITIONAL_AFTER_FLAG',
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// Visit command body with params in scope
|
|
1012
|
+
this.pushScope('function');
|
|
1013
|
+
for (const param of cmd.params) {
|
|
1014
|
+
this.currentScope.define(param.name,
|
|
1015
|
+
new Symbol(param.name, 'parameter', null, false, param.loc));
|
|
1016
|
+
}
|
|
1017
|
+
this.visitNode(cmd.body);
|
|
1018
|
+
this.popScope();
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
_validateCliCrossBlock() {
|
|
1023
|
+
const cliBlocks = this.ast.body.filter(n => n.type === 'CliBlock');
|
|
1024
|
+
if (cliBlocks.length === 0) return;
|
|
1025
|
+
|
|
1026
|
+
// Warn if cli + server coexist
|
|
1027
|
+
const hasServer = this.ast.body.some(n => n.type === 'ServerBlock');
|
|
1028
|
+
if (hasServer) {
|
|
1029
|
+
this.warnings.push({
|
|
1030
|
+
message: 'cli {} and server {} blocks in the same file — cli produces a standalone executable, not a web server',
|
|
1031
|
+
loc: cliBlocks[0].loc,
|
|
1032
|
+
code: 'W_CLI_WITH_SERVER',
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
// Check for missing name across all cli blocks
|
|
1037
|
+
let hasName = false;
|
|
1038
|
+
for (const block of cliBlocks) {
|
|
1039
|
+
for (const field of block.config) {
|
|
1040
|
+
if (field.key === 'name') hasName = true;
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
if (!hasName) {
|
|
1044
|
+
this.warnings.push({
|
|
1045
|
+
message: 'cli block has no name: field — consider adding name: "your-tool"',
|
|
1046
|
+
loc: cliBlocks[0].loc,
|
|
1047
|
+
code: 'W_CLI_MISSING_NAME',
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
_validateSecurityCrossBlock() {
|
|
1053
|
+
// Collect ALL security declarations across ALL security blocks in the AST
|
|
1054
|
+
const allRoles = new Set();
|
|
1055
|
+
const allProtects = [];
|
|
1056
|
+
const allSensitives = [];
|
|
1057
|
+
let hasAuth = false;
|
|
1058
|
+
let hasProtect = false;
|
|
1059
|
+
let authDecl = null;
|
|
1060
|
+
let corsDecl = null;
|
|
1061
|
+
let rateLimitDecl = null;
|
|
1062
|
+
let csrfDecl = null;
|
|
1063
|
+
|
|
1064
|
+
const roleDecls = []; // track all role declarations for cross-block duplicate detection
|
|
1065
|
+
for (const node of this.ast.body) {
|
|
1066
|
+
if (node.type !== 'SecurityBlock') continue;
|
|
1067
|
+
for (const stmt of node.body) {
|
|
1068
|
+
if (stmt.type === 'SecurityRoleDeclaration') {
|
|
1069
|
+
roleDecls.push(stmt);
|
|
1070
|
+
allRoles.add(stmt.name);
|
|
1071
|
+
} else if (stmt.type === 'SecurityProtectDeclaration') {
|
|
1072
|
+
allProtects.push(stmt);
|
|
1073
|
+
hasProtect = true;
|
|
1074
|
+
} else if (stmt.type === 'SecuritySensitiveDeclaration') {
|
|
1075
|
+
allSensitives.push(stmt);
|
|
1076
|
+
} else if (stmt.type === 'SecurityAuthDeclaration') {
|
|
1077
|
+
hasAuth = true;
|
|
1078
|
+
authDecl = stmt;
|
|
1079
|
+
} else if (stmt.type === 'SecurityCorsDeclaration') {
|
|
1080
|
+
corsDecl = stmt;
|
|
1081
|
+
} else if (stmt.type === 'SecurityRateLimitDeclaration') {
|
|
1082
|
+
rateLimitDecl = stmt;
|
|
1083
|
+
} else if (stmt.type === 'SecurityCsrfDeclaration') {
|
|
1084
|
+
csrfDecl = stmt;
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
// W_DUPLICATE_ROLE across blocks: detect roles with same name in different security blocks
|
|
1090
|
+
const seenRoleNames = new Map(); // name -> first declaration
|
|
1091
|
+
for (const decl of roleDecls) {
|
|
1092
|
+
const prev = seenRoleNames.get(decl.name);
|
|
1093
|
+
if (prev && prev.loc !== decl.loc) {
|
|
1094
|
+
// Only warn if this is from a different block (same-block dupes handled by visitSecurityBlock)
|
|
1095
|
+
const prevInSameBlock = this.ast.body.some(b =>
|
|
1096
|
+
b.type === 'SecurityBlock' && b.body.includes(prev) && b.body.includes(decl)
|
|
1097
|
+
);
|
|
1098
|
+
if (!prevInSameBlock) {
|
|
1099
|
+
this.warnings.push({
|
|
1100
|
+
message: `Role '${decl.name}' is defined in multiple security blocks — later definition overwrites earlier one`,
|
|
1101
|
+
loc: decl.loc,
|
|
1102
|
+
code: 'W_DUPLICATE_ROLE',
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
seenRoleNames.set(decl.name, decl);
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// W_UNKNOWN_AUTH_TYPE — validate auth type is a known value
|
|
1110
|
+
if (authDecl && authDecl.authType) {
|
|
1111
|
+
const validAuthTypes = ['jwt', 'api_key'];
|
|
1112
|
+
if (!validAuthTypes.includes(authDecl.authType)) {
|
|
1113
|
+
this.warnings.push({
|
|
1114
|
+
message: `Unknown auth type '${authDecl.authType}' — supported types are: ${validAuthTypes.join(', ')}`,
|
|
1115
|
+
loc: authDecl.loc,
|
|
1116
|
+
code: 'W_UNKNOWN_AUTH_TYPE',
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// Fix 2: W_HARDCODED_SECRET — warn if auth secret is a string literal
|
|
1122
|
+
if (authDecl && authDecl.config.secret) {
|
|
1123
|
+
const secretNode = authDecl.config.secret;
|
|
1124
|
+
if (secretNode.type === 'StringLiteral') {
|
|
1125
|
+
this.warnings.push({
|
|
1126
|
+
message: 'Auth secret is hardcoded as a string literal — use env("SECRET_NAME") instead',
|
|
1127
|
+
loc: authDecl.loc,
|
|
1128
|
+
code: 'W_HARDCODED_SECRET',
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// Fix 7: W_CORS_WILDCARD — warn if cors origins contains "*"
|
|
1134
|
+
if (corsDecl && corsDecl.config.origins) {
|
|
1135
|
+
const originsNode = corsDecl.config.origins;
|
|
1136
|
+
if (originsNode.elements) {
|
|
1137
|
+
for (const elem of originsNode.elements) {
|
|
1138
|
+
if (elem.type === 'StringLiteral' && elem.value === '*') {
|
|
1139
|
+
this.warnings.push({
|
|
1140
|
+
message: 'CORS origins contains wildcard "*" — consider restricting to specific origins',
|
|
1141
|
+
loc: corsDecl.loc,
|
|
1142
|
+
code: 'W_CORS_WILDCARD',
|
|
1143
|
+
});
|
|
1144
|
+
break;
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// W_INVALID_RATE_LIMIT — validate rate limit max/window are positive numbers
|
|
1151
|
+
const _rlNumericValue = (node) => {
|
|
1152
|
+
if (!node) return null;
|
|
1153
|
+
if (node.type === 'NumberLiteral') return node.value;
|
|
1154
|
+
if (node.type === 'UnaryExpression' && node.operator === '-' && node.operand && node.operand.type === 'NumberLiteral') return -node.operand.value;
|
|
1155
|
+
return null;
|
|
1156
|
+
};
|
|
1157
|
+
if (rateLimitDecl && rateLimitDecl.config) {
|
|
1158
|
+
const rlMaxVal = _rlNumericValue(rateLimitDecl.config.max);
|
|
1159
|
+
const rlWindowVal = _rlNumericValue(rateLimitDecl.config.window);
|
|
1160
|
+
if (rlMaxVal !== null && rlMaxVal <= 0) {
|
|
1161
|
+
this.warnings.push({
|
|
1162
|
+
message: `Rate limit max must be a positive number, got ${rlMaxVal}`,
|
|
1163
|
+
loc: rateLimitDecl.loc,
|
|
1164
|
+
code: 'W_INVALID_RATE_LIMIT',
|
|
1165
|
+
});
|
|
1166
|
+
}
|
|
1167
|
+
if (rlWindowVal !== null && rlWindowVal <= 0) {
|
|
1168
|
+
this.warnings.push({
|
|
1169
|
+
message: `Rate limit window must be a positive number, got ${rlWindowVal}`,
|
|
1170
|
+
loc: rateLimitDecl.loc,
|
|
1171
|
+
code: 'W_INVALID_RATE_LIMIT',
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
// W_CSRF_DISABLED — warn when CSRF is explicitly disabled
|
|
1177
|
+
if (csrfDecl && csrfDecl.config && csrfDecl.config.enabled) {
|
|
1178
|
+
const enabledNode = csrfDecl.config.enabled;
|
|
1179
|
+
if ((enabledNode.type === 'BooleanLiteral' && enabledNode.value === false) ||
|
|
1180
|
+
(enabledNode.type === 'Identifier' && enabledNode.name === 'false')) {
|
|
1181
|
+
this.warnings.push({
|
|
1182
|
+
message: 'CSRF protection is explicitly disabled — this increases vulnerability to cross-site request forgery attacks',
|
|
1183
|
+
loc: csrfDecl.loc,
|
|
1184
|
+
code: 'W_CSRF_DISABLED',
|
|
1185
|
+
});
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
// W_LOCALSTORAGE_TOKEN — warn when auth uses default localStorage storage (XSS-vulnerable)
|
|
1190
|
+
if (authDecl && authDecl.authType === 'jwt') {
|
|
1191
|
+
const storageNode = authDecl.config.storage;
|
|
1192
|
+
const isCookieAuth = storageNode && storageNode.type === 'StringLiteral' && storageNode.value === 'cookie';
|
|
1193
|
+
if (!isCookieAuth) {
|
|
1194
|
+
this.warnings.push({
|
|
1195
|
+
message: 'Auth tokens stored in localStorage are vulnerable to XSS attacks — consider using storage: "cookie" for HttpOnly cookie storage',
|
|
1196
|
+
loc: authDecl.loc,
|
|
1197
|
+
code: 'W_LOCALSTORAGE_TOKEN',
|
|
1198
|
+
});
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// Fix 5: W_INMEMORY_RATELIMIT — warn that rate limiting is in-memory only
|
|
1203
|
+
if (rateLimitDecl) {
|
|
1204
|
+
this.warnings.push({
|
|
1205
|
+
message: 'Rate limiting uses in-memory storage — not shared across server instances. Consider an external store for production multi-instance deployments',
|
|
1206
|
+
loc: rateLimitDecl.loc,
|
|
1207
|
+
code: 'W_INMEMORY_RATELIMIT',
|
|
1208
|
+
});
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
// Fix 6: W_NO_AUTH_RATELIMIT — warn when auth exists but no rate limiting protects against brute-force
|
|
1212
|
+
if (hasAuth && !rateLimitDecl) {
|
|
1213
|
+
const hasAuthRateLimit = allProtects.some(p => p.config && p.config.rate_limit);
|
|
1214
|
+
if (!hasAuthRateLimit) {
|
|
1215
|
+
this.warnings.push({
|
|
1216
|
+
message: 'Auth is configured without rate limiting — consider adding rate_limit to protect against brute-force attacks',
|
|
1217
|
+
loc: authDecl.loc,
|
|
1218
|
+
code: 'W_NO_AUTH_RATELIMIT',
|
|
1219
|
+
});
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// Fix 7: W_HASH_NOT_ENFORCED — warn when sensitive declares hash but it's not auto-enforced
|
|
1224
|
+
for (const s of allSensitives) {
|
|
1225
|
+
if (s.config && s.config.hash) {
|
|
1226
|
+
const hashVal = s.config.hash.value || s.config.hash;
|
|
1227
|
+
this.warnings.push({
|
|
1228
|
+
message: `sensitive ${s.typeName}.${s.fieldName} declares hash: "${hashVal}" but hashing is not automatically enforced — use hash_password() in your write handlers`,
|
|
1229
|
+
loc: s.loc,
|
|
1230
|
+
code: 'W_HASH_NOT_ENFORCED',
|
|
1231
|
+
});
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
// No security blocks → nothing to validate (but allow auth/cors checks above)
|
|
1236
|
+
if (!hasProtect && allSensitives.length === 0) return;
|
|
1237
|
+
|
|
1238
|
+
// W_PROTECT_WITHOUT_AUTH: protect rules exist but no auth configured
|
|
1239
|
+
if (hasProtect && !hasAuth) {
|
|
1240
|
+
// Find first protect for location
|
|
1241
|
+
this.warnings.push({
|
|
1242
|
+
message: 'Route protection rules exist but no auth is configured — all protected routes will be inaccessible',
|
|
1243
|
+
loc: allProtects[0].loc,
|
|
1244
|
+
code: 'W_PROTECT_WITHOUT_AUTH',
|
|
1245
|
+
});
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
// W_UNDEFINED_ROLE: protect rules reference roles not defined anywhere
|
|
1249
|
+
for (const protect of allProtects) {
|
|
1250
|
+
const requireExpr = protect.config.require;
|
|
1251
|
+
if (!requireExpr) {
|
|
1252
|
+
// W_PROTECT_NO_REQUIRE: protect rule has no require key
|
|
1253
|
+
this.warnings.push({
|
|
1254
|
+
message: `Protect rule for "${protect.pattern}" has no 'require' — route is unprotected`,
|
|
1255
|
+
loc: protect.loc,
|
|
1256
|
+
code: 'W_PROTECT_NO_REQUIRE',
|
|
1257
|
+
});
|
|
1258
|
+
continue;
|
|
1259
|
+
}
|
|
1260
|
+
if (requireExpr.type === 'Identifier' && requireExpr.name !== 'authenticated') {
|
|
1261
|
+
if (!allRoles.has(requireExpr.name)) {
|
|
1262
|
+
this.warnings.push({
|
|
1263
|
+
message: `Protect rule references undefined role '${requireExpr.name}'`,
|
|
1264
|
+
loc: protect.loc,
|
|
1265
|
+
code: 'W_UNDEFINED_ROLE',
|
|
1266
|
+
});
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
// W_UNDEFINED_ROLE: sensitive visible_to references roles not defined anywhere
|
|
1272
|
+
for (const sensitive of allSensitives) {
|
|
1273
|
+
const visibleTo = sensitive.config.visible_to;
|
|
1274
|
+
if (visibleTo && (visibleTo.type === 'ArrayExpression' || visibleTo.type === 'ArrayLiteral')) {
|
|
1275
|
+
for (const elem of visibleTo.elements) {
|
|
1276
|
+
if (elem.type === 'Identifier' && elem.name !== 'self') {
|
|
1277
|
+
if (!allRoles.has(elem.name)) {
|
|
1278
|
+
this.warnings.push({
|
|
1279
|
+
message: `Sensitive field '${sensitive.typeName}.${sensitive.fieldName}' visible_to references undefined role '${elem.name}'`,
|
|
1280
|
+
loc: sensitive.loc,
|
|
1281
|
+
code: 'W_UNDEFINED_ROLE',
|
|
1282
|
+
});
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
|
|
994
1290
|
// visitClientBlock and other client visitors are in client-analyzer.js (lazy-loaded)
|
|
995
1291
|
|
|
996
1292
|
visitSharedBlock(node) {
|