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:
- Terminal visibility: Anyone looking at the screen sees the password as it's typed.
- Shell history exposure: In some terminal environments and logging configurations, input prompts and their responses can be captured in logs or scrollback buffers.
- Process listing risk: On multi-user systems, process arguments and I/O streams can sometimes be inspected.
- Plain string in memory: The password lands in a standard Python
strwith 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
getpasslogic 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()inaroma.pywas a general-purpose function that was unsafe for password collection — the fix adds an explicitsecret=Trueflag 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
storepassprompt was also vulnerable but the fix comprehensively addresses all three credential collection points. import getpassis 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 = Falseparameter 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.