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.
- {codesnake-1.3.0/src/codesnake.egg-info → codesnake-1.3.1}/PKG-INFO +1 -1
- {codesnake-1.3.0 → codesnake-1.3.1}/src/codesnake/_version.py +1 -1
- {codesnake-1.3.0 → codesnake-1.3.1}/src/codesnake/checker.py +19 -0
- {codesnake-1.3.0 → codesnake-1.3.1/src/codesnake.egg-info}/PKG-INFO +1 -1
- {codesnake-1.3.0 → codesnake-1.3.1}/test/test_codesnake.py +58 -0
- {codesnake-1.3.0 → codesnake-1.3.1}/LICENSE +0 -0
- {codesnake-1.3.0 → codesnake-1.3.1}/README.md +0 -0
- {codesnake-1.3.0 → codesnake-1.3.1}/pyproject.toml +0 -0
- {codesnake-1.3.0 → codesnake-1.3.1}/setup.cfg +0 -0
- {codesnake-1.3.0 → codesnake-1.3.1}/src/codesnake/__init__.py +0 -0
- {codesnake-1.3.0 → codesnake-1.3.1}/src/codesnake/__main__.py +0 -0
- {codesnake-1.3.0 → codesnake-1.3.1}/src/codesnake/banner.py +0 -0
- {codesnake-1.3.0 → codesnake-1.3.1}/src/codesnake/cli.py +0 -0
- {codesnake-1.3.0 → codesnake-1.3.1}/src/codesnake.egg-info/SOURCES.txt +0 -0
- {codesnake-1.3.0 → codesnake-1.3.1}/src/codesnake.egg-info/dependency_links.txt +0 -0
- {codesnake-1.3.0 → codesnake-1.3.1}/src/codesnake.egg-info/entry_points.txt +0 -0
- {codesnake-1.3.0 → codesnake-1.3.1}/src/codesnake.egg-info/requires.txt +0 -0
- {codesnake-1.3.0 → codesnake-1.3.1}/src/codesnake.egg-info/top_level.txt +0 -0
|
@@ -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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|