66e728b078
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Generic: Repo Health / Release configuration (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Access control (push) Successful in 18s
Generic: Repo Health / Site Health (push) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Successful in 3s
Universal: Auto Version Bump / Version Bump (push) Failing after 27s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 28s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 3s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Failing after 1m7s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 1m7s
Auto-fixed 5006 tab-indent and line-ending errors via phpcbf, then manually broke 100 lines exceeding 150-char limit. All 74 files in cli/, automation/, maintenance/, deploy/ now pass PHPCS PSR-12 clean. Authored-by: Moko Consulting Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
266 lines
10 KiB
PHP
266 lines
10 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: 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 <name> [--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());
|