codebase-receipts-cli 1.0.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.
Files changed (78) hide show
  1. codebase_receipts_cli-1.0.0.dist-info/METADATA +268 -0
  2. codebase_receipts_cli-1.0.0.dist-info/RECORD +78 -0
  3. codebase_receipts_cli-1.0.0.dist-info/WHEEL +4 -0
  4. codebase_receipts_cli-1.0.0.dist-info/entry_points.txt +2 -0
  5. codebase_receipts_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
  6. receipts/__init__.py +0 -0
  7. receipts/ama/__init__.py +0 -0
  8. receipts/ama/interviewer.py +415 -0
  9. receipts/ats/__init__.py +1 -0
  10. receipts/ats/comparator.py +98 -0
  11. receipts/ats/scorer.py +550 -0
  12. receipts/ats/stats.py +164 -0
  13. receipts/cli.py +2062 -0
  14. receipts/config.py +106 -0
  15. receipts/errors.py +18 -0
  16. receipts/export.py +120 -0
  17. receipts/ingest/__init__.py +0 -0
  18. receipts/ingest/artifact_extractor.py +679 -0
  19. receipts/ingest/git_source.py +159 -0
  20. receipts/ingest/manifest.py +114 -0
  21. receipts/ingest/scanner.py +141 -0
  22. receipts/ingest/secrets_scanner.py +225 -0
  23. receipts/interactive/__init__.py +1 -0
  24. receipts/interactive/repl.py +755 -0
  25. receipts/ledger/__init__.py +0 -0
  26. receipts/ledger/pricing_table.py +54 -0
  27. receipts/ledger/token_ledger.py +226 -0
  28. receipts/llm/__init__.py +0 -0
  29. receipts/llm/anthropic_provider.py +95 -0
  30. receipts/llm/factory.py +124 -0
  31. receipts/llm/fake_provider.py +79 -0
  32. receipts/llm/gemini_provider.py +205 -0
  33. receipts/llm/json_utils.py +46 -0
  34. receipts/llm/metered.py +62 -0
  35. receipts/llm/ollama_provider.py +117 -0
  36. receipts/llm/openai_provider.py +118 -0
  37. receipts/llm/provider.py +42 -0
  38. receipts/llm/split.py +78 -0
  39. receipts/llm/token_estimate.py +35 -0
  40. receipts/mine/__init__.py +1 -0
  41. receipts/mine/code_metrics.py +246 -0
  42. receipts/mine/git_evidence.py +109 -0
  43. receipts/prep/__init__.py +1 -0
  44. receipts/prep/dossier.py +313 -0
  45. receipts/prep/readiness.py +227 -0
  46. receipts/resume/__init__.py +0 -0
  47. receipts/resume/claim_extractor.py +338 -0
  48. receipts/resume/loader.py +35 -0
  49. receipts/resume/pdf_parser.py +259 -0
  50. receipts/resume/tex_parser.py +394 -0
  51. receipts/rewrite/__init__.py +0 -0
  52. receipts/rewrite/compiler.py +180 -0
  53. receipts/rewrite/optimizer.py +140 -0
  54. receipts/rewrite/tex_rewriter.py +227 -0
  55. receipts/tui/__init__.py +0 -0
  56. receipts/tui/ama_app.py +454 -0
  57. receipts/tui/app.py +316 -0
  58. receipts/tui/dossier_app.py +170 -0
  59. receipts/tui/hub.py +367 -0
  60. receipts/tui/ingest_app.py +183 -0
  61. receipts/tui/rewrite_app.py +463 -0
  62. receipts/tui/score_app.py +237 -0
  63. receipts/tui/widgets/__init__.py +0 -0
  64. receipts/tui/widgets/claims_table.py +50 -0
  65. receipts/tui/widgets/diff_view.py +38 -0
  66. receipts/tui/widgets/status_bar.py +38 -0
  67. receipts/verify/__init__.py +0 -0
  68. receipts/verify/bm25.py +112 -0
  69. receipts/verify/enrich.py +128 -0
  70. receipts/verify/jd_fetch.py +101 -0
  71. receipts/verify/kb.py +379 -0
  72. receipts/verify/keyword_gap.py +127 -0
  73. receipts/verify/query_expand.py +88 -0
  74. receipts/verify/rerank.py +74 -0
  75. receipts/verify/rewriter.py +172 -0
  76. receipts/verify/router.py +211 -0
  77. receipts/verify/summaries.py +258 -0
  78. receipts/verify/verifier.py +552 -0
