variables.js (5676B)
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 // Unlike resolve, a value that cannot be opened throws rather than 44 // reading as absent, or a rotated key would look like a missing variable. 45 async reveal(projectId, name) { 46 const row = await db.get( 47 `SELECT project_id, name, value, masked FROM project_variables 48 WHERE project_id = {project} AND name = {name}`, 49 { project: projectId, name } 50 ); 51 if (!row) return null; 52 return { name: row.name, masked: row.masked === 1, value: open(row) }; 53 }, 54 55 async set(projectId, name, value, { masked = true } = {}) { 56 if (!NAME_PATTERN.test(String(name))) { 57 throw new Error(`variable name ${JSON.stringify(name)} is not a valid environment variable name`); 58 } 59 if (typeof value !== 'string') throw new Error('variable value must be a string'); 60 if (value.length > 8192) throw new Error('variable value must be at most 8192 characters'); 61 62 const sealed = secrets.seal(value, aad(projectId, name)); 63 64 // No portable upsert, so the read and the write happen together. 65 await db.transaction(async (tx) => { 66 const existing = await tx.get( 67 'SELECT name FROM project_variables WHERE project_id = {project} AND name = {name}', 68 { project: projectId, name } 69 ); 70 if (existing) { 71 await tx.run( 72 `UPDATE project_variables SET value = {value}, masked = {masked} 73 WHERE project_id = {project} AND name = {name}`, 74 { project: projectId, name, value: sealed, masked: masked ? 1 : 0 } 75 ); 76 } else { 77 await tx.run( 78 `INSERT INTO project_variables (project_id, name, value, masked, created_at) 79 VALUES ({project}, {name}, {value}, {masked}, {now})`, 80 { project: projectId, name, value: sealed, masked: masked ? 1 : 0, now: Date.now() } 81 ); 82 } 83 }); 84 85 maskCache.delete(projectId); 86 return { name, masked }; 87 }, 88 89 async remove(projectId, name) { 90 const res = await db.run( 91 'DELETE FROM project_variables WHERE project_id = {project} AND name = {name}', 92 { project: projectId, name } 93 ); 94 maskCache.delete(projectId); 95 return res.changes > 0; 96 }, 97 98 // Decrypted, for injection into a job environment. 99 async resolve(projectId) { 100 const rows = await db.all( 101 'SELECT project_id, name, value, masked FROM project_variables WHERE project_id = {project}', 102 { project: projectId } 103 ); 104 105 const env = {}; 106 const masked = []; 107 for (const row of rows) { 108 let value; 109 try { 110 value = open(row); 111 } catch { 112 // A value that cannot be opened is skipped rather than failing 113 // the job, since the rest of the pipeline may not need it. 114 continue; 115 } 116 env[row.name] = value; 117 if (row.masked === 1) masked.push(value); 118 } 119 return { env, masked }; 120 }, 121 122 // Just the values that must not appear in a log. 123 async maskedValues(projectId) { 124 const cached = maskCache.get(projectId); 125 if (cached && cached.expires > Date.now()) return cached.values; 126 127 const { masked } = await this.resolve(projectId); 128 maskCache.set(projectId, { values: masked, expires: Date.now() + ttl }); 129 return masked; 130 }, 131 132 invalidate(projectId) { 133 if (projectId) maskCache.delete(projectId); 134 else maskCache.clear(); 135 }, 136 }; 137 } 138 139 // Replaces known secrets in a buffer. Used by the conductor on ingest as a 140 // backstop; it works within a chunk only, since the worker is responsible 141 // for handling values split across chunk boundaries. 142 export function maskBuffer(buffer, values) { 143 if (!values || values.length === 0) return buffer; 144 145 let text = buffer.toString('utf8'); 146 let changed = false; 147 // Longest first, so an overlapping shorter value cannot partially reveal 148 // a longer one. 149 for (const value of [...values].sort((a, b) => b.length - a.length)) { 150 if (value.length < 4 || !text.includes(value)) continue; 151 text = text.split(value).join('[masked]'); 152 changed = true; 153 } 154 155 return changed ? Buffer.from(text, 'utf8') : buffer; 156 }