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.
- deployproof/__init__.py +3 -0
- deployproof/cli.py +288 -0
- deployproof/dependencies.py +874 -0
- deployproof/diff.py +234 -0
- deployproof/mutator.py +475 -0
- deployproof/reporter.py +281 -0
- deployproof/secrets.py +221 -0
- deployproof/symlinks.py +156 -0
- deployproof/wsl.py +147 -0
- deployproof-0.1.0.dist-info/METADATA +111 -0
- deployproof-0.1.0.dist-info/RECORD +15 -0
- deployproof-0.1.0.dist-info/WHEEL +5 -0
- deployproof-0.1.0.dist-info/entry_points.txt +2 -0
- deployproof-0.1.0.dist-info/licenses/LICENSE +21 -0
- deployproof-0.1.0.dist-info/top_level.txt +1 -0
deployproof/reporter.py
ADDED
|
@@ -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
|
+
)
|
deployproof/symlinks.py
ADDED
|
@@ -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
|
+
)
|