Sentinel-IR 0.4: it now notices native code, reads big files in seconds instead of minutes, and stops trusting variable names. Windows update
description: "What changed in sentinel-ir 0.4.2 to 0.4.4: native code is critical, near-linear JavaScript analysis, parser error locations, and fs/HTTP calls recognised through any local name. 30 seconds, 2 minutes for e
description: "What changed in sentinel-ir 0.4.2 to 0.4.4: native code is critical, near-linear JavaScript analysis, parser error locations, and fs/HTTP calls recognised through any local name. 30 seconds, 2 minutes for everyone, then the developer part."
- Source (GitLab, Apache-2.0): https://gitlab.com/jakubrojicek11/sentinel-ir
- Package (npm): https://www.npmjs.com/package/sentinel-ir
- Try it in the browser: https://gitlab.com/jakubrojicek11/sentinel-ir-playground
In 30 seconds
sentinel-ir turns a JavaScript, TypeScript or Python file into a short,
structural note for an AI model: what the file touches and how risky it is.
The 0.4 line (0.4.2, 0.4.3, 0.4.4) added four things:
-
Native code is critical.
ctypes,cffi,process.dlopen,.nodeaddons and FFI packages used to below. Now they arecritical. - Big JavaScript is fast. A 480 KB file went from 75 s to 1.3 s in our run below. Long-running processes no longer run out of parser memory.
-
Broken files say where they broke.
parseErrorgives the line, the column and what the parser expected. -
Names no longer fool it.
require("fs").writeFileSync(...)orconst net = require("https"); net.get(...)used to belowbecause the variable was not calledfsorhttps. Now they aremediumorhigh.
320 tests, the same 8,800-file corpus compared before and after every
release, and a few thousand generated programs per change.
Part one: for everyone (about 2 minutes)
What the tool is for
When an AI assistant works on your code, it usually reads whole files. That
costs money (AI models are billed by the amount of text), it is slow, and
the files can contain text that tries to steer the AI. sentinel-ir gives
the AI a short summary instead: "this file reads environment variables,
writes to disk, and calls the network on line 12. Risk: medium." The AI
decides which files are worth reading in full.
Think of it as the label on a box when you move house. You don't open
every box to find the kitchen knives. You read "KITCHEN, SHARP, line 12".
What 0.4 fixed, in plain words
1. The label forgot the chainsaw. Some programs can reach outside their
own safe box and run raw machine code. It is the equivalent of a box with
"cutlery" on the label and a chainsaw inside. In Python this is ctypes, in
Node.js it is native addons. Before 0.4, a file that loaded the C library and
ran a shell command through it was labelled low. Now it is critical, the
highest level, with the line number.
2. The mover was very slow with big boxes. For every word in a big file,
the old version re-read a large part of the file to check what the word
meant. On a 1 MB file that took almost three minutes. It is like checking
the whole inventory again each time you pick up a spoon. Now each answer is
remembered, and the same file takes about 6 seconds.
There was also a slow leak. The parser kept every file it had ever read in
memory and never let go. After roughly 87 MB of code, it quietly gave up and
fell back to guessing. That matters for a server that runs all day. Fixed:
the same file analysed 20,000 times in a row now stays complete every time.
3. "This box is damaged" now says where. When a file has a syntax error,
the summary used to say only "incomplete". Now it says "line 42, column 1, a
} is missing". It never quotes your code.
4. It trusted the name on the door. The old version recognised file
writes only when the module was called fs. Call it files, or skip the
variable entirely (require("fs").writeFileSync(...)), and the write was
invisible. It is like a security guard who only stops people wearing a
badge that says "burglar". Now it follows where the value came from, not what
it is called.
What it still does not do
It reads one file at a time. If one file hands the file-system module to a
function in another file, it does not follow. It describes what code can
do, not whether it is a vulnerability. A medium web server is normal.
Part two: for developers
Install
npm install sentinel-ir@^0.4.4
const { analyzeAsync, compress } = require("sentinel-ir");
const ir = await analyzeAsync(source, { target: "src/upload.js" });
console.log(ir.world.risk, ir.analysisStatus, ir.facts.operations);
const forTheModel = compress(ir); // the compact form you send to an LLM
Language comes from the target extension (.js .mjs .cjs .jsx .ts .mts .cts) or
.tsx .py .pyilanguage. Node 20, 22 and 24 are tested in CI.
1. nativeCode (0.4.0, hardened in 0.4.1 and 0.4.2)
0.4.0 and 0.4.1 were never published; 0.4.2 was the first 0.4 release.
import ctypes
ctypes.CDLL("libc.so.6").system(b"echo hi") # 0.3.1: low, 0.4.2+: critical
facts.operations.nativeCode is true, forbidden contains native_code,
and the signal carries the line (native_code:...@2). Covered:
-
Python:
ctypesloaders (CDLL,WinDLL,cdll.LoadLibrary,windll.kernel32), calls into a loaded library,CFUNCTYPE, raw memory (memmove,string_at,from_address, pointer indexing other thanp[0]),cffi(dlopen,verify,set_source,compile),mmapwithPROT_EXEC,win32api.LoadLibrary,llvmliteJIT. -
JS/TS:
process.dlopen,process.binding,Deno.dlopen,*.nodeaddons (also built paths likepath.join(dir, "addon.node")),koffi,ffi-napi,bun:ffi,node-gyp-buildand others.
Aliases are resolved: from ctypes import CDLL as load, getattr(ctypes,,
"CDLL")sys.modules["ctypes"], const { dlopen } = process,
Reflect.apply(process.dlopen, ...). A name built at run time
(getattr(ctypes, "CD" + "LL"), process[n](...)) is reported, because it
can be any loader. A plain import ctypes or sizeof is not.
How it was tested: an adversarial set of 119 hand-written evasions and
look-alikes found 36 wrong results in the first version (34 misses, 2 false
positives); 31 of them were fixed in 0.4.1, and 0.4.2 added run-time names and the stricter text fallback. A generated matrix
in test/native-matrix.test.js builds 486 positive cases (every API in
every access form, in JS, TS and TSX) and 123 negative ones with a harmless
member. On the 8,815-file corpus (Python stdlib, site-packages, JS/TS
projects) 64 files gained nativeCode, and each one was checked by hand. All
64 were real native calls. Bandit has no ctypes rule, so there is no
external recall number for this fact.
The memory leak. The WebAssembly parser trees were never delete()d.
WebAssembly memory is not garbage-collected, so on the same 18 KB file,
0.4.1 aborted at analysis 4,881 (88 MB parsed). From then on every result was
incomplete, without an error. 0.4.2 passes 20,000. A regression test
checks that every tree is released.
2. Near-linear JavaScript (0.4.3)
Scope lookups re-walked scopes and ancestor chains for every reference, which
is quadratic. 0.4.3 caches binding names per scope, parents and execution
paths for one analysis, and indexes alias writes by offset.
| input | 0.4.2 | 0.4.3 |
|---|---|---|
| valid JS, 473 KiB | 32.8 s, 1,030 MB RSS | 2.3 s, 522 MB |
| valid JS, 1 MiB | 168 s | 5.9–7.2 s, 917 MB |
deeply nested if
|
~11 s already at 100 levels | 0.12 s at 800 levels |
Those are from MR !9.
The reproduction script at the end gives this on Node 22 for a 480 KB
generated file: 0.4.2: 75.4 s, 0.4.4: 1.3 s. The ratio depends a lot on
the shape of the code, so measure your own files.
Same output check: on 8,816 files, 8,807 were byte-identical to 0.4.2 and the
other 9 only gained parseError. 10,000 random programs (aliases,
shadowing, destructuring, loops, closures) gave 0 differences.
3. parseError (0.4.3)
const ir = await analyzeAsync("function f(a {\n return a\n}\n", { target: "broken.js" });
ir.analysisStatus // "incomplete"
ir.facts.parseError // { line: 1, column: 13, kind: "missing", expected: ")" }
kind is missing (with expected) or unexpected. It is where the parser
first noticed, which can be a few characters before the real mistake. It does
not tell a bug from syntax the grammar does not know. All 9 incomplete files
in our corpus are the second case: newer TypeScript such as export type *
or variance annotations (in T). A low risk on an incomplete file is not
evidence of safety; the text fallback is deliberately stricter for
eval, native code, fs and HTTP.
4. Bindings instead of names (0.4.4)
Until 0.4.3, fs and HTTP calls were recognised on a receiver with a
conventional name. 0.4.4 reuses the scope-aware binding walker that already
handled child_process, with one spec for fs and one for HTTP, and it
records where each binding came from.
| code | 0.3.1 | 0.4.2 | 0.4.4 |
|---|---|---|---|
require("fs").writeFileSync("out.txt", data) |
low | low | medium |
const { unlinkSync: del } = require("fs"); del("old.txt") |
low | low | high |
const net = require("https"); net.get(url) |
low | low | medium |
require("axios").get(url) |
low | low | medium |
const fse = require("fs-extra"); fse.remove("build") |
low | low | high |
import("fs").then(m => m.writeFileSync("x", "y")) |
low | low | medium |
const { isAxiosError } = require("axios"); isAxiosError(e) |
low | low | low |
Deletes are high, writes and network calls are medium. Modules: fs,
fs/promises, fs-extra, graceful-fs (with node: prefixes), and http,
https, http2, axios, got, superagent, request, node-fetch,
undici, cross-fetch, isomorphic-fetch, ky, needle. Only the
clients' documented request methods count, for example got.paginate,
axios.postForm and undici.upgrade. Helpers such as isAxiosError,
setGlobalDispatcher or MockAgent do not.
The first version of the HTTP rule made helper functions imported under
their own name look like network calls (import { setGlobalDispatcher } from was
"undici"medium). The fix classifies a destructured import by the
export it came from.
Negative tests keep low for a parameter or local that shadows the module, an
unrelated object (db.writeFileSync(), cache.get(), Map.get()), a local
function called writeFileSync, lodash's get, and type-only TS imports.
Corpus of 8,819 files against 0.4.3: 8,807 identical, 12 gained real fs
calls (3 went from low to medium), none changed through the HTTP rules. 9,000
generated programs with random module bindings: no crash and no lost
signal. The cost is about 10 % more time (a 1.4 MB file: 5.2–6.0 s →
5.6–7.2 s, noisy).
Not followed yet
- A module passed into another function:
function save(f) { f.writeFileSync() } save(require("fs")). A parameter literally namedfsis still assumed to be the module, as before. - Anything across files. One file at a time.
- Values:
readFileSync(req.query.f)islow, since user input is not tracked.
Tests
npm test → 320 tests, 320 pass (tag v0.4.4, Node 20/22/24 in CI)
Each release was compared on the same corpus as the previous one and fuzzed
with generated programs before it was merged.
Reproduce the tables
mkdir ir-04 && cd ir-04 && npm init -y >/dev/null
npm i ir031@npm:[email protected] ir042@npm:[email protected] ir044@npm:[email protected]
// ir-bench.js
const V = { "0.3.1": require("ir031"), "0.4.2": require("ir042"), "0.4.4": require("ir044") };
const cases = [
["native.py", 'import ctypes\nctypes.CDLL("libc.so.6").system(b"echo hi")\n'],
["a.js", 'require("fs").writeFileSync("out.txt", data)\n'],
["b.js", 'const { unlinkSync: del } = require("fs")\ndel("old.txt")\n'],
["c.js", 'const net = require("https")\nnet.get("https://example.com")\n'],
["d.js", 'require("axios").get("https://example.com")\n'],
["e.js", 'const fse = require("fs-extra")\nfse.remove("build")\n'],
["f.js", 'import("fs").then(m => m.writeFileSync("x", "y"))\n'],
["g.js", 'const { isAxiosError } = require("axios")\nisAxiosError(e)\n'],
];
(async () => {
for (const [target, code] of cases) {
const row = [];
for (const [v, m] of Object.entries(V)) row.push(`${v}: ${(await m.analyzeAsync(code, { target })).world.risk}`);
console.log(target.padEnd(10), row.join(" "));
}
const ir = await V["0.4.4"].analyzeAsync("function f(a {\n return a\n}\n", { target: "broken.js" });
console.log("parseError", JSON.stringify(ir.facts.parseError));
let src = "", i = 0;
while (src.length < 480000) { src += `function f${i}(a, b) { const x${i} = a + b; if (x${i} > ${i}) { return helper${i % 7}(x${i}); } return [a, b].map((v) => v * ${i}); }\n`; i++; }
for (const v of ["0.4.2", "0.4.4"]) {
const t = Date.now(); await V[v].analyzeAsync(src, { target: "big.js" });
console.log(v, ((Date.now() - t) / 1000).toFixed(1), "s");
}
})();
Our run (Node 22, Linux VM):
native.py 0.3.1: low 0.4.2: critical 0.4.4: critical
a.js 0.3.1: low 0.4.2: low 0.4.4: medium
b.js 0.3.1: low 0.4.2: low 0.4.4: high
c.js 0.3.1: low 0.4.2: low 0.4.4: medium
d.js 0.3.1: low 0.4.2: low 0.4.4: medium
e.js 0.3.1: low 0.4.2: low 0.4.4: high
f.js 0.3.1: low 0.4.2: low 0.4.4: medium
g.js 0.3.1: low 0.4.2: low 0.4.4: low
parseError {"line":1,"column":13,"kind":"missing","expected":")"}
0.4.2 75.4 s
0.4.4 1.3 s
If you use it through an AI client, the MCP server
sentinel-ir-mcp picks up
these versions on its own. It has its own 0.4 article.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.