Skip to content

fix: support continuation lines in scan_sudoers#122

Open
richm wants to merge 1 commit into
linux-system-roles:mainfrom
richm:fix-continuation-lines
Open

fix: support continuation lines in scan_sudoers#122
richm wants to merge 1 commit into
linux-system-roles:mainfrom
richm:fix-continuation-lines

Conversation

@richm
Copy link
Copy Markdown
Contributor

@richm richm commented May 22, 2026

Cause: The scan_sudoers parser did not handle continuation lines in /etc/sudoers

Consequence: The /etc/sudoers file was not parsed correctly, and the role would
think it needed to change /etc/sudoers, and so was not idempotent.

Fix: scan_sudoers is fixed to correctly process continuation lines.

Result: The role is idempotent if the existing /etc/sudoers file uses
continuation lines.

Signed-off-by: Rich Megginson rmeggins@redhat.com

Summary by CodeRabbit

  • Improvements

    • Sudoers parsing now correctly resolves and handles line continuations so multi-line directives and aliases are parsed as intended.
  • Tests

    • Added tests validating sudoers parsing with multi-line aliases and continuation syntax to ensure accurate scan results across varied sudoers formats.

Review Change Stack

@richm richm requested a review from spetrosi as a code owner May 22, 2026 18:02
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 22, 2026

📝 Walkthrough

Walkthrough

This PR adds line continuation support to the sudoers scanner. The get_config_lines() function now preprocesses sudoers file contents to resolve backslash-newline continuations per the sudoers manual before parsing defaults, aliases, and user specs. The test suite validates alias parsing when definitions span multiple lines with continuations.

Changes

Sudoers line continuation support

Layer / File(s) Summary
Line continuation preprocessing
library/scan_sudoers.py
get_config_lines() adds a regex-based preprocessing step that removes unescaped backslash-newline sequences from sudoers content before the parsing loop, enabling proper handling of multi-line directives.
Test validation with line continuations
tests/tests_scan_sudoers.yml
Test defines expected parsed output for /etc/sudoers with aliases, adjusts Jinja templating to write alias definitions with continuation syntax, and asserts that ansible_facts.sudoers matches the expected output including properly parsed continuation-split aliases.
🚥 Pre-merge checks | ✅ 4 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description Format ⚠️ Warning PR description uses "Cause/Consequence/Fix/Result" structure instead of required "Enhancement/Feature", "Reason", "Result" template format from .github/pull_request_template.md. Rewrite PR description to follow template: start with "Enhancement:" or "Feature:", add "Reason:" section (currently labeled "Cause:"), keep "Result:" section, optional "Issue Tracker Tickets:". Remove "Consequence:" and "Fix:" sections.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The pull request title follows the Conventional Commits format with 'fix' type and clearly describes the change: supporting continuation lines in scan_sudoers.
Description check ✅ Passed The pull request description covers the cause, consequence, fix, and result of the changes, though it uses 'Cause/Consequence/Fix/Result' format instead of the template's 'Enhancement/Reason/Result' sections.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov
Copy link
Copy Markdown

codecov Bot commented May 22, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
⚠️ Please upload report for BASE (main@36574a0). Learn more about missing BASE report.

Additional details and impacted files
@@           Coverage Diff           @@
##             main     #122   +/-   ##
=======================================
  Coverage        ?   47.90%           
=======================================
  Files           ?        2           
  Lines           ?      382           
  Branches        ?        0           
=======================================
  Hits            ?      183           
  Misses          ?      199           
  Partials        ?        0           

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@richm
Copy link
Copy Markdown
Contributor Author

richm commented May 22, 2026

fixes #121

Cause: The scan_sudoers parser did not handle continuation lines in /etc/sudoers

Consequence: The /etc/sudoers file was not parsed correctly, and the role would
think it needed to change /etc/sudoers, and so was not idempotent.

Fix: scan_sudoers is fixed to correctly process continuation lines.

Result: The role is idempotent if the existing /etc/sudoers file uses
continuation lines.

Signed-off-by: Rich Megginson <rmeggins@redhat.com>
@richm richm force-pushed the fix-continuation-lines branch from b61f705 to 60ef18c Compare May 22, 2026 18:05
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@library/scan_sudoers.py`:
- Around line 225-231: get_includes currently parses the raw sudoers text before
continuation-line normalization, so any `#include/`#includedir split across lines
with a trailing backslash are missed; update get_includes to perform the same
continuation-folding used later (the re.sub r"(?<!\\)\\\n" replacement on
all_lines) before scanning for include/#includedir directives so folded lines
produce correct include tokens, preserving escaped backslashes as the existing
regex does.

