toolstile 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +125 -2
- package/dist/card.d.ts +6 -0
- package/dist/card.js +51 -0
- package/dist/checks.d.ts +24 -0
- package/dist/checks.js +343 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +160 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +25 -0
- package/dist/parse.d.ts +3 -0
- package/dist/parse.js +76 -0
- package/dist/report.d.ts +24 -0
- package/dist/report.js +106 -0
- package/dist/secrets.d.ts +18 -0
- package/dist/secrets.js +122 -0
- package/dist/sources.d.ts +27 -0
- package/dist/sources.js +211 -0
- package/dist/types.d.ts +69 -0
- package/dist/types.js +1 -0
- package/package.json +53 -4
package/dist/cli.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import { renderCard, shareText, shareUrl } from "./card.js";
|
|
6
|
+
import { audit } from "./index.js";
|
|
7
|
+
import { redact, renderJson, renderReport, style } from "./report.js";
|
|
8
|
+
const HELP = `toolstile: audit the MCP server configs your AI agents use
|
|
9
|
+
|
|
10
|
+
Usage
|
|
11
|
+
npx toolstile audit [options]
|
|
12
|
+
|
|
13
|
+
Checks Claude Code, Claude Desktop, Cursor, Codex, VS Code, Windsurf,
|
|
14
|
+
Gemini CLI and Cline configs for plaintext secrets, credentials copied
|
|
15
|
+
between clients, unpinned packages, auto-approved tools, broad
|
|
16
|
+
filesystem access and unencrypted remote servers.
|
|
17
|
+
|
|
18
|
+
It only reads files. It sends nothing over the network and never
|
|
19
|
+
prints secret values.
|
|
20
|
+
|
|
21
|
+
Options
|
|
22
|
+
--json Print results as JSON
|
|
23
|
+
--card [file] Write a shareable grade card (default toolstile-audit.png;
|
|
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
|
|
26
|
+
--project <dir> Also scan project configs in <dir> (default: current directory)
|
|
27
|
+
--no-project Scan only user-level configs
|
|
28
|
+
--fail-on <level> Exit with code 1 if any finding is at or above high, medium or low
|
|
29
|
+
-h, --help Show this help
|
|
30
|
+
-v, --version Show the version
|
|
31
|
+
`;
|
|
32
|
+
function version() {
|
|
33
|
+
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
34
|
+
return pkg.version;
|
|
35
|
+
}
|
|
36
|
+
function parseArgs(argv) {
|
|
37
|
+
const args = { json: false, redact: false, card: null, project: process.cwd(), failOn: null };
|
|
38
|
+
const rest = [...argv];
|
|
39
|
+
if (rest[0] === "audit")
|
|
40
|
+
rest.shift();
|
|
41
|
+
for (let i = 0; i < rest.length; i++) {
|
|
42
|
+
const a = rest[i];
|
|
43
|
+
const [flag, inline] = a.startsWith("--") && a.includes("=") ? [a.slice(0, a.indexOf("=")), a.slice(a.indexOf("=") + 1)] : [a, undefined];
|
|
44
|
+
const value = () => {
|
|
45
|
+
const v = inline ?? rest[++i];
|
|
46
|
+
if (v === undefined || v.startsWith("-"))
|
|
47
|
+
throw new Error(`${flag} needs a value`);
|
|
48
|
+
return v;
|
|
49
|
+
};
|
|
50
|
+
switch (flag) {
|
|
51
|
+
case "-h":
|
|
52
|
+
case "--help":
|
|
53
|
+
case "help":
|
|
54
|
+
return "help";
|
|
55
|
+
case "-v":
|
|
56
|
+
case "--version":
|
|
57
|
+
return "version";
|
|
58
|
+
case "--json":
|
|
59
|
+
args.json = true;
|
|
60
|
+
break;
|
|
61
|
+
case "--redact":
|
|
62
|
+
args.redact = true;
|
|
63
|
+
break;
|
|
64
|
+
case "--card": {
|
|
65
|
+
const next = inline ?? rest[i + 1];
|
|
66
|
+
if (next !== undefined && !next.startsWith("-")) {
|
|
67
|
+
args.card = next;
|
|
68
|
+
if (inline === undefined)
|
|
69
|
+
i++;
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
args.card = "toolstile-audit.png";
|
|
73
|
+
}
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
case "--project":
|
|
77
|
+
args.project = resolve(value());
|
|
78
|
+
break;
|
|
79
|
+
case "--no-project":
|
|
80
|
+
args.project = null;
|
|
81
|
+
break;
|
|
82
|
+
case "--fail-on": {
|
|
83
|
+
const v = value();
|
|
84
|
+
if (v !== "high" && v !== "medium" && v !== "low")
|
|
85
|
+
throw new Error("--fail-on must be high, medium or low");
|
|
86
|
+
args.failOn = v;
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
default:
|
|
90
|
+
throw new Error(`Unknown option: ${a}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return args;
|
|
94
|
+
}
|
|
95
|
+
async function writeCard(path, svg) {
|
|
96
|
+
if (path.toLowerCase().endsWith(".svg")) {
|
|
97
|
+
writeFileSync(path, svg);
|
|
98
|
+
return path;
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
const { Resvg } = await import("@resvg/resvg-js");
|
|
102
|
+
const png = new Resvg(svg, { font: { loadSystemFonts: true, defaultFontFamily: "Helvetica" } }).render().asPng();
|
|
103
|
+
writeFileSync(path, png);
|
|
104
|
+
return path;
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// The optional PNG renderer is unavailable on this platform; fall back to SVG.
|
|
108
|
+
const fallback = path.replace(/\.[^./\\]+$/, "") + ".svg";
|
|
109
|
+
writeFileSync(fallback, svg);
|
|
110
|
+
return fallback;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const RANK = { high: 3, medium: 2, low: 1, info: 0 };
|
|
114
|
+
async function main() {
|
|
115
|
+
let args;
|
|
116
|
+
try {
|
|
117
|
+
args = parseArgs(process.argv.slice(2));
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
process.stderr.write(`${error.message}\n\n${HELP}`);
|
|
121
|
+
return 2;
|
|
122
|
+
}
|
|
123
|
+
if (args === "help") {
|
|
124
|
+
process.stdout.write(HELP);
|
|
125
|
+
return 0;
|
|
126
|
+
}
|
|
127
|
+
if (args === "version") {
|
|
128
|
+
process.stdout.write(`${version()}\n`);
|
|
129
|
+
return 0;
|
|
130
|
+
}
|
|
131
|
+
const home = homedir();
|
|
132
|
+
const raw = audit({ home, cwd: args.project });
|
|
133
|
+
const result = args.redact ? redact(raw, home, args.project) : raw;
|
|
134
|
+
const color = !args.json && process.stdout.isTTY === true && !process.env.NO_COLOR;
|
|
135
|
+
const s = style(color);
|
|
136
|
+
if (args.json)
|
|
137
|
+
process.stdout.write(renderJson(result));
|
|
138
|
+
else
|
|
139
|
+
process.stdout.write(renderReport(result, home, s, args.redact));
|
|
140
|
+
if (args.card) {
|
|
141
|
+
if (result.grade === null) {
|
|
142
|
+
process.stderr.write("No MCP servers found, so there is no grade to put on a card.\n");
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
const written = await writeCard(resolve(args.card), renderCard(result));
|
|
146
|
+
const out = args.json ? process.stderr : process.stdout;
|
|
147
|
+
out.write(`Card written to ${written}\n\n${shareText(result)}\n\nPost it: ${shareUrl(result)}\n`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (args.failOn) {
|
|
151
|
+
const threshold = RANK[args.failOn];
|
|
152
|
+
if (result.findings.some((f) => RANK[f.severity] >= threshold))
|
|
153
|
+
return 1;
|
|
154
|
+
}
|
|
155
|
+
return 0;
|
|
156
|
+
}
|
|
157
|
+
main().then((code) => process.exit(code), (error) => {
|
|
158
|
+
process.stderr.write(`toolstile: ${error.stack ?? String(error)}\n`);
|
|
159
|
+
process.exit(2);
|
|
160
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type ScanOptions } from "./sources.js";
|
|
2
|
+
import type { AuditResult } from "./types.js";
|
|
3
|
+
export type { AuditResult, ClientId, Finding, Location, Severity } from "./types.js";
|
|
4
|
+
export type { ScanOptions } from "./sources.js";
|
|
5
|
+
/** Scans local MCP client configs and returns findings. Reads files only; never writes or sends anything. */
|
|
6
|
+
export declare function audit(options?: Partial<ScanOptions>): AuditResult;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { runChecks, score } from "./checks.js";
|
|
3
|
+
import { discover } from "./sources.js";
|
|
4
|
+
/** Scans local MCP client configs and returns findings. Reads files only; never writes or sends anything. */
|
|
5
|
+
export function audit(options = {}) {
|
|
6
|
+
const opts = {
|
|
7
|
+
home: options.home ?? homedir(),
|
|
8
|
+
cwd: options.cwd === undefined ? process.cwd() : options.cwd,
|
|
9
|
+
platform: options.platform ?? process.platform,
|
|
10
|
+
env: options.env ?? process.env,
|
|
11
|
+
};
|
|
12
|
+
const d = discover(opts);
|
|
13
|
+
const findings = runChecks(d, opts.home);
|
|
14
|
+
const clients = [...new Set(d.files.map((f) => f.client))];
|
|
15
|
+
const graded = d.servers.length > 0 || findings.length > 0 ? score(findings) : null;
|
|
16
|
+
return {
|
|
17
|
+
files: d.files.map((f) => ({ client: f.client, path: f.path })),
|
|
18
|
+
unreadable: d.unreadable,
|
|
19
|
+
servers: d.servers.length,
|
|
20
|
+
clients,
|
|
21
|
+
findings,
|
|
22
|
+
score: graded?.score ?? null,
|
|
23
|
+
grade: graded?.grade ?? null,
|
|
24
|
+
};
|
|
25
|
+
}
|
package/dist/parse.d.ts
ADDED
package/dist/parse.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { parse as parseToml } from "smol-toml";
|
|
2
|
+
/** Parses JSON that may contain comments and trailing commas (VS Code style). */
|
|
3
|
+
export function parseJsonc(text) {
|
|
4
|
+
let out = "";
|
|
5
|
+
let inString = false;
|
|
6
|
+
for (let i = 0; i < text.length; i++) {
|
|
7
|
+
const ch = text[i];
|
|
8
|
+
const next = text[i + 1];
|
|
9
|
+
if (inString) {
|
|
10
|
+
out += ch;
|
|
11
|
+
if (ch === "\\") {
|
|
12
|
+
out += next ?? "";
|
|
13
|
+
i++;
|
|
14
|
+
}
|
|
15
|
+
else if (ch === '"') {
|
|
16
|
+
inString = false;
|
|
17
|
+
}
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
if (ch === '"') {
|
|
21
|
+
inString = true;
|
|
22
|
+
out += ch;
|
|
23
|
+
}
|
|
24
|
+
else if (ch === "/" && next === "/") {
|
|
25
|
+
while (i < text.length && text[i] !== "\n")
|
|
26
|
+
i++;
|
|
27
|
+
out += "\n";
|
|
28
|
+
}
|
|
29
|
+
else if (ch === "/" && next === "*") {
|
|
30
|
+
i += 2;
|
|
31
|
+
while (i < text.length && !(text[i] === "*" && text[i + 1] === "/"))
|
|
32
|
+
i++;
|
|
33
|
+
i++;
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
out += ch;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return JSON.parse(stripTrailingCommas(out));
|
|
40
|
+
}
|
|
41
|
+
function stripTrailingCommas(text) {
|
|
42
|
+
let out = "";
|
|
43
|
+
let inString = false;
|
|
44
|
+
for (let i = 0; i < text.length; i++) {
|
|
45
|
+
const ch = text[i];
|
|
46
|
+
if (inString) {
|
|
47
|
+
out += ch;
|
|
48
|
+
if (ch === "\\") {
|
|
49
|
+
out += text[i + 1] ?? "";
|
|
50
|
+
i++;
|
|
51
|
+
}
|
|
52
|
+
else if (ch === '"') {
|
|
53
|
+
inString = false;
|
|
54
|
+
}
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (ch === '"')
|
|
58
|
+
inString = true;
|
|
59
|
+
if (ch === ",") {
|
|
60
|
+
let j = i + 1;
|
|
61
|
+
while (j < text.length && /\s/.test(text[j]))
|
|
62
|
+
j++;
|
|
63
|
+
if (text[j] === "}" || text[j] === "]")
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
out += ch;
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
export function parseConfig(path, text) {
|
|
71
|
+
if (path.endsWith(".toml"))
|
|
72
|
+
return parseToml(text);
|
|
73
|
+
if (text.trim() === "")
|
|
74
|
+
return {};
|
|
75
|
+
return parseJsonc(text);
|
|
76
|
+
}
|
package/dist/report.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { AuditResult, Finding, Severity } from "./types.js";
|
|
2
|
+
export interface Style {
|
|
3
|
+
bold(s: string): string;
|
|
4
|
+
dim(s: string): string;
|
|
5
|
+
sev(sev: Severity, s: string): string;
|
|
6
|
+
grade(grade: string, s: string): string;
|
|
7
|
+
}
|
|
8
|
+
export declare function style(color: boolean): Style;
|
|
9
|
+
export declare const tildify: (path: string, home: string) => string;
|
|
10
|
+
export declare function counts(findings: Finding[]): {
|
|
11
|
+
high: number;
|
|
12
|
+
medium: number;
|
|
13
|
+
low: number;
|
|
14
|
+
info: number;
|
|
15
|
+
secrets: number;
|
|
16
|
+
};
|
|
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. */
|
|
24
|
+
export declare function renderJson(r: AuditResult): string;
|
package/dist/report.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { CLIENT_NAMES } from "./sources.js";
|
|
2
|
+
export function style(color) {
|
|
3
|
+
const wrap = (open, close) => (s) => (color ? `\x1b[${open}m${s}\x1b[${close}m` : s);
|
|
4
|
+
const red = wrap(31, 39);
|
|
5
|
+
const yellow = wrap(33, 39);
|
|
6
|
+
const blue = wrap(34, 39);
|
|
7
|
+
const green = wrap(32, 39);
|
|
8
|
+
const dim = wrap(2, 22);
|
|
9
|
+
return {
|
|
10
|
+
bold: wrap(1, 22),
|
|
11
|
+
dim,
|
|
12
|
+
sev: (sev, s) => (sev === "high" ? red(s) : sev === "medium" ? yellow(s) : sev === "low" ? blue(s) : dim(s)),
|
|
13
|
+
grade: (g, s) => (g === "A" || g === "B" ? green(s) : g === "C" ? yellow(s) : red(s)),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export const tildify = (path, home) => home && (path === home || path.startsWith(home + "/") || path.startsWith(home + "\\")) ? "~" + path.slice(home.length) : path;
|
|
17
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
18
|
+
export function counts(findings) {
|
|
19
|
+
return {
|
|
20
|
+
high: findings.filter((f) => f.severity === "high").length,
|
|
21
|
+
medium: findings.filter((f) => f.severity === "medium").length,
|
|
22
|
+
low: findings.filter((f) => f.severity === "low").length,
|
|
23
|
+
info: findings.filter((f) => f.severity === "info").length,
|
|
24
|
+
secrets: findings.filter((f) => f.rule === "plaintext-secret").reduce((n, f) => n + f.locations.length, 0),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
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) {
|
|
54
|
+
const lines = [];
|
|
55
|
+
lines.push("", `${s.bold("toolstile audit")} ${s.dim("· local MCP config check · nothing leaves this machine")}`, "");
|
|
56
|
+
if (r.files.length === 0 && r.unreadable.length === 0) {
|
|
57
|
+
lines.push("No MCP client configs found.", s.dim("Looked for Claude Code, Claude Desktop, Cursor, Codex, VS Code, Windsurf, Gemini CLI and Cline."), "");
|
|
58
|
+
return lines.join("\n");
|
|
59
|
+
}
|
|
60
|
+
const clientList = r.clients.map((c) => CLIENT_NAMES[c]).join(", ");
|
|
61
|
+
lines.push(`Scanned ${plural(r.files.length, "file")} from ${clientList}: ${plural(r.servers, "MCP server")}.`);
|
|
62
|
+
for (const u of r.unreadable) {
|
|
63
|
+
lines.push(s.dim(`Skipped ${tildify(u.path, home)} (${CLIENT_NAMES[u.client]}): could not parse it.`));
|
|
64
|
+
}
|
|
65
|
+
lines.push("");
|
|
66
|
+
if (r.grade !== null) {
|
|
67
|
+
const c = counts(r.findings);
|
|
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";
|
|
74
|
+
lines.push(` ${s.bold(s.grade(r.grade, `Grade ${r.grade}`))} ${s.dim(`${r.score}/100`)} ${summary}`, "");
|
|
75
|
+
}
|
|
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) {
|
|
79
|
+
const tag = s.sev(f.severity, f.severity.toUpperCase().padEnd(6));
|
|
80
|
+
lines.push(`${tag} ${s.bold(f.title)}`);
|
|
81
|
+
lines.push(` ${f.detail}`);
|
|
82
|
+
const shown = f.locations.slice(0, 5);
|
|
83
|
+
for (const l of shown)
|
|
84
|
+
lines.push(s.dim(` ${tildify(l.file, home)} ${l.pointer}`));
|
|
85
|
+
if (f.locations.length > shown.length)
|
|
86
|
+
lines.push(s.dim(` … and ${f.locations.length - shown.length} more`));
|
|
87
|
+
lines.push(` ${s.dim("Fix:")} ${f.fix}`, "");
|
|
88
|
+
}
|
|
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"), "");
|
|
101
|
+
return lines.join("\n");
|
|
102
|
+
}
|
|
103
|
+
/** JSON output for scripts. Paths are included unless redacted; secret values never are. */
|
|
104
|
+
export function renderJson(r) {
|
|
105
|
+
return JSON.stringify(r, null, 2) + "\n";
|
|
106
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret detection. Values are only classified here; callers never print them.
|
|
3
|
+
*/
|
|
4
|
+
/** True for `${VAR}`, `${env:VAR}`, `$VAR`, `%VAR%` and `{env:VAR}` references. */
|
|
5
|
+
export declare function isReference(value: string): boolean;
|
|
6
|
+
/** Returns a label for a known secret format in `value`, or null. */
|
|
7
|
+
export declare function matchPattern(value: string): string | null;
|
|
8
|
+
/**
|
|
9
|
+
* Classifies a literal value stored under `key`. Returns a label such as "GitHub token"
|
|
10
|
+
* when it looks like a credential written into the file, or null.
|
|
11
|
+
*/
|
|
12
|
+
export declare function classify(key: string, value: string): string | null;
|
|
13
|
+
export declare function urlSecret(url: string): string | null;
|
|
14
|
+
/** Finds credentials passed as command-line arguments, e.g. `--api-key VALUE` or `--token=VALUE`. */
|
|
15
|
+
export declare function argSecrets(args: string[]): {
|
|
16
|
+
index: number;
|
|
17
|
+
label: string;
|
|
18
|
+
}[];
|
package/dist/secrets.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret detection. Values are only classified here; callers never print them.
|
|
3
|
+
*/
|
|
4
|
+
const PATTERNS = [
|
|
5
|
+
[/\bgithub_pat_[A-Za-z0-9_]{40,}/, "GitHub token"],
|
|
6
|
+
[/\bgh[pousr]_[A-Za-z0-9]{30,}/, "GitHub token"],
|
|
7
|
+
[/\bglpat-[A-Za-z0-9_-]{20,}/, "GitLab token"],
|
|
8
|
+
[/\bsk-ant-[A-Za-z0-9_-]{20,}/, "Anthropic API key"],
|
|
9
|
+
[/\bsk-(?:proj-|svcacct-)?[A-Za-z0-9_-]{32,}/, "OpenAI API key"],
|
|
10
|
+
[/\bxox[abposr]-[A-Za-z0-9-]{10,}/, "Slack token"],
|
|
11
|
+
[/\bxapp-[A-Za-z0-9-]{10,}/, "Slack token"],
|
|
12
|
+
[/\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/, "AWS access key"],
|
|
13
|
+
[/\bAIza[0-9A-Za-z_-]{35}/, "Google API key"],
|
|
14
|
+
[/\blin_(?:api|oauth)_[A-Za-z0-9]{30,}/, "Linear API key"],
|
|
15
|
+
[/\b(?:ntn_[A-Za-z0-9]{40,}|secret_[A-Za-z0-9]{40,})/, "Notion token"],
|
|
16
|
+
[/\b[sr]k_(?:live|test)_[A-Za-z0-9]{20,}/, "Stripe key"],
|
|
17
|
+
[/\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}/, "SendGrid key"],
|
|
18
|
+
[/\bnpm_[A-Za-z0-9]{36}\b/, "npm token"],
|
|
19
|
+
[/\bsntr[ysu]_[A-Za-z0-9_=-]{30,}/, "Sentry token"],
|
|
20
|
+
[/\bdop_v1_[a-f0-9]{64}\b/, "DigitalOcean token"],
|
|
21
|
+
[/\bhf_[A-Za-z0-9]{30,}/, "Hugging Face token"],
|
|
22
|
+
[/\bfig[dhu]_[A-Za-z0-9_-]{30,}/, "Figma token"],
|
|
23
|
+
[/\bpplx-[A-Za-z0-9]{40,}/, "Perplexity API key"],
|
|
24
|
+
[/\bgsk_[A-Za-z0-9]{40,}/, "Groq API key"],
|
|
25
|
+
[/\bATATT3[A-Za-z0-9_=-]{40,}/, "Atlassian API token"],
|
|
26
|
+
[/\bshp(?:at|ca|pa|ss)_[a-fA-F0-9]{32}\b/, "Shopify token"],
|
|
27
|
+
[/-----BEGIN [A-Z ]*PRIVATE KEY-----/, "private key"],
|
|
28
|
+
[/\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/, "JWT"],
|
|
29
|
+
[/\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:[^\s@/]{3,}@/i, "password in connection string"],
|
|
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;
|
|
34
|
+
const PLACEHOLDER = /^(<.*>|\{.*\}|\[.*\]|x{4,}|\*{3,}|\.{3,}|your[-_ ].*|change[-_ ]?me|todo|placeholder|example|redacted|none|null|undefined|true|false|test|secret|token)$/i;
|
|
35
|
+
/** True for `${VAR}`, `${env:VAR}`, `$VAR`, `%VAR%` and `{env:VAR}` references. */
|
|
36
|
+
export function isReference(value) {
|
|
37
|
+
const v = value.trim();
|
|
38
|
+
return /^(Bearer\s+|Basic\s+|token\s+)?(\$\{[^}]+\}|\$[A-Za-z_][A-Za-z0-9_]*|%[A-Za-z_][A-Za-z0-9_]*%|\{env:[^}]+\})$/.test(v);
|
|
39
|
+
}
|
|
40
|
+
/** Returns a label for a known secret format in `value`, or null. */
|
|
41
|
+
export function matchPattern(value) {
|
|
42
|
+
for (const [re, label] of PATTERNS)
|
|
43
|
+
if (re.test(value))
|
|
44
|
+
return label;
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Classifies a literal value stored under `key`. Returns a label such as "GitHub token"
|
|
49
|
+
* when it looks like a credential written into the file, or null.
|
|
50
|
+
*/
|
|
51
|
+
export function classify(key, value) {
|
|
52
|
+
const v = value.trim();
|
|
53
|
+
if (!v || isReference(v))
|
|
54
|
+
return null;
|
|
55
|
+
const known = matchPattern(v);
|
|
56
|
+
if (known)
|
|
57
|
+
return known;
|
|
58
|
+
if (/^authorization$/i.test(key) || /^(Bearer|Basic|token)\s+\S{8,}$/i.test(v)) {
|
|
59
|
+
return isReference(v) ? null : "authorization header";
|
|
60
|
+
}
|
|
61
|
+
if (!SECRET_KEY.test(key) || NOT_SECRET_KEY.test(key))
|
|
62
|
+
return null;
|
|
63
|
+
if (v.length < 8 || PLACEHOLDER.test(v) || /\s/.test(v) || /^https?:\/\//i.test(v))
|
|
64
|
+
return null;
|
|
65
|
+
if (/^\d+$/.test(v) || /^(\/|~\/|\.\/|[A-Za-z]:\\)/.test(v))
|
|
66
|
+
return null;
|
|
67
|
+
return "credential";
|
|
68
|
+
}
|
|
69
|
+
/** URL query parameters that carry credentials. */
|
|
70
|
+
const URL_SECRET_PARAM = /^(token|access_token|api[_-]?key|apikey|key|secret|auth|password|pat|sig|signature)$/i;
|
|
71
|
+
export function urlSecret(url) {
|
|
72
|
+
const known = matchPattern(url);
|
|
73
|
+
if (known)
|
|
74
|
+
return known;
|
|
75
|
+
try {
|
|
76
|
+
const parsed = new URL(url);
|
|
77
|
+
if (parsed.password)
|
|
78
|
+
return "password in URL";
|
|
79
|
+
for (const [k, v] of parsed.searchParams) {
|
|
80
|
+
if (URL_SECRET_PARAM.test(k) && v.length >= 8 && !isReference(v))
|
|
81
|
+
return "credential in URL query";
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
/** Finds credentials passed as command-line arguments, e.g. `--api-key VALUE` or `--token=VALUE`. */
|
|
90
|
+
export function argSecrets(args) {
|
|
91
|
+
const out = [];
|
|
92
|
+
args.forEach((arg, i) => {
|
|
93
|
+
const eq = /^--?([A-Za-z0-9_-]+)=(.+)$/.exec(arg);
|
|
94
|
+
if (eq) {
|
|
95
|
+
const label = classify(eq[1], eq[2]);
|
|
96
|
+
if (label)
|
|
97
|
+
out.push({ index: i, label });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
// `docker run -e KEY=VALUE` and similar.
|
|
101
|
+
const assign = /^([A-Za-z_][A-Za-z0-9_]*)=(.+)$/.exec(arg);
|
|
102
|
+
if (assign) {
|
|
103
|
+
const label = classify(assign[1], assign[2]);
|
|
104
|
+
if (label)
|
|
105
|
+
out.push({ index: i, label });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const prev = args[i - 1];
|
|
109
|
+
const flag = prev && /^--?([A-Za-z0-9_-]+)$/.exec(prev);
|
|
110
|
+
if (flag) {
|
|
111
|
+
const label = classify(flag[1], arg);
|
|
112
|
+
if (label) {
|
|
113
|
+
out.push({ index: i, label });
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const known = matchPattern(arg);
|
|
118
|
+
if (known)
|
|
119
|
+
out.push({ index: i, label: known });
|
|
120
|
+
});
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ApprovalSetting, ClientId, ConfigFile, ServerEntry } from "./types.js";
|
|
2
|
+
export interface ScanOptions {
|
|
3
|
+
home: string;
|
|
4
|
+
/** Project directory whose checked-in configs are also scanned; `null` skips it. */
|
|
5
|
+
cwd: string | null;
|
|
6
|
+
platform: NodeJS.Platform;
|
|
7
|
+
env: Record<string, string | undefined>;
|
|
8
|
+
}
|
|
9
|
+
export declare const CLIENT_NAMES: Record<ClientId, string>;
|
|
10
|
+
/** Appends a key to a pointer, quoting keys that are not plain identifiers. */
|
|
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;
|
|
17
|
+
export interface Discovery {
|
|
18
|
+
files: ConfigFile[];
|
|
19
|
+
unreadable: {
|
|
20
|
+
client: ClientId;
|
|
21
|
+
path: string;
|
|
22
|
+
error: string;
|
|
23
|
+
}[];
|
|
24
|
+
servers: ServerEntry[];
|
|
25
|
+
approvals: ApprovalSetting[];
|
|
26
|
+
}
|
|
27
|
+
export declare function discover(opts: ScanOptions): Discovery;
|