74일차 자료 추가(답 제외), 73일차 정답 공개(LAB-A · VIBE-A · answers · tscan), 64 · 65 · 68~73일차 교안 · 실습 문서 수정 반영

This commit is contained in:
mcchae committed 2026-10-27 23:16:10 +09:00
1 parent 4ef0464525
commit bb719d81fa
191 files changed
+36875 -64

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
.venv/
__pycache__/
*.egg-info/
.pytest_cache/
+18
View File
@@ -0,0 +1,18 @@
# tscan IMP-PLAN (강사가 직접 만든 기록)
## 엔진 1단계 · 찾고 가리기 (R1 · R2 · R3 · R4 · R13)
- [x] scanner.py: 종류 7개(키 4 · 주민번호 · 전화 · 이메일) + 경계, mask, scan_text → Finding(가린 값만)
- [x] files.py: 건너뛰기(.git · node_modules · .venv · 1MB · NUL), .env 값 → env_value
- [x] tests: 가짜 비밀을 테스트 안에서, 날짜 · 주문 번호 · 버전은 0건
## 엔진 2단계 · 커밋 전에 막기 (R5 · R6)
- [x] staged = git show :<path>, 훅 설치 · 해제(표시 줄), 종료 코드 0 · 1 · 2
## TUI 1단계 (R8)
- [x] DataTable · 가린 미리 보기(mask_line) · worker
## 설치 (R14)
- [x] [project.scripts] tscan = "tscan.cli:main", uv tool install --editable .
## 엔진 3단계 (R7 · R10 · R12)
- [x] .tscanignore, --json, --summary
## 엔진 4단계 (R9 · R11)
- [x] history (git log -p 의 + 줄), .tscan.toml · ~/.tscan/rules.toml
## TUI 2단계
- [x] [i] 무시 목록에 더하고 다시 검사, [r]
+36
View File
@@ -0,0 +1,36 @@
# tscan · 강사가 수업 준비 때 직접 만든 본 (2026-10-26)
> **정답 자료** · 학생 저장소에는 수업 뒤 [VIBE-A-TSCAN.md](../VIBE-A-TSCAN.md)와 함께 올립니다.
> 이 본은 강사가 CLI 에이전트에 단계 요청문을 보내 만든 것이 아니라, Claude Code가 VIBE-A의 SPEC · IMP-PLAN을 보고 직접 썼습니다. 학생이 만든 것과 파일 구성이 달라도 됩니다. 요구 R1~R14의 **확인 방법**이 같으면 됩니다.
## 설치 · 실행
```bash
cd ~/aio-01d4-stu/Last3Mon/73/tscan
uv sync && uv run pytest -q # 가짜 비밀 테스트 12개 (가짜 값은 테스트 안에서 만듦)
uv tool install --editable . # 어느 폴더에서나 tscan (R14)
cd /tmp && tscan --help
```
| 하고 싶은 것 | 명령 |
| --- | --- |
| 폴더 검사 (글자) | `tscan scan <폴더> --plain` · 요약 `--summary` · 다른 프로그램용 `--json` |
| 화면(TUI) | `tscan <폴더>` (`[i]` 무시 목록에, `[r]` 다시, `[q]` 끝) |
| 커밋 전 검사 | `tscan staged` (찾으면 종료 코드 1) |
| 훅 | `tscan install-hook` · `tscan uninstall-hook` (남의 훅은 건드리지 않음) |
| 지난 커밋 | `tscan history --max 200` |
| 무시 | `.tscanignore`: `path:line` · `kind:glob` · `glob` |
| 사용자 모양 | `.tscan.toml` 또는 `~/.tscan/rules.toml`: `[[rule]] name = "employee_id"`, `pattern = 'E-\d{6}'` |
종료 코드: 0 없음, 1 찾음, 2 오류 (저장소가 아님, 남의 훅).
## 파일
| 파일 | 하는 일 |
| --- | --- |
| `src/tscan/scanner.py` | 종류별 정규식(경계), 가리기, 글자 정리(NFKC), 한 줄 가리기(미리 보기) |
| `src/tscan/files.py` | 폴더 훑기 · 건너뛰기, `.env` 값, 무시 목록, 사용자 모양 |
| `src/tscan/gitscan.py` | staged(index 내용), 훅 설치 · 해제, 지난 커밋 |
| `src/tscan/cli.py` | 명령줄, `--plain` · `--json` · `--summary`, 종료 코드 |
| `src/tscan/app.py` | Textual 화면 (worker, 가린 미리 보기, `[i]`) |
| `tests/conftest.py` | 가짜 비밀을 **테스트 안에서** 이어 붙여 임시 폴더 · 임시 저장소에 |
+22
View File
@@ -0,0 +1,22 @@
[project]
name = "tscan"
version = "0.1.0"
description = "Find secrets and personal data in a folder or in staged git files, shown masked"
requires-python = ">=3.11"
dependencies = ["textual>=0.80"]
[project.scripts]
tscan = "tscan.cli:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/tscan"]
[dependency-groups]
dev = ["pytest>=8", "pytest-asyncio>=0.23"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
+1
View File
@@ -0,0 +1 @@
"""tscan: find secrets and personal data, shown masked."""
+82
View File
@@ -0,0 +1,82 @@
"""Textual screen: findings table, masked preview of the lines around the chosen one, [i] to ignore."""
from pathlib import Path
from textual import work
from textual.app import App, ComposeResult
from textual.widgets import DataTable, Footer, Header, Static
from .files import add_ignore, env_values_from, is_env_file, iter_files, load_rules, read_text, scan_folder
from .scanner import mask_line
class ScanApp(App):
TITLE = "tscan"
BINDINGS = [("i", "ignore", "Ignore (add to .tscanignore)"), ("r", "rescan", "Rescan"), ("q", "quit", "Quit")]
CSS = """
#summary { height: auto; margin: 0 1; }
DataTable { height: 1fr; }
#preview { height: 7; border: round $accent; padding: 0 1; }
"""
def __init__(self, root):
super().__init__()
self.root = Path(root)
self.found = []
self.env_values = []
def compose(self) -> ComposeResult:
yield Header()
yield Static("scanning...", id="summary")
yield DataTable(id="found", cursor_type="row")
yield Static("", id="preview")
yield Footer()
def on_mount(self):
self.query_one(DataTable).add_columns("kind", "file", "line", "value")
self.sub_title = str(self.root)
self.scan()
@work(thread=True, exclusive=True)
def scan(self):
found, report = scan_folder(self.root)
values = set()
for path in iter_files(self.root):
if is_env_file(path.name):
text, _ = read_text(path)
values |= env_values_from(text or "")
self.call_from_thread(self.show, found, report, sorted(values, key=len, reverse=True))
def show(self, found, report, env_values):
self.found, self.env_values = found, env_values
table = self.query_one(DataTable)
table.clear()
for f in found:
table.add_row(f.kind, f.path, str(f.line), f.masked)
skipped = sum(report["skipped"].values())
self.query_one("#summary", Static).update(f"files {report['files']} · found {len(found)} · skipped {skipped}")
self.query_one("#preview", Static).update("" if found else "nothing found")
def preview_lines(self, finding, around=2):
text, _ = read_text(self.root / finding.path)
lines = (text or "").splitlines()
rules = load_rules(self.root)
start = max(1, finding.line - around)
rows = []
for n in range(start, min(len(lines), finding.line + around) + 1):
mark = ">" if n == finding.line else " "
rows.append(f"{mark}{n:4} {mask_line(lines[n - 1], rules, self.env_values)}")
return "\n".join(rows)
def on_data_table_row_highlighted(self, event):
if 0 <= event.cursor_row < len(self.found):
self.query_one("#preview", Static).update(self.preview_lines(self.found[event.cursor_row]))
def action_ignore(self):
row = self.query_one(DataTable).cursor_row
if 0 <= row < len(self.found):
f = self.found[row]
add_ignore(self.root, f"{f.path}:{f.line}")
self.scan()
def action_rescan(self):
self.scan()
+76
View File
@@ -0,0 +1,76 @@
"""Command line. Exit codes: 0 nothing found, 1 found, 2 error."""
import argparse
import json
import subprocess
import sys
from collections import Counter
from . import gitscan
from .files import scan_folder
def summary(found, report):
return {"total": len(found), "by_kind": dict(Counter(f.kind for f in found)),
"by_file": dict(Counter(f.path for f in found)), **report}
def print_plain(found, report, with_summary):
for f in found:
print(f"{f.kind:13} {f.path}:{f.line} {f.masked}")
s = summary(found, report)
skipped = sum(report.get("skipped", {}).values())
print(f"found {s['total']} in {report.get('files', 0)} file(s)" + (f", skipped {skipped}" if "skipped" in report else ""))
if with_summary:
print("by kind " + " ".join(f"{k} {v}" for k, v in sorted(s["by_kind"].items())))
for path, count in sorted(s["by_file"].items(), key=lambda kv: -kv[1]):
print(f" {count:3} {path}")
if report.get("skipped"):
print("skipped " + " ".join(f"{k} {v}" for k, v in sorted(report["skipped"].items())))
def emit(found, report, args):
if getattr(args, "json", False):
print(json.dumps({"findings": [f.to_dict() for f in found], "summary": summary(found, report)}, ensure_ascii=False, indent=2))
else:
print_plain(found, report, getattr(args, "summary", False))
return 1 if found else 0
def main(argv=None):
argv = sys.argv[1:] if argv is None else argv
commands = ("scan", "staged", "install-hook", "uninstall-hook", "history")
if argv and argv[0] not in commands and not argv[0].startswith("-"):
from .app import ScanApp # `tscan <folder>` opens the TUI
ScanApp(argv[0]).run()
sys.exit(0)
p = argparse.ArgumentParser(prog="tscan", description="Find secrets and personal data, shown masked. "
"`tscan <folder>` opens the TUI.")
sub = p.add_subparsers(dest="cmd", required=True)
s = sub.add_parser("scan", help="scan a folder")
s.add_argument("folder")
for q in (s, sub.add_parser("staged", help="scan staged files (for pre-commit)"),
sub.add_parser("history", help="scan lines added in past commits")):
q.add_argument("--plain", action="store_true", help="plain lines (default)")
q.add_argument("--json", action="store_true", help="JSON for other programs (values masked)")
q.add_argument("--summary", action="store_true", help="counts by kind and file, skipped files")
sub.choices["history"].add_argument("--max", type=int, default=200, help="commits to read (default 200)")
sub.add_parser("install-hook", help="run `tscan staged` before every commit in this repository")
sub.add_parser("uninstall-hook", help="remove the tscan pre-commit hook")
args = p.parse_args(argv)
try:
if args.cmd == "scan":
found, report = scan_folder(args.folder)
elif args.cmd == "staged":
found, report = gitscan.staged(".")
if found and not args.json:
print("tscan: secrets or personal data in staged files; commit stopped", file=sys.stderr)
elif args.cmd == "history":
found, report = gitscan.history(".", args.max)
else:
ok, message = (gitscan.install_hook if args.cmd == "install-hook" else gitscan.uninstall_hook)(".")
print(message)
sys.exit(0 if ok else 2)
sys.exit(emit(found, report, args))
except (subprocess.CalledProcessError, FileNotFoundError, OSError) as error:
print(f"tscan: {error.__class__.__name__}: not a git repository or unreadable path", file=sys.stderr)
sys.exit(2)
+117
View File
@@ -0,0 +1,117 @@
"""Walk a folder: text files only, with .env values, ignore list and user rules."""
import fnmatch
import os
import tomllib
from pathlib import Path
from .scanner import compile_rules, scan_text
SKIP_DIRS = {".git", "node_modules", ".venv", "venv", "__pycache__", ".mypy_cache", ".pytest_cache", ".tox"}
MAX_BYTES = 1_000_000
IGNORE_FILE = ".tscanignore"
def home():
return Path(os.environ.get("TSCAN_HOME", Path.home() / ".tscan"))
def read_text(path):
"""Return (text, None) or (None, reason) for big or binary files."""
try:
size = path.stat().st_size
if size > MAX_BYTES:
return None, "too_big"
data = path.read_bytes()
except OSError:
return None, "unreadable"
if b"\0" in data[:8192]:
return None, "binary"
return data.decode("utf-8", errors="replace"), None
def is_env_file(name):
return name == ".env" or (name.startswith(".env.") and not name.endswith((".example", ".sample", ".template")))
def env_values_from(text, min_len=6):
values = set()
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
value = line.split("=", 1)[1].strip().strip("'\"")
if len(value) >= min_len:
values.add(value)
return values
def load_rules(root):
"""User rules from ~/.tscan/rules.toml and <root>/.tscan.toml: [[rule]] name = ..., pattern = ... (R11)."""
extra = []
for p in (home() / "rules.toml", Path(root) / ".tscan.toml"):
if p.is_file():
extra += tomllib.loads(p.read_text(encoding="utf-8")).get("rule", [])
return compile_rules(extra)
def load_ignore(root):
p = Path(root) / IGNORE_FILE
if not p.is_file():
return []
return [line.strip() for line in p.read_text(encoding="utf-8").splitlines() if line.strip() and not line.startswith("#")]
def ignored(finding, entries):
"""Entries: 'path:line', 'kind:path' or a path glob (R7)."""
for entry in entries:
head, _, tail = entry.rpartition(":")
if head and tail.isdigit() and head == finding.path and int(tail) == finding.line:
return True
if head and not tail.isdigit() and head == finding.kind and fnmatch.fnmatch(finding.path, tail):
return True
if not head and fnmatch.fnmatch(finding.path, entry):
return True
return False
def add_ignore(root, entry):
p = Path(root) / IGNORE_FILE
with p.open("a", encoding="utf-8") as f:
f.write(entry + "\n")
return p
def iter_files(root):
root = Path(root)
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIRS)
for name in sorted(filenames):
yield Path(dirpath) / name
def scan_items(items, root, env_values=()):
"""items: (relative path, text). Shared by folder, staged and history scans."""
rules = load_rules(root)
entries = load_ignore(root)
found = []
for rel, text in items:
values = () if is_env_file(Path(rel).name) else env_values
found += [f for f in scan_text(text, rel, rules, values) if not ignored(f, entries)]
return found
def scan_folder(root):
"""Return (findings, report) where report has files, skipped (by reason)."""
root = Path(root)
texts, skipped, env_values = [], {}, set()
for path in iter_files(root):
text, reason = read_text(path)
if reason:
skipped[reason] = skipped.get(reason, 0) + 1
continue
rel = path.relative_to(root).as_posix()
texts.append((rel, text))
if is_env_file(path.name):
env_values |= env_values_from(text)
found = scan_items(texts, root, sorted(env_values, key=len, reverse=True))
return found, {"files": len(texts), "skipped": skipped}
+80
View File
@@ -0,0 +1,80 @@
"""git: staged content (R5), pre-commit hook (R6), past commits (R9)."""
import stat
import subprocess
from pathlib import Path
from .files import env_values_from, is_env_file, scan_items
MARK = "# tscan pre-commit hook"
HOOK = f"#!/bin/sh\n{MARK} (remove with: tscan uninstall-hook)\nexec tscan staged\n"
def git(root, *args):
return subprocess.run(["git", "-C", str(root), *args], capture_output=True, check=True).stdout
def top(root):
return Path(git(root, "rev-parse", "--show-toplevel").decode().strip())
def staged(root):
"""Scan what `git add` put in the index, not the working file."""
root = top(root)
names = [n for n in git(root, "diff", "--cached", "--name-only", "--diff-filter=ACM", "-z").decode().split("\0") if n]
items, env_values = [], set()
for name in names:
data = git(root, "show", f":{name}")
if b"\0" in data[:8192] or len(data) > 1_000_000:
continue
text = data.decode("utf-8", errors="replace")
items.append((name, text))
if is_env_file(Path(name).name):
env_values |= env_values_from(text)
return scan_items(items, root, sorted(env_values, key=len, reverse=True)), {"files": len(items)}
def hook_path(root):
return top(root) / Path(git(root, "rev-parse", "--git-path", "hooks").decode().strip()) / "pre-commit"
def install_hook(root):
p = hook_path(root)
if p.exists() and MARK not in p.read_text(errors="replace"):
return False, f"{p} already exists and is not a tscan hook; add 'tscan staged' to it yourself"
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(HOOK)
p.chmod(p.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
return True, f"installed {p}"
def uninstall_hook(root):
p = hook_path(root)
if not p.exists():
return True, "no pre-commit hook"
if MARK not in p.read_text(errors="replace"):
return False, f"{p} is not a tscan hook; left as is"
p.unlink()
return True, f"removed {p}"
def history(root, max_commits=200):
"""Lines added in past commits. Each finding's path is '<short commit> <file>'."""
root = top(root)
log = git(root, "log", f"-{max_commits}", "-p", "--no-color", "--format=commit %h").decode("utf-8", errors="replace")
items, commit, path, lines = [], "", "", []
def flush():
if path and lines:
items.append((f"{commit} {path}", "\n".join(lines)))
for row in log.splitlines():
if row.startswith("commit "):
flush()
commit, path, lines = row.split()[1], "", []
elif row.startswith("+++ "):
flush()
path, lines = row[6:] if row.startswith("+++ b/") else "", []
elif row.startswith("+") and path:
lines.append(row[1:])
flush()
return scan_items(items, root), {"commits": max_commits}
+99
View File
@@ -0,0 +1,99 @@
"""Find secrets and personal data in text. A finding keeps only the masked value, never the original."""
import re
import unicodedata
from dataclasses import asdict, dataclass
INVISIBLE = re.compile("[​-‍⁠­]")
# (kind, pattern). Boundaries keep dates, versions and long order numbers out.
PATTERNS = [
("openai_key", r"(?<![A-Za-z0-9_-])sk-(?:proj-)?[A-Za-z0-9_-]{20,}"),
("aws_key", r"(?<![A-Z0-9])AKIA[0-9A-Z]{16}(?![A-Z0-9])"),
("github_token", r"(?<![A-Za-z0-9_])gh[pousr]_[A-Za-z0-9]{36,}"),
("google_key", r"(?<![A-Za-z0-9_-])AIza[0-9A-Za-z_-]{35}"),
("rrn", r"(?<![\d.-])\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])-?[1-4]\d{6}(?![\d-])"),
("phone", r"(?<![\d.-])01[016789][-. ]?\d{3,4}[-. ]?\d{4}(?![\d-])"),
("email", r"(?<![\w.+-])[\w.+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+"),
]
KEY_KINDS = {"openai_key", "aws_key", "github_token", "google_key", "env_value"}
@dataclass(frozen=True)
class Finding:
kind: str
path: str
line: int
masked: str
def to_dict(self):
return asdict(self)
def compile_rules(extra=None):
"""Built-in patterns plus user rules [{"name", "pattern"}] (R11)."""
rules = [(kind, re.compile(p)) for kind, p in PATTERNS]
for rule in extra or []:
rules.append((str(rule["name"]), re.compile(str(rule["pattern"]))))
return rules
def mask(value, kind):
if kind == "phone":
digits = [i for i, ch in enumerate(value) if ch.isdigit()]
hide = set(digits[3:-4])
return "".join("*" if i in hide else ch for i, ch in enumerate(value))
if kind == "email":
name, _, domain = value.partition("@")
return f"{name[:1]}***@{domain}"
if kind == "rrn":
return value[:6] + "-*******"
if kind in KEY_KINDS or len(value) >= 8:
return f"{value[:3]}…****…{value[-2:]}"
return "*" * len(value)
def clean(line):
"""Same shape for hidden text: full-width to normal (NFKC), invisible characters removed."""
return INVISIBLE.sub("", unicodedata.normalize("NFKC", line))
def _matches(line, rules, env_values):
text = clean(line)
spans = []
for kind, rule in rules:
for m in rule.finditer(text):
spans.append((m.start(), m.end(), kind, m.group(0)))
for value in env_values:
start = text.find(value)
while start != -1:
spans.append((start, start + len(value), "env_value", value))
start = text.find(value, start + 1)
spans.sort(key=lambda s: (s[0], -(s[1] - s[0])))
chosen, end = [], -1
for s in spans: # keep the first, longest match where matches overlap
if s[0] >= end:
chosen.append(s)
end = s[1]
return text, chosen
def scan_text(text, path="", rules=None, env_values=()):
rules = rules or compile_rules()
found = []
for number, line in enumerate(text.splitlines(), 1):
_, matches = _matches(line, rules, env_values)
for _, _, kind, value in matches:
found.append(Finding(kind, path, number, mask(value, kind)))
return found
def mask_line(line, rules=None, env_values=()):
"""A whole line with every finding masked (TUI preview, R8)."""
text, matches = _matches(line, rules or compile_rules(), env_values)
out, last = [], 0
for start, end, kind, value in matches:
out.append(text[last:start])
out.append(mask(value, kind))
last = end
out.append(text[last:])
return "".join(out)
+41
View File
@@ -0,0 +1,41 @@
"""Fake secrets are built here at test time, so no file in this repository holds one (tscan would find it)."""
import subprocess
import pytest
FAKE_KEY = "sk-" + "test-" + "abcdefghij" * 2
FAKE_PHONE = "010-" + "0000-" + "1234"
FAKE_EMAIL = "tester" + "@" + "example.com"
FAKE_RRN = "900101-" + "1000000"
FAKE_TOKEN = "abc123" + "def456"
@pytest.fixture
def fake(tmp_path, monkeypatch):
monkeypatch.setenv("TSCAN_HOME", str(tmp_path / "home"))
root = tmp_path / "proj"
root.mkdir()
(root / "notes.md").write_text(
f"key = {FAKE_KEY}\n"
f"call {FAKE_PHONE} or mail {FAKE_EMAIL}\n"
f"id {FAKE_RRN}\n"
"date 2026-10-27, order 202610271234, version 1.0.10-20261027\n"
)
(root / ".env").write_text(f"TOKEN={FAKE_TOKEN}\nDEBUG=true\n")
(root / "config.py").write_text(f'token = "{FAKE_TOKEN}"\n')
(root / "big.log").write_text("x" * 1_100_000)
(root / "image.png").write_bytes(b"\x89PNG\0\0" + FAKE_KEY.encode())
(root / "node_modules").mkdir()
(root / "node_modules" / "lib.js").write_text(f"k='{FAKE_KEY}'\n")
return root
@pytest.fixture
def repo(tmp_path, monkeypatch):
monkeypatch.setenv("TSCAN_HOME", str(tmp_path / "home"))
root = tmp_path / "repo"
root.mkdir()
for args in (["init", "-q"], ["config", "user.email", "t@t"], ["config", "user.name", "t"]):
subprocess.run(["git", "-C", str(root), *args], check=True)
monkeypatch.chdir(root)
return root
+32
View File
@@ -0,0 +1,32 @@
"""R8: table, masked preview, [i] ignore."""
import asyncio
from conftest import FAKE_EMAIL, FAKE_KEY, FAKE_PHONE
from textual.widgets import DataTable
from tscan.app import ScanApp
async def wait_rows(app, pilot, n=None):
for _ in range(50):
await asyncio.sleep(0.05)
await pilot.pause()
rows = app.query_one(DataTable).row_count
if rows and (n is None or rows == n):
return rows
return app.query_one(DataTable).row_count
async def test_table_preview_and_ignore(fake):
app = ScanApp(fake)
async with app.run_test() as pilot:
assert await wait_rows(app, pilot) == 5
await pilot.press("down")
await pilot.pause()
preview = str(app.query_one("#preview").render())
for secret in (FAKE_KEY, FAKE_PHONE, FAKE_EMAIL):
assert secret not in preview
assert "010-****-1234" in preview
await pilot.press("i")
assert await wait_rows(app, pilot, 4) == 4
assert (fake / ".tscanignore").read_text() == "notes.md:1\n"
+124
View File
@@ -0,0 +1,124 @@
import json
import subprocess
import pytest
from conftest import FAKE_EMAIL, FAKE_KEY, FAKE_PHONE, FAKE_RRN, FAKE_TOKEN
from tscan import cli, gitscan
from tscan.files import add_ignore, scan_folder
from tscan.scanner import mask, mask_line, scan_text
def kinds(found):
return sorted(f.kind for f in found)
# R1 · R4 · R13
def test_finds_kinds_but_not_dates_and_skips(fake):
found, report = scan_folder(fake)
notes = [f for f in found if f.path == "notes.md"]
assert kinds(notes) == ["email", "openai_key", "phone", "rrn"]
assert all(f.line in (1, 2, 3) for f in notes) # line 4 has a date, an order number and a version only
assert report["skipped"] == {"too_big": 1, "binary": 1}
assert not any(f.path.startswith("node_modules") for f in found)
# R2: the original value is nowhere in the output
def test_values_are_masked_everywhere(fake, capsys):
with pytest.raises(SystemExit) as e:
cli.main(["scan", str(fake), "--json"])
assert e.value.code == 1
out = capsys.readouterr().out
data = json.loads(out)
assert data["summary"]["total"] == 5
for secret in (FAKE_KEY, FAKE_PHONE, FAKE_EMAIL, FAKE_RRN, FAKE_TOKEN):
assert secret not in out
assert mask(FAKE_PHONE, "phone") == "010-****-1234"
assert mask(FAKE_EMAIL, "email") == "t***@example.com"
assert mask(FAKE_RRN, "rrn") == "900101-*******"
# R3
def test_env_value_copied_elsewhere(fake):
found, _ = scan_folder(fake)
env = [f for f in found if f.kind == "env_value"]
assert [(f.path, f.line) for f in env] == [("config.py", 1)]
assert FAKE_TOKEN not in env[0].masked
def test_hidden_with_full_width(fake):
wide = "".join(chr(ord(c) + 0xFEE0) if "!" <= c <= "~" else c for c in FAKE_KEY)
assert kinds(scan_text(f"k={wide}")) == ["openai_key"]
def test_preview_line_is_masked():
line = f"call {FAKE_PHONE}, key {FAKE_KEY}"
shown = mask_line(line)
assert FAKE_PHONE not in shown and FAKE_KEY not in shown and "010-****-1234" in shown
# R7 · R11 · R12
def test_ignore_rules_and_summary(fake, capsys, tmp_path):
add_ignore(fake, "notes.md:3")
add_ignore(fake, "email:*.md")
(fake / ".tscan.toml").write_text('[[rule]]\nname = "employee_id"\npattern = "E-\\\\d{6}"\n')
(fake / "staff.txt").write_text("owner E-123456\n")
found, _ = scan_folder(fake)
assert "rrn" not in kinds(found) and "email" not in kinds(found)
assert "employee_id" in kinds(found)
with pytest.raises(SystemExit):
cli.main(["scan", str(fake), "--summary"])
out = capsys.readouterr().out
assert "by kind" in out and "skipped binary 1 too_big 1" in out
def test_nothing_found_exit_0(tmp_path, monkeypatch):
monkeypatch.setenv("TSCAN_HOME", str(tmp_path / "home"))
(tmp_path / "ok.txt").write_text("hello 2026-10-27\n")
with pytest.raises(SystemExit) as e:
cli.main(["scan", str(tmp_path)])
assert e.value.code == 0
# R5: the staged content counts, not the working file
def test_staged(repo, capsys):
(repo / "a.txt").write_text(f"k={FAKE_KEY}\n")
subprocess.run(["git", "add", "a.txt"], check=True)
(repo / "a.txt").write_text("clean now\n") # changed after git add: the index still has the key
with pytest.raises(SystemExit) as e:
cli.main(["staged"])
assert e.value.code == 1
subprocess.run(["git", "add", "a.txt"], check=True)
with pytest.raises(SystemExit) as e:
cli.main(["staged"])
assert e.value.code == 0
# R6
def test_hook_keeps_other_hooks(repo):
ok, _ = gitscan.install_hook(repo)
assert ok and "tscan staged" in gitscan.hook_path(repo).read_text()
assert gitscan.uninstall_hook(repo)[0] and not gitscan.hook_path(repo).exists()
gitscan.hook_path(repo).write_text("#!/bin/sh\necho mine\n")
ok, message = gitscan.install_hook(repo)
assert not ok and "already exists" in message
assert gitscan.hook_path(repo).read_text() == "#!/bin/sh\necho mine\n"
assert not gitscan.uninstall_hook(repo)[0]
# R9
def test_history_finds_removed_secret(repo):
(repo / "a.txt").write_text(f"k={FAKE_KEY}\n")
subprocess.run(["git", "add", "a.txt"], check=True)
subprocess.run(["git", "commit", "-qm", "add"], check=True)
(repo / "a.txt").write_text("gone\n")
subprocess.run(["git", "commit", "-qam", "remove"], check=True)
found, _ = gitscan.history(repo)
assert len(found) == 1 and found[0].kind == "openai_key" and found[0].path.endswith(" a.txt")
def test_not_a_repo_exit_2(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
with pytest.raises(SystemExit) as e:
cli.main(["staged"])
assert e.value.code == 2
+195
View File
@@ -0,0 +1,195 @@
version = 1
revision = 3
requires-python = ">=3.11"
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" },
]
[[package]]
name = "linkify-it-py"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/45/98/7a1a5f31fd5c7ba93e963b168e244b8e3dd705b3d2a718e3c3307583bf57/linkify_it_py-2.2.0.tar.gz", hash = "sha256:907acd2d17ac1fbb9ddb62c8957ccbd6158cac602231a15c3b0cd1e215f03cee", size = 32939, upload-time = "2026-08-29T07:07:08.305Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/13/d4/1152d1c7ab42d8b908be64fd200ddc870dc9d4925e951198702084aa1a7d/linkify_it_py-2.2.0-py3-none-any.whl", hash = "sha256:3adc40eb5af300b2605fcfdb968c24e1d780a90f1f2221af7c15e5111e94d443", size = 21971, upload-time = "2026-08-29T07:07:07.164Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
]
[package.optional-dependencies]
linkify = [
{ name = "linkify-it-py" },
]
[[package]]
name = "mdit-py-plugins"
version = "0.6.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "packaging"
version = "26.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
]
[[package]]
name = "platformdirs"
version = "4.13.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/e6/d0abbb256b44e6a05748c6942511ff67db8dbb068de0c35b24bf1b395b09/platformdirs-4.13.5.tar.gz", hash = "sha256:05831adf87d392760f4b5c3a0a95579ab5a10b18b9b9bd43680dcb47544788a0", size = 62362, upload-time = "2026-10-19T01:02:11.778Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6c/8a/1d8cb3842909dc193a73835adedf412e32e18f8ffd59f43983949622fe6f/platformdirs-4.13.5-py3-none-any.whl", hash = "sha256:53d3ec4280b815c1e0b02edad7f9ee8fda1ba9c0865dd9c2e6e63849ce82fc1e", size = 32779, upload-time = "2026-10-19T01:02:10.17Z" },
]
[[package]]
name = "pluggy"
version = "1.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" },
]
[[package]]
name = "pygments"
version = "2.21.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
]
[[package]]
name = "pytest"
version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
name = "pytest-asyncio"
version = "1.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f8/9a/887535e477847f5159377ab35303923644840b13c0ca03c0a467f84d4680/pytest_asyncio-1.4.1.tar.gz", hash = "sha256:e8266eb094e5ee9ffdb2068c9836f33e209ea77a970eb846933d416b5602669b", size = 145237, upload-time = "2026-10-25T21:05:37.135Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/46/d7/cd9e2708afdbf059658adaf08bdb0792ca52489871051d6ce7d498b51277/pytest_asyncio-1.4.1-py3-none-any.whl", hash = "sha256:507afa78ec77c6661164ef1a2368bc7820383fd1a14b2a7f813de8b5a92949cc", size = 17061, upload-time = "2026-10-25T21:05:35.473Z" },
]
[[package]]
name = "rich"
version = "15.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
]
[[package]]
name = "textual"
version = "8.2.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py", extra = ["linkify"] },
{ name = "mdit-py-plugins" },
{ name = "platformdirs" },
{ name = "pygments" },
{ name = "rich" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/00/21/39a76b01bd5eea82a04baaca7580e105d8c59450df03998345bb2cfb307b/textual-8.2.8.tar.gz", hash = "sha256:3f106a9fbc73e39dd266c9712432087de78a6d644084c7c241d6a25c3169115b", size = 1860502, upload-time = "2026-06-30T06:51:24.495Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl", hash = "sha256:267375fd402dc8d981457212efa71f0e3365fd17bba144ba9bb3ed7563cb374a", size = 731418, upload-time = "2026-06-30T06:51:26.364Z" },
]
[[package]]
name = "tscan"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "textual" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "pytest-asyncio" },
]
[package.metadata]
requires-dist = [{ name = "textual", specifier = ">=0.80" }]
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = ">=8" },
{ name = "pytest-asyncio", specifier = ">=0.23" },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]