feat(server): enhance secret management and logging functionality
- Added SCRUNNER_SECRETS_KEY to README as a required variable for production. - Implemented redaction of sensitive information in logs across various components. - Enhanced script execution functions to include an owner parameter for better secret management. - Introduced a secrets API in the script sandbox for retrieving and managing secrets. - Updated UI components to support owner selection for script execution and secret management.
This commit is contained in:
@@ -3,6 +3,18 @@ import path from "path";
|
||||
import pino from "pino";
|
||||
import * as store from "./store.js";
|
||||
import { LOGS_DIR } from "./paths.js";
|
||||
import { redactString } from "./secret-value.js";
|
||||
|
||||
/**
|
||||
* @param {{ write: (line: string) => unknown }} dest
|
||||
*/
|
||||
function redactStream(dest) {
|
||||
return {
|
||||
write(line) {
|
||||
dest.write(redactString(typeof line === "string" ? line : String(line)));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const LEVEL_TO_NUM = {
|
||||
trace: 10,
|
||||
@@ -27,7 +39,7 @@ let timer = null;
|
||||
function enqueueLine(line) {
|
||||
let record;
|
||||
try {
|
||||
record = JSON.parse(line);
|
||||
record = JSON.parse(redactString(line));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
@@ -103,9 +115,9 @@ const rollingFile = pino.transport({
|
||||
export const log = pino(
|
||||
{ level: process.env.SCRUNNER_LOG_LEVEL ?? "debug" },
|
||||
pino.multistream([
|
||||
{ level: "debug", stream: process.stdout },
|
||||
{ level: "debug", stream: rollingFile },
|
||||
{ level: "debug", stream: sqliteStream },
|
||||
{ level: "debug", stream: redactStream(process.stdout) },
|
||||
{ level: "debug", stream: redactStream(rollingFile) },
|
||||
{ level: "debug", stream: redactStream(sqliteStream) },
|
||||
]),
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* @param {import("knex").Knex} knex
|
||||
*/
|
||||
export async function up(knex) {
|
||||
await knex.schema.createTable("secrets", (t) => {
|
||||
t.text("id").primary();
|
||||
t.text("owner").notNullable();
|
||||
t.text("name").notNullable();
|
||||
t.text("ciphertext").notNullable();
|
||||
t.text("iv").notNullable();
|
||||
t.text("auth_tag").notNullable();
|
||||
t.text("created_at").notNullable();
|
||||
t.text("updated_at").notNullable();
|
||||
t.unique(["owner", "name"]);
|
||||
});
|
||||
|
||||
await knex.schema.raw(
|
||||
"CREATE INDEX secrets_owner_name_idx ON secrets (owner, name)",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("knex").Knex} knex
|
||||
*/
|
||||
export async function down(knex) {
|
||||
await knex.schema.dropTableIfExists("secrets");
|
||||
}
|
||||
@@ -207,12 +207,13 @@ export function createRegistry(server) {
|
||||
* @param {string} runId
|
||||
* @param {import("pino").Logger} runLog
|
||||
* @param {string} key
|
||||
* @param {string} owner
|
||||
*/
|
||||
async function runLinearSteps(compiled, ctx, runId, runLog, key) {
|
||||
async function runLinearSteps(compiled, ctx, runId, runLog, key, owner) {
|
||||
let next = ctx;
|
||||
for (const index of compiled.order) {
|
||||
const parsed = compiled.steps[index];
|
||||
next = await runCompiledStep(parsed, next, index, runId, runLog, key);
|
||||
next = await runCompiledStep(parsed, next, index, runId, runLog, key, owner);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@@ -223,8 +224,9 @@ export function createRegistry(server) {
|
||||
* @param {string} runId
|
||||
* @param {import("pino").Logger} runLog
|
||||
* @param {string} key
|
||||
* @param {string} owner
|
||||
*/
|
||||
async function runDagSteps(compiled, ctx, runId, runLog, key) {
|
||||
async function runDagSteps(compiled, ctx, runId, runLog, key, owner) {
|
||||
const triggerData = ctx.data;
|
||||
/** @type {Map<string, unknown>} */
|
||||
const outputsById = new Map();
|
||||
@@ -240,6 +242,7 @@ export function createRegistry(server) {
|
||||
runId,
|
||||
runLog,
|
||||
key,
|
||||
owner,
|
||||
);
|
||||
if (parsed.id) {
|
||||
outputsById.set(parsed.id, last);
|
||||
@@ -255,8 +258,9 @@ export function createRegistry(server) {
|
||||
* @param {string} runId
|
||||
* @param {import("pino").Logger} runLog
|
||||
* @param {string} key
|
||||
* @param {string} owner
|
||||
*/
|
||||
async function runCompiledStep(parsed, ctx, index, runId, runLog, key) {
|
||||
async function runCompiledStep(parsed, ctx, index, runId, runLog, key, owner) {
|
||||
const script = parsed.kind === "set" ? SET_STEP_SCRIPT : parsed.script;
|
||||
const config = parsed.config;
|
||||
const step = await store.startStep({
|
||||
@@ -287,6 +291,7 @@ export function createRegistry(server) {
|
||||
const result = await runScript(script, { ...ctx, config }, {
|
||||
log: stepLog,
|
||||
workflowName: key,
|
||||
owner,
|
||||
});
|
||||
await store.finishStep(step.id, "success", result);
|
||||
return result;
|
||||
@@ -327,9 +332,9 @@ export function createRegistry(server) {
|
||||
try {
|
||||
const compiled = compileWorkflowScripts(workflow.scripts);
|
||||
if (compiled.dagMode) {
|
||||
ctx = await runDagSteps(compiled, ctx, run.id, runLog, key);
|
||||
ctx = await runDagSteps(compiled, ctx, run.id, runLog, key, owner);
|
||||
} else {
|
||||
ctx = await runLinearSteps(compiled, ctx, run.id, runLog, key);
|
||||
ctx = await runLinearSteps(compiled, ctx, run.id, runLog, key, owner);
|
||||
}
|
||||
await store.finishRun(run.id, "success", ctx);
|
||||
return { runId: run.id, status: "success", result: ctx };
|
||||
|
||||
@@ -15,7 +15,9 @@ import scriptsPluginFactory from "./src/api/scripts.js";
|
||||
import workflowsPluginFactory from "./src/api/workflows.js";
|
||||
import runsPlugin from "./src/api/runs.js";
|
||||
import dashboardPluginFactory from "./src/api/dashboard.js";
|
||||
import secretsPlugin from "./src/api/secrets.js";
|
||||
import { WEB_DIST } from "./paths.js";
|
||||
import { resolveSecretsKeyMaterial } from "./secrets.js";
|
||||
|
||||
await migrate();
|
||||
enableLogPersistence();
|
||||
@@ -29,6 +31,13 @@ if (!jwtSecret) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
resolveSecretsKeyMaterial();
|
||||
} catch (err) {
|
||||
log.error(err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const server = fastify({ loggerInstance: log });
|
||||
|
||||
await server.register(cookie);
|
||||
@@ -78,6 +87,7 @@ await server.register(
|
||||
});
|
||||
await api.register(authPlugin);
|
||||
await api.register(usersPlugin);
|
||||
await api.register(secretsPlugin);
|
||||
await api.register(scriptsPluginFactory(registry));
|
||||
await api.register(workflowsPluginFactory(registry));
|
||||
await api.register(runsPlugin);
|
||||
|
||||
@@ -6,6 +6,8 @@ import axios from "axios";
|
||||
import pino from "pino";
|
||||
import { createKvApi } from "./kv-store.js";
|
||||
import { SCRIPTS_DIR } from "./paths.js";
|
||||
import { isSecret, Secret, unwrapSecretsDeep } from "./secret-value.js";
|
||||
import { getSecretPlaintext } from "./secrets-store.js";
|
||||
|
||||
const hostRequire = createRequire(import.meta.url);
|
||||
|
||||
@@ -256,7 +258,26 @@ function createScreenedAxios(log) {
|
||||
screenRequestUrl(url, log);
|
||||
return config;
|
||||
});
|
||||
return instance;
|
||||
|
||||
for (const method of [
|
||||
"request",
|
||||
"get",
|
||||
"delete",
|
||||
"head",
|
||||
"options",
|
||||
"post",
|
||||
"put",
|
||||
"patch",
|
||||
]) {
|
||||
const orig = instance[method].bind(instance);
|
||||
instance[method] = (...args) => orig(...unwrapSecretsDeep(args));
|
||||
}
|
||||
|
||||
return new Proxy(instance, {
|
||||
apply(_target, _thisArg, args) {
|
||||
return instance.request(...args);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createRestrictedRequire(screenedAxios) {
|
||||
@@ -272,18 +293,45 @@ function createRestrictedRequire(screenedAxios) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ log: import("pino").Logger, script: string, workflowName: string }} opts
|
||||
* @param {string} owner
|
||||
*/
|
||||
function createScriptSandbox({ log, script, workflowName }) {
|
||||
function createSecretsApi(owner) {
|
||||
return {
|
||||
/**
|
||||
* @param {string} name
|
||||
*/
|
||||
async get(name) {
|
||||
const value = await getSecretPlaintext(owner, name);
|
||||
if (value == null) {
|
||||
throw new Error(`secret "${name}" not found`);
|
||||
}
|
||||
return new Secret(value);
|
||||
},
|
||||
/**
|
||||
* @param {unknown} value
|
||||
*/
|
||||
reveal(value) {
|
||||
if (isSecret(value)) return value.reveal();
|
||||
throw new Error("reveal() expects a Secret from $secrets.get()");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ log: import("pino").Logger, script: string, workflowName: string, owner?: string }} opts
|
||||
*/
|
||||
function createScriptSandbox({ log, script, workflowName, owner = "default" }) {
|
||||
const scriptLog = log.child({ workflow: workflowName, script });
|
||||
const $axios = createScreenedAxios(scriptLog);
|
||||
const $kv = createKvApi(workflowName);
|
||||
const $secrets = createSecretsApi(owner);
|
||||
const sandbox = {
|
||||
...pickBuiltins(),
|
||||
log: scriptLog,
|
||||
console: createConsole(scriptLog),
|
||||
$axios,
|
||||
$kv,
|
||||
$secrets,
|
||||
require: createRestrictedRequire($axios),
|
||||
};
|
||||
|
||||
@@ -328,10 +376,10 @@ export function extractScriptMeta(fn) {
|
||||
|
||||
/**
|
||||
* @param {import("node:vm").Script} compiled
|
||||
* @param {{ log: import("pino").Logger, script: string, workflowName: string }} opts
|
||||
* @param {{ log: import("pino").Logger, script: string, workflowName: string, owner?: string }} opts
|
||||
*/
|
||||
function instantiateCompiled(compiled, { log, script, workflowName }) {
|
||||
const sandbox = createScriptSandbox({ log, script, workflowName });
|
||||
function instantiateCompiled(compiled, { log, script, workflowName, owner }) {
|
||||
const sandbox = createScriptSandbox({ log, script, workflowName, owner });
|
||||
return compiled.runInContext(sandbox);
|
||||
}
|
||||
|
||||
@@ -341,7 +389,7 @@ function instantiateCompiled(compiled, { log, script, workflowName }) {
|
||||
*
|
||||
* @param {string} script
|
||||
* @param {string} source
|
||||
* @param {{ log?: import("pino").Logger, workflowName?: string }} [opts]
|
||||
* @param {{ log?: import("pino").Logger, workflowName?: string, owner?: string }} [opts]
|
||||
*/
|
||||
export function instantiateScriptSource(script, source, opts = {}) {
|
||||
const compiled = compileScriptSource(source, script);
|
||||
@@ -349,6 +397,7 @@ export function instantiateScriptSource(script, source, opts = {}) {
|
||||
log: opts.log ?? inspectLog,
|
||||
script,
|
||||
workflowName: opts.workflowName ?? "inspect",
|
||||
owner: opts.owner ?? "default",
|
||||
});
|
||||
return { fn, ...extractScriptMeta(fn) };
|
||||
}
|
||||
@@ -390,11 +439,11 @@ function loadCompiledScript(script) {
|
||||
*
|
||||
* @param {string} script
|
||||
* @param {unknown} ctx
|
||||
* @param {{ log: import("pino").Logger, workflowName: string }} opts
|
||||
* @param {{ log: import("pino").Logger, workflowName: string, owner?: string }} opts
|
||||
*/
|
||||
export async function runScript(script, ctx, { log, workflowName }) {
|
||||
export async function runScript(script, ctx, { log, workflowName, owner }) {
|
||||
const compiled = loadCompiledScript(script);
|
||||
const fn = instantiateCompiled(compiled, { log, script, workflowName });
|
||||
const fn = instantiateCompiled(compiled, { log, script, workflowName, owner });
|
||||
return await fn(ctx);
|
||||
}
|
||||
|
||||
@@ -404,9 +453,9 @@ export async function runScript(script, ctx, { log, workflowName }) {
|
||||
* @param {string} script
|
||||
* @param {string} source
|
||||
* @param {unknown} ctx
|
||||
* @param {{ log: import("pino").Logger, workflowName: string }} opts
|
||||
* @param {{ log: import("pino").Logger, workflowName: string, owner?: string }} opts
|
||||
*/
|
||||
export async function runScriptSource(script, source, ctx, { log, workflowName }) {
|
||||
const { fn } = instantiateScriptSource(script, source, { log, workflowName });
|
||||
export async function runScriptSource(script, source, ctx, { log, workflowName, owner }) {
|
||||
const { fn } = instantiateScriptSource(script, source, { log, workflowName, owner });
|
||||
return await fn(ctx);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
async function getSecret(ctx) {
|
||||
const name = ctx.config?.name;
|
||||
if (typeof name !== "string" || name.length === 0) {
|
||||
throw new Error("config.name is required");
|
||||
}
|
||||
const as =
|
||||
typeof ctx.config?.as === "string" && ctx.config.as.length > 0
|
||||
? ctx.config.as
|
||||
: name;
|
||||
|
||||
const value = await $secrets.get(name);
|
||||
const base =
|
||||
ctx != null && typeof ctx === "object" && !Array.isArray(ctx) ? { ...ctx } : {};
|
||||
const data =
|
||||
base.data != null && typeof base.data === "object" && !Array.isArray(base.data)
|
||||
? { ...base.data }
|
||||
: {};
|
||||
data[as] = value;
|
||||
return { ...base, data };
|
||||
}
|
||||
|
||||
getSecret.meta = {
|
||||
description:
|
||||
"Load a named secret for this workflow owner into ctx.data. The value is wrapped and redacted in logs.",
|
||||
config: {
|
||||
name: {
|
||||
type: "string",
|
||||
required: true,
|
||||
description: "Secret name (per owner)",
|
||||
},
|
||||
as: {
|
||||
type: "string",
|
||||
required: false,
|
||||
description: "ctx.data field to write (defaults to name)",
|
||||
},
|
||||
},
|
||||
input: {},
|
||||
output: {
|
||||
data: {
|
||||
type: "object",
|
||||
description: "Previous ctx.data plus the retrieved Secret at [as]",
|
||||
},
|
||||
},
|
||||
example: {
|
||||
data: {},
|
||||
config: { name: "ntfy_token", as: "ntfyToken" },
|
||||
},
|
||||
};
|
||||
|
||||
export default getSecret;
|
||||
@@ -0,0 +1,101 @@
|
||||
import { inspect } from "node:util";
|
||||
|
||||
export const REDACTED = "[secret]";
|
||||
export const MIN_SECRET_LENGTH = 8;
|
||||
|
||||
/** @type {Set<string>} */
|
||||
const plaintextValues = new Set();
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
*/
|
||||
export function registerPlaintext(value) {
|
||||
if (typeof value === "string" && value.length >= MIN_SECRET_LENGTH) {
|
||||
plaintextValues.add(value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace registered secret strings in text (raw and JSON-escaped forms).
|
||||
* @param {string} text
|
||||
*/
|
||||
export function redactString(text) {
|
||||
if (typeof text !== "string" || plaintextValues.size === 0) return text;
|
||||
let out = text;
|
||||
for (const secret of plaintextValues) {
|
||||
if (out.includes(secret)) {
|
||||
out = out.split(secret).join("***");
|
||||
}
|
||||
const escaped = JSON.stringify(secret).slice(1, -1);
|
||||
if (escaped !== secret && out.includes(escaped)) {
|
||||
out = out.split(escaped).join("***");
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export class Secret {
|
||||
/** @type {string} */
|
||||
#value;
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
*/
|
||||
constructor(value) {
|
||||
if (typeof value !== "string") {
|
||||
throw new Error("secret value must be a string");
|
||||
}
|
||||
this.#value = value;
|
||||
registerPlaintext(value);
|
||||
}
|
||||
|
||||
reveal() {
|
||||
return this.#value;
|
||||
}
|
||||
|
||||
toString() {
|
||||
return REDACTED;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return REDACTED;
|
||||
}
|
||||
|
||||
[inspect.custom]() {
|
||||
return REDACTED;
|
||||
}
|
||||
|
||||
[Symbol.toPrimitive]() {
|
||||
return REDACTED;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
*/
|
||||
export function isSecret(value) {
|
||||
return value instanceof Secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @param {WeakSet<object>} [seen]
|
||||
*/
|
||||
export function unwrapSecretsDeep(value, seen = new WeakSet()) {
|
||||
if (isSecret(value)) return value.reveal();
|
||||
if (value == null || typeof value !== "object") return value;
|
||||
if (Buffer.isBuffer(value) || ArrayBuffer.isView(value)) return value;
|
||||
if (seen.has(value)) return value;
|
||||
seen.add(value);
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => unwrapSecretsDeep(item, seen));
|
||||
}
|
||||
|
||||
/** @type {Record<string, unknown>} */
|
||||
const out = {};
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
out[key] = unwrapSecretsDeep(child, seen);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { db } from "./db.js";
|
||||
import { assertOwner } from "./fs-store.js";
|
||||
import { decryptSecret, encryptSecret } from "./secrets.js";
|
||||
import { MIN_SECRET_LENGTH, registerPlaintext } from "./secret-value.js";
|
||||
|
||||
const MAX_NAME_LENGTH = 128;
|
||||
const SECRET_NAME_RE = /^[A-Za-z0-9._-]+$/;
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} name
|
||||
* @returns {string}
|
||||
*/
|
||||
export function assertSecretName(name) {
|
||||
if (typeof name !== "string" || !SECRET_NAME_RE.test(name)) {
|
||||
const err = new Error("invalid secret name");
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
if (name.length > MAX_NAME_LENGTH) {
|
||||
const err = new Error(`secret name must be at most ${MAX_NAME_LENGTH} characters`);
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function publicSecret(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
owner: row.owner,
|
||||
name: row.name,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ owner?: string }} [filters]
|
||||
*/
|
||||
export async function listSecrets(filters = {}) {
|
||||
let q = db("secrets")
|
||||
.select("id", "owner", "name", "created_at", "updated_at")
|
||||
.orderBy("owner", "asc")
|
||||
.orderBy("name", "asc");
|
||||
if (filters.owner) {
|
||||
q = q.where("owner", assertOwner(filters.owner));
|
||||
}
|
||||
return q;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
*/
|
||||
export async function getSecretById(id) {
|
||||
const row = await db("secrets")
|
||||
.select("id", "owner", "name", "created_at", "updated_at")
|
||||
.where({ id })
|
||||
.first();
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ owner: string, name: string, value: string }} opts
|
||||
*/
|
||||
export async function upsertSecret({ owner, name, value }) {
|
||||
if (typeof value !== "string" || value.length < MIN_SECRET_LENGTH) {
|
||||
const err = new Error(`value must be at least ${MIN_SECRET_LENGTH} characters`);
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
const ownerName = assertOwner(owner);
|
||||
const secretName = assertSecretName(name);
|
||||
registerPlaintext(value);
|
||||
const { ciphertext, iv, authTag } = encryptSecret(value);
|
||||
const now = nowIso();
|
||||
const existing = await db("secrets")
|
||||
.where({ owner: ownerName, name: secretName })
|
||||
.first();
|
||||
|
||||
if (existing) {
|
||||
await db("secrets")
|
||||
.where({ id: existing.id })
|
||||
.update({
|
||||
ciphertext,
|
||||
iv,
|
||||
auth_tag: authTag,
|
||||
updated_at: now,
|
||||
});
|
||||
return getSecretById(existing.id);
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
await db("secrets").insert({
|
||||
id,
|
||||
owner: ownerName,
|
||||
name: secretName,
|
||||
ciphertext,
|
||||
iv,
|
||||
auth_tag: authTag,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
return getSecretById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function deleteSecret(id) {
|
||||
const n = await db("secrets").where({ id }).del();
|
||||
return n > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a named secret for an owner. Returns null if missing.
|
||||
* @param {string} owner
|
||||
* @param {string} name
|
||||
* @returns {Promise<string | null>}
|
||||
*/
|
||||
export async function getSecretPlaintext(owner, name) {
|
||||
const ownerName = assertOwner(owner);
|
||||
const secretName = assertSecretName(name);
|
||||
const row = await db("secrets")
|
||||
.where({ owner: ownerName, name: secretName })
|
||||
.first();
|
||||
if (!row) return null;
|
||||
try {
|
||||
const plaintext = decryptSecret({
|
||||
ciphertext: row.ciphertext,
|
||||
iv: row.iv,
|
||||
authTag: row.auth_tag,
|
||||
});
|
||||
registerPlaintext(plaintext);
|
||||
return plaintext;
|
||||
} catch {
|
||||
throw new Error(`failed to decrypt secret "${secretName}"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
|
||||
|
||||
const DEV_DEFAULT = "scrunner-dev-secrets-key";
|
||||
const SCRYPT_SALT = Buffer.from("scrunner-secrets-v1");
|
||||
const KEY_LEN = 32;
|
||||
const IV_LEN = 12;
|
||||
const AUTH_TAG_LEN = 16;
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
export function resolveSecretsKeyMaterial() {
|
||||
const raw =
|
||||
process.env.SCRUNNER_SECRETS_KEY ??
|
||||
(process.env.NODE_ENV === "production" ? "" : DEV_DEFAULT);
|
||||
if (!raw) {
|
||||
throw new Error("SCRUNNER_SECRETS_KEY is required in production");
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
/** @type {Buffer | null} */
|
||||
let cachedKey = null;
|
||||
|
||||
/**
|
||||
* @returns {Buffer}
|
||||
*/
|
||||
export function getMasterKey() {
|
||||
if (cachedKey) return cachedKey;
|
||||
const raw = resolveSecretsKeyMaterial();
|
||||
cachedKey = /^[0-9a-fA-F]{64}$/.test(raw)
|
||||
? Buffer.from(raw, "hex")
|
||||
: scryptSync(raw, SCRYPT_SALT, KEY_LEN);
|
||||
return cachedKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} plaintext
|
||||
* @returns {{ ciphertext: string, iv: string, authTag: string }}
|
||||
*/
|
||||
export function encryptSecret(plaintext) {
|
||||
const iv = randomBytes(IV_LEN);
|
||||
const cipher = createCipheriv("aes-256-gcm", getMasterKey(), iv, {
|
||||
authTagLength: AUTH_TAG_LEN,
|
||||
});
|
||||
const encrypted = Buffer.concat([
|
||||
cipher.update(plaintext, "utf8"),
|
||||
cipher.final(),
|
||||
]);
|
||||
return {
|
||||
ciphertext: encrypted.toString("base64"),
|
||||
iv: iv.toString("base64"),
|
||||
authTag: cipher.getAuthTag().toString("base64"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ ciphertext: string, iv: string, authTag: string }} row
|
||||
* @returns {string}
|
||||
*/
|
||||
export function decryptSecret(row) {
|
||||
const decipher = createDecipheriv(
|
||||
"aes-256-gcm",
|
||||
getMasterKey(),
|
||||
Buffer.from(row.iv, "base64"),
|
||||
{ authTagLength: AUTH_TAG_LEN },
|
||||
);
|
||||
decipher.setAuthTag(Buffer.from(row.authTag, "base64"));
|
||||
return Buffer.concat([
|
||||
decipher.update(Buffer.from(row.ciphertext, "base64")),
|
||||
decipher.final(),
|
||||
]).toString("utf8");
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import pino from "pino";
|
||||
import { redactString } from "../../secret-value.js";
|
||||
|
||||
const LEVEL_TO_NUM = {
|
||||
trace: 10,
|
||||
@@ -22,7 +23,9 @@ export function createDryRunLogger() {
|
||||
write(line) {
|
||||
let record;
|
||||
try {
|
||||
record = JSON.parse(typeof line === "string" ? line : String(line));
|
||||
record = JSON.parse(
|
||||
redactString(typeof line === "string" ? line : String(line)),
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
@@ -43,7 +46,7 @@ export function createDryRunLogger() {
|
||||
logs.push({
|
||||
ts,
|
||||
level,
|
||||
msg,
|
||||
msg: typeof msg === "string" ? redactString(msg) : msg,
|
||||
payload: Object.keys(rest).length ? rest : null,
|
||||
});
|
||||
},
|
||||
@@ -60,14 +63,16 @@ export function safeSerialize(value) {
|
||||
const seen = new WeakSet();
|
||||
try {
|
||||
return JSON.parse(
|
||||
JSON.stringify(value, (_key, v) => {
|
||||
if (typeof v === "bigint") return v.toString();
|
||||
if (typeof v === "object" && v !== null) {
|
||||
if (seen.has(v)) return "[Circular]";
|
||||
seen.add(v);
|
||||
}
|
||||
return v;
|
||||
}),
|
||||
redactString(
|
||||
JSON.stringify(value, (_key, v) => {
|
||||
if (typeof v === "bigint") return v.toString();
|
||||
if (typeof v === "object" && v !== null) {
|
||||
if (seen.has(v)) return "[Circular]";
|
||||
seen.add(v);
|
||||
}
|
||||
return v;
|
||||
}),
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
return {
|
||||
|
||||
@@ -85,13 +85,22 @@ export default function scriptsPluginFactory(registry) {
|
||||
return reply.code(err.statusCode ?? 400).send({ error: err.message });
|
||||
}
|
||||
|
||||
const body = /** @type {{ content?: string, data?: unknown, config?: unknown }} */ (
|
||||
const body = /** @type {{ content?: string, data?: unknown, config?: unknown, owner?: string }} */ (
|
||||
req.body ?? {}
|
||||
);
|
||||
if (typeof body.content !== "string") {
|
||||
return reply.code(400).send({ error: "content is required" });
|
||||
}
|
||||
|
||||
let owner = "default";
|
||||
if (body.owner != null && body.owner !== "") {
|
||||
try {
|
||||
owner = fsStore.assertOwner(String(body.owner));
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 400).send({ error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
data: body.data ?? null,
|
||||
config: body.config ?? null,
|
||||
@@ -104,6 +113,7 @@ export default function scriptsPluginFactory(registry) {
|
||||
const { fn, meta, metaError } = instantiateScriptSource(name, body.content, {
|
||||
log,
|
||||
workflowName: "dry-run",
|
||||
owner,
|
||||
});
|
||||
const output = await fn(ctx);
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import * as fsStore from "../../fs-store.js";
|
||||
import {
|
||||
assertSecretName,
|
||||
deleteSecret,
|
||||
getSecretById,
|
||||
listSecrets,
|
||||
upsertSecret,
|
||||
} from "../../secrets-store.js";
|
||||
import { MIN_SECRET_LENGTH } from "../../secret-value.js";
|
||||
|
||||
/**
|
||||
* @param {import("fastify").FastifyInstance} fastify
|
||||
*/
|
||||
export default async function secretsPlugin(fastify) {
|
||||
fastify.addHook("onRequest", fastify.requireAdmin);
|
||||
|
||||
fastify.get("/secrets", async (req, reply) => {
|
||||
const q = /** @type {{ owner?: string }} */ (req.query ?? {});
|
||||
try {
|
||||
const owner = q.owner ? fsStore.assertOwner(q.owner) : undefined;
|
||||
const secrets = await listSecrets({ owner });
|
||||
return { secrets };
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 500).send({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
fastify.put("/secrets", async (req, reply) => {
|
||||
const body = /** @type {{ owner?: string, name?: string, value?: string }} */ (
|
||||
req.body ?? {}
|
||||
);
|
||||
try {
|
||||
fsStore.assertOwner(String(body.owner ?? ""));
|
||||
assertSecretName(String(body.name ?? ""));
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 400).send({ error: err.message });
|
||||
}
|
||||
|
||||
const value = String(body.value ?? "");
|
||||
if (value.length < MIN_SECRET_LENGTH) {
|
||||
return reply
|
||||
.code(400)
|
||||
.send({ error: `value must be at least ${MIN_SECRET_LENGTH} characters` });
|
||||
}
|
||||
|
||||
try {
|
||||
const secret = await upsertSecret({
|
||||
owner: String(body.owner),
|
||||
name: String(body.name),
|
||||
value,
|
||||
});
|
||||
return reply.send({ secret });
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 500).send({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
fastify.delete("/secrets/:id", async (req, reply) => {
|
||||
const { id } = /** @type {{ id: string }} */ (req.params);
|
||||
const existing = await getSecretById(id);
|
||||
if (!existing) {
|
||||
return reply.code(404).send({ error: "secret not found" });
|
||||
}
|
||||
await deleteSecret(id);
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { db } from "./db.js";
|
||||
import { redactString } from "./secret-value.js";
|
||||
|
||||
const MAX_JSON_BYTES = 64 * 1024;
|
||||
|
||||
@@ -15,6 +16,7 @@ export function serialize(value) {
|
||||
} catch {
|
||||
json = JSON.stringify({ truncated: true, reason: "unserializable" });
|
||||
}
|
||||
json = redactString(json);
|
||||
if (Buffer.byteLength(json, "utf8") <= MAX_JSON_BYTES) return json;
|
||||
return JSON.stringify({
|
||||
truncated: true,
|
||||
|
||||
Reference in New Issue
Block a user