querydig 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +67 -1
- package/dist/analyzer.d.ts +13 -1
- package/dist/chunk-DZLU5MLV.js +26 -0
- package/dist/{chunk-WTZLJ5ZV.js → chunk-P3O2JP5S.js} +38 -13
- package/dist/{chunk-VAYEO4RI.js → chunk-YKUITQ6N.js} +150 -23
- package/dist/cli.js +13 -3
- package/dist/index.js +3 -2
- package/dist/live/correlate.d.ts +14 -0
- package/dist/live/index.d.ts +13 -0
- package/dist/live/match.d.ts +16 -0
- package/dist/live/mongo.d.ts +25 -0
- package/dist/live/postgres.d.ts +29 -0
- package/dist/live/types.d.ts +77 -0
- package/dist/live-DSIYWIT4.js +697 -0
- package/dist/scan.d.ts +4 -0
- package/dist/types.d.ts +50 -1
- package/dist/worker.js +2 -1
- package/package.json +8 -3
package/README.md
CHANGED
|
@@ -12,6 +12,7 @@ npx querydig # scan the current directory
|
|
|
12
12
|
- **Fast.** Tree-sitter (WASM) parsing in a worker pool: about 2,000 files in under 1 second.
|
|
13
13
|
- **Multi-language.** JavaScript, TypeScript, Python and Go.
|
|
14
14
|
- **Multi-database.** MongoDB (Mongoose, native driver, PyMongo/Motor, mongo-go-driver) and SQL (Prisma, TypeORM, Sequelize, pg/mysql2/knex raw, Django ORM, SQLAlchemy, DB-API cursors, GORM/xorm, `database/sql`).
|
|
15
|
+
- **Live evidence (optional).** Point `--uri` at a MongoDB or Postgres replica to confirm full scans, missing and unused indexes, and slow queries.
|
|
15
16
|
- **CI-ready.** `--fail-on`, baselines, `--diff`, SARIF for GitHub code scanning, Markdown for PR comments, HTML report.
|
|
16
17
|
|
|
17
18
|
> Requires Node.js 22.12+.
|
|
@@ -91,6 +92,70 @@ npm i -D querydig
|
|
|
91
92
|
|
|
92
93
|
Built-in noise control: `while`/`for {}` polling loops, batching loops (`skip`/`offset` in the loop), loops over collections or model classes, and array helpers like `arr.find(x => …)` or Python `str.find()` are not flagged.
|
|
93
94
|
|
|
95
|
+
## Live mode: evidence from your database
|
|
96
|
+
|
|
97
|
+
Static analysis finds the patterns. Live mode connects **read-only** to MongoDB or Postgres and checks them against reality: how big each collection is, which indexes exist, and what the query planner actually does.
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
npx querydig --uri "mongodb://reader:***@replica-host/shop"
|
|
101
|
+
QUERYDIG_DB_URI="postgres://reader:***@replica-host/shop" npx querydig # keeps the secret out of shell history
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
What changes:
|
|
105
|
+
|
|
106
|
+
- **Promotes** an N+1 whose inner query is a full scan of a large table to critical ("N × full scan of 4.2M docs") and adds the missing index.
|
|
107
|
+
- **Demotes** unbounded reads on tiny lookup tables to low.
|
|
108
|
+
- **Adds live-only findings:**
|
|
109
|
+
|
|
110
|
+
| ID | Severity | Finds |
|
|
111
|
+
|---|---|---|
|
|
112
|
+
| LV001 | high / critical | Queries in your code that do a COLLSCAN / Seq Scan on 10k+ rows, with the exact index to create |
|
|
113
|
+
| LV002 | medium | Queries that sort in memory |
|
|
114
|
+
| LV003 | high / critical | Slow queries from the MongoDB profiler or `pg_stat_statements`, traced back to the line of code when possible |
|
|
115
|
+
| LV004 | low | Indexes that are never used |
|
|
116
|
+
| LV005 | medium | Postgres foreign keys without an index that your code filters on |
|
|
117
|
+
|
|
118
|
+
Every live finding shows its evidence:
|
|
119
|
+
|
|
120
|
+
```text
|
|
121
|
+
CRITICAL QD002 Nested query over another query's results (N² risk) [mongodb]
|
|
122
|
+
src/reports.ts:15:25
|
|
123
|
+
live: Each iteration does a full scan of orders (300k docs): N × 300k docs read. (high → critical)
|
|
124
|
+
fix: … Until then, at least add an index: db.orders.createIndex({ userId: 1 })
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
**Safety model**
|
|
128
|
+
|
|
129
|
+
- Only metadata and query plans are read by default: collection stats, index lists, `explain('queryPlanner')`, and `EXPLAIN (GENERIC_PLAN)` (PostgreSQL 16+). Queries are **planned, never executed**.
|
|
130
|
+
- `--explain-execute` opts into execution stats (`executionStats` / `EXPLAIN ANALYZE`, SELECTs only), which do run the query. Use it on a replica.
|
|
131
|
+
- Postgres: every statement runs in a read-only transaction (`default_transaction_read_only = on`) with a 5s statement timeout. MongoDB: `secondaryPreferred` reads with `maxTimeMS: 5000`.
|
|
132
|
+
- Passwords are redacted from all output. If the database can't be reached, querydig warns and still reports static findings (`--live-required` makes that an error).
|
|
133
|
+
|
|
134
|
+
**Recommended read-only users**
|
|
135
|
+
|
|
136
|
+
```js
|
|
137
|
+
// MongoDB
|
|
138
|
+
db.createUser({ user: 'querydig', pwd: '…', roles: [{ role: 'read', db: 'shop' }, { role: 'clusterMonitor', db: 'admin' }] })
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
```sql
|
|
142
|
+
-- Postgres
|
|
143
|
+
CREATE ROLE querydig LOGIN PASSWORD '…';
|
|
144
|
+
GRANT pg_read_all_stats TO querydig;
|
|
145
|
+
GRANT SELECT ON ALL TABLES IN SCHEMA public TO querydig;
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Slow-query detection needs the MongoDB profiler (`db.setProfilingLevel(1, { slowms: 100 })`) or the `pg_stat_statements` extension. querydig tells you when either is missing.
|
|
149
|
+
|
|
150
|
+
| Option | Default | |
|
|
151
|
+
|---|---|---|
|
|
152
|
+
| `--uri <uri>` / `QUERYDIG_DB_URI` | | `mongodb://`, `mongodb+srv://`, `postgres://`, `postgresql://` |
|
|
153
|
+
| `--db <name>` | from URI | MongoDB database when the URI has none |
|
|
154
|
+
| `--slow-ms <n>` | 100 | Slow-query threshold |
|
|
155
|
+
| `--explain-execute` | off | Execute explain (replicas only) |
|
|
156
|
+
| `--max-explain <n>` | 200 | Cap on query shapes explained |
|
|
157
|
+
| `--live-required` | off | Exit 2 if the database can't be reached |
|
|
158
|
+
|
|
94
159
|
## Suppressing findings
|
|
95
160
|
|
|
96
161
|
```ts
|
|
@@ -165,7 +230,8 @@ console.log(summary.bySeverity, summary.grade);
|
|
|
165
230
|
|
|
166
231
|
## Roadmap
|
|
167
232
|
|
|
168
|
-
-
|
|
233
|
+
- Live mode for MySQL (`performance_schema` + `EXPLAIN`).
|
|
234
|
+
- Repo-wide index recommendations and PR comments.
|
|
169
235
|
- Java/Kotlin (Hibernate/Spring Data), Ruby (ActiveRecord), PHP (Eloquent).
|
|
170
236
|
- An official GitHub Action and incremental cache.
|
|
171
237
|
|
package/dist/analyzer.d.ts
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
|
-
import type { Finding, LangId } from './types.js';
|
|
1
|
+
import type { Finding, LangId, QueryShape, SortKey } from './types.js';
|
|
2
2
|
export interface AnalyzeOptions {
|
|
3
3
|
relPath: string;
|
|
4
4
|
lang: LangId;
|
|
5
5
|
}
|
|
6
|
+
export interface AnalyzeResult {
|
|
7
|
+
findings: Finding[];
|
|
8
|
+
shapes: QueryShape[];
|
|
9
|
+
}
|
|
10
|
+
export declare function analyzeSourceFull(source: string, opts: AnalyzeOptions): Promise<AnalyzeResult>;
|
|
6
11
|
export declare function analyzeSource(source: string, opts: AnalyzeOptions): Promise<Finding[]>;
|
|
12
|
+
/** Table name and WHERE / ORDER BY columns from a SQL string (best effort). */
|
|
13
|
+
export declare function sqlShape(sql: string): {
|
|
14
|
+
table?: string;
|
|
15
|
+
filterKeys: string[];
|
|
16
|
+
sortKeys: SortKey[];
|
|
17
|
+
hasLimit: boolean;
|
|
18
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import {
|
|
2
|
+
analyzeSourceFull,
|
|
3
|
+
langForFile
|
|
4
|
+
} from "./chunk-YKUITQ6N.js";
|
|
5
|
+
|
|
6
|
+
// src/fileAnalyzer.ts
|
|
7
|
+
import { readFile } from "fs/promises";
|
|
8
|
+
import { join } from "path";
|
|
9
|
+
var MAX_BYTES = 15e5;
|
|
10
|
+
var QUICK_FILTER = /find|aggregate|query|execute|Query|Exec|objects|count|Count|update|Update|insert|Insert|delete|Delete|save|Save|create|Create|select|SELECT|First|Take|remove|upsert|distinct|Distinct/;
|
|
11
|
+
async function analyzeFile(root, rel) {
|
|
12
|
+
const lang = langForFile(rel);
|
|
13
|
+
if (!lang) return { file: rel, findings: [] };
|
|
14
|
+
try {
|
|
15
|
+
const src = await readFile(join(root, rel), "utf8");
|
|
16
|
+
if (src.length > MAX_BYTES || !QUICK_FILTER.test(src)) return { file: rel, findings: [] };
|
|
17
|
+
const { findings, shapes } = await analyzeSourceFull(src, { relPath: rel, lang });
|
|
18
|
+
return { file: rel, findings, shapes };
|
|
19
|
+
} catch (e) {
|
|
20
|
+
return { file: rel, findings: [], error: e.message };
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export {
|
|
25
|
+
analyzeFile
|
|
26
|
+
};
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import {
|
|
2
|
+
analyzeFile
|
|
3
|
+
} from "./chunk-DZLU5MLV.js";
|
|
1
4
|
import {
|
|
2
5
|
RULES,
|
|
3
6
|
RULE_MAP,
|
|
4
|
-
SUPPORTED_EXTENSIONS
|
|
5
|
-
|
|
6
|
-
} from "./chunk-VAYEO4RI.js";
|
|
7
|
+
SUPPORTED_EXTENSIONS
|
|
8
|
+
} from "./chunk-YKUITQ6N.js";
|
|
7
9
|
|
|
8
10
|
// src/config.ts
|
|
9
11
|
import { existsSync, readFileSync } from "fs";
|
|
@@ -103,14 +105,14 @@ function renderSarif(s) {
|
|
|
103
105
|
}))
|
|
104
106
|
}
|
|
105
107
|
},
|
|
106
|
-
results: s.findings.map((f) => ({
|
|
108
|
+
results: s.findings.filter((f) => f.line > 0 && !f.file.startsWith("db://")).map((f) => ({
|
|
107
109
|
ruleId: f.ruleId,
|
|
108
110
|
ruleIndex: rules.findIndex((r) => r.id === f.ruleId),
|
|
109
111
|
level: SARIF_LEVEL[f.severity],
|
|
110
112
|
message: { text: `${f.message} Fix: ${f.fix}` },
|
|
111
113
|
locations: [{ physicalLocation: { artifactLocation: { uri: f.file }, region: { startLine: f.line, startColumn: f.column } } }],
|
|
112
114
|
partialFingerprints: { querydig: f.fingerprint },
|
|
113
|
-
properties: { severity: f.severity, db: f.db, confidence: f.confidence }
|
|
115
|
+
properties: { severity: f.severity, db: f.db, confidence: f.confidence, ...f.evidence ? { evidence: f.evidence } : {} }
|
|
114
116
|
}))
|
|
115
117
|
}]
|
|
116
118
|
};
|
|
@@ -121,7 +123,7 @@ function renderMarkdown(s, limit = 50) {
|
|
|
121
123
|
const o = [];
|
|
122
124
|
o.push(`## querydig \u2014 query performance report`);
|
|
123
125
|
o.push("");
|
|
124
|
-
o.push(`**Health: ${s.grade} (${s.score}/100)** \xB7 ${s.filesScanned} files \xB7 ${s.findings.length} issue(s)` + (s.suppressedByBaseline ? ` \xB7 ${s.suppressedByBaseline} hidden by baseline` : ""));
|
|
126
|
+
o.push(`**Health: ${s.grade} (${s.score}/100)** \xB7 ${s.filesScanned} files \xB7 ${s.findings.length} issue(s)` + (s.suppressedByBaseline ? ` \xB7 ${s.suppressedByBaseline} hidden by baseline` : "") + (s.live && !s.live.error ? ` \xB7 live: ${s.live.adapter} \`${s.live.database}\`, ${s.live.explained} plans` : ""));
|
|
125
127
|
o.push("");
|
|
126
128
|
o.push("| Severity | Count |");
|
|
127
129
|
o.push("|---|---|");
|
|
@@ -134,7 +136,9 @@ function renderMarkdown(s, limit = 50) {
|
|
|
134
136
|
o.push("| | Rule | Location | Issue |");
|
|
135
137
|
o.push("|---|---|---|---|");
|
|
136
138
|
for (const f of s.findings.slice(0, limit)) {
|
|
137
|
-
|
|
139
|
+
const loc = f.line > 0 ? `${f.file}:${f.line}` : f.file;
|
|
140
|
+
const ev = f.evidence ? `<br>\u{1F50E} ${f.evidence.note}${f.evidence.severityChange ? ` (${f.evidence.severityChange.from} \u2192 ${f.evidence.severityChange.to})` : ""}` : "";
|
|
141
|
+
o.push(`| ${EMOJI[f.severity]} | \`${f.ruleId}\` ${RULE_MAP.get(f.ruleId)?.name ?? ""} | \`${loc}\` | ${(f.message + ev).replace(/\|/g, "\\|")} |`);
|
|
138
142
|
}
|
|
139
143
|
if (s.findings.length > limit) o.push(`
|
|
140
144
|
\u2026and ${s.findings.length - limit} more.`);
|
|
@@ -152,7 +156,7 @@ function renderMarkdown(s, limit = 50) {
|
|
|
152
156
|
function renderHtml(s) {
|
|
153
157
|
const used = new Set(s.findings.map((f) => f.ruleId));
|
|
154
158
|
const data = {
|
|
155
|
-
summary: { root: s.root, files: s.filesScanned, ms: s.durationMs, grade: s.grade, score: s.score, bySeverity: s.bySeverity, baseline: s.suppressedByBaseline, version: s.version, generated: (/* @__PURE__ */ new Date()).toISOString() },
|
|
159
|
+
summary: { root: s.root, files: s.filesScanned, ms: s.durationMs, grade: s.grade, score: s.score, bySeverity: s.bySeverity, baseline: s.suppressedByBaseline, version: s.version, generated: (/* @__PURE__ */ new Date()).toISOString(), live: s.live ?? null },
|
|
156
160
|
findings: s.findings,
|
|
157
161
|
rules: RULES.filter((r) => used.has(r.id))
|
|
158
162
|
};
|
|
@@ -194,6 +198,8 @@ h1{font-size:22px;margin:0 0 4px;letter-spacing:-.01em}
|
|
|
194
198
|
.loc{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:var(--muted);margin:4px 0 8px;word-break:break-all}
|
|
195
199
|
pre{background:var(--code);border-radius:8px;padding:10px 12px;overflow-x:auto;margin:8px 0;font:12.5px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace}
|
|
196
200
|
.fix{color:var(--ok)}
|
|
201
|
+
.ev{margin-top:6px;padding:6px 10px;border-radius:8px;background:color-mix(in oklab,#a855f7 12%,transparent);color:var(--ink);font-size:13px}
|
|
202
|
+
.ev b{color:#a855f7;font-weight:600;margin-right:6px}
|
|
197
203
|
.tag{background:var(--chip);border-radius:6px;padding:1px 7px;font-size:11px;color:var(--muted)}
|
|
198
204
|
details{margin-top:6px}summary{cursor:pointer;color:var(--muted)}
|
|
199
205
|
.empty{text-align:center;color:var(--muted);padding:48px 0}
|
|
@@ -220,7 +226,7 @@ const D=${json};
|
|
|
220
226
|
const SEV=['critical','high','medium','low','info'];
|
|
221
227
|
const esc=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
222
228
|
const S=D.summary, on=new Set(['critical','high','medium','low','info']);
|
|
223
|
-
document.getElementById('sub').textContent=S.root+' \xB7 '+S.files+' files \xB7 '+(S.ms/1000).toFixed(2)+'s'+(S.baseline?' \xB7 '+S.baseline+' hidden by baseline':'');
|
|
229
|
+
document.getElementById('sub').textContent=S.root+' \xB7 '+S.files+' files \xB7 '+(S.ms/1000).toFixed(2)+'s'+(S.baseline?' \xB7 '+S.baseline+' hidden by baseline':'')+(S.live&&!S.live.error?' \xB7 live: '+S.live.adapter+' '+S.live.database+', '+S.live.explained+' query plans':'');
|
|
224
230
|
const gc={A:'var(--ok)',B:'var(--ok)',C:'var(--med)',D:'var(--high)',F:'var(--crit)'}[S.grade];
|
|
225
231
|
document.getElementById('cards').innerHTML='<div class="card grade"><div class="k">Health</div><div class="v" style="color:'+gc+'">'+S.grade+' <small style="font-size:14px;color:var(--muted)">'+S.score+'/100</small></div></div>'+
|
|
226
232
|
['critical','high','medium','low'].map(k=>'<div class="card"><div class="k">'+k+'</div><div class="v c-'+k+'">'+S.bySeverity[k]+'</div></div>').join('');
|
|
@@ -238,7 +244,7 @@ function render(){
|
|
|
238
244
|
const L=document.getElementById('list');
|
|
239
245
|
if(!fs.length){L.innerHTML='<div class="empty">'+(D.findings.length?'No findings match the filters.':'\u2714 No query performance issues found.')+'</div>';return}
|
|
240
246
|
L.innerHTML=fs.slice(0,1000).map(f=>{const r=RM[f.ruleId]||{};return '<div class="f '+f.severity+'"><h3><span class="sev c-'+f.severity+'">'+f.severity+'</span><span class="rid">'+f.ruleId+'</span>'+esc(f.title)+' <span class="tag">'+f.db+'</span><span class="tag">'+f.confidence+' confidence</span></h3>'+
|
|
241
|
-
'<div class="loc">'+esc(f.file)+':'+f.line+':'+f.column+'</div><pre>'+esc(f.snippet)+'</pre><div>'+esc(f.message)+'</div><div class="fix"><b>Fix:</b> '+esc(f.fix)+'</div>'+
|
|
247
|
+
'<div class="loc">'+esc(f.file)+(f.line>0?':'+f.line+':'+f.column:'')+'</div><pre>'+esc(f.snippet)+'</pre><div>'+esc(f.message)+'</div>'+(f.evidence?'<div class="ev"><b>live</b>'+esc(f.evidence.note)+(f.evidence.severityChange?' ('+f.evidence.severityChange.from+' \u2192 '+f.evidence.severityChange.to+')':'')+'</div>':'')+'<div class="fix"><b>Fix:</b> '+esc(f.fix)+'</div>'+
|
|
242
248
|
(r.why?'<details><summary>Why is this slow?</summary><p>'+esc(r.why)+'</p>'+(r.example?'<div>Before</div><pre>'+esc(r.example.bad)+'</pre><div>After</div><pre>'+esc(r.example.good)+'</pre>':'')+'</details>':'')+'</div>'}).join('')+(fs.length>1000?'<div class="empty">Showing first 1000 of '+fs.length+'</div>':'');
|
|
243
249
|
}
|
|
244
250
|
['q','db'].forEach(id=>document.getElementById(id).addEventListener('input',render));rsel.addEventListener('input',render);
|
|
@@ -266,6 +272,10 @@ function renderTerminal(s, opts = {}) {
|
|
|
266
272
|
out.push("");
|
|
267
273
|
out.push(pc.bold(`querydig ${pc.dim("v" + s.version)} \u2014 slow-query scan of ${s.root}`));
|
|
268
274
|
out.push(pc.dim(`${s.filesScanned} files in ${(s.durationMs / 1e3).toFixed(2)}s`));
|
|
275
|
+
if (s.live && !s.live.error) {
|
|
276
|
+
out.push(pc.dim(`live: ${s.live.adapter} ${s.live.database} \xB7 ${s.live.tables} ${s.live.adapter === "mongodb" ? "collections" : "tables"} \xB7 ${s.live.explained} query plans \xB7 ${s.live.slowQueries} slow queries`));
|
|
277
|
+
}
|
|
278
|
+
for (const w of s.live?.warnings ?? []) out.push(pc.yellow(` ! ${w}`));
|
|
269
279
|
out.push("");
|
|
270
280
|
if (!s.findings.length) {
|
|
271
281
|
out.push(pc.green(pc.bold("\u2714 No query performance issues found.")));
|
|
@@ -297,9 +307,13 @@ function renderFinding(f, verbose) {
|
|
|
297
307
|
const lines = [];
|
|
298
308
|
const db = f.db === "unknown" ? "" : pc.dim(` [${f.db}]`);
|
|
299
309
|
lines.push(`${SEV_COLOR[f.severity](` ${f.severity.toUpperCase()} `)} ${pc.bold(f.ruleId)} ${f.title}${db}`);
|
|
300
|
-
lines.push(` ${pc.underline(`${f.file}:${f.line}:${f.column}`)}`);
|
|
310
|
+
lines.push(` ${pc.underline(f.line > 0 ? `${f.file}:${f.line}:${f.column}` : f.file)}`);
|
|
301
311
|
for (const l of f.snippet.split("\n").slice(0, 3)) lines.push(pc.dim(" \u2502 ") + l);
|
|
302
312
|
lines.push(` ${f.message}`);
|
|
313
|
+
if (f.evidence) {
|
|
314
|
+
const sc = f.evidence.severityChange ? ` ${pc.bold(`(${f.evidence.severityChange.from} \u2192 ${f.evidence.severityChange.to})`)}` : "";
|
|
315
|
+
lines.push(` ${pc.magenta("live:")} ${f.evidence.note}${sc}`);
|
|
316
|
+
}
|
|
303
317
|
lines.push(` ${pc.green("fix:")} ${f.fix}`);
|
|
304
318
|
if (verbose) lines.push(pc.dim(` confidence: ${f.confidence} fingerprint: ${f.fingerprint}`));
|
|
305
319
|
lines.push("");
|
|
@@ -311,7 +325,7 @@ var SEVERITIES = ["critical", "high", "medium", "low", "info"];
|
|
|
311
325
|
var SEVERITY_RANK = { critical: 4, high: 3, medium: 2, low: 1, info: 0 };
|
|
312
326
|
|
|
313
327
|
// src/version.ts
|
|
314
|
-
var VERSION = true ? "0.
|
|
328
|
+
var VERSION = true ? "0.2.0" : "0.0.0-dev";
|
|
315
329
|
|
|
316
330
|
// src/scan.ts
|
|
317
331
|
import { execFileSync } from "child_process";
|
|
@@ -406,11 +420,21 @@ async function scan(opts) {
|
|
|
406
420
|
const threads = opts.threads ?? Math.max(1, availableParallelism() - 1);
|
|
407
421
|
const results = await runPool(root, files, threads, opts.onProgress);
|
|
408
422
|
let findings = [];
|
|
423
|
+
const shapes = [];
|
|
409
424
|
const errors = [];
|
|
410
425
|
for (const r of results) {
|
|
411
426
|
if (!r) continue;
|
|
412
427
|
if (r.error) errors.push({ file: r.file, error: r.error });
|
|
413
428
|
findings.push(...r.findings);
|
|
429
|
+
if (r.shapes) shapes.push(...r.shapes);
|
|
430
|
+
}
|
|
431
|
+
let live;
|
|
432
|
+
if (opts.live) {
|
|
433
|
+
opts.onLiveStart?.();
|
|
434
|
+
const { runLive } = await import("./live-DSIYWIT4.js");
|
|
435
|
+
const res = await runLive(findings, shapes, opts.live);
|
|
436
|
+
findings = res.findings;
|
|
437
|
+
live = res.summary;
|
|
414
438
|
}
|
|
415
439
|
if (opts.rules) {
|
|
416
440
|
findings = findings.flatMap((f) => {
|
|
@@ -454,7 +478,8 @@ async function scan(opts) {
|
|
|
454
478
|
score,
|
|
455
479
|
suppressedByBaseline,
|
|
456
480
|
errors,
|
|
457
|
-
version: VERSION
|
|
481
|
+
version: VERSION,
|
|
482
|
+
...live ? { live } : {}
|
|
458
483
|
};
|
|
459
484
|
}
|
|
460
485
|
|
|
@@ -215,6 +215,52 @@ var RULES = [
|
|
|
215
215
|
db: "sql",
|
|
216
216
|
why: "NOT IN with a subquery is often executed as a nested loop and has surprising NULL semantics.",
|
|
217
217
|
fix: "Rewrite as NOT EXISTS or a LEFT JOIN ... WHERE x IS NULL."
|
|
218
|
+
}),
|
|
219
|
+
// ---------- live database (--uri) ----------
|
|
220
|
+
r({
|
|
221
|
+
id: "LV001",
|
|
222
|
+
name: "full-scan",
|
|
223
|
+
title: "Query does a full collection / table scan",
|
|
224
|
+
severity: "high",
|
|
225
|
+
db: "any",
|
|
226
|
+
why: "The live query plan shows COLLSCAN / Seq Scan: every document or row is read to answer this query. Cost grows linearly with the table and competes with every other query for I/O and cache.",
|
|
227
|
+
fix: "Create an index that matches the filter (equality fields first), then the sort fields, then range fields."
|
|
228
|
+
}),
|
|
229
|
+
r({
|
|
230
|
+
id: "LV002",
|
|
231
|
+
name: "in-memory-sort",
|
|
232
|
+
title: "Query sorts in memory",
|
|
233
|
+
severity: "medium",
|
|
234
|
+
db: "any",
|
|
235
|
+
why: "The plan contains a blocking SORT stage: all matching rows are buffered and sorted before the first one is returned.",
|
|
236
|
+
fix: "Add the sort fields to an index after the equality fields, in the same order and direction as the sort."
|
|
237
|
+
}),
|
|
238
|
+
r({
|
|
239
|
+
id: "LV003",
|
|
240
|
+
name: "slow-query",
|
|
241
|
+
title: "Slow query recorded by the database",
|
|
242
|
+
severity: "high",
|
|
243
|
+
db: "any",
|
|
244
|
+
why: "The database profiler / pg_stat_statements recorded this query shape above the slow threshold in real traffic.",
|
|
245
|
+
fix: "Check its plan with explain(), add a matching index, or reduce the documents/rows it has to read."
|
|
246
|
+
}),
|
|
247
|
+
r({
|
|
248
|
+
id: "LV004",
|
|
249
|
+
name: "unused-index",
|
|
250
|
+
title: "Index is never used",
|
|
251
|
+
severity: "low",
|
|
252
|
+
db: "any",
|
|
253
|
+
why: "The index has zero recorded uses since statistics were last reset, but every write still has to update it and it takes RAM.",
|
|
254
|
+
fix: "Confirm across all replicas and a full business cycle, then drop it."
|
|
255
|
+
}),
|
|
256
|
+
r({
|
|
257
|
+
id: "LV005",
|
|
258
|
+
name: "missing-fk-index",
|
|
259
|
+
title: "Foreign key column has no index",
|
|
260
|
+
severity: "medium",
|
|
261
|
+
db: "sql",
|
|
262
|
+
why: "Queries that filter or join on this foreign key, and deletes on the parent table, have to scan the whole child table.",
|
|
263
|
+
fix: "CREATE INDEX CONCURRENTLY on the foreign key column."
|
|
218
264
|
})
|
|
219
265
|
];
|
|
220
266
|
var RULE_MAP = new Map(RULES.map((x) => [x.id, x]));
|
|
@@ -528,16 +574,30 @@ var BUILTIN_ROOTS = /* @__PURE__ */ new Set([
|
|
|
528
574
|
var DEFAULT_TEST_LOOP_BREAKERS = /* @__PURE__ */ new Set(["while_statement", "do_statement"]);
|
|
529
575
|
var same = (a, b) => !!a && !!b && a.startIndex === b.startIndex && a.endIndex === b.endIndex && a.type === b.type;
|
|
530
576
|
var stripQuotes = (s) => s.replace(/^[rRbBuUfF]{0,2}(['"`]{1,3})([\s\S]*?)\1$/, "$2");
|
|
531
|
-
async function
|
|
532
|
-
if (/querydig-ignore-file/.test(source.slice(0, 2e3))) return [];
|
|
577
|
+
async function analyzeSourceFull(source, opts) {
|
|
578
|
+
if (/querydig-ignore-file/.test(source.slice(0, 2e3))) return { findings: [], shapes: [] };
|
|
533
579
|
const parser = await getParser(opts.lang);
|
|
534
580
|
const tree = parser.parse(source);
|
|
535
581
|
try {
|
|
536
|
-
|
|
582
|
+
const a = new Analyzer(source, opts, LANGS[opts.lang]);
|
|
583
|
+
const findings = a.run(tree.rootNode);
|
|
584
|
+
return { findings, shapes: a.shapes };
|
|
537
585
|
} finally {
|
|
538
586
|
tree.delete();
|
|
539
587
|
}
|
|
540
588
|
}
|
|
589
|
+
async function analyzeSource(source, opts) {
|
|
590
|
+
return (await analyzeSourceFull(source, opts)).findings;
|
|
591
|
+
}
|
|
592
|
+
function sqlShape(sql) {
|
|
593
|
+
const unq = (x) => x.replace(/["`\[\]]/g, "").split(".").pop();
|
|
594
|
+
const table = /\b(?:from|update|into)\s+([\w."`\[\]]+)/i.exec(sql)?.[1];
|
|
595
|
+
const where = /\bwhere\b([\s\S]*?)(\border\s+by\b|\bgroup\s+by\b|\blimit\b|\boffset\b|\breturning\b|$)/i.exec(sql)?.[1] ?? "";
|
|
596
|
+
const filterKeys = [...where.matchAll(/([\w."`]+)\s*(?:=|<>|!=|<=|>=|<|>|\bin\b|\blike\b|\bilike\b|\bis\b|\bbetween\b)/gi)].map((m) => unq(m[1])).filter((k) => !/^(and|or|not|\d+)$/i.test(k));
|
|
597
|
+
const order = /\border\s+by\s+([\s\S]*?)(\blimit\b|\boffset\b|$)/i.exec(sql)?.[1] ?? "";
|
|
598
|
+
const sortKeys = order.split(",").map((p) => p.trim()).filter((p) => /^[\w."`]+/.test(p) && !/\(/.test(p)).map((p) => ({ key: unq(p.split(/\s+/)[0]), dir: /\bdesc\b/i.test(p) ? -1 : 1 }));
|
|
599
|
+
return { table: table ? unq(table) : void 0, filterKeys: [...new Set(filterKeys)], sortKeys, hasLimit: /\blimit\b|\bfetch\s+first\b/i.test(sql) };
|
|
600
|
+
}
|
|
541
601
|
var Analyzer = class {
|
|
542
602
|
constructor(src, opts, spec) {
|
|
543
603
|
this.src = src;
|
|
@@ -554,6 +614,7 @@ var Analyzer = class {
|
|
|
554
614
|
spec;
|
|
555
615
|
lines;
|
|
556
616
|
findings = [];
|
|
617
|
+
shapes = [];
|
|
557
618
|
queryVars = /* @__PURE__ */ new Set();
|
|
558
619
|
hasMongo;
|
|
559
620
|
hasSql;
|
|
@@ -846,6 +907,7 @@ var Analyzer = class {
|
|
|
846
907
|
// ------------------------------------------------------------- rules
|
|
847
908
|
evaluate(q, ctx) {
|
|
848
909
|
this.trackAssignment(q);
|
|
910
|
+
this.recordShape(q, ctx);
|
|
849
911
|
const target = q.collection ? `${q.collection}.${q.method}` : `${q.receiver}.${q.method}`;
|
|
850
912
|
const loopInfo = ctx.loopLine ? ` (loop at line ${ctx.loopLine})` : "";
|
|
851
913
|
const paginating = q.chain.some((c) => /^(skip|Skip|offset|Offset)$/.test(c.name) || /^(Limit)$/.test(c.name) && c.args.includes(","));
|
|
@@ -875,6 +937,80 @@ var Analyzer = class {
|
|
|
875
937
|
if (q.db === "mongodb") this.mongoRules(q);
|
|
876
938
|
if (q.db === "sql") this.sqlOrmRules(q);
|
|
877
939
|
}
|
|
940
|
+
recordShape(q, ctx) {
|
|
941
|
+
const id = `${this.opts.relPath}:${q.node.startPosition.row + 1}:${q.node.startPosition.column + 1}`;
|
|
942
|
+
q.shapeId = id;
|
|
943
|
+
let filterKeys = this.shapeFilterKeys(q);
|
|
944
|
+
let sortKeys = this.sortKeys(q);
|
|
945
|
+
let collection = q.collection;
|
|
946
|
+
let sql;
|
|
947
|
+
if (q.kind === "raw" && q.args[0] && this.spec.strings.includes(q.args[0].type)) {
|
|
948
|
+
sql = stripQuotes(q.args[0].text.replace(/\$\{[^}]*\}/g, "?")).replace(/\s+/g, " ").trim();
|
|
949
|
+
const sh = sqlShape(sql);
|
|
950
|
+
collection = sh.table;
|
|
951
|
+
filterKeys = sh.filterKeys;
|
|
952
|
+
sortKeys = sh.sortKeys;
|
|
953
|
+
}
|
|
954
|
+
const hasLimit = q.kind === "read" || this.chainHas(q, /^(limit|Limit|take|first|First|one|get|Take|Last)$/) || /\b(limit|take|SetLimit|first)\b/.test(q.argsText) || !!sql && /\blimit\b/i.test(sql);
|
|
955
|
+
this.shapes.push({
|
|
956
|
+
id,
|
|
957
|
+
db: q.db,
|
|
958
|
+
orm: q.orm,
|
|
959
|
+
collection,
|
|
960
|
+
method: q.method,
|
|
961
|
+
kind: q.kind,
|
|
962
|
+
filterKeys,
|
|
963
|
+
sortKeys,
|
|
964
|
+
hasLimit,
|
|
965
|
+
inLoop: ctx.depth > 0,
|
|
966
|
+
sql,
|
|
967
|
+
file: this.opts.relPath,
|
|
968
|
+
line: q.node.startPosition.row + 1
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
shapeFilterKeys(q) {
|
|
972
|
+
const clean = (k) => k.split("__")[0];
|
|
973
|
+
if (q.orm === "prisma" && q.args[0]?.type === "object") {
|
|
974
|
+
const where = q.args[0].namedChildren.find((c) => c.type === "pair" && c.childForFieldName("key")?.text === "where");
|
|
975
|
+
const v = where?.childForFieldName("value");
|
|
976
|
+
return v ? this.objectKeys(v) : [];
|
|
977
|
+
}
|
|
978
|
+
if (this.spec.id === "python" && (q.orm === "django" || q.orm === "sqlalchemy")) {
|
|
979
|
+
const txt = [q.argsText, ...q.chain.filter((c) => /^(filter|filter_by|get|exclude)$/.test(c.name)).map((c) => c.args)].join(" ");
|
|
980
|
+
return [...new Set([...txt.matchAll(/(?:^|[(,\s])([a-z_][\w]*)\s*=(?!=)/gi)].map((m) => clean(m[1])))];
|
|
981
|
+
}
|
|
982
|
+
if (q.orm === "gorm" || this.spec.id === "go") {
|
|
983
|
+
const w = q.chain.filter((c) => /^(Where|And|Or)$/.test(c.name)).map((c) => c.args).join(" ");
|
|
984
|
+
if (w) return sqlShape("select 1 from t where " + stripQuotes(w.replace(/^\(|\)$/g, "").split(",")[0].trim())).filterKeys;
|
|
985
|
+
}
|
|
986
|
+
if (/^(findById|findByIdAndUpdate|findByIdAndDelete|findByIdAndRemove|findByPk|UpdateByID|findUnique)$/.test(q.method) && !this.filterKeys(q).length) return q.db === "mongodb" ? ["_id"] : ["id"];
|
|
987
|
+
return this.filterKeys(q);
|
|
988
|
+
}
|
|
989
|
+
/** Sort keys from .sort({ a: -1 }), .sort("a", -1), sort=[("a", -1)], SetSort(bson.D{{"a", -1}}), order_by("-a"). */
|
|
990
|
+
sortKeys(q) {
|
|
991
|
+
const out = [];
|
|
992
|
+
const dirOf = (v) => v && /^(-1|['"]?desc(ending)?['"]?|pymongo\.DESCENDING|DESCENDING)$/i.test(v.trim()) ? -1 : 1;
|
|
993
|
+
const links = q.chain.filter((c) => /^(sort|Sort|order_by|orderBy|Order)$/.test(c.name)).map((c) => c.args);
|
|
994
|
+
const fromArgs = /\bSetSort\(([^)]*\))|\bsort\s*=\s*(\[[^\]]*\])/.exec(q.argsText);
|
|
995
|
+
if (fromArgs) links.push(fromArgs[1] ?? fromArgs[2]);
|
|
996
|
+
const prismaOrder = /orderBy\s*:\s*(\{[^}]*\}|\[[^\]]*\])/.exec(q.argsText);
|
|
997
|
+
if (prismaOrder) links.push(prismaOrder[1]);
|
|
998
|
+
for (const txt of links) {
|
|
999
|
+
const obj = /\{([^{}]*)\}/.exec(txt);
|
|
1000
|
+
if (obj && /:/.test(obj[1])) {
|
|
1001
|
+
for (const part of obj[1].split(",")) {
|
|
1002
|
+
const [k, v] = part.split(":").map((x) => x.trim().replace(/['"]/g, ""));
|
|
1003
|
+
if (k && /^[\w.]+$/.test(k)) out.push({ key: k, dir: dirOf(v) });
|
|
1004
|
+
}
|
|
1005
|
+
continue;
|
|
1006
|
+
}
|
|
1007
|
+
for (const m of txt.matchAll(/["'](-?)([\w.]+)["']\s*(?:,\s*(-?1|pymongo\.\w+|[A-Z]+))?/g)) {
|
|
1008
|
+
out.push({ key: m[2], dir: m[1] === "-" ? -1 : dirOf(m[3]) });
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1012
|
+
return out.filter((k) => !seen.has(k.key) && seen.add(k.key));
|
|
1013
|
+
}
|
|
878
1014
|
/** `collections.map(c => c.deleteMany({}))` hits a different collection each time: not N+1. */
|
|
879
1015
|
targetVariesPerIteration(q, ctx) {
|
|
880
1016
|
const vars = ctx.loopVars ?? [];
|
|
@@ -1122,6 +1258,12 @@ var Analyzer = class {
|
|
|
1122
1258
|
if (/^(delete)\b/i.test(txt) && !/\bfrom\b/i.test(txt)) return;
|
|
1123
1259
|
if (/^insert\b/i.test(txt) && !/\binto\b/i.test(txt)) return;
|
|
1124
1260
|
const pseudo = { node, top: node, method: "sql", kind: "raw", db: "sql", receiver: "sql", receiverText: "", args: [], argsText: txt, chain: [], confidence: "high" };
|
|
1261
|
+
if (!dynamic) {
|
|
1262
|
+
const sh = sqlShape(txt);
|
|
1263
|
+
const id = `${this.opts.relPath}:${node.startPosition.row + 1}:${node.startPosition.column + 1}:sql`;
|
|
1264
|
+
pseudo.shapeId = id;
|
|
1265
|
+
this.shapes.push({ id, db: "sql", collection: sh.table, method: "sql", kind: /^select|^with/i.test(txt) ? "readMany" : "write", filterKeys: sh.filterKeys, sortKeys: sh.sortKeys, hasLimit: sh.hasLimit, inLoop: false, sql: txt, file: this.opts.relPath, line: node.startPosition.row + 1 });
|
|
1266
|
+
}
|
|
1125
1267
|
const concatenated = !!node.parent && ["binary_expression", "concatenated_string", "binary_operator"].includes(node.parent.type);
|
|
1126
1268
|
if (/^select\s+(\w+\.)?\*\s+from/i.test(txt)) this.add("SQ002", node, pseudo, { message: `\`${txt.slice(0, 60)}\` selects every column.` });
|
|
1127
1269
|
if (/\b(i?like)\s+['"]%/i.test(txt) || /\b(i?like)\s+(concat\(\s*)?['"]%['"]\s*(\|\||,|\+)/i.test(txt)) {
|
|
@@ -1165,7 +1307,8 @@ var Analyzer = class {
|
|
|
1165
1307
|
message: o.message,
|
|
1166
1308
|
fix: o.fix ?? rule.fix,
|
|
1167
1309
|
confidence: o.confidence ?? q.confidence,
|
|
1168
|
-
fingerprint
|
|
1310
|
+
fingerprint,
|
|
1311
|
+
...q.shapeId ? { shapeId: q.shapeId } : {}
|
|
1169
1312
|
});
|
|
1170
1313
|
}
|
|
1171
1314
|
applySuppressions() {
|
|
@@ -1180,29 +1323,13 @@ var Analyzer = class {
|
|
|
1180
1323
|
}
|
|
1181
1324
|
};
|
|
1182
1325
|
|
|
1183
|
-
// src/fileAnalyzer.ts
|
|
1184
|
-
import { readFile } from "fs/promises";
|
|
1185
|
-
import { join as join2 } from "path";
|
|
1186
|
-
var MAX_BYTES = 15e5;
|
|
1187
|
-
var QUICK_FILTER = /find|aggregate|query|execute|Query|Exec|objects|count|Count|update|Update|insert|Insert|delete|Delete|save|Save|create|Create|select|SELECT|First|Take|remove|upsert|distinct|Distinct/;
|
|
1188
|
-
async function analyzeFile(root, rel) {
|
|
1189
|
-
const lang = langForFile(rel);
|
|
1190
|
-
if (!lang) return { file: rel, findings: [] };
|
|
1191
|
-
try {
|
|
1192
|
-
const src = await readFile(join2(root, rel), "utf8");
|
|
1193
|
-
if (src.length > MAX_BYTES || !QUICK_FILTER.test(src)) return { file: rel, findings: [] };
|
|
1194
|
-
const findings = await analyzeSource(src, { relPath: rel, lang });
|
|
1195
|
-
return { file: rel, findings };
|
|
1196
|
-
} catch (e) {
|
|
1197
|
-
return { file: rel, findings: [], error: e.message };
|
|
1198
|
-
}
|
|
1199
|
-
}
|
|
1200
|
-
|
|
1201
1326
|
export {
|
|
1202
1327
|
RULES,
|
|
1203
1328
|
RULE_MAP,
|
|
1204
1329
|
findRule,
|
|
1330
|
+
langForFile,
|
|
1205
1331
|
SUPPORTED_EXTENSIONS,
|
|
1332
|
+
analyzeSourceFull,
|
|
1206
1333
|
analyzeSource,
|
|
1207
|
-
|
|
1334
|
+
sqlShape
|
|
1208
1335
|
};
|
package/dist/cli.js
CHANGED
|
@@ -11,11 +11,12 @@ import {
|
|
|
11
11
|
renderSarif,
|
|
12
12
|
renderTerminal,
|
|
13
13
|
scan
|
|
14
|
-
} from "./chunk-
|
|
14
|
+
} from "./chunk-P3O2JP5S.js";
|
|
15
|
+
import "./chunk-DZLU5MLV.js";
|
|
15
16
|
import {
|
|
16
17
|
RULES,
|
|
17
18
|
findRule
|
|
18
|
-
} from "./chunk-
|
|
19
|
+
} from "./chunk-YKUITQ6N.js";
|
|
19
20
|
|
|
20
21
|
// src/cli.ts
|
|
21
22
|
import { existsSync as existsSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
@@ -44,7 +45,7 @@ function writeBaseline(path, findings) {
|
|
|
44
45
|
// src/cli.ts
|
|
45
46
|
var program = new Command();
|
|
46
47
|
program.name("querydig").description("Find slow and unoptimized database queries (MongoDB, SQL) in JS/TS, Python and Go code.").version(VERSION);
|
|
47
|
-
program.command("scan", { isDefault: true }).description("Scan a project for slow query patterns").argument("[path]", "project root", ".").addOption(new Option("-f, --format <format>", "output format").choices(["terminal", "json", "sarif", "md", "html"]).default("terminal")).option("-o, --output <file>", "write report to a file (default for html: querydig-report.html)").addOption(new Option("--fail-on <severity>", "exit with code 1 if any finding is at or above this severity").choices([...SEVERITIES, "none"])).addOption(new Option("--min-severity <severity>", "hide findings below this severity").choices(SEVERITIES)).option("--baseline <file>", "ignore findings recorded in this baseline file").option("--update-baseline [file]", "write all current findings to a baseline file and exit 0").option("--diff <ref>", "only scan files changed since a git ref (e.g. origin/main)").option("--include-tests", "also scan tests, fixtures, migrations and seeds", false).option("--ignore <globs...>", "extra glob patterns to ignore").option("--rules <list>", "only report these rule ids (comma separated)").option("--disable <list>", "disable these rule ids (comma separated)").option("-c, --config <file>", "path to a config file").option("--limit <n>", "max findings shown in terminal (0 = all)", "25").option("--threads <n>", "worker threads").option("-v, --verbose", "show confidence, fingerprints and parse errors", false).action(async (path, o) => {
|
|
48
|
+
program.command("scan", { isDefault: true }).description("Scan a project for slow query patterns").argument("[path]", "project root", ".").addOption(new Option("-f, --format <format>", "output format").choices(["terminal", "json", "sarif", "md", "html"]).default("terminal")).option("-o, --output <file>", "write report to a file (default for html: querydig-report.html)").addOption(new Option("--fail-on <severity>", "exit with code 1 if any finding is at or above this severity").choices([...SEVERITIES, "none"])).addOption(new Option("--min-severity <severity>", "hide findings below this severity").choices(SEVERITIES)).option("--baseline <file>", "ignore findings recorded in this baseline file").option("--update-baseline [file]", "write all current findings to a baseline file and exit 0").option("--diff <ref>", "only scan files changed since a git ref (e.g. origin/main)").option("--include-tests", "also scan tests, fixtures, migrations and seeds", false).option("--ignore <globs...>", "extra glob patterns to ignore").option("--rules <list>", "only report these rule ids (comma separated)").option("--disable <list>", "disable these rule ids (comma separated)").option("-c, --config <file>", "path to a config file").option("--limit <n>", "max findings shown in terminal (0 = all)", "25").option("--threads <n>", "worker threads").option("-v, --verbose", "show confidence, fingerprints and parse errors", false).option("--uri <uri>", "read-only connection to MongoDB or Postgres for live evidence (or set QUERYDIG_DB_URI)").option("--db <name>", "MongoDB database name when the URI has none").option("--slow-ms <n>", "slow-query threshold for profiler / pg_stat_statements", "100").option("--explain-execute", "run explain with execution stats (executes queries; use a replica)", false).option("--max-explain <n>", "maximum number of query shapes to explain", "200").option("--live-required", "fail (exit 2) if the live database cannot be reached", false).action(async (path, o) => {
|
|
48
49
|
const root = resolve(path);
|
|
49
50
|
if (!existsSync2(root)) {
|
|
50
51
|
console.error(pc.red(`querydig: path not found: ${root}`));
|
|
@@ -61,6 +62,8 @@ program.command("scan", { isDefault: true }).description("Scan a project for slo
|
|
|
61
62
|
const updating = o.updateBaseline !== void 0;
|
|
62
63
|
const baseline = !updating && baselinePath ? readBaseline(baselinePath) : void 0;
|
|
63
64
|
if (baselinePath && !updating && !baseline) console.error(pc.yellow(`querydig: baseline ${baselinePath} not found \u2014 reporting all findings`));
|
|
65
|
+
const uri = o.uri ?? process.env.QUERYDIG_DB_URI;
|
|
66
|
+
const live = uri ? { uri, db: o.db, slowMs: Number(o.slowMs) || 100, explainExecute: !!o.explainExecute, maxExplain: Number(o.maxExplain) || 200, timeoutMs: 5e3 } : void 0;
|
|
64
67
|
const interactive = o.format === "terminal" && process.stderr.isTTY;
|
|
65
68
|
let last = 0;
|
|
66
69
|
const summary = await scan({
|
|
@@ -73,6 +76,8 @@ program.command("scan", { isDefault: true }).description("Scan a project for slo
|
|
|
73
76
|
diff: o.diff,
|
|
74
77
|
baseline,
|
|
75
78
|
threads: o.threads ? Number(o.threads) : void 0,
|
|
79
|
+
live,
|
|
80
|
+
onLiveStart: live && process.stderr.isTTY ? () => process.stderr.write(pc.dim("\rconnecting to the database for live evidence\u2026") + "\r") : void 0,
|
|
76
81
|
onProgress: interactive ? (d, t) => {
|
|
77
82
|
const now = Date.now();
|
|
78
83
|
if (now - last > 80 || d === t) {
|
|
@@ -81,6 +86,11 @@ program.command("scan", { isDefault: true }).description("Scan a project for slo
|
|
|
81
86
|
}
|
|
82
87
|
} : void 0
|
|
83
88
|
});
|
|
89
|
+
if (live && process.stderr.isTTY) process.stderr.write("\x1B[K");
|
|
90
|
+
if (summary.live?.error) {
|
|
91
|
+
console.error(pc.yellow(`querydig: live mode skipped: ${summary.live.error}`));
|
|
92
|
+
if (o.liveRequired) process.exit(2);
|
|
93
|
+
}
|
|
84
94
|
if (updating) {
|
|
85
95
|
const file = resolve(typeof o.updateBaseline === "string" ? o.updateBaseline : baselinePath ?? ".querydig-baseline.json");
|
|
86
96
|
const n = writeBaseline(file, summary.findings);
|
package/dist/index.js
CHANGED
|
@@ -6,12 +6,13 @@ import {
|
|
|
6
6
|
renderSarif,
|
|
7
7
|
renderTerminal,
|
|
8
8
|
scan
|
|
9
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-P3O2JP5S.js";
|
|
10
|
+
import "./chunk-DZLU5MLV.js";
|
|
10
11
|
import {
|
|
11
12
|
RULES,
|
|
12
13
|
analyzeSource,
|
|
13
14
|
findRule
|
|
14
|
-
} from "./chunk-
|
|
15
|
+
} from "./chunk-YKUITQ6N.js";
|
|
15
16
|
export {
|
|
16
17
|
RULES,
|
|
17
18
|
analyzeSource,
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Finding, QueryShape } from '../types.js';
|
|
2
|
+
import type { LiveData } from './types.js';
|
|
3
|
+
export declare const THRESHOLDS: {
|
|
4
|
+
big: number;
|
|
5
|
+
huge: number;
|
|
6
|
+
scanMin: number;
|
|
7
|
+
tiny: number;
|
|
8
|
+
criticalMs: number;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Merge live database evidence into static findings and add live-only findings.
|
|
12
|
+
* Pure: no I/O, so it can be unit tested with fake LiveData.
|
|
13
|
+
*/
|
|
14
|
+
export declare function correlate(findings: Finding[], shapes: QueryShape[], data: LiveData): Finding[];
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Finding, LiveSummary, QueryShape } from '../types.js';
|
|
2
|
+
import type { LiveAdapter, LiveData, LiveOptions } from './types.js';
|
|
3
|
+
export type { LiveOptions } from './types.js';
|
|
4
|
+
export { correlate } from './correlate.js';
|
|
5
|
+
export { redactUri } from './match.js';
|
|
6
|
+
export declare function adapterFor(opts: LiveOptions): LiveAdapter;
|
|
7
|
+
/** Connect, collect evidence for the static shapes, and return it. Always closes the connection. */
|
|
8
|
+
export declare function collectLive(shapes: QueryShape[], opts: LiveOptions, adapter?: LiveAdapter): Promise<LiveData>;
|
|
9
|
+
/** Collect live evidence and merge it into the findings. */
|
|
10
|
+
export declare function runLive(findings: Finding[], shapes: QueryShape[], opts: LiveOptions): Promise<{
|
|
11
|
+
findings: Finding[];
|
|
12
|
+
summary: LiveSummary;
|
|
13
|
+
}>;
|