rafter-cli 0.11.2__tar.gz → 0.11.3__tar.gz

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 (102) hide show
  1. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/PKG-INFO +1 -1
  2. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/pyproject.toml +1 -1
  3. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/commands/agent.py +39 -0
  4. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/resources/rafter-security-skill.md +1 -1
  5. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/resources/skills/rafter/SKILL.md +1 -0
  6. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/scanners/betterleaks.py +135 -80
  7. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/scanners/regex_scanner.py +1 -0
  8. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/README.md +0 -0
  9. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/__init__.py +0 -0
  10. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/__main__.py +0 -0
  11. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/commands/__init__.py +0 -0
  12. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/commands/agent_components.py +0 -0
  13. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/commands/backend.py +0 -0
  14. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/commands/brief.py +0 -0
  15. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/commands/ci.py +0 -0
  16. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/commands/docs.py +0 -0
  17. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/commands/hook.py +0 -0
  18. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/commands/issues/__init__.py +0 -0
  19. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/commands/issues/dedup.py +0 -0
  20. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/commands/issues/github_client.py +0 -0
  21. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/commands/issues/issue_builder.py +0 -0
  22. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/commands/issues/issues_app.py +0 -0
  23. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/commands/mcp_server.py +0 -0
  24. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/commands/notify.py +0 -0
  25. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/commands/policy.py +0 -0
  26. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/commands/report.py +0 -0
  27. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/commands/scan.py +0 -0
  28. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/commands/sites.py +0 -0
  29. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/commands/skill.py +0 -0
  30. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/commands/skill_remote.py +0 -0
  31. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/core/__init__.py +0 -0
  32. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/core/audit_logger.py +0 -0
  33. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/core/command_interceptor.py +0 -0
  34. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/core/config_manager.py +0 -0
  35. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/core/config_schema.py +0 -0
  36. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/core/custom_patterns.py +0 -0
  37. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/core/docs_loader.py +0 -0
  38. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/core/hook_control.py +0 -0
  39. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/core/pattern_engine.py +0 -0
  40. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/core/policy_loader.py +0 -0
  41. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/core/risk_rules.py +0 -0
  42. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/core/suppression_writer.py +0 -0
  43. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/resources/__init__.py +0 -0
  44. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/resources/agents/__init__.py +0 -0
  45. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/resources/agents/rafter.md +0 -0
  46. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/resources/continue-rules/rafter-code-review.md +0 -0
  47. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/resources/continue-rules/rafter-secure-design.md +0 -0
  48. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/resources/continue-rules/rafter-skill-review.md +0 -0
  49. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/resources/continue-rules/rafter.md +0 -0
  50. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/resources/cursor-rules/rafter-code-review.mdc +0 -0
  51. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/resources/cursor-rules/rafter-secure-design.mdc +0 -0
  52. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/resources/cursor-rules/rafter-skill-review.mdc +0 -0
  53. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/resources/cursor-rules/rafter.mdc +0 -0
  54. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/resources/pre-commit-hook.sh +0 -0
  55. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/resources/pre-push-hook.sh +0 -0
  56. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/resources/skills/__init__.py +0 -0
  57. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/resources/skills/rafter/__init__.py +0 -0
  58. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/resources/skills/rafter/docs/backend.md +0 -0
  59. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/resources/skills/rafter/docs/cli-reference.md +0 -0
  60. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/resources/skills/rafter/docs/finding-triage.md +0 -0
  61. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/resources/skills/rafter/docs/guardrails.md +0 -0
  62. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/resources/skills/rafter/docs/shift-left.md +0 -0
  63. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/resources/skills/rafter-code-review/SKILL.md +0 -0
  64. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/resources/skills/rafter-code-review/docs/api.md +0 -0
  65. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/resources/skills/rafter-code-review/docs/asvs.md +0 -0
  66. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/resources/skills/rafter-code-review/docs/cwe-top25.md +0 -0
  67. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/resources/skills/rafter-code-review/docs/investigation-playbook.md +0 -0
  68. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/resources/skills/rafter-code-review/docs/llm.md +0 -0
  69. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/resources/skills/rafter-code-review/docs/web-app.md +0 -0
  70. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/resources/skills/rafter-secure-design/SKILL.md +0 -0
  71. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/resources/skills/rafter-secure-design/docs/api-design.md +0 -0
  72. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/resources/skills/rafter-secure-design/docs/auth.md +0 -0
  73. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/resources/skills/rafter-secure-design/docs/data-storage.md +0 -0
  74. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/resources/skills/rafter-secure-design/docs/dependencies.md +0 -0
  75. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/resources/skills/rafter-secure-design/docs/deployment.md +0 -0
  76. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/resources/skills/rafter-secure-design/docs/ingestion.md +0 -0
  77. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/resources/skills/rafter-secure-design/docs/standards-pointers.md +0 -0
  78. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/resources/skills/rafter-secure-design/docs/threat-modeling.md +0 -0
  79. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/resources/skills/rafter-skill-review/SKILL.md +0 -0
  80. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/resources/skills/rafter-skill-review/docs/authorship-provenance.md +0 -0
  81. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/resources/skills/rafter-skill-review/docs/changelog-review.md +0 -0
  82. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/resources/skills/rafter-skill-review/docs/data-practices.md +0 -0
  83. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/resources/skills/rafter-skill-review/docs/malware-indicators.md +0 -0
  84. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/resources/skills/rafter-skill-review/docs/prompt-injection.md +0 -0
  85. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/resources/skills/rafter-skill-review/docs/telemetry.md +0 -0
  86. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/resources/windsurf-rules/rafter-code-review.md +0 -0
  87. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/resources/windsurf-rules/rafter-secure-design.md +0 -0
  88. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/resources/windsurf-rules/rafter-skill-review.md +0 -0
  89. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/resources/windsurf-rules/rafter.md +0 -0
  90. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/scanners/__init__.py +0 -0
  91. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/scanners/git_diff_scan.py +0 -0
  92. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/scanners/git_history_scan.py +0 -0
  93. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/scanners/secret_patterns.py +0 -0
  94. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/scanners/skill_scanner.py +0 -0
  95. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/scanners/union.py +0 -0
  96. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/utils/__init__.py +0 -0
  97. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/utils/api.py +0 -0
  98. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/utils/binary_manager.py +0 -0
  99. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/utils/formatter.py +0 -0
  100. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/utils/git.py +0 -0
  101. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/utils/git_diff.py +0 -0
  102. {rafter_cli-0.11.2 → rafter_cli-0.11.3}/rafter_cli/utils/skill_manager.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rafter-cli
3
- Version: 0.11.2
3
+ Version: 0.11.3
4
4
  Summary: Rafter CLI — the default security agent for AI workflows. Free for individuals and open source.
5
5
  License: MIT
6
6
  Author: Rafter Team
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "rafter-cli"
3
- version = "0.11.2"
3
+ version = "0.11.3"
4
4
  description = "Rafter CLI — the default security agent for AI workflows. Free for individuals and open source."
5
5
  authors = ["Rafter Team <hello@rafter.so>"]
6
6
  license = "MIT"
@@ -783,6 +783,44 @@ def _install_codex_hooks(root: Path, quiet: bool = False) -> None:
783
783
  _init_print(quiet, fmt.success(f"Installed PostToolUse hooks to {hooks_path}"))
784
784
 
785
785
 
786
+ def _install_gemini_hooks(root: Path, quiet: bool = False) -> None:
787
+ """Install Rafter BeforeTool/AfterTool hooks into <root>/.gemini/settings.json.
788
+
789
+ Mirrors Node's installGeminiHooks. Gemini matchers are regexes against its
790
+ built-in tool names; the mutating ones are matched explicitly.
791
+ """
792
+ gemini_dir = root / ".gemini"
793
+ gemini_dir.mkdir(parents=True, exist_ok=True)
794
+ settings_path = gemini_dir / "settings.json"
795
+
796
+ settings: dict[str, Any] = {}
797
+ if settings_path.exists():
798
+ try:
799
+ settings = json.loads(settings_path.read_text())
800
+ except (json.JSONDecodeError, ValueError):
801
+ _init_alert(quiet, fmt.warning("Existing Gemini settings.json was unreadable, creating new one"))
802
+
803
+ hooks = settings.setdefault("hooks", {})
804
+ rafter_bin = _resolve_rafter_path()
805
+ for event, sub, matcher in (
806
+ ("BeforeTool", "pretool", "run_shell_command|write_file|replace|edit"),
807
+ ("AfterTool", "posttool", ".*"),
808
+ ):
809
+ entries = hooks.get(event) or []
810
+ entries = [
811
+ e for e in entries
812
+ if not any(f"hook {sub}" in str(h.get("command", "")) for h in (e.get("hooks") or []))
813
+ ]
814
+ entries.append({
815
+ "matcher": matcher,
816
+ "hooks": [{"type": "command", "command": f"{rafter_bin} hook {sub} --format gemini", "timeout": 5000}],
817
+ })
818
+ hooks[event] = entries
819
+
820
+ settings_path.write_text(json.dumps(settings, indent=2))
821
+ _init_print(quiet, fmt.success(f"Installed hooks to {settings_path}"))
822
+
823
+
786
824
  def _install_cursor_hooks(root: Path, quiet: bool = False) -> None:
787
825
  """Install Cursor hooks at <root>/.cursor/hooks.json.
788
826
 
@@ -1505,6 +1543,7 @@ def init(
1505
1543
  _init_alert(quiet, fmt.error(f"Failed to install Gemini CLI skills: {skills_error}"))
1506
1544
  else:
1507
1545
  _register_gemini_skills(root / ".agents" / "skills", quiet=quiet)
1546
+ _install_gemini_hooks(root, quiet=quiet)
1508
1547
  if gemini_ok and scope == "user":
1509
1548
  manager.set("agent.environments.gemini.enabled", True)
1510
1549
  except Exception as e:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: rafter-security
3
3
  description: Security toolkit for AI workflows. Use when scanning code or repos for vulnerabilities, auditing third-party skills/MCPs/agent configs before installing, evaluating shell commands before running them, or generating secure design questions for new features. Provides `rafter run` (remote SAST + SCA, needs RAFTER_API_KEY), `rafter secrets` (offline secrets-only), `rafter agent exec --dry-run` (command-risk classification), and `rafter skill review`.
4
- version: 0.11.2
4
+ version: 0.11.3
5
5
  homepage: https://rafter.so
6
6
  metadata:
7
7
  openclaw:
@@ -113,6 +113,7 @@ rafter usage # check API quota
113
113
 
114
114
  Full CLI tree: **Read `docs/cli-reference.md`**. Full digest: `rafter brief commands`.
115
115
 
116
+ **Scan budget:** Remote scans spend the user's quota. `rafter run` scans the **pushed** branch, so re-running without a new push repeats it — run it once per task or before a PR and reuse `rafter get <scan-id>`; while iterating scan only changes (`rafter secrets --staged` / `--diff <ref>`); never loop, retry, or `--watch` from an agent session — exit `3` means quota exhausted, so stop, tell the user, and check `rafter usage`.
116
117
  ## Configuration
117
118
 
118
119
  `rafter run` (the full code scan) needs an API key:
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
  import json
5
5
  import os
6
6
  import platform
7
+ import re
7
8
  import subprocess
8
9
  import sys
9
10
  import tempfile
@@ -16,74 +17,118 @@ from ..utils.binary_manager import BinaryManager
16
17
  # Per-invocation timeout for the betterleaks subprocess.
17
18
  SCAN_TIMEOUT_S = 60
18
19
 
19
- # Budget for the path arguments of one `betterleaks dir` call. Windows caps a
20
- # whole command line at 32,767 characters; elsewhere the limit is far larger.
21
- ARG_CHARS_PER_CALL = 24_000 if sys.platform == "win32" else 500_000
22
-
23
- # Maximum number of file paths handed to one `betterleaks dir` invocation.
24
- #
25
- # sable-jiqb — betterleaks' run time scales with the length of the explicit file
26
- # list it is given (measured ~0.2s/file: 50 files ~10s, 300 files ~60s), not with
27
- # the size of the files. A whole git-tracked list on a few-hundred-file repo
28
- # therefore exceeds SCAN_TIMEOUT_S in a single call and the engine falls back to
29
- # patterns. Capping each call at this many files keeps every invocation well under
30
- # the timeout (~0.2s * 64 ~= 13s, roughly a fifth of the 60s floor, with headroom
31
- # on a loaded box); the full tree is covered across sequential calls, unioned by
32
- # file. Mirrors MAX_FILES_PER_CALL in node/src/scanners/betterleaks.ts.
33
- MAX_FILES_PER_CALL = 64
34
-
35
- # Config env vars betterleaks consults before the target's own .toml.
36
- CONFIG_ENV_VARS = (
37
- "BETTERLEAKS_CONFIG", "GITLEAKS_CONFIG", "BETTERLEAKS_CONFIG_TOML", "GITLEAKS_CONFIG_TOML",
38
- )
39
-
40
-
41
- def git_visible_files(dir_path: str) -> list[str] | None:
42
- """Files under ``dir_path`` that git does not ignore (tracked, plus
43
- untracked and not ignored), as absolute paths. Returns None when
20
+ # Config env vars betterleaks consults before the target's own .toml, in its order.
21
+ CONFIG_PATH_ENV_VARS = ("BETTERLEAKS_CONFIG", "GITLEAKS_CONFIG")
22
+ CONFIG_TOML_ENV_VARS = ("BETTERLEAKS_CONFIG_TOML", "GITLEAKS_CONFIG_TOML")
23
+
24
+
25
+ def git_skipped_paths(dir_path: str) -> list[str] | None:
26
+ """Paths under ``dir_path`` that a git-aware scan must skip, relative to
27
+ it: what git ignores (a wholly ignored directory as one ``dir/`` entry),
28
+ submodules (a gitlink, mode 160000, in the index) and untracked nested
29
+ repositories (which ``ls-files --others`` lists as one ``dir/`` entry). Git
30
+ lists no files inside either kind, so the file-list scan this replaced
31
+ never scanned them. A tracked path that is now a directory on disk is NOT
32
+ skipped: its files are untracked and get scanned. Returns None when
44
33
  ``dir_path`` is not inside a git work tree or git is unavailable, so the
45
- caller scans the directory whole. Mirrors ``gitVisibleFiles`` in
34
+ caller scans the directory whole. Mirrors ``gitSkippedPaths`` in
46
35
  node/src/scanners/betterleaks.ts.
47
36
  """
