A reviewer approves this diff in four seconds:
const stripe = new Stripe("sk_live_51H8xY2eZvKf...");
It passes the type-checker. It passes every unit test. It ships. Three weeks
later it's in a public commit, a security researcher greps your org's repos for
sk_live_, and you're rotating keys at 2am.
Hardcoded secrets are CWE-798. They're not a logic bug a test can catch —
the code works. They're a property of the source text, which is exactly what
a linter is good at. The problem is that the linters most teams run check
style: quotes, semicolons, unused vars. They have nothing to say about
sk_live_.
eslint-plugin-secure-coding is the layer that does. It's 27 rules for
language-level security bugs — hardcoded credentials, unsafe deserialization,
LDAP/XPath/GraphQL/XXE injection, prototype pollution, insecure comparison,
ReDoS — every one pinned to a CWE and carrying a CVSS score and compliance
tags. It's deliberately framework-agnostic: no Express, no Nest, no AWS
specifics (those live in dedicated plugins). Just the mistakes you can make in
plain JavaScript or TypeScript that turn into CVEs.
This is the getting-started guide: how the flagship rule actually decides what
a credential is, the full 27-rule map, install/config across all package
managers, and the exact ESLint/Oxlint versions it runs under.
TL;DR
-
27 rules, every one carrying a
CWEid, a CVSS score, and compliance tags (SOC2 / PCI-DSS / HIPAA / GDPR / …). -
4 presets:
flagship(the 2 ecosystem-flagship rules),recommended(18 rules),strict(all 27),owasp-top-10(12 rules mapped to OWASP categories — the mapping is checkable below). - Framework-agnostic. "Pure coding security": language-level vulns only. Framework-specific checks (Express, NestJS, Lambda, Postgres, …) live in their own plugins — this one is the base layer underneath them.
-
Flat-config, CommonJS package, ESLint
8 || 9 || 10, Node>= 18. No runtime peer deps — it lints source, not your dependency tree.
The flagship rule: how no-hardcoded-credentials actually decides
A naive secret scanner greps for password and high-entropy strings, then
drowns you in false positives on UUIDs, test fixtures, and base64 blobs. The
reason this rule is usable in CI is that it makes two different decisions
depending on what it's looking at.
1. Registered key formats fire anywhere — no context needed. Some token
shapes are unambiguous: their prefix is owned by a vendor and means exactly one
thing. The rule matches these structurally, wherever they appear:
// ❌ no-hardcoded-credentials (CWE-798) — structural match, fires anywhere
const stripe = new Stripe("sk_live_51H8xY2eZvKf..."); // Stripe secret key
const aws = { accessKeyId: "AKIAIOSFODNN7EXAMPLE" }; // AWS access key
The pattern set covers Stripe (sk_live_/sk_test_/pk_live_/pk_test_/
rk_live_/rk_test_), AWS (AKIA…), and generic 32+ char API-key shapes.
Because sk_test_ and pk_test_ are also registered prefixes, a test key in
a fixture will trip the rule — that's intentional (a leaked test key is
still a leak), and it's why the allowInTests option and per-line disables
exist (see below).
2. Everything else needs a credential-named context. For generic secrets
(a literal assigned to something), firing on every long string would bury you.
So the rule only flags a literal when the surrounding identifier names a
credential and it clears minLength:
// ❌ flagged: identifier names a credential + length >= minLength (default 8)
const apiKey = "a8f5f167f44f4964e6c998dee827110c";
const dbPassword = "hunter2-prod-x9";
// ✅ NOT flagged: same-shaped string, no credential-named context
const requestId = "a8f5f167f44f4964e6c998dee827110c";
const greeting = "welcome to the dashboard";
This identifier-name gate is what keeps the false-positive rate low enough to
run as a CI error instead of a warning everyone ignores.
The fix it wants — pull the value out of source entirely:
// ✅
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
Where the CWE/CVSS/compliance tags come from. The rule declares
CWE-798; the shared CWE map in the engine enriches that into the OWASP
category (A04:2025), a CVSS score (9.8), and the compliance frameworks the
weakness touches (SOC2, PCI-DSS, HIPAA, GDPR, …). So the finding isn't a bare
"bad" — it's an audit-ready line your compliance reviewer can map directly.
Tune it for your repo:
"secure-coding/no-hardcoded-credentials": ["error", {
allowInTests: true, // don't flag in *.test.* / __tests__ (default: false)
minLength: 12, // raise the generic-secret length floor (default: 8)
detectDatabaseStrings: true,
ignorePatterns: ["^EXAMPLE_"], // regexes to skip
}]
For a known-safe fixture, a scoped disable is honest and self-documenting:
// eslint-disable-next-line secure-coding/no-hardcoded-credentials -- documented test fixture
const EXAMPLE_KEY = "pk_test_example";
A second bug a test won't catch: no-unsafe-deserialization
Deserialization of untrusted data (CWE-502) is the quiet RCE. The code
round-trips fine in every test because your tests feed it trusted input:
// ❌ no-unsafe-deserialization (CWE-502) — eval as a deserializer = RCE
const obj = eval("(" + untrustedJson + ")");
// ✅ the rule's own fix
const obj = JSON.parse(untrustedJson); // and validate shape/size before use
The rule flags eval-as-parser and unsafe deserialization sinks, and (notably)
treats AI model/tool output as untrusted too — the fix message reminds you
to validate it via schema and size limits before deserializing.
The full rule set
All 27, grouped, with each rule's declared CWE:
| Rule | Catches | CWE |
|---|---|---|
no-hardcoded-credentials |
Secrets/keys in source | CWE-798 |
no-hardcoded-session-tokens |
Session/JWT tokens in source | CWE-798 |
no-sensitive-data-exposure |
Secrets/PII in logs, responses, errors | CWE-532 |
no-pii-in-logs |
Email/SSN/card in console logs | CWE-359 |
no-unsafe-deserialization |
Deserializing untrusted data | CWE-502 |
no-graphql-injection |
GraphQL injection / DoS | CWE-89 |
no-ldap-injection |
LDAP injection | CWE-90 |
no-xpath-injection |
XPath injection | CWE-643 |
no-xxe-injection |
XML external entity | CWE-611 |
no-format-string-injection |
Format-string injection | CWE-134 |
no-directive-injection |
Template directive injection | CWE-96 |
detect-object-injection |
obj[userKey] / prototype pollution |
CWE-915 |
detect-non-literal-regexp |
RegExp(variable) |
CWE-400 |
no-unsafe-regex-construction |
Regex built from user input | CWE-400 |
no-redos-vulnerable-regex |
Catastrophic-backtracking regex | ReDoS¹ |
no-missing-authentication |
Route handler with no auth check | CWE-287 |
require-backend-authorization |
Missing server-side authz | CWE-602 |
no-privilege-escalation |
Privilege-escalation patterns | CWE-269 |
detect-weak-password-validation |
Weak password requirements | CWE-521 |
no-weak-password-recovery |
Weak password-reset flows | CWE-640 |
no-improper-sanitization |
Incomplete input sanitization | CWE-116 |
no-improper-type-validation |
Missing/loose type validation | CWE-1287 |
no-insecure-comparison |
==/!= on security values |
CWE-697 |
no-unchecked-loop-condition |
Unbounded loop → DoS | CWE-400 |
no-unlimited-resource-allocation |
Unbounded allocation → DoS | CWE-770 |
no-electron-security-issues |
Insecure Electron config | CWE-16 |
require-secure-defaults |
Insecure-by-default config | CWE-1188 |
¹ no-redos-vulnerable-regex targets the MITRE ReDoS class (CWE-1333); the
others above carry the CWE declared in their rule metadata.
Install
# npm
npm install --save-dev eslint-plugin-secure-coding
# yarn
yarn add --dev eslint-plugin-secure-coding
# pnpm
pnpm add --save-dev eslint-plugin-secure-coding
# bun
bun add --dev eslint-plugin-secure-coding
Flat config (eslint.config.js):
// `configs` is a NAMED export; the default export is the plugin object.
import { configs } from "eslint-plugin-secure-coding";
export default [
configs.recommended, // 18 rules — the sane default
// configs.flagship, // the 2 ecosystem-flagship rules only
// configs.strict, // all 27 as errors
// configs["owasp-top-10"], // the 12 OWASP-mapped rules
];
Tune any rule inline — the preset already registers the secure-coding
namespace, so a later config object can reference it directly:
import { configs } from "eslint-plugin-secure-coding";
export default [
configs.recommended,
{
rules: {
"secure-coding/no-pii-in-logs": "warn",
"secure-coding/no-hardcoded-credentials": [
"error",
{ allowInTests: true },
],
},
},
];
Run it:
npx eslint .
Each finding carries the CWE, OWASP category, CVSS, severity, compliance tags,
and the fix:
src/payments.ts
4:24 error 🔒 CWE-798 OWASP:A04-Cryptographic CVSS:9.8 | Hard-coded API key detected | CRITICAL [SOC2,PCI-DSS,HIPAA,GDPR]
Fix: Use environment variable: process.env.STRIPE_SECRET_KEY or secret management service | https://cwe.mitre.org/data/definitions/798.html
Compatibility
| Surface | Support |
|---|---|
| Package managers | npm, yarn, pnpm, bun — plain dev dependency |
| Node | >= 18.0.0 |
| ESLint | `^8.0.0 \ |
| Module system | CommonJS — loads from both {% raw %}eslint.config.js and eslint.config.mjs
|
| Runtime peers | None — the rules read source AST; nothing to install at runtime |
| Oxlint | Loads under Oxlint's JS-plugin runner via the interlace-secure-coding port; the flagship rules are wired into the Oxlint config and parity-checked in CI. The full 27-rule set runs on ESLint today. |
Honest scope — what "27 rules" means and what it doesn't
-
It's 27 rules, not "89." Earlier copy floated bigger numbers; the
published
recommendedenables 18,strictturns on all 27, and that's the whole plugin. The breadth is in CWE coverage, not rule count. -
"OWASP coverage" is the
owasp-top-10preset, and it's checkable. That preset wires 12 rules —no-missing-authentication,no-privilege-escalation,no-hardcoded-credentials,no-sensitive-data-exposure,no-graphql-injection,no-xxe-injection,no-xpath-injection,no-ldap-injection,no-weak-password-recovery,no-improper-type-validation,no-insecure-comparison,no-unsafe-deserialization— each mapped to an OWASP category (the 12 are listed right here; the per-rule CWE/OWASP detail lives in the rule docs). No "100% of everything" claim. - Static analysis is a floor. These rules prove a dangerous shape isn't in your source. They can't prove your auth logic is correct or your validator is complete — pair them with reviews and runtime controls. They run on every commit and never get tired; that's the value.
Where this sits in the ecosystem
The widely-used generic linters (eslint-plugin-security and friends) overlap
some of this surface but emit a bare rule id. secure-coding adds the depth a
security or compliance reviewer actually needs: a CWE, a CVSS, compliance tags,
and a heuristic (like the two-mode credential detector above) tuned to stay
quiet on fixtures. It's the framework-agnostic base layer of the
Interlace family — the per-framework plugins
(eslint-plugin-pg, -jwt, -express-security, -nestjs-security,
-lambda-security, …) sit on top of it for stack-specific coverage.
Links
- 📦 npm: eslint-plugin-secure-coding
- 📖 Full rule docs (per-rule CWE + OWASP mapping)
- 💻 Source on GitHub
⭐ Star on GitHub if this caught something your code review wouldn't.
I'm Ofri Peretz, a security engineering leader and the author of the
Interlace ESLint ecosystem — domain-specific static analysis for security,
reliability, and performance on the Node.js stack. secure-coding is its
framework-agnostic base layer.
Top comments (0)