Skip to content

[v0.3.0] security — audit and document PyYAML safe-loader discipline#56

Merged
ayhammouda merged 3 commits into
mainfrom
agent/48-pyaml-safe-loader-audit
May 30, 2026
Merged

[v0.3.0] security — audit and document PyYAML safe-loader discipline#56
ayhammouda merged 3 commits into
mainfrom
agent/48-pyaml-safe-loader-audit

Conversation

@ayhammouda
Copy link
Copy Markdown
Owner

@ayhammouda ayhammouda commented May 30, 2026

Closes #48

Acceptance criteria

  • grep -rn 'yaml.load(' src/ tests/ returns zero hits — command produced no output.
  • grep -rn 'yaml.safe_load(' src/ returns at least the two expected call sites — found src/mcp_server_python_docs/server.py:57 and src/mcp_server_python_docs/ingestion/sphinx_json.py:597.
  • grep -rln '\.ya\?ml' src/mcp_server_python_docs/ shows data/synonyms.yaml is the only YAML data input loaded at runtime/ingestion — source-reference grep excluding generated __pycache__ lists server.py, ingestion/sphinx_json.py, retrieval/query.py, and __main__.py; only the first two parse YAML, both parse packaged data/synonyms.yaml with safe_load, while retrieval/query.py and __main__.py only mention/use already-loaded synonym data. find src/mcp_server_python_docs -type f \( -name '*.yaml' -o -name '*.yml' \) -print returns only src/mcp_server_python_docs/data/synonyms.yaml.
  • A new test asserts the discipline programmatically — added tests/test_synonyms.py::test_yaml_loaded_only_via_safe_load, scanning src/ and tests/ for unsafe YAML loaders, confirming both source safe-load sites, and asserting synonyms.yaml is the only package YAML file.
  • A short YAML trust boundary note is added to allowed in-repo docs — added docs/architecture/YAML-TRUST-BOUNDARY.md. SECURITY.md was not edited.

Validation gate output

$ uv run ruff check src/ tests/
All checks passed!

$ uv run pyright src/
0 errors, 0 warnings, 0 informations

$ uv run pytest --tb=short -q
299 passed in 23.76s

$ uv run python-docs-mcp-server doctor
All checks passed.

Issue-specific checks:

$ grep -rn 'yaml.load(' src/ tests/

$ grep -rn 'yaml.safe_load(' src/
src/mcp_server_python_docs/server.py:57:        data = yaml.safe_load(path.read_text())
src/mcp_server_python_docs/ingestion/sphinx_json.py:597:        data = yaml.safe_load(path.read_text())

$ grep -rln --exclude-dir=__pycache__ '\.ya\?ml' src/mcp_server_python_docs/
src/mcp_server_python_docs/server.py
src/mcp_server_python_docs/ingestion/sphinx_json.py
src/mcp_server_python_docs/retrieval/query.py
src/mcp_server_python_docs/__main__.py

$ find src/mcp_server_python_docs -type f \( -name '*.yaml' -o -name '*.yml' \) -print
src/mcp_server_python_docs/data/synonyms.yaml

CodeRabbit review

  • Blocking: None.
  • Follow-up: None.
  • False positive: None.

CodeRabbit raised a valid initial finding that the regression test should scan tests/ as well as src/; addressed in ef7736e. A valid deterministic-read nit was also addressed in bffc37f with explicit UTF-8 reads. Final CodeRabbit check is passing.

Why this approach

The issue prescribed a read-only audit, regression test, and allowed architecture note. The codebase was already clean; this PR locks the discipline into tests and documents the trust boundary without changing ingestion behavior or SECURITY.md.

Why this triggered supervisor review

None.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 30, 2026

📝 Walkthrough

Walkthrough

A new test test_yaml_loaded_only_via_safe_load enforces that YAML is loaded safely across the codebase by scanning src/ for disallowed unsafe patterns, collecting all safe_load call sites, verifying expected call sites are present, and confirming synonyms.yaml is the only packaged YAML input.

