Back to Blog
medium SEVERITY7 min read

Plaintext Password Exposure Fixed in aroma.py CLI Keystore Tool

A medium-severity vulnerability in `tools/cli/aroma.py` allowed keystore passwords entered via the `get_input()` function to be displayed in plaintext on the terminal, stored unmasked in memory, and potentially recorded in shell history files. The fix introduces Python's `getpass` module and a new `secret` parameter to `get_input()`, ensuring sensitive credential input is properly masked during entry. This change directly protects users of the CLI tool from credential exposure during routine key

O
By Orbis AppSec
Published June 1, 2026Reviewed June 3, 2026

Answer Summary

This vulnerability is a plaintext password exposure (CWE-312) in Python's `tools/cli/aroma.py`, where the `get_input()` function used standard terminal input to collect keystore passwords, causing them to be visible on-screen and potentially stored in shell history. The fix adds Python's built-in `getpass` module and a `secret` boolean parameter to `get_input()`, so that when `secret=True` is passed, input is collected silently without echoing characters to the terminal. Any Python CLI tool that collects passwords or secrets should use `getpass.getpass()` instead of `input()` or `sys.stdin.readline()`.

Vulnerability at a Glance

cweCWE-312
fixIntroduced `getpass` module and `secret` parameter to `get_input()` to silently collect sensitive credentials
riskKeystore passwords visible on terminal and potentially stored in shell history
languagePython
root cause`get_input()` used standard input without masking, exposing passwords in cleartext
vulnerabilityPlaintext Password Exposure

Plaintext Password Exposure in aroma.py's get_input() Function

Introduction

The tools/cli/aroma.py file serves as the command-line interface for managing keystores, handling the creation and updating of keystore and key passwords through the create_or_update() method. However, a flaw in how the get_input() function handled sensitive input — specifically at lines 320–326 — meant that every time a user typed a keystore password (storepass) or key password (keypass), the characters appeared in full on their terminal screen, no different from typing a filename or a project name.

This is a classic case where a general-purpose input helper function was reused for a security-sensitive context without the appropriate safeguards. For developers building CLI tools in Python, this pattern is surprisingly easy to overlook — and surprisingly dangerous in practice.


The Vulnerability Explained

What Was the Vulnerable Code Doing?

The get_input() function in aroma.py was defined as:

def get_input(
    prompt: str, default: str = None, validator=None, error_msg: str = "Invalid input"
) -> str:
    while True:
        prompt_str = f"{Colors.BOLD}{prompt}{Colors.ENDC}"
        if default:
            prompt_str += f" [{default}]"
        prompt_str += ": "

        try:
            val = input(prompt_str).strip()
        ...

The core problem is this single line:

val = input(prompt_str).strip()

Python's built-in input() function echoes every character the user types back to the terminal in real time. This is perfectly fine for prompts like "Enter your project name" — but for a password prompt, it means:

  1. Terminal visibility: Anyone looking at the screen sees the password as it's typed.
  2. Shell history exposure: In some terminal environments and logging configurations, input prompts and their responses can be captured in logs or scrollback buffers.
  3. Process listing risk: On multi-user systems, process arguments and I/O streams can sometimes be inspected.
  4. Plain string in memory: The password lands in a standard Python str with no special memory handling.

Where Was This Called?

The create_or_update() method called get_input() for three sensitive credential inputs:

# Storepass (first entry)
storepass = get_input(
    "Keystore password",
    validator=lambda x: len(x) >= 4,
    error_msg="Password must be at least 4 characters",
)

# Confirmation password — VULNERABLE
confirm = get_input("Confirm password")
if confirm != storepass:
    print_error("Passwords don't match")
    return None

# Key password — VULNERABLE
keypass = get_input("Key password", validator=lambda x: len(x) >= 4)
confirm_key = get_input("Confirm key password")

Notice that confirm, keypass, and confirm_key were all collected through the standard input() path — fully visible on screen.

A Concrete Attack Scenario

Imagine a developer running the aroma.py CLI tool in a shared office environment, a pair-programming session, or a screen-sharing call. When they reach the "Confirm password" prompt, the password they type is displayed character-by-character in the terminal. A colleague, meeting participant, or screen recording captures the credential without any special effort.

On a shared Linux server, a second user running ps aux or inspecting /proc at the right moment might also catch terminal I/O in certain configurations. Similarly, terminal multiplexers like tmux or screen with logging enabled would record the plaintext password in their scrollback logs.

