Skip to content

Commit c569d20

Browse files
authored
Add custom ESLint rule: safe-typeof-object (#5993)
Ensures that you are not allowed to check if something is an object simply by using `typeof x === 'object'` without also checking if `x !== null`.
1 parent 5d6e10e commit c569d20

File tree

24 files changed

+260
-30
lines changed

24 files changed

+260
-30
lines changed

.github/workflows/eslint-rules.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
name: ESLint Rules
2+
3+
on:
4+
pull_request:
5+
paths:
6+
- 'eslint-rules/**'
7+
push:
8+
branches: [master]
9+
paths:
10+
- 'eslint-rules/**'
11+
12+
concurrency:
13+
group: ${{ github.workflow }}-${{ github.ref || github.run_id }}
14+
cancel-in-progress: true
15+
16+
jobs:
17+
eslint-rules:
18+
runs-on: ubuntu-latest
19+
steps:
20+
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
21+
- uses: ./.github/actions/node/active-lts
22+
- uses: ./.github/actions/install
23+
- run: yarn test:eslint-rules
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
// The content of this file is inspired from the following rule:
2+
// https://github.com/liferay/liferay-frontend-projects/blob/master/projects/eslint-plugin/rules/general/lib/rules/no-typeof-object.js
3+
4+
export default {
5+
meta: {
6+
type: 'problem',
7+
docs: {
8+
description: "Ensure that `typeof x === 'object'` checks are guarded against not-null values",
9+
recommended: true
10+
},
11+
fixable: 'code',
12+
schema: [],
13+
},
14+
15+
create (context) {
16+
return {
17+
BinaryExpression (node) {
18+
if (
19+
node.left?.type === 'UnaryExpression' &&
20+
node.left.operator === 'typeof' &&
21+
node.operator === '===' &&
22+
node.right?.value === 'object'
23+
) {
24+
// Get the expression being checked (x or x.y)
25+
const targetExpression = getSourceText(context, node.left.argument)
26+
27+
let hasNullCheck = false
28+
29+
// First check if there's a null guard at the immediate AND level
30+
let current = node.parent
31+
if (current && current.type === 'LogicalExpression' && current.operator === '&&') {
32+
const conditions = collectAndConditions(current)
33+
hasNullCheck = conditions.some(condition =>
34+
condition !== node && isNullCheck(condition, targetExpression, context)
35+
)
36+
}
37+
38+
// If not found at immediate level, check broader logical expression
39+
if (!hasNullCheck) {
40+
current = node.parent
41+
let rootLogicalExpression = null
42+
43+
// Find the root of any logical expression chain
44+
while (current && current.type === 'LogicalExpression') {
45+
rootLogicalExpression = current
46+
current = current.parent
47+
}
48+
49+
if (rootLogicalExpression) {
50+
// Collect all conditions in the AND chain
51+
const conditions = collectAndConditions(rootLogicalExpression)
52+
53+
// Check if any condition is a null check for our variable
54+
hasNullCheck = conditions.some(condition =>
55+
condition !== node && isNullCheck(condition, targetExpression, context)
56+
)
57+
}
58+
}
59+
60+
if (!hasNullCheck) {
61+
context.report({
62+
fix (fixer) {
63+
return fixer.insertTextBefore(node, `${targetExpression} !== null && `)
64+
},
65+
message: `"typeof ${targetExpression} === 'object'" missing not-null guard`,
66+
node
67+
})
68+
}
69+
}
70+
}
71+
}
72+
}
73+
}
74+
75+
// Helper function to get source text of a node
76+
function getSourceText (context, node) {
77+
return context.getSourceCode().getText(node)
78+
}
79+
80+
// Helper function to collect all conditions from a logical AND chain
81+
function collectAndConditions (node) {
82+
const conditions = []
83+
84+
function traverse (node) {
85+
if (node.type === 'LogicalExpression' && node.operator === '&&') {
86+
traverse(node.left)
87+
traverse(node.right)
88+
} else {
89+
// Don't traverse into OR expressions, just add them as a whole condition
90+
conditions.push(node)
91+
}
92+
}
93+
94+
traverse(node)
95+
96+
return conditions
97+
}
98+
99+
// Helper function to check if a condition is a null check for the given variable
100+
function isNullCheck (condition, targetExpression, context) {
101+
// Check for x !== null or x.y !== null
102+
if (
103+
condition.type === 'BinaryExpression' &&
104+
condition.operator === '!==' &&
105+
condition.right?.value === null &&
106+
getSourceText(context, condition.left) === targetExpression
107+
) {
108+
return true
109+
}
110+
111+
// Check for x (truthy check) or x.y (truthy check)
112+
if (getSourceText(context, condition) === targetExpression) {
113+
return true
114+
}
115+
116+
// Check for !!x (boolean conversion check) or !!x.y
117+
if (
118+
condition.type === 'UnaryExpression' &&
119+
condition.operator === '!' &&
120+
condition.argument?.type === 'UnaryExpression' &&
121+
condition.argument.operator === '!' &&
122+
getSourceText(context, condition.argument.argument) === targetExpression
123+
) {
124+
return true
125+
}
126+
127+
return false
128+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { RuleTester } from 'eslint'
2+
import rule from './eslint-safe-typeof-object.mjs'
3+
4+
const ruleTester = new RuleTester({
5+
languageOptions: { ecmaVersion: 2015 }
6+
})
7+
8+
ruleTester.run('no-typeof-object', rule, {
9+
valid: [
10+
"x !== null && typeof x === 'object'",
11+
"typeof x === 'object' && x !== null",
12+
13+
"x.y !== null && typeof x.y === 'object'",
14+
15+
"true && x !== null && typeof x === 'object'",
16+
"someCondition && x !== null && typeof x === 'object'",
17+
"someCheck() && x !== null && typeof x === 'object'",
18+
"a && b && x !== null && typeof x === 'object'",
19+
20+
"x && typeof x === 'object'",
21+
"!!x && typeof x === 'object'",
22+
23+
"x !== null && (typeof x === 'object' || typeof x === 'function')",
24+
"someCondition && x && (typeof x === 'object' || typeof x === 'function')",
25+
"(x !== null) && (typeof x === 'object' || typeof x === 'function')",
26+
"!!x && (typeof x === 'object' || typeof x === 'function')",
27+
28+
"(x !== null && typeof x === 'object') || typeof x === 'number'",
29+
30+
"typeof x !== 'object'"
31+
],
32+
invalid: [
33+
{
34+
code: "typeof x === 'object'",
35+
output: "x !== null && typeof x === 'object'",
36+
errors: 1
37+
},
38+
{
39+
code: "typeof x.y === 'object'",
40+
output: "x.y !== null && typeof x.y === 'object'",
41+
errors: 1
42+
},
43+
{
44+
code: "!x && typeof x === 'object'",
45+
output: "!x && x !== null && typeof x === 'object'",
46+
errors: 1
47+
},
48+
{
49+
code: "true && typeof x === 'object'",
50+
output: "true && x !== null && typeof x === 'object'",
51+
errors: 1
52+
},
53+
{
54+
code: "someCondition && someOtherCondition && typeof x === 'object'",
55+
output: "someCondition && someOtherCondition && x !== null && typeof x === 'object'",
56+
errors: 1
57+
},
58+
{
59+
code: "a && b && c && typeof x === 'object'",
60+
output: "a && b && c && x !== null && typeof x === 'object'",
61+
errors: 1
62+
},
63+
{
64+
code: "(typeof x === 'object' || typeof x === 'function')",
65+
output: "(x !== null && typeof x === 'object' || typeof x === 'function')",
66+
errors: 1
67+
}
68+
]
69+
})

eslint.config.mjs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import globals from 'globals'
1111

1212
import eslintProcessEnv from './eslint-rules/eslint-process-env.mjs'
1313
import eslintEnvAliases from './eslint-rules/eslint-env-aliases.mjs'
14+
import eslintSafeTypeOfObject from './eslint-rules/eslint-safe-typeof-object.mjs'
1415

1516
const __filename = fileURLToPath(import.meta.url)
1617
const __dirname = path.dirname(__filename)
@@ -117,13 +118,15 @@ export default [
117118
'eslint-rules': {
118119
rules: {
119120
'eslint-process-env': eslintProcessEnv,
120-
'eslint-env-aliases': eslintEnvAliases
121+
'eslint-env-aliases': eslintEnvAliases,
122+
'eslint-safe-typeof-object': eslintSafeTypeOfObject
121123
}
122124
}
123125
},
124126
rules: {
125127
'eslint-rules/eslint-process-env': 'error',
126128
'eslint-rules/eslint-env-aliases': 'error',
129+
'eslint-rules/eslint-safe-typeof-object': 'error',
127130
'n/no-restricted-require': ['error', [
128131
{
129132
name: 'diagnostics_channel',

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"test:appsec:plugins:ci": "yarn services && nyc --no-clean --include \"packages/dd-trace/src/appsec/**/*.js\" -- npm run test:appsec:plugins",
2525
"test:debugger": "mocha -r 'packages/dd-trace/test/setup/mocha.js' 'packages/dd-trace/test/debugger/**/*.spec.js'",
2626
"test:debugger:ci": "nyc --no-clean --include 'packages/dd-trace/src/debugger/**/*.js' -- npm run test:debugger",
27+
"test:eslint-rules": "node eslint-rules/*.test.mjs",
2728
"test:trace:core": "tap packages/dd-trace/test/*.spec.js \"packages/dd-trace/test/{ci-visibility,datastreams,encode,exporters,opentelemetry,opentracing,plugins,service-naming,standalone,telemetry}/**/*.spec.js\"",
2829
"test:trace:core:ci": "npm run test:trace:core -- --coverage --nyc-arg=--include=\"packages/dd-trace/src/**/*.js\"",
2930
"test:instrumentations": "mocha -r 'packages/dd-trace/test/setup/mocha.js' \"packages/datadog-instrumentations/test/@($(echo $PLUGINS)).spec.js\"",

packages/datadog-instrumentations/src/azure-functions.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ addHook({ name: '@azure/functions', versions: ['>=4'] }, azureFunction => {
2525
// The arguments are either an object with a handler property or the handler function itself
2626
function wrapHandler (method) {
2727
return function (name, arg) {
28-
if (typeof arg === 'object' && arg.hasOwnProperty('handler')) {
28+
if (arg !== null && typeof arg === 'object' && arg.hasOwnProperty('handler')) {
2929
const options = arg
3030
shimmer.wrap(options, 'handler', handler => traceHandler(handler, name, method.name))
3131
} else if (typeof arg === 'function') {

packages/datadog-instrumentations/src/helpers/register.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ for (const packageName of names) {
7272

7373
let hook = hooks[packageName]
7474

75-
if (typeof hook === 'object') {
75+
if (hook !== null && typeof hook === 'object') {
7676
if (hook.serverless === false && isInServerlessEnvironment()) continue
7777

7878
// some integrations are disabled by default, but can be enabled by setting

packages/datadog-instrumentations/src/mysql2.js

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ function wrapConnection (Connection, version) {
3535
shimmer.wrap(Connection.prototype, 'query', query => function (sql, values, cb) {
3636
if (!startOuterQueryCh.hasSubscribers) return query.apply(this, arguments)
3737

38-
if (typeof sql === 'object') sql = sql?.sql
38+
if (sql !== null && typeof sql === 'object') sql = sql.sql
3939

4040
if (!sql) return query.apply(this, arguments)
4141

@@ -76,7 +76,7 @@ function wrapConnection (Connection, version) {
7676
shimmer.wrap(Connection.prototype, 'execute', execute => function (sql, values, cb) {
7777
if (!startOuterQueryCh.hasSubscribers) return execute.apply(this, arguments)
7878

79-
if (typeof sql === 'object') sql = sql?.sql
79+
if (sql !== null && typeof sql === 'object') sql = sql.sql
8080

8181
if (!sql) return execute.apply(this, arguments)
8282

@@ -167,7 +167,7 @@ function wrapPool (Pool, version) {
167167
shimmer.wrap(Pool.prototype, 'query', query => function (sql, values, cb) {
168168
if (!startOuterQueryCh.hasSubscribers) return query.apply(this, arguments)
169169

170-
if (typeof sql === 'object') sql = sql?.sql
170+
if (sql !== null && typeof sql === 'object') sql = sql.sql
171171

172172
if (!sql) return query.apply(this, arguments)
173173

@@ -206,7 +206,7 @@ function wrapPool (Pool, version) {
206206
shimmer.wrap(Pool.prototype, 'execute', execute => function (sql, values, cb) {
207207
if (!startOuterQueryCh.hasSubscribers) return execute.apply(this, arguments)
208208

209-
if (typeof sql === 'object') sql = sql?.sql
209+
if (sql !== null && typeof sql === 'object') sql = sql.sql
210210

211211
if (!sql) return execute.apply(this, arguments)
212212

@@ -239,7 +239,7 @@ function wrapPoolCluster (PoolCluster) {
239239

240240
if (startOuterQueryCh.hasSubscribers && !wrappedPoolNamespaces.has(poolNamespace)) {
241241
shimmer.wrap(poolNamespace, 'query', query => function (sql, values, cb) {
242-
if (typeof sql === 'object') sql = sql?.sql
242+
if (sql !== null && typeof sql === 'object') sql = sql.sql
243243

244244
if (!sql) return query.apply(this, arguments)
245245

@@ -274,7 +274,7 @@ function wrapPoolCluster (PoolCluster) {
274274
})
275275

276276
shimmer.wrap(poolNamespace, 'execute', execute => function (sql, values, cb) {
277-
if (typeof sql === 'object') sql = sql?.sql
277+
if (sql !== null && typeof sql === 'object') sql = sql.sql
278278

279279
if (!sql) return execute.apply(this, arguments)
280280

packages/datadog-instrumentations/src/next.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,9 @@ function wrapHandleApiRequest (handleApiRequest) {
5151
function wrapHandleApiRequestWithMatch (handleApiRequest) {
5252
return function (req, res, query, match) {
5353
return instrument(req, res, () => {
54-
const page = (match !== null && typeof match === 'object' && typeof match.definition === 'object')
54+
const page = (
55+
match !== null && typeof match === 'object' && match.definition !== null && typeof match.definition === 'object'
56+
)
5557
? match.definition.pathname
5658
: undefined
5759

packages/datadog-plugin-child_process/src/scrub-cmd-params.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ function scrubChildProcessCmd (expression) {
6363

6464
if (token === null) {
6565
continue
66-
} else if (typeof token === 'object') {
66+
} else if (typeof token === 'object') { // eslint-disable-line eslint-rules/eslint-safe-typeof-object
6767
if (token.pattern) {
6868
result.push(token.pattern)
6969
} else if (token.op) {

0 commit comments

Comments
 (0)