toolstile 0.1.0 → 0.1.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/README.md +34 -5
- package/dist/checks.d.ts +9 -0
- package/dist/checks.js +110 -40
- package/dist/cli.js +10 -5
- package/dist/parse.d.ts +15 -1
- package/dist/parse.js +72 -13
- package/dist/report.d.ts +8 -2
- package/dist/report.js +54 -12
- package/dist/secrets.js +3 -3
- package/dist/sources.d.ts +5 -0
- package/dist/sources.js +49 -5
- package/dist/types.d.ts +10 -2
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -15,19 +15,21 @@ Windsurf, Gemini CLI and Cline and grades them for:
|
|
|
15
15
|
| Remote servers reached over plain `http://` | high |
|
|
16
16
|
| Containers run with `--privileged` or the Docker socket | high |
|
|
17
17
|
| The same credential pasted into several clients' configs | medium |
|
|
18
|
-
| Config files holding secrets that other
|
|
18
|
+
| Config files holding secrets that other accounts can reach (file and folder permissions) | medium |
|
|
19
19
|
| `npx`, `uvx` and `docker` servers that run whatever version is newest | medium |
|
|
20
20
|
| Filesystem servers that can read your whole home folder or disk | medium |
|
|
21
21
|
| Permission rules that approve every tool of a server without asking | medium |
|
|
22
22
|
| Tools on a client's auto-approve list | low |
|
|
23
|
-
|
|
23
|
+
|
|
24
|
+
It also lists servers set up separately in several clients. That is context, not
|
|
25
|
+
scored: remote servers need an entry per client.
|
|
24
26
|
|
|
25
27
|
```text
|
|
26
28
|
toolstile audit · local MCP config check · nothing leaves this machine
|
|
27
29
|
|
|
28
30
|
Scanned 4 files from Claude Code, Cursor, Codex: 10 MCP servers.
|
|
29
31
|
|
|
30
|
-
Grade
|
|
32
|
+
Grade B 77/100 1 high · 1 medium · 1 low
|
|
31
33
|
|
|
32
34
|
HIGH Plaintext secret in github (Cursor)
|
|
33
35
|
A GitHub token is written directly into the config file. Anything that can
|
|
@@ -42,7 +44,9 @@ HIGH Plaintext secret in github (Cursor)
|
|
|
42
44
|
- Makes no network requests and collects no telemetry.
|
|
43
45
|
- Never prints, stores or sends secret values. Findings name the file and key,
|
|
44
46
|
for example `mcpServers.github.env.GITHUB_TOKEN`, and the kind of secret.
|
|
45
|
-
-
|
|
47
|
+
- Never quotes a config file: one that cannot be read or parsed is listed with a
|
|
48
|
+
reason and position only, for example `invalid TOML at line 3, column 12`.
|
|
49
|
+
- About 1,400 lines of TypeScript with one runtime dependency
|
|
46
50
|
([smol-toml](https://github.com/squirrelchat/smol-toml), for Codex's TOML
|
|
47
51
|
config) and an optional one ([resvg-js](https://github.com/thx/resvg-js), only
|
|
48
52
|
to render `--card` as PNG). Read it before you run it.
|
|
@@ -53,6 +57,7 @@ HIGH Plaintext secret in github (Cursor)
|
|
|
53
57
|
--json Print results as JSON
|
|
54
58
|
--card [file] Write a shareable grade card (default toolstile-audit.png;
|
|
55
59
|
use a .svg name for SVG). Contains only the grade and counts.
|
|
60
|
+
--redact Hide your home folder and project paths, for posting the output
|
|
56
61
|
--project <dir> Also scan project configs in <dir> (default: current directory)
|
|
57
62
|
--no-project Scan only user-level configs
|
|
58
63
|
--fail-on <level> Exit with code 1 if any finding is at or above high, medium or low
|
|
@@ -61,6 +66,23 @@ HIGH Plaintext secret in github (Cursor)
|
|
|
61
66
|
`--fail-on high` makes it usable in CI or a pre-commit hook for repositories that
|
|
62
67
|
check in `.mcp.json`, `.cursor/mcp.json` or `.vscode/mcp.json`.
|
|
63
68
|
|
|
69
|
+
Exit codes:
|
|
70
|
+
|
|
71
|
+
| Code | Meaning |
|
|
72
|
+
| --- | --- |
|
|
73
|
+
| 0 | The audit ran, and no finding reached the `--fail-on` level (or `--fail-on` was not set) |
|
|
74
|
+
| 1 | A finding is at or above the `--fail-on` level |
|
|
75
|
+
| 2 | Invalid options, or the audit itself failed |
|
|
76
|
+
|
|
77
|
+
## Scoring
|
|
78
|
+
|
|
79
|
+
Each distinct problem has a weight: 10 for a leaked secret, plain-http remote or
|
|
80
|
+
privileged container; 4 for readable secret files, unpinned packages, broad
|
|
81
|
+
filesystem access and whole-server auto-approval; 2 for each copied secret; 1 for
|
|
82
|
+
auto-approved tools. A secret pasted into several files counts as one leak. The
|
|
83
|
+
score is `100 / (1 + weight / 50)`: one leaked secret scores 83 (B), four score
|
|
84
|
+
about 56 (D). Grades: A 90+, B 75+, C 60+, D 40+, F below 40.
|
|
85
|
+
|
|
64
86
|
## Share your grade
|
|
65
87
|
|
|
66
88
|
```sh
|
|
@@ -68,7 +90,8 @@ npx toolstile audit --card
|
|
|
68
90
|
```
|
|
69
91
|
|
|
70
92
|
writes a 1200×630 image with your grade and counts (no server names, paths or
|
|
71
|
-
values) and prints a ready-to-post link.
|
|
93
|
+
values) and prints a ready-to-post link. To post the text report instead, add
|
|
94
|
+
`--redact` so it shows `~` and `<project>` in place of your folders.
|
|
72
95
|
|
|
73
96
|
## Files it reads
|
|
74
97
|
|
|
@@ -100,6 +123,12 @@ The audit works on its own and does not require a Toolstile account.
|
|
|
100
123
|
|
|
101
124
|
Questions and reports: support@toolstile.com.
|
|
102
125
|
|
|
126
|
+
## Releases
|
|
127
|
+
|
|
128
|
+
The npm package is published by [GitHub Actions](.github/workflows/publish.yml)
|
|
129
|
+
with npm trusted publishing and provenance, so each release links back to the
|
|
130
|
+
commit and workflow that built it. No npm token is stored anywhere.
|
|
131
|
+
|
|
103
132
|
## License
|
|
104
133
|
|
|
105
134
|
[MIT](LICENSE)
|
package/dist/checks.d.ts
CHANGED
|
@@ -4,13 +4,22 @@ interface Package {
|
|
|
4
4
|
registry: "npm" | "pypi" | "docker";
|
|
5
5
|
name: string;
|
|
6
6
|
version: string | null;
|
|
7
|
+
/** Index of the package in the unwrapped words. */
|
|
8
|
+
wordIndex: number;
|
|
9
|
+
/** Index of the package in `args`, or -1 when it is inside `command`. */
|
|
7
10
|
argIndex: number;
|
|
8
11
|
}
|
|
9
12
|
/** Finds the package a launcher such as npx, uvx or docker runs. */
|
|
10
13
|
export declare function packageOf(s: ServerEntry): Package | null;
|
|
11
14
|
/** A stable identity for the same server configured in several places. */
|
|
12
15
|
export declare function identityOf(s: ServerEntry): string | null;
|
|
16
|
+
/** A path for a shell command: `~/...` under the home folder, single-quoted when needed. */
|
|
17
|
+
export declare function shellPath(path: string, home: string): string;
|
|
13
18
|
export declare function runChecks(d: Discovery, home: string): Finding[];
|
|
19
|
+
/**
|
|
20
|
+
* 100 / (1 + weight / 50): one leaked secret (10) scores 83, four score about 50, and the score
|
|
21
|
+
* keeps separating bad setups from worse ones instead of bottoming out at zero.
|
|
22
|
+
*/
|
|
14
23
|
export declare function score(findings: Finding[]): {
|
|
15
24
|
score: number;
|
|
16
25
|
grade: string;
|
package/dist/checks.js
CHANGED
|
@@ -41,20 +41,67 @@ const LAUNCHERS = {
|
|
|
41
41
|
pnpx: "npm",
|
|
42
42
|
uvx: "pypi",
|
|
43
43
|
};
|
|
44
|
-
|
|
44
|
+
const base = (p) => win32.basename(posix.basename(p)).replace(/\.(cmd|exe)$/i, "");
|
|
45
|
+
const split = (text) => text.trim().split(/\s+/).filter(Boolean);
|
|
46
|
+
/** WSL options that take a value. */
|
|
47
|
+
const WSL_VALUED = new Set(["-d", "--distribution", "--distribution-id", "-u", "--user", "--cd", "--shell-type"]);
|
|
48
|
+
/** Index of the first word after a `cmd /c` or `wsl` wrapper at `start`, or `start` when there is none. */
|
|
49
|
+
function afterWrapper(list, start) {
|
|
50
|
+
const name = base(list[start] ?? "").toLowerCase();
|
|
51
|
+
let j = start + 1;
|
|
52
|
+
if (name === "cmd") {
|
|
53
|
+
// cmd [/d] [/s] [/q] … /c|/k command
|
|
54
|
+
while (j < list.length && /^\/[a-z]$/i.test(list[j]) && !/^\/[ck]$/i.test(list[j]))
|
|
55
|
+
j++;
|
|
56
|
+
return /^\/[ck]$/i.test(list[j] ?? "") ? j + 1 : start;
|
|
57
|
+
}
|
|
58
|
+
if (name === "wsl") {
|
|
59
|
+
while (j < list.length) {
|
|
60
|
+
const a = list[j];
|
|
61
|
+
if (WSL_VALUED.has(a))
|
|
62
|
+
j += 2;
|
|
63
|
+
else if (a === "-e" || a === "--exec" || a === "--")
|
|
64
|
+
return j + 1;
|
|
65
|
+
else if (a.startsWith("-"))
|
|
66
|
+
j++;
|
|
67
|
+
else
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
return j;
|
|
71
|
+
}
|
|
72
|
+
return start;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Splits `command` and `args` into words, handling configs that put the whole command in one string,
|
|
76
|
+
* and skips Windows wrappers (`cmd /c npx …`, `wsl npx …`) so the real launcher comes first.
|
|
77
|
+
*/
|
|
45
78
|
function words(s) {
|
|
46
79
|
if (!s.command)
|
|
47
|
-
return [];
|
|
48
|
-
const
|
|
49
|
-
|
|
80
|
+
return { list: [], arg: [] };
|
|
81
|
+
const oneString = s.args.length === 0 && /\s/.test(s.command.trim());
|
|
82
|
+
let list = oneString ? split(s.command) : [s.command, ...s.args];
|
|
83
|
+
let arg = oneString ? list.map(() => -1) : list.map((_, i) => i - 1);
|
|
84
|
+
for (;;) {
|
|
85
|
+
const start = afterWrapper(list, 0);
|
|
86
|
+
if (start === 0 || start >= list.length)
|
|
87
|
+
break;
|
|
88
|
+
list = list.slice(start);
|
|
89
|
+
arg = arg.slice(start);
|
|
90
|
+
// `cmd /c "npx -y pkg"` passes the inner command as one argument.
|
|
91
|
+
if (list.length === 1 && /\s/.test(list[0].trim())) {
|
|
92
|
+
const from = arg[0];
|
|
93
|
+
list = split(list[0]);
|
|
94
|
+
arg = list.map(() => from);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return { list, arg };
|
|
50
98
|
}
|
|
51
|
-
const base = (p) => win32.basename(posix.basename(p)).replace(/\.(cmd|exe)$/i, "");
|
|
52
99
|
/** Finds the package a launcher such as npx, uvx or docker runs. */
|
|
53
100
|
export function packageOf(s) {
|
|
54
|
-
const w = words(s);
|
|
101
|
+
const { list: w, arg } = words(s);
|
|
55
102
|
if (w.length === 0)
|
|
56
103
|
return null;
|
|
57
|
-
const cmd = base(w[0]);
|
|
104
|
+
const cmd = base(w[0]).toLowerCase();
|
|
58
105
|
const sub = w.slice(1);
|
|
59
106
|
let registry = LAUNCHERS[cmd];
|
|
60
107
|
let skip = 0;
|
|
@@ -74,6 +121,7 @@ export function packageOf(s) {
|
|
|
74
121
|
return null;
|
|
75
122
|
const rest = sub.slice(skip);
|
|
76
123
|
const offset = 1 + skip;
|
|
124
|
+
const at = (spec, i) => parsePackage(registry, spec, i, arg[i] ?? -1);
|
|
77
125
|
// Flags that take a value; their value is not the package.
|
|
78
126
|
const valued = new Set(["-p", "--package", "--from", "--with", "-e", "--env", "-v", "--volume", "--name", "--network", "--mount", "--env-file", "-w", "--workdir", "--entrypoint", "-u", "--user", "--python"]);
|
|
79
127
|
for (let i = 0; i < rest.length; i++) {
|
|
@@ -83,41 +131,42 @@ export function packageOf(s) {
|
|
|
83
131
|
if (a.startsWith("-")) {
|
|
84
132
|
if (valued.has(a)) {
|
|
85
133
|
if ((a === "--package" || a === "-p" || a === "--from") && registry !== "docker") {
|
|
86
|
-
return
|
|
134
|
+
return at(rest[i + 1] ?? "", offset + i + 1);
|
|
87
135
|
}
|
|
88
136
|
i++;
|
|
89
137
|
}
|
|
90
138
|
continue;
|
|
91
139
|
}
|
|
92
|
-
return
|
|
140
|
+
return at(a, offset + i);
|
|
93
141
|
}
|
|
94
142
|
return null;
|
|
95
143
|
}
|
|
96
|
-
function parsePackage(registry, spec, argIndex) {
|
|
144
|
+
function parsePackage(registry, spec, wordIndex, argIndex) {
|
|
97
145
|
if (!spec)
|
|
98
146
|
return null;
|
|
147
|
+
const where = { wordIndex, argIndex };
|
|
99
148
|
if (registry === "docker") {
|
|
100
149
|
const at = spec.indexOf("@sha256:");
|
|
101
150
|
if (at > 0)
|
|
102
|
-
return { registry, name: spec.slice(0, at), version: spec.slice(at + 1),
|
|
151
|
+
return { registry, name: spec.slice(0, at), version: spec.slice(at + 1), ...where };
|
|
103
152
|
const slash = spec.lastIndexOf("/");
|
|
104
153
|
const colon = spec.lastIndexOf(":");
|
|
105
154
|
return colon > slash
|
|
106
|
-
? { registry, name: spec.slice(0, colon), version: spec.slice(colon + 1),
|
|
107
|
-
: { registry, name: spec, version: null,
|
|
155
|
+
? { registry, name: spec.slice(0, colon), version: spec.slice(colon + 1), ...where }
|
|
156
|
+
: { registry, name: spec, version: null, ...where };
|
|
108
157
|
}
|
|
109
158
|
if (registry === "pypi") {
|
|
110
159
|
const m = /^([A-Za-z0-9._\-[\],]+?)(?:(==|@|>=|~=)(.+))?$/.exec(spec);
|
|
111
160
|
if (!m)
|
|
112
161
|
return null;
|
|
113
|
-
return { registry, name: m[1], version: m[2] === "==" || m[2] === "@" ? m[3] : null,
|
|
162
|
+
return { registry, name: m[1], version: m[2] === "==" || m[2] === "@" ? m[3] : null, ...where };
|
|
114
163
|
}
|
|
115
164
|
if (/^(https?:|git\+|file:|\.|\/)/.test(spec))
|
|
116
165
|
return null;
|
|
117
166
|
const at = spec.indexOf("@", spec.startsWith("@") ? 1 : 0);
|
|
118
167
|
return at > 0
|
|
119
|
-
? { registry, name: spec.slice(0, at), version: spec.slice(at + 1),
|
|
120
|
-
: { registry, name: spec, version: null,
|
|
168
|
+
? { registry, name: spec.slice(0, at), version: spec.slice(at + 1), ...where }
|
|
169
|
+
: { registry, name: spec, version: null, ...where };
|
|
121
170
|
}
|
|
122
171
|
const isPinned = (p) => p.version !== null &&
|
|
123
172
|
p.version !== "latest" &&
|
|
@@ -138,7 +187,7 @@ export function identityOf(s) {
|
|
|
138
187
|
const pkg = packageOf(s);
|
|
139
188
|
if (pkg)
|
|
140
189
|
return `${pkg.registry}:${pkg.name.toLowerCase()}`;
|
|
141
|
-
const w = words(s);
|
|
190
|
+
const w = words(s).list;
|
|
142
191
|
return w.length ? `cmd:${w.join(" ")}` : null;
|
|
143
192
|
}
|
|
144
193
|
const LOOPBACK = /^(localhost|127\.\d+\.\d+\.\d+|\[::1\]|0\.0\.0\.0)$/i;
|
|
@@ -149,10 +198,17 @@ function broadRoots(s, home) {
|
|
|
149
198
|
return [];
|
|
150
199
|
const homes = new Set([home, home.replace(/\/+$/, ""), "~", "~/", "$HOME", "${HOME}", "%USERPROFILE%"]);
|
|
151
200
|
return words(s)
|
|
152
|
-
.slice(pkg.
|
|
201
|
+
.list.slice(pkg.wordIndex + 1)
|
|
153
202
|
.filter((a) => a === "/" || /^[A-Za-z]:\\?$/.test(a) || homes.has(a) || homes.has(a.replace(/\/+$/, "")));
|
|
154
203
|
}
|
|
155
|
-
const SEVERITY_ORDER = { high: 0, medium: 1, low: 2 };
|
|
204
|
+
const SEVERITY_ORDER = { high: 0, medium: 1, low: 2, info: 3 };
|
|
205
|
+
/** Toolstile's own gateway is meant to be added to every client. */
|
|
206
|
+
const TOOLSTILE = /^url:([a-z0-9-]+\.)*toolstile\.com\//i;
|
|
207
|
+
/** A path for a shell command: `~/...` under the home folder, single-quoted when needed. */
|
|
208
|
+
export function shellPath(path, home) {
|
|
209
|
+
const quote = (p) => (/^[\w@%+=:,./-]+$/.test(p) ? p : `'${p.replace(/'/g, "'\\''")}'`);
|
|
210
|
+
return home && path.startsWith(home + "/") ? `~/${quote(path.slice(home.length + 1))}` : quote(path);
|
|
211
|
+
}
|
|
156
212
|
export function runChecks(d, home) {
|
|
157
213
|
const findings = [];
|
|
158
214
|
const secrets = d.servers.flatMap(secretsOf);
|
|
@@ -160,7 +216,10 @@ export function runChecks(d, home) {
|
|
|
160
216
|
const byServer = new Map();
|
|
161
217
|
for (const s of secrets)
|
|
162
218
|
byServer.set(s.server, [...(byServer.get(s.server) ?? []), s]);
|
|
219
|
+
const counted = new Set();
|
|
163
220
|
for (const [server, list] of byServer) {
|
|
221
|
+
const fresh = new Set(list.map((s) => s.hash).filter((h) => !counted.has(h)));
|
|
222
|
+
fresh.forEach((h) => counted.add(h));
|
|
164
223
|
const kinds = [...new Set(list.map((s) => s.kind))].join(", ");
|
|
165
224
|
const one = list.length === 1;
|
|
166
225
|
const what = one ? `${/^[aeiou]/i.test(kinds) ? "An" : "A"} ${kinds} is` : `${list.length} secrets (${kinds}) are`;
|
|
@@ -171,6 +230,8 @@ export function runChecks(d, home) {
|
|
|
171
230
|
detail: `${what} written directly into the config file. Anything that can read the file, including backups, dotfile syncs and other tools, can use ${one ? "it" : "them"}.`,
|
|
172
231
|
fix: "Reference an environment variable instead (for example ${API_TOKEN}, or \"Bearer ${API_TOKEN}\" in a header), or use the server's OAuth sign-in. Rotate the secret if the file was ever shared or synced.",
|
|
173
232
|
locations: list.map((s) => s.location),
|
|
233
|
+
// A secret pasted into several files is one leak; the copies are scored by copied-secret.
|
|
234
|
+
weight: 10 * fresh.size,
|
|
174
235
|
});
|
|
175
236
|
}
|
|
176
237
|
// The same secret copied into several files.
|
|
@@ -188,20 +249,23 @@ export function runChecks(d, home) {
|
|
|
188
249
|
detail: `One credential is pasted into ${files.size} separate config files (${[...new Set(list.map((s) => CLIENT_NAMES[s.server.client]))].join(", ")}). Rotating or revoking it means finding every copy.`,
|
|
189
250
|
fix: "Keep one copy in your environment or keychain and reference it from each client, or route the clients through one gateway.",
|
|
190
251
|
locations: list.map((s) => s.location),
|
|
252
|
+
weight: 2,
|
|
191
253
|
});
|
|
192
254
|
}
|
|
193
|
-
// Config files that hold secrets
|
|
255
|
+
// Config files that hold secrets and that other accounts on the machine can actually reach.
|
|
194
256
|
const secretFiles = new Set(secrets.map((s) => s.location.file));
|
|
195
257
|
for (const f of d.files) {
|
|
196
|
-
if (f.mode === undefined || !
|
|
258
|
+
if (f.mode === undefined || !f.exposure || !secretFiles.has(f.path))
|
|
197
259
|
continue;
|
|
260
|
+
const [open, who] = f.exposure === "others" ? ["everyone", "every account on this machine"] : ["its group", "other accounts in that group"];
|
|
198
261
|
findings.push({
|
|
199
262
|
rule: "readable-config",
|
|
200
263
|
severity: "medium",
|
|
201
|
-
title: "Config file with secrets is readable by other
|
|
202
|
-
detail: `This file
|
|
203
|
-
fix: `Restrict it to your user: chmod 600
|
|
264
|
+
title: "Config file with secrets is readable by other accounts",
|
|
265
|
+
detail: `This file holds plaintext secrets and has permissions ${f.mode.toString(8).padStart(3, "0")}, and every folder above it is open to ${open}, so ${who} can read it.`,
|
|
266
|
+
fix: `Restrict it to your user: chmod 600 ${shellPath(f.path, home)}`,
|
|
204
267
|
locations: [{ file: f.path, pointer: "(file permissions)" }],
|
|
268
|
+
weight: 4,
|
|
205
269
|
});
|
|
206
270
|
}
|
|
207
271
|
for (const s of d.servers) {
|
|
@@ -216,6 +280,7 @@ export function runChecks(d, home) {
|
|
|
216
280
|
detail: `${u.host} is reached over plain http://, so requests, tool results and any credentials travel unencrypted.`,
|
|
217
281
|
fix: "Use the server's https:// endpoint.",
|
|
218
282
|
locations: [at(s, "url")],
|
|
283
|
+
weight: 10,
|
|
219
284
|
});
|
|
220
285
|
}
|
|
221
286
|
}
|
|
@@ -234,10 +299,11 @@ export function runChecks(d, home) {
|
|
|
234
299
|
fix: pkg.registry === "docker"
|
|
235
300
|
? `Pin an image digest or exact tag, for example ${pkg.name}:<version>.`
|
|
236
301
|
: `Pin an exact version, for example ${pkg.name}${pkg.registry === "pypi" ? "==" : "@"}<version>.`,
|
|
237
|
-
locations: [at(s, "args", String(
|
|
302
|
+
locations: [pkg.argIndex >= 0 ? at(s, "args", String(pkg.argIndex)) : at(s, "command")],
|
|
303
|
+
weight: 4,
|
|
238
304
|
});
|
|
239
305
|
}
|
|
240
|
-
const w = words(s);
|
|
306
|
+
const w = words(s).list;
|
|
241
307
|
if (pkg?.registry === "docker") {
|
|
242
308
|
const risky = w.filter((a) => a === "--privileged" || /\/var\/run\/docker\.sock/.test(a) || a === "--net=host" || a === "--network=host");
|
|
243
309
|
if (risky.length) {
|
|
@@ -248,6 +314,7 @@ export function runChecks(d, home) {
|
|
|
248
314
|
detail: `The server runs with ${[...new Set(risky)].join(", ")}, which gives it effective control of the host.`,
|
|
249
315
|
fix: "Remove --privileged and the Docker socket mount unless the server truly needs them.",
|
|
250
316
|
locations: [at(s, "args")],
|
|
317
|
+
weight: 10,
|
|
251
318
|
});
|
|
252
319
|
}
|
|
253
320
|
}
|
|
@@ -260,6 +327,7 @@ export function runChecks(d, home) {
|
|
|
260
327
|
detail: `Allowed root ${roots.join(", ")} includes SSH keys, cloud credentials and every other config file.`,
|
|
261
328
|
fix: "Allow only the project folders the agent needs.",
|
|
262
329
|
locations: [at(s, "args")],
|
|
330
|
+
weight: 4,
|
|
263
331
|
});
|
|
264
332
|
}
|
|
265
333
|
if (s.autoApproved.length) {
|
|
@@ -270,6 +338,7 @@ export function runChecks(d, home) {
|
|
|
270
338
|
detail: `Auto-approved: ${s.autoApproved.slice(0, 6).join(", ")}${s.autoApproved.length > 6 ? ", …" : ""}. A prompt injection in any content the agent reads can call these.`,
|
|
271
339
|
fix: "Keep auto-approval to read-only tools.",
|
|
272
340
|
locations: [at(s)],
|
|
341
|
+
weight: 1,
|
|
273
342
|
});
|
|
274
343
|
}
|
|
275
344
|
}
|
|
@@ -284,13 +353,15 @@ export function runChecks(d, home) {
|
|
|
284
353
|
detail: `Permission rule ${a.subject} skips the confirmation prompt, including for write and delete tools. A prompt injection in a web page, issue or email the agent reads can call them.`,
|
|
285
354
|
fix: "Allow specific read-only tools (mcp__server__tool) instead of whole servers.",
|
|
286
355
|
locations: [a.location],
|
|
356
|
+
weight: 4,
|
|
287
357
|
});
|
|
288
358
|
}
|
|
289
|
-
// The same server configured separately in several clients.
|
|
359
|
+
// The same server configured separately in several clients. Context only: remote servers need an
|
|
360
|
+
// entry per client, and copies that carry credentials are already scored by copied-secret.
|
|
290
361
|
const byIdentity = new Map();
|
|
291
362
|
for (const s of d.servers) {
|
|
292
363
|
const id = identityOf(s);
|
|
293
|
-
if (id)
|
|
364
|
+
if (id && !TOOLSTILE.test(id))
|
|
294
365
|
byIdentity.set(id, [...(byIdentity.get(id) ?? []), s]);
|
|
295
366
|
}
|
|
296
367
|
for (const list of byIdentity.values()) {
|
|
@@ -299,24 +370,23 @@ export function runChecks(d, home) {
|
|
|
299
370
|
continue;
|
|
300
371
|
findings.push({
|
|
301
372
|
rule: "duplicated-server",
|
|
302
|
-
severity: "
|
|
373
|
+
severity: "info",
|
|
303
374
|
title: `${list[0].name} is set up separately in ${clients.length} clients`,
|
|
304
|
-
detail: `Configured in ${clients.map((c) => CLIENT_NAMES[c]).join(", ")}. Each copy has its own
|
|
305
|
-
fix: "
|
|
375
|
+
detail: `Configured in ${clients.map((c) => CLIENT_NAMES[c]).join(", ")}. Each copy has its own settings and sign-in to keep in sync and revoke.`,
|
|
376
|
+
fix: "Nothing to fix; listed so you know where each copy lives.",
|
|
306
377
|
locations: list.map((s) => at(s)),
|
|
378
|
+
weight: 0,
|
|
307
379
|
});
|
|
308
380
|
}
|
|
309
381
|
return findings.sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
|
|
310
382
|
}
|
|
311
|
-
|
|
312
|
-
|
|
383
|
+
/**
|
|
384
|
+
* 100 / (1 + weight / 50): one leaked secret (10) scores 83, four score about 50, and the score
|
|
385
|
+
* keeps separating bad setups from worse ones instead of bottoming out at zero.
|
|
386
|
+
*/
|
|
313
387
|
export function score(findings) {
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
total -= Math.min(n * PENALTY[sev], CAP[sev]);
|
|
318
|
-
}
|
|
319
|
-
total = Math.max(0, total);
|
|
320
|
-
const grade = total >= 90 ? "A" : total >= 80 ? "B" : total >= 65 ? "C" : total >= 50 ? "D" : "F";
|
|
388
|
+
const weight = findings.reduce((n, f) => n + f.weight, 0);
|
|
389
|
+
const total = Math.round(100 / (1 + weight / 50));
|
|
390
|
+
const grade = total >= 90 ? "A" : total >= 75 ? "B" : total >= 60 ? "C" : total >= 40 ? "D" : "F";
|
|
321
391
|
return { score: total, grade };
|
|
322
392
|
}
|
package/dist/cli.js
CHANGED
|
@@ -4,7 +4,7 @@ import { homedir } from "node:os";
|
|
|
4
4
|
import { resolve } from "node:path";
|
|
5
5
|
import { renderCard, shareText, shareUrl } from "./card.js";
|
|
6
6
|
import { audit } from "./index.js";
|
|
7
|
-
import { renderJson, renderReport, style } from "./report.js";
|
|
7
|
+
import { redact, renderJson, renderReport, style } from "./report.js";
|
|
8
8
|
const HELP = `toolstile: audit the MCP server configs your AI agents use
|
|
9
9
|
|
|
10
10
|
Usage
|
|
@@ -22,6 +22,7 @@ Options
|
|
|
22
22
|
--json Print results as JSON
|
|
23
23
|
--card [file] Write a shareable grade card (default toolstile-audit.png;
|
|
24
24
|
use a .svg name for SVG). Contains only the grade and counts.
|
|
25
|
+
--redact Hide your home folder and project paths, for posting the output
|
|
25
26
|
--project <dir> Also scan project configs in <dir> (default: current directory)
|
|
26
27
|
--no-project Scan only user-level configs
|
|
27
28
|
--fail-on <level> Exit with code 1 if any finding is at or above high, medium or low
|
|
@@ -33,7 +34,7 @@ function version() {
|
|
|
33
34
|
return pkg.version;
|
|
34
35
|
}
|
|
35
36
|
function parseArgs(argv) {
|
|
36
|
-
const args = { json: false, card: null, project: process.cwd(), failOn: null };
|
|
37
|
+
const args = { json: false, redact: false, card: null, project: process.cwd(), failOn: null };
|
|
37
38
|
const rest = [...argv];
|
|
38
39
|
if (rest[0] === "audit")
|
|
39
40
|
rest.shift();
|
|
@@ -57,6 +58,9 @@ function parseArgs(argv) {
|
|
|
57
58
|
case "--json":
|
|
58
59
|
args.json = true;
|
|
59
60
|
break;
|
|
61
|
+
case "--redact":
|
|
62
|
+
args.redact = true;
|
|
63
|
+
break;
|
|
60
64
|
case "--card": {
|
|
61
65
|
const next = inline ?? rest[i + 1];
|
|
62
66
|
if (next !== undefined && !next.startsWith("-")) {
|
|
@@ -106,7 +110,7 @@ async function writeCard(path, svg) {
|
|
|
106
110
|
return fallback;
|
|
107
111
|
}
|
|
108
112
|
}
|
|
109
|
-
const RANK = { high: 3, medium: 2, low: 1 };
|
|
113
|
+
const RANK = { high: 3, medium: 2, low: 1, info: 0 };
|
|
110
114
|
async function main() {
|
|
111
115
|
let args;
|
|
112
116
|
try {
|
|
@@ -125,13 +129,14 @@ async function main() {
|
|
|
125
129
|
return 0;
|
|
126
130
|
}
|
|
127
131
|
const home = homedir();
|
|
128
|
-
const
|
|
132
|
+
const raw = audit({ home, cwd: args.project });
|
|
133
|
+
const result = args.redact ? redact(raw, home, args.project) : raw;
|
|
129
134
|
const color = !args.json && process.stdout.isTTY === true && !process.env.NO_COLOR;
|
|
130
135
|
const s = style(color);
|
|
131
136
|
if (args.json)
|
|
132
137
|
process.stdout.write(renderJson(result));
|
|
133
138
|
else
|
|
134
|
-
process.stdout.write(renderReport(result, home, s));
|
|
139
|
+
process.stdout.write(renderReport(result, home, s, args.redact));
|
|
135
140
|
if (args.card) {
|
|
136
141
|
if (result.grade === null) {
|
|
137
142
|
process.stderr.write("No MCP servers found, so there is no grade to put on a card.\n");
|
package/dist/parse.d.ts
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* A config file that could not be parsed. The message is fixed text plus a position: parser
|
|
3
|
+
* messages quote the offending source line, which can hold a secret, so they are never kept.
|
|
4
|
+
*/
|
|
5
|
+
export declare class ConfigParseError extends Error {
|
|
6
|
+
readonly format: "JSON" | "TOML";
|
|
7
|
+
readonly line?: number | undefined;
|
|
8
|
+
readonly column?: number | undefined;
|
|
9
|
+
constructor(format: "JSON" | "TOML", line?: number | undefined, column?: number | undefined);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Parses JSON that may contain comments and trailing commas (VS Code style). Comments and
|
|
13
|
+
* trailing commas become spaces so parser positions still match the original text.
|
|
14
|
+
*/
|
|
2
15
|
export declare function parseJsonc(text: string): unknown;
|
|
16
|
+
/** Parses a config file. Throws only {@link ConfigParseError}, whose message never contains file text. */
|
|
3
17
|
export declare function parseConfig(path: string, text: string): unknown;
|
package/dist/parse.js
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
|
-
import { parse as parseToml } from "smol-toml";
|
|
2
|
-
/**
|
|
1
|
+
import { parse as parseToml, TomlError } from "smol-toml";
|
|
2
|
+
/**
|
|
3
|
+
* A config file that could not be parsed. The message is fixed text plus a position: parser
|
|
4
|
+
* messages quote the offending source line, which can hold a secret, so they are never kept.
|
|
5
|
+
*/
|
|
6
|
+
export class ConfigParseError extends Error {
|
|
7
|
+
format;
|
|
8
|
+
line;
|
|
9
|
+
column;
|
|
10
|
+
constructor(format, line, column) {
|
|
11
|
+
super(`invalid ${format}${line ? ` at line ${line}${column ? `, column ${column}` : ""}` : ""}`);
|
|
12
|
+
this.format = format;
|
|
13
|
+
this.line = line;
|
|
14
|
+
this.column = column;
|
|
15
|
+
this.name = "ConfigParseError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Parses JSON that may contain comments and trailing commas (VS Code style). Comments and
|
|
20
|
+
* trailing commas become spaces so parser positions still match the original text.
|
|
21
|
+
*/
|
|
3
22
|
export function parseJsonc(text) {
|
|
4
23
|
let out = "";
|
|
5
24
|
let inString = false;
|
|
@@ -22,15 +41,19 @@ export function parseJsonc(text) {
|
|
|
22
41
|
out += ch;
|
|
23
42
|
}
|
|
24
43
|
else if (ch === "/" && next === "/") {
|
|
25
|
-
while (i < text.length && text[i] !== "\n")
|
|
44
|
+
while (i < text.length && text[i] !== "\n") {
|
|
45
|
+
out += blank(text[i]);
|
|
26
46
|
i++;
|
|
27
|
-
|
|
47
|
+
}
|
|
48
|
+
if (i < text.length)
|
|
49
|
+
out += "\n";
|
|
28
50
|
}
|
|
29
51
|
else if (ch === "/" && next === "*") {
|
|
30
|
-
i
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
52
|
+
const end = text.indexOf("*/", i + 2);
|
|
53
|
+
const stop = end === -1 ? text.length : end + 2;
|
|
54
|
+
for (; i < stop; i++)
|
|
55
|
+
out += blank(text[i]);
|
|
56
|
+
i--;
|
|
34
57
|
}
|
|
35
58
|
else {
|
|
36
59
|
out += ch;
|
|
@@ -38,6 +61,8 @@ export function parseJsonc(text) {
|
|
|
38
61
|
}
|
|
39
62
|
return JSON.parse(stripTrailingCommas(out));
|
|
40
63
|
}
|
|
64
|
+
/** Keeps line breaks so line numbers survive; everything else becomes a space. */
|
|
65
|
+
const blank = (ch) => (ch === "\n" || ch === "\r" ? ch : " ");
|
|
41
66
|
function stripTrailingCommas(text) {
|
|
42
67
|
let out = "";
|
|
43
68
|
let inString = false;
|
|
@@ -60,17 +85,51 @@ function stripTrailingCommas(text) {
|
|
|
60
85
|
let j = i + 1;
|
|
61
86
|
while (j < text.length && /\s/.test(text[j]))
|
|
62
87
|
j++;
|
|
63
|
-
if (text[j] === "}" || text[j] === "]")
|
|
88
|
+
if (text[j] === "}" || text[j] === "]") {
|
|
89
|
+
out += " ";
|
|
64
90
|
continue;
|
|
91
|
+
}
|
|
65
92
|
}
|
|
66
93
|
out += ch;
|
|
67
94
|
}
|
|
68
95
|
return out;
|
|
69
96
|
}
|
|
97
|
+
/** 1-based line and column of a character offset. */
|
|
98
|
+
function lineColumn(text, offset) {
|
|
99
|
+
const before = text.slice(0, Math.min(Math.max(offset, 0), text.length));
|
|
100
|
+
const lastBreak = before.lastIndexOf("\n");
|
|
101
|
+
return { line: before.split("\n").length, column: offset - lastBreak };
|
|
102
|
+
}
|
|
103
|
+
/** Reads only the position from a V8 JSON error; the rest of its message may quote the file. */
|
|
104
|
+
function jsonPosition(error, text) {
|
|
105
|
+
const message = error instanceof Error ? error.message : "";
|
|
106
|
+
const at = /\bposition (\d+)/.exec(message);
|
|
107
|
+
if (at)
|
|
108
|
+
return lineColumn(text, Number(at[1]));
|
|
109
|
+
if (/Unexpected end of JSON input/.test(message))
|
|
110
|
+
return lineColumn(text, text.length);
|
|
111
|
+
return {};
|
|
112
|
+
}
|
|
113
|
+
/** Parses a config file. Throws only {@link ConfigParseError}, whose message never contains file text. */
|
|
70
114
|
export function parseConfig(path, text) {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
115
|
+
const body = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
116
|
+
if (path.endsWith(".toml")) {
|
|
117
|
+
try {
|
|
118
|
+
return parseToml(body);
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
if (error instanceof TomlError)
|
|
122
|
+
throw new ConfigParseError("TOML", error.line, error.column);
|
|
123
|
+
throw new ConfigParseError("TOML");
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (body.trim() === "")
|
|
74
127
|
return {};
|
|
75
|
-
|
|
128
|
+
try {
|
|
129
|
+
return parseJsonc(body);
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
const { line, column } = jsonPosition(error, body);
|
|
133
|
+
throw new ConfigParseError("JSON", line, column);
|
|
134
|
+
}
|
|
76
135
|
}
|
package/dist/report.d.ts
CHANGED
|
@@ -11,8 +11,14 @@ export declare function counts(findings: Finding[]): {
|
|
|
11
11
|
high: number;
|
|
12
12
|
medium: number;
|
|
13
13
|
low: number;
|
|
14
|
+
info: number;
|
|
14
15
|
secrets: number;
|
|
15
16
|
};
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Returns a copy safe to post publicly: the home folder becomes `~`, the scanned project folder
|
|
19
|
+
* becomes `<project>`, and project paths inside pointers (Claude Code's `projects["/path"]`) are hidden.
|
|
20
|
+
*/
|
|
21
|
+
export declare function redact(r: AuditResult, home: string, project: string | null): AuditResult;
|
|
22
|
+
export declare function renderReport(r: AuditResult, home: string, s: Style, redacted?: boolean): string;
|
|
23
|
+
/** JSON output for scripts. Paths are included unless redacted; secret values never are. */
|
|
18
24
|
export declare function renderJson(r: AuditResult): string;
|
package/dist/report.js
CHANGED
|
@@ -5,10 +5,11 @@ export function style(color) {
|
|
|
5
5
|
const yellow = wrap(33, 39);
|
|
6
6
|
const blue = wrap(34, 39);
|
|
7
7
|
const green = wrap(32, 39);
|
|
8
|
+
const dim = wrap(2, 22);
|
|
8
9
|
return {
|
|
9
10
|
bold: wrap(1, 22),
|
|
10
|
-
dim
|
|
11
|
-
sev: (sev, s) => (sev === "high" ? red(s) : sev === "medium" ? yellow(s) : blue(s)),
|
|
11
|
+
dim,
|
|
12
|
+
sev: (sev, s) => (sev === "high" ? red(s) : sev === "medium" ? yellow(s) : sev === "low" ? blue(s) : dim(s)),
|
|
12
13
|
grade: (g, s) => (g === "A" || g === "B" ? green(s) : g === "C" ? yellow(s) : red(s)),
|
|
13
14
|
};
|
|
14
15
|
}
|
|
@@ -19,10 +20,37 @@ export function counts(findings) {
|
|
|
19
20
|
high: findings.filter((f) => f.severity === "high").length,
|
|
20
21
|
medium: findings.filter((f) => f.severity === "medium").length,
|
|
21
22
|
low: findings.filter((f) => f.severity === "low").length,
|
|
23
|
+
info: findings.filter((f) => f.severity === "info").length,
|
|
22
24
|
secrets: findings.filter((f) => f.rule === "plaintext-secret").reduce((n, f) => n + f.locations.length, 0),
|
|
23
25
|
};
|
|
24
26
|
}
|
|
25
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Returns a copy safe to post publicly: the home folder becomes `~`, the scanned project folder
|
|
29
|
+
* becomes `<project>`, and project paths inside pointers (Claude Code's `projects["/path"]`) are hidden.
|
|
30
|
+
*/
|
|
31
|
+
export function redact(r, home, project) {
|
|
32
|
+
const scrub = (text) => {
|
|
33
|
+
let out = text.replace(/projects\["(?:[^"\\]|\\.)*"\]/g, 'projects["…"]');
|
|
34
|
+
if (project)
|
|
35
|
+
out = out.split(project).join("<project>");
|
|
36
|
+
if (home)
|
|
37
|
+
out = out.split(home).join("~");
|
|
38
|
+
return out;
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
...r,
|
|
42
|
+
files: r.files.map((f) => ({ ...f, path: scrub(f.path) })),
|
|
43
|
+
unreadable: r.unreadable.map((u) => ({ ...u, path: scrub(u.path), error: scrub(u.error) })),
|
|
44
|
+
findings: r.findings.map((f) => ({
|
|
45
|
+
...f,
|
|
46
|
+
title: scrub(f.title),
|
|
47
|
+
detail: scrub(f.detail),
|
|
48
|
+
fix: scrub(f.fix),
|
|
49
|
+
locations: f.locations.map((l) => ({ file: scrub(l.file), pointer: scrub(l.pointer) })),
|
|
50
|
+
})),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export function renderReport(r, home, s, redacted = false) {
|
|
26
54
|
const lines = [];
|
|
27
55
|
lines.push("", `${s.bold("toolstile audit")} ${s.dim("· local MCP config check · nothing leaves this machine")}`, "");
|
|
28
56
|
if (r.files.length === 0 && r.unreadable.length === 0) {
|
|
@@ -32,19 +60,22 @@ export function renderReport(r, home, s) {
|
|
|
32
60
|
const clientList = r.clients.map((c) => CLIENT_NAMES[c]).join(", ");
|
|
33
61
|
lines.push(`Scanned ${plural(r.files.length, "file")} from ${clientList}: ${plural(r.servers, "MCP server")}.`);
|
|
34
62
|
for (const u of r.unreadable) {
|
|
35
|
-
lines.push(s.dim(`Skipped ${tildify(u.path, home)} (${CLIENT_NAMES[u.client]}):
|
|
63
|
+
lines.push(s.dim(`Skipped ${tildify(u.path, home)} (${CLIENT_NAMES[u.client]}): ${u.error}.`));
|
|
36
64
|
}
|
|
37
65
|
lines.push("");
|
|
38
66
|
if (r.grade !== null) {
|
|
39
67
|
const c = counts(r.findings);
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
68
|
+
const parts = [
|
|
69
|
+
c.high && s.sev("high", `${c.high} high`),
|
|
70
|
+
c.medium && s.sev("medium", `${c.medium} medium`),
|
|
71
|
+
c.low && s.sev("low", `${c.low} low`),
|
|
72
|
+
].filter(Boolean);
|
|
73
|
+
const summary = parts.length ? parts.join(s.dim(" · ")) : "no issues found";
|
|
45
74
|
lines.push(` ${s.bold(s.grade(r.grade, `Grade ${r.grade}`))} ${s.dim(`${r.score}/100`)} ${summary}`, "");
|
|
46
75
|
}
|
|
47
|
-
|
|
76
|
+
const issues = r.findings.filter((f) => f.severity !== "info");
|
|
77
|
+
const info = r.findings.filter((f) => f.severity === "info");
|
|
78
|
+
for (const f of issues) {
|
|
48
79
|
const tag = s.sev(f.severity, f.severity.toUpperCase().padEnd(6));
|
|
49
80
|
lines.push(`${tag} ${s.bold(f.title)}`);
|
|
50
81
|
lines.push(` ${f.detail}`);
|
|
@@ -55,10 +86,21 @@ export function renderReport(r, home, s) {
|
|
|
55
86
|
lines.push(s.dim(` … and ${f.locations.length - shown.length} more`));
|
|
56
87
|
lines.push(` ${s.dim("Fix:")} ${f.fix}`, "");
|
|
57
88
|
}
|
|
58
|
-
|
|
89
|
+
if (info.length) {
|
|
90
|
+
lines.push(s.dim("Also set up in more than one client (not scored):"));
|
|
91
|
+
for (const f of info)
|
|
92
|
+
lines.push(s.dim(` ${f.title.replace(/ is set up separately in (\d+) clients$/, "")}: ${f.detail.split(".")[0].replace(/^Configured in /, "")}`));
|
|
93
|
+
lines.push("");
|
|
94
|
+
}
|
|
95
|
+
lines.push(s.dim("Secret values are never printed."));
|
|
96
|
+
if (!redacted)
|
|
97
|
+
lines.push(s.dim("Posting this? Run with --redact to hide your paths, or --card for an image with only the grade."));
|
|
98
|
+
else
|
|
99
|
+
lines.push(s.dim("Share an image with only the grade: npx toolstile audit --card"));
|
|
100
|
+
lines.push(s.dim("Keep credentials off your machines and control each agent's tools: https://toolstile.com/?ref=cli"), "");
|
|
59
101
|
return lines.join("\n");
|
|
60
102
|
}
|
|
61
|
-
/** JSON output for scripts. Paths are included; secret values never are. */
|
|
103
|
+
/** JSON output for scripts. Paths are included unless redacted; secret values never are. */
|
|
62
104
|
export function renderJson(r) {
|
|
63
105
|
return JSON.stringify(r, null, 2) + "\n";
|
|
64
106
|
}
|
package/dist/secrets.js
CHANGED
|
@@ -28,9 +28,9 @@ const PATTERNS = [
|
|
|
28
28
|
[/\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/, "JWT"],
|
|
29
29
|
[/\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:[^\s@/]{3,}@/i, "password in connection string"],
|
|
30
30
|
];
|
|
31
|
-
const SECRET_KEY = /(token|secret|passw(or)?d|passwd|api[_-]?key|apikey|access[_-]?key|private[_-]?key|client[_-]?secret|credential|auth|bearer|session|cookie|\bpat\b|_pat$|^pat_)/i;
|
|
32
|
-
/** Key suffixes that name configuration about a secret rather than the secret itself. */
|
|
33
|
-
const NOT_SECRET_KEY = /(url|uri|endpoint|host|port|path|file|dir|type|mode|enabled|header|name|user(name)?|id|region|scope|scopes|method|env_var|_var)$/i;
|
|
31
|
+
const SECRET_KEY = /(token|secret|passw(or)?d|passwd|api[_-]?key|apikey|access[_-]?key|private[_-]?key|client[_-]?secret|credential|auth|bearer|session|cookie|\bpat\b|_pat$|^pat_|(^|[_-])key$)/i;
|
|
32
|
+
/** Key suffixes that name configuration about a secret, or a public half, rather than the secret itself. */
|
|
33
|
+
const NOT_SECRET_KEY = /(url|uri|endpoint|host|port|path|file|dir|type|mode|enabled|header|name|user(name)?|id|region|scope|scopes|method|env_var|_var|public[_-]?key)$/i;
|
|
34
34
|
const PLACEHOLDER = /^(<.*>|\{.*\}|\[.*\]|x{4,}|\*{3,}|\.{3,}|your[-_ ].*|change[-_ ]?me|todo|placeholder|example|redacted|none|null|undefined|true|false|test|secret|token)$/i;
|
|
35
35
|
/** True for `${VAR}`, `${env:VAR}`, `$VAR`, `%VAR%` and `{env:VAR}` references. */
|
|
36
36
|
export function isReference(value) {
|
package/dist/sources.d.ts
CHANGED
|
@@ -9,6 +9,11 @@ export interface ScanOptions {
|
|
|
9
9
|
export declare const CLIENT_NAMES: Record<ClientId, string>;
|
|
10
10
|
/** Appends a key to a pointer, quoting keys that are not plain identifiers. */
|
|
11
11
|
export declare function pointerJoin(base: string, key: string): string;
|
|
12
|
+
/**
|
|
13
|
+
* Who besides the owner can read a file, given its mode and the modes of every folder above it.
|
|
14
|
+
* A group or other user needs read on the file and execute (traverse) on each folder.
|
|
15
|
+
*/
|
|
16
|
+
export declare function exposureOf(fileMode: number, ancestorModes: number[]): "group" | "others" | null;
|
|
12
17
|
export interface Discovery {
|
|
13
18
|
files: ConfigFile[];
|
|
14
19
|
unreadable: {
|
package/dist/sources.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFileSync, statSync } from "node:fs";
|
|
2
|
-
import { join, resolve } from "node:path";
|
|
3
|
-
import { parseConfig } from "./parse.js";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { ConfigParseError, parseConfig } from "./parse.js";
|
|
4
4
|
export const CLIENT_NAMES = {
|
|
5
5
|
"claude-code": "Claude Code",
|
|
6
6
|
"claude-desktop": "Claude Desktop",
|
|
@@ -121,6 +121,42 @@ function claudeApprovals(file) {
|
|
|
121
121
|
});
|
|
122
122
|
return out;
|
|
123
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* Who besides the owner can read a file, given its mode and the modes of every folder above it.
|
|
126
|
+
* A group or other user needs read on the file and execute (traverse) on each folder.
|
|
127
|
+
*/
|
|
128
|
+
export function exposureOf(fileMode, ancestorModes) {
|
|
129
|
+
if (fileMode & 0o004 && ancestorModes.every((m) => m & 0o001))
|
|
130
|
+
return "others";
|
|
131
|
+
if (fileMode & 0o040 && ancestorModes.every((m) => m & 0o010))
|
|
132
|
+
return "group";
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
function ancestorModes(path) {
|
|
136
|
+
const modes = [];
|
|
137
|
+
let dir = dirname(path);
|
|
138
|
+
for (;;) {
|
|
139
|
+
try {
|
|
140
|
+
modes.push(statSync(dir).mode & 0o777);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
// An unreadable folder is not traversable by us either; treat it as closed.
|
|
144
|
+
modes.push(0);
|
|
145
|
+
}
|
|
146
|
+
const parent = dirname(dir);
|
|
147
|
+
if (parent === dir)
|
|
148
|
+
return modes;
|
|
149
|
+
dir = parent;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/** A fixed description of a read failure; system messages are not passed through. */
|
|
153
|
+
function readError(code) {
|
|
154
|
+
if (code === "EACCES" || code === "EPERM")
|
|
155
|
+
return "permission denied";
|
|
156
|
+
if (code === "EISDIR")
|
|
157
|
+
return "is a directory";
|
|
158
|
+
return code && /^E[A-Z]+$/.test(code) ? `could not read it (${code})` : "could not read it";
|
|
159
|
+
}
|
|
124
160
|
export function discover(opts) {
|
|
125
161
|
const result = { files: [], unreadable: [], servers: [], approvals: [] };
|
|
126
162
|
for (const c of candidates(opts)) {
|
|
@@ -133,7 +169,12 @@ export function discover(opts) {
|
|
|
133
169
|
mode = opts.platform === "win32" ? undefined : stat.mode & 0o777;
|
|
134
170
|
text = readFileSync(c.path, "utf8");
|
|
135
171
|
}
|
|
136
|
-
catch {
|
|
172
|
+
catch (error) {
|
|
173
|
+
const code = error.code;
|
|
174
|
+
// Absent files are normal; anything else means a config exists that we could not check.
|
|
175
|
+
if (code !== "ENOENT" && code !== "ENOTDIR") {
|
|
176
|
+
result.unreadable.push({ client: c.client, path: c.path, error: readError(code) });
|
|
177
|
+
}
|
|
137
178
|
continue;
|
|
138
179
|
}
|
|
139
180
|
let data;
|
|
@@ -141,10 +182,13 @@ export function discover(opts) {
|
|
|
141
182
|
data = parseConfig(c.path, text);
|
|
142
183
|
}
|
|
143
184
|
catch (error) {
|
|
144
|
-
|
|
185
|
+
// parseConfig's messages are fixed text plus a position, never file contents.
|
|
186
|
+
const message = error instanceof ConfigParseError ? error.message : "could not parse it";
|
|
187
|
+
result.unreadable.push({ client: c.client, path: c.path, error: message });
|
|
145
188
|
continue;
|
|
146
189
|
}
|
|
147
|
-
const
|
|
190
|
+
const exposure = mode === undefined ? undefined : exposureOf(mode, ancestorModes(c.path));
|
|
191
|
+
const file = { client: c.client, path: c.path, data, mode, exposure };
|
|
148
192
|
result.files.push(file);
|
|
149
193
|
if (!isObject(data))
|
|
150
194
|
continue;
|
package/dist/types.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
|
|
1
|
+
/** `info` findings are context only and do not affect the score. */
|
|
2
|
+
export type Severity = "high" | "medium" | "low" | "info";
|
|
2
3
|
export type ClientId = "claude-code" | "claude-desktop" | "cursor" | "codex" | "vscode" | "windsurf" | "gemini" | "cline";
|
|
3
4
|
export interface Location {
|
|
4
5
|
/** Absolute path of the config file. */
|
|
@@ -13,6 +14,8 @@ export interface ConfigFile {
|
|
|
13
14
|
data: unknown;
|
|
14
15
|
/** Unix permission bits, when available. */
|
|
15
16
|
mode?: number;
|
|
17
|
+
/** Who besides the owner can read the file, counting the folders above it. */
|
|
18
|
+
exposure?: "group" | "others" | null;
|
|
16
19
|
}
|
|
17
20
|
export interface ServerEntry {
|
|
18
21
|
client: ClientId;
|
|
@@ -44,13 +47,18 @@ export interface Finding {
|
|
|
44
47
|
detail: string;
|
|
45
48
|
fix: string;
|
|
46
49
|
locations: Location[];
|
|
50
|
+
/** Points this finding takes off the score; repeats of one root cause count once. */
|
|
51
|
+
weight: number;
|
|
47
52
|
}
|
|
48
53
|
export interface AuditResult {
|
|
49
54
|
files: {
|
|
50
55
|
client: ClientId;
|
|
51
56
|
path: string;
|
|
52
57
|
}[];
|
|
53
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* Files that exist but could not be read or parsed. `error` is a fixed description with at most
|
|
60
|
+
* a line and column, for example `invalid TOML at line 3, column 12`; it never quotes the file.
|
|
61
|
+
*/
|
|
54
62
|
unreadable: {
|
|
55
63
|
client: ClientId;
|
|
56
64
|
path: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolstile",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Audit the MCP server configs your AI agents use for plaintext secrets, unpinned packages, auto-approved tools and duplicated setups. Local and read-only.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"scripts": {
|
|
38
38
|
"build": "rm -rf dist && tsc -p tsconfig.build.json && chmod +x dist/cli.js",
|
|
39
39
|
"typecheck": "tsc --noEmit",
|
|
40
|
-
"test": "vitest run",
|
|
41
|
-
"prepublishOnly": "pnpm typecheck && pnpm
|
|
40
|
+
"test": "pnpm build && vitest run",
|
|
41
|
+
"prepublishOnly": "pnpm typecheck && pnpm test"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"smol-toml": "^1.9.1"
|