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.
- codebase_receipts_cli-1.0.0.dist-info/METADATA +268 -0
- codebase_receipts_cli-1.0.0.dist-info/RECORD +78 -0
- codebase_receipts_cli-1.0.0.dist-info/WHEEL +4 -0
- codebase_receipts_cli-1.0.0.dist-info/entry_points.txt +2 -0
- codebase_receipts_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
- receipts/__init__.py +0 -0
- receipts/ama/__init__.py +0 -0
- receipts/ama/interviewer.py +415 -0
- receipts/ats/__init__.py +1 -0
- receipts/ats/comparator.py +98 -0
- receipts/ats/scorer.py +550 -0
- receipts/ats/stats.py +164 -0
- receipts/cli.py +2062 -0
- receipts/config.py +106 -0
- receipts/errors.py +18 -0
- receipts/export.py +120 -0
- receipts/ingest/__init__.py +0 -0
- receipts/ingest/artifact_extractor.py +679 -0
- receipts/ingest/git_source.py +159 -0
- receipts/ingest/manifest.py +114 -0
- receipts/ingest/scanner.py +141 -0
- receipts/ingest/secrets_scanner.py +225 -0
- receipts/interactive/__init__.py +1 -0
- receipts/interactive/repl.py +755 -0
- receipts/ledger/__init__.py +0 -0
- receipts/ledger/pricing_table.py +54 -0
- receipts/ledger/token_ledger.py +226 -0
- receipts/llm/__init__.py +0 -0
- receipts/llm/anthropic_provider.py +95 -0
- receipts/llm/factory.py +124 -0
- receipts/llm/fake_provider.py +79 -0
- receipts/llm/gemini_provider.py +205 -0
- receipts/llm/json_utils.py +46 -0
- receipts/llm/metered.py +62 -0
- receipts/llm/ollama_provider.py +117 -0
- receipts/llm/openai_provider.py +118 -0
- receipts/llm/provider.py +42 -0
- receipts/llm/split.py +78 -0
- receipts/llm/token_estimate.py +35 -0
- receipts/mine/__init__.py +1 -0
- receipts/mine/code_metrics.py +246 -0
- receipts/mine/git_evidence.py +109 -0
- receipts/prep/__init__.py +1 -0
- receipts/prep/dossier.py +313 -0
- receipts/prep/readiness.py +227 -0
- receipts/resume/__init__.py +0 -0
- receipts/resume/claim_extractor.py +338 -0
- receipts/resume/loader.py +35 -0
- receipts/resume/pdf_parser.py +259 -0
- receipts/resume/tex_parser.py +394 -0
- receipts/rewrite/__init__.py +0 -0
- receipts/rewrite/compiler.py +180 -0
- receipts/rewrite/optimizer.py +140 -0
- receipts/rewrite/tex_rewriter.py +227 -0
- receipts/tui/__init__.py +0 -0
- receipts/tui/ama_app.py +454 -0
- receipts/tui/app.py +316 -0
- receipts/tui/dossier_app.py +170 -0
- receipts/tui/hub.py +367 -0
- receipts/tui/ingest_app.py +183 -0
- receipts/tui/rewrite_app.py +463 -0
- receipts/tui/score_app.py +237 -0
- receipts/tui/widgets/__init__.py +0 -0
- receipts/tui/widgets/claims_table.py +50 -0
- receipts/tui/widgets/diff_view.py +38 -0
- receipts/tui/widgets/status_bar.py +38 -0
- receipts/verify/__init__.py +0 -0
- receipts/verify/bm25.py +112 -0
- receipts/verify/enrich.py +128 -0
- receipts/verify/jd_fetch.py +101 -0
- receipts/verify/kb.py +379 -0
- receipts/verify/keyword_gap.py +127 -0
- receipts/verify/query_expand.py +88 -0
- receipts/verify/rerank.py +74 -0
- receipts/verify/rewriter.py +172 -0
- receipts/verify/router.py +211 -0
- receipts/verify/summaries.py +258 -0
- 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."""
|