-
Notifications
You must be signed in to change notification settings - Fork 9
fix: restrict /proc/self/environ and docker-compose.yml secret exposure #647
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ccc32da
ef81ba7
ed74c81
a92d57a
0a5631a
e767a90
37fe454
24c55a2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,13 +1,26 @@ | ||
| /** | ||
| * One-Shot Token LD_PRELOAD Library | ||
| * | ||
| * Intercepts getenv() calls for sensitive token environment variables. | ||
| * On first access, caches the value in memory and unsets from environment. | ||
| * Subsequent calls return the cached value, so the process can read tokens | ||
| * multiple times while /proc/self/environ no longer exposes them. | ||
| * Protects sensitive token environment variables from exposure via | ||
| * /proc/self/environ and limits access via getenv(). | ||
| * | ||
| * When loaded, the library constructor reads cached token values from | ||
| * AWF_TOKEN_CACHE_FILE (written by entrypoint.sh) and populates an | ||
| * in-memory cache. The file is NOT deleted by the library because the | ||
| * constructor runs in every process in the exec chain (capsh → gosu → | ||
| * user command), and each exec() resets static data. The file is | ||
| * cleaned up by the EXIT trap in entrypoint.sh instead. | ||
| * | ||
| * The sensitive variables are never present in the process environment | ||
| * (entrypoint.sh unsets them before exec), so /proc/self/environ is clean. | ||
| * Subsequent getenv() calls return the cached values from memory. | ||
| * | ||
| * Fallback: If no cache file is found, tokens are read from the environment | ||
| * on library load, cached, and unset (original behavior). | ||
| * | ||
| * Configuration: | ||
| * AWF_ONE_SHOT_TOKENS - Comma-separated list of token names to protect | ||
| * AWF_TOKEN_CACHE_FILE - Path to the token cache file (set by entrypoint.sh) | ||
| * If not set, uses built-in defaults | ||
| * | ||
| * Compile: gcc -shared -fPIC -o one-shot-token.so one-shot-token.c -ldl | ||
|
|
@@ -20,6 +33,7 @@ | |
| #include <string.h> | ||
| #include <pthread.h> | ||
| #include <stdio.h> | ||
| #include <unistd.h> | ||
| #include <ctype.h> | ||
|
|
||
| /* Default sensitive token environment variable names */ | ||
|
|
@@ -31,6 +45,7 @@ static const char *DEFAULT_SENSITIVE_TOKENS[] = { | |
| "GITHUB_API_TOKEN", | ||
| "GITHUB_PAT", | ||
| "GH_ACCESS_TOKEN", | ||
| "GITHUB_PERSONAL_ACCESS_TOKEN", | ||
| /* OpenAI tokens */ | ||
| "OPENAI_API_KEY", | ||
| "OPENAI_KEY", | ||
|
|
@@ -75,6 +90,10 @@ static char *(*real_secure_getenv)(const char *name) = NULL; | |
| static pthread_once_t getenv_init_once = PTHREAD_ONCE_INIT; | ||
| static pthread_once_t secure_getenv_init_once = PTHREAD_ONCE_INIT; | ||
|
|
||
| /* Forward declarations */ | ||
| static int get_token_index(const char *name); | ||
| static const char *format_token_value(const char *value); | ||
|
|
||
| /* Initialize the real getenv pointer (called exactly once via pthread_once) */ | ||
| static void init_real_getenv_once(void) { | ||
| real_getenv = dlsym(RTLD_NEXT, "getenv"); | ||
|
|
@@ -183,6 +202,114 @@ static void init_token_list(void) { | |
|
|
||
| tokens_initialized = 1; | ||
| } | ||
|
|
||
| /** | ||
| * Load cached token values from AWF_TOKEN_CACHE_FILE. | ||
| * | ||
| * The file format is one NAME=VALUE per line. The file is NOT deleted here | ||
| * because the LD_PRELOAD constructor runs in every process in the exec chain | ||
| * (capsh → gosu → user command), and each exec() creates a fresh process | ||
| * image with reset static data. Deleting here would cause subsequent processes | ||
| * to lose access to the cached tokens. The file is cleaned up by the EXIT | ||
| * trap in entrypoint.sh instead. The file is created with mode 0600 and | ||
| * owned by awfuser, so it is not readable by other users. | ||
| * | ||
| * Must be called with token_mutex held and after init_token_list(). | ||
| */ | ||
| static void load_token_cache_file(void) { | ||
| const char *cache_path = real_getenv("AWF_TOKEN_CACHE_FILE"); | ||
| if (cache_path == NULL || cache_path[0] == '\0') { | ||
| return; | ||
| } | ||
|
|
||
| FILE *f = fopen(cache_path, "r"); | ||
| if (f == NULL) { | ||
| fprintf(stderr, "[one-shot-token] WARNING: Could not open token cache file: %s\n", cache_path); | ||
| return; | ||
| } | ||
|
Comment on lines
+227
to
+229
|
||
|
|
||
| char line[8192]; | ||
| int loaded = 0; | ||
| while (fgets(line, sizeof(line), f) != NULL) { | ||
| /* Strip trailing newline */ | ||
| size_t len = strlen(line); | ||
| if (len > 0 && line[len - 1] == '\n') { | ||
| line[len - 1] = '\0'; | ||
| len--; | ||
| } | ||
|
|
||
| /* Find the '=' separator */ | ||
| char *eq = strchr(line, '='); | ||
| if (eq == NULL || eq == line) continue; | ||
|
|
||
| *eq = '\0'; | ||
| const char *name = line; | ||
| const char *value = eq + 1; | ||
|
||
|
|
||
| /* Find if this name matches a sensitive token (first-wins for duplicates) */ | ||
| int idx = get_token_index(name); | ||
| if (idx >= 0 && !token_accessed[idx]) { | ||
| token_cache[idx] = strdup(value); | ||
| if (token_cache[idx] != NULL) { | ||
| token_accessed[idx] = 1; | ||
| loaded++; | ||
| fprintf(stderr, "[one-shot-token] Loaded cached token %s from file\n", name); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| fclose(f); | ||
|
|
||
| if (loaded > 0) { | ||
| fprintf(stderr, "[one-shot-token] Loaded %d token(s) from cache file\n", loaded); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Library constructor - runs when the library is loaded (before main()). | ||
| * | ||
| * If AWF_TOKEN_CACHE_FILE is set (by entrypoint.sh), loads cached token | ||
| * values from the file. The sensitive variables are never present in | ||
| * /proc/self/environ because entrypoint.sh unsets them before exec. | ||
| * | ||
| * If no cache file exists, tokens remain in the environment and will be | ||
| * cached + unset on first getenv() call (original fallback behavior). | ||
| */ | ||
| __attribute__((constructor)) | ||
| static void one_shot_token_init(void) { | ||
| /* Initialize the real getenv pointer first */ | ||
| init_real_getenv_once(); | ||
|
|
||
| pthread_mutex_lock(&token_mutex); | ||
| if (!tokens_initialized) { | ||
| init_token_list(); | ||
| } | ||
|
|
||
| /* Load tokens from cache file if available (set by entrypoint.sh) */ | ||
| load_token_cache_file(); | ||
|
|
||
| /* Eagerly cache any remaining sensitive tokens still in the environment | ||
| * (fallback for when no cache file was used) */ | ||
| for (int i = 0; i < num_tokens; i++) { | ||
| if (!token_accessed[i]) { | ||
| char *value = real_getenv(sensitive_tokens[i]); | ||
| if (value != NULL) { | ||
| token_cache[i] = strdup(value); | ||
| if (token_cache[i] != NULL) { | ||
| unsetenv(sensitive_tokens[i]); | ||
| fprintf(stderr, "[one-shot-token] Token %s eagerly cached and scrubbed from environ\n", | ||
| sensitive_tokens[i]); | ||
| } | ||
| token_accessed[i] = 1; | ||
| } | ||
| } | ||
| } | ||
| pthread_mutex_unlock(&token_mutex); | ||
|
|
||
| fprintf(stderr, "[one-shot-token] Library initialized: %d token(s) protected, /proc/self/environ scrubbed\n", | ||
| num_tokens); | ||
| } | ||
|
|
||
| /* Ensure real_getenv is initialized (thread-safe) */ | ||
| static void init_real_getenv(void) { | ||
| pthread_once(&getenv_init_once, init_real_getenv_once); | ||
|
|
||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The token cache file is created at
/host${TOKEN_CACHE_FILE}(/host/tmp/.awf-token-cache-$$) from the chroot context, but the cleanup command uses${TOKEN_CACHE_FILE}without the /host prefix. Since the trap runs within the chroot, this path should work, but if the cleanup trap executes after the chroot context has changed or if there's an error, the file might not be deleted. Consider making the cleanup path more explicit or documenting why the /host prefix is intentionally omitted in the cleanup command.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good observation. In e767a90 this is clarified with a comment: the cleanup trap uses
${TOKEN_CACHE_FILE}(without/hostprefix) because it runs inside the chroot where the file is at that path. The chrootexecchain always runs within the chroot context, so the path is correct.