easyvibegate 0.6.8 → 0.6.9
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.en.md +10 -2
- package/README.md +11 -2
- package/dist/engine/checkers/deep/deps.js +198 -54
- package/dist/engine/checkers/index.js +2 -0
- package/dist/engine/checkers/static/next-image-ssrf.js +137 -0
- package/dist/engine/checkers/static/secrets.js +43 -15
- package/dist/engine/util/git-exposure.js +41 -0
- package/dist/engine/util/text.js +11 -1
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -8,7 +8,16 @@
|
|
|
8
8
|
|
|
9
9
|
Most scanners grep your code and say *"you might have leaked a key."* EasyVibeGate also goes to the running backend: for **Supabase/Firebase** it walks in with the public key and shows which tables are readable by anyone, with the exact `curl` — plus SQL to close it. It is honest about coverage: every check reports whether it actually ran, so a failed or skipped check is never shown as a green "all clear".
|
|
10
10
|
|
|
11
|
-
> Status: **
|
|
11
|
+
> Status: **actively maintained, version 0.6.8.** Best-supported stack: **Next.js + Supabase**. Code review runs on any stack; the live backend probe is read-only and Supabase/Firebase-focused. Not a penetration test.
|
|
12
|
+
|
|
13
|
+
### What it actually checks
|
|
14
|
+
|
|
15
|
+
1. **Secrets hardcoded in source** — API keys (OpenAI, Stripe, AWS, Supabase, etc.) and database passwords written directly into code instead of environment variables. If the code ever leaves your machine (a public repo, a screenshot, a shared link), those keys leak with it.
|
|
16
|
+
2. **Row Level Security (RLS) disabled in the database** — if a Supabase/Postgres table has no row-level protection, anyone holding only your app's public (anon) key — i.e. literally every visitor — can read and sometimes write every user's data through the API, not just their own.
|
|
17
|
+
3. **Secrets shipped in the client bundle** — variables prefixed `NEXT_PUBLIC_` / `VITE_` / `REACT_APP_` are baked straight into the JS every visitor's browser downloads. If a real secret ends up behind one of those prefixes, it's visible in the page source (F12 → Sources).
|
|
18
|
+
4. **Dangerous config** — CORS allowing any origin, debug/verbose mode left on, use of `eval`, SQL built by string concatenation (injection risk), a JWT with `alg: none`.
|
|
19
|
+
5. **`.env` / git hygiene** — a secret in a committed `.env` is already in git history and visible to anyone with repo access, even after the file is deleted later.
|
|
20
|
+
6. **Live backend probe** (opt-in, your own projects only) — the tool actually talks to your running Supabase/Firebase with the public key and checks which tables/buckets are readable without auth, plus hits discovered API endpoints with no token. Read-only — it never writes or breaks anything.
|
|
12
21
|
|
|
13
22
|
## How to run
|
|
14
23
|
|
|
@@ -22,7 +31,6 @@ If you use **Cursor, Claude Code, or Codex**, you don't need to type anything in
|
|
|
22
31
|
Check this project's security with EasyVibeGate and explain the result in plain language.
|
|
23
32
|
|
|
24
33
|
1. Run at the project root: npx easyvibegate . --no-wizard --deps
|
|
25
|
-
(After the npm release the short form is: npx easyvibegate . --no-wizard --deps)
|
|
26
34
|
(Add --url <my-app-url> --i-own-this only if this is my project
|
|
27
35
|
and I allow the live backend probe.)
|
|
28
36
|
2. Open and read the file: easyvibegate-report/ai-fix-prompt.md
|
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
Большинство сканеров просто грепают код и говорят «возможно, ты слил ключ». EasyVibeGate идёт дальше: для **Supabase/Firebase** заходит публичным ключом и показывает, какие таблицы читаются кем угодно, с готовым `curl` и SQL для починки. И он честен про покрытие: каждая проверка сообщает, реально ли она выполнилась, поэтому упавшая или пропущенная проверка **никогда не выдаётся за зелёное «всё чисто»**.
|
|
10
10
|
|
|
11
|
-
> Статус:
|
|
11
|
+
> Статус: **активно развивается, версия 0.6.8.** Лучше всего поддержан стек **Next.js + Supabase**. Осмотр кода работает на любом стеке; живой пробинг бэкенда — только чтение и заточен под Supabase/Firebase. Это не пентест.
|
|
12
12
|
|
|
13
13
|
## 🇷🇺 Простыми словами
|
|
14
14
|
|
|
@@ -21,7 +21,16 @@
|
|
|
21
21
|
2. Ответь на пару вопросов «да / нет».
|
|
22
22
|
3. Получишь понятный отчёт и готовый план починки — вставь его в свой ИИ (Cursor/Claude), и он всё исправит.
|
|
23
23
|
|
|
24
|
-
Разбираться в том, что внутри, не нужно: сложное — под капотом, снаружи одна команда и вопросы «да / нет».
|
|
24
|
+
Разбираться в том, что внутри, не нужно: сложное — под капотом, снаружи одна команда и вопросы «да / нет». Но если интересно — вот что конкретно проверяется.
|
|
25
|
+
|
|
26
|
+
### Что именно оно ищет
|
|
27
|
+
|
|
28
|
+
1. **Секреты прямо в коде** — API-ключи (OpenAI, Stripe, AWS, Supabase и т.д.) и пароли от базы, вписанные в код текстом, а не через переменные окружения. Если код когда-нибудь попадёт наружу (публичный репозиторий, скриншот, share-ссылка) — эти ключи утекут вместе с ним.
|
|
29
|
+
2. **Отключённый RLS (Row Level Security) в базе** — если у таблицы в Supabase/Postgres не включена построчная защита, то любой человек, у которого есть только публичный (анонимный) ключ твоего приложения — то есть буквально каждый посетитель сайта — может через API читать и иногда менять данные ВСЕХ пользователей, а не только свои.
|
|
30
|
+
3. **Секреты в клиентском бандле** — переменные с префиксом `NEXT_PUBLIC_` / `VITE_` / `REACT_APP_` попадают прямо в JS-файлы, которые скачивает браузер каждого посетителя. Если под таким префиксом лежит не публичный, а секретный ключ — его видно в исходниках страницы (F12 → Sources).
|
|
31
|
+
4. **Опасные настройки конфигурации** — CORS, разрешающий запросы с любого домена, включённый debug/verbose-режим, использование `eval`, сборка SQL-запроса через склейку строк (риск SQL-инъекции), JWT с `alg: none`.
|
|
32
|
+
5. **Гигиена `.env` и git** — секрет в закоммиченном `.env` уже попал в историю git и виден всем, у кого есть доступ к репозиторию (даже если файл потом удалить — история останется).
|
|
33
|
+
6. **Живая проверка бэкенда** (по желанию, только на своих проектах) — инструмент реально обращается к твоему работающему Supabase/Firebase с публичным ключом и смотрит, какие таблицы/бакеты читаются без авторизации, плюс дёргает найденные API-эндпоинты без токена. Только чтение, ничего не пишет и не ломает.
|
|
25
34
|
|
|
26
35
|
## Как запустить
|
|
27
36
|
|
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
2
|
const MANAGERS = new Set(['npm', 'pnpm', 'yarn', 'bun']);
|
|
3
|
+
/**
|
|
4
|
+
* Extra args that restrict the audit to non-dev dependencies, per manager.
|
|
5
|
+
* Used for a SECOND audit pass so dev-only tooling (eslint, vitest, turbo —
|
|
6
|
+
* never shipped, never run on the server) can be told apart from the runtime
|
|
7
|
+
* dependency tree, without guessing from node_modules paths.
|
|
8
|
+
*/
|
|
9
|
+
const PROD_ONLY_ARGS = {
|
|
10
|
+
npm: ['audit', '--omit=dev', '--json'],
|
|
11
|
+
pnpm: ['audit', '--prod', '--json'],
|
|
12
|
+
// yarn's --groups takes each value as its OWN argument, not comma-joined —
|
|
13
|
+
// `--groups dependencies,optionalDependencies` silently audited 0 packages.
|
|
14
|
+
yarn: ['audit', '--groups', 'dependencies', 'optionalDependencies', '--json'],
|
|
15
|
+
};
|
|
3
16
|
function run(cmd, args, cwd, timeoutMs) {
|
|
4
17
|
return new Promise((resolve) => {
|
|
5
18
|
execFile(cmd, args, { cwd, timeout: timeoutMs, maxBuffer: 20 * 1024 * 1024 }, (err, stdout) => {
|
|
@@ -48,6 +61,128 @@ function validCounts(v) {
|
|
|
48
61
|
}
|
|
49
62
|
return out;
|
|
50
63
|
}
|
|
64
|
+
/** `fixAvailable` in npm's (and yarn's) newer `vulnerabilities` map: `true`, an object, or absent/false. */
|
|
65
|
+
function formatFixAvailable(fixAvailable) {
|
|
66
|
+
if (fixAvailable === true)
|
|
67
|
+
return 'update available';
|
|
68
|
+
if (fixAvailable && typeof fixAvailable === 'object') {
|
|
69
|
+
const f = fixAvailable;
|
|
70
|
+
if (f.name && f.version)
|
|
71
|
+
return `needs ${f.name}@${f.version}${f.isSemVerMajor ? ' (major)' : ''}`;
|
|
72
|
+
}
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
/** Keep the worst-severity entry per package name, in original order, capped. */
|
|
76
|
+
function dedupePackages(list, cap) {
|
|
77
|
+
const rank = { critical: 0, high: 1, moderate: 2, low: 3, info: 4 };
|
|
78
|
+
const best = new Map();
|
|
79
|
+
for (const p of list) {
|
|
80
|
+
const cur = best.get(p.name);
|
|
81
|
+
if (!cur || (rank[p.severity] ?? 9) < (rank[cur.severity] ?? 9))
|
|
82
|
+
best.set(p.name, p);
|
|
83
|
+
}
|
|
84
|
+
return [...best.values()].slice(0, cap);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Normalize one audit run's stdout into counts + a package list, across three
|
|
88
|
+
* incompatible report shapes:
|
|
89
|
+
* - npm (v7+) / yarn's `vulnerabilities` map, keyed by package name, each
|
|
90
|
+
* entry carrying `name`/`severity`/`range`/`fixAvailable` directly.
|
|
91
|
+
* - pnpm's `advisories` map — the OLDER npm-audit-v1 report shape pnpm still
|
|
92
|
+
* emits, keyed by numeric advisory id, each entry carrying `module_name` /
|
|
93
|
+
* `vulnerable_versions` / `patched_versions` instead. A package with
|
|
94
|
+
* several advisories appears more than once, so results get deduped by
|
|
95
|
+
* name. (The previous version of this reader only knew the newer shape,
|
|
96
|
+
* so pnpm projects silently got an empty package list — 0 names, always —
|
|
97
|
+
* even though `metadata.vulnerabilities` parsed fine.)
|
|
98
|
+
* - yarn v1's NDJSON stream of `{type, data}` lines: one `auditSummary` line
|
|
99
|
+
* with the counts, and one `auditAdvisory` line per finding, each nesting
|
|
100
|
+
* the same `module_name`/`vulnerable_versions`/`patched_versions` shape as
|
|
101
|
+
* pnpm's under `data.advisory`.
|
|
102
|
+
*/
|
|
103
|
+
function parseAuditPayload(pm, stdout) {
|
|
104
|
+
if (pm === 'yarn') {
|
|
105
|
+
let counts = null;
|
|
106
|
+
const packages = [];
|
|
107
|
+
for (const line of stdout.split('\n')) {
|
|
108
|
+
const s = line.trim();
|
|
109
|
+
if (!s.startsWith('{'))
|
|
110
|
+
continue;
|
|
111
|
+
let obj;
|
|
112
|
+
try {
|
|
113
|
+
obj = JSON.parse(s);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (obj.type === 'auditSummary') {
|
|
119
|
+
counts = validCounts(obj.data?.vulnerabilities);
|
|
120
|
+
}
|
|
121
|
+
else if (obj.type === 'auditAdvisory') {
|
|
122
|
+
const a = obj.data?.advisory;
|
|
123
|
+
if (a && typeof a['module_name'] === 'string' && typeof a['severity'] === 'string') {
|
|
124
|
+
packages.push({
|
|
125
|
+
name: a['module_name'],
|
|
126
|
+
severity: a['severity'],
|
|
127
|
+
range: typeof a['vulnerable_versions'] === 'string' ? a['vulnerable_versions'] : undefined,
|
|
128
|
+
fix: typeof a['patched_versions'] === 'string' ? a['patched_versions'] : undefined,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return counts ? { counts, packages } : null;
|
|
134
|
+
}
|
|
135
|
+
let parsed;
|
|
136
|
+
try {
|
|
137
|
+
parsed = JSON.parse(stdout);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
const line = stdout.trim().split('\n').filter(Boolean).pop() ?? '';
|
|
141
|
+
try {
|
|
142
|
+
parsed = JSON.parse(line);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (typeof parsed !== 'object' || parsed === null || parsed['error'] !== undefined)
|
|
149
|
+
return null;
|
|
150
|
+
const counts = validCounts(parsed['metadata']?.vulnerabilities);
|
|
151
|
+
if (!counts)
|
|
152
|
+
return null;
|
|
153
|
+
const packages = [];
|
|
154
|
+
const vulnMap = parsed['vulnerabilities'];
|
|
155
|
+
if (vulnMap && typeof vulnMap === 'object') {
|
|
156
|
+
// npm v2 / yarn-berry-style: keyed by package name, one entry each.
|
|
157
|
+
for (const v of Object.values(vulnMap)) {
|
|
158
|
+
if (typeof v['name'] !== 'string' || typeof v['severity'] !== 'string')
|
|
159
|
+
continue;
|
|
160
|
+
packages.push({
|
|
161
|
+
name: v['name'],
|
|
162
|
+
severity: v['severity'],
|
|
163
|
+
range: typeof v['range'] === 'string' ? v['range'] : undefined,
|
|
164
|
+
fix: formatFixAvailable(v['fixAvailable']),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
// pnpm: the old npm-audit-v1 shape, keyed by numeric advisory id.
|
|
170
|
+
const advisories = parsed['advisories'];
|
|
171
|
+
if (advisories && typeof advisories === 'object') {
|
|
172
|
+
for (const a of Object.values(advisories)) {
|
|
173
|
+
if (typeof a['module_name'] !== 'string' || typeof a['severity'] !== 'string')
|
|
174
|
+
continue;
|
|
175
|
+
packages.push({
|
|
176
|
+
name: a['module_name'],
|
|
177
|
+
severity: a['severity'],
|
|
178
|
+
range: typeof a['vulnerable_versions'] === 'string' ? a['vulnerable_versions'] : undefined,
|
|
179
|
+
fix: typeof a['patched_versions'] === 'string' ? a['patched_versions'] : undefined,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return { counts, packages };
|
|
185
|
+
}
|
|
51
186
|
/**
|
|
52
187
|
* Level 1: dependency vulnerability audit via the project's package manager.
|
|
53
188
|
* Returns a status so an audit that could not run (offline, missing tool,
|
|
@@ -99,78 +234,87 @@ export async function auditDeps(root, packageManagers, timeoutMs = 60000) {
|
|
|
99
234
|
return failed(`${pm} audit exited with code ${res.code}`);
|
|
100
235
|
if (!res.stdout.trim())
|
|
101
236
|
return failed(`${pm} audit produced no output (offline or no lockfile?)`);
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
// A valid JSON error envelope (e.g. registry unavailable) is NOT "clean".
|
|
134
|
-
if (typeof parsed !== 'object' || parsed === null || parsed.error !== undefined) {
|
|
135
|
-
return failed('audit returned an error envelope');
|
|
136
|
-
}
|
|
137
|
-
counts = validCounts(parsed.metadata?.vulnerabilities);
|
|
138
|
-
if (!counts)
|
|
139
|
-
return failed('unparseable audit output (metadata.vulnerabilities missing or malformed)');
|
|
140
|
-
vulnMap = parsed.vulnerabilities ?? {};
|
|
141
|
-
}
|
|
142
|
-
const { critical, high, moderate, low } = counts;
|
|
143
|
-
const total = counts.total ?? critical + high + moderate + low + counts.info;
|
|
237
|
+
const full = parseAuditPayload(pm, res.stdout);
|
|
238
|
+
if (!full)
|
|
239
|
+
return failed('unparseable audit output');
|
|
240
|
+
// Second pass, prod-only: tells apart a real runtime hole from a vulnerable
|
|
241
|
+
// devDependency (eslint, vitest, turbo — never shipped, never executed on
|
|
242
|
+
// the server) that would otherwise inflate the score and the verdict the
|
|
243
|
+
// same way a live SQL injection would. Best-effort: if this second call
|
|
244
|
+
// fails for any reason, fall back to treating the full count as the runtime
|
|
245
|
+
// count — never the other way around, so a failure here cannot make a real
|
|
246
|
+
// vulnerability look dev-only.
|
|
247
|
+
const prodRes = await run(pm, PROD_ONLY_ARGS[pm], root, timeoutMs);
|
|
248
|
+
const prod = !prodRes.failedToSpawn && prodRes.code !== null
|
|
249
|
+
&& (prodRes.code === 0 || isVulnsFoundExit(pm, prodRes.code))
|
|
250
|
+
&& prodRes.stdout.trim()
|
|
251
|
+
? parseAuditPayload(pm, prodRes.stdout)
|
|
252
|
+
: null;
|
|
253
|
+
const runtime = prod ?? full;
|
|
254
|
+
// Only ever attribute MORE to dev, never invent runtime risk the tool
|
|
255
|
+
// itself never reported — clamp at 0 in case the two audit runs disagree
|
|
256
|
+
// (a lockfile changing between calls, or manager-specific flag quirks).
|
|
257
|
+
const devOnly = {
|
|
258
|
+
info: Math.max(0, full.counts.info - runtime.counts.info),
|
|
259
|
+
low: Math.max(0, full.counts.low - runtime.counts.low),
|
|
260
|
+
moderate: Math.max(0, full.counts.moderate - runtime.counts.moderate),
|
|
261
|
+
high: Math.max(0, full.counts.high - runtime.counts.high),
|
|
262
|
+
critical: Math.max(0, full.counts.critical - runtime.counts.critical),
|
|
263
|
+
};
|
|
264
|
+
const devTotal = devOnly.info + devOnly.low + devOnly.moderate + devOnly.high + devOnly.critical;
|
|
265
|
+
const splitKnown = prod !== null;
|
|
266
|
+
const { critical, high, moderate, low } = runtime.counts;
|
|
267
|
+
const runtimeTotal = runtime.counts.total ?? critical + high + moderate + low + runtime.counts.info;
|
|
144
268
|
const findings = [...conflictFindings];
|
|
145
|
-
if (
|
|
269
|
+
if (runtimeTotal > 0) {
|
|
146
270
|
const severity = critical + high > 0 ? 'critical' : moderate > 0 ? 'warning' : 'info';
|
|
271
|
+
const devNote = splitKnown && devTotal > 0
|
|
272
|
+
? ` (plus ${devTotal} in dev-only tooling — not counted here, does not ship or run on the server)`
|
|
273
|
+
: !splitKnown
|
|
274
|
+
? ' (dev/runtime split unavailable — this count includes dev dependencies)'
|
|
275
|
+
: '';
|
|
147
276
|
findings.push({
|
|
148
277
|
id: 'deps_vulnerabilities',
|
|
149
278
|
severity,
|
|
150
|
-
title: `${
|
|
151
|
-
detail: `${pm} audit: ${critical} critical, ${high} high, ${moderate} moderate, ${low} low.`,
|
|
279
|
+
title: `${runtimeTotal} vulnerable runtime dependenc${runtimeTotal === 1 ? 'y' : 'ies'}${devNote}`,
|
|
280
|
+
detail: `${pm} audit (runtime only): ${critical} critical, ${high} high, ${moderate} moderate, ${low} low.`,
|
|
152
281
|
fix: `Run \`${pm} audit${pm === 'npm' ? ' fix' : ''}\` and upgrade the flagged packages; check breaking changes.`,
|
|
153
282
|
checker: 'deps',
|
|
154
283
|
level: 1,
|
|
155
284
|
});
|
|
156
|
-
const named =
|
|
157
|
-
.filter((v) => v.severity === 'critical' || v.severity === 'high')
|
|
158
|
-
.map((v) => v.name)
|
|
159
|
-
.filter(Boolean)
|
|
160
|
-
.slice(0, 8);
|
|
285
|
+
const named = dedupePackages(runtime.packages.filter((p) => p.severity === 'critical' || p.severity === 'high'), 8);
|
|
161
286
|
if (named.length) {
|
|
162
287
|
findings.push({
|
|
163
288
|
id: 'deps_top_packages',
|
|
164
289
|
severity: 'info',
|
|
165
|
-
title: 'High/critical packages to upgrade',
|
|
166
|
-
detail: named.join(', '),
|
|
290
|
+
title: 'High/critical runtime packages to upgrade',
|
|
291
|
+
detail: named.map((p) => `${p.name} (${p.range ?? '?'})${p.fix ? ` → ${p.fix}` : ''}`).join(', '),
|
|
167
292
|
fix: 'Upgrade these first; they carry the most severe advisories.',
|
|
168
293
|
checker: 'deps',
|
|
169
294
|
level: 1,
|
|
170
295
|
});
|
|
171
296
|
}
|
|
172
297
|
}
|
|
173
|
-
//
|
|
174
|
-
//
|
|
298
|
+
// Dev-only vulnerabilities are still worth knowing about (a compromised
|
|
299
|
+
// build-time tool can still do damage), but they are hygiene, not a hole a
|
|
300
|
+
// client can reach — info, and never why the gate fails.
|
|
301
|
+
if (splitKnown && devTotal > 0) {
|
|
302
|
+
const devCritHigh = devOnly.critical + devOnly.high;
|
|
303
|
+
const devNamed = dedupePackages(full.packages
|
|
304
|
+
.filter((p) => p.severity === 'critical' || p.severity === 'high')
|
|
305
|
+
.filter((p) => !runtime.packages.some((r) => r.name === p.name)), 8);
|
|
306
|
+
findings.push({
|
|
307
|
+
id: 'deps_dev_vulnerabilities',
|
|
308
|
+
severity: 'info',
|
|
309
|
+
title: `${devTotal} vulnerable dev-only dependenc${devTotal === 1 ? 'y' : 'ies'}`,
|
|
310
|
+
detail: `${devOnly.critical} critical, ${devOnly.high} high, ${devOnly.moderate} moderate, ${devOnly.low} low — only in build/test tooling (e.g. eslint, vitest, turbo), never shipped to the browser or run on the server.${devCritHigh > 0 && devNamed.length ? ` Top: ${devNamed.map((p) => p.name).join(', ')}.` : ''}`,
|
|
311
|
+
fix: `Worth updating on its own schedule — run \`${pm} audit\` (full) to see them; they do not affect this project's runtime exposure.`,
|
|
312
|
+
checker: 'deps',
|
|
313
|
+
level: 1,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
// "completed" with zero runtime findings means genuinely no known runtime
|
|
317
|
+
// vulns — the status, not an info finding, records that the check ran
|
|
318
|
+
// cleanly. A dev-only info finding can still be present.
|
|
175
319
|
return { findings, run: withNote({ id: 'deps', level: 1, status: 'completed' }) };
|
|
176
320
|
}
|
|
@@ -4,6 +4,7 @@ import { configRisksChecker } from './static/config-risks.js';
|
|
|
4
4
|
import { rlsMigrationsChecker } from './static/rls-migrations.js';
|
|
5
5
|
import { envGitChecker } from './static/env-git.js';
|
|
6
6
|
import { routeInventoryChecker } from './static/route-inventory.js';
|
|
7
|
+
import { nextImageSsrfChecker } from './static/next-image-ssrf.js';
|
|
7
8
|
/** Level 0 checkers: read-only static analysis, no install, any stack. */
|
|
8
9
|
export const staticCheckers = [
|
|
9
10
|
secretsChecker,
|
|
@@ -12,4 +13,5 @@ export const staticCheckers = [
|
|
|
12
13
|
rlsMigrationsChecker,
|
|
13
14
|
envGitChecker,
|
|
14
15
|
routeInventoryChecker,
|
|
16
|
+
nextImageSsrfChecker,
|
|
15
17
|
];
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { lineAt } from '../../util/text.js';
|
|
2
|
+
import { maskCode } from '../../util/mask.js';
|
|
3
|
+
const NEXT_CONFIG_RE = /(^|\/)next\.config\.(js|mjs|cjs|ts)$/;
|
|
4
|
+
const MIDDLEWARE_RE = /(^|\/)middleware\.(js|mjs|ts)$/;
|
|
5
|
+
/**
|
|
6
|
+
* A `remotePatterns` entry with a wildcard hostname. Next.js's built-in image
|
|
7
|
+
* optimizer (`/_next/image?url=...`) will then fetch ANY https URL a caller
|
|
8
|
+
* gives it and return the decoded result — a textbook SSRF primitive (probe
|
|
9
|
+
* internal services, cloud metadata endpoints, port-scan the private network)
|
|
10
|
+
* independent of whatever RCE a given `next`/`sharp` version does or doesn't
|
|
11
|
+
* carry this month. Flagging only the version misses this: upgrading closes
|
|
12
|
+
* today's CVE but leaves the wildcard open for the next one.
|
|
13
|
+
*/
|
|
14
|
+
const WILDCARD_HOSTNAME = /hostname\s*:\s*["'`]\*{1,2}["'`]/;
|
|
15
|
+
const REMOTE_PATTERNS_BLOCK = /remotePatterns\s*:\s*\[/g;
|
|
16
|
+
/** How far past `remotePatterns: [` to look for a wildcard entry inside it. */
|
|
17
|
+
const BLOCK_WINDOW = 2000;
|
|
18
|
+
/**
|
|
19
|
+
* The Next.js docs' own idiom for "run this middleware on everything except
|
|
20
|
+
* X": a negative lookahead inside the matcher regex,
|
|
21
|
+
* `'/((?!_next/static|_next/image|favicon.ico).*)'`. `_next/image` appearing
|
|
22
|
+
* inside a matcher block that also contains a lookahead means the image
|
|
23
|
+
* optimizer is explicitly carved out of whatever auth gate this middleware
|
|
24
|
+
* otherwise enforces — reachable with no session at all.
|
|
25
|
+
*/
|
|
26
|
+
const MATCHER_BLOCK = /matcher\s*[:=]/g;
|
|
27
|
+
const MATCHER_WINDOW = 500;
|
|
28
|
+
const dirOf = (rel) => {
|
|
29
|
+
const i = rel.lastIndexOf('/');
|
|
30
|
+
return i === -1 ? '' : rel.slice(0, i);
|
|
31
|
+
};
|
|
32
|
+
/** middleware.ts commonly sits at the app root OR under src/ — normalize both to the app root. */
|
|
33
|
+
const appRootOf = (rel) => {
|
|
34
|
+
const d = dirOf(rel);
|
|
35
|
+
return d.endsWith('/src') ? d.slice(0, -4) : d === 'src' ? '' : d;
|
|
36
|
+
};
|
|
37
|
+
function findWildcardRemotePatterns(content) {
|
|
38
|
+
const hits = [];
|
|
39
|
+
for (const m of content.matchAll(REMOTE_PATTERNS_BLOCK)) {
|
|
40
|
+
const start = (m.index ?? 0) + m[0].length;
|
|
41
|
+
const window = content.slice(start, start + BLOCK_WINDOW);
|
|
42
|
+
// Stop at the block's own closing `]` when it's inside the window, so a
|
|
43
|
+
// SECOND, unrelated remotePatterns array later in a long file is not
|
|
44
|
+
// credited with this one's wildcard.
|
|
45
|
+
const closeAt = window.indexOf(']');
|
|
46
|
+
const scoped = closeAt === -1 ? window : window.slice(0, closeAt);
|
|
47
|
+
if (WILDCARD_HOSTNAME.test(scoped))
|
|
48
|
+
hits.push(m.index ?? 0);
|
|
49
|
+
}
|
|
50
|
+
return hits;
|
|
51
|
+
}
|
|
52
|
+
function findImageExcludedMatchers(content) {
|
|
53
|
+
const hits = [];
|
|
54
|
+
for (const m of content.matchAll(MATCHER_BLOCK)) {
|
|
55
|
+
const start = m.index ?? 0;
|
|
56
|
+
const window = content.slice(start, start + MATCHER_WINDOW);
|
|
57
|
+
if (window.includes('(?!') && window.includes('_next/image'))
|
|
58
|
+
hits.push(start);
|
|
59
|
+
}
|
|
60
|
+
return hits;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Correlates two static signals that no per-file, per-checker rule can see
|
|
64
|
+
* together: an image optimizer open to any host (`next.config.*`), and an
|
|
65
|
+
* auth middleware that explicitly exempts the optimizer's own route
|
|
66
|
+
* (`middleware.*`). Neither file alone shows the hole; grep for either one in
|
|
67
|
+
* isolation reads as routine Next.js configuration.
|
|
68
|
+
*/
|
|
69
|
+
export const nextImageSsrfChecker = {
|
|
70
|
+
id: 'next-image-ssrf',
|
|
71
|
+
title: 'Next.js image optimizer open to any host',
|
|
72
|
+
level: 0,
|
|
73
|
+
run(ctx) {
|
|
74
|
+
const configFiles = ctx.files.filter((f) => NEXT_CONFIG_RE.test(f.rel));
|
|
75
|
+
const middlewareFiles = ctx.files.filter((f) => MIDDLEWARE_RE.test(f.rel));
|
|
76
|
+
const wildcardHits = [];
|
|
77
|
+
// Comments never count as config: `// hostname: '**' — SSRF risk, do not
|
|
78
|
+
// do this` in a file that sets a real, specific hostname must not fire.
|
|
79
|
+
for (const f of configFiles) {
|
|
80
|
+
const masked = maskCode(f.content, { file: f.rel });
|
|
81
|
+
for (const index of findWildcardRemotePatterns(masked))
|
|
82
|
+
wildcardHits.push({ file: f.rel, index });
|
|
83
|
+
}
|
|
84
|
+
if (wildcardHits.length === 0)
|
|
85
|
+
return { findings: [] };
|
|
86
|
+
const excludedHits = [];
|
|
87
|
+
for (const f of middlewareFiles) {
|
|
88
|
+
const masked = maskCode(f.content, { file: f.rel });
|
|
89
|
+
for (const index of findImageExcludedMatchers(masked))
|
|
90
|
+
excludedHits.push({ file: f.rel, index });
|
|
91
|
+
}
|
|
92
|
+
// Pair a config hit with a middleware hit when there is only one of each
|
|
93
|
+
// in the whole project (the common single-app case), or when one file's
|
|
94
|
+
// app root is a prefix of the other's (a monorepo with `apps/<name>/`).
|
|
95
|
+
const pairFor = (configFile) => {
|
|
96
|
+
if (excludedHits.length === 1)
|
|
97
|
+
return excludedHits[0];
|
|
98
|
+
const root = appRootOf(configFile);
|
|
99
|
+
return excludedHits.find((h) => {
|
|
100
|
+
const hRoot = appRootOf(h.file);
|
|
101
|
+
return hRoot === root || hRoot.startsWith(root ? `${root}/` : '') || (root.startsWith(hRoot ? `${hRoot}/` : '') && hRoot !== '');
|
|
102
|
+
});
|
|
103
|
+
};
|
|
104
|
+
const findings = [];
|
|
105
|
+
for (const hit of wildcardHits) {
|
|
106
|
+
const file = ctx.files.find((f) => f.rel === hit.file);
|
|
107
|
+
const line = file ? lineAt(file.content, hit.index) : 1;
|
|
108
|
+
const paired = configFiles.length === 1 ? excludedHits[0] : pairFor(hit.file);
|
|
109
|
+
if (paired) {
|
|
110
|
+
findings.push({
|
|
111
|
+
id: 'next_image_ssrf_unauth',
|
|
112
|
+
severity: 'critical',
|
|
113
|
+
title: 'Next.js image optimizer is open to any host AND reachable without auth',
|
|
114
|
+
detail: `${hit.file}:${line} allows the built-in image optimizer (${'/_next/image'}) to fetch an https URL from ANY host (a wildcard \`hostname\` in \`remotePatterns\`). ${paired.file} explicitly excludes \`_next/image\` from its auth matcher, so any unauthenticated caller can make this server fetch an attacker-chosen URL — a classic SSRF (internal services, cloud metadata endpoints, private-network scanning), and a live vector for whatever RCE the installed \`next\`/\`sharp\` version carries in its image decoder. One \`GET\` request against the real path confirms this without needing to read either file in isolation.`,
|
|
115
|
+
fix: 'Restrict `images.remotePatterns` to the specific hosts you actually serve images from — never a bare `\'*\'`/`\'**\'` hostname. If the optimizer must stay open to arbitrary hosts, remove `_next/image` from the middleware matcher exclusion so it is not reachable pre-auth. Also keep `next` and `sharp` on a current, patched version — the wildcard host and the decoder CVEs compound each other.',
|
|
116
|
+
checker: 'next-image-ssrf',
|
|
117
|
+
level: 0,
|
|
118
|
+
file: hit.file,
|
|
119
|
+
line,
|
|
120
|
+
});
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
findings.push({
|
|
124
|
+
id: 'next_image_ssrf_open',
|
|
125
|
+
severity: 'warning',
|
|
126
|
+
title: 'Next.js image optimizer accepts any remote host',
|
|
127
|
+
detail: `${hit.file}:${line} sets a wildcard \`hostname\` in \`images.remotePatterns\`, so the built-in image optimizer will fetch an https URL from any host a caller supplies. This is an SSRF primitive on its own — internal services, cloud metadata endpoints, and the private network become reachable through this server — independent of whether \`_next/image\` also sits behind auth.`,
|
|
128
|
+
fix: 'List the specific hosts you actually serve images from in `remotePatterns` instead of a bare `\'*\'`/`\'**\'` hostname. If the route is not behind auth, this is worth fixing regardless of the current `next`/`sharp` version.',
|
|
129
|
+
checker: 'next-image-ssrf',
|
|
130
|
+
level: 0,
|
|
131
|
+
file: hit.file,
|
|
132
|
+
line,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
return { findings };
|
|
136
|
+
},
|
|
137
|
+
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { decodeJwtPayload, lineAt, looksLikePlaceholder, looksLikeTestOrDocPath, redact, shannonEntropy } from '../../util/text.js';
|
|
2
|
-
import { createExposure } from '../../util/git-exposure.js';
|
|
2
|
+
import { createExposure, createHistoryChecker } from '../../util/git-exposure.js';
|
|
3
3
|
import { partitionIgnores } from '../../config.js';
|
|
4
4
|
/** No project-wide rules — used to run ONLY the inline-marker half of partitionIgnores. */
|
|
5
5
|
const EMPTY_CONFIG = { ignore: [], ignorePaths: [] };
|
|
@@ -246,7 +246,7 @@ function collectRealEnvValues(files) {
|
|
|
246
246
|
* it is. Builds the per-file `push` used by every scan pass below, so that
|
|
247
247
|
* pipeline lives in one place instead of being reimplemented per pass.
|
|
248
248
|
*/
|
|
249
|
-
function makePush({ rel, env, example, ex, realEnvValues, findings }) {
|
|
249
|
+
function makePush({ rel, env, example, ex, realEnvValues, findings, wasCommitted }) {
|
|
250
250
|
return (f, proven = false, raw) => {
|
|
251
251
|
let out = f;
|
|
252
252
|
const realValueInExample = !!raw && isExampleEnv(rel) && (realEnvValues.get(dirOf(rel))?.has(raw) ?? false);
|
|
@@ -291,15 +291,28 @@ function makePush({ rel, env, example, ex, realEnvValues, findings }) {
|
|
|
291
291
|
// there is nothing a separate ignored-env-file branch would add.
|
|
292
292
|
out = { ...out, severity: 'advisory', detail: `${out.detail} Not committed — this is where the value belongs; keep the file out of git and out of client bundles.` };
|
|
293
293
|
}
|
|
294
|
-
else if (ex === 'ignored') {
|
|
295
|
-
// Gitignored
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
294
|
+
else if (ex === 'ignored' || ex === 'untracked') {
|
|
295
|
+
// Gitignored or untracked now — but "not committed NOW" is not the
|
|
296
|
+
// same claim as "never committed": a file `git rm --cached` removed
|
|
297
|
+
// after the mistake was noticed still carries the real value forever
|
|
298
|
+
// in history. For a structurally-proven credential (not a name-based
|
|
299
|
+
// guess), check whether THIS value ever showed up in a version of
|
|
300
|
+
// this file that WAS committed — "rotate immediately" is warranted
|
|
301
|
+
// for a real incident and needless (if harmless) urgency otherwise,
|
|
302
|
+
// and rotating a production service_role key across every
|
|
303
|
+
// environment that holds it is not a free action to ask for blindly.
|
|
304
|
+
const committedBefore = proven && !!raw ? wasCommitted(raw) : null;
|
|
305
|
+
const base = ex === 'ignored'
|
|
306
|
+
? `${out.detail} The file is gitignored, so this cannot leak through git — but it is a credential pasted into a config/source file, not an env var. Move it to .env (also gitignored) so one \`git add -A\` or a shared zip never carries it.`
|
|
307
|
+
: `${out.detail} The file is not committed yet — it is one \`git add -A\` away from being. Add it to .gitignore or move the value to an env var.`;
|
|
308
|
+
out = {
|
|
309
|
+
...out,
|
|
310
|
+
severity: atMost(out.severity, 'warning'),
|
|
311
|
+
detail: committedBefore === false
|
|
312
|
+
? `${base} Checked this file's git history for this exact value and did not find it — it does not look like it was ever actually committed, so this reads as a hygiene fix, not a confirmed leak.`
|
|
313
|
+
: base,
|
|
314
|
+
fix: committedBefore === false ? `${out.fix} (No evidence this exact value was ever committed, so rotation is precautionary here, not a confirmed-leak emergency.)` : out.fix,
|
|
315
|
+
};
|
|
303
316
|
}
|
|
304
317
|
else if (ex === 'no-git') {
|
|
305
318
|
out = { ...out, severity: atMost(out.severity, 'warning'), detail: `${out.detail} This folder is not a git repository, so nothing leaks through git; the risk is copying or zipping the folder. Move the value to an env var before this becomes a repo.` };
|
|
@@ -459,21 +472,30 @@ function scanInlineEnv(content, rel, push) {
|
|
|
459
472
|
}, false, value);
|
|
460
473
|
}
|
|
461
474
|
}
|
|
475
|
+
const NAME_BASED = new Set(['env_secret', 'generic_secret']);
|
|
462
476
|
/**
|
|
463
477
|
* De-duplicate only true repeats: the same secret, same place, same rule.
|
|
464
478
|
* (Keying on file:line alone hid every extra key on a minified line.)
|
|
479
|
+
*
|
|
480
|
+
* env_secret and generic_secret are bucketed together (not by their own id):
|
|
481
|
+
* a config-ish file's single assignment runs through BOTH the line-anchored
|
|
482
|
+
* ASSIGN rule and the mid-line GENERIC rule, and when they independently
|
|
483
|
+
* derive the same value from the same line (an `auth_token = "env(NAME)"` in
|
|
484
|
+
* a Supabase config.toml matched both, each producing its own id) that is one
|
|
485
|
+
* fact reported twice, not two facts. Still keyed on evidence, not line alone
|
|
486
|
+
* — two DIFFERENT secrets on one line stay two findings either way.
|
|
465
487
|
*/
|
|
466
488
|
function dedupeFindings(findings) {
|
|
467
489
|
const seen = new Map();
|
|
468
490
|
for (const f of findings) {
|
|
469
|
-
const
|
|
491
|
+
const bucket = NAME_BASED.has(f.id) ? 'name-based' : f.id;
|
|
492
|
+
const key = `${f.file ?? ''}:${f.line ?? 0}:${bucket}:${f.evidence ?? ''}`;
|
|
470
493
|
const cur = seen.get(key);
|
|
471
494
|
if (!cur || SEVERITY_RANK[f.severity] < SEVERITY_RANK[cur.severity])
|
|
472
495
|
seen.set(key, f);
|
|
473
496
|
}
|
|
474
497
|
return [...seen.values()];
|
|
475
498
|
}
|
|
476
|
-
const NAME_BASED = new Set(['env_secret', 'generic_secret']);
|
|
477
499
|
/**
|
|
478
500
|
* A vendor pattern or a decoded JWT already identifies a VALUE on a line; the
|
|
479
501
|
* name-based env_secret / generic_secret finding for that SAME value is the
|
|
@@ -539,6 +561,7 @@ export const secretsChecker = {
|
|
|
539
561
|
run(ctx) {
|
|
540
562
|
const findings = [];
|
|
541
563
|
const exposure = createExposure(ctx.root);
|
|
564
|
+
const history = createHistoryChecker(ctx.root);
|
|
542
565
|
const realEnvValues = collectRealEnvValues(ctx.files);
|
|
543
566
|
for (const file of ctx.files) {
|
|
544
567
|
const { rel } = file;
|
|
@@ -546,11 +569,16 @@ export const secretsChecker = {
|
|
|
546
569
|
const env = isEnvFile(rel);
|
|
547
570
|
const example = looksLikeTestOrDocPath(rel);
|
|
548
571
|
const ex = exposure(rel);
|
|
549
|
-
const
|
|
572
|
+
const wasCommitted = (raw) => history(rel, raw);
|
|
573
|
+
const push = makePush({ rel, env, example, ex, realEnvValues, findings, wasCommitted });
|
|
550
574
|
scanVendorPatterns(content, rel, env, push);
|
|
551
575
|
scanServiceRoleJwt(content, rel, env, push);
|
|
552
|
-
scanGenericAssignments
|
|
576
|
+
// scanConfigAssignments before scanGenericAssignments/scanInlineEnv: on
|
|
577
|
+
// a config-ish file, all three can match the same line, and this one's
|
|
578
|
+
// title/fix is the most specific (it knows the file IS config/env) —
|
|
579
|
+
// dedupeFindings keeps the first-pushed on a tie, so it should win.
|
|
553
580
|
scanConfigAssignments(content, rel, env, push);
|
|
581
|
+
scanGenericAssignments(content, rel, push);
|
|
554
582
|
scanInlineEnv(content, rel, push);
|
|
555
583
|
}
|
|
556
584
|
const kept = dropRedundantNameBased(dedupeFindings(findings));
|
|
@@ -31,3 +31,44 @@ export function createExposure(root) {
|
|
|
31
31
|
return ex;
|
|
32
32
|
};
|
|
33
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* For a file that is NOT committed now (ignored or untracked): was THIS
|
|
36
|
+
* value ever in a version of it that WAS committed? Tells apart "moved to
|
|
37
|
+
* .gitignore before the real value was ever written here — never leaked"
|
|
38
|
+
* from "was committed with this exact credential, then removed — a real
|
|
39
|
+
* incident, still in history forever." Advice like "rotate it" is sound for
|
|
40
|
+
* the second and needless noise for the first, especially for a production
|
|
41
|
+
* key whose rotation itself carries real operational risk.
|
|
42
|
+
*
|
|
43
|
+
* Bounded to the file's last `maxCommits` commits touching it (a `git log`,
|
|
44
|
+
* then up to that many `git show` blob reads) — deliberate for a file with
|
|
45
|
+
* unusually long history; the answer degrades to "unknown", never "safe".
|
|
46
|
+
*
|
|
47
|
+
* Returns `true` (found it committed), `false` (checked history, never
|
|
48
|
+
* found), or `null` (no history to check, or git failed) — `null` must be
|
|
49
|
+
* treated as "no evidence either way", never as "confirmed safe".
|
|
50
|
+
*/
|
|
51
|
+
export function createHistoryChecker(root) {
|
|
52
|
+
const commitsCache = new Map();
|
|
53
|
+
const commitsFor = (rel, maxCommits) => {
|
|
54
|
+
const key = `${rel}\u0000${maxCommits}`;
|
|
55
|
+
const hit = commitsCache.get(key);
|
|
56
|
+
if (hit !== undefined)
|
|
57
|
+
return hit;
|
|
58
|
+
const log = git(root, ['log', '--all', '-n', String(maxCommits), '--format=%H', '--', rel]);
|
|
59
|
+
const commits = log === null ? null : log.split('\n').filter(Boolean);
|
|
60
|
+
commitsCache.set(key, commits);
|
|
61
|
+
return commits;
|
|
62
|
+
};
|
|
63
|
+
return (rel, value, maxCommits = 20) => {
|
|
64
|
+
const commits = commitsFor(rel, maxCommits);
|
|
65
|
+
if (commits === null || commits.length === 0)
|
|
66
|
+
return null; // never tracked at all, or git failed
|
|
67
|
+
for (const sha of commits) {
|
|
68
|
+
const blob = git(root, ['show', `${sha}:${rel}`]);
|
|
69
|
+
if (blob !== null && blob.includes(value))
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
return false;
|
|
73
|
+
};
|
|
74
|
+
}
|
package/dist/engine/util/text.js
CHANGED
|
@@ -29,7 +29,17 @@ export function lineAt(content, index) {
|
|
|
29
29
|
}
|
|
30
30
|
return line;
|
|
31
31
|
}
|
|
32
|
-
|
|
32
|
+
/**
|
|
33
|
+
* A value that IS a reference to a secret, not the secret itself: `${NAME}`
|
|
34
|
+
* (shell/JS template), `env(NAME)` (Supabase CLI's config.toml substitution —
|
|
35
|
+
* the exact shape `supabase init` writes into every generated config, so this
|
|
36
|
+
* fired on every Supabase project's default file, not one repo's mistake),
|
|
37
|
+
* `{{ NAME }}` (Jinja/Helm/GitHub Actions expression), or a bare `$NAME`
|
|
38
|
+
* shell variable with nothing else in the value. The last one is anchored to
|
|
39
|
+
* the WHOLE value (`^...$`) — a real secret that merely contains a `$`
|
|
40
|
+
* somewhere must not be excluded, only a value that IS just a var reference.
|
|
41
|
+
*/
|
|
42
|
+
const PLACEHOLDER = /(x{3,}|your[_-]?|<[^>]+>|\$\{|process\.env|import\.meta\.env|example|placeholder|changeme|dummy|test[_-]?key|xxxxx|\.\.\.|env\(\s*[A-Z][A-Z0-9_]*\s*\)|\{\{\s*[\w.]+\s*\}\}|^\$[A-Z][A-Z0-9_]*$)/i;
|
|
33
43
|
/** True if a captured value looks like a template/placeholder, not a real secret. */
|
|
34
44
|
export function looksLikePlaceholder(value) {
|
|
35
45
|
return PLACEHOLDER.test(value);
|
package/package.json
CHANGED