From d605eb17a5a1c1584732f3c04f6920388cd89c5a Mon Sep 17 00:00:00 2001 From: Scrum4Me Agent <30029041+madhura68@users.noreply.github.com> Date: Wed, 13 May 2026 17:18:45 +0200 Subject: [PATCH] feat(ops-agent): whitelist-config parser + strict command executor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CommandDef now uses cmd[] array instead of exec string — no shell splitting - validateArgs() checks every request arg against allowed list; rejects unknown values - spawn() called with shell:false (execFile semantics); cwd from config - Audit log (JSON) per call to stdout → captured by systemd journal - commands.yml.example updated to new schema with 4 read-only commands Co-Authored-By: Claude Sonnet 4.6 --- ops-agent/commands.yml.example | 28 +++++++++++++++++++------ ops-agent/src/routes/exec.ts | 37 ++++++++++++++++++++++++++++++--- ops-agent/src/whitelist.ts | 38 ++++++++++++++++++++++++++++++++-- 3 files changed, 92 insertions(+), 11 deletions(-) diff --git a/ops-agent/commands.yml.example b/ops-agent/commands.yml.example index 884d65c..ae082e3 100644 --- a/ops-agent/commands.yml.example +++ b/ops-agent/commands.yml.example @@ -1,21 +1,37 @@ # Whitelist of allowed commands for ops-agent. # Copy to /etc/ops-agent/commands.yml on the host. # Restart ops-agent after changes. +# +# Schema per command: +# cmd: required — command + static args as array (no shell, no interpolation) +# cwd: optional — working directory for the subprocess +# cwd_pattern: optional — working directory as a glob/pattern (resolved at runtime) +# args: +# allowed: optional — whitelist of argument values accepted from the caller +# If absent or empty, the command takes no extra arguments. +# description: optional — human-readable description commands: docker_ps: - exec: "docker ps --format table" + cmd: ["docker", "ps", "--format", "table"] description: "List running Docker containers" git_status: - exec: "git -C /srv/ops status --short" + cmd: ["git", "status", "--short"] + cwd: "/srv/ops" description: "Git status of the ops directory" systemctl_status: - exec: "systemctl status" - args_allowed: [] - description: "Show systemctl status (no args)" + cmd: ["systemctl", "status"] + args: + allowed: + - ops-agent + - caddy + - docker + - nginx + - postgresql + description: "Show systemctl status for an allowed service" caddy_show_config: - exec: "caddy fmt /etc/caddy/Caddyfile" + cmd: ["caddy", "fmt", "/etc/caddy/Caddyfile"] description: "Print the formatted Caddy config" diff --git a/ops-agent/src/routes/exec.ts b/ops-agent/src/routes/exec.ts index a708657..915c3ee 100644 --- a/ops-agent/src/routes/exec.ts +++ b/ops-agent/src/routes/exec.ts @@ -1,12 +1,30 @@ import { FastifyInstance, FastifyRequest } from 'fastify'; import { spawn } from 'child_process'; -import { getCommand } from '../whitelist.js'; +import { getCommand, validateArgs } from '../whitelist.js'; interface ExecBody { command_key: string; args?: string[]; } +function auditLog( + command_key: string, + args: string[], + exit_code: number | null, + duration_ms: number, +): void { + // systemd captures stdout/stderr into the journal + console.log( + JSON.stringify({ + audit: true, + command_key, + args, + exit_code, + duration_ms, + }), + ); +} + export async function execRoutes(app: FastifyInstance): Promise { app.post('/agent/v1/exec', async (req: FastifyRequest<{ Body: ExecBody }>, reply) => { const { command_key, args = [] } = req.body; @@ -18,6 +36,11 @@ export async function execRoutes(app: FastifyInstance): Promise { .send({ error: `command_key '${command_key}' is not in the whitelist` }); } + const argError = validateArgs(def, args); + if (argError) { + return reply.status(400).send({ error: argError }); + } + reply.raw.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', @@ -28,8 +51,14 @@ export async function execRoutes(app: FastifyInstance): Promise { reply.raw.write(`event: ${event}\ndata: ${JSON.stringify({ data })}\n\n`); }; - const [bin, ...staticArgs] = def.exec.split(' '); - const child = spawn(bin, [...staticArgs, ...args], { shell: false }); + const [bin, ...staticArgs] = def.cmd; + // shell: false ensures no shell interpolation — args are passed as-is (execFile semantics) + const child = spawn(bin, [...staticArgs, ...args], { + shell: false, + cwd: def.cwd, + }); + + const startedAt = Date.now(); child.stdout.on('data', (chunk: Buffer) => { sendEvent('stdout', chunk.toString()); @@ -40,11 +69,13 @@ export async function execRoutes(app: FastifyInstance): Promise { }); child.on('close', (code) => { + auditLog(command_key, args, code, Date.now() - startedAt); reply.raw.write(`event: exit\ndata: ${JSON.stringify({ code })}\n\n`); reply.raw.end(); }); child.on('error', (err) => { + auditLog(command_key, args, null, Date.now() - startedAt); reply.raw.write(`event: error\ndata: ${JSON.stringify({ message: err.message })}\n\n`); reply.raw.end(); }); diff --git a/ops-agent/src/whitelist.ts b/ops-agent/src/whitelist.ts index 519931d..c3a64fa 100644 --- a/ops-agent/src/whitelist.ts +++ b/ops-agent/src/whitelist.ts @@ -1,9 +1,15 @@ import fs from 'fs'; import yaml from 'js-yaml'; +export interface ArgsConfig { + allowed?: string[]; +} + export interface CommandDef { - exec: string; - args_allowed?: string[]; + cmd: string[]; + cwd?: string; + cwd_pattern?: string; + args?: ArgsConfig; description?: string; } @@ -15,6 +21,12 @@ export function loadWhitelist(path: string): void { const raw = fs.readFileSync(path, 'utf8'); const parsed = yaml.load(raw) as { commands: Whitelist }; whitelist = parsed.commands ?? {}; + + for (const [key, def] of Object.entries(whitelist)) { + if (!Array.isArray(def.cmd) || def.cmd.length === 0) { + throw new Error(`commands.yml: '${key}' must have a non-empty 'cmd' array`); + } + } } export function getCommand(key: string): CommandDef | undefined { @@ -24,3 +36,25 @@ export function getCommand(key: string): CommandDef | undefined { export function listCommands(): string[] { return Object.keys(whitelist); } + +/** + * Validates request args against a command's allowed list. + * Returns an error string if validation fails, null on success. + */ +export function validateArgs(def: CommandDef, requestArgs: string[]): string | null { + const allowed = def.args?.allowed; + + if (requestArgs.length === 0) return null; + + if (!allowed || allowed.length === 0) { + return `command does not accept arguments`; + } + + for (const arg of requestArgs) { + if (!allowed.includes(arg)) { + return `argument '${arg}' is not in the allowed list`; + } + } + + return null; +}