For a keystore — which often protects signing keys for mobile apps, code signing certificates, or cryptographic material — this exposure is particularly consequential. A leaked keystore password can allow an attacker to sign malicious builds as if they were legitimate.


The Fix

What Changed

The fix makes two targeted modifications:

1. Added the getpass import at the top of the file:

import getpass

Python's getpass module is the standard library solution for reading passwords without echoing them to the terminal. It suppresses character display during input and is specifically designed for credential collection.

2. Added a secret parameter to get_input():

# Before
def get_input(
    prompt: str, default: str = None, validator=None, error_msg: str = "Invalid input"
) -> str:

# After
def get_input(
    prompt: str, default: str = None, validator=None, error_msg: str = "Invalid input",
    secret: bool = False
) -> str:

3. Changed the input collection line to branch on secret:

# Before
val = input(prompt_str).strip()

# After
val = getpass.getpass(prompt_str).strip() if secret else input(prompt_str).strip()

This is a clean, non-breaking change. Existing callers of get_input() that don't pass secret=True continue to work exactly as before. Only calls that explicitly opt into secret mode use getpass.getpass().

4. Updated the call sites in create_or_update():

# Before
confirm = get_input("Confirm password")

# After
confirm = get_input("Confirm password", secret=True)
# Before
keypass = get_input("Key password", validator=lambda x: len(x) >= 4)

# After
keypass = get_input("Key password", validator=lambda x: len(x) >= 4, secret=True)

Why This Fix Works

getpass.getpass() disables terminal echo for the duration of the input call. The characters the user types are read by the process but never sent back to the display. On Unix-like systems, it achieves this by directly manipulating the terminal's termios settings. On Windows, it uses msvcrt. The result is a blank prompt line where the password is typed invisibly.

Importantly, the secret=True flag makes the intent explicit in the code. Future developers reading get_input("Key password", secret=True) immediately understand that this is a sensitive field — the parameter name serves as inline documentation of the security requirement.


Prevention & Best Practices

Never Use input() for Passwords in Python

This is the single most important takeaway. Python's standard library provides getpass.getpass() specifically for this purpose. There is no legitimate reason to use input() for password collection.

import getpass

# ✅ Correct
password = getpass.getpass("Enter your password: ")

# ❌ Wrong — displays password on screen
password = input("Enter your password: ")

Design Input Helpers With a secret Flag

When building CLI tools with a shared input utility function, always include a mechanism for secret input from the start. The pattern used in this fix — a secret: bool = False parameter — is a clean, Pythonic approach that:

  • Maintains backward compatibility
  • Makes call sites self-documenting
  • Centralizes the getpass logic in one place

Audit All input() Calls in CLI Tools

If you maintain a CLI tool, run a quick search for every use of input() and ask: "Could this prompt ever collect a password, token, private key, or other secret?" If yes, it should use getpass.getpass().

