mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-01 20:33:50 +00:00
vault + teams: filter secrets by category, export to CSV, simpler help
Every secret now has a category derived from its name (db, social, server,
api, cloud, finance, crypto, ai, email, messaging, storage, dns, analytics,
devtools, auth, config, other). One rule table in @logicsrc/opencreds serves
both vaults; services win over generic words, so STRIPE_WEBHOOK_SECRET is
finance, not auth. Checked against the 1,208 distinct key names in the
profullstack team: 74 fall to "other".
Team vaults (where the shared .env secrets live):
- teams categories [team] the filter words, with per-category counts
- teams secrets <team> [project] [env] --category/-c --search/-s
names + categories, never decrypts; --format csv
- teams export <team> [project] [env] --category -o file.csv [--yes]
decrypts into team,project,env,category,key,
value,updated_at (0600); skips vaults without a
grant and names them
Personal vault (OpenCreds):
- vault list --category, and the category column in list output
- vault export --format csv: one flat row per item, keeps key/account
secrets that a Bitwarden CSV drops; --category on every export format
DX:
- examples in `logicsrc vault help` / -h / --help that start by saying which
of the two vaults you want, plus examples on teams and each subcommand
- password prompts go to stderr, so eval "$(logicsrc vault unlock)" works
- hints name the command you actually ran (logicsrc vault init, not
opencreds init)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
156c9164a9
commit
a0f8f2c125
11 changed files with 846 additions and 19 deletions
|
|
@ -18,13 +18,46 @@ import { registerCredsCommands } from "@logicsrc/opencreds/commands";
|
|||
* account — encrypted end to end and portable as one file rather than a
|
||||
* plaintext CSV. They meet at the `key` item: a synced .env entry, stored.
|
||||
*/
|
||||
export const VAULT_GUIDE = `
|
||||
There are two vaults. Pick the one you need:
|
||||
|
||||
Team secrets .env keys shared with your team (DATABASE_URL, STRIPE_SECRET_KEY, …)
|
||||
-> logicsrc teams … (this is where the company secrets are)
|
||||
Personal vault your own logins, cards, SSH keys, notes
|
||||
-> logicsrc vault …
|
||||
|
||||
Team secrets, the everyday commands:
|
||||
logicsrc login once per machine
|
||||
logicsrc teams list the teams you are in
|
||||
logicsrc teams secrets <team> every secret name + its category
|
||||
logicsrc teams secrets <team> --category db only database secrets
|
||||
logicsrc teams secrets <team> -s stripe names containing "stripe"
|
||||
logicsrc teams export <team> --category db -o db.csv decrypt them into a CSV
|
||||
logicsrc teams pull <team> <project> <env> write one vault into ./.env
|
||||
logicsrc teams categories db, social, server, api, cloud, …
|
||||
|
||||
Personal vault:
|
||||
logicsrc vault init create it (once)
|
||||
eval "$(logicsrc vault unlock)" unlock for this shell
|
||||
logicsrc vault add login --name GitHub --username me --password -
|
||||
logicsrc vault list --category social never shows values
|
||||
logicsrc vault get GitHub --field login.password --reveal
|
||||
logicsrc vault export --format csv --category db --out db.csv --yes
|
||||
logicsrc vault import bitwarden.csv
|
||||
|
||||
Help for any command: logicsrc vault <command> --help (or: logicsrc vault help <command>)
|
||||
`;
|
||||
|
||||
export function registerOpenCredsCommands(program: Command): void {
|
||||
const vault = program
|
||||
.command("vault")
|
||||
.description(
|
||||
"OpenCreds: an end-to-end-encrypted vault for logins, cards, identities, notes, keys " +
|
||||
"and accounts, portable as one file. Also available as the standalone `opencreds` command.",
|
||||
);
|
||||
"Your personal encrypted vault (logins, cards, keys, notes). " +
|
||||
"Team .env secrets are under `logicsrc teams` — examples below.",
|
||||
)
|
||||
// Most people typing `logicsrc vault` want the TEAM secrets, which live
|
||||
// under `teams`. Say so first, with commands they can paste.
|
||||
.addHelpText("after", VAULT_GUIDE);
|
||||
|
||||
registerCredsCommands(vault);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,3 +20,18 @@ export function print(data: unknown, format: OutputFormat) {
|
|||
|
||||
console.table(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* A plain, aligned table: no index column, no quotes, no colour. Easier to read
|
||||
* than console.table and pipes cleanly into grep, sort and awk.
|
||||
*/
|
||||
export function printColumns(rows: Array<Record<string, unknown>>): void {
|
||||
if (rows.length === 0) return;
|
||||
const headers = Object.keys(rows[0]!);
|
||||
const cells = rows.map((row) => headers.map((h) => String(row[h] ?? "")));
|
||||
const widths = headers.map((h, i) => Math.max(h.length, ...cells.map((c) => c[i]!.length)));
|
||||
const line = (values: string[]) =>
|
||||
values.map((v, i) => (i === values.length - 1 ? v : v.padEnd(widths[i]!))).join(" ").trimEnd();
|
||||
console.log(line(headers.map((h) => h.toUpperCase())));
|
||||
for (const c of cells) console.log(line(c));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ import {
|
|||
teamsAcceptAction,
|
||||
teamsMembersAction,
|
||||
teamsVaultsAction,
|
||||
teamsCategoriesAction,
|
||||
teamsSecretsAction,
|
||||
teamsExportAction,
|
||||
teamsGrantAction,
|
||||
teamsTuiAction,
|
||||
teamsPushAction,
|
||||
|
|
@ -716,7 +719,25 @@ credentials
|
|||
print(credentialEngine().exportCredentialAudit(options.run), options.format as OutputFormat);
|
||||
});
|
||||
|
||||
const teams = program.command("teams").description("Share credentials with teammates by email — end-to-end encrypted team vaults.");
|
||||
const teams = program
|
||||
.command("teams")
|
||||
.description("Share credentials with teammates by email — end-to-end encrypted team vaults.")
|
||||
.addHelpText(
|
||||
"after",
|
||||
`
|
||||
Quick start (<team> is e.g. profullstack; see yours with "logicsrc teams list"):
|
||||
logicsrc login once per machine
|
||||
logicsrc teams vaults <team> the vaults: <project>--<env>
|
||||
logicsrc teams secrets <team> every secret name + its category
|
||||
logicsrc teams secrets <team> --category db filter: db, social, server, api, …
|
||||
logicsrc teams export <team> --category db -o db.csv decrypt into a CSV
|
||||
logicsrc teams pull <team> <project> <env> vault -> ./.env
|
||||
logicsrc teams push <team> <project> <env> ./.env -> vault
|
||||
logicsrc teams grant <team> <project> <env> dev@example.com
|
||||
|
||||
Categories: logicsrc teams categories. Help for one command: logicsrc teams <command> --help
|
||||
`
|
||||
);
|
||||
|
||||
teams
|
||||
.command("create")
|
||||
|
|
@ -762,6 +783,83 @@ teams
|
|||
.description("List a team's credential vaults.")
|
||||
.action((slug, options) => teamsVaultsAction(slug, options.format as OutputFormat));
|
||||
|
||||
// `--category db --category api` and `--category db,api` both work.
|
||||
const collectCategory = (value: string, previous: string[] = []) => [...previous, value];
|
||||
|
||||
teams
|
||||
.command("categories")
|
||||
.argument("[slug]", "Team slug; when given, counts that team's secrets per category")
|
||||
.option("--format <format>", "table, json, or markdown", "table")
|
||||
.description("List the secret categories you can filter by (db, social, server, api, …).")
|
||||
.addHelpText(
|
||||
"after",
|
||||
`
|
||||
Examples:
|
||||
logicsrc teams categories the category words and what each one catches
|
||||
logicsrc teams categories profullstack how many secrets the team has in each
|
||||
|
||||
The category is worked out from the secret's NAME, e.g. DATABASE_URL is db and
|
||||
TWITTER_API_KEY is social. Use a category with "teams secrets" and "teams export".
|
||||
`
|
||||
)
|
||||
.action((slug, options) => teamsCategoriesAction(slug, { format: options.format as OutputFormat }));
|
||||
|
||||
teams
|
||||
.command("secrets")
|
||||
.alias("ls")
|
||||
.argument("<slug>", "Team slug")
|
||||
.argument("[project]", "Only this project (omit for every vault in the team)")
|
||||
.argument("[env]", "Only this environment (prod, staging, …)")
|
||||
.option("-c, --category <names>", "db, social, server, api, … (comma-separated or repeated)", collectCategory)
|
||||
.option("-s, --search <text>", "only keys whose name contains this")
|
||||
.option("--format <format>", "table, json, markdown, or csv", "table")
|
||||
.description("List secret names and their category. Never decrypts or shows a value.")
|
||||
.addHelpText(
|
||||
"after",
|
||||
`
|
||||
Examples:
|
||||
logicsrc teams secrets profullstack every secret name in the team
|
||||
logicsrc teams secrets profullstack --category db database secrets only
|
||||
logicsrc teams secrets profullstack -c social,api two categories at once
|
||||
logicsrc teams secrets profullstack coinpayportal prod one vault
|
||||
logicsrc teams secrets profullstack -s stripe names containing "stripe"
|
||||
logicsrc teams secrets profullstack --format csv > names.csv
|
||||
|
||||
Categories: logicsrc teams categories
|
||||
`
|
||||
)
|
||||
.action((slug, project, env, options) =>
|
||||
teamsSecretsAction(slug, { project, env, category: options.category, search: options.search }, { format: options.format })
|
||||
);
|
||||
|
||||
teams
|
||||
.command("export")
|
||||
.argument("<slug>", "Team slug")
|
||||
.argument("[project]", "Only this project (omit for every vault in the team)")
|
||||
.argument("[env]", "Only this environment (prod, staging, …)")
|
||||
.option("-o, --out <file>", "CSV file to write, or - for stdout", "secrets.csv")
|
||||
.option("-c, --category <names>", "db, social, server, api, … (comma-separated or repeated)", collectCategory)
|
||||
.option("-s, --search <text>", "only keys whose name contains this")
|
||||
.option("-y, --yes", "skip the are-you-sure prompt (needed when not in a terminal)")
|
||||
.description("Decrypt secrets into a CSV: team,project,env,category,key,value,updated_at.")
|
||||
.addHelpText(
|
||||
"after",
|
||||
`
|
||||
Examples:
|
||||
logicsrc teams export profullstack everything you can decrypt -> secrets.csv
|
||||
logicsrc teams export profullstack --category db -o db.csv
|
||||
logicsrc teams export profullstack coinpayportal prod -o coinpay-prod.csv
|
||||
logicsrc teams export profullstack -c social --out - --yes | less
|
||||
|
||||
The file holds every value in the clear (written 0600). Delete it when done:
|
||||
shred -u secrets.csv
|
||||
Vaults you have no access to are skipped and listed at the end.
|
||||
`
|
||||
)
|
||||
.action((slug, project, env, options) =>
|
||||
teamsExportAction(slug, { project, env, category: options.category, search: options.search }, { out: options.out, yes: options.yes })
|
||||
);
|
||||
|
||||
teams
|
||||
.command("tui")
|
||||
.alias("ui")
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { createHash, randomBytes } from "node:crypto";
|
|||
import { hostname } from "node:os";
|
||||
import { spawn } from "node:child_process";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { chmodSync, writeFileSync } from "node:fs";
|
||||
import { OTHER_CATEGORY, SECRET_CATEGORIES, categorizeSecret, csvLine, parseCategories } from "@logicsrc/opencreds";
|
||||
import {
|
||||
TeamClient,
|
||||
TeamApiError,
|
||||
|
|
@ -16,9 +18,11 @@ import {
|
|||
identityPath,
|
||||
unwrapVaultKey,
|
||||
wrapVaultKey,
|
||||
type CredentialEndpoint
|
||||
decryptValue,
|
||||
type CredentialEndpoint,
|
||||
type RemoteVault
|
||||
} from "@logicsrc/plugin-credential-sharing";
|
||||
import { print, type OutputFormat } from "./format.js";
|
||||
import { print, printColumns, type OutputFormat } from "./format.js";
|
||||
import { linkedDirectory, requireSecretsLink, writeSecretsLink } from "./secrets-link.js";
|
||||
|
||||
/**
|
||||
|
|
@ -516,3 +520,213 @@ export async function teamsTuiAction(options: { theme?: string } = {}): Promise<
|
|||
const { runVaultTui } = await import("./vault-tui-run.js");
|
||||
await runVaultTui({ client, identity: identity.email, theme: options.theme });
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ categories ---
|
||||
//
|
||||
// `teams secrets` and `teams export` answer "show me every database password"
|
||||
// or "give me all the social keys as a spreadsheet". The category comes from
|
||||
// the key NAME (see @logicsrc/opencreds categories.ts), so listing never has to
|
||||
// decrypt anything and a list and an export of the same filter always agree.
|
||||
|
||||
export interface SecretFilter {
|
||||
project?: string;
|
||||
env?: string;
|
||||
category?: string | string[];
|
||||
search?: string;
|
||||
}
|
||||
|
||||
interface SecretRow {
|
||||
team: string;
|
||||
project: string;
|
||||
env: string;
|
||||
vault: string;
|
||||
category: string;
|
||||
key: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Map with at most `limit` promises in flight; a team can hold hundreds of vaults. */
|
||||
async function mapLimit<T, R>(items: readonly T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
|
||||
const out = new Array<R>(items.length);
|
||||
let next = 0;
|
||||
const worker = async (): Promise<void> => {
|
||||
while (next < items.length) {
|
||||
const index = next++;
|
||||
out[index] = await fn(items[index]!);
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The vaults a filter addresses: all of them, one project's, or one project/env. */
|
||||
function selectVaults(vaults: RemoteVault[], filter: SecretFilter): Array<RemoteVault & { project: string; env: string }> {
|
||||
return vaults
|
||||
.map((v) => {
|
||||
const parts = splitVaultName(v.name);
|
||||
return { ...v, project: parts?.project ?? v.name, env: parts?.env ?? "" };
|
||||
})
|
||||
.filter((v) => (filter.project ? v.project === filter.project : true))
|
||||
.filter((v) => (filter.env ? v.env === filter.env : true))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
function matcher(filter: SecretFilter): (row: { key: string; category: string }) => boolean {
|
||||
const categories = parseCategories(filter.category);
|
||||
const needle = filter.search?.toLowerCase();
|
||||
return (row) =>
|
||||
(!categories || categories.has(row.category)) && (!needle || row.key.toLowerCase().includes(needle));
|
||||
}
|
||||
|
||||
/** Every secret NAME the filter matches, with its category. Decrypts nothing. */
|
||||
async function collectSecretRows(client: TeamClient, slug: string, filter: SecretFilter): Promise<SecretRow[]> {
|
||||
const keep = matcher(filter);
|
||||
const { vaults } = await client.listVaults(slug);
|
||||
const selected = selectVaults(vaults, filter);
|
||||
if (selected.length === 0) throw new Error(noVaultsMessage(slug, filter));
|
||||
const perVault = await mapLimit(selected, 8, async (v) => {
|
||||
const { secrets } = await client.listSecrets(v.id);
|
||||
return secrets.map((s) => ({
|
||||
team: slug, project: v.project, env: v.env, vault: v.name,
|
||||
category: categorizeSecret(s.name), key: s.name, updatedAt: s.updatedAt
|
||||
}));
|
||||
});
|
||||
return perVault.flat().filter(keep);
|
||||
}
|
||||
|
||||
function noVaultsMessage(slug: string, filter: SecretFilter): string {
|
||||
const where = [filter.project, filter.env].filter(Boolean).join("/");
|
||||
return where
|
||||
? `No vault matches ${slug}/${where}. See what exists: logicsrc teams vaults ${slug}`
|
||||
: `Team "${slug}" has no vaults yet. Push one: logicsrc teams push ${slug} <project> <env>`;
|
||||
}
|
||||
|
||||
function namesCsv(rows: SecretRow[]): string {
|
||||
const lines = [csvLine(["team", "project", "env", "category", "key", "updated_at"])];
|
||||
for (const r of rows) lines.push(csvLine([r.team, r.project, r.env, r.category, r.key, r.updatedAt]));
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
/** `logicsrc teams categories [team]` — the filter words, with counts when a team is given. */
|
||||
export async function teamsCategoriesAction(slug: string | undefined, options: { format: OutputFormat }): Promise<void> {
|
||||
let counts: Map<string, number> | undefined;
|
||||
if (slug) {
|
||||
const { client } = authedClient();
|
||||
counts = new Map();
|
||||
for (const row of await collectSecretRows(client, slug, {})) counts.set(row.category, (counts.get(row.category) ?? 0) + 1);
|
||||
}
|
||||
const rows = [...SECRET_CATEGORIES, OTHER_CATEGORY].map((c) => ({
|
||||
category: c.id,
|
||||
...(counts ? { secrets: counts.get(c.id) ?? 0 } : {}),
|
||||
description: c.description,
|
||||
aliases: c.aliases.join(", "),
|
||||
examples: c.examples.join(", ")
|
||||
}));
|
||||
if (options.format === "table") {
|
||||
printColumns(rows.map(({ examples: _examples, ...row }) => row));
|
||||
console.error("\nFilter with: logicsrc teams secrets <team> --category db (or: teams export <team> --category db)");
|
||||
return;
|
||||
}
|
||||
print(rows, options.format);
|
||||
}
|
||||
|
||||
/** `logicsrc teams secrets <team> [project] [env]` — names and categories, never values. */
|
||||
export async function teamsSecretsAction(
|
||||
slug: string,
|
||||
filter: SecretFilter,
|
||||
options: { format: OutputFormat | "csv" }
|
||||
): Promise<void> {
|
||||
const { client } = authedClient();
|
||||
const rows = await collectSecretRows(client, slug, filter);
|
||||
if (options.format === "csv") {
|
||||
process.stdout.write(namesCsv(rows));
|
||||
return;
|
||||
}
|
||||
if (rows.length === 0) {
|
||||
console.error("No secrets match. Categories: logicsrc teams categories");
|
||||
return;
|
||||
}
|
||||
const vaults = new Set(rows.map((r) => r.vault)).size;
|
||||
console.error(`${rows.length} secret(s) in ${vaults} vault(s). Values stay encrypted; export them with: logicsrc teams export ${slug} …`);
|
||||
const brief = rows.map((r) => ({ project: r.project, env: r.env, category: r.category, key: r.key }));
|
||||
if (options.format === "table") printColumns(brief);
|
||||
else print(options.format === "json" ? rows : brief, options.format);
|
||||
}
|
||||
|
||||
async function confirmPlaintext(message: string): Promise<boolean> {
|
||||
if (!process.stdin.isTTY || !process.stderr.isTTY) return false;
|
||||
const prompt = createInterface({ input: process.stdin, output: process.stderr });
|
||||
try {
|
||||
return /^y(es)?$/i.test((await prompt.question(`${message} [y/N] `)).trim());
|
||||
} finally {
|
||||
prompt.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `logicsrc teams export <team> [project] [env] --out creds.csv`
|
||||
*
|
||||
* Decrypts on this machine and writes one CSV row per secret:
|
||||
* team,project,env,category,key,value,updated_at. Vaults you hold no grant for
|
||||
* are skipped and named, rather than failing the whole export.
|
||||
*/
|
||||
export async function teamsExportAction(
|
||||
slug: string,
|
||||
filter: SecretFilter,
|
||||
options: { out: string; yes?: boolean }
|
||||
): Promise<void> {
|
||||
const { client, identity } = authedClient();
|
||||
const keep = matcher(filter);
|
||||
const { vaults } = await client.listVaults(slug);
|
||||
const selected = selectVaults(vaults, filter);
|
||||
if (selected.length === 0) throw new Error(noVaultsMessage(slug, filter));
|
||||
|
||||
const toStdout = options.out === "-";
|
||||
const target = toStdout ? "stdout" : options.out;
|
||||
if (!options.yes) {
|
||||
console.error(`About to write decrypted secrets from ${selected.length} vault(s) to ${target} in the clear.`);
|
||||
console.error("Anyone who can read that file can use every secret in it.");
|
||||
if (!(await confirmPlaintext("Continue?"))) {
|
||||
throw new Error("Refused: exporting plaintext secrets needs confirmation. Re-run with --yes to skip the prompt.");
|
||||
}
|
||||
}
|
||||
|
||||
const skipped: string[] = [];
|
||||
const perVault = await mapLimit(selected, 6, async (v) => {
|
||||
if (!v.hasAccess) {
|
||||
skipped.push(v.name);
|
||||
return [];
|
||||
}
|
||||
const { secrets } = await client.listSecrets(v.id);
|
||||
const wanted = secrets
|
||||
.map((s) => ({ secret: s, category: categorizeSecret(s.name), key: s.name }))
|
||||
.filter(keep);
|
||||
if (wanted.length === 0) return [];
|
||||
let dek: string;
|
||||
try {
|
||||
dek = await unwrapVaultKey((await client.getMyGrant(v.id)).wrappedDek, identity.keys);
|
||||
} catch {
|
||||
skipped.push(v.name);
|
||||
return [];
|
||||
}
|
||||
return Promise.all(
|
||||
wanted.map(async ({ secret, category }) =>
|
||||
csvLine([slug, v.project, v.env, category, secret.name,
|
||||
await decryptValue({ nonce: secret.nonce, ciphertext: secret.ciphertext }, dek), secret.updatedAt])
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
const lines = perVault.flat();
|
||||
const csv = `${[csvLine(["team", "project", "env", "category", "key", "value", "updated_at"]), ...lines].join("\n")}\n`;
|
||||
if (toStdout) {
|
||||
process.stdout.write(csv);
|
||||
} else {
|
||||
writeFileSync(options.out, csv, { encoding: "utf8", mode: 0o600 });
|
||||
try { chmodSync(options.out, 0o600); } catch { /* no modes on this platform */ }
|
||||
}
|
||||
console.error(`Exported ${lines.length} secret(s) to ${target}.${toStdout ? "" : " Delete it when you are done: shred -u " + options.out}`);
|
||||
if (skipped.length) {
|
||||
console.error(`Skipped ${skipped.length} vault(s) you cannot decrypt: ${skipped.sort().join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
106
packages/opencreds/src/categories.test.ts
Normal file
106
packages/opencreds/src/categories.test.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
SECRET_CATEGORIES,
|
||||
SECRET_CATEGORY_IDS,
|
||||
categorizeItem,
|
||||
categorizeSecret,
|
||||
csvLine,
|
||||
parseCategories,
|
||||
toSimpleCsv,
|
||||
} from "./categories.js";
|
||||
import { createItem } from "./items.js";
|
||||
|
||||
describe("categorizeSecret", () => {
|
||||
it("puts every documented example in its own category", () => {
|
||||
for (const category of SECRET_CATEGORIES) {
|
||||
for (const example of category.examples) {
|
||||
expect([example, categorizeSecret(example)]).toEqual([example, category.id]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
["DATABASE_URL", "db"],
|
||||
["SUPABASE_SERVICE_ROLE_KEY", "db"],
|
||||
["SUPABAsE_URL", "db"],
|
||||
["TWITTER_API_KEY", "social"],
|
||||
["X_CLIENT_SECRET", "social"],
|
||||
["SSH_PORT", "server"],
|
||||
["SEED1_SUDO_PASSWORD", "server"],
|
||||
["TMDB_API_KEY", "api"],
|
||||
// a service beats the generic word: rotate Stripe, get its webhook secret too
|
||||
["STRIPE_WEBHOOK_SECRET", "finance"],
|
||||
["COINPAYPORTAL_API_KEY", "finance"],
|
||||
["SMTP_HOST", "email"],
|
||||
["DB_HOST", "db"],
|
||||
["R2_SECRET_ACCESS_KEY", "storage"],
|
||||
["SUPABASE_S3_SECRET_KEY", "storage"],
|
||||
["SYSTEM_MNEMONIC_BTC", "crypto"],
|
||||
["JWT_SECRET", "auth"],
|
||||
["NODE_ENV", "config"],
|
||||
["NEXT_PUBLIC_APP_URL", "config"],
|
||||
["PLANETSCALE_DATABASE_URL", "db"],
|
||||
["SOMETHING_ELSE", "other"],
|
||||
])("%s is %s", (name, category) => {
|
||||
expect(categorizeSecret(name)).toBe(category);
|
||||
});
|
||||
|
||||
it("reads item names and hosts the same way as env names", () => {
|
||||
expect(categorizeSecret("My Postgres")).toBe("db");
|
||||
expect(categorizeSecret("api.stripe.com")).toBe("finance");
|
||||
});
|
||||
});
|
||||
|
||||
describe("categorizeItem", () => {
|
||||
it("uses the item type where it is unambiguous", () => {
|
||||
expect(categorizeItem(createItem("card", { name: "Visa" }))).toBe("finance");
|
||||
expect(categorizeItem(createItem("key", { name: "laptop", key: { keyType: "ssh" } }))).toBe("server");
|
||||
});
|
||||
|
||||
it("classifies a login by its URL when the name says nothing", () => {
|
||||
const item = createItem("login", { name: "work", login: { uris: [{ uri: "https://www.reddit.com/login" }] } });
|
||||
expect(categorizeItem(item)).toBe("social");
|
||||
});
|
||||
|
||||
it("classifies an env key by its name", () => {
|
||||
expect(categorizeItem(createItem("key", { name: "REDIS_URL", key: { keyType: "env", value: "redis://x" } }))).toBe("db");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseCategories", () => {
|
||||
it("accepts commas, repeats and aliases", () => {
|
||||
expect(parseCategories(["db,social", "payments"])).toEqual(new Set(["db", "social", "finance"]));
|
||||
expect(parseCategories(" DB ")).toEqual(new Set(["db"]));
|
||||
});
|
||||
|
||||
it("means no filter when nothing is given", () => {
|
||||
expect(parseCategories(undefined)).toBeUndefined();
|
||||
expect(parseCategories([])).toBeUndefined();
|
||||
});
|
||||
|
||||
it("names the valid words when one is wrong", () => {
|
||||
expect(() => parseCategories("databse")).toThrow(/Unknown category "databse".*db/);
|
||||
});
|
||||
|
||||
it("lists other last so every secret has a category to filter on", () => {
|
||||
expect(SECRET_CATEGORY_IDS.at(-1)).toBe("other");
|
||||
});
|
||||
});
|
||||
|
||||
describe("csv", () => {
|
||||
it("quotes only what needs quoting, including multi-line keys", () => {
|
||||
expect(csvLine(["a", 'b"c', "d,e", "-----BEGIN\nKEY-----", undefined])).toBe('a,"b""c","d,e","-----BEGIN\nKEY-----",');
|
||||
});
|
||||
|
||||
it("keeps key and account secrets, which a Bitwarden CSV drops", () => {
|
||||
const items = [
|
||||
createItem("key", { name: "DATABASE_URL", key: { keyType: "env", value: "postgres://u:p@h/db" } }),
|
||||
createItem("account", { name: "Mastodon", account: { provider: "mastodon", handle: "@me", accessToken: "tok" } }),
|
||||
];
|
||||
const lines = toSimpleCsv(items, []).trim().split("\n");
|
||||
expect(lines[0]).toBe("folder,category,type,name,username,password,url,value,totp,notes");
|
||||
expect(lines[1]).toBe(",db,key,DATABASE_URL,,,,postgres://u:p@h/db,,");
|
||||
expect(lines[2]).toBe(",social,account,Mastodon,@me,,mastodon,tok,,");
|
||||
});
|
||||
});
|
||||
224
packages/opencreds/src/categories.ts
Normal file
224
packages/opencreds/src/categories.ts
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
/**
|
||||
* Secret categories — "is this a database password, a social login, a server
|
||||
* key or an API token?" — derived from the NAME alone.
|
||||
*
|
||||
* Names only, on purpose: `logicsrc teams secrets` lists a vault without
|
||||
* decrypting it, and `teams export` has the values in hand. If a value could
|
||||
* move a secret between categories, the same key would land in one bucket when
|
||||
* listed and another when exported, and a filter that disagrees with itself is
|
||||
* worse than no filter.
|
||||
*
|
||||
* The rules are an ordered list and the first match wins, with specific
|
||||
* services before generic words: STRIPE_WEBHOOK_SECRET is `finance` (you want
|
||||
* it when rotating Stripe), not `auth` (every webhook secret), and SMTP_HOST is
|
||||
* `email`, not `server`. Each pattern runs against the name upper-cased with
|
||||
* every non-alphanumeric run turned into "_" and padded with "_" at both ends,
|
||||
* so `_OPENAI_` is a whole token and `_COINPAY` is a token prefix
|
||||
* (COINPAYPORTAL_API_KEY). The same normaliser turns an OpenCreds item name or
|
||||
* login URL ("api.stripe.com") into tokens, so one table serves both vaults.
|
||||
*/
|
||||
|
||||
import type { Item } from "./types.js";
|
||||
|
||||
export interface SecretCategory {
|
||||
/** The name typed on the command line: `--category db`. */
|
||||
id: string;
|
||||
/** One line for `logicsrc teams categories`. */
|
||||
description: string;
|
||||
/** Other words people reach for; `--category payments` means `finance`. */
|
||||
aliases: readonly string[];
|
||||
/** A few names this category catches, shown as examples. */
|
||||
examples: readonly string[];
|
||||
patterns: readonly RegExp[];
|
||||
}
|
||||
|
||||
const rule = (
|
||||
id: string,
|
||||
description: string,
|
||||
aliases: string[],
|
||||
examples: string[],
|
||||
patterns: RegExp[],
|
||||
): SecretCategory => Object.freeze({ id, description, aliases, examples, patterns });
|
||||
|
||||
export const SECRET_CATEGORIES: readonly SecretCategory[] = Object.freeze([
|
||||
rule("crypto", "Wallets, seed phrases, chain RPCs, exchanges", ["wallet", "wallets", "web3", "blockchain", "exchange"],
|
||||
["SYSTEM_MNEMONIC_BTC", "SOLANA_RPC_URL", "KRAKEN_API_KEY"], [
|
||||
/_(MNEMONIC|SEED_PHRASE|WALLET|WALLETCONNECT|RPC|ALCHEMY|INFURA|HELIUS|QUICKNODE|TATUM|BLOCKFROST|BLOCKSTREAM|LNBITS|LIGHTNING|LN|EVM|SOLANA|SOL|ETH|ETHEREUM|BTC|BITCOIN|BCH|DOGE|XRP|XMR|MONERO|POLYGON|BNB|USDC|USDT|ZEROX|UNISWAP|PUMPFUN|CHANGENOW|KRAKEN|BINANCE|COINBASE|BITSTAMP|MOONPAY|SIDESHIFT|GEMINI_API_SECRET|BYBIT|OKX|KUCOIN|DEX|ARB)_/,
|
||||
/_(ETHERSCAN|BSCSCAN|POLYGONSCAN|SOLSCAN|CRYPTO_?APIS)/,
|
||||
]),
|
||||
rule("ai", "LLM and model providers", ["llm", "ml", "model", "models"],
|
||||
["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "ELEVENLABS_API_KEY"], [
|
||||
/_(OPENAI|ANTHROPIC|CLAUDE|GEMINI|GOOGLE_AI|GROQ|GROK|XAI|MISTRAL|COHERE|DEEPSEEK|PERPLEXITY|MOONSHOT|DASHSCOPE|QWEN|ZAI|OPENROUTER|TOGETHER|FIREWORKS|REPLICATE|HUGGINGFACE|HF|ELEVENLABS|STABILITY|OLLAMA|VOYAGE|AI)_/,
|
||||
]),
|
||||
rule("finance", "Payments, billing, banking and brokerage", ["payment", "payments", "billing", "bank", "banking", "stripe"],
|
||||
["STRIPE_SECRET_KEY", "COINPAY_API_KEY", "PLAID_SECRET"], [
|
||||
/_(STRIPE|PAYPAL|COINPAY|SQUARE|BRAINTREE|LEMONSQUEEZY|PADDLE|GUMROAD|CHARGEBEE|REVENUECAT|PLAID|SIMPLEFIN|BTCPAY|X402|ALPACA|APCA|FINNHUB|ROBINHOOD|SHOPIFY|MERCHANT)/,
|
||||
/_(PAY|PAYMENT|PAYMENTS|PAYOUT|PAYOUTS|INVOICE)_/,
|
||||
/_(PRICE|PLAN)_/,
|
||||
]),
|
||||
rule("email", "Sending and receiving mail", ["mail", "smtp"],
|
||||
["RESEND_API_KEY", "SMTP_PASS", "MAILGUN_DOMAIN"], [
|
||||
/_(RESEND|MAILGUN|SENDGRID|POSTMARK|SMTP|IMAP|POP3|SES|MAILCHIMP|BREVO|SENDINBLUE|MAILJET|SPARKPOST|MAIL|EMAIL|EMAILS)_/,
|
||||
]),
|
||||
rule("messaging", "SMS, voice, push, chat and realtime", ["sms", "phone", "push", "chat", "realtime"],
|
||||
["TWILIO_AUTH_TOKEN", "VAPID_PRIVATE_KEY", "LIVEKIT_API_SECRET"], [
|
||||
/_(TWILIO|TELNYX|VONAGE|NEXMO|PLIVO|SMS|PHONE|VAPID|ONESIGNAL|FCM|APNS|PUSHER|SLACK|DISCORD|TELEGRAM|WHATSAPP|LIVEKIT|TURN|JITSI|AGORA)_/,
|
||||
]),
|
||||
rule("social", "Social networks and publishing platforms", ["socials", "oauth-social"],
|
||||
["X_CLIENT_SECRET", "REDDIT_CLIENT_ID", "META_APP_SECRET"], [
|
||||
/_(TWITTER|X|FACEBOOK|FB|META|INSTAGRAM|THREADS|LINKEDIN|REDDIT|TIKTOK|YOUTUBE|MASTODON|BLUESKY|BSKY|PINTEREST|NOSTR|FARCASTER|DEVTO|HASHNODE|MEDIUM|POSTIZ|TUMBLR|SNAPCHAT|TWITCH|KICK|LEMMY|SOCIAL)_/,
|
||||
]),
|
||||
rule("storage", "Object storage, buckets and file/media hosts", ["s3", "bucket", "files", "media"],
|
||||
["R2_SECRET_ACCESS_KEY", "SUPABASE_S3_SECRET_KEY", "CLOUDINARY_API_SECRET"], [
|
||||
/_(S3|R2|B2|BUCKET|STORAGE|CLOUDINARY|UPLOADTHING|BACKBLAZE|MINIO|SPACES|DROPBOX|GCS)_/,
|
||||
]),
|
||||
rule("db", "Databases, caches and backends-as-a-service", ["database", "databases", "sql", "cache", "supabase"],
|
||||
["DATABASE_URL", "SUPABASE_SERVICE_ROLE_KEY", "REDIS_URL"], [
|
||||
/_(DATABASE|DB|POSTGRES|POSTGRESQL|PG|PGHOST|PGUSER|PGPASSWORD|PGDATABASE|MYSQL|MARIADB|MONGO|MONGODB|REDIS|VALKEY|UPSTASH|TURSO|LIBSQL|SQLITE|SQLITECLOUD|DBSTRING|SUPABASE|NEON|PLANETSCALE|COCKROACH|CLICKHOUSE|ELASTIC|ELASTICSEARCH|SURREAL|DYNAMODB|FIRESTORE|GOTRUE)_/,
|
||||
]),
|
||||
rule("dns", "Domains, DNS and TLS certificates", ["domain", "domains", "tls", "ssl", "certs"],
|
||||
["PORKBUN_API_KEY", "PORKBUN_SECRET_API_KEY", "CLOUDFLARE_DNS_TOKEN"], [
|
||||
/_(PORKBUN|NAMECHEAP|GODADDY|ROUTE53|DNSIMPLE|DNS|CERTBOT|ACME|LETSENCRYPT)_/,
|
||||
]),
|
||||
rule("analytics", "Analytics, error tracking and logging", ["monitoring", "observability", "logging", "tracking"],
|
||||
["SENTRY_DSN", "POSTHOG_KEY", "GOOGLE_ANALYTICS_ID"], [
|
||||
/_(SENTRY|DATADOG|POSTHOG|ANALYTICS|GA|PLAUSIBLE|UMAMI|MIXPANEL|AMPLITUDE|SEGMENT|LOGTAIL|BETTERSTACK|NEW_RELIC|NEWRELIC|GRAFANA|HONEYCOMB|DATAFAST|MAXMIND)_/,
|
||||
]),
|
||||
rule("devtools", "Source control, package registries, app stores, signing", ["dev", "git", "github", "ci", "registry"],
|
||||
["GITHUB_TOKEN", "NPM_TOKEN", "GPG_PRIVATE_KEY"], [
|
||||
/_(GITHUB|GH|GITLAB|BITBUCKET|GITEA|NPM|PYPI|DOCKER|GHCR|AUR|CHOCOLATEY|GPG|PGP|PKG|EXPO|EAS|APPLE|CHROME|FIREFOX|EDGE|CODECOV|LINEAR|JIRA|SH1PT)_/,
|
||||
]),
|
||||
rule("cloud", "Cloud and hosting platforms", ["hosting", "paas", "infra"],
|
||||
["RAILWAY_API_TOKEN", "CLOUDFLARE_GLOBAL_API_TOKEN", "FIREBASE_PRIVATE_KEY"], [
|
||||
/_(AWS|GCP|GCLOUD|GOOGLE_APPLICATION_CREDENTIALS|AZURE|CLOUDFLARE|CF|DIGITALOCEAN|HETZNER|VULTR|LINODE|RAILWAY|VERCEL|NETLIFY|FLY|RENDER|HEROKU|DOPPLER|FIREBASE)_/,
|
||||
]),
|
||||
rule("server", "Servers, SSH, hosts and proxies", ["ssh", "host", "hosts", "vps", "proxy"],
|
||||
["SSH_PORT", "HOST_USER", "PROXY_PASSWORD"], [
|
||||
/_(SSH|HOST|HOSTNAME|SERVER|VPS|SUDO|ROOT|DROPLET|SFTP|FTP|PROXY|SEED1)_/,
|
||||
/_SEED\d+_/,
|
||||
]),
|
||||
rule("auth", "App auth: JWT/session/encryption keys, OAuth apps, webhook and signing secrets", ["jwt", "session", "oauth", "signing", "webhook", "encryption"],
|
||||
["JWT_SECRET", "SESSION_SECRET", "GOOGLE_CLIENT_SECRET"], [
|
||||
/_(JWT|JWKS|SESSION|AUTH|NEXTAUTH|OAUTH|CLIENT_SECRET|CLIENT_ID|ENCRYPTION|ENCRYPT|COOKIE|SIGNING|SIGN|HMAC|WEBHOOK|PEPPER|SALT|TOTP|OTP|PASSKEY|WEBAUTHN|CLERK|AUTH0|CAPTCHA|HCAPTCHA|RECAPTCHA|TURNSTILE|CRON|TICKET|VERIFY|CSRF|PASSWORD|PASS|USERNAME|LOGIN)_/,
|
||||
/_SHARED_SECRET_/,
|
||||
]),
|
||||
rule("api", "Other third-party API keys and tokens", ["apis", "token", "tokens", "keys"],
|
||||
["TMDB_API_KEY", "VALUESERP_API_KEY", "CAPSOLVER_API_KEY"], [
|
||||
/_(API_KEY|API_TOKEN|API_SECRET|APIKEY|ACCESS_KEY|ACCESS_TOKEN|REFRESH_TOKEN|TOKEN|KEY|SECRET|PAT|PRIVATE_KEY|SECRET_KEY|LICENSE_KEY|KEY_ID)_/,
|
||||
]),
|
||||
rule("config", "Settings that are not secrets: URLs, ports, flags, limits, names", ["settings", "env", "public"],
|
||||
["NODE_ENV", "PORT", "NEXT_PUBLIC_APP_URL"], [
|
||||
/^_(NEXT_PUBLIC|PUBLIC|VITE|EXPO_PUBLIC|NODE|APP)_/,
|
||||
/_(URL|URLS|URI|ORIGIN|ORIGINS|DOMAIN|NAME|ID|PORT|ENV|ENVIRONMENT|MODE|LEVEL|DEBUG|ENABLED|ENABLE|DISABLE|DISABLED|MS|SECONDS|MINUTES|TTL|LIMIT|MAX|MIN|PCT|BPS|USD|CENTS|PATH|DIR|FILE|DESCRIPTION|VERSION|REGION|CONFIG|PROJECT|MODEL|PROVIDER|FORMAT|TIMEOUT|HEADLESS|SIZE|AGENT|INTERVAL|FROM|TO|CONCURRENCY|WIDTH|HEIGHT|FPS|QUALITY|SCOPES|ROLES)_/,
|
||||
]),
|
||||
]);
|
||||
|
||||
/** Everything the rules did not recognise. Always a valid filter value. */
|
||||
export const OTHER_CATEGORY: SecretCategory = rule("other", "Anything the rules did not recognise", ["misc", "unknown"], [], []);
|
||||
|
||||
export const SECRET_CATEGORY_IDS: readonly string[] = Object.freeze([...SECRET_CATEGORIES.map((c) => c.id), OTHER_CATEGORY.id]);
|
||||
|
||||
function tokens(text: string): string {
|
||||
return `_${text.toUpperCase().replace(/[^A-Z0-9]+/g, "_").replace(/^_+|_+$/g, "")}_`;
|
||||
}
|
||||
|
||||
/** The category of one secret, from its name (an env var, an item name, a URL). */
|
||||
export function categorizeSecret(...names: Array<string | undefined>): string {
|
||||
const texts = names.filter((n): n is string => Boolean(n && n.trim())).map(tokens);
|
||||
for (const category of SECRET_CATEGORIES) {
|
||||
if (texts.some((text) => category.patterns.some((p) => p.test(text)))) return category.id;
|
||||
}
|
||||
return OTHER_CATEGORY.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* The category of an OpenCreds item. The item type decides where it is
|
||||
* unambiguous (a card is money, an ssh key is a server credential); otherwise
|
||||
* the name, account provider and login hosts are classified like env names.
|
||||
*/
|
||||
export function categorizeItem(item: Item): string {
|
||||
if (item.type === "card") return "finance";
|
||||
switch (item.key?.keyType) {
|
||||
case "ssh": return "server";
|
||||
case "pgp": return "devtools";
|
||||
case "certificate": return "dns";
|
||||
}
|
||||
const hosts = (item.login?.uris ?? []).map((u) => {
|
||||
try {
|
||||
return new URL(u.uri.includes("://") ? u.uri : `https://${u.uri}`).hostname.replace(/^www\./, "").replace(/\.[a-z]+$/, "");
|
||||
} catch {
|
||||
return u.uri;
|
||||
}
|
||||
});
|
||||
const found = categorizeSecret(item.name, item.account?.provider, ...hosts);
|
||||
if (found !== OTHER_CATEGORY.id) return found;
|
||||
if (item.key?.keyType === "api") return "api";
|
||||
if (item.key?.keyType === "symmetric") return "auth";
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `--category db,social --category api` into canonical ids, accepting
|
||||
* aliases. Unknown words throw with the list of valid ones, so a typo fails
|
||||
* loudly instead of silently matching nothing.
|
||||
*/
|
||||
export function parseCategories(input: string | readonly string[] | undefined): Set<string> | undefined {
|
||||
if (input === undefined) return undefined;
|
||||
const words = (Array.isArray(input) ? input : [input])
|
||||
.flatMap((part) => String(part).split(","))
|
||||
.map((word) => word.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
if (words.length === 0) return undefined;
|
||||
const all = [...SECRET_CATEGORIES, OTHER_CATEGORY];
|
||||
const picked = new Set<string>();
|
||||
for (const word of words) {
|
||||
const match = all.find((c) => c.id === word || c.aliases.includes(word));
|
||||
if (!match) {
|
||||
throw new Error(`Unknown category "${word}". Choose from: ${SECRET_CATEGORY_IDS.join(", ")}.`);
|
||||
}
|
||||
picked.add(match.id);
|
||||
}
|
||||
return picked;
|
||||
}
|
||||
|
||||
export const SIMPLE_CSV_COLUMNS = Object.freeze([
|
||||
"folder", "category", "type", "name", "username", "password", "url", "value", "totp", "notes",
|
||||
] as const);
|
||||
|
||||
/**
|
||||
* One flat row per item, for a spreadsheet or a script: `username`/`password`
|
||||
* for anything you log in with, and `value` for the one opaque secret of the
|
||||
* rest — an env or API key, a private key, an access token, a card number.
|
||||
* Unlike Bitwarden's CSV it keeps key and account items, which is where most
|
||||
* developer secrets live. It is not an import format; export OpenCreds for that.
|
||||
*/
|
||||
export function toSimpleCsv(items: readonly Item[], folders: ReadonlyArray<{ id: string; name: string }>): string {
|
||||
const folderName = new Map(folders.map((f) => [f.id, f.name]));
|
||||
const lines = [csvLine(SIMPLE_CSV_COLUMNS)];
|
||||
for (const item of items) {
|
||||
const { login, key, account, card, identity } = item;
|
||||
lines.push(csvLine([
|
||||
item.folderId ? folderName.get(item.folderId) : "",
|
||||
categorizeItem(item),
|
||||
item.type,
|
||||
item.name,
|
||||
login?.username || account?.handle || account?.email || identity?.username || identity?.email || card?.cardholderName,
|
||||
login?.password || key?.passphrase || card?.code,
|
||||
login?.uris?.[0]?.uri || key?.path || account?.provider,
|
||||
key?.value || key?.privateKey || account?.accessToken || card?.number,
|
||||
login?.totp,
|
||||
item.notes,
|
||||
]));
|
||||
}
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
/** One RFC 4180 CSV line; quotes a cell only when it has to. */
|
||||
export function csvLine(cells: ReadonlyArray<string | number | undefined | null>): string {
|
||||
return cells
|
||||
.map((cell) => {
|
||||
const value = cell === undefined || cell === null ? "" : String(cell);
|
||||
return /[",\n\r]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
|
||||
})
|
||||
.join(",");
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ import {
|
|||
parseDatabase,
|
||||
readHeader,
|
||||
} from "./database.js";
|
||||
import { categorizeItem, parseCategories, toSimpleCsv } from "./categories.js";
|
||||
import { CSV_LOSSY_FIELDS, IMPORT_SOURCES, parseCsvImport, toBitwardenCsv } from "./importers.js";
|
||||
import {
|
||||
createItem,
|
||||
|
|
@ -68,6 +69,12 @@ class CliError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The command as the person typed it — `opencreds` standalone, `logicsrc vault`
|
||||
* when mounted — so every hint and example is one they can paste as is.
|
||||
*/
|
||||
let cli = "opencreds";
|
||||
|
||||
function fail(message: string, code: number): never {
|
||||
throw new CliError(message, code);
|
||||
}
|
||||
|
|
@ -94,7 +101,7 @@ function storeFor(command: Command) {
|
|||
|
||||
function requireMeta(store: ReturnType<typeof createVaultStore>) {
|
||||
const meta = store.readMeta();
|
||||
if (!meta) fail(`No vault at ${store.baseDir} — run \`opencreds init\` first`, EXIT.USAGE);
|
||||
if (!meta) fail(`No vault at ${store.baseDir} — create one with \`${cli} init\``, EXIT.USAGE);
|
||||
return meta;
|
||||
}
|
||||
|
||||
|
|
@ -257,14 +264,27 @@ function applyTypeFlags(command: Command, type: ItemTypeName): Command {
|
|||
return command;
|
||||
}
|
||||
|
||||
function categoriesOrFail(input: string | undefined): Set<string> | undefined {
|
||||
try {
|
||||
return parseCategories(input);
|
||||
} catch (error) {
|
||||
return fail((error as Error).message, EXIT.USAGE);
|
||||
}
|
||||
}
|
||||
|
||||
function printItemLine(item: Item): string {
|
||||
const type = item.type.padEnd(8);
|
||||
const id = item.id.slice(0, 8);
|
||||
return `${id} ${type} ${item.name}`;
|
||||
const category = categorizeItem(item).padEnd(9);
|
||||
return `${id} ${type} ${category} ${item.name}`;
|
||||
}
|
||||
|
||||
/** Register every OpenCreds command onto `parent`. */
|
||||
export function registerCredsCommands(parent: Command): void {
|
||||
const names: string[] = [];
|
||||
for (let c: Command | null = parent; c; c = c.parent) names.unshift(c.name());
|
||||
cli = names.join(" ");
|
||||
const examples = (lines: string) => `\nExamples:\n${lines.replace(/^\n/, "").replace(/\$CLI/g, cli)}\n`;
|
||||
parent.option("--home <dir>", "vault directory (default $OPENCREDS_HOME)");
|
||||
|
||||
// ---------------------------------------------------------------- vault ---
|
||||
|
|
@ -272,6 +292,9 @@ export function registerCredsCommands(parent: Command): void {
|
|||
parent
|
||||
.command("init")
|
||||
.description("create a vault")
|
||||
.addHelpText("after", examples(`
|
||||
$CLI init create your vault (asks for a master password)
|
||||
$CLI status is there a vault, is it unlocked, what is in it`))
|
||||
.option("--namespace <name>", "domain-separation namespace", "opencreds")
|
||||
.option("--iterations <n>", "PBKDF2 iterations", (v: string) => Number.parseInt(v, 10))
|
||||
.option("--password-stdin", "read the master password from stdin instead of prompting twice")
|
||||
|
|
@ -311,6 +334,9 @@ export function registerCredsCommands(parent: Command): void {
|
|||
parent
|
||||
.command("unlock")
|
||||
.description("start a session")
|
||||
.addHelpText("after", examples(`
|
||||
eval "$($CLI unlock)" unlock for this shell only (nothing written to disk)
|
||||
$CLI unlock --persist --timeout 30 unlock for scripts for 30 minutes; \`lock\` ends it`))
|
||||
.option("--persist", "write the session to a 0600 file instead of printing a token")
|
||||
.option("--password-stdin", "read the master password from stdin")
|
||||
.option("--timeout <minutes>", "session lifetime when persisted", (v: string) => Number.parseInt(v, 10), 15)
|
||||
|
|
@ -335,7 +361,7 @@ export function registerCredsCommands(parent: Command): void {
|
|||
process.stdout.write(`Session written to ${path}, expiring in ${opts.timeout} minutes.\n`);
|
||||
process.stdout.write(
|
||||
"That file holds the key to this vault. Anything that can read it can read\n" +
|
||||
"every item. Run `opencreds lock` when you are done.\n",
|
||||
"every item. Run `" + cli + " lock` when you are done.\n",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
|
@ -433,7 +459,13 @@ export function registerCredsCommands(parent: Command): void {
|
|||
|
||||
// ---------------------------------------------------------------- items ---
|
||||
|
||||
const add = parent.command("add").description("add an item");
|
||||
const add = parent
|
||||
.command("add")
|
||||
.description("add an item")
|
||||
.addHelpText("after", examples(`
|
||||
$CLI add login --name GitHub --username me --password - password read from stdin
|
||||
$CLI add key --name DATABASE_URL --key-type env --value - one .env secret
|
||||
$CLI add login --help every flag for one type`));
|
||||
for (const type of ITEM_TYPE_NAMES) {
|
||||
const sub = add
|
||||
.command(type)
|
||||
|
|
@ -484,11 +516,20 @@ export function registerCredsCommands(parent: Command): void {
|
|||
parent
|
||||
.command("list")
|
||||
.description("list items; never prints secret values")
|
||||
.addHelpText("after", examples(`
|
||||
$CLI list everything (never shows values)
|
||||
$CLI list --category db database credentials only
|
||||
$CLI list -c social,api two categories at once
|
||||
$CLI list --type login --search github`))
|
||||
.option("--type <type>", "filter by item type")
|
||||
.option("-c, --category <names>", "filter by category: db, social, server, api, … (comma-separated)")
|
||||
.option("--folder <name>", "filter by folder")
|
||||
.option("--search <text>", "match against the item name")
|
||||
.option("--json", "machine-readable output, masked identically")
|
||||
.action(async function (this: Command, opts: { type?: string; folder?: string; search?: string; json?: boolean }) {
|
||||
.action(async function (
|
||||
this: Command,
|
||||
opts: { type?: string; category?: string; folder?: string; search?: string; json?: boolean },
|
||||
) {
|
||||
await run(async () => {
|
||||
const store = storeFor(this);
|
||||
const userKey = await unlock(store);
|
||||
|
|
@ -500,9 +541,11 @@ export function registerCredsCommands(parent: Command): void {
|
|||
const folderId = opts.folder ? folders.find((f) => f.name === opts.folder)?.id : undefined;
|
||||
if (opts.folder && !folderId) fail(`No folder named "${opts.folder}"`, EXIT.USAGE);
|
||||
|
||||
const categories = categoriesOrFail(opts.category);
|
||||
const needle = opts.search?.toLowerCase();
|
||||
const filtered = items
|
||||
.filter((item) => (opts.type ? item.type === opts.type : true))
|
||||
.filter((item) => (categories ? categories.has(categorizeItem(item)) : true))
|
||||
.filter((item) => (folderId ? item.folderId === folderId : true))
|
||||
.filter((item) => (needle ? item.name.toLowerCase().includes(needle) : true))
|
||||
.sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id));
|
||||
|
|
@ -523,6 +566,9 @@ export function registerCredsCommands(parent: Command): void {
|
|||
.command("get")
|
||||
.argument("<needle>", "item id or name")
|
||||
.description("show one item, with every secret masked")
|
||||
.addHelpText("after", examples(`
|
||||
$CLI get GitHub the item, secrets masked
|
||||
$CLI get GitHub --field login.password --reveal one value, in the clear`))
|
||||
.option("--field <path>", "a single dotted field path, e.g. login.password")
|
||||
.option("--reveal", "print the value of --field in the clear")
|
||||
.option("--json", "machine-readable output, masked identically")
|
||||
|
|
@ -649,22 +695,35 @@ export function registerCredsCommands(parent: Command): void {
|
|||
parent
|
||||
.command("export")
|
||||
.description("export the vault as an OpenCreds database")
|
||||
.addHelpText("after", examples(`
|
||||
$CLI export --format csv --out vault.csv --yes everything, one flat row per item
|
||||
$CLI export --format csv --category db --out db.csv --yes one category
|
||||
$CLI export --out backup.opencreds encrypted backup (asks for a passphrase)`))
|
||||
.option("--out <file>", "output file", `vault${DATABASE_EXTENSION}`)
|
||||
.option("--passphrase-stdin", "read the export passphrase from stdin")
|
||||
.option("--plaintext", "write every secret in the clear (requires --yes)")
|
||||
.option("--format <format>", "opencreds or bitwarden-csv", "opencreds")
|
||||
.option("--format <format>", "opencreds, csv (one flat row per item) or bitwarden-csv", "opencreds")
|
||||
.option("-c, --category <names>", "only items in these categories: db, social, server, api, …")
|
||||
.option("--yes", "confirm a plaintext export")
|
||||
.action(async function (
|
||||
this: Command,
|
||||
opts: { out: string; passphraseStdin?: boolean; plaintext?: boolean; format: string; yes?: boolean },
|
||||
opts: { out: string; passphraseStdin?: boolean; plaintext?: boolean; format: string; category?: string; yes?: boolean },
|
||||
) {
|
||||
await run(async () => {
|
||||
if (!["opencreds", "csv", "bitwarden-csv"].includes(opts.format)) {
|
||||
fail(`Unknown format "${opts.format}"; expected opencreds, csv or bitwarden-csv`, EXIT.USAGE);
|
||||
}
|
||||
if (opts.format !== "opencreds" && this.getOptionValueSource("out") === "default") opts.out = "vault.csv";
|
||||
const categories = categoriesOrFail(opts.category);
|
||||
const store = storeFor(this);
|
||||
const meta = requireMeta(store);
|
||||
const userKey = await unlock(store);
|
||||
const payload = await loadPayload(store, userKey);
|
||||
const loaded = await loadPayload(store, userKey);
|
||||
const payload = categories
|
||||
? { ...loaded, items: loaded.items.filter((item) => categories.has(categorizeItem(item))) }
|
||||
: loaded;
|
||||
|
||||
const wantsPlaintext = Boolean(opts.plaintext) || opts.format === "bitwarden-csv";
|
||||
const wantsPlaintext = Boolean(opts.plaintext) || opts.format !== "opencreds";
|
||||
|
||||
if (wantsPlaintext) {
|
||||
process.stdout.write(
|
||||
|
|
@ -677,6 +736,18 @@ export function registerCredsCommands(parent: Command): void {
|
|||
}
|
||||
}
|
||||
|
||||
if (opts.format === "csv") {
|
||||
writeFileSync(opts.out, toSimpleCsv(payload.items, payload.folders), { encoding: "utf8", mode: 0o600 });
|
||||
try {
|
||||
chmodSync(opts.out, 0o600);
|
||||
} catch {
|
||||
/* no modes on this platform */
|
||||
}
|
||||
store.appendAudit(auditEvent({ action: "database.export_plaintext", itemCount: payload.items.length }));
|
||||
process.stdout.write(`Wrote ${opts.out} — ${payload.items.length} items, every secret in the clear.\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.format === "bitwarden-csv") {
|
||||
const { csv, dropped } = toBitwardenCsv(payload.items, payload.folders);
|
||||
writeFileSync(opts.out, csv, { encoding: "utf8", mode: 0o600 });
|
||||
|
|
@ -730,6 +801,10 @@ export function registerCredsCommands(parent: Command): void {
|
|||
.command("import")
|
||||
.argument("<file>", "an OpenCreds database, or a CSV export from another product")
|
||||
.description("import into the vault")
|
||||
.addHelpText("after", examples(`
|
||||
$CLI import bitwarden.csv --dry-run see what would be imported
|
||||
$CLI import bitwarden.csv import it (Bitwarden, 1Password, Chrome, LastPass or KeePass CSV)
|
||||
$CLI import backup.opencreds restore an OpenCreds export`))
|
||||
.option("--dry-run", "report what would happen and write nothing")
|
||||
.option("--merge <strategy>", "skip, replace or duplicate", "skip")
|
||||
.option("--source <name>", `force a CSV source (${Object.keys(IMPORT_SOURCES).join(", ")})`)
|
||||
|
|
|
|||
|
|
@ -197,3 +197,16 @@ export type {
|
|||
UriMatch,
|
||||
VaultMeta,
|
||||
} from "./types.js";
|
||||
|
||||
export {
|
||||
SECRET_CATEGORIES,
|
||||
SECRET_CATEGORY_IDS,
|
||||
OTHER_CATEGORY,
|
||||
categorizeSecret,
|
||||
categorizeItem,
|
||||
parseCategories,
|
||||
csvLine,
|
||||
toSimpleCsv,
|
||||
SIMPLE_CSV_COLUMNS,
|
||||
type SecretCategory,
|
||||
} from "./categories.js";
|
||||
|
|
|
|||
|
|
@ -8,7 +8,9 @@
|
|||
*/
|
||||
|
||||
import { createInterface } from "node:readline";
|
||||
import { stdin, stdout } from "node:process";
|
||||
// Prompts go to stderr so stdout carries only the answer: `eval "$(opencreds
|
||||
// unlock)"` then captures the export line and nothing else.
|
||||
import { stdin, stderr } from "node:process";
|
||||
|
||||
/** Read a line with the terminal's echo turned off. */
|
||||
export async function promptSecret(label: string): Promise<string> {
|
||||
|
|
@ -18,7 +20,7 @@ export async function promptSecret(label: string): Promise<string> {
|
|||
return readLineFromStdin();
|
||||
}
|
||||
|
||||
const rl = createInterface({ input: stdin, output: stdout, terminal: true });
|
||||
const rl = createInterface({ input: stdin, output: stderr, terminal: true });
|
||||
const asMutable = rl as unknown as { output: { write: (chunk: string) => void }; _writeToOutput?: (s: string) => void };
|
||||
|
||||
let muted = false;
|
||||
|
|
@ -51,7 +53,7 @@ export async function promptNewSecret(label: string, confirmLabel = "Repeat: "):
|
|||
}
|
||||
|
||||
export async function promptLine(label: string): Promise<string> {
|
||||
const rl = createInterface({ input: stdin, output: stdout });
|
||||
const rl = createInterface({ input: stdin, output: stderr });
|
||||
const answer = await new Promise<string>((resolve) => rl.question(label, resolve));
|
||||
rl.close();
|
||||
return answer;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue