safetyvibe-connect 0.2.1 → 0.3.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/AGENTS.md CHANGED
@@ -1,78 +1,96 @@
1
- # Instructions for cautious AI coding agents
1
+ # SafetyVibe Connect instructions
2
2
 
3
- Reports dependency inventory and, if used, the Supabase project URL + public key to SafetyVibe. Requires Node >=18; no dependencies or package install scripts.
3
+ SafetyVibe Connect reads package.json, the lockfile, and client-exposed Supabase config (client-prefixed env vars and the client Supabase file). It reports your package list so we can warn you about vulnerable dependencies. The JSON body sends exactly: v, optional name, url, platform and environment, lockfile, versions_source, source, packages, optional truncated, and supabase (null, or project_ref and host with key_kind and key_fingerprint, or secret_key_exposed_client_side). It never sends Supabase keys, secrets or file contents. Requires Node >=18; no dependencies or package install scripts.
4
+
5
+ Full source: https://safetyvibe.eu/connect/source/0.3.0
4
6
 
5
7
  ## Run without installing
6
8
 
7
9
  Get a claim token from the dashboard, then run:
8
10
 
9
11
  ```sh
10
- npx -y safetyvibe-connect@0.2.1 setup --no-hook --claim-token <TOKEN>
12
+ npx -y safetyvibe-connect@0.3.0 setup --no-hook --claim-token <TOKEN>
11
13
  # pnpm
12
- pnpm dlx safetyvibe-connect@0.2.1 setup --no-hook --claim-token <TOKEN>
14
+ pnpm dlx safetyvibe-connect@0.3.0 setup --no-hook --claim-token <TOKEN>
13
15
  # bun
14
- bunx safetyvibe-connect@0.2.1 setup --no-hook --claim-token <TOKEN>
16
+ bunx safetyvibe-connect@0.3.0 setup --no-hook --claim-token <TOKEN>
15
17
  # yarn
16
- yarn dlx safetyvibe-connect@0.2.1 setup --no-hook --claim-token <TOKEN>
18
+ yarn dlx safetyvibe-connect@0.3.0 setup --no-hook --claim-token <TOKEN>
17
19
  ```
18
20
 
19
21
  Add `--url <published URL>` if known. Without a token, setup prints a claim link.
20
22
  On Replit/pnpm projects, `npm install` can fail with "Cannot read properties of null (reading 'isDescendantOf')".
21
23
  Use `pnpm add -D safetyvibe-connect`, or run without installing:
22
- `npx -y safetyvibe-connect@0.2.1 setup --no-hook --claim-token <TOKEN>`.
24
+ `npx -y safetyvibe-connect@0.3.0 setup --no-hook --claim-token <TOKEN>`.
23
25
  Optional installation commands: `npm install -D safetyvibe-connect`, `pnpm add -D safetyvibe-connect`, `bun add -d safetyvibe-connect`, `yarn add -D safetyvibe-connect`.
24
26
 
25
27
  ## Exactly what is sent
26
28
 
27
- For cautious AI coding agents: inspect the report before sending it:
29
+ Preview the report before sending it:
28
30
 
29
31
  ```sh
30
- npx -y safetyvibe-connect@0.2.1 setup --dry-run --url https://notes.example.com
32
+ npx -y safetyvibe-connect@0.3.0 setup --dry-run --url https://notes.example.com
31
33
  ```
32
34
 
33
- stdout is exactly the JSON body; stderr lists destination and header names with values hidden, plus safety warnings (also under --quiet). Dry-run makes no requests and writes no files.
35
+ By default dry-run stdout is exactly the pretty JSON body plus a newline. Its stderr carries five summary lines: Platform, Lockfile (package and dev-only counts), Supabase (fingerprint or exposed-secret flag only), Keys sent: 0, and Dry run: nothing sent. The summary is under 400 characters. It is followed by a plain explanation, destination and header names with values hidden, then one-line warnings (including any "Dropped <field>: it looked like a key." notices). Dry-run makes no requests. With --dry-run --out report.json, the body is written to that file without sending; otherwise JSON goes to stdout and the summary to stderr.
36
+
37
+ Real runs print the summary on stdout, ending with Connected: and the dashboard or validated claim link (printed once). --quiet suppresses success chatter, but safety warnings always appear on stderr, including during dry runs. Accepted reports show the Connected line even if saving local config or installing the hook fails; warnings and errors follow the summary. Rejected or unreachable reports have no Connected line.
38
+
39
+ --summary prints only the summary on stdout in dry and real runs, with warnings on stderr and no JSON or follow-up chatter. --out <file.json> (also --out=file.json) writes the exact JSON body plus newline instead of printing JSON; in dry runs stdout stays empty and the summary stays on stderr unless --summary is also used. It works for real runs too. The file must be new, end in .json, and resolve inside the project root with an existing parent directory; parent symlink escapes are refused. It uses exclusive creation and never overwrites files. Connector config filenames and package.json are reserved so later setup writes cannot replace the exported body. Invalid output paths exit 2 for setup/dry run; scan always exits 0 with an error on stderr.
34
40
  Example (optional `truncated` appears only when the inventory exceeds 5000):
35
41
 
36
42
  ```json
37
43
  {
38
44
  "v": 1,
39
- "name": "notes",
45
+ "name": "notes.example.com",
40
46
  "url": "https://notes.example.com",
41
- "platform": "Lovable",
42
47
  "environment": "production",
43
48
  "lockfile": "package-lock.json",
49
+ "versions_source": "lockfile",
50
+ "source": "connector",
44
51
  "packages": [{ "ecosystem": "npm", "name": "vite", "version": "6.0.0", "dev": true }],
45
- "supabase": { "url": "https://example.supabase.co", "key": "sb_publishable_abc123def456", "kind": "publishable" }
52
+ "supabase": { "project_ref": "abcdefghijklmnopqrst", "host": "abcdefghijklmnopqrst.supabase.co", "key_kind": "anon", "key_fingerprint": "12ab34cd" }
46
53
  }
47
54
  ```
48
55
 
49
56
  - `v`: report format version.
50
- - `name`: explicit name, package name, URL host or folder name, skipping template and key-like names; limited to 80 letters, digits, spaces, @ / . _ -; otherwise empty.
51
- - `url`: HTTP(S) origin and path only, without credentials, query or fragment; paths are kept only with up to eight safe ASCII segments of at most 40 characters, excluding sb_, svk_ and eyJ markers; otherwise empty.
52
- - `platform`: detected project builder/hosting label; otherwise empty.
53
- - `environment`: `SAFETYVIBE_ENV`, or production when `NODE_ENV=production`; limited to 20 letters, digits, underscores or hyphens; otherwise empty.
54
- - `lockfile`: selected lockfile filename, `node_modules`, or `none`. Parent locks and node_modules are used only at workspace roots when the project matches a workspace glob in package.json workspaces (array or workspaces.packages) or the pnpm-workspace.yaml packages list. Literal paths, * and ** are supported; negated globs exclude matching paths from membership. Search stops at .git or six parent levels.
55
- - `packages`: registry package names and versions, npm ecosystem, and optional `dev: true` for dev-only dependencies when recorded in the lockfile. Workspace entries are excluded; incompatible optional npm binaries are excluded.
57
+ - `name`: explicit or rc name, then URL host (first label for platform hosts), then package name, skipping template and key-like names; limited to 80 letters, digits, spaces, @ / . _ -; omitted when unknown.
58
+ - `url`: HTTP(S) origin and path only, without credentials, query or fragment; paths are kept only with up to eight safe ASCII segments of at most 40 characters, excluding sb_, svk_ and eyJ markers; key-like hostnames are rejected; omitted when unknown.
59
+ - `platform`: detected project builder/hosting label; omitted when unknown.
60
+ - `environment`: `SAFETYVIBE_ENV`, or production when `NODE_ENV=production`; limited to 20 letters, digits, underscores or hyphens; omitted when unknown.
61
+ - `lockfile`: selected lockfile filename, or `none` when no readable lockfile exists. Parent locks are used only at workspace roots when the project matches a workspace glob in package.json workspaces (array or workspaces.packages) or the pnpm-workspace.yaml packages list. Literal paths, * and ** are supported; negated globs exclude matching paths from membership. Search stops at .git or six parent levels.
62
+ - `packages`: registry package names and versions, npm ecosystem, and optional `dev: true` for dev-only dependencies when recorded in the lockfile. Workspace entries are excluded; incompatible optional npm binaries are excluded. Names that a lockfile declares with a git/tarball/file/link spec are excluded, including hoisted entries. Recognized empty or fully excluded locks remain authoritative. Supported formats are npm v1/v2/v3, Yarn v1/Berry, pnpm v5.4/v6/v9 and text bun.lock; installed node_modules folders are never read. Without a readable lockfile (none, only binary bun.lockb, or an unparseable one) no packages are sent, `lockfile` is `none` (the dashboard keeps the last known list) and the CLI prints: "No readable lockfile found (package-lock.json, pnpm-lock.yaml, yarn.lock or bun.lock); dependency check unavailable." For bun.lockb run `bun install --save-text-lockfile` to create bun.lock.
56
63
  - `truncated`: optional boolean; only the first 5000 packages are sent.
57
- - `supabase`: public project URL, strictly validated public key and kind (`publishable` or `anon`); otherwise null.
64
+ - `versions_source`: always `lockfile`; `source`: always `connector`.
65
+ - `supabase`: null unless a validated URL and public key are found, or a client-exposed secret is detected. Public keys yield only `project_ref`, `host`, `key_kind` (`anon` or `publishable`) and `key_fingerprint` (first eight lowercase hex characters of SHA-256 of the UTF-8 key). `host` is the URL host only; `project_ref` is the lowercase 20-character alphanumeric ref of a .supabase.co host, otherwise null. An exposed secret always wins: only `{ "project_ref": null, "host": null, "secret_key_exposed_client_side": true }` is sent, with project_ref/host filled when a validated URL is known. No key kind, fingerprint or length is sent for secrets.
58
66
 
59
- Never sent: source code, file paths (except the folder-name fallback for the app name), full lockfiles, .env files, Supabase secret/service keys, URL credentials or query strings.
60
- The request headers x-safetyvibe-site, x-safetyvibe-key and x-safetyvibe-claim-token carry the site ID, validated report key and validated one-shot claim token when available; these are not in the JSON body. The server also refuses non-public Supabase keys and deletes stored non-public keys during cleanup.
61
- Public keys are found in supported environment variables/.env files or a Supabase client file. Local secret-key warnings name only the variable.
67
+ All report strings are screened before sending or printing: key-like values (including encoded and Unicode-disguised keys) are dropped, with a local warning naming only the field. A key-like URL is dropped entirely.
62
68
 
63
- Review the entire implementation in `cli.mjs` and `lib.mjs` (approximately 485 lines total). There are no dependencies and no install scripts in this package itself.
69
+ Never sent: source code, file paths, full lockfiles, .env files, Supabase keys (including anon/publishable), secrets, URL credentials or query strings.
70
+ The request headers x-safetyvibe-site, x-safetyvibe-key and x-safetyvibe-claim-token carry the validated UUID site ID, validated report key and validated one-shot claim token when available; these are not in the JSON body. The server validates public keys too for older clients; this connector sends no Supabase keys.
71
+ Public keys are read only from client-exposed variables (prefixes VITE_, NEXT_PUBLIC_, EXPO_PUBLIC_, PUBLIC_, REACT_APP_, NUXT_PUBLIC_; from the environment or .env files) or a client-side Supabase source file (src/integrations/supabase/client.*, src/lib/supabase.*, lib/supabase.ts, utils/supabase.ts). Files that import or require server-only, start with "use server", are named *.server.*, or sit under server/, api/, app/api/, pages/api/, functions/, supabase/functions/, netlify/functions/ or are middleware.* are skipped. Server-only names such as SUPABASE_URL or SUPABASE_ANON_KEY are not read. Every client-prefixed variable in process env and .env/.env.local/.env.production/.env.production.local is checked for exposed secrets, as is every quoted string in the scanned client files. Server-only names remain unflagged. Local warnings name only the variable or relative client file, never the value, and tell you to rotate exposed secrets in Supabase.
72
+
73
+ Review the entire implementation in `cli.mjs` and `lib.mjs` (approximately 633 lines total). There are no dependencies and no install scripts in this package itself.
64
74
  By default reports go only to `https://dashboard.safetyvibe.eu/connect/api/v1/report`. The committed rc cannot change the destination.
65
75
  An explicit `SAFETYVIBE_API` environment override supports HTTPS, or HTTP on localhost/127.0.0.1/[::1] for local testing. Redirects are refused.
66
76
 