48
- try:
49
- r = subprocess.run(
50
- ["git", "-C", dir_path, "ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", "."],
51
- capture_output=True, timeout=30,
52
- )
53
- except (OSError, subprocess.TimeoutExpired):
54
- return None
55
- if r.returncode != 0:
37
+ def ls(*args: str) -> list[str] | None:
38
+ try:
39
+ r = subprocess.run(
40
+ ["git", "-C", dir_path, "ls-files", "-z", *args, "--", "."],
41
+ capture_output=True, timeout=30,
42
+ )
43
+ except (OSError, subprocess.TimeoutExpired):
44
+ return None
45
+ if r.returncode != 0:
46
+ return None
47
+ return [p for p in r.stdout.decode("utf-8", "surrogateescape").split("\0") if p]
48
+
49
+ ignored = ls("--others", "--ignored", "--exclude-standard", "--directory")
50
+ staged = ls("--cached", "--stage")
51
+ untracked = ls("--others", "--exclude-standard")
52
+ if ignored is None or staged is None or untracked is None:
56
53
  return None
57
- files: dict[str, None] = {}
58
- for rel in r.stdout.decode("utf-8", "surrogateescape").split("\0"):
59
- if not rel:
60
- continue
61
- abs_path = os.path.join(dir_path, rel)
62
- # Skip deleted-but-tracked paths, submodule dirs and symlinks
63
- # (betterleaks does not follow symlinks by default either).
64
- if os.path.isfile(abs_path) and not os.path.islink(abs_path):
65
- files[abs_path] = None
66
- return list(files)
67
-
68
-
69
- def _chunk_files(paths: list[str], budget: int, max_count: int) -> list[list[str]]:
70
- """Split ``paths`` into groups within both bounds: at most ``max_count``
71
- paths per group (sable-jiqb — betterleaks' cost scales with the file count,
72
- so this is what keeps each invocation under the timeout) and a joined length
73
- under ``budget`` chars (the OS command-line cap, the original #262 constraint).
54
+ # ``--stage`` lines read "<mode> <object> <stage>\t<path>".
55
+ submodules = [line.split("\t", 1)[1] for line in staged if line.startswith("160000 ")]
56
+ nested_repos = [rel for rel in untracked if rel.endswith("/")]
57
+ return ignored + submodules + nested_repos
58
+
59
+
60
+ def _toml_string(s: str) -> str:
61
+ """Write ``s`` as a TOML basic string. JSON escapes every control character
62
+ TOML forbids raw except DEL (U+007F), which would make the whole config
63
+ unreadable and drop the scan to the patterns engine."""
64
+ return json.dumps(s, ensure_ascii=False).replace("\x7f", "\\u007f")
65
+
66
+
67
+ def _is_utf8(s: str) -> bool:
68
+ try:
69
+ s.encode("utf-8")
70
+ return True
71
+ except UnicodeEncodeError: # surrogateescape-decoded raw bytes
72
+ return False
73
+
74
+
75
+ def _path_prefix_pattern(p: str) -> str:
76
+ """A regex (RE2, JS and Python read it alike) matching ``p`` and everything
77
+ below it. Only on Windows is ``\\`` a separator; on POSIX it is an ordinary
78
+ filename character and stays literal, or an ignored ``a\\b`` would also
79
+ skip ``a/b``."""
80
+ slashed = (p.replace("\\", "/") if sys.platform == "win32" else p).rstrip("/")
81
+ return "^" + re.sub(r"[\\.+*?()|\[\]{}^$]", r"\\\g<0>", slashed) + "(?:/.*)?$"
82
+
83
+
84
+ def _read_text(path: str) -> str:
85
+ with open(path, encoding="utf-8") as fh:
86
+ return fh.read()
87
+
88
+
89
+ _LEGACY_ALLOWLIST = re.compile(r"^[ \t]*\[[ \t]*allowlist[ \t]*\][ \t]*(#.*)?$", re.MULTILINE)
90
+
91
+
92
+ def scan_config_toml(dir_path: str, skipped: list[str], base_toml: str | None) -> str:
93
+ """The betterleaks config for one git-aware directory scan: the config the
94
+ scan would otherwise use (``base_toml``, its text; betterleaks' default
95
+ rules when None) plus a global path allowlist for ``skipped``, so one
96
+ ``betterleaks dir <root>`` walk passes over those paths without reading them.
97
+
98
+ The base config is copied in, not extended: betterleaks 1.1.x stops
99
+ following ``[extend]`` two levels down, so wrapping a repo config that
100
+ itself extends a shared config (which extends the defaults) silently
101
+ dropped the default rules. A relative ``extend.path`` in the copy still
102
+ resolves as it did, against the working directory. The deprecated
103
+ ``[allowlist]`` table is renamed to ``[[allowlists]]``, its equivalent,
104
+ since betterleaks rejects a config that has both.
105
+
106
+ #262 — rafter used to hand betterleaks the git-visible files as path
107
+ arguments, 64 per call. betterleaks 1.1.x rebuilds its config and detector
108
+ for every path argument (~0.2s each with the default rules), and its
109
+ ``extend`` depth counter is a process global it never resets, so with a
110
+ config that extends another (``[extend] useDefault = true``) only the first
111
+ two path arguments of a call got the extended rules: every later file was
112
+ scanned with the repo's own rules alone. A single walk builds one detector
113
+ and applies the full rule set to every file. Mirrors ``scanConfigToml`` in
114
+ node/src/scanners/betterleaks.ts.
74
115
  """
75
- chunks: list[list[str]] = []
76
- cur: list[str] = []
77
- length = 0
78
- for p in paths:
79
- if cur and (len(cur) >= max_count or length + len(p) + 1 > budget):
80
- chunks.append(cur)
81
- cur, length = [], 0
82
- cur.append(p)
83
- length += len(p) + 1
84
- if cur:
85
- chunks.append(cur)
86
- return chunks
116
+ # A path git reports in bytes that are not UTF-8 cannot be written into a
117
+ # TOML file, so it is left out: that file is scanned rather than skipped.
118
+ joined = (os.path.join(dir_path, rel) for rel in skipped)
119
+ patterns = [r"(?:^|/)\.git(?:/.*)?$"] + [_path_prefix_pattern(p) for p in joined if _is_utf8(p)]
120
+ lines = [
121
+ "# Generated by rafter for one scan (scan_config_toml, rafter's betterleaks scanner).",
122
+ *(["[extend]", "useDefault = true"] if base_toml is None else [_LEGACY_ALLOWLIST.sub("[[allowlists]]", base_toml)]),
123
+ "",
124
+ "[[allowlists]]",
125
+ 'description = "rafter: paths git ignores"',
126
+ "paths = [",
127
+ *(f" {_toml_string(p)}," for p in patterns),
128
+ "]",
129
+ "",
130
+ ]
131
+ return "\n".join(lines)
87
132
 
88
133
 
89
134
  @dataclass
@@ -197,26 +242,36 @@ class BetterleaksScanner:
197
242
  walks node_modules, build output and local secret stores (.env) alike:
198
243
  slow enough to hit the timeout on an ordinary checkout, and noisy
199
244
  (#262). With respect_gitignore (the default, matching the patterns
200
- engine) and a target inside a git work tree, it is handed git's own
201
- list of non-ignored files instead.
245
+ engine) and a target inside a git work tree, it walks the tree once
246
+ under a generated config that skips what git ignores (see
247
+ scan_config_toml).
202
248
  """
