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:
Tomas DvorakandDevin committed 2026-09-24 21:56:11 +02:00
1 parent 3b90dae767
commit 2d6135121e
28 files changed
+705 -32

No files matched your search

+65 -3
View File
@@ -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);
+37 -2
View File
@@ -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)) {
+21 -2
View File
@@ -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}`);
}
}
+29
View File
@@ -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);
+28
View File
@@ -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);
}
+31
View File
@@ -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);
}
+2
View File
@@ -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
View File
@@ -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;