Skip to content

Commit d121fc4

Browse files
Marcelo Fariasclaude
authored andcommitted
feat(compiler): SYN070 — inline array-element .at(N) bypass of SYN-guarded globals (?bs 0.7+)
SYN069 closes the bracket-notation form `[eval][0](code)`. Array.prototype.at() is the modern equivalent — `[eval].at(0)(code)` — and bypasses SYN069 because the token sequence after `]` is `.at(N)(` rather than `[N](`. SYN070 closes this gap with the same pre-pass strategy: find the guarded global in array-element position, confirm the .at() argument matches the element index, fire when called. 17 tests: fires cases (eval/fetch/Function/WebSocket, multi-element arrays, paren-wrapped form), no-fire cases (index mismatch, dynamic index, negative index, no-call form, unsafe suppression, non-guarded globals, below ?bs 0.7), message content check, and SYN069/SYN070 exclusivity check. 2768 → 2785 tests. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 7666b63 commit d121fc4

4 files changed

Lines changed: 362 additions & 1 deletion

File tree

packages/compiler/src/error-codes.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4920,6 +4920,53 @@ const E: Record<string, ErrorCodeEntry> = {
49204920
" return eval(code) // SYN004 — visible to checker\n" +
49214921
"}",
49224922
},
4923+
SYN070: {
4924+
code: "SYN070",
4925+
title: "inline array-element .at(N) retrieval of a SYN-guarded global — Array.prototype.at bypass (?bs 0.7+)",
4926+
rule:
4927+
"A SYN-guarded global (`eval`, `fetch`, `Function`, `WebSocket`, etc.) appears as an element " +
4928+
"at index N in an inline array literal, and that array is immediately accessed via " +
4929+
"`.at(N)` and called — `[eval].at(0)(code)`, `[x, fetch].at(1)(url)`. " +
4930+
"SYN069 closes the bracket-notation gap (`[eval][0](code)`), but `Array.prototype.at()` is " +
4931+
"the modern equivalent and bypasses SYN069: the token pattern after `]` is `.at(N)(` rather " +
4932+
"than `[N](`. All per-ident SYN checks still miss the guarded global inside the array literal " +
4933+
"(not in call position). All alias-binding checks miss it (no binding declaration). " +
4934+
"SYN070 closes the gap: when a guarded global appears at index N in an inline array literal " +
4935+
"that is immediately accessed via `.at(literal-N)` and called, the warning fires. " +
4936+
"Limitation: cross-statement forms (`const arr = [eval]; arr.at(0)(code)`) require taint " +
4937+
"analysis and are not yet detected.",
4938+
idiom:
4939+
"call the guarded global directly — `eval(code)` or `fetch(url)` — so the relevant SYN check " +
4940+
"fires; if the `.at()` form is genuinely needed, wrap in " +
4941+
"`unsafe \"reason\" { [eval].at(0)(code) }` to make the bypass auditable",
4942+
rewrite:
4943+
"// before — .at() hides guarded global from call-site SYN checks\n" +
4944+
"?bs 0.7\n" +
4945+
"fn run(code: string) -> any {\n" +
4946+
" return [eval].at(0)(code) // SYN070: eval at index 0, retrieved via .at(0)\n" +
4947+
"}\n\n" +
4948+
"// after — call directly so SYN004 fires\n" +
4949+
"?bs 0.7\n" +
4950+
"fn run(code: string) -> any {\n" +
4951+
" return eval(code) // SYN004: direct call, visible to checker\n" +
4952+
"}",
4953+
example:
4954+
"// SYN070: eval at index 0, retrieved via .at(0)\n" +
4955+
"?bs 0.7\n" +
4956+
"fn run(code: string) -> any {\n" +
4957+
" return [eval].at(0)(code) // SYN070\n" +
4958+
"}\n\n" +
4959+
"// SYN070: fetch at index 1, accessed via .at(1)\n" +
4960+
"?bs 0.7\n" +
4961+
"fn load(url: string) -> any {\n" +
4962+
" return [something, fetch].at(1)(url) // SYN070\n" +
4963+
"}\n\n" +
4964+
"// fix: call the guarded global directly\n" +
4965+
"?bs 0.7\n" +
4966+
"fn run(code: string) -> any {\n" +
4967+
" return eval(code) // SYN004 — visible to checker\n" +
4968+
"}",
4969+
},
49234970
};
49244971