@@ -0,0 +1,159 @@
1
+ """Clone a git repo to a temporary directory for ingestion.
2
+
3
+ Windows facts verified against the installed GitPython 3.1.50: the clone's
4
+ ``.git/objects/pack`` files are read-only (plain ``shutil.rmtree`` fails with
5
+ WinError 5) and mmap'd pack handles keep files locked until ``repo.close()``
6
+ runs. ``git.util.rmtree`` handles the read-only case, so cleanup is always
7
+ ``repo.close()`` then ``git.util.rmtree``.
8
+
9
+ Phase 19: remote repos are first-class. A URL gets a durable KB identity
10
+ (``kb_dir_for_url``) derived from its canonical form — the same URL always
11
+ maps to the same KB directory, so re-ingesting reuses the incremental sync
12
+ instead of orphaning a new KB per clone. Private HTTPS repos authenticate
13
+ via the ``GITHUB_TOKEN`` env var (BYOK-style: read at clone time, injected
14
+ into the URL for that one process, never stored, and scrubbed from every
15
+ error message).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ import os
22
+ import re
23
+ import tempfile
24
+ from collections.abc import Iterator
25
+ from contextlib import contextmanager
26
+ from pathlib import Path
27
+
28
+ from git import Repo
29
+ from git.exc import GitCommandError, GitCommandNotFound
30
+ from git.util import rmtree as _git_rmtree # NOT `git.index.util` — see module doc
31
+
32
+ from receipts.errors import ReceiptsError
33
+
34
+ _GIT_URL_PREFIXES = ("http://", "https://", "git@", "ssh://", "file://")
35
+
36
+
37
+ def looks_like_git_url(source: str) -> bool:
38
+ """Heuristic: is this ingest source a git URL rather than a local folder?"""
39
+ return source.startswith(_GIT_URL_PREFIXES) or source.endswith(".git")
40
+
41
+
42
+ def canonical_git_url(url: str) -> str:
43
+ """Reduce a git URL to a canonical ``host/owner/repo`` form.
44
+
45
+ ``https://github.com/Owner/Repo.git``, ``git@github.com:Owner/Repo`` and
46
+ ``https://github.com/Owner/Repo/`` all canonicalize identically, so they
47
+ share one KB. Credentials embedded in the URL are stripped — they must
48
+ never leak into KB names or the manifest.
49
+ """
50
+ s = url.strip().rstrip("/")
51
+ if s.endswith(".git"):
52
+ s = s[: -len(".git")]
53
+ if s.startswith("git@"):
54
+ host_path = s[len("git@") :].replace(":", "/", 1)
55
+ else:
56
+ for prefix in ("ssh://git@", "https://", "http://", "ssh://", "file://"):
57
+ if s.startswith(prefix):
58
+ host_path = s[len(prefix) :]
59
+ break
60
+ else:
61
+ host_path = s
62
+ first, sep, rest = host_path.partition("/")
63
+ if "@" in first: # user:token@host — drop the credentials
64
+ first = first.rsplit("@", 1)[-1]
65
+ host_path = f"{first.lower()}{sep}{rest}"
66
+ return "/".join(p for p in host_path.split("/") if p)
67
+
68
+
69
+ def repo_slug(url: str) -> str:
70
+ """Short human label for a repo URL — ``owner/repo`` where possible."""
71
+ parts = canonical_git_url(url).split("/")
72
+ return "/".join(parts[-2:]) if len(parts) >= 3 else parts[-1]
73
+
74
+
75
+ def kb_dir_for_url(url: str) -> Path:
76
+ """Durable per-URL KB directory under ``~/.receipts/kb/``.
77
+
78
+ Keyed by the canonical URL, not the (random) clone directory — the same
79
+ repo always resolves to the same KB, which is what lets verify/ama/
80
+ dossier find it again and lets re-ingest hit the incremental sync.
81
+ """
82
+ canon = canonical_git_url(url)
83
+ short_hash = hashlib.sha256(canon.encode()).hexdigest()[:8]
84
+ safe_slug = re.sub(r"[^A-Za-z0-9._-]", "_", repo_slug(url))
85
+ return Path.home() / ".receipts" / "kb" / f"{safe_slug}_{short_hash}"
86
+
87
+
88
+ def _with_token(url: str, token: str) -> str:
89
+ """Inject a token into an HTTPS clone URL (no-op otherwise).
90
+
91
+ Only applies when the URL carries no credentials already. The result is
92
+ used solely as the clone argument — never logged, never stored.
93
+ """
94
+ if not token or not url.startswith("https://"):
95
+ return url
96
+ rest = url[len("https://") :]
97
+ if "@" in rest.split("/", 1)[0]:
98
+ return url # URL already carries credentials — leave it alone
99
+ return f"https://x-access-token:{token}@{rest}"
100
+
101
+
102
+ def _scrub(text: str, token: str) -> str:
103
+ """Remove a token from text destined for the terminal or an exception."""
104
+ return text.replace(token, "***") if token else text
105
+
106
+
107
+ @contextmanager
108
+ def cloned_repo(
109
+ url: str,
110
+ *,
111
+ branch: str | None = None,
112
+ full_history: bool = False,
113
+ ) -> Iterator[Path]:
114
+ """Clone ``url`` into a temp dir, yield its path, always clean up.
115
+
116
+ Shallow (depth=1) by default — verification only needs the current tree.
117
+ ``full_history=True`` clones everything so git-history evidence (commit
118
+ timespan, contributors) works on remote repos too. ``branch`` selects a
119
+ branch or tag. A ``GITHUB_TOKEN`` env var, when present, authenticates
120
+ HTTPS clones of private repos.
121
+ """
122
+ token = os.environ.get("GITHUB_TOKEN", "").strip()
123
+ clone_url = _with_token(url, token)
124
+
125
+ kwargs: dict = {"env": {"GIT_TERMINAL_PROMPT": "0"}} # fail fast, no prompts
126
+ if not full_history:
127
+ kwargs["depth"] = 1
128
+ if branch:
129
+ kwargs["branch"] = branch
130
+
131
+ tmp = Path(tempfile.mkdtemp(prefix="receipts-clone-"))
132
+ repo = None
133
+ try:
134
+ try:
135
+ repo = Repo.clone_from(clone_url, tmp, **kwargs)
136
+ except GitCommandNotFound as exc:
137
+ raise ReceiptsError(
138
+ "The `git` executable was not found. Install git from"
139
+ " https://git-scm.com/downloads and try again."
140
+ ) from exc
141
+ except GitCommandError as exc:
142
+ stderr = _scrub((exc.stderr or "").strip(), token)
143
+ hint = ""
144
+ if not token and url.startswith("https://"):
145
+ hint = (
146
+ "\n Private repo? Set GITHUB_TOKEN=<personal access token>"
147
+ " and retry (the token is read from the environment, never"
148
+ " stored)."
149
+ )
150
+ raise ReceiptsError(
151
+ f"Could not clone '{url}'.\n"
152
+ f" Check the URL and that you have access. git said:\n"
153
+ f"{stderr}{hint}"
154
+ ) from None # never chain the raw exception — it embeds the URL+token
155
+ yield tmp
156
+ finally:
157
+ if repo is not None:
158
+ repo.close() # release mmap'd pack handles so Windows can delete
159
+ _git_rmtree(tmp)
@@ -0,0 +1,114 @@
1
+ """Source manifest — which KB belongs to which ingested source.
2
+
3
+ A small JSON file (``~/.receipts/kb/manifest.json``) recording every source
4
+ that was ever ingested: local folders and remote URLs alike, each with the
5
+ KB directory it produced and a short alias. This is what lets every command
6
+ accept ``receipts verify resume.tex owner/repo`` (or the full URL, or a
7
+ folder name) anywhere a codebase folder is accepted — the KB outlives the
8
+ temp clone.
9
+
10
+ The manifest never stores credentials; remote entries hold the canonical
11
+ URL with any embedded tokens already stripped.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import time
18
+ from dataclasses import asdict, dataclass
19
+ from pathlib import Path
20
+
21
+ _DEFAULT_MANIFEST_PATH = Path.home() / ".receipts" / "kb" / "manifest.json"
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class SourceEntry:
26
+ source: str # canonical URL or resolved local path
27
+ kb_dir: str
28
+ alias: str # short label: "owner/repo" or the folder name
29
+ kind: str # "remote" | "local"
30
+ last_ingested: float
31
+
32
+
33
+ def _load(manifest_path: Path) -> dict:
34
+ if not manifest_path.is_file():
35
+ return {"version": 1, "sources": {}}
36
+ try:
37
+ data = json.loads(manifest_path.read_text(encoding="utf-8"))
38
+ except (OSError, json.JSONDecodeError):
39
+ return {"version": 1, "sources": {}}
40
+ if not isinstance(data.get("sources"), dict):
41
+ data["sources"] = {}
42
+ return data
43
+
44
+
45
+ def record_source(
46
+ source: str,
47
+ kb_dir: Path,
48
+ *,
49
+ alias: str,
50
+ kind: str,
51
+ manifest_path: Path | None = None,
52
+ ) -> None:
53
+ """Record (or refresh) a source → KB mapping after a successful ingest."""
54
+ path = manifest_path or _DEFAULT_MANIFEST_PATH
55
+ path.parent.mkdir(parents=True, exist_ok=True)
56
+ data = _load(path)
57
+ data["sources"][source] = asdict(
58
+ SourceEntry(
59
+ source=source,
60
+ kb_dir=str(kb_dir),
61
+ alias=alias,
62
+ kind=kind,
63
+ last_ingested=time.time(),
64
+ )
65
+ )
66
+ tmp = path.with_suffix(".json.tmp")
67
+ tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
68
+ tmp.replace(path)
69
+
70
+
71
+ def lookup_source(
72
+ spec: str,
73
+ *,
74
+ manifest_path: Path | None = None,
75
+ ) -> SourceEntry | None:
76
+ """Find a recorded source by exact source key or by alias (case-insensitive).
77
+
78
+ Alias lookups prefer the most recently ingested entry when two sources
79
+ share an alias — the newest ingest is almost always the one meant.
80
+ """
81
+ path = manifest_path or _DEFAULT_MANIFEST_PATH
82
+ data = _load(path)
83
+ sources = data["sources"]
84
+
85
+ if spec in sources:
86
+ return SourceEntry(**sources[spec])
87
+
88
+ wanted = spec.strip().lower()
89
+ matches = [
90
+ SourceEntry(**e)
91
+ for e in sources.values()
92
+ if e.get("alias", "").lower() == wanted
93
+ ]
94
+ if not matches:
95
+ return None
96
+ return max(matches, key=lambda e: e.last_ingested)
97
+
98
+
99
+ def known_aliases(*, manifest_path: Path | None = None) -> list[str]:
100
+ """All recorded aliases, newest first — for friendly error messages."""
101
+ path = manifest_path or _DEFAULT_MANIFEST_PATH
102
+ data = _load(path)
103
+ entries = sorted(
104
+ data["sources"].values(),
105
+ key=lambda e: -float(e.get("last_ingested", 0)),
106
+ )
107
+ seen: set[str] = set()
108
+ out: list[str] = []
109
+ for e in entries:
110
+ alias = e.get("alias", "")
111
+ if alias and alias not in seen:
112
+ seen.add(alias)
113
+ out.append(alias)
114
+ return out
@@ -0,0 +1,141 @@
1
+ """Tree-style structural scan of a local folder.
2
+
3
+ Respects the repo's root ``.gitignore`` (nested .gitignore files are a known,
4
+ documented limitation for now), always skips ``.git`` and cache directories,
5
+ detects virtualenv directories without descending into them (they get flagged
6
+ by the secrets scanner instead of ingested), and classifies files as text or
7
+ binary so downstream stages only ever read text.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+
16
+ import pathspec
17
+
18
+ _ALWAYS_SKIP_DIRS = {".git", "__pycache__", ".pytest_cache", ".ruff_cache"}
19
+ # Bulk dependency dirs: skipped for ingestion but recorded, never silent.
20
+ _DEPENDENCY_DIRS = {"node_modules"}
21
+ _VENV_DIR_NAMES = {".venv", "venv"}
22
+
23
+ #: Per-file content read cap — protects against pathological giant files.
24
+ MAX_CONTENT_BYTES = 2_000_000
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class ScannedFile:
29
+ rel_path: str # posix-style, relative to the scan root
30
+ size_bytes: int
31
+ is_text: bool
32
+
33
+
34
+ @dataclass
35
+ class ScanResult:
36
+ root: Path
37
+ files: list[ScannedFile] = field(default_factory=list)
38
+ venv_dirs: list[str] = field(default_factory=list)
39
+ skipped_dirs: list[str] = field(default_factory=list) # e.g. node_modules
40
+ ignored_count: int = 0 # files/dirs excluded by .gitignore
41
+
42
+ @property
43
+ def text_files(self) -> list[ScannedFile]:
44
+ return [f for f in self.files if f.is_text]
45
+
46
+ def render_tree(self, max_entries: int = 200) -> str:
47
+ """ASCII tree of scanned files (ASCII so Windows consoles render it)."""
48
+ lines = [f"{self.root.name}/"]
49
+ shown = 0
50
+ current_dir = None
51
+ for file in self.files:
52
+ if shown >= max_entries:
53
+ lines.append(f" ... and {len(self.files) - shown} more files")
54
+ break
55
+ parent = file.rel_path.rsplit("/", 1)[0] if "/" in file.rel_path else ""
56
+ if parent and parent != current_dir:
57
+ lines.append(f" {parent}/")
58
+ current_dir = parent or current_dir
59
+ name = file.rel_path.rsplit("/", 1)[-1]
60
+ indent = " " if parent else " "
61
+ suffix = "" if file.is_text else " [binary]"
62
+ lines.append(f"{indent}{name}{suffix}")
63
+ shown += 1
64
+ return "\n".join(lines)
65
+
66
+
67
+ def _looks_like_text(path: Path) -> bool:
68
+ try:
69
+ with open(path, "rb") as handle:
70
+ chunk = handle.read(8192)
71
+ except OSError:
72
+ return False
73
+ return b"\x00" not in chunk
74
+
75
+
76
+ def _is_venv_dir(path: Path, name: str) -> bool:
77
+ return name in _VENV_DIR_NAMES or (path / "pyvenv.cfg").is_file()
78
+
79
+
80
+ def scan(root: Path) -> ScanResult:
81
+ """Walk ``root`` and return the structural scan."""
82
+ root = Path(root).resolve()
83
+ if not root.is_dir():
84
+ raise NotADirectoryError(f"Not a folder: {root}")
85
+
86
+ gitignore = root / ".gitignore"
87
+ lines = (
88
+ gitignore.read_text(encoding="utf-8", errors="replace").splitlines()
89
+ if gitignore.is_file()
90
+ else []
91
+ )
92
+ spec = pathspec.GitIgnoreSpec.from_lines(lines)
93
+
94
+ result = ScanResult(root=root)
95
+ for dirpath, dirnames, filenames in os.walk(root):
96
+ rel_dir = Path(dirpath).relative_to(root).as_posix()
97
+
98
+ kept: list[str] = []
99
+ for name in sorted(dirnames):
100
+ rel = name if rel_dir == "." else f"{rel_dir}/{name}"
101
+ full = Path(dirpath) / name
102
+ if name in _ALWAYS_SKIP_DIRS:
103
+ continue
104
+ # .gitignore first: a properly ignored venv/.env is the CORRECT
105
+ # setup and must not be flagged — only content git would pick up
106
+ # counts as committed-by-mistake.
107
+ if spec.match_file(rel + "/"): # dir patterns need a trailing slash
108
+ result.ignored_count += 1
109
+ continue
110
+ if name in _DEPENDENCY_DIRS:
111
+ result.skipped_dirs.append(rel)
112
+ continue
113
+ if _is_venv_dir(full, name):
114
+ result.venv_dirs.append(rel)
115
+ continue
116
+ kept.append(name)
117
+ dirnames[:] = kept
118
+
119
+ for name in sorted(filenames):
120
+ rel = name if rel_dir == "." else f"{rel_dir}/{name}"
121
+ if spec.match_file(rel):
122
+ result.ignored_count += 1
123
+ continue
124
+ full = Path(dirpath) / name
125
+ try:
126
+ size = full.stat().st_size
127
+ except OSError:
128
+ continue
129
+ result.files.append(
130
+ ScannedFile(
131
+ rel_path=rel, size_bytes=size, is_text=_looks_like_text(full)
132
+ )
133
+ )
134
+ return result
135
+
136
+
137
+ def read_text(root: Path, rel_path: str, max_bytes: int = MAX_CONTENT_BYTES) -> str:
138
+ """Read a scanned file's text content, capped at ``max_bytes``."""
139
+ with open(Path(root) / rel_path, "rb") as handle:
140
+ data = handle.read(max_bytes)
141
+ return data.decode("utf-8", errors="replace")
@@ -0,0 +1,225 @@
1
+ """Pre-embedding secrets scan.
2
+
3
+ This runs BEFORE any repo content is embedded or sent to a provider (ground
4
+ rule: security hygiene is a real feature). It detects likely secrets with
5
+ named regex patterns plus a Shannon-entropy check, and it calls out two
6
+ specific, common mistakes loudly: a real ``.env`` file and a committed
7
+ virtualenv directory inside the scanned content.
8
+
9
+ ``redact()`` strips matched secrets so downstream code can embed or send
10
+ content without leaking them. Entropy-only findings are warnings and are NOT
11
+ redacted automatically — high-entropy strings are often legitimate (hashes,
12
+ IDs), so mangling them silently would corrupt code content.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import math
18
+ import re
19
+ from collections import Counter
20
+ from collections.abc import Iterable
21
+ from dataclasses import dataclass, field
22
+
23
+ HIGH = "high"
24
+ WARNING = "warning"
25
+
26
+ # Named patterns for well-known credential formats. Each match is a HIGH
27
+ # finding and is redactable.
28
+ _PATTERNS: list[tuple[str, re.Pattern[str]]] = [
29
+ ("aws-access-key-id", re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b")),
30
+ ("google-api-key", re.compile(r"\bAIza[0-9A-Za-z_\-]{35}\b")),
31
+ # Covers OpenAI (sk-...) and Anthropic (sk-ant-...) style keys.
32
+ ("sk-style-api-key", re.compile(r"\bsk-[A-Za-z0-9_\-]{16,}\b")),
33
+ (
34
+ "github-token",
35
+ re.compile(
36
+ r"\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36}\b|\bgithub_pat_[A-Za-z0-9_]{22,}\b"
37
+ ),
38
+ ),
39
+ ("slack-token", re.compile(r"\bxox[baprs]-[A-Za-z0-9\-]{10,}\b")),
40
+ ("private-key-block", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")),
41
+ (
42
+ "credential-assignment",
43
+ re.compile(
44
+ r"(?i)\b(?:api[_-]?key|secret|token|password|passwd)\s*[:=]\s*"
45
+ r"['\"](?P<value>[^'\"\s]{8,})['\"]"
46
+ ),
47
+ ),
48
+ ]
49
+
50
+ # Candidate tokens for the entropy check: long runs of base64/url-safe chars.
51
+ _ENTROPY_CANDIDATE = re.compile(r"\b[A-Za-z0-9+/_\-]{24,}\b")
52
+ _ENTROPY_THRESHOLD = 4.2 # pure hex tops out at 4.0, so this skips plain hashes
53
+
54
+ # Machine-generated dependency manifests are wall-to-wall hashes and URLs —
55
+ # the classic entropy false-positive source. Named patterns still run on them;
56
+ # only the entropy heuristic is skipped.
57
+ _LOCKFILE_NAMES = {
58
+ "uv.lock",
59
+ "poetry.lock",
60
+ "package-lock.json",
61
+ "yarn.lock",
62
+ "pnpm-lock.yaml",
63
+ "Cargo.lock",
64
+ "Gemfile.lock",
65
+ "composer.lock",
66
+ "go.sum",
67
+ }
68
+
69
+ # Past this many entropy findings in one file, collapse the rest into a single
70
+ # summary finding — a flood of them means machine-generated content, and 900
71
+ # identical warnings would bury the real ones.
72
+ _MAX_ENTROPY_FINDINGS_PER_FILE = 15
73
+
74
+ _SAFE_ENV_EXAMPLE_NAMES = {".env.example", ".env.sample", ".env.template"}
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class SecretFinding:
79
+ rel_path: str
80
+ line: int # 1-based; 0 for file/directory-level findings
81
+ kind: str
82
+ severity: str
83
+ message: str
84
+
85
+
86
+ @dataclass
87
+ class SecretsReport:
88
+ findings: list[SecretFinding] = field(default_factory=list)
89
+
90
+ @property
91
+ def high_severity(self) -> list[SecretFinding]:
92
+ return [f for f in self.findings if f.severity == HIGH]
93
+
94
+ @property
95
+ def has_high_severity(self) -> bool:
96
+ return bool(self.high_severity)
97
+
98
+ def render(self) -> str:
99
+ if not self.findings:
100
+ return "Secrets scan: nothing suspicious found."
101
+ lines = [f"Secrets scan: {len(self.findings)} finding(s)."]
102
+ for f in sorted(self.findings, key=lambda f: (f.severity != HIGH, f.rel_path)):
103
+ location = f"{f.rel_path}:{f.line}" if f.line else f.rel_path
104
+ marker = "!!" if f.severity == HIGH else " !"
105
+ lines.append(f" {marker} [{f.kind}] {location} - {f.message}")
106
+ return "\n".join(lines)
107
+
108
+
109
+ def _shannon_entropy(token: str) -> float:
110
+ counts = Counter(token)
111
+ length = len(token)
112
+ return -sum(
113
+ (count / length) * math.log2(count / length) for count in counts.values()
114
+ )
115
+
116
+
117
+ def _is_env_file(rel_path: str) -> bool:
118
+ name = rel_path.rsplit("/", 1)[-1]
119
+ if name in _SAFE_ENV_EXAMPLE_NAMES:
120
+ return False
121
+ return name == ".env" or name.startswith(".env.")
122
+
123
+
124
+ def scan_text(rel_path: str, text: str) -> list[SecretFinding]:
125
+ """Scan one file's text for likely secrets."""
126
+ findings: list[SecretFinding] = []
127
+ entropy_enabled = rel_path.rsplit("/", 1)[-1] not in _LOCKFILE_NAMES
128
+ entropy_findings = 0
129
+ entropy_overflow = 0
130
+ for line_no, line in enumerate(text.splitlines(), start=1):
131
+ matched_spans: list[tuple[int, int]] = []
132
+ for kind, pattern in _PATTERNS:
133
+ for match in pattern.finditer(line):
134
+ matched_spans.append(match.span())
135
+ findings.append(
136
+ SecretFinding(
137
+ rel_path=rel_path,
138
+ line=line_no,
139
+ kind=kind,
140
+ severity=HIGH,
141
+ message="Likely credential; it will be redacted before "
142
+ "any content leaves this machine.",
143
+ )
144
+ )
145
+ if not entropy_enabled or "://" in line:
146
+ continue # lockfile or URL line: named patterns only
147
+ for match in _ENTROPY_CANDIDATE.finditer(line):
148
+ span = match.span()
149
+ if any(s <= span[0] and span[1] <= e for s, e in matched_spans):
150
+ continue # already reported by a named pattern
151
+ token = match.group()
152
+ if _shannon_entropy(token) >= _ENTROPY_THRESHOLD:
153
+ if entropy_findings >= _MAX_ENTROPY_FINDINGS_PER_FILE:
154
+ entropy_overflow += 1
155
+ continue
156
+ entropy_findings += 1
157
+ findings.append(
158
+ SecretFinding(
159
+ rel_path=rel_path,
160
+ line=line_no,
161
+ kind="high-entropy-string",
162
+ severity=WARNING,
163
+ message="High-entropy string - double-check this is not "
164
+ "a real secret.",
165
+ )
166
+ )
167
+ if entropy_overflow:
168
+ findings.append(
169
+ SecretFinding(
170
+ rel_path=rel_path,
171
+ line=0,
172
+ kind="high-entropy-string",
173
+ severity=WARNING,
174
+ message=f"...plus {entropy_overflow} more high-entropy strings "
175
+ "in this file - it looks machine-generated; review it once "
176
+ "rather than string by string.",
177
+ )
178
+ )
179
+ return findings
180
+
181
+
182
+ def flag_special_paths(
183
+ file_rel_paths: Iterable[str], venv_dirs: Iterable[str]
184
+ ) -> list[SecretFinding]:
185
+ """File/directory-level red flags: real .env files and committed venvs."""
186
+ findings: list[SecretFinding] = []
187
+ for rel_path in file_rel_paths:
188
+ if _is_env_file(rel_path):
189
+ findings.append(
190
+ SecretFinding(
191
+ rel_path=rel_path,
192
+ line=0,
193
+ kind="env-file",
194
+ severity=HIGH,
195
+ message="A real .env file is part of the scanned content. "
196
+ "If this repo is shared or public, rotate every key in it "
197
+ "and add .env to .gitignore.",
198
+ )
199
+ )
200
+ for rel_path in venv_dirs:
201
+ findings.append(
202
+ SecretFinding(
203
+ rel_path=rel_path,
204
+ line=0,
205
+ kind="venv-directory",
206
+ severity=HIGH,
207
+ message="A virtualenv is part of the scanned content - it "
208
+ "doesn't belong in a repo. Add it to .gitignore and remove it "
209
+ "from version control.",
210
+ )
211
+ )
212
+ return findings
213
+
214
+
215
+ def redact(text: str) -> tuple[str, int]:
216
+ """Replace every named-pattern match with a [REDACTED:<kind>] marker.
217
+
218
+ Returns the redacted text and the number of replacements. Entropy-only
219
+ findings are intentionally left alone (see module docstring).
220
+ """
221
+ total = 0
222
+ for kind, pattern in _PATTERNS:
223
+ text, count = pattern.subn(f"[REDACTED:{kind}]", text)
224
+ total += count
225
+ return text, total
@@ -0,0 +1 @@
1
+ """Interactive slash-command REPL for Receipts."""