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.
@@ -1319,818 +1319,9 @@ export class Analyzer {
1319
1319
  return best;
1320
1320
  }
1321
1321
 
1322
- visitSecurityBlock(node) {
1323
- // Per-block: only check for duplicate role names within this block
1324
- const localRoles = new Set();
1325
- for (const stmt of node.body) {
1326
- if (stmt.type === 'SecurityRoleDeclaration') {
1327
- if (localRoles.has(stmt.name)) {
1328
- this.warnings.push({
1329
- message: `Duplicate role definition: '${stmt.name}'`,
1330
- loc: stmt.loc,
1331
- code: 'W_DUPLICATE_ROLE',
1332
- category: 'security',
1333
- });
1334
- }
1335
- localRoles.add(stmt.name);
1336
- }
1337
- }
1338
- }
1339
-
1340
- visitCliBlock(node) {
1341
- const validKeys = new Set(['name', 'version', 'description']);
1342
-
1343
- // Validate config keys
1344
- for (const field of node.config) {
1345
- if (!validKeys.has(field.key)) {
1346
- this.warnings.push({
1347
- message: `Unknown cli config key '${field.key}' — valid keys are: ${[...validKeys].join(', ')}`,
1348
- loc: field.loc,
1349
- code: 'W_UNKNOWN_CLI_CONFIG',
1350
- });
1351
- }
1352
- }
1353
-
1354
- // Validate commands
1355
- const commandNames = new Set();
1356
- for (const cmd of node.commands) {
1357
- // Duplicate command names
1358
- if (commandNames.has(cmd.name)) {
1359
- this.warnings.push({
1360
- message: `Duplicate cli command '${cmd.name}'`,
1361
- loc: cmd.loc,
1362
- code: 'W_DUPLICATE_CLI_COMMAND',
1363
- });
1364
- }
1365
- commandNames.add(cmd.name);
1366
-
1367
- // Check for positional args after flags
1368
- let seenFlag = false;
1369
- for (const param of cmd.params) {
1370
- if (param.isFlag) {
1371
- seenFlag = true;
1372
- } else if (seenFlag) {
1373
- this.warnings.push({
1374
- message: `Positional argument '${param.name}' after flag in command '${cmd.name}' — positionals should come before flags`,
1375
- loc: param.loc,
1376
- code: 'W_POSITIONAL_AFTER_FLAG',
1377
- });
1378
- }
1379
- }
1380
-
1381
- // Visit command body with params in scope
1382
- this.pushScope('function');
1383
- for (const param of cmd.params) {
1384
- this.currentScope.define(param.name,
1385
- new Symbol(param.name, 'parameter', null, false, param.loc));
1386
- }
1387
- this.visitNode(cmd.body);
1388
- this.popScope();
1389
- }
1390
- }
1391
-
1392
- visitConcurrentBlock(node) {
1393
- // Validate mode
1394
- const validModes = new Set(['all', 'cancel_on_error', 'first', 'timeout']);
1395
- if (!validModes.has(node.mode)) {
1396
- this.warn(`Unknown concurrent block mode '${node.mode}'`, node.loc, null, {
1397
- code: 'W_UNKNOWN_CONCURRENT_MODE',
1398
- });
1399
- }
1400
-
1401
- // Validate timeout
1402
- if (node.mode === 'timeout' && !node.timeout) {
1403
- this.warn("concurrent timeout mode requires a timeout value", node.loc, null, {
1404
- code: 'W_MISSING_TIMEOUT',
1405
- });
1406
- }
1407
-
1408
- // Warn on empty block
1409
- if (node.body.length === 0) {
1410
- this.warn("Empty concurrent block", node.loc, null, {
1411
- code: 'W_EMPTY_CONCURRENT',
1412
- });
1413
- }
1414
-
1415
- // Track concurrent depth for spawn validation
1416
- this._concurrentDepth = (this._concurrentDepth || 0) + 1;
1417
-
1418
- // Visit body statements (concurrent block does NOT create a new scope —
1419
- // variables assigned inside should be visible after the block)
1420
- for (const stmt of node.body) {
1421
- this.visitNode(stmt);
1422
- }
1423
-
1424
- // Check spawned functions for WASM compatibility — warn if mixed WASM/non-WASM
1425
- let hasWasm = false;
1426
- let hasNonWasm = false;
1427
- for (const stmt of node.body) {
1428
- const spawn = (stmt.type === 'Assignment' && stmt.values && stmt.values[0] && stmt.values[0].type === 'SpawnExpression')
1429
- ? stmt.values[0]
1430
- : (stmt.type === 'ExpressionStatement' && stmt.expression && stmt.expression.type === 'SpawnExpression')
1431
- ? stmt.expression
1432
- : null;
1433
- if (!spawn) continue;
1434
- const calleeName = spawn.callee && spawn.callee.type === 'Identifier' ? spawn.callee.name : null;
1435
- if (calleeName) {
1436
- const sym = this.currentScope.lookup(calleeName);
1437
- if (sym && sym.isWasm) {
1438
- hasWasm = true;
1439
- } else {
1440
- hasNonWasm = true;
1441
- }
1442
- } else {
1443
- // Lambda or complex expression — always non-WASM
1444
- hasNonWasm = true;
1445
- }
1446
- }
1447
- if (hasWasm && hasNonWasm) {
1448
- this.warn(
1449
- "concurrent block mixes @wasm and non-WASM tasks — non-WASM tasks will fall back to async JS execution",
1450
- node.loc, null, { code: 'W_SPAWN_WASM_FALLBACK' }
1451
- );
1452
- }
1453
-
1454
- this._concurrentDepth--;
1455
- }
1456
-
1457
- visitSelectStatement(node) {
1458
- if (node.cases.length === 0) {
1459
- this.warn("Empty select block", node.loc, null, {
1460
- code: 'W_EMPTY_SELECT',
1461
- });
1462
- }
1463
-
1464
- let defaultCount = 0;
1465
- let timeoutCount = 0;
1466
- for (const c of node.cases) {
1467
- if (c.kind === 'default') defaultCount++;
1468
- if (c.kind === 'timeout') timeoutCount++;
1469
- }
1470
-
1471
- if (defaultCount > 1) {
1472
- this.warn("select block has multiple default cases", node.loc, null, {
1473
- code: 'W_DUPLICATE_SELECT_DEFAULT',
1474
- });
1475
- }
1476
- if (timeoutCount > 1) {
1477
- this.warn("select block has multiple timeout cases", node.loc, null, {
1478
- code: 'W_DUPLICATE_SELECT_TIMEOUT',
1479
- });
1480
- }
1481
- if (defaultCount > 0 && timeoutCount > 0) {
1482
- this.warn("select block has both default and timeout — default makes timeout unreachable", node.loc, null, {
1483
- code: 'W_SELECT_DEFAULT_TIMEOUT',
1484
- });
1485
- }
1486
-
1487
- // Visit each case's expressions and body
1488
- for (const c of node.cases) {
1489
- if (c.channel) this.visitNode(c.channel);
1490
- if (c.value) this.visitNode(c.value);
1491
-
1492
- if (c.kind === 'receive' && c.binding) {
1493
- // Create scope for the binding variable
1494
- this.pushScope('select-case');
1495
- this.currentScope.define(c.binding,
1496
- new Symbol(c.binding, 'variable', null, false, c.loc));
1497
- for (const stmt of c.body) {
1498
- this.visitNode(stmt);
1499
- }
1500
- this.popScope();
1501
- } else {
1502
- for (const stmt of c.body) {
1503
- this.visitNode(stmt);
1504
- }
1505
- }
1506
- }
1507
- }
1508
-
1509
- _validateCliCrossBlock() {
1510
- const cliBlocks = this.ast.body.filter(n => n.type === 'CliBlock');
1511
- if (cliBlocks.length === 0) return;
1512
-
1513
- // Warn if cli + server coexist
1514
- const hasServer = this.ast.body.some(n => n.type === 'ServerBlock');
1515
- if (hasServer) {
1516
- this.warnings.push({
1517
- message: 'cli {} and server {} blocks in the same file — cli produces a standalone executable, not a web server',
1518
- loc: cliBlocks[0].loc,
1519
- code: 'W_CLI_WITH_SERVER',
1520
- });
1521
- }
1522
-
1523
- // Check for missing name across all cli blocks
1524
- let hasName = false;
1525
- for (const block of cliBlocks) {
1526
- for (const field of block.config) {
1527
- if (field.key === 'name') hasName = true;
1528
- }
1529
- }
1530
- if (!hasName) {
1531
- this.warnings.push({
1532
- message: 'cli block has no name: field — consider adding name: "your-tool"',
1533
- loc: cliBlocks[0].loc,
1534
- code: 'W_CLI_MISSING_NAME',
1535
- });
1536
- }
1537
- }
1538
-
1539
- visitEdgeBlock(node) {
1540
- const validTargets = new Set(['cloudflare', 'deno', 'vercel', 'lambda', 'bun']);
1541
- const validConfigKeys = new Set(['target']);
1542
- const bindingNames = new Set();
1543
-
1544
- // Binding support matrix per target
1545
- const BINDING_SUPPORT = {
1546
- cloudflare: { kv: true, sql: true, storage: true, queue: true },
1547
- deno: { kv: true, sql: false, storage: false, queue: false },
1548
- vercel: { kv: false, sql: false, storage: false, queue: false },
1549
- lambda: { kv: false, sql: false, storage: false, queue: false },
1550
- bun: { kv: false, sql: true, storage: false, queue: false },
1551
- };
1552
-
1553
- // Targets that support schedule/consume/middleware
1554
- const SCHEDULE_TARGETS = new Set(['cloudflare', 'deno']);
1555
- const CONSUME_TARGETS = new Set(['cloudflare']);
1556
-
1557
- // Determine target from config fields
1558
- let target = 'cloudflare';
1559
- for (const stmt of node.body) {
1560
- if (stmt.type === 'EdgeConfigField' && stmt.key === 'target' && stmt.value.type === 'StringLiteral') {
1561
- target = stmt.value.value;
1562
- }
1563
- }
1564
-
1565
- this.pushScope('edge');
1566
-
1567
- let kvCount = 0;
1568
- const queueNames = new Set();
1569
- const consumers = [];
1570
-
1571
- for (const stmt of node.body) {
1572
- // Validate config fields
1573
- if (stmt.type === 'EdgeConfigField') {
1574
- if (!validConfigKeys.has(stmt.key)) {
1575
- this.warnings.push({
1576
- message: `Unknown edge config key '${stmt.key}' — valid keys are: ${[...validConfigKeys].join(', ')}`,
1577
- loc: stmt.loc,
1578
- code: 'W_UNKNOWN_EDGE_CONFIG',
1579
- });
1580
- }
1581
- if (stmt.key === 'target' && stmt.value.type === 'StringLiteral') {
1582
- if (!validTargets.has(stmt.value.value)) {
1583
- this.warnings.push({
1584
- message: `Unknown edge target '${stmt.value.value}' — valid targets are: ${[...validTargets].join(', ')}`,
1585
- loc: stmt.loc,
1586
- code: 'W_UNKNOWN_EDGE_TARGET',
1587
- });
1588
- }
1589
- }
1590
- continue;
1591
- }
1592
-
1593
- // Check for duplicate binding names
1594
- if (stmt.type === 'EdgeKVDeclaration' || stmt.type === 'EdgeSQLDeclaration' ||
1595
- stmt.type === 'EdgeStorageDeclaration' || stmt.type === 'EdgeQueueDeclaration') {
1596
- if (bindingNames.has(stmt.name)) {
1597
- this.warnings.push({
1598
- message: `Duplicate edge binding '${stmt.name}'`,
1599
- loc: stmt.loc,
1600
- code: 'W_DUPLICATE_EDGE_BINDING',
1601
- });
1602
- }
1603
- bindingNames.add(stmt.name);
1604
- }
1605
-
1606
- // Track queue names for consume validation
1607
- if (stmt.type === 'EdgeQueueDeclaration') {
1608
- queueNames.add(stmt.name);
1609
- }
1610
-
1611
- // Check for duplicate env/secret names
1612
- if (stmt.type === 'EdgeEnvDeclaration' || stmt.type === 'EdgeSecretDeclaration') {
1613
- if (bindingNames.has(stmt.name)) {
1614
- this.warnings.push({
1615
- message: `Duplicate edge binding '${stmt.name}'`,
1616
- loc: stmt.loc,
1617
- code: 'W_DUPLICATE_EDGE_BINDING',
1618
- });
1619
- }
1620
- bindingNames.add(stmt.name);
1621
- }
1622
-
1623
- // Unsupported binding warnings (per target)
1624
- const support = BINDING_SUPPORT[target] || BINDING_SUPPORT.cloudflare;
1625
- if (stmt.type === 'EdgeKVDeclaration' && !support.kv) {
1626
- this.warnings.push({
1627
- message: `KV binding '${stmt.name}' is not supported on target '${target}' — it will be stubbed as null`,
1628
- loc: stmt.loc,
1629
- code: 'W_UNSUPPORTED_KV',
1630
- });
1631
- }
1632
- if (stmt.type === 'EdgeSQLDeclaration' && !support.sql) {
1633
- this.warnings.push({
1634
- message: `SQL binding '${stmt.name}' is not supported on target '${target}' — it will be stubbed as null`,
1635
- loc: stmt.loc,
1636
- code: 'W_UNSUPPORTED_SQL',
1637
- });
1638
- }
1639
- if (stmt.type === 'EdgeStorageDeclaration' && !support.storage) {
1640
- this.warnings.push({
1641
- message: `Storage binding '${stmt.name}' is not supported on target '${target}' — it will be stubbed as null`,
1642
- loc: stmt.loc,
1643
- code: 'W_UNSUPPORTED_STORAGE',
1644
- });
1645
- }
1646
- if (stmt.type === 'EdgeQueueDeclaration' && !support.queue) {
1647
- this.warnings.push({
1648
- message: `Queue binding '${stmt.name}' is not supported on target '${target}' — it will be stubbed as null`,
1649
- loc: stmt.loc,
1650
- code: 'W_UNSUPPORTED_QUEUE',
1651
- });
1652
- }
1653
-
1654
- // Deno multi-KV warning
1655
- if (stmt.type === 'EdgeKVDeclaration') {
1656
- kvCount++;
1657
- if (kvCount > 1 && target === 'deno') {
1658
- this.warnings.push({
1659
- message: `Deno Deploy supports only one KV store — '${stmt.name}' will share the same store as the first KV binding`,
1660
- loc: stmt.loc,
1661
- code: 'W_DENO_MULTI_KV',
1662
- });
1663
- }
1664
- }
1665
-
1666
- // Validate schedule cron expressions + target support
1667
- if (stmt.type === 'EdgeScheduleDeclaration') {
1668
- const parts = stmt.cron.split(/\s+/);
1669
- if (parts.length < 5 || parts.length > 6) {
1670
- this.warnings.push({
1671
- message: `Invalid cron expression '${stmt.cron}' — expected 5 or 6 space-separated fields`,
1672
- loc: stmt.loc,
1673
- code: 'W_INVALID_CRON',
1674
- });
1675
- }
1676
- if (!SCHEDULE_TARGETS.has(target)) {
1677
- this.warnings.push({
1678
- message: `Scheduled tasks are not supported on target '${target}' — schedule '${stmt.name}' will be ignored. Supported targets: ${[...SCHEDULE_TARGETS].join(', ')}`,
1679
- loc: stmt.loc,
1680
- code: 'W_UNSUPPORTED_SCHEDULE',
1681
- });
1682
- }
1683
- }
1684
-
1685
- // Collect consume declarations for post-loop validation
1686
- if (stmt.type === 'EdgeConsumeDeclaration') {
1687
- consumers.push(stmt);
1688
- if (!CONSUME_TARGETS.has(target)) {
1689
- this.warnings.push({
1690
- message: `Queue consumers are not supported on target '${target}' — consume '${stmt.queue}' will be ignored. Supported targets: ${[...CONSUME_TARGETS].join(', ')}`,
1691
- loc: stmt.loc,
1692
- code: 'W_UNSUPPORTED_CONSUME',
1693
- });
1694
- }
1695
- }
1696
-
1697
- // Visit child nodes — edge-specific types are noop in the registry,
1698
- // so explicitly visit bodies that contain statements
1699
- if (stmt.type === 'EdgeScheduleDeclaration' && stmt.body) {
1700
- for (const s of stmt.body.body || []) this.visitNode(s);
1701
- } else if (stmt.type === 'FunctionDeclaration' || stmt.type === 'RouteDeclaration') {
1702
- this.visitNode(stmt);
1703
- }
1704
- }
1705
-
1706
- // Post-loop: validate consume references a declared queue
1707
- for (const consumer of consumers) {
1708
- if (!queueNames.has(consumer.queue)) {
1709
- this.warnings.push({
1710
- message: `consume '${consumer.queue}' references undeclared queue binding — add 'queue ${consumer.queue}' to the edge block`,
1711
- loc: consumer.loc,
1712
- code: 'W_CONSUME_UNKNOWN_QUEUE',
1713
- });
1714
- }
1715
- }
1716
-
1717
- // Warn if edge block has no route or schedule handlers
1718
- const hasRoutes = node.body.some(s => s.type === 'RouteDeclaration');
1719
- const hasSchedules = node.body.some(s => s.type === 'EdgeScheduleDeclaration');
1720
- const hasConsumers = consumers.length > 0;
1721
- if (!hasRoutes && !hasSchedules && !hasConsumers) {
1722
- this.warnings.push({
1723
- message: 'edge block has no routes, schedules, or consumers — it will produce no handlers',
1724
- loc: node.loc,
1725
- code: 'W_EDGE_NO_HANDLERS',
1726
- });
1727
- }
1728
-
1729
- this.popScope();
1730
- }
1731
-
1732
- _validateEdgeCrossBlock() {
1733
- const edgeBlocks = this.ast.body.filter(n => n.type === 'EdgeBlock');
1734
- if (edgeBlocks.length === 0) return;
1735
-
1736
- // Warn if edge + cli coexist (cli takes over with earlyReturn)
1737
- const hasCli = this.ast.body.some(n => n.type === 'CliBlock');
1738
- if (hasCli) {
1739
- this.warnings.push({
1740
- message: 'edge {} and cli {} blocks in the same file — cli produces a standalone executable, edge block will be ignored',
1741
- loc: edgeBlocks[0].loc,
1742
- code: 'W_EDGE_WITH_CLI',
1743
- });
1744
- }
1745
- }
1746
-
1747
- _validateSecurityCrossBlock() {
1748
- // W_NO_SECURITY_BLOCK: server/edge block without security block
1749
- const hasServerOrEdge = this.ast.body.some(n => n.type === 'ServerBlock' || n.type === 'EdgeBlock');
1750
- const hasSecurityBlock = this.ast.body.some(n => n.type === 'SecurityBlock');
1751
- if (hasServerOrEdge && !hasSecurityBlock) {
1752
- const block = this.ast.body.find(n => n.type === 'ServerBlock' || n.type === 'EdgeBlock');
1753
- this.warnings.push({
1754
- message: 'Server/edge block defined without a security block — consider adding security { ... } for auth, CORS, and CSRF protection',
1755
- loc: block.loc,
1756
- code: 'W_NO_SECURITY_BLOCK',
1757
- category: 'security',
1758
- });
1759
- }
1760
-
1761
- // Collect ALL security declarations across ALL security blocks in the AST
1762
- const allRoles = new Set();
1763
- const allProtects = [];
1764
- const allSensitives = [];
1765
- let hasAuth = false;
1766
- let hasProtect = false;
1767
- let authDecl = null;
1768
- let corsDecl = null;
1769
- let rateLimitDecl = null;
1770
- let csrfDecl = null;
1771
-
1772
- // Check for top-level AuthBlock (independent auth block, not security sub-block)
1773
- for (const node of this.ast.body) {
1774
- if (node.type === 'AuthBlock') { hasAuth = true; authDecl = node; break; }
1775
- }
1776
-
1777
- const roleDecls = []; // track all role declarations for cross-block duplicate detection
1778
- for (const node of this.ast.body) {
1779
- if (node.type !== 'SecurityBlock') continue;
1780
- for (const stmt of node.body) {
1781
- if (stmt.type === 'SecurityRoleDeclaration') {
1782
- roleDecls.push(stmt);
1783
- allRoles.add(stmt.name);
1784
- } else if (stmt.type === 'SecurityProtectDeclaration') {
1785
- allProtects.push(stmt);
1786
- hasProtect = true;
1787
- } else if (stmt.type === 'SecuritySensitiveDeclaration') {
1788
- allSensitives.push(stmt);
1789
- } else if (stmt.type === 'SecurityAuthDeclaration') {
1790
- hasAuth = true;
1791
- authDecl = stmt;
1792
- } else if (stmt.type === 'SecurityCorsDeclaration') {
1793
- corsDecl = stmt;
1794
- } else if (stmt.type === 'SecurityRateLimitDeclaration') {
1795
- rateLimitDecl = stmt;
1796
- } else if (stmt.type === 'SecurityCsrfDeclaration') {
1797
- csrfDecl = stmt;
1798
- }
1799
- }
1800
- }
1801
-
1802
- // W_DUPLICATE_ROLE across blocks: detect roles with same name in different security blocks
1803
- const seenRoleNames = new Map(); // name -> first declaration
1804
- for (const decl of roleDecls) {
1805
- const prev = seenRoleNames.get(decl.name);
1806
- if (prev && prev.loc !== decl.loc) {
1807
- // Only warn if this is from a different block (same-block dupes handled by visitSecurityBlock)
1808
- const prevInSameBlock = this.ast.body.some(b =>
1809
- b.type === 'SecurityBlock' && b.body.includes(prev) && b.body.includes(decl)
1810
- );
1811
- if (!prevInSameBlock) {
1812
- this.warnings.push({
1813
- message: `Role '${decl.name}' is defined in multiple security blocks — later definition overwrites earlier one`,
1814
- loc: decl.loc,
1815
- code: 'W_DUPLICATE_ROLE',
1816
- category: 'security',
1817
- });
1818
- }
1819
- }
1820
- seenRoleNames.set(decl.name, decl);
1821
- }
1822
-
1823
- // W_UNKNOWN_AUTH_TYPE — validate auth type is a known value
1824
- if (authDecl && authDecl.authType) {
1825
- const validAuthTypes = ['jwt', 'api_key'];
1826
- if (!validAuthTypes.includes(authDecl.authType)) {
1827
- this.warnings.push({
1828
- message: `Unknown auth type '${authDecl.authType}' — supported types are: ${validAuthTypes.join(', ')}`,
1829
- loc: authDecl.loc,
1830
- code: 'W_UNKNOWN_AUTH_TYPE',
1831
- category: 'security',
1832
- });
1833
- }
1834
- }
1835
-
1836
- // Fix 2: W_HARDCODED_SECRET — warn if auth secret is a string literal
1837
- // Only check SecurityAuthDeclaration (which has .config), not top-level AuthBlock
1838
- if (authDecl && authDecl.config && authDecl.config.secret) {
1839
- const secretNode = authDecl.config.secret;
1840
- if (secretNode.type === 'StringLiteral') {
1841
- this.warnings.push({
1842
- message: 'Auth secret is hardcoded as a string literal — use env("SECRET_NAME") instead',
1843
- loc: authDecl.loc,
1844
- code: 'W_HARDCODED_SECRET',
1845
- category: 'security',
1846
- });
1847
- }
1848
- }
1849
-
1850
- // Fix 7: W_CORS_WILDCARD — warn if cors origins contains "*"
1851
- if (corsDecl && corsDecl.config.origins) {
1852
- const originsNode = corsDecl.config.origins;
1853
- if (originsNode.elements) {
1854
- for (const elem of originsNode.elements) {
1855
- if (elem.type === 'StringLiteral' && elem.value === '*') {
1856
- this.warnings.push({
1857
- message: 'CORS origins contains wildcard "*" — consider restricting to specific origins',
1858
- loc: corsDecl.loc,
1859
- code: 'W_CORS_WILDCARD',
1860
- category: 'security',
1861
- });
1862
- break;
1863
- }
1864
- }
1865
- }
1866
- }
1867
-
1868
- // W_INVALID_RATE_LIMIT — validate rate limit max/window are positive numbers
1869
- const _rlNumericValue = (node) => {
1870
- if (!node) return null;
1871
- if (node.type === 'NumberLiteral') return node.value;
1872
- if (node.type === 'UnaryExpression' && node.operator === '-' && node.operand && node.operand.type === 'NumberLiteral') return -node.operand.value;
1873
- return null;
1874
- };
1875
- if (rateLimitDecl && rateLimitDecl.config) {
1876
- const rlMaxVal = _rlNumericValue(rateLimitDecl.config.max);
1877
- const rlWindowVal = _rlNumericValue(rateLimitDecl.config.window);
1878
- if (rlMaxVal !== null && rlMaxVal <= 0) {
1879
- this.warnings.push({
1880
- message: `Rate limit max must be a positive number, got ${rlMaxVal}`,
1881
- loc: rateLimitDecl.loc,
1882
- code: 'W_INVALID_RATE_LIMIT',
1883
- category: 'security',
1884
- });
1885
- }
1886
- if (rlWindowVal !== null && rlWindowVal <= 0) {
1887
- this.warnings.push({
1888
- message: `Rate limit window must be a positive number, got ${rlWindowVal}`,
1889
- loc: rateLimitDecl.loc,
1890
- code: 'W_INVALID_RATE_LIMIT',
1891
- category: 'security',
1892
- });
1893
- }
1894
- }
1895
-
1896
- // W_CSRF_DISABLED — warn when CSRF is explicitly disabled
1897
- if (csrfDecl && csrfDecl.config && csrfDecl.config.enabled) {
1898
- const enabledNode = csrfDecl.config.enabled;
1899
- if ((enabledNode.type === 'BooleanLiteral' && enabledNode.value === false) ||
1900
- (enabledNode.type === 'Identifier' && enabledNode.name === 'false')) {
1901
- this.warnings.push({
1902
- message: 'CSRF protection is explicitly disabled — this increases vulnerability to cross-site request forgery attacks',
1903
- loc: csrfDecl.loc,
1904
- code: 'W_CSRF_DISABLED',
1905
- category: 'security',
1906
- });
1907
- }
1908
- }
1909
-
1910
- // W_LOCALSTORAGE_TOKEN — warn when auth uses default localStorage storage (XSS-vulnerable)
1911
- if (authDecl && authDecl.authType === 'jwt' && authDecl.config) {
1912
- const storageNode = authDecl.config.storage;
1913
- const isCookieAuth = storageNode && storageNode.type === 'StringLiteral' && storageNode.value === 'cookie';
1914
- if (!isCookieAuth) {
1915
- this.warnings.push({
1916
- message: 'Auth tokens stored in localStorage are vulnerable to XSS attacks — consider using storage: "cookie" for HttpOnly cookie storage',
1917
- loc: authDecl.loc,
1918
- code: 'W_LOCALSTORAGE_TOKEN',
1919
- category: 'security',
1920
- });
1921
- }
1922
- }
1923
-
1924
- // Fix 5: W_INMEMORY_RATELIMIT — warn that rate limiting is in-memory only
1925
- if (rateLimitDecl) {
1926
- this.warnings.push({
1927
- message: 'Rate limiting uses in-memory storage — not shared across server instances. Consider an external store for production multi-instance deployments',
1928
- loc: rateLimitDecl.loc,
1929
- code: 'W_INMEMORY_RATELIMIT',
1930
- category: 'security',
1931
- });
1932
- }
1933
-
1934
- // Fix 6: W_NO_AUTH_RATELIMIT — warn when auth exists but no rate limiting protects against brute-force
1935
- if (hasAuth && !rateLimitDecl) {
1936
- const hasAuthRateLimit = allProtects.some(p => p.config && p.config.rate_limit);
1937
- if (!hasAuthRateLimit) {
1938
- this.warnings.push({
1939
- message: 'Auth is configured without rate limiting — consider adding rate_limit to protect against brute-force attacks',
1940
- loc: authDecl.loc,
1941
- code: 'W_NO_AUTH_RATELIMIT',
1942
- category: 'security',
1943
- });
1944
- }
1945
- }
1946
-
1947
- // Fix 7: W_HASH_NOT_ENFORCED — warn when sensitive declares hash but it's not auto-enforced
1948
- for (const s of allSensitives) {
1949
- if (s.config && s.config.hash) {
1950
- const hashVal = s.config.hash.value || s.config.hash;
1951
- this.warnings.push({
1952
- message: `sensitive ${s.typeName}.${s.fieldName} declares hash: "${hashVal}" but hashing is not automatically enforced — use hash_password() in your write handlers`,
1953
- loc: s.loc,
1954
- code: 'W_HASH_NOT_ENFORCED',
1955
- category: 'security',
1956
- });
1957
- }
1958
- }
1959
-
1960
- // No security blocks → nothing to validate (but allow auth/cors checks above)
1961
- if (!hasProtect && allSensitives.length === 0) return;
1962
-
1963
- // W_PROTECT_WITHOUT_AUTH: protect rules exist but no auth configured
1964
- if (hasProtect && !hasAuth) {
1965
- // Find first protect for location
1966
- this.warnings.push({
1967
- message: 'Route protection rules exist but no auth is configured — all protected routes will be inaccessible',
1968
- loc: allProtects[0].loc,
1969
- code: 'W_PROTECT_WITHOUT_AUTH',
1970
- category: 'security',
1971
- });
1972
- }
1973
-
1974
- // W_UNDEFINED_ROLE: protect rules reference roles not defined anywhere
1975
- for (const protect of allProtects) {
1976
- const requireExpr = protect.config.require;
1977
- if (!requireExpr) {
1978
- // W_PROTECT_NO_REQUIRE: protect rule has no require key
1979
- this.warnings.push({
1980
- message: `Protect rule for "${protect.pattern}" has no 'require' — route is unprotected`,
1981
- loc: protect.loc,
1982
- code: 'W_PROTECT_NO_REQUIRE',
1983
- category: 'security',
1984
- });
1985
- continue;
1986
- }
1987
- if (requireExpr.type === 'Identifier' && requireExpr.name !== 'authenticated') {
1988
- if (!allRoles.has(requireExpr.name)) {
1989
- this.warnings.push({
1990
- message: `Protect rule references undefined role '${requireExpr.name}'`,
1991
- loc: protect.loc,
1992
- code: 'W_UNDEFINED_ROLE',
1993
- category: 'security',
1994
- });
1995
- }
1996
- }
1997
- }
1998
-
1999
- // W_UNDEFINED_ROLE: sensitive visible_to references roles not defined anywhere
2000
- for (const sensitive of allSensitives) {
2001
- const visibleTo = sensitive.config.visible_to;
2002
- if (visibleTo && (visibleTo.type === 'ArrayExpression' || visibleTo.type === 'ArrayLiteral')) {
2003
- for (const elem of visibleTo.elements) {
2004
- if (elem.type === 'Identifier' && elem.name !== 'self') {
2005
- if (!allRoles.has(elem.name)) {
2006
- this.warnings.push({
2007
- message: `Sensitive field '${sensitive.typeName}.${sensitive.fieldName}' visible_to references undefined role '${elem.name}'`,
2008
- loc: sensitive.loc,
2009
- code: 'W_UNDEFINED_ROLE',
2010
- category: 'security',
2011
- });
2012
- }
2013
- }
2014
- }
2015
- }
2016
- }
2017
- }
2018
-
2019
- visitAuthBlock(node) {
2020
- const validHookEvents = new Set(['signup', 'login', 'logout', 'oauth_link']);
2021
- const providerTypes = new Set();
2022
- let hasProvider = false;
2023
-
2024
- for (const stmt of node.body) {
2025
- if (stmt.type === 'AuthConfigField') {
2026
- if (stmt.key === 'secret' && stmt.value.type === 'StringLiteral') {
2027
- this.warnings.push({
2028
- message: 'Auth secret should use env() — hardcoded secrets are insecure',
2029
- loc: stmt.loc, code: 'W_AUTH_HARDCODED_SECRET', category: 'auth',
2030
- });
2031
- }
2032
- if (stmt.key === 'token_expires' && stmt.value.type === 'NumberLiteral' && stmt.value.value < 300) {
2033
- this.warnings.push({
2034
- message: 'Access token expires too quickly (< 5 minutes) — may cause frequent logouts',
2035
- loc: stmt.loc, code: 'W_AUTH_SHORT_TOKEN', category: 'auth',
2036
- });
2037
- }
2038
- if (stmt.key === 'refresh_expires' && stmt.value.type === 'NumberLiteral' && stmt.value.value > 2592000) {
2039
- this.warnings.push({
2040
- message: 'Refresh token lives longer than 30 days — consider shorter lifetime',
2041
- loc: stmt.loc, code: 'W_AUTH_LONG_REFRESH', category: 'auth',
2042
- });
2043
- }
2044
- if (stmt.key === 'storage' && stmt.value.type === 'StringLiteral' && stmt.value.value === 'local') {
2045
- this.warnings.push({
2046
- message: 'localStorage tokens are vulnerable to XSS — prefer storage: "cookie"',
2047
- loc: stmt.loc, code: 'W_AUTH_LOCAL_STORAGE', category: 'auth',
2048
- });
2049
- }
2050
- }
2051
-
2052
- if (stmt.type === 'AuthProviderDeclaration') {
2053
- hasProvider = true;
2054
- const key = stmt.providerType + (stmt.name ? ':' + stmt.name : '');
2055
- if (providerTypes.has(key)) {
2056
- this.warnings.push({
2057
- message: `Duplicate auth provider '${key}'`,
2058
- loc: stmt.loc, code: 'W_AUTH_DUPLICATE_PROVIDER', category: 'auth',
2059
- });
2060
- }
2061
- providerTypes.add(key);
2062
-
2063
- if (stmt.providerType === 'email') {
2064
- if (!stmt.config.confirm_email) {
2065
- this.warnings.push({
2066
- message: 'Email provider without confirm_email — consider requiring email verification',
2067
- loc: stmt.loc, code: 'W_AUTH_NO_CONFIRM', category: 'auth',
2068
- });
2069
- }
2070
- if (stmt.config.password_min && stmt.config.password_min.type === 'NumberLiteral' && stmt.config.password_min.value < 8) {
2071
- this.warnings.push({
2072
- message: 'Minimum password length less than 8 — weak passwords allowed',
2073
- loc: stmt.loc, code: 'W_AUTH_WEAK_PASSWORD', category: 'auth',
2074
- });
2075
- }
2076
- }
2077
- }
2078
-
2079
- if (stmt.type === 'AuthHookDeclaration') {
2080
- if (!validHookEvents.has(stmt.event)) {
2081
- this.warnings.push({
2082
- message: `Unknown auth hook event '${stmt.event}' — valid: ${[...validHookEvents].join(', ')}`,
2083
- loc: stmt.loc, code: 'W_AUTH_UNKNOWN_HOOK', category: 'auth',
2084
- });
2085
- }
2086
- }
2087
-
2088
- if (stmt.type === 'AuthProtectedRoute') {
2089
- if (!stmt.config.redirect) {
2090
- this.warnings.push({
2091
- message: `Protected route '${stmt.pattern}' has no redirect`,
2092
- loc: stmt.loc, code: 'W_AUTH_PROTECTED_NO_REDIRECT', category: 'auth',
2093
- });
2094
- }
2095
- }
2096
- }
2097
-
2098
- if (!hasProvider) {
2099
- this.warnings.push({
2100
- message: 'Auth block has no providers — add at least one',
2101
- loc: node.loc, code: 'W_AUTH_MISSING_PROVIDER', category: 'auth',
2102
- });
2103
- }
2104
- }
2105
-
2106
- _validateAuthCrossBlock() {
2107
- const securityRoles = new Set();
2108
- for (const node of this.ast.body) {
2109
- if (node.type === 'SecurityBlock') {
2110
- for (const stmt of node.body) {
2111
- if (stmt.type === 'SecurityRoleDeclaration') {
2112
- securityRoles.add(stmt.name);
2113
- }
2114
- }
2115
- }
2116
- }
2117
-
2118
- for (const node of this.ast.body) {
2119
- if (node.type === 'AuthBlock') {
2120
- for (const stmt of node.body) {
2121
- if (stmt.type === 'AuthProtectedRoute' && stmt.config.require) {
2122
- const roleName = stmt.config.require.type === 'Identifier' ? stmt.config.require.name : null;
2123
- if (roleName && securityRoles.size > 0 && !securityRoles.has(roleName)) {
2124
- this.warnings.push({
2125
- message: `Protected route requires role '${roleName}' not defined in security block`,
2126
- loc: stmt.loc, code: 'W_AUTH_UNKNOWN_ROLE', category: 'auth',
2127
- });
2128
- }
2129
- }
2130
- }
2131
- }
2132
- }
2133
- }
1322
+ // visitSecurityBlock, visitCliBlock, visitConcurrentBlock, visitSelectStatement,
1323
+ // visitEdgeBlock, visitAuthBlock and their cross-block validators are in their
1324
+ // respective *-analyzer.js files (lazy-loaded via plugins)
2134
1325
 
2135
1326
  // visitBrowserBlock and other browser visitors are in browser-analyzer.js (lazy-loaded)
2136
1327