codesnake 1.3.0__tar.gz → 1.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codesnake
3
- Version: 1.3.0
3
+ Version: 1.3.1
4
4
  Summary: Semantic code checker for Python 3
5
5
  Author: CodeSnake Contributors
6
6
  License-Expression: MIT
@@ -1,3 +1,3 @@
1
1
  """Single source of truth for the package version (read by pyproject.toml)."""
2
2
 
3
- __version__ = "1.3.0"
3
+ __version__ = "1.3.1"
@@ -432,6 +432,19 @@ def issue_ignored_by_pragma(code: str, line: int, source_lines: Sequence[str]) -
432
432
  return code.upper() in codes
433
433
 
434
434
 
435
+ def _within(path: Path, root: Path) -> bool:
436
+ """True if ``path`` resolves to somewhere inside ``root``.
437
+
438
+ Reading through a symlink that leaves the tree the caller asked to scan
439
+ discloses files outside it, so the walk declines to follow one.
440
+ """
441
+ try:
442
+ resolved = path.resolve()
443
+ except OSError:
444
+ return False
445
+ return resolved == root or resolved.is_relative_to(root)
446
+
447
+
435
448
  def _load_ignore_state(
436
449
  root_resolved: Path,
437
450
  ) -> Tuple['_IgnoreStack', Set[Path], Set[Path]]:
@@ -511,6 +524,12 @@ def iter_python_files(root: Path, *, respect_gitignore: bool = True) -> Iterable
511
524
  if not name.endswith('.py'):
512
525
  continue
513
526
  candidate = current / name
527
+ # os.walk does not follow directory symlinks, but a *file* symlink is
528
+ # yielded and would be read through. `src/x.py -> ~/.ssh/id_rsa` would
529
+ # otherwise be opened, and a syntax error prints the offending line
530
+ # into the report. Only follow links that stay inside the scan root.
531
+ if candidate.is_symlink() and not _within(candidate, root_resolved):
532
+ continue
514
533
  if (
515
534
  respect_gitignore
516
535
  and candidate not in tracked
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codesnake
3
- Version: 1.3.0
3
+ Version: 1.3.1
4
4
  Summary: Semantic code checker for Python 3
5
5
  Author: CodeSnake Contributors
6
6
  License-Expression: MIT
@@ -881,6 +881,64 @@ class TestDirectoryWalkAndEncoding(unittest.TestCase):
881
881
  import subprocess
882
882
  subprocess.run(['git', *args], cwd=root, check=True, capture_output=True, timeout=30)
883
883
 
884
+ def test_symlink_escaping_the_root_is_not_followed(self):
885
+ """`src/x.py -> /outside/secret` must not be read.
886
+
887
+ os.walk does not follow directory symlinks, but a file symlink is
888
+ yielded and would be read through, and a syntax error prints the
889
+ offending line into the report.
890
+ """
891
+ with tempfile.TemporaryDirectory() as tmp:
892
+ root = Path(tmp).resolve()
893
+ (root / 'project' / 'src').mkdir(parents=True)
894
+ outside = root / 'outside'
895
+ outside.mkdir()
896
+ secret = outside / 'secret.txt'
897
+ secret.write_text('DB_PASSWORD = hunter2!$nope\n', encoding='utf-8')
898
+ (root / 'project' / 'src' / 'real.py').write_text('x = 1\n', encoding='utf-8')
899
+ try:
900
+ (root / 'project' / 'src' / 'leaked.py').symlink_to(secret)
901
+ except (OSError, NotImplementedError):
902
+ self.skipTest('symlinks unavailable')
903
+
904
+ targets, _ = expand_python_targets([str(root / 'project' / 'src')])
905
+ self.assertEqual({Path(t).name for t in targets}, {'real.py'})
906
+
907
+ def test_symlink_staying_inside_the_root_is_followed(self):
908
+ """Containment, not a blanket ban on symlinks."""
909
+ from codesnake.checker import iter_python_files
910
+ with tempfile.TemporaryDirectory() as tmp:
911
+ root = Path(tmp).resolve()
912
+ (root / 'src' / 'pkg').mkdir(parents=True)
913
+ target = root / 'src' / 'pkg' / 'shared.py'
914
+ target.write_text('x = 1\n', encoding='utf-8')
915
+ try:
916
+ (root / 'src' / 'alias.py').symlink_to(target)
917
+ except (OSError, NotImplementedError):
918
+ self.skipTest('symlinks unavailable')
919
+
920
+ found = {p.name for p in iter_python_files(root / 'src')}
921
+ self.assertEqual(found, {'shared.py', 'alias.py'})
922
+
923
+ def test_escaping_symlink_content_never_reaches_the_report(self):
924
+ """The disclosure that made this worth fixing: the source line is printed."""
925
+ with tempfile.TemporaryDirectory() as tmp:
926
+ root = Path(tmp).resolve()
927
+ (root / 'project' / 'src').mkdir(parents=True)
928
+ secret = root / 'secret.env'
929
+ secret.write_text('DEBUG = False\nDB_PASSWORD = hunter2!$nope\n', encoding='utf-8')
930
+ (root / 'project' / 'src' / 'ok.py').write_text('x = 1\n', encoding='utf-8')
931
+ try:
932
+ (root / 'project' / 'src' / 'config.py').symlink_to(secret)
933
+ except (OSError, NotImplementedError):
934
+ self.skipTest('symlinks unavailable')
935
+
936
+ out = StringIO()
937
+ run_check([str(root / 'project' / 'src')], stream=out,
938
+ color=False, show_banner=False)
939
+ self.assertNotIn('DB_PASSWORD', out.getvalue())
940
+ self.assertNotIn('hunter2', out.getvalue())
941
+
884
942
  def test_committed_file_is_not_hidden_by_gitignore(self):
885
943
  """git ignores .gitignore for tracked files; so must the walk.
886
944
 
File without changes
File without changes
File without changes
File without changes