// Credential references (note section 2, addendum A section 7). A // reference names where a token lives, never the token. The checks here use // lstat and realpath only; nothing in this package opens a token file. import { lstatSync, realpathSync } from "node:fs"; import { isAbsolute, join, normalize, relative, sep } from "node:path"; import { refuse } from "./errors.mjs"; import { credentialKind, isHostKind } from "./vocabulary.mjs"; import { requireObject, rejectUnknownKeys, requireDate } from "./util.mjs"; import { pluginName } from "../../plugins/src/organization.mjs"; const ENV_NAME = /^[A-Z][A-Z0-9_]{0,63}$/; export const DATE_KEY = Object.freeze({ gitea: "rotateBy", vikunja: "expires" }); const WARN_DAYS = 7; const DAY_MS = 24 * 60 * 60 * 1000; // Shape check for one reference. Returns a frozen { service, file | env, // rotateBy | expires }. Row 199 (#1697, design D1 and D2): `store` is the // file at /secrets/agents///, derived // here and never written, so an entry can't name another agent's file. A // reference with no agent (the tracker's sync bot) passes store = null and // can't use the form. A `file` reference into the agents' store must be the // agent's own path (`credential-not-yours`). export function parseCredentialRef(ref, service, where, { store = null } = {}) { if (!credentialKind(service) && !pluginName(service)) refuse(`${where}: unknown credential service ${JSON.stringify(service)}`); requireObject(ref, where); // Row 210 (#1707, design D1): an ssh key doesn't expire, so host. takes rotateBy as gitea does. const dateKey = isHostKind(service) ? "rotateBy" : (DATE_KEY[service] ?? "rotateBy"); rejectUnknownKeys(ref, ["file", "env", "store", dateKey], where); const hasFile = ref.file !== undefined; const hasEnv = ref.env !== undefined; const hasStore = ref.store !== undefined; if (Number(hasFile) + Number(hasEnv) + Number(hasStore) !== 1) refuse(`${where} must name exactly one of "file", "env" or "store"`); const out = { service }; if (hasStore) { if (ref.store !== true) refuse(`${where}.store must be true`); if (!store) refuse(`${where}: a "store" reference needs an agent`); out.store = true; out.file = storePath(store, service); } else if (hasFile) { if (typeof ref.file !== "string" || !isAbsolute(ref.file) || normalize(ref.file) !== ref.file || ref.file.includes("\0")) { refuse(`${where}.file must be a normalized absolute path`); } out.file = ref.file; if (store && inside(join(store.configDir, "secrets", "agents"), ref.file) && ref.file !== storePath(store, service)) { refuse(`${where}.file credential-not-yours: the file is in the agents' store but not this agent's path`); } } else { if (typeof ref.env !== "string" || !ENV_NAME.test(ref.env)) refuse(`${where}.env must match ${ENV_NAME}`); out.env = ref.env; } if (ref[dateKey] === undefined) refuse(`${where} needs "${dateKey}" (YYYY-MM-DD)`); out[dateKey] = requireDate(ref[dateKey], `${where}.${dateKey}`); return Object.freeze(out); } // The store path for one agent and service (design D1). export function storePath({ configDir, business, agent }, service) { return join(configDir, "secrets", "agents", business, agent, service); } function inside(root, path) { const rel = relative(root, path); return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel)); } // Check a parsed reference against the filesystem and the calendar. // Returns { problems: [...], warnings: [...] }; a caller that finds any // problem refuses. `forbiddenRoots` are directories a token file must not // sit in (the repository, dataRoot): each a path, or { path, label } so a // problem can name it. `where` is the reference's field (for example // roles.coder.credentials.gitea). `now` is a Date. // A problem or warning names the reference by its field, its service and its // kind, and an env reference by its checked variable name. It never quotes the // token file's path or a root's path (#1873). export function checkCredentialRef(ref, { forbiddenRoots = [], now = new Date(), env = process.env, uid = process.getuid(), where = null } = {}) { const problems = []; const warnings = []; const label = `${where ?? ref.service} ${ref.store ? "store file" : ref.file ? "file" : `env ${ref.env}`}`; let notIssued = false; if (ref.file) { let stat = null; try { stat = lstatSync(ref.file); } catch (e) { // Row 199 (design D3a): a store reference with no file is an agent not issued yet. notIssued = Boolean(ref.store) && e.code === "ENOENT"; if (notIssued) warnings.push(`${label}: not issued`); else problems.push(`${label}: not found`); } if (stat) { if (stat.isSymbolicLink() || !stat.isFile()) problems.push(`${label}: must be a regular file, not a symbolic link`); else { if (stat.uid !== uid) problems.push(`${label}: owned by uid ${stat.uid}, not ${uid}`); if ((stat.mode & 0o077) !== 0) problems.push(`${label}: mode ${(stat.mode & 0o777).toString(8)} gives group or other access; use 600`); if (stat.size === 0) problems.push(`${label}: empty`); let real = ref.file; try { real = realpathSync(ref.file); } catch { problems.push(`${label}: path can't be resolved`); } for (const entry of forbiddenRoots) { const root = typeof entry === "string" ? entry : entry.path; const name = typeof entry === "string" ? "a forbidden root" : entry.label; let realRoot = root; try { realRoot = realpathSync(root); } catch { // A root that doesn't exist yet can't contain the file. } if (inside(realRoot, real)) problems.push(`${label}: inside ${name}; token files live outside the repository and dataRoot`); } } } } else if (env[ref.env] === undefined || env[ref.env] === "") { warnings.push(`${label}: not set in this environment; the launcher must provide it`); } // Row 194 (brief item 7, sage-1605 pick a): a human create writes the create day as the date, // and issue writes the real one. Until then the date describes no token, so a store reference // with no file skips both date checks. Every other reference keeps them. if (notIssued) return { problems, warnings }; const days = (dateText) => Math.floor((Date.parse(`${dateText}T00:00:00Z`) - now.getTime()) / DAY_MS); if (ref.expires) { const left = days(ref.expires); if (Date.parse(`${ref.expires}T00:00:00Z`) <= now.getTime()) problems.push(`${label}: expired on ${ref.expires}`); else if (left < WARN_DAYS) warnings.push(`${label}: expires on ${ref.expires}`); } if (ref.rotateBy && Date.parse(`${ref.rotateBy}T00:00:00Z`) <= now.getTime()) { warnings.push(`${label}: rotation was due on ${ref.rotateBy}`); } return { problems, warnings }; }