77
+ ## Changes
78
+
79
+ 0.3.0 never sends Supabase keys: it reports public-key fingerprints or exposed-secret flags, adds a five-line summary, --summary and safe --out JSON export, and ships MIT licensing and security contact details. 0.2.3 prints a one-line summary first (dry run: package count, Supabase yes/no, destination; setup/scan: result and the dashboard link, printed once) so terminals that cut off long output still show it; in a dry run the full JSON follows. 0.2.2 improves response deadlines, registry-only inventory, project selection and app labels, restores flag-only setup, and rejects ambiguous JWT payloads. It reads pnpm v5.4 locks and no longer reads installed node_modules folders: without a readable lockfile no packages are sent. 0.2.1 fixed a bug where a key on a commented .env line could be included; upgrade from 0.2.0.
80
+
81
+ [Documentation](https://safetyvibe.eu/connect) and [changelog / what is sent](https://safetyvibe.eu/connect/changelog).
82
+
67
83
  ## Files written and removal
68
84
 
69
- Before a setup/scan request, legacy credentials are migrated best-effort into the local file, the committed api field is dropped and its URL normalized. Dry-run, status and help never write. After a successful report:
85
+ Before a setup/scan request, legacy credentials are migrated best-effort into the local file, the committed api field is dropped and its URL normalized. Dry-run writes only an explicitly requested --out file; status and help never write. After a successful report:
70
86
 
71
87
  - `.safetyviberc.json`: app ID, normalized public URL, optional name and `hook: false`.
72
88
  - `.safetyviberc.local.json`: report key and optional fallback claim token; never commit this file. The token is removed once used or rejected as expired/used. Only stored credentials require the local file and ignore entry. The ignore entry is attempted first, then this file is written with mode 0600. An ignore failure warns and fails setup; scan still exits 0. Empty local credentials are removed.
73
89
  - `.gitignore`: adds the local config filename.
74
90
  - `package.json`: setup normally adds a postinstall report hook. `--no-hook` leaves package.json untouched and persists this preference. Existing unsafe shell scripts are left untouched with a manual instruction.
75
91
 
92
+ The project directory is cwd. INIT_CWD is used only when its own package.json lists the connector or its postinstall hook and it is not a workspace root. Scan at an npm/Yarn workspace root (package.json workspaces) or pnpm workspace root (pnpm-workspace.yaml with a packages list) that has no app ID in .safetyviberc.json is skipped without network or writes; run setup in the app directory. A root that was linked by running setup there is treated like any app: it reports and updates its rc files. Dry-run still previews the selected directory. An unlinked scan without a key or claim token skips projects without the connector dependency. Requests have one deadline of at most 10 seconds, including the response body, capped at 64 KiB. Failed responses keep the token unless the server reports it expired or already used, and do not install a hook.
93
+
76
94
  The postinstall hook runs `safetyvibe-connect scan --quiet || exit 0`: it re-sends the current inventory and public Supabase details using the local report key, suppresses success chatter but keeps safety warnings on stderr, and never fails installation. Without a local key it skips reporting for a linked app unless SAFETYVIBE_KEY or a claim token is supplied.
77
95
 
78
96
  Without terminal access, first add `.safetyviberc.local.json` to `.gitignore`, then write `{"claimToken": "<TOKEN>"}` into `.safetyviberc.local.json` and configure the dependency and postinstall hook. Never put the token in the committed `.safetyviberc.json`.
@@ -85,10 +103,12 @@ Remove the connector dependency if installed, its postinstall command, both rc f
85
103
  | Command | Effect |
86
104
  |---|---|
87
105
  | `setup [--claim-token T] [--url URL] [--name NAME] [--no-hook]` | Send report, save config, optionally add hook |
88
- | `setup --dry-run` | Print exact report without network or writes |
106
+ | `setup --dry-run` or `--dry-run` | Print exact report without network; only --out writes a file |
89
107
  | `scan [--quiet]` | Re-report; always exits 0 for install hooks |
90
108
  | `status` | Show local link state |
91
109
  | `help`, `--help`, `-h`, no arguments | Usage only |
92
110
  | `--version`, `-v` | Package version only |
93
111
 
112
+ Flags without a command imply setup. Unknown name, URL, platform and environment fields are omitted, never empty strings.
113
+
94
114
  Value flags accept `--flag=value` or `--flag value`, including values starting with a dash. In the space form, a known flag (also with `=...`) is treated as a missing value. Unknown commands/flags and missing values exit 2, except scan exits 0.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SafetyVibe
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,78 +1,96 @@
1
1
  # safetyvibe-connect
2
2
 
3
- Reports dependency inventory and, if used, the Supabase project URL + public key to SafetyVibe. Requires Node >=18; no dependencies or package install scripts.
3
+ SafetyVibe Connect reads package.json, the lockfile, and client-exposed Supabase config (client-prefixed env vars and the client Supabase file). It reports your package list so we can warn you about vulnerable dependencies. The JSON body sends exactly: v, optional name, url, platform and environment, lockfile, versions_source, source, packages, optional truncated, and supabase (null, or project_ref and host with key_kind and key_fingerprint, or secret_key_exposed_client_side). It never sends Supabase keys, secrets or file contents. Requires Node >=18; no dependencies or package install scripts.
4
+
5
+ Full source: https://safetyvibe.eu/connect/source/0.3.0
4
6
 
5
7
  ## Run without installing
6
8
 
7
9
  Get a claim token from the dashboard, then run:
8
10
 
9
11
  ```sh
10
- npx -y safetyvibe-connect@0.2.1 setup --no-hook --claim-token <TOKEN>
12
+ npx -y safetyvibe-connect@0.3.0 setup --no-hook --claim-token <TOKEN>
11
13
  # pnpm
12
- pnpm dlx safetyvibe-connect@0.2.1 setup --no-hook --claim-token <TOKEN>
14
+ pnpm dlx safetyvibe-connect@0.3.0 setup --no-hook --claim-token <TOKEN>
13
15
  # bun
14
- bunx safetyvibe-connect@0.2.1 setup --no-hook --claim-token <TOKEN>
16
+ bunx safetyvibe-connect@0.3.0 setup --no-hook --claim-token <TOKEN>
15
17
  # yarn
16
- yarn dlx safetyvibe-connect@0.2.1 setup --no-hook --claim-token <TOKEN>
18
+ yarn dlx safetyvibe-connect@0.3.0 setup --no-hook --claim-token <TOKEN>
17
19
  ```
18
20
 
19
21
  Add `--url <published URL>` if known. Without a token, setup prints a claim link.
20
22
  On Replit/pnpm projects, `npm install` can fail with "Cannot read properties of null (reading 'isDescendantOf')".
21
23
  Use `pnpm add -D safetyvibe-connect`, or run without installing:
22
- `npx -y safetyvibe-connect@0.2.1 setup --no-hook --claim-token <TOKEN>`.
24
+ `npx -y safetyvibe-connect@0.3.0 setup --no-hook --claim-token <TOKEN>`.
23
25
  Optional installation commands: `npm install -D safetyvibe-connect`, `pnpm add -D safetyvibe-connect`, `bun add -d safetyvibe-connect`, `yarn add -D safetyvibe-connect`.
24
26
 
25
27
  ## Exactly what is sent
26
28
 
27
- For cautious AI coding agents: inspect the report before sending it:
29
+ Preview the report before sending it:
28
30
 
29
31
  ```sh
30
- npx -y safetyvibe-connect@0.2.1 setup --dry-run --url https://notes.example.com
32
+ npx -y safetyvibe-connect@0.3.0 setup --dry-run --url https://notes.example.com
31
33
  ```
32
34
 
33
- stdout is exactly the JSON body; stderr lists destination and header names with values hidden, plus safety warnings (also under --quiet). Dry-run makes no requests and writes no files.
35
+ By default dry-run stdout is exactly the pretty JSON body plus a newline. Its stderr carries five summary lines: Platform, Lockfile (package and dev-only counts), Supabase (fingerprint or exposed-secret flag only), Keys sent: 0, and Dry run: nothing sent. The summary is under 400 characters. It is followed by a plain explanation, destination and header names with values hidden, then one-line warnings (including any "Dropped <field>: it looked like a key." notices). Dry-run makes no requests. With --dry-run --out report.json, the body is written to that file without sending; otherwise JSON goes to stdout and the summary to stderr.
36
+
37
+ Real runs print the summary on stdout, ending with Connected: and the dashboard or validated claim link (printed once). --quiet suppresses success chatter, but safety warnings always appear on stderr, including during dry runs. Accepted reports show the Connected line even if saving local config or installing the hook fails; warnings and errors follow the summary. Rejected or unreachable reports have no Connected line.
38
+
39
+ --summary prints only the summary on stdout in dry and real runs, with warnings on stderr and no JSON or follow-up chatter. --out <file.json> (also --out=file.json) writes the exact JSON body plus newline instead of printing JSON; in dry runs stdout stays empty and the summary stays on stderr unless --summary is also used. It works for real runs too. The file must be new, end in .json, and resolve inside the project root with an existing parent directory; parent symlink escapes are refused. It uses exclusive creation and never overwrites files. Connector config filenames and package.json are reserved so later setup writes cannot replace the exported body. Invalid output paths exit 2 for setup/dry run; scan always exits 0 with an error on stderr.
34
40
  Example (optional `truncated` appears only when the inventory exceeds 5000):
35
41
 
36
42
  ```json
37
43
  {
38
44
  "v": 1,
39
- "name": "notes",
45
+ "name": "notes.example.com",
40
46
  "url": "https://notes.example.com",
41
- "platform": "Lovable",
42
47
  "environment": "production",
43
48
  "lockfile": "package-lock.json",
49
+ "versions_source": "lockfile",
50
+ "source": "connector",
44
51
  "packages": [{ "ecosystem": "npm", "name": "vite", "version": "6.0.0", "dev": true }],
45
- "supabase": { "url": "https://example.supabase.co", "key": "sb_publishable_abc123def456", "kind": "publishable" }
52
+ "supabase": { "project_ref": "abcdefghijklmnopqrst", "host": "abcdefghijklmnopqrst.supabase.co", "key_kind": "anon", "key_fingerprint": "12ab34cd" }
46
53
  }
47
54
  ```
48
55
 
49
56
  - `v`: report format version.
50
- - `name`: explicit name, package name, URL host or folder name, skipping template and key-like names; limited to 80 letters, digits, spaces, @ / . _ -; otherwise empty.
51
- - `url`: HTTP(S) origin and path only, without credentials, query or fragment; paths are kept only with up to eight safe ASCII segments of at most 40 characters, excluding sb_, svk_ and eyJ markers; otherwise empty.
52
- - `platform`: detected project builder/hosting label; otherwise empty.
53
- - `environment`: `SAFETYVIBE_ENV`, or production when `NODE_ENV=production`; limited to 20 letters, digits, underscores or hyphens; otherwise empty.
54
- - `lockfile`: selected lockfile filename, `node_modules`, or `none`. Parent locks and node_modules are used only at workspace roots when the project matches a workspace glob in package.json workspaces (array or workspaces.packages) or the pnpm-workspace.yaml packages list. Literal paths, * and ** are supported; negated globs exclude matching paths from membership. Search stops at .git or six parent levels.
55
- - `packages`: registry package names and versions, npm ecosystem, and optional `dev: true` for dev-only dependencies when recorded in the lockfile. Workspace entries are excluded; incompatible optional npm binaries are excluded.
57
+ - `name`: explicit or rc name, then URL host (first label for platform hosts), then package name, skipping template and key-like names; limited to 80 letters, digits, spaces, @ / . _ -; omitted when unknown.
58
+ - `url`: HTTP(S) origin and path only, without credentials, query or fragment; paths are kept only with up to eight safe ASCII segments of at most 40 characters, excluding sb_, svk_ and eyJ markers; key-like hostnames are rejected; omitted when unknown.
59
+ - `platform`: detected project builder/hosting label; omitted when unknown.
60
+ - `environment`: `SAFETYVIBE_ENV`, or production when `NODE_ENV=production`; limited to 20 letters, digits, underscores or hyphens; omitted when unknown.
61
+ - `lockfile`: selected lockfile filename, or `none` when no readable lockfile exists. Parent locks are used only at workspace roots when the project matches a workspace glob in package.json workspaces (array or workspaces.packages) or the pnpm-workspace.yaml packages list. Literal paths, * and ** are supported; negated globs exclude matching paths from membership. Search stops at .git or six parent levels.
62
+ - `packages`: registry package names and versions, npm ecosystem, and optional `dev: true` for dev-only dependencies when recorded in the lockfile. Workspace entries are excluded; incompatible optional npm binaries are excluded. Names that a lockfile declares with a git/tarball/file/link spec are excluded, including hoisted entries. Recognized empty or fully excluded locks remain authoritative. Supported formats are npm v1/v2/v3, Yarn v1/Berry, pnpm v5.4/v6/v9 and text bun.lock; installed node_modules folders are never read. Without a readable lockfile (none, only binary bun.lockb, or an unparseable one) no packages are sent, `lockfile` is `none` (the dashboard keeps the last known list) and the CLI prints: "No readable lockfile found (package-lock.json, pnpm-lock.yaml, yarn.lock or bun.lock); dependency check unavailable." For bun.lockb run `bun install --save-text-lockfile` to create bun.lock.
56
63
  - `truncated`: optional boolean; only the first 5000 packages are sent.
57
- - `supabase`: public project URL, strictly validated public key and kind (`publishable` or `anon`); otherwise null.
64
+ - `versions_source`: always `lockfile`; `source`: always `connector`.
65
+ - `supabase`: null unless a validated URL and public key are found, or a client-exposed secret is detected. Public keys yield only `project_ref`, `host`, `key_kind` (`anon` or `publishable`) and `key_fingerprint` (first eight lowercase hex characters of SHA-256 of the UTF-8 key). `host` is the URL host only; `project_ref` is the lowercase 20-character alphanumeric ref of a .supabase.co host, otherwise null. An exposed secret always wins: only `{ "project_ref": null, "host": null, "secret_key_exposed_client_side": true }` is sent, with project_ref/host filled when a validated URL is known. No key kind, fingerprint or length is sent for secrets.
58
66
 
59
- Never sent: source code, file paths (except the folder-name fallback for the app name), full lockfiles, .env files, Supabase secret/service keys, URL credentials or query strings.
60
- The request headers x-safetyvibe-site, x-safetyvibe-key and x-safetyvibe-claim-token carry the site ID, validated report key and validated one-shot claim token when available; these are not in the JSON body. The server also refuses non-public Supabase keys and deletes stored non-public keys during cleanup.
61
- Public keys are found in supported environment variables/.env files or a Supabase client file. Local secret-key warnings name only the variable.
67
+ All report strings are screened before sending or printing: key-like values (including encoded and Unicode-disguised keys) are dropped, with a local warning naming only the field. A key-like URL is dropped entirely.
62
68
 
63
- Review the entire implementation in `cli.mjs` and `lib.mjs` (approximately 485 lines total). There are no dependencies and no install scripts in this package itself.
69
+ Never sent: source code, file paths, full lockfiles, .env files, Supabase keys (including anon/publishable), secrets, URL credentials or query strings.
70
+ The request headers x-safetyvibe-site, x-safetyvibe-key and x-safetyvibe-claim-token carry the validated UUID site ID, validated report key and validated one-shot claim token when available; these are not in the JSON body. The server validates public keys too for older clients; this connector sends no Supabase keys.
71
+ Public keys are read only from client-exposed variables (prefixes VITE_, NEXT_PUBLIC_, EXPO_PUBLIC_, PUBLIC_, REACT_APP_, NUXT_PUBLIC_; from the environment or .env files) or a client-side Supabase source file (src/integrations/supabase/client.*, src/lib/supabase.*, lib/supabase.ts, utils/supabase.ts). Files that import or require server-only, start with "use server", are named *.server.*, or sit under server/, api/, app/api/, pages/api/, functions/, supabase/functions/, netlify/functions/ or are middleware.* are skipped. Server-only names such as SUPABASE_URL or SUPABASE_ANON_KEY are not read. Every client-prefixed variable in process env and .env/.env.local/.env.production/.env.production.local is checked for exposed secrets, as is every quoted string in the scanned client files. Server-only names remain unflagged. Local warnings name only the variable or relative client file, never the value, and tell you to rotate exposed secrets in Supabase.
72
+
73
+ Review the entire implementation in `cli.mjs` and `lib.mjs` (approximately 633 lines total). There are no dependencies and no install scripts in this package itself.
64
74
  By default reports go only to `https://dashboard.safetyvibe.eu/connect/api/v1/report`. The committed rc cannot change the destination.
65
75
  An explicit `SAFETYVIBE_API` environment override supports HTTPS, or HTTP on localhost/127.0.0.1/[::1] for local testing. Redirects are refused.
66
76
 
77
+ ## Changes
78
+
79
+ 0.3.0 never sends Supabase keys: it reports public-key fingerprints or exposed-secret flags, adds a five-line summary, --summary and safe --out JSON export, and ships MIT licensing and security contact details. 0.2.3 prints a one-line summary first (dry run: package count, Supabase yes/no, destination; setup/scan: result and the dashboard link, printed once) so terminals that cut off long output still show it; in a dry run the full JSON follows. 0.2.2 improves response deadlines, registry-only inventory, project selection and app labels, restores flag-only setup, and rejects ambiguous JWT payloads. It reads pnpm v5.4 locks and no longer reads installed node_modules folders: without a readable lockfile no packages are sent. 0.2.1 fixed a bug where a key on a commented .env line could be included; upgrade from 0.2.0.
80
+
81
+ [Documentation](https://safetyvibe.eu/connect) and [changelog / what is sent](https://safetyvibe.eu/connect/changelog).
82
+
67
83
  ## Files written and removal
68
84
 
69
- Before a setup/scan request, legacy credentials are migrated best-effort into the local file, the committed api field is dropped and its URL normalized. Dry-run, status and help never write. After a successful report:
85
+ Before a setup/scan request, legacy credentials are migrated best-effort into the local file, the committed api field is dropped and its URL normalized. Dry-run writes only an explicitly requested --out file; status and help never write. After a successful report:
70
86
 
71
87
  - `.safetyviberc.json`: app ID, normalized public URL, optional name and `hook: false`.
72
88
  - `.safetyviberc.local.json`: report key and optional fallback claim token; never commit this file. The token is removed once used or rejected as expired/used. Only stored credentials require the local file and ignore entry. The ignore entry is attempted first, then this file is written with mode 0600. An ignore failure warns and fails setup; scan still exits 0. Empty local credentials are removed.
73
89
  - `.gitignore`: adds the local config filename.
74
90
  - `package.json`: setup normally adds a postinstall report hook. `--no-hook` leaves package.json untouched and persists this preference. Existing unsafe shell scripts are left untouched with a manual instruction.
75
91
 
92
+ The project directory is cwd. INIT_CWD is used only when its own package.json lists the connector or its postinstall hook and it is not a workspace root. Scan at an npm/Yarn workspace root (package.json workspaces) or pnpm workspace root (pnpm-workspace.yaml with a packages list) that has no app ID in .safetyviberc.json is skipped without network or writes; run setup in the app directory. A root that was linked by running setup there is treated like any app: it reports and updates its rc files. Dry-run still previews the selected directory. An unlinked scan without a key or claim token skips projects without the connector dependency. Requests have one deadline of at most 10 seconds, including the response body, capped at 64 KiB. Failed responses keep the token unless the server reports it expired or already used, and do not install a hook.
93
+
76
94
  The postinstall hook runs `safetyvibe-connect scan --quiet || exit 0`: it re-sends the current inventory and public Supabase details using the local report key, suppresses success chatter but keeps safety warnings on stderr, and never fails installation. Without a local key it skips reporting for a linked app unless SAFETYVIBE_KEY or a claim token is supplied.
77
95
 
78
96
  Without terminal access, first add `.safetyviberc.local.json` to `.gitignore`, then write `{"claimToken": "<TOKEN>"}` into `.safetyviberc.local.json` and configure the dependency and postinstall hook. Never put the token in the committed `.safetyviberc.json`.
@@ -85,10 +103,12 @@ Remove the connector dependency if installed, its postinstall command, both rc f
85
103
  | Command | Effect |
86
104
  |---|---|
87
105
  | `setup [--claim-token T] [--url URL] [--name NAME] [--no-hook]` | Send report, save config, optionally add hook |
88
- | `setup --dry-run` | Print exact report without network or writes |
106
+ | `setup --dry-run` or `--dry-run` | Print exact report without network; only --out writes a file |
89
107
  | `scan [--quiet]` | Re-report; always exits 0 for install hooks |
90
108
  | `status` | Show local link state |
91
109
  | `help`, `--help`, `-h`, no arguments | Usage only |
92
110
  | `--version`, `-v` | Package version only |
93
111
 
112
+ Flags without a command imply setup. Unknown name, URL, platform and environment fields are omitted, never empty strings.
113
+
94
114
  Value flags accept `--flag=value` or `--flag value`, including values starting with a dash. In the space form, a known flag (also with `=...`) is treated as a missing value. Unknown commands/flags and missing values exit 2, except scan exits 0.
package/SECURITY.md ADDED
@@ -0,0 +1,9 @@
1
+ # Security
2
+
3
+ Report vulnerabilities privately to safetyvibe@advertis.ee.
4
+ See https://safetyvibe.eu/.well-known/security.txt.
5
+
6
+ The package reads package.json, lockfiles and client-exposed Supabase config,
7
+ and sends app metadata, registry package versions and Supabase project identity
8
+ with a public-key fingerprint or an exposed-secret flag, never Supabase keys,
9
+ secrets or file contents.
package/cli.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync, writeFileSync, chmodSync, unlinkSync } from 'node:fs';
3
- import { join } from 'node:path';
4
- import { buildReport, findSupabase, normPublicUrl, urlNeedsCleaning, withHook, HOOK, packageManager, readPackages } from './lib.mjs';
2
+ import { readFileSync, writeFileSync, chmodSync, unlinkSync, realpathSync } from 'node:fs';
3
+ import { join, resolve, relative, dirname, isAbsolute, sep } from 'node:path';
4
+ import { buildReport, findSupabase, normPublicUrl, urlNeedsCleaning, withHook, HOOK, packageManager, readPackages, screenString, secretLike, takeScreenWarnings } from './lib.mjs';
5
5
 
6
6
  const RC = '.safetyviberc.json';
7
7
  const LOCAL = '.safetyviberc.local.json';
@@ -9,7 +9,7 @@ const DEFAULT_API = 'https://dashboard.safetyvibe.eu/connect/api/v1';
9
9
  const REPORT_KEY = /^svk_[A-Za-z0-9_-]{32}$/;
10
10
  const CLAIM_TOKEN = /^[A-Za-z0-9_-]{16,64}$/;
11
11
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
12
- const USAGE = 'Usage: safetyvibe-connect setup|scan|status|help [--claim-token TOKEN] [--url URL] [--name NAME] [--dry-run] [--quiet] [--no-hook] [--help/-h] [--version/-v]';
12
+ const USAGE = 'Usage: safetyvibe-connect setup|scan|status|help [--claim-token TOKEN] [--url URL] [--name NAME] [--dry-run] [--summary] [--out FILE.json] [--quiet] [--no-hook] [--help/-h] [--version/-v]\n--dry-run: summary on stderr, JSON on stdout; --out FILE.json writes the body without sending.';
13
13
  const argv = process.argv.slice(2);
14
14
  const flags = {};
15
15
  let cmd = ''; let error = '';
@@ -22,11 +22,11 @@ for (let i = 0; i < argv.length; i++) {
22
22
  }
23
23
  const [raw, ...rest] = arg.split('=');
24
24
  const name = ({ '-h': 'help', '-v': 'version' })[raw] || raw.replace(/^--/, '');
25
- if (['claim-token', 'url', 'name'].includes(name)) {
25
+ if (['claim-token', 'url', 'name', 'out'].includes(name)) {
26
26
  const value = rest.length ? rest.join('=') : argv[++i];
27
- if (!value || (!rest.length && /^(?:--(?:dry-run|quiet|no-hook|help|version|claim-token|url|name)|-h|-v)(?:=|$)/.test(value))) { error = 'Missing value for --' + name + '.'; break; }
27
+ if (!value || (!rest.length && /^(?:--(?:dry-run|quiet|no-hook|help|version|claim-token|url|name|summary|out)|-h|-v)(?:=|$)/.test(value))) { error = 'Missing value for --' + name + '.'; break; }
28
28
  flags[name] = value;
29
- } else if (['dry-run', 'quiet', 'no-hook', 'help', 'version'].includes(name) && !rest.length) flags[name] = true;
29
+ } else if (['dry-run', 'quiet', 'no-hook', 'help', 'version', 'summary'].includes(name) && !rest.length) flags[name] = true;
30
30
  else { error = 'Unknown flag.'; break; }
31
31
  }
