Skip to content

Commit a7ace4a

Browse files
dionhaefnerxalelax
andauthored
fix: Ensure container users always exist (#427)
#### Relevant issue or PR Fixes #425 #### Description of changes - Add `addmeplease` executable to every container that adds the current user + group to `/etc/passwd` and a few other system files. - This ensures user code can now rely on the fact that the executing OS user always has a proper uid, gid, home directory, and shell. We achieve this by compiling a simple C binary that we can run as root with `setuid` even from non-privileged accounts. #### Testing done Tested on reproducer from #425, and added new e2e test on CI. --------- Co-authored-by: Alessandro Angioi <[email protected]>
1 parent 21b7351 commit a7ace4a

File tree

9 files changed

+295
-8
lines changed

9 files changed

+295
-8
lines changed
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# Copyright 2025 Pasteur Labs. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
# Tesseract API module for userhandler
5+
# Generated by tesseract 1.2.1.dev14+g667850bb1.d20251217 on 2025-12-18T14:34:34.613891
6+
7+
8+
import os
9+
import pwd
10+
11+
from pydantic import BaseModel
12+
13+
#
14+
# Schemas
15+
#
16+
17+
18+
class InputSchema(BaseModel):
19+
pass
20+
21+
22+
class OutputSchema(BaseModel):
23+
uid: int
24+
gid: int
25+
username: str
26+
home: str
27+
shell: str
28+
29+
30+
#
31+
# Required endpoints
32+
#
33+
34+
35+
def apply(inputs: InputSchema) -> OutputSchema:
36+
"""A simple Tesseract that fails if the current OS user doesn't have a valid /etc/passwd entry."""
37+
# Get current user ID
38+
uid = os.getuid()
39+
gid = os.getgid()
40+
41+
# Try to get the passwd entry for the current user
42+
try:
43+
pw_entry = pwd.getpwuid(uid)
44+
except KeyError as exc:
45+
raise RuntimeError(
46+
f"Current user (UID: {uid}) does not have an entry in /etc/passwd. "
47+
"This container should have run addmeplease in the entrypoint."
48+
) from exc
49+
50+
if pw_entry.pw_gid != gid:
51+
raise RuntimeError(
52+
f"Current group (GID: {gid}) does not match expected group from /etc/passwd ({pw_entry.pw_gid})."
53+
)
54+
55+
# Return the user information
56+
return OutputSchema(
57+
uid=pw_entry.pw_uid,
58+
gid=pw_entry.pw_gid,
59+
username=pw_entry.pw_name,
60+
home=pw_entry.pw_dir,
61+
shell=pw_entry.pw_shell,
62+
)
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Tesseract configuration file
2+
# Generated by tesseract 1.2.1.dev14+g667850bb1.d20251217 on 2025-12-18T14:34:34.613891
3+
4+
name: "userhandler"
5+
version: "unknown"
6+
description: "A simple Tesseract that fails if the current OS user doesn't have a valid /etc/passwd entry"
7+
8+
build_config:
9+
target_platform: "native"

tesseract_core/sdk/engine.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -214,11 +214,13 @@ def prepare_build_context(
214214

215215
template_dir = get_template_dir()
216216

217+
extra_files = [template_dir / "entrypoint.sh", template_dir / "addmeplease.c"]
218+
217219
requirement_config = user_config.build_config.requirements
218-
copy(
219-
template_dir / requirement_config._build_script,
220-
context_dir / "__tesseract_source__" / requirement_config._build_script,
221-
)
220+
extra_files.append(template_dir / requirement_config._build_script)
221+
222+
for path in extra_files:
223+
copy(path, context_dir / path.relative_to(template_dir))
222224

223225
# When building from a requirements.txt we support local dependencies.
224226
# We separate local dep. lines from the requirements.txt and copy the

tesseract_core/sdk/templates/Dockerfile.base

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
# Copyright 2025 Pasteur Labs. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
14
# Stage 1: Build Python environment and install requirements
25
{% if config.build_config.target_platform.strip() == "native" %}
36
FROM {{ config.build_config.base_image }} AS build_stage
@@ -22,8 +25,19 @@ fi
2225
RUN apt-get update && apt-get install -y --no-install-recommends \
2326
git \
2427
ssh \
28+
# Needed to compile addmeplease, and nice to have in general
29+
gcc \
30+
libc6-dev \
2531
&& rm -rf /var/lib/apt/lists/*
2632

33+
# Copy and compile addmeplease
34+
COPY "addmeplease.c" /tmp/addmeplease.c
35+
RUN gcc -o /bin/addmeplease /tmp/addmeplease.c && \
36+
rm /tmp/addmeplease.c && \
37+
chown root:root /bin/addmeplease && \
38+
chmod 4755 /bin/addmeplease && \
39+
ls -l /bin/addmeplease
40+
2741
{% if config.build_config.extra_packages %}
2842
# Install extra packages
2943
RUN apt-get update && apt-get install -y --no-install-recommends \
@@ -40,7 +54,7 @@ RUN mkdir -p -m 0700 ~/.ssh && ssh-keyscan github.com >> ~/.ssh/known_hosts
4054
WORKDIR /tmp/build
4155
COPY {{ tesseract_runtime_location }} ./tesseract_runtime/
4256
COPY {{ tesseract_source_directory }}/{{ config.build_config.requirements._filename }} ./
43-
COPY {{ tesseract_source_directory }}/{{ config.build_config.requirements._build_script }} ./
57+
COPY {{ config.build_config.requirements._build_script }} ./
4458
COPY local_requirements/ ./local_requirements
4559

4660
# Build a python venv from python provider build scripts.
@@ -79,6 +93,9 @@ ENV TESSERACT_NAME="{{ config.name | replace('"', '\\"') | replace('\n', '\\n')
7993

8094
# Copy only necessary files
8195
COPY --from=build_stage /python-env /python-env
96+
COPY --from=build_stage /bin/addmeplease /bin/addmeplease
97+
COPY "entrypoint.sh" /tesseract/entrypoint.sh
98+
RUN chmod 555 /tesseract/entrypoint.sh
8299
COPY "{{ tesseract_source_directory }}/tesseract_api.py" ${TESSERACT_API_PATH}
83100
RUN chmod 444 ${TESSERACT_API_PATH}
84101

@@ -102,14 +119,17 @@ RUN mkdir -p /tesseract/input_data /tesseract/output_data && \
102119
chmod 777 /tesseract/output_data
103120
VOLUME ["/tesseract/input_data", "/tesseract/output_data"]
104121

105-
ENV HOME=/tesseract
106122
ENV TESSERACT_INPUT_PATH="/tesseract/input_data"
107123
ENV TESSERACT_OUTPUT_PATH="/tesseract/output_data"
108124

125+
# Drop to random non-root user to prevent exploits
126+
# (this way, we never start the container as root unless specifically requested)
127+
USER 9999:9999
128+
109129
# Final sanity check to ensure the runtime is installed and tesseract_api.py is valid
110130
{% if not config.build_config.skip_checks %}
111-
RUN _TESSERACT_IS_BUILDING=1 tesseract-runtime check
131+
RUN _TESSERACT_IS_BUILDING=1 /tesseract/entrypoint.sh tesseract-runtime check
112132
{% endif %}
113133

114-
ENTRYPOINT ["tesseract-runtime"]
134+
ENTRYPOINT ["/tesseract/entrypoint.sh", "tesseract-runtime"]
115135
CMD ["--help"]
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
// Copyright 2025 Pasteur Labs. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
/*
5+
* addmeplease - Create user/group entries for non-privileged container users
6+
*
7+
* This program allows a non-privileged user to create their own entry in
8+
* /etc/passwd and /etc/group. This is necessary because:
9+
*
10+
* 1. Containers run as non-root users (e.g., USER 501:501) for security
11+
* 2. Some applications require an entry in /etc/passwd to function properly
12+
* 3. The container entrypoint runs as the non-privileged user and cannot
13+
* directly modify /etc/passwd without elevated privileges
14+
*
15+
* This binary is compiled and installed with setuid root (chmod 4755), which
16+
* allows it to temporarily elevate privileges to modify /etc/passwd and
17+
* /etc/group, then immediately drop privileges back to the calling user.
18+
*
19+
* Note: setuid only works with compiled binaries, not shell scripts. This is
20+
* why this must be implemented in C rather than as a bash script.
21+
*/
22+
23+
#define _GNU_SOURCE
24+
#include <stdio.h>
25+
#include <stdlib.h>
26+
#include <string.h>
27+
#include <unistd.h>
28+
#include <sys/types.h>
29+
#include <pwd.h>
30+
#include <grp.h>
31+
#include <errno.h>
32+
#include <fcntl.h>
33+
#include <sys/stat.h>
34+
35+
#define PASSWD_FILE "/etc/passwd"
36+
#define GROUP_FILE "/etc/group"
37+
#define SHADOW_FILE "/etc/shadow"
38+
#define GSHADOW_FILE "/etc/gshadow"
39+
40+
int group_exists(gid_t gid) {
41+
struct group *grp = getgrgid(gid);
42+
return (grp != NULL);
43+
}
44+
45+
int user_exists(uid_t uid) {
46+
struct passwd *pwd = getpwuid(uid);
47+
return (pwd != NULL);
48+
}
49+
50+
int append_to_file(const char *filepath, const char *line) {
51+
int fd = open(filepath, O_WRONLY | O_APPEND | O_CREAT, 0644);
52+
if (fd < 0) {
53+
return -1;
54+
}
55+
56+
size_t len = strlen(line);
57+
ssize_t written = write(fd, line, len);
58+
close(fd);
59+
60+
return (written == (ssize_t)len) ? 0 : -1;
61+
}
62+
63+
int create_group(gid_t gid, const char *groupname) {
64+
if (group_exists(gid)) {
65+
return 0;
66+
}
67+
68+
// Format: groupname:x:gid:
69+
char group_line[256];
70+
snprintf(group_line, sizeof(group_line), "%s:x:%d:\n", groupname, gid);
71+
72+
if (append_to_file(GROUP_FILE, group_line) != 0) {
73+
fprintf(stderr, "addmeplease: Failed to add group to %s: %s\n",
74+
GROUP_FILE, strerror(errno));
75+
return -1;
76+
}
77+
78+
// Add to gshadow if it exists
79+
char gshadow_line[256];
80+
snprintf(gshadow_line, sizeof(gshadow_line), "%s:!::\n", groupname);
81+
82+
struct stat st;
83+
if (stat(GSHADOW_FILE, &st) == 0) {
84+
append_to_file(GSHADOW_FILE, gshadow_line);
85+
}
86+
87+
return 0;
88+
}
89+
90+
int create_user(uid_t uid, gid_t gid, const char *username) {
91+
if (user_exists(uid)) {
92+
return 0;
93+
}
94+
95+
// Format: username:x:uid:gid:comment:home:shell
96+
char passwd_line[512];
97+
snprintf(passwd_line, sizeof(passwd_line),
98+
"%s:x:%d:%d:Tesseract User:/tesseract:/bin/bash\n",
99+
username, uid, gid);
100+
101+
if (append_to_file(PASSWD_FILE, passwd_line) != 0) {
102+
fprintf(stderr, "addmeplease: Failed to add user to %s: %s\n",
103+
PASSWD_FILE, strerror(errno));
104+
return -1;
105+
}
106+
107+
// Add to shadow file with locked password
108+
// Format: username:!:lastchanged:min:max:warn:inactive:expire:
109+
// We use ! for locked password and 0 for lastchanged (epoch)
110+
char shadow_line[256];
111+
snprintf(shadow_line, sizeof(shadow_line), "%s:!:0:0:99999:7:::\n", username);
112+
113+
struct stat st;
114+
if (stat(SHADOW_FILE, &st) == 0) {
115+
// Shadow file typically has restricted permissions
116+
int fd = open(SHADOW_FILE, O_WRONLY | O_APPEND);
117+
if (fd >= 0) {
118+
size_t len = strlen(shadow_line);
119+
write(fd, shadow_line, len);
120+
close(fd);
121+
}
122+
}
123+
124+
return 0;
125+
}
126+
127+
int main(void) {
128+
uid_t real_uid = getuid();
129+
gid_t real_gid = getgid();
130+
131+
// If running as root, nothing to do
132+
if (real_uid == 0) {
133+
return 0;
134+
}
135+
136+
// Check if user already exists
137+
if (user_exists(real_uid)) {
138+
return 0;
139+
}
140+
141+
// We need root privileges to modify /etc/passwd and /etc/group
142+
// This is why the binary needs setuid root
143+
if (seteuid(0) != 0) {
144+
fprintf(stderr, "addmeplease: Failed to elevate privileges: %s\n", strerror(errno));
145+
fprintf(stderr, "addmeplease: This binary must be setuid root (chmod 4755)\n");
146+
return 1;
147+
}
148+
149+
// Create group if it doesn't exist
150+
if (create_group(real_gid, "tesseract-group") != 0) {
151+
fprintf(stderr, "addmeplease: Warning: Failed to create group\n");
152+
// Continue anyway, the GID might exist with a different name
153+
}
154+
155+
// Create user with current UID/GID
156+
if (create_user(real_uid, real_gid, "tesseract-user") != 0) {
157+
fprintf(stderr, "addmeplease: Warning: Failed to create user\n");
158+
// Continue anyway
159+
}
160+
161+
// Drop privileges back to the original user
162+
if (seteuid(real_uid) != 0) {
163+
fprintf(stderr, "addmeplease: Failed to drop privileges: %s\n", strerror(errno));
164+
return 1;
165+
}
166+
167+
return 0;
168+
}

tesseract_core/sdk/templates/build_conda_venv.sh

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
#!/bin/bash
22

3+
# Copyright 2025 Pasteur Labs. All Rights Reserved.
4+
# SPDX-License-Identifier: Apache-2.0
5+
36
set -e # Exit immediately if a command exits with a non-zero status
47

58
conda env create --file tesseract_environment.yaml -p /python-env --quiet

tesseract_core/sdk/templates/build_pip_venv.sh

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
#!/bin/bash
22

3+
# Copyright 2025 Pasteur Labs. All Rights Reserved.
4+
# SPDX-License-Identifier: Apache-2.0
5+
36
set -e # Exit immediately if a command exits with a non-zero status
47

58
uv venv /python-env
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
#!/bin/sh
2+
3+
# Copyright 2025 Pasteur Labs. All Rights Reserved.
4+
# SPDX-License-Identifier: Apache-2.0
5+
6+
/bin/addmeplease
7+
exec "$@"

tests/endtoend_tests/test_examples.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -791,6 +791,19 @@ class Config:
791791
"tesseractreference": Config( # Can't test requests standalone; needs target Tesseract. Covered in separate test.
792792
test_with_random_inputs=False, sample_requests=[]
793793
),
794+
"userhandling": Config(
795+
test_with_random_inputs=False,
796+
sample_requests=[
797+
SampleRequest(
798+
endpoint="apply",
799+
payload={"inputs": {}},
800+
output_contains_pattern=[
801+
'"home":"/tesseract"',
802+
'"username":"tesseract-user"',
803+
],
804+
)
805+
],
806+
),
794807
}
795808

796809

0 commit comments

Comments
 (0)