deployproof 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,281 @@
1
+ """Terminal output reporter for DeployProof."""
2
+
3
+ from pathlib import Path
4
+ from typing import List, Optional
5
+
6
+ from deployproof.dependencies import DependencyScanSummary
7
+ from deployproof.mutator import MutationResult
8
+ from deployproof.secrets import SecretsScanResult
9
+ from deployproof.symlinks import SymlinkScanResult
10
+
11
+ LARGE_FILE_LOC_THRESHOLD = 300
12
+
13
+
14
+ def format_report(
15
+ result: MutationResult,
16
+ target_files: List[Path],
17
+ secrets_result: Optional[SecretsScanResult] = None,
18
+ symlink_result: Optional[SymlinkScanResult] = None,
19
+ dependency_result: Optional[DependencyScanSummary] = None,
20
+ repo_root: Optional[Path] = None,
21
+ threshold: float = 80.0,
22
+ ) -> str:
23
+ """Format mutation testing results, secrets scan, symlink escape, and slopsquatting checks as terminal output."""
24
+ lines: List[str] = []
25
+ lines.append("DeployProof - LOCAL PRE-CHECK (approximate) - not the verified score")
26
+ lines.append("=" * 68)
27
+
28
+ root = (repo_root or Path.cwd()).resolve()
29
+
30
+ # Scope section
31
+ file_count = len(target_files)
32
+ lines.append(f"\nTarget Scope ({file_count} file{'s' if file_count != 1 else ''} evaluated):")
33
+ if target_files:
34
+ for f in target_files:
35
+ try:
36
+ rel = f.relative_to(root)
37
+ except ValueError:
38
+ rel = f
39
+ try:
40
+ loc = len(f.read_text(encoding="utf-8", errors="replace").splitlines())
41
+ except Exception:
42
+ loc = 0
43
+
44
+ if loc >= LARGE_FILE_LOC_THRESHOLD:
45
+ lines.append(
46
+ f" * {rel} ({loc} LOC) - [!] Large file: pre-check may take several minutes."
47
+ )
48
+ else:
49
+ lines.append(f" * {rel}")
50
+ else:
51
+ lines.append(" (No modified Python files in scope)")
52
+
53
+ # Symlink & Sandbox Escape Scan section
54
+ lines.append("\nSymlink & Sandbox Escape Scan (CWE-61/CWE-451):")
55
+ if symlink_result and symlink_result.escape_findings:
56
+ escape_count = len(symlink_result.escape_findings)
57
+ lines.append(
58
+ f" [!] {escape_count} sandbox-escape symlink{'s' if escape_count != 1 else ''} detected:"
59
+ )
60
+ for idx, finding in enumerate(symlink_result.escape_findings, 1):
61
+ try:
62
+ rel_sym = finding.symlink_path.relative_to(root)
63
+ except ValueError:
64
+ rel_sym = finding.symlink_path
65
+ lines.append(f"\n [{idx}] {rel_sym} -> {finding.link_target_raw}")
66
+ lines.append(f" Apparent Path: {rel_sym}")
67
+ lines.append(f" Resolved Target: {finding.resolved_target} (Exists on disk: {finding.target_exists})")
68
+ lines.append(" Severity: CRITICAL (Escapes repository sandbox)")
69
+ lines.append(f" Note: {finding.description}")
70
+ elif symlink_result and symlink_result.safe_symlinks:
71
+ safe_count = len(symlink_result.safe_symlinks)
72
+ lines.append(
73
+ f" Clean: No sandbox-escape symlinks detected across {symlink_result.files_scanned} session files ({safe_count} safe in-repo symlink{'s' if safe_count != 1 else ''} verified)."
74
+ )
75
+ else:
76
+ scanned_count = symlink_result.files_scanned if symlink_result else file_count
77
+ lines.append(
78
+ f" Clean: No symlinks or sandbox-escape traversal links detected across {scanned_count} session file{'s' if scanned_count != 1 else ''}."
79
+ )
80
+
81
+ # Secrets scan section
82
+ lines.append("\nSecrets & Credentials Pre-Push Scan:")
83
+ if secrets_result and secrets_result.findings:
84
+ findings_count = len(secrets_result.findings)
85
+ lines.append(
86
+ f" [!] {findings_count} potential secret/credential finding{'s' if findings_count != 1 else ''} detected:"
87
+ )
88
+ for idx, finding in enumerate(secrets_result.findings, 1):
89
+ try:
90
+ rel_path = finding.file_path.relative_to(root)
91
+ except ValueError:
92
+ rel_path = finding.file_path
93
+ lines.append(f"\n [{idx}] {rel_path}:{finding.line_number} [{finding.rule_name}]")
94
+ lines.append(f" Redacted: {finding.redacted_value}")
95
+ if finding.snippet:
96
+ lines.append(f" Snippet: {finding.snippet}")
97
+ lines.append(f" Note: {finding.description}")
98
+ else:
99
+ scanned_count = secrets_result.files_scanned if secrets_result else file_count
100
+ lines.append(
101
+ f" Clean: No hardcoded secrets or tracked .env files detected across {scanned_count} session file{'s' if scanned_count != 1 else ''}."
102
+ )
103
+
104
+ # Dependency & Slopsquatting Scan section
105
+ lines.append("\nDependency & Slopsquatting Scan (PyPI Registry & Age Analysis):")
106
+ if dependency_result and (dependency_result.high_risk_count > 0 or dependency_result.medium_risk_count > 0):
107
+ flagged_count = dependency_result.high_risk_count + dependency_result.medium_risk_count
108
+ lines.append(
109
+ f" [!] {flagged_count} suspicious dependency finding{'s' if flagged_count != 1 else ''} detected:"
110
+ )
111
+ idx = 1
112
+ for finding in dependency_result.findings:
113
+ if finding.status in ("HIGH_RISK", "MEDIUM_RISK"):
114
+ try:
115
+ rel_src = finding.source_file.relative_to(root)
116
+ except ValueError:
117
+ rel_src = finding.source_file
118
+ src_str = f"{rel_src}:{finding.lineno}" if finding.lineno else str(rel_src)
119
+ lines.append(f"\n [{idx}] {finding.package_name} [{finding.status}]")
120
+ lines.append(f" Source: {src_str} ({finding.source_type})")
121
+ if finding.status == "HIGH_RISK":
122
+ lines.append(" Classification: HIGH RISK (Package does NOT exist on PyPI)")
123
+ elif finding.status == "MEDIUM_RISK":
124
+ lines.append(
125
+ f" Classification: MEDIUM RISK (Registered {finding.age_days} day{'s' if finding.age_days != 1 else ''} ago, first published {finding.first_release_date})"
126
+ )
127
+ lines.append(f" Note: {finding.details}")
128
+ idx += 1
129
+
130
+ elif dependency_result and dependency_result.total_scanned > 0:
131
+ lines.append(
132
+ f" Clean: {dependency_result.ok_count} external package{'s' if dependency_result.ok_count != 1 else ''} verified on PyPI (0 hallucinated, 0 recently registered)."
133
+ )
134
+ else:
135
+ scanned_count = secrets_result.files_scanned if secrets_result else file_count
136
+ lines.append(
137
+ f" Clean: No new external packages introduced across {scanned_count} session file{'s' if scanned_count != 1 else ''}."
138
+ )
139
+
140
+ # UNKNOWN findings (network/registry query errors)
141
+ if dependency_result and dependency_result.unknown_count > 0:
142
+ lines.append(
143
+ f"\n [?] {dependency_result.unknown_count} unverified dependency check{'s' if dependency_result.unknown_count != 1 else ''} (Network / registry query error):"
144
+ )
145
+ for finding in dependency_result.findings:
146
+ if finding.status == "UNKNOWN":
147
+ try:
148
+ rel_src = finding.source_file.relative_to(root)
149
+ except ValueError:
150
+ rel_src = finding.source_file
151
+ src_str = f"{rel_src}:{finding.lineno}" if finding.lineno else str(rel_src)
152
+ lines.append(f" * {finding.package_name} (Source: {src_str}) - {finding.details}")
153
+
154
+ # Unscanned dependency sources (seen, not checked)
155
+ if dependency_result and dependency_result.unscanned_count > 0:
156
+ lines.append(
157
+ f"\n [!] {dependency_result.unscanned_count} unscanned dependency source{'s' if dependency_result.unscanned_count != 1 else ''} (seen, not checked):"
158
+ )
159
+ for finding in dependency_result.findings:
160
+ if finding.status == "UNSCANNED":
161
+ try:
162
+ rel_src = finding.source_file.relative_to(root)
163
+ except ValueError:
164
+ rel_src = finding.source_file
165
+ src_str = f"{rel_src}:{finding.lineno}" if finding.lineno else str(rel_src)
166
+ lines.append(f" * {finding.import_name} (Source: {src_str}) - {finding.details}")
167
+
168
+ # Score section
169
+ lines.append("\nLocal Pre-Check Mutation Verification:")
170
+ if result.total_mutants == 0:
171
+ lines.append(" Mutants Generated: 0 (No mutable AST locations found)")
172
+ lines.append(" Approx Score: 100.0%")
173
+ else:
174
+ if result.untested_files:
175
+ status_tag = f"FAILED (0 tests collected for {len(result.untested_files)} file{'s' if len(result.untested_files) != 1 else ''})"
176
+ elif result.mutation_score < threshold:
177
+ status_tag = f"FAILED (score {result.mutation_score:.1f}% below {threshold:.1f}%)"
178
+ elif result.survived_mutants:
179
+ status_tag = f"PASSED (with {len(result.survived_mutants)} surviving mutant{'s' if len(result.survived_mutants) != 1 else ''})"
180
+ elif result.skipped_constructs:
181
+ status_tag = f"PARTIALLY VERIFIED ({len(result.skipped_constructs)} construct{'s' if len(result.skipped_constructs) != 1 else ''} skipped)"
182
+ else:
183
+ status_tag = "PASSED"
184
+
185
+ lines.append(
186
+ f" Score: {result.mutation_score:.1f}% ({result.killed_mutants}/{result.total_mutants} mutants killed)"
187
+ )
188
+ lines.append(f" Status: {status_tag} (threshold: {threshold:.1f}%)")
189
+ lines.append(f" Time: {result.duration_seconds:.2f}s")
190
+
191
+ # Untested files warning section
192
+ if result.untested_files:
193
+ lines.append(
194
+ f"\n[!] Untested Files ({len(result.untested_files)} file{'s' if len(result.untested_files) != 1 else ''} with 0 tests collected):"
195
+ )
196
+ for f in result.untested_files:
197
+ try:
198
+ rel_f = f.relative_to(root)
199
+ except ValueError:
200
+ rel_f = f
201
+ lines.append(f" * {rel_f} (0 tests ran against this file - all mutations survived)")
202
+
203
+ # Runner errors section
204
+ if result.runner_errors:
205
+ lines.append(
206
+ f"\nRunner Errors ({len(result.runner_errors)} error{'s' if len(result.runner_errors) != 1 else ''} excluded from score):"
207
+ )
208
+ for mutant, err_msg in result.runner_errors:
209
+ try:
210
+ rel_f = mutant.file_path.relative_to(root)
211
+ except ValueError:
212
+ rel_f = mutant.file_path
213
+ lines.append(f" * {rel_f}:{mutant.line_number} [{err_msg}]")
214
+
215
+ # Skipped Constructs Section (Always shown alongside score)
216
+ skipped_count = len(result.skipped_constructs)
217
+ if result.skipped_constructs:
218
+ lines.append(
219
+ f"\nSkipped Constructs ({skipped_count} line{'s' if skipped_count != 1 else ''} skipped - not verified by Tier 1):"
220
+ )
221
+ for i, s in enumerate(result.skipped_constructs, 1):
222
+ try:
223
+ rel_f = s.file_path.relative_to(root)
224
+ except ValueError:
225
+ rel_f = s.file_path
226
+ lines.append(f" * {rel_f}:{s.line_number} [{s.construct_name}]")
227
+ if s.snippet:
228
+ lines.append(f" Code: {s.snippet}")
229
+ lines.append(f" Note: {s.description}")
230
+ else:
231
+ lines.append("\nSkipped Constructs: None (No known unsupported constructs detected)")
232
+
233
+ # Surviving mutants section
234
+ if result.survived_mutants:
235
+ lines.append(
236
+ f"\nSurviving Mutants ({len(result.survived_mutants)} unverified change{'s' if len(result.survived_mutants) != 1 else ''}):"
237
+ )
238
+ for i, m in enumerate(result.survived_mutants, 1):
239
+ try:
240
+ rel_f = m.file_path.relative_to(root)
241
+ except ValueError:
242
+ rel_f = m.file_path
243
+ lines.append(f"\n [{i}] {rel_f}:{m.line_number}")
244
+ lines.append(f" Mutation: {m.description}")
245
+ if m.original_line:
246
+ lines.append(f" Original: {m.original_line}")
247
+ if m.mutated_line:
248
+ lines.append(f" Mutated: {m.mutated_line}")
249
+ else:
250
+ lines.append("\nSurviving Mutants: None (All generated mutants caught by test suite)")
251
+
252
+ lines.append("\n" + "=" * 68)
253
+ lines.append("Notice: Local pre-check only. Full verified score runs in CI on push (via mutmut).")
254
+ if symlink_result and symlink_result.escape_findings:
255
+ lines.append(
256
+ f"SECURITY ALERT: {len(symlink_result.escape_findings)} symlink(s) escape repository sandbox. Do not approve or push."
257
+ )
258
+ elif dependency_result and dependency_result.high_risk_count > 0:
259
+ lines.append(
260
+ f"SECURITY ALERT: {dependency_result.high_risk_count} non-existent / hallucinated package(s) detected. Fix imports before pushing."
261
+ )
262
+ elif result.untested_files:
263
+ lines.append(
264
+ f"Pre-check FAILED: {len(result.untested_files)} file(s) have 0 tests. Write tests for these files before pushing."
265
+ )
266
+ elif result.mutation_score < threshold:
267
+ lines.append(
268
+ f"Pre-check FAILED: Score {result.mutation_score:.1f}% is below threshold {threshold:.1f}% ({len(result.survived_mutants)} surviving mutants)."
269
+ )
270
+ elif result.survived_mutants:
271
+ lines.append(
272
+ f"Pre-check PASSED ({result.mutation_score:.1f}% >= {threshold:.1f}%), with {len(result.survived_mutants)} surviving mutant(s) flagged."
273
+ )
274
+ elif result.skipped_constructs:
275
+ lines.append(
276
+ f"Pre-check passed on basic operators, but {skipped_count} unsupported construct(s) were skipped. Run in CI for full verification."
277
+ )
278
+ else:
279
+ lines.append("Pre-check clean: 100% of tested basic mutations caught.")
280
+
281
+ return "\n".join(lines)
deployproof/secrets.py ADDED
@@ -0,0 +1,221 @@
1
+ """Secrets and hardcoded credentials scanner for DeployProof."""
2
+
3
+ import math
4
+ import re
5
+ import time
6
+ from collections import Counter
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import List, Optional, Pattern, Tuple
10
+
11
+ ENV_FILE_NAMES = {
12
+ ".env",
13
+ ".env.local",
14
+ ".env.production",
15
+ ".env.staging",
16
+ ".env.development",
17
+ ".env.secret",
18
+ ".env.prod",
19
+ }
20
+
21
+ # Regex patterns for well-known API keys and credentials
22
+ KNOWN_PATTERNS: List[Tuple[str, Pattern[str], str]] = [
23
+ (
24
+ "OpenAI / Anthropic API Key",
25
+ re.compile(r"\b(sk-[a-zA-Z0-9_-]{20,})\b"),
26
+ "Likely hardcoded OpenAI / Anthropic API key",
27
+ ),
28
+ (
29
+ "AWS Access Key ID",
30
+ re.compile(r"\b(AKIA[0-9A-Z]{16})\b"),
31
+ "Likely hardcoded AWS Access Key ID",
32
+ ),
33
+ (
34
+ "AWS Secret Access Key",
35
+ re.compile(r"(?i)\b(?:aws_secret_access_key|aws_secret_key)\s*[:=]\s*[\"']?([A-Za-z0-9/+=]{40})[\"']?"),
36
+ "Likely hardcoded AWS Secret Access Key",
37
+ ),
38
+ (
39
+ "GitHub Token",
40
+ re.compile(r"\b(gh[pousr]_[A-Za-z0-9_]{36,255}|github_pat_[A-Za-z0-9_]{50,})\b"),
41
+ "Likely hardcoded GitHub Personal Access Token",
42
+ ),
43
+ (
44
+ "Google API Key",
45
+ re.compile(r"\b(AIza[0-9A-Za-z-_]{35})\b"),
46
+ "Likely hardcoded Google / AI Studio API key",
47
+ ),
48
+ (
49
+ "Slack Token",
50
+ re.compile(r"\b(xox[baprs]-[0-9a-zA-Z-]{10,48})\b"),
51
+ "Likely hardcoded Slack OAuth / Bot token",
52
+ ),
53
+ (
54
+ "Stripe Secret Key",
55
+ re.compile(r"\b([sr]k_(?:live|test)_[0-9a-zA-Z]{24,})\b"),
56
+ "Likely hardcoded Stripe API Key",
57
+ ),
58
+ (
59
+ "HuggingFace Token",
60
+ re.compile(r"\b(hf_[a-zA-Z0-9]{34,})\b"),
61
+ "Likely hardcoded HuggingFace API Token",
62
+ ),
63
+ (
64
+ "Private Key Header",
65
+ re.compile(r"(-----BEGIN [A-Z ]*PRIVATE KEY-----)"),
66
+ "Unencrypted Private Key Block",
67
+ ),
68
+ ]
69
+
70
+ GENERIC_ASSIGNMENT_PATTERN = re.compile(
71
+ r"(?i)\b([a-z0-9_]*(?:api_?key|secret|token|pass(?:word|wd)?|auth_?token|access_?token|private_?key|client_?secret)[a-z0-9_]*)\s*[:=]\s*(?:[\"']([^\"'\r\n]{16,})[\"']|([^\s\"'#]{16,}))"
72
+ )
73
+
74
+ PLACEHOLDER_EXACT_OR_PREFIX = {
75
+ "your-api-key", "your_api_key", "your-api-token", "your_token",
76
+ "placeholder", "my-secret-key", "changeme", "change_me",
77
+ "sample_key", "dummy_token", "fake_key", "mock_key", "test_secret", "test_token",
78
+ }
79
+
80
+
81
+ @dataclass
82
+ class SecretFinding:
83
+ """Represents a detected secret or credential finding."""
84
+ file_path: Path
85
+ line_number: int
86
+ rule_name: str
87
+ description: str
88
+ redacted_value: str
89
+ snippet: str
90
+ is_env_file: bool = False
91
+
92
+
93
+ @dataclass
94
+ class SecretsScanResult:
95
+ """Aggregated results of a secrets scan across session files."""
96
+ files_scanned: int
97
+ findings: List[SecretFinding] = field(default_factory=list)
98
+ duration_seconds: float = 0.0
99
+
100
+
101
+ def calculate_shannon_entropy(text: str) -> float:
102
+ """Calculate the Shannon entropy of a string."""
103
+ if not text:
104
+ return 0.0
105
+ counts = Counter(text)
106
+ length = len(text)
107
+ return -sum((c / length) * math.log2(c / length) for c in counts.values())
108
+
109
+
110
+ def redact_secret(raw: str) -> str:
111
+ """
112
+ Safely redact a secret string, displaying only the first and last 2 characters.
113
+
114
+ Example: 'sk-proj-abc1234xyz' -> 'sk****************yz'
115
+ """
116
+ raw = raw.strip()
117
+ if len(raw) <= 4:
118
+ return "****"
119
+ prefix = raw[:2]
120
+ suffix = raw[-2:]
121
+ return f"{prefix}****************{suffix}"
122
+
123
+
124
+ def is_placeholder(value: str) -> bool:
125
+ """Check if a string is an obvious placeholder rather than a real credential."""
126
+ lowered = value.lower()
127
+ for kw in PLACEHOLDER_EXACT_OR_PREFIX:
128
+ if kw in lowered:
129
+ return True
130
+ return False
131
+
132
+
133
+ def scan_file_for_secrets(file_path: Path) -> List[SecretFinding]:
134
+ """Scan a single file for credentials, keys, or .env tracking."""
135
+ findings: List[SecretFinding] = []
136
+
137
+ # 1. Tracked .env file check
138
+ file_name = file_path.name.lower()
139
+ if file_name in ENV_FILE_NAMES or file_name.startswith(".env."):
140
+ findings.append(
141
+ SecretFinding(
142
+ file_path=file_path,
143
+ line_number=1,
144
+ rule_name="Tracked Environment File",
145
+ description="Environment configuration file is tracked in git. This risks leaking private environment variables.",
146
+ redacted_value=file_path.name,
147
+ snippet=f"Tracked file: {file_path.name}",
148
+ is_env_file=True,
149
+ )
150
+ )
151
+
152
+ try:
153
+ content = file_path.read_text(encoding="utf-8", errors="replace")
154
+ except Exception:
155
+ return findings
156
+
157
+ lines = content.splitlines()
158
+ for line_idx, line in enumerate(lines, 1):
159
+ stripped = line.strip()
160
+ if not stripped or stripped.startswith("#") or stripped.startswith("//"):
161
+ # Skip empty lines and full line comments
162
+ continue
163
+
164
+ # Check known regex patterns
165
+ matched_known = False
166
+ for rule_name, pattern, desc in KNOWN_PATTERNS:
167
+ match = pattern.search(line)
168
+ if match:
169
+ matched_str = match.group(1)
170
+ if not is_placeholder(matched_str):
171
+ findings.append(
172
+ SecretFinding(
173
+ file_path=file_path,
174
+ line_number=line_idx,
175
+ rule_name=rule_name,
176
+ description=desc,
177
+ redacted_value=redact_secret(matched_str),
178
+ snippet=stripped,
179
+ )
180
+ )
181
+ matched_known = True
182
+
183
+ # Check generic high-entropy secret assignments
184
+ if not matched_known:
185
+ assignment_match = GENERIC_ASSIGNMENT_PATTERN.search(line)
186
+ if assignment_match:
187
+ var_name = assignment_match.group(1)
188
+ secret_val = assignment_match.group(2) or assignment_match.group(3)
189
+ if secret_val and not is_placeholder(secret_val):
190
+ entropy = calculate_shannon_entropy(secret_val)
191
+ # Strings with length >= 16 and entropy >= 3.8 indicate high-entropy random credentials
192
+ if entropy >= 3.8:
193
+ findings.append(
194
+ SecretFinding(
195
+ file_path=file_path,
196
+ line_number=line_idx,
197
+ rule_name="High-Entropy Credential Assignment",
198
+ description=f"High-entropy credential assigned to '{var_name}' (entropy: {entropy:.2f})",
199
+ redacted_value=redact_secret(secret_val),
200
+ snippet=stripped,
201
+ )
202
+ )
203
+
204
+ return findings
205
+
206
+
207
+ def scan_session_files_for_secrets(files: List[Path]) -> SecretsScanResult:
208
+ """Scan all session files for secrets and credentials."""
209
+ start_time = time.time()
210
+ all_findings: List[SecretFinding] = []
211
+
212
+ for f in files:
213
+ if f.is_file():
214
+ findings = scan_file_for_secrets(f)
215
+ all_findings.extend(findings)
216
+
217
+ return SecretsScanResult(
218
+ files_scanned=len(files),
219
+ findings=all_findings,
220
+ duration_seconds=round(time.time() - start_time, 2),
221
+ )
@@ -0,0 +1,156 @@
1
+ """Symlink and sandbox-escape scanner for DeployProof (CWE-61 + CWE-451)."""
2
+
3
+ import os
4
+ import subprocess
5
+ import time
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import List, Optional, Set, Tuple
9
+
10
+
11
+ @dataclass
12
+ class SymlinkFinding:
13
+ """Represents a detected symlink and its sandbox-escape analysis."""
14
+ symlink_path: Path
15
+ link_target_raw: str
16
+ resolved_target: Path
17
+ is_escape: bool
18
+ description: str
19
+ target_exists: bool = False
20
+
21
+
22
+ @dataclass
23
+ class SymlinkScanResult:
24
+ """Aggregated symlink and sandbox-escape results across session files."""
25
+ files_scanned: int
26
+ symlinks_found: int
27
+ escape_findings: List[SymlinkFinding] = field(default_factory=list)
28
+ safe_symlinks: List[SymlinkFinding] = field(default_factory=list)
29
+ duration_seconds: float = 0.0
30
+
31
+
32
+ def get_git_symlink_paths(repo_root: Path) -> Set[Path]:
33
+ """Retrieve all files tracked with git symlink mode 120000."""
34
+ symlinks: Set[Path] = set()
35
+ try:
36
+ res = subprocess.run(
37
+ ["git", "ls-files", "-s"],
38
+ cwd=repo_root,
39
+ capture_output=True,
40
+ text=True,
41
+ encoding="utf-8",
42
+ errors="replace",
43
+ check=False,
44
+ )
45
+ if res.returncode == 0:
46
+ for line in res.stdout.splitlines():
47
+ if line.startswith("120000"):
48
+ parts = line.split(maxsplit=3)
49
+ if len(parts) >= 4:
50
+ rel_path = parts[3]
51
+ symlinks.add((repo_root / rel_path).resolve())
52
+ except Exception:
53
+ pass
54
+ return symlinks
55
+
56
+
57
+ def is_symlink_path(file_path: Path, git_symlinks: Optional[Set[Path]] = None) -> Tuple[bool, str]:
58
+ """
59
+ Check if a path is a symbolic link via filesystem or git mode 120000.
60
+
61
+ Returns (is_symlink: bool, raw_target_string: str).
62
+ """
63
+ # 1. Native filesystem symlink check
64
+ if file_path.is_symlink() or os.path.islink(file_path):
65
+ try:
66
+ raw_target = os.readlink(file_path)
67
+ return True, str(raw_target)
68
+ except Exception:
69
+ return True, ""
70
+
71
+ # 2. Git-tracked mode 120000 check (for Windows environments where git checkouts store pointer text)
72
+ if git_symlinks and file_path.resolve() in git_symlinks:
73
+ try:
74
+ content = file_path.read_text(encoding="utf-8", errors="replace").strip()
75
+ # If the file is small and contains a relative or absolute path pointer
76
+ if content and "\n" not in content and len(content) < 1024:
77
+ return True, content
78
+ except Exception:
79
+ pass
80
+
81
+ return False, ""
82
+
83
+
84
+ def inspect_symlink(
85
+ symlink_path: Path,
86
+ raw_target: str,
87
+ repo_root: Path,
88
+ ) -> SymlinkFinding:
89
+ """Analyze a symlink to verify whether its resolved target escapes repo root."""
90
+ resolved_root = repo_root.resolve()
91
+
92
+ # Determine resolved absolute target
93
+ if symlink_path.is_symlink() or os.path.islink(symlink_path):
94
+ try:
95
+ resolved_target = symlink_path.resolve()
96
+ except Exception:
97
+ resolved_target = (symlink_path.parent / raw_target).resolve()
98
+ else:
99
+ resolved_target = (symlink_path.parent / raw_target).resolve()
100
+
101
+ # Check if target escapes repository root
102
+ try:
103
+ resolved_target.relative_to(resolved_root)
104
+ is_escape = False
105
+ except ValueError:
106
+ is_escape = True
107
+
108
+ target_exists = resolved_target.exists()
109
+
110
+ if is_escape:
111
+ desc = (
112
+ "CRITICAL: Symlink resolves outside the repository root directory (CWE-61/CWE-451). "
113
+ "Apparent path differs from external destination, representing a potential GhostApproval / sandbox escape."
114
+ )
115
+ else:
116
+ desc = "Safe in-repo symbolic link pointing to internal repository target."
117
+
118
+ return SymlinkFinding(
119
+ symlink_path=symlink_path,
120
+ link_target_raw=raw_target,
121
+ resolved_target=resolved_target,
122
+ is_escape=is_escape,
123
+ description=desc,
124
+ target_exists=target_exists,
125
+ )
126
+
127
+
128
+ def scan_session_files_for_symlinks(
129
+ files: List[Path],
130
+ repo_root: Optional[Path] = None,
131
+ ) -> SymlinkScanResult:
132
+ """Scan all session files for symlinks and sandbox-escape vulnerabilities."""
133
+ start_time = time.time()
134
+ root = (repo_root or Path.cwd()).resolve()
135
+
136
+ git_symlinks = get_git_symlink_paths(root)
137
+
138
+ escapes: List[SymlinkFinding] = []
139
+ safe_links: List[SymlinkFinding] = []
140
+
141
+ for f in files:
142
+ is_link, raw_target = is_symlink_path(f, git_symlinks)
143
+ if is_link:
144
+ finding = inspect_symlink(f, raw_target, root)
145
+ if finding.is_escape:
146
+ escapes.append(finding)
147
+ else:
148
+ safe_links.append(finding)
149
+
150
+ return SymlinkScanResult(
151
+ files_scanned=len(files),
152
+ symlinks_found=len(escapes) + len(safe_links),
153
+ escape_findings=escapes,
154
+ safe_symlinks=safe_links,
155
+ duration_seconds=round(time.time() - start_time, 2),
156
+ )