easyvibegate 0.6.2 → 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.
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- import { DNS_LABEL } from './util/text.js';
3
+ import { DNS_LABEL, looksLikeTestOrDocPath } from './util/text.js';
4
+ import { maskCode } from './util/mask.js';
4
5
  const SUPABASE_URL_FALLBACK = new RegExp(`https?://${DNS_LABEL}\\.supabase\\.co`, 'i');
5
6
  /** Infer the project's frameworks, backends, languages and package managers. */
6
7
  export function detect(root, files) {
@@ -104,10 +105,23 @@ export function detect(root, files) {
104
105
  // false positives from code that merely mentions a backend by name. Uses the
105
106
  // shared bounded DNS_LABEL (see util/text.ts) so this cannot go quadratic on
106
107
  // a long run of matching characters in a large file.
107
- if (!backends.has('supabase') && files.some((f) => SUPABASE_URL_FALLBACK.test(f.content))) {
108
+ //
109
+ // Two independent guards against a project's own code making itself look
110
+ // like it uses a backend it only mentions: test/fixture files are excluded
111
+ // (a test suite for code that talks to Supabase routinely contains
112
+ // synthetic mock URLs shaped like a real project's own), and comments are
113
+ // masked before matching (an explanatory comment describing that exact URL
114
+ // shape is otherwise itself a match). Both were needed: this fallback once
115
+ // made EasyVibeGate's own test suite, and separately its own source
116
+ // comments, detect EasyVibeGate itself as a Supabase project — a security
117
+ // scanner flagging itself is exactly what dogfooding exists to catch.
118
+ // Strings are NOT masked: a real URL/call normally appears in one.
119
+ const nonTestFiles = files.filter((f) => !looksLikeTestOrDocPath(f.rel));
120
+ const codeOnly = (f) => maskCode(f.content, { file: f.rel });
121
+ if (!backends.has('supabase') && nonTestFiles.some((f) => SUPABASE_URL_FALLBACK.test(codeOnly(f)))) {
108
122
  backends.add('supabase');
109
123
  }
110
- if (!backends.has('firebase') && files.some((f) => /\binitializeApp\s*\(/.test(f.content) && /firebase/i.test(f.content))) {
124
+ if (!backends.has('firebase') && nonTestFiles.some((f) => { const c = codeOnly(f); return /\binitializeApp\s*\(/.test(c) && /firebase/i.test(c); })) {
111
125
  backends.add('firebase');
112
126
  }
113
127
  return {
@@ -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
+ }
@@ -29,7 +29,17 @@ export function lineAt(content, index) {
29
29
  }
30
30
  return line;
31
31
  }
32
- const PLACEHOLDER = /(x{3,}|your[_-]?|<[^>]+>|\$\{|process\.env|import\.meta\.env|example|placeholder|changeme|dummy|test[_-]?key|xxxxx|\.\.\.)/i;
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);
@@ -60,3 +70,17 @@ export function decodeJwtPayload(token) {
60
70
  * milliseconds — and no real hostname label is longer than this anyway.
61
71
  */
62
72
  export const DNS_LABEL = '[a-z0-9-]{1,63}';
73
+ /**
74
+ * Documentation, examples and test/fixture paths — where a sample config
75
+ * value (a fake Supabase URL, a placeholder key) legitimately lives without
76
+ * describing anything about the real project. A test suite for code that
77
+ * TALKS to a backend routinely contains literal-looking URLs/keys for that
78
+ * backend as mock data; without this, scanning a project's own tests can
79
+ * make the project appear to use a backend it merely tests against.
80
+ */
81
+ export function looksLikeTestOrDocPath(rel) {
82
+ return /\.(md|txt|mdx|rst)$/i.test(rel)
83
+ || /\.(example|sample|template|dist)$/i.test(rel)
84
+ || /(^|\/)(docs?|examples?|fixtures?|__fixtures__|__tests__|test|tests|spec|__mocks__)(\/|$)/i.test(rel)
85
+ || /\.(test|spec)\.[a-z]+$/i.test(rel);
86
+ }
@@ -15,22 +15,32 @@ const SKIP_DIRS = new Set([
15
15
  ]);
16
16
  /**
17
17
  * Names ambiguous enough that they are sometimes real source (a module named
18
- * "cache", a package called "tmp") and sometimes pure data. Skipped only when
19
- * `relDir` (the ambiguous directory's own path, relative to the project root,
20
- * e.g. "cache" or "data/cache") has at most 2 path segments — i.e. the
21
- * directory IS the root's own child, or is nested exactly one level below it.
22
- * The real-world evidence for this was `data/cache/*.json` full of API
23
- * pagination tokens, 559 of 569 "generic secrets" in one real project. Deeper
24
- * nesting (`apps/api/src/lib/cache/`, 4 segments) is ordinary source and stays
25
- * scanned; blanket name-matching at any depth once made a source directory
26
- * invisible to every check with no visible coverage gap.
18
+ * "cache", a package called "tmp") and sometimes pure data. Path depth alone
19
+ * does not tell them apart: `src/cache` and `data/cache` are both 2 segments,
20
+ * but the first is exactly as likely to be a project's own source directory
21
+ * (a caching module) as the second is to be scan output. What DOES tell them
22
+ * apart is the parent: a data-ish parent name (`data`, `var`, `storage`) means
23
+ * generated/fetched content; an ordinary source-tree parent does not. Skipped
24
+ * only when the ambiguous name is the project root's own child (`cache/`,
25
+ * `tmp/` at the top level), or its immediate parent is one of those data-ish
26
+ * names (`data/cache`, `var/tmp`). The real-world evidence for this was
27
+ * `data/cache/*.json` full of API pagination tokens, 559 of 569 "generic
28
+ * secrets" in one real project; blanket name-matching at any depth once made
29
+ * a source directory invisible to every check with no visible coverage gap,
30
+ * and matching by depth alone reopened that exact gap for `src/cache`.
27
31
  */
28
32
  const AMBIGUOUS_DATA_DIRS = new Set(['cache', 'caches', 'tmp', 'temp', '.tmp']);
29
- const isShallowDataDir = (name, relDir) => AMBIGUOUS_DATA_DIRS.has(name) && relDir.split('/').length <= 2;
33
+ const DATA_ISH_PARENTS = new Set(['data', 'var', 'storage']);
34
+ const isShallowDataDir = (name, relDir) => {
35
+ if (!AMBIGUOUS_DATA_DIRS.has(name))
36
+ return false;
37
+ const segs = relDir.split('/');
38
+ return segs.length === 1 || (segs.length === 2 && DATA_ISH_PARENTS.has((segs[0] ?? '').toLowerCase()));
39
+ };
30
40
  const TEXT_EXT = new Set([
31
- '.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.vue', '.svelte',
41
+ '.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.mts', '.cts', '.vue', '.svelte',
32
42
  '.py', '.rb', '.php', '.go', '.rs', '.java', '.kt', '.cs',
33
- '.html', '.css', '.scss', '.json', '.yml', '.yaml', '.toml',
43
+ '.html', '.css', '.scss', '.json', '.jsonc', '.json5', '.yml', '.yaml', '.toml',
34
44
  '.env', '.sh', '.sql', '.md', '.txt', '.ini', '.conf', '.tf',
35
45
  '.pem', '.key', '.crt', '.cert', '.pkcs8',
36
46
  '.astro', '.properties', '.plist', '.swift', '.dart', '.ipynb', '.bash', '.zsh', '.mdx',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "easyvibegate",
3
- "version": "0.6.2",
3
+ "version": "0.6.9",
4
4
  "description": "Interactive security scanner for vibe-coded apps (focus: Next.js + Supabase). Checks common risks and shows evidence where it can.",
5
5
  "type": "module",
6
6
  "bin": {