diff options
Diffstat (limited to 'src/zenutil/config')
| -rw-r--r-- | src/zenutil/config/commandlineoptions.cpp | 758 | ||||
| -rw-r--r-- | src/zenutil/config/loggingconfig.cpp | 24 |
2 files changed, 771 insertions, 11 deletions
diff --git a/src/zenutil/config/commandlineoptions.cpp b/src/zenutil/config/commandlineoptions.cpp index 84c718ecc..42ce0d06a 100644 --- a/src/zenutil/config/commandlineoptions.cpp +++ b/src/zenutil/config/commandlineoptions.cpp @@ -2,11 +2,18 @@ #include <zenutil/config/commandlineoptions.h> +#include <zencore/filesystem.h> #include <zencore/string.h> #include <filesystem> #include <zencore/windows.h> +ZEN_THIRD_PARTY_INCLUDES_START +#include <EASTL/fixed_vector.h> +ZEN_THIRD_PARTY_INCLUDES_END + +#include <algorithm> + #if ZEN_WITH_TESTS # include <zencore/testing.h> #endif // ZEN_WITH_TESTS @@ -187,6 +194,422 @@ CommandLineConverter::CommandLineConverter(int& argc, char**& argv) 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<std::string_view, kTokenInlineCapacity>; + + 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<typename EditVecT> + 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<Edit, kTokenInlineCapacity> 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<size_t>(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 @@ -194,6 +617,24 @@ 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<std::string> v1 = ParseCommandLine("c:\\my\\exe.exe \"quoted arg\" \"one\",two,\"three\\\""); @@ -235,5 +676,322 @@ TEST_CASE("CommandLine") 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:[email protected]/path"; + ScrubUrlCredentials(T); + CHECK_EQ(T, "https://***:***@host.example.com/path"); + + T = "https://[email protected]"; + ScrubUrlCredentials(T); + CHECK_EQ(T, "https://***@host.example.com"); + + T = "ftp://u:[email protected]: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:[email protected]/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:[email protected] 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 diff --git a/src/zenutil/config/loggingconfig.cpp b/src/zenutil/config/loggingconfig.cpp index 9ec816b1b..e2db31160 100644 --- a/src/zenutil/config/loggingconfig.cpp +++ b/src/zenutil/config/loggingconfig.cpp @@ -21,14 +21,16 @@ ZenLoggingCmdLineOptions::AddCliOptions(cxxopts::Options& options, ZenLoggingCon ("log-id", "Specify id for adding context to log output", cxxopts::value<std::string>(LoggingConfig.LogId)) ("quiet", "Configure console logger output to level WARN", cxxopts::value<bool>(LoggingConfig.QuietConsole)->default_value("false")) ("noconsole", "Disable console logging", cxxopts::value<bool>(LoggingConfig.NoConsoleOutput)->default_value("false")) - ("log-trace", "Change selected loggers to level TRACE", cxxopts::value<std::string>(LoggingConfig.Loggers[logging::level::Trace])) - ("log-debug", "Change selected loggers to level DEBUG", cxxopts::value<std::string>(LoggingConfig.Loggers[logging::level::Debug])) - ("log-info", "Change selected loggers to level INFO", cxxopts::value<std::string>(LoggingConfig.Loggers[logging::level::Info])) - ("log-warn", "Change selected loggers to level WARN", cxxopts::value<std::string>(LoggingConfig.Loggers[logging::level::Warn])) - ("log-error", "Change selected loggers to level ERROR", cxxopts::value<std::string>(LoggingConfig.Loggers[logging::level::Err])) - ("log-critical", "Change selected loggers to level CRITICAL", cxxopts::value<std::string>(LoggingConfig.Loggers[logging::level::Critical])) - ("log-off", "Change selected loggers to level OFF", cxxopts::value<std::string>(LoggingConfig.Loggers[logging::level::Off])) + ("log-trace", "Change selected loggers to level TRACE", cxxopts::value<std::string>(LoggingConfig.Loggers[logging::Trace])) + ("log-debug", "Change selected loggers to level DEBUG", cxxopts::value<std::string>(LoggingConfig.Loggers[logging::Debug])) + ("log-info", "Change selected loggers to level INFO", cxxopts::value<std::string>(LoggingConfig.Loggers[logging::Info])) + ("log-warn", "Change selected loggers to level WARN", cxxopts::value<std::string>(LoggingConfig.Loggers[logging::Warn])) + ("log-error", "Change selected loggers to level ERROR", cxxopts::value<std::string>(LoggingConfig.Loggers[logging::Err])) + ("log-critical", "Change selected loggers to level CRITICAL", cxxopts::value<std::string>(LoggingConfig.Loggers[logging::Critical])) + ("log-off", "Change selected loggers to level OFF", cxxopts::value<std::string>(LoggingConfig.Loggers[logging::Off])) ("otlp-endpoint", "OpenTelemetry endpoint URI (e.g http://localhost:4318)", cxxopts::value<std::string>(LoggingConfig.OtelEndpointUri)) + ("force-color", "Force colored log output even when stdout is not a terminal", cxxopts::value<bool>(LoggingConfig.ForceColor)->default_value("false")) + ("log-stream", "TCP log stream endpoint (host:port)", cxxopts::value<std::string>(LoggingConfig.LogStreamEndpoint)) ; // clang-format on } @@ -47,7 +49,7 @@ ApplyLoggingOptions(cxxopts::Options& options, ZenLoggingConfig& LoggingConfig) if (LoggingConfig.QuietConsole) { bool HasExplicitConsoleLevel = false; - for (int i = 0; i < logging::level::LogLevelCount; ++i) + for (int i = 0; i < logging::LogLevelCount; ++i) { if (LoggingConfig.Loggers[i].find("console") != std::string::npos) { @@ -58,7 +60,7 @@ ApplyLoggingOptions(cxxopts::Options& options, ZenLoggingConfig& LoggingConfig) if (!HasExplicitConsoleLevel) { - std::string& WarnLoggers = LoggingConfig.Loggers[logging::level::Warn]; + std::string& WarnLoggers = LoggingConfig.Loggers[logging::Warn]; if (!WarnLoggers.empty()) { WarnLoggers += ","; @@ -67,9 +69,9 @@ ApplyLoggingOptions(cxxopts::Options& options, ZenLoggingConfig& LoggingConfig) } } - for (int i = 0; i < logging::level::LogLevelCount; ++i) + for (int i = 0; i < logging::LogLevelCount; ++i) { - logging::ConfigureLogLevels(logging::level::LogLevel(i), LoggingConfig.Loggers[i]); + logging::ConfigureLogLevels(logging::LogLevel(i), LoggingConfig.Loggers[i]); } logging::RefreshLogLevels(); } |