Read-only mirrors. Development, issues and pull requests are on GitHub.

summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorLeitwolf112026-11-05 18:30:13 +0300
committerLeitwolf112026-11-05 18:30:13 +0300
commit89b4769c35986444a0297ad0806a584c7181c85c (patch)
tree2ec3dcb30828ef3749c503eb58e401b918beae7e
parent268698be92962247a21a86dda4de2d4267adcf8f (diff)
downloadcatch3-89b4769c35986444a0297ad0806a584c7181c85c.tar.gz
Withhold secret-looking variables from the processes the runner starts
Workers and the other processes the runner starts copied its whole environment, tokens and passwords included. ChildProcess::Start now leaves out every variable whose name contains, in any case, TOKEN, SECRET, PASSWORD, PASSWD, CREDENTIAL, API_KEY, APIKEY, PRIVATE_KEY or ACCESS_KEY (IsSecretVariable), unless ChildOptions::keptEnvironment names it; names compare without case on Windows. --pass-env=NAME, repeatable, and "pass-env" in catch3.json keep a variable for the tests. The runner itself still starts with the whole environment, so code that runs before the first worker sees it; docs/progmasoft-runner.md says so. A runner test starts the shell's environment listing with a secret and a plain variable set, sees only the plain one, and sees the secret once it is kept.
-rw-r--r--docs/command-line.md1
-rw-r--r--docs/progmasoft-runner.md13
-rw-r--r--src/Progmasoft/Runner/Options.cpp11
-rw-r--r--src/Progmasoft/Runner/Options.hpp2
-rw-r--r--src/Progmasoft/Runner/Process.cpp85
-rw-r--r--src/Progmasoft/Runner/Process.hpp9
-rw-r--r--src/Progmasoft/Runner/ProjectConfig.cpp1
-rw-r--r--src/Progmasoft/Runner/Scheduler.cpp1
-rw-r--r--tests/Progmasoft/Runner.tests.cpp40
9 files changed, 160 insertions, 3 deletions
diff --git a/docs/command-line.md b/docs/command-line.md
index 4bd46f3b..236128e1 100644
--- a/docs/command-line.md
+++ b/docs/command-line.md
@@ -71,6 +71,7 @@ Without a spec, every test runs except hidden ones, whose tags start with
| `-j`, `--jobs=N` | worker processes; 0, the default, uses one per hardware thread |
| `--in-process` | run the tests in this process, one after another |
| `--isolate` | start a fresh process for every test |
+| `--pass-env=NAME` | give the tests the variable `NAME`, which they do not get because its name looks like a secret (repeatable) |
| `--timeout=SECONDS` | the limit for a test without history, and the cap of learned limits; 0 for none |
| `--no-adaptive-timeout` | use `--timeout` for every test |
| `--retries=N\|auto\|off` | `auto` (default) retries what may pass again; N retries every failure up to N times |
diff --git a/docs/progmasoft-runner.md b/docs/progmasoft-runner.md
index 8134ae18..19d1e06d 100644
--- a/docs/progmasoft-runner.md
+++ b/docs/progmasoft-runner.md
@@ -274,6 +274,17 @@ accident. `Progmasoft::Catch3::TestTemporaryDirectory()` in
The directory is removed when the attempt passes and kept when it fails;
the report says where.
+## Environment variables
+
+Workers, and the processes the runner starts for itself, inherit its
+environment without the variables whose names look like secrets: those that
+contain, in any case, `TOKEN`, `SECRET`, `PASSWORD`, `PASSWD`, `CREDENTIAL`,
+`API_KEY`, `APIKEY`, `PRIVATE_KEY` or `ACCESS_KEY`. A test that needs one
+gets it with `--pass-env=NAME`, which can be repeated, or `"pass-env"` in
+the project configuration. The runner itself starts with the whole
+environment, so code that runs before the first worker starts still sees
+them.
+
## What a failure report says
@@ -376,7 +387,7 @@ as on the command line, with the key named in the message:
| a whole number or a string | `retries` (`"auto"`, `"off"`), `seed` (`"random"`) |
| a number | `timeout` (seconds), `benchmark-confidence-interval` |
| a path, relative to the file | `junit`, `json`, `trace`, `shard-durations`; `history`, or `false` for none |
-| an array of strings | `tests` (test specs), `warn` |
+| an array of strings | `tests` (test specs), `warn`, `pass-env` |
| `"quiet"`, `"normal"` or `"verbose"` | `verbosity` |
`"version": 1` is required; `"$schema"` may name a schema for editors.
diff --git a/src/Progmasoft/Runner/Options.cpp b/src/Progmasoft/Runner/Options.cpp
index 383ed68d..13cf4be7 100644
--- a/src/Progmasoft/Runner/Options.cpp
+++ b/src/Progmasoft/Runner/Options.cpp
@@ -361,6 +361,17 @@ namespace Progmasoft::Catch3::Runner
options.isolate = true;
return Status::Ok();
}},
+ {"pass-env",
+ '\0',
+ Takes::kValue,
+ "NAME",
+ "Running",
+ "give the tests the variable NAME, which they do not get "
+ "because its name looks like a secret (repeatable)",
+ [](Options &options, const std::optional<std::string> &value) {
+ options.passedEnvironment.push_back(*value);
+ return Status::Ok();
+ }},
{"timeout",
'\0',
Takes::kValue,
diff --git a/src/Progmasoft/Runner/Options.hpp b/src/Progmasoft/Runner/Options.hpp
index 35e6c302..256f5ac7 100644
--- a/src/Progmasoft/Runner/Options.hpp
+++ b/src/Progmasoft/Runner/Options.hpp
@@ -138,6 +138,8 @@ namespace Progmasoft::Catch3::Runner
unsigned jobs = 0;
bool inProcess = false;
bool isolate = false;
+ /// Variables the tests keep although their names look like secrets.
+ std::vector<std::string> passedEnvironment;
/// The per-test limit; zero disables it.
std::chrono::milliseconds timeout{0};
bool timeoutGiven = false;
diff --git a/src/Progmasoft/Runner/Process.cpp b/src/Progmasoft/Runner/Process.cpp
index c1d01302..b9463e94 100644
--- a/src/Progmasoft/Runner/Process.cpp
+++ b/src/Progmasoft/Runner/Process.cpp
@@ -1,9 +1,12 @@
// Copyright (c) 2026 Progmasoft.
// SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-2.0
#include <algorithm>
+#include <cctype>
#include <cstdio>
#include <cstdlib>
#include <cstring>
+#include <iterator>
+#include <string_view>
#include <thread>
#include "Process.hpp"
@@ -39,8 +42,64 @@ extern char **environ;
namespace Progmasoft::Catch3::Runner
{
+ bool
+ IsSecretVariable(std::string_view name)
+ {
+ std::string upper;
+ upper.reserve(name.size());
+ for (const char character : name)
+ {
+ upper.push_back(character >= 'a' && character <= 'z'
+ ? static_cast<char>(character - 'a' + 'A')
+ : character);
+ }
+ constexpr std::string_view kSecretWords[] = {
+ "TOKEN", "SECRET", "PASSWORD", "PASSWD", "CREDENTIAL",
+ "API_KEY", "APIKEY", "PRIVATE_KEY", "ACCESS_KEY",
+ };
+ return std::any_of(std::begin(kSecretWords),
+ std::end(kSecretWords),
+ [&](std::string_view word) {
+ return upper.find(word) != std::string::npos;
+ });
+ }
+
namespace
{
+ /// Whether a variable of this process stays out of a child: a secret
+ /// that the options do not keep. Windows spells names in any case.
+ bool
+ Withheld(std::string_view name, const std::vector<std::string> &kept)
+ {
+ if (!IsSecretVariable(name))
+ {
+ return false;
+ }
+ return std::none_of(
+ kept.begin(),
+ kept.end(),
+ [&](const std::string &keptName) {
+ if (keptName.size() != name.size())
+ {
+ return false;
+ }
+#if defined(_WIN32)
+ return std::equal(
+ keptName.begin(),
+ keptName.end(),
+ name.begin(),
+ [](char left, char right) {
+ return std::toupper(
+ static_cast<unsigned char>(left))
+ == std::toupper(
+ static_cast<unsigned char>(right));
+ });
+#else
+ return keptName == name;
+#endif
+ });
+ }
+
#if defined(_WIN32)
HANDLE
AsHandle(std::intptr_t value) noexcept
@@ -175,7 +234,8 @@ namespace Progmasoft::Catch3::Runner
std::wstring
EnvironmentBlock(
- const std::vector<std::pair<std::string, std::string>> &additions)
+ const std::vector<std::pair<std::string, std::string>> &additions,
+ const std::vector<std::string> &kept)
{
std::vector<std::wstring> entries;
wchar_t *current = GetEnvironmentStringsW();
@@ -184,6 +244,20 @@ namespace Progmasoft::Catch3::Runner
for (const wchar_t *entry = current; *entry != L'\0';
entry += wcslen(entry) + 1)
{
+ // A leading '=' marks the hidden per-drive variables.
+ const std::wstring_view text{ entry };
+ const auto equals = text.find(L'=', 1);
+ std::string name;
+ for (const wchar_t character : text.substr(0, equals))
+ {
+ name.push_back(character < 0x80
+ ? static_cast<char>(character)
+ : '?');
+ }
+ if (Withheld(name, kept))
+ {
+ continue;
+ }
entries.emplace_back(entry);
}
FreeEnvironmentStringsW(current);
@@ -634,7 +708,8 @@ namespace Progmasoft::Catch3::Runner
}
AppendQuotedArgument(commandLine, Widen(options.arguments[index]));
}
- std::wstring environment = EnvironmentBlock(options.environment);
+ std::wstring environment
+ = EnvironmentBlock(options.environment, options.keptEnvironment);
const std::wstring directory = options.workingDirectory.wstring();
PROCESS_INFORMATION information{};
@@ -957,6 +1032,12 @@ namespace Progmasoft::Catch3::Runner
entry != nullptr && *entry != nullptr;
++entry)
{
+ const std::string_view text{ *entry };
+ if (Withheld(text.substr(0, text.find('=')),
+ options.keptEnvironment))
+ {
+ continue;
+ }
environmentStorage.emplace_back(*entry);
}
for (const auto &[name, value] : options.environment)
diff --git a/src/Progmasoft/Runner/Process.hpp b/src/Progmasoft/Runner/Process.hpp
index 733bdbf7..3e0a1dd2 100644
--- a/src/Progmasoft/Runner/Process.hpp
+++ b/src/Progmasoft/Runner/Process.hpp
@@ -121,6 +121,9 @@ namespace Progmasoft::Catch3::Runner
std::vector<std::string> arguments;
/// Variables added to, or replacing, those of this process.
std::vector<std::pair<std::string, std::string>> environment;
+ /// Variables of this process the child keeps although their names
+ /// look like secrets; see `IsSecretVariable`.
+ std::vector<std::string> keptEnvironment;
/// The working directory; empty keeps the current one.
std::filesystem::path workingDirectory;
bool searchPath = false;
@@ -230,6 +233,12 @@ namespace Progmasoft::Catch3::Runner
[[nodiscard]] unsigned
HardwareThreads() noexcept;
+ /// Whether a variable of this name holds a secret that a child does not
+ /// inherit: its name contains, in any case, TOKEN, SECRET, PASSWORD,
+ /// PASSWD, CREDENTIAL, API_KEY, APIKEY, PRIVATE_KEY or ACCESS_KEY.
+ [[nodiscard]] bool
+ IsSecretVariable(std::string_view name);
+
/// The value of an environment variable; empty when it is not set.
[[nodiscard]] std::string
ReadEnvironment(const char *name);
diff --git a/src/Progmasoft/Runner/ProjectConfig.cpp b/src/Progmasoft/Runner/ProjectConfig.cpp
index e382b5c5..7a466f34 100644
--- a/src/Progmasoft/Runner/ProjectConfig.cpp
+++ b/src/Progmasoft/Runner/ProjectConfig.cpp
@@ -68,6 +68,7 @@ namespace Progmasoft::Catch3::Runner
{ "jobs", ValueKind::kCount },
{ "in-process", ValueKind::kFlag },
{ "isolate", ValueKind::kFlag },
+ { "pass-env", ValueKind::kWords },
{ "timeout", ValueKind::kNumber },
{ "adaptive-timeout",
ValueKind::kNegated,
diff --git a/src/Progmasoft/Runner/Scheduler.cpp b/src/Progmasoft/Runner/Scheduler.cpp
index 4637a851..56a2aa36 100644
--- a/src/Progmasoft/Runner/Scheduler.cpp
+++ b/src/Progmasoft/Runner/Scheduler.cpp
@@ -994,6 +994,7 @@ namespace Progmasoft::Catch3::Runner
const std::size_t id = ++workersStarted_;
child.environment.emplace_back("CATCH3_WORKER",
std::to_string(id));
+ child.keptEnvironment = options_.passedEnvironment;
auto process = ChildProcess::Start(child);
if (!process)
{
diff --git a/tests/Progmasoft/Runner.tests.cpp b/tests/Progmasoft/Runner.tests.cpp
index 7b7d032e..66586f77 100644
--- a/tests/Progmasoft/Runner.tests.cpp
+++ b/tests/Progmasoft/Runner.tests.cpp
@@ -292,3 +292,43 @@ TEST_CASE("JUnit and trace outputs are written", "[progmasoft][runner]")
REQUIRE(traceJson);
CHECK((*traceJson)["traceEvents"].Items().size() >= 3);
}
+
+TEST_CASE("A child does not inherit the secrets of the runner",
+ "[progmasoft][runner]")
+{
+ CHECK(Runner::IsSecretVariable("GITHUB_TOKEN"));
+ CHECK(Runner::IsSecretVariable("aws_secret_access_key"));
+ CHECK(Runner::IsSecretVariable("DB_PASSWORD"));
+ CHECK(Runner::IsSecretVariable("NPM_ApiKey"));
+ CHECK_FALSE(Runner::IsSecretVariable("PATH"));
+ CHECK_FALSE(Runner::IsSecretVariable("XAUTHORITY"));
+
+ Runner::ChildOptions options;
+#if defined(_WIN32)
+ REQUIRE(_putenv_s("CATCH3_TEST_TOKEN", "withheld") == 0);
+ REQUIRE(_putenv_s("CATCH3_TEST_PLAIN", "kept") == 0);
+ options.arguments = { "cmd.exe", "/d", "/c", "set" };
+#else
+ REQUIRE(setenv("CATCH3_TEST_TOKEN", "withheld", 1) == 0);
+ REQUIRE(setenv("CATCH3_TEST_PLAIN", "kept", 1) == 0);
+ options.arguments = { "env" };
+#endif
+ options.searchPath = true;
+ auto printed = Runner::RunAndCapture(options);
+ REQUIRE(printed);
+ CHECK(printed->output.find("CATCH3_TEST_TOKEN") == std::string::npos);
+ CHECK_THAT(printed->output, ContainsSubstring("CATCH3_TEST_PLAIN=kept"));
+
+ options.keptEnvironment = { "CATCH3_TEST_TOKEN" };
+ printed = Runner::RunAndCapture(options);
+ REQUIRE(printed);
+ CHECK_THAT(printed->output,
+ ContainsSubstring("CATCH3_TEST_TOKEN=withheld"));
+#if defined(_WIN32)
+ CHECK(_putenv_s("CATCH3_TEST_TOKEN", "") == 0);
+ CHECK(_putenv_s("CATCH3_TEST_PLAIN", "") == 0);
+#else
+ CHECK(unsetenv("CATCH3_TEST_TOKEN") == 0);
+ CHECK(unsetenv("CATCH3_TEST_PLAIN") == 0);
+#endif
+}