feat(ops-agent): whitelist-config parser + strict command executor
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
4bccbf28f3
commit
d605eb17a5
3 changed files with 92 additions and 11 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<void> {
|
|||
.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<void> {
|
|||
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<void> {
|
|||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue