Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 143 additions & 21 deletions packages/drivers/src/snowflake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,43 +48,165 @@ export async function connect(config: ConnectionConfig): Promise<Connector> {
role: config.role,
}

// Key-pair auth
if (config.private_key_path) {
const keyPath = config.private_key_path as string
if (!fs.existsSync(keyPath)) {
throw new Error(`Snowflake private key file not found: ${keyPath}`)
// ---------------------------------------------------------------
// Normalize field names: accept snake_case (dbt), camelCase (SDK),
// and common LLM-generated variants so auth "just works".
// ---------------------------------------------------------------
const keyPath = (config.private_key_path ?? config.privateKeyPath) as string | undefined
const inlineKey = (config.private_key ?? config.privateKey) as string | undefined
const keyPassphrase = (config.private_key_passphrase ?? config.privateKeyPassphrase ?? config.privateKeyPass) as string | undefined
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The privateKeyPass field, an alias for the Snowflake private key passphrase, is not included in the SENSITIVE_FIELDS set, causing it to be stored in plaintext.
Severity: HIGH

Suggested Fix

Add "privateKeyPass" to the SENSITIVE_FIELDS set in packages/opencode/src/altimate/native/connections/credential-store.ts to ensure it is treated as a sensitive value and stored securely.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: packages/drivers/src/snowflake.ts#L57

Potential issue: The Snowflake driver accepts `privateKeyPass` as an alias for the
private key passphrase. However, this field is missing from the `SENSITIVE_FIELDS` set
in `credential-store.ts`. As a result, the `saveConnection()` function does not identify
it as a sensitive credential and writes it to the `connections.json` file in plaintext
instead of storing it securely in the system keychain.

const oauthToken = (config.token ?? config.access_token) as string | undefined
const oauthClientId = (config.oauth_client_id ?? config.oauthClientId) as string | undefined
const oauthClientSecret = (config.oauth_client_secret ?? config.oauthClientSecret) as string | undefined
Comment on lines +59 to +60

This comment was marked as outdated.

const authenticator = (config.authenticator as string | undefined)?.trim()
const authUpper = authenticator?.toUpperCase()
const passcode = config.passcode as string | undefined

// ---------------------------------------------------------------
// 1. Key-pair auth (SNOWFLAKE_JWT)
// Accepts: private_key_path (file), private_key (inline PEM or
// file path auto-detected), privateKey, privateKeyPath.
// ---------------------------------------------------------------
// Resolve private_key: could be a file path or PEM content
let resolvedKeyPath = keyPath
let resolvedInlineKey = inlineKey
if (!resolvedKeyPath && resolvedInlineKey && !resolvedInlineKey.includes("-----BEGIN")) {
// Looks like a file path, not PEM content
if (fs.existsSync(resolvedInlineKey)) {
resolvedKeyPath = resolvedInlineKey
resolvedInlineKey = undefined
} else {
throw new Error(
`Snowflake private key: '${resolvedInlineKey}' is not a valid file path or PEM content. ` +
`Use 'private_key_path' for file paths or provide PEM content starting with '-----BEGIN PRIVATE KEY-----'.`,
)
}
Comment on lines +73 to +83

This comment was marked as outdated.

}

if (resolvedKeyPath || resolvedInlineKey) {
let keyContent: string
if (resolvedKeyPath) {
if (!fs.existsSync(resolvedKeyPath)) {
throw new Error(`Snowflake private key file not found: ${resolvedKeyPath}`)
}
keyContent = fs.readFileSync(resolvedKeyPath, "utf-8")
} else {
keyContent = resolvedInlineKey!
// Normalize escaped newlines from env vars / JSON configs
if (keyContent.includes("\\n")) {
keyContent = keyContent.replace(/\\n/g, "\n")
}
}
const keyContent = fs.readFileSync(keyPath, "utf-8")

// If key is encrypted (has ENCRYPTED in header or passphrase provided),
// decrypt it using Node crypto — snowflake-sdk expects unencrypted PEM.
// If key is encrypted, decrypt using Node crypto —
// snowflake-sdk expects unencrypted PKCS#8 PEM.
let privateKey: string
if (config.private_key_passphrase || keyContent.includes("ENCRYPTED")) {
if (keyPassphrase || keyContent.includes("ENCRYPTED")) {
const crypto = await import("crypto")
const keyObject = crypto.createPrivateKey({
key: keyContent,
format: "pem",
passphrase: (config.private_key_passphrase as string) || undefined,
})
privateKey = keyObject
.export({ type: "pkcs8", format: "pem" })
.toString()
try {
const keyObject = crypto.createPrivateKey({
key: keyContent,
format: "pem",
passphrase: keyPassphrase || undefined,
})
privateKey = keyObject
.export({ type: "pkcs8", format: "pem" })
.toString()
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
throw new Error(
`Snowflake: Failed to decrypt private key. Verify the passphrase and key format (must be PEM/PKCS#8). ${msg}`,
)
}
} else {
privateKey = keyContent
}

options.authenticator = "SNOWFLAKE_JWT"
options.privateKey = privateKey

// ---------------------------------------------------------------
// 2. External browser SSO
// Interactive — opens user's browser for IdP login. Requires
// connectAsync() instead of connect().
// ---------------------------------------------------------------
} else if (authUpper === "EXTERNALBROWSER") {
options.authenticator = "EXTERNALBROWSER"

// ---------------------------------------------------------------
// 3. Okta native SSO (authenticator is an Okta URL)
// ---------------------------------------------------------------
} else if (authenticator && /^https?:\/\/.+\.okta\.com/i.test(authenticator)) {
options.authenticator = authenticator
if (config.password) options.password = config.password

// ---------------------------------------------------------------
// 4. OAuth token auth
// Triggered by: authenticator="oauth", OR token/access_token
// present without a password.
// ---------------------------------------------------------------
} else if (authUpper === "OAUTH" || (oauthToken && !config.password)) {
if (!oauthToken) {
throw new Error(
"Snowflake OAuth authenticator specified but no token provided (expected 'token' or 'access_token')",
)
}
options.authenticator = "OAUTH"
options.token = oauthToken

// ---------------------------------------------------------------
// 5. JWT / Programmatic access token (pre-generated)
// The Node.js snowflake-sdk only accepts pre-generated tokens
// via the OAUTH authenticator. SNOWFLAKE_JWT expects a privateKey
// for self-signing, and PROGRAMMATIC_ACCESS_TOKEN is not recognized.
// Alias both to OAUTH so the token is passed correctly.
// ---------------------------------------------------------------
} else if (authUpper === "JWT" || authUpper === "PROGRAMMATIC_ACCESS_TOKEN") {
if (!oauthToken) {
throw new Error(`Snowflake ${authenticator} authenticator specified but no token provided (expected 'token' or 'access_token')`)
}
options.authenticator = "OAUTH"
options.token = oauthToken

// ---------------------------------------------------------------
// 7. Username + password + MFA
// ---------------------------------------------------------------
} else if (authUpper === "USERNAME_PASSWORD_MFA") {
if (!config.password) {
throw new Error("Snowflake USERNAME_PASSWORD_MFA authenticator requires 'password'")
}
options.authenticator = "USERNAME_PASSWORD_MFA"
options.password = config.password
if (passcode) options.passcode = passcode

// ---------------------------------------------------------------
// 8. Plain password auth (default)
// ---------------------------------------------------------------
} else if (config.password) {
options.password = config.password
}

// Use connectAsync for browser-based auth (SSO/Okta), connect for everything else
const isOktaUrl = authenticator && /^https?:\/\/.+\.okta\.com/i.test(authenticator)
const useBrowserAuth = authUpper === "EXTERNALBROWSER" || isOktaUrl

connection = await new Promise<any>((resolve, reject) => {
const conn = snowflake.createConnection(options)
conn.connect((err: Error | null) => {
if (err) reject(err)
else resolve(conn)
})
if (useBrowserAuth) {
if (typeof conn.connectAsync !== "function") {
reject(new Error("Snowflake browser/SSO auth requires snowflake-sdk with connectAsync support. Upgrade snowflake-sdk."))
return
}
conn.connectAsync((err: Error | null) => {
if (err) reject(err)
Comment on lines +200 to +201

This comment was marked as outdated.

else resolve(conn)
}).catch(reject)
} else {
conn.connect((err: Error | null) => {
if (err) reject(err)
else resolve(conn)
})
}
})
},

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,16 @@ const SERVICE_NAME = "altimate-code"

const SENSITIVE_FIELDS = new Set([
"password",
"private_key",
"privateKey",
Comment on lines +17 to +18
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: When keytar is unavailable, saving a connection with an inline private_key strips and permanently loses the key, causing subsequent connection attempts to fail.
Severity: HIGH

Suggested Fix

Modify saveConnection to not strip the private_key if keytar is unavailable. Instead, rely on the existing warning that advises the user to use environment variables for credentials in such environments. This prevents data loss while still informing the user of the recommended practice.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: packages/opencode/src/altimate/native/connections/credential-store.ts#L17-L18

Potential issue: When a new connection with an inline `private_key` is saved in an
environment where `keytar` is unavailable (e.g., CI/CD or headless servers), the
`saveConnection` function strips the `private_key` from the configuration for security.
However, the stripped configuration is then persisted in memory, permanently losing the
key for the current session. Subsequent connection attempts using this configuration
will fail authentication because the private key is missing.

"private_key_passphrase",
"privateKeyPassphrase",
"privateKeyPass",
"access_token",
"token",
"oauth_client_secret",
"oauthClientSecret",
"passcode",
"ssh_password",
"connection_string",
])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,12 @@ const KEY_MAP: Record<string, string> = {
server_hostname: "server_hostname",
http_path: "http_path",
token: "access_token",
private_key: "private_key",
private_key_path: "private_key_path",
private_key_passphrase: "private_key_passphrase",
authenticator: "authenticator",
oauth_client_id: "oauth_client_id",
oauth_client_secret: "oauth_client_secret",
keyfile: "credentials_path",
keyfile_json: "credentials_json",
project: "project",
Expand Down
11 changes: 8 additions & 3 deletions packages/opencode/src/altimate/native/connections/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,10 @@ async function createConnector(
export function detectAuthMethod(config: ConnectionConfig | null | undefined): string {
if (!config || typeof config !== "object") return "unknown"
if (config.connection_string) return "connection_string"
if (config.private_key_path) return "key_pair"
if (config.private_key_path || config.privateKeyPath || config.private_key || config.privateKey) return "key_pair"
const auth = typeof config.authenticator === "string" ? config.authenticator.toUpperCase() : ""
if (auth === "EXTERNALBROWSER" || (typeof config.authenticator === "string" && /^https?:\/\/.+\.okta\.com/i.test(config.authenticator))) return "sso"
if (auth === "OAUTH") return "oauth"
if (config.access_token || config.token) return "token"
if (config.password) return "password"
const t = typeof config.type === "string" ? config.type.toLowerCase() : ""
Expand Down Expand Up @@ -374,8 +377,10 @@ export async function add(
existing[name] = sanitized
fs.writeFileSync(globalPath, JSON.stringify(existing, null, 2), "utf-8")

// Update in-memory with sanitized config (no plaintext credentials)
configs.set(name, sanitized)
// In-memory: keep original config (with credentials) so the current
// session can connect even when keytar is unavailable. Only the disk
// file uses the sanitized version (credentials stripped).
configs.set(name, config)

// Clear cached connector
const cached = connectors.get(name)
Expand Down
3 changes: 2 additions & 1 deletion packages/opencode/src/altimate/tools/dbt-profiles.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import z from "zod"
import { Tool } from "../../tool/tool"
import { Dispatcher } from "../native"
import { isSensitiveField } from "../native/connections/credential-store"

export const DbtProfilesTool = Tool.define("dbt_profiles", {
description:
Expand Down Expand Up @@ -52,7 +53,7 @@ function formatConnections(connections: Array<{ name: string; type: string; conf
for (const conn of connections) {
lines.push(`${conn.name} (${conn.type})`)
for (const [key, val] of Object.entries(conn.config)) {
if (key === "password" || key === "private_key_passphrase" || key === "access_token") {
if (isSensitiveField(key)) {
lines.push(` ${key}: ****`)
} else {
lines.push(` ${key}: ${val}`)
Expand Down
10 changes: 9 additions & 1 deletion packages/opencode/src/altimate/tools/warehouse-add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,15 @@ export const WarehouseAddTool = Tool.define("warehouse_add", {
config: z
.record(z.string(), z.unknown())
.describe(
'Connection configuration. Must include "type" (postgres, snowflake, duckdb, etc). Example: {"type": "postgres", "host": "localhost", "port": 5432, "database": "mydb", "user": "admin", "password": "secret"}',
'Connection configuration. Must include "type" (postgres, snowflake, duckdb, etc). ' +
'Snowflake auth methods: ' +
'(1) Password: {"type":"snowflake","account":"xy12345","user":"admin","password":"secret","warehouse":"WH","database":"db","schema":"public","role":"ROLE"}. ' +
'(2) Key-pair (file): {"type":"snowflake","account":"xy12345","user":"admin","private_key_path":"/path/to/rsa_key.p8","private_key_passphrase":"optional","warehouse":"WH","database":"db","schema":"public","role":"ROLE"}. ' +
'(3) Key-pair (inline): use "private_key" instead of "private_key_path" with PEM content. ' +
'(4) OAuth: {"type":"snowflake","account":"xy12345","authenticator":"oauth","token":"<access_token>","warehouse":"WH","database":"db","schema":"public"}. ' +
'(5) SSO: {"type":"snowflake","account":"xy12345","user":"admin","authenticator":"externalbrowser","warehouse":"WH","database":"db","schema":"public","role":"ROLE"}. ' +
'IMPORTANT: For private key file paths, always use "private_key_path" (not "private_key"). ' +
'Postgres: {"type":"postgres","host":"localhost","port":5432,"database":"mydb","user":"admin","password":"secret"}.',
),
}),
async execute(args, ctx) {
Expand Down
63 changes: 63 additions & 0 deletions packages/opencode/test/altimate/connections.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,35 @@ describe("CredentialStore", () => {

test("isSensitiveField identifies sensitive fields", () => {
expect(CredentialStore.isSensitiveField("password")).toBe(true)
expect(CredentialStore.isSensitiveField("private_key")).toBe(true)
expect(CredentialStore.isSensitiveField("privateKey")).toBe(true)
expect(CredentialStore.isSensitiveField("private_key_passphrase")).toBe(true)
expect(CredentialStore.isSensitiveField("privateKeyPassphrase")).toBe(true)
expect(CredentialStore.isSensitiveField("privateKeyPass")).toBe(true)
expect(CredentialStore.isSensitiveField("access_token")).toBe(true)
expect(CredentialStore.isSensitiveField("token")).toBe(true)
expect(CredentialStore.isSensitiveField("oauth_client_secret")).toBe(true)
expect(CredentialStore.isSensitiveField("oauthClientSecret")).toBe(true)
expect(CredentialStore.isSensitiveField("passcode")).toBe(true)
expect(CredentialStore.isSensitiveField("connection_string")).toBe(true)
expect(CredentialStore.isSensitiveField("host")).toBe(false)
expect(CredentialStore.isSensitiveField("port")).toBe(false)
expect(CredentialStore.isSensitiveField("authenticator")).toBe(false)
})

test("saveConnection strips inline private_key as sensitive", async () => {
const config = { type: "snowflake", private_key: "-----BEGIN PRIVATE KEY-----\nMIIE..." } as any
const { sanitized, warnings } = await CredentialStore.saveConnection("sf_keypair", config)
expect(sanitized.private_key).toBeUndefined()
expect(warnings.length).toBeGreaterThan(0)
})

test("saveConnection strips OAuth credentials as sensitive", async () => {
const config = { type: "snowflake", authenticator: "oauth", token: "access-token-123", oauth_client_secret: "secret" } as any
const { sanitized } = await CredentialStore.saveConnection("sf_oauth", config)
expect(sanitized.token).toBeUndefined()
expect(sanitized.oauth_client_secret).toBeUndefined()
expect(sanitized.authenticator).toBe("oauth")
})
})

Expand Down Expand Up @@ -165,6 +190,44 @@ myproject:
}
})

test("parses Snowflake private_key from dbt profile", async () => {
const fs = await import("fs")
const os = await import("os")
const path = await import("path")

const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "dbt-test-"))
const profilesPath = path.join(tmpDir, "profiles.yml")

fs.writeFileSync(
profilesPath,
`
snowflake_keypair:
outputs:
prod:
type: snowflake
account: abc123
user: svc_user
private_key: "-----BEGIN PRIVATE KEY-----\\nMIIEvQ..."
private_key_passphrase: "my-passphrase"
database: ANALYTICS
warehouse: COMPUTE_WH
schema: PUBLIC
role: TRANSFORMER
`,
)

try {
const connections = await parseDbtProfiles(profilesPath)
expect(connections).toHaveLength(1)
expect(connections[0].type).toBe("snowflake")
expect(connections[0].config.private_key).toBe("-----BEGIN PRIVATE KEY-----\nMIIEvQ...")
expect(connections[0].config.private_key_passphrase).toBe("my-passphrase")
expect(connections[0].config.password).toBeUndefined()
} finally {
fs.rmSync(tmpDir, { recursive: true })
}
})

test("maps dbt adapter types correctly", async () => {
const fs = await import("fs")
const os = await import("os")
Expand Down
8 changes: 8 additions & 0 deletions packages/opencode/test/altimate/telemetry-safety.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,15 @@ describe("Telemetry Safety: Helper functions never throw", () => {
test("detectAuthMethod handles all config shapes", () => {
expect(detectAuthMethod({ type: "postgres", connection_string: "pg://..." })).toBe("connection_string")
expect(detectAuthMethod({ type: "snowflake", private_key_path: "/key.p8" })).toBe("key_pair")
expect(detectAuthMethod({ type: "snowflake", private_key: "-----BEGIN PRIVATE KEY-----\n..." })).toBe("key_pair")
expect(detectAuthMethod({ type: "snowflake", privateKey: "-----BEGIN PRIVATE KEY-----\n..." })).toBe("key_pair")
expect(detectAuthMethod({ type: "snowflake", privateKeyPath: "/key.p8" })).toBe("key_pair")
expect(detectAuthMethod({ type: "snowflake", authenticator: "externalbrowser" })).toBe("sso")
expect(detectAuthMethod({ type: "snowflake", authenticator: "https://myorg.okta.com" })).toBe("sso")
expect(detectAuthMethod({ type: "snowflake", authenticator: "oauth" })).toBe("oauth")
expect(detectAuthMethod({ type: "snowflake", authenticator: "OAUTH" })).toBe("oauth")
expect(detectAuthMethod({ type: "databricks", access_token: "dapi..." })).toBe("token")
expect(detectAuthMethod({ type: "snowflake", token: "jwt-token" })).toBe("token")
expect(detectAuthMethod({ type: "postgres", password: "secret" })).toBe("password")
expect(detectAuthMethod({ type: "duckdb" })).toBe("file")
expect(detectAuthMethod({ type: "sqlite" })).toBe("file")
Expand Down
Loading