DEV Community

jackymenCZ (jackymenCZ)
jackymenCZ (jackymenCZ)

Posted on Edited on

The Word "eval" Is Not the Problem. Understanding What It Refers To Is.

How SentinelIR learned to follow variable bindings, aliases and control flow instead of blindly matching code.

Imagine you're building a security analyzer.

Thanks for support..
Release: "Sentinel-IR-MCP v0.2.2 on GitLab" (https://gitlab.com/jakubrojicek11/sentinel-ir-mcp/-/tree/v0.2.2?ref_type=tags)

Package: "sentinel-ir-mcp@0.2.2 on npm" (https://www.npmjs.com/package/sentinel-ir-mcp/v/0.2.2)

You want to detect dangerous JavaScript functions, so you search the source code for the word "eval".

Simple, right?

Not quite.

The first trap: the same name can mean different things

Consider this:

function evaluate(code) {
return String(code);
}

Should this trigger a critical security warning?

No. The function is called "evaluate", not "eval".

Now look at this:

function run(eval) {
eval();
}

Should this trigger a warning?

Not necessarily. Here, "eval" is a function parameter. It shadows the global JavaScript function.

But this is different:

function run() {
globalThis.eval("some code");
}

Now we're dealing with the actual global "eval".

Three examples. Similar names. Three different meanings.

A security analyzer cannot understand code by looking at names alone.

The second trap: dangerous functions can change their names

What happens when someone creates an alias?

const execute = eval;

execute("some code");

Searching for calls to "eval" will miss this.

The analyzer needs to understand that "execute" refers to the original global function.

And then things get more interesting.

switch (mode) {
case 1:
execute = eval;
case 2:
execute("some code");
break;
}

JavaScript's "switch" can fall through into the next case.

That means the meaning of a variable can change in one branch and affect code in another.

The same problem appears with "break", assignments, exceptions and "catch" blocks.

This is no longer simple keyword detection.

It's about understanding variable bindings and following how information moves through a program.

What we worked on in SentinelIR

We expanded the analyzer to handle these situations through AST-based analysis, scope resolution and alias tracking.

The test suite now covers:

  • Global "eval" and "Function" detection.
  • Local variables and parameters that shadow global functions.
  • Explicit and computed "globalThis" access.
  • Aliases of dangerous functions.
  • "switch" fallthrough and "break" behavior.
  • Alias propagation through "try" and "catch".
  • "child_process" imports, renamed imports and chained aliases.
  • TypeScript and TSX syntax.

We also added an important rule:

When the analyzer cannot reliably understand the source, it must say so.

Broken TypeScript or unparseable source should not silently become a confident low-risk result.

Unknown is not the same as safe.

The result

The latest test run:

Metric| Result
Tests| 106
Passed| 106
Failed| 0
Skipped| 0

These tests verify specific behaviors. They do not prove that every possible security issue can be detected. But they give us concrete regression checks for cases that simple text matching cannot reliably handle.

Why does this matter for AI agents?

An AI agent can read source code and reason about it.

But giving it thousands of lines of raw code doesn't automatically give it a reliable understanding of the program.

SentinelIR takes a different approach:

  1. Parse the source code.
  2. Understand its structure and bindings.
  3. Track relevant operations and relationships.
  4. Produce a compact, structured Intermediate Representation.
  5. Give that representation to an AI agent as additional context.

The goal isn't to replace the AI.

It's to give it better information before asking it to make a decision.

Because sometimes the most important security question isn't:

Does this code contain the word "eval"?

It's:

What does "eval" actually refer to here?


SentinelIR is an ongoing software project. The implementation and its test suite are public.

Termux test .
Node --test test/*.test.js
✔ routes come from call syntax, with method and path (251.610625ms) ✔ a getter called on something that is not a route host is not a route (18.535052ms) ✔ exports and imports are read from module syntax (20.536041ms) ✔ named and default ESM exports are collected too (40.578593ms) ✔ environment reads are collected from member and subscript access (44.361355ms) ✔ filesystem operations distinguish read, write and delete (7.136511ms) ✔ a function named evaluate is not eval (10.472604ms) ✔ real dynamic code and process control are critical (23.18875ms) ✔ a call named exec is only process spawning when it comes from a spawning module (17.108333ms)
✔ process spawning is found through every binding of a spawning module (29.07901ms) ✔ a dynamically imported child_process is still child_process (5.443854ms) ✔ secret comparisons are recognised as an auth check (1.849114ms)
✔ every risk signal names the syntax and the line that produced it (0.558854ms)
✔ unparseable source degrades to the text fallback instead of claiming facts (9.546927ms)
✔ SentinelIR derives risk and forbidden list from the AST, not from substrings (18.017865ms)
✔ the compressed IR carries the facts and omits empty categories (11.90151ms)
✔ sources above the tree-sitter direct input limit are still parsed (2283.448488ms)
✔ static ES namespace import records child_process capability (1.030938ms)
✔ static ES named import records child_process capability (1.918334ms)
✔ static ES renamed import records child_process capability (0.538334ms)
✔ static ES side-effect import records child_process capability (0.234063ms)
✔ static ES unprefixed import records child_process capability (0.287657ms)
✔ dynamic code detection covers globalThis.eval() (0.632968ms)
✔ dynamic code detection covers computed globalThis eval (0.404844ms)
✔ dynamic code detection covers Function() without new (0.402448ms)
✔ dynamic code detection covers globalThis.Function constructor (0.291927ms)
✔ dynamic code detection covers computed globalThis Function constructor (0.298073ms)
✔ computed child_process method detects actual process spawning (0.714532ms) ✔ an ordinary object method named eval is not dynamic code by itself (0.522968ms)
✔ shadowing: global Function call (0.424219ms) ✔ shadowing: global Function constructor (0.24276ms) ✔ shadowing: explicit global Function with local shadow (0.490312ms)
✔ shadowing: local Function variable (0.687865ms)
✔ shadowing: Function parameter (0.63776ms)
✔ shadowing: local Function constructor name (0.351354ms) ✔ shadowing: local eval variable (0.543906ms)
✔ shadowing: eval parameter (0.535521ms)
✔ shadowing: explicit global eval despite local shadow (0.53125ms) ✔ eval alias control-flow regression: switch fallthrough carries an eval alias into the next case (3.775365ms)
✔ eval alias control-flow regression: switch break prevents an eval alias reaching the next case (1.613958ms) ✔ eval alias control-flow regression: a later switch case overwrites the alias before the call (2.059375ms)
✔ eval alias control-flow regression: eval alias assigned before throw is visible in catch (1.684427ms) ✔ eval alias control-flow regression: throw before eval alias assignment does not taint catch (1.329062ms)
✔ eval alias control-flow regression: assignment in try without a later explicit throw does not taint catch (1.31552ms) ✔ analyzeAsync initializes WASM and analyzes different source types (115.814948ms) ✔ analyze without options uses the documented defaults (5.129219ms) ✔ analyze passes caller-supplied world signals through unchanged (6.024167ms) ✔ zero is a valid world signal, not a missing one (0.988125ms)
✔ invalid world signals fall back to the defaults instead of being clamped (6.427448ms) ✔ a non-object options argument is treated as no options (1.720157ms) ✔ consecutiveFailures drives the stability objective and the scope constraint (5.292709ms)
✔ a target on the crypto path is never below medium risk and keeps security (0.887135ms)
✔ analyze reads structure from the AST (4.216511ms)
✔ dangerous syntax becomes critical risk and a forbidden list (3.207031ms)
✔ analyze is stateless: the same input gives the same IR (6.768385ms)
✔ compress produces the documented top-level shape (55.804583ms)
✔ the compressed IR is smaller than the source it describes (84.88974ms)
✔ compressFacts keeps only enabled operations and non-empty categories (10.092969ms)
✔ compressFacts renders risk signals as signal:evidence@line (1.460938ms)
✔ compressFacts marks missing or unparsed facts as ast:false with a reason (0.398125ms)
✔ unparseable source falls back to text heuristics and says so (23.936979ms)
✔ expandDecision joins an LLM decision with the IR it was made on (7.852083ms)
✔ expandDecision only copies known fields from the decision (20.10625ms)
✔ scope regression: default import shadows global Function (147.53125ms)
✔ scope regression: renamed import shadows global Function (12.971927ms)
✔ scope regression: local globalThis.Function is not the global constructor (13.693021ms)
✔ scope regression: local globalThis.eval is not global eval (9.978281ms) ✔ scope regression: local globalThis does not make its constructor global (8.399167ms)
✔ scope regression: computed local globalThis.eval is not global eval (9.23276ms) ✔ scope regression: object method named Function does not shadow the global (14.322188ms) ✔ scope regression: class method named Function does not shadow the global (10.254114ms)
✔ scope regression: object method named eval does not shadow the global (12.957396ms)
✔ scope regression: real global Function remains critical (11.915729ms)
✔ scope regression: real globalThis.Function remains critical (6.445782ms) ✔ scope regression: real globalThis.eval remains critical (8.754219ms) ✔ sync analyze() on TypeScript before initialization is incomplete, not guessed (27.143021ms) ✔ LANGUAGES lists the supported grammars (13.769947ms) ✔ language is inferred from the target extension (411.018334ms) ✔ an explicit language overrides the extension and accepts short aliases (14.720678ms) ✔ an unsupported language is reported, never parsed with the wrong grammar (14.282656ms) ✔ JavaScript grammar on TypeScript source is incomplete (unchanged 0.1.x behavior) (14.587188ms) ✔ a TypeScript server yields the same kind of facts as JavaScript (57.112135ms) ✔ type-only imports are erased and do not count as runtime imports (1.512969ms) ✔ import types in type positions are not runtime imports (3.452083ms) ✔ a type-only specifier is erased, the runtime specifier still binds (3.174583ms) ✔ TypeScript wrappers around a callee do not hide the call (27.114114ms) ✔ import x = require() is a runtime require (2.135365ms)
✔ typed parameters shadow eval like JavaScript parameters (3.579531ms) ✔ TypeScript export forms are listed (1.999948ms)✔ declaration files describe types only (1.25026ms) ✔ decorators are runtime code (4.492812ms)
✔ TSX components are parsed with the TSX grammar (2.978438ms)
✔ the wrong TypeScript dialect is incomplete, not a confident low risk (2.309479ms) ✔ broken TypeScript is incomplete (1.543698ms)
✔ comments in TypeScript do not reach the IR (1.272656ms)
✔ sync analyze() works on TypeScript after initializeWasm({ languages }) (3.880989ms)
✔ indirect global eval through comma operator is detected (378.258749ms)
✔ direct alias of child_process method is detected (29.728229ms)
✔ parameter shadowing a spawn alias is not a process spawn (10.045208ms)
✔ bracket access to a secret environment key emits a warning (2.346875ms)
✔ static template literal property is recognized (2.521407ms)
✔ interpolated template literal is not assumed to be a fixed eval key (1.373385ms)
✔ indirect eval does not flag a shadowed eval binding (1.469531ms)
✔ local lexical binding shadows a spawn alias (3.339323ms)
✔ local cp parameter shadows a module binding (2.274375ms)
✔ process spawn detection follows a chain of local aliases (18.665208ms)
ℹ tests 106 ℹ suites 0
ℹ pass 106 ℹ fail 0 ℹ cancelled 0
ℹ skipped 0
ℹ todo 0 ~/sentinel-ir-021 $ cd

Top comments (0)