Files
Jonathan Miller 1d87be7d5e
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
fix: standardize file headers — REPO rename, SPDX case, missing fields
- Update REPO: from MokoStandards-API to moko-platform in 125 files
- Fix wrong org path (mokoconsulting-tech → MokoConsulting) in 10 files
- Fix SPDX-LICENSE-IDENTIFIER case in 2 template files
- Add missing REPO: field to 3 files

Authored-by: Moko Consulting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-11 17:01:17 -05:00

218 lines
7.8 KiB
PHP

#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: MokoStandards.Maintenance
* INGROUP: MokoStandards
* 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
*
* USAGE
* php maintenance/rotate_secrets.php --all # Audit all repos
* php maintenance/rotate_secrets.php --repo MokoCRM # Single repo
* php maintenance/rotate_secrets.php --all --json # JSON output
* php maintenance/rotate_secrets.php --all --create-issue # Post results as issue
*/
declare(strict_types=1);
$allMode = in_array('--all', $argv);
$jsonOut = in_array('--json', $argv);
$createIssue = in_array('--create-issue', $argv);
$org = 'mokoconsulting-tech';
$repoName = null;
foreach ($argv as $i => $arg) {
if ($arg === '--repo' && isset($argv[$i + 1])) { $repoName = $argv[$i + 1]; }
if ($arg === '--org' && isset($argv[$i + 1])) { $org = $argv[$i + 1]; }
}
if (!$repoName && !$allMode) {
fwrite(STDERR, "Usage: php rotate_secrets.php --all | --repo <name> [--json] [--create-issue]\n");
exit(2);
}
$config = \MokoEnterprise\Config::load();
try {
$_adapter = \MokoEnterprise\PlatformAdapterFactory::create($config);
$_api = $_adapter->getApiClient();
} catch (\Exception $e) {
fwrite(STDERR, "Platform init failed: " . $e->getMessage() . "\n");
exit(1);
}
$token = $config->getString('platform', 'gitea') === 'gitea'
? $config->getString('gitea.token', '')
: $config->getString('github.token', '');
$ALWAYS_EXCLUDE = ['MokoStandards', '.github-private'];
$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']],
];
/**
* GitHub REST API helper.
*
* @return array{int, array<string, mixed>}
*/
function ghApi(string $method, string $path, ?array $body, string $token): array
{
global $_api;
try {
$result = match ($method) {
'GET' => $_api->get("/{$path}"),
'POST' => $_api->post("/{$path}", $body ?? []),
'PATCH' => $_api->patch("/{$path}", $body ?? []),
'PUT' => $_api->put("/{$path}", $body ?? []),
'DELETE' => $_api->delete("/{$path}"),
default => throw new \RuntimeException("Unsupported method: {$method}"),
};
return [200, $result];
} catch (\Exception $e) {
return [500, ['message' => $e->getMessage()]];
}
}
/**
* Fetch all names from a paginated list endpoint.
*
* @return string[]
*/
function listNames(string $path, string $key, string $token): array
{
$names = [];
$page = 1;
do {
[$status, $data] = ghApi('GET', "{$path}?per_page=100&page={$page}", null, $token);
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;
}
// ── Build repo list ─────────────────────────────────────────────────────
$repos = [];
if ($allMode) {
if (!$jsonOut) { echo "Fetching repositories from {$org}...\n"; }
$page = 1;
do {
[$_, $batch] = ghApi('GET', "orgs/{$org}/repos?per_page=100&page={$page}&type=all", null, $token);
foreach ($batch as $r) {
if (!($r['archived'] ?? false) && !in_array($r['name'], $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];
}
// ── Audit each repo ─────────────────────────────────────────────────────
$results = [];
$issueCount = 0;
foreach ($repos as $repo) {
$fullRepo = "{$org}/{$repo}";
$repoVars = listNames("repos/{$fullRepo}/actions/variables", 'variables', $token);
$repoSecrets = listNames("repos/{$fullRepo}/actions/secrets", 'secrets', $token);
$result = ['repo' => $repo, 'envs' => [], 'missing' => []];
foreach ($ENVS as $env => $config) {
$missingVars = array_diff($config['vars'], $repoVars);
$hasAuth = !empty(array_intersect($config['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 ($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";
}
// ── Create issue if requested ───────────────────────────────────────────
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";
[$_, $existing] = ghApi('GET', "repos/{$org}/MokoStandards/issues?labels=secret-audit&state=all&per_page=1&sort=created&direction=desc", null, $token);
if (!empty($existing[0]['number'])) {
$num = $existing[0]['number'];
ghApi('PATCH', "repos/{$org}/MokoStandards/issues/{$num}", ['title' => "audit: FTP secrets — {$issueCount} issues", 'body' => $body, 'state' => 'open', 'assignees' => ['jmiller']], $token);
if (!$jsonOut) { echo "Updated audit issue #{$num}\n"; }
} else {
[$_, $issue] = ghApi('POST', "repos/{$org}/MokoStandards/issues", [
'title' => "audit: FTP secrets — {$issueCount} issues", 'body' => $body,
'labels' => ['secret-audit', 'type: chore', 'automation'], 'assignees' => ['jmiller'],
], $token);
if (!$jsonOut) { echo "Created audit issue #{$issue['number']}\n"; }
}
}
exit($issueCount > 0 ? 1 : 0);