mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 07:41:23 +00:00
security: harden agent surface — vault_exec allow-list, SSRF, error hygiene
Stress-test remediation across MCP, CLI, and backend:
- primora_vault_exec rewritten as an allow-list (was deny-list):
colon-form single-token commands bypassed it, leaking every vault
secret; verbatim env-name injection allowed NODE_OPTIONS=--require
host RCE. Now fixed read-mostly command set, arg/path deny regex,
strict env-name grammar, loader-hook name blocklist. All prior
bypasses verified denied; allowed commands still inject.
- SSRF: webhook/job URL validation now blocks cloud metadata IPs
(169.254.169.254, fd00:ec2::254) inside the link-local allowance
and non-canonical IP encodings (decimal/hex bare hosts) smuggled
as "docker service names".
- Path params encodeURIComponent in all client consumers — encodeURI
left ?#& intact, smuggling query segments through {name} params.
- primora_upload_object strict-validates contentBase64 — Buffer.from
silently dropped invalid chars.
- MCP no-creds startup degrades instead of crashing mid-handshake.
- GET /api/v1/context — API-key self-discovery; fixes the CLI dead
end (login --api-key validates the key + seeds org/project,
use/context/whoami work under key auth).
- CLI: unknown commands / bare group exit 1 with hints; mri numeric
coercion reversed via raw-argv restore (007 stays "007");
login --json; orgs:rm / projects:rm / buckets:rm behind --yes;
fetch-failed errors name the cause.
- Error hygiene: pgx.ErrNoRows -> 404, pgconn codes -> clean
400/409 without SQLSTATE leaks, validator failures formatted,
services.InputError maps input faults to 400 (webhook URL/event
errors were 500s), presign on local driver -> 400.
- Webhook events accept wildcard patterns (*, ns.*, custom.*)
matched via SQL LIKE — client-published events can be webhooked.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
3b90dae767
commit
2d6135121e
28 files changed
+705
-32
No files matched your search
+65
-3
@@ -4,7 +4,7 @@ import { cmdLogin, cmdLogout, cmdWhoami } from "./commands/auth.js";
|
||||
import { cmdAuditList } from "./commands/audit.js";
|
||||
import { cmdDocumentsList } from "./commands/documents.js";
|
||||
import { cmdEventsSend } from "./commands/events.js";
|
||||
import { cmdBucketsCreate, cmdBucketsList } from "./commands/buckets.js";
|
||||
import { cmdBucketsCreate, cmdBucketsList, cmdBucketsRemove } from "./commands/buckets.js";
|
||||
import { cmdContext, cmdUse } from "./commands/context.js";
|
||||
import { cmdInject } from "./commands/inject.js";
|
||||
import { cmdJobsCreate, cmdJobsList, cmdJobsRemove, cmdJobsRun, cmdJobsRuns } from "./commands/jobs.js";
|
||||
@@ -16,8 +16,8 @@ import {
|
||||
cmdObjectsPresign,
|
||||
cmdObjectsUpload,
|
||||
} from "./commands/objects.js";
|
||||
import { cmdOrgsCreate, cmdOrgsList } from "./commands/orgs.js";
|
||||
import { cmdProjectsCreate, cmdProjectsList } from "./commands/projects.js";
|
||||
import { cmdOrgsCreate, cmdOrgsList, cmdOrgsRemove } from "./commands/orgs.js";
|
||||
import { cmdProjectsCreate, cmdProjectsList, cmdProjectsRemove } from "./commands/projects.js";
|
||||
import {
|
||||
cmdSecretsGet,
|
||||
cmdSecretsImport,
|
||||
@@ -52,6 +52,7 @@ cli
|
||||
.option("--email <email>", "Account email")
|
||||
.option("--password <password>", "Account password")
|
||||
.option("--api-key <key>", "Store a pk_live_/pk_test_ API key instead of a session")
|
||||
.option("--json", "Machine-readable output")
|
||||
.action(cmdLogin);
|
||||
|
||||
cli.command("logout", "Remove stored credentials").action(cmdLogout);
|
||||
@@ -70,6 +71,10 @@ cli
|
||||
.command("orgs:create <name>", "Create an organization")
|
||||
.option("--slug <slug>", "URL-safe slug (derived from name if omitted)")
|
||||
.action(cmdOrgsCreate);
|
||||
cli
|
||||
.command("orgs:rm <org>", "Delete an organization (id, slug, or name)")
|
||||
.option("--yes", "Skip the confirmation prompt")
|
||||
.action(cmdOrgsRemove);
|
||||
|
||||
cli
|
||||
.command("projects:list", "List projects in the current org")
|
||||
@@ -82,6 +87,10 @@ cli
|
||||
.option("--slug <slug>", "URL-safe slug (derived from name if omitted)")
|
||||
.option("--description <text>", "Description")
|
||||
.action(cmdProjectsCreate);
|
||||
cli
|
||||
.command("projects:rm <project>", "Delete a project (id, slug, or name)")
|
||||
.option("--yes", "Skip the confirmation prompt")
|
||||
.action(cmdProjectsRemove);
|
||||
|
||||
cli
|
||||
.command("buckets:list", "List buckets in the current project")
|
||||
@@ -94,6 +103,11 @@ cli
|
||||
.option("--slug <slug>", "URL-safe slug (derived from name if omitted)")
|
||||
.option("--public", "Public visibility (default private)")
|
||||
.action(cmdBucketsCreate);
|
||||
cli
|
||||
.command("buckets:rm <bucket>", "Delete a bucket and its objects (id or slug)")
|
||||
.option("--project <id>", "Project override")
|
||||
.option("--yes", "Skip the confirmation prompt")
|
||||
.action(cmdBucketsRemove);
|
||||
|
||||
cli
|
||||
.command("objects:list <bucket>", "List objects in a bucket (id or slug)")
|
||||
@@ -331,9 +345,57 @@ if (GROUPS.has(argv[0]) && argv[1] && !argv[1].startsWith("-")) {
|
||||
argv[0] = "secrets:list";
|
||||
}
|
||||
|
||||
// mri coerces numeric-looking option values ("007" → 7, "" → 0), which
|
||||
// corrupts secrets, keys, and passwords. Re-extract raw strings for every
|
||||
// non-boolean flag and patch parsed options before the action runs.
|
||||
function restoreRawOptionStrings(): void {
|
||||
const cmd = (cli as unknown as { matchedCommand?: { options: { isBoolean: boolean; name: string; names: string[] }[] } })
|
||||
.matchedCommand;
|
||||
if (!cmd) return;
|
||||
const state = cli as unknown as { options: Record<string, unknown>; globalCommand: { options: { isBoolean: boolean; name: string; names: string[] }[] } };
|
||||
const specs = [...state.globalCommand.options, ...cmd.options].filter((o) => !o.isBoolean);
|
||||
const raw = process.argv.slice(2);
|
||||
const kebab = (s: string) => s.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
|
||||
for (const spec of specs) {
|
||||
if (typeof state.options[spec.name] !== "number") continue;
|
||||
let value: string | undefined;
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
for (const n of spec.names) {
|
||||
const flags = n.length === 1 ? [`-${n}`] : [`--${n}`, `--${kebab(n)}`];
|
||||
for (const f of flags) {
|
||||
if (raw[i] === f) value = raw[i + 1];
|
||||
else if (raw[i].startsWith(f + "=")) value = raw[i].slice(f.length + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (value !== undefined) state.options[spec.name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown command or a bare group — cac only fires this on non-empty args.
|
||||
cli.addEventListener("command:*", () => {
|
||||
const state = cli as unknown as { args: string[]; commands: { name: string }[] };
|
||||
const arg = String(state.args[0] ?? "");
|
||||
const group = arg.split(":")[0];
|
||||
const verbs = GROUPS.has(group)
|
||||
? state.commands.map((c) => c.name).filter((n) => n.startsWith(group + ":")).map((n) => n.slice(group.length + 1))
|
||||
: [];
|
||||
process.stderr.write(`error: unknown command "${arg.replace(":", " ")}"\n`);
|
||||
if (verbs.length) process.stderr.write(` ${group} verbs: ${verbs.join(", ")}\n`);
|
||||
process.stderr.write(" run `primora --help` for usage\n");
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
cli.parse([process.argv[0], process.argv[1], ...argv], { run: false });
|
||||
restoreRawOptionStrings();
|
||||
const state = cli as unknown as { matchedCommand?: unknown; options: Record<string, unknown> };
|
||||
if (!state.matchedCommand && !state.options.help && !state.options.version) {
|
||||
cli.outputHelp();
|
||||
if (argv.length) process.exit(1);
|
||||
return;
|
||||
}
|
||||
await cli.runMatchedCommand();
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
|
||||
@@ -10,6 +10,7 @@ interface LoginOptions {
|
||||
email?: string;
|
||||
password?: string;
|
||||
apiKey?: string;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
function cancel(): never {
|
||||
@@ -39,9 +40,22 @@ export async function cmdLogin(options: LoginOptions): Promise<void> {
|
||||
throw new CliError("Malformed API key.", "Expected the full key, e.g. prm_xxxx_…");
|
||||
}
|
||||
cfg.auth = { type: "apiKey", key: options.apiKey };
|
||||
// Validate the key against the instance before saving — a stored-but-
|
||||
// dead key is a worse failure than a rejected login.
|
||||
configureClient(cfg);
|
||||
const ctx = await PlatformService.getActorContext();
|
||||
if (ctx.organization) cfg.organizationId = ctx.organization.id;
|
||||
if (ctx.project) cfg.projectId = ctx.project.id;
|
||||
saveConfig(cfg);
|
||||
success(`Saved API key credentials to ${configFilePath()}`);
|
||||
dim("API keys are project-scoped — set context with `primora use --org <id> --project <id>`.");
|
||||
if (isJson(options)) {
|
||||
printJson(ctx);
|
||||
return;
|
||||
}
|
||||
success(`API key verified — ${ctx.key_prefix}… (scopes: ${ctx.scopes?.join(", ") || "none"})`);
|
||||
if (ctx.organization && ctx.project) {
|
||||
success(`Context → ${ctx.organization.slug} / ${ctx.project.slug}`);
|
||||
}
|
||||
dim(`Config written to ${configFilePath()}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -94,6 +108,10 @@ export async function cmdLogin(options: LoginOptions): Promise<void> {
|
||||
|
||||
configureClient(cfg);
|
||||
const me = await PlatformService.getMe();
|
||||
if (isJson(options)) {
|
||||
printJson(me);
|
||||
return;
|
||||
}
|
||||
success(`Signed in as ${me.user.email}`);
|
||||
dim(`Config written to ${configFilePath()}`);
|
||||
}
|
||||
@@ -108,6 +126,23 @@ export async function cmdLogout(): Promise<void> {
|
||||
export async function cmdWhoami(options: { json?: boolean }): Promise<void> {
|
||||
const cfg = loadConfig();
|
||||
configureClient(cfg);
|
||||
if (cfg.auth?.type === "apiKey") {
|
||||
const ctx = await PlatformService.getActorContext();
|
||||
if (isJson(options)) {
|
||||
printJson(ctx);
|
||||
return;
|
||||
}
|
||||
printTable(["KEY", "SCOPES", "ORGANIZATION", "PROJECT"], [
|
||||
[
|
||||
`${ctx.key_prefix}…`,
|
||||
ctx.scopes?.join(", ") || "—",
|
||||
ctx.organization?.slug ?? "—",
|
||||
ctx.project?.slug ?? "—",
|
||||
],
|
||||
]);
|
||||
dim(`\n${cfg.baseUrl} · auth: api_key`);
|
||||
return;
|
||||
}
|
||||
const me = await PlatformService.getMe();
|
||||
|
||||
if (isJson(options)) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { CreateBucketRequest, StorageService } from "@primora/api-client";
|
||||
|
||||
import { loadConfig } from "../config.js";
|
||||
import { configureClient, requireAuth, requireProject } from "../http.js";
|
||||
import { isJson, printJson, printTable, success } from "../out.js";
|
||||
import { configureClient, requireAuth, requireProject, resolveBucketId } from "../http.js";
|
||||
import { confirmOrAbort, isJson, printJson, printTable, success } from "../out.js";
|
||||
|
||||
export async function cmdBucketsList(options: {
|
||||
project?: string;
|
||||
@@ -54,3 +54,22 @@ export async function cmdBucketsCreate(
|
||||
}
|
||||
success(`Bucket "${bucket.name}" created (${bucket.slug}, ${bucket.visibility})`);
|
||||
}
|
||||
|
||||
export async function cmdBucketsRemove(
|
||||
bucketRef: string,
|
||||
options: { project?: string; yes?: boolean; json?: boolean },
|
||||
): Promise<void> {
|
||||
const cfg = loadConfig();
|
||||
requireAuth(cfg);
|
||||
configureClient(cfg);
|
||||
|
||||
const projectId = requireProject(cfg, options.project);
|
||||
const bucketId = await resolveBucketId(projectId, bucketRef);
|
||||
await confirmOrAbort(`Delete bucket "${bucketRef}" and all its objects?`, options.yes);
|
||||
await StorageService.deleteBucket({ bucketId });
|
||||
if (isJson(options)) {
|
||||
printJson({ deleted: bucketId });
|
||||
} else {
|
||||
success(`deleted bucket ${bucketRef}`);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,22 @@ export async function cmdUse(options: UseOptions): Promise<void> {
|
||||
requireAuth(cfg);
|
||||
configureClient(cfg);
|
||||
|
||||
// API keys are bound to one org+project — resolve the scope directly.
|
||||
if (cfg.auth?.type === "apiKey") {
|
||||
const ctx = await PlatformService.getActorContext();
|
||||
if (options.org && ctx.organization && options.org !== ctx.organization.id && options.org !== ctx.organization.slug) {
|
||||
throw new Error(`API key is scoped to ${ctx.organization.slug} — cannot switch to "${options.org}".`);
|
||||
}
|
||||
if (options.project && ctx.project && options.project !== ctx.project.id && options.project !== ctx.project.slug) {
|
||||
throw new Error(`API key is scoped to ${ctx.project.slug} — cannot switch to "${options.project}".`);
|
||||
}
|
||||
if (ctx.organization) cfg.organizationId = ctx.organization.id;
|
||||
if (ctx.project) cfg.projectId = ctx.project.id;
|
||||
saveConfig(cfg);
|
||||
success(`Context → ${ctx.organization?.slug ?? "—"} / ${ctx.project?.slug ?? "—"}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const me = await PlatformService.getMe();
|
||||
const orgs = me.organizations;
|
||||
if (orgs.length === 0) {
|
||||
@@ -77,6 +93,19 @@ export async function cmdContext(options: { json?: boolean }): Promise<void> {
|
||||
requireAuth(cfg);
|
||||
configureClient(cfg);
|
||||
|
||||
if (cfg.auth?.type === "apiKey") {
|
||||
const ctx = await PlatformService.getActorContext();
|
||||
if (isJson(options)) {
|
||||
printJson({ baseUrl: cfg.baseUrl, auth: "apiKey", ...ctx });
|
||||
return;
|
||||
}
|
||||
dim(`url ${cfg.baseUrl}`);
|
||||
dim(`auth api_key (${ctx.key_prefix}…, scopes: ${ctx.scopes?.join(", ") || "none"})`);
|
||||
process.stdout.write(`org ${ctx.organization ? `${ctx.organization.name} (${ctx.organization.slug})` : "—"}\n`);
|
||||
process.stdout.write(`project ${ctx.project ? `${ctx.project.name} (${ctx.project.slug})` : "—"}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
const me = await PlatformService.getMe();
|
||||
const org = me.organizations.find((o) => o.id === cfg.organizationId);
|
||||
const project = org?.projects.find((pr) => pr.id === cfg.projectId);
|
||||
|
||||
@@ -38,3 +38,31 @@ export async function cmdOrgsCreate(
|
||||
}
|
||||
success(`Organization "${org.name}" created (${org.slug})`);
|
||||
}
|
||||
|
||||
export async function cmdOrgsRemove(
|
||||
orgRef: string,
|
||||
options: { yes?: boolean; json?: boolean },
|
||||
): Promise<void> {
|
||||
const cfg = loadConfig();
|
||||
requireAuth(cfg);
|
||||
configureClient(cfg);
|
||||
|
||||
let organizationId = orgRef;
|
||||
if (!isUuid(orgRef)) {
|
||||
const me = await PlatformService.getMe();
|
||||
const found = me.organizations.find((o) => o.id === orgRef || o.slug === orgRef || o.name === orgRef);
|
||||
if (!found) throw new Error(`Organization "${orgRef}" not found — pass its id directly.`);
|
||||
organizationId = found.id;
|
||||
}
|
||||
await confirmOrAbort(`Delete organization "${orgRef}"? Every project inside goes with it.`, options.yes);
|
||||
await OrganizationsService.deleteOrganization({ organizationId });
|
||||
if (isJson(options)) {
|
||||
printJson({ deleted: organizationId });
|
||||
} else {
|
||||
success(`deleted organization ${orgRef}`);
|
||||
}
|
||||
}
|
||||
|
||||
function isUuid(v: string): boolean {
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v);
|
||||
}
|
||||
@@ -50,3 +50,34 @@ export async function cmdProjectsCreate(
|
||||
}
|
||||
success(`Project "${project.name}" created (${project.slug})`);
|
||||
}
|
||||
|
||||
export async function cmdProjectsRemove(
|
||||
projectRef: string,
|
||||
options: { org?: string; yes?: boolean; json?: boolean },
|
||||
): Promise<void> {
|
||||
const cfg = loadConfig();
|
||||
requireAuth(cfg);
|
||||
configureClient(cfg);
|
||||
|
||||
let projectId = projectRef;
|
||||
if (!isUuid(projectRef)) {
|
||||
const me = await PlatformService.getMe();
|
||||
const orgs = cfg.auth?.type === "apiKey" ? [] : me.organizations;
|
||||
const found = orgs
|
||||
.flatMap((o) => o.projects.map((pr) => ({ ...pr, orgId: o.id })))
|
||||
.find((pr) => pr.id === projectRef || pr.slug === projectRef || pr.name === projectRef);
|
||||
if (!found) throw new Error(`Project "${projectRef}" not found — pass its id directly.`);
|
||||
projectId = found.id;
|
||||
}
|
||||
await confirmOrAbort(`Delete project "${projectRef}"? Buckets, secrets, and functions go with it.`, options.yes);
|
||||
await ProjectsService.deleteProject({ projectId });
|
||||
if (isJson(options)) {
|
||||
printJson({ deleted: projectId });
|
||||
} else {
|
||||
success(`deleted project ${projectRef}`);
|
||||
}
|
||||
}
|
||||
|
||||
function isUuid(v: string): boolean {
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v);
|
||||
}
|
||||
@@ -60,6 +60,8 @@ export function configureClient(cfg: CliConfig): void {
|
||||
OpenAPI.BASE = `${cfg.baseUrl}/api/v1`;
|
||||
OpenAPI.HEADERS = undefined;
|
||||
OpenAPI.TOKEN = undefined;
|
||||
// encodeURI leaves ?#& intact in path params — smuggles query segments.
|
||||
OpenAPI.ENCODE_PATH = encodeURIComponent;
|
||||
|
||||
if (cfg.auth?.type === "apiKey") {
|
||||
OpenAPI.HEADERS = { "X-API-Key": cfg.auth.key };
|
||||
|
||||
+19
-1
@@ -17,9 +17,27 @@ export function dim(message: string): void {
|
||||
process.stdout.write(pc.dim(message) + "\n");
|
||||
}
|
||||
|
||||
/** Destructive-action gate — requires --yes in scripts, prompts on a TTY. */
|
||||
export async function confirmOrAbort(message: string, yes?: boolean): Promise<void> {
|
||||
if (yes) return;
|
||||
if (!process.stdin.isTTY) {
|
||||
throw new Error(`${message} — pass --yes to confirm in non-interactive mode`);
|
||||
}
|
||||
const answer = await p.confirm({ message });
|
||||
if (p.isCancel(answer) || !answer) {
|
||||
p.cancel("Aborted.");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export function fail(error: unknown): never {
|
||||
const err = error as { message?: string; hint?: string; status?: number; body?: unknown };
|
||||
const err = error as { message?: string; hint?: string; status?: number; body?: unknown; cause?: { code?: string } };
|
||||
let message = err?.message ?? "unknown error";
|
||||
// undici TypeError "fetch failed" — the useful part is the cause code.
|
||||
if (message === "fetch failed" || message.startsWith("fetch failed")) {
|
||||
const code = err?.cause?.code;
|
||||
message = `could not reach the instance${code ? ` (${code})` : ""} — check \`primora context\` and that it is running`;
|
||||
}
|
||||
// generated client throws ApiError with a parsed body
|
||||
const body = err?.body as { error?: { message?: string } } | undefined;
|
||||
if (body?.error?.message) message = body.error.message;
|
||||
|
||||
Reference in new issue
Block a user