Two commands and one line of config, and your CI starts failing on the four Lambda bugs that code review reliably waves through.
That's the whole outcome of this guide: npm install, one import in eslint.config.js, and every push gets checked for the same four patterns that ship again and again because they look like ordinary code. Every one compiles. Every one passes tests. Every one passes review.
Below: what each bug actually does, why reviewers miss it, and the rule that catches it. In a hurry? Jump straight to install.
Version note: this guide tracks eslint-plugin-lambda-security v1.2.7 (latest on npm, published 2026-07-19) — 14 rules, two presets.
Bug 1: Unvalidated event input reaching a request
// ❌ no-user-controlled-requests (CWE-918, CVSS 9.1)
export const handler = async (event) => {
const res = await fetch(event.queryStringParameters.callbackUrl);
return { statusCode: 200, body: await res.text() };
};
Why it survived review. The fetch is a single line doing an ordinary thing — a webhook callback, a "fetch the user's avatar URL" feature. The reviewer isn't picturing the trust boundary that line sits inside: the function can reach VPC-internal services, and its own role credentials are sitting in process.env one reflected-env away. SSRF only reads as dangerous when you already hold the runtime's internal surface in your head. Reading a feature PR, that intuition isn't there. The line reads as "calls a URL," and "calls a URL" is not a red flag.
Here's the Lambda-specific nuance most write-ups get wrong: Lambda has no EC2 metadata service. There's no 169.254.169.254 handing out role credentials the way IMDSv1 does on an EC2 box. The execution role's keys are injected as environment variables — AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN. So the SSRF that steals credentials is the one whose client can read file:///proc/self/environ, or a handler you can coax into echoing process.env — not the EC2 IMDS payload people reflexively block.
The rule: no-user-controlled-requests flags a request whose URL carries user-controlled input (rule docs). It reports as CWE-918 at CVSS 9.1 — the top band.
// ✅ allow-list the destination before you call it
const ALLOWED = new Set(["api.partner.com", "hooks.example.com"]);
const url = new URL(event.queryStringParameters.callbackUrl);
if (!ALLOWED.has(url.hostname)) throw new Error("destination not allowed");
const res = await fetch(url);
Bug 2: Hardcoded credentials in environment configuration
// ❌ no-secrets-in-env / no-hardcoded-credentials-sdk (CWE-798)
export const handler = async (event) => {
const client = new DynamoDBClient({
credentials: {
accessKeyId: "AKIAIOSFODNN7EXAMPLE",
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
},
});
// ...
};
Why it survived review. Secrets in code look like configuration. A reviewer scanning a PR diff sees a string literal — not a privilege that, if leaked, gives an attacker durable access. The Lambda-specific danger is compounded: even secrets you put in Lambda environment variables (not hardcoded, "properly" externalized) are readable by anyone with lambda:GetFunctionConfiguration and are visible in the AWS console. One console.log(process.env) dumps them to CloudWatch forever. Entropy scanners often miss the assignment that matters because they scan for pattern rather than structure — why a structural AST rule beats a secret scanner here is the argument in Hardcoded Secrets in AI-Generated Code, and the Autofix That Removes Them.
The rules: no-hardcoded-credentials-sdk (CWE-798, CVSS 9.8) catches AWS credentials hardcoded in SDK config. no-secrets-in-env (CWE-798, CVSS 9.8) flags secrets assigned to environment variables.
// ✅ fetch secrets at runtime from Secrets Manager / SSM
import {
GetSecretValueCommand,
SecretsManagerClient,
} from "@aws-sdk/client-secrets-manager";
const sm = new SecretsManagerClient({});
const secret = await sm.send(
new GetSecretValueCommand({ SecretId: "my-db-password" }),
);
Bug 3: Missing IAM least-privilege in infrastructure code
// ❌ no-overly-permissive-iam-policy (CWE-732)
const policy = {
Effect: "Allow",
Action: "*",
Resource: "*",
};
Why it survived review. The handler ships in application code; the IAM policy ships in a SAM/CDK/Serverless template that a different person reviews — often a platform engineer optimizing for "the deploy stops failing with AccessDenied," not for blast radius. Each half is locally reasonable. The chain is only visible when you hold both files at once, which no single reviewer does. That's the gap a linter closes: it reads the source AST, not the diff, and it doesn't get bored on line 4 of a 600-line PR.
The real stakes: stolen Lambda credentials are short-lived session tokens — but when the role policy contains "Action": "*", those tokens do anything in your account for their duration. A small SSRF becomes a full account takeover.
The rule: no-overly-permissive-iam-policy flags "*" in Action/Resource of IAM policy literals — the shape you write in SAM, CDK, the Serverless Framework, or inline policy objects (rule docs).
// ✅ scope to exactly what the function needs
const policy = {
Effect: "Allow",
Action: ["s3:GetObject"],
Resource: "arn:aws:s3:::my-bucket/*",
};
Bug 4: Sensitive data written to logs
// ❌ no-env-logging + no-exposed-error-details (CWE-532, CWE-209)
export const handler = async (event) => {
try {
console.log("env:", process.env); // dumps credentials to CloudWatch
// ...
} catch (err) {
return {
statusCode: 500,
body: JSON.stringify({ error: err.stack }), // stack trace in response
};
}
};
Why it survived review. Debug logging during development is routine. console.log(process.env) is the fastest way to verify configuration is wired up — and it persists to CloudWatch forever after the debug session ends. The stack trace in the error response is the mirror image: a well-intentioned "give the client enough to debug," which also gives an attacker the file paths, function names, and dependency versions they need to find the next exploit. Both patterns are so normal that reviewers read past them.
The rules: no-env-logging (CWE-532) catches process.env written to logs. no-exposed-error-details (CWE-209) catches error.stack returned in the HTTP response.
// ✅ log a structured message; return a generic response
export const handler = async (event) => {
try {
console.log("handler invoked", {
requestId: event.requestContext?.requestId,
});
// ...
} catch (err) {
console.error("handler error", { message: err.message }); // log detail
return {
statusCode: 500,
body: JSON.stringify({ error: "internal error" }),
}; // return generic
}
};
Here's the guard that catches all of this in CI
All four patterns — and ten more Lambda-specific rules — are caught by eslint-plugin-lambda-security. Add it once; it runs on every push.
npm install --save-dev eslint-plugin-lambda-security
// eslint.config.js — `configs` is a NAMED export
import { configs } from "eslint-plugin-lambda-security";
export default [configs.recommended]; // all 14 rules, CWE-tagged
Findings carry the CWE, the OWASP category, the CVSS score, and a concrete fix instruction — two lines, same shape, every rule:
src/handlers/proxy.ts
4:21 error 🔒 CWE-918 OWASP:A01-Broken CVSS:9.1 | HTTP request URL contains user-controlled input from event.queryStringParameters. Attackers can access internal services or exfiltrate data. | CRITICAL
Fix: Validate URL against allowlist before making request. Never use user input directly in URLs. | https://owasp.org/www-community/attacks/Server_Side_Request_Forgery
You can wire a cross-codebase protocol for what to do when this fires in
The 30-Minute Security Audit: A Static Analysis Protocol for Onboarding.
The full rule set
All 14 rules are organized around the OWASP Serverless Top 10 and pinned to a CWE — the same CWE-as-the-unit approach used to map a whole codebase in Mapping Your Codebase to the OWASP Top 10 with 247 ESLint Rules.
| Rule | Catches | CWE |
|---|---|---|
no-user-controlled-requests |
SSRF via user-controlled URL | CWE-918 |
no-overly-permissive-iam-policy |
* in IAM Action/Resource
|
CWE-732 |
no-missing-authorization-check |
handler with no authorization | CWE-862 |
no-unvalidated-event-body |
event body used unvalidated | CWE-20 |
no-secrets-in-env |
secrets in environment variables | CWE-798 |
no-hardcoded-credentials-sdk |
AWS creds hardcoded in SDK config | CWE-798 |
no-env-logging |
process.env written to logs |
CWE-532 |
no-exposed-error-details |
stack traces in the response | CWE-209 |
no-exposed-debug-endpoints |
debug endpoints left enabled | CWE-489 |
no-error-swallowing |
empty catch hides failures |
CWE-390 |
no-permissive-cors-response |
Access-Control-Allow-Origin: * |
CWE-942 |
no-permissive-cors-middy |
permissive CORS via Middy | CWE-942 |
no-unbounded-batch-processing |
uncapped record processing → DoS | CWE-770 |
require-timeout-handling |
no fallback before hard timeout | CWE-400 |
Two presets. Both turn on all 14 — the difference is severity, and severity is what decides whether your build actually goes red:
| Preset | Rules on | Severity | Use when |
|---|---|---|---|
recommended |
14 | 7 error (the critical ones), 7 warn
|
adopting on an existing codebase |
strict |
14 | all 14 error
|
you want CI to fail on any of the 14 |
Starting on recommended and still want warnings to block? Run eslint --max-warnings 0. That's the usual middle step while you burn down an existing backlog — visible, counted, and not yet fatal.
What happens when an AI assistant writes the handler
I wanted first-party numbers for this article instead of borrowing them, so I ran the experiment twice — and the second run found something I didn't expect. The corpus, the prompts, and the scan script are all in the repo, so you can re-run this rather than take my word for it:
# corpus + scan live in the benchmark suite. Model: claude-opus-4-7, run 2026-06-21.
node benchmarks/lambda-ai-corpus/scripts/generate.mjs # 10 from-scratch handlers
node benchmarks/lambda-ai-corpus/scripts/generate.mjs prompts-terse.json generated-terse # 10 "just make it work" edits
node benchmarks/lambda-ai-corpus/scripts/scan.mjs [generated|generated-terse] # lambda-security over a corpus
Two caveats that bound everything below: ten prompts per run is a probe, not a benchmark (n=10 settles nothing on its own), and I labelled every handler by hand, so the ground truth is mine.
Run 1 — ten neutral, from-scratch prompts ("write a Lambda that fetches a callbackUrl and returns the body," "give this function an IAM role to read/write S3"). The uncomfortable result: on the SSRF prompt the model did not hand me the naked fetch(callbackUrl). It wrote a full assertSafeUrl guard — protocol allow-list, an explicit 169.254.169.254 block, DNS checks against private ranges, redirect: 'error'. Zero of ten handlers tripped any critical rule. Frontier defaults have genuinely moved — on a clean, explicit prompt, today's model often writes the hardened version.
Run 2 — the same tasks, but phrased the way assistants are actually used under deadline: "Quick one — fetch the callbackUrl and return the body, just make it work," "simple proxy, read target from the body, GET it, don't overthink it." The guard evaporated. Three of ten handlers carried a textbook user-controlled-fetch with no allow-list. Same model, same day; the only variable was the word "quick."
Here's the part I didn't expect: the rule flagged zero of those three. Three real vulnerabilities, three false negatives — a recall gap, not a precision one, which is the less embarrassing failure to have and still a failure.
Each terse handler parked the tainted value in a local first — and that hop alone isn't the problem, because the rule does follow a plain single-assignment local. What it loses is the shape of the read. An optional chain (event.queryStringParameters?.callbackUrl) parses to a ChainExpression, which the taint lookup never unwraps; a destructured binding (const { target } = …) isn't an identifier assignment at all, so it never gets recorded as tainted in the first place. The rule docs flag multi-step taint flow in general under "Known False Negatives," but these two shapes are narrower and far more common than the example there. I pinned the three shapes as fixtures so the gap stops being anecdotal:
// fn-probe/direct.ts → flagged ✅
await fetch(event.queryStringParameters.callbackUrl);
// fn-probe/via-local.ts → missed ❌ (the shape all three terse handlers took)
// it's the `?.` that loses the taint, not the local — drop the `?.` here and it flags
const callbackUrl = event.queryStringParameters?.callbackUrl;
await fetch(callbackUrl);
// fn-probe/via-destructure.ts → missed ❌
const { target } = JSON.parse(event.body);
await fetch(target);
That optional-chained read is the most common shape AI-generated handlers actually take — the defensive ?. a model reaches for by reflex is the same token that drops the value off the taint map — I filed it. And yes: I wrote the rule, and I wrote the corpus that caught the rule not working. Only one of those is a good look, which is why the fixtures are in the repo rather than in this paragraph.
The honest scorecard: the vulnerable pattern came back the moment the prompt got terse, and today's taint tracking catches the obvious form — including a plain one-variable detour — but not the optional-chained or destructured read.
The broader picture: 80 common Node.js functions written with zero security context came back 65–75% vulnerable across every model I tried in I Let Claude Write 80 Functions. 65–75% Had Security Vulnerabilities, and across 700 functions from five frontier models in We Ranked 5 AI Models by Security. The Leaderboard Is Wrong. every model landed at a 49–73% vulnerability rate. Those are the numbers with enough sample behind them to lean on; my twenty handlers are the local colour. A CI guard doesn't care which way the model leaned today: it re-asserts the invariant on every commit.
Install and tune
# npm
npm install --save-dev eslint-plugin-lambda-security
# yarn
yarn add --dev eslint-plugin-lambda-security
# pnpm
pnpm add --save-dev eslint-plugin-lambda-security
# bun
bun add --dev eslint-plugin-lambda-security
Flat config (eslint.config.js):
// `configs` is a NAMED export; the default export is the plugin object.
import { configs } from "eslint-plugin-lambda-security";
export default [
configs.recommended, // all 14 rules — 7 error, 7 warn
// configs.strict, // all 14 as error
];
Confirm it's actually live. Paste this into a scratch file and lint it:
// scratch.js
export const handler = async (event) => {
const res = await fetch(event.queryStringParameters.callbackUrl);
return { statusCode: 200, body: await res.text() };
};
npx eslint scratch.js
# 3:21 error 🔒 CWE-918 OWASP:A01-Broken CVSS:9.1 | ... | CRITICAL
If that fires, you're wired up — delete the scratch file and push.
Use a .js file for that check, not .ts. configs.recommended ships the rules only — no files glob and no parser. On a TypeScript file that means ESLint skips it outright and tells you File ignored because no matching configuration was supplied, which reads like a clean pass if you don't look closely. To lint the .ts handlers this article opens with, hand ESLint a parser for them:
import { configs } from "eslint-plugin-lambda-security";
import tseslint from "typescript-eslint";
export default [
{ files: ["**/*.ts"], languageOptions: { parser: tseslint.parser } },
configs.recommended,
];
If nothing fires on a .js file either, the usual cause is eslint.config.js not being picked up (ESLint 8 needs ESLINT_USE_FLAT_CONFIG=true; ESLint 9+ uses flat config by default).
Tune a rule inline — the namespace is lambda-security:
import { configs } from "eslint-plugin-lambda-security";
export default [
configs.recommended,
{
rules: {
"lambda-security/no-exposed-debug-endpoints": "warn",
"lambda-security/no-unbounded-batch-processing": [
"error",
{ maxBatchSize: 50 },
],
},
},
];
Compatibility
| Surface | Support |
|---|---|
| Package managers | npm, yarn, pnpm, bun — plain dev dependency |
| Node | >= 18.0.0 |
| ESLint | `^8.0.0 \ |
| Deploy tooling | Detects raw handlers, Middy middleware, and IAM policy literals (SAM / CDK / Serverless Framework / inline CloudFormation) — it reads source, so no framework lock-in |
| Module system | CommonJS — loads from both {% raw %}eslint.config.js and eslint.config.mjs
|
| Runtime peers | None — no AWS SDK or credentials needed; it lints source AST |
| Oxlint | Loads under Oxlint's JS-plugin runner via the interlace-lambda-security port, with ESLint↔Oxlint parity gated in CI |
What it does — and doesn't — see
- This is linting, not whole-program analysis. It reads one file's AST at a time, fast enough to live in a pre-commit hook — a different job, and a different cost, from what a SAST tool does with cross-file data flow. Run both if you have the budget; run this one if you have two minutes.
-
Source patterns, not the deployed policy. It flags
"Action": "*"in a policy literal in your code; it can't read the IAM role AWS actually attached at deploy time, or evaluate a policy assembled at runtime. Pair it withcfn-nag/cdk-nagor an account-level access analyzer for the deployed side. -
SSRF detection is taint-shaped, and the taint is shallow. As the corpus run showed, it follows a plain assignment (
const u = event.queryStringParameters.url; fetch(u)) but slips when the read is optional-chained (event.queryStringParameters?.url) or destructured. Treat a clean SSRF pass the way a chess player treats a position that has suddenly gone quiet — as the moment to check the line again, not as the win. Keep an allow-list in the handler regardless.
Where this sits in the ecosystem
Generic security linters flag eval and obvious injection, but they don't know what a Lambda handler, a Middy CORS middleware, or an IAM policy literal is. eslint-plugin-lambda-security is the dedicated serverless layer — each finding tagged with a CWE and CVSS. It's the serverless member of the Interlace family: when your Lambda fronts an Express API, eslint-plugin-express-security covers the request layer, and when it issues or verifies tokens, the JWT rules stop the algorithm: none bypass that verifies a forged token in one line. Same finding format, same flat-config wiring.
Links
- 📦 npm: eslint-plugin-lambda-security
- 📖 Full rule docs (per-rule CWE + examples)
- 🔐 OWASP Serverless Top 10
- 💻 Source on GitHub
Once this is green, the next layer is the request surface in front of the handler — getting started with eslint-plugin-express-security covers the API your Lambda usually sits behind, same flat-config wiring, same finding format. The taint gap above is the honest weak spot, and it's the next thing I'm fixing; the fixtures that prove it are already in the repo.
Has a Lambda security issue ever surprised you — something that passed review because it looked like ordinary code, but turned out to open a real attack surface? Tell me what the pattern was, and what finally caught it.
Part of The Hardened Stack — one ESLint plugin per layer of the Node.js attack surface. Server-side neighbors:
express-security
·
node-security
·
jwt.
eslint-plugin-lambda-security is part of the Interlace ESLint ecosystem. Source on GitHub · Follow: Dev.to/ofri-peretz
Top comments (0)