Changes

YAML Safe-Loader Trust Boundary Test

Layer / File(s) Summary
YAML safe-loader enforcement test
tests/test_synonyms.py
Adds re and Path imports to support regex scanning and filesystem path handling; introduces test_yaml_loaded_only_via_safe_load to scan src/ for unsafe yaml.load( and yaml.unsafe_load usages, collect all yaml.safe_load( call sites, assert specific expected safe-load locations are present, and verify that synonyms.yaml is the only packaged YAML input.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~8 minutes

Poem

🐰 Hippity-hop through the source code bright,
Safe YAML loading checked with might!
No risky load( shall pass our sight,
Just safe_load rules the test at night,
Synonyms secured, all done right! ✓

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: auditing and documenting PyYAML safe-loader discipline as a security measure for v0.3.0.
Linked Issues check ✅ Passed The PR successfully fulfills all acceptance criteria: zero unsafe yaml.load() found, yaml.safe_load() confirmed at expected call sites, synonyms.yaml verified as sole YAML data input, regression test added that scans for unsafe loaders, and YAML-TRUST-BOUNDARY.md documentation created.
Out of Scope Changes check ✅ Passed All changes align with the defined scope: read-only audit, regression test, and architecture documentation. No changes to YAML content, ingestion behavior, or forbidden SECURITY.md file.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch agent/48-pyaml-safe-loader-audit

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@coderabbiteu
Copy link
Copy Markdown

coderabbiteu Bot commented May 30, 2026

📝 Walkthrough

Walkthrough

This PR adds a regression test to enforce the YAML safety discipline for the repository. The test scans all Python sources to verify that no unsafe YAML loaders (yaml.load, yaml.unsafe_load) are used, records all yaml.safe_load call sites, and asserts that synonyms.yaml is the only YAML input file in the codebase.

Changes

YAML safety regression test

Layer / File(s) Summary
YAML safety regression test with imports
tests/test_synonyms.py
Imports re and Path to support source scanning. Adds test_yaml_loaded_only_via_safe_load which scans src/**/*.py files to detect unsafe YAML loading patterns, collects all yaml.safe_load(...) call sites, enumerates YAML files, and asserts (1) no unsafe loaders are used, (2) required safe-load call sites are present in server.py and sphinx_json.py, and (3) src/mcp_server_python_docs/data/synonyms.yaml is the only YAML input file.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

🐰 A test hops through source code with care,
Seeking unsafe YAML loaders here and there,
Safe-load only, the discipline's locked in place,
Synonyms.yaml holds its trusted space!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the PR’s safe-loader audit and trust-boundary documentation work.
Linked Issues check ✅ Passed The changes add the required regression test and documentation note and align with the safe_load audit goals.
Out of Scope Changes check ✅ Passed The changes stay within the audit, regression-test, and documentation scope.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch agent/48-pyaml-safe-loader-audit

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@ayhammouda ayhammouda added agent-pr-opened Autonomous agent opened an implementation PR for this issue verification-needed PR is ready for independent Heimdall verification labels May 30, 2026
Copy link
Copy Markdown

@coderabbiteu coderabbiteu 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

🧹 Nitpick comments (1)
tests/test_synonyms.py (1)

97-109: Narrow Loader= detection to YAML-call context to avoid brittle false positives.

re.compile(r"\bLoader\s*=") will match unrelated code and can fail this test for non-YAML reasons. Consider coupling this check to yaml.load( usage (or dropping it, since yaml.load is already blocked).

Suggested patch
-    loader_override = re.compile(r"\bLoader\s*=")
+    yaml_load_with_loader = re.compile(r"\byaml[.]load\s*[(].*\bLoader\s*=", re.DOTALL)
@@
-            if loader_override.search(line) and "SafeLoader" not in line:
-                violations.append(f"{relative_path}:{line_number}: custom YAML Loader")
+            if yaml_load_with_loader.search(line) and "SafeLoader" not in line:
+                violations.append(f"{relative_path}:{line_number}: custom YAML Loader")
As per coding guidelines, “`tests/**`: Focus review on meaningful assertions... and deterministic behavior...”.
🤖 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/test_synonyms.py` around lines 97 - 109, The Loader= regex
(loader_override) is too broad and triggers false positives; narrow its scope by
only flagging assignments that occur immediately in a yaml.load call context
(i.e., when a line matches unsafe_load_call or when loader appears inside a
yaml.load(...) argument), or remove the loader_override check entirely since
unsafe_load_call/unsafe_loader_name already block yaml.load; update the logic in
the loop that builds violations to check loader_override only when
unsafe_load_call.search(line) or when safe_load_call.search(line) shows a YAML
call, referencing the loader_override, unsafe_load_call, unsafe_loader_name, and
safe_load_call symbols to locate and modify the conditional.
🤖 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/test_synonyms.py`:
- Around line 85-87: The scan currently only builds src_root and includes "src/"
in the expected_yaml_input set; update the test to also include the tests
directory by creating a tests_root (or reuse repo_root) and adding "tests/"
entries to expected_yaml_input and the other scan set used around lines
referencing expected_yaml_input (also update the analogous set at the other
occurrence around lines 103-106); locate the variables repo_root, src_root and
expected_yaml_input and append the corresponding tests path (e.g., repo_root /
"tests" or str(tests_root) with the same glob pattern used for "src") so the
audit includes tests/**/*.py.

---

Nitpick comments:
In `@tests/test_synonyms.py`:
- Around line 97-109: The Loader= regex (loader_override) is too broad and
triggers false positives; narrow its scope by only flagging assignments that
occur immediately in a yaml.load call context (i.e., when a line matches
unsafe_load_call or when loader appears inside a yaml.load(...) argument), or
remove the loader_override check entirely since
unsafe_load_call/unsafe_loader_name already block yaml.load; update the logic in
the loop that builds violations to check loader_override only when
unsafe_load_call.search(line) or when safe_load_call.search(line) shows a YAML
call, referencing the loader_override, unsafe_load_call, unsafe_loader_name, and
safe_load_call symbols to locate and modify the conditional.
🪄 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

Run ID: a13dc24f-ac07-4298-b241-cf7a761f49b7

📥 Commits

Reviewing files that changed from the base of the PR and between 362fed7 and fb1c398.

⛔ Files ignored due to path filters (1)
  • docs/architecture/YAML-TRUST-BOUNDARY.md is excluded by none and included by none
📒 Files selected for processing (1)
  • tests/test_synonyms.py

Comment thread tests/test_synonyms.py
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.

🧹 Nitpick comments (3)
tests/test_synonyms.py (3)

105-105: ⚡ Quick win

Pin read_text(encoding="utf-8") for deterministic, environment-independent reads.

read_text() falls back to the locale default encoding, which can differ across CI/dev environments (e.g. cp1252 on Windows) and produce inconsistent results when scanning source files.

♻️ Proposed fix
-        for line_number, line in enumerate(source_path.read_text().splitlines(), 1):
+        for line_number, line in enumerate(
+            source_path.read_text(encoding="utf-8").splitlines(), 1
+        ):
🤖 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/test_synonyms.py` at line 105, The file reads source files with
source_path.read_text() which uses the system locale; change the call to
source_path.read_text(encoding="utf-8") to force UTF-8 decoding for
deterministic, environment-independent test runs (locate the loop using
source_path.read_text() in tests/test_synonyms.py and update that invocation).

86-121: ⚡ Quick win

Scan misses tests/, which the trust boundary also covers.

The PR acceptance criteria is "no occurrences of yaml.load( in src/ or tests/", but the guard only walks src_root. An unsafe loader added under tests/ would slip past this regression test.

♻️ Extend the scan roots to include tests/
     repo_root = Path(__file__).resolve().parents[1]
-    src_root = repo_root / "src"
+    scan_roots = (repo_root / "src", repo_root / "tests")
@@
-    for source_path in sorted(src_root.rglob("*.py")):
+    py_files = sorted(
+        p for root in scan_roots for p in root.rglob("*.py")
+    )
+    for source_path in py_files:
         relative_path = source_path.relative_to(repo_root).as_posix()

Note: keep the packaged-YAML enumeration (yaml_inputs) scoped to src_root so the synonyms.yaml assertion is unaffected.

🤖 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/test_synonyms.py` around lines 86 - 121, The test currently only scans
src_root for unsafe yaml usages (variables: src_root, violations,
safe_load_sites) so occurrences in tests/ are missed; modify the scanning to
include the tests directory in the walk (e.g., create a tests_root = repo_root /
"tests" and iterate over both roots or build a combined iterable of roots) when
collecting violations and safe_load_sites, while keeping the packaged-YAML
enumeration (yaml_inputs) unchanged and still only scanning src_root so the
synonyms.yaml assertion remains scoped to source files.

106-111: ⚖️ Poor tradeoff

Line-based scan can false-positive on comments/strings.

Because matching runs over raw lines, a benign mention of yaml.load( or Loader = in a comment, docstring, or string literal inside src/ would fail this lock-in test spuriously. Acceptable for now given the audited codebase, but an AST-based check (ast.walk for Call/Attribute nodes) would be more robust if false alarms appear later.

🤖 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/test_synonyms.py` around lines 106 - 111, The current line-based regex
scan (using unsafe_load_call, unsafe_loader_name, loader_override,
safe_load_call) can false-positive on comments/strings; replace the per-line
checks with an AST-based scan: parse the file content with ast.parse and
ast.walk to find ast.Call nodes whose function is either an ast.Attribute with
value named "yaml" and attr "load" or a Name "load" imported from yaml, then
inspect call.keywords/args for a "Loader" argument (and mark unsafe if Loader is
not SafeLoader), collect safe sites when Loader is SafeLoader or safe_load is
used, and detect loader override via ast.Assign where target id/name matches
loader variables (e.g., assignments to "Loader" or custom loader classes).
Update the code that currently appends to violations and safe_load_sites to use
these AST checks instead of raw line regex matches.
🤖 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.

Nitpick comments:
In `@tests/test_synonyms.py`:
- Line 105: The file reads source files with source_path.read_text() which uses
the system locale; change the call to source_path.read_text(encoding="utf-8") to
force UTF-8 decoding for deterministic, environment-independent test runs
(locate the loop using source_path.read_text() in tests/test_synonyms.py and
update that invocation).
- Around line 86-121: The test currently only scans src_root for unsafe yaml
usages (variables: src_root, violations, safe_load_sites) so occurrences in
tests/ are missed; modify the scanning to include the tests directory in the
walk (e.g., create a tests_root = repo_root / "tests" and iterate over both
roots or build a combined iterable of roots) when collecting violations and
safe_load_sites, while keeping the packaged-YAML enumeration (yaml_inputs)
unchanged and still only scanning src_root so the synonyms.yaml assertion
remains scoped to source files.
- Around line 106-111: The current line-based regex scan (using
unsafe_load_call, unsafe_loader_name, loader_override, safe_load_call) can
false-positive on comments/strings; replace the per-line checks with an
AST-based scan: parse the file content with ast.parse and ast.walk to find
ast.Call nodes whose function is either an ast.Attribute with value named "yaml"
and attr "load" or a Name "load" imported from yaml, then inspect
call.keywords/args for a "Loader" argument (and mark unsafe if Loader is not
SafeLoader), collect safe sites when Loader is SafeLoader or safe_load is used,
and detect loader override via ast.Assign where target id/name matches loader
variables (e.g., assignments to "Loader" or custom loader classes). Update the
code that currently appends to violations and safe_load_sites to use these AST
checks instead of raw line regex matches.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 1c5df3aa-4539-4ac0-aeca-79847735fd39

📥 Commits

Reviewing files that changed from the base of the PR and between 362fed7 and fb1c398.

⛔ Files ignored due to path filters (1)
  • docs/architecture/YAML-TRUST-BOUNDARY.md is excluded by none and included by none
📒 Files selected for processing (1)
  • tests/test_synonyms.py

@ayhammouda
Copy link
Copy Markdown
Owner Author

Heimdall verification passed for PR #56 at bffc37fa151d4a992d774ce9e4d268441b049907.

Independent checkout: /tmp/heimdall-python-docs-pr56, fresh clone of ayhammouda/python-docs-mcp-server, PR ref pull/56/head.

Protocol/context read:

Scope verification:

  • Changed files are only docs/architecture/YAML-TRUST-BOUNDARY.md and tests/test_synonyms.py.
  • No forbidden territory touched; SECURITY.md was not edited.
  • The test addition extends existing synonym coverage without deleting or weakening tests.
  • Trust-boundary documentation matches the audited source state: synonyms.yaml is the only packaged YAML input and the only source parser call sites use yaml.safe_load.

Canonical gate run locally:

$ uv run ruff check src/ tests/
All checks passed!

$ uv run pyright src/
0 errors, 0 warnings, 0 informations

$ uv run pytest --tb=short -q
299 passed in 23.52s

$ uv run python-docs-mcp-server doctor
All checks passed.

Issue-specific checks:

$ grep -rn 'yaml.load(' src/ tests/
# no output

$ grep -rn 'yaml.safe_load(' src/
src/mcp_server_python_docs/server.py:57:        data = yaml.safe_load(path.read_text())
src/mcp_server_python_docs/ingestion/sphinx_json.py:597:        data = yaml.safe_load(path.read_text())

$ grep -rln --exclude-dir=__pycache__ '\.ya\?ml' src/mcp_server_python_docs/
src/mcp_server_python_docs/server.py
src/mcp_server_python_docs/ingestion/sphinx_json.py
src/mcp_server_python_docs/retrieval/query.py
src/mcp_server_python_docs/__main__.py

$ find src/mcp_server_python_docs -type f \( -name '*.yaml' -o -name '*.yml' \) -print | sort
src/mcp_server_python_docs/data/synonyms.yaml

$ uv run pytest tests/test_synonyms.py::test_yaml_loaded_only_via_safe_load tests/test_synonyms.py -q
6 passed in 0.37s

$ git diff --check origin/main...HEAD
# clean

GitHub checks at verification time:

  • CI matrix: pass for Python 3.12/3.13 on Ubuntu and macOS.
  • Security Audit: pass.
  • CodeQL: pass.
  • CodeRabbit status: pass.

CodeRabbit triage:

  • Blocking: none remaining.
  • Resolved: initial scan-scope finding was addressed by ef7736e; deterministic UTF-8 read was addressed by bffc37f.
  • Follow-up / non-blocking: AST-based scan would be more robust against future false positives, but the current grep-style test satisfies issue [v0.3.0] security — audit and document PyYAML safe-loader discipline #48 and the audited codebase is clean.
  • Open review threads query returned no unresolved, non-outdated threads.

Label action: replacing verification-needed with verified. No merge or approval performed.

@ayhammouda ayhammouda added verified Independent Heimdall verification passed and removed verification-needed PR is ready for independent Heimdall verification labels May 30, 2026
@ayhammouda ayhammouda merged commit cf8fc97 into main May 30, 2026
8 checks passed
@ayhammouda ayhammouda deleted the agent/48-pyaml-safe-loader-audit branch May 30, 2026 13:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent-pr-opened Autonomous agent opened an implementation PR for this issue verified Independent Heimdall verification passed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[v0.3.0] security — audit and document PyYAML safe-loader discipline

1 participant