File size: 10,523 Bytes
53b2ac9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
# SPDX-License-Identifier: MIT
"""
Env Audit Plugin for TrashClaw

Scan a directory for common security misconfigurations:
  - .env files committed to git
  - Exposed secrets (API keys, passwords, tokens in source)
  - World-readable private keys
  - Sensitive files without .gitignore coverage

Plugin contract:
  TOOL_DEF = dict with name, description, parameters (OpenAI function schema)
  run(**kwargs) -> str  (called with the parsed arguments)
"""

import os
import re
import stat

TOOL_DEF = {
    "name": "env_audit",
    "description": "Scan a directory for common security misconfigurations: .env in git, exposed secrets, world-readable keys, missing .gitignore entries.",
    "parameters": {
        "type": "object",
        "properties": {
            "path": {
                "type": "string",
                "description": "Directory to scan (default: current directory)"
            },
            "deep": {
                "type": "boolean",
                "description": "If true, scan file contents for secrets (slower). Default: true."
            }
        },
        "required": []
    }
}

# Filenames that should never be committed
SENSITIVE_FILES = [
    ".env",
    ".env.local",
    ".env.production",
    ".env.staging",
    ".env.development",
    "credentials.json",
    "service-account.json",
    "secrets.yaml",
    "secrets.yml",
    ".netrc",
    ".npmrc",  # can contain tokens
    ".pypirc",  # can contain tokens
]

# Private key file patterns
KEY_PATTERNS = [
    "*.pem",
    "*.key",
    "*.p12",
    "*.pfx",
    "id_rsa",
    "id_ed25519",
    "id_ecdsa",
    "id_dsa",
]

# Regex patterns for secrets in file contents
SECRET_PATTERNS = [
    (r'(?i)(?:api[_-]?key|apikey)\s*[:=]\s*["\']?[A-Za-z0-9_\-]{16,}', "API key"),
    (r'(?i)(?:secret|password|passwd|pwd)\s*[:=]\s*["\']?[^\s"\']{8,}', "Password/Secret"),
    (r'(?i)(?:aws_access_key_id|aws_secret_access_key)\s*[:=]\s*["\']?[A-Za-z0-9/+=]{16,}', "AWS credential"),
    (r'(?i)bearer\s+[A-Za-z0-9_\-\.]{20,}', "Bearer token"),
    (r'ghp_[A-Za-z0-9]{36}', "GitHub PAT"),
    (r'sk-[A-Za-z0-9]{20,}', "OpenAI/Stripe secret key"),
    (r'(?i)(?:private[_-]?key)\s*[:=]\s*["\']?[A-Za-z0-9_\-/+=]{16,}', "Private key value"),
    (r'-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----', "Private key block"),
]

# Extensions to scan for secrets
SCANNABLE_EXTENSIONS = {
    ".py", ".js", ".ts", ".jsx", ".tsx", ".rb", ".go", ".java",
    ".yaml", ".yml", ".json", ".toml", ".ini", ".cfg", ".conf",
    ".sh", ".bash", ".zsh", ".env", ".properties", ".xml",
    ".tf", ".hcl", ".dockerfile",
}

# Directories to skip
SKIP_DIRS = {
    ".git", "node_modules", "__pycache__", ".venv", "venv",
    "env", ".tox", ".mypy_cache", ".pytest_cache", "dist",
    "build", ".eggs", "*.egg-info",
}

MAX_FILE_SIZE = 512 * 1024  # 512KB max per file for content scanning


def _is_git_tracked(filepath: str, repo_root: str) -> bool:
    """Check if a file is tracked by git (would be committed)."""
    try:
        import subprocess
        result = subprocess.run(
            ["git", "ls-files", "--error-unmatch", filepath],
            cwd=repo_root, capture_output=True, text=True, timeout=5
        )
        return result.returncode == 0
    except Exception:
        return False  # Can't determine; assume not tracked


def _is_gitignored(filepath: str, repo_root: str) -> bool:
    """Check if a file is covered by .gitignore."""
    try:
        import subprocess
        result = subprocess.run(
            ["git", "check-ignore", "-q", filepath],
            cwd=repo_root, capture_output=True, text=True, timeout=5
        )
        return result.returncode == 0
    except Exception:
        return False


def _check_permissions(filepath: str) -> str:
    """Check if file has overly permissive permissions (world-readable)."""
    try:
        st = os.stat(filepath)
        mode = st.st_mode
        if mode & stat.S_IROTH:  # World-readable
            return f"{oct(mode & 0o777)} (world-readable)"
    except Exception:
        pass
    return ""


def _scan_file_contents(filepath: str) -> list:
    """Scan a file for secret patterns. Returns list of (line_no, pattern_name, snippet)."""
    findings = []
    try:
        size = os.path.getsize(filepath)
        if size > MAX_FILE_SIZE:
            return findings
        with open(filepath, "r", errors="replace") as f:
            for line_no, line in enumerate(f, 1):
                for pattern, name in SECRET_PATTERNS:
                    if re.search(pattern, line):
                        # Truncate the line for display
                        snippet = line.strip()
                        if len(snippet) > 100:
                            snippet = snippet[:97] + "..."
                        findings.append((line_no, name, snippet))
                        break  # One finding per line is enough
    except Exception:
        pass
    return findings


