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 }