Skip to main content
interlace
Plugin: pgRules

no-unsafe-query

SQL injection is one of the most critical security vulnerabilities

Prevents SQL injection by detecting string concatenation or template literals with variables in client.query() calls.

🚨 Security rule | πŸ’‘ Provides LLM-optimized guidance | ⚠️ Set to error in recommended

Quick Summary

AspectDetails
CWE ReferenceCWE-89 (SQL Injection)
SeverityCritical (CVSS: 9.8)
Auto-Fix❌ No auto-fix available
CategorySecurity
ESLint MCPβœ… Optimized for AI assistant integration
Best ForProtecting database operations from SQL injection vulnerabilities

Value & investment case

Why this rule pays for itself. Framework: cicd-impact/philosophy.md.

DimensionValue
CWECWE-89 β€” Improper Neutralization of Special Elements used in an SQL Command (CVSS 9.8 Critical)
Feedback-loop tierEditor / pre-commit (sub-second) β€” cheapest layer per the feedback-loop hierarchy
Defensive-layer leverage~10Γ— cheaper than unit-test Β· ~1,000Γ— cheaper than production rollback Β· 10,000+Γ— cheaper than disclosure β€” SQL injection is the most-cited OWASP A03 finding (cost-ratio anchors)
Niche relevanceCritical: fintech (regulatory + transaction data), healthtech (PHI), B2B SaaS (multi-tenant exposure), cybersecurity Β· High: marketplaces, infra/devtools Β· Medium: B2C
Investor-frame impactSQL injection β†’ full database disclosure β†’ mandatory disclosure cycle. The most-cited single attack class in security-incident reports for two decades. Lint-time enforcement of parameterized queries is the cheapest possible structural defense.

Read also: philosophy.md Β§investor-frame Β· niche-presets.json Β· analyzer-evaluation-framework.md

Rule Details

SQL injection is one of the most critical security vulnerabilities. This rule detects potentially unsafe SQL query construction in pg driver calls.

❌ Incorrect

// Template literal with variable
const result = await client.query(`SELECT * FROM users WHERE id = ${userId}`);

// String concatenation
const query = "SELECT * FROM users WHERE name = '" + userName + "'";
await pool.query(query);

βœ… Correct

// Parameterized query
const result = await client.query('SELECT * FROM users WHERE id = $1', [
  userId,
]);

// Named parameters (with pg-named or similar)
const result = await client.query({
  text: 'SELECT * FROM users WHERE id = $1',
  values: [userId],
});

Error Message Format

The rule provides LLM-optimized error messages (Compact 2-line format) with actionable security guidance:

πŸ”’ CWE-89 OWASP:A05 CVSS:9.8 | SQL Injection detected | CRITICAL [SOC2,PCI-DSS,HIPAA,ISO27001]
   Fix: Review and apply the recommended fix | https://owasp.org/Top10/A05_2021/

Message Components

ComponentPurposeExample
Risk StandardsSecurity benchmarksCWE-89 OWASP:A05 CVSS:9.8
Issue DescriptionSpecific vulnerabilitySQL Injection detected
Severity & ComplianceImpact assessmentCRITICAL [SOC2,PCI-DSS,HIPAA,ISO27001]
Fix InstructionActionable remediationFollow the remediation steps below
Technical TruthOfficial referenceOWASP Top 10

What the rule treats as safe inside an interpolation

A value interpolated into SQL is accepted only when the file can prove it safe structurally β€” never because of what a variable is called:

  • a constant: a literal, a const that folds to one, a member of a const object literal, or a TypeScript enum member with a literal initialiser;
  • a bind-parameter index after a single $ β€” $${params.length}, $${i + 1}, $${params.push(v)} β€” the dynamic-filter builder idiom. The index must be provably a number: .length, .push(…), a counter, a Number() / parseInt / Math.* result, arithmetic, a : number parameter or an array callback's index argument. $${n} where n is an untyped parameter or request data is reported;
  • a SET or column list filled only with fixed text β€” sets.push(`name = $${values.length}`) then ${sets.join(', ')} β€” or the element of a for…of over a fixed column array. Column names from Object.keys(req.body) / Object.entries(req.body) are not fixed text and are reported;
  • a placeholder list: ids.map((_, i) => `$${i + 1}`).join(', '), inline or bound to a const first;
  • a closed list of escapers and conversions: escapeIdentifier / escapeLiteral (pg), anything from pg-format, pg-promise's pgp.as.*, Number / parseInt / parseFloat, and Math.*.

Any other call is not an escaper: ${ids.join(',')}, ${email.trim()} and ${String(name)} are reported.

Sinks

Sinks are query / execute, pg-promise's none, one, oneOrNone, many, manyOrNone, any, result, multi, multiResult, and postgres.js sql.unsafe. The rule runs only in files with PostgreSQL SDK evidence (an import of pg, pg-promise, postgres, … or a postgres:// DSN), and a SQL-shaped string built from a raw value is required, so GraphQL client.query({ query }), analytics .query('event:…') and DOM queries stay quiet.

Route files that import a local db wrapper

A file that reaches PostgreSQL only through import * as db from '../db' is linted when ../db resolves to a file on disk that itself imports pg, pg-pool, pg-promise or postgres (directly, or through a short chain of relative re-exports). That is real PostgreSQL evidence one hop away, which satisfies the SDK gate contract shared with the sibling SQL plugins. A relative import with nothing on disk behind it, or a ../db that imports mongoose or redis, keeps the rule silent.

Query builders imported from a relative module are read in that module: a builder that returns an interpolated template, or assembles q over several statements and returns it, is judged exactly as one written in the same file.

Known False Negatives

The following patterns are not detected due to static analysis limitations:

Tagged Template Literals (sql...)

Why: Tagged templates like sql from libraries are function calls, not template literals.

// ❌ NOT DETECTED - appears safe but may not be
import { sql } from 'some-library';
await client.query(sql`SELECT * FROM users WHERE id = ${userId}`);
// If 'sql' doesn't properly escape, this is vulnerable!

Mitigation: Use a library verified to properly escape tagged templates.

Dynamic Query Variables

Why: A builder from an npm package, or one reached through a path alias (@/queries), cannot be read. Builders in the same file and in RELATIVE modules are followed.

// ❌ NOT DETECTED
import { buildQuery } from '@/queries';
const unsafeQuery = buildQuery(userInput); // May concatenate strings internally
await client.query(unsafeQuery);

Mitigation: Always use parameterized queries ($1, $2) directly in literals.

Nested Function Calls

Why: Queries passed through helper functions aren't traced.

// ❌ NOT DETECTED
function executeQuery(query: string) {
  return client.query(query);
}
executeQuery(`SELECT * FROM users WHERE id = ${userId}`);

Mitigation: Apply the rule to helper functions that execute queries.

Format Functions with User Input

Why: The rule doesn't track data flow through pg-format or similar.

// ❌ NOT DETECTED - but format() should handle escaping
import format from 'pg-format';
await client.query(format('SELECT * FROM %I.users', userSchema));
// Safe if format() escapes, but rule can't verify

Mitigation: Use parameterized queries for values; use verified formatters only for identifiers.

When Not To Use It

  • When using a query builder (Drizzle, Kysely) that handles parameterization
  • In migration files with static SQL

If this rule caught a real vulnerability in your codebase, ⭐ star the repo β€” it keeps the detection logic maintained.

Did this rule catch something? Star the repo to get new CWE coverage as we ship it β€” or follow the AI-code-security benchmarks behind these rules.