203
- files = None if use_git or not respect_gitignore else git_visible_files(dir_path)
204
- if files is None:
249
+ skipped = None if use_git or not respect_gitignore else git_skipped_paths(dir_path)
250
+ if skipped is None:
205
251
  results = self._run_scan(dir_path, use_git=use_git)
206
252
  else:
207
- # Given file paths instead of a directory, betterleaks no longer
208
- # finds the target's own .betterleaks.toml / .gitleaks.toml, so pass
209
- # it explicitly unless an env var already chooses the config.
210
- config_args: list[str] = []
211
- if not any(os.environ.get(v) for v in CONFIG_ENV_VARS):
212
- for name in (".betterleaks.toml", ".gitleaks.toml"):
213
- cfg = os.path.join(dir_path, name)
214
- if os.path.exists(cfg):
215
- config_args = ["-c", cfg]
216
- break
217
- results = []
218
- for chunk in _chunk_files(files, ARG_CHARS_PER_CALL, MAX_FILES_PER_CALL):
219
- results.extend(self._run_scan(chunk, extra_args=config_args))
253
+ with tempfile.TemporaryDirectory(prefix="betterleaks-cfg-") as cfg_dir:
254
+ # The generated config is passed as -c, which outranks every
255
+ # other source, so it carries whichever config betterleaks
256
+ # would have chosen, in betterleaks' own order: env path, env
257
+ # content, the target's file.
258
+ env_path = next((os.environ[v] for v in CONFIG_PATH_ENV_VARS if os.environ.get(v)), None)
259
+ env_toml = next((os.environ[v] for v in CONFIG_TOML_ENV_VARS if os.environ.get(v)), None)
260
+ repo_path = next(
261
+ (p for p in (os.path.join(dir_path, n) for n in (".betterleaks.toml", ".gitleaks.toml"))
262
+ if os.path.exists(p)),
263
+ None,
264
+ )
265
+ if env_path:
266
+ base_toml = _read_text(env_path)
267
+ elif env_toml:
268
+ base_toml = env_toml
269
+ else:
270
+ base_toml = _read_text(repo_path) if repo_path else None
271
+ cfg = os.path.join(cfg_dir, "scan.toml")
272
+ with open(cfg, "w", encoding="utf-8") as fh:
273
+ fh.write(scan_config_toml(dir_path, skipped, base_toml))
274
+ results = self._run_scan(dir_path, extra_args=["-c", cfg])
220
275
  grouped: dict[str, list[PatternMatch]] = {}
221
276
  for r in results:
222
277
  f = r.get("File", "unknown")
@@ -38,6 +38,7 @@ DEFAULT_EXCLUDE = [
38
38
  # Vendored / virtual-env / generated dirs that cause false positives
39
39
  "vendor", ".venv", "venv", "__pycache__", ".tox", ".mypy_cache",
40
40
  ".pytest_cache", ".ruff_cache", "results", ".terraform", "bower_components",
41
+ ".trigger", # Trigger.dev build output (bundled JS + source maps)
41
42
  ]
42
43
 
43
44
 
File without changes