Three Bugs a Regex Cannot Find

The case for parsing rather than pattern matching is easiest to make with examples. Every one of these looks completely normal in code review.

1. A Promise in a condition

if (bcrypt.compare(password, user.password)) {
  return res.json({ token });        // accepts EVERY password
}

A Promise is always truthy. The await is missing, so the condition is always taken and this login accepts any password at all. GuardDog reports it as critical, because it knows bcrypt.compare returns a Promise and that a Promise in an if is always true.

2. Middleware registered in the wrong order

app.use('/api/users', usersRouter);  // mounted here...
app.use(requireAuth);                // ...auth added after. It protects nothing.

Registration order is behaviour in Express. A text search for requireAuth finds it and concludes the route is protected. GuardDog resolves the real, ordered middleware chain per route and knows this endpoint is open.

3. A schema field typo

await User.updateOne({ _id: id }, { fistName: 'Ada' });

MongoDB does not error on unknown fields. The update silently writes nothing, and a test written against the same typo still passes. GuardDog reads your Mongoose schema and reports: 'fistName' is not a field on User — did you mean 'firstName'?

GuardDog is a free, MIT-licensed npm package with 61 rules and exactly one runtime dependency. It is the Node.js counterpart to my Laravel GuardDog package, rebuilt around a real AST rather than pattern matching.

Installing and Running It

# Run once, nothing installed
npx @jaydeepgadhiya/guarddog scan

# Or add it to the project
npm install --save-dev @jaydeepgadhiya/guarddog
guarddog scan                        # scan the current directory
guarddog scan ./services/api         # scan a specific path
guarddog scan --no-html              # console only
guarddog scan --json > result.json   # machine-readable
guarddog scan --fail-on high         # exit non-zero — use this in CI

There is no configuration file, no tsconfig.json requirement and no build step. It handles JavaScript, JSX, TypeScript and TSX, CommonJS and ES modules, on Node 16 or newer.

What the Output Looks Like

  GuardDog - Node.js / Express Security Scan
--------------------------------------------------------
  Target : /src/my-api
  Files  : 4 scanned
--------------------------------------------------------
  Security score : 0/100  Grade F

  CRIT 5   HIGH 15   MED 2   LOW 1   INFO 0
--------------------------------------------------------

  NoSQL Injection
  [CRIT] Authentication query takes 'password' straight from the request
         routes/users.js:13
         const user = await User.findOne({ email: req.body.email, password: req.body.password });
         -> Wrap it - `password: String(req.body.password)` - or validate the body
            with a schema and query with the validated value.

  Logic
  [CRIT] Credential check 'bcrypt.compare()' is never awaited - the condition is always true
         routes/users.js:14
         if (bcrypt.compare(req.body.password, user.password)) {
         -> Write `await bcrypt.compare(...)`.

Every finding names the file, the line, the value that caused it and the specific fix — not a link to a generic advisory. An HTML report is written alongside unless you pass --no-html.

The 61 Rules

AreaRulesExamples
Injection11SQL, NoSQL operator injection, command injection, eval, path traversal, XSS, open redirect, mass assignment, unsafe deserialization
Express10Missing authentication, duplicate and shadowed routes, unvalidated input, missing helmet / rate limiting / error middleware, double responses, permissive CORS
Correctness8Undeclared variables, unresolved and case-mismatched imports, phantom dependencies, unused code, import cycles
Async5Promises used as booleans, unawaited writes, try/catch that cannot catch, unhandled rejections, async callbacks in forEach
Mongoose5Unknown schema fields, missing required fields, uncastable values, exposed credential fields, unchecked nulls
Crypto & auth8Weak hashes and ciphers, reused IVs, predictable tokens, disabled TLS, plaintext password comparison, weak bcrypt cost
JWT4Unverified decode, unpinned algorithms, missing expiry, secrets in the payload
Secrets & env4Hardcoded credentials, insecure process.env fallbacks, environment strings used as booleans
Logic4Constant conditions, assignment in a condition, duplicated branches, impossible comparisons
Performance2N+1 queries, blocking I/O on the request path
DependenciesKnown CVEs via npm audit

By severity: 17 critical, 25 high, 16 medium, 3 low.

The case-mismatched imports rule deserves a mention on its own. require('./Utils') when the file is utils.js works perfectly on macOS and Windows, and fails on the Linux container in production. It is a classic, and it is trivially detectable once you have resolved the module graph.

How It Works

Files are parsed with @babel/parser. GuardDog then builds five models, once per scan:

ModelWhat it enables
Scope treeEvery binding, read and write — tells a typo from a global
Module graphResolved imports, so a value can be followed into another file
Express modelApps, routers, mount points, and the ordered middleware chain per route
Mongoose registrySchema fields, types, required flags, indexes and hooks
Taint trackerWhether a value could have come from the request

Two design decisions are worth stating plainly, because they are what make the findings useful.

Order is modelled, because order is behaviour. app.use(helmet()) written after a router mount does not protect that router, and GuardDog says so.

Middleware is identified by what it does, not what it is called. A function that verifies a credential, sets req.user and rejects with a 401 is authentication, whatever its name. That is why loginLimiter is correctly classified as a rate limiter, and a middleware called checkTenant that verifies a JWT is correctly classified as authentication.

Anything the analysis cannot resolve is treated as clean. A missed finding is cheaper than a false accusation — rules stay silent rather than guess. That is the opposite of how most scanners are tuned, and it is why the output is short enough to read.

Severity and Confidence

Every finding carries both, and they are independent:

  • Severity — how bad it is if real. That is your policy; override it freely.
  • Confidence — how sure the analysis is. That is a property of the analysis, not a preference.

Low-confidence findings count for less in the score, and --min-confidence medium hides them entirely.

Two scores are reported. The security score drives the letter grade; the code health score covers everything else. Collapsing “200 unused imports” and “one SQL injection” into a single number destroys the signal that matters.

CLI Reference

OptionDescription
[path]Directory to scan (default: current directory)
--output <file>HTML report path (default guarddog-report.html)
--no-htmlConsole output only
--jsonPrint JSON to stdout (implies --no-html)
--skip-depsSkip the npm audit dependency check
--min-confidence <level>Hide findings below high | medium | low
--rule <id>Run only this rule (repeatable)
--no-rule <id>Disable a rule (repeatable)
--fail-on <severity>Exit non-zero at or above critical | high | medium | low

Continuous Integration

name: Security
on: [push, pull_request]

jobs:
  guarddog:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npx @jaydeepgadhiya/guarddog scan --fail-on high --no-html

Adopting this on an existing codebase? Start with --fail-on critical and --min-confidence high, then tighten as you clear the backlog. A gate that fails on day one gets disabled on day two.

Suppressing a Finding

When a finding is wrong, or the risk is accepted, record the decision in the code where the next reader will see it:

// guarddog-disable-next-line inject/sql -- column name comes from a fixed enum
const rows = await db.query(`SELECT * FROM logs ORDER BY ${column}`);

someCall();  // guarddog-disable-line inject/command

/* guarddog-disable-file secret/hardcoded-credential */

Omitting the rule id disables every rule on that line. The -- reason is not enforced, but a suppression without one is a decision nobody can review later — write it anyway.

Programmatic Use

const { scan } = require('@jaydeepgadhiya/guarddog');

const result = scan('./services/api', {
  skipDeps: true,
  minConfidence: 'medium',
  disabledRules: ['core/unused-var'],
});

console.log(result.securityScore, result.grade);

for (const finding of result.findings) {
  console.log(`${finding.severity} ${finding.id} ${finding.file}:${finding.line}`);
}

The result carries the target path, both scores, the grade, file counts, per-severity counts and the full findings array — each with rule id, title, severity, confidence, category, file, line, column, snippet, description and recommendation. Values that look like credentials are redacted before they reach any report.

GuardDog is tested against fixtures that pair every planted bug with a correct version of the same code, so both halves are asserted: the rule fires on the bug, and stays silent on the fix. It is also run against its own source on every test run, where the expected result is zero findings.

npm test    # 539 checks across 61 rules

It is still a static analyser. It cannot see through dynamic dispatch, eval, or values that arrive from outside the codebase, and it will miss things. Review findings in context, and use --min-confidence and suppression comments when it gets one wrong. A false positive is a bug worth reporting — include the smallest snippet that reproduces it and the rule id.