Skip to content

Using --level=warn/--level=fail silently suppresses all JSON output #373

Description

@ljzxc

Summary

When --level is set to anything above info, JSON output is discarded entirely and the program prints only an empty line. The exit code is still correct, but nothing parseable reaches STDOUT.

Version

  • ssh-audit v3.9.1-dev (commit 111399e), also present in the current release 3.9.0
  • Python: 3.14.6

Steps to reproduce

$ ./ssh-audit.py --level=warn -j localhost:22

Expected behaviour

A JSON document on STDOUT. --level either filters the structure or is ignored, but the document is emitted either way.

Actual behaviour

A single empty line. Exit code is still set correctly (2 for warnings, 3 for failures), so the audit itself runs fine, only the output is lost.

Both of these work as expected:

$ ./ssh-audit.py -j localhost:22           # JSON output, fine
$ ./ssh-audit.py --level=warn localhost:22 # filtered text output, fine

Root cause

The JSON document is emitted through the severity-filtered output buffer, at info level.

src/ssh_audit/ssh_audit.py:

if aconf.json:
    out.reset()
    # Build & write the JSON struct.
    out.info(json.dumps(build_struct(...), indent=..., sort_keys=True))

OutputBuffer._print() in src/ssh_audit/outputbuffer.py drops anything below the configured minimum level:

if (always_print is False) and (self.get_level(level) < self.__level):
    return

--level=warn sets out.level = 'warn' (__level == 1) at ssh_audit.py:1154. The JSON is written at info (0), so 0 < 1 and the entire document is dropped. The buffer is then empty and out.write() executes print(""), which is the blank line.

This reproduces without needing a server:

>>> import sys
>>> sys.path.insert(0, 'src')
>>> from ssh_audit.outputbuffer import OutputBuffer
>>> out = OutputBuffer()
>>> out.level = 'warn'
>>> out.info('{"json": "payload"}')
<ssh_audit.outputbuffer.OutputBuffer object at 0x7f03e3b90c20>
>>> print(repr(out.get_buffer()))
''

Suggested fix

out.info() already accepts always_print for this case.

--- a/src/ssh_audit/ssh_audit.py
+++ b/src/ssh_audit/ssh_audit.py
@@ audit()
-        out.info(json.dumps(build_struct(...), indent=4 if aconf.json_print_indent else None, sort_keys=True))
+        out.info(json.dumps(build_struct(...), indent=4 if aconf.json_print_indent else None, sort_keys=True), always_print=True)
 
@@ gex modulus output
-            out.info(json.dumps(json_struct, indent=4 if aconf.json_print_indent else None, sort_keys=True))
+            out.info(json.dumps(json_struct, indent=4 if aconf.json_print_indent else None, sort_keys=True), always_print=True)

This fix is needed regardless of what is decided about the design question below: filtering inside build_struct() alone would not help, since the emission would still pass through out.info() and still be dropped by _print().

Rejecting --level/-l + --json/-j at the argument parser would also resolve the silent-failure aspect, but it could break existing scripts.

Design question: should --level filter the JSON?

Once the output is no longer swallowed, there are two reasonable behaviours.

Option A: --level remains a no-op for JSON. The document always contains the complete inventory; consumers filter downstream:

./ssh-audit.py -j localhost:22 | jq '{kex, key, enc, mac} | map_values(map(select(.notes.fail or .notes.warn)))'

This has zero implementation cost beyond the fix above, however leaves a silently inert flag (a one-line note on STDERR (so jq pipelines aren't broken) would make that discoverable).

Option B: filter build_struct() output by level. --level means the same thing in every output mode.

Consistent flag semantics (expected when using --level) and does not require use of external tools.

If Option B is preferred, one detail worth deciding: whether recommendations (which carries its own critical / warning / informational levels) should be filtered by the same threshold, or left intact.

I'm happy to submit a PR for whichever you prefer.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions