safetyvibe-connect 0.2.1 → 0.2.2
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 +31 -22
- package/README.md +30 -21
- package/cli.mjs +40 -9
- package/lib.mjs +80 -53
- package/package.json +5 -7
package/AGENTS.md
CHANGED
|
@@ -1,33 +1,33 @@
|
|
|
1
|
-
#
|
|
1
|
+
# SafetyVibe Connect instructions
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
SafetyVibe Connect reports your package list so we can warn you about vulnerable dependencies and, if the app uses Supabase, the public project URL and public anon/publishable key already shipped to every visitor, so we can check the public configuration. Requires Node >=18; no dependencies or package install scripts.
|
|
4
4
|
|
|
5
5
|
## Run without installing
|
|
6
6
|
|
|
7
7
|
Get a claim token from the dashboard, then run:
|
|
8
8
|
|
|
9
9
|
```sh
|
|
10
|
-
npx -y safetyvibe-connect@0.2.
|
|
10
|
+
npx -y safetyvibe-connect@0.2.2 setup --no-hook --claim-token <TOKEN>
|
|
11
11
|
# pnpm
|
|
12
|
-
pnpm dlx safetyvibe-connect@0.2.
|
|
12
|
+
pnpm dlx safetyvibe-connect@0.2.2 setup --no-hook --claim-token <TOKEN>
|
|
13
13
|
# bun
|
|
14
|
-
bunx safetyvibe-connect@0.2.
|
|
14
|
+
bunx safetyvibe-connect@0.2.2 setup --no-hook --claim-token <TOKEN>
|
|
15
15
|
# yarn
|
|
16
|
-
yarn dlx safetyvibe-connect@0.2.
|
|
16
|
+
yarn dlx safetyvibe-connect@0.2.2 setup --no-hook --claim-token <TOKEN>
|
|
17
17
|
```
|
|
18
18
|
|
|
19
19
|
Add `--url <published URL>` if known. Without a token, setup prints a claim link.
|
|
20
20
|
On Replit/pnpm projects, `npm install` can fail with "Cannot read properties of null (reading 'isDescendantOf')".
|
|
21
21
|
Use `pnpm add -D safetyvibe-connect`, or run without installing:
|
|
22
|
-
`npx -y safetyvibe-connect@0.2.
|
|
22
|
+
`npx -y safetyvibe-connect@0.2.2 setup --no-hook --claim-token <TOKEN>`.
|
|
23
23
|
Optional installation commands: `npm install -D safetyvibe-connect`, `pnpm add -D safetyvibe-connect`, `bun add -d safetyvibe-connect`, `yarn add -D safetyvibe-connect`.
|
|
24
24
|
|
|
25
25
|
## Exactly what is sent
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
Preview the report before sending it:
|
|
28
28
|
|
|
29
29
|
```sh
|
|
30
|
-
npx -y safetyvibe-connect@0.2.
|
|
30
|
+
npx -y safetyvibe-connect@0.2.2 setup --dry-run --url https://notes.example.com
|
|
31
31
|
```
|
|
32
32
|
|
|
33
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.
|
|
@@ -36,9 +36,8 @@ Example (optional `truncated` appears only when the inventory exceeds 5000):
|
|
|
36
36
|
```json
|
|
37
37
|
{
|
|
38
38
|
"v": 1,
|
|
39
|
-
"name": "notes",
|
|
39
|
+
"name": "notes.example.com",
|
|
40
40
|
"url": "https://notes.example.com",
|
|
41
|
-
"platform": "Lovable",
|
|
42
41
|
"environment": "production",
|
|
43
42
|
"lockfile": "package-lock.json",
|
|
44
43
|
"packages": [{ "ecosystem": "npm", "name": "vite", "version": "6.0.0", "dev": true }],
|
|
@@ -47,23 +46,29 @@ Example (optional `truncated` appears only when the inventory exceeds 5000):
|
|
|
47
46
|
```
|
|
48
47
|
|
|
49
48
|
- `v`: report format version.
|
|
50
|
-
- `name`: explicit
|
|
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;
|
|
52
|
-
- `platform`: detected project builder/hosting label;
|
|
53
|
-
- `environment`: `SAFETYVIBE_ENV`, or production when `NODE_ENV=production`; limited to 20 letters, digits, underscores or hyphens;
|
|
54
|
-
- `lockfile`: selected lockfile filename,
|
|
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.
|
|
49
|
+
- `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.
|
|
50
|
+
- `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.
|
|
51
|
+
- `platform`: detected project builder/hosting label; omitted when unknown.
|
|
52
|
+
- `environment`: `SAFETYVIBE_ENV`, or production when `NODE_ENV=production`; limited to 20 letters, digits, underscores or hyphens; omitted when unknown.
|
|
53
|
+
- `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
|
+
- `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
55
|
- `truncated`: optional boolean; only the first 5000 packages are sent.
|
|
57
56
|
- `supabase`: public project URL, strictly validated public key and kind (`publishable` or `anon`); otherwise null.
|
|
58
57
|
|
|
59
|
-
Never sent: source code, file paths
|
|
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
|
|
61
|
-
Public keys are
|
|
58
|
+
Never sent: source code, file paths, full lockfiles, .env files, Supabase secret/service keys, URL credentials or query strings.
|
|
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 key warnings name only the variable.
|
|
62
61
|
|
|
63
|
-
Review the entire implementation in `cli.mjs` and `lib.mjs` (approximately
|
|
62
|
+
Review the entire implementation in `cli.mjs` and `lib.mjs` (approximately 543 lines total). There are no dependencies and no install scripts in this package itself.
|
|
64
63
|
By default reports go only to `https://dashboard.safetyvibe.eu/connect/api/v1/report`. The committed rc cannot change the destination.
|
|
65
64
|
An explicit `SAFETYVIBE_API` environment override supports HTTPS, or HTTP on localhost/127.0.0.1/[::1] for local testing. Redirects are refused.
|
|
66
65
|
|
|
66
|
+
## Changes
|
|
67
|
+
|
|
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.
|
|
69
|
+
|
|
70
|
+
[Documentation](https://safetyvibe.eu/connect) and [changelog / what is sent](https://safetyvibe.eu/connect/changelog).
|
|
71
|
+
|
|
67
72
|
## Files written and removal
|
|
68
73
|
|
|
69
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, status and help never write. After a successful report:
|
|
@@ -73,6 +78,8 @@ Before a setup/scan request, legacy credentials are migrated best-effort into th
|
|
|
73
78
|
- `.gitignore`: adds the local config filename.
|
|
74
79
|
- `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
80
|
|
|
81
|
+
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.
|
|
82
|
+
|
|
76
83
|
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
84
|
|
|
78
85
|
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 +92,12 @@ Remove the connector dependency if installed, its postinstall command, both rc f
|
|
|
85
92
|
| Command | Effect |
|
|
86
93
|
|---|---|
|
|
87
94
|
| `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 |
|
|
95
|
+
| `setup --dry-run` or `--dry-run` | Print exact report without network or writes |
|
|
89
96
|
| `scan [--quiet]` | Re-report; always exits 0 for install hooks |
|
|
90
97
|
| `status` | Show local link state |
|
|
91
98
|
| `help`, `--help`, `-h`, no arguments | Usage only |
|
|
92
99
|
| `--version`, `-v` | Package version only |
|
|
93
100
|
|
|
101
|
+
Flags without a command imply setup. Unknown name, URL, platform and environment fields are omitted, never empty strings.
|
|
102
|
+
|
|
94
103
|
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/README.md
CHANGED
|
@@ -1,33 +1,33 @@
|
|
|
1
1
|
# safetyvibe-connect
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
SafetyVibe Connect reports your package list so we can warn you about vulnerable dependencies and, if the app uses Supabase, the public project URL and public anon/publishable key already shipped to every visitor, so we can check the public configuration. Requires Node >=18; no dependencies or package install scripts.
|
|
4
4
|
|
|
5
5
|
## Run without installing
|
|
6
6
|
|
|
7
7
|
Get a claim token from the dashboard, then run:
|
|
8
8
|
|
|
9
9
|
```sh
|
|
10
|
-
npx -y safetyvibe-connect@0.2.
|
|
10
|
+
npx -y safetyvibe-connect@0.2.2 setup --no-hook --claim-token <TOKEN>
|
|
11
11
|
# pnpm
|
|
12
|
-
pnpm dlx safetyvibe-connect@0.2.
|
|
12
|
+
pnpm dlx safetyvibe-connect@0.2.2 setup --no-hook --claim-token <TOKEN>
|
|
13
13
|
# bun
|
|
14
|
-
bunx safetyvibe-connect@0.2.
|
|
14
|
+
bunx safetyvibe-connect@0.2.2 setup --no-hook --claim-token <TOKEN>
|
|
15
15
|
# yarn
|
|
16
|
-
yarn dlx safetyvibe-connect@0.2.
|
|
16
|
+
yarn dlx safetyvibe-connect@0.2.2 setup --no-hook --claim-token <TOKEN>
|
|
17
17
|
```
|
|
18
18
|
|
|
19
19
|
Add `--url <published URL>` if known. Without a token, setup prints a claim link.
|
|
20
20
|
On Replit/pnpm projects, `npm install` can fail with "Cannot read properties of null (reading 'isDescendantOf')".
|
|
21
21
|
Use `pnpm add -D safetyvibe-connect`, or run without installing:
|
|
22
|
-
`npx -y safetyvibe-connect@0.2.
|
|
22
|
+
`npx -y safetyvibe-connect@0.2.2 setup --no-hook --claim-token <TOKEN>`.
|
|
23
23
|
Optional installation commands: `npm install -D safetyvibe-connect`, `pnpm add -D safetyvibe-connect`, `bun add -d safetyvibe-connect`, `yarn add -D safetyvibe-connect`.
|
|
24
24
|
|
|
25
25
|
## Exactly what is sent
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
Preview the report before sending it:
|
|
28
28
|
|
|
29
29
|
```sh
|
|
30
|
-
npx -y safetyvibe-connect@0.2.
|
|
30
|
+
npx -y safetyvibe-connect@0.2.2 setup --dry-run --url https://notes.example.com
|
|
31
31
|
```
|
|
32
32
|
|
|
33
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.
|
|
@@ -36,9 +36,8 @@ Example (optional `truncated` appears only when the inventory exceeds 5000):
|
|
|
36
36
|
```json
|
|
37
37
|
{
|
|
38
38
|
"v": 1,
|
|
39
|
-
"name": "notes",
|
|
39
|
+
"name": "notes.example.com",
|
|
40
40
|
"url": "https://notes.example.com",
|
|
41
|
-
"platform": "Lovable",
|
|
42
41
|
"environment": "production",
|
|
43
42
|
"lockfile": "package-lock.json",
|
|
44
43
|
"packages": [{ "ecosystem": "npm", "name": "vite", "version": "6.0.0", "dev": true }],
|
|
@@ -47,23 +46,29 @@ Example (optional `truncated` appears only when the inventory exceeds 5000):
|
|
|
47
46
|
```
|
|
48
47
|
|
|
49
48
|
- `v`: report format version.
|
|
50
|
-
- `name`: explicit
|
|
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;
|
|
52
|
-
- `platform`: detected project builder/hosting label;
|
|
53
|
-
- `environment`: `SAFETYVIBE_ENV`, or production when `NODE_ENV=production`; limited to 20 letters, digits, underscores or hyphens;
|
|
54
|
-
- `lockfile`: selected lockfile filename,
|
|
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.
|
|
49
|
+
- `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.
|
|
50
|
+
- `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.
|
|
51
|
+
- `platform`: detected project builder/hosting label; omitted when unknown.
|
|
52
|
+
- `environment`: `SAFETYVIBE_ENV`, or production when `NODE_ENV=production`; limited to 20 letters, digits, underscores or hyphens; omitted when unknown.
|
|
53
|
+
- `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
|
+
- `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
55
|
- `truncated`: optional boolean; only the first 5000 packages are sent.
|
|
57
56
|
- `supabase`: public project URL, strictly validated public key and kind (`publishable` or `anon`); otherwise null.
|
|
58
57
|
|
|
59
|
-
Never sent: source code, file paths
|
|
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
|
|
61
|
-
Public keys are
|
|
58
|
+
Never sent: source code, file paths, full lockfiles, .env files, Supabase secret/service keys, URL credentials or query strings.
|
|
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 key warnings name only the variable.
|
|
62
61
|
|
|
63
|
-
Review the entire implementation in `cli.mjs` and `lib.mjs` (approximately
|
|
62
|
+
Review the entire implementation in `cli.mjs` and `lib.mjs` (approximately 543 lines total). There are no dependencies and no install scripts in this package itself.
|
|
64
63
|
By default reports go only to `https://dashboard.safetyvibe.eu/connect/api/v1/report`. The committed rc cannot change the destination.
|
|
65
64
|
An explicit `SAFETYVIBE_API` environment override supports HTTPS, or HTTP on localhost/127.0.0.1/[::1] for local testing. Redirects are refused.
|
|
66
65
|
|
|
66
|
+
## Changes
|
|
67
|
+
|
|
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.
|
|
69
|
+
|
|
70
|
+
[Documentation](https://safetyvibe.eu/connect) and [changelog / what is sent](https://safetyvibe.eu/connect/changelog).
|
|
71
|
+
|
|
67
72
|
## Files written and removal
|
|
68
73
|
|
|
69
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, status and help never write. After a successful report:
|
|
@@ -73,6 +78,8 @@ Before a setup/scan request, legacy credentials are migrated best-effort into th
|
|
|
73
78
|
- `.gitignore`: adds the local config filename.
|
|
74
79
|
- `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
80
|
|
|
81
|
+
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.
|
|
82
|
+
|
|
76
83
|
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
84
|
|
|
78
85
|
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 +92,12 @@ Remove the connector dependency if installed, its postinstall command, both rc f
|
|
|
85
92
|
| Command | Effect |
|
|
86
93
|
|---|---|
|
|
87
94
|
| `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 |
|
|
95
|
+
| `setup --dry-run` or `--dry-run` | Print exact report without network or writes |
|
|
89
96
|
| `scan [--quiet]` | Re-report; always exits 0 for install hooks |
|
|
90
97
|
| `status` | Show local link state |
|
|
91
98
|
| `help`, `--help`, `-h`, no arguments | Usage only |
|
|
92
99
|
| `--version`, `-v` | Package version only |
|
|
93
100
|
|
|
101
|
+
Flags without a command imply setup. Unknown name, URL, platform and environment fields are omitted, never empty strings.
|
|
102
|
+
|
|
94
103
|
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/cli.mjs
CHANGED
|
@@ -35,10 +35,18 @@ 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
|
|
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
|
-
|
|
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();
|
|
42
50
|
const say = (...args) => { if (!flags.quiet && !flags['dry-run']) console.log(...args); };
|
|
43
51
|
const alert = (message) => console.error(message);
|
|
44
52
|
const safeText = value => String(value).replace(/[\x00-\x1f\x7f-\x9f]/g, '').slice(0, 300);
|
|
@@ -88,13 +96,14 @@ function apiEndpoint() {
|
|
|
88
96
|
}
|
|
89
97
|
async function report() {
|
|
90
98
|
const setup = cmd === 'setup';
|
|
99
|
+
if (!setup && !flags['dry-run'] && workspaceRoot(root) && !readRc().site) { say('SafetyVibe: workspace root; run setup in an app directory.'); return 0; }
|
|
91
100
|
let destination;
|
|
92
101
|
try { destination = apiEndpoint(); } catch { alert('Invalid SAFETYVIBE_API: use HTTPS or local HTTP.'); return 1; }
|
|
93
102
|
const rc = readRc();
|
|
94
103
|
const rawToken = flags['claim-token'] || rc.claimToken || process.env.SAFETYVIBE_CLAIM_TOKEN || '';
|
|
95
104
|
const token = typeof rawToken === 'string' && CLAIM_TOKEN.test(rawToken) ? rawToken : '';
|
|
96
105
|
if (rawToken && !token) alert('Invalid claim token in --claim-token, SAFETYVIBE_CLAIM_TOKEN or .safetyviberc.local.json/.safetyviberc.json.');
|
|
97
|
-
const givenUrl = flags.url ||
|
|
106
|
+
const givenUrl = flags.url || process.env.SAFETYVIBE_URL || rc.url || '';
|
|
98
107
|
const url = normPublicUrl(givenUrl);
|
|
99
108
|
if (urlNeedsCleaning(givenUrl)) alert('Removed credentials/query/fragment or unsafe path from URL; only the public URL is stored and sent');
|
|
100
109
|
const body = buildReport(root, { url, name: flags.name || rc.name });
|
|
@@ -102,15 +111,21 @@ async function report() {
|
|
|
102
111
|
const rawKey = rc.key || process.env.SAFETYVIBE_KEY || '';
|
|
103
112
|
const key = typeof rawKey === 'string' && REPORT_KEY.test(rawKey) ? rawKey : '';
|
|
104
113
|
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;
|
|
114
|
+
if (typeof rc.site === 'string' && UUID.test(rc.site)) headers['x-safetyvibe-site'] = rc.site;
|
|
106
115
|
if (rc.site && key) headers['x-safetyvibe-key'] = key;
|
|
107
116
|
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.`);
|
|
117
|
+
for (const variable of findSupabase(root).warnings) alert(`Warning: ${variable} holds a secret key. Variables starting with VITE_/NEXT_PUBLIC_/EXPO_PUBLIC_/PUBLIC_/REACT_APP_/NUXT_PUBLIC_ are shipped to every visitor; rotate that key in Supabase.`);
|
|
118
|
+
const lockState = readPackages(root);
|
|
119
|
+
if (lockState.noLockfile && !flags.quiet) console.error('No readable lockfile found (package-lock.json, pnpm-lock.yaml, yarn.lock or bun.lock); dependency check unavailable.'
|
|
120
|
+
+ (lockState.binaryBun ? ' Run `bun install --save-text-lockfile` to create bun.lock.' : ''));
|
|
109
121
|
if (flags['dry-run']) {
|
|
110
122
|
console.log(JSON.stringify(body, null, 2));
|
|
111
123
|
console.error(destination + ' headers: ' + Object.keys(headers).join(', ') + ' (values hidden)');
|
|
112
124
|
return 0;
|
|
113
125
|
}
|
|
126
|
+
if (!setup && !rc.site && !key && !token && !installed(root)) {
|
|
127
|
+
say('SafetyVibe: no project connection or connector dependency; skipping report.'); return 0;
|
|
128
|
+
}
|
|
114
129
|
let saveFailed = false;
|
|
115
130
|
const legacy = readJson(RC);
|
|
116
131
|
if (['key', 'claimToken', 'api'].some(k => Object.hasOwn(legacy, k)) || (legacy.url && legacy.url !== normPublicUrl(legacy.url))) {
|
|
@@ -125,10 +140,26 @@ async function report() {
|
|
|
125
140
|
return setup ? 1 : 0;
|
|
126
141
|
}
|
|
127
142
|
let response; let b;
|
|
128
|
-
const ctl = new AbortController();
|
|
143
|
+
const ctl = new AbortController(); let reader;
|
|
144
|
+
const requested = Number(process.env.SAFETYVIBE_TIMEOUT_MS);
|
|
145
|
+
const timeout = Number.isFinite(requested) && requested > 0 ? Math.min(requested, 10000) : 10000;
|
|
146
|
+
const cancel = () => { ctl.abort(); if (reader) void reader.cancel().catch(() => {}); };
|
|
147
|
+
let timer;
|
|
148
|
+
const deadline = new Promise((_, reject) => { timer = setTimeout(() => { cancel(); reject(new Error('deadline')); }, timeout); });
|
|
129
149
|
try {
|
|
130
|
-
|
|
131
|
-
|
|
150
|
+
const work = (async () => {
|
|
151
|
+
response = await fetch(destination, { method: 'POST', headers, body: JSON.stringify(body), signal: ctl.signal, redirect: 'error' });
|
|
152
|
+
reader = response.body?.getReader();
|
|
153
|
+
const chunks = []; let size = 0;
|
|
154
|
+
if (reader) while (true) {
|
|
155
|
+
const { done, value } = await reader.read(); if (done) break;
|
|
156
|
+
size += value.byteLength; if (size > 65536) { cancel(); throw new Error('response too large'); }
|
|
157
|
+
chunks.push(Buffer.from(value));
|
|
158
|
+
}
|
|
159
|
+
try { b = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch {}
|
|
160
|
+
})();
|
|
161
|
+
work.catch(() => {}); // a late rejection after the deadline must not become an unhandled rejection
|
|
162
|
+
await Promise.race([deadline, work]);
|
|
132
163
|
} catch { alert('SafetyVibe: could not reach ' + new URL(destination).host + '.'); return 1; }
|
|
133
164
|
finally { clearTimeout(timer); }
|
|
134
165
|
if (typeof b?.warning === 'string') alert('SafetyVibe: ' + safeText(b.warning));
|
|
@@ -155,7 +186,7 @@ async function report() {
|
|
|
155
186
|
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
187
|
return 1;
|
|
157
188
|
}
|
|
158
|
-
const inventory =
|
|
189
|
+
const inventory = lockState;
|
|
159
190
|
say(`SafetyVibe: reported ${body.packages.length} packages (${body.packages.filter(p => p.dev).length} dev-only).`);
|
|
160
191
|
if (body.truncated) alert(`Only the first 5000 of ${inventory.total} packages were reported.`);
|
|
161
192
|
if (setup && next.hook !== false) {
|
package/lib.mjs
CHANGED
|
@@ -2,32 +2,42 @@
|
|
|
2
2
|
// Egress = buildReport() body + headers x-safetyvibe-site / x-safetyvibe-key /
|
|
3
3
|
// x-safetyvibe-claim-token, only to the default API or SAFETYVIBE_API
|
|
4
4
|
// (https, or http on localhost); redirects refused. Never source code or secret Supabase keys.
|
|
5
|
-
import { readFileSync, existsSync,
|
|
6
|
-
import { join,
|
|
5
|
+
import { readFileSync, existsSync, statSync } from 'node:fs';
|
|
6
|
+
import { join, dirname, sep, relative } from 'node:path';
|
|
7
7
|
|
|
8
8
|
const MAX_PACKAGES = 5000;
|
|
9
9
|
const read = (f) => { try { return readFileSync(f, 'utf8'); } catch { return null; } };
|
|
10
10
|
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);
|
|
12
|
+
function registryTarball(value) {
|
|
13
|
+
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
|
+
}
|
|
15
|
+
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()));
|
|
16
|
+
const dependencySpecs = p => ({ ...p?.dependencies, ...p?.devDependencies, ...p?.optionalDependencies, ...p?.requires });
|
|
17
|
+
const nonRegistryNames = p => Object.entries(dependencySpecs(p)).filter(([, spec]) => typeof spec === 'string' && !registrySpec(spec)).map(([name]) => name);
|
|
18
|
+
const registryEntry = v => v && !v.link && semver(v.version) && (v.resolved == null || registryTarball(v.resolved));
|
|
11
19
|
|
|
12
20
|
// ---- lockfiles -------------------------------------------------------------
|
|
13
21
|
// Each parser returns [{ name, version }] for every installed package (deduped later).
|
|
14
22
|
|
|
15
23
|
export function fromPackageLock(json, { platform = process.platform, arch = process.arch } = {}) {
|
|
16
|
-
const out = [];
|
|
24
|
+
const out = []; const excluded = new Set();
|
|
25
|
+
const remember = p => { for (const name of nonRegistryNames(p)) excluded.add(name); };
|
|
17
26
|
if (json && json.packages && typeof json.packages === 'object') {
|
|
27
|
+
for (const v of Object.values(json.packages)) remember(v);
|
|
18
28
|
for (const [path, v] of Object.entries(json.packages)) {
|
|
19
|
-
if (!path || !
|
|
29
|
+
if (!path || !registryEntry(v)) continue;
|
|
20
30
|
const matches = (list, value) => !Array.isArray(list) || (!list.includes('!' + value) && (!list.some(x => !x.startsWith('!')) || list.includes(value) || list.includes('any')));
|
|
21
31
|
if (v.optional && (!matches(v.os, platform) || !matches(v.cpu, arch))) continue;
|
|
22
32
|
const i = path.lastIndexOf('node_modules/');
|
|
23
|
-
if (i === -1) continue;
|
|
33
|
+
if (i === -1 || excluded.has(path.slice(i + 'node_modules/'.length))) continue;
|
|
24
34
|
out.push({ name: v.name || path.slice(i + 'node_modules/'.length), version: v.version, ...(v.dev === true ? { dev: true } : {}) });
|
|
25
35
|
}
|
|
26
36
|
} else if (json && json.dependencies) { // lockfileVersion 1
|
|
27
|
-
const walk = (deps) => { for (const [n, v] of Object.entries(deps || {})) {
|
|
37
|
+
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
38
|
walk(json.dependencies);
|
|
29
39
|
}
|
|
30
|
-
return out;
|
|
40
|
+
return out.filter(p => !excluded.has(p.name));
|
|
31
41
|
}
|
|
32
42
|
|
|
33
43
|
// bun.lock (text, JSONC): "packages": { "<key>": ["name@version", registry, {...}, integrity], ... }
|
|
@@ -41,49 +51,39 @@ export function fromBunLock(text) {
|
|
|
41
51
|
const at = spec.lastIndexOf('@');
|
|
42
52
|
if (at <= 0) continue;
|
|
43
53
|
const version = spec.slice(at + 1);
|
|
44
|
-
if (
|
|
54
|
+
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
55
|
}
|
|
46
56
|
return out;
|
|
47
57
|
}
|
|
48
58
|
|
|
49
59
|
// yarn.lock v1 ("name@range":\n version "x") and berry ("name@npm:range":\n version: x)
|
|
50
60
|
export function fromYarnLock(text) {
|
|
51
|
-
const out = [];
|
|
52
|
-
for (const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
61
|
+
const out = []; const seen = new Set(); const berry = /^__metadata:/m.test(text);
|
|
62
|
+
for (const block of String(text).split(/(?=^\S)/m)) {
|
|
63
|
+
const lines = block.split(/\r?\n/); const head = lines.shift();
|
|
64
|
+
if (!head?.endsWith(':') || head.startsWith('#') || head.startsWith('__metadata')) continue;
|
|
65
|
+
const specs = head.slice(0, -1).split(/,\s*/).map(s => s.replace(/^"|"$/g, ''));
|
|
66
|
+
const version = /^\s+version:?\s+"?([^"\s]+)/m.exec(block)?.[1];
|
|
67
|
+
const resolved = /^\s+resolved\s+["']?([^"'\s]+)/m.exec(block)?.[1];
|
|
68
|
+
const resolution = /^\s+resolution:\s+"?([^"\s]+)/m.exec(block)?.[1];
|
|
69
|
+
if (!semver(version) || (berry ? !/^(?:@[^/]+\/)?[^@:/]+@npm:/.test(resolution || '') : resolved != null && !registryTarball(resolved))) continue;
|
|
70
|
+
for (const spec of new Set(specs)) {
|
|
71
|
+
const at = spec.indexOf('@', spec.startsWith('@') ? 1 : 0);
|
|
72
|
+
if (at < 1 || !(berry ? spec.slice(at + 1).startsWith('npm:') : registrySpec(spec.slice(at + 1)))) continue;
|
|
73
|
+
const name = spec.slice(0, at); const id = name + '@' + version;
|
|
74
|
+
if (!seen.has(id)) { seen.add(id); out.push({ name, version }); }
|
|
57
75
|
}
|
|
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
76
|
}
|
|
61
77
|
return out;
|
|
62
78
|
}
|
|
63
79
|
|
|
64
|
-
// pnpm-lock.yaml: package keys like "/name@1.2.3", "name@1.2.3", "/@scope/name@1.2.3(peer@x)"
|
|
65
80
|
export function fromPnpmLock(text) {
|
|
66
|
-
const out = [];
|
|
67
|
-
for (const
|
|
68
|
-
|
|
69
|
-
if (
|
|
70
|
-
|
|
71
|
-
|
|
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));
|
|
81
|
+
const out = []; const section = /^packages:\s*\r?\n([\s\S]*?)(?=^\S|(?![\s\S]))/m.exec(text)?.[1] || '';
|
|
82
|
+
for (const block of section.split(/(?=^ {2}\S)/m)) {
|
|
83
|
+
const head = block.split(/\r?\n/)[0];
|
|
84
|
+
if (/https?:|git[+:]|github|file:|link:|workspace:/.test(head) || /\b(?:tarball|repo|directory|type):/.test(block) || !/\bintegrity:/.test(block)) continue;
|
|
85
|
+
const m = /^ {2}['"]?\/?((?:@[^/\s]+\/)?[^@/\s]+)(?:@|\/)(\d+\.\d+\.\d+(?:-[\w.-]+)?)/.exec(head);
|
|
86
|
+
if (m) out.push({ name: m[1], version: m[2], ...(/^ {4}dev: true\s*$/m.test(block) ? { dev: true } : {}) });
|
|
87
87
|
}
|
|
88
88
|
return out;
|
|
89
89
|
}
|
|
@@ -131,11 +131,14 @@ export function readPackages(root) {
|
|
|
131
131
|
let ok = true;
|
|
132
132
|
try { list = parse(t); } catch { list = []; ok = false; }
|
|
133
133
|
if (list.length) { source = file; break; }
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
if (ok && /\.json$|^bun\.lock$/.test(file)) { source = file; break; }
|
|
134
|
+
// Recognized locks remain authoritative when every entry was excluded; falling back
|
|
135
|
+
// to installed packages could reintroduce names from non-registry sources.
|
|
136
|
+
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
137
|
}
|
|
138
|
-
|
|
138
|
+
// No node_modules fallback: installed folders do not say where a package came from, so a git/tarball/
|
|
139
|
+
// file dependency could be reported under its name. Without a readable lockfile no packages are sent and
|
|
140
|
+
// lockfile stays 'none', so the dashboard keeps the last known inventory.
|
|
141
|
+
const missing = source === 'none' ? { noLockfile: true, ...(existsSync(join(lockRoot, 'bun.lockb')) || existsSync(join(root, 'bun.lockb')) ? { binaryBun: true } : {}) } : {};
|
|
139
142
|
const seen = new Map(); const pkgs = [];
|
|
140
143
|
for (const p of list) {
|
|
141
144
|
const k = p.name + '@' + p.version;
|
|
@@ -147,13 +150,19 @@ export function readPackages(root) {
|
|
|
147
150
|
seen.set(k, row); pkgs.push(row);
|
|
148
151
|
|
|
149
152
|
}
|
|
150
|
-
return { packages: pkgs.slice(0, MAX_PACKAGES), source, ...(pkgs.length > MAX_PACKAGES ? { total: pkgs.length, truncated: true } : {}) };
|
|
153
|
+
return { packages: pkgs.slice(0, MAX_PACKAGES), source, ...missing, ...(pkgs.length > MAX_PACKAGES ? { total: pkgs.length, truncated: true } : {}) };
|
|
151
154
|
}
|
|
152
155
|
|
|
153
156
|
// ---- Supabase (project URL + PUBLIC key only) ------------------------------
|
|
154
157
|
function jwtPayload(value) {
|
|
155
158
|
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 {
|
|
159
|
+
try {
|
|
160
|
+
const raw = Buffer.from(value.split('.')[1], 'base64url').toString('utf8');
|
|
161
|
+
// Tokenize complete strings so quoted text inside a value cannot become a key.
|
|
162
|
+
const keys = [...raw.matchAll(/"(?:[^"\\]|\\.)*"/g)]
|
|
163
|
+
.filter(m => /^\s*:/.test(raw.slice(m.index + m[0].length))).map(m => JSON.parse(m[0]));
|
|
164
|
+
if (new Set(keys).size !== keys.length) return { role: 'rejected' };
|
|
165
|
+
const p = JSON.parse(raw); return p && typeof p === 'object' && !Array.isArray(p) ? p : null; } catch { return null; }
|
|
157
166
|
}
|
|
158
167
|
export function publicKeyKind(value) {
|
|
159
168
|
if (typeof value !== 'string' || /sb_secret_|[\s#'"`]/i.test(value)) return null;
|
|
@@ -191,10 +200,20 @@ export function parseEnv(text) {
|
|
|
191
200
|
return env;
|
|
192
201
|
}
|
|
193
202
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
203
|
+
// Only client-exposed variable names (bundled into the browser build by these prefixes); server-only
|
|
204
|
+
// names such as SUPABASE_URL / SUPABASE_ANON_KEY are never read.
|
|
205
|
+
const CLIENT_PREFIXES = ['VITE_', 'NEXT_PUBLIC_', 'EXPO_PUBLIC_', 'PUBLIC_', 'REACT_APP_', 'NUXT_PUBLIC_'];
|
|
206
|
+
const URL_VARS = CLIENT_PREFIXES.map(p => p + 'SUPABASE_URL');
|
|
207
|
+
const KEY_VARS = CLIENT_PREFIXES.flatMap(p => [p + 'SUPABASE_PUBLISHABLE_KEY', p + 'SUPABASE_ANON_KEY']);
|
|
197
208
|
|
|
209
|
+
// Only clearly client-side source files: what the browser bundle already ships to every visitor.
|
|
210
|
+
const SERVER_PATH = /(^|\/)(server|api|functions)\/|(^|\/)app\/api\/|(^|\/)pages\/api\/|(^|\/)supabase\/functions\/|(^|\/)netlify\/functions\/|(^|\/)middleware\.[cm]?[jt]sx?$|\.server\.[cm]?[jt]sx?$/i;
|
|
211
|
+
export function clientSource(path, text) {
|
|
212
|
+
if (SERVER_PATH.test(String(path).split('\\').join('/'))) return false;
|
|
213
|
+
// Any quoted 'server-only' or "use server" anywhere (comments, spacing or syntax tricks included) marks
|
|
214
|
+
// the file as server code. Excluding a client file by mistake is the safe direction.
|
|
215
|
+
return !/['"`]server-only['"`]/.test(text) && !/['"`]use server['"`]/.test(text);
|
|
216
|
+
}
|
|
198
217
|
export function findSupabase(root, procEnv = process.env) {
|
|
199
218
|
const env = {};
|
|
200
219
|
for (const f of ['.env', '.env.local', '.env.production', '.env.production.local']) Object.assign(env, parseEnv(read(join(root, f))));
|
|
@@ -205,7 +224,7 @@ export function findSupabase(root, procEnv = process.env) {
|
|
|
205
224
|
// Lovable hard-codes them in src/integrations/supabase/client.ts
|
|
206
225
|
if (!url || !key) {
|
|
207
226
|
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;
|
|
227
|
+
const t = read(join(root, f)); if (!t || !clientSource(f, t)) continue;
|
|
209
228
|
url = url || ((t.match(/["'`](https:\/\/[a-z0-9-]+\.supabase\.co)["'`]/i) || [])[1] || '');
|
|
210
229
|
for (const match of t.matchAll(/([\w]*(?:PUBLISHABLE|ANON)_KEY)\s*=\s*["'`]([^"'`]+)["'`]/gi)) {
|
|
211
230
|
if (keyKind(match[2]) === 'secret') warnings.push(match[1]);
|
|
@@ -229,6 +248,12 @@ export function detectPlatform(root, procEnv = process.env, url = '') {
|
|
|
229
248
|
if (deps['lovable-tagger'] || existsSync(join(root, '.lovable'))) return 'Lovable';
|
|
230
249
|
if (existsSync(join(root, '.replit'))) return 'Replit';
|
|
231
250
|
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;
|
|
251
|
+
if (String(pj.name || '').toLowerCase().replace(/^@[^/]+\//, '') === 'softgen-starter') return 'Softgen';
|
|
252
|
+
for (const dir of [root, join(root, 'frontend')]) for (const file of ['.env', '.env.local', '.env.production']) {
|
|
253
|
+
for (const value of Object.values(parseEnv(read(join(dir, file))))) {
|
|
254
|
+
try { const u = new URL(value); if (/^https?:$/.test(u.protocol) && /\.(emergentagent\.com|emergent\.host)$/.test(u.hostname)) return 'Emergent'; } catch {}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
232
257
|
if (deps['@base44/sdk']) return 'Base44';
|
|
233
258
|
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
259
|
try { const host = new URL(url).hostname; for (const [suffix, label] of Object.entries(HOSTS)) if (host.endsWith('.' + suffix)) return label; } catch {}
|
|
@@ -237,16 +262,17 @@ export function detectPlatform(root, procEnv = process.env, url = '') {
|
|
|
237
262
|
if (procEnv.NETLIFY) return 'Netlify';
|
|
238
263
|
return '';
|
|
239
264
|
}
|
|
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(' '));
|
|
265
|
+
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
|
+
TEMPLATES.add('');
|
|
241
267
|
// Real JWTs start with eyJ (base64 of {"); plain dotted names like notes.example.com stay allowed.
|
|
242
268
|
const secretLike = value => keyKind(value) !== 'unknown' || /sb_secret_|svk_|eyJ/i.test(value);
|
|
243
269
|
export function appName(root, explicit = '', url = '') {
|
|
244
270
|
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
|
|
271
|
+
for (const candidate of [explicit, host, readJson(join(root, 'package.json'))?.name]) {
|
|
246
272
|
const raw = String(candidate || '').trim();
|
|
247
273
|
if (secretLike(raw)) continue;
|
|
248
274
|
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);
|
|
275
|
+
if (n && !TEMPLATES.has(n.toLowerCase().replace(/^@[^/]+\//, '').replace(/_/g, '-')) && !secretLike(n) && !secretLike(n.slice(0, 80))) return n.slice(0, 80);
|
|
250
276
|
}
|
|
251
277
|
return '';
|
|
252
278
|
}
|
|
@@ -273,6 +299,7 @@ function publicUrl(given) {
|
|
|
273
299
|
try {
|
|
274
300
|
const u = new URL(String(given));
|
|
275
301
|
if (!['http:', 'https:'].includes(u.protocol)) return { url: '', cleaning: false };
|
|
302
|
+
if (/sb_|svk_|eyJ/i.test(u.hostname)) return { url: '', cleaning: true };
|
|
276
303
|
const keep = /^(\/[A-Za-z0-9._~-]{1,64}){0,8}\/?$/.test(u.pathname)
|
|
277
304
|
&& u.pathname.split('/').every(segment => segment.length <= 40 && !/sb_|svk_|eyJ/i.test(segment));
|
|
278
305
|
return { url: u.origin + (keep && u.pathname !== '/' ? u.pathname : ''),
|
|
@@ -288,7 +315,7 @@ export function buildReport(root, opts = {}, procEnv = process.env) {
|
|
|
288
315
|
const sb = findSupabase(root, procEnv);
|
|
289
316
|
const rawEnvironment = String(opts.environment || procEnv.SAFETYVIBE_ENV || (procEnv.NODE_ENV === 'production' ? 'production' : ''));
|
|
290
317
|
const environment = rawEnvironment.replace(/[^A-Za-z0-9_-]/g, '');
|
|
291
|
-
return {
|
|
318
|
+
return Object.fromEntries(Object.entries({
|
|
292
319
|
v: 1,
|
|
293
320
|
name: appName(root, opts.name, url),
|
|
294
321
|
url,
|
|
@@ -298,5 +325,5 @@ export function buildReport(root, opts = {}, procEnv = process.env) {
|
|
|
298
325
|
packages,
|
|
299
326
|
...(truncated ? { truncated: true } : {}),
|
|
300
327
|
supabase: sb.url && sb.key ? { url: sb.url, key: sb.key, kind: sb.kind } : null,
|
|
301
|
-
};
|
|
328
|
+
}).filter(([, value]) => value !== ''));
|
|
302
329
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safetyvibe-connect",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.2",
|
|
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"
|
|
@@ -20,11 +20,9 @@
|
|
|
20
20
|
"keywords": [
|
|
21
21
|
"security",
|
|
22
22
|
"vulnerabilities",
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"supabase",
|
|
27
|
-
"vibe-coding",
|
|
23
|
+
"dependencies",
|
|
24
|
+
"audit",
|
|
25
|
+
"osv",
|
|
28
26
|
"safetyvibe"
|
|
29
27
|
],
|
|
30
28
|
"author": "SafetyVibe <safetyvibe@advertis.ee>"
|