VYPR
Medium severity5.5GHSA Advisory· Published May 28, 2026· Updated May 28, 2026

Shamefile has an arbitrary file read via shamefile.yaml in shame next

CVE-2026-47144

Description

Impact

A path traversal vulnerability in shame next allows an attacker-controlled shamefile.yaml to disclose contents of files outside the repository, one line at a time, to the terminal of a user who runs the command. See patch commit for technical details.

Patches

Fixed in 0.1.7. Upgrade to either 0.1.7 or later versions to incorporate the patch.

Workarounds

Do not run shame next against untrusted shamefile.yaml. Use shame me --dry-run for CI validation.

Resources

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Path traversal in `shame next` allows reading arbitrary files via crafted `shamefile.yaml`.

Vulnerability

A path traversal vulnerability in the shame next command of shamefile versions <=0.1.6 allows an attacker-controlled shamefile.yaml to disclose the contents of files outside the repository, one line at a time, to the terminal of a user who runs the command [1][2][3]. The vulnerable print_entry_snippet function read a file path from the entry.location field without validating that the path stays within the repository [1][3].

Exploitation

An attacker crafts a shamefile.yaml with an entry.location pointing to a target file outside the repository (e.g., /etc/passwd). When a victim runs shame next with this malicious file, the function reads the file and prints the first line of the target file to the terminal [1][2][3]. No authentication or special privileges are required beyond the victim executing the command against the attacker-controlled YAML [2][4].

Impact

Successful exploitation leads to an information disclosure: an attacker can read the first line of any file on the system that the victim can read [1][2][3]. This could expose sensitive data such as credentials, configuration files, or other secrets. The confidentiality impact is high, while integrity and availability remain unaffected [4].

Mitigation

The vulnerability is fixed in shamefile version 0.1.7 [1][2][3]. Users should upgrade to this version or later. As a workaround, do not run shame next against untrusted shamefile.yaml files. For CI validation, use shame me --dry-run instead [2]. No EOL status or KEV listing is known.

AI Insight generated on May 28, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

5

Patches

1
77b0aeea3185

fix(main): render shame next snippet from registry, not disk (#80)

https://github.com/BKDDFS/shamefileBartłomiej FlisMay 17, 2026via ghsa
5 files changed · +268 30
  • CHANGELOG.md+7 0 modified
    @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
     
     ## [Unreleased]
     
    +### Security
    +
    +- Fix path traversal in `shame next` that allowed a crafted
    +  `shamefile.yaml` entry to disclose one line of any file readable by the
    +  current user. The snippet renderer no longer reads from disk; output is
    +  rendered from the registry's cached `content` field instead. CWE-22.
    +
     ## [0.1.6](https://github.com/BKDDFS/shamefile/compare/v0.1.5...v0.1.6) - 2026-05-16
     
     ### Other
    
  • e2e_tests/features/shame_next_path_traversal.feature+41 0 added
    @@ -0,0 +1,41 @@
    +Feature: shame next snippet rendering
    +  shame next renders the suppression snippet from the registry's cached
    +  `content` field. The renderer must not open or display files referenced
    +  by `location`, so that a `location` pointing outside the repository
    +  (absolute path or `..` traversal) never discloses file contents.
    +
    +  Scenario: Absolute location pointing outside the repository does not leak file contents
    +    Given a project with a hand-crafted shamefile.yaml
    +    And a sensitive file outside the project containing "SHOULD_NOT_LEAK_AAAA"
    +    And the registry has an entry whose location is the absolute path of that file at line 1
    +    When I run shame next
    +    Then the command exits with code 0
    +    And stdout does not contain "SHOULD_NOT_LEAK_AAAA"
    +    And stdout contains "placeholder"
    +
    +  Scenario: Parent-dir traversal in location does not leak file contents
    +    Given a project with a hand-crafted shamefile.yaml
    +    And a sensitive file outside the project containing "OUTSIDE_REPO_BBBB"
    +    And the registry has an entry whose location is a "../"-prefixed path to that file at line 1
    +    When I run shame next
    +    Then the command exits with code 0
    +    And stdout does not contain "OUTSIDE_REPO_BBBB"
    +    And stdout contains "placeholder"
    +
    +  Scenario: shame next with reason advances without leaking the next entry's target file
    +    Given a project with a hand-crafted shamefile.yaml
    +    And a sensitive file outside the project containing "CHAINED_LEAK_CCCC"
    +    And the registry has two undocumented entries: a benign one followed by an absolute path to that file at line 1
    +    When I run shame next with reason "benign fix"
    +    Then the command exits with code 0
    +    And stdout does not contain "CHAINED_LEAK_CCCC"
    +    And stdout contains "placeholder"
    +
    +  Scenario: Legitimate entry renders snippet from registry content
    +    Given a project with a hand-crafted shamefile.yaml
    +    And the registry has an entry at "./code.py:42" with token "# noqa" and content "x = 1  # noqa"
    +    When I run shame next
    +    Then the command exits with code 0
    +    And stdout contains "./code.py:42"
    +    And stdout contains "x = 1  # noqa"
    +    And stdout contains "  42|"
    
  • e2e_tests/test_shame_next_path_traversal.py+137 0 added
    @@ -0,0 +1,137 @@
    +"""BDD tests for shame next snippet rendering.
    +
    +Feature file: features/shame_next_path_traversal.feature.
    +"""
    +
    +from __future__ import annotations
    +
    +import os
    +import subprocess
    +from typing import TYPE_CHECKING
    +
    +from conftest import BINARY_PATH, run_shame_next
    +from pytest_bdd import given, parsers, scenarios, then, when
    +
    +if TYPE_CHECKING:
    +    from pathlib import Path
    +
    +scenarios("features/shame_next_path_traversal.feature")
    +
    +
    +def _entry_yaml(location: str, token: str, content: str) -> str:
    +    """Format a single registry entry block."""
    +    return (
    +        f"  - location: {location}\n"
    +        f"    token: '{token}'\n"
    +        f"    content: '{content}'\n"
    +        "    created_at: 2024-01-01\n"
    +        "    owner: attacker\n"
    +        "    why: ''\n"
    +    )
    +
    +
    +def _write_registry(project_path: Path, *entries: str) -> None:
    +    """Write shamefile.yaml containing the given entry blocks in order."""
    +    yaml = "# yamllint disable-file\n---\nconfig: {}\nentries:\n\n" + "\n".join(entries)
    +    (project_path / "shamefile.yaml").write_text(yaml, encoding="utf-8")
    +
    +
    +# --- Given ---
    +
    +
    +@given("a project with a hand-crafted shamefile.yaml", target_fixture="project")
    +def project_with_handcrafted_registry(tmp_path):
    +    """Allocate an empty project directory; registry is filled by later steps."""
    +    return {"path": tmp_path, "result": None, "secret_path": None}
    +
    +
    +@given(parsers.parse('a sensitive file outside the project containing "{marker}"'))
    +def sensitive_file_outside_project(project, tmp_path_factory, marker):
    +    """Place a file in a sibling tmp directory, outside the project root."""
    +    outside_dir = tmp_path_factory.mktemp("outside")
    +    secret_path = outside_dir / "secret.txt"
    +    secret_path.write_text(f"{marker}\nline two\n", encoding="utf-8")
    +    project["secret_path"] = secret_path
    +
    +
    +@given("the registry has an entry whose location is the absolute path of that file at line 1")
    +def registry_with_absolute_location(project):
    +    """Write an entry whose location is the absolute path of the sensitive file."""
    +    abs_path = str(project["secret_path"])
    +    _write_registry(project["path"], _entry_yaml(f"{abs_path}:1", "# noqa", "placeholder"))
    +
    +
    +@given('the registry has an entry whose location is a "../"-prefixed path to that file at line 1')
    +def registry_with_relative_traversal(project):
    +    """Write an entry whose location is a ../-prefixed path to the sensitive file."""
    +    rel = os.path.relpath(project["secret_path"], project["path"]).replace("\\", "/")
    +    _write_registry(project["path"], _entry_yaml(f"{rel}:1", "# noqa", "placeholder"))
    +
    +
    +@given(
    +    "the registry has two undocumented entries: "
    +    "a benign one followed by an absolute path to that file at line 1"
    +)
    +def registry_with_benign_then_malicious(project):
    +    """Write two entries: first benign (to be documented), second malicious (next in queue)."""
    +    benign = _entry_yaml("./benign.py:1", "# noqa", "benign_content")
    +    malicious = _entry_yaml(f"{project['secret_path']}:1", "# noqa", "placeholder")
    +    _write_registry(project["path"], benign, malicious)
    +
    +
    +@given(
    +    parsers.parse(
    +        'the registry has an entry at "{location}" with token "{token}" and content "{content}"'
    +    )
    +)
    +def registry_with_legitimate_entry(project, location, token, content):
    +    """Write a normal entry at the given relative location."""
    +    _write_registry(project["path"], _entry_yaml(location, token, content))
    +
    +
    +# --- When ---
    +
    +
    +@when("I run shame next")
    +def run_next(project):
    +    """Invoke shame next against the project."""
    +    project["result"] = run_shame_next(project["path"])
    +
    +
    +@when(parsers.parse('I run shame next with reason "{reason}"'))
    +def run_next_with_reason(project, reason):
    +    """Invoke shame next with a reason argument."""
    +    project["result"] = subprocess.run(
    +        [BINARY_PATH, "next", reason],
    +        capture_output=True,
    +        text=True,
    +        cwd=str(project["path"]),
    +        check=False,
    +    )
    +
    +
    +# --- Then ---
    +
    +
    +@then(parsers.parse("the command exits with code {code:d}"))
    +def check_exit_code(project, code):
    +    """Verify the last command's exit code."""
    +    assert project["result"].returncode == code, (
    +        f"exit code {project['result'].returncode}\n"
    +        f"stdout: {project['result'].stdout}\n"
    +        f"stderr: {project['result'].stderr}"
    +    )
    +
    +
    +@then(parsers.parse('stdout contains "{text}"'))
    +def check_stdout_contains(project, text):
    +    """Verify stdout contains the given substring."""
    +    assert text in project["result"].stdout, f"{text!r} not in stdout: {project['result'].stdout!r}"
    +
    +
    +@then(parsers.parse('stdout does not contain "{text}"'))
    +def check_stdout_absent(project, text):
    +    """Verify stdout does not contain the given substring."""
    +    assert text not in project["result"].stdout, (
    +        f"unexpected leak: {text!r} found in stdout:\n{project['result'].stdout}"
    +    )
    
  • e2e_tests/test_shame_next.py+6 7 modified
    @@ -68,8 +68,8 @@ def test_next_no_registry(tmp_path):
         assert "Registry not found" in result.stderr
     
     
    -def test_next_snippet_handles_missing_source_file(tmp_path):
    -    """Shame next should print location only when entry's source file is gone."""
    +def test_next_snippet_renders_from_registry_when_source_file_missing(tmp_path):
    +    """Snippet body comes from registry content, even when the source file is gone."""
         registry = tmp_path / "shamefile.yaml"
         registry.write_text(
             "---\n"
    @@ -87,12 +87,11 @@ def test_next_snippet_handles_missing_source_file(tmp_path):
     
         assert result.returncode == 0
         assert "./gone.py:1" in result.stdout
    -    # No snippet rendered because source file does not exist.
    -    assert "    |" not in result.stdout
    +    assert "   1| x = 1  # noqa" in result.stdout
     
     
    -def test_next_snippet_handles_line_beyond_eof(tmp_path):
    -    """Shame next should skip the snippet body when the line is past EOF."""
    +def test_next_snippet_renders_registered_line_number_regardless_of_disk(tmp_path):
    +    """Snippet uses the line number from the registry, not from the file on disk."""
         (tmp_path / "short.py").write_text("only_one_line = 1\n")
         registry = tmp_path / "shamefile.yaml"
         registry.write_text(
    @@ -111,7 +110,7 @@ def test_next_snippet_handles_line_beyond_eof(tmp_path):
     
         assert result.returncode == 0
         assert "./short.py:99" in result.stdout
    -    assert "    |" not in result.stdout
    +    assert "  99| x" in result.stdout
     
     
     def test_next_with_reason_documents_entry(tmp_path):
    
  • src/main.rs+77 23 modified
    @@ -40,14 +40,12 @@ fn cascade_match(
             if v2e[vi].is_some() {
                 continue;
             }
    -        let v_file = v.path.to_string_lossy();
    +        let v_file = v.path.to_string_lossy().replace('\\', "/");
             let v_hash = content_hash(&v.line_content);
             if let Some(oi) = old_entries.iter().enumerate().find_map(|(i, e)| {
                 if e2v[i].is_none() && e.content == v_hash && e.token == v.matched_token {
    -                let file_matches = e.file() == v_file.as_ref()
    -                    || renames
    -                        .get(e.file())
    -                        .is_some_and(|new| new == v_file.as_ref());
    +                let file_matches =
    +                    e.file() == v_file || renames.get(e.file()).is_some_and(|new| *new == v_file);
                     if file_matches { Some(i) } else { None }
                 } else {
                     None
    @@ -698,22 +696,22 @@ fn find_registry_path() -> Result<PathBuf> {
         Ok(config_path)
     }
     
    -fn print_entry_snippet(entry: &Entry, registry_dir: &Path) {
    -    println!("{}", entry.location);
    -
    -    let file_path = registry_dir.join(entry.file());
    -    if let Ok(source) = std::fs::read_to_string(&file_path) {
    -        let line_num = entry.line() as usize;
    -        if let Some(line) = source.lines().nth(line_num - 1) {
    -            let trimmed = line.trim_start();
    -            println!("    |");
    -            println!("{:>4}| {}", line_num, trimmed);
    -            if let Some(col) = trimmed.rfind(&entry.token) {
    -                let underline = " ".repeat(col) + &"^".repeat(entry.token.len());
    -                println!("    | {underline}");
    -            }
    -        }
    +fn print_entry_snippet(entry: &Entry) {
    +    print!("{}", format_entry_snippet(entry));
    +}
    +
    +fn format_entry_snippet(entry: &Entry) -> String {
    +    let mut out = format!("{}\n", entry.location);
    +    if entry.content.is_empty() {
    +        return out;
         }
    +    out.push_str("    |\n");
    +    out.push_str(&format!("{:>4}| {}\n", entry.line(), entry.content));
    +    if let Some(col) = entry.content.rfind(&entry.token) {
    +        let underline = " ".repeat(col) + &"^".repeat(entry.token.len());
    +        out.push_str(&format!("    | {underline}\n"));
    +    }
    +    out
     }
     
     fn print_remaining(remaining: usize) {
    @@ -735,7 +733,6 @@ fn handle_next(fix: Option<&str>) -> Result<()> {
     
         let config_path = find_registry_path()?;
         let mut registry = Registry::load(&config_path).context("Failed to load registry")?;
    -    let registry_dir = config_path.parent().unwrap_or_else(|| Path::new("."));
     
         let entry_idx = registry
             .entries
    @@ -769,7 +766,7 @@ fn handle_next(fix: Option<&str>) -> Result<()> {
                     .filter(|e| e.why.trim().is_empty())
                     .count();
                 println!("{} remaining.\n", remaining);
    -            print_entry_snippet(&registry.entries[next], registry_dir);
    +            print_entry_snippet(&registry.entries[next]);
                 println!(
                     "\nFix with:\n  shame next \"<reason>\"\n  shame fix \"{}\" \"{}\" --why \"<reason>\"",
                     registry.entries[next].location, registry.entries[next].token
    @@ -778,7 +775,7 @@ fn handle_next(fix: Option<&str>) -> Result<()> {
                 print_remaining(0);
             }
         } else {
    -        print_entry_snippet(&registry.entries[idx], registry_dir);
    +        print_entry_snippet(&registry.entries[idx]);
             println!(
                 "\nFix with:\n  shame next \"<reason>\"\n  shame fix \"{}\" \"{}\" --why \"<reason>\"",
                 registry.entries[idx].location, registry.entries[idx].token
    @@ -929,6 +926,28 @@ mod tests {
             assert_eq!(m.violation_to_entry, vec![None]);
         }
     
    +    #[test]
    +    fn cascade_match_pass_b_normalizes_backslash_in_violation_path() {
    +        let entries = vec![entry("./a/b.py:10", "# noqa", "x = 1  # noqa")];
    +        let mut v = violation("./a/b.py", 12, "x = 1  # noqa", "# noqa");
    +        v.path = PathBuf::from(r"./a\b.py");
    +        let violations = vec![v];
    +        let m = cascade_match(&entries, &violations, &HashMap::new());
    +        assert_eq!(m.violation_to_entry, vec![Some(0)]);
    +    }
    +
    +    #[test]
    +    fn cascade_match_pass_b_normalizes_backslash_for_rename_lookup() {
    +        let entries = vec![entry("./old.py:10", "# noqa", "x = 1  # noqa")];
    +        let mut v = violation("./new.py", 10, "x = 1  # noqa", "# noqa");
    +        v.path = PathBuf::from(r".\new.py");
    +        let violations = vec![v];
    +        let mut renames = HashMap::new();
    +        renames.insert("./old.py".to_string(), "./new.py".to_string());
    +        let m = cascade_match(&entries, &violations, &renames);
    +        assert_eq!(m.violation_to_entry, vec![Some(0)]);
    +    }
    +
         #[test]
         fn cascade_match_does_not_pair_different_tokens_at_same_location() {
             let entries = vec![entry(
    @@ -1038,4 +1057,39 @@ mod tests {
                 &registry_dir,
             ));
         }
    +
    +    #[test]
    +    fn format_entry_snippet_renders_location_and_content_line() {
    +        let e = entry("./src/foo.py:42", "# noqa", "x = 1  # noqa");
    +        let out = format_entry_snippet(&e);
    +        assert!(out.contains("./src/foo.py:42"));
    +        assert!(out.contains("  42| x = 1  # noqa"));
    +    }
    +
    +    #[test]
    +    fn format_entry_snippet_uses_content_field_not_filesystem() {
    +        let e = entry("/etc/passwd:1", "# noqa", "REGISTRY_CACHED_LINE");
    +        let out = format_entry_snippet(&e);
    +        assert!(out.contains("/etc/passwd:1"));
    +        assert!(out.contains("REGISTRY_CACHED_LINE"));
    +        assert!(!out.contains("root:"));
    +    }
    +
    +    #[test]
    +    fn format_entry_snippet_underline_aligns_with_token_column() {
    +        let line = "x = 1  # noqa";
    +        let token = "# noqa";
    +        let e = entry("./a.py:1", token, line);
    +        let out = format_entry_snippet(&e);
    +        let col = line.find(token).unwrap();
    +        let expected_underline = format!("    | {}{}", " ".repeat(col), "^".repeat(token.len()));
    +        assert!(out.contains(&expected_underline));
    +    }
    +
    +    #[test]
    +    fn format_entry_snippet_omits_body_when_content_empty() {
    +        let e = entry("./a.py:1", "# noqa", "");
    +        let out = format_entry_snippet(&e);
    +        assert_eq!(out, "./a.py:1\n");
    +    }
     }
    

Vulnerability mechanics

Root cause

"The `print_entry_snippet` function read the file at `registry_dir.join(entry.file())` from disk instead of using the registry's cached `content` field, allowing a `location` with absolute or `../`-prefixed paths to disclose arbitrary file contents."

Attack vector

An attacker crafts a `shamefile.yaml` whose entry `location` field contains an absolute path (e.g. `/etc/passwd:1`) or a `../`-relative path pointing to a sensitive file outside the repository. When a victim runs `shame next`, the old `print_entry_snippet` function joined the registry directory with that path and called `std::fs::read_to_string`, then extracted the specified line number. The line content was printed to the terminal, leaking one line per entry. No authentication or special privileges are required beyond the victim executing the command in a project that contains the attacker-controlled registry file [CWE-22] [ref_id=1].

Affected code

The vulnerable function was `print_entry_snippet` in `src/main.rs` (lines 698-715 before the patch). It called `std::fs::read_to_string(&file_path)` where `file_path` was `registry_dir.join(entry.file())`, allowing path traversal via the `location` field. The patch replaces this with `format_entry_snippet` which reads from `entry.content` instead [patch_id=2979570].

What the fix does

The patch replaces `print_entry_snippet` (which read from disk via `registry_dir.join(entry.file())`) with `format_entry_snippet`, which renders the snippet body exclusively from the registry's `entry.content` field. The `registry_dir` variable is removed entirely from `handle_next`, and the function signature drops the `registry_dir: &Path` parameter. New unit tests (`format_entry_snippet_uses_content_field_not_filesystem`) confirm that even when `location` is `/etc/passwd:1`, the output contains only the cached content string, not the actual file on disk [patch_id=2979570].

Preconditions

  • inputVictim must run `shame next` in a directory containing a `shamefile.yaml` controlled by the attacker.
  • inputThe attacker-controlled `shamefile.yaml` must contain an entry whose `location` field is an absolute path or `../`-relative path to a target file.
  • configThe target file must be readable by the user running `shame next`.

Generated on May 28, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

5

News mentions

0

No linked articles in our index yet.