safetyvibe-connect 0.2.2 → 0.3.1
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 +60 -20
- package/LICENSE +21 -0
- package/README.md +60 -20
- package/SECURITY.md +9 -0
- package/cli.mjs +91 -38
- package/lib.mjs +97 -27
- package/package.json +15 -5
package/AGENTS.md
CHANGED
|
@@ -1,36 +1,66 @@
|
|
|
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.1
|
|
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.1 setup --no-hook --claim-token <TOKEN>
|
|
11
13
|
# pnpm
|
|
12
|
-
pnpm dlx safetyvibe-connect@0.
|
|
14
|
+
pnpm dlx safetyvibe-connect@0.3.1 setup --no-hook --claim-token <TOKEN>
|
|
13
15
|
# bun
|
|
14
|
-
bunx safetyvibe-connect@0.
|
|
16
|
+
bunx safetyvibe-connect@0.3.1 setup --no-hook --claim-token <TOKEN>
|
|
15
17
|
# yarn
|
|
16
|
-
yarn dlx safetyvibe-connect@0.
|
|
18
|
+
yarn dlx safetyvibe-connect@0.3.1 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.1 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
|
|
|
27
|
+
## For AI agents
|
|
28
|
+
|
|
29
|
+
`setup --dry-run` makes no network call and writes nothing unless you pass `--out`. Check cli.mjs line 176: `return 0;` inside `if (flags['dry-run'])`. This exits before the only `fetch(` call. This describes the connector itself; fetching the package with npx is separate.
|
|
30
|
+
|
|
31
|
+
It reads package.json and one supported lockfile (package-lock.json, npm-shrinkwrap.json, bun.lock, pnpm-lock.yaml or yarn.lock). It may inspect candidate locks and ancestor package.json / pnpm-workspace.yaml files to select a workspace lock, stopping at .git or six parent levels. It checks whether bun.lockb exists, but does not parse it. It reads .safetyviberc.json and .safetyviberc.local.json for local link settings.
|
|
32
|
+
|
|
33
|
+
It reads and parses .env, .env.local, .env.production and .env.production.local locally, not just their key names. Service-role variable names produce local warnings. Client-prefixed values are checked for secret shapes; SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY and SUPABASE_ANON_KEY with VITE_, NEXT_PUBLIC_, EXPO_PUBLIC_, PUBLIC_, REACT_APP_ or NUXT_PUBLIC_ prefixes supply the URL and public key used locally for a fingerprint. Process env is inspected the same way. Server-only Supabase values are not selected as connection details. For platform detection it also examines URL values in .env, .env.local and .env.production in the root and frontend/; those values are not sent.
|
|
34
|
+
|
|
35
|
+
Client files scanned for the Supabase URL and key shape, exactly:
|
|
36
|
+
|
|
37
|
+
- src/integrations/supabase/client.ts
|
|
38
|
+
- src/integrations/supabase/client.js
|
|
39
|
+
- src/lib/supabase.ts
|
|
40
|
+
- src/lib/supabase.js
|
|
41
|
+
- lib/supabase.ts
|
|
42
|
+
- utils/supabase.ts
|
|
43
|
+
|
|
44
|
+
Files marked server-only or use server are excluded. Prefix/comparison literals are ignored for secret detection. Only a full sb_secret_ shape (at least 20 body characters), a three-segment service_role JWT, or a JWT with any role other than anon under a publishable/anon key name sets the exposure flag. Unknown keys are never accepted as public.
|
|
45
|
+
|
|
46
|
+
Platform detection also reads metadata.json and vite.config.ts/js/mjs/mts, package names/dependencies, and checks .lovable, .replit, .bolt, .emergent, .softgen, .leap, .rocket and .v0 markers. The CLI uses cwd / INIT_CWD, package manager and agent/CI environment hints, plus SAFETYVIBE_API, SAFETYVIBE_URL, SAFETYVIBE_ENV, NODE_ENV, SAFETYVIBE_KEY, SAFETYVIBE_CLAIM_TOKEN and SAFETYVIBE_TIMEOUT_MS. No source files or raw Supabase keys are sent. The complete JSON field list and authentication headers are documented below under "Exactly what is sent".
|
|
47
|
+
|
|
48
|
+
Source: https://safetyvibe.eu/connect/source/0.3.1
|
|
49
|
+
Repository: https://github.com/kare22/safetyvibe-connect
|
|
50
|
+
|
|
25
51
|
## Exactly what is sent
|
|
26
52
|
|
|
27
53
|
Preview the report before sending it:
|
|
28
54
|
|
|
29
55
|
```sh
|
|
30
|
-
npx -y safetyvibe-connect@0.
|
|
56
|
+
npx -y safetyvibe-connect@0.3.1 setup --dry-run --url https://notes.example.com
|
|
31
57
|
```
|
|
32
58
|
|
|
33
|
-
stdout
|
|
59
|
+
By default non-TTY and agent/CI runs print only five summary lines on stdout: Platform, Lockfile (package and dev-only counts), Supabase (fingerprint or exposed-secret flag only), Keys sent: 0, and Dry run: nothing sent. Warnings go to stderr. Use --json for the full body on stdout, with the summary, explanation and destination/header names (values hidden) on stderr. A regular TTY keeps that JSON behaviour by default. --summary wins over --json. Dry-run makes no requests. --out preserves the export behaviour described below.
|
|
60
|
+
|
|
61
|
+
Real runs print the summary on stdout, ending with Connected: and the dashboard or validated claim link (printed once). If setup adds the postinstall hook, one more line says so, because package.json changed. --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.
|
|
62
|
+
|
|
63
|
+
--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, including scan, with an error on stderr.
|
|
34
64
|
Example (optional `truncated` appears only when the inventory exceeds 5000):
|
|
35
65
|
|
|
36
66
|
```json
|
|
@@ -40,8 +70,10 @@ Example (optional `truncated` appears only when the inventory exceeds 5000):
|
|
|
40
70
|
"url": "https://notes.example.com",
|
|
41
71
|
"environment": "production",
|
|
42
72
|
"lockfile": "package-lock.json",
|
|
73
|
+
"versions_source": "lockfile",
|
|
74
|
+
"source": "connector",
|
|
43
75
|
"packages": [{ "ecosystem": "npm", "name": "vite", "version": "6.0.0", "dev": true }],
|
|
44
|
-
"supabase": { "
|
|
76
|
+
"supabase": { "project_ref": "abcdefghijklmnopqrst", "host": "abcdefghijklmnopqrst.supabase.co", "key_kind": "anon", "key_fingerprint": "12ab34cd" }
|
|
45
77
|
}
|
|
46
78
|
```
|
|
47
79
|
|
|
@@ -53,28 +85,36 @@ Example (optional `truncated` appears only when the inventory exceeds 5000):
|
|
|
53
85
|
- `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
86
|
- `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
87
|
- `truncated`: optional boolean; only the first 5000 packages are sent.
|
|
56
|
-
- `
|
|
88
|
+
- `versions_source`: always `lockfile`.
|
|
89
|
+
- `source`: always `connector`.
|
|
90
|
+
- `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.
|
|
57
91
|
|
|
58
|
-
|
|
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.
|
|
92
|
+
Nested fields: `packages[].ecosystem` is `npm`; `packages[].name` is the registry name; `packages[].version` is the exact lockfile version; optional `packages[].dev` is true for dev-only packages. `supabase.project_ref` identifies the project (or null); `supabase.host` is the validated host (or null); `supabase.key_kind` is anon or publishable; `supabase.key_fingerprint` is the eight-character SHA-256 prefix; `supabase.secret_key_exposed_client_side` is true only for a detected client-exposed secret and replaces kind/fingerprint.
|
|
61
93
|
|
|
62
|
-
|
|
94
|
+
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.
|
|
95
|
+
|
|
96
|
+
Never sent: source code, file paths, full lockfiles, .env files, Supabase keys (including anon/publishable), secrets, URL credentials or query strings.
|
|
97
|
+
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.
|
|
98
|
+
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 contain the quoted text server-only or use server anywhere (even in a comment), 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 selected as connection details. Every client-prefixed variable in process env and .env/.env.local/.env.production/.env.production.local is checked for exposed secrets, as are quoted strings in the scanned client files (each key-shaped quoted value is also checked on its own, so a stray apostrophe in a comment cannot hide it), excluding prefix checks and comparisons. A key split across concatenated strings is not detected. Any service-role variable name in a .env* file, including SUPABASE_SERVICE_ROLE_KEY and SUPABASE_SERVICE_KEY, produces a separate local warning; its presence alone never sets an exposure flag or adds anything to the body. Local warnings name only the variable or relative client file, never the value, and tell you to rotate exposed secrets in Supabase.
|
|
99
|
+
|
|
100
|
+
Review the entire implementation in `cli.mjs` and `lib.mjs` (approximately 666 lines total). There are no dependencies and no install scripts in this package itself.
|
|
63
101
|
By default reports go only to `https://dashboard.safetyvibe.eu/connect/api/v1/report`. The committed rc cannot change the destination.
|
|
64
102
|
An explicit `SAFETYVIBE_API` environment override supports HTTPS, or HTTP on localhost/127.0.0.1/[::1] for local testing. Redirects are refused.
|
|
65
103
|
|
|
66
104
|
## Changes
|
|
67
105
|
|
|
68
|
-
0.
|
|
106
|
+
0.3.1 fixes false secret alarms from Supabase prefix checks while keeping real secret warnings and public fingerprints. Non-TTY and agent/CI runs default to a five-line summary; --json opts into the body. It checks config writability before sending, stops re-sending env claim tokens for linked projects, warns locally about service-role variable names, and exits 2 for bad --out paths even on scan. It adds AI-agent source guidance and the public repository link.
|
|
107
|
+
|
|
108
|
+
0.3.0 never sends Supabase keys: it reports public-key fingerprints or exposed-secret flags, puts a five-line summary first, adds --summary and safe --out JSON export, and ships MIT licensing and security contact details. 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
109
|
|
|
70
110
|
[Documentation](https://safetyvibe.eu/connect) and [changelog / what is sent](https://safetyvibe.eu/connect/changelog).
|
|
71
111
|
|
|
72
112
|
## Files written and removal
|
|
73
113
|
|
|
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
|
|
114
|
+
Before a setup/scan request, the connector probes directory writability and checks existing config files; a failure sends nothing and exits 1 for setup (0 for scan). Then 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
115
|
|
|
76
116
|
- `.safetyviberc.json`: app ID, normalized public URL, optional name and `hook: false`.
|
|
77
|
-
- `.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.
|
|
117
|
+
- `.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. Once a valid site ID and report key are present, SAFETYVIBE_CLAIM_TOKEN is not re-sent. 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.
|
|
78
118
|
- `.gitignore`: adds the local config filename.
|
|
79
119
|
- `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.
|
|
80
120
|
|
|
@@ -92,12 +132,12 @@ Remove the connector dependency if installed, its postinstall command, both rc f
|
|
|
92
132
|
| Command | Effect |
|
|
93
133
|
|---|---|
|
|
94
134
|
| `setup [--claim-token T] [--url URL] [--name NAME] [--no-hook]` | Send report, save config, optionally add hook |
|
|
95
|
-
| `setup --dry-run` or `--dry-run` |
|
|
96
|
-
| `scan [--quiet]` | Re-report;
|
|
135
|
+
| `setup --dry-run` or `--dry-run` | Preview summary (--json for the body) without network; only --out writes a file |
|
|
136
|
+
| `scan [--quiet]` | Re-report; exits 0 for install hooks, except bad --out exits 2 |
|
|
97
137
|
| `status` | Show local link state |
|
|
98
138
|
| `help`, `--help`, `-h`, no arguments | Usage only |
|
|
99
139
|
| `--version`, `-v` | Package version only |
|
|
100
140
|
|
|
101
141
|
Flags without a command imply setup. Unknown name, URL, platform and environment fields are omitted, never empty strings.
|
|
102
142
|
|
|
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.
|
|
143
|
+
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 (a missing --out value still exits 2).
|
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,36 +1,66 @@
|
|
|
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.1
|
|
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.1 setup --no-hook --claim-token <TOKEN>
|
|
11
13
|
# pnpm
|
|
12
|
-
pnpm dlx safetyvibe-connect@0.
|
|
14
|
+
pnpm dlx safetyvibe-connect@0.3.1 setup --no-hook --claim-token <TOKEN>
|
|
13
15
|
# bun
|
|
14
|
-
bunx safetyvibe-connect@0.
|
|
16
|
+
bunx safetyvibe-connect@0.3.1 setup --no-hook --claim-token <TOKEN>
|
|
15
17
|
# yarn
|
|
16
|
-
yarn dlx safetyvibe-connect@0.
|
|
18
|
+
yarn dlx safetyvibe-connect@0.3.1 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.1 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
|
|
|
27
|
+
## For AI agents
|
|
28
|
+
|
|
29
|
+
`setup --dry-run` makes no network call and writes nothing unless you pass `--out`. Check cli.mjs line 176: `return 0;` inside `if (flags['dry-run'])`. This exits before the only `fetch(` call. This describes the connector itself; fetching the package with npx is separate.
|
|
30
|
+
|
|
31
|
+
It reads package.json and one supported lockfile (package-lock.json, npm-shrinkwrap.json, bun.lock, pnpm-lock.yaml or yarn.lock). It may inspect candidate locks and ancestor package.json / pnpm-workspace.yaml files to select a workspace lock, stopping at .git or six parent levels. It checks whether bun.lockb exists, but does not parse it. It reads .safetyviberc.json and .safetyviberc.local.json for local link settings.
|
|
32
|
+
|
|
33
|
+
It reads and parses .env, .env.local, .env.production and .env.production.local locally, not just their key names. Service-role variable names produce local warnings. Client-prefixed values are checked for secret shapes; SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY and SUPABASE_ANON_KEY with VITE_, NEXT_PUBLIC_, EXPO_PUBLIC_, PUBLIC_, REACT_APP_ or NUXT_PUBLIC_ prefixes supply the URL and public key used locally for a fingerprint. Process env is inspected the same way. Server-only Supabase values are not selected as connection details. For platform detection it also examines URL values in .env, .env.local and .env.production in the root and frontend/; those values are not sent.
|
|
34
|
+
|
|
35
|
+
Client files scanned for the Supabase URL and key shape, exactly:
|
|
36
|
+
|
|
37
|
+
- src/integrations/supabase/client.ts
|
|
38
|
+
- src/integrations/supabase/client.js
|
|
39
|
+
- src/lib/supabase.ts
|
|
40
|
+
- src/lib/supabase.js
|
|
41
|
+
- lib/supabase.ts
|
|
42
|
+
- utils/supabase.ts
|
|
43
|
+
|
|
44
|
+
Files marked server-only or use server are excluded. Prefix/comparison literals are ignored for secret detection. Only a full sb_secret_ shape (at least 20 body characters), a three-segment service_role JWT, or a JWT with any role other than anon under a publishable/anon key name sets the exposure flag. Unknown keys are never accepted as public.
|
|
45
|
+
|
|
46
|
+
Platform detection also reads metadata.json and vite.config.ts/js/mjs/mts, package names/dependencies, and checks .lovable, .replit, .bolt, .emergent, .softgen, .leap, .rocket and .v0 markers. The CLI uses cwd / INIT_CWD, package manager and agent/CI environment hints, plus SAFETYVIBE_API, SAFETYVIBE_URL, SAFETYVIBE_ENV, NODE_ENV, SAFETYVIBE_KEY, SAFETYVIBE_CLAIM_TOKEN and SAFETYVIBE_TIMEOUT_MS. No source files or raw Supabase keys are sent. The complete JSON field list and authentication headers are documented below under "Exactly what is sent".
|
|
47
|
+
|
|
48
|
+
Source: https://safetyvibe.eu/connect/source/0.3.1
|
|
49
|
+
Repository: https://github.com/kare22/safetyvibe-connect
|
|
50
|
+
|
|
25
51
|
## Exactly what is sent
|
|
26
52
|
|
|
27
53
|
Preview the report before sending it:
|
|
28
54
|
|
|
29
55
|
```sh
|
|
30
|
-
npx -y safetyvibe-connect@0.
|
|
56
|
+
npx -y safetyvibe-connect@0.3.1 setup --dry-run --url https://notes.example.com
|
|
31
57
|
```
|
|
32
58
|
|
|
33
|
-
stdout
|
|
59
|
+
By default non-TTY and agent/CI runs print only five summary lines on stdout: Platform, Lockfile (package and dev-only counts), Supabase (fingerprint or exposed-secret flag only), Keys sent: 0, and Dry run: nothing sent. Warnings go to stderr. Use --json for the full body on stdout, with the summary, explanation and destination/header names (values hidden) on stderr. A regular TTY keeps that JSON behaviour by default. --summary wins over --json. Dry-run makes no requests. --out preserves the export behaviour described below.
|
|
60
|
+
|
|
61
|
+
Real runs print the summary on stdout, ending with Connected: and the dashboard or validated claim link (printed once). If setup adds the postinstall hook, one more line says so, because package.json changed. --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.
|
|
62
|
+
|
|
63
|
+
--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, including scan, with an error on stderr.
|
|
34
64
|
Example (optional `truncated` appears only when the inventory exceeds 5000):
|
|
35
65
|
|
|
36
66
|
```json
|
|
@@ -40,8 +70,10 @@ Example (optional `truncated` appears only when the inventory exceeds 5000):
|
|
|
40
70
|
"url": "https://notes.example.com",
|
|
41
71
|
"environment": "production",
|
|
42
72
|
"lockfile": "package-lock.json",
|
|
73
|
+
"versions_source": "lockfile",
|
|
74
|
+
"source": "connector",
|
|
43
75
|
"packages": [{ "ecosystem": "npm", "name": "vite", "version": "6.0.0", "dev": true }],
|
|
44
|
-
"supabase": { "
|
|
76
|
+
"supabase": { "project_ref": "abcdefghijklmnopqrst", "host": "abcdefghijklmnopqrst.supabase.co", "key_kind": "anon", "key_fingerprint": "12ab34cd" }
|
|
45
77
|
}
|
|
46
78
|
```
|
|
47
79
|
|
|
@@ -53,28 +85,36 @@ Example (optional `truncated` appears only when the inventory exceeds 5000):
|
|
|
53
85
|
- `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
86
|
- `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
87
|
- `truncated`: optional boolean; only the first 5000 packages are sent.
|
|
56
|
-
- `
|
|
88
|
+
- `versions_source`: always `lockfile`.
|
|
89
|
+
- `source`: always `connector`.
|
|
90
|
+
- `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.
|
|
57
91
|
|
|
58
|
-
|
|
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.
|
|
92
|
+
Nested fields: `packages[].ecosystem` is `npm`; `packages[].name` is the registry name; `packages[].version` is the exact lockfile version; optional `packages[].dev` is true for dev-only packages. `supabase.project_ref` identifies the project (or null); `supabase.host` is the validated host (or null); `supabase.key_kind` is anon or publishable; `supabase.key_fingerprint` is the eight-character SHA-256 prefix; `supabase.secret_key_exposed_client_side` is true only for a detected client-exposed secret and replaces kind/fingerprint.
|
|
61
93
|
|
|
62
|
-
|
|
94
|
+
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.
|
|
95
|
+
|
|
96
|
+
Never sent: source code, file paths, full lockfiles, .env files, Supabase keys (including anon/publishable), secrets, URL credentials or query strings.
|
|
97
|
+
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.
|
|
98
|
+
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 contain the quoted text server-only or use server anywhere (even in a comment), 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 selected as connection details. Every client-prefixed variable in process env and .env/.env.local/.env.production/.env.production.local is checked for exposed secrets, as are quoted strings in the scanned client files (each key-shaped quoted value is also checked on its own, so a stray apostrophe in a comment cannot hide it), excluding prefix checks and comparisons. A key split across concatenated strings is not detected. Any service-role variable name in a .env* file, including SUPABASE_SERVICE_ROLE_KEY and SUPABASE_SERVICE_KEY, produces a separate local warning; its presence alone never sets an exposure flag or adds anything to the body. Local warnings name only the variable or relative client file, never the value, and tell you to rotate exposed secrets in Supabase.
|
|
99
|
+
|
|
100
|
+
Review the entire implementation in `cli.mjs` and `lib.mjs` (approximately 666 lines total). There are no dependencies and no install scripts in this package itself.
|
|
63
101
|
By default reports go only to `https://dashboard.safetyvibe.eu/connect/api/v1/report`. The committed rc cannot change the destination.
|
|
64
102
|
An explicit `SAFETYVIBE_API` environment override supports HTTPS, or HTTP on localhost/127.0.0.1/[::1] for local testing. Redirects are refused.
|
|
65
103
|
|
|
66
104
|
## Changes
|
|
67
105
|
|
|
68
|
-
0.
|
|
106
|
+
0.3.1 fixes false secret alarms from Supabase prefix checks while keeping real secret warnings and public fingerprints. Non-TTY and agent/CI runs default to a five-line summary; --json opts into the body. It checks config writability before sending, stops re-sending env claim tokens for linked projects, warns locally about service-role variable names, and exits 2 for bad --out paths even on scan. It adds AI-agent source guidance and the public repository link.
|
|
107
|
+
|
|
108
|
+
0.3.0 never sends Supabase keys: it reports public-key fingerprints or exposed-secret flags, puts a five-line summary first, adds --summary and safe --out JSON export, and ships MIT licensing and security contact details. 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
109
|
|
|
70
110
|
[Documentation](https://safetyvibe.eu/connect) and [changelog / what is sent](https://safetyvibe.eu/connect/changelog).
|
|
71
111
|
|
|
72
112
|
## Files written and removal
|
|
73
113
|
|
|
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
|
|
114
|
+
Before a setup/scan request, the connector probes directory writability and checks existing config files; a failure sends nothing and exits 1 for setup (0 for scan). Then 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
115
|
|
|
76
116
|
- `.safetyviberc.json`: app ID, normalized public URL, optional name and `hook: false`.
|
|
77
|
-
- `.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.
|
|
117
|
+
- `.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. Once a valid site ID and report key are present, SAFETYVIBE_CLAIM_TOKEN is not re-sent. 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.
|
|
78
118
|
- `.gitignore`: adds the local config filename.
|
|
79
119
|
- `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.
|
|
80
120
|
|
|
@@ -92,12 +132,12 @@ Remove the connector dependency if installed, its postinstall command, both rc f
|
|
|
92
132
|
| Command | Effect |
|
|
93
133
|
|---|---|
|
|
94
134
|
| `setup [--claim-token T] [--url URL] [--name NAME] [--no-hook]` | Send report, save config, optionally add hook |
|
|
95
|
-
| `setup --dry-run` or `--dry-run` |
|
|
96
|
-
| `scan [--quiet]` | Re-report;
|
|
135
|
+
| `setup --dry-run` or `--dry-run` | Preview summary (--json for the body) without network; only --out writes a file |
|
|
136
|
+
| `scan [--quiet]` | Re-report; exits 0 for install hooks, except bad --out exits 2 |
|
|
97
137
|
| `status` | Show local link state |
|
|
98
138
|
| `help`, `--help`, `-h`, no arguments | Usage only |
|
|
99
139
|
| `--version`, `-v` | Package version only |
|
|
100
140
|
|
|
101
141
|
Flags without a command imply setup. Unknown name, URL, platform and environment fields are omitted, never empty strings.
|
|
102
142
|
|
|
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.
|
|
143
|
+
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 (a missing --out value still exits 2).
|
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, accessSync, statSync, constants } 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] [--json] [--out FILE.json] [--quiet] [--no-hook] [--help/-h] [--version/-v]\n--dry-run: no network; non-TTY/agent default is summary. --json: full body on stdout, summary on stderr; --summary wins; --dry-run --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,17 +22,17 @@ 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|json|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', 'json'].includes(name) && !rest.length) flags[name] = true;
|
|
30
30
|
else { error = 'Unknown flag.'; break; }
|
|
31
31
|
}
|
|
32
32
|
if (error) {
|
|
33
33
|
const scan = cmd === 'scan' || argv[0] === 'scan';
|
|
34
34
|
if (!(scan && argv.includes('--quiet'))) console.error(USAGE + '\n' + error);
|
|
35
|
-
process.exit(scan ? 0 : 2);
|
|
35
|
+
process.exit(scan && !error.startsWith('Missing value for --out') ? 0 : 2);
|
|
36
36
|
}
|
|
37
37
|
if (!argv.length || cmd === 'help' || flags.help) { console.log(USAGE); process.exit(0); }
|
|
38
38
|
if (!cmd) cmd = 'setup';
|
|
@@ -47,9 +47,14 @@ 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
|
|
51
|
-
const
|
|
52
|
-
const
|
|
50
|
+
const summaryOnly = flags.summary || (!flags.json && !flags.out && (!process.stdout.isTTY || process.env.TERM === 'dumb' || (process.env.CI && !/^(false|0)$/i.test(process.env.CI)) || Object.keys(process.env).some(k => /^(CLAUDECODE|REPLIT.*|REPL_ID|LOVABLE.*|BOLT.*|CODEX.*|EMERGENT.*|GITHUB_ACTIONS|GITLAB_CI|BUILD_ID|CONTINUOUS_INTEGRATION)$/.test(k))));
|
|
51
|
+
const say = (...args) => { if (!flags.quiet && !flags['dry-run'] && !flags.summary && (cmd === 'status' || !summaryOnly)) console.log(...args); };
|
|
52
|
+
const warnings = [];
|
|
53
|
+
const alert = message => warnings.push(message);
|
|
54
|
+
const flushWarnings = () => { for (const message of [...takeScreenWarnings(), ...warnings.splice(0)]) console.error(message); };
|
|
55
|
+
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); };
|
|
56
|
+
// Variable names such as VITE_SUPABASE_SERVICE_ROLE_KEY are printed; only names that carry key material are hidden.
|
|
57
|
+
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
58
|
const readJson = (file) => { try { return JSON.parse(readFileSync(join(root, file), 'utf8')) || {}; } catch { return {}; } };
|
|
54
59
|
function readRc() {
|
|
55
60
|
const pub = readJson(RC); const local = readJson(LOCAL);
|
|
@@ -76,6 +81,20 @@ function writeRc(rc) {
|
|
|
76
81
|
return ignored ? 'saved' : 'unignored';
|
|
77
82
|
}
|
|
78
83
|
|
|
84
|
+
function writableConfig() {
|
|
85
|
+
const probe = join(root, '.safetyvibe-write-' + process.pid); let created = false;
|
|
86
|
+
try {
|
|
87
|
+
for (const file of [RC, LOCAL]) {
|
|
88
|
+
try { if (!statSync(join(root, file)).isFile()) return false; accessSync(join(root, file), constants.W_OK); }
|
|
89
|
+
catch (e) { if (e.code !== 'ENOENT') return false; }
|
|
90
|
+
}
|
|
91
|
+
writeFileSync(probe, '', { flag: 'wx', mode: 0o600 }); created = true;
|
|
92
|
+
unlinkSync(probe); created = false;
|
|
93
|
+
return true;
|
|
94
|
+
} catch { return false; }
|
|
95
|
+
finally { if (created) try { unlinkSync(probe); } catch {} }
|
|
96
|
+
}
|
|
97
|
+
|
|
79
98
|
function addHook() {
|
|
80
99
|
let pj; let original;
|
|
81
100
|
try { original = readFileSync(join(root, 'package.json'), 'utf8'); pj = JSON.parse(original); } catch { return 'skipped (no readable package.json)'; }
|
|
@@ -94,19 +113,41 @@ function apiEndpoint() {
|
|
|
94
113
|
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
114
|
return u.href.replace(/\/+$/, '') + '/report';
|
|
96
115
|
}
|
|
116
|
+
function summary(body, last) {
|
|
117
|
+
const label = value => safeText(value).slice(0, 32);
|
|
118
|
+
const lines = [
|
|
119
|
+
'Platform: ' + label(body.platform || 'unknown'),
|
|
120
|
+
`Lockfile: ${label(body.lockfile || 'none')}, ${body.packages.length} package(s) (${body.packages.filter(p => p.dev).length} dev-only)`,
|
|
121
|
+
'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'),
|
|
122
|
+
'Keys sent: 0', last,
|
|
123
|
+
];
|
|
124
|
+
if (lines.join('\n').length >= 400) lines[4] = 'Connected: open your SafetyVibe dashboard';
|
|
125
|
+
if (flags['dry-run'] || !flags.quiet) (flags['dry-run'] && !summaryOnly ? console.error : console.log)(lines.join('\n'));
|
|
126
|
+
flushWarnings();
|
|
127
|
+
}
|
|
128
|
+
function writeBody(json) {
|
|
129
|
+
if (!flags.out) return;
|
|
130
|
+
const target = resolve(root, flags.out);
|
|
131
|
+
const inside = (base, path) => { const r = relative(base, path); return r && r !== '..' && !r.startsWith('..' + sep) && !isAbsolute(r); };
|
|
132
|
+
if (!target.endsWith('.json') || !inside(resolve(root), target) || [RC, LOCAL, 'package.json'].some(file => relative(root, target).toLowerCase() === file)) throw new Error('out');
|
|
133
|
+
const base = realpathSync(root); const parent = realpathSync(dirname(target));
|
|
134
|
+
if (parent !== base && !inside(base, parent)) throw new Error('out');
|
|
135
|
+
writeFileSync(target, json, { flag: 'wx', mode: 0o600 });
|
|
136
|
+
}
|
|
97
137
|
async function report() {
|
|
98
138
|
const setup = cmd === 'setup';
|
|
99
139
|
if (!setup && !flags['dry-run'] && workspaceRoot(root) && !readRc().site) { say('SafetyVibe: workspace root; run setup in an app directory.'); return 0; }
|
|
100
140
|
let destination;
|
|
101
141
|
try { destination = apiEndpoint(); } catch { alert('Invalid SAFETYVIBE_API: use HTTPS or local HTTP.'); return 1; }
|
|
102
142
|
const rc = readRc();
|
|
103
|
-
const
|
|
143
|
+
const linked = typeof rc.site === 'string' && UUID.test(rc.site) && REPORT_KEY.test(rc.key || process.env.SAFETYVIBE_KEY || '');
|
|
144
|
+
const rawToken = flags['claim-token'] || rc.claimToken || (!linked && process.env.SAFETYVIBE_CLAIM_TOKEN) || '';
|
|
104
145
|
const token = typeof rawToken === 'string' && CLAIM_TOKEN.test(rawToken) ? rawToken : '';
|
|
105
146
|
if (rawToken && !token) alert('Invalid claim token in --claim-token, SAFETYVIBE_CLAIM_TOKEN or .safetyviberc.local.json/.safetyviberc.json.');
|
|
106
147
|
const givenUrl = flags.url || process.env.SAFETYVIBE_URL || rc.url || '';
|
|
107
148
|
const url = normPublicUrl(givenUrl);
|
|
108
149
|
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 });
|
|
150
|
+
const body = buildReport(root, { url: givenUrl, name: flags.name || rc.name });
|
|
110
151
|
const headers = { 'content-type': 'application/json', 'user-agent': 'safetyvibe-connect/' + VERSION };
|
|
111
152
|
const rawKey = rc.key || process.env.SAFETYVIBE_KEY || '';
|
|
112
153
|
const key = typeof rawKey === 'string' && REPORT_KEY.test(rawKey) ? rawKey : '';
|
|
@@ -114,18 +155,30 @@ async function report() {
|
|
|
114
155
|
if (typeof rc.site === 'string' && UUID.test(rc.site)) headers['x-safetyvibe-site'] = rc.site;
|
|
115
156
|
if (rc.site && key) headers['x-safetyvibe-key'] = key;
|
|
116
157
|
if (token) headers['x-safetyvibe-claim-token'] = token;
|
|
117
|
-
|
|
158
|
+
const supabase = findSupabase(root);
|
|
159
|
+
for (const name of supabase.serviceVariables) alert(`found a service-role key variable (${safeName(name)}); make sure it is never in client code`);
|
|
160
|
+
for (const finding of supabase.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
161
|
const lockState = readPackages(root);
|
|
119
|
-
if (lockState.noLockfile && !flags.quiet)
|
|
162
|
+
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
163
|
+ (lockState.binaryBun ? ' Run `bun install --save-text-lockfile` to create bun.lock.' : ''));
|
|
164
|
+
const json = JSON.stringify(body, null, 2) + '\n';
|
|
165
|
+
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
166
|
if (flags['dry-run']) {
|
|
122
|
-
|
|
123
|
-
|
|
167
|
+
// Keep the five summary lines first; warnings follow the destination details.
|
|
168
|
+
const pending = [...takeScreenWarnings(), ...warnings.splice(0)];
|
|
169
|
+
summary(body, 'Dry run: nothing sent');
|
|
170
|
+
if (!summaryOnly) {
|
|
171
|
+
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.');
|
|
172
|
+
console.error(safeText(destination) + ' headers: ' + Object.keys(headers).join(', ') + ' (values hidden)');
|
|
173
|
+
}
|
|
174
|
+
warnings.push(...pending); flushWarnings();
|
|
175
|
+
if (!summaryOnly && !flags.out) process.stdout.write(json);
|
|
124
176
|
return 0;
|
|
125
177
|
}
|
|
126
178
|
if (!setup && !rc.site && !key && !token && !installed(root)) {
|
|
127
179
|
say('SafetyVibe: no project connection or connector dependency; skipping report.'); return 0;
|
|
128
180
|
}
|
|
181
|
+
if (!writableConfig()) { alert('SafetyVibe: project config is not writable (.safetyviberc.json / .safetyviberc.local.json); nothing sent. Run setup in a writable checkout.'); return 1; }
|
|
129
182
|
let saveFailed = false;
|
|
130
183
|
const legacy = readJson(RC);
|
|
131
184
|
if (['key', 'claimToken', 'api'].some(k => Object.hasOwn(legacy, k)) || (legacy.url && legacy.url !== normPublicUrl(legacy.url))) {
|
|
@@ -136,7 +189,7 @@ async function report() {
|
|
|
136
189
|
const manager = packageManager(root);
|
|
137
190
|
const runner = { npm: 'npx', pnpm: 'pnpm dlx', yarn: 'yarn dlx', bun: 'bunx' }[manager];
|
|
138
191
|
if (rc.site && !key && !token) {
|
|
139
|
-
|
|
192
|
+
alert('SafetyVibe: report key is not on this machine. Set SAFETYVIBE_KEY or get a new prompt and run ' + runner + ' safetyvibe-connect setup --claim-token <TOKEN>.');
|
|
140
193
|
return setup ? 1 : 0;
|
|
141
194
|
}
|
|
142
195
|
let response; let b;
|
|
@@ -148,7 +201,7 @@ async function report() {
|
|
|
148
201
|
const deadline = new Promise((_, reject) => { timer = setTimeout(() => { cancel(); reject(new Error('deadline')); }, timeout); });
|
|
149
202
|
try {
|
|
150
203
|
const work = (async () => {
|
|
151
|
-
response = await fetch(destination, { method: 'POST', headers, body:
|
|
204
|
+
response = await fetch(destination, { method: 'POST', headers, body: json, signal: ctl.signal, redirect: 'error' });
|
|
152
205
|
reader = response.body?.getReader();
|
|
153
206
|
const chunks = []; let size = 0;
|
|
154
207
|
if (reader) while (true) {
|
|
@@ -160,7 +213,7 @@ async function report() {
|
|
|
160
213
|
})();
|
|
161
214
|
work.catch(() => {}); // a late rejection after the deadline must not become an unhandled rejection
|
|
162
215
|
await Promise.race([deadline, work]);
|
|
163
|
-
} catch { alert('SafetyVibe: could not reach ' + new URL(destination).host + '.'); return 1; }
|
|
216
|
+
} catch { alert('SafetyVibe: could not reach ' + safeText(new URL(destination).host) + '.'); return 1; }
|
|
164
217
|
finally { clearTimeout(timer); }
|
|
165
218
|
if (typeof b?.warning === 'string') alert('SafetyVibe: ' + safeText(b.warning));
|
|
166
219
|
if (!response.ok) {
|
|
@@ -175,8 +228,16 @@ async function report() {
|
|
|
175
228
|
return 1;
|
|
176
229
|
}
|
|
177
230
|
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;
|
|
231
|
+
alert('SafetyVibe: unexpected response from ' + safeText(new URL(destination).host) + ' (HTTP ' + response.status + ')'); return 1;
|
|
179
232
|
}
|
|
233
|
+
let claim = '';
|
|
234
|
+
if (!b.claimed) try {
|
|
235
|
+
if (typeof b.claimUrl === 'string' && !/[\x00-\x1f\x7f-\x9f]/.test(b.claimUrl)) {
|
|
236
|
+
const u = new URL(b.claimUrl);
|
|
237
|
+
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
|
|
238
|
+
}
|
|
239
|
+
} catch {}
|
|
240
|
+
summary(body, 'Connected: ' + (b.claimed ? 'https://dashboard.safetyvibe.eu' : claim || 'open your SafetyVibe dashboard'));
|
|
180
241
|
const next = { ...rc, site: b.site, url, ...(flags.name ? { name: body.name } : {}), ...(flags['no-hook'] ? { hook: false } : {}) };
|
|
181
242
|
if (typeof b.key === 'string' && REPORT_KEY.test(b.key)) next.key = b.key;
|
|
182
243
|
else if (b.key) alert('Invalid report key in server response.');
|
|
@@ -187,28 +248,20 @@ async function report() {
|
|
|
187
248
|
return 1;
|
|
188
249
|
}
|
|
189
250
|
const inventory = lockState;
|
|
190
|
-
say(`SafetyVibe: reported ${body.packages.length} packages (${body.packages.filter(p => p.dev).length} dev-only).`);
|
|
191
251
|
if (body.truncated) alert(`Only the first 5000 of ${inventory.total} packages were reported.`);
|
|
252
|
+
let followup = '';
|
|
192
253
|
if (setup && next.hook !== false) {
|
|
193
254
|
const hook = addHook();
|
|
194
255
|
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
|
-
|
|
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;
|
|
256
|
+
followup = 'SafetyVibe: postinstall hook ' + hook + '.';
|
|
257
|
+
} else if (setup) followup = 'Re-report manually: ' + runner + ' safetyvibe-connect scan';
|
|
258
|
+
if (saveFailed || saved === 'unignored') return 1;
|
|
259
|
+
// A changed package.json is always reported, even in summary mode.
|
|
260
|
+
if (followup) (/hook added/.test(followup) && !flags.quiet && !flags.summary ? console.log : say)(followup);
|
|
261
|
+
return 0;
|
|
209
262
|
}
|
|
210
263
|
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; }
|
|
264
|
+
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
265
|
return report();
|
|
213
266
|
}
|
|
214
|
-
main().then(code => process.exit(cmd === 'scan' ? 0 : code), () => { alert('SafetyVibe: could not complete report.'); process.exit(cmd === 'scan' ? 0 : 1); });
|
|
267
|
+
main().then(code => { flushWarnings(); process.exit(cmd === 'scan' && code !== 2 ? 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,9 @@ 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' && (/^sb_secret_[A-Za-z0-9_-]{20,}$/.test(value) || (/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(value) && jwtPayload(value)?.role === 'service_role'));
|
|
215
|
+
// A value assigned to a publishable/anon key name must be public: any other JWT role there is a secret.
|
|
216
|
+
const secretInKeySlot = value => exposedSupabaseSecret(value) || (typeof value === 'string' && /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(value) && !publicKeyKind(value) && typeof jwtPayload(value)?.role === 'string' && jwtPayload(value).role !== 'anon');
|
|
208
217
|
|
|
209
218
|
// Only clearly client-side source files: what the browser bundle already ships to every visitor.
|
|
210
219
|
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 +224,47 @@ export function clientSource(path, text) {
|
|
|
215
224
|
return !/['"`]server-only['"`]/.test(text) && !/['"`]use server['"`]/.test(text);
|
|
216
225
|
}
|
|
217
226
|
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
|
-
|
|
227
|
+
const env = {}; const warnings = new Set(); const serviceVariables = new Set();
|
|
228
|
+
const collect = (values, file = false) => {
|
|
229
|
+
for (const k of Object.keys(values)) {
|
|
230
|
+
// Server-side env vars are normal; only names written into .env* files get the local reminder.
|
|
231
|
+
if (file && /SERVICE_ROLE|(?:^|_)SUPABASE_SERVICE_KEY$/i.test(k)) serviceVariables.add(k);
|
|
232
|
+
if (!CLIENT_PREFIXES.some(p => k.startsWith(p))) continue;
|
|
233
|
+
const value = values[k];
|
|
234
|
+
if (KEY_VARS.includes(k) ? secretInKeySlot(value) : exposedSupabaseSecret(value)) warnings.add(k);
|
|
235
|
+
if (value) env[k] = value;
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
for (const f of ['.env', '.env.local', '.env.production', '.env.production.local']) collect(parseEnv(read(join(root, f))), true);
|
|
239
|
+
collect(procEnv);
|
|
240
|
+
let url = URL_VARS.map(k => env[k]).find(value => /^https:\/\/[a-z0-9-]+\.supabase\.(co|in)\/?$/i.test(value || '')) || '';
|
|
241
|
+
let key = KEY_VARS.map(k => env[k]).find(value => publicKeyKind(value)) || '';
|
|
242
|
+
// Scan every allowed client file even when environment config is complete.
|
|
243
|
+
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']) {
|
|
244
|
+
const t = read(join(root, f)); if (!t || !clientSource(f, t)) continue;
|
|
245
|
+
url = url || ((t.match(/["'`](https:\/\/[a-z0-9-]+\.supabase\.co)["'`]/i) || [])[1] || '');
|
|
246
|
+
for (const match of t.matchAll(/(["'`])((?:\\.|(?!\1)[^\\])*?)\1/g)) {
|
|
247
|
+
const before = t.slice(0, match.index); const after = t.slice(match.index + match[0].length);
|
|
248
|
+
if (/\.(startsWith|endsWith|includes|indexOf)\(\s*$/.test(before) || /[!=]==?\s*$/.test(before) || /^\s*[!=]==?/.test(after)) continue;
|
|
249
|
+
if (exposedSupabaseSecret(match[2])) warnings.add(f);
|
|
250
|
+
}
|
|
251
|
+
// Quote pairing above can drift (an apostrophe in a comment); also check each key-shaped run that is
|
|
252
|
+
// directly quoted on both sides, with the same comparison/prefix-check skips.
|
|
253
|
+
for (const match of t.matchAll(/(["'`])(sb_secret_[A-Za-z0-9_-]{20,}|eyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)\1/g)) {
|
|
254
|
+
const before = t.slice(0, match.index); const after = t.slice(match.index + match[0].length);
|
|
255
|
+
if (/\.(startsWith|endsWith|includes|indexOf)\(\s*$/.test(before) || /[!=]==?\s*$/.test(before) || /^\s*[!=]==?/.test(after)) continue;
|
|
256
|
+
if (exposedSupabaseSecret(match[2])) warnings.add(f);
|
|
257
|
+
}
|
|
258
|
+
for (const match of t.matchAll(/([\w]*(?:PUBLISHABLE|ANON)_KEY)\s*=\s*["'`]([^"'`]+)["'`]/gi)) {
|
|
259
|
+
if (secretInKeySlot(match[2])) warnings.add(f);
|
|
260
|
+
if (!key && publicKeyKind(match[2])) key = match[2];
|
|
234
261
|
}
|
|
235
262
|
}
|
|
236
263
|
if (!/^https:\/\/[a-z0-9-]+\.supabase\.(co|in)\/?$/i.test(url)) url = '';
|
|
237
264
|
const kind = key ? keyKind(key) : 'none';
|
|
238
265
|
// Never send anything that isn't provably a public key.
|
|
239
266
|
if (!publicKeyKind(key)) key = '';
|
|
240
|
-
return { url: url.replace(/\/$/, ''), key, kind, warnings };
|
|
267
|
+
return { url: url.replace(/\/$/, ''), key, kind, warnings: [...warnings], serviceVariables: [...serviceVariables] };
|
|
241
268
|
}
|
|
242
269
|
|
|
243
270
|
// ---- app identity ------------------------------------------------------------
|
|
@@ -265,14 +292,50 @@ export function detectPlatform(root, procEnv = process.env, url = '') {
|
|
|
265
292
|
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
293
|
TEMPLATES.add('');
|
|
267
294
|
// Real JWTs start with eyJ (base64 of {"); plain dotted names like notes.example.com stay allowed.
|
|
268
|
-
|
|
295
|
+
export function screenNormalized(value) {
|
|
296
|
+
let text = String(value || '');
|
|
297
|
+
for (let i = 0; i < 3; i++) {
|
|
298
|
+
const decoded = text.replace(/(?:%[0-9a-f]{2})+/gi, run => { try { return decodeURIComponent(run); } catch { return run; } });
|
|
299
|
+
if (decoded === text) break;
|
|
300
|
+
text = decoded;
|
|
301
|
+
}
|
|
302
|
+
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' };
|
|
303
|
+
// Decompose first so accents composed onto letters (e + U+0301 -> U+00E9) are stripped too, then recompose.
|
|
304
|
+
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);
|
|
305
|
+
}
|
|
306
|
+
export function secretLike(value, packageName = false, credentials = true) {
|
|
307
|
+
if (typeof value !== 'string') return false;
|
|
308
|
+
return [value, screenNormalized(value)].some(v => {
|
|
309
|
+
// eyJ is case-sensitive (base64 of '{"'): lowercase "eyj" occurs in ordinary words (honeyjar, journeyjs).
|
|
310
|
+
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;
|
|
311
|
+
// Generic secret-key shapes (sk_<random>, sk-<random>) with no separators in the random part.
|
|
312
|
+
if (/sk_[A-Za-z0-9]{16,}|sk-[A-Za-z0-9]{20,}/i.test(v)) return true;
|
|
313
|
+
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;
|
|
314
|
+
// Within a run, a separator-free piece of >= 32 characters, or >= 16 mixing letters and digits, is key
|
|
315
|
+
// material; hyphenated words and paths ("some-long-path/another-segment") are not. Real npm package
|
|
316
|
+
// names are exempt from this long-run rule only, never from the key prefixes above.
|
|
317
|
+
if (packageName && /^(@[\w.-]+\/)?[\w.-]{1,214}$/.test(value)) return false;
|
|
318
|
+
return (v.match(/[A-Za-z0-9+/_=-]{40,}/g) || []).some(run => run.split(/[-/_+=]/)
|
|
319
|
+
.some(piece => piece.length >= 32 || (piece.length >= 16 && /[a-z]/i.test(piece) && /\d/.test(piece))));
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
// Screening warnings are collected (deduplicated) and printed by the CLI after the summary.
|
|
323
|
+
const screenWarnings = new Set();
|
|
324
|
+
export function takeScreenWarnings() { const list = [...screenWarnings]; screenWarnings.clear(); return list; }
|
|
325
|
+
export function screenString(value, field, warn = message => screenWarnings.add(message), packageName = false) {
|
|
326
|
+
if (typeof value !== 'string') return '';
|
|
327
|
+
if (!secretLike(value, packageName, false)) return value;
|
|
328
|
+
warn('Dropped ' + field + ': it looked like a key.');
|
|
329
|
+
return '';
|
|
330
|
+
}
|
|
269
331
|
export function appName(root, explicit = '', url = '') {
|
|
270
332
|
let host = ''; try { host = new URL(url).hostname; if (Object.keys(HOSTS).some(s => host.endsWith('.' + s))) host = host.split('.')[0]; } catch {}
|
|
271
333
|
for (const candidate of [explicit, host, readJson(join(root, 'package.json'))?.name]) {
|
|
272
334
|
const raw = String(candidate || '').trim();
|
|
273
|
-
if (
|
|
335
|
+
if (!screenString(raw, 'name')) { if (raw) return ''; continue; }
|
|
274
336
|
const n = raw.replace(/[^A-Za-z0-9 @/._-]/g, '').trim();
|
|
275
|
-
if (n && !
|
|
337
|
+
if (n && (!screenString(n, 'name') || !screenString(n.slice(0, 80), 'name'))) return '';
|
|
338
|
+
if (n && !TEMPLATES.has(n.toLowerCase().replace(/^@[^/]+\//, '').replace(/_/g, '-'))) return n.slice(0, 80);
|
|
276
339
|
}
|
|
277
340
|
return '';
|
|
278
341
|
}
|
|
@@ -296,6 +359,7 @@ export function withHook(current = '') {
|
|
|
296
359
|
}
|
|
297
360
|
|
|
298
361
|
function publicUrl(given) {
|
|
362
|
+
if (!screenString(given, 'url')) return { url: '', cleaning: Boolean(given) };
|
|
299
363
|
try {
|
|
300
364
|
const u = new URL(String(given));
|
|
301
365
|
if (!['http:', 'https:'].includes(u.protocol)) return { url: '', cleaning: false };
|
|
@@ -313,17 +377,23 @@ export function buildReport(root, opts = {}, procEnv = process.env) {
|
|
|
313
377
|
const { packages, source, truncated } = readPackages(root);
|
|
314
378
|
const url = normPublicUrl(opts.url || procEnv.SAFETYVIBE_URL || '');
|
|
315
379
|
const sb = findSupabase(root, procEnv);
|
|
380
|
+
const host = sb.url ? screenString(new URL(sb.url).host, 'supabase host') || null : null;
|
|
381
|
+
const project_ref = host && /^([a-z0-9]{20})\.supabase\.co$/.exec(host)?.[1] || null;
|
|
382
|
+
const supabase = sb.warnings.length ? { project_ref, host, secret_key_exposed_client_side: true }
|
|
383
|
+
: 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
384
|
const rawEnvironment = String(opts.environment || procEnv.SAFETYVIBE_ENV || (procEnv.NODE_ENV === 'production' ? 'production' : ''));
|
|
317
385
|
const environment = rawEnvironment.replace(/[^A-Za-z0-9_-]/g, '');
|
|
318
386
|
return Object.fromEntries(Object.entries({
|
|
319
387
|
v: 1,
|
|
320
388
|
name: appName(root, opts.name, url),
|
|
321
389
|
url,
|
|
322
|
-
platform: detectPlatform(root, procEnv, url),
|
|
323
|
-
environment:
|
|
390
|
+
platform: screenString(detectPlatform(root, procEnv, url), 'platform'),
|
|
391
|
+
environment: screenString(rawEnvironment, 'environment') && screenString(environment, 'environment') ? environment.slice(0, 20) : '',
|
|
324
392
|
lockfile: source,
|
|
393
|
+
versions_source: "lockfile",
|
|
394
|
+
source: "connector",
|
|
325
395
|
packages,
|
|
326
396
|
...(truncated ? { truncated: true } : {}),
|
|
327
|
-
supabase
|
|
397
|
+
supabase,
|
|
328
398
|
}).filter(([, value]) => value !== ''));
|
|
329
399
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safetyvibe-connect",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
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,13 @@
|
|
|
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
|
+
},
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/kare22/safetyvibe-connect.git"
|
|
38
|
+
}
|
|
29
39
|
}
|