// Copyright Epic Games, Inc. All Rights Reserved. #include #include #include #include #include ZEN_THIRD_PARTY_INCLUDES_START #include ZEN_THIRD_PARTY_INCLUDES_END #include #if ZEN_WITH_TESTS # include #endif // ZEN_WITH_TESTS #ifndef CXXOPTS_HAS_FILESYSTEM void cxxopts::values::parse_value(const std::string& text, std::filesystem::path& value) { value = zen::StringToPath(text); } #endif namespace zen { std::vector ParseCommandLine(std::string_view CommandLine) { auto IsWhitespaceOrEnd = [](std::string_view CommandLine, std::string::size_type Pos) { if (Pos == CommandLine.length()) { return true; } if (CommandLine[Pos] == ' ') { return true; } return false; }; bool IsParsingArg = false; bool IsInQuote = false; std::string::size_type Pos = 0; std::string::size_type ArgStart = 0; std::vector Args; while (Pos < CommandLine.length()) { if (IsInQuote) { if (CommandLine[Pos] == '"' && IsWhitespaceOrEnd(CommandLine, Pos + 1)) { Args.push_back(std::string(CommandLine.substr(ArgStart, Pos - ArgStart + 1))); Pos++; IsInQuote = false; IsParsingArg = false; } else { Pos++; } } else if (IsParsingArg) { ZEN_ASSERT(Pos > ArgStart); if (CommandLine[Pos] == ' ') { Args.push_back(std::string(CommandLine.substr(ArgStart, Pos - ArgStart))); Pos++; IsParsingArg = false; } else if (CommandLine[Pos] == '"') { IsInQuote = true; Pos++; } else { Pos++; } } else if (CommandLine[Pos] == '"') { IsInQuote = true; IsParsingArg = true; ArgStart = Pos; Pos++; } else if (CommandLine[Pos] != ' ') { IsParsingArg = true; ArgStart = Pos; Pos++; } else { Pos++; } } if (IsParsingArg) { ZEN_ASSERT(Pos > ArgStart); Args.push_back(std::string(CommandLine.substr(ArgStart))); } return Args; } std::vector StripCommandlineQuotes(std::vector& InOutArgs) { std::vector RawArgs; RawArgs.reserve(InOutArgs.size()); for (std::string& Arg : InOutArgs) { std::string::size_type EscapedQuotePos = Arg.find("\\\"", 1); while (EscapedQuotePos != std::string::npos && Arg.rfind('\"', EscapedQuotePos - 1) != std::string::npos) { Arg.erase(EscapedQuotePos, 1); EscapedQuotePos = Arg.find("\\\"", EscapedQuotePos); } if (Arg.starts_with("\"")) { if (Arg.find('"', 1) == Arg.length() - 1) { Arg = Arg.substr(1, Arg.length() - 2); } } else if (Arg.ends_with("\"")) { std::string::size_type EqualSign = Arg.find("=", 1); if (EqualSign != std::string::npos && Arg[EqualSign + 1] == '\"') { Arg = Arg.substr(0, EqualSign + 1) + Arg.substr(EqualSign + 2, Arg.length() - (EqualSign + 2) - 1); } } RawArgs.push_back(const_cast(Arg.c_str())); } return RawArgs; } std::filesystem::path StringToPath(const std::string_view& Path) { std::string_view UnquotedPath = RemoveQuotes(Path); if (UnquotedPath.ends_with('/') || UnquotedPath.ends_with('\\') || UnquotedPath.ends_with(std::filesystem::path::preferred_separator)) { UnquotedPath = UnquotedPath.substr(0, UnquotedPath.length() - 1); } return std::filesystem::path(UnquotedPath).make_preferred(); } std::string_view RemoveQuotes(const std::string_view& Arg) { if (Arg.length() > 2) { if (Arg[0] == '"' && Arg[Arg.length() - 1] == '"') { return Arg.substr(1, Arg.length() - 2); } } return Arg; } CommandLineConverter::CommandLineConverter(int& argc, char**& argv) { #if ZEN_PLATFORM_WINDOWS LPWSTR RawCommandLine = GetCommandLineW(); std::string CommandLine = WideToUtf8(RawCommandLine); Args = ParseCommandLine(CommandLine); #else Args.reserve(argc); for (int I = 0; I < argc; I++) { std::string Arg(argv[I]); if ((!Arg.empty()) && (Arg != " ")) { Args.emplace_back(std::move(Arg)); } } #endif RawArgs = StripCommandlineQuotes(Args); argc = static_cast(RawArgs.size()); argv = RawArgs.data(); } ////////////////////////////////////////////////////////////////////////// // Command-line scrubber (used by invocation history and Sentry integration). namespace { // Suffixes (matched against the normalized option name) that mark an option // as carrying a sensitive value. Names are normalized before comparison: // leading dashes dropped, remaining '-' / '_' stripped, lowercased. So // `--access-token` / `--Access-Token` / `--access_token` all normalize to // "accesstoken" and match the "token" suffix. // // Picked deliberately to be sharp: catches every credential-bearing option // in zen and zenserver (access tokens, OAuth client secrets, AES keys, // Sentry DSNs, upstream Jupiter tokens) without false-positive masks on // look-alikes (--access-token-env, --access-token-path, --oidctoken-exe-path, // --valuekey, --opkey, the bare --key cloud lookup hash, --encryption-aes-iv). // // For freeform / unknown sensitive values that follow a known format (AWS // access keys, Google API keys, JWT bearer tokens) the value-pattern scanner // (kSecretPatterns below) provides an orthogonal safety net. constexpr std::string_view kSensitiveNameSuffixes[] = { "token", "aeskey", "secret", "dsn", }; constexpr char ToLowerAscii(char C) { return (C >= 'A' && C <= 'Z') ? char(C + ('a' - 'A')) : C; } bool IsSensitiveOptionName(std::string_view Name) { // Normalize: skip syntactic quotes, drop leading dashes, strip remaining // '-' / '_', lowercase ASCII. "--access-token" / "--Access-Token" / // "--access_token" / `"--Access_Token` all collapse to "accesstoken". // The 64-byte inline buffer covers every realistic option name; the // builder spills to the heap on a pathological name. ExtendableStringBuilder<64> Norm; bool LeadingDashes = true; for (char C : Name) { if (C == '"') { continue; } if (LeadingDashes) { if (C == '-') { continue; } LeadingDashes = false; } if (C == '-' || C == '_') { continue; } Norm.Append(ToLowerAscii(C)); } const std::string_view View = Norm.ToView(); for (std::string_view Suffix : kSensitiveNameSuffixes) { if (View.ends_with(Suffix)) { return true; } } return false; } constexpr std::string_view kUserAndPass = "***:***"; constexpr std::string_view kUserOnly = "***"; // Locate scheme://[user[:pass]]@ credentials in Text. Sets HostStart to the // offset right after "://", UserInfoLen to the length of the userinfo span // to redact (i.e. AtPos - HostStart), and HasPassword to true when a ':' // separates user and password within the userinfo. Returns false if Text // has no credentialed authority component. struct UrlCredentials { size_t HostStart; size_t UserInfoLen; bool HasPassword; }; bool FindUrlCredentials(std::string_view Text, UrlCredentials& Out) { const size_t SchemePos = Text.find("://"); if (SchemePos == std::string_view::npos) { return false; } const size_t HostStart = SchemePos + 3; const size_t AtPos = Text.find('@', HostStart); if (AtPos == std::string_view::npos) { return false; } // '@' must be in the authority, not the path/query/fragment. const size_t TermPos = Text.find_first_of("/?#", HostStart); if (TermPos != std::string_view::npos && TermPos < AtPos) { return false; } const size_t ColonPos = Text.find(':', HostStart); Out.HostStart = HostStart; Out.UserInfoLen = AtPos - HostStart; Out.HasPassword = (ColonPos != std::string_view::npos && ColonPos < AtPos); return true; } // Inline capacity for the tokenized cmdline. 32 covers any realistic // invocation; if exceeded the eastl::fixed_vector spills to the heap. constexpr size_t kTokenInlineCapacity = 32; using TokenVector = eastl::fixed_vector; constexpr AsciiSet kUpperAlnum = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; constexpr AsciiSet kBase64Url = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; constexpr AsciiSet kJwtCharset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_."; // Known credential value formats. The cmdline scanner walks each non-argv[0] // token and at every position checks whether one of these prefixes matches. // On match it greedily consumes [MinBodyLen, MaxBodyLen] characters from // BodyChars; if the consumed body is at least MinBodyLen the prefix + body // range is masked. Patterns are kept strict (distinctive prefix + length // + charset) to keep false positives near zero. // // Only formats that zen/zenserver interacts with (or may plausibly accept // via a cloud config) are listed here: // - AWS access keys: used by the S3 client (SigV4) and EC2 IMDS provider. // - Google API keys: covers keys passed via GCP-adjacent configuration. // - JWTs: OAuth/OIDC bearer tokens accepted via --access-token and the // upstream-jupiter-oauth-* options are typically JWTs. struct SecretPattern { std::string_view Prefix; size_t MinBodyLen; size_t MaxBodyLen; AsciiSet BodyChars; }; constexpr SecretPattern kSecretPatterns[] = { // AWS access key (long-term and temporary / STS). {"AKIA", 16, 16, kUpperAlnum}, {"ASIA", 16, 16, kUpperAlnum}, // Google API key: exactly "AIza" + 35 base64url body chars. {"AIza", 35, 35, kBase64Url}, // JWT (header.payload.signature). Loose - prefix "eyJ" plus body chars. // Verified to contain at least two '.' before accepting. {"eyJ", 20, 8192, kJwtCharset}, }; // Scan a token for embedded secret patterns. For each match append an Edit // that replaces the matched range with HideSensitiveString(matched value). // The first 4 characters of any 17+ char match leak through - safe here // since they are part of the public format prefix that triggered the match // (e.g. "AKIA...", "AIza...", "eyJh..."). Edit offsets are absolute // Cmdline offsets. template void FindSecretPatternEdits(std::string_view Token, size_t TokStart, EditVecT& Edits) { size_t I = 0; while (I < Token.size()) { bool Matched = false; for (const SecretPattern& Pat : kSecretPatterns) { if (I + Pat.Prefix.size() > Token.size()) { continue; } if (Token.compare(I, Pat.Prefix.size(), Pat.Prefix) != 0) { continue; } const size_t BodyStart = I + Pat.Prefix.size(); size_t J = BodyStart; while (J < Token.size() && (J - BodyStart) < Pat.MaxBodyLen && Pat.BodyChars.Contains(Token[J])) { ++J; } const size_t BodyLen = J - BodyStart; if (BodyLen < Pat.MinBodyLen) { continue; } if (Pat.Prefix == "eyJ") { int DotCount = 0; for (size_t K = BodyStart; K < J; ++K) { if (Token[K] == '.' && ++DotCount == 2) { break; } } if (DotCount < 2) { continue; } } const size_t MatchLen = Pat.Prefix.size() + BodyLen; Edits.push_back({TokStart + I, MatchLen, HideSensitiveString(Token.substr(I, MatchLen))}); I = J; Matched = true; break; } if (!Matched) { ++I; } } } // Tokenize a Windows-style command line into views into the original // string. Each token preserves any surrounding/embedded quote characters. // Splits on unquoted ASCII whitespace. No heap allocation up to // kTokenInlineCapacity tokens. TokenVector SplitCommandLineTokens(std::string_view Input) { TokenVector Tokens; size_t I = 0; while (I < Input.size()) { while (I < Input.size() && (Input[I] == ' ' || Input[I] == '\t')) { ++I; } if (I >= Input.size()) { break; } const size_t Start = I; bool InQuote = false; while (I < Input.size()) { const char C = Input[I]; if (C == '"') { InQuote = !InQuote; ++I; continue; } if (!InQuote && (C == ' ' || C == '\t')) { break; } ++I; } Tokens.push_back(Input.substr(Start, I - Start)); } return Tokens; } // First non-quote character in a token (or '\0' if there isn't one). // Used to test whether a token starts with '-' through any leading quotes. char FirstNonQuote(std::string_view Token) { for (char C : Token) { if (C != '"') { return C; } } return '\0'; } void ScrubSensitiveValuesImpl(std::string& Cmdline) { const TokenVector Tokens = SplitCommandLineTokens(Cmdline); if (Tokens.size() <= 1) { return; } // Edits are accumulated and applied right-to-left at the end, so // untouched offsets remain valid. fixed_vector keeps the storage // inline; spills to heap only on a pathological number of edits. The // std::string Replacement uses small-string optimization for the // short masks we produce (max ~12 chars) so no heap traffic per edit // in the common case. struct Edit { size_t Start; size_t Len; std::string Replacement; }; eastl::fixed_vector Edits; auto PushMask = [&](size_t Start, size_t Len, std::string_view Replacement) { Edits.push_back({Start, Len, std::string(Replacement)}); }; bool MaskNext = false; // Skip Tokens[0] (executable path) - never scrub. for (size_t Idx = 1; Idx < Tokens.size(); ++Idx) { const std::string_view Tok = Tokens[Idx]; const size_t TokStart = static_cast(Tok.data() - Cmdline.data()); const char Lead = FirstNonQuote(Tok); const bool IsFlag = (Lead == '-'); if (MaskNext) { MaskNext = false; if (!IsFlag && Lead != '\0') { PushMask(TokStart, Tok.size(), kUserOnly); continue; } // Otherwise fall through to re-evaluate this token as a flag. } if (!IsFlag) { // Positional. URL credentials and secret patterns target // different ranges so they can coexist; overlap is filtered // out below. if (UrlCredentials U; FindUrlCredentials(Tok, U)) { PushMask(TokStart + U.HostStart, U.UserInfoLen, U.HasPassword ? kUserAndPass : kUserOnly); } FindSecretPatternEdits(Tok, TokStart, Edits); continue; } // It's a flag. Look for inline =value. const size_t EqPos = Tok.find('='); if (EqPos == std::string_view::npos) { if (IsSensitiveOptionName(Tok)) { MaskNext = true; } else { // Bare flag - still scan for embedded secret prefixes // (e.g. "--AKIAEXAMPLE..." would be unusual but harmless). FindSecretPatternEdits(Tok, TokStart, Edits); } continue; } const std::string_view Name = Tok.substr(0, EqPos); if (IsSensitiveOptionName(Name)) { const size_t ValueStart = TokStart + EqPos + 1; size_t ValueLen = Tok.size() - (EqPos + 1); // Preserve the closing quote when the whole token is outer-quoted // (e.g. `"--name=value"`); otherwise the replacement would eat it // and leave an unbalanced quote in the cmdline string. if (ValueLen > 0 && Tok.front() == '"' && Tok.back() == '"') { --ValueLen; } PushMask(ValueStart, ValueLen, kUserOnly); } else { // Non-sensitive flag with a value: URL scrub + pattern scan // on the value (same coexistence as the positional branch). const std::string_view Value = Tok.substr(EqPos + 1); const size_t ValueAbsStart = TokStart + EqPos + 1; if (UrlCredentials U; FindUrlCredentials(Value, U)) { PushMask(ValueAbsStart + U.HostStart, U.UserInfoLen, U.HasPassword ? kUserAndPass : kUserOnly); } FindSecretPatternEdits(Value, ValueAbsStart, Edits); } } if (Edits.empty()) { return; } // Sort by start ascending and drop overlapping/duplicate edits so the // right-to-left replacement pass does not corrupt the string. Earlier // (lower-Start) edits win over later ones that fall inside their range. std::sort(Edits.begin(), Edits.end(), [](const Edit& A, const Edit& B) { return A.Start < B.Start; }); size_t Write = 0; for (size_t Read = 0; Read < Edits.size(); ++Read) { if (Write > 0 && Edits[Read].Start < Edits[Write - 1].Start + Edits[Write - 1].Len) { continue; // overlaps the prior kept edit } if (Write != Read) { Edits[Write] = Edits[Read]; } ++Write; } Edits.resize(Write); // Apply right-to-left so earlier offsets stay valid as later edits // resize the string. for (size_t E = Edits.size(); E-- > 0;) { Cmdline.replace(Edits[E].Start, Edits[E].Len, Edits[E].Replacement); } } } // namespace void ScrubSensitiveValues(std::string& Cmdline) noexcept { try { ScrubSensitiveValuesImpl(Cmdline); } catch (...) { } } #if ZEN_WITH_TESTS void commandlineoptions_forcelink() { } namespace { // Test helper: in-place redaction of user:password@ in a URL. Production // code calls FindUrlCredentials + PushMask directly inside the cmdline // walker; this wrapper exists only for direct unit-test coverage of the // URL redaction rule. void ScrubUrlCredentials(std::string& Token) { UrlCredentials U; if (!FindUrlCredentials(Token, U)) { return; } Token.replace(U.HostStart, U.UserInfoLen, U.HasPassword ? kUserAndPass : kUserOnly); } } // namespace TEST_SUITE_BEGIN("util.commandlineoptions"); TEST_CASE("CommandLine") { std::vector v1 = ParseCommandLine("c:\\my\\exe.exe \"quoted arg\" \"one\",two,\"three\\\""); CHECK_EQ(v1[0], "c:\\my\\exe.exe"); CHECK_EQ(v1[1], "\"quoted arg\""); CHECK_EQ(v1[2], "\"one\",two,\"three\\\""); std::vector v2 = ParseCommandLine( "--tracehost 127.0.0.1 builds download --url=https://jupiter.devtools.epicgames.com --namespace=ue.oplog " "--bucket=citysample.packaged-build.fortnite-main.windows \"c:\\just\\a\\path\" " "--access-token-path=\"C:\\Users\\dan.engelbrecht\\jupiter-token.json\" \"D:\\Dev\\Spaced Folder\\Target\\\" " "--alt-path=\"D:\\Dev\\Spaced Folder2\\Target\\\" 07dn23ifiwesnvoasjncasab --build-part-name win64,linux,ps5"); std::vector v2Stripped = StripCommandlineQuotes(v2); CHECK_EQ(v2Stripped[0], std::string("--tracehost")); CHECK_EQ(v2Stripped[1], std::string("127.0.0.1")); CHECK_EQ(v2Stripped[2], std::string("builds")); CHECK_EQ(v2Stripped[3], std::string("download")); CHECK_EQ(v2Stripped[4], std::string("--url=https://jupiter.devtools.epicgames.com")); CHECK_EQ(v2Stripped[5], std::string("--namespace=ue.oplog")); CHECK_EQ(v2Stripped[6], std::string("--bucket=citysample.packaged-build.fortnite-main.windows")); CHECK_EQ(v2Stripped[7], std::string("c:\\just\\a\\path")); CHECK_EQ(v2Stripped[8], std::string("--access-token-path=C:\\Users\\dan.engelbrecht\\jupiter-token.json")); CHECK_EQ(v2Stripped[9], std::string("D:\\Dev\\Spaced Folder\\Target")); CHECK_EQ(v2Stripped[10], std::string("--alt-path=D:\\Dev\\Spaced Folder2\\Target")); CHECK_EQ(v2Stripped[11], std::string("07dn23ifiwesnvoasjncasab")); CHECK_EQ(v2Stripped[12], std::string("--build-part-name")); CHECK_EQ(v2Stripped[13], std::string("win64,linux,ps5")); std::vector v3 = ParseCommandLine( "--tracehost \"127.0.0.1\" builds download --url=\"https://jupiter.devtools.epicgames.com\" --build-part-name=\"win64\""); std::vector v3Stripped = StripCommandlineQuotes(v3); CHECK_EQ(v3Stripped[0], std::string("--tracehost")); CHECK_EQ(v3Stripped[1], std::string("127.0.0.1")); CHECK_EQ(v3Stripped[2], std::string("builds")); CHECK_EQ(v3Stripped[3], std::string("download")); CHECK_EQ(v3Stripped[4], std::string("--url=https://jupiter.devtools.epicgames.com")); CHECK_EQ(v3Stripped[5], std::string("--build-part-name=win64")); } TEST_CASE("IsSensitiveOptionName.matches") { // Real zen / zenserver options ending in one of the sensitive suffixes. CHECK(IsSensitiveOptionName("--access-token")); // token CHECK(IsSensitiveOptionName("--openid-refresh-token")); // token CHECK(IsSensitiveOptionName("--upstream-jupiter-token")); // token CHECK(IsSensitiveOptionName("--encryption-aes-key")); // aeskey CHECK(IsSensitiveOptionName("--oauth-clientsecret")); // secret CHECK(IsSensitiveOptionName("--upstream-jupiter-oauth-clientsecret")); // secret CHECK(IsSensitiveOptionName("--sentry-dsn")); // dsn // Generic forms ending in one of the suffixes. CHECK(IsSensitiveOptionName("--token")); CHECK(IsSensitiveOptionName("--my-aeskey")); // Normalization equivalents - dashes / underscores stripped, case folded. CHECK(IsSensitiveOptionName("--ACCESS-TOKEN")); CHECK(IsSensitiveOptionName("--access_token")); CHECK(IsSensitiveOptionName("--AccessToken")); CHECK(IsSensitiveOptionName("--Encryption_AES_Key")); CHECK(IsSensitiveOptionName("--Sentry-DSN")); // Quotes around the option name are stripped before matching. CHECK(IsSensitiveOptionName("\"--access-token\"")); CHECK(IsSensitiveOptionName("\"--sentry-dsn")); } TEST_CASE("IsSensitiveOptionName.no-match") { // Real zen options whose name contains "token" or "key" mid-name but // whose value is NOT a secret (suffix doesn't match). CHECK_FALSE(IsSensitiveOptionName("--access-token-env")); // env name CHECK_FALSE(IsSensitiveOptionName("--access-token-path")); // file path CHECK_FALSE(IsSensitiveOptionName("--oidctoken-exe-path")); // exe path CHECK_FALSE(IsSensitiveOptionName("--allow-external-oidctoken-exe")); // boolean CHECK_FALSE(IsSensitiveOptionName("--encryption-aes-iv")); // IV is public CHECK_FALSE(IsSensitiveOptionName("--key")); // cloud lookup hash CHECK_FALSE(IsSensitiveOptionName("--valuekey")); // IoHash filter CHECK_FALSE(IsSensitiveOptionName("--opkey")); // chunk OID filter // "key" alone is not a sensitive suffix in this scheme - keeps lookup // hashes / cache filter / api-key / ssh-key visible. CHECK_FALSE(IsSensitiveOptionName("--api-key")); CHECK_FALSE(IsSensitiveOptionName("--ssh-key")); // Non-sensitive option names. CHECK_FALSE(IsSensitiveOptionName("--port")); CHECK_FALSE(IsSensitiveOptionName("--data-dir")); CHECK_FALSE(IsSensitiveOptionName("--debug")); CHECK_FALSE(IsSensitiveOptionName("--no-sentry")); CHECK_FALSE(IsSensitiveOptionName("--filter")); CHECK_FALSE(IsSensitiveOptionName("--monkey")); CHECK_FALSE(IsSensitiveOptionName("--keychain")); CHECK_FALSE(IsSensitiveOptionName("")); CHECK_FALSE(IsSensitiveOptionName("--")); CHECK_FALSE(IsSensitiveOptionName("---")); // Pathologically long non-sensitive name spills the inline buffer but // still resolves cleanly. CHECK_FALSE(IsSensitiveOptionName(std::string(200, 'x'))); } TEST_CASE("ScrubUrlCredentials.basic") { std::string T = "https://user:pwd@host.example.com/path"; ScrubUrlCredentials(T); CHECK_EQ(T, "https://***:***@host.example.com/path"); T = "https://user@host.example.com"; ScrubUrlCredentials(T); CHECK_EQ(T, "https://***@host.example.com"); T = "ftp://u:p@example.com:21/file"; ScrubUrlCredentials(T); CHECK_EQ(T, "ftp://***:***@example.com:21/file"); } TEST_CASE("ScrubUrlCredentials.passthrough") { std::string T = "just a string"; ScrubUrlCredentials(T); CHECK_EQ(T, "just a string"); T = "https://example.com/path"; ScrubUrlCredentials(T); CHECK_EQ(T, "https://example.com/path"); T = "https://example.com/users/foo@bar"; ScrubUrlCredentials(T); CHECK_EQ(T, "https://example.com/users/foo@bar"); T = "user@host"; ScrubUrlCredentials(T); CHECK_EQ(T, "user@host"); } TEST_CASE("ScrubSensitiveValues.inline-equals") { std::string C = "zen.exe --access-token=secret --port=8558 version"; ScrubSensitiveValues(C); CHECK_EQ(C, "zen.exe --access-token=*** --port=8558 version"); } TEST_CASE("ScrubSensitiveValues.next-token") { std::string C = "zen.exe --access-token mysecret version"; ScrubSensitiveValues(C); CHECK_EQ(C, "zen.exe --access-token *** version"); // Multiple next-token sensitive options in a row. C = "zen.exe --access-token tok1 --oauth-clientsecret sec1 --port 8558 version"; ScrubSensitiveValues(C); CHECK_EQ(C, "zen.exe --access-token *** --oauth-clientsecret *** --port 8558 version"); } TEST_CASE("ScrubSensitiveValues.sensitive-flag-followed-by-flag") { // `--access-token` here is acting like a switch (no value); the next // flag must NOT be masked. std::string C = "zen.exe --access-token --debug version"; ScrubSensitiveValues(C); CHECK_EQ(C, "zen.exe --access-token --debug version"); } TEST_CASE("ScrubSensitiveValues.normalized-name-forms") { // Same option masked under different casing/separator styles. std::string C = "zen.exe --Access-Token bar --ACCESS_TOKEN=foo --AccessToken=baz version"; ScrubSensitiveValues(C); CHECK_EQ(C, "zen.exe --Access-Token *** --ACCESS_TOKEN=*** --AccessToken=*** version"); } TEST_CASE("ScrubSensitiveValues.aes-key-and-iv-and-bare-key") { // --encryption-aes-key matches the "aeskey" suffix and is masked. // --encryption-aes-iv ends in "iv", not in the suffix set, and stays. // --key (cloud lookup hash) ends in just "key", not in the suffix set, // and stays. std::string C = "zen.exe --encryption-aes-key=BASE64STUFF --encryption-aes-iv ABCDEF --key=cloudval --key bareval version"; ScrubSensitiveValues(C); CHECK_EQ(C, "zen.exe --encryption-aes-key=*** --encryption-aes-iv ABCDEF --key=cloudval --key bareval version"); } TEST_CASE("ScrubSensitiveValues.no-false-positives") { // Names that don't end in any sensitive suffix stay untouched. std::string C = "zen.exe --password mypass --api-key bar --monkey=banana --filter zen --no-sentry --port 8558 version"; const std::string Original = C; ScrubSensitiveValues(C); CHECK_EQ(C, Original); } TEST_CASE("ScrubSensitiveValues.url-credentials-positional") { std::string C = "zen.exe serve https://user:pwd@host.example.com/path"; ScrubSensitiveValues(C); CHECK_EQ(C, "zen.exe serve https://***:***@host.example.com/path"); } TEST_CASE("ScrubSensitiveValues.url-credentials-in-option-value") { std::string C = "zen.exe --url=https://user:pwd@host.example.com version"; ScrubSensitiveValues(C); CHECK_EQ(C, "zen.exe --url=https://***:***@host.example.com version"); } TEST_CASE("ScrubSensitiveValues.outer-quoted-token-preserves-closing-quote") { std::string C = "zen.exe status \"--access-token=Bearer xyz\""; ScrubSensitiveValues(C); CHECK_EQ(C, "zen.exe status \"--access-token=***\""); } TEST_CASE("ScrubSensitiveValues.executable-path-never-touched") { // argv[0] stays untouched even if it embeds a token-shaped substring. std::string C = "/path/with/AKIAIOSFODNN7EXAMPLE/zen.exe version"; const std::string Original = C; ScrubSensitiveValues(C); CHECK_EQ(C, Original); } TEST_CASE("ScrubSensitiveValues.empty-and-trivial") { std::string C; ScrubSensitiveValues(C); CHECK_EQ(C, ""); C = "zen.exe"; ScrubSensitiveValues(C); CHECK_EQ(C, "zen.exe"); } TEST_CASE("ScrubSensitiveValues.long-value") { const std::string LongSecret(4096, 'X'); std::string C = "zen.exe --access-token=" + LongSecret + " version"; ScrubSensitiveValues(C); CHECK_EQ(C, "zen.exe --access-token=*** version"); C = "zen.exe --access-token " + LongSecret + " version"; ScrubSensitiveValues(C); CHECK_EQ(C, "zen.exe --access-token *** version"); C = "zen.exe \"--access-token=" + LongSecret + "\""; ScrubSensitiveValues(C); CHECK_EQ(C, "zen.exe \"--access-token=***\""); } TEST_CASE("ScrubSensitiveValues.long-option-name-spills-buffer") { // Names longer than the 64-byte inline buffer normalize correctly via // ExtendableStringBuilder's heap-spill path, and the suffix check still // identifies them as sensitive. const std::string LongName = "--" + std::string(80, 'a') + "-token"; std::string C = "zen.exe " + LongName + "=secretvalue version"; const std::string Expected = "zen.exe " + LongName + "=*** version"; ScrubSensitiveValues(C); CHECK_EQ(C, Expected); } TEST_CASE("ScrubSensitiveValues.no-allocation-on-noop") { std::string C = "zen.exe version --port=8558 --debug"; C.reserve(256); const auto* DataBefore = C.data(); const auto CapacityBefore = C.capacity(); ScrubSensitiveValues(C); CHECK_EQ(C.data(), DataBefore); CHECK_EQ(C.capacity(), CapacityBefore); } // Value-based pattern matching ------------------------------------------ // Pattern-matched values are masked via HideSensitiveString, which leaks // the first 4 characters for any value over 16 chars. Safe here because // those 4 characters are part of the public format prefix that triggered // the match (AKIA, AIza, eyJh) and serve as a useful debugging hint when // reading a crash report. TEST_CASE("ScrubSensitiveValues.aws-access-key") { std::string C = "zen.exe upload --bucket foo AKIAIOSFODNN7EXAMPLE done"; ScrubSensitiveValues(C); CHECK_EQ(C, "zen.exe upload --bucket foo AKIAXXXX... done"); // Inside an option value with non-sensitive name. C = "zen.exe --note=AKIAIOSFODNN7EXAMPLE"; ScrubSensitiveValues(C); CHECK_EQ(C, "zen.exe --note=AKIAXXXX..."); // AKIA followed by too few chars: not a match. C = "zen.exe AKIA12345"; ScrubSensitiveValues(C); CHECK_EQ(C, "zen.exe AKIA12345"); } TEST_CASE("ScrubSensitiveValues.google-api-key") { // Google API keys are exactly AIza + 35 body chars. std::string C = "zen.exe lookup AIzaSyA_abcdefghijklmnopqrstuvwx-123456 done"; ScrubSensitiveValues(C); CHECK_EQ(C, "zen.exe lookup AIzaXXXX... done"); // AIza body too short: not a match. C = "zen.exe AIzaShort"; ScrubSensitiveValues(C); CHECK_EQ(C, "zen.exe AIzaShort"); } TEST_CASE("ScrubSensitiveValues.jwt") { std::string C = "zen.exe call eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c " "done"; ScrubSensitiveValues(C); CHECK_EQ(C, "zen.exe call eyJhXXXX... done"); // "eyJ" but no two dots: not a JWT. C = "zen.exe eyJonly"; ScrubSensitiveValues(C); CHECK_EQ(C, "zen.exe eyJonly"); } TEST_CASE("ScrubSensitiveValues.no-pattern-false-positives") { // Common identifier formats used by zen that must NOT be flagged: // - 24-hex Oid // - SHA-like hex hashes // - file paths // - port numbers // - lowercase words / common shell commands std::string C = "zen.exe cache get 09f7831b0139270d22cf2fe2 --port=8558 run /var/tmp/zen.log"; const std::string Original = C; ScrubSensitiveValues(C); CHECK_EQ(C, Original); } TEST_CASE("ScrubSensitiveValues.pattern-and-name-on-same-token") { // Sensitive-name mask wins; pattern scan is suppressed for the value. std::string C = "zen.exe --access-token=AKIAIOSFODNN7EXAMPLE done"; ScrubSensitiveValues(C); CHECK_EQ(C, "zen.exe --access-token=*** done"); } TEST_CASE("ScrubSensitiveValues.multiple-patterns-in-one-token") { // Two AWS keys glued together via separator - both should mask. std::string C = "zen.exe AKIAIOSFODNN7EXAMPLE,AKIAIOSFODNN7OTHER12 list"; ScrubSensitiveValues(C); CHECK_EQ(C, "zen.exe AKIAXXXX...,AKIAXXXX... list"); } TEST_SUITE_END(); #endif } // namespace zen