File size: 10,066 Bytes
71d239c 7603aa2 71d239c 7603aa2 71d239c 7603aa2 71d239c 7603aa2 71d239c 7603aa2 | 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 | """Turn whatever the user gives us into a clean list of analysable files.
Handles a ZIP upload, a single pasted/uploaded file, a folder path, or a
GitHub URL. Filters out noise (node_modules, .git, binaries, lockfiles),
enforces size caps, and — critically — runs a **secret scan** so we never
display or send API keys and passwords to the model.
"""
from __future__ import annotations
import os
import re
import tempfile
import zipfile
from dataclasses import dataclass, field
import config
@dataclass
class SourceFile:
path: str # project-relative, forward slashes
abspath: str # where it lives on disk right now
text: str # file contents, with secrets already redacted
lang: str
@dataclass
class Ingested:
name: str
files: list[SourceFile] = field(default_factory=list)
skipped: list[str] = field(default_factory=list) # listed but not parsed
secrets_found: list[str] = field(default_factory=list) # redacted descriptions
note: str = "" # user-facing caveat, if any
# --- Secret scanning --------------------------------------------------------
# Best-effort, conservative patterns. The goal is to protect the user, not to be
# a full secret scanner. Each match is redacted in-place before anything is shown
# or sent to the model.
_SECRET_PATTERNS: tuple[tuple[str, re.Pattern[str]], None | str] = ()
SECRET_RULES: list[tuple[str, re.Pattern[str]]] = [
("OpenAI / Anthropic-style key", re.compile(r"\b(sk-[A-Za-z0-9_-]{16,}|sk-ant-[A-Za-z0-9_-]{16,})")),
("AWS access key", re.compile(r"\bAKIA[0-9A-Z]{16}\b")),
("Google API key", re.compile(r"\bAIza[0-9A-Za-z_-]{30,}\b")),
("GitHub token", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}\b")),
("Slack token", re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b")),
("Private key block", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")),
# Generic "NAME = 'long-secret'" assignments for password/secret/token/api_key.
("Hard-coded secret value", re.compile(
r"(?i)\b(password|passwd|secret|token|api[_-]?key|access[_-]?key)\b\s*[:=]\s*"
r"['\"][^'\"]{8,}['\"]")),
]
_REDACTION = "«REDACTED-SECRET»"
def redact_secrets(text: str) -> tuple[str, list[str]]:
"""Replace likely secrets with a placeholder. Returns (clean_text, hits)."""
hits: list[str] = []
for label, pattern in SECRET_RULES:
if pattern.search(text):
hits.append(label)
text = pattern.sub(_REDACTION, text)
return text, hits
# --- Filtering --------------------------------------------------------------
def _lang_for(path: str) -> str | None:
_, ext = os.path.splitext(path.lower())
if ext in config.BINARY_EXTS:
return None
return config.LANG_BY_EXT.get(ext)
def _should_skip_dir(name: str) -> bool:
return name in config.IGNORE_DIRS or name.startswith(".") or name == "__MACOSX"
def _iter_files(root: str):
"""Yield (relpath, abspath) for candidate files under root, pruning noise."""
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if not _should_skip_dir(d)]
for fn in filenames:
if fn.lower() in config.IGNORE_FILES:
continue
abspath = os.path.join(dirpath, fn)
rel = os.path.relpath(abspath, root).replace(os.sep, "/")
yield rel, abspath
def _read(abspath: str) -> str | None:
try:
if os.path.getsize(abspath) > config.MAX_FILE_BYTES:
return None
with open(abspath, "r", encoding="utf-8", errors="ignore") as fh:
return fh.read()
except (OSError, ValueError):
return None
# --- Public entry points ----------------------------------------------------
def from_folder(root: str, name: str | None = None) -> Ingested:
"""Analyse a directory already on disk."""
out = Ingested(name=name or os.path.basename(os.path.normpath(root)) or "your project")
total = 0
for rel, abspath in _iter_files(root):
lang = _lang_for(rel)
base = os.path.basename(rel).lower()
is_manifest = base in config.DEP_MANIFESTS
if lang is None and not is_manifest:
out.skipped.append(rel)
continue
text = _read(abspath)
if text is None:
out.skipped.append(rel)
continue
total += len(text.encode("utf-8", "ignore"))
if total > config.MAX_TOTAL_BYTES or len(out.files) >= config.MAX_FILES:
out.skipped.append(rel)
out.note = (f"Large project — analysed the first {len(out.files)} files; "
f"the rest are listed but not narrated.")
continue
clean, hits = redact_secrets(text)
for h in hits:
desc = f"{rel}: {h}"
if desc not in out.secrets_found:
out.secrets_found.append(desc)
out.files.append(SourceFile(path=rel, abspath=abspath, text=clean,
lang=lang or "other"))
out.files.sort(key=lambda f: (0 if f.lang in ("python", "javascript", "typescript") else 1, f.path))
out.skipped.sort()
return out
def from_zip(zip_path: str, name: str | None = None) -> Ingested:
"""Unzip to a temp dir and analyse it."""
tmp = tempfile.mkdtemp(prefix="storycode_")
derived = name or os.path.splitext(os.path.basename(zip_path))[0]
with zipfile.ZipFile(zip_path) as zf:
for member in zf.namelist():
# Guard against zip-slip; skip absolute / parent-escaping paths.
target = os.path.normpath(os.path.join(tmp, member))
if not target.startswith(os.path.abspath(tmp) + os.sep) and target != os.path.abspath(tmp):
continue
if member.endswith("/"):
continue
os.makedirs(os.path.dirname(target), exist_ok=True)
try:
with zf.open(member) as src, open(target, "wb") as dst:
dst.write(src.read())
except (OSError, zipfile.BadZipFile):
continue
# A zip often wraps everything in a single top folder; descend into it.
entries = [e for e in os.listdir(tmp) if not e.startswith("__MACOSX")]
root = tmp
if len(entries) == 1 and os.path.isdir(os.path.join(tmp, entries[0])):
root = os.path.join(tmp, entries[0])
derived = name or entries[0]
return from_folder(root, name=derived)
def from_text(text: str, filename: str = "snippet.py") -> Ingested:
"""Analyse a single pasted file."""
lang = _lang_for(filename) or "other"
clean, hits = redact_secrets(text)
out = Ingested(name=filename)
out.files.append(SourceFile(path=filename, abspath="", text=clean, lang=lang))
out.secrets_found = [f"{filename}: {h}" for h in hits]
return out
# --- GitHub URL ingestion ---------------------------------------------------
_GH_URL_RE = re.compile(
r"https?://github\.com/(?P<owner>[^/\s]+)/(?P<repo>[^/\s]+?)(?:\.git)?"
r"(?:/(?:tree|blob)/(?P<branch>[^/\s]+))?"
r"(?:/(?P<path>[^\s]*))?"
r"\s*$"
)
def parse_github_url(url: str) -> tuple[str, str, str] | None:
"""Parse a GitHub URL into (owner, repo, branch). Returns None if invalid."""
m = _GH_URL_RE.search(url.strip())
if not m:
return None
owner = m.group("owner")
repo = m.group("repo")
branch = m.group("branch") or "HEAD"
return owner, repo, branch
def from_github(url: str, name: str | None = None) -> Ingested:
"""Download a public GitHub repo and analyse it.
Uses the GitHub archive endpoint (no git required). For large repos,
only the first MAX_FILES files are analysed; the rest are listed but
not narrated (handled by from_folder's size caps).
"""
import urllib.request
parsed = parse_github_url(url)
if not parsed:
return Ingested(name="invalid URL", note="Could not parse GitHub URL. Use: https://github.com/owner/repo")
owner, repo, branch = parsed
repo_name = name or f"{owner}/{repo}"
# Try the archive endpoint: main → master fallback
archive_urls = [
f"https://github.com/{owner}/{repo}/archive/refs/heads/{branch}.zip",
f"https://github.com/{owner}/{repo}/archive/refs/heads/main.zip",
f"https://github.com/{owner}/{repo}/archive/refs/heads/master.zip",
]
tmp = tempfile.mkdtemp(prefix="storycode_gh_")
zip_path = os.path.join(tmp, "repo.zip")
for archive_url in archive_urls:
try:
req = urllib.request.Request(archive_url, headers={"User-Agent": "StoryCode/1.0"})
with urllib.request.urlopen(req, timeout=60) as resp:
data = resp.read()
with open(zip_path, "wb") as f:
f.write(data)
break
except (urllib.error.URLError, OSError):
continue
else:
return Ingested(
name=repo_name,
note="Could not download the repository. Make sure it's public and the URL is correct.",
)
# Extract and find the root
with zipfile.ZipFile(zip_path) as zf:
for member in zf.namelist():
target = os.path.normpath(os.path.join(tmp, member))
if not target.startswith(os.path.abspath(tmp) + os.sep) and target != os.path.abspath(tmp):
continue
if member.endswith("/"):
continue
os.makedirs(os.path.dirname(target), exist_ok=True)
try:
with zf.open(member) as src, open(target, "wb") as dst:
dst.write(src.read())
except (OSError, zipfile.BadZipFile):
continue
# GitHub archives wrap in {repo}-{branch}/ folder
entries = [e for e in os.listdir(tmp) if not e.startswith("__MACOSX") and e != "repo.zip"]
root = tmp
if len(entries) == 1 and os.path.isdir(os.path.join(tmp, entries[0])):
root = os.path.join(tmp, entries[0])
return from_folder(root, name=repo_name)
|