def _find_git_root(path: str) -> str:
    """Walk up to find .git directory."""
    current = os.path.abspath(path)
    while current != os.path.dirname(current):
        if os.path.isdir(os.path.join(current, ".git")):
            return current
        current = os.path.dirname(current)
    return ""


def run(path: str = "", deep: bool = True, **kwargs) -> str:
    scan_dir = os.path.abspath(path or os.getcwd())
    if not os.path.isdir(scan_dir):
        return f"Error: not a directory: {scan_dir}"

    git_root = _find_git_root(scan_dir)
    has_git = bool(git_root)

    issues = []  # (severity, category, filepath, detail)

    # Walk the directory tree
    for root, dirs, files in os.walk(scan_dir):
        # Skip hidden/vendor directories
        dirs[:] = [d for d in dirs if d not in SKIP_DIRS and not d.startswith(".")]

        rel_root = os.path.relpath(root, scan_dir)

        for fname in files:
            filepath = os.path.join(root, fname)
            rel_path = os.path.relpath(filepath, scan_dir)

            # Check 1: Sensitive files present
            if fname.lower() in [s.lower() for s in SENSITIVE_FILES]:
                if has_git:
                    tracked = _is_git_tracked(rel_path, git_root)
                    ignored = _is_gitignored(rel_path, git_root)
                    if tracked:
                        issues.append(("CRITICAL", "sensitive_file_tracked", rel_path,
                                       f"Sensitive file is tracked by git!"))
                    elif not ignored:
                        issues.append(("WARNING", "sensitive_file_not_ignored", rel_path,
                                       f"Sensitive file exists but is NOT in .gitignore"))
                else:
                    issues.append(("WARNING", "sensitive_file_present", rel_path,
                                   f"Sensitive file found (no git repo to check ignore rules)"))

            # Check 2: Private key files
            for pat in KEY_PATTERNS:
                if pat.startswith("*"):
                    if fname.endswith(pat[1:]):
                        perm = _check_permissions(filepath)
                        if perm:
                            issues.append(("WARNING", "key_permissions", rel_path,
                                           f"Private key is {perm}"))
                        if has_git and _is_git_tracked(rel_path, git_root):
                            issues.append(("CRITICAL", "key_tracked", rel_path,
                                           f"Private key tracked by git!"))
                        break
                elif fname == pat:
                    perm = _check_permissions(filepath)
                    if perm:
                        issues.append(("WARNING", "key_permissions", rel_path,
                                       f"Private key is {perm}"))
                    if has_git and _is_git_tracked(rel_path, git_root):
                        issues.append(("CRITICAL", "key_tracked", rel_path,
                                       f"Private key tracked by git!"))
                    break

            # Check 3: Content scanning for secrets
            if deep:
                _, ext = os.path.splitext(fname)
                if ext.lower() in SCANNABLE_EXTENSIONS or fname.lower() in (".env",):
                    findings = _scan_file_contents(filepath)
                    for line_no, pattern_name, snippet in findings:
                        issues.append(("WARNING", "secret_in_source", rel_path,
                                       f"Line {line_no}: possible {pattern_name}"))

    # Check 4: .gitignore coverage
    if has_git:
        gitignore_path = os.path.join(git_root, ".gitignore")
        if not os.path.exists(gitignore_path):
            issues.append(("INFO", "no_gitignore", ".gitignore",
                           "No .gitignore file found in repository"))
        else:
            try:
                with open(gitignore_path, "r") as f:
                    gitignore_content = f.read()
                recommended = [".env", "*.pem", "*.key", "id_rsa", "credentials.json"]
                missing = [p for p in recommended if p not in gitignore_content]
                if missing:
                    issues.append(("INFO", "gitignore_incomplete", ".gitignore",
                                   f"Missing recommended patterns: {', '.join(missing)}"))
            except Exception:
                pass

    # Format output
    lines = [f"Env Audit: {scan_dir}", "=" * 50]

    if not issues:
        lines.append("")
        lines.append("No issues found. Directory looks clean.")
        return "\n".join(lines)

    # Group by severity
    critical = [i for i in issues if i[0] == "CRITICAL"]
    warnings = [i for i in issues if i[0] == "WARNING"]
    info = [i for i in issues if i[0] == "INFO"]

    if critical:
        lines.append("")
        lines.append(f"CRITICAL ({len(critical)}):")
        for sev, cat, fpath, detail in critical:
            lines.append(f"  [!] {fpath}: {detail}")

    if warnings:
        lines.append("")
        lines.append(f"WARNINGS ({len(warnings)}):")
        for sev, cat, fpath, detail in warnings:
            lines.append(f"  [*] {fpath}: {detail}")

    if info:
        lines.append("")
        lines.append(f"INFO ({len(info)}):")
        for sev, cat, fpath, detail in info:
            lines.append(f"  [i] {fpath}: {detail}")

    lines.append("")
    lines.append(f"Total: {len(critical)} critical, {len(warnings)} warnings, {len(info)} info")

    return "\n".join(lines)