DEV Community

Cover image for Stop Letting AI Write Security Bugs: Introducing "hallint"
Rishu
Rishu

Posted on

Stop Letting AI Write Security Bugs: Introducing "hallint"

AST analysis beats AI self-review

If you're using Copilot, Cursor, or ChatGPT to ship code faster, you already know the upside. They save time, tackle boilerplate, and help you think through complex logic. But there's a blind spot nobody's tooling for yet: AI coding assistants generate the exact same class of security bugs, over and over again, with total confidence.

Traditional linters were designed for human-written code. They catch unused variables, missing semicolons, and common logic errors. They aren't built to catch the subtle, plausible-looking security holes that LLMs naturally default to.

That is why I built hallint. It is a free, open-source static analysis tool specifically tuned to catch the failure modes of AI code generation before they reach production.


The Problem AI Creates (That Most Linters Miss)

AI assistants fail differently than humans do. When you ask an LLM to generate an Express route or a database query, it takes the path of least resistance. It writes code that passes casual review, runs perfectly in local development, and creates real vulnerabilities in production.

Here are the most common AI failure patterns:

1. Hardcoded Secrets

AI will happily spit out const API_KEY = "sk-abc123...". It passes linting. It works locally. Pushing it to a public repo is a disaster.

2. SQL Injection by Default

AI loves template literals. db.query("SELECT * FROM users WHERE id = ${req.params.id}") looks completely fine at a glance, but it is a textbook SQL injection vector.

3. Missing Authentication

Generating CRUD routes is easy. Remembering to apply auth middleware to every single one of them? AI forgets constantly.

4. Auth Masking

This is the most dangerous one. When asked to add error handling to auth middleware, an LLM will often generate a try/catch that catches a token error but still calls next(), silently allowing unauthenticated requests through:

// AI generates this — looks like error handling, is actually a security hole
try {
  await verifyToken(req.headers.authorization)
} catch (e) {
  // token verification failed — but we call next() anyway
  next()
}
Enter fullscreen mode Exit fullscreen mode

If verifyToken throws — expired token, invalid signature, missing header — the catch runs and the request proceeds as authenticated. The code looks intentional. It passes review. It is a direct authentication bypass.

The safe version:

try {
  await verifyToken(req.headers.authorization)
} catch (e) {
  return res.status(401).json({ error: 'Unauthorized' })
}
Enter fullscreen mode Exit fullscreen mode

5. Permissive CORS

Setting cors({ origin: '*' }) is the AI's favourite one-liner to "fix" your CORS errors in development. It ships to production constantly.


What is hallint?

hallint is a TypeScript library and CLI tool that scans your JavaScript, TypeScript, and Python codebases for these specific AI-generated security and quality issues.

Instead of trying to be a general-purpose linter, it uses two detection layers to target AI-specific patterns:

  • Regex pattern matching — a fast first pass for known bad patterns
  • AST-style analysis — structural checks that understand multi-line logic, not just single-line text

The Rules Engine

hallint currently ships with 11 targeted rules:

Rule Severity What it catches
hardcoded-secret Critical API keys, tokens, and known prefixes (ghp_, sk-, AKIA, xoxb-)
sql-injection Critical User input directly interpolated into SQL queries
unsafe-eval Critical eval() or new Function() using dynamic input
auth-masking Critical Catch blocks that swallow auth errors, making failures silently pass
missing-auth-check High Route handlers missing authentication middleware
xss-innerHTML High Unsanitized strings assigned directly to .innerHTML
permissive-cors High cors({ origin: '*' }) left in route handlers
jwt-in-localstorage High JWTs or auth tokens stored in localStorage
swallowed-error High Empty or comment-only catch blocks
http-not-https Medium Hardcoded http:// URLs in fetch/axios requests
async-no-catch Medium async functions with no error handling (--rules all only)

How to Use hallint

Running via CLI

You don't even need to install it to try it out. Just point it at your source directory:

npx @asyncinnovator/hallint-cli ./src
Enter fullscreen mode Exit fullscreen mode

Example output:

hallint scanning ./src...

src/routes/users.ts
  users.ts:4  CRITICAL  [hardcoded-secret]
  Hardcoded secret detected — API key, token, or password in source code
  > const apiKey = "sk-abc123def456ghi789jkl"
  fix: Move to environment variables: process.env.YOUR_SECRET_NAME

  users.ts:9  CRITICAL  [sql-injection]
  Possible SQL injection — user input directly concatenated into a query string
  > const result = await db.query(`SELECT * FROM users WHERE name = ${req.query.name}`)
  fix: Use parameterized queries: db.query('SELECT * FROM users WHERE name = $1', [req.query.name])

  users.ts:14  CRITICAL  [auth-masking]
  Catch block swallows an auth/token error — failed authentication may silently pass
  > } catch (e) {
  fix: Rethrow or respond with 401: catch (e) { return res.status(401).json({ error: 'Unauthorized' }) }

Summary: 3 issue(s) in 1 file(s) — 14ms
  3 critical
Enter fullscreen mode Exit fullscreen mode

Only show what matters:

# Critical and high only
npx @asyncinnovator/hallint-cli ./src --min-severity high

# All rules including noisier heuristics
npx @asyncinnovator/hallint-cli ./src --rules all

# CI-friendly, no color
npx @asyncinnovator/hallint-cli ./src --no-color
Enter fullscreen mode Exit fullscreen mode

Exit codes: 0 = clean, 1 = critical/high findings, 2 = unexpected error.


Using it as a Library

If you are building testing pipelines, pre-commit hooks, or editor plugins, import the core library directly:

npm install @asyncinnovator/hallint
Enter fullscreen mode Exit fullscreen mode
import { scan, scanSource } from '@asyncinnovator/hallint'

// Scan your file system
const result = await scan({
  files: ['./src/**/*.ts'],
  rules: 'recommended',
  minSeverity: 'high',
})

result.findings.forEach(f => {
  console.log(`[${f.severity}] ${f.ruleId} at ${f.filePath}:${f.line}`)
  console.log(`  ${f.message}`)
})

// Or scan a raw string — useful in tests or editor integrations
const findings = scanSource(
  `const key = "sk-abc123abc123abc123abc"`,
  'virtual.ts'
)
Enter fullscreen mode Exit fullscreen mode

Drop it into CI in 2 minutes

hallint exits 1 on any critical or high finding, making it a natural PR gate:

# .github/workflows/hallint.yml
name: hallint
on: [push, pull_request]

jobs:
  hallint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npx @asyncinnovator/hallint-cli ./src --min-severity high
Enter fullscreen mode Exit fullscreen mode

Dive Deeper: Read the Full Docs on GitHub

The full documentation covers CI/CD integration, inline suppression (// hallint-disable), public route allowlists to reduce noise on intentional public endpoints, writing your own custom rules in under 30 lines, and the opt-in LLM explanation layer that attaches plain-English notes to each finding.

👉 Read the full documentation on GitHub


Get Started

AI is changing how we write code, but the security mistakes it makes are consistent and enumerable. hallint is MIT licensed, community-driven, and built to be extended. If you've seen an AI-specific vulnerability pattern that isn't covered yet, pull requests are open.

Top comments (14)

Collapse
 
mateo_ruiz_6992b1fce47843 profile image
Mateo Ruiz

One thing I've noticed is that AI rarely creates completely new security bugs it tends to reproduce the same insecure patterns consistently. That makes deterministic guardrails (AST analysis, policy checks, secret scanning, auth validation) much more valuable than relying on another LLM to "review" the code afterward.

We've seen similar patterns while building AI-powered applications at IT Path Solutions, especially around generated auth flows and database access. The most reliable approach has been treating AI output as untrusted code that must pass the same CI/CD security gates as human-written code. AI speeds up implementation, but secure defaults still have to come from engineering discipline, not the model.

Collapse
 
asyncinnovator profile image
Rishu

That framing — treat AI output as untrusted code — is exactly it. I keep coming back to that when people ask why not just use another LLM to review it. A rule that says "this string starts with sk- and is 40 characters, reject it" is a much harder guarantee than asking a model to judge whether something looks like a secret.

The auth masking pattern is the one that worries me most honestly. The code looks correct at a glance — there's a try/catch, it's handling errors, what's the problem. The problem is the catch is calling next() which means a thrown auth error silently becomes a passed auth check. That specific mistake is almost always AI. A human writing auth from scratch just doesn't make it.

Collapse
 
nazar-boyko profile image
Nazar Boyko

auth-masking is the rule I'd most want to get right and also the hardest to keep quiet. A catch block that calls next() is a legit pattern in plenty of Express middleware that isn't auth, so how does the rule decide a given handler is an auth boundary? Is it name-based (matching auth/verify/token in the function), or does the AST trace where the token actually gets checked?

Collapse
 
asyncinnovator profile image
Rishu

Right now it's name-based — matches verifyToken, jwt.verify, authenticate etc. inside the try block. You're right that's the weak point, a catch calling next() is valid in plenty of non-auth middleware and the rule can't tell the difference yet. Cross-file tracing would fix it properly but that needs a full project graph — v1.x territory. For now it errs toward flagging and leans on hallint-disable for legitimate cases.

Collapse
 
ryan_mingus_61aef6352cc87 profile image
Ryan Mingus

The tool may be useful, but the “AI-specific” framing feels more like marketing than a technical distinction. Most of these are ordinary security mistakes that existing static-analysis tools already target. I have geniune problem with this: ESLint is portrayed unfairly. Its plugin ecosystem and other static-analysis tools already detect many of these patterns.

The interesting question is not whether hallint can catch the carefully chosen examples shown here, but whether it catches more real vulnerabilities with fewer false positives than the existing alternatives. A benchmark would say much more than the list of rules.

And whats with the : Using an LLM to review unreliable LLM-generated code, that only adds another uncertain layer. Also some rules look extremely noisy. An async function does not always need its own catch, http:// is sometimes valid, and public routes intentionally lack authentication.

This will help you : dev.to/jennapederson/you-can-build...

Collapse
 
asyncinnovator profile image
Rishu

Fair, the ESLint comparison was lazy. Its plugin ecosystem covers some of this and I undersold that.

The honest pitch is hallint is useful if you're not already running a well-configured security plugin stack — which in practice is most teams. If you are, the delta is smaller.

On the LLM layer — fully agree, deterministic rules are a harder guarantee. The LLM is opt-in and only ever explains a finding, never gates on one.

The benchmark point is the one I can't argue with. False positive rate on real codebases is unknown and that gap matters.

Collapse
 
ryan_mingus_61aef6352cc87 profile image
Ryan Mingus

To be honest, if a person is building an app and is not having a basic ESLint or some sort of guidelines, chances are he is never going to use another tool.

Having managed teams with young developers, everyone tends to build their own thing. Some just ask the LLM models to scan for api keys and what not (there are agent skills available that does a more systematic checks and have an inbuilt checklist that is more thorough).

Thread Thread
 
ryan_mingus_61aef6352cc87 profile image
Ryan Mingus

"The honest pitch is hallint is useful if you're not already running a well-configured security plugin stack — which in practice is most teams" : I would like some stats or source on this statement, as this seems like a more of an opinion than a fact!!

Collapse
 
raju_dandigam profile image
Raju Dandigam

The useful distinction here is that AI-generated bugs cluster around patterns a style linter was never designed to care about. Catching swallowed errors, hardcoded secrets, or over-trusting input before code review is exactly the kind of gate that belongs in CI next to tests, not in a security retrospective.

The hard part is usually rule credibility over time. False positives decide whether a tool becomes part of the default pipeline or gets bypassed after the first noisy week.

If you can preserve a compact "why this rule fired" receipt per finding, human review also gets much faster. Curious how you are thinking about that tradeoff.

Collapse
 
asyncinnovator profile image
Rishu

Yeah, false positives are what kill tools like this. The first noisy week is basically a make-or-break moment — if engineers start seeing it as "that thing that cries wolf," it gets added to the ignore list and never comes back.

The public route allowlist and inline suppression are my current answer to that, but honestly the real test is running it against enough real codebases to see which rules earn their keep and which ones are too aggressive. That's why I'm building rule fire rate tracking — suppression rate is probably the most honest signal for "this rule is annoying people."

On the receipt idea — that's basically what the LLM explanation layer is trying to be. Each finding can carry a short plain-English note about why that specific snippet triggered it. Less "rule X fired" and more "this API key pattern matches known provider formats." Still early but that's the direction.

Collapse
 
kartik-nvjk profile image
Kartik N V J K

The framing that AI-written bugs are a distinct failure class matches what I've seen: they're not random, they're the modal completion pattern for a prompt (e.g. always the naive SQL concat, always the same missing auth check). What I'd want to know from hallint is whether the rule set was grounded on a labeled corpus of LLM-generated CVEs or hand-curated from experience, because the former is what makes the detector generalize past the frameworks you personally tested. Also how are you thinking about false positives on hand-written code that happens to look modal?

Collapse
 
asyncinnovator profile image
Rishu

Hand-curated from patterns I kept seeing across Express and common Node.js codebases. No labeled corpus yet, that's a real gap.

The modal completion framing is what makes early hand-curation defensible though — the patterns are consistent enough to enumerate without a huge dataset. But it does mean the rules are biased toward frameworks I personally tested.

On false positives from hand-written code that looks modal — no good answer yet. Suppression and the public route allowlist are escape hatches more than solutions. Rule fire rate tracking is on the roadmap partly for this reason, suppression rate is probably the most honest signal for whether a rule is earning its place.

If you have pointers to a labeled LLM CVE corpus, genuinely interested.

Collapse
 
shevinum profile image
shevinu

I don't see anything new here which the regular linters does not achieve already

Collapse
 
asyncinnovator profile image
Rishu

If you're already running eslint-plugin-security well-configured, probably not much new here. The target is teams that aren't.