@deepsql/mcp 0.8.0 → 0.10.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/AGENT-SETUP.md +289 -0
- package/CLAUDE.md +330 -0
- package/package.json +3 -1
- package/src/auth/store.js +32 -1
- package/src/auth/store.test.js +22 -0
- package/src/cli.js +40 -5
- package/src/commands/_connections.js +26 -3
- package/src/commands/_connections.test.js +21 -4
- package/src/commands/_session.js +4 -1
- package/src/commands/access.js +0 -2
- package/src/commands/admin.test.js +37 -0
- package/src/commands/anti-patterns.js +0 -1
- package/src/commands/brain-context.js +27 -8
- package/src/commands/business-rules.js +0 -1
- package/src/commands/connections.js +579 -9
- package/src/commands/digest.js +3 -5
- package/src/commands/explain.js +0 -1
- package/src/commands/query.js +0 -1
- package/src/commands/relationships.js +0 -1
- package/src/commands/schema.js +0 -1
- package/src/commands/slow-queries.js +2 -3
- package/src/commands/whoami.js +11 -5
- package/src/connections/schema.js +213 -0
- package/src/connections/secrets.js +167 -0
- package/src/connections/secrets.test.js +151 -0
package/src/commands/whoami.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
-
const store = require("../auth/store");
|
|
4
3
|
const { request } = require("../api/client");
|
|
5
4
|
const { resolveSession } = require("./_session");
|
|
6
5
|
|
|
@@ -8,10 +7,17 @@ async function run(opts, { stdout = process.stdout } = {}) {
|
|
|
8
7
|
const session = resolveSession(opts);
|
|
9
8
|
try {
|
|
10
9
|
const me = await request(session.baseUrl, "/auth/me", { token: session.token });
|
|
11
|
-
stdout.write(`Username:
|
|
12
|
-
if (me.role) stdout.write(`Role:
|
|
13
|
-
stdout.write(`URL:
|
|
14
|
-
if (session.profile?.tokenId) stdout.write(`Token id:
|
|
10
|
+
stdout.write(`Username: ${me.username || me.email || session.profile?.username || "(unknown)"}\n`);
|
|
11
|
+
if (me.role) stdout.write(`Role: ${me.role}\n`);
|
|
12
|
+
stdout.write(`URL: ${session.baseUrl}\n`);
|
|
13
|
+
if (session.profile?.tokenId) stdout.write(`Token id: ${session.profile.tokenId}\n`);
|
|
14
|
+
stdout.write(
|
|
15
|
+
`Connection: ${
|
|
16
|
+
session.defaultConnection
|
|
17
|
+
? session.defaultConnection
|
|
18
|
+
: "(none — pin one with `deepsql connections use <name>`)"
|
|
19
|
+
}\n`,
|
|
20
|
+
);
|
|
15
21
|
} catch (err) {
|
|
16
22
|
if (err.status === 401 || err.status === 403) {
|
|
17
23
|
throw new Error("Saved token is no longer valid. Run `deepsql login` again.");
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* JSON Schema (Draft 2020-12) for the `deepsql connections add --from-file`
|
|
5
|
+
* input. Mirrors the backend's ConnectionRequest DTO field-for-field so an
|
|
6
|
+
* AI agent can use this as the canonical contract.
|
|
7
|
+
*
|
|
8
|
+
* Field definitions are kept in one place so it's hard for the JSON Schema
|
|
9
|
+
* and the CLI's interactive prompts to drift apart. We also use this for
|
|
10
|
+
* `connections show` masking and for `connections add` validation.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const SECRET_FIELDS = [
|
|
14
|
+
"password",
|
|
15
|
+
"sshPassword",
|
|
16
|
+
"sshPrivateKey",
|
|
17
|
+
"sshPassphrase",
|
|
18
|
+
"sslCaCertificate",
|
|
19
|
+
"sslClientCertificate",
|
|
20
|
+
"sslClientKey",
|
|
21
|
+
"sslClientKeyPassphrase",
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const SCHEMA = {
|
|
25
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
26
|
+
$id: "https://deepsql.ai/schemas/connection-request.json",
|
|
27
|
+
title: "DeepSQL Connection",
|
|
28
|
+
description:
|
|
29
|
+
"Input for `deepsql connections add --from-file`. Mirrors the backend's ConnectionRequest DTO. Secret fields support $VAR_NAME (env var) and @file:<path> (file ref) — see `deepsql connections schema` for details.",
|
|
30
|
+
type: "object",
|
|
31
|
+
required: ["connectionName", "dbType", "host", "port", "database", "username"],
|
|
32
|
+
additionalProperties: false,
|
|
33
|
+
properties: {
|
|
34
|
+
connectionName: {
|
|
35
|
+
type: "string",
|
|
36
|
+
minLength: 1,
|
|
37
|
+
description: "Display name. Pass this to `--connection` everywhere else.",
|
|
38
|
+
},
|
|
39
|
+
dbType: {
|
|
40
|
+
type: "string",
|
|
41
|
+
enum: ["postgres", "mysql"],
|
|
42
|
+
description: "Database engine. Aliases (postgresql, aurora-postgres, etc.) are normalized server-side.",
|
|
43
|
+
},
|
|
44
|
+
host: { type: "string", minLength: 1, description: "Database hostname or IP. If sshEnabled=true, this is reached *through* the bastion." },
|
|
45
|
+
port: { type: "integer", minimum: 1, maximum: 65535 },
|
|
46
|
+
database: { type: "string", minLength: 1 },
|
|
47
|
+
username: { type: "string", minLength: 1 },
|
|
48
|
+
password: {
|
|
49
|
+
type: "string",
|
|
50
|
+
description: "DB password. Required on create; on update, omit to keep existing. Accepts $VAR / @file: refs.",
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
// Legacy SSL boolean (back-compat — prefer sslMode).
|
|
54
|
+
ssl: { type: "boolean", description: "Legacy: use sslMode instead. true = server-only TLS." },
|
|
55
|
+
|
|
56
|
+
// SSL fields
|
|
57
|
+
sslMode: {
|
|
58
|
+
type: "string",
|
|
59
|
+
enum: ["none", "server-only", "server-client"],
|
|
60
|
+
description: "TLS mode. server-client requires the three sslClient* fields.",
|
|
61
|
+
},
|
|
62
|
+
sslCaCertificate: { type: "string", description: "PEM-encoded CA cert. Accepts @file:." },
|
|
63
|
+
sslClientCertificate: { type: "string", description: "PEM-encoded client cert (sslMode=server-client only). Accepts @file:." },
|
|
64
|
+
sslClientKey: { type: "string", description: "PEM-encoded client key (sslMode=server-client only). Accepts @file:." },
|
|
65
|
+
sslClientKeyPassphrase: { type: "string", description: "Passphrase for an encrypted client key. Accepts $VAR / @file:." },
|
|
66
|
+
|
|
67
|
+
// SSH tunnel fields
|
|
68
|
+
sshEnabled: { type: "boolean", default: false, description: "Connect via an SSH bastion." },
|
|
69
|
+
sshAuthType: {
|
|
70
|
+
type: "string",
|
|
71
|
+
enum: ["PASSWORD", "PRIVATE_KEY"],
|
|
72
|
+
default: "PASSWORD",
|
|
73
|
+
description: "PASSWORD requires sshPassword; PRIVATE_KEY requires sshPrivateKey (and optionally sshPassphrase).",
|
|
74
|
+
},
|
|
75
|
+
sshHost: { type: "string", description: "Bastion hostname. Required when sshEnabled=true." },
|
|
76
|
+
sshPort: { type: "integer", default: 22, minimum: 1, maximum: 65535 },
|
|
77
|
+
sshUsername: { type: "string", description: "Bastion login. Required when sshEnabled=true." },
|
|
78
|
+
sshPassword: { type: "string", description: "Bastion password (sshAuthType=PASSWORD). Accepts $VAR." },
|
|
79
|
+
sshPrivateKey: { type: "string", description: "PEM-encoded SSH private key (sshAuthType=PRIVATE_KEY). Accepts @file:." },
|
|
80
|
+
sshPassphrase: { type: "string", description: "Passphrase for an encrypted SSH key. Accepts $VAR / @file:." },
|
|
81
|
+
|
|
82
|
+
// Cloud / instance metadata (informational; used for performance tuning calculations)
|
|
83
|
+
cloudProvider: { type: "string", enum: ["aws", "azure", "gcp", "self-hosted"], description: "Used for feature gating + tuning hints." },
|
|
84
|
+
managedService: { type: "string", enum: ["rds", "aurora", "cloud-sql", "azure-flexible", "azure-single"] },
|
|
85
|
+
instanceClass: { type: "string", description: "e.g. db.r6g.xlarge, db-n1-standard-4." },
|
|
86
|
+
instanceVcpus: { type: "integer", minimum: 1 },
|
|
87
|
+
instanceMemoryGb: { type: "number", minimum: 0 },
|
|
88
|
+
storageType: { type: "string", description: "e.g. gp3, io1, premium-ssd, pd-ssd." },
|
|
89
|
+
storageMaxIops: { type: "integer", minimum: 0 },
|
|
90
|
+
|
|
91
|
+
enableDataSampling: {
|
|
92
|
+
type: "boolean",
|
|
93
|
+
description: "Default true. Disables column-value sampling that DeepSQL uses for entity disambiguation.",
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
// Allow id on update flow (ignored on create).
|
|
97
|
+
id: { type: "string", description: "Set by the server; ignored on create." },
|
|
98
|
+
},
|
|
99
|
+
// Conditional "if SSH enabled, host+username required" — JSON Schema 2020-12.
|
|
100
|
+
allOf: [
|
|
101
|
+
{
|
|
102
|
+
if: { properties: { sshEnabled: { const: true } }, required: ["sshEnabled"] },
|
|
103
|
+
then: { required: ["sshHost", "sshUsername"] },
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
if: {
|
|
107
|
+
properties: {
|
|
108
|
+
sshEnabled: { const: true },
|
|
109
|
+
sshAuthType: { const: "PASSWORD" },
|
|
110
|
+
},
|
|
111
|
+
required: ["sshEnabled", "sshAuthType"],
|
|
112
|
+
},
|
|
113
|
+
then: { required: ["sshPassword"] },
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
if: {
|
|
117
|
+
properties: {
|
|
118
|
+
sshEnabled: { const: true },
|
|
119
|
+
sshAuthType: { const: "PRIVATE_KEY" },
|
|
120
|
+
},
|
|
121
|
+
required: ["sshEnabled", "sshAuthType"],
|
|
122
|
+
},
|
|
123
|
+
then: { required: ["sshPrivateKey"] },
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
if: { properties: { sslMode: { const: "server-client" } }, required: ["sslMode"] },
|
|
127
|
+
then: { required: ["sslClientCertificate", "sslClientKey"] },
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Lightweight validator. We don't pull in ajv to keep the package small —
|
|
134
|
+
* this checks the things we care about: required fields (incl. conditional),
|
|
135
|
+
* enum values, simple types, and minLength on strings.
|
|
136
|
+
*
|
|
137
|
+
* Returns { ok: true } or { ok: false, errors: [{ path, message }] }.
|
|
138
|
+
*/
|
|
139
|
+
function validate(input) {
|
|
140
|
+
if (input == null || typeof input !== "object" || Array.isArray(input)) {
|
|
141
|
+
return { ok: false, errors: [{ path: "$", message: "Input must be a JSON object." }] };
|
|
142
|
+
}
|
|
143
|
+
const errors = [];
|
|
144
|
+
|
|
145
|
+
for (const [key, schema] of Object.entries(SCHEMA.properties)) {
|
|
146
|
+
const value = input[key];
|
|
147
|
+
if (value === undefined) continue;
|
|
148
|
+
const typeError = checkType(key, value, schema);
|
|
149
|
+
if (typeError) errors.push(typeError);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
for (const required of SCHEMA.required) {
|
|
153
|
+
if (input[required] === undefined || input[required] === null || input[required] === "") {
|
|
154
|
+
errors.push({ path: required, message: `is required` });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Conditional rules
|
|
159
|
+
if (input.sshEnabled === true) {
|
|
160
|
+
if (!input.sshHost) errors.push({ path: "sshHost", message: "is required when sshEnabled=true" });
|
|
161
|
+
if (!input.sshUsername) errors.push({ path: "sshUsername", message: "is required when sshEnabled=true" });
|
|
162
|
+
const auth = input.sshAuthType || "PASSWORD";
|
|
163
|
+
if (auth === "PASSWORD" && !input.sshPassword) {
|
|
164
|
+
errors.push({ path: "sshPassword", message: "is required when sshEnabled=true and sshAuthType=PASSWORD" });
|
|
165
|
+
}
|
|
166
|
+
if (auth === "PRIVATE_KEY" && !input.sshPrivateKey) {
|
|
167
|
+
errors.push({ path: "sshPrivateKey", message: "is required when sshEnabled=true and sshAuthType=PRIVATE_KEY" });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (input.sslMode === "server-client") {
|
|
171
|
+
if (!input.sslClientCertificate) errors.push({ path: "sslClientCertificate", message: "is required when sslMode=server-client" });
|
|
172
|
+
if (!input.sslClientKey) errors.push({ path: "sslClientKey", message: "is required when sslMode=server-client" });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Forbid unknown fields (additionalProperties: false in the schema).
|
|
176
|
+
for (const key of Object.keys(input)) {
|
|
177
|
+
if (!(key in SCHEMA.properties)) {
|
|
178
|
+
errors.push({ path: key, message: "is not a recognized field" });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return errors.length === 0 ? { ok: true } : { ok: false, errors };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function checkType(path, value, schema) {
|
|
186
|
+
if (schema.enum && !schema.enum.includes(value)) {
|
|
187
|
+
return { path, message: `must be one of: ${schema.enum.join(", ")}` };
|
|
188
|
+
}
|
|
189
|
+
if (schema.type === "string" && typeof value !== "string") {
|
|
190
|
+
return { path, message: `must be a string` };
|
|
191
|
+
}
|
|
192
|
+
if (schema.type === "integer" && (!Number.isInteger(value))) {
|
|
193
|
+
return { path, message: `must be an integer` };
|
|
194
|
+
}
|
|
195
|
+
if (schema.type === "number" && typeof value !== "number") {
|
|
196
|
+
return { path, message: `must be a number` };
|
|
197
|
+
}
|
|
198
|
+
if (schema.type === "boolean" && typeof value !== "boolean") {
|
|
199
|
+
return { path, message: `must be true or false` };
|
|
200
|
+
}
|
|
201
|
+
if (schema.minLength != null && typeof value === "string" && value.length < schema.minLength) {
|
|
202
|
+
return { path, message: `must be at least ${schema.minLength} character(s)` };
|
|
203
|
+
}
|
|
204
|
+
if (schema.minimum != null && typeof value === "number" && value < schema.minimum) {
|
|
205
|
+
return { path, message: `must be ≥ ${schema.minimum}` };
|
|
206
|
+
}
|
|
207
|
+
if (schema.maximum != null && typeof value === "number" && value > schema.maximum) {
|
|
208
|
+
return { path, message: `must be ≤ ${schema.maximum}` };
|
|
209
|
+
}
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
module.exports = { SCHEMA, SECRET_FIELDS, validate };
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Secret resolution for connection-config JSON inputs.
|
|
5
|
+
*
|
|
6
|
+
* Three accepted forms for any string field:
|
|
7
|
+
*
|
|
8
|
+
* "plain string" — used as-is. If the source file lives in a git
|
|
9
|
+
* tree we warn (suppressible with
|
|
10
|
+
* `--allow-plaintext-secrets`).
|
|
11
|
+
* "$VAR_NAME" — substitute from process.env at CLI runtime. The
|
|
12
|
+
* variable name appears in logs; the value never
|
|
13
|
+
* does.
|
|
14
|
+
* "@file:/path/to/key" — read file contents at CLI runtime. `~/` is
|
|
15
|
+
* expanded. File mode 0600 is enforced unless
|
|
16
|
+
* DEEPSQL_INSECURE_AUTH=1 is set (matching the
|
|
17
|
+
* existing auth-store convention).
|
|
18
|
+
*
|
|
19
|
+
* The list of fields treated as secrets is small and fixed — they're the
|
|
20
|
+
* fields where leaking the value would let an attacker connect to a database.
|
|
21
|
+
* Non-secret fields (host, port, etc.) pass through untouched.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const fs = require("node:fs");
|
|
25
|
+
const os = require("node:os");
|
|
26
|
+
const path = require("node:path");
|
|
27
|
+
const { execFileSync } = require("node:child_process");
|
|
28
|
+
|
|
29
|
+
const SECRET_FIELDS = new Set([
|
|
30
|
+
"password",
|
|
31
|
+
"sshPassword",
|
|
32
|
+
"sshPrivateKey",
|
|
33
|
+
"sshPassphrase",
|
|
34
|
+
"sslCaCertificate",
|
|
35
|
+
"sslClientCertificate",
|
|
36
|
+
"sslClientKey",
|
|
37
|
+
"sslClientKeyPassphrase",
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
const ENV_REF = /^\$([A-Z_][A-Z0-9_]*)$/;
|
|
41
|
+
const FILE_REF = /^@file:(.+)$/;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolve all secret references in a parsed connection-config object,
|
|
45
|
+
* mutating-by-replacement (returns a new object). Throws on missing env vars
|
|
46
|
+
* or unreadable files.
|
|
47
|
+
*
|
|
48
|
+
* resolveSecrets(cfg, { sourcePath, allowPlaintextSecrets, log })
|
|
49
|
+
*
|
|
50
|
+
* sourcePath — original JSON file path (for plaintext warnings)
|
|
51
|
+
* allowPlaintextSecrets — true to suppress git-tree warnings
|
|
52
|
+
* log — function for emitting warnings to stderr
|
|
53
|
+
*/
|
|
54
|
+
function resolveSecrets(cfg, opts = {}) {
|
|
55
|
+
const { sourcePath = null, allowPlaintextSecrets = false, log = () => {} } = opts;
|
|
56
|
+
if (cfg == null || typeof cfg !== "object") return cfg;
|
|
57
|
+
|
|
58
|
+
const inGitTree = sourcePath ? isInsideGitTree(sourcePath) : false;
|
|
59
|
+
const out = { ...cfg };
|
|
60
|
+
|
|
61
|
+
for (const key of Object.keys(out)) {
|
|
62
|
+
const value = out[key];
|
|
63
|
+
if (typeof value !== "string") continue;
|
|
64
|
+
|
|
65
|
+
const envMatch = value.match(ENV_REF);
|
|
66
|
+
if (envMatch) {
|
|
67
|
+
const varName = envMatch[1];
|
|
68
|
+
if (!(varName in process.env)) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`Field "${key}" references env var ${varName}, but it is not set.`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
out[key] = process.env[varName];
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const fileMatch = value.match(FILE_REF);
|
|
78
|
+
if (fileMatch) {
|
|
79
|
+
const resolvedPath = expandHome(fileMatch[1].trim());
|
|
80
|
+
assertSafeMode(resolvedPath, key);
|
|
81
|
+
try {
|
|
82
|
+
out[key] = fs.readFileSync(resolvedPath, "utf8");
|
|
83
|
+
} catch (err) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`Field "${key}" references @file:${resolvedPath}, but the file could not be read: ${err.message}`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Plaintext — warn if it's a non-empty secret in a git-tracked file.
|
|
92
|
+
if (
|
|
93
|
+
SECRET_FIELDS.has(key) &&
|
|
94
|
+
value.length > 0 &&
|
|
95
|
+
inGitTree &&
|
|
96
|
+
!allowPlaintextSecrets
|
|
97
|
+
) {
|
|
98
|
+
log(
|
|
99
|
+
`[deepsql] Warning: field "${key}" contains a plaintext secret and ${sourcePath} is inside a git working tree. ` +
|
|
100
|
+
`Move the value to an env var ($VAR) or a 0600 file (@file:path), or pass --allow-plaintext-secrets.`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function expandHome(p) {
|
|
108
|
+
if (!p) return p;
|
|
109
|
+
if (p === "~") return os.homedir();
|
|
110
|
+
if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2));
|
|
111
|
+
return p;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function assertSafeMode(filePath, fieldName) {
|
|
115
|
+
if (process.platform === "win32") return;
|
|
116
|
+
if (process.env.DEEPSQL_INSECURE_AUTH === "1") return;
|
|
117
|
+
let stat;
|
|
118
|
+
try {
|
|
119
|
+
stat = fs.statSync(filePath);
|
|
120
|
+
} catch (err) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`Cannot read ${filePath} for field "${fieldName}": ${err.message}`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
if ((stat.mode & 0o077) !== 0) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`${filePath} (referenced by "${fieldName}") has insecure permissions ${(stat.mode & 0o777).toString(8)}. ` +
|
|
128
|
+
`Run \`chmod 600 ${filePath}\` or set DEEPSQL_INSECURE_AUTH=1.`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function isInsideGitTree(filePath) {
|
|
134
|
+
try {
|
|
135
|
+
const dir = path.dirname(path.resolve(filePath));
|
|
136
|
+
execFileSync("git", ["-C", dir, "rev-parse", "--is-inside-work-tree"], {
|
|
137
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
138
|
+
});
|
|
139
|
+
return true;
|
|
140
|
+
} catch {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Mask secret values in a config for display. Useful before logging the
|
|
147
|
+
* resolved object for debugging.
|
|
148
|
+
*/
|
|
149
|
+
function maskSecrets(cfg) {
|
|
150
|
+
if (cfg == null || typeof cfg !== "object") return cfg;
|
|
151
|
+
const out = { ...cfg };
|
|
152
|
+
for (const key of Object.keys(out)) {
|
|
153
|
+
if (SECRET_FIELDS.has(key) && out[key]) {
|
|
154
|
+
out[key] = typeof out[key] === "string" && out[key].length > 0 ? "(set)" : out[key];
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
module.exports = {
|
|
161
|
+
SECRET_FIELDS,
|
|
162
|
+
resolveSecrets,
|
|
163
|
+
maskSecrets,
|
|
164
|
+
// exported for testing
|
|
165
|
+
_isInsideGitTree: isInsideGitTree,
|
|
166
|
+
_expandHome: expandHome,
|
|
167
|
+
};
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const test = require("node:test");
|
|
4
|
+
const assert = require("node:assert/strict");
|
|
5
|
+
const fs = require("node:fs");
|
|
6
|
+
const os = require("node:os");
|
|
7
|
+
const path = require("node:path");
|
|
8
|
+
|
|
9
|
+
const { resolveSecrets, maskSecrets, SECRET_FIELDS } = require("./secrets");
|
|
10
|
+
|
|
11
|
+
function tempfile(content, mode = 0o600) {
|
|
12
|
+
const file = path.join(os.tmpdir(), `deepsql-secrets-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`);
|
|
13
|
+
fs.writeFileSync(file, content, { mode });
|
|
14
|
+
return file;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
test("plaintext non-secret fields pass through unchanged", () => {
|
|
18
|
+
const out = resolveSecrets({ host: "db.x", port: 5432, dbType: "postgres" });
|
|
19
|
+
assert.deepEqual(out, { host: "db.x", port: 5432, dbType: "postgres" });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("$VAR substitution pulls from process.env", () => {
|
|
23
|
+
process.env.DEEPSQL_TEST_PWD = "s3cret";
|
|
24
|
+
try {
|
|
25
|
+
const out = resolveSecrets({ password: "$DEEPSQL_TEST_PWD", host: "x" });
|
|
26
|
+
assert.equal(out.password, "s3cret");
|
|
27
|
+
assert.equal(out.host, "x");
|
|
28
|
+
} finally {
|
|
29
|
+
delete process.env.DEEPSQL_TEST_PWD;
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("$VAR throws when the env var is missing", () => {
|
|
34
|
+
delete process.env.DEEPSQL_TEST_MISSING_PWD;
|
|
35
|
+
assert.throws(
|
|
36
|
+
() => resolveSecrets({ password: "$DEEPSQL_TEST_MISSING_PWD" }),
|
|
37
|
+
/references env var DEEPSQL_TEST_MISSING_PWD, but it is not set/,
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("@file: reads file contents at runtime", () => {
|
|
42
|
+
const file = tempfile("-----BEGIN CERT-----\nblob\n-----END CERT-----\n");
|
|
43
|
+
try {
|
|
44
|
+
const out = resolveSecrets({ sslCaCertificate: `@file:${file}` });
|
|
45
|
+
assert.match(out.sslCaCertificate, /BEGIN CERT/);
|
|
46
|
+
} finally {
|
|
47
|
+
fs.unlinkSync(file);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("@file: rejects insecure permissions unless DEEPSQL_INSECURE_AUTH=1", { skip: process.platform === "win32" }, () => {
|
|
52
|
+
const file = tempfile("body", 0o644);
|
|
53
|
+
try {
|
|
54
|
+
assert.throws(
|
|
55
|
+
() => resolveSecrets({ sshPrivateKey: `@file:${file}` }),
|
|
56
|
+
/insecure permissions/,
|
|
57
|
+
);
|
|
58
|
+
process.env.DEEPSQL_INSECURE_AUTH = "1";
|
|
59
|
+
try {
|
|
60
|
+
const out = resolveSecrets({ sshPrivateKey: `@file:${file}` });
|
|
61
|
+
assert.equal(out.sshPrivateKey, "body");
|
|
62
|
+
} finally {
|
|
63
|
+
delete process.env.DEEPSQL_INSECURE_AUTH;
|
|
64
|
+
}
|
|
65
|
+
} finally {
|
|
66
|
+
fs.unlinkSync(file);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("@file: expands ~/ to homedir", () => {
|
|
71
|
+
// Write a real file under homedir to confirm expansion works end-to-end.
|
|
72
|
+
const dir = fs.mkdtempSync(path.join(os.homedir(), ".deepsql-test-"));
|
|
73
|
+
const filename = path.join(dir, "secret.pem");
|
|
74
|
+
fs.writeFileSync(filename, "homedir-secret", { mode: 0o600 });
|
|
75
|
+
const relative = `~/${path.relative(os.homedir(), filename)}`;
|
|
76
|
+
try {
|
|
77
|
+
const out = resolveSecrets({ sshPrivateKey: `@file:${relative}` });
|
|
78
|
+
assert.equal(out.sshPrivateKey, "homedir-secret");
|
|
79
|
+
} finally {
|
|
80
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("plaintext secrets warn only when sourcePath is inside a git tree (best-effort)", () => {
|
|
85
|
+
const warnings = [];
|
|
86
|
+
resolveSecrets(
|
|
87
|
+
{ password: "plain-text-password" },
|
|
88
|
+
{
|
|
89
|
+
sourcePath: __filename, // this file is in our git tree
|
|
90
|
+
log: (msg) => warnings.push(msg),
|
|
91
|
+
},
|
|
92
|
+
);
|
|
93
|
+
assert.ok(
|
|
94
|
+
warnings.some((m) => m.includes("plaintext secret")),
|
|
95
|
+
`expected a plaintext-secret warning, got: ${warnings.join(" / ") || "(none)"}`,
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("plaintext secrets do NOT warn when --allow-plaintext-secrets is set", () => {
|
|
100
|
+
const warnings = [];
|
|
101
|
+
resolveSecrets(
|
|
102
|
+
{ password: "plain-text-password" },
|
|
103
|
+
{
|
|
104
|
+
sourcePath: __filename,
|
|
105
|
+
allowPlaintextSecrets: true,
|
|
106
|
+
log: (msg) => warnings.push(msg),
|
|
107
|
+
},
|
|
108
|
+
);
|
|
109
|
+
assert.equal(warnings.length, 0);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("plaintext non-secret fields don't trigger warnings even in a git tree", () => {
|
|
113
|
+
const warnings = [];
|
|
114
|
+
resolveSecrets(
|
|
115
|
+
{ host: "plain-host" },
|
|
116
|
+
{
|
|
117
|
+
sourcePath: __filename,
|
|
118
|
+
log: (msg) => warnings.push(msg),
|
|
119
|
+
},
|
|
120
|
+
);
|
|
121
|
+
assert.equal(warnings.length, 0);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("maskSecrets replaces non-empty secret fields with '(set)'", () => {
|
|
125
|
+
const cfg = {
|
|
126
|
+
host: "db.x",
|
|
127
|
+
password: "abc",
|
|
128
|
+
sshPrivateKey: "",
|
|
129
|
+
sshPassphrase: "phrase",
|
|
130
|
+
};
|
|
131
|
+
const masked = maskSecrets(cfg);
|
|
132
|
+
assert.equal(masked.host, "db.x");
|
|
133
|
+
assert.equal(masked.password, "(set)");
|
|
134
|
+
assert.equal(masked.sshPrivateKey, "");
|
|
135
|
+
assert.equal(masked.sshPassphrase, "(set)");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("SECRET_FIELDS export covers the canonical list", () => {
|
|
139
|
+
for (const f of [
|
|
140
|
+
"password",
|
|
141
|
+
"sshPassword",
|
|
142
|
+
"sshPrivateKey",
|
|
143
|
+
"sshPassphrase",
|
|
144
|
+
"sslCaCertificate",
|
|
145
|
+
"sslClientCertificate",
|
|
146
|
+
"sslClientKey",
|
|
147
|
+
"sslClientKeyPassphrase",
|
|
148
|
+
]) {
|
|
149
|
+
assert.ok(SECRET_FIELDS.has(f), `${f} should be in SECRET_FIELDS`);
|
|
150
|
+
}
|
|
151
|
+
});
|