32
32
  if (error) {
@@ -35,13 +35,25 @@ if (error) {
35
35
  process.exit(scan ? 0 : 2);
36
36
  }
37
37
  if (!argv.length || cmd === 'help' || flags.help) { console.log(USAGE); process.exit(0); }
38
- if (!cmd && !flags.version) { console.error(USAGE + '\nA command is required.'); process.exit(2); }
38
+ if (!cmd) cmd = 'setup';
39
39
  const VERSION = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version;
40
40
  if (flags.version) { console.log(VERSION); process.exit(0); }
41
- const root = process.env.INIT_CWD || process.cwd();
42
- const say = (...args) => { if (!flags.quiet && !flags['dry-run']) console.log(...args); };
43
- const alert = (message) => console.error(message);
44
- const safeText = value => String(value).replace(/[\x00-\x1f\x7f-\x9f]/g, '').slice(0, 300);
41
+ function installed(dir, hook = false) {
42
+ try { const p = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')); return ['dependencies', 'devDependencies', 'optionalDependencies'].some(k => Object.hasOwn(p[k] || {}, 'safetyvibe-connect')) || (hook && /(?:^|[\s(])safetyvibe-connect\s+scan\b/.test(p.scripts?.postinstall || '')); } catch { return false; }
43
+ }
44
+ function workspaceRoot(dir) {
45
+ try { const p = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')); if (p.workspaces) return true; } catch {}
46
+ // pnpm 10 also writes pnpm-workspace.yaml for build settings in single apps: only a packages list counts.
47
+ try { return /^packages\s*:/m.test(readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8')); } catch { return false; }
48
+ }
49
+ const root = process.env.INIT_CWD && process.env.INIT_CWD !== process.cwd() && !workspaceRoot(process.env.INIT_CWD) && installed(process.env.INIT_CWD, true) ? process.env.INIT_CWD : process.cwd();
50
+ const say = (...args) => { if (!flags.quiet && !flags['dry-run'] && !flags.summary) console.log(...args); };
51
+ const warnings = [];
52
+ const alert = message => warnings.push(message);
53
+ const flushWarnings = () => { for (const message of [...takeScreenWarnings(), ...warnings.splice(0)]) console.error(message); };
54
+ const safeText = value => { const text = String(value).replace(/[\x00-\x1f\x7f-\x9f]/g, ''); return (secretLike(text, false, false) ? '[text removed: it looked like a key]' : text).slice(0, 300); };
55
+ // Variable names such as VITE_SUPABASE_SERVICE_ROLE_KEY are printed; only names that carry key material are hidden.
56
+ const safeName = value => { const text = String(value).replace(/[\x00-\x1f\x7f-\x9f]/g, ''); return /^[\w./-]{1,120}$/.test(text) && !secretLike(text.replace(/service_role/gi, ''), false, false) ? text : 'a client-exposed variable'; };
45
57
  const readJson = (file) => { try { return JSON.parse(readFileSync(join(root, file), 'utf8')) || {}; } catch { return {}; } };
46
58
  function readRc() {
47
59
  const pub = readJson(RC); const local = readJson(LOCAL);
@@ -86,31 +98,68 @@ function apiEndpoint() {
86
98
  if (u.username || u.password || u.search || u.hash || !(u.protocol === 'https:' || (u.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(u.hostname)))) throw new Error('Invalid SAFETYVIBE_API');
87
99
  return u.href.replace(/\/+$/, '') + '/report';
88
100
  }
101
+ function summary(body, last) {
102
+ const label = value => safeText(value).slice(0, 32);
103
+ const lines = [
104
+ 'Platform: ' + label(body.platform || 'unknown'),
105
+ `Lockfile: ${label(body.lockfile || 'none')}, ${body.packages.length} package(s) (${body.packages.filter(p => p.dev).length} dev-only)`,
106
+ 'Supabase: ' + (body.supabase?.secret_key_exposed_client_side ? 'SECRET key exposed client-side (flag only)' : body.supabase ? body.supabase.key_kind + ' key (fingerprint only)' : 'none'),
107
+ 'Keys sent: 0', last,
108
+ ];
109
+ if (lines.join('\n').length >= 400) lines[4] = 'Connected: open your SafetyVibe dashboard';
110
+ if (flags['dry-run'] || !flags.quiet) (flags['dry-run'] && !flags.summary ? console.error : console.log)(lines.join('\n'));
111
+ flushWarnings();
112
+ }
113
+ function writeBody(json) {
114
+ if (!flags.out) return;
115
+ const target = resolve(root, flags.out);
116
+ const inside = (base, path) => { const r = relative(base, path); return r && r !== '..' && !r.startsWith('..' + sep) && !isAbsolute(r); };
117
+ if (!target.endsWith('.json') || !inside(resolve(root), target) || [RC, LOCAL, 'package.json'].some(file => relative(root, target).toLowerCase() === file)) throw new Error('out');
118
+ const base = realpathSync(root); const parent = realpathSync(dirname(target));
119
+ if (parent !== base && !inside(base, parent)) throw new Error('out');
120
+ writeFileSync(target, json, { flag: 'wx', mode: 0o600 });
121
+ }
89
122
  async function report() {
90
123
  const setup = cmd === 'setup';
124
+ if (!setup && !flags['dry-run'] && workspaceRoot(root) && !readRc().site) { say('SafetyVibe: workspace root; run setup in an app directory.'); return 0; }
91
125
  let destination;
92
126
  try { destination = apiEndpoint(); } catch { alert('Invalid SAFETYVIBE_API: use HTTPS or local HTTP.'); return 1; }
93
127
  const rc = readRc();
94
128
  const rawToken = flags['claim-token'] || rc.claimToken || process.env.SAFETYVIBE_CLAIM_TOKEN || '';
95
129
  const token = typeof rawToken === 'string' && CLAIM_TOKEN.test(rawToken) ? rawToken : '';
96
130
  if (rawToken && !token) alert('Invalid claim token in --claim-token, SAFETYVIBE_CLAIM_TOKEN or .safetyviberc.local.json/.safetyviberc.json.');
97
- const givenUrl = flags.url || rc.url || process.env.SAFETYVIBE_URL || '';
131
+ const givenUrl = flags.url || process.env.SAFETYVIBE_URL || rc.url || '';
98
132
  const url = normPublicUrl(givenUrl);
99
133
  if (urlNeedsCleaning(givenUrl)) alert('Removed credentials/query/fragment or unsafe path from URL; only the public URL is stored and sent');
100
- const body = buildReport(root, { url, name: flags.name || rc.name });
134
+ const body = buildReport(root, { url: givenUrl, name: flags.name || rc.name });
101
135
  const headers = { 'content-type': 'application/json', 'user-agent': 'safetyvibe-connect/' + VERSION };
102
136
  const rawKey = rc.key || process.env.SAFETYVIBE_KEY || '';
103
137
  const key = typeof rawKey === 'string' && REPORT_KEY.test(rawKey) ? rawKey : '';
104
138
  if (rawKey && !key) alert('Invalid report key in SAFETYVIBE_KEY or .safetyviberc.local.json/.safetyviberc.json.');
105
- if (rc.site) headers['x-safetyvibe-site'] = rc.site;
139
+ if (typeof rc.site === 'string' && UUID.test(rc.site)) headers['x-safetyvibe-site'] = rc.site;
106
140
  if (rc.site && key) headers['x-safetyvibe-key'] = key;
107
141
  if (token) headers['x-safetyvibe-claim-token'] = token;
108
- for (const variable of findSupabase(root).warnings) alert(`Warning: ${variable} holds a secret key. Variables starting with VITE_/NEXT_PUBLIC_/EXPO_PUBLIC_/PUBLIC_ are shipped to every visitor; rotate that key in Supabase.`);
142
+ for (const finding of findSupabase(root).warnings) alert(`WARNING: ${safeName(finding)} holds a Supabase secret/service_role key in client-exposed config; every visitor can read it. Rotate it in Supabase now. Only a flag is sent, never the key.`);
143
+ const lockState = readPackages(root);
144
+ if (lockState.noLockfile && !flags.quiet) alert('No readable lockfile found (package-lock.json, pnpm-lock.yaml, yarn.lock or bun.lock); dependency check unavailable.'
145
+ + (lockState.binaryBun ? ' Run `bun install --save-text-lockfile` to create bun.lock.' : ''));
146
+ const json = JSON.stringify(body, null, 2) + '\n';
147
+ try { writeBody(json); } catch { alert('Could not write --out: use a new .json file inside the project root with an existing parent directory.'); return 2; }
109
148
  if (flags['dry-run']) {
110
- console.log(JSON.stringify(body, null, 2));
111
- console.error(destination + ' headers: ' + Object.keys(headers).join(', ') + ' (values hidden)');
149
+ // Keep the five summary lines first; warnings follow the destination details.
150
+ const pending = [...takeScreenWarnings(), ...warnings.splice(0)];
151
+ summary(body, 'Dry run: nothing sent');
152
+ if (!flags.summary) {
153
+ console.error(body.supabase?.secret_key_exposed_client_side ? 'A secret key is exposed in client code; only that fact is reported, never the key.' : 'No keys or secrets are sent. The anon key is public by design. Only a fingerprint is sent.');
154
+ console.error(safeText(destination) + ' headers: ' + Object.keys(headers).join(', ') + ' (values hidden)');
155
+ }
156
+ warnings.push(...pending); flushWarnings();
157
+ if (!flags.summary && !flags.out) process.stdout.write(json);
112
158
  return 0;
113
159
  }
160
+ if (!setup && !rc.site && !key && !token && !installed(root)) {
161
+ say('SafetyVibe: no project connection or connector dependency; skipping report.'); return 0;
162
+ }
114
163
  let saveFailed = false;
115
164
  const legacy = readJson(RC);
116
165
  if (['key', 'claimToken', 'api'].some(k => Object.hasOwn(legacy, k)) || (legacy.url && legacy.url !== normPublicUrl(legacy.url))) {
@@ -125,11 +174,27 @@ async function report() {
125
174
  return setup ? 1 : 0;
126
175
  }
127
176
  let response; let b;
128
- const ctl = new AbortController(); const timer = setTimeout(() => ctl.abort(), 10000);
177
+ const ctl = new AbortController(); let reader;
178
+ const requested = Number(process.env.SAFETYVIBE_TIMEOUT_MS);
179
+ const timeout = Number.isFinite(requested) && requested > 0 ? Math.min(requested, 10000) : 10000;
180
+ const cancel = () => { ctl.abort(); if (reader) void reader.cancel().catch(() => {}); };
181
+ let timer;
182
+ const deadline = new Promise((_, reject) => { timer = setTimeout(() => { cancel(); reject(new Error('deadline')); }, timeout); });
129
183
  try {
130
- response = await fetch(destination, { method: 'POST', headers, body: JSON.stringify(body), signal: ctl.signal, redirect: 'error' });
131
- try { b = await response.json(); } catch {}
132
- } catch { alert('SafetyVibe: could not reach ' + new URL(destination).host + '.'); return 1; }
184
+ const work = (async () => {
185
+ response = await fetch(destination, { method: 'POST', headers, body: json, signal: ctl.signal, redirect: 'error' });
186
+ reader = response.body?.getReader();
187
+ const chunks = []; let size = 0;
188
+ if (reader) while (true) {
189
+ const { done, value } = await reader.read(); if (done) break;
190
+ size += value.byteLength; if (size > 65536) { cancel(); throw new Error('response too large'); }
191
+ chunks.push(Buffer.from(value));
192
+ }
193
+ try { b = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch {}
194
+ })();
195
+ work.catch(() => {}); // a late rejection after the deadline must not become an unhandled rejection
196
+ await Promise.race([deadline, work]);
197
+ } catch { alert('SafetyVibe: could not reach ' + safeText(new URL(destination).host) + '.'); return 1; }
133
198
  finally { clearTimeout(timer); }
134
199
  if (typeof b?.warning === 'string') alert('SafetyVibe: ' + safeText(b.warning));
135
200
  if (!response.ok) {
@@ -144,8 +209,16 @@ async function report() {
144
209
  return 1;
145
210
  }
146
211
  if (!b || typeof b !== 'object' || Array.isArray(b) || typeof b.site !== 'string' || !UUID.test(b.site)) {
147
- alert('SafetyVibe: unexpected response from ' + new URL(destination).host + ' (HTTP ' + response.status + ')'); return 1;
212
+ alert('SafetyVibe: unexpected response from ' + safeText(new URL(destination).host) + ' (HTTP ' + response.status + ')'); return 1;
148
213
  }
214
+ let claim = '';
215
+ if (!b.claimed) try {
216
+ if (typeof b.claimUrl === 'string' && !/[\x00-\x1f\x7f-\x9f]/.test(b.claimUrl)) {
217
+ const u = new URL(b.claimUrl);
218
+ if (u.protocol === 'https:' && !u.username && !u.password && u.origin === new URL(destination).origin) claim = u.href; // same-origin, validated; a random claim code must not be dropped by the key screen
219
+ }
220
+ } catch {}
221
+ summary(body, 'Connected: ' + (b.claimed ? 'https://dashboard.safetyvibe.eu' : claim || 'open your SafetyVibe dashboard'));
149
222
  const next = { ...rc, site: b.site, url, ...(flags.name ? { name: body.name } : {}), ...(flags['no-hook'] ? { hook: false } : {}) };
150
223
  if (typeof b.key === 'string' && REPORT_KEY.test(b.key)) next.key = b.key;
151
224
  else if (b.key) alert('Invalid report key in server response.');
@@ -155,29 +228,20 @@ async function report() {
155
228
  alert('SafetyVibe: report sent; app id ' + b.site + '; could not save .safetyviberc.json here, so future reports from this folder cannot reach the same app - set SAFETYVIBE_KEY or run setup in a writable checkout');
156
229
  return 1;
157
230
  }
158
- const inventory = readPackages(root);
159
- say(`SafetyVibe: reported ${body.packages.length} packages (${body.packages.filter(p => p.dev).length} dev-only).`);
231
+ const inventory = lockState;
160
232
  if (body.truncated) alert(`Only the first 5000 of ${inventory.total} packages were reported.`);
233
+ let followup = '';
161
234
  if (setup && next.hook !== false) {
162
235
  const hook = addHook();
163
236
  if (hook === 'could not write package.json') { alert('SafetyVibe: report sent; app id ' + b.site + '; could not write package.json.'); return 1; }
164
- say('SafetyVibe: postinstall hook ' + hook + '.');
165
- } else if (setup) say('Re-report manually: ' + runner + ' safetyvibe-connect scan');
166
- if (b.claimed) say('SafetyVibe: linked to your dashboard: https://dashboard.safetyvibe.eu');
167
- else {
168
- let claim = '';
169
- try {
170
- if (typeof b.claimUrl === 'string' && !/[\x00-\x1f\x7f-\x9f]/.test(b.claimUrl)) {
171
- const u = new URL(b.claimUrl);
172
- if (u.protocol === 'https:' && !u.username && !u.password && u.origin === new URL(destination).origin) claim = u.href;
173
- }
174
- } catch {}
175
- say(claim ? 'SafetyVibe: open this link to add the app to your dashboard: ' + claim : 'SafetyVibe: open your SafetyVibe dashboard');
176
- }
177
- return saveFailed || saved === 'unignored' ? 1 : 0;
237
+ followup = 'SafetyVibe: postinstall hook ' + hook + '.';
238
+ } else if (setup) followup = 'Re-report manually: ' + runner + ' safetyvibe-connect scan';
239
+ if (saveFailed || saved === 'unignored') return 1;
240
+ if (followup) say(followup);
241
+ return 0;
178
242
  }
179
243
  async function main() {
180
- if (cmd === 'status') { const rc = readRc(); say(rc.site ? 'Linked to SafetyVibe app ' + rc.site + (normPublicUrl(rc.url) ? ' (' + normPublicUrl(rc.url) + ')' : '') + '.' : 'Not linked.'); return 0; }
244
+ if (cmd === 'status') { const rc = readRc(); say(rc.site ? 'Linked to SafetyVibe app ' + safeText(rc.site) + (normPublicUrl(rc.url) ? ' (' + normPublicUrl(rc.url) + ')' : '') + '.' : 'Not linked.'); return 0; }
181
245
  return report();
182
246
  }
183
- main().then(code => process.exit(cmd === 'scan' ? 0 : code), () => { alert('SafetyVibe: could not complete report.'); process.exit(cmd === 'scan' ? 0 : 1); });
247
+ main().then(code => { flushWarnings(); process.exit(cmd === 'scan' ? 0 : code); }, () => { alert('SafetyVibe: could not complete report.'); flushWarnings(); process.exit(cmd === 'scan' ? 0 : 1); });
package/lib.mjs CHANGED
@@ -1,33 +1,47 @@
1
1
  // safetyvibe-connect - pure helpers (no network, no writes). Tested in connect.test.mjs.
2
- // Egress = buildReport() body + headers x-safetyvibe-site / x-safetyvibe-key /
2
+ // Egress = buildReport() body: v, name?, url?, platform?, environment?, lockfile,
3
+ // versions_source, source, packages, truncated?, supabase (project_ref, host,
4
+ // key_kind + key_fingerprint OR secret_key_exposed_client_side; never keys).
5
+ // Authentication headers x-safetyvibe-site / x-safetyvibe-key /
3
6
  // x-safetyvibe-claim-token, only to the default API or SAFETYVIBE_API
4
- // (https, or http on localhost); redirects refused. Never source code or secret Supabase keys.
5
- import { readFileSync, existsSync, readdirSync, statSync, lstatSync, realpathSync } from 'node:fs';
6
- import { join, basename, dirname, sep, relative } from 'node:path';
7
+ // (https, or http on localhost); redirects refused. Never source code or any Supabase keys.
8
+ import { createHash } from 'node:crypto';
9
+ import { readFileSync, existsSync, statSync } from 'node:fs';
10
+ import { join, dirname, sep, relative } from 'node:path';
7
11
 
8
12
  const MAX_PACKAGES = 5000;
9
13
  const read = (f) => { try { return readFileSync(f, 'utf8'); } catch { return null; } };
10
14
  const readJson = (f) => { const t = read(f); if (t == null) return null; try { return JSON.parse(t); } catch { return null; } };
15
+ const semver = v => typeof v === 'string' && !!screenString(v, 'package version') && /^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/.test(v);
16
+ function registryTarball(value) {
17
+ try { const u = new URL(value); return /^https?:$/.test(u.protocol) && /\/(?:@[^/]+\/)?[^/]+\/-\/[^/]+-\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?\.tgz$/.test(decodeURIComponent(u.pathname)); } catch { return false; }
18
+ }
19
+ const registrySpec = v => typeof v === 'string' && (/^npm:(?:@[^/]+\/)?[^@]+@/.test(v) ? registrySpec(v.slice(v.lastIndexOf('@') + 1)) : /^(?:[~^<>=]*\s*v?(?:\d+|[xX*])(?:\.(?:\d+|[xX*])){0,2}(?:-[\w.-]+)?(?:\+[\w.-]+)?)(?:(?:\s+(?:-\s+)?|\s*\|\|\s*)[~^<>=]*\s*v?(?:\d+|[xX*])(?:\.(?:\d+|[xX*])){0,2}(?:-[\w.-]+)?(?:\+[\w.-]+)?)*$/.test(v.trim()));
20
+ const dependencySpecs = p => ({ ...p?.dependencies, ...p?.devDependencies, ...p?.optionalDependencies, ...p?.requires });
21
+ const nonRegistryNames = p => Object.entries(dependencySpecs(p)).filter(([, spec]) => typeof spec === 'string' && !registrySpec(spec)).map(([name]) => name);
22
+ const registryEntry = v => v && !v.link && semver(v.version) && (v.resolved == null || registryTarball(v.resolved));
11
23
 
12
24
  // ---- lockfiles -------------------------------------------------------------
13
25
  // Each parser returns [{ name, version }] for every installed package (deduped later).
14
26
 
15
27
  export function fromPackageLock(json, { platform = process.platform, arch = process.arch } = {}) {
16
- const out = [];
28
+ const out = []; const excluded = new Set();
29
+ const remember = p => { for (const name of nonRegistryNames(p)) excluded.add(name); };
17
30
  if (json && json.packages && typeof json.packages === 'object') {
31
+ for (const v of Object.values(json.packages)) remember(v);
18
32
  for (const [path, v] of Object.entries(json.packages)) {
19
- if (!path || !v || v.link || /^(workspace:|link:|file:)/.test(v.resolved || '') || typeof v.version !== 'string') continue; // "" = the root project
33
+ if (!path || !registryEntry(v)) continue;
20
34
  const matches = (list, value) => !Array.isArray(list) || (!list.includes('!' + value) && (!list.some(x => !x.startsWith('!')) || list.includes(value) || list.includes('any')));
21
35
  if (v.optional && (!matches(v.os, platform) || !matches(v.cpu, arch))) continue;
22
36
  const i = path.lastIndexOf('node_modules/');
23
- if (i === -1) continue;
37
+ if (i === -1 || excluded.has(path.slice(i + 'node_modules/'.length))) continue;
24
38
  out.push({ name: v.name || path.slice(i + 'node_modules/'.length), version: v.version, ...(v.dev === true ? { dev: true } : {}) });
25
39
  }
26
40
  } else if (json && json.dependencies) { // lockfileVersion 1
27
- const walk = (deps) => { for (const [n, v] of Object.entries(deps || {})) { if (v && v.version && !v.link && !/^(workspace:|link:|file:)/.test(v.resolved || '')) out.push({ name: n, version: v.version, ...(v.dev === true ? { dev: true } : {}) }); walk(v && v.dependencies); } };
41
+ const walk = (deps) => { for (const [n, v] of Object.entries(deps || {})) { remember(v); if (registryEntry(v)) out.push({ name: n, version: v.version, ...(v.dev === true ? { dev: true } : {}) }); walk(v && v.dependencies); } };
28
42
  walk(json.dependencies);
29
43
  }
30
- return out;
44
+ return out.filter(p => !excluded.has(p.name));
31
45
  }
32
46
 
33
47
  // bun.lock (text, JSONC): "packages": { "<key>": ["name@version", registry, {...}, integrity], ... }
@@ -41,49 +55,39 @@ export function fromBunLock(text) {
41
55
  const at = spec.lastIndexOf('@');
42
56
  if (at <= 0) continue;
43
57
  const version = spec.slice(at + 1);
44
- if (/^\d/.test(version)) out.push({ name: spec.slice(0, at), version }); // skip workspace:/git:/file:
58
+ if (semver(version) && (v[1] === '' || (typeof v[1] === 'string' && /^https?:\/\//.test(v[1]) && !/\.tgz|\.git|github/i.test(v[1])))) out.push({ name: spec.slice(0, at), version });
45
59
  }
46
60
  return out;
47
61
  }
48
62
 
49
63
  // yarn.lock v1 ("name@range":\n version "x") and berry ("name@npm:range":\n version: x)
50
64
  export function fromYarnLock(text) {
51
- const out = []; let names = null;
52
- for (const line of String(text).split(/\r?\n/)) {
53
- if (/^\S.*:\s*$/.test(line) && !line.startsWith('#') && !line.startsWith('__metadata')) {
54
- names = /@(workspace:|link:|file:)/.test(line) ? null : line.replace(/:\s*$/, '').split(',').map((s) => s.trim().replace(/^"|"$/g, ''))
55
- .map((s) => s.slice(0, s.lastIndexOf('@') > 0 ? s.lastIndexOf('@') : s.length));
56
- continue;
65
+ const out = []; const seen = new Set(); const berry = /^__metadata:/m.test(text);
66
+ for (const block of String(text).split(/(?=^\S)/m)) {
67
+ const lines = block.split(/\r?\n/); const head = lines.shift();
68
+ if (!head?.endsWith(':') || head.startsWith('#') || head.startsWith('__metadata')) continue;
69
+ const specs = head.slice(0, -1).split(/,\s*/).map(s => s.replace(/^"|"$/g, ''));
70
+ const version = /^\s+version:?\s+"?([^"\s]+)/m.exec(block)?.[1];
71
+ const resolved = /^\s+resolved\s+["']?([^"'\s]+)/m.exec(block)?.[1];
72
+ const resolution = /^\s+resolution:\s+"?([^"\s]+)/m.exec(block)?.[1];
73
+ if (!semver(version) || (berry ? !/^(?:@[^/]+\/)?[^@:/]+@npm:/.test(resolution || '') : resolved != null && !registryTarball(resolved))) continue;
74
+ for (const spec of new Set(specs)) {
75
+ const at = spec.indexOf('@', spec.startsWith('@') ? 1 : 0);
76
+ if (at < 1 || !(berry ? spec.slice(at + 1).startsWith('npm:') : registrySpec(spec.slice(at + 1)))) continue;
77
+ const name = spec.slice(0, at); const id = name + '@' + version;
78
+ if (!seen.has(id)) { seen.add(id); out.push({ name, version }); }
57
79
  }
58
- const m = line.match(/^\s+version:?\s+"?([^"\s]+)"?\s*$/);
59
- if (m && names) { for (const n of new Set(names)) if (n) out.push({ name: n, version: m[1] }); names = null; }
60
80
  }
61
81
  return out;
62
82
  }
63
83
 
64
- // pnpm-lock.yaml: package keys like "/name@1.2.3", "name@1.2.3", "/@scope/name@1.2.3(peer@x)"
65
84
  export function fromPnpmLock(text) {
66
- const out = []; let inPackages = false;
67
- for (const line of String(text).split(/\r?\n/)) {
68
- if (/^(packages|snapshots):\s*$/.test(line)) { inPackages = line.startsWith('packages'); continue; }
69
- if (/^\S/.test(line)) { inPackages = false; continue; }
70
- if (!inPackages) continue;
71
- const m = line.match(/^ {2}'?\/?((?:@[^/@\s]+\/)?[^@\s/]+)@(\d[^('\s:]*)/);
72
- if (m) out.push({ name: m[1], version: m[2] });
73
- else if (/^ {4}dev: true\s*$/.test(line) && out.length) out[out.length - 1].dev = true;
74
- }
75
- return out;
76
- }
77
-
78
- // Fallback (bun.lockb is binary): what is actually installed in node_modules.
79
- export function fromNodeModules(root) {
80
- const out = []; const nm = join(root, 'node_modules');
81
- let entries = []; try { entries = readdirSync(nm); } catch { return out; }
82
- const add = (dir) => { try { if (lstatSync(dir).isSymbolicLink() && !realpathSync(dir).startsWith(realpathSync(nm) + sep)) return; } catch { return; } const pj = readJson(join(dir, 'package.json')); if (pj && pj.name && pj.version) out.push({ name: pj.name, version: pj.version }); };
83
- for (const e of entries) {
84
- if (e.startsWith('.')) continue;
85
- if (e.startsWith('@')) { let sub = []; try { sub = readdirSync(join(nm, e)); } catch {} for (const s of sub) add(join(nm, e, s)); }
86
- else add(join(nm, e));
85
+ const out = []; const section = /^packages:\s*\r?\n([\s\S]*?)(?=^\S|(?![\s\S]))/m.exec(text)?.[1] || '';
86
+ for (const block of section.split(/(?=^ {2}\S)/m)) {
87
+ const head = block.split(/\r?\n/)[0];
88
+ if (/https?:|git[+:]|github|file:|link:|workspace:/.test(head) || /\b(?:tarball|repo|directory|type):/.test(block) || !/\bintegrity:/.test(block)) continue;
89
+ const m = /^ {2}['"]?\/?((?:@[^/\s]+\/)?[^@/\s]+)(?:@|\/)(\d+\.\d+\.\d+(?:-[\w.-]+)?)/.exec(head);
90
+ if (m) out.push({ name: m[1], version: m[2], ...(/^ {4}dev: true\s*$/m.test(block) ? { dev: true } : {}) });
87
91
  }
88
92
  return out;
89
93
  }
@@ -131,29 +135,40 @@ export function readPackages(root) {
131
135
  let ok = true;
132
136
  try { list = parse(t); } catch { list = []; ok = false; }
133
137
  if (list.length) { source = file; break; }
134
- // A JSON lockfile that parses and lists nothing really means "no dependencies". The line-based
135
- // yarn/pnpm parsers can't tell empty from unsupported, so those stay "unknown".
136
- if (ok && /\.json$|^bun\.lock$/.test(file)) { source = file; break; }
138
+ // Recognized locks remain authoritative when every entry was excluded; falling back
139
+ // to installed packages could reintroduce names from non-registry sources.
140
+ if (ok && (/\.json$|^bun\.lock$/.test(file) || (file === 'pnpm-lock.yaml' && /^packages:/m.test(t)) || (file === 'yarn.lock' && /^(?:__metadata:|# yarn lockfile v1|\S.*:\s*$)/m.test(t)))) { source = file; break; }
137
141
  }
138
- if (!list.length && source === 'none') { list = fromNodeModules(root); if (!list.length && lockRoot !== root) list = fromNodeModules(lockRoot); if (list.length) source = 'node_modules'; }
142
+ // No node_modules fallback: installed folders do not say where a package came from, so a git/tarball/
143
+ // file dependency could be reported under its name. Without a readable lockfile no packages are sent and
144
+ // lockfile stays 'none', so the dashboard keeps the last known inventory.
145
+ const missing = source === 'none' ? { noLockfile: true, ...(existsSync(join(lockRoot, 'bun.lockb')) || existsSync(join(root, 'bun.lockb')) ? { binaryBun: true } : {}) } : {};
139
146
  const seen = new Map(); const pkgs = [];
140
147
  for (const p of list) {
141
148
  const k = p.name + '@' + p.version;
142
149
  // Registry versions only. git/file/link/http specs can carry tokens or local paths: never sent.
143
- if ([p.name, p.version].some(v => /sb_secret_|svk_/i.test(String(v)) || keyKind(v) !== 'unknown')) continue;
150
+ const safeName = screenString(p.name, 'package name', undefined, true);
151
+ const safeVersion = screenString(p.version, 'package version');
152
+ if (!safeName || !safeVersion) continue;
144
153
  if (!/^(@[\w.-]+\/)?[\w.-]+$/.test(p.name) || !/^\d+\.\d+\.\d+[\w.+-]{0,40}$/.test(String(p.version))) continue;
145
154
  if (seen.has(k)) { if (p.dev !== true) delete seen.get(k).dev; continue; }
146
155
  const row = { ecosystem: 'npm', name: p.name, version: p.version, ...(p.dev === true ? { dev: true } : {}) };
147
156
  seen.set(k, row); pkgs.push(row);
148
157
 
149
158
  }
150
- return { packages: pkgs.slice(0, MAX_PACKAGES), source, ...(pkgs.length > MAX_PACKAGES ? { total: pkgs.length, truncated: true } : {}) };
159
+ return { packages: pkgs.slice(0, MAX_PACKAGES), source, ...missing, ...(pkgs.length > MAX_PACKAGES ? { total: pkgs.length, truncated: true } : {}) };
151
160
  }
152
161
 
153
162
  // ---- Supabase (project URL + PUBLIC key only) ------------------------------
154
163
  function jwtPayload(value) {
155
164
  if (typeof value !== 'string' || value.length > 2000 || !/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(value)) return null;
156
- try { const p = JSON.parse(Buffer.from(value.split('.')[1], 'base64url').toString('utf8')); return p && typeof p === 'object' && !Array.isArray(p) ? p : null; } catch { return null; }
165
+ try {
166
+ const raw = Buffer.from(value.split('.')[1], 'base64url').toString('utf8');
167
+ // Tokenize complete strings so quoted text inside a value cannot become a key.
168
+ const keys = [...raw.matchAll(/"(?:[^"\\]|\\.)*"/g)]
169
+ .filter(m => /^\s*:/.test(raw.slice(m.index + m[0].length))).map(m => JSON.parse(m[0]));
170
+ if (new Set(keys).size !== keys.length) return { role: 'rejected' };
171
+ const p = JSON.parse(raw); return p && typeof p === 'object' && !Array.isArray(p) ? p : null; } catch { return null; }
157
172
  }
158
173
  export function publicKeyKind(value) {
159
174
  if (typeof value !== 'string' || /sb_secret_|[\s#'"`]/i.test(value)) return null;
@@ -191,34 +206,52 @@ export function parseEnv(text) {
191
206
  return env;
192
207
  }
193
208
 
194
- const URL_VARS = ['VITE_SUPABASE_URL', 'NEXT_PUBLIC_SUPABASE_URL', 'PUBLIC_SUPABASE_URL', 'EXPO_PUBLIC_SUPABASE_URL', 'SUPABASE_URL'];
195
- const KEY_VARS = ['VITE_SUPABASE_PUBLISHABLE_KEY', 'VITE_SUPABASE_ANON_KEY', 'NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY', 'NEXT_PUBLIC_SUPABASE_ANON_KEY',
196
- 'PUBLIC_SUPABASE_PUBLISHABLE_KEY', 'EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY', 'PUBLIC_SUPABASE_ANON_KEY', 'EXPO_PUBLIC_SUPABASE_ANON_KEY', 'SUPABASE_PUBLISHABLE_KEY', 'SUPABASE_ANON_KEY'];
209
+ // Only client-exposed variable names (bundled into the browser build by these prefixes); server-only
210
+ // names such as SUPABASE_URL / SUPABASE_ANON_KEY are never read.
211
+ const CLIENT_PREFIXES = ['VITE_', 'NEXT_PUBLIC_', 'EXPO_PUBLIC_', 'PUBLIC_', 'REACT_APP_', 'NUXT_PUBLIC_'];
212
+ const URL_VARS = CLIENT_PREFIXES.map(p => p + 'SUPABASE_URL');
213
+ const KEY_VARS = CLIENT_PREFIXES.flatMap(p => [p + 'SUPABASE_PUBLISHABLE_KEY', p + 'SUPABASE_ANON_KEY']);
214
+ const exposedSupabaseSecret = value => typeof value === 'string' && (value.startsWith('sb_secret_') || jwtPayload(value)?.role === 'service_role');
197
215
 
216
+ // Only clearly client-side source files: what the browser bundle already ships to every visitor.
217
+ const SERVER_PATH = /(^|\/)(server|api|functions)\/|(^|\/)app\/api\/|(^|\/)pages\/api\/|(^|\/)supabase\/functions\/|(^|\/)netlify\/functions\/|(^|\/)middleware\.[cm]?[jt]sx?$|\.server\.[cm]?[jt]sx?$/i;
218
+ export function clientSource(path, text) {
219
+ if (SERVER_PATH.test(String(path).split('\\').join('/'))) return false;
220
+ // Any quoted 'server-only' or "use server" anywhere (comments, spacing or syntax tricks included) marks
221
+ // the file as server code. Excluding a client file by mistake is the safe direction.
222
+ return !/['"`]server-only['"`]/.test(text) && !/['"`]use server['"`]/.test(text);
223
+ }
198
224
  export function findSupabase(root, procEnv = process.env) {
199
- const env = {};
200
- for (const f of ['.env', '.env.local', '.env.production', '.env.production.local']) Object.assign(env, parseEnv(read(join(root, f))));
201
- for (const k of [...URL_VARS, ...KEY_VARS]) if (procEnv[k]) env[k] = procEnv[k];
202
- const warnings = KEY_VARS.filter(k => keyKind(env[k]) === 'secret');
203
- let url = URL_VARS.map((k) => env[k]).find(Boolean) || '';
204
- let key = KEY_VARS.map((k) => env[k]).find(value => publicKeyKind(value)) || '';
205
- // Lovable hard-codes them in src/integrations/supabase/client.ts
206
- if (!url || !key) {
207
- for (const f of ['src/integrations/supabase/client.ts', 'src/integrations/supabase/client.js', 'src/lib/supabase.ts', 'src/lib/supabase.js', 'lib/supabase.ts', 'utils/supabase.ts']) {
208
- const t = read(join(root, f)); if (!t) continue;
209
- url = url || ((t.match(/["'`](https:\/\/[a-z0-9-]+\.supabase\.co)["'`]/i) || [])[1] || '');
210
- for (const match of t.matchAll(/([\w]*(?:PUBLISHABLE|ANON)_KEY)\s*=\s*["'`]([^"'`]+)["'`]/gi)) {
211
- if (keyKind(match[2]) === 'secret') warnings.push(match[1]);
212
- if (!key && publicKeyKind(match[2])) key = match[2];
213
- }
214
- if (url && key) break;
225
+ const env = {}; const warnings = new Set();
226
+ const collect = values => {
227
+ for (const k of Object.keys(values)) {
228
+ if (!CLIENT_PREFIXES.some(p => k.startsWith(p))) continue;
229
+ const value = values[k];
230
+ if (KEY_VARS.includes(k) ? keyKind(value) === 'secret' : exposedSupabaseSecret(value)) warnings.add(k);
231
+ if (value) env[k] = value;
232
+ }
233
+ };
234
+ for (const f of ['.env', '.env.local', '.env.production', '.env.production.local']) collect(parseEnv(read(join(root, f))));
235
+ collect(procEnv);
236
+ let url = URL_VARS.map(k => env[k]).find(value => /^https:\/\/[a-z0-9-]+\.supabase\.(co|in)\/?$/i.test(value || '')) || '';
237
+ let key = KEY_VARS.map(k => env[k]).find(value => publicKeyKind(value)) || '';
238
+ // Scan every allowed client file even when environment config is complete.
239
+ for (const f of ['src/integrations/supabase/client.ts', 'src/integrations/supabase/client.js', 'src/lib/supabase.ts', 'src/lib/supabase.js', 'lib/supabase.ts', 'utils/supabase.ts']) {
240
+ const t = read(join(root, f)); if (!t || !clientSource(f, t)) continue;
241
+ url = url || ((t.match(/["'`](https:\/\/[a-z0-9-]+\.supabase\.co)["'`]/i) || [])[1] || '');
242
+ for (const match of t.matchAll(/(["'`])((?:\\.|(?!\1)[^\\])*?)\1/g)) {
243
+ if (exposedSupabaseSecret(match[2])) warnings.add(f);
244
+ }
245
+ for (const match of t.matchAll(/([\w]*(?:PUBLISHABLE|ANON)_KEY)\s*=\s*["'`]([^"'`]+)["'`]/gi)) {
246
+ if (keyKind(match[2]) === 'secret') warnings.add(f);
247
+ if (!key && publicKeyKind(match[2])) key = match[2];
215
248
  }
216
249
  }
217
250
  if (!/^https:\/\/[a-z0-9-]+\.supabase\.(co|in)\/?$/i.test(url)) url = '';
218
251
  const kind = key ? keyKind(key) : 'none';
219
252
  // Never send anything that isn't provably a public key.
220
253
  if (!publicKeyKind(key)) key = '';
221
- return { url: url.replace(/\/$/, ''), key, kind, warnings };
254
+ return { url: url.replace(/\/$/, ''), key, kind, warnings: [...warnings] };
222
255
  }
223
256
 
224
257
  // ---- app identity ------------------------------------------------------------
@@ -229,6 +262,12 @@ export function detectPlatform(root, procEnv = process.env, url = '') {
229
262
  if (deps['lovable-tagger'] || existsSync(join(root, '.lovable'))) return 'Lovable';
230
263
  if (existsSync(join(root, '.replit'))) return 'Replit';
231
264
  for (const [marker, label] of Object.entries({ '.bolt': 'Bolt', '.emergent': 'Emergent', '.softgen': 'Softgen', '.leap': 'Leap', '.rocket': 'Rocket', '.v0': 'v0' })) if (existsSync(join(root, marker))) return label;
265
+ if (String(pj.name || '').toLowerCase().replace(/^@[^/]+\//, '') === 'softgen-starter') return 'Softgen';
266
+ for (const dir of [root, join(root, 'frontend')]) for (const file of ['.env', '.env.local', '.env.production']) {
267
+ for (const value of Object.values(parseEnv(read(join(dir, file))))) {
268
+ try { const u = new URL(value); if (/^https?:$/.test(u.protocol) && /\.(emergentagent\.com|emergent\.host)$/.test(u.hostname)) return 'Emergent'; } catch {}
269
+ }
270
+ }
232
271
  if (deps['@base44/sdk']) return 'Base44';
233
272
  if (Object.hasOwn(readJson(join(root, 'metadata.json')) || {}, 'requestFramePermissions') || (pj.name === 'react-example' && deps['@google/genai']) || ['ts', 'js', 'mjs', 'mts'].some(ext => /process\.env\.GEMINI_API_KEY/.test(read(join(root, 'vite.config.' + ext)) || ''))) return 'Google AI Studio';
234
273
  try { const host = new URL(url).hostname; for (const [suffix, label] of Object.entries(HOSTS)) if (host.endsWith('.' + suffix)) return label; } catch {}
@@ -237,16 +276,53 @@ export function detectPlatform(root, procEnv = process.env, url = '') {
237
276
  if (procEnv.NETLIFY) return 'Netlify';
238
277
  return '';
239
278
  }
240
- const TEMPLATES = new Set('workspace frontend backend client web app project src home repl code dev-server react-example vite-project vite-react-shadcn-ts tanstack-start-ts vite-react-typescript-starter rest-express my-app react-app nextjs next-app nextjs-app my-v0-project'.split(' '));
279
+ const TEMPLATES = new Set('softgen-starter my-react-app create-react-app starter template workspace frontend backend client web app project src home repl code dev-server react-example vite-project vite-react-shadcn-ts tanstack-start-ts vite-react-typescript-starter rest-express my-app react-app nextjs next-app nextjs-app my-v0-project'.split(' '));
280
+ TEMPLATES.add('');
241
281
  // Real JWTs start with eyJ (base64 of {"); plain dotted names like notes.example.com stay allowed.
242
- const secretLike = value => keyKind(value) !== 'unknown' || /sb_secret_|svk_|eyJ/i.test(value);
282
+ export function screenNormalized(value) {
283
+ let text = String(value || '');
284
+ for (let i = 0; i < 3; i++) {
285
+ const decoded = text.replace(/(?:%[0-9a-f]{2})+/gi, run => { try { return decodeURIComponent(run); } catch { return run; } });
286
+ if (decoded === text) break;
287
+ text = decoded;
288
+ }
289
+ const glyphs = { \u0430:'a', \u0435:'e', \u043e:'o', \u0440:'p', \u0441:'c', \u0445:'x', \u0443:'y', \u0456:'i', \u0455:'s', \u0458:'j', \u043a:'k', \u043c:'m', \u043d:'h', \u0442:'t', \u0432:'b', \u03bf:'o', \u03b1:'a', \u03b5:'e', \u03b9:'i', \u03ba:'k', \u03bd:'v', \u03c1:'p', \u03c4:'t', \u03c5:'y', \u03c7:'x' };
290
+ // Decompose first so accents composed onto letters (e + U+0301 -> U+00E9) are stripped too, then recompose.
291
+ return text.normalize('NFKD').replace(/[\p{M}\p{Cf}\p{Default_Ignorable_Code_Point}]/gu, '').normalize('NFKC').replace(/[\u0430 \u0435 \u043e \u0440 \u0441 \u0445 \u0443 \u0456 \u0455 \u0458 \u043a \u043c \u043d \u0442 \u0432 \u03bf \u03b1 \u03b5 \u03b9 \u03ba \u03bd \u03c1 \u03c4 \u03c5 \u03c7]/giu, c => glyphs[c.toLowerCase()] || c);
292
+ }
293
+ export function secretLike(value, packageName = false, credentials = true) {
294
+ if (typeof value !== 'string') return false;
295
+ return [value, screenNormalized(value)].some(v => {
296
+ // eyJ is case-sensitive (base64 of '{"'): lowercase "eyj" occurs in ordinary words (honeyjar, journeyjs).
297
+ if (/sb_publishable_|sb_secret_|sk_live_|sk_test_|sk-ant-|sk-proj-|rk_live_|service_role|svk_/i.test(v) || /eyJ/.test(v)) return true;
298
+ // Generic secret-key shapes (sk_<random>, sk-<random>) with no separators in the random part.
299
+ if (/sk_[A-Za-z0-9]{16,}|sk-[A-Za-z0-9]{20,}/i.test(v)) return true;
300
+ if (credentials && (/[a-z][a-z0-9+.-]*:\/\/[^\s/?#]*@/i.test(v) || /(?:^|[?&])(?:apikey|api_key|access_token|token|key|sig|signature|secret|password)=/i.test(v))) return true;
301
+ // Within a run, a separator-free piece of >= 32 characters, or >= 16 mixing letters and digits, is key
302
+ // material; hyphenated words and paths ("some-long-path/another-segment") are not. Real npm package
303
+ // names are exempt from this long-run rule only, never from the key prefixes above.
304
+ if (packageName && /^(@[\w.-]+\/)?[\w.-]{1,214}$/.test(value)) return false;
305
+ return (v.match(/[A-Za-z0-9+/_=-]{40,}/g) || []).some(run => run.split(/[-/_+=]/)
306
+ .some(piece => piece.length >= 32 || (piece.length >= 16 && /[a-z]/i.test(piece) && /\d/.test(piece))));
307
+ });
308
+ }
309
+ // Screening warnings are collected (deduplicated) and printed by the CLI after the summary.
310
+ const screenWarnings = new Set();
311
+ export function takeScreenWarnings() { const list = [...screenWarnings]; screenWarnings.clear(); return list; }
312
+ export function screenString(value, field, warn = message => screenWarnings.add(message), packageName = false) {
313
+ if (typeof value !== 'string') return '';
314
+ if (!secretLike(value, packageName, false)) return value;
315
+ warn('Dropped ' + field + ': it looked like a key.');
316
+ return '';
317
+ }
243
318
  export function appName(root, explicit = '', url = '') {
244
319
  let host = ''; try { host = new URL(url).hostname; if (Object.keys(HOSTS).some(s => host.endsWith('.' + s))) host = host.split('.')[0]; } catch {}
245
- for (const candidate of [explicit, readJson(join(root, 'package.json'))?.name, host, basename(root)]) {
320
+ for (const candidate of [explicit, host, readJson(join(root, 'package.json'))?.name]) {
246
321
  const raw = String(candidate || '').trim();
247
- if (secretLike(raw)) continue;
322
+ if (!screenString(raw, 'name')) { if (raw) return ''; continue; }
248
323
  const n = raw.replace(/[^A-Za-z0-9 @/._-]/g, '').trim();
249
- if (n && !TEMPLATES.has(n.toLowerCase().replace(/_/g, '-')) && !secretLike(n) && !secretLike(n.slice(0, 80))) return n.slice(0, 80);
324
+ if (n && (!screenString(n, 'name') || !screenString(n.slice(0, 80), 'name'))) return '';
325
+ if (n && !TEMPLATES.has(n.toLowerCase().replace(/^@[^/]+\//, '').replace(/_/g, '-'))) return n.slice(0, 80);
250
326
  }
251
327
  return '';
252
328
  }
@@ -270,9 +346,11 @@ export function withHook(current = '') {
270
346
  }
271
347
 
272
348
  function publicUrl(given) {
349
+ if (!screenString(given, 'url')) return { url: '', cleaning: Boolean(given) };
273
350
  try {
274
351
  const u = new URL(String(given));
275
352
  if (!['http:', 'https:'].includes(u.protocol)) return { url: '', cleaning: false };
353
+ if (/sb_|svk_|eyJ/i.test(u.hostname)) return { url: '', cleaning: true };
276
354
  const keep = /^(\/[A-Za-z0-9._~-]{1,64}){0,8}\/?$/.test(u.pathname)
277
355
  && u.pathname.split('/').every(segment => segment.length <= 40 && !/sb_|svk_|eyJ/i.test(segment));
278
356
  return { url: u.origin + (keep && u.pathname !== '/' ? u.pathname : ''),
@@ -286,17 +364,23 @@ export function buildReport(root, opts = {}, procEnv = process.env) {
286
364
  const { packages, source, truncated } = readPackages(root);
287
365
  const url = normPublicUrl(opts.url || procEnv.SAFETYVIBE_URL || '');
288
366
  const sb = findSupabase(root, procEnv);
367
+ const host = sb.url ? screenString(new URL(sb.url).host, 'supabase host') || null : null;
368
+ const project_ref = host && /^([a-z0-9]{20})\.supabase\.co$/.exec(host)?.[1] || null;
369
+ const supabase = sb.warnings.length ? { project_ref, host, secret_key_exposed_client_side: true }
370
+ : sb.url && sb.key ? { project_ref, host, key_kind: sb.kind, key_fingerprint: createHash('sha256').update(sb.key, 'utf8').digest('hex').slice(0, 8) } : null;
289
371
  const rawEnvironment = String(opts.environment || procEnv.SAFETYVIBE_ENV || (procEnv.NODE_ENV === 'production' ? 'production' : ''));
290
372
  const environment = rawEnvironment.replace(/[^A-Za-z0-9_-]/g, '');
291
- return {
373
+ return Object.fromEntries(Object.entries({
292
374
  v: 1,
293
375
  name: appName(root, opts.name, url),
294
376
  url,
295
- platform: detectPlatform(root, procEnv, url),
296
- environment: [rawEnvironment, environment, environment.slice(0, 20)].some(secretLike) ? '' : environment.slice(0, 20),
377
+ platform: screenString(detectPlatform(root, procEnv, url), 'platform'),
378
+ environment: screenString(rawEnvironment, 'environment') && screenString(environment, 'environment') ? environment.slice(0, 20) : '',
297
379
  lockfile: source,
380
+ versions_source: "lockfile",
381
+ source: "connector",
298
382
  packages,
299
383
  ...(truncated ? { truncated: true } : {}),
300
- supabase: sb.url && sb.key ? { url: sb.url, key: sb.key, kind: sb.kind } : null,
301
- };
384
+ supabase,
385
+ }).filter(([, value]) => value !== ''));
302
386
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "safetyvibe-connect",
3
- "version": "0.2.1",
4
- "description": "SafetyVibe security monitoring for AI-built apps (Lovable, Replit, Bolt, Claude Code\u2026). Reports the app's dependency list, public URL and, if used, its Supabase project URL + public anon key. Never source code or secrets.",
3
+ "version": "0.3.0",
4
+ "description": "Checks your app's dependencies for known vulnerabilities and its public configuration for common mistakes. Sends the package list and public settings only; preview with --dry-run.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "safetyvibe-connect": "cli.mjs"
@@ -10,22 +10,26 @@
10
10
  "cli.mjs",
11
11
  "lib.mjs",
12
12
  "README.md",
13
- "AGENTS.md"
13
+ "AGENTS.md",
14
+ "LICENSE",
15
+ "SECURITY.md"
14
16
  ],
15
17
  "engines": {
16
18
  "node": ">=18"
17
19
  },
18
- "license": "UNLICENSED",
19
- "homepage": "https://safetyvibe.eu",
20
+ "license": "MIT",
21
+ "homepage": "https://safetyvibe.eu/connect",
20
22
  "keywords": [
21
23
  "security",
22
24
  "vulnerabilities",
23
- "lovable",
24
- "replit",
25
- "bolt",
26
- "supabase",
27
- "vibe-coding",
25
+ "dependencies",
26
+ "audit",
27
+ "osv",
28
28
  "safetyvibe"
29
29
  ],
30
- "author": "SafetyVibe <safetyvibe@advertis.ee>"
30
+ "author": "SafetyVibe <safetyvibe@advertis.ee>",
31
+ "bugs": {
32
+ "email": "safetyvibe@advertis.ee",
33
+ "url": "https://safetyvibe.eu/connect"
34
+ }
31
35
  }