Security Checklist¶
This page provides security best practices for handling secrets and sensitive data in CLI applications built with argclass.
Quick Checklist¶
Use
argclass.Secret()for sensitive argumentsSanitize secrets after parsing (use
parse_args(sanitize_secrets=True)or callparser.sanitize_env())Use
{secret!r}in f-strings (never{secret})Prefer environment variables over config files for secrets
If using config files for secrets, verify
chmod 600permissionsUse
logging.info("value: %s", secret)(not f-strings) for safe loggingNever pass secrets via command-line arguments in production
Threat Model¶
What sanitize_env() Protects Against¶
sanitize_env() only prevents accidental secret leakage via environment
variable inheritance to child processes. It removes environment variables from
the current process after parsing, so that subprocesses spawned afterward do not
inherit them.
There are two ways to sanitize:
parse_args(sanitize_secrets=True)- automatically removes only secret env vars during parsingsanitize_env()- removes all used env vars after parsingsanitize_env(only_secrets=True)- removes only secret env vars after parsing
Protected |
Not Protected |
|---|---|
Environment variable inheritance to child processes |
Secrets in process memory |
Accidental leakage to trusted tools |
Exfiltration by malicious code |
Defense-in-depth for well-behaved subprocesses |
Command-line argument visibility ( |
Secrets in config files, logs, crash dumps |
|
Network-based exfiltration |
|
Filesystem access by child processes |
What sanitize_env() Does NOT Do¶
Danger
sanitize_env() is NOT a sandbox.
It does not prevent malicious or compromised code from accessing secrets. Any code running in your process can read memory, inspect stack frames, or extract secrets before sanitization occurs. Do not run untrusted code.
If you need to run untrusted code, use proper isolation:
Containers (Docker, Podman) with restricted capabilities
Virtual machines for complete isolation
OS-level sandboxing (seccomp, AppArmor, SELinux)
Separate user accounts with minimal privileges
These are outside the scope of argclass.
Why Sanitization Is Not Enabled by Default¶
Sanitization is explicitly opt-in (sanitize_secrets=False by default) because
argclass cannot know how your application will be used:
Process lifecycle considerations:
Your application may re-execute itself (e.g., for privilege escalation via
sudo, or restarting with different permissions)Your application may
fork()withoutexec(), expecting child processes to inherit the full environmentYour application may be a wrapper that intentionally passes secrets to child processes
Your application may need environment variables for later phases of execution
Principle of least surprise:
A library should not implicitly modify global process state. Removing environment variables is a side effect that could break legitimate use cases.
For example, an application that re-executes itself with elevated privileges
(via sudo -E) expects the environment to be preserved. If sanitization were
automatic, the re-executed process would silently lose its secrets.
Explicit is better than implicit:
By requiring explicit opt-in, argclass ensures that:
Developers consciously decide when sanitization is appropriate
Code review can verify that sanitization matches the application’s needs
No unexpected behavior occurs in edge cases
Best practice:
Use sanitize_secrets=True for most applications—it removes only secret
environment variables while preserving non-secret configuration:
import os
import argclass
os.environ["SECRET_KEY"] = "secret"
os.environ["APP_PORT"] = "8080"
class Parser(argclass.Parser):
secret_key: str = argclass.Secret(env_var="SECRET_KEY")
port: int = argclass.Argument(env_var="APP_PORT")
parser = Parser()
parser.parse_args([], sanitize_secrets=True) # Recommended for most cases
assert "SECRET_KEY" not in os.environ # Secret removed
assert os.environ["APP_PORT"] == "8080" # Non-secret preserved
del os.environ["APP_PORT"]
Use sanitize_env() when you need to remove ALL configuration-related
environment variables (both secrets and non-secrets):
import os
import argclass
os.environ["SECRET_KEY"] = "secret"
os.environ["APP_PORT"] = "8080"
class Parser(argclass.Parser):
secret_key: str = argclass.Secret(env_var="SECRET_KEY")
port: int = argclass.Argument(env_var="APP_PORT")
parser = Parser()
parser.parse_args([])
parser.sanitize_env() # Removes all env vars used during parsing
assert "SECRET_KEY" not in os.environ
assert "APP_PORT" not in os.environ
Preventing Environment Leakage to Child Processes¶
The Problem: Environment Variable Inheritance¶
When your application spawns subprocesses, runs shell commands, or calls external tools, those processes inherit a copy of ALL environment variables— including your secrets.
Leakage scenario:
1. Your app reads DB_PASSWORD from environment
2. Your app calls subprocess.run(["backup-tool", ...])
3. backup-tool inherits DB_PASSWORD in its environment
4. Secret is exposed (intentionally or via logging/crash dumps)
Solution: Sanitize Environment for Trusted Subprocesses¶
This pattern is for cases where you run trusted subprocesses but want to prevent accidental secret inheritance:
import os
import subprocess
import argclass
os.environ["DB_PASSWORD"] = "secret123"
os.environ["API_KEY"] = "key456"
class Parser(argclass.Parser):
db_password: str = argclass.Secret(env_var="DB_PASSWORD")
api_key: str = argclass.Secret(env_var="API_KEY")
parser = Parser()
parser.parse_args([])
# Secrets are parsed and stored in parser
assert str(parser.db_password) == "secret123"
# BEFORE sanitizing: subprocess inherits the secret
leaked = subprocess.check_output(
"echo $DB_PASSWORD", shell=True, text=True
).strip()
assert leaked == "secret123" # Secret visible to child process
# Sanitize environment
parser.sanitize_env()
# AFTER sanitizing: subprocess cannot see the secret via env
clean_output = subprocess.check_output(
"echo $DB_PASSWORD", shell=True, text=True
).strip()
assert clean_output == "" # Environment variable removed
# Your application still has access to the parsed value
assert str(parser.db_password) == "secret123"
Recommended Flow¶
import argclass
import subprocess
class Parser(argclass.Parser):
api_key: str = argclass.Secret(env_var="API_KEY")
database_url: str = argclass.Secret(env_var="DATABASE_URL")
def main():
# Step 1: Parse arguments and sanitize secrets in one call
parser = Parser()
parser.parse_args(sanitize_secrets=True)
# Step 2: Extract secrets into local variables if needed
api_key = str(parser.api_key)
db_url = str(parser.database_url)
# Step 3: Use secrets in your application
connect_to_db(db_url)
# Step 4: Spawn trusted subprocesses (they won't inherit secrets)
subprocess.run(["backup-tool", "--compress"])
if __name__ == "__main__":
main()
Alternatively, sanitize manually after parsing:
def main():
parser = Parser()
parser.parse_args()
# Sanitize only secrets, keep other env vars
parser.sanitize_env(only_secrets=True)
# Or sanitize all used env vars
# parser.sanitize_env()
subprocess.run(["backup-tool", "--compress"])
Other Secret Leakage Channels¶
Even with sanitize_env(), secrets can leak through other channels:
Command-Line Arguments¶
Secrets in command-line arguments are visible to all users via ps:
# Anyone on the system can see this:
$ ps aux | grep python
user 1234 python app.py --password=supersecret
Mitigation: Never pass secrets via command-line arguments. Use environment variables or config files with restricted permissions.
Log Files¶
Secrets can be accidentally logged when using f-strings:
import logging
import argclass
api_key = argclass.SecretString("LEAK ME")
# WRONG: F-string evaluates str() before logging
logging.info(f"Connecting with key: {api_key}")
# RIGHT: %-formatting lets logging call repr()
logging.info("Connecting with key: %s", api_key)
Mitigation: Use logging.info("msg: %s", secret) instead of f-strings.
The logging module calls repr() on SecretString, which returns '******'.
Config Files¶
Config files persist on disk and may be readable by other users:
# Check permissions
$ ls -la config.ini
-rw-r--r-- 1 user user ... config.ini # WRONG: world-readable
Mitigation: Use chmod 600 for config files containing secrets. Prefer
environment variables for secrets.
Crash Dumps and Core Files¶
Secrets in memory may appear in crash dumps:
# Core dumps may contain secrets
$ ulimit -c unlimited # Enables core dumps
Mitigation: Disable core dumps in production (ulimit -c 0), or ensure
core dump directories have restricted permissions.
Process Memory¶
Any code running in your process can inspect memory:
import argclass
import os
class Parser(argclass.Parser):
api_key: str = argclass.Secret(env_var="API_KEY")
os.environ["API_KEY"] = "supersecret"
parser = Parser()
parser.parse_args([])
# Malicious code can extract secrets from parser object
secret_value = str(parser.api_key)
Mitigation: Do not run untrusted code. There is no library-level protection against code running in the same process.
SecretString Guarantees¶
What SecretString Protects Against¶
Scenario |
Protected? |
Details |
|---|---|---|
|
Yes |
Returns |
|
Yes |
Uses repr, shows |
|
Yes |
Logging uses repr, shows |
|
No |
Uses str, shows actual value |
|
No |
Uses str, shows actual value |
|
No |
F-string uses str before logging |
|
No |
Returns actual value (intended) |
Safe Logging¶
import argclass
import logging
class Parser(argclass.Parser):
password: str = argclass.Secret()
parser = Parser()
parser.parse_args(["--password", "supersecret"])
log_output = ""
# Add a custom logging handler to capture log output for
# this example will demonstrate safe logging practices.
class LogCaptureHandler(logging.Handler):
def emit(self, record):
global log_output
log_output += self.format(record) + "\n"
logger = logging.getLogger()
logger.setLevel(logging.INFO)
logger.addHandler(LogCaptureHandler())
# SAFE - logging with %s uses repr()
logging.info("Password: %s", parser.password)
assert "supersecret" not in log_output
# SAFE - f-string with !r also works
safe_fstring = f"Password: {parser.password!r}"
assert "supersecret" not in safe_fstring
assert "******" in safe_fstring
# UNSAFE - f-string without !r exposes the secret
assert f"Password: {parser.password}" == "Password: supersecret"
Recommended logging pattern:
import logging
from argclass import SecretString
secret = SecretString("api_key_value")
# SAFE: Use %-formatting, logging calls repr() on SecretString
# logging.info("Connecting with API key: %s", secret) # Shows '******'
Comparison Without Exposure¶
SecretString supports equality comparison without exposing the value:
from argclass import SecretString
secret1 = SecretString("password123")
secret2 = SecretString("password123")
# Comparisons work without exposing values
assert secret1 == secret2
assert secret1 == "password123"
# repr never exposes the value
assert repr(secret1) == "'******'"
Config File Security¶
File Permissions¶
If you choose to store secrets in config files, you must verify file permissions:
# Restrict permissions (Unix/Linux/macOS)
chmod 600 /path/to/config.ini
# Owner can read/write, no one else
# -rw------- 1 user user ... config.ini
Prefer Environment Variables¶
Environment variables are often more secure than config files because:
Config files can be accidentally committed to version control
Config files persist on disk and can be read by other users
Environment variables are process-scoped and not persisted
# Store secrets in environment, not in config.ini
os.environ["API_KEY"] = "your_secret" # Set by deployment system
Here is the recommended pattern:
import os
import argclass
# RECOMMENDED: Use environment variables for secrets
os.environ["API_KEY"] = "secret_from_env"
class Parser(argclass.Parser):
# Secret from environment - not stored in files
api_key: str = argclass.Secret(env_var="API_KEY")
# Non-secrets can come from config
log_level: str = "info"
max_retries: int = 3
parser = Parser(config_files=["config.ini"]) # Config for non-secrets only
parser.parse_args([])
parser.sanitize_env() # Removes API_KEY from environment
Passing Secrets to Subprocesses¶
Passing Secrets via Command Line is Insecure¶
Not passing secrets via command-line arguments is critical, as they are visible to all users on the system:
import subprocess
# WRONG - secret visible in process listing
subprocess.check_output(
["ps aux | grep python | grep --password"],
shell=True,
text=True,
)
Common Mistakes¶
Don’t Do This¶
Notice the mistakes in this example:
import os
import argclass
import logging
import subprocess
class Parser(argclass.Parser):
api_key: str = argclass.Secret(env_var="API_KEY")
os.environ["API_KEY"] = "supersecret"
parser = Parser()
parser.parse_args([])
# WRONG: F-string logs the actual secret
print(f"Using API key: {parser.api_key}")
# WRONG: Includes secret in exception
raise ValueError(f"Invalid key: {parser.api_key}")
# WRONG: Forgets to sanitize before subprocess
print(subprocess.check_output("echo $API_KEY", shell=True, text=True).strip())
Do This Instead¶
import argclass
import logging
import subprocess
class Parser(argclass.Parser):
api_key: str = argclass.Secret(env_var="API_KEY")
parser = Parser()
parser.parse_args([])
# IMMEDIATELY sanitize
parser.sanitize_env()
# RIGHT: %-formatting uses repr() - shows '******'
logging.info("Using API key: %s", parser.api_key)
# RIGHT: Just indicate presence
print("API key configured: Yes")
# RIGHT: Don't include secret in errors
raise ValueError("Invalid API key provided")
# RIGHT: Environment sanitized before subprocess
subprocess.run(["some-tool"])
FAQ¶
If I sanitize env, can I safely run third-party scripts?¶
No. Sanitizing environment variables only removes secrets from the environment that child processes inherit. It does not make it safe to run untrusted or third-party code.
Untrusted code running in your process (before or after sanitization) can:
Read secrets from the parser object or local variables
Inspect process memory or stack frames
Access secrets before
sanitize_env()is calledRead secrets from config files on disk
Intercept secrets passed to functions
If you need to run untrusted code, you must use proper isolation mechanisms
(containers, VMs, separate user accounts) that are outside the scope of this
library. sanitize_env() is a defense-in-depth measure for preventing
accidental leakage to well-behaved, trusted subprocesses—not a security
boundary against malicious code.