redact6. A refusal names the field, the file or the instance. It never quotes a
config value, a host path, or a raw e.message or stderr that carries one.
- organization: checkCredentialRef names the field and each forbidden root by
label; OrgError keeps .path only for a document field ($...); the registry,
fence and lock refusals use the quiet helpers (call and code).
- cli: loadSystem never relays mosaic-config stderr: instance refusals keep
their code and names, everything else reads as a field and its constraint
against a labelled config. Project-key, access warning, halt gate, sessions
file, launch socket, harness relays and the workspace rename name labels.
Launch-log reasons read a Node system error as its call and code.
- scripts/mosaic-config: configuration not found names where the config came from.
- discord, seat, control-board: config, token, context, tool-root, journal,
binding, registration and seen-marks refusals name labels.
Exit codes, refusal codes and check order are unchanged. Each new or changed
test fails on BASE be698e602.
135 lines
6.8 KiB
JavaScript
135 lines
6.8 KiB
JavaScript
// 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 <configDir>/secrets/agents/<business>/<agent>/<service>, 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.<id> 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 };
|
|
}
|