aboutsummaryrefslogtreecommitdiff
path: root/src/zenutil/config
diff options
context:
space:
mode:
Diffstat (limited to 'src/zenutil/config')
-rw-r--r--src/zenutil/config/commandlineoptions.cpp758
-rw-r--r--src/zenutil/config/loggingconfig.cpp24
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();
}