In `@tests/tests_scan_sudoers.yml`:
- Around line 81-83: Extend the continuation-lines test that currently asserts
"__expected_scan_sudoers_parsed_continations == ansible_facts.sudoers" by adding
two checks: (1) a failure-path case that supplies a sudoers input with an
escaped trailing backslash (so the line must NOT be continued) and asserts the
parsed result differs from the happy-path expected value (use a distinct
expected variable, e.g., __expected_scan_sudoers_escaped_backslash, and assert
it != ansible_facts.sudoers or compare to that escaped expectation), and (2) an
idempotence check that runs the role a second time and asserts no changes (use
the existing play host result facts and add an assert that the second run’s
changed count is 0 or that ansible_facts.sudoers still equals the happy-path
expected). Target the same test blocks that use ansible_facts.sudoers and
__expected_scan_sudoers_parsed_continations so the new asserts are colocated
with the original continuation test.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 100ede1b-d4a5-4fa4-b7ce-f62e2038cf37

📥 Commits

Reviewing files that changed from the base of the PR and between 36574a0 and b61f705.

📒 Files selected for processing (2)
  • library/scan_sudoers.py
  • tests/tests_scan_sudoers.yml

Comment thread library/scan_sudoers.py
Comment on lines +225 to +231
# process continuation lines - from the man page for sudoers:
# Long lines can be continued with a backslash (‘\’) as the last
# character on the line.
# replace backslash followed by a newline with an empty string, but only if the backslash
# is not escaped (preceded by a backslash)
# I guess someone might have a legitimate use case for ending a line with a backslash . . .
all_lines = re.sub(r"(?<!\\)\\\n", "", all_lines)
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot May 22, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fold continuation lines before include discovery.

At Line 215, get_includes(path) parses the raw file before the normalization added at Line 231. If #include/#includedir uses continuation syntax, those includes are missed and child sudoers files are not scanned.

Proposed fix
+def _fold_continuations(text):
+    # Fold unescaped backslash-newline continuations
+    return re.sub(r"(?<!\\)\\\n", "", text)
+
 def get_includes(path):
     # Get includes
     fp = open(path, "r")
-    sudoers_file = fp.read()
+    sudoers_file = _fold_continuations(fp.read())
     fp.close()
@@
-    all_lines = re.sub(r"(?<!\\)\\\n", "", all_lines)
+    all_lines = _fold_continuations(all_lines)
🧰 Tools
🪛 Ruff (0.15.13)

[warning] 226-226: Comment contains ambiguous (LEFT SINGLE QUOTATION MARK). Did you mean ``` (GRAVE ACCENT)?

(RUF003)


[warning] 226-226: Comment contains ambiguous (RIGHT SINGLE QUOTATION MARK). Did you mean ``` (GRAVE ACCENT)?

(RUF003)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@library/scan_sudoers.py` around lines 225 - 231, get_includes currently
parses the raw sudoers text before continuation-line normalization, so any
`#include/`#includedir split across lines with a trailing backslash are missed;
update get_includes to perform the same continuation-folding used later (the
re.sub r"(?<!\\)\\\n" replacement on all_lines) before scanning for
include/#includedir directives so folded lines produce correct include tokens,
preserving escaped backslashes as the existing regex does.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

yeah - this parser is not ideal - I want to replace it all with cvtsudoers, but mapping the output of cvtsudoers to the format expected by the role input is pretty tricky - I really don't want to spend any more time fixing the existing parsing code than is necessary - I think

  • support for continuation lines is necessary
  • it is highly unlikely that an include directive will use a continuation line, therefore, unnecessary

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@richm, understood — the parser is a known limitation and the continuation-line fix you've added is sufficient for the current goal of restoring idempotence. The get_includes edge case is an acceptable deferral given how uncommon a continuation-split #include directive would be in practice.

Would you like me to open a follow-up GitHub issue to track the planned migration from the current parser to cvtsudoers?


✏️ Learnings added
Learnt from: richm
Repo: linux-system-roles/sudo PR: 122
File: library/scan_sudoers.py:225-231
Timestamp: 2026-05-22T18:12:36.843Z
Learning: In the `linux-system-roles/sudo` repository, the `library/scan_sudoers.py` module is a known imperfect parser. The maintainer (richm) plans to eventually replace it entirely with `cvtsudoers`, but mapping `cvtsudoers` output to the role's expected input format is non-trivial. Incremental fixes to the existing parser should be kept minimal — only what is strictly necessary for correctness. For example, fixing continuation-line handling in `get_includes()` for `#include`/`#includedir` directives was explicitly deemed unnecessary because such usage is highly unlikely in practice.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Comment on lines +81 to +83
- name: Assert that scan_sudoers parsed the sudoers file correctly with continuation lines
assert:
that: __expected_scan_sudoers_parsed_continations == ansible_facts.sudoers
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot May 22, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add failure-path and idempotence checks to this continuation test.

