@pikku/inspector 0.12.65 → 0.12.67
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/CHANGELOG.md +73 -0
- package/dist/add/add-functions.js +18 -9
- package/dist/add/add-rpc-invocations.js +25 -2
- package/dist/error-codes.d.ts +2 -1
- package/dist/error-codes.js +1 -0
- package/dist/inspector.js +5 -0
- package/dist/types.d.ts +18 -0
- package/dist/utils/check-classified-sinks.d.ts +40 -0
- package/dist/utils/check-classified-sinks.js +170 -0
- package/package.json +3 -3
- package/src/add/add-functions.ts +19 -9
- package/src/add/add-rpc-invocations.test.ts +50 -0
- package/src/add/add-rpc-invocations.ts +26 -2
- package/src/add/{secret-sink-check.test.ts → classified-sink-check.test.ts} +135 -2
- package/src/error-codes.ts +1 -0
- package/src/inspector.ts +5 -0
- package/src/types.ts +18 -0
- package/src/utils/check-classified-sinks.ts +215 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/src/utils/check-secret-sinks.ts +0 -148
|
@@ -23,11 +23,22 @@ function makeLogger() {
|
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
25
|
* Stands in for @pikku/core, which is not importable from a tmp dir. The shapes
|
|
26
|
-
* are what matters: `Secret<T>` is the optional brand `.reveal()` returns,
|
|
27
|
-
*
|
|
26
|
+
* are what matters: `Secret<T>` is the optional brand `.reveal()` returns,
|
|
27
|
+
* `Pii<T>` and `Private<T>` are the brands a generated row type carries, and the
|
|
28
|
+
* service interfaces are matched by name, so these must keep the real names.
|
|
28
29
|
*/
|
|
29
30
|
const PRELUDE = `
|
|
30
31
|
type Secret<T> = T & { readonly __classification__?: 'secret' }
|
|
32
|
+
type Pii<T> = T & { readonly __classification__?: 'pii' }
|
|
33
|
+
type Private<T> = T & { readonly __classification__?: 'private' }
|
|
34
|
+
|
|
35
|
+
/** What \`db.selectFrom('user')\` hands back once the schema is generated. */
|
|
36
|
+
interface UserRow {
|
|
37
|
+
userId: string
|
|
38
|
+
email: Pii<string>
|
|
39
|
+
internalNote: Private<string>
|
|
40
|
+
}
|
|
41
|
+
declare function getUser(): Promise<UserRow>
|
|
31
42
|
|
|
32
43
|
declare class SecretValue<T = string> {
|
|
33
44
|
private brand: T
|
|
@@ -88,6 +99,9 @@ async function runInspect(source: string) {
|
|
|
88
99
|
const sinkErrors = (diagnostics: Array<{ code: ErrorCode; message: string }>) =>
|
|
89
100
|
diagnostics.filter((d) => d.code === ErrorCode.SECRET_REVEALED_INTO_SINK)
|
|
90
101
|
|
|
102
|
+
const piiErrors = (diagnostics: Array<{ code: ErrorCode; message: string }>) =>
|
|
103
|
+
diagnostics.filter((d) => d.code === ErrorCode.PII_INTO_SINK)
|
|
104
|
+
|
|
91
105
|
describe('PKU953 — a revealed secret reaching a sink', () => {
|
|
92
106
|
test('flags a secret logged inline', async () => {
|
|
93
107
|
const diagnostics = await runInspect(`
|
|
@@ -212,3 +226,122 @@ describe('PKU953 — a revealed secret reaching a sink', () => {
|
|
|
212
226
|
assert.equal(sinkErrors(diagnostics).length, 0)
|
|
213
227
|
})
|
|
214
228
|
})
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* PII is not a secret, so the policy is not "never write it down" — it is "not
|
|
232
|
+
* where it leaves your control". A log ships to an aggregator and a webhook
|
|
233
|
+
* posts to somebody else's server; an email is addressed to the data subject,
|
|
234
|
+
* and a queue and an audit stay on the operator's own infrastructure.
|
|
235
|
+
*/
|
|
236
|
+
describe('PKU954 — personal data reaching a sink', () => {
|
|
237
|
+
test('flags PII logged inline and through a local binding', async () => {
|
|
238
|
+
const diagnostics = await runInspect(`
|
|
239
|
+
export const leak = pikkuFunc<void, void>({
|
|
240
|
+
func: async ({ logger }) => {
|
|
241
|
+
const user = await getUser()
|
|
242
|
+
logger.info(user.email)
|
|
243
|
+
const email = user.email
|
|
244
|
+
logger.error('send failed', { email })
|
|
245
|
+
},
|
|
246
|
+
})
|
|
247
|
+
`)
|
|
248
|
+
const errors = piiErrors(diagnostics)
|
|
249
|
+
assert.equal(errors.length, 2)
|
|
250
|
+
assert.match(errors[0]!.message, /logger\.info/)
|
|
251
|
+
assert.match(errors[1]!.message, /logger\.error/)
|
|
252
|
+
assert.equal(sinkErrors(diagnostics).length, 0)
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
test('flags a whole row logged, since the PII rides along inside it', async () => {
|
|
256
|
+
const diagnostics = await runInspect(`
|
|
257
|
+
export const leak = pikkuFunc<void, void>({
|
|
258
|
+
func: async ({ logger }) => {
|
|
259
|
+
const user = await getUser()
|
|
260
|
+
logger.info('loaded', user)
|
|
261
|
+
},
|
|
262
|
+
})
|
|
263
|
+
`)
|
|
264
|
+
assert.equal(piiErrors(diagnostics).length, 1)
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
test('flags PII on console and in a webhook', async () => {
|
|
268
|
+
const diagnostics = await runInspect(`
|
|
269
|
+
export const leak = pikkuFunc<void, void>({
|
|
270
|
+
func: async ({ webhooks }) => {
|
|
271
|
+
const user = await getUser()
|
|
272
|
+
console.log(user.email)
|
|
273
|
+
await webhooks.send({ url: 'https://x', data: { email: user.email } })
|
|
274
|
+
},
|
|
275
|
+
})
|
|
276
|
+
`)
|
|
277
|
+
const errors = piiErrors(diagnostics)
|
|
278
|
+
assert.equal(errors.length, 2)
|
|
279
|
+
assert.match(errors[0]!.message, /console\.log/)
|
|
280
|
+
assert.match(errors[1]!.message, /webhooks\.send/)
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
test('stays quiet for an email to the data subject, an audit and a queue', async () => {
|
|
284
|
+
const diagnostics = await runInspect(`
|
|
285
|
+
export const fine = pikkuFunc<void, void>({
|
|
286
|
+
func: async ({ email, audit, auditLog, queueService }) => {
|
|
287
|
+
const user = await getUser()
|
|
288
|
+
await email.send({ to: user.email, text: 'Hi ' + user.email })
|
|
289
|
+
await audit?.audit({ type: 'viewed', input: { email: user.email } })
|
|
290
|
+
await auditLog?.write({ type: 'viewed', input: user.email })
|
|
291
|
+
await queueService.add('welcome', { email: user.email })
|
|
292
|
+
},
|
|
293
|
+
})
|
|
294
|
+
`)
|
|
295
|
+
assert.equal(piiErrors(diagnostics).length, 0)
|
|
296
|
+
assert.equal(sinkErrors(diagnostics).length, 0)
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
test('stays quiet for private, which is about who may read the row', async () => {
|
|
300
|
+
const diagnostics = await runInspect(`
|
|
301
|
+
export const fine = pikkuFunc<void, void>({
|
|
302
|
+
func: async ({ logger }) => {
|
|
303
|
+
const user = await getUser()
|
|
304
|
+
logger.info('note', { note: user.internalNote, id: user.userId })
|
|
305
|
+
},
|
|
306
|
+
})
|
|
307
|
+
`)
|
|
308
|
+
assert.equal(piiErrors(diagnostics).length, 0)
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
test('stays quiet when an explicit annotation erases the brand', async () => {
|
|
312
|
+
const diagnostics = await runInspect(`
|
|
313
|
+
export const documentedLimit = pikkuFunc<void, void>({
|
|
314
|
+
func: async ({ logger }) => {
|
|
315
|
+
const user = await getUser()
|
|
316
|
+
const email: string = user.email
|
|
317
|
+
logger.info(email)
|
|
318
|
+
},
|
|
319
|
+
})
|
|
320
|
+
`)
|
|
321
|
+
assert.equal(piiErrors(diagnostics).length, 0)
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
test('does not run without the classification flag', async () => {
|
|
325
|
+
const tmpDir = await mkdtemp(join(tmpdir(), 'pikku-pii-sink-off-'))
|
|
326
|
+
const file = join(tmpDir, 'funcs.ts')
|
|
327
|
+
await writeFile(
|
|
328
|
+
file,
|
|
329
|
+
PRELUDE +
|
|
330
|
+
`
|
|
331
|
+
export const leak = pikkuFunc<void, void>({
|
|
332
|
+
func: async ({ logger }) => {
|
|
333
|
+
const user = await getUser()
|
|
334
|
+
logger.info(user.email)
|
|
335
|
+
},
|
|
336
|
+
})
|
|
337
|
+
`
|
|
338
|
+
)
|
|
339
|
+
const { logger, diagnostics } = makeLogger()
|
|
340
|
+
try {
|
|
341
|
+
await inspect(logger, [file], { rootDir: tmpDir })
|
|
342
|
+
} finally {
|
|
343
|
+
await rm(tmpDir, { recursive: true, force: true })
|
|
344
|
+
}
|
|
345
|
+
assert.equal(piiErrors(diagnostics).length, 0)
|
|
346
|
+
})
|
|
347
|
+
})
|
package/src/error-codes.ts
CHANGED
package/src/inspector.ts
CHANGED
|
@@ -493,6 +493,10 @@ export const inspect = async (
|
|
|
493
493
|
)
|
|
494
494
|
|
|
495
495
|
if (!options.setupOnly) {
|
|
496
|
+
// Everything below re-derives from the state this pass just built, so it
|
|
497
|
+
// supersedes whatever an earlier pass concluded about a source graph the
|
|
498
|
+
// run has since finished generating into.
|
|
499
|
+
logger.beginValidationPass?.()
|
|
496
500
|
const startAggregate = performance.now()
|
|
497
501
|
// Apply the inspected auth handler service set before aggregation so it
|
|
498
502
|
// flows into requiredServices (the generated handler's own func is opaque).
|
|
@@ -542,6 +546,7 @@ export const inspect = async (
|
|
|
542
546
|
validateScopeReferences(logger, state)
|
|
543
547
|
validateSystemRoleScopes(logger, state)
|
|
544
548
|
validatePersonaRoles(logger, state)
|
|
549
|
+
logger.endValidationPass?.()
|
|
545
550
|
}
|
|
546
551
|
|
|
547
552
|
state.program = program
|
package/src/types.ts
CHANGED
|
@@ -366,6 +366,24 @@ export interface InspectorLogger {
|
|
|
366
366
|
/** Sugar for `diagnostic({ severity: 'critical', code, message })`. */
|
|
367
367
|
critical: (code: ErrorCode, message: string) => void
|
|
368
368
|
hasCriticalErrors: () => boolean
|
|
369
|
+
/**
|
|
370
|
+
* Opens a validation pass, discarding whatever a previous one recorded.
|
|
371
|
+
*
|
|
372
|
+
* A single `pikku all` inspects several times, because generating the
|
|
373
|
+
* scaffold, the leaf indexes and the type files each changes the source graph
|
|
374
|
+
* the next inspection reads. The validators are pure functions of that graph
|
|
375
|
+
* and every full inspection re-runs all of them, so the newest pass is always
|
|
376
|
+
* the complete one and the ones before it are strictly superseded.
|
|
377
|
+
*
|
|
378
|
+
* Without this the diagnostics accumulated instead, and a run failed on a
|
|
379
|
+
* complaint about its own half-built input: a project whose roles reference
|
|
380
|
+
* scaffold-declared scopes was reported as failing even though the pass that
|
|
381
|
+
* ran once the scaffold existed was clean. Optional so a host supplying its
|
|
382
|
+
* own logger keeps working — one that omits it simply never discards.
|
|
383
|
+
*/
|
|
384
|
+
beginValidationPass?: () => void
|
|
385
|
+
/** Closes the pass opened by `beginValidationPass`. */
|
|
386
|
+
endValidationPass?: () => void
|
|
369
387
|
}
|
|
370
388
|
|
|
371
389
|
export type AddWiring = (
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import * as ts from 'typescript'
|
|
2
|
+
import { findPiiPaths } from './check-pii-output.js'
|
|
3
|
+
import { doesTypeExtendsCore } from './does-type-extend-core-type.js'
|
|
4
|
+
|
|
5
|
+
export type SinkClassification = 'secret' | 'pii'
|
|
6
|
+
|
|
7
|
+
export type SinkViolation = {
|
|
8
|
+
/** How the value reaches the outside world, e.g. `logger.info`. */
|
|
9
|
+
sink: string
|
|
10
|
+
/** Dotted path to the secret inside the argument, or `<argument>` when bare. */
|
|
11
|
+
path: string
|
|
12
|
+
/** Position of the offending argument, for the diagnostic message. */
|
|
13
|
+
line: number
|
|
14
|
+
/** Which brand was found, so the caller can pick the diagnostic. */
|
|
15
|
+
classification: SinkClassification
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A method that writes its arguments somewhere a classified value must never
|
|
20
|
+
* land, keyed by the core interface that owns it. `'*'` means every method on
|
|
21
|
+
* the type is a sink, which is true of a logger.
|
|
22
|
+
*
|
|
23
|
+
* `rejects` differs per sink because the two brands leak differently. A vault
|
|
24
|
+
* secret is wrong everywhere: no sink has a legitimate reason to receive one.
|
|
25
|
+
* PII is wrong only where it leaves the operator's control, or lands somewhere
|
|
26
|
+
* never designed to hold it:
|
|
27
|
+
*
|
|
28
|
+
* - a log is read far more widely than the record it came from, is usually
|
|
29
|
+
* shipped to a third-party aggregator, and is retained long past the
|
|
30
|
+
* lifetime of any consent — so PII does not belong in one;
|
|
31
|
+
* - a webhook posts to somebody else's server, which is the same disclosure
|
|
32
|
+
* with a different transport;
|
|
33
|
+
* - an email is addressed to the data subject, and their own name in it is
|
|
34
|
+
* the point;
|
|
35
|
+
* - an audit is a record in the operator's own database, and naming who did
|
|
36
|
+
* what is usually the legal requirement rather than a breach of it;
|
|
37
|
+
* - a queue payload stays on the operator's own infrastructure and is
|
|
38
|
+
* consumed by their own worker.
|
|
39
|
+
*
|
|
40
|
+
* `private` is deliberately absent everywhere: it marks a value that is not
|
|
41
|
+
* public, which is a statement about who may read the row — not a claim that
|
|
42
|
+
* writing it down is a disclosure.
|
|
43
|
+
*/
|
|
44
|
+
const SINKS: Array<{
|
|
45
|
+
coreType: string
|
|
46
|
+
methods: '*' | string[]
|
|
47
|
+
rejects: SinkClassification[]
|
|
48
|
+
}> = [
|
|
49
|
+
{ coreType: 'Logger', methods: '*', rejects: ['secret', 'pii'] },
|
|
50
|
+
{ coreType: 'WebhookService', methods: ['send'], rejects: ['secret', 'pii'] },
|
|
51
|
+
{ coreType: 'QueueService', methods: ['add'], rejects: ['secret'] },
|
|
52
|
+
{ coreType: 'EmailService', methods: ['send'], rejects: ['secret'] },
|
|
53
|
+
{
|
|
54
|
+
coreType: 'AuditService',
|
|
55
|
+
methods: ['audit', 'write'],
|
|
56
|
+
rejects: ['secret'],
|
|
57
|
+
},
|
|
58
|
+
{ coreType: 'AuditLog', methods: ['write'], rejects: ['secret'] },
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
/** `console` has no Pikku type, and is read as widely as any logger. */
|
|
62
|
+
const CONSOLE_REJECTS: SinkClassification[] = ['secret', 'pii']
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Finds revealed vault secrets — and, for the sinks that reject them, PII
|
|
66
|
+
* columns — that flow into a sink.
|
|
67
|
+
*
|
|
68
|
+
* `SecretValue` is nominally typed, so the type system alone stops it reaching
|
|
69
|
+
* any of these. `.reveal()` is the deliberate escape hatch, and it hands back
|
|
70
|
+
* `Secret<T>` — the erasable classification brand. That brand is what this scan
|
|
71
|
+
* follows, which means it catches the revealed value both inline
|
|
72
|
+
* (`logger.info(s.reveal())`) and through a local binding, because the local's
|
|
73
|
+
* inferred type carries the brand too.
|
|
74
|
+
*
|
|
75
|
+
* PII arrives by the same route without a `.reveal()`: `Pii<T>` is the brand the
|
|
76
|
+
* generated row types carry, so a column read straight out of the database keeps
|
|
77
|
+
* it through a local binding and into the call.
|
|
78
|
+
*
|
|
79
|
+
* The brand is optional by design, so an explicit annotation (`const t: string =
|
|
80
|
+
* s.reveal()`, or `const email: string = user.email`) erases it and this scan
|
|
81
|
+
* goes quiet. That is the documented limit
|
|
82
|
+
* of a structural marker: it catches the mistake, not someone determined to
|
|
83
|
+
* silence it.
|
|
84
|
+
*
|
|
85
|
+
* Sinks are matched on the receiver's resolved core interface, which means the
|
|
86
|
+
* scan needs the services parameter to be typed. Real projects get that from
|
|
87
|
+
* the generated `#pikku` factory; a hand-rolled file that calls the raw core
|
|
88
|
+
* factory without annotating its services leaves the parameter `any`, and an
|
|
89
|
+
* `any` receiver matches nothing.
|
|
90
|
+
*/
|
|
91
|
+
export function findClassifiedSinks(
|
|
92
|
+
checker: ts.TypeChecker,
|
|
93
|
+
handler: ts.Node
|
|
94
|
+
): SinkViolation[] {
|
|
95
|
+
const violations: SinkViolation[] = []
|
|
96
|
+
|
|
97
|
+
const visit = (node: ts.Node): void => {
|
|
98
|
+
if (
|
|
99
|
+
ts.isCallExpression(node) &&
|
|
100
|
+
ts.isPropertyAccessExpression(node.expression)
|
|
101
|
+
) {
|
|
102
|
+
const sink = describeSink(checker, node.expression)
|
|
103
|
+
if (sink) {
|
|
104
|
+
for (const arg of node.arguments) {
|
|
105
|
+
const found = findClassifiedExpression(checker, arg, sink.rejects)
|
|
106
|
+
if (found) {
|
|
107
|
+
violations.push({
|
|
108
|
+
sink: sink.name,
|
|
109
|
+
path: found.node.getText(),
|
|
110
|
+
line:
|
|
111
|
+
found.node
|
|
112
|
+
.getSourceFile()
|
|
113
|
+
.getLineAndCharacterOfPosition(found.node.getStart()).line +
|
|
114
|
+
1,
|
|
115
|
+
classification: found.classification,
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
ts.forEachChild(node, visit)
|
|
122
|
+
}
|
|
123
|
+
visit(handler)
|
|
124
|
+
|
|
125
|
+
return violations
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Finds the first expression inside a sink argument whose own type carries one
|
|
130
|
+
* of the brands this sink rejects, and returns it so the diagnostic can quote
|
|
131
|
+
* it.
|
|
132
|
+
*
|
|
133
|
+
* This walks the argument rather than typing it whole, because an object
|
|
134
|
+
* literal in an argument position is contextually typed by the parameter — for
|
|
135
|
+
* a guarded sink that parameter is `Safe<M>`, and resolving the literal against
|
|
136
|
+
* it drops the optional brand before it can be read. Each inner expression
|
|
137
|
+
* (`token.reveal()`, or a reference to a local holding it) carries its own type
|
|
138
|
+
* regardless of the surrounding context, so reading those is what actually
|
|
139
|
+
* sees the secret.
|
|
140
|
+
*/
|
|
141
|
+
function findClassifiedExpression(
|
|
142
|
+
checker: ts.TypeChecker,
|
|
143
|
+
node: ts.Node,
|
|
144
|
+
rejects: SinkClassification[],
|
|
145
|
+
/**
|
|
146
|
+
* The node is only being read to reach a property of it, so its own type says
|
|
147
|
+
* nothing about what the call receives. Without this, `user.userId` reports
|
|
148
|
+
* the row's `email` column: the walk types the `user` receiver on its way
|
|
149
|
+
* down, and a row type is branded wherever any of its columns are.
|
|
150
|
+
*/
|
|
151
|
+
isReceiver = false
|
|
152
|
+
): { node: ts.Expression; classification: SinkClassification } | undefined {
|
|
153
|
+
// Descend first, so an object literal wrapping the secret reports the
|
|
154
|
+
// expression that actually holds it (`token.reveal()`) rather than the whole
|
|
155
|
+
// literal. Nothing inside a `SecretValue` receiver is itself branded, so a
|
|
156
|
+
// bare `token.reveal()` still falls through to the self-check below.
|
|
157
|
+
for (const child of node.getChildren()) {
|
|
158
|
+
// A property name is not a value, and typing it re-resolves the property.
|
|
159
|
+
if (ts.isPropertyAssignment(node) && child === node.name) continue
|
|
160
|
+
const childIsReceiver =
|
|
161
|
+
(ts.isPropertyAccessExpression(node) ||
|
|
162
|
+
ts.isElementAccessExpression(node)) &&
|
|
163
|
+
child === node.expression
|
|
164
|
+
const found = findClassifiedExpression(
|
|
165
|
+
checker,
|
|
166
|
+
child,
|
|
167
|
+
rejects,
|
|
168
|
+
childIsReceiver
|
|
169
|
+
)
|
|
170
|
+
if (found) return found
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (!isReceiver && ts.isExpression(node) && !ts.isStringLiteralLike(node)) {
|
|
174
|
+
const type = checker.getTypeAtLocation(node)
|
|
175
|
+
/* `secret` outranks `pii` when a value somehow carries both, so the caller
|
|
176
|
+
reports the more serious of the two rather than whichever came first. */
|
|
177
|
+
for (const classification of ['secret', 'pii'] as const) {
|
|
178
|
+
if (!rejects.includes(classification)) continue
|
|
179
|
+
const hit = findPiiPaths(checker, type).some(
|
|
180
|
+
(f) => f.classification === classification
|
|
181
|
+
)
|
|
182
|
+
if (hit) return { node, classification }
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return undefined
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Names the sink a call writes to, or undefined when the call is not one.
|
|
191
|
+
* `console` is matched by identifier because it is a global with no Pikku type;
|
|
192
|
+
* everything else is matched on its resolved core interface so a renamed
|
|
193
|
+
* service or a decorator still counts.
|
|
194
|
+
*/
|
|
195
|
+
function describeSink(
|
|
196
|
+
checker: ts.TypeChecker,
|
|
197
|
+
callee: ts.PropertyAccessExpression
|
|
198
|
+
): { name: string; rejects: SinkClassification[] } | undefined {
|
|
199
|
+
const method = callee.name.text
|
|
200
|
+
const receiver = callee.expression
|
|
201
|
+
|
|
202
|
+
if (ts.isIdentifier(receiver) && receiver.text === 'console') {
|
|
203
|
+
return { name: `console.${method}`, rejects: CONSOLE_REJECTS }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const receiverType = checker.getTypeAtLocation(receiver)
|
|
207
|
+
for (const { coreType, methods, rejects } of SINKS) {
|
|
208
|
+
if (methods !== '*' && !methods.includes(method)) continue
|
|
209
|
+
if (doesTypeExtendsCore(receiverType, checker, new Set(), coreType)) {
|
|
210
|
+
return { name: `${receiver.getText()}.${method}`, rejects }
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return undefined
|
|
215
|
+
}
|