49254972
export function getErrorCode(code: string): ErrorCodeEntry | undefined {

packages/compiler/src/passes/syn-check.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -740,6 +740,16 @@
740740
* index matches the bracket-access literal, and fires when the result is called.
741741
* `unsafe {}` suppresses.
742742
*
743+
* SYN070 A SYN-guarded global (`eval`, `fetch`, `Function`, etc.) appears at index N in an
744+
* inline array literal that is immediately accessed via `.at(N)` and called in a fn
745+
* body — `[eval].at(0)(code)`, `[x, fetch].at(1)(url)`. SYN069 closes the bracket-
746+
* notation gap (`[eval][0](...)`); `Array.prototype.at()` is the modern equivalent and
747+
* bypasses SYN069 because the token sequence after `]` is `.at(N)(` not `[N](`. All
748+
* per-ident checks still miss the global (not in call position inside `[...]`); all
749+
* alias-binding checks miss it (no binding). SYN070 closes the gap: a pre-pass finds
750+
* guarded globals in array-element position, confirms the index matches the `.at()`
751+
* argument, and fires when the result is called. `unsafe {}` suppresses.
752+
*
743753
* All checks share a single token scan per fn body. The outer loop runs once,
744754
* skipping nested fn bodies once. Per-token dispatch is a switch on tok.text
745755
* after a kind==="ident" guard.
@@ -1027,6 +1037,7 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult
10271037
const syn067 = getErrorCode("SYN067")!;
10281038
const syn068 = getErrorCode("SYN068")!;
10291039
const syn069 = getErrorCode("SYN069")!;
1040+
const syn070 = getErrorCode("SYN070")!;
10301041

10311042
// Collect char-offset ranges where all SYN checks are suppressed:
10321043
// 1. `unsafe "reason" { ... }` expression blocks — explicit acknowledgment.
@@ -1928,6 +1939,123 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult
19281939
});
19291940
}
19301941

