Repository navigation
[Fix] 취약점 파일 경로를 저장소 상대경로로 정규화 #49
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| """Validate scanner paths against the clone, preserving repository structure.""" | ||
|
|
||
| import re | ||
| from pathlib import Path | ||
| from urllib.parse import unquote, urljoin, urlsplit | ||
|
|
||
|
|
||
| class InvalidFindingPath(ValueError): | ||
| pass | ||
|
|
||
|
|
||
| def repository_relative_path(value: str, repo_root: str | Path) -> str: | ||
| if not value or "\x00" in value: | ||
| raise InvalidFindingPath("missing or invalid file path") | ||
| value = value.replace("\\", "/") | ||
| if re.match(r"^[A-Za-z][A-Za-z0-9+.-]*:", value) or value.startswith("//"): | ||
| raise InvalidFindingPath("unsupported URI, drive or network path") | ||
| root = Path(repo_root).absolute() | ||
| candidate = Path(value) | ||
| if not candidate.is_absolute(): | ||
| candidate = root / candidate | ||
| try: | ||
| # Resolve before collapsing '..' so symlink traversal cannot hide an escape. | ||
| resolved_relative = candidate.resolve().relative_to(root.resolve()) | ||
| try: | ||
| relative = candidate.relative_to(root) | ||
| except ValueError: | ||
| # Accept the canonical clone root spelling, never an unrelated symlink | ||
| # outside the clone merely because its target points back inside. | ||
| relative = candidate.relative_to(root.resolve()) | ||
| except (ValueError, OSError, RuntimeError) as exc: | ||
| raise InvalidFindingPath("path or symlink escapes repository") from exc | ||
| parts: list[str] = [] | ||
| traversed = root | ||
| through_symlink = False | ||
| for part in relative.parts: | ||
| traversed = traversed / part | ||
| try: | ||
| if traversed.is_symlink(): | ||
| through_symlink = True | ||
| traversed.resolve(strict=True).relative_to(root.resolve()) | ||
| traversed.resolve().relative_to(root.resolve()) | ||
| except (ValueError, OSError, RuntimeError) as exc: | ||
| raise InvalidFindingPath("path traverses outside repository") from exc | ||
| if part == "..": | ||
| if not parts: | ||
| raise InvalidFindingPath("relative traversal escapes repository") | ||
| parts.pop() | ||
| elif part != ".": | ||
| parts.append(part) | ||
| if not parts: | ||
| raise InvalidFindingPath("path refers to repository root") | ||
| if through_symlink and ".." in relative.parts: | ||
| if not resolved_relative.parts: | ||
| raise InvalidFindingPath("path refers to repository root") | ||
| return resolved_relative.as_posix() | ||
| return "/".join(parts) | ||
|
|
||
|
|
||
| def sarif_artifact_path(artifact: dict, bases: dict) -> str: | ||
| def resolve(location: dict, seen: frozenset[str]) -> str: | ||
| uri = location.get("uri") | ||
| if not isinstance(uri, str) or not uri: | ||
| raise InvalidFindingPath("missing artifact URI (index-only locations unsupported)") | ||
| if any(ord(char) < 32 for char in uri): | ||
| raise InvalidFindingPath("URI contains control characters") | ||
| base_id = location.get("uriBaseId") | ||
| if base_id is not None: | ||
| if not isinstance(base_id, str) or base_id in seen or len(seen) >= 64: | ||
| raise InvalidFindingPath("invalid or cyclic URI base") | ||
| base = bases.get(base_id) | ||
| if not isinstance(base, dict): | ||
| raise InvalidFindingPath("unknown URI base") | ||
| base_uri = resolve(base, seen | {base_id}) | ||
| if not urlsplit(base_uri).scheme or not base_uri.endswith("/"): | ||
| raise InvalidFindingPath("URI base requires an absolute directory URI") | ||
| uri = urljoin(base_uri, uri) | ||
| return uri | ||
|
|
||
| try: | ||
| uri = resolve(artifact, frozenset()) | ||
| parsed = urlsplit(uri) | ||
| except ValueError as exc: | ||
| raise InvalidFindingPath(str(exc)) from exc | ||
| if parsed.scheme not in ("", "file") or parsed.netloc not in ("", "localhost"): | ||
| raise InvalidFindingPath("unsupported URI scheme or authority") | ||
| if parsed.query or parsed.fragment or (parsed.scheme == "file" and not parsed.path.startswith("/")): | ||
| raise InvalidFindingPath("unsupported URI query, fragment or relative file URI") | ||
| if re.search(r"%(?![0-9A-Fa-f]{2})", parsed.path): | ||
| raise InvalidFindingPath("malformed URI percent encoding") | ||
| try: | ||
| return unquote(parsed.path, encoding="utf-8", errors="strict") | ||
| except UnicodeError as exc: | ||
| raise InvalidFindingPath("invalid URI UTF-8 encoding") from exc | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| import tempfile | ||
| import unittest | ||
| from pathlib import Path | ||
|
|
||
| from app.services.normalizer.repository_path import ( | ||
| InvalidFindingPath, repository_relative_path, sarif_artifact_path, | ||
| ) | ||
| from app.services.normalizer.finding_normalizer import normalize_findings | ||
| from app.services.normalizer.deduplicator import deduplicate_findings | ||
| from app.services.scanner.base import RawFinding | ||
|
|
||
|
|
||
| class RepositoryPathTest(unittest.TestCase): | ||
| def setUp(self): | ||
| self.tmp = tempfile.TemporaryDirectory() | ||
| self.addCleanup(self.tmp.cleanup) | ||
| self.root = Path(self.tmp.name) / "repository" | ||
| self.root.mkdir() | ||
| (self.root / "src").mkdir() | ||
| (self.root / "src" / "한글 file.py").touch() | ||
| (self.root / "internal").symlink_to(self.root / "src", target_is_directory=True) | ||
| (self.root / "external").symlink_to(self.root.parent, target_is_directory=True) | ||
|
|
||
| def test_paths_preserve_structure_and_names(self): | ||
| for path in ["src/한글 file.py", str(self.root / "src/한글 file.py"), | ||
| "./src/../src/한글 file.py", "src\\한글 file.py", "src//한글 file.py"]: | ||
| with self.subTest(path=path): | ||
| self.assertEqual(repository_relative_path(path, self.root), "src/한글 file.py") | ||
|
|
||
| def test_internal_symlink_keeps_repository_name(self): | ||
| self.assertEqual(repository_relative_path("internal/한글 file.py", self.root), "internal/한글 file.py") | ||
|
|
||
| def test_parent_after_internal_symlink_uses_actual_target(self): | ||
| (self.root / "src/nested").mkdir() | ||
| (self.root / "nested-link").symlink_to(self.root / "src/nested", target_is_directory=True) | ||
| self.assertEqual(repository_relative_path("nested-link/../한글 file.py", self.root), "src/한글 file.py") | ||
|
|
||
| def test_rejects_external_traversal_and_symlinks(self): | ||
| for path in [str(self.root.parent / "outside.py"), "../outside.py", "src/../../x.py", | ||
| "external/x.py", "external/../repository/src/한글 file.py", | ||
| "../repository/src/한글 file.py", "", ".", "C:\\src\\x.py", "https://host/x.py"]: | ||
| with self.subTest(path=path), self.assertRaises(InvalidFindingPath): | ||
| repository_relative_path(path, self.root) | ||
|
|
||
| def test_looping_symlink_is_invalid(self): | ||
| (self.root / "loop").symlink_to("loop") | ||
| with self.assertRaises(InvalidFindingPath): | ||
| repository_relative_path("loop/x.py", self.root) | ||
|
|
||
| def test_external_absolute_symlink_pointing_inside_is_invalid(self): | ||
| alias = self.root.parent / "outside-link" | ||
| alias.symlink_to(self.root, target_is_directory=True) | ||
| with self.assertRaises(InvalidFindingPath): | ||
| repository_relative_path(str(alias / "src/한글 file.py"), self.root) | ||
|
|
||
| def test_normalization_before_dedup_isolates_invalid_finding(self): | ||
| def raw(path): | ||
| return RawFinding(tool="SEMGREP", type="SQL", severity="HIGH", file_path=path, | ||
| message="message /tmp/unchanged", evidence="/tmp/evidence", line_start=1) | ||
| inputs = [raw(str(self.root / "src/한글 file.py")), raw("src/한글 file.py"), raw("../bad")] | ||
| with self.assertLogs("app.services.normalizer.finding_normalizer", level="WARNING"): | ||
| findings = deduplicate_findings(normalize_findings(inputs, self.root)) | ||
| self.assertEqual(len(findings), 1) | ||
| self.assertEqual(findings[0].file_path, "src/한글 file.py") | ||
| self.assertEqual(findings[0].message, "message /tmp/unchanged") | ||
| self.assertEqual(findings[0].evidence, "/tmp/evidence") | ||
| self.assertEqual(inputs[0].file_path, str(self.root / "src/한글 file.py")) | ||
|
|
||
| def test_uri_formats_and_recursive_base(self): | ||
| uri = (self.root / "src/한글 file.py").as_uri() | ||
| bases = {"ROOT": {"uri": self.root.as_uri() + "/"}, | ||
| "SRC": {"uri": "src/", "uriBaseId": "ROOT"}} | ||
| for artifact in [{"uri": uri}, {"uri": uri.replace("file:///", "file://localhost/")}, | ||
| {"uri": "src/%ED%95%9C%EA%B8%80%20file.py"}, | ||
| {"uri": "%ED%95%9C%EA%B8%80%20file.py", "uriBaseId": "SRC"}]: | ||
| with self.subTest(artifact=artifact): | ||
| self.assertEqual(repository_relative_path(sarif_artifact_path(artifact, bases), self.root), | ||
| "src/한글 file.py") | ||
|
|
||
| def test_uri_rejections(self): | ||
| bases = {"A": {"uri": "a/", "uriBaseId": "B"}, | ||
| "B": {"uri": "b/", "uriBaseId": "A"}, "REL": {"uri": "src/"}} | ||
| for artifact in [{"uri": "https://host/src/x.py"}, {"uri": "file://remote/src/x.py"}, | ||
| {"uri": "file:src/x.py"}, {"uri": "file://[bad/x.py"}, | ||
| {"uri": "\nx.py"}, {"uri": "x.py?query"}, {"uri": "x.py#fragment"}, | ||
| {"uri": "%ZZ.py"}, {"uri": "%FF.py"}, {"index": 0}, | ||
| {"uri": "x.py", "uriBaseId": "MISSING"}, | ||
| {"uri": "x.py", "uriBaseId": "A"}, {"uri": "x.py", "uriBaseId": "REL"}]: | ||
| with self.subTest(artifact=artifact), self.assertRaises(InvalidFindingPath): | ||
| sarif_artifact_path(artifact, bases) | ||
|
|
||
| def test_uri_encoded_traversal_and_external_file_are_rejected_at_boundary(self): | ||
| for uri in ["%2e%2e/out.py", self.root.parent.as_uri() + "/out.py"]: | ||
| with self.subTest(uri=uri), self.assertRaises(InvalidFindingPath): | ||
| repository_relative_path(sarif_artifact_path({"uri": uri}, {}), self.root) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
\를 전부 /로 바꾸면 Linux에서 파일명 자체에 \가 포함된 경우에도 경로가 변경됩니다. 따라서 실제 파일과 다른 위치를 가리킬 수 있을 것 같은데, 스캐너가 반환하는 경로 형식에 따라 변환 여부를 구분하면 어떨까요?