This assertion only validates the happy path once. Add (1) a failure scenario (e.g., escaped trailing backslash that should not continue) and (2) a second role run with an idempotence assertion.

Minimal extension sketch
+        - name: Run the role a second time (idempotence check)
+          include_tasks: tasks/run_role_with_clear_facts.yml
+          vars:
+            sudo_rewrite_default_sudoers_file: true
+            sudo_remove_unauthorized_included_files: true
+            sudo_sudoers_files:
+              - path: /etc/sudoers
+                aliases: "{{ aliases_in_role_input_format }}"
+          register: __second_run
+
+        - name: Assert idempotence on second run
+          assert:
+            that:
+              - not __second_run.changed

As per coding guidelines, tests in tests/tests_*.yml should verify both success and failure scenarios and be idempotent.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/tests_scan_sudoers.yml` around lines 81 - 83, Extend the
continuation-lines test that currently asserts
"__expected_scan_sudoers_parsed_continations == ansible_facts.sudoers" by adding
two checks: (1) a failure-path case that supplies a sudoers input with an
escaped trailing backslash (so the line must NOT be continued) and asserts the
parsed result differs from the happy-path expected value (use a distinct
expected variable, e.g., __expected_scan_sudoers_escaped_backslash, and assert
it != ansible_facts.sudoers or compare to that escaped expectation), and (2) an
idempotence check that runs the role a second time and asserts no changes (use
the existing play host result facts and add an assert that the second run’s
changed count is 0 or that ansible_facts.sudoers still equals the happy-path
expected). Target the same test blocks that use ansible_facts.sudoers and
__expected_scan_sudoers_parsed_continations so the new asserts are colocated
with the original continuation test.

Copy link
Copy Markdown
Contributor Author

@richm richm May 22, 2026

Choose a reason for hiding this comment

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

yeah - this parser is not ideal - I want to replace it all with cvtsudoers, but mapping the output of cvtsudoers to the format expected by the role input is pretty tricky - I really don't want to spend any more time fixing the existing parsing code than is necessary

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@richm, that's a completely reasonable call — the fix addresses the immediate idempotence bug without over-investing in a parser that's slated for replacement. I'll drop the suggestion.

If you'd like, I can open a follow-up issue to track the cvtsudoers-based migration so it isn't lost. Just let me know!


✏️ Learnings added
Learnt from: richm
Repo: linux-system-roles/sudo PR: 122
File: tests/tests_scan_sudoers.yml:81-83
Timestamp: 2026-05-22T18:12:48.559Z
Learning: In the `linux-system-roles/sudo` role, the `scan_sudoers` parser (library/scan_sudoers.py) is intentionally minimal and the maintainer (richm) plans to replace it entirely with `cvtsudoers`. Do not suggest expanding or improving the existing parser beyond critical bug fixes.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

@richm
Copy link
Copy Markdown
Contributor Author

richm commented May 22, 2026

[citest]

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (2)
library/scan_sudoers.py (1)

225-231: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Apply continuation folding to get_includes() as well.

The get_includes() function at line 126 reads and parses the sudoers file before continuation folding is applied. If #include or #includedir directives use continuation syntax, they won't be detected and child sudoers files will be missed.

This issue was already flagged in a previous review.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@library/scan_sudoers.py` around lines 225 - 231, get_includes() currently
parses the raw sudoers text before folding continuations, so
`#include/`#includedir lines that use a backslash newline are missed; apply the
same continuation-folding regex used for all_lines (re.sub(r"(?<!\\)\\\n", "",
...)) to the file contents inside get_includes() immediately after reading the
file and before searching for include directives, so continuation-joined lines
are detected while preserving escaped backslashes.
tests/tests_scan_sudoers.yml (1)

81-83: 🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Add failure scenarios and idempotence checks.

This test only validates the happy path. Per coding guidelines, tests must verify both success and failure scenarios and must be idempotent.

Missing test coverage:

  1. Failure scenario: Test that escaped backslashes (e.g., \\ at end of line) do NOT trigger continuation
  2. Idempotence: Run the role a second time and assert no changes

As per coding guidelines, tests in tests/tests_*.yml should verify both success and failure scenarios and be idempotent - running twice should not cause failures.

