Scanner detecting the use of JavaScript libraries with known vulnerabilities. Can also generate an SBOM of the libraries it finds.
---
name: retire-add-ast-query
description: Add an AST query for a JavaScript library to the retire.js vulnerability database. Given a library name, downloads multiple versions, analyzes the code structure where the version string is assigned, writes an ASTronomical XPath-like query anchored with library-specific identifiers, tests the query locally, adds it to jsrepository-ast.js, and runs validate/test-detection. Use this skill whenever adding an AST-based version detection query to retire.js.
---
# retire-add-ast-query
This skill adds an ASTronomical query for a JavaScript library to the retire.js AST detection system. It downloads real files from CDNs, reads the code structure around version assignments, builds a query anchored to library-specific identifiers, tests it locally with `astronomical`, and validates with `test-detection.js`.
The repo is at `/Users/erlend/code/github/RetireJS/retire.js`. All file edits and validation commands run there.
## Overview
AST queries detect the library version by traversing the JavaScript AST rather than matching raw text. Queries must be anchored to library-specific identifiers on sibling or parent nodes — otherwise they'll fire on unrelated files that happen to have a `version` assignment. The query returns the version string directly (no `§§version§§` placeholder needed — the Literal node's value is the result).
All queries live in `repository/jsrepository-ast.js` (a CommonJS module). After editing it, run `./convertToVersioned` to propagate to the versioned JSON files used at runtime.
## Step 1 — CDN Lookup
Fetch library metadata from cdnjs:
```
https://api.cdnjs.com/libraries/{NAME}?fields=name,versions,filename,description
```
or jsdelivr if not on cdnjs:
```
https://data.jsdelivr.com/v1/packages/npm/{NAME}
```
Note the `filename` field (minified name) and derive the non-minified name (strip `.min`). Record the full list of versions.
## Step 2 — Select Versions
Pick 4–5 versions spread across history: 1 early (not the very first), 2–3 middle, and the latest.
## Step 3 — Download Files
```bash
mkdir -p /tmp/retire-ast-{NAME}
# For each VERSION:
curl -sL "https://cdnjs.cloudflare.com/ajax/libs/{NAME}/{VERSION}/{MIN_FILENAME}" \
-o /tmp/retire-ast-{NAME}/{VERSION}.min.js
curl -sL "https://cdnjs.cloudflare.com/ajax/libs/{NAME}/{VERSION}/{UNMIN_FILENAME}" \
-o /tmp/retire-ast-{NAME}/{VERSION}.js
```
Verify each downloaded successfully (non-zero, not an HTML 404 page):
```bash
ls -la /tmp/retire-ast-{NAME}/
head -3 /tmp/retire-ast-{NAME}/{VERSION}.js
```
If cdnjs returns 404, fall back to jsDelivr:
```bash
curl -sL "https://cdn.jsdelivr.net/npm/{NAME}@{VERSION}/dist/{FILENAME}" \
-o /tmp/retire-ast-{NAME}/{VERSION}.js
```
## Step 4 — Find Version Strings
Grep each downloaded file for the exact version number:
```bash
grep -n "1\.2\.3" /tmp/retire-ast-{NAME}/{VERSION}.js
grep -n "1\.2\.3" /tmp/retire-ast-{NAME}/{VERSION}.min.js
```
For single-line minified files where grep context flags fail, use Python:
```bash
python3 -c "
import re
with open('/tmp/retire-ast-{NAME}/{VERSION}.min.js', 'r', errors='replace') as f:
content = f.read()
for m in re.findall(r'.{0,100}1\.2\.3.{0,100}', content)[:5]:
print(m)
"
```
## Step 5 — Understand the Code Pattern
Read 10–20 lines around each version occurrence in non-minified files:
```bash
grep -n "1\.2\.3" /tmp/retire-ast-{NAME}/{VERSION}.js
# Then:
sed -n '${LINE_MINUS_10},${LINE_PLUS_10}p' /tmp/retire-ast-{NAME}/{VERSION}.js
```
Identify two things:
1. **What node holds the version**: e.g. `x.VERSION = "1.2.3"` (AssignmentExpression → right → Literal), `{version: "1.2.3"}` (ObjectExpression → Property → value → Literal), `var version = "1.2.3"` (VariableDeclarator → init → Literal)
2. **Library-specific anchors nearby**: an identifier that uniquely identifies this library. The anchor must appear in the same block/expression as the version assignment — or inside a method body of the same object.
**Anchor quality, best to worst:**
- **Gold**: an identifier that contains the library name — e.g. `tinyMCEPreInit`, `isMoment`, `HandlebarsEnvironment`, `__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED`. These cannot match any other library by definition.
- **Good**: a highly domain-specific sibling property or method that wouldn't appear together with a version string in any other library — e.g. `migrateVersion`, `focusedEditor` combined with `majorVersion`.
- **Weak**: generic property names like `releaseDate`, `activeEditor`, `major`, `init`, `build`. These could plausibly appear in other libraries and should only be used in combination with other anchors, never alone.
**Look inside method bodies too.** If the version is in an object literal, scan the body of methods on that same object for library-name identifiers — e.g. `setup() { ... window.tinyMCEPreInit ... }` inside the same ObjectExpression. A `//MemberExpression` or `//Identifier` traversal into a method body provides a much stronger anchor than any generic sibling property name.
Ask yourself: *would this query fire on a file from an unrelated library that happens to assign a version string?* If you're not confident the answer is no, strengthen the anchor.
## Step 6 — Write a Candidate Query
Use ASTronomical syntax to navigate from the root to the version Literal, filtering with library-specific anchors.
### ASTronomical syntax quick reference
| Syntax | Meaning |
|--------|---------|
| `//NodeType` | Descendant at any depth |
| `/NodeType` | Direct child |
| `/:attr` | Named attribute of current node |
| `/$:name` | Resolve identifier binding (follow variable reference) |
| `/$$:name` | Binding or attribute fallback (when value may be variable or literal) |
| `[cond]` | Filter; use `&&`, `\|\|`, `==` |
| `../ ` | Go to parent (use sparingly — causes extra traversal) |
| `/fn:concat(sel1, ".", sel2)` | Concatenate results (for `major.minor.patch`) |
### Common patterns
**Named property assignment in a block with a sibling anchor:**
```
//BlockStatement[
/ExpressionStatement/AssignmentExpression[/:left/:property/:name == "ANCHOR_PROP"]
]/ExpressionStatement/AssignmentExpression[
/:left/:property/:name == "version"
]/$$:right/:value
```
**Named object property with a co-occurring sibling property:**
```
//ObjectExpression[
/Property/:key/:name == "ANCHOR_PROP"
]/Property[/:key/:name == "version"]/:value/:value
```
**Assignment where the object is named directly:**
```
//AssignmentExpression[
/:left/:object/:name == "LibName" &&
/:left/:property/:name == "VERSION"
]/:right/:value
```
**Two properties sharing the same object binding (stronger anchor):**
```
//SequenceExpression[
/AssignmentExpression[/:left/:property/:name == "ANCHOR_PROP"]/:left/$:object ==
/AssignmentExpression[/:left/:property/:name == "version"]/:left/$:object
]/AssignmentExpression[/:left/:property/:name == "version"]/$$:right/:value
```
**Object with library-name identifier inside a method body (gold anchor):**
```
//ObjectExpression[
/Property[/:key/:name == "majorVersion"] &&
/Property[/:key/:name == "setup"]//MemberExpression[/:property/:name == "libraryNamePreInit"]
]/fn:concat(
/Property[/:key/:name == "majorVersion"]/:value/:value,
".",
/Property[/:key/:name == "minorVersion"]/:value/:value
)
```
Start simple, add anchors until the query is specific enough. Multiple queries in the array are OR'd — add a second entry for a different version era if the code structure changed across major versions.
**If two candidate queries end up nearly identical, stop and suspect an ASTronomical bug instead of writing both.** "Nearly identical" means the same filters/anchors, differing only in the terminal extraction step — e.g. one query ends in `/:value` and works against a plain string `Literal` (`x.version = "1.2.3"`), but a real-world build needs a second, otherwise-identical query ending in `/TemplateElement/:value/:cooked` because that build emits the version as a non-interpolated template literal instead (`` x.version = `1.2.3` ``). A `Literal` and a template literal with no `${}` are semantically the same string — the query *engine* should resolve either transparently, not every library's query author. Duplicating the query per literal-style is a local workaround that only fixes the one library, and this exact shape of gap tends to recur (react, react-dom, moment.js, and ua-parser-js all independently hit it in the same session because one bundler happened to prefer backtick strings).
When you notice this, don't add the duplicate to `jsrepository-ast.js`. Instead:
1. Report the pattern to the user and propose fixing it in ASTronomical itself, at `/Users/erlend/code/github/RetireJS/ASTronomical` (a sibling checkout — confirm it exists before assuming the path).
2. The fix belongs in `src/index.ts`'s attribute-resolution helpers (`getPrimitiveChildren` / `getPrimitiveChildrenOrNodePaths`) — add a fallback so a requested attribute (e.g. `value`) transparently resolves through the alternate AST shape, instead of requiring the query to spell out both shapes.
3. Validate with the engine's own suite (`npm run typecheck && npm test` in the ASTronomical repo) before touching retire.js again, and confirm the *original* (single, non-duplicated) query now matches both shapes.
4. Bumping ASTronomical's version, updating its `CHANGELOG.md`, publishing to npm, and bumping the dependency in `node/package.json` are all separate, more consequential steps (a published package affects every consumer, not just this repo) — check with the user before doing any of them rather than assuming the fix should ship immediately.
## Step 7 — Test the Query Locally
Create a test script:
```javascript
// /tmp/test-ast-query.js
const { multiQuery } = require('/Users/erlend/code/github/RetireJS/retire.js/node/node_modules/astronomical');
const fs = require('fs');
const code = fs.readFileSync(process.argv[2], 'utf8');
const query = `YOUR QUERY HERE`;
const results = multiQuery(code, { version: query });
console.log(JSON.stringify(results.version, null, 2));
```
Run against each downloaded version:
```bash
node /tmp/test-ast-query.js /tmp/retire-ast-{NAME}/{VERSION}.js
node /tmp/test-ast-query.js /tmp/retire-ast-{NAME}/{VERSION}.min.js
```
Expected output: a JSON array containing exactly the version string, e.g. `["1.2.3"]`. If the output is `[]`, the query didn't match — revise it. If it returns multiple values, the anchor is too loose.
Iterate across all tested versions until each returns the correct version string. If an early version has a structurally different pattern, write a second query string.
## Step 8 — Add to jsrepository-ast.js
Edit `repository/jsrepository-ast.js`. Add the library key to `exports.queries` (in alphabetical order is nice but not required):
```javascript
"library-name": [
/*
Code pattern this query matches, e.g.:
LibName.isMoment = function; LibName.version = "1.2.3"
*/
`//SequenceExpression[
/AssignmentExpression[/:left/:property/:name == "isMoment"]/:left/$:object ==
/AssignmentExpression[/:left/:property/:name == "version"]/:left/$:object
]/AssignmentExpression[/:left/:property/:name == "version"]/$$:right/:value`,
],
```
If the library already has an entry (added by someone else), append to its array rather than replacing it.
## Step 9 — Update testcases.json if needed
If the library is already in `repository/testcases.json`, check if any of the tested versions have a different code structure that the query can't reach. Add `allowAstMiss` for those versions:
```json
"library-name": {
"https://cdnjs.cloudflare.com/.../§§version§§/...§§subversion§§.js": {
"versions": ["1.0.0", "2.0.0", "3.0.0"],
"subversions": ["", ".min"],
"allowAstMiss": ["1.0.0"]
}
}
```
If the library is **not yet** in `testcases.json`, add a minimal entry covering the 3–5 tested versions. Without a `testcases.json` entry, `test-detection.js` won't exercise the AST query.
## Step 10 — Validate and Test
```bash
cd /Users/erlend/code/github/RetireJS/retire.js/repository
./convertToVersioned && ./validate
node test-detection.js {LIBRARY_NAME}
node test-detection.js # full suite — catch any false positives introduced
```
**Fixing failures:**
- `validate` error: check `jsrepository-ast.js` syntax — it's a JS module, not JSON; look for unclosed template literals or missing commas
- `Did not detect ... using ast`: query doesn't match that version — refine the query (back to Step 7) or add `allowAstMiss` for that version. If the fix you're reaching for is "add a near-duplicate query that only changes how the final value is extracted," stop — see the callout in Step 6 about suspecting an ASTronomical engine gap instead.
- `Detect multiple components using ast`: query is too broad — strengthen the anchor (back to Step 6)
## Step 11 — Report Results
Show:
- The final query string(s) added to `jsrepository-ast.js`
- Which versions were successfully detected via AST
- The timing output from `test-detection.js` (C: content ms, B: AST ms per version)