DEV Community

Cover image for How I Built an AST Scanner to Detect Quantum-Vulnerable Cryptography in JavaScript Projects
takundanashebmuchena-pixel
takundanashebmuchena-pixel

Posted on

How I Built an AST Scanner to Detect Quantum-Vulnerable Cryptography in JavaScript Projects

`Part 2 of the quantum-audit series. Part 1 here

🌐 Tool: quantum-audit-site.vercel.app


When I published quantum-audit — a CLI that scans npm projects for quantum-vulnerable cryptography — I got a question immediately:

"How does it actually detect crypto calls in source code?"

The answer is AST scanning. In this post I'll break down exactly how it works, with real code from the scanner.


What is an AST?

AST stands for Abstract Syntax Tree. It's a tree representation of your source code that a parser produces after reading it.

Think of it like this. When you write:

js
crypto.createSign('RSA-SHA256')

To you, that's a function call. To a parser, it's a tree:

plaintext
CallExpression
├── callee: MemberExpression
│ ├── object: Identifier (name: "crypto")
│ └── property: Identifier (name: "createSign")
└── arguments:
└── StringLiteral (value: "RSA-SHA256")

Instead of using regex — which would miss edge cases and produce false positives — we walk this tree and look for specific patterns. That's the core idea.


Why not just use regex?

You could try:

js
const hasRSA = code.includes('createSign') && code.includes('RSA');

But this breaks immediately on:

js
// createSign is used for RSA-SHA256
const comment = "createSign";

Regex doesn't understand code structure. An AST does.


The tools: @babel/parser and @babel/traverse

quantum-audit uses two packages:

  • @babel/parser — reads JavaScript/TypeScript and produces an AST
  • @babel/traverse — walks the AST and lets you hook into specific node types

bash
npm install @babel/parser @babel/traverse


Reading the file and parsing it

The first step is reading the source file and converting it into an AST:

`js
const fs = require('fs');
const parser = require('@babel/parser');

function scanSourceFile(filePath) {
const findings = [];
let code;

try {
code = fs.readFileSync(filePath, 'utf8');
} catch {
return findings; // skip unreadable files
}

let ast;
try {
ast = parser.parse(code, {
sourceType: 'unambiguous', // handles both CommonJS and ES modules
plugins: ['jsx', 'typescript'], // supports React and TypeScript
});
} catch {
return findings; // skip files that fail to parse
}
`

sourceType: 'unambiguous' tells the parser to figure out automatically whether the file uses import/export or require(). The plugins array enables JSX and TypeScript support so the scanner works on React and TS projects.


Walking the tree with traverse

Once we have the AST, we use traverse to walk every node and check for dangerous patterns:

`js
const traverse = require('@babel/traverse').default;

traverse(ast, {
CallExpression(path) {
const callee = path.node.callee;
const line = path.node.loc ? path.node.loc.start.line : null;

// Check for: crypto.createSign('RSA-SHA256')
if (
  callee.type === 'MemberExpression' &&
  callee.property.name === 'createSign'
) {
  const arg = path.node.arguments[0];
  if (arg && arg.type === 'StringLiteral') {
    if (/RSA/i.test(arg.value)) {
      findings.push({
        algorithm: 'RSA (crypto.createSign)',
        risk: 'critical',
        weight: 35,
        file: filePath,
        line,
      });
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

}
});
`

CallExpression is the node type for any function call. Every time traverse finds one, our callback fires. We then inspect the node to see:

  1. Is the callee a MemberExpression (i.e. something.method())?
  2. Is the method name createSign?
  3. Is the first argument a string containing RSA?

If all three are true — we've found RSA signing code. Flag it.


Detecting all the vulnerable patterns

Here's the full set of patterns quantum-audit looks for:

RSA and DSA via createSign

js
crypto.createSign('RSA-SHA256') // CRITICAL
crypto.createSign('DSA-SHA1') // CRITICAL

Detection:

js
if (callee.property.name === 'createSign') {
const arg = path.node.arguments[0];
if (arg && arg.type === 'StringLiteral') {
if (/RSA/i.test(arg.value)) {
findings.push({ algorithm: 'RSA (crypto.createSign)', risk: 'critical', weight: 35, file: filePath, line });
} else if (/DSA/i.test(arg.value)) {
findings.push({ algorithm: 'DSA (crypto.createSign)', risk: 'critical', weight: 35, file: filePath, line });
}
}
}

ECDSA and RSA via generateKeyPair

js
crypto.generateKeyPairSync('ec', { namedCurve: 'secp256k1' }) // CRITICAL
crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }) // CRITICAL

Detection:

js
if (/generateKeyPair/.test(callee.property.name || '')) {
const arg = path.node.arguments[0];
if (arg && arg.type === 'StringLiteral') {
if (arg.value === 'rsa') {
findings.push({ algorithm: 'RSA (generateKeyPair)', risk: 'critical', weight: 35, file: filePath, line });
} else if (arg.value === 'ec') {
findings.push({ algorithm: 'ECDSA (generateKeyPair)', risk: 'critical', weight: 40, file: filePath, line });
}
}
}

SHA-256 via createHash (medium risk)

js
crypto.createHash('sha256') // MEDIUM — weakened by Grover's algorithm

Detection:

js
if (callee.property.name === 'createHash') {
const arg = path.node.arguments[0];
if (arg && arg.type === 'StringLiteral' && /^sha256$/i.test(arg.value)) {
findings.push({ algorithm: 'SHA-256 (crypto.createHash)', risk: 'medium', weight: 8, file: filePath, line });
}
}

SHA-256 isn't broken by Shor's algorithm — but Grover's algorithm halves its effective security from 256 bits to ~128 bits. It's a medium risk, not critical.


Walking the entire project

The scanner doesn't just check one file. It walks your entire project directory recursively, skipping node_modules, dist, and other non-source folders:

`js
const SOURCE_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']);
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'coverage']);

function walkSourceFiles(dir, fileList = []) {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (SKIP_DIRS.has(entry.name)) continue;
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walkSourceFiles(fullPath, fileList);
} else if (SOURCE_EXTENSIONS.has(path.extname(entry.name))) {
fileList.push(fullPath);
}
}
return fileList;
}
`

Every .js, .ts, .jsx, .tsx, .mjs, and .cjs file gets scanned.


Two layers working together

The AST scanner is Layer 2. Layer 1 is a dependency scan — checking your package.json against a database of known vulnerable libraries:

js
// known-libs.js — excerpt
module.exports = {
'elliptic': { algorithm: 'ECDSA (secp256k1/p256)', risk: 'critical', weight: 40 },
'ethers': { algorithm: 'ECDSA (secp256k1) signing', risk: 'critical', weight: 40 },
'node-rsa': { algorithm: 'RSA', risk: 'critical', weight: 35 },
};

The dependency scan catches high-level exposure. The AST scan catches the specific lines in your source where vulnerable crypto is called directly.

Together they produce a score:

plaintext
Score = 100 - (sum of weights of all findings)

With deduplication so the same finding doesn't penalise you twice.


Try it on your project

bash
npx quantum-audit .
npx quantum-audit . --json # machine-readable for CI

GitHub: takundanashebmuchena-pixel/quantum-audit
npm: npmjs.com/package/quantum-audit

What patterns are you seeing in your own codebase? Drop your score in the comments.`

Top comments (0)