safetyvibe-connect 0.2.2 → 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 +28 -17
- package/LICENSE +21 -0
- package/README.md +28 -17
- package/SECURITY.md +9 -0
- package/cli.mjs +68 -35
- package/lib.mjs +84 -27
- package/package.json +11 -5
package/AGENTS.md
CHANGED
|
@@ -1,25 +1,27 @@
|
|
|
1
1
|
# SafetyVibe Connect instructions
|
|
2
2
|
|
|
3
|
-
SafetyVibe Connect reports your package list so we can warn you about vulnerable dependencies
|
|
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.
|
|
12
|
+
npx -y safetyvibe-connect@0.3.0 setup --no-hook --claim-token <TOKEN>
|
|
11
13
|
# pnpm
|
|
12
|
-
pnpm dlx safetyvibe-connect@0.
|
|
14
|
+
pnpm dlx safetyvibe-connect@0.3.0 setup --no-hook --claim-token <TOKEN>
|
|
13
15
|
# bun
|
|
14
|
-
bunx safetyvibe-connect@0.
|
|
16
|
+
bunx safetyvibe-connect@0.3.0 setup --no-hook --claim-token <TOKEN>
|
|
15
17
|
# yarn
|
|
16
|
-
yarn dlx safetyvibe-connect@0.
|
|
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.
|
|
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
|
|
@@ -27,10 +29,14 @@ Optional installation commands: `npm install -D safetyvibe-connect`, `pnpm add -
|
|
|
27
29
|
Preview the report before sending it:
|
|
28
30
|
|
|
29
31
|
```sh
|
|
30
|
-
npx -y safetyvibe-connect@0.
|
|
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
|
|
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
|
|
@@ -40,8 +46,10 @@ Example (optional `truncated` appears only when the inventory exceeds 5000):
|
|
|
40
46
|
"url": "https://notes.example.com",
|
|
41
47
|
"environment": "production",
|
|
42
48
|
"lockfile": "package-lock.json",
|
|
49
|
+
"versions_source": "lockfile",
|
|
50
|
+
"source": "connector",
|
|
43
51
|
"packages": [{ "ecosystem": "npm", "name": "vite", "version": "6.0.0", "dev": true }],
|
|
44
|
-
"supabase": { "
|
|
52
|
+
"supabase": { "project_ref": "abcdefghijklmnopqrst", "host": "abcdefghijklmnopqrst.supabase.co", "key_kind": "anon", "key_fingerprint": "12ab34cd" }
|
|
45
53
|
}
|
|
46
54
|
```
|
|
47
55
|
|
|
@@ -53,25 +61,28 @@ Example (optional `truncated` appears only when the inventory exceeds 5000):
|
|
|
53
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.
|
|
54
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.
|
|
55
63
|
- `truncated`: optional boolean; only the first 5000 packages are sent.
|
|
56
|
-
- `
|
|
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.
|
|
66
|
+
|
|
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.
|
|
57
68
|
|
|
58
|
-
Never sent: source code, file paths, full lockfiles, .env files, Supabase
|
|
59
|
-
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.
|
|
60
|
-
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. Local
|
|
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.
|
|
61
72
|
|
|
62
|
-
Review the entire implementation in `cli.mjs` and `lib.mjs` (approximately
|
|
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.
|
|
63
74
|
By default reports go only to `https://dashboard.safetyvibe.eu/connect/api/v1/report`. The committed rc cannot change the destination.
|
|
64
75
|
An explicit `SAFETYVIBE_API` environment override supports HTTPS, or HTTP on localhost/127.0.0.1/[::1] for local testing. Redirects are refused.
|
|
65
76
|
|
|
66
77
|
## Changes
|
|
67
78
|
|
|
68
|
-
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.
|
|
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.
|
|
69
80
|
|
|
70
81
|
[Documentation](https://safetyvibe.eu/connect) and [changelog / what is sent](https://safetyvibe.eu/connect/changelog).
|
|
71
82
|
|
|
72
83
|
## Files written and removal
|
|
73
84
|
|
|
74
|
-
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
|
|
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:
|
|
75
86
|
|
|
76
87
|
- `.safetyviberc.json`: app ID, normalized public URL, optional name and `hook: false`.
|
|
77
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.
|
|
@@ -92,7 +103,7 @@ Remove the connector dependency if installed, its postinstall command, both rc f
|
|
|
92
103
|
| Command | Effect |
|
|
93
104
|
|---|---|
|
|
94
105
|
| `setup [--claim-token T] [--url URL] [--name NAME] [--no-hook]` | Send report, save config, optionally add hook |
|
|
95
|
-
| `setup --dry-run` or `--dry-run` | Print exact report without network
|
|
106
|
+
| `setup --dry-run` or `--dry-run` | Print exact report without network; only --out writes a file |
|
|
96
107
|
| `scan [--quiet]` | Re-report; always exits 0 for install hooks |
|
|
97
108
|
| `status` | Show local link state |
|
|
98
109
|
| `help`, `--help`, `-h`, no arguments | Usage only |
|
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,25 +1,27 @@
|
|
|
1
1
|
# safetyvibe-connect
|
|
2
2
|
|
|
3
|
-
SafetyVibe Connect reports your package list so we can warn you about vulnerable dependencies
|
|
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.
|
|
12
|
+
npx -y safetyvibe-connect@0.3.0 setup --no-hook --claim-token <TOKEN>
|
|
11
13
|
# pnpm
|
|
12
|
-
pnpm dlx safetyvibe-connect@0.
|
|
14
|
+
pnpm dlx safetyvibe-connect@0.3.0 setup --no-hook --claim-token <TOKEN>
|
|
13
15
|
# bun
|
|
14
|
-
bunx safetyvibe-connect@0.
|
|
16
|
+
bunx safetyvibe-connect@0.3.0 setup --no-hook --claim-token <TOKEN>
|
|
15
17
|
# yarn
|
|
16
|
-
yarn dlx safetyvibe-connect@0.
|
|
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.
|
|
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
|
|
@@ -27,10 +29,14 @@ Optional installation commands: `npm install -D safetyvibe-connect`, `pnpm add -
|
|
|
27
29
|
Preview the report before sending it:
|
|
28
30
|
|
|
29
31
|
```sh
|
|
30
|
-
npx -y safetyvibe-connect@0.
|
|
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
|
|
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
|
|
@@ -40,8 +46,10 @@ Example (optional `truncated` appears only when the inventory exceeds 5000):
|
|
|
40
46
|
"url": "https://notes.example.com",
|
|
41
47
|
"environment": "production",
|
|
42
48
|
"lockfile": "package-lock.json",
|
|
49
|
+
"versions_source": "lockfile",
|
|
50
|
+
"source": "connector",
|
|
43
51
|
"packages": [{ "ecosystem": "npm", "name": "vite", "version": "6.0.0", "dev": true }],
|
|
44
|
-
"supabase": { "
|
|
52
|
+
"supabase": { "project_ref": "abcdefghijklmnopqrst", "host": "abcdefghijklmnopqrst.supabase.co", "key_kind": "anon", "key_fingerprint": "12ab34cd" }
|
|
45
53
|
}
|
|
46
54
|
```
|
|
47
55
|
|
|
@@ -53,25 +61,28 @@ Example (optional `truncated` appears only when the inventory exceeds 5000):
|
|
|
53
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.
|
|
54
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.
|
|
55
63
|
- `truncated`: optional boolean; only the first 5000 packages are sent.
|
|
56
|
-
- `
|
|
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.
|
|
66
|
+
|
|
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.
|
|
57
68
|
|
|
58
|
-
Never sent: source code, file paths, full lockfiles, .env files, Supabase
|
|
59
|
-
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.
|
|
60
|
-
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. Local
|
|
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.
|
|
61
72
|
|
|
62
|
-
Review the entire implementation in `cli.mjs` and `lib.mjs` (approximately
|
|
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.
|
|
63
74
|
By default reports go only to `https://dashboard.safetyvibe.eu/connect/api/v1/report`. The committed rc cannot change the destination.
|
|
64
75
|
An explicit `SAFETYVIBE_API` environment override supports HTTPS, or HTTP on localhost/127.0.0.1/[::1] for local testing. Redirects are refused.
|
|
65
76
|
|
|
66
77
|
## Changes
|
|
67
78
|
|
|
68
|
-
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.
|
|
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.
|
|
69
80
|
|
|
70
81
|
[Documentation](https://safetyvibe.eu/connect) and [changelog / what is sent](https://safetyvibe.eu/connect/changelog).
|
|
71
82
|
|
|
72
83
|
## Files written and removal
|
|
73
84
|
|
|
74
|
-
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
|
|
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:
|
|
75
86
|
|
|
76
87
|
- `.safetyviberc.json`: app ID, normalized public URL, optional name and `hook: false`.
|
|
77
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.
|
|
@@ -92,7 +103,7 @@ Remove the connector dependency if installed, its postinstall command, both rc f
|
|
|
92
103
|
| Command | Effect |
|
|
93
104
|
|---|---|
|
|
94
105
|
| `setup [--claim-token T] [--url URL] [--name NAME] [--no-hook]` | Send report, save config, optionally add hook |
|
|
95
|
-
| `setup --dry-run` or `--dry-run` | Print exact report without network
|
|
106
|
+
| `setup --dry-run` or `--dry-run` | Print exact report without network; only --out writes a file |
|
|
96
107
|
| `scan [--quiet]` | Re-report; always exits 0 for install hooks |
|
|
97
108
|
| `status` | Show local link state |
|
|
98
109
|
| `help`, `--help`, `-h`, no arguments | Usage only |
|
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) {
|
|
@@ -47,9 +47,13 @@ function workspaceRoot(dir) {
|
|
|
47
47
|
try { return /^packages\s*:/m.test(readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8')); } catch { return false; }
|
|
48
48
|
}
|
|
49
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']) console.log(...args); };
|
|
51
|
-
const
|
|
52
|
-
const
|
|
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'; };
|
|
53
57
|
const readJson = (file) => { try { return JSON.parse(readFileSync(join(root, file), 'utf8')) || {}; } catch { return {}; } };
|
|
54
58
|
function readRc() {
|
|
55
59
|
const pub = readJson(RC); const local = readJson(LOCAL);
|
|
@@ -94,6 +98,27 @@ function apiEndpoint() {
|
|
|
94
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');
|
|
95
99
|
return u.href.replace(/\/+$/, '') + '/report';
|
|
96
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
|
+
}
|
|
97
122
|
async function report() {
|
|
98
123
|
const setup = cmd === 'setup';
|
|
99
124
|
if (!setup && !flags['dry-run'] && workspaceRoot(root) && !readRc().site) { say('SafetyVibe: workspace root; run setup in an app directory.'); return 0; }
|
|
@@ -106,7 +131,7 @@ async function report() {
|
|
|
106
131
|
const givenUrl = flags.url || process.env.SAFETYVIBE_URL || rc.url || '';
|
|
107
132
|
const url = normPublicUrl(givenUrl);
|
|
108
133
|
if (urlNeedsCleaning(givenUrl)) alert('Removed credentials/query/fragment or unsafe path from URL; only the public URL is stored and sent');
|
|
109
|
-
const body = buildReport(root, { url, name: flags.name || rc.name });
|
|
134
|
+
const body = buildReport(root, { url: givenUrl, name: flags.name || rc.name });
|
|
110
135
|
const headers = { 'content-type': 'application/json', 'user-agent': 'safetyvibe-connect/' + VERSION };
|
|
111
136
|
const rawKey = rc.key || process.env.SAFETYVIBE_KEY || '';
|
|
112
137
|
const key = typeof rawKey === 'string' && REPORT_KEY.test(rawKey) ? rawKey : '';
|
|
@@ -114,13 +139,22 @@ async function report() {
|
|
|
114
139
|
if (typeof rc.site === 'string' && UUID.test(rc.site)) headers['x-safetyvibe-site'] = rc.site;
|
|
115
140
|
if (rc.site && key) headers['x-safetyvibe-key'] = key;
|
|
116
141
|
if (token) headers['x-safetyvibe-claim-token'] = token;
|
|
117
|
-
for (const
|
|
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.`);
|
|
118
143
|
const lockState = readPackages(root);
|
|
119
|
-
if (lockState.noLockfile && !flags.quiet)
|
|
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.'
|
|
120
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; }
|
|
121
148
|
if (flags['dry-run']) {
|
|
122
|
-
|
|
123
|
-
|
|
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);
|
|
124
158
|
return 0;
|
|
125
159
|
}
|
|
126
160
|
if (!setup && !rc.site && !key && !token && !installed(root)) {
|
|
@@ -148,7 +182,7 @@ async function report() {
|
|
|
148
182
|
const deadline = new Promise((_, reject) => { timer = setTimeout(() => { cancel(); reject(new Error('deadline')); }, timeout); });
|
|
149
183
|
try {
|
|
150
184
|
const work = (async () => {
|
|
151
|
-
response = await fetch(destination, { method: 'POST', headers, body:
|
|
185
|
+
response = await fetch(destination, { method: 'POST', headers, body: json, signal: ctl.signal, redirect: 'error' });
|
|
152
186
|
reader = response.body?.getReader();
|
|
153
187
|
const chunks = []; let size = 0;
|
|
154
188
|
if (reader) while (true) {
|
|
@@ -160,7 +194,7 @@ async function report() {
|
|
|
160
194
|
})();
|
|
161
195
|
work.catch(() => {}); // a late rejection after the deadline must not become an unhandled rejection
|
|
162
196
|
await Promise.race([deadline, work]);
|
|
163
|
-
} catch { alert('SafetyVibe: could not reach ' + new URL(destination).host + '.'); return 1; }
|
|
197
|
+
} catch { alert('SafetyVibe: could not reach ' + safeText(new URL(destination).host) + '.'); return 1; }
|
|
164
198
|
finally { clearTimeout(timer); }
|
|
165
199
|
if (typeof b?.warning === 'string') alert('SafetyVibe: ' + safeText(b.warning));
|
|
166
200
|
if (!response.ok) {
|
|
@@ -175,8 +209,16 @@ async function report() {
|
|
|
175
209
|
return 1;
|
|
176
210
|
}
|
|
177
211
|
if (!b || typeof b !== 'object' || Array.isArray(b) || typeof b.site !== 'string' || !UUID.test(b.site)) {
|
|
178
|
-
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;
|
|
179
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'));
|
|
180
222
|
const next = { ...rc, site: b.site, url, ...(flags.name ? { name: body.name } : {}), ...(flags['no-hook'] ? { hook: false } : {}) };
|
|
181
223
|
if (typeof b.key === 'string' && REPORT_KEY.test(b.key)) next.key = b.key;
|
|
182
224
|
else if (b.key) alert('Invalid report key in server response.');
|
|
@@ -187,28 +229,19 @@ async function report() {
|
|
|
187
229
|
return 1;
|
|
188
230
|
}
|
|
189
231
|
const inventory = lockState;
|
|
190
|
-
say(`SafetyVibe: reported ${body.packages.length} packages (${body.packages.filter(p => p.dev).length} dev-only).`);
|
|
191
232
|
if (body.truncated) alert(`Only the first 5000 of ${inventory.total} packages were reported.`);
|
|
233
|
+
let followup = '';
|
|
192
234
|
if (setup && next.hook !== false) {
|
|
193
235
|
const hook = addHook();
|
|
194
236
|
if (hook === 'could not write package.json') { alert('SafetyVibe: report sent; app id ' + b.site + '; could not write package.json.'); return 1; }
|
|
195
|
-
|
|
196
|
-
} else if (setup)
|
|
197
|
-
if (
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
try {
|
|
201
|
-
if (typeof b.claimUrl === 'string' && !/[\x00-\x1f\x7f-\x9f]/.test(b.claimUrl)) {
|
|
202
|
-
const u = new URL(b.claimUrl);
|
|
203
|
-
if (u.protocol === 'https:' && !u.username && !u.password && u.origin === new URL(destination).origin) claim = u.href;
|
|
204
|
-
}
|
|
205
|
-
} catch {}
|
|
206
|
-
say(claim ? 'SafetyVibe: open this link to add the app to your dashboard: ' + claim : 'SafetyVibe: open your SafetyVibe dashboard');
|
|
207
|
-
}
|
|
208
|
-
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;
|
|
209
242
|
}
|
|
210
243
|
async function main() {
|
|
211
|
-
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; }
|
|
212
245
|
return report();
|
|
213
246
|
}
|
|
214
|
-
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,14 +1,18 @@
|
|
|
1
1
|
// safetyvibe-connect - pure helpers (no network, no writes). Tested in connect.test.mjs.
|
|
2
|
-
// Egress = buildReport() body
|
|
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
|
|
7
|
+
// (https, or http on localhost); redirects refused. Never source code or any Supabase keys.
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
5
9
|
import { readFileSync, existsSync, statSync } from 'node:fs';
|
|
6
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; } };
|
|
11
|
-
const semver = v => typeof v === 'string' && /^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/.test(v);
|
|
15
|
+
const semver = v => typeof v === 'string' && !!screenString(v, 'package version') && /^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/.test(v);
|
|
12
16
|
function registryTarball(value) {
|
|
13
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; }
|
|
14
18
|
}
|
|
@@ -143,7 +147,9 @@ export function readPackages(root) {
|
|
|
143
147
|
for (const p of list) {
|
|
144
148
|
const k = p.name + '@' + p.version;
|
|
145
149
|
// Registry versions only. git/file/link/http specs can carry tokens or local paths: never sent.
|
|
146
|
-
|
|
150
|
+
const safeName = screenString(p.name, 'package name', undefined, true);
|
|
151
|
+
const safeVersion = screenString(p.version, 'package version');
|
|
152
|
+
if (!safeName || !safeVersion) continue;
|
|
147
153
|
if (!/^(@[\w.-]+\/)?[\w.-]+$/.test(p.name) || !/^\d+\.\d+\.\d+[\w.+-]{0,40}$/.test(String(p.version))) continue;
|
|
148
154
|
if (seen.has(k)) { if (p.dev !== true) delete seen.get(k).dev; continue; }
|
|
149
155
|
const row = { ecosystem: 'npm', name: p.name, version: p.version, ...(p.dev === true ? { dev: true } : {}) };
|
|
@@ -205,6 +211,7 @@ export function parseEnv(text) {
|
|
|
205
211
|
const CLIENT_PREFIXES = ['VITE_', 'NEXT_PUBLIC_', 'EXPO_PUBLIC_', 'PUBLIC_', 'REACT_APP_', 'NUXT_PUBLIC_'];
|
|
206
212
|
const URL_VARS = CLIENT_PREFIXES.map(p => p + 'SUPABASE_URL');
|
|
207
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');
|
|
208
215
|
|
|
209
216
|
// Only clearly client-side source files: what the browser bundle already ships to every visitor.
|
|
210
217
|
const SERVER_PATH = /(^|\/)(server|api|functions)\/|(^|\/)app\/api\/|(^|\/)pages\/api\/|(^|\/)supabase\/functions\/|(^|\/)netlify\/functions\/|(^|\/)middleware\.[cm]?[jt]sx?$|\.server\.[cm]?[jt]sx?$/i;
|
|
@@ -215,29 +222,36 @@ export function clientSource(path, text) {
|
|
|
215
222
|
return !/['"`]server-only['"`]/.test(text) && !/['"`]use server['"`]/.test(text);
|
|
216
223
|
}
|
|
217
224
|
export function findSupabase(root, procEnv = process.env) {
|
|
218
|
-
const env = {};
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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];
|
|
234
248
|
}
|
|
235
249
|
}
|
|
236
250
|
if (!/^https:\/\/[a-z0-9-]+\.supabase\.(co|in)\/?$/i.test(url)) url = '';
|
|
237
251
|
const kind = key ? keyKind(key) : 'none';
|
|
238
252
|
// Never send anything that isn't provably a public key.
|
|
239
253
|
if (!publicKeyKind(key)) key = '';
|
|
240
|
-
return { url: url.replace(/\/$/, ''), key, kind, warnings };
|
|
254
|
+
return { url: url.replace(/\/$/, ''), key, kind, warnings: [...warnings] };
|
|
241
255
|
}
|
|
242
256
|
|
|
243
257
|
// ---- app identity ------------------------------------------------------------
|
|
@@ -265,14 +279,50 @@ export function detectPlatform(root, procEnv = process.env, url = '') {
|
|
|
265
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(' '));
|
|
266
280
|
TEMPLATES.add('');
|
|
267
281
|
// Real JWTs start with eyJ (base64 of {"); plain dotted names like notes.example.com stay allowed.
|
|
268
|
-
|
|
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
|
+
}
|
|
269
318
|
export function appName(root, explicit = '', url = '') {
|
|
270
319
|
let host = ''; try { host = new URL(url).hostname; if (Object.keys(HOSTS).some(s => host.endsWith('.' + s))) host = host.split('.')[0]; } catch {}
|
|
271
320
|
for (const candidate of [explicit, host, readJson(join(root, 'package.json'))?.name]) {
|
|
272
321
|
const raw = String(candidate || '').trim();
|
|
273
|
-
if (
|
|
322
|
+
if (!screenString(raw, 'name')) { if (raw) return ''; continue; }
|
|
274
323
|
const n = raw.replace(/[^A-Za-z0-9 @/._-]/g, '').trim();
|
|
275
|
-
if (n && !
|
|
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);
|
|
276
326
|
}
|
|
277
327
|
return '';
|
|
278
328
|
}
|
|
@@ -296,6 +346,7 @@ export function withHook(current = '') {
|
|
|
296
346
|
}
|
|
297
347
|
|
|
298
348
|
function publicUrl(given) {
|
|
349
|
+
if (!screenString(given, 'url')) return { url: '', cleaning: Boolean(given) };
|
|
299
350
|
try {
|
|
300
351
|
const u = new URL(String(given));
|
|
301
352
|
if (!['http:', 'https:'].includes(u.protocol)) return { url: '', cleaning: false };
|
|
@@ -313,17 +364,23 @@ export function buildReport(root, opts = {}, procEnv = process.env) {
|
|
|
313
364
|
const { packages, source, truncated } = readPackages(root);
|
|
314
365
|
const url = normPublicUrl(opts.url || procEnv.SAFETYVIBE_URL || '');
|
|
315
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;
|
|
316
371
|
const rawEnvironment = String(opts.environment || procEnv.SAFETYVIBE_ENV || (procEnv.NODE_ENV === 'production' ? 'production' : ''));
|
|
317
372
|
const environment = rawEnvironment.replace(/[^A-Za-z0-9_-]/g, '');
|
|
318
373
|
return Object.fromEntries(Object.entries({
|
|
319
374
|
v: 1,
|
|
320
375
|
name: appName(root, opts.name, url),
|
|
321
376
|
url,
|
|
322
|
-
platform: detectPlatform(root, procEnv, url),
|
|
323
|
-
environment:
|
|
377
|
+
platform: screenString(detectPlatform(root, procEnv, url), 'platform'),
|
|
378
|
+
environment: screenString(rawEnvironment, 'environment') && screenString(environment, 'environment') ? environment.slice(0, 20) : '',
|
|
324
379
|
lockfile: source,
|
|
380
|
+
versions_source: "lockfile",
|
|
381
|
+
source: "connector",
|
|
325
382
|
packages,
|
|
326
383
|
...(truncated ? { truncated: true } : {}),
|
|
327
|
-
supabase
|
|
384
|
+
supabase,
|
|
328
385
|
}).filter(([, value]) => value !== ''));
|
|
329
386
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safetyvibe-connect",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
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": {
|
|
@@ -10,13 +10,15 @@
|
|
|
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": "
|
|
19
|
-
"homepage": "https://safetyvibe.eu",
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"homepage": "https://safetyvibe.eu/connect",
|
|
20
22
|
"keywords": [
|
|
21
23
|
"security",
|
|
22
24
|
"vulnerabilities",
|
|
@@ -25,5 +27,9 @@
|
|
|
25
27
|
"osv",
|
|
26
28
|
"safetyvibe"
|
|
27
29
|
],
|
|
28
|
-
"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
|
+
}
|
|
29
35
|
}
|