This issue was already flagged in a previous review.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/tests_scan_sudoers.yml` around lines 81 - 83, The test only covers the
happy path; add a failure-case assertion and an idempotence check: extend the
playbook/tasks around the existing task that asserts
__expected_scan_sudoers_parsed_continations == ansible_facts.sudoers by adding a
new expected fixture (or inline expected data) that includes a line ending with
an escaped backslash (\\) and assert that this escaped backslash does NOT
produce a continuation entry in ansible_facts.sudoers (use a negative assert
comparing the failure-expected structure), and then add a second run of the role
(or a handler to re-run the relevant task) and assert no changes occurred on the
second run (e.g., assert result.changed is false or assert that
ansible_run_changes == 0) so the test is idempotent; reference the existing
variable __expected_scan_sudoers_parsed_continations and ansible_facts.sudoers
to locate where to add these assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/tests_scan_sudoers.yml`:
- Line 68: Replace the incorrect empty-separator join in the template so alias
value lists are comma-separated; change the expression using itemvals.1
(currently "{{ itemvals.1 | join("") }}") to join with ", " (e.g., "{{
itemvals.1 | join(', ') }}") so multi-item lists like ['/bin/ls','/bin/cat']
render as "/bin/ls, /bin/cat" instead of being concatenated into
"/bin/ls/bin/cat".

---

Duplicate comments:
In `@library/scan_sudoers.py`:
- Around line 225-231: get_includes() currently parses the raw sudoers text
before folding continuations, so `#include/`#includedir lines that use a backslash
newline are missed; apply the same continuation-folding regex used for all_lines
(re.sub(r"(?<!\\)\\\n", "", ...)) to the file contents inside get_includes()
immediately after reading the file and before searching for include directives,
so continuation-joined lines are detected while preserving escaped backslashes.

In `@tests/tests_scan_sudoers.yml`:
- Around line 81-83: The test only covers the happy path; add a failure-case
assertion and an idempotence check: extend the playbook/tasks around the
existing task that asserts __expected_scan_sudoers_parsed_continations ==
ansible_facts.sudoers by adding a new expected fixture (or inline expected data)
that includes a line ending with an escaped backslash (\\) and assert that this
escaped backslash does NOT produce a continuation entry in ansible_facts.sudoers
(use a negative assert comparing the failure-expected structure), and then add a
second run of the role (or a handler to re-run the relevant task) and assert no
changes occurred on the second run (e.g., assert result.changed is false or
assert that ansible_run_changes == 0) so the test is idempotent; reference the
existing variable __expected_scan_sudoers_parsed_continations and
ansible_facts.sudoers to locate where to add these assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 017b7e3d-e3af-443e-89cb-09c66847c3fc

📥 Commits

Reviewing files that changed from the base of the PR and between b61f705 and 60ef18c.

📒 Files selected for processing (2)
  • library/scan_sudoers.py
  • tests/tests_scan_sudoers.yml

{{ alias.0 }} {{ itemvals.0 }}{{ space }}={{ space }}{{ itemvals.1 | join("") }}
{{ alias.0 }} \
{{ itemvals.0 }}{{ space }}={{ space }}\
{{ itemvals.1 | join("") }}
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot May 22, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fix incorrect list concatenation in alias values.

Line 68 uses join("") which concatenates list items with no separator. For multi-item lists like ['/bin/ls', '/bin/cat'], this produces invalid sudoers syntax /bin/ls/bin/cat instead of the required /bin/ls, /bin/cat.

The test currently passes only because all test data uses single-item lists, hiding this bug.

🔧 Proposed fix
-              {{ itemvals.1 | join("") }}
+              {{ itemvals.1 | join(", ") }}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{{ itemvals.1 | join("") }}
{{ itemvals.1 | join(", ") }}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/tests_scan_sudoers.yml` at line 68, Replace the incorrect
empty-separator join in the template so alias value lists are comma-separated;
change the expression using itemvals.1 (currently "{{ itemvals.1 | join("") }}")
to join with ", " (e.g., "{{ itemvals.1 | join(', ') }}") so multi-item lists
like ['/bin/ls','/bin/cat'] render as "/bin/ls, /bin/cat" instead of being
concatenated into "/bin/ls/bin/cat".

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

this is true, but in the case of the test, there is only one value, so the join("") just converts the list with one element to a string.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@richm, understood — since the test data has single-item lists, join("") is effectively equivalent to join(", ") here and the output is correct. My concern was about future-proofing in case multi-item lists are added, but it doesn't affect the current test.

Would you like me to open a follow-up issue to track making the join separator explicit (, ) as a defensive improvement for when test data is extended?

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.

2 participants