show logging: remove shell command injection risk - #4847
Conversation
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>
|
Azure Pipelines: There may be pipelines that require an authorized user to comment /azp run to run. |
|
/azp run |
|
Azure Pipelines: Successfully started running 1 pipeline(s). |
There was a problem hiding this comment.
🔵 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
--linesstill accepts negative integers because the Click option usestype=int. Before this change,tail -5meant the last five lines, whereastail -n -5means 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 bytail, butclick.echo(output)appends another newline. As a result, normalshow logging -l Ninvocations gain an extra blank line compared with the existingrun_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
--linespath calls the pipe helper directly, bypassing the alias-mode handling thatrun_command()applies atshow/main.py:133-135. When interface naming mode isalias,show loggingand the no---linesfilter path rewrite interface names in log output, butshow logging -l Ndoes 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.
|
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
|
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>
|
/azp run |
|
Azure Pipelines: Successfully started running 1 pipeline(s). |
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>
|
/azp run |
|
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>
|
/azp run |
|
Azure Pipelines: Successfully started running 1 pipeline(s). |
|
Pretest Static Analysis build failure fixed: 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. |
Problem
show loggingbuilt a shell pipeline string and executed it withshell=True:The
processargument (and, for--lines, atailstage) 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()inshow/main.py. All commands are now built as argv lists and run withshell=False, so user input is always passed as data, never as shell syntax.How I did it
--lines:run_command(['sudo', 'cat', <log_files>], ...)— unchanged privilege posture (root reads the log files, nothing else runs).--lines:sudo cat <log_files>is always the first pipeline stage;grep -h -- processand/ortail -n Nare chained after it viasubprocess.Popen, running unprivileged (as the invoking user), the same as the oldsudo cat ... | grep ... | tail ...shell form. Onlycatneeds root, to read the log files.-hkeeps output filename-free across multiple files, preserving the previouscat ... | grep ...output shape.--separator stops aprocessvalue starting with-from being parsed as a grep option.stdouthandle in the parent after starting the next stage letsSIGPIPEpropagate normally if a downstream stage exits early.--follow(-f): unchanged —['sudo', 'tail', '-F', <current_log>], alreadyshell=False./var/logvs/var/log.tmpfsselection and rotatedsyslog.1detection logic are unchanged.Justification for the exit-code change: previously,
show logging PROCESS -l Nran as a single bash pipeline (cat | grep | tail) viashell=True. Bash pipelines report only the last command's exit status by default, so agrepfailure/no-match (exit 1) was silently masked bytail'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
--linesfix usedgetstatusoutput_noshell_pipe(), which internally calls.communicate()and therefore buffers the entire pipeline's output before printing it — a streaming regression versus the oldshell=Truepath (imperceptible for a small-l N, but delays the first line for a largeN). Addressed by chainingsubprocess.Popendirectly: 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.sudohad been scoped to the wholegrepinvocation, so the user-controlled regex ran as root instead of as the invoking user (as it did under the oldsudo cat ... | grep ...shell form). A pathological/expensive regex would therefore burn root cycles. Fixed by always runningsudo catas the first, and only privileged, pipeline stage, withgrep/tailchained after it unprivileged — restoring the original privilege boundary while keeping the argv-list (no-shell) posture.Popenpipeline bypassesrun_command()→run_command_in_alias_mode(), so underinterface_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 plainshow logging(no filter, no-l) still does. This is a real, intentional behavior difference from the pre-existingaliasmode, 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
Test cases added/updated (
tests/show_test.py)/var/logand/var/log.tmpfs, each with and without rotatedsyslog.1, for no-filter, filter,--lines, and--follow--followignores other supplied options (process,-l) as before--linesalone, and process filter +--linestogether, each asserting the exact argv and stdin/stdout wiring passed to everysubprocess.Popenstage (sudo catalways first and unprivilegedgrep/tailchained after it)cat,grepno-match, ortailitself failing)--linesvalue-(e.g.-rf /), verifying it is placed after grep's--separator, not read as an option, and thatcat(notgrep) is the privileged stage;,|,`,$(), newlines, spaces — for both the process-only and--linesPopenpipelines, asserting each stays a single argv element and is never shell-interpretedPrevious 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 defaultinterface_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 PROCESSand/orshow logging -l Nno longer substitute port names with their configured aliases in the emitted log text (they now go through a direct, unprivilegedPopenpipeline instead ofrun_command()/run_command_in_alias_mode()). Plainshow logging(no process filter, no-l) andshow logging -fare unaffected and still substitute aliases as before.