conductor

CI task system
git clone git://git.finwo.net/app/conductor
Log | Files | Refs | README | LICENSE

variables.js (5167B)


      1 // src/lib/variables.js - per project variables
      2 //
      3 // Values are sealed with the secret box, bound to their project and name so
      4 // a row copied elsewhere will not open. They are injected into a job's
      5 // environment when it is claimed, and never written into the jobs table, so
      6 // the only place a secret rests is this one.
      7 //
      8 // Masked variables are also redacted from job logs. The worker does that
      9 // before anything leaves the host; the conductor repeats it on ingest as a
     10 // backstop against a worker that does not.
     11 
     12 const NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
     13 
     14 export function createVariables({ db, secrets, ttl = 30000 }) {
     15   // Decrypting on every log append would be wasteful, so the masked values
     16   // for a project are cached briefly.
     17   const maskCache = new Map();
     18 
     19   function aad(projectId, name) {
     20     return `project:${projectId}/variable:${name}`;
     21   }
     22 
     23   function open(row) {
     24     return secrets.open(row.value, aad(row.project_id, row.name));
     25   }
     26 
     27   return {
     28     async list(projectId) {
     29       const rows = await db.all(
     30         `SELECT project_id, name, value, masked, created_at
     31            FROM project_variables WHERE project_id = {project} ORDER BY name`,
     32         { project: projectId }
     33       );
     34       // Values are never listed, only whether they are masked.
     35       return rows.map((row) => ({
     36         name: row.name,
     37         masked: row.masked === 1,
     38         created_at: row.created_at,
     39         plaintext_at_rest: secrets.isPlaintext(row.value),
     40       }));
     41     },
     42 
     43     async set(projectId, name, value, { masked = true } = {}) {
     44       if (!NAME_PATTERN.test(String(name))) {
     45         throw new Error(`variable name ${JSON.stringify(name)} is not a valid environment variable name`);
     46       }
     47       if (typeof value !== 'string') throw new Error('variable value must be a string');
     48       if (value.length > 8192) throw new Error('variable value must be at most 8192 characters');
     49 
     50       const sealed = secrets.seal(value, aad(projectId, name));
     51 
     52       // No portable upsert, so the read and the write happen together.
     53       await db.transaction(async (tx) => {
     54         const existing = await tx.get(
     55           'SELECT name FROM project_variables WHERE project_id = {project} AND name = {name}',
     56           { project: projectId, name }
     57         );
     58         if (existing) {
     59           await tx.run(
     60             `UPDATE project_variables SET value = {value}, masked = {masked}
     61               WHERE project_id = {project} AND name = {name}`,
     62             { project: projectId, name, value: sealed, masked: masked ? 1 : 0 }
     63           );
     64         } else {
     65           await tx.run(
     66             `INSERT INTO project_variables (project_id, name, value, masked, created_at)
     67              VALUES ({project}, {name}, {value}, {masked}, {now})`,
     68             { project: projectId, name, value: sealed, masked: masked ? 1 : 0, now: Date.now() }
     69           );
     70         }
     71       });
     72 
     73       maskCache.delete(projectId);
     74       return { name, masked };
     75     },
     76 
     77     async remove(projectId, name) {
     78       const res = await db.run(
     79         'DELETE FROM project_variables WHERE project_id = {project} AND name = {name}',
     80         { project: projectId, name }
     81       );
     82       maskCache.delete(projectId);
     83       return res.changes > 0;
     84     },
     85 
     86     // Decrypted, for injection into a job environment.
     87     async resolve(projectId) {
     88       const rows = await db.all(
     89         'SELECT project_id, name, value, masked FROM project_variables WHERE project_id = {project}',
     90         { project: projectId }
     91       );
     92 
     93       const env = {};
     94       const masked = [];
     95       for (const row of rows) {
     96         let value;
     97         try {
     98           value = open(row);
     99         } catch {
    100           // A value that cannot be opened is skipped rather than failing
    101           // the job, since the rest of the pipeline may not need it.
    102           continue;
    103         }
    104         env[row.name] = value;
    105         if (row.masked === 1) masked.push(value);
    106       }
    107       return { env, masked };
    108     },
    109 
    110     // Just the values that must not appear in a log.
    111     async maskedValues(projectId) {
    112       const cached = maskCache.get(projectId);
    113       if (cached && cached.expires > Date.now()) return cached.values;
    114 
    115       const { masked } = await this.resolve(projectId);
    116       maskCache.set(projectId, { values: masked, expires: Date.now() + ttl });
    117       return masked;
    118     },
    119 
    120     invalidate(projectId) {
    121       if (projectId) maskCache.delete(projectId);
    122       else maskCache.clear();
    123     },
    124   };
    125 }
    126 
    127 // Replaces known secrets in a buffer. Used by the conductor on ingest as a
    128 // backstop; it works within a chunk only, since the worker is responsible
    129 // for handling values split across chunk boundaries.
    130 export function maskBuffer(buffer, values) {
    131   if (!values || values.length === 0) return buffer;
    132 
    133   let text = buffer.toString('utf8');
    134   let changed = false;
    135   // Longest first, so an overlapping shorter value cannot partially reveal
    136   // a longer one.
    137   for (const value of [...values].sort((a, b) => b.length - a.length)) {
    138     if (value.length < 4 || !text.includes(value)) continue;
    139     text = text.split(value).join('[masked]');
    140     changed = true;
    141   }
    142 
    143   return changed ? Buffer.from(text, 'utf8') : buffer;
    144 }