# Find all input() calls in Python CLI tools
grep -n "input(" tools/cli/*.py

Consider Memory Handling for Long-Lived Secrets

While outside the scope of this specific fix, it's worth noting that Python strings are immutable and their memory is managed by the garbage collector — you cannot reliably zero out a password string after use. For highly sensitive applications, libraries like securestring or OS-level secret stores provide stronger guarantees.

Relevant Standards

  • OWASP: Sensitive Data Exposure — Passwords should never be displayed in plaintext, even transiently during input.
  • CWE-549: Missing Password Field Masking — Directly describes this vulnerability pattern.
  • CWE-312: Cleartext Storage of Sensitive Information — Related concern for how credentials are handled post-collection.

Key Takeaways

  • get_input() in aroma.py was a general-purpose function that was unsafe for password collection — the fix adds an explicit secret=True flag rather than creating a separate function, keeping the codebase clean while making intent obvious.
  • The "Confirm password" and "Key password" prompts were the unmasked fields — the initial storepass prompt was also vulnerable but the fix comprehensively addresses all three credential collection points.
  • import getpass is a one-line fix with zero external dependencies — it's part of Python's standard library and available on all platforms including Windows, macOS, and Linux.
  • Keystore passwords are high-value targets — exposure of a keystore credential can compromise code signing integrity, making this more than a UX issue.
  • A secret: bool = False parameter pattern is reusable — any CLI tool with a shared input helper should adopt this pattern proactively, before sensitive prompts are added.

Conclusion

The vulnerability in aroma.py's get_input() function is a straightforward but impactful issue: passwords were being collected with the same input() call used for non-sensitive data, causing them to appear in plaintext on the terminal. The fix is equally straightforward — Python's getpass module exists precisely for this scenario, and the addition of a secret parameter to get_input() ensures the fix is applied consistently and readably across all sensitive call sites.

For developers building CLI tools, this case is a useful reminder that security-sensitive input requires explicit, deliberate handling. A utility function that works perfectly for collecting project names or file paths can silently become a vulnerability when reused for passwords. The secret=True pattern introduced here is a low-friction, high-value addition to any Python CLI toolkit.

This fix was identified and resolved by Orbis AppSec's automated security scanning pipeline.

Frequently Asked Questions

What is plaintext password exposure in a CLI tool?

It occurs when a command-line tool collects sensitive input like passwords using standard terminal input functions (e.g., Python's `input()`), causing the typed characters to be echoed visibly to the screen and potentially recorded in shell history files.

How do you prevent plaintext password exposure in Python?

Use Python's built-in `getpass.getpass()` function instead of `input()` for any sensitive credential collection. It suppresses terminal echo so typed characters are never displayed.

What CWE is plaintext password exposure?

CWE-312 (Cleartext Storage of Sensitive Information) and CWE-359 (Exposure of Private Personal Information to an Unauthorized Actor) are the most relevant. For terminal echo specifically, CWE-549 (Missing Password Field Masking) also applies.

Is encrypting the password after input enough to prevent plaintext password exposure?

No. Encrypting after the fact does not help if the password was already echoed to the terminal or recorded in shell history before encryption occurs. The input must be masked at the point of collection.

Can static analysis detect plaintext password exposure?

Yes. Tools like Semgrep can detect patterns where `input()` is called in contexts that suggest password or secret collection, especially when variable names contain terms like "password", "secret", or "key".

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #8

Related Articles

critical

How LDAP injection happens in Python Apache Airflow FAB security manager and how to fix it

A critical LDAP injection vulnerability was discovered in Apache Airflow's FAB (Flask-AppBuilder) security manager, specifically in the `_search_ldap()` method of `override.py`. The `AUTH_LDAP_SEARCH_FILTER` configuration value was interpolated directly into LDAP filter strings without validation, enabling attackers who could influence that configuration value to craft malicious filters that bypass authentication or exfiltrate directory data. The fix adds structural validation of the filter stri

high

How path traversal in open() happens in Python and how to fix it

A high-severity path traversal vulnerability was discovered in `tool/update-doc.py`, where user-controlled input was passed directly to Python's `open()` function without sanitization. This flaw could allow an attacker to read arbitrary files on the server by manipulating the file path. The fix ensures that file paths are validated and restricted to an intended directory before being opened.

critical

Critical OIDC Cache Key Collision in LiteLLM: Authentication Bypass & Privilege Escalation

LiteLLM versions prior to 1.87.0 contained a critical vulnerability in OIDC userinfo caching that allowed attackers to bypass authentication and escalate privileges through cache key collisions. By upgrading to version 1.87.0, applications eliminate the attack surface that could permit unauthorized users to assume the identity of legitimate authenticated users. This fix is essential for any production system using LiteLLM's OIDC integration.

critical

Critical Shell Injection in autoban.py: How os.system() Opened a Root Shell

A critical shell injection vulnerability in `autoban.py` allowed attackers to execute arbitrary commands as root on OpenWrt routers by crafting malicious connection data containing shell metacharacters. The fix replaces a dangerous `os.system(cmd)` call with `os.fork()` + `os.execvp()`, eliminating shell interpretation entirely. This change ensures that IP addresses extracted from network connections can never be used to inject arbitrary shell commands, even if they contain semicolons, pipes, ba

critical

Local File Inclusion in Crawl4AI Docker API via file:// URL Injection

CVE-2026-26217 is a critical Local File Inclusion (LFI) vulnerability in Crawl4AI versions prior to 0.8.0, where the Docker API fails to restrict `file://` URL schemes, allowing attackers to read arbitrary files from the host filesystem. The fix upgrades `crawl4ai` from `0.7.6` to `0.8.0` in `pyproject.toml` and `uv.lock`, closing a direct path to sensitive file exfiltration in any containerized deployment using this library.

medium

How path traversal happens in C file extraction and how to fix it

A path traversal vulnerability in the borpak archive extraction tool allowed attackers to write files to arbitrary locations on the filesystem by crafting malicious .pak archives with `../` sequences in filenames. This medium-severity issue in `tools/borpak/source/borpak.c` could enable system compromise through overwriting critical files like `.bashrc` or cron jobs. The fix implements path validation to ensure extracted files never escape the intended extraction directory.