#!/usr/bin/env php * * This file is part of a Moko Consulting project. * * SPDX-License-Identifier: GPL-3.0-or-later * * FILE INFORMATION * DEFGROUP: MokoPlatform.Maintenance * INGROUP: MokoPlatform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /maintenance/rotate_secrets.php * BRIEF: Audit FTP secrets and variables across all governed repos -- report missing or stale */ declare(strict_types=1); require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; use MokoEnterprise\CliFramework; class RotateSecretsCli extends CliFramework { private $api = null; private string $token = ''; private const ALWAYS_EXCLUDE = ['moko-platform', '.github-private']; private const ENVS = [ 'DEV' => [ 'vars' => ['DEV_FTP_HOST', 'DEV_FTP_PATH', 'DEV_FTP_USERNAME', 'DEV_FTP_SUFFIX'], 'secrets' => ['DEV_FTP_KEY', 'DEV_FTP_PASSWORD'], ], 'DEMO' => [ 'vars' => ['DEMO_FTP_HOST', 'DEMO_FTP_PATH', 'DEMO_FTP_USERNAME', 'DEMO_FTP_SUFFIX'], 'secrets' => ['DEMO_FTP_KEY', 'DEMO_FTP_PASSWORD'], ], 'RS' => [ 'vars' => ['RS_FTP_HOST', 'RS_FTP_PATH', 'RS_FTP_USERNAME', 'RS_FTP_SUFFIX'], 'secrets' => ['RS_FTP_KEY', 'RS_FTP_PASSWORD'], ], ]; protected function configure(): void { $this->setDescription('Audit FTP secrets and variables across all governed repos'); $this->addArgument('--all', 'Audit all repos', false); $this->addArgument('--repo', 'Single repo name', null); $this->addArgument('--org', 'Organization', 'mokoconsulting-tech'); $this->addArgument('--json', 'JSON output', false); $this->addArgument('--create-issue', 'Post results as issue', false); } protected function initialize(): void { $config = \MokoEnterprise\Config::load(); try { $adapter = \MokoEnterprise\PlatformAdapterFactory::create($config); $this->api = $adapter->getApiClient(); } catch (\Exception $e) { $this->log('ERROR', "Platform init failed: " . $e->getMessage()); exit(1); } $this->token = $config->getString('platform', 'gitea') === 'gitea' ? $config->getString('gitea.token', '') : $config->getString('github.token', ''); } protected function run(): int { $allMode = (bool) $this->getArgument('--all'); $jsonOut = (bool) $this->getArgument('--json'); $createIssue = (bool) $this->getArgument('--create-issue'); $org = $this->getArgument('--org'); $repoName = $this->getArgument('--repo'); if (!$repoName && !$allMode) { $this->log('ERROR', "Usage: php rotate_secrets.php --all | --repo [--json] [--create-issue]"); return 2; } $repos = []; if ($allMode) { if (!$jsonOut) { echo "Fetching repositories from {$org}...\n"; } $page = 1; do { [$_, $batch] = $this->ghApi('GET', "orgs/{$org}/repos?per_page=100&page={$page}&type=all", null); foreach ($batch as $r) { if (!($r['archived'] ?? false) && !in_array($r['name'], self::ALWAYS_EXCLUDE, true)) { $repos[] = $r['name']; } } $page++; } while (count($batch) === 100); sort($repos); if (!$jsonOut) { echo "Found " . count($repos) . " repositories\n\n"; } } else { $repos = [$repoName]; } $results = []; $issueCount = 0; foreach ($repos as $repo) { $fullRepo = "{$org}/{$repo}"; $repoVars = $this->listNames("repos/{$fullRepo}/actions/variables", 'variables'); $repoSecrets = $this->listNames("repos/{$fullRepo}/actions/secrets", 'secrets'); $result = ['repo' => $repo, 'envs' => [], 'missing' => []]; foreach (self::ENVS as $env => $envConfig) { $missingVars = array_diff($envConfig['vars'], $repoVars); $hasAuth = !empty(array_intersect($envConfig['secrets'], $repoSecrets)); $hostVar = "{$env}_FTP_HOST"; $configured = in_array($hostVar, $repoVars, true); $result['envs'][$env] = ['configured' => $configured, 'missing_vars' => array_values($missingVars), 'has_auth' => $hasAuth]; if ($configured) { foreach ($missingVars as $v) { if ($v !== "{$env}_FTP_SUFFIX") { $result['missing'][] = "{$env}: missing {$v}"; $issueCount++; } } if (!$hasAuth) { $result['missing'][] = "{$env}: no auth key/password"; $issueCount++; } } } if (!$jsonOut) { $parts = []; foreach (self::ENVS as $env => $_) { $e = $result['envs'][$env]; if ($e['configured'] && $e['has_auth'] && empty($e['missing_vars'])) { $parts[] = "{$env}:OK"; } elseif ($e['configured']) { $parts[] = "{$env}:INCOMPLETE"; } else { $parts[] = "{$env}:--"; } } echo "{$repo}: " . implode(' | ', $parts) . (empty($result['missing']) ? '' : ' [' . implode('; ', $result['missing']) . ']') . "\n"; } $results[] = $result; } if ($jsonOut) { echo json_encode($results, JSON_PRETTY_PRINT) . "\n"; } else { echo "\n" . str_repeat('-', 50) . "\n"; $total = count($results); $devReady = count(array_filter( $results, fn($r) => ($r['envs']['DEV']['configured'] ?? false) && ($r['envs']['DEV']['has_auth'] ?? false) )); $demoReady = count(array_filter( $results, fn($r) => ($r['envs']['DEMO']['configured'] ?? false) && ($r['envs']['DEMO']['has_auth'] ?? false) )); $rsReady = count(array_filter( $results, fn($r) => ($r['envs']['RS']['configured'] ?? false) && ($r['envs']['RS']['has_auth'] ?? false) )); echo "Total: {$total} | DEV: {$devReady} | DEMO: {$demoReady} | RS: {$rsReady} | Issues: {$issueCount}\n"; } if ($createIssue && $issueCount > 0) { $now = gmdate('Y-m-d H:i:s') . ' UTC'; $rows = []; foreach ($results as $r) { foreach ($r['missing'] as $m) { $rows[] = "| `{$r['repo']}` | {$m} |"; } } $table = implode("\n", $rows); $body = "## FTP Secret/Variable Audit\n\n" . "**Date:** {$now}\n" . "**Issues:** {$issueCount}\n\n" . "| Repository | Issue |\n|---|---|\n" . "{$table}\n\n---\n" . "*Auto-created by `rotate_secrets.php`*\n"; $auditQuery = "repos/{$org}/moko-platform/issues" . "?labels=secret-audit&state=all" . "&per_page=1&sort=created&direction=desc"; [$_, $existing] = $this->ghApi('GET', $auditQuery, null); $auditTitle = "audit: FTP secrets" . " -- {$issueCount} issues"; if (!empty($existing[0]['number'])) { $num = $existing[0]['number']; $this->ghApi( 'PATCH', "repos/{$org}/moko-platform/issues/{$num}", [ 'title' => $auditTitle, 'body' => $body, 'state' => 'open', 'assignees' => ['jmiller'], ] ); if (!$jsonOut) { echo "Updated audit issue #{$num}\n"; } } else { [$_, $issue] = $this->ghApi( 'POST', "repos/{$org}/moko-platform/issues", [ 'title' => $auditTitle, 'body' => $body, 'labels' => ['secret-audit', 'type: chore', 'automation'], 'assignees' => ['jmiller'], ] ); if (!$jsonOut) { echo "Created audit issue #{$issue['number']}\n"; } } } return $issueCount > 0 ? 1 : 0; } private function ghApi(string $method, string $path, ?array $body): array { try { $result = match ($method) { 'GET' => $this->api->get("/{$path}"), 'POST' => $this->api->post("/{$path}", $body ?? []), 'PATCH' => $this->api->patch("/{$path}", $body ?? []), 'PUT' => $this->api->put("/{$path}", $body ?? []), 'DELETE' => $this->api->delete("/{$path}"), default => throw new \RuntimeException("Unsupported method: {$method}"), }; return [200, $result]; } catch (\Exception $e) { return [500, ['message' => $e->getMessage()]]; } } private function listNames(string $path, string $key): array { $names = []; $page = 1; do { [$status, $data] = $this->ghApi('GET', "{$path}?per_page=100&page={$page}", null); if ($status !== 200) { break; } $items = ($key === '') ? $data : ($data[$key] ?? []); foreach ($items as $item) { if (isset($item['name'])) { $names[] = $item['name']; } } $page++; } while (count($items) === 100); return $names; } } $app = new RotateSecretsCli(); exit($app->execute());