aboutsummaryrefslogtreecommitdiff
path: root/src/zen/cmds/builds_cmd.cpp
diff options
context:
space:
mode:
authorStefan Boberg <[email protected]>2026-04-21 13:48:41 +0200
committerGitHub Enterprise <[email protected]>2026-04-21 13:48:41 +0200
commit245d2e562165d048e5ee2ab97f1260975a8142d3 (patch)
treec01a36119ccacf830c8486634d78d0af137ec47f /src/zen/cmds/builds_cmd.cpp
parentasync consul register/deregister (#992) (diff)
downloadarchived-zen-245d2e562165d048e5ee2ab97f1260975a8142d3.tar.xz
archived-zen-245d2e562165d048e5ee2ab97f1260975a8142d3.zip
zen CLI security review fixes (#974)
Security review follow-ups to the `zen` CLI. Each fix stands on its own commit. Grouped by category below. ## Credentials and secrets - **Per-install random auth encryption key instead of a hardcoded literal.** The default AES key and IV used to encrypt persisted OIDC refresh tokens / OAuth client secrets were ASCII literals compiled into the public source. Replaced with 32+16 random bytes persisted to `<system-root>/auth/machinekey.dat`. `SecureRandomBytes` added in zencore/crypto wrapping BCryptGenRandom / OpenSSL / mbedTLS CTR_DRBG. Partial override (only one of `--encryption-aes-key`/`--encryption-aes-iv`) is now rejected instead of silently using the hardcoded half. - **Wrap the machine key with OS-protected storage.** `machinekey.dat` is now a tagged format (4-byte magic + flags + wrapped-or-raw payload). Windows wraps via DPAPI (`CryptProtectData` at per-user scope) so a stolen disk copy cannot decrypt without the OS master key. macOS uses Keychain Services (GenericPassword under `org.unrealengine.zen.auth`, `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`). Linux uses libsecret (opt-in via `--zenlibsecret=yes`, off by default because headless servers typically have no Secret Service daemon). All platforms fall back to raw persistence with `0600` perms on POSIX when wrapping is unavailable. Legacy files from the prior commit are detected by size and still read. > Note: argv-redaction before Sentry on crash was previously part of this PR but was superseded by `ScrubSensitiveValues()` from #989; this PR now just calls that helper instead of walking argv itself. ## Path traversal - **Reject unsafe filenames from the remote oplog in `oplog-mirror`.** The filename from each oplog entry was joined to the mirror root without normalisation; a compromised remote could use drive letters, UNC shares, device path prefixes, absolute paths, or `..` components to write anywhere the zen user could write. An `UnsafeFileNameReason` check runs immediately after extraction, logs the offending filename, and aborts the mirror. - **Use the resolved absolute download-spec path in `builds download`.** `--download-spec-path` was computed into a sanitised absolute path, then the original unresolved path was passed to `ParseBuildManifest`, bypassing the `MakeSafeAbsolutePath` mitigations and reading from the process cwd rather than `--local-path`. ## Input validation - **Stop asserting on malformed `--build-id` / `--build-part-id`.** `Oid::FromHexString` asserts on bad input and `ZEN_ASSERT` is active in release, so a too-short or non-hex user value aborted the process instead of surfacing an `OptionParseException`. Routed all callers through `TryFromHexString`. Also fixes `ParseBuildPartId` reporting errors under the wrong option name. - **Check the JSON parse error in `oplog-export --builds-metadata-path`.** The single-arg `LoadCompactBinaryFromJson` overload discarded the parser error; malformed JSON shipped a truncated compact-binary `metadata` field to the server with no indication. Switched to the two-arg overload and throws a descriptive error naming the file and reason. - **Format the actual value in the malformed `--url` error.** The message was constructed with a literal `{}` placeholder and no `fmt::format` call, so users saw the placeholder instead of the offending URL. - **Require `--output-path` in `cache get` unless `--as-text` is set.** Previously an empty path auto-filled from the value key / attachment hash and wrote into the process cwd; the `--as-text && empty path` stdout branch was unreachable because the auto-fill ran first. - **Clear the cxxopts `allow_unrecognised_options` flag after permissive parse.** `ParseOptionsPermissive` set the flag on the Options it received and never cleared it, priming that Options for silent typo acceptance on any later reuse. Added `disallow_unrecognised_options()` to the vendored cxxopts (local patch — flagged at the declaration) and wrapped the toggle in RAII. ## Resource lifecycle - **Restore signal handlers via RAII.** `wipe`, `builds`, and `oplog-mirror` installed SIGINT/SIGBREAK handlers with raw `signal()` and never restored them; an option-parse throw left the handler targeting an abort flag nothing reads. Added `zen::ScopedSignalHandler` in zen.h and applied at all three sites (builds uses `std::optional` members so the guards survive past `OnParentOptionsParsed` into the subcommand's Run). - **Route SIGINT in `oplog-mirror` to the worker-pool abort flag.** The command declared a local `std::atomic<bool> AbortFlag` but no handler targeted it — Ctrl-C killed the process instead of cleanly aborting. Added a `MirrorAbortFlag` / `MirrorSignalCallbackHandler` pair in projectstore_impl and bound the local as a reference; existing `.store`/`.load`/capture sites unchanged. - **Clean up the `cache get` temp download on every exit path.** `Http.Download` parks the payload in the system temp dir; a failed `MoveToFile` (cross-volume, denied target) or an exception could leave the temp file behind. The downloaded buffer is already flagged delete-on-close by `HttpClient`, so the fix is just to clear that flag after a successful `MoveToFile` so the renamed-out file isn't reaped. ## Other - **Fix wrong URL fields in `oplog-export` / `oplog-import` builds-branch descriptions.** Two operator-facing "[builds] URL/namespace/bucket/buildsid" messages formatted `m_CloudUrl` instead of `m_BuildsUrl` / `m_BuildsHost` (copy-paste from neighbouring `[cloud]` branches), shown as empty or stale at the start of an export/import. - **Fix "Can't find oplog in project '{}'" formatting and a "Failed top mirror" typo in projectstore_cmd.** - **Fix a misleading `oplog-export` comment on the `--zen` scheme default** ("Assume https" vs. the `http://` the code writes). - **Fail `ScrambleDir` when `RemoveFile` doesn't delete.** The `zen builds test` scramble phase used `(void)RemoveFile(FilePath)`, discarding both the bool return and the error. A quiet delete failure let verification run against stale state; switched to the two-arg overload and throw on false return or non-empty `error_code`.
Diffstat (limited to 'src/zen/cmds/builds_cmd.cpp')
-rw-r--r--src/zen/cmds/builds_cmd.cpp61
1 files changed, 30 insertions, 31 deletions
diff --git a/src/zen/cmds/builds_cmd.cpp b/src/zen/cmds/builds_cmd.cpp
index 820ca9c45..a98bec250 100644
--- a/src/zen/cmds/builds_cmd.cpp
+++ b/src/zen/cmds/builds_cmd.cpp
@@ -4,6 +4,7 @@
#include <zencore/basicfile.h>
#include <zencore/compactbinarybuilder.h>
+#include <zencore/except_fmt.h>
#include <zencore/filesystem.h>
#include <zencore/fmtutils.h>
#include <zencore/logging.h>
@@ -467,10 +468,12 @@ BuildsCommand::OnParentOptionsParsed(const ZenCliOptions& /*GlobalOptions*/)
{
using namespace builds_impl;
- signal(SIGINT, SignalCallbackHandler);
+ // Held in member variables so the handlers stay installed through the
+ // subcommand's Run() and are restored when BuildsCommand is destroyed.
+ m_SigIntGuard.emplace(SIGINT, SignalCallbackHandler);
#if ZEN_PLATFORM_WINDOWS
- signal(SIGBREAK, SignalCallbackHandler);
-#endif // ZEN_PLATFORM_WINDOWS
+ m_SigBreakGuard.emplace(SIGBREAK, SignalCallbackHandler);
+#endif
// Validate output options
if (m_Configuration.Verbose && m_Configuration.Quiet)
@@ -601,7 +604,9 @@ BuildsSubCmdBase::ParseStorageOptions(std::string& BuildId,
}
if (!ParseBuildStorageUrl(m_Config.Url, Resolved.Host, Resolved.Namespace, Resolved.Bucket, BuildId))
{
- throw OptionParseException("'--url' ('{}') is malformed, it does not match the Cloud Artifact URL format", SubOpts.help());
+ throw OptionParseException(
+ fmt::format("'--url' ('{}') is malformed, it does not match the Cloud Artifact URL format", m_Config.Url),
+ SubOpts.help());
}
}
@@ -826,39 +831,27 @@ BuildsSubCmdBase::CreateBuildStorage(const std::filesystem::path& ZenFolderDef
Oid
BuildsSubCmdBase::ParseBuildId(const std::string& BuildIdStr, cxxopts::Options& SubOpts)
{
- if (BuildIdStr.length() != Oid::StringLength)
+ const Oid BuildId = Oid::TryFromHexString(BuildIdStr);
+ if (BuildId == Oid::Zero)
{
throw OptionParseException(
- fmt::format("'--build-id' ('{}') is malformed, it must be {} characters long", BuildIdStr, Oid::StringLength),
+ fmt::format("'--build-id' ('{}') is malformed, expected {} hex characters", BuildIdStr, Oid::StringLength),
SubOpts.help());
}
- else if (Oid BuildId = Oid::FromHexString(BuildIdStr); BuildId == Oid::Zero)
- {
- throw OptionParseException(fmt::format("'--build-id' ('{}') is invalid", BuildIdStr), SubOpts.help());
- }
- else
- {
- return BuildId;
- }
+ return BuildId;
}
Oid
BuildsSubCmdBase::ParseBuildPartId(const std::string& BuildPartIdStr, cxxopts::Options& SubOpts)
{
- if (BuildPartIdStr.length() != Oid::StringLength)
+ const Oid BuildPartId = Oid::TryFromHexString(BuildPartIdStr);
+ if (BuildPartId == Oid::Zero)
{
throw OptionParseException(
- fmt::format("'--build-id' ('{}') is malformed, it must be {} characters long", BuildPartIdStr, Oid::StringLength),
+ fmt::format("'--build-part-id' ('{}') is malformed, expected {} hex characters", BuildPartIdStr, Oid::StringLength),
SubOpts.help());
}
- else if (Oid BuildPartId = Oid::FromHexString(BuildPartIdStr); BuildPartId == Oid::Zero)
- {
- throw OptionParseException(fmt::format("'--build-id' ('{}') is malformed", BuildPartIdStr), SubOpts.help());
- }
- else
- {
- return BuildPartId;
- }
+ return BuildPartId;
}
std::vector<Oid>
@@ -2217,12 +2210,11 @@ BuildsTestSubCmd::Run(const ZenCliOptions& /*GlobalOptions*/)
const ResolvedStorage Resolved = ParseStorageOptions(TestBuildId, {}, Opts, m_TestSystemRootDir, m_TestStoragePath);
StorageInstance Storage = CreateBuildStorage(m_Path / ZenFolderName, {}, Opts, StorageStats, StorageCacheStats, Auth, Resolved);
- m_BuildId = Oid::NewOid().ToString();
- m_BuildPartId = Oid::NewOid().ToString();
- m_CreateBuild = true;
-
- const Oid BuildId = Oid::FromHexString(m_BuildId);
- const Oid BuildPartId = Oid::FromHexString(m_BuildPartId);
+ const Oid BuildId = Oid::NewOid();
+ const Oid BuildPartId = Oid::NewOid();
+ m_BuildId = BuildId.ToString();
+ m_BuildPartId = BuildPartId.ToString();
+ m_CreateBuild = true;
auto MakeMetaData = [](const Oid& BuildId) -> CbObject {
CbObjectWriter BuildMetaDataWriter;
@@ -2577,7 +2569,14 @@ BuildsTestSubCmd::Run(const ZenCliOptions& /*GlobalOptions*/)
case 1:
{
(void)SetFileReadOnly(FilePath, false);
- (void)RemoveFile(FilePath);
+ std::error_code Ec;
+ const bool Removed = RemoveFile(FilePath, Ec);
+ if (!Removed || Ec)
+ {
+ throw zen::runtime_error("ScrambleDir: failed to delete '{}'. Reason: '{}'",
+ FilePath,
+ Ec ? Ec.message() : "file no longer present");
+ }
}
break;
default: