Skip to content

show logging: remove shell command injection risk - #4847

Merged
qiluo-msft merged 4 commits into
sonic-net:masterfrom
pkawatkar14:dev_pb39463623_pk
Sep 17, 2026
Merged

qiluo-msft merged 4 commits into
sonic-net:masterfrom
pkawatkar14:dev_pb39463623_pk

Conversation

@pkawatkar14

@pkawatkar14 pkawatkar14 commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Problem

show logging built a shell pipeline string and executed it with shell=True:

cmd += " | grep '{}'".format(process)
run_command(cmd, display_cmd=verbose, shell=True)

The process argument (and, for --lines, a tail stage) came from user input and was interpolated directly into a shell command string. A value containing shell metacharacters (;, |, backticks, $(), quotes, newlines) could alter the command structure that actually executes, i.e. a command injection vulnerability. Tracked as Microsoft ADO work item 39463623.

What I did

Removed shell execution from logging() in show/main.py. All commands are now built as argv lists and run with shell=False, so user input is always passed as data, never as shell syntax.

How I did it

  • No filter, no --lines: run_command(['sudo', 'cat', <log_files>], ...) — unchanged privilege posture (root reads the log files, nothing else runs).
  • With a process filter and/or --lines: sudo cat <log_files> is always the first pipeline stage; grep -h -- process and/or tail -n N are chained after it via subprocess.Popen, running unprivileged (as the invoking user), the same as the old sudo cat ... | grep ... | tail ... shell form. Only cat needs root, to read the log files.
    • -h keeps output filename-free across multiple files, preserving the previous cat ... | grep ... output shape.
    • The -- separator stops a process value starting with - from being parsed as a grep option.
    • Each stage's stdout feeds the next stage's stdin directly via a pipe; the final stage inherits the real stdout, so output streams straight through the kernel pipe instead of being buffered in Python. Closing the upstream stdout handle in the parent after starting the next stage lets SIGPIPE propagate normally if a downstream stage exits early.
  • --follow (-f): unchanged — ['sudo', 'tail', '-F', <current_log>], already shell=False.
  • /var/log vs /var/log.tmpfs selection and rotated syslog.1 detection logic are unchanged.

Justification for the exit-code change: previously, show logging PROCESS -l N ran as a single bash pipeline (cat | grep | tail) via shell=True. Bash pipelines report only the last command's exit status by default, so a grep failure/no-match (exit 1) was silently masked by tail's near-always-0 exit code. Since removing the shell also removes this implicit $? propagation, the new implementation explicitly checks every pipeline stage's exit code and returns the last non-zero one it finds, so failures are surfaced instead of silently reported as success. This is a deliberate, intentional behavior improvement, not a regression.

No shlex.quote() escaping was used — quoting a shell string is fragile and easy to get wrong; removing the shell entirely eliminates the injection vector rather than mitigating it.

Review feedback addressed

  • qiluo-msft: noted that the original --lines fix used getstatusoutput_noshell_pipe(), which internally calls .communicate() and therefore buffers the entire pipeline's output before printing it — a streaming regression versus the old shell=True path (imperceptible for a small -l N, but delays the first line for a large N). Addressed by chaining subprocess.Popen directly: each stage's stdin reads from the previous stage's stdout pipe and the last stage inherits the real stdout, restoring line-by-line streaming while keeping the argv-list (no-shell) posture. getstatusoutput_noshell_pipe() remains imported/used elsewhere in this file, unaffected.
  • qiluo-msft: noted a privilege-boundary shift — with a process filter, sudo had been scoped to the whole grep invocation, so the user-controlled regex ran as root instead of as the invoking user (as it did under the old sudo cat ... | grep ... shell form). A pathological/expensive regex would therefore burn root cycles. Fixed by always running sudo cat as the first, and only privileged, pipeline stage, with grep/tail chained after it unprivileged — restoring the original privilege boundary while keeping the argv-list (no-shell) posture.
  • qiluo-msft: noted that this direct Popen pipeline bypasses run_command()run_command_in_alias_mode(), so under interface_naming_mode = alias, show logging -l N (and, with this fix, any invocation that filters by process and/or lines) no longer substitutes port names with configured aliases in the log text, while plain show logging (no filter, no -l) still does. This is a real, intentional behavior difference from the pre-existing alias mode, called out below rather than restored, since doing substring alias-rewrites on raw, unstructured log lines is fragile and error-prone in general.

How to verify it

cd src/sonic-utilities
python3 -m pytest tests/show_test.py -v
python3 -m compileall -q show tests

Test cases added/updated (tests/show_test.py)

  • All log path variants: /var/log and /var/log.tmpfs, each with and without rotated syslog.1, for no-filter, filter, --lines, and --follow
  • --follow ignores other supplied options (process, -l) as before
  • Process filter alone, --lines alone, and process filter + --lines together, each asserting the exact argv and stdin/stdout wiring passed to every subprocess.Popen stage (sudo cat always first and unprivileged grep/tail chained after it)
  • Verbose command display for the piped path(s)
  • Nonzero exit-code propagation when any pipeline stage fails (cat, grep no-match, or tail itself failing)
  • Existing Click rejection of a non-numeric --lines value
  • A process value starting with - (e.g. -rf /), verifying it is placed after grep's -- separator, not read as an option, and that cat (not grep) is the privileged stage
  • Injection-style process values — quotes, ;, |, `, $(), newlines, spaces — for both the process-only and --lines Popen pipelines, asserting each stays a single argv element and is never shell-interpreted

Previous command output (if the output of a command-line utility has changed)

show logging, show logging PROCESS, show logging -l N, show logging -f — output unchanged under the default interface_naming_mode = native.

New command output (if the output of a command-line utility has changed)

Known behavior change under interface_naming_mode = alias: show logging PROCESS and/or show logging -l N no longer substitute port names with their configured aliases in the emitted log text (they now go through a direct, unprivileged Popen pipeline instead of run_command()/run_command_in_alias_mode()). Plain show logging (no process filter, no -l) and show logging -f are unaffected and still substitute aliases as before.

The 'show logging' CLI command built a shell pipeline string and executed
it with shell=True, allowing the user-supplied process filter to alter
shell command structure (e.g. quotes, semicolons, pipes, command
substitution).

Replace the shell-based implementation with argv-list commands executed
with shell=False:
  - No filter: ['sudo', 'cat', <log_files>]
  - Filter:    ['sudo', 'grep', '-h', '--', process, <log_files>]
    (the grep '--' separator prevents a process value starting with '-'
    from being read as a grep option)
  - Follow:    ['sudo', 'tail', '-F', <current_log>] (unchanged, already
    shell=False)
  - --lines:   the above cat/grep command piped into ['tail', '-n', N] via
    the existing shell=False getstatusoutput_noshell_pipe() helper

As part of propagating command failures consistently instead of silently
returning success (previously masked by bash reporting only the last
pipeline command's exit code when --lines was used), the CLI now returns
the last non-zero exit code across all pipeline stages.

Preserves existing behavior: /var/log vs /var/log.tmpfs, rotated
syslog.1 handling, grep regex semantics, verbose command display, and
nonzero exit propagation.

Add regression tests covering all log path variants, filter/no-filter,
--lines, follow, a process value starting with '-', and injection-style
process values (quotes, semicolons, pipes, $(), backticks, newlines,
spaces) asserting they remain single argv elements and are never
interpreted by a shell.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Pooja Kawatkar <pkawatkar@microsoft.com>
Copilot AI lite review requested due to automatic review settings September 11, 2026 13:44
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
There may be pipelines that require an authorized user to comment /azp run to run.

@mssonicbld

Copy link
Copy Markdown
Collaborator

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Address negative --lines semantics, duplicate newline output, and missing alias conversion in the limited-output path.

Pull request overview

This PR removes shell command injection risk from show logging by using shell-free argv execution.

Changes:

  • Replaces shell pipelines with safe argv-based commands.
  • Adds non-shell piping and exit-status handling for --lines.
  • Expands logging and injection-regression tests.
File summaries
File Summary
show/main.py Implements shell-free logging commands and pipeline handling.
tests/show_test.py Adds comprehensive logging and security regression coverage.
Review details

Suppressed comments (3)

show/main.py:1735

  • --lines still accepts negative integers because the Click option uses type=int. Before this change, tail -5 meant the last five lines, whereas tail -n -5 means all but the last five, so this silently changes behavior for an input the CLI currently accepts. Please either preserve the old semantics (for example, normalize the value) or reject negative values explicitly.
    tail_cmd = ["tail", "-n", str(lines)]

show/main.py:1741

  • getstatusoutput_noshell_pipe() returns the captured output including the newline normally emitted by tail, but click.echo(output) appends another newline. As a result, normal show logging -l N invocations gain an extra blank line compared with the existing run_command() path, which strips each line ending before echoing. Emit the captured output without adding a newline.
        click.echo(output)

show/main.py:1741

  • The --lines path calls the pipe helper directly, bypassing the alias-mode handling that run_command() applies at show/main.py:133-135. When interface naming mode is alias, show logging and the no---lines filter path rewrite interface names in log output, but show logging -l N does not, so the same command produces inconsistent output depending on whether a line limit is supplied. Route this pipeline output through the same alias conversion logic before printing it.
    exitcodes, output = getstatusoutput_noshell_pipe(cmd, tail_cmd)
    if output:
        click.echo(output)
  • Files reviewed: 2/2 changed files
  • Comments generated: 0
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@mssonicbld

Copy link
Copy Markdown
Collaborator

Hi, there are workflow run(s) waiting for approval, you may be first-time contributor. I will notify maintainers to help approve once PR is approved. Thanks!

---Powered by SONiC BuildBot

Comment thread show/main.py Outdated
getstatusoutput_noshell_pipe() uses .communicate(), which buffers the
entire pipeline output before returning it for a single click.echo().
This is a regression from the old shell=True path, which streamed
output line-by-line through run_command(). For a bounded -l N this is
imperceptible, but for a large N it delays the first line until the
whole tail completes.

Restore streaming by chaining subprocess.Popen ourselves: tail's stdin
is connected directly to the source command's stdout pipe, and tail
inherits the real stdout, so output flows straight through the kernel
pipe instead of being buffered in Python. p1.stdout.close() in the
parent lets SIGPIPE propagate normally if tail exits early.

Updates the corresponding unit tests to mock subprocess.Popen and
assert on the two invocations instead of on captured output text.

Signed-off-by: Pooja Kawatkar <pkawatkar@microsoft.com>

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@mssonicbld

Copy link
Copy Markdown
Collaborator

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

Comment thread show/main.py Outdated
Comment thread show/main.py Outdated
Address qiluo-msft's review feedback on PR sonic-net#4847: the argv-list refactor
scoped 'sudo' to the whole 'grep' invocation when a process filter was
given, so the user-controlled process regex ran as root -- a privilege
boundary shift from the old 'sudo cat ... | grep ...' shell form, where
sudo only covered 'cat' and grep ran as the invoking user.

'sudo cat' is now always the first pipeline stage; 'grep'/'tail' are
chained after it unprivileged, restoring the old boundary while keeping
the argv-list (no shell) posture. Update the existing Popen-chaining
tests accordingly.

Signed-off-by: Pooja Kawatkar <pkawatkar@microsoft.com>
@mssonicbld

Copy link
Copy Markdown
Collaborator

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

Pre-commit's flake8 diff check flagged an extra blank line introduced
between test_show_logging_lines_non_numeric and
test_show_logging_process_argv_integrity in the previous commit.

Signed-off-by: Pooja Kawatkar <pkawatkar@microsoft.com>
@mssonicbld

Copy link
Copy Markdown
Collaborator

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

@pkawatkar14

Copy link
Copy Markdown
Contributor Author

Pretest Static Analysis build failure fixed:
Root cause: the "Pretest Static Analysis" check runs a diff-scoped  flake8  (via pre-commit) on lines touched by the PR. My previous commit left an extra blank line in  tests/show_test.py  (3 blank lines instead of ≤2) right before  test_show_logging_process_argv_integrity , triggering  E303: too many blank lines (3)  at line 257.

Fix: removed the extra blank line and verified with the exact pre-commit invocation ( flake8==4.0.1 , diff-scoped). Pushed as a follow-up commit —  Pretest Static Analysis  now passes, along with DCO and EasyCLA. The remaining checks ( Analyze (python) ,  Azure.sonic-utilities Build Python3 ) are still running.

@qiluo-msft
qiluo-msft merged commit fb6581a into sonic-net:master Sep 17, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants