Skip to content

Commit 95c0412

Browse files
committed
feat(cli): --registry-password-stdin and --zap-auth-password-stdin
Closes hardening item #2 from "Secret Handling & Credential Surface Hardening" in docs/developer/SDK-ROADMAP.md. Two new flags on `argus scan` let users supply credentials via stdin, mirroring `docker login --password-stdin`. The value never appears on argv, in shell history, in argus.yml, or in any persisted argus artifact (argus-audit.json, argus.log, argus-results.*). Implementation: - argus/core/secrets.py: module-level `_STDIN_OVERRIDES` slot registry with set_stdin_override / get_stdin_override / clear_stdin_overrides. Slots are stable cross-scanner names ("registry_password", "zap_auth_password") so the same stdin value can fill the same logical credential across scanners (e.g., registry password for container + zap), while distinct credentials (registry vs. ZAP web-app auth) stay in separate slots. - argus/cli.py: --registry-password-stdin → slot: registry_password --zap-auth-password-stdin → slot: zap_auth_password New helper _consume_stdin_password_flags(args) runs at the top of cmd_scan. Errors out cleanly when stdin is a TTY, when more than one stdin flag is set (stdin is single-stream), or when stdin is empty. Reads stdin once, strips a single trailing newline (multi-line tokens preserved for PEM-style payloads), routes the value into the slot registry. - argus/scanners/container.py + zap.py: container_env / _build_env pass `stdin_override=get_stdin_override(slot)` to resolve_secret. Highest precedence: stdin > _env > literal > None. Test coverage: - 5 new tests in argus/tests/core/test_secrets.py::TestStdinOverrideSlots cover the slot APIs (set/get/clear/isolation/explicit-override-arg). - 9 new tests in argus/tests/test_cli.py::TestStdinPasswordFlags cover the CLI helper: no-flag noop, single-flag happy path for both flags, trailing-newline trim, no-newline preserved, multi-line preserved, both-flags error, TTY error, empty-stdin error. Docs + .ai/: - docs/config-reference.md: new "Third form — CLI stdin" subsection in the credential-fields section with a precedence table and worked examples. The validation-rules list now sits below it. - docs/cli-reference.md: regenerated from cli.py via scripts/ci/check_cli_docs.py --fix. - .ai/architecture.yaml: core/secrets.py description updated in both SDK structure blocks to document the three-form precedence and the slot registry; explicit note that the stdin path never reaches the per-scanner config dict. - docs/developer/SDK-ROADMAP.md: hardening item (2) flipped to shipped with implementation summary. Full suite: 3080 passed, 2 skipped.
1 parent 37e433d commit 95c0412

10 files changed

Lines changed: 407 additions & 12 deletions

File tree

.ai/architecture.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ components:
3838
"core/exclusions.py": "Path exclusion set (builtins + .gitignore + config + CLI), ``**``-glob matcher"
3939
"core/tool_config.py": "Auto-discovery of per-scanner canonical config files (.bandit, .checkov.yaml, trivy.yaml, osv-scanner.toml, semgrep.yml)"
4040
"core/sbom.py": "SBOM format detection (CycloneDX JSON/XML, SPDX JSON/tag-value, Syft JSON) for ``argus scan --sbom``"
41-
"core/secrets.py": "Credential resolution for any scanner config field. resolve_secret(config, field) accepts either <field> (literal, warned at config-load if vendor-shaped via looks_like_literal_secret) or <field>_env (env-var name reference; reads os.environ at scan time). validate_env_var_name enforces POSIX shell identifier rules. Stdlib-only. Used by scanners.container and scanners.zap; future scanners with credential needs use the same helper. See ADR-024."
41+
"core/secrets.py": "Credential resolution for any scanner config field. resolve_secret(config, field, *, stdin_override=None) accepts three forms in precedence order: stdin_override (highest, populated by CLI --*-password-stdin flags via a module-level slot registry — set_stdin_override / get_stdin_override / clear_stdin_overrides), <field>_env (env-var name reference; reads os.environ at scan time), or <field> literal (back-compat, warned at config-load if vendor-shaped via looks_like_literal_secret). The stdin path never reaches the per-scanner config dict so it can't leak into argus-audit.json / argus.log. validate_env_var_name enforces POSIX shell identifier rules. Stdlib-only. Used by scanners.container and scanners.zap; future scanners with credential needs use the same helper. See ADR-024."
4242
"core/findings_view.py": "Shared UI-free logic for findings display — ViewState, SEVERITY_ORDER, finding_detail_rows, compute_summary, diff_scans (scan-over-scan bucketing keyed off (scanner, id, location)). Consumed by argus view terminal (TUI ``D`` keybind, DiffScreen) and argus view browser (web UI ``/diff`` route)."
4343
"viewers/": "`argus view` interfaces (optional extras)"
4444
"viewers/__init__.py": "ViewerUnavailable shared exception"
@@ -471,7 +471,7 @@ docsite:
471471
"core/exclusions.py": "Path exclusion set (builtins + .gitignore + config + CLI), ``**``-glob matcher"
472472
"core/tool_config.py": "Auto-discovery of per-scanner canonical config files (.bandit, .checkov.yaml, trivy.yaml, osv-scanner.toml, semgrep.yml)"
473473
"core/sbom.py": "SBOM format detection (CycloneDX JSON/XML, SPDX JSON/tag-value, Syft JSON) for ``argus scan --sbom``"
474-
"core/secrets.py": "Credential resolution for any scanner config field. resolve_secret(config, field) accepts either <field> (literal, warned at config-load if vendor-shaped via looks_like_literal_secret) or <field>_env (env-var name reference; reads os.environ at scan time). validate_env_var_name enforces POSIX shell identifier rules. Stdlib-only. Used by scanners.container and scanners.zap; future scanners with credential needs use the same helper. See ADR-024."
474+
"core/secrets.py": "Credential resolution for any scanner config field. resolve_secret(config, field, *, stdin_override=None) accepts three forms in precedence order: stdin_override (highest, populated by CLI --*-password-stdin flags via a module-level slot registry — set_stdin_override / get_stdin_override / clear_stdin_overrides), <field>_env (env-var name reference; reads os.environ at scan time), or <field> literal (back-compat, warned at config-load if vendor-shaped via looks_like_literal_secret). The stdin path never reaches the per-scanner config dict so it can't leak into argus-audit.json / argus.log. validate_env_var_name enforces POSIX shell identifier rules. Stdlib-only. Used by scanners.container and scanners.zap; future scanners with credential needs use the same helper. See ADR-024."
475475
"core/findings_view.py": "Shared UI-free logic for findings display — ViewState, SEVERITY_ORDER, finding_detail_rows, compute_summary, diff_scans (scan-over-scan bucketing keyed off (scanner, id, location)). Consumed by argus view terminal (TUI ``D`` keybind, DiffScreen) and argus view browser (web UI ``/diff`` route)."
476476
"viewers/": "`argus view` interfaces (optional extras)"
477477
"viewers/__init__.py": "ViewerUnavailable shared exception"

argus/cli.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -811,6 +811,30 @@ def _build_scan_parser(subparsers: argparse._SubParsersAction) -> None:
811811
"is available via 'reporting.keep_raw: false' in argus.yml.",
812812
)
813813

814+
# Credential stdin flags — read a password from stdin and use it
815+
# as the highest-precedence credential for the named slot. Mirrors
816+
# ``docker login --password-stdin``: keeps the value off the
817+
# process argv (no ``ps``/``docker inspect`` exposure), off the
818+
# shell history, and out of the config file. At most one stdin
819+
# flag may be used per invocation (stdin is a single stream).
820+
scan_parser.add_argument(
821+
"--registry-password-stdin",
822+
action="store_true",
823+
dest="registry_password_stdin",
824+
help="Read the private-registry password from stdin and use it "
825+
"for any scanner that needs registry auth (container, zap "
826+
"with app_image_ref). Overrides registry_password / "
827+
"registry_password_env in argus.yml.",
828+
)
829+
scan_parser.add_argument(
830+
"--zap-auth-password-stdin",
831+
action="store_true",
832+
dest="zap_auth_password_stdin",
833+
help="Read the ZAP web-app authentication password from stdin. "
834+
"Overrides scanners.zap.auth.password / password_env in "
835+
"argus.yml.",
836+
)
837+
814838
# Container-specific flags (used with: argus scan container)
815839
container_group = scan_parser.add_argument_group(
816840
"container scanning",
@@ -1206,6 +1230,72 @@ def _build_report_parser(subparsers: argparse._SubParsersAction) -> None:
12061230
)
12071231

12081232

1233+
_STDIN_PASSWORD_FLAGS = {
1234+
# CLI flag attr name on argparse.Namespace -> credential slot
1235+
"registry_password_stdin": "registry_password",
1236+
"zap_auth_password_stdin": "zap_auth_password",
1237+
}
1238+
1239+
1240+
def _consume_stdin_password_flags(args: argparse.Namespace) -> int:
1241+
"""Read a password from stdin for any ``--*-password-stdin`` flag.
1242+
1243+
Mirrors ``docker login --password-stdin``: the value is read once,
1244+
stored in ``argus.core.secrets`` under a named slot, and consumed
1245+
by scanner modules via ``get_stdin_override(slot)`` at scan time.
1246+
The value never reaches the per-scanner config dict, so it can't
1247+
leak into argus-audit.json / argus.log.
1248+
1249+
Returns ``EXIT_SUCCESS`` on success (including when no stdin flag was
1250+
provided) or ``EXIT_ERROR`` on a usage / TTY violation. Logs no
1251+
secret material.
1252+
"""
1253+
active = [
1254+
slot for attr, slot in _STDIN_PASSWORD_FLAGS.items()
1255+
if getattr(args, attr, False)
1256+
]
1257+
if not active:
1258+
return EXIT_SUCCESS
1259+
1260+
if len(active) > 1:
1261+
print(
1262+
f"Error: only one stdin password flag may be used per "
1263+
f"invocation (got: {', '.join(sorted(active))}). "
1264+
f"stdin is a single stream — pass other credentials via "
1265+
f"<field>_env config keys instead.",
1266+
file=sys.stderr,
1267+
)
1268+
return EXIT_ERROR
1269+
1270+
if sys.stdin.isatty():
1271+
print(
1272+
"Error: --*-password-stdin requires piped input.\n"
1273+
"Example:\n"
1274+
" echo \"$REGISTRY_TOKEN\" | "
1275+
"argus scan --registry-password-stdin --config argus.yml",
1276+
file=sys.stderr,
1277+
)
1278+
return EXIT_ERROR
1279+
1280+
raw = sys.stdin.read()
1281+
# Strip a single trailing newline if present (matches
1282+
# ``docker login --password-stdin`` behavior — handles
1283+
# ``echo $X | ...`` cleanly without eating multi-line tokens).
1284+
if raw.endswith("\n"):
1285+
raw = raw[:-1]
1286+
if not raw:
1287+
print(
1288+
"Error: stdin was empty. The password must be supplied "
1289+
"as the body of the pipe.",
1290+
file=sys.stderr,
1291+
)
1292+
return EXIT_ERROR
1293+
1294+
from argus.core.secrets import set_stdin_override
1295+
set_stdin_override(active[0], raw)
1296+
return EXIT_SUCCESS
1297+
1298+
12091299
def cmd_scan(args: argparse.Namespace) -> int:
12101300
"""Execute the scan subcommand.
12111301
@@ -1215,6 +1305,14 @@ def cmd_scan(args: argparse.Namespace) -> int:
12151305
- zap + --image/--target → DAST lifecycle
12161306
- everything else → source code scanning
12171307
"""
1308+
# Consume any --*-password-stdin flags before further dispatch.
1309+
# Done first so a usage error surfaces before the scanner-registry
1310+
# import (which is slower) and so the stdin value is in place for
1311+
# both the container and DAST lifecycle paths below.
1312+
rc = _consume_stdin_password_flags(args)
1313+
if rc != EXIT_SUCCESS:
1314+
return rc
1315+
12181316
# Validate scanner name against registry
12191317
if args.scanner and not args.list:
12201318
try:

