Skip to content

Commit c5ca1f2

Browse files
fix: sanitize threshold values in complexity SQL queries (#136)
* fix: sanitize threshold values in complexity SQL queries Coerce threshold warn values through Number() and guard with isNaN before interpolating into SQL HAVING clauses, preventing malformed queries when non-numeric values are provided in config. Impact: 1 functions changed, 4 affected * test: add regression tests for non-numeric threshold sanitization * fix: strict type validation for threshold values in complexity queries Replace Number() coercion + isNaN with typeof === 'number' && isFinite() to reject values like Number(""), Number(null), Number(true) that silently coerce to valid numbers. Add maintainabilityIndex to default thresholds. Update regression tests to verify exceeds arrays and summary.aboveWarn. Addresses Greptile review on #136. Impact: 2 functions changed, 1 affected * test: verify exceeds arrays are empty with invalid thresholds Assert that no function has exceeds when thresholds are non-numeric strings, complementing the summary.aboveWarn === 0 assertions. --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent 452d9e9 commit c5ca1f2

2 files changed

Lines changed: 73 additions & 13 deletions

File tree

src/complexity.js

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -673,6 +673,7 @@ export function complexityData(customDbPath, opts = {}) {
673673
cognitive: { warn: 15, fail: null },
674674
cyclomatic: { warn: 10, fail: null },
675675
maxNesting: { warn: 4, fail: null },
676+
maintainabilityIndex: { warn: 20, fail: null },
676677
};
677678

678679
// Build query
@@ -699,19 +700,21 @@ export function complexityData(customDbPath, opts = {}) {
699700
params.push(kindFilter);
700701
}
701702

703+
const isValidThreshold = (v) => typeof v === 'number' && Number.isFinite(v);
704+
702705
let having = '';
703706
if (aboveThreshold) {
704707
const conditions = [];
705-
if (thresholds.cognitive?.warn != null) {
708+
if (isValidThreshold(thresholds.cognitive?.warn)) {
706709
conditions.push(`fc.cognitive >= ${thresholds.cognitive.warn}`);
707710
}
708-
if (thresholds.cyclomatic?.warn != null) {
711+
if (isValidThreshold(thresholds.cyclomatic?.warn)) {
709712
conditions.push(`fc.cyclomatic >= ${thresholds.cyclomatic.warn}`);
710713
}
711-
if (thresholds.maxNesting?.warn != null) {
714+
if (isValidThreshold(thresholds.maxNesting?.warn)) {
712715
conditions.push(`fc.max_nesting >= ${thresholds.maxNesting.warn}`);
713716
}
714-
if (thresholds.maintainabilityIndex?.warn != null) {
717+
if (isValidThreshold(thresholds.maintainabilityIndex?.warn)) {
715718
conditions.push(
716719
`fc.maintainability_index > 0 AND fc.maintainability_index <= ${thresholds.maintainabilityIndex.warn}`,
717720
);
@@ -758,14 +761,17 @@ export function complexityData(customDbPath, opts = {}) {
758761

759762
const functions = filtered.map((r) => {
760763
const exceeds = [];
761-
if (thresholds.cognitive?.warn != null && r.cognitive >= thresholds.cognitive.warn)
764+
if (isValidThreshold(thresholds.cognitive?.warn) && r.cognitive >= thresholds.cognitive.warn)
762765
exceeds.push('cognitive');
763-
if (thresholds.cyclomatic?.warn != null && r.cyclomatic >= thresholds.cyclomatic.warn)
766+
if (isValidThreshold(thresholds.cyclomatic?.warn) && r.cyclomatic >= thresholds.cyclomatic.warn)
764767
exceeds.push('cyclomatic');
765-
if (thresholds.maxNesting?.warn != null && r.max_nesting >= thresholds.maxNesting.warn)
768+
if (
769+
isValidThreshold(thresholds.maxNesting?.warn) &&
770+
r.max_nesting >= thresholds.maxNesting.warn
771+
)
766772
exceeds.push('maxNesting');
767773
if (
768-
thresholds.maintainabilityIndex?.warn != null &&
774+
isValidThreshold(thresholds.maintainabilityIndex?.warn) &&
769775
r.maintainability_index > 0 &&
770776
r.maintainability_index <= thresholds.maintainabilityIndex.warn
771777
)
@@ -817,10 +823,13 @@ export function complexityData(customDbPath, opts = {}) {
817823
minMI: +Math.min(...miValues).toFixed(1),
818824
aboveWarn: allRows.filter(
819825
(r) =>
820-
(thresholds.cognitive?.warn != null && r.cognitive >= thresholds.cognitive.warn) ||
821-
(thresholds.cyclomatic?.warn != null && r.cyclomatic >= thresholds.cyclomatic.warn) ||
822-
(thresholds.maxNesting?.warn != null && r.max_nesting >= thresholds.maxNesting.warn) ||
823-
(thresholds.maintainabilityIndex?.warn != null &&
826+
(isValidThreshold(thresholds.cognitive?.warn) &&
827+
r.cognitive >= thresholds.cognitive.warn) ||
828+
(isValidThreshold(thresholds.cyclomatic?.warn) &&
829+
r.cyclomatic >= thresholds.cyclomatic.warn) ||
830+
(isValidThreshold(thresholds.maxNesting?.warn) &&
831+
r.max_nesting >= thresholds.maxNesting.warn) ||
832+
(isValidThreshold(thresholds.maintainabilityIndex?.warn) &&
824833
r.maintainability_index > 0 &&
825834
r.maintainability_index <= thresholds.maintainabilityIndex.warn),
826835
).length,

tests/integration/complexity.test.js

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,15 @@ import fs from 'node:fs';
99
import os from 'node:os';
1010
import path from 'node:path';
1111
import Database from 'better-sqlite3';
12-
import { afterAll, beforeAll, describe, expect, test } from 'vitest';
12+
import { afterAll, beforeAll, describe, expect, test, vi } from 'vitest';
1313
import { complexityData } from '../../src/complexity.js';
14+
import { loadConfig } from '../../src/config.js';
1415
import { initSchema } from '../../src/db.js';
1516

17+
vi.mock('../../src/config.js', () => ({
18+
loadConfig: vi.fn(() => ({})),
19+
}));
20+
1621
// ─── Helpers ───────────────────────────────────────────────────────────
1722

1823
function insertNode(db, name, kind, file, line, endLine = null) {
@@ -320,4 +325,50 @@ describe('complexityData', () => {
320325
expect(typeof fn.maintainabilityIndex).toBe('number');
321326
}
322327
});
328+
329+
// ─── Threshold sanitization (regression) ────────────────────────────
330+
331+
test('non-numeric threshold values do not crash SQL query', () => {
332+
vi.mocked(loadConfig).mockReturnValueOnce({
333+
manifesto: {
334+
rules: {
335+
cognitive: { warn: 'abc' },
336+
cyclomatic: { warn: '123xyz' },
337+
maxNesting: { warn: undefined },
338+
},
339+
},
340+
});
341+
// Should not throw — invalid thresholds are silently skipped
342+
const data = complexityData(dbPath, { aboveThreshold: true });
343+
expect(data.functions).toBeDefined();
344+
expect(Array.isArray(data.functions)).toBe(true);
345+
// With all thresholds invalid, no filtering occurs — all functions returned
346+
expect(data.functions.length).toBeGreaterThanOrEqual(4);
347+
expect(data.summary.aboveWarn).toBe(0);
348+
// No function should have exceeds when all thresholds are invalid
349+
for (const fn of data.functions) {
350+
expect(fn.exceeds).toBeUndefined();
351+
}
352+
});
353+
354+
test('string-numeric thresholds are rejected (strict type check)', () => {
355+
vi.mocked(loadConfig).mockReturnValueOnce({
356+
manifesto: {
357+
rules: {
358+
cognitive: { warn: '15' },
359+
cyclomatic: { warn: '10' },
360+
maxNesting: { warn: '4' },
361+
},
362+
},
363+
});
364+
const data = complexityData(dbPath, { aboveThreshold: true });
365+
// String thresholds fail typeof === 'number' — treated as no threshold
366+
// so all functions are returned (no HAVING filter applied)
367+
expect(data.functions.length).toBeGreaterThanOrEqual(4);
368+
expect(data.summary.aboveWarn).toBe(0);
369+
// No exceeds when thresholds are strings
370+
for (const fn of data.functions) {
371+
expect(fn.exceeds).toBeUndefined();
372+
}
373+
});
323374
});

0 commit comments

Comments
 (0)