@clear-capabilities/agentic-security-scanner 0.187.0 → 0.188.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/CHANGELOG.md +9 -0
- package/dist/{deabc47d0e27e21bf99b.json → 92f78fd3e2dbe30fb58d.json} +3 -3
- package/dist/agentic-security.mjs +2 -2
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +3 -3
- package/src/dataflow/CLAUDE.md +13 -0
- package/src/dataflow/catalog.js +14 -0
- package/src/dataflow/engine.js +131 -10
- package/src/engine.js +11 -3
- package/src/language/CLAUDE.md +7 -0
- package/src/language/haskell-comments.js +65 -0
- package/src/language/haskell-findings.js +2 -1
- package/src/language/haskell-fix.js +118 -2
- package/src/language/haskell-guards.js +2 -2
- package/src/language/haskell-ir.js +46 -3
- package/src/language/haskell-models.js +15 -4
- package/src/language/haskell-security-rules.js +113 -9
- package/src/language/haskell-trust.js +268 -0
- package/src/language/haskell-web.js +2 -1
- package/src/sast/cloud-iam.js +3 -1
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import { buildHaskellIR } from './haskell-ir.js';
|
|
17
17
|
import { HS_LOG_SINKS, HS_MODEL_VERSION } from './haskell-models.js';
|
|
18
|
+
import { codeOfLine } from './haskell-comments.js';
|
|
18
19
|
|
|
19
20
|
export const HS_RULES_VERSION = 'haskell-security-rules/1';
|
|
20
21
|
|
|
@@ -68,7 +69,9 @@ const UNSAFE_CALLS = { 'System.IO.Unsafe.unsafePerformIO': 'unsafePerformIO', 'S
|
|
|
68
69
|
const SECRET_KEY_CALLS = new Set(['Web.JWT.hmacSecret', 'Web.JWT.secret', 'Servant.Auth.Server.fromSecret', 'Web.ClientSession.initKey']);
|
|
69
70
|
const isTrueLit = (e) => !!e && ((e.kind === 'literal' && /^true$/i.test(String(e.value))) || (e.kind === 'ident' && /(?:^|\.)True$/.test(e.name || '')));
|
|
70
71
|
const TIME_SOURCES = new Set(['Data.Time.Clock.POSIX.getPOSIXTime', 'Data.Time.Clock.getCurrentTime', 'System.CPUTime.getCPUTime']);
|
|
71
|
-
|
|
72
|
+
// A salt is deliberately absent: it must be unique, not unpredictable or secret (it is stored beside the hash), so a non-cryptographic generator
|
|
73
|
+
// producing one is not a weakness the way it is for a token, nonce or key.
|
|
74
|
+
const SECURITY_FN_SUBSTR = /(?:token|nonce|secret|otp|csrf|session|apikey|resetcode|passcode|verification)/i;
|
|
72
75
|
// Short credential words that would over-match as substrings (`spin`, `mapping`, `keyboard`): matched as whole camelCase / snake_case words.
|
|
73
76
|
const SECURITY_WORDS = new Set(['pin', 'password', 'passwd', 'credential', 'credentials']);
|
|
74
77
|
const SECURITY_FN = { test: (name) => SECURITY_FN_SUBSTR.test(name) || String(name).replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase().split(/[^a-z0-9]+/).some((w) => SECURITY_WORDS.has(w)) };
|
|
@@ -89,7 +92,7 @@ const EMPTINESS_CHECK = /(?:^|\.)(?:null|notNull|isEmpty|length|size|isJust|isNo
|
|
|
89
92
|
const SESSION_COOKIE = /(?:sess|sid|token|tok|auth|jwt|csrf|login|remember|secret)/i;
|
|
90
93
|
|
|
91
94
|
// What makes a bound variable security-shaped, and what makes a value sensitive for logging.
|
|
92
|
-
const SECURITY_VAR = /^(?:.*(?:token|secret|nonce|
|
|
95
|
+
const SECURITY_VAR = /^(?:.*(?:token|secret|nonce|otp|csrf|apikey|api_?key|sessionid|session_?key|sessiontoken|resettoken)[a-z0-9_']*|key|iv|sessionId|sid)$/i;
|
|
93
96
|
const PASSWORD_VAR = /(?:^|[^a-z])(?:pass(?:word|wd|phrase)?|pwd|pw)(?:[^a-z]|$)|password/i;
|
|
94
97
|
const LOW_ENTROPY = /(?:pass(?:word|wd|phrase)?|pwd|ssn|creditcard|cardnumber|cvv|\bpin\b)/i;
|
|
95
98
|
export const SENSITIVE = /(?:pass(?:word|wd|phrase)?|pwd|(?:^|[^a-z])pw(?:[^a-z]|$)|secret|token|api_?key|ssn|credit_?card|card_?number|cvv|authorization|bearer|private_?key|session_?id)/i;
|
|
@@ -128,6 +131,15 @@ const isLiteralLike = (e, depth = 0) => {
|
|
|
128
131
|
return false;
|
|
129
132
|
};
|
|
130
133
|
|
|
134
|
+
// A concatenation of literals only (`"a=" <> "0" <> "; Path=/"`).
|
|
135
|
+
const constantConcat = (e, depth = 0) => {
|
|
136
|
+
if (!e || depth > 12) return false;
|
|
137
|
+
if (e.kind === 'literal') return true;
|
|
138
|
+
if (e.kind === 'binary' && e.hs && e.hs.concat) return constantConcat(e.left, depth + 1) && constantConcat(e.right, depth + 1);
|
|
139
|
+
if (e.kind === 'call' && /(?:^|\.)(?:pack|fromString|encodeUtf8|fromStrict|toStrict)$/.test(e.callee || '') && e.args && e.args.length === 1) return constantConcat(e.args[0], depth + 1);
|
|
140
|
+
return false;
|
|
141
|
+
};
|
|
142
|
+
|
|
131
143
|
function fnNodes(fn) { return Object.values(fn.cfg.nodes); }
|
|
132
144
|
|
|
133
145
|
// The text a concatenation spells out, in order, skipping the parts that are not literals (`"session_id=" <> sid <> "; Path=/"` -> `session_id=; Path=/`).
|
|
@@ -142,7 +154,80 @@ function literalTextOf(e, depth = 0) {
|
|
|
142
154
|
function snippetOf(text, line) {
|
|
143
155
|
if (!text || !Number.isInteger(line) || line < 1) return '';
|
|
144
156
|
const l = text.split('\n')[line - 1];
|
|
145
|
-
return typeof l === 'string' ? l
|
|
157
|
+
return typeof l === 'string' ? codeOfLine(l).slice(0, 240) : '';
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// An unbounded read is a resource-exhaustion finding when the party that fills the input is not the operator. A handle opened on a
|
|
161
|
+
// local file, a child process's pipe or a handle passed in is the operator's own (a file's size is what the file system says); a
|
|
162
|
+
// socket or connection handle is filled by the peer. A read of standard input is the operator's in a program's own control flow, and
|
|
163
|
+
// the caller's only in an entry point nothing in the analysed code calls (a library function handing back whatever its caller pipes in).
|
|
164
|
+
const NETWORK_HANDLE_CALL = /(?:^|\.)(?:socketToHandle|connectTo|accept|acceptOn|openTCPConnection)$/;
|
|
165
|
+
function readsOutsideInput(fn, callee, args, importsNetwork) {
|
|
166
|
+
const h = args && args[0];
|
|
167
|
+
const isStdin = !!h && h.kind === 'ident' && /(?:^|\.)stdin$/.test(h.name || '');
|
|
168
|
+
const handleRead = /hGetContents$/.test(callee) && !isStdin;
|
|
169
|
+
// the networking packages are not all in the model registry, so a call keeps its bare name; the module imports say which library it is
|
|
170
|
+
if (handleRead) return !!importsNetwork && (fn.calls || []).some((x) => typeof x.callee === 'string' && NETWORK_HANDLE_CALL.test(x.callee));
|
|
171
|
+
const hs = fn.hs || {};
|
|
172
|
+
return !hs.reached && !/(?:^|\.)main$/.test(fn.name || '');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const EQUALITY_OPS = new Set(['==', '/=', '!=']);
|
|
176
|
+
const VERIFY_CALL = /(?:^|\.)(?:constEq|secureCompare|verify\w*|authenticate\w*|checkPassword|validate\w*|hmac\w*|sign\w*|jwt\w*|encode(?:Signed)?)$/i;
|
|
177
|
+
// Whole camelCase / snake_case words: `sig` must not match `design`, `mac` must not match `machine`.
|
|
178
|
+
const CREDENTIAL_WORDS = new Set(['token', 'secret', 'nonce', 'otp', 'csrf', 'apikey', 'session', 'signature', 'sig', 'mac', 'hmac', 'credential', 'credentials', 'auth', 'cookie', 'passcode', 'cert', 'fingerprint']);
|
|
179
|
+
const CREDENTIAL_NAME = { test: (name) => String(name).replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase().split(/[^a-z0-9]+/).some((w) => CREDENTIAL_WORDS.has(w)) };
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Why a weak-hash call is a security matter: the list is empty when the digest is only an identifier or its use is not visible.
|
|
183
|
+
* Everything is read from the function's own IR (a value compared, bound to a credential-shaped name, hashing a secret-shaped value,
|
|
184
|
+
* handed to a signing or verifying call); nothing is read from comments or from the file's name.
|
|
185
|
+
*/
|
|
186
|
+
function weakDigestUse(fn, node, hashCall, hashArgs, fnBase) {
|
|
187
|
+
const reasons = [];
|
|
188
|
+
const nodes = Object.values(fn.cfg.nodes);
|
|
189
|
+
const exprsOf = (n) => [n.source, n.value, n.cond, ...(n.args || [])].filter(Boolean);
|
|
190
|
+
const hasNode = (x, target) => { for (const y of walk(x)) if (y === target) return true; return false; };
|
|
191
|
+
// 1. what is hashed
|
|
192
|
+
const hashed = (hashArgs || []).flatMap((a) => [...names(a)]).map((n) => n.replace(/^\./, ''));
|
|
193
|
+
if (hashed.some((n) => SENSITIVE.test(n) && !PASSWORD_VAR.test(n))) reasons.push('hashes a secret-shaped value');
|
|
194
|
+
// 2. values carrying the digest: the assignment target and everything derived from it
|
|
195
|
+
const carriers = new Set();
|
|
196
|
+
// the digest travels through formatting (`show`, `pack`, hex encoding, concatenation), not through an arbitrary call that merely receives it
|
|
197
|
+
const FORMAT = /(?:^|\.)(?:show|pack|unpack|take|drop|toLower|toUpper|showDigest|convertToBase|encode\w*|decode\w*|fromStrict|toStrict|toString|fromString|digestToHexByteString|toHex|hexlify|concat|append|map|fmap|fromText|toText)$/;
|
|
198
|
+
const flowsFrom = (e, depth = 0) => {
|
|
199
|
+
if (!e || depth > 10) return false;
|
|
200
|
+
if (e === hashCall) return true;
|
|
201
|
+
if (e.kind === 'ident') return carriers.has(e.name);
|
|
202
|
+
if (e.kind === 'binary' && e.hs && e.hs.concat) return flowsFrom(e.left, depth + 1) || flowsFrom(e.right, depth + 1);
|
|
203
|
+
if (e.kind === 'call' && typeof e.callee === 'string' && FORMAT.test(e.callee)) return (e.args || []).some((a) => flowsFrom(a, depth + 1));
|
|
204
|
+
if (e.kind === 'call' && e === hashCall) return true;
|
|
205
|
+
return false;
|
|
206
|
+
};
|
|
207
|
+
if (node.kind === 'assign' && node.target && node.source && flowsFrom(node.source)) carriers.add(node.target);
|
|
208
|
+
for (let round = 0; round < 8; round++) {
|
|
209
|
+
let grew = false;
|
|
210
|
+
for (const n of nodes) if (n.kind === 'assign' && n.target && n.source && !carriers.has(n.target) && flowsFrom(n.source)) { carriers.add(n.target); grew = true; }
|
|
211
|
+
if (!grew) break;
|
|
212
|
+
}
|
|
213
|
+
const carries = (x) => !!x && (flowsFrom(x) || [...names(x)].some((y) => carriers.has(y)));
|
|
214
|
+
let compared = false; let signed = false;
|
|
215
|
+
for (const n of nodes) {
|
|
216
|
+
for (const root of exprsOf(n)) {
|
|
217
|
+
for (const x of walk(root)) {
|
|
218
|
+
if (x.kind === 'binary' && EQUALITY_OPS.has(x.op) && (carries(x.left) || carries(x.right))) compared = true;
|
|
219
|
+
if (x.kind === 'call' && typeof x.callee === 'string' && VERIFY_CALL.test(x.callee) && (x.args || []).some((a) => carries(a))) signed = true;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (compared) reasons.push('the digest is compared with another value');
|
|
224
|
+
if (signed) reasons.push('the digest is passed to a verifying or signing call');
|
|
225
|
+
// 3. what it becomes: a credential-shaped binding, or the result of a credential-shaped function
|
|
226
|
+
const bound = [...carriers].find((c) => CREDENTIAL_NAME.test(c) || SECURITY_VAR.test(c));
|
|
227
|
+
if (bound) reasons.push(`the digest is bound to \`${bound}\``);
|
|
228
|
+
const returned = nodes.some((n) => n.kind === 'return' && n.value && carries(n.value));
|
|
229
|
+
if (returned && (SECURITY_FN.test(fnBase) || CREDENTIAL_NAME.test(fnBase))) reasons.push(`the digest is the result of \`${fnBase}\``);
|
|
230
|
+
return reasons;
|
|
146
231
|
}
|
|
147
232
|
|
|
148
233
|
/**
|
|
@@ -186,6 +271,7 @@ export function analyzeHaskellRules(files, opts = {}) {
|
|
|
186
271
|
};
|
|
187
272
|
|
|
188
273
|
for (const [file, fir] of Object.entries(ir.perFile)) {
|
|
274
|
+
const importsNetwork = (fir.imports || []).some((im) => /^Network(?:\.|$)/.test(im.module));
|
|
189
275
|
// imports of obsolete primitives (type-directed use is not resolved: disclosed on the finding)
|
|
190
276
|
for (const imp of fir.imports || []) {
|
|
191
277
|
if (WEAK_CIPHER_MODULES.has(imp.module)) {
|
|
@@ -199,7 +285,14 @@ export function analyzeHaskellRules(files, opts = {}) {
|
|
|
199
285
|
|
|
200
286
|
const fnBase = fn.name.replace(/^.*\./, '');
|
|
201
287
|
// text-typed parameters of an exported function, and every local value derived from them
|
|
202
|
-
|
|
288
|
+
// A parameter a web framework hands to a handler IS request input wherever the handler is registered. Any other exported
|
|
289
|
+
// function's text parameter is "caller-supplied" only while nothing in the analysed code calls the function: once something does,
|
|
290
|
+
// its parameters take the values those callers pass (a check the taint engine makes per call), and a rule that does not follow
|
|
291
|
+
// calls cannot say the text comes from outside.
|
|
292
|
+
const fnHs = fn.hs || {};
|
|
293
|
+
const strongParam = new Set((fnHs.paramSources || []).filter((p) => /^hs:handler-param:/.test(p.decorator || '')).map((p) => p.name));
|
|
294
|
+
const entryLike = !fnHs.reached;
|
|
295
|
+
const derivedText = new Set([...(fnHs.textParams || []).filter((n) => strongParam.has(n) || entryLike), ...(entryLike ? (fnHs.inputParams || []) : [])]);
|
|
203
296
|
if (derivedText.size) {
|
|
204
297
|
for (let round = 0; round < 8; round++) {
|
|
205
298
|
let grew = false;
|
|
@@ -255,7 +348,7 @@ export function analyzeHaskellRules(files, opts = {}) {
|
|
|
255
348
|
if (TIME_SOURCES.has(e.name) && SECURITY_FN.test(fnBase)) emitRandom(node.line, { vuln: 'Clock value used as a security value', detail: `${e.name} feeds \`${fnBase}\``, evidence: { function: fnBase, source: e.name } });
|
|
256
349
|
// a generator named as the whole value of a security-shaped action (`newCsrfToken = randomIO`): no call is written, the name is the call
|
|
257
350
|
if (RANDOM_CALLS.has(e.name) && SECURITY_FN.test(fnBase) && !(e.hs && e.hs.functionRef)) emitRandom(node.line, { detail: `${e.name} feeds \`${fnBase}\``, evidence: { function: fnBase, source: e.name } });
|
|
258
|
-
if (INPUT_READS.has(e.name) && !bounded) emit('hs-unbounded-input-read', file, node.line, { detail: `${e.name} reads everything`, confidence: 0.6 });
|
|
351
|
+
if (INPUT_READS.has(e.name) && !bounded && readsOutsideInput(fn, e.name, [], importsNetwork)) emit('hs-unbounded-input-read', file, node.line, { detail: `${e.name} reads everything`, confidence: 0.6 });
|
|
259
352
|
continue;
|
|
260
353
|
}
|
|
261
354
|
// a Set-Cookie header written by hand: `(hSetCookie, "session_id=" <> sid <> "; Path=/")`
|
|
@@ -268,7 +361,8 @@ export function analyzeHaskellRules(files, opts = {}) {
|
|
|
268
361
|
const sessionLike = !nm || SESSION_COOKIE.test(nm[1]);
|
|
269
362
|
const secure = /;\s*Secure\b/i.test(text); const httpOnly = /;\s*HttpOnly\b/i.test(text);
|
|
270
363
|
// an attribute string that is not written here (a rendered cookie value) cannot be judged: only a cookie whose text is visible is
|
|
271
|
-
|
|
364
|
+
// a header value that is entirely constant text (`authn=0` written to clear a login) carries no per-user credential
|
|
365
|
+
if (text && sessionLike && !isLiteralLike(v) && !constantConcat(v) && (!secure || !httpOnly)) emit('hs-insecure-cookie', file, e.line || node.line, { detail: `${!httpOnly ? 'HttpOnly missing' : ''}${!httpOnly && !secure ? ', ' : ''}${!secure ? 'Secure missing' : ''}`, evidence: { httpOnly, secure, cookie: nm ? nm[1] : null } });
|
|
272
366
|
}
|
|
273
367
|
}
|
|
274
368
|
if (e.kind === 'object' && e.hs && e.hs.update && Array.isArray(e.props)) {
|
|
@@ -324,7 +418,17 @@ export function analyzeHaskellRules(files, opts = {}) {
|
|
|
324
418
|
// weak hash
|
|
325
419
|
if (WEAK_HASH_CALLS.has(c) || (c === 'Crypto.Hash.hashWith' && args[0] && args[0].kind === 'literal' && WEAK_ALGO_CONS.has(args[0].value))) {
|
|
326
420
|
const pw = args.some((a) => [...names(a)].some((n) => PASSWORD_VAR.test(n)));
|
|
327
|
-
|
|
421
|
+
// MD5 and SHA-1 are broken for collision and preimage resistance, which only matters where an attacker gains from a
|
|
422
|
+
// collision: a digest that is VERIFIED (compared with an expected value), that authenticates or signs, that hashes a
|
|
423
|
+
// secret, or that is a credential. A digest that only NAMES something (a file name, a cache key, a bookmark, an ETag) is
|
|
424
|
+
// an identifier, and a hash helper handed on to callers says nothing about its use: neither is a finding.
|
|
425
|
+
if (!pw) {
|
|
426
|
+
const use = weakDigestUse(fn, node, e, args, fnBase);
|
|
427
|
+
// The API identity is always reported (a reviewer wants to know where MD5/SHA-1 is computed); it is a vulnerability
|
|
428
|
+
// finding only where a use that depends on the hash's strength is visible. Otherwise it is an `info` note.
|
|
429
|
+
if (use.length) emit('hs-weak-hash', file, line, { detail: `${c}: ${use.join('; ')}`, evidence: { call: c, use } });
|
|
430
|
+
else emit('hs-weak-hash', file, line, { severity: 'info', confidence: 0.4, detail: `${c}: no use that depends on the digest's collision or preimage resistance is visible (it may only name or key something)`, evidence: { call: c, use: [] } });
|
|
431
|
+
}
|
|
328
432
|
}
|
|
329
433
|
// password hashed with a fast hash
|
|
330
434
|
if (ANY_HASH_CALLS.has(c) && !STRONG_KDF.test(c) && args.some((a) => [...names(a)].some((n) => PASSWORD_VAR.test(n.replace(/^\./, ''))))) {
|
|
@@ -346,7 +450,7 @@ export function analyzeHaskellRules(files, opts = {}) {
|
|
|
346
450
|
emitRandom(line, { detail: `${c} feeds \`${fnBase}\``, evidence: { function: fnBase, source: c } });
|
|
347
451
|
}
|
|
348
452
|
if (TIME_SOURCES.has(c) && SECURITY_FN.test(fnBase)) emitRandom(line, { vuln: 'Clock value used as a security value', detail: `${c} feeds \`${fnBase}\``, evidence: { function: fnBase, source: c } });
|
|
349
|
-
if (INPUT_READS.has(c) && !bounded) emit('hs-unbounded-input-read', file, line, { detail: `${c} reads everything`, confidence: 0.6 });
|
|
453
|
+
if (INPUT_READS.has(c) && !bounded && readsOutsideInput(fn, c, args, importsNetwork)) emit('hs-unbounded-input-read', file, line, { detail: `${c} reads everything`, confidence: 0.6 });
|
|
350
454
|
// an allocation whose size is parsed caller text and is not clamped
|
|
351
455
|
if (ALLOCATORS.has(c) && args[0] && mentionsText(args[0]) && callsIn(args[0]).some((x) => /(?:^|\.)(?:read|readMaybe|readEither)$/.test(x.callee)) && !callsIn(args[0]).some((x) => CLAMPS.test(x.callee))) {
|
|
352
456
|
emit('hs-unbounded-allocation', file, line, { detail: `${c} sized by parsed caller text`, evidence: { allocator: c } });
|
|
@@ -369,7 +473,7 @@ export function analyzeHaskellRules(files, opts = {}) {
|
|
|
369
473
|
if (yes) emit('hs-xml-entity-substitution', file, line, { detail: 'withSubstDTDEntities yes' });
|
|
370
474
|
}
|
|
371
475
|
// boundaries
|
|
372
|
-
if (UNSAFE_CALLS
|
|
476
|
+
if (Object.hasOwn(UNSAFE_CALLS, c)) {
|
|
373
477
|
const b = UNSAFE_CALLS[c];
|
|
374
478
|
emit('hs-unsafe-io-boundary', file, line, { vuln: `Unsafe boundary: ${b}`, confidence: 0.5, modelNote: BOUNDARIES[b], detail: BOUNDARIES[b] });
|
|
375
479
|
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// Trust-boundary adjudication of Haskell taint findings.
|
|
2
|
+
//
|
|
3
|
+
// A taint finding says "data from a SOURCE reaches a SINK". Whether that is a vulnerability depends on whether the party that controls the
|
|
4
|
+
// source is a different party from the one the program runs for. This module reads the origins the taint engine recorded for each finding
|
|
5
|
+
// (`sourceRoots`, or the evidence chain) and decides, from code facts only, which side of that line they sit on:
|
|
6
|
+
//
|
|
7
|
+
// attacker-reachable a request, a socket, a header, a cookie, a remote response: the peer is not the operator
|
|
8
|
+
// operator-controlled command-line arguments and environment variables (the person who starts the process chooses them), and, in a
|
|
9
|
+
// command-line program, standard input and the files it is pointed at: no boundary is crossed. The finding
|
|
10
|
+
// is lowered to `info`, never dropped
|
|
11
|
+
// api-contract the value is a parameter of an exported function that nothing in the analysed code calls, and the function
|
|
12
|
+
// hands it WHOLE (or as the root it appends fixed text to) to a path or command operation: a thin wrapper.
|
|
13
|
+
// The caller chose what to read or run, and the wrapper has no fixed part to escape from. Lowered to `info`
|
|
14
|
+
// entry-parameter a parameter of such a function used inside a larger value the function builds: its own construction, kept
|
|
15
|
+
// data-input standard input or a file's content in a service or a library: data the program processes, kept
|
|
16
|
+
// unattributed the engine recorded no origin: kept
|
|
17
|
+
//
|
|
18
|
+
// A command-line program is one reached only from `main`s whose reachable modules import no server framework or socket library
|
|
19
|
+
// (`entryRole` in haskell-ir.js). The role is a fact about the call graph, not about a name or a path.
|
|
20
|
+
//
|
|
21
|
+
// Nothing here reads a comment, a name or a path: the inputs are the recorded origins, the lowered expression at the sink and the
|
|
22
|
+
// parameter annotations the IR derived from the signature.
|
|
23
|
+
|
|
24
|
+
import { findSites, identNames } from './haskell-guards.js';
|
|
25
|
+
|
|
26
|
+
export const TRUST_VERDICTS = Object.freeze(['attacker-reachable', 'operator-controlled', 'api-contract', 'entry-parameter', 'data-input', 'unattributed']);
|
|
27
|
+
|
|
28
|
+
const OPERATOR = new Set(['cli', 'env']);
|
|
29
|
+
const EXTERNAL = new Set(['http-body', 'url-param', 'path-param', 'header', 'cookie', 'network']);
|
|
30
|
+
const DATA = new Set(['stdin', 'file-read']);
|
|
31
|
+
const WEAK_PARAM_LABEL = /\(caller-controlled\)$/;
|
|
32
|
+
const WRAPPER_FAMILIES = new Set(['CWE-78', 'CWE-88']);
|
|
33
|
+
const CONVERSIONS = /(?:^|\.)(?:pack|unpack|fromString|toString|show|encodeUtf8|decodeUtf8|toStrict|fromStrict|toText|unpackFS|fromText)$/;
|
|
34
|
+
const PATH_JOIN = /(?:^|\.)(?:<\/>|combine|joinPath|<\.>)$/;
|
|
35
|
+
// list plumbing that only lays the parameters out as text (`unwords args`, `concatMap (' ':) args`): no fixed words are added
|
|
36
|
+
const LIST_LAYOUT = /(?:^|\.)(?:unwords|unlines|intercalate|concatMap|concat|map|fmap)$/;
|
|
37
|
+
const SPEC_CTORS = new Set(['System.Process.shell', 'System.Process.proc', 'System.Process.Typed.shell', 'System.Process.Typed.proc']);
|
|
38
|
+
const hasAlnum = (v) => /[A-Za-z0-9]/.test(String(v));
|
|
39
|
+
|
|
40
|
+
function rootsOf(f) {
|
|
41
|
+
// A hop through a function's return that names no origin behind it (`interproc`, `param`) is not an origin.
|
|
42
|
+
if (Array.isArray(f.sourceRoots) && f.sourceRoots.length) return f.sourceRoots.filter((r) => r && r.provenance !== 'interproc' && r.provenance !== 'param').map((r) => ({ label: r.label, provenance: r.provenance || null, ...(r.file ? { file: r.file } : {}), ...(Number.isInteger(r.line) ? { line: r.line } : {}) }));
|
|
43
|
+
const out = [];
|
|
44
|
+
for (const c of f.chain || []) {
|
|
45
|
+
if (!c || !c.label || c.kind === 'sink') continue;
|
|
46
|
+
if (c.provenance === 'interproc' || c.provenance === 'param') continue;
|
|
47
|
+
out.push({ label: c.label, provenance: c.provenance || null });
|
|
48
|
+
}
|
|
49
|
+
if (!out.length && f.source && f.source.label && !/^(?:interproc-return:|parameter )/.test(f.source.label)) out.push({ label: f.source.label, provenance: f.sourceProvenance || null });
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const classOf = (r) => {
|
|
54
|
+
if (r.provenance && OPERATOR.has(r.provenance)) return 'operator';
|
|
55
|
+
if (r.provenance && EXTERNAL.has(r.provenance)) return 'external';
|
|
56
|
+
if (r.provenance && DATA.has(r.provenance)) return 'data';
|
|
57
|
+
if (WEAK_PARAM_LABEL.test(String(r.label || ''))) return 'weak-param';
|
|
58
|
+
return 'other';
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// The expression a sink was handed, with the conversions that only change its type (`unpack`, `fromString`, `show`) peeled off.
|
|
62
|
+
function peel(e, depth = 0) {
|
|
63
|
+
while (e && e.kind === 'call' && depth++ < 8 && typeof e.callee === 'string' && CONVERSIONS.test(e.callee) && (e.args || []).length === 1) e = e.args[0];
|
|
64
|
+
return e;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Flatten a concatenation / path join into its operands, left to right.
|
|
68
|
+
function operandsOf(e, out = [], depth = 0, resolve = null) {
|
|
69
|
+
e = peel(e);
|
|
70
|
+
if (!e || depth > 12) { out.push(e); return out; }
|
|
71
|
+
if (resolve && e.kind === 'ident') { const r = resolve(e); if (r && r !== e) return operandsOf(r, out, depth + 1, resolve); }
|
|
72
|
+
if (e.kind === 'binary' && e.hs && e.hs.concat) { operandsOf(e.left, out, depth + 1, resolve); operandsOf(e.right, out, depth + 1, resolve); return out; }
|
|
73
|
+
if (e.kind === 'binary' && (e.op === '</>' || e.op === '<>' || e.op === '++')) { operandsOf(e.left, out, depth + 1, resolve); operandsOf(e.right, out, depth + 1, resolve); return out; }
|
|
74
|
+
if (e.kind === 'call' && typeof e.callee === 'string' && PATH_JOIN.test(e.callee) && (e.args || []).length === 2) { operandsOf(e.args[0], out, depth + 1, resolve); operandsOf(e.args[1], out, depth + 1, resolve); return out; }
|
|
75
|
+
out.push(e);
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Is the sink argument a value the function merely passes on? True when every non-literal operand is one of `params` (or list plumbing over
|
|
81
|
+
* them) and either (a) nothing in the value is fixed text that names something (only separators), or (b) for a path, the parameter is the
|
|
82
|
+
* LEFTMOST operand, so it chooses the whole location and the fixed tail is only a file name inside it.
|
|
83
|
+
*/
|
|
84
|
+
export function isPassThrough(arg, params, family, resolve = null) {
|
|
85
|
+
const ops = operandsOf(arg, [], 0, resolve);
|
|
86
|
+
if (!ops.length || ops.some((o) => !o)) return false;
|
|
87
|
+
const dyn = ops.filter((o) => o.kind !== 'literal');
|
|
88
|
+
if (!dyn.length) return false;
|
|
89
|
+
// a dynamic operand is acceptable when it is made only of parameters and separators (a list laid out as text, a member of a parameter)
|
|
90
|
+
const ok = (o, depth = 0) => {
|
|
91
|
+
if (!o || depth > 10) return false;
|
|
92
|
+
if (o.kind === 'literal') return !hasAlnum(o.value);
|
|
93
|
+
if (o.kind === 'ident') return params.has(o.name);
|
|
94
|
+
if (o.kind === 'member') return ok(o.object, depth + 1);
|
|
95
|
+
if (o.kind === 'array') return o.elements.length > 0 && o.elements.every((x) => ok(x, depth + 1));
|
|
96
|
+
if (o.kind === 'call' && typeof o.callee === 'string' && LIST_LAYOUT.test(o.callee)) return (o.args || []).length > 0 && o.args.every((x) => ok(x, depth + 1));
|
|
97
|
+
return false;
|
|
98
|
+
};
|
|
99
|
+
if (!dyn.every((o) => ok(o))) return false;
|
|
100
|
+
const namedLiteral = ops.some((o) => o.kind === 'literal' && hasAlnum(o.value));
|
|
101
|
+
if (!namedLiteral) return true;
|
|
102
|
+
return family === 'path' && ops[0].kind === 'ident' && params.has(ops[0].name);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function weakParamsOf(fn) {
|
|
106
|
+
const out = new Set();
|
|
107
|
+
for (const a of fn.paramAnnotations || []) if (a && a.decorator === 'hs:caller-controlled' && a.name) out.add(a.name);
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// A local name bound exactly once (`let cmd = exe <> " " <> args`) stands for its definition when the sink argument is judged.
|
|
112
|
+
function localResolver(fn) {
|
|
113
|
+
const defs = new Map();
|
|
114
|
+
for (const n of Object.values(fn.cfg.nodes)) if (n.kind === 'assign' && typeof n.target === 'string' && n.source) defs.set(n.target, [...(defs.get(n.target) || []), n.source]);
|
|
115
|
+
const params = new Set(Array.isArray(fn.params) ? fn.params : []);
|
|
116
|
+
return (e) => (e && e.kind === 'ident' && !params.has(e.name) && defs.has(e.name) && defs.get(e.name).length === 1 ? defs.get(e.name)[0] : e);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const fnByQid = (perFile, qid) => {
|
|
120
|
+
if (!qid) return null;
|
|
121
|
+
const file = String(qid).split('::')[0];
|
|
122
|
+
const ir = perFile && perFile[file];
|
|
123
|
+
return ir ? ir.functions.find((x) => x.qid === qid) || null : null;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// The role the IR gave the function the finding was found in: `cli` (reached only from mains that import no server framework), `service`, or
|
|
127
|
+
// `library` (no main reaches it, or the walk could not).
|
|
128
|
+
function roleOf(f, perFile) {
|
|
129
|
+
const fn = fnByQid(perFile, f._funcQid);
|
|
130
|
+
return (fn && fn.hs && fn.hs.entryRole) || 'library';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// The function that holds the sink call, and the sink's argument expressions there. A finding found while analysing a CALLER (its `_funcQid`) is
|
|
134
|
+
// located at the callee's line; the sink function is the one in the finding's file that has the call at that line.
|
|
135
|
+
function locateSink(perFile, f) {
|
|
136
|
+
const ir = perFile && perFile[f.file];
|
|
137
|
+
if (!ir || !f.callee) return null;
|
|
138
|
+
const direct = fnByQid(perFile, f._funcQid);
|
|
139
|
+
const candidates = direct && direct.file === f.file ? [direct, ...ir.functions.filter((x) => x !== direct)] : ir.functions;
|
|
140
|
+
for (const fn of candidates) {
|
|
141
|
+
const sites = findSites(fn, f.callee, f.line);
|
|
142
|
+
if (sites.length) return { fn, sites };
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Is this finding a thin wrapper's pass-through? `weakParams` are the caller-controlled parameters of the function the engine analysed. When the
|
|
149
|
+
* sink sits in a callee, the callee must itself hand its own parameter on whole, and every call of it from the analysed function must pass
|
|
150
|
+
* values built only from that function's caller-controlled parameters (one hop; a longer chain is not judged).
|
|
151
|
+
*/
|
|
152
|
+
function passThroughVerdict(perFile, f) {
|
|
153
|
+
if (!WRAPPER_FAMILIES.has(f.cwe)) return null;
|
|
154
|
+
const family = f.cwe === 'CWE-22' ? 'path' : 'cmd';
|
|
155
|
+
const sink = locateSink(perFile, f);
|
|
156
|
+
if (!sink) return null;
|
|
157
|
+
const argIdx = Number.isInteger(f.argIndex) ? f.argIndex : 0;
|
|
158
|
+
let args = sink.sites.map((s) => (s.call.args || [])[argIdx]).filter(Boolean);
|
|
159
|
+
// A process is started from a SPEC value (`createProcess ((shell cmd) { cwd = d })`): what decides the verdict is what the spec constructor
|
|
160
|
+
// was handed, so the constructor's own arguments are judged instead of the record update around it.
|
|
161
|
+
const specArgs = [];
|
|
162
|
+
const findSpecs = (e, d = 0) => {
|
|
163
|
+
if (!e || typeof e !== 'object' || d > 20) return;
|
|
164
|
+
if (e.kind === 'call' && typeof e.callee === 'string' && SPEC_CTORS.has(e.callee)) { for (const a of (e.args || []).slice(0, 2)) if (a) specArgs.push(a); return; }
|
|
165
|
+
for (const k of ['left', 'right', 'object', 'value']) if (e[k] && typeof e[k] === 'object') findSpecs(e[k], d + 1);
|
|
166
|
+
for (const k of ['args', 'elements', 'branches']) if (Array.isArray(e[k])) e[k].forEach((x) => findSpecs(x, d + 1));
|
|
167
|
+
if (Array.isArray(e.props)) for (const p of e.props) findSpecs(p && p.value, d + 1);
|
|
168
|
+
};
|
|
169
|
+
for (const a of args) findSpecs(a);
|
|
170
|
+
if (specArgs.length) args = specArgs;
|
|
171
|
+
if (!args.length) return null;
|
|
172
|
+
const analysed = fnByQid(perFile, f._funcQid);
|
|
173
|
+
if (!analysed) return null;
|
|
174
|
+
if (sink.fn.qid === analysed.qid) {
|
|
175
|
+
const weak = weakParamsOf(analysed);
|
|
176
|
+
if (!weak.size) return null;
|
|
177
|
+
const own = new Set(Array.isArray(analysed.params) ? analysed.params : []);
|
|
178
|
+
const res = localResolver(analysed);
|
|
179
|
+
return args.every((a) => isPassThrough(a, own, family, res)) && args.some((a) => { const seen = new Set(); const walkIds = (e, d = 0) => { if (!e || d > 12) return; if (e.kind === 'ident') seen.add(e.name); for (const k of ['left', 'right', 'object', 'value']) if (e[k] && typeof e[k] === 'object') walkIds(e[k], d + 1); for (const k of ['args', 'elements']) if (Array.isArray(e[k])) e[k].forEach((x) => walkIds(x, d + 1)); }; let x = a; for (let i = 0; i < 4; i++) { const r = res(x); if (r === x) break; x = r; } walkIds(x); return [...seen].some((n) => weak.has(n)); }) ? 'direct' : null;
|
|
180
|
+
}
|
|
181
|
+
// the sink is in a callee
|
|
182
|
+
const own = new Set(Array.isArray(sink.fn.params) ? sink.fn.params : []);
|
|
183
|
+
const ownVars = [...new Set(args.flatMap((a) => [...identNames(a)]))];
|
|
184
|
+
if (!own.size || !ownVars.length || !ownVars.every((v) => own.has(v)) || !args.every((a) => isPassThrough(a, own, family))) return null;
|
|
185
|
+
const weak = weakParamsOf(analysed);
|
|
186
|
+
if (!weak.size) return null;
|
|
187
|
+
const calls = findCallsTo(analysed, sink.fn);
|
|
188
|
+
if (!calls.length) return null;
|
|
189
|
+
const used = new Set(ownVars);
|
|
190
|
+
for (const call of calls) {
|
|
191
|
+
for (let i = 0; i < (call.args || []).length; i++) {
|
|
192
|
+
const p = sink.fn.params[i];
|
|
193
|
+
if (!p || !used.has(p)) continue;
|
|
194
|
+
const a = call.args[i];
|
|
195
|
+
if (!a) return null;
|
|
196
|
+
if (a.kind === 'literal') continue;
|
|
197
|
+
if (!isPassThrough(a, weak, family)) return null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return 'chain';
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function findCallsTo(fn, target) {
|
|
204
|
+
const out = [];
|
|
205
|
+
const visit = (e, d) => {
|
|
206
|
+
if (!e || typeof e !== 'object' || d > 50) return;
|
|
207
|
+
if (e.kind === 'call' && e.callee === target.name) out.push(e);
|
|
208
|
+
for (const k of ['left', 'right', 'object', 'value', 'callee']) if (e[k] && typeof e[k] === 'object') visit(e[k], d + 1);
|
|
209
|
+
for (const k of ['args', 'elements', 'branches', 'parts']) if (Array.isArray(e[k])) for (const x of e[k]) visit(x, d + 1);
|
|
210
|
+
if (Array.isArray(e.props)) for (const p of e.props) visit(p && p.value, d + 1);
|
|
211
|
+
};
|
|
212
|
+
for (const n of Object.values(fn.cfg.nodes)) {
|
|
213
|
+
if (n.kind === 'call' && n.callee === target.name) out.push({ kind: 'call', callee: n.callee, args: n.args || [] });
|
|
214
|
+
for (const k of ['source', 'value', 'cond']) if (n[k]) visit(n[k], 0);
|
|
215
|
+
if (Array.isArray(n.args)) for (const a of n.args) visit(a, 0);
|
|
216
|
+
}
|
|
217
|
+
return out;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Stamps `trustBoundary` on every Haskell IR-TAINT finding and lowers to `info` the verdicts that name no boundary crossed
|
|
222
|
+
* (`operator-controlled`, `api-contract`). Mutates the findings; returns counts.
|
|
223
|
+
*/
|
|
224
|
+
export function applyTrustBoundary(findings, perFile) {
|
|
225
|
+
const stats = { attackerReachable: 0, operatorControlled: 0, apiContract: 0, entryParameter: 0, dataInput: 0, unattributed: 0 };
|
|
226
|
+
for (const f of findings || []) {
|
|
227
|
+
if (!f || f.parser !== 'IR-TAINT' || !/\.(?:l?hs|hsc)$/i.test(String(f.file || ''))) continue;
|
|
228
|
+
const roots = rootsOf(f);
|
|
229
|
+
const classes = new Set(roots.map(classOf));
|
|
230
|
+
const role = roleOf(f, perFile);
|
|
231
|
+
let verdict;
|
|
232
|
+
let reason;
|
|
233
|
+
let inferred = false;
|
|
234
|
+
if (classes.has('external')) { verdict = 'attacker-reachable'; reason = 'a request, socket, header, cookie or remote response supplies the value'; }
|
|
235
|
+
else if (roots.length && [...classes].every((c) => c === 'operator')) {
|
|
236
|
+
verdict = 'operator-controlled';
|
|
237
|
+
reason = 'only command-line arguments or environment variables supply the value; whoever starts the program chooses them, so no trust boundary is crossed';
|
|
238
|
+
} else if (roots.length && role === 'cli' && roots.every((r) => r.provenance === 'file-read' || OPERATOR.has(r.provenance))) {
|
|
239
|
+
verdict = 'operator-controlled';
|
|
240
|
+
reason = 'the function belongs to a command-line program (reached from a main that imports no server framework) and the value comes from files the person running it pointed the program at, or from its arguments and environment; standard input is not covered by this rule';
|
|
241
|
+
} else if (classes.has('data')) { verdict = 'data-input'; reason = 'standard input or file content supplies the value'; }
|
|
242
|
+
else if (classes.has('weak-param')) {
|
|
243
|
+
verdict = 'entry-parameter';
|
|
244
|
+
reason = 'a parameter of an exported function that nothing in the analysed code calls';
|
|
245
|
+
const pt = passThroughVerdict(perFile, f);
|
|
246
|
+
if (pt) { verdict = 'api-contract'; reason = 'the function hands its parameter on whole (or as the root of a fixed tail) to a path or command operation: a thin wrapper, whose caller chose what to read or run'; }
|
|
247
|
+
} else if (!roots.length && role === 'cli') {
|
|
248
|
+
// No origin was recorded. Every external origin the model knows (a request, a socket, a remote response) is recorded when it is read, so
|
|
249
|
+
// an empty record in a command-line program means the value came from the operator's side: its arguments, environment, files or
|
|
250
|
+
// configuration reached the sink through a path the engine could not attribute. Stated as an inference on the finding.
|
|
251
|
+
verdict = 'operator-controlled';
|
|
252
|
+
reason = 'no origin was recorded for the tainted value and the function belongs to a command-line program; no request, socket or remote-response read feeds it (inferred, not proven)';
|
|
253
|
+
inferred = true;
|
|
254
|
+
} else { verdict = 'unattributed'; reason = roots.length ? 'the recorded origins are not classified' : 'the engine recorded no origin for the value'; }
|
|
255
|
+
|
|
256
|
+
f.trustBoundary = { verdict, reason, role, ...(inferred ? { inferred: true } : {}), roots: roots.slice(0, 5) };
|
|
257
|
+
const key = verdict.replace(/-(\w)/g, (_, c) => c.toUpperCase());
|
|
258
|
+
stats[key] = (stats[key] || 0) + 1;
|
|
259
|
+
if ((verdict === 'operator-controlled' || verdict === 'api-contract') && !f._trustLowered) {
|
|
260
|
+
f._trustLowered = true;
|
|
261
|
+
f.originalSeverity = f.originalSeverity || f.severity;
|
|
262
|
+
f.severity = 'info';
|
|
263
|
+
f.controls = [...(f.controls || []), { kind: verdict === 'operator-controlled' ? 'operator-controlled-input' : 'api-contract-wrapper', evidenceKind: 'source' }];
|
|
264
|
+
f.guardNote = { kind: verdict, reason };
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return stats;
|
|
268
|
+
}
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
// is recorded as `unknown` coverage, never as protected and never omitted.
|
|
20
20
|
|
|
21
21
|
import { buildHaskellIR, lowerHandlerExpr, resolveName } from './haskell-ir.js';
|
|
22
|
+
import { codeOfLine } from './haskell-comments.js';
|
|
22
23
|
import { qualifyAmbiguous, HS_WEB_FRAMEWORKS, HS_WEB_MODEL_VERSION, modelStatus } from './haskell-models.js';
|
|
23
24
|
|
|
24
25
|
export const HS_WEB_RULES_VERSION = 'haskell-web-rules/1';
|
|
@@ -1055,7 +1056,7 @@ function emitFindings(ctx, result, yesodAuthz) {
|
|
|
1055
1056
|
const vs = result.versionStatus[R.framework];
|
|
1056
1057
|
const untested = vs && vs.status !== 'tested';
|
|
1057
1058
|
if (untested) extra = { ...extra, confidence: (extra.confidence ?? 0.75) * 0.8, uncertainty: [...(extra.uncertainty || []), { kind: 'untested-model', detail: `${vs.package} ${vs.version || '(version unknown)'} is not in the tested set (${vs.tested.join(', ')}); the route model is applied best-effort` }] };
|
|
1058
|
-
const snip = (text.split('\n')[line - 1] || '').
|
|
1059
|
+
const snip = codeOfLine(text.split('\n')[line - 1] || '').slice(0, 240);
|
|
1059
1060
|
out.push({
|
|
1060
1061
|
id: `hs-web:${rule}:${R.file}:${line}:${R.method}:${R.path}`, severity: extra.severity || r.severity, file: R.file, line, vuln: r.vuln, cwe: r.cwe,
|
|
1061
1062
|
description: `${r.vuln}: ${R.method} ${R.path} (${R.framework})${extra.detail ? ` - ${extra.detail}` : ''}`,
|
package/src/sast/cloud-iam.js
CHANGED
|
@@ -363,7 +363,9 @@ export function scanCloudIam(fp, raw) {
|
|
|
363
363
|
if (!raw || raw.length > 500_000) return [];
|
|
364
364
|
const out = [];
|
|
365
365
|
const seen = new Set();
|
|
366
|
-
|
|
366
|
+
// A file is JSON by its extension or because it OPENS like a JSON object: a brace, then a quoted key or a closing brace. A bare leading `{` is not
|
|
367
|
+
// enough: a Haskell module that starts with a `{-# LANGUAGE ... #-}` pragma (or a `{- ... -}` comment) begins with one too, and its prose was read as policy.
|
|
368
|
+
const isJson = /\.json$/i.test(fp) || /^\s*\{\s*(?:"|\})/.test(raw.slice(0, 4096));
|
|
367
369
|
const isYaml = /\.(?:yaml|yml)$/i.test(fp);
|
|
368
370
|
const isBicep = /\.bicep$/i.test(fp);
|
|
369
371
|
const isTf = /\.tf$/i.test(fp);
|