argus/core/secrets.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,47 @@ def resolve_secret(
124124
return None
125125

126126

127+
# ── CLI ``--*-password-stdin`` plumbing ─────────────────────────────
128+
#
129+
# A module-level mapping populated by ``argus/cli.py`` after reading
130+
# a password from stdin (one CLI flag per logical credential slot).
131+
# Scanner modules look up by *slot* — a stable cross-scanner name —
132+
# rather than by raw field name so that, e.g., ``registry_password``
133+
# in container.py and zap.py both pick up the same stdin value but
134+
# zap's ``auth.password`` does not.
135+
#
136+
# Lives in this module (not the CLI) for three reasons:
137+
# - the consuming code (resolve_secret callers in scanner modules)
138+
# already imports from here;
139+
# - tests can clear state via ``clear_stdin_overrides``;
140+
# - the stdin value never reaches the per-scanner config dict, so
141+
# it can't accidentally leak into argus-audit.json / argus.log
142+
# (both of which serialize the per-scanner config block).
143+
_STDIN_OVERRIDES: dict[str, str] = {}
144+
145+
146+
def set_stdin_override(slot: str, value: str) -> None:
147+
"""Record a stdin-supplied credential under a named slot.
148+
149+
Slots are stable cross-scanner names — e.g. ``"registry_password"``
150+
fills the registry password for every scanner that uses it; an
151+
invocation never reaches both ``container.registry_password`` and
152+
``zap.auth.password`` via the same stdin pipe (stdin is a single
153+
stream), so distinct slots stay decoupled.
154+
"""
155+
_STDIN_OVERRIDES[slot] = value
156+
157+
158+
def get_stdin_override(slot: str) -> str | None:
159+
"""Return the stdin-supplied value for ``slot``, or None."""
160+
return _STDIN_OVERRIDES.get(slot)
161+
162+
163+
def clear_stdin_overrides() -> None:
164+
"""Reset all stdin overrides — test seam, not for production use."""
165+
_STDIN_OVERRIDES.clear()
166+
167+
127168
def validate_env_var_name(name: str) -> bool:
128169
"""Return ``True`` if ``name`` is a valid POSIX shell identifier."""
129170
return isinstance(name, str) and bool(_ENV_NAME_VALID.match(name))

argus/scanners/container.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,11 +174,14 @@ def _build_env(self, config: dict) -> dict[str, str]:
174174
The resolved values are exported to the env vars Trivy / Grype /
175175
Syft each natively read for registry authentication.
176176
"""
177-
from argus.core.secrets import resolve_secret
177+
from argus.core.secrets import get_stdin_override, resolve_secret
178178

179179
env = dict(os.environ)
180180
username = resolve_secret(config, "registry_username")
181-
password = resolve_secret(config, "registry_password")
181+
password = resolve_secret(
182+
config, "registry_password",
183+
stdin_override=get_stdin_override("registry_password"),
184+
)
182185

183186
if username:
184187
env["TRIVY_USERNAME"] = username

argus/scanners/zap.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,19 +135,27 @@ def container_env(self, config: dict | None = None) -> dict[str, str | None]:
135135
context files reference via ``{%username%}`` / ``{%password%}``
136136
placeholders.
137137
"""
138+
from argus.core.secrets import get_stdin_override
139+
138140
config = config or {}
139141
env: dict[str, str | None] = {}
140142

141143
reg_user = resolve_secret(config, "registry_username")
142-
reg_pass = resolve_secret(config, "registry_password")
144+
reg_pass = resolve_secret(
145+
config, "registry_password",
146+
stdin_override=get_stdin_override("registry_password"),
147+
)
143148
if reg_user:
144149
env["ZAP_REGISTRY_USERNAME"] = reg_user
145150
if reg_pass:
146151
env["ZAP_REGISTRY_PASSWORD"] = reg_pass
147152

148153
auth_block = config.get("auth") or {}
149154
auth_user = resolve_secret(auth_block, "username")
150-
auth_pass = resolve_secret(auth_block, "password")
155+
auth_pass = resolve_secret(
156+
auth_block, "password",
157+
stdin_override=get_stdin_override("zap_auth_password"),
158+
)
151159
if auth_user:
152160
env["ZAP_AUTH_USERNAME"] = auth_user
153161
if auth_pass:

argus/tests/core/test_secrets.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,3 +209,69 @@ def test_does_not_match_non_secret_strings(self, value):
209209
def test_non_string_returns_false(self):
210210
assert looks_like_literal_secret(None) is False
211211
assert looks_like_literal_secret(12345) is False
212+
213+
214+
class TestStdinOverrideSlots:
215+
"""Slot-based stdin override registry — populated by the CLI's
216+
``--*-password-stdin`` flags, consumed by scanners via
217+
``get_stdin_override(slot)``.
218+
"""
219+
220+
def setup_method(self):
221+
from argus.core.secrets import clear_stdin_overrides
222+
clear_stdin_overrides()
223+
224+
def teardown_method(self):
225+
# Process-level state: tests must reset to avoid leaking
226+
# values to other tests in the same pytest run.
227+
from argus.core.secrets import clear_stdin_overrides
228+
clear_stdin_overrides()
229+
230+
def test_set_and_get_roundtrip(self):
231+
from argus.core.secrets import get_stdin_override, set_stdin_override
232+
233+
set_stdin_override("registry_password", "from-stdin")
234+
assert get_stdin_override("registry_password") == "from-stdin"
235+
236+
def test_unset_slot_returns_none(self):
237+
from argus.core.secrets import get_stdin_override
238+
239+
assert get_stdin_override("never_set") is None
240+
241+
def test_distinct_slots_dont_collide(self):
242+
from argus.core.secrets import get_stdin_override, set_stdin_override
243+
244+
set_stdin_override("registry_password", "reg-pass")
245+
set_stdin_override("zap_auth_password", "app-pass")
246+
247+
assert get_stdin_override("registry_password") == "reg-pass"
248+
assert get_stdin_override("zap_auth_password") == "app-pass"
249+
250+
def test_clear_resets_all_slots(self):
251+
from argus.core.secrets import (
252+
clear_stdin_overrides,
253+
get_stdin_override,
254+
set_stdin_override,
255+
)
256+
257+
set_stdin_override("registry_password", "x")
258+
set_stdin_override("zap_auth_password", "y")
259+
clear_stdin_overrides()
260+
261+
assert get_stdin_override("registry_password") is None
262+
assert get_stdin_override("zap_auth_password") is None
263+
264+
def test_resolve_secret_uses_stdin_override_arg(self):
265+
"""The resolver's existing ``stdin_override=`` kwarg still wins
266+
even when nothing is set in the slot registry. Slot lookup is
267+
the caller's responsibility — the resolver itself does not
268+
introspect the registry."""
269+
from argus.core.secrets import resolve_secret
270+
271+
result = resolve_secret(
272+
{"registry_password_env": "ANY"},
273+
"registry_password",
274+
env={},
275+
stdin_override="explicit-override",
276+
)
277+
assert result == "explicit-override"

0 commit comments

Comments
 (0)