66 lines
4.1 KiB
JavaScript
66 lines
4.1 KiB
JavaScript
'use strict';
|
|
const fs=require('node:fs');
|
|
const path=require('node:path');
|
|
const crypto=require('node:crypto');
|
|
const {SCOPES}=require('./agent-control.cjs');
|
|
const validId=id=>typeof id==='string'&&/^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/.test(id);
|
|
const live=(value,now)=>Number.isFinite(Date.parse(value))&&Date.parse(value)>now;
|
|
function activeGrants(policy,now=Date.now()){
|
|
if(policy?.agents?.identityMode!=='individual'||!live(policy.expiresAt,now))return [];
|
|
return (policy.agents.grants||[]).slice(0,32).filter(g=>validId(g.id)&&g.enabled===true&&live(g.expiresAt,now)&&Number.isSafeInteger(g.generation)&&g.generation>=1).map(g=>structuredClone(g));
|
|
}
|
|
function authorizeGrant({policy,ready,principalId,generation,action,url,hostMatches,now=Date.now()}){
|
|
const denied={allowed:false,role:'agent',scopes:[]};
|
|
const agents=policy?.agents,scope=SCOPES[action];
|
|
if(!ready||agents?.enabled!==true||!live(policy.expiresAt,now)||!scope||!agents.scopes?.includes(action))return denied;
|
|
let grant;
|
|
if(agents.identityMode==='individual'){
|
|
grant=activeGrants(policy,now).find(g=>g.id===principalId&&g.generation===generation);
|
|
if(!grant||!grant.scopes.includes(action))return denied;
|
|
}else if(agents.identityMode!=='legacy'||principalId!==undefined||generation!==undefined)return denied;
|
|
if(url!==undefined){
|
|
let address;try{address=new URL(url);}catch{return denied;}
|
|
if(!['http:','https:'].includes(address.protocol)||address.username||address.password)return denied;
|
|
for(const patterns of [agents.allowedHosts,grant?.allowedHosts]){
|
|
if(patterns?.length&&(!hostMatches||!patterns.some(pattern=>hostMatches(address.hostname,pattern))))return denied;
|
|
}
|
|
}
|
|
return {allowed:true,role:'agent',scopes:[scope]};
|
|
}
|
|
function reconcileGenerationFloor(grants,floor={}){
|
|
const next=Object.create(null);
|
|
for(const [id,value]of Object.entries(floor))if(validId(id)&&Number.isSafeInteger(value)&&value>=1)next[id]=value;
|
|
const result=grants.map(g=>{
|
|
if(!validId(g.id)||!Number.isSafeInteger(g.generation)||g.generation<1)throw Error('Invalid agent generation');
|
|
const prior=next[g.id]||0;next[g.id]=Math.max(prior,g.generation);
|
|
return {...structuredClone(g),enabled:g.enabled===true&&g.generation>=prior};
|
|
});
|
|
return {grants:result,floor:{...next}};
|
|
}
|
|
function descriptorPath(directory,id){
|
|
if(!validId(id))throw Error('Invalid agent identifier');
|
|
return path.join(directory,'agent-'+crypto.createHash('sha256').update(id).digest('hex')+'.json');
|
|
}
|
|
function guard(filePath){
|
|
const absolute=path.resolve(filePath),parent=path.dirname(absolute);
|
|
let current=parent;
|
|
while(true){const stat=fs.lstatSync(current);if(!stat.isDirectory()||stat.isSymbolicLink()||fs.realpathSync(current).toLowerCase()!==path.resolve(current).toLowerCase())throw Error('Unsafe credential directory');const next=path.dirname(current);if(next===current)break;current=next;}
|
|
try{const stat=fs.lstatSync(absolute);if(!stat.isFile()||stat.isSymbolicLink()||stat.nlink!==1)throw Error('Unsafe credential file');}catch(error){if(error.code!=='ENOENT')throw error;}
|
|
return absolute;
|
|
}
|
|
async function persistConnection({filePath,connection,protect,isCurrent}){
|
|
if(typeof protect!=='function'||typeof isCurrent!=='function'||!isCurrent())throw Error('Agent credential context changed');
|
|
const target=guard(filePath);
|
|
const encrypted=await protect(JSON.stringify(connection));
|
|
if(!isCurrent())throw Error('Agent credential context changed');
|
|
const bytes=Buffer.from(JSON.stringify({format:'windows-dpapi-v1',protectedConnection:encrypted}));
|
|
const temporary=target+'.'+crypto.randomUUID()+'.tmp';let fd;
|
|
try{
|
|
guard(target);fd=fs.openSync(temporary,'wx',0o600);fs.writeFileSync(fd,bytes);fs.fsyncSync(fd);fs.closeSync(fd);fd=undefined;
|
|
if(!isCurrent())throw Error('Agent credential context changed');guard(target);fs.renameSync(temporary,target);
|
|
return target;
|
|
// eslint-disable-next-line no-unsafe-finally -- Cleanup failure must fail closed rather than report successful protected cleanup.
|
|
}finally{bytes.fill(0);if(fd!==undefined)fs.closeSync(fd);try{fs.unlinkSync(temporary);}catch(error){if(error.code!=='ENOENT')throw error;}}
|
|
}
|
|
module.exports={activeGrants,authorizeGrant,reconcileGenerationFloor,descriptorPath,persistConnection};
|