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

high

How unsafe pickle deserialization happens in NumPy's np.load() and how to fix it

A high-severity arbitrary code execution vulnerability was discovered in `tools/ardy-engine/retarget.py` where `np.load()` was called with `allow_pickle=True`, enabling attackers to embed malicious pickle payloads in `.npz` files. The fix was a single-character change—switching `allow_pickle=True` to `allow_pickle=False`—that eliminates the deserialization attack vector while preserving the file's legitimate array data loading functionality.

high

How pickle-based arbitrary code execution happens in PyTorch and how to fix it

A high-severity arbitrary code execution vulnerability was discovered in `scripts/export_joyvasa_audio.py` where `torch.load()` was called with `weights_only=False`, allowing any pickle-serialized Python object — including malicious code — to execute during checkpoint loading. The fix switches to `weights_only=True` and explicitly allowlists only the two non-standard classes the checkpoint actually requires: `argparse.Namespace` and `pathlib.PosixPath`. This closes a real code execution path tha

high

How Server-Side Request Forgery (SSRF) happens in Python Flask and how to fix it

A high-severity Server-Side Request Forgery (SSRF) vulnerability was discovered in `webui/backend/main.py` at line 6597 of the Posterizarr project. User-controlled `request.media_type` was interpolated directly into a URL used for server-side HTTP requests to The Movie Database (TMDB) API, allowing attackers to manipulate the destination of outbound requests. The fix introduces a strict allowlist that only permits `"movie"` or `"tv"` as valid media types.

high

How Memory Exhaustion via Large Comma-Separated Selector Lists happens in Python Soup Sieve and how to fix it

A high-severity memory exhaustion vulnerability (CVE-2026-49476) was discovered in Soup Sieve version 2.8.3, affecting Python applications that parse CSS selectors from user-controlled input. The vulnerability allows attackers to craft malicious selector lists that consume excessive memory, potentially causing denial of service. The fix involves upgrading to soupsieve 2.8.4, which implements proper resource limits on selector parsing.

high

How memory exhaustion via large comma-separated selector lists happens in Python soupsieve and how to fix it

A high-severity memory exhaustion vulnerability (CVE-2026-49476) was discovered in soupsieve 2.8.3, a CSS selector library used by BeautifulSoup in Python. An attacker who could influence CSS selector input could craft large comma-separated selector lists to exhaust system memory, causing denial of service. The fix upgrades soupsieve from 2.8.3 to 2.8.4 in the backend's `uv.lock` dependency file.

high

How URL-Encoded Path Traversal happens in Python nltk.data.load() and how to fix it

CVE-2026-54293 is a high-severity path traversal vulnerability in NLTK's `nltk.data.load()` function that allows attackers to read arbitrary local files by supplying URL-encoded path sequences. The fix pins NLTK to version 3.10.0 or later via a constraint dependency in `pyproject.toml`, preventing the vulnerable version from being resolved transitively through `rouge-score` and `lm-eval`. Because this project is a web service, the vulnerability was directly exploitable by remote attackers withou