1942+
// ── SYN070: inline array-element .at(N) bypass — pre-pass ───────────────
1943+
// Pattern: [guarded_global].at(N)(...) — guarded global at index N in an
1944+
// inline array literal, retrieved via Array.prototype.at(N) and called.
1945+
// Mirrors SYN069 but for the .at() method form instead of [N] bracket form.
1946+
for (let i70 = bodyStart; i70 < decl.tokenEnd; i70++) {
1947+
const tok70 = tokens[i70];
1948+
if (!tok70 || tok70.kind !== "ident") continue;
1949+
if (!SYN037_GUARDED_GLOBALS.has(tok70.text)) continue;
1950+
1951+
// Must be in array-element position: prev significant token is `[` or `,`
1952+
const prevIdx70 = prevSignificant(tokens, i70 - 1);
1953+
const prev70 = tokens[prevIdx70];
1954+
const inArray70 =
1955+
(prev70 && prev70.kind === "open" && prev70.text === "[") ||
1956+
(prev70 && prev70.kind === "punct" && prev70.text === ",");
1957+
if (!inArray70) continue;
1958+
1959+
// Backward scan to find the opening `[` of the enclosing array literal.
1960+
let openBracketIdx70 = -1;
1961+
{
1962+
let d70 = 0;
1963+
for (let j = i70 - 1; j >= bodyStart; j--) {
1964+
const t = tokens[j];
1965+
if (!t) continue;
1966+
if (t.kind === "close") { d70++; continue; }
1967+
if (t.kind === "open") {
1968+
if (d70 === 0 && t.text === "[") { openBracketIdx70 = j; break; }
1969+
d70--;
1970+
}
1971+
}
1972+
}
1973+
if (openBracketIdx70 < 0) continue;
1974+
1975+
// Count commas at depth 0 between opening `[` and tok70 to determine index.
1976+
let elemIndex70 = 0;
1977+
{
1978+
let d70 = 0;
1979+
for (let j = openBracketIdx70 + 1; j < i70; j++) {
1980+
const t = tokens[j];
1981+
if (!t) continue;
1982+
if (t.kind === "open") { d70++; continue; }
1983+
if (t.kind === "close") { d70--; continue; }
1984+
if (d70 === 0 && t.kind === "punct" && t.text === ",") elemIndex70++;
1985+
}
1986+
}
1987+
1988+
// Use matchedAt to find the closing `]` of the array literal.
1989+
const closeBracketIdx70 = tokens[openBracketIdx70]!.matchedAt;
1990+
if (closeBracketIdx70 === undefined) continue;
1991+
1992+
// After `]`, skip any closing parens (handles `([eval]).at(0)(code)`).
1993+
let afterCloseIdx70 = nextSignificant(tokens, closeBracketIdx70 + 1);
1994+
while (tokens[afterCloseIdx70]?.kind === "close" && tokens[afterCloseIdx70]?.text === ")") {
1995+
afterCloseIdx70 = nextSignificant(tokens, afterCloseIdx70 + 1);
1996+
}
1997+
1998+
// Expect `.at` — a dot token followed by ident `at`.
1999+
const dotTok70 = tokens[afterCloseIdx70];
2000+
if (!dotTok70 || dotTok70.kind !== "punct" || dotTok70.text !== ".") continue;
2001+
const atIdx70 = nextSignificant(tokens, afterCloseIdx70 + 1);
2002+
const atTok70 = tokens[atIdx70];
2003+
if (!atTok70 || atTok70.kind !== "ident" || atTok70.text !== "at") continue;
2004+
2005+
// Expect `(` opening the .at() argument list.
2006+
const openParenIdx70 = nextSignificant(tokens, atIdx70 + 1);
2007+
const openParen70 = tokens[openParenIdx70];
2008+
if (!openParen70 || !(openParen70.kind === "open" && openParen70.text === "(")) continue;
2009+
2010+
// Inside the parens, must be a numeric literal.
2011+
const numIdx70 = nextSignificant(tokens, openParenIdx70 + 1);
2012+
const numTok70 = tokens[numIdx70];
2013+
if (!numTok70 || numTok70.kind !== "number") continue;
2014+
2015+
// The numeric value must match the element index (negative indices not tracked).
2016+
const indexVal70 = parseInt(numTok70.text, 10);
2017+
if (isNaN(indexVal70) || indexVal70 < 0 || indexVal70 !== elemIndex70) continue;
2018+
2019+
// After the number, must be closing `)`.
2020+
const closeParenIdx70 = nextSignificant(tokens, numIdx70 + 1);
2021+
const closeParen70 = tokens[closeParenIdx70];
2022+
if (!closeParen70 || !(closeParen70.kind === "close" && closeParen70.text === ")")) continue;
2023+
2024+
// After `)`, must be `(` or `?.(` — a call on the returned value.
2025+
const callIdx70 = nextSignificant(tokens, closeParenIdx70 + 1);
2026+
const callTok70 = tokens[callIdx70];
2027+
const isCall70 =
2028+
callTok70 && (
2029+
(callTok70.kind === "open" && callTok70.text === "(") ||
2030+
callTok70.kind === "questionDot"
2031+
);
2032+
if (!isCall70) continue;
2033+
2034+
if (isInsideRange(tok70.start, unsafeRanges)) continue;
2035+
2036+
const loc70 = locationOf(src, tok70.start);
2037+
warnings.push({
2038+
code: "SYN070",
2039+
severity: "warning",
2040+
file: null,
2041+
line: loc70.line,
2042+
column: loc70.column,
2043+
start: tok70.start,
2044+
end: closeParen70!.end,
2045+
message:
2046+
`fn '${decl.name}' stores ${tok70.text} at index ${elemIndex70} of an inline array ` +
2047+
`then calls it via [${tok70.text}].at(${elemIndex70})(...) — ` +
2048+
`SYN004/SYN007/… only fire when ${tok70.text} is in call position (followed by '('); ` +
2049+
`SYN069 closes the [N] bracket form but .at(N) is the modern equivalent and bypasses it; ` +
2050+
`alias-binding checks (SYN044–SYN068) only track binding declarations, not inline array elements; ` +
2051+
`the runtime effect is identical to calling ${tok70.text}(...) directly; ` +
2052+
`refactor to call ${tok70.text} directly or wrap in unsafe "reason" { [${tok70.text}].at(${elemIndex70})(...) }`,
2053+
rule: syn070.rule,
2054+
idiom: syn070.idiom,
2055+
rewrite: syn070.rewrite,
2056+
});
2057+
}
2058+
19312059
// Single dispatch loop: nesting bookkeeping runs once per token position.
19322060
// All SYN checks are dispatched via a switch on tok.text after an ident guard.
19332061
for (let i = bodyStart; i < decl.tokenEnd; i++) {

packages/compiler/tests/error-codes.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ describe("error-code registry", () => {
1515
"INT001", "INT002", "INT003", "INT004", "INT005", "INT006", "INT007", "INT008", "INT009", "INT010", "INT011", "INT012", "INT013", "INT014", "INT015", "INT016", "INT017", "INT018", "INT019", "INT020", "INT021", "INT022", "INT023", "INT024", "INT025", "INT026", "INT027", "INT028", "INT029", "INT030", "INT031", "INT032", "INT033", "INT034", "INT035",
1616
"MAT001", "MAT002", "MAT003", "MAT004", "MAT005", "MAT006",
1717
"RES001", "RES002", "RES003",
18-
"SYN001", "SYN002", "SYN003", "SYN004", "SYN005", "SYN006", "SYN007", "SYN008", "SYN009", "SYN010", "SYN011", "SYN012", "SYN013", "SYN014", "SYN015", "SYN016", "SYN017", "SYN018", "SYN019", "SYN020", "SYN021", "SYN022", "SYN023", "SYN024", "SYN025", "SYN026", "SYN027", "SYN028", "SYN029", "SYN030", "SYN031", "SYN032", "SYN033", "SYN034", "SYN035", "SYN036", "SYN037", "SYN038", "SYN039", "SYN040", "SYN041", "SYN042", "SYN043", "SYN044", "SYN045", "SYN046", "SYN047", "SYN048", "SYN049", "SYN050", "SYN051", "SYN052", "SYN053", "SYN054", "SYN055", "SYN056", "SYN057", "SYN058", "SYN059", "SYN060", "SYN061", "SYN062", "SYN063", "SYN064", "SYN065", "SYN066", "SYN067", "SYN068", "SYN069",
18+
"SYN001", "SYN002", "SYN003", "SYN004", "SYN005", "SYN006", "SYN007", "SYN008", "SYN009", "SYN010", "SYN011", "SYN012", "SYN013", "SYN014", "SYN015", "SYN016", "SYN017", "SYN018", "SYN019", "SYN020", "SYN021", "SYN022", "SYN023", "SYN024", "SYN025", "SYN026", "SYN027", "SYN028", "SYN029", "SYN030", "SYN031", "SYN032", "SYN033", "SYN034", "SYN035", "SYN036", "SYN037", "SYN038", "SYN039", "SYN040", "SYN041", "SYN042", "SYN043", "SYN044", "SYN045", "SYN046", "SYN047", "SYN048", "SYN049", "SYN050", "SYN051", "SYN052", "SYN053", "SYN054", "SYN055", "SYN056", "SYN057", "SYN058", "SYN059", "SYN060", "SYN061", "SYN062", "SYN063", "SYN064", "SYN065", "SYN066", "SYN067", "SYN068", "SYN069", "SYN070",
1919
"THR001", "THR002", "THR003", "THR004",
2020
"UNS001", "UNS002", "UNS003", "UNS004", "UNS005", "UNS006", "UNS007", "UNS008", "UNS009",
2121
"VER001", "VER002", "VER003",
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
/**
2+
* Tests for SYN070: inline array-element .at(N) bypass (?bs 0.7+).
3+
*
4+
* SYN070 fires when a SYN-guarded global (`eval`, `fetch`, `Function`, etc.) appears
5+
* at index N in an inline array literal that is immediately accessed via `.at(N)` and
6+
* called in a fn body.
7+
*
8+
* Example: `[eval].at(0)(code)`, `[x, fetch].at(1)(url)`.
9+
*
10+
* SYN069 closes the bracket-notation gap (`[eval][0](...)`). `Array.prototype.at()` is
11+
* the modern equivalent and bypasses SYN069: the token sequence after `]` is `.at(N)(`
12+
* rather than `[N](`. SYN070 closes this remaining gap.
13+
*/
14+
15+
import { describe, expect, it } from "vitest";
16+
import { transform } from "../src/transform.js";
17+
18+
describe("SYN070: inline array-element .at(N) bypass (?bs 0.7+)", () => {
19+
// ── fires cases ──────────────────────────────────────────────────────────
20+
21+
it("fires on [eval].at(0)(code)", () => {
22+
const src =
23+
"?bs 0.7\n" +
24+
"fn run(code: string) -> any {\n" +
25+
" return [eval].at(0)(code)\n" +
26+
"}\n";
27+
expect(transform(src).warnings.some((w) => w.code === "SYN070")).toBe(true);
28+
});
29+
30+
it("fires on [fetch].at(0)(url)", () => {
31+
const src =
32+
"?bs 0.7\n" +
33+
"fn load(url: string) -> any {\n" +
34+
" return [fetch].at(0)(url)\n" +
35+
"}\n";
36+
expect(transform(src).warnings.some((w) => w.code === "SYN070")).toBe(true);
37+
});
38+
39+
it("fires on [Function].at(0)(body)", () => {
40+
const src =
41+
"?bs 0.7\n" +
42+
"fn execute(body: string) -> any {\n" +
43+
" return [Function].at(0)(body)()\n" +
44+
"}\n";
45+
expect(transform(src).warnings.some((w) => w.code === "SYN070")).toBe(true);
46+
});
47+
48+
it("fires on [x, fetch].at(1)(url) — guarded global at index 1", () => {
49+
const src =
50+
"?bs 0.7\n" +
51+
"fn load(url: string) -> any {\n" +
52+
" return [something, fetch].at(1)(url)\n" +
53+
"}\n";
54+
expect(transform(src).warnings.some((w) => w.code === "SYN070")).toBe(true);
55+
});
56+
57+
it("fires on [a, b, eval].at(2)(code) — guarded global at index 2", () => {
58+
const src =
59+
"?bs 0.7\n" +
60+
"fn run(code: string) -> any {\n" +
61+
" return [a, b, eval].at(2)(code)\n" +
62+
"}\n";
63+
expect(transform(src).warnings.some((w) => w.code === "SYN070")).toBe(true);
64+
});
65+
66+
it("fires on [WebSocket].at(0)(url)", () => {
67+
const src =
68+
"?bs 0.7\n" +
69+
"fn connect(url: string) -> any {\n" +
70+
" return [WebSocket].at(0)(url)\n" +
71+
"}\n";
72+
expect(transform(src).warnings.some((w) => w.code === "SYN070")).toBe(true);
73+
});
74+
75+
it("fires on paren-wrapped ([eval]).at(0)(code)", () => {
76+
const src =
77+
"?bs 0.7\n" +
78+
"fn run(code: string) -> any {\n" +
79+
" return ([eval]).at(0)(code)\n" +
80+
"}\n";
81+
expect(transform(src).warnings.some((w) => w.code === "SYN070")).toBe(true);
82+
});
83+
84+
// ── no-fire cases ────────────────────────────────────────────────────────
85+
86+
it("does NOT fire when index mismatches — [eval].at(1)", () => {
87+
const src =
88+
"?bs 0.7\n" +
89+
"fn run(code: string) -> any {\n" +
90+
" return [eval].at(1)(code)\n" +
91+
"}\n";
92+
expect(transform(src).warnings.some((w) => w.code === "SYN070")).toBe(false);
93+
});
94+
95+
it("does NOT fire when index mismatches — [x, fetch].at(0)", () => {
96+
const src =
97+
"?bs 0.7\n" +
98+
"fn load(url: string) -> any {\n" +
99+
" return [something, fetch].at(0)(url)\n" +
100+
"}\n";
101+
expect(transform(src).warnings.some((w) => w.code === "SYN070")).toBe(false);
102+
});
103+
104+
it("does NOT fire when index is not a numeric literal — [eval].at(n)", () => {
105+
const src =
106+
"?bs 0.7\n" +
107+
"fn run(code: string, n: number) -> any {\n" +
108+
" return [eval].at(n)(code)\n" +
109+
"}\n";
110+
expect(transform(src).warnings.some((w) => w.code === "SYN070")).toBe(false);
111+
});
112+
113+
it("does NOT fire on negative index — [eval].at(-1)", () => {
114+
const src =
115+
"?bs 0.7\n" +
116+
"fn run(code: string) -> any {\n" +
117+
" return [eval].at(-1)(code)\n" +
118+
"}\n";
119+
// Negative indices are not tracked (would require knowing array length).
120+
expect(transform(src).warnings.some((w) => w.code === "SYN070")).toBe(false);
121+
});
122+
123+
it("does NOT fire when no call follows — [eval].at(0) as expression", () => {
124+
const src =
125+
"?bs 0.7\n" +
126+
"fn run() -> any {\n" +
127+
" const x = [eval].at(0)\n" +
128+
" return x\n" +
129+
"}\n";
130+
expect(transform(src).warnings.some((w) => w.code === "SYN070")).toBe(false);
131+
});
132+
133+
it("does NOT fire when suppressed by unsafe block", () => {
134+
const src =
135+
"?bs 0.7\n" +
136+
"fn run(code: string) -> any {\n" +
137+
' return unsafe "intentional bypass for sandboxed eval" { [eval].at(0)(code) }\n' +
138+
"}\n";
139+
expect(transform(src).warnings.some((w) => w.code === "SYN070")).toBe(false);
140+
});
141+
142+
it("does NOT fire on non-guarded globals — [JSON].at(0).stringify(x)", () => {
143+
const src =
144+
"?bs 0.7\n" +
145+
"fn run(x: any) -> string {\n" +
146+
" return [JSON].at(0).stringify(x)\n" +
147+
"}\n";
148+
expect(transform(src).warnings.some((w) => w.code === "SYN070")).toBe(false);
149+
});
150+
151+
it("does NOT fire below ?bs 0.7", () => {
152+
const src =
153+
"?bs 0.6\n" +
154+
"fn run(code: string) -> any {\n" +
155+
" return [eval].at(0)(code)\n" +
156+
"}\n";
157+
expect(transform(src).warnings.some((w) => w.code === "SYN070")).toBe(false);
158+
});
159+
160+
// ── message content ──────────────────────────────────────────────────────
161+
162+
it("message names the guarded global and index", () => {
163+
const src =
164+
"?bs 0.7\n" +
165+
"fn run(code: string) -> any {\n" +
166+
" return [eval].at(0)(code)\n" +
167+
"}\n";
168+
const w = transform(src).warnings.find((w) => w.code === "SYN070");
169+
expect(w).toBeDefined();
170+
expect(w!.message).toContain("eval");
171+
expect(w!.message).toContain("0");
172+
});
173+
174+
// ── SYN069 not fired (different pattern) ────────────────────────────────
175+
176+
it("does NOT fire SYN069 when the pattern is .at() form (SYN070 fires instead)", () => {
177+
const src =
178+
"?bs 0.7\n" +
179+
"fn run(code: string) -> any {\n" +
180+
" return [eval].at(0)(code)\n" +
181+
"}\n";
182+
const result = transform(src);
183+
expect(result.warnings.some((w) => w.code === "SYN069")).toBe(false);
184+
expect(result.warnings.some((w) => w.code === "SYN070")).toBe(true);
185+
});
186+
});

0 commit comments

Comments
 (0)