@safeguard-sh/iast
v1.0.1
Published
Safeguard IAST agent for Node.js: tracks untrusted input from source to dangerous sink at runtime
Maintainers
Readme
Safeguard IAST agent — Node.js
Covers JavaScript and TypeScript. Tracks untrusted input from the point it enters the application to the point a dangerous operation consumes it, and reports the flows it observes to the collector hosted by a running Safeguard scan.
There is no separate TypeScript agent: TypeScript compiles to JavaScript before
it runs, so the agent instruments the emitted JavaScript and resolves each
finding's position back through the source map to the .ts file you actually
edit. Findings resolved that way are marked sourceMapped.
Install
No dependencies, deliberately — the agent runs inside someone else's application, and forcing a package into their tree risks conflicting with their own versions.
# .npmrc
@safeguard-sh:registry=https://registry.safeguard.sh/npm/
npm install @safeguard-sh/[email protected]The package is public on registry.safeguard.sh; no token is needed to install it.
The package was first published as @safeguard/iast. On npmjs.com the @safeguard scope belongs to someone else, so the agent moved to Safeguard's own scope, @safeguard-sh. @safeguard/iast still installs from registry.safeguard.sh, unchanged, but npm shows a deprecation notice; switch to @safeguard-sh/iast (same code) and the .npmrc line above.
Always-on mode
For a long-running application, report to Safeguard's persistent collector instead of a scan's. Issue an agent token in the app (Integrations > Configure > IAST agents) and set it; that is the whole opt-in:
SAFEGUARD_IAST_TOKEN=sg_iast_... \
SAFEGUARD_IAST_APP=acme-api \
node --require @safeguard-sh/iast/register server.js| Variable | Meaning |
|---|---|
| SAFEGUARD_IAST_TOKEN | An sg_iast_ agent token selects always-on mode |
| SAFEGUARD_IAST_ENDPOINT | Collector base URL (default https://api.safeguard.sh/gpt/api/v1/iast) |
| SAFEGUARD_IAST_ENABLED | 0 turns an always-on agent off without removing it |
In always-on mode the agent also sends a heartbeat every 5 minutes (so "last seen" means running),
re-sends a flow it already reported at most every 15 minutes with only the new observations, keeps
findings across a collector outage (bounded queue), and tracks at most 10,000 distinct flows. Scan
mode (SAFEGUARD_IAST_COLLECTOR + SAFEGUARD_IAST_ENABLED=1) works exactly as before.
Full guide: https://docs.safeguard.sh/integrations/iast-agents
Run (scan mode)
SAFEGUARD_IAST_ENABLED=1 \
SAFEGUARD_IAST_COLLECTOR=http://scanner-host:7878 \
SAFEGUARD_IAST_TOKEN=<the scan's iast_agent_token> \
SAFEGUARD_IAST_APP=acme-api \
node -r @safeguard-sh/iast/register server.js| Variable | Meaning |
|---|---|
| SAFEGUARD_IAST_ENABLED | 1/true to turn the agent on. Default off. |
| SAFEGUARD_IAST_COLLECTOR | Collector base URL, e.g. http://scanner:7878 |
| SAFEGUARD_IAST_TOKEN | Bearer token; must equal the scan's iast_agent_token |
| SAFEGUARD_IAST_APP | Application name shown against findings |
| SAFEGUARD_IAST_DEBUG | 1 to log what was instrumented |
The preload puts the module hook in place before your code loads its database
driver, and wraps the http/https servers so every request's query, headers and
cookies are registered with no code change. Mounting the middleware as well lets
the agent see what Express has parsed (req.query, req.body, cookies) —
first, before any body parser or route:
const iast = require('@safeguard-sh/iast');
iast.install(); // not needed if you used -r @safeguard-sh/iast/register
app.use(iast.middleware()); // Express / Connect / NestJSWithout Express:
http.createServer(iast.wrapHandler(handler));What it detects
Sources: query parameters, path parameters, headers, cookies, and the parsed
body (registered lazily on first read, since body parsers run after the
middleware). Anything else, mark yourself with
iast.track(value, kind, name).
| Class | Covers |
|---|---|
| sql | mysql, mysql2, pg, sqlite3 (Database.run/all/get/each/exec/prepare), better-sqlite3 (prepare/exec), mssql, oracledb, sequelize, knex |
| command | child_process.exec/execSync, and spawn/execFile when shell: true |
| eval | new Function(...) / Function(...) |
| path | fs and fs.promises read/write/unlink/rename/mkdir/readdir/open, streams (a value passed through path.join/resolve/normalize is still recognised) |
| ssrf | axios, node-fetch, got |
| nosql | mongodb collection queries |
What it will not report, on purpose
- Bound query parameters.
query(sql, params)is correct, and params are where user input belongs. Only the statement is inspected. spawn/execFilewithout a shell. The argument array goes to the OS directly; no shell parses it, so a tainted argument is an argument.- Short values. Anything under 6 characters is never matched —
en,idand1appear inside ordinary SQL by coincidence. - The
hostheader. Set by infrastructure and present in ordinary URLs throughout an application.
How it works, and what it cannot do
The Python agent carries taint on a str subclass. JavaScript does not allow
that, and it is worth being precise about why, because it rules the approach
out rather than merely complicating it:
- a boxed
Stringis not interchangeable with a primitive (typeofis"object",=== "abc"is false), and handing one to application code would change how that code behaves; - primitives cannot be WeakMap keys, so no side table can attach to the value;
- template literals,
+and everyStringmethod produce new primitives.
So this agent tracks by recognition: inputs are registered per request, and a sink checks whether one appears verbatim in what it is about to execute. When a request parameter is present inside the SQL being run, that is not an inference — the attacker's exact bytes are in the statement.
The safeguards that make this sound rather than noisy are the minimum length, the per-request scope, the cap on tracked values, and registering the decoded form of each parameter as well as the raw one.
Per-request scope uses AsyncLocalStorage, which is a genuine advantage over
the Python agent's thread-local: Node is single-threaded and asynchronous, so a
thread-local equivalent would attribute a flow to whichever request happened to
be on the stack. The scope follows the request through awaits, callbacks and
promise chains, and concurrent requests cannot see each other's input — there is
a test for exactly that.
Known limitations, stated rather than papered over:
- CommonJS only. Modules are intercepted through
Module._load. An application using native ESMimportfor its own code does not go through it. Most Node servers and effectively all database drivers still load via CJS. evalcannot be wrapped. It is a syntactic form with access to the caller's scope; replacing the binding would change what code means.new Function(...)is covered, which is the form that takes a string built at runtime.- The trace has two frames, the source and the sink. The agent recognises the value at the sink, so it honestly knows where the input entered and where it was consumed. Inventing intermediate hops would be fabricating evidence.
Safety
- Never throws into application code.
- Never blocks a request: findings go on a bounded queue drained by an
unref'd timer, so the agent cannot keep a process alive or stall a request. - One vulnerable call produces one finding — nested sinks (
exec→spawn) are guarded. - Deduplicates locally, reporting a count rather than thousands of copies.
Tests
npm testtest/endtoend.test.js also writes the golden report that
pkg/appsec/iast/crosslang_test.go decodes. Regenerate it with:
SAFEGUARD_IAST_UPDATE_GOLDEN=1 node --test test/endtoend.test.js