rafter-cli 0.8.4__tar.gz → 0.8.6__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.
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/PKG-INFO +4 -4
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/README.md +3 -3
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/pyproject.toml +1 -1
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/commands/agent.py +159 -24
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/commands/hook.py +185 -38
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/commands/mcp_server.py +38 -40
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/commands/scan.py +11 -4
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/core/config_manager.py +22 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/core/config_schema.py +21 -0
- rafter_cli-0.8.6/rafter_cli/core/hook_control.py +101 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/core/pattern_engine.py +4 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/core/policy_loader.py +8 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/rafter-security-skill.md +1 -1
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter/docs/finding-triage.md +9 -2
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/scanners/betterleaks.py +7 -1
- rafter_cli-0.8.6/rafter_cli/scanners/union.py +69 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/utils/binary_manager.py +20 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/__init__.py +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/__main__.py +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/commands/__init__.py +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/commands/agent_components.py +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/commands/backend.py +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/commands/brief.py +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/commands/ci.py +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/commands/docs.py +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/commands/issues/__init__.py +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/commands/issues/dedup.py +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/commands/issues/github_client.py +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/commands/issues/issue_builder.py +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/commands/issues/issues_app.py +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/commands/notify.py +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/commands/policy.py +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/commands/report.py +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/commands/skill.py +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/commands/skill_remote.py +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/core/__init__.py +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/core/audit_logger.py +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/core/command_interceptor.py +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/core/custom_patterns.py +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/core/docs_loader.py +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/core/risk_rules.py +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/__init__.py +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/agents/__init__.py +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/agents/rafter.md +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/continue-rules/rafter-code-review.md +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/continue-rules/rafter-secure-design.md +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/continue-rules/rafter-skill-review.md +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/continue-rules/rafter.md +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/cursor-rules/rafter-code-review.mdc +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/cursor-rules/rafter-secure-design.mdc +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/cursor-rules/rafter-skill-review.mdc +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/cursor-rules/rafter.mdc +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/pre-commit-hook.sh +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/pre-push-hook.sh +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/__init__.py +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter/SKILL.md +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter/__init__.py +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter/docs/backend.md +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter/docs/cli-reference.md +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter/docs/guardrails.md +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter/docs/shift-left.md +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter-code-review/SKILL.md +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter-code-review/docs/api.md +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter-code-review/docs/asvs.md +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter-code-review/docs/cwe-top25.md +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter-code-review/docs/investigation-playbook.md +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter-code-review/docs/llm.md +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter-code-review/docs/web-app.md +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter-secure-design/SKILL.md +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter-secure-design/docs/api-design.md +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter-secure-design/docs/auth.md +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter-secure-design/docs/data-storage.md +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter-secure-design/docs/dependencies.md +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter-secure-design/docs/deployment.md +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter-secure-design/docs/ingestion.md +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter-secure-design/docs/standards-pointers.md +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter-secure-design/docs/threat-modeling.md +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter-skill-review/SKILL.md +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter-skill-review/docs/authorship-provenance.md +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter-skill-review/docs/changelog-review.md +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter-skill-review/docs/data-practices.md +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter-skill-review/docs/malware-indicators.md +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter-skill-review/docs/prompt-injection.md +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter-skill-review/docs/telemetry.md +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/windsurf-rules/rafter-code-review.md +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/windsurf-rules/rafter-secure-design.md +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/windsurf-rules/rafter-skill-review.md +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/windsurf-rules/rafter.md +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/scanners/__init__.py +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/scanners/regex_scanner.py +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/scanners/secret_patterns.py +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/utils/__init__.py +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/utils/api.py +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/utils/formatter.py +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/utils/git.py +0 -0
- {rafter_cli-0.8.4 → rafter_cli-0.8.6}/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.8.
|
|
3
|
+
Version: 0.8.6
|
|
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
|
|
@@ -66,9 +66,9 @@ rafter agent init --all # install all detected integrations
|
|
|
66
66
|
rafter agent init --local # write config to ./.rafter (ephemeral/benchmark)
|
|
67
67
|
rafter agent list # show detected integrations + status
|
|
68
68
|
rafter agent enable claude-code # toggle a single platform on/off
|
|
69
|
-
rafter
|
|
70
|
-
rafter
|
|
71
|
-
rafter
|
|
69
|
+
rafter secrets . # scan for secrets
|
|
70
|
+
rafter secrets --diff HEAD~1 # scan changed files
|
|
71
|
+
rafter secrets --history # scan full git history (betterleaks engine)
|
|
72
72
|
rafter agent exec "git commit" # execute with risk assessment
|
|
73
73
|
rafter agent audit # view security logs
|
|
74
74
|
rafter agent audit --verify # verify tamper-evident hash chain
|
|
@@ -40,9 +40,9 @@ rafter agent init --all # install all detected integrations
|
|
|
40
40
|
rafter agent init --local # write config to ./.rafter (ephemeral/benchmark)
|
|
41
41
|
rafter agent list # show detected integrations + status
|
|
42
42
|
rafter agent enable claude-code # toggle a single platform on/off
|
|
43
|
-
rafter
|
|
44
|
-
rafter
|
|
45
|
-
rafter
|
|
43
|
+
rafter secrets . # scan for secrets
|
|
44
|
+
rafter secrets --diff HEAD~1 # scan changed files
|
|
45
|
+
rafter secrets --history # scan full git history (betterleaks engine)
|
|
46
46
|
rafter agent exec "git commit" # execute with risk assessment
|
|
47
47
|
rafter agent audit # view security logs
|
|
48
48
|
rafter agent audit --verify # verify tamper-evident hash chain
|
|
@@ -30,9 +30,10 @@ from ..core.pattern_engine import PatternEngine
|
|
|
30
30
|
from ..scanners.betterleaks import BetterleaksScanner
|
|
31
31
|
from ..scanners.regex_scanner import RegexScanner, ScanResult
|
|
32
32
|
from ..scanners.secret_patterns import DEFAULT_SECRET_PATTERNS
|
|
33
|
+
from ..scanners.union import union_scan_results
|
|
33
34
|
from ..utils.formatter import fmt, is_agent_mode, print_stderr
|
|
34
35
|
from ..utils.skill_manager import SkillManager
|
|
35
|
-
from ..utils.binary_manager import BinaryManager
|
|
36
|
+
from ..utils.binary_manager import BinaryManager, BETTERLEAKS_VERSION
|
|
36
37
|
|
|
37
38
|
agent_app = typer.Typer(name="agent", help="Agent security features", no_args_is_help=True)
|
|
38
39
|
|
|
@@ -1453,8 +1454,22 @@ def init(
|
|
|
1453
1454
|
# ── scan ─────────────────────────────────────────────────────────────
|
|
1454
1455
|
|
|
1455
1456
|
|
|
1456
|
-
def _select_engine(preference: str, quiet: bool) -> str:
|
|
1457
|
-
"""Return 'betterleaks' or '
|
|
1457
|
+
def _select_engine(preference: str, quiet: bool, auto_update: bool = True) -> str:
|
|
1458
|
+
"""Return 'betterleaks', 'patterns', or 'both'.
|
|
1459
|
+
|
|
1460
|
+
``auto`` (the default) resolves to ``both`` whenever betterleaks is
|
|
1461
|
+
available and usable — rafter then runs both engines and unions their
|
|
1462
|
+
findings (sable-j85), so a miss in one (betterleaks 1.1.x does not detect
|
|
1463
|
+
AWS access keys — sable-h2y) is still caught by the other. It degrades to
|
|
1464
|
+
``patterns``-only when betterleaks is absent or a stale binary can't be
|
|
1465
|
+
refreshed. Explicit ``--engine betterleaks`` / ``--engine patterns`` stay
|
|
1466
|
+
single-engine.
|
|
1467
|
+
|
|
1468
|
+
``auto_update`` (default True) controls sable-o4k behavior: when the
|
|
1469
|
+
resolved engine is betterleaks but the managed binary is stale, auto-update
|
|
1470
|
+
it before scanning. The caller resolves this from ``--no-auto-update`` and
|
|
1471
|
+
the ``scan.auto_update_betterleaks`` config key.
|
|
1472
|
+
"""
|
|
1458
1473
|
valid_engines = ("auto", "betterleaks", "patterns")
|
|
1459
1474
|
if preference not in valid_engines:
|
|
1460
1475
|
print(f"Invalid engine: {preference}. Valid values: {', '.join(valid_engines)}", file=sys.stderr)
|
|
@@ -1471,26 +1486,93 @@ def _select_engine(preference: str, quiet: bool) -> str:
|
|
|
1471
1486
|
if not quiet:
|
|
1472
1487
|
print_stderr(fmt.warning("Betterleaks requested but not available, using patterns"))
|
|
1473
1488
|
return "patterns"
|
|
1489
|
+
return _ensure_betterleaks_usable(quiet, auto_update)
|
|
1490
|
+
|
|
1491
|
+
# auto — run BOTH engines when betterleaks is usable; otherwise patterns.
|
|
1492
|
+
if not available:
|
|
1493
|
+
return "patterns"
|
|
1494
|
+
usable = _ensure_betterleaks_usable(quiet, auto_update)
|
|
1495
|
+
return "both" if usable == "betterleaks" else "patterns"
|
|
1496
|
+
|
|
1497
|
+
|
|
1498
|
+
def _ensure_betterleaks_usable(quiet: bool, auto_update: bool) -> str:
|
|
1499
|
+
"""sable-o4k — guard against a stale rafter-managed betterleaks binary
|
|
1500
|
+
silently returning zero findings (a leftover binary from an older rafter
|
|
1501
|
+
parses fine with ``version`` but emits a JSON shape the current parser
|
|
1502
|
+
rejects).
|
|
1503
|
+
|
|
1504
|
+
When the managed binary is stale we auto-update it to the pinned version
|
|
1505
|
+
(default on; opt out with ``--no-auto-update`` or
|
|
1506
|
+
``scan.auto_update_betterleaks: false``). In an interactive TTY we confirm
|
|
1507
|
+
first. If the update is disabled, declined, or fails, we degrade to the
|
|
1508
|
+
patterns engine and print a CTA rather than scanning with a binary that
|
|
1509
|
+
yields nothing.
|
|
1510
|
+
"""
|
|
1511
|
+
bm = BinaryManager()
|
|
1512
|
+
if not bm.is_managed_betterleaks_stale():
|
|
1474
1513
|
return "betterleaks"
|
|
1475
1514
|
|
|
1476
|
-
|
|
1477
|
-
|
|
1515
|
+
current = bm.get_betterleaks_version()
|
|
1516
|
+
cta = "Fix manually with: rafter agent update-betterleaks"
|
|
1517
|
+
stale = f"Stale betterleaks binary ({current}; expected v{BETTERLEAKS_VERSION})"
|
|
1518
|
+
|
|
1519
|
+
if not auto_update:
|
|
1520
|
+
if not quiet:
|
|
1521
|
+
print_stderr(fmt.warning(f"{stale}. Auto-update is off — using the patterns engine for this scan. {cta}"))
|
|
1522
|
+
return "patterns"
|
|
1523
|
+
|
|
1524
|
+
# Only prompt when there's a human at a TTY; otherwise auto-update proceeds
|
|
1525
|
+
# (default on) so non-interactive runs aren't left with a broken engine.
|
|
1526
|
+
if sys.stdin.isatty() and not quiet:
|
|
1527
|
+
answer = input(f" {stale}. Update it now? [Y/n] ").strip().lower()
|
|
1528
|
+
if answer not in ("", "y", "yes"):
|
|
1529
|
+
print_stderr(fmt.warning(f"Skipped — using the patterns engine for this scan. {cta}"))
|
|
1530
|
+
return "patterns"
|
|
1531
|
+
|
|
1532
|
+
if not quiet:
|
|
1533
|
+
print_stderr(fmt.info(f"Updating betterleaks to v{BETTERLEAKS_VERSION}..."))
|
|
1534
|
+
try:
|
|
1535
|
+
bm.download_betterleaks(
|
|
1536
|
+
(lambda m: print_stderr(f" {m}")) if not quiet else None,
|
|
1537
|
+
BETTERLEAKS_VERSION,
|
|
1538
|
+
)
|
|
1539
|
+
if not quiet:
|
|
1540
|
+
print_stderr(fmt.success(f"Betterleaks updated to v{BETTERLEAKS_VERSION}."))
|
|
1541
|
+
return "betterleaks"
|
|
1542
|
+
except Exception as exc: # noqa: BLE001 — degrade to patterns on any failure
|
|
1543
|
+
if not quiet:
|
|
1544
|
+
print_stderr(fmt.warning(f"Betterleaks update failed ({exc}) — using the patterns engine for this scan. {cta}"))
|
|
1545
|
+
return "patterns"
|
|
1478
1546
|
|
|
1479
1547
|
|
|
1480
1548
|
def _scan_file(file_path: str, engine: str, custom_patterns=None) -> list[ScanResult]:
|
|
1549
|
+
def run_patterns() -> list[ScanResult]:
|
|
1550
|
+
scanner = RegexScanner(custom_patterns)
|
|
1551
|
+
r = scanner.scan_file(file_path)
|
|
1552
|
+
return [r] if r.matches else []
|
|
1553
|
+
|
|
1554
|
+
if engine == "both":
|
|
1555
|
+
# sable-j85 — run both engines and union. A betterleaks failure
|
|
1556
|
+
# degrades to patterns-only rather than losing the scan.
|
|
1557
|
+
bl_results: list[ScanResult] = []
|
|
1558
|
+
try:
|
|
1559
|
+
bl = BetterleaksScanner()
|
|
1560
|
+
result = bl.scan_file(file_path)
|
|
1561
|
+
if result.matches:
|
|
1562
|
+
bl_results = [ScanResult(file=result.file, matches=result.matches)]
|
|
1563
|
+
except Exception:
|
|
1564
|
+
print_stderr(fmt.warning("Betterleaks scan failed, using patterns only for this run"))
|
|
1565
|
+
return union_scan_results(bl_results, run_patterns())
|
|
1566
|
+
|
|
1481
1567
|
if engine == "betterleaks":
|
|
1482
1568
|
try:
|
|
1483
1569
|
bl = BetterleaksScanner()
|
|
1484
1570
|
result = bl.scan_file(file_path)
|
|
1485
1571
|
return [ScanResult(file=result.file, matches=result.matches)] if result.matches else []
|
|
1486
1572
|
except Exception:
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
else:
|
|
1491
|
-
scanner = RegexScanner(custom_patterns)
|
|
1492
|
-
r = scanner.scan_file(file_path)
|
|
1493
|
-
return [r] if r.matches else []
|
|
1573
|
+
return run_patterns()
|
|
1574
|
+
|
|
1575
|
+
return run_patterns()
|
|
1494
1576
|
|
|
1495
1577
|
|
|
1496
1578
|
def _path_matches_exclude_pattern(rel_path: str, pattern: str) -> bool:
|
|
@@ -1569,17 +1651,29 @@ def _scan_directory(
|
|
|
1569
1651
|
custom = [{"name": p.name, "regex": p.regex, "severity": p.severity} for p in scan_cfg.custom_patterns] if scan_cfg.custom_patterns else None
|
|
1570
1652
|
exclude = scan_cfg.exclude_paths or None
|
|
1571
1653
|
|
|
1572
|
-
|
|
1654
|
+
def run_patterns() -> list[ScanResult]:
|
|
1655
|
+
scanner = RegexScanner(custom)
|
|
1656
|
+
return scanner.scan_directory(dir_path, exclude_paths=exclude, respect_gitignore=respect_gitignore)
|
|
1657
|
+
|
|
1658
|
+
if engine == "both":
|
|
1659
|
+
# sable-j85 — run both engines and union; the exclude-paths post-filter
|
|
1660
|
+
# below applies to the merged set.
|
|
1661
|
+
bl_results: list[ScanResult] = []
|
|
1662
|
+
try:
|
|
1663
|
+
bl = BetterleaksScanner()
|
|
1664
|
+
bl_results = [ScanResult(file=r.file, matches=r.matches) for r in bl.scan_directory(dir_path, use_git=history)]
|
|
1665
|
+
except Exception:
|
|
1666
|
+
print_stderr(fmt.warning("Betterleaks scan failed, using patterns only for this run"))
|
|
1667
|
+
results = union_scan_results(bl_results, run_patterns())
|
|
1668
|
+
elif engine == "betterleaks":
|
|
1573
1669
|
try:
|
|
1574
1670
|
bl = BetterleaksScanner()
|
|
1575
1671
|
results = bl.scan_directory(dir_path, use_git=history)
|
|
1576
1672
|
results = [ScanResult(file=r.file, matches=r.matches) for r in results]
|
|
1577
1673
|
except Exception:
|
|
1578
|
-
|
|
1579
|
-
results = scanner.scan_directory(dir_path, exclude_paths=exclude, respect_gitignore=respect_gitignore)
|
|
1674
|
+
results = run_patterns()
|
|
1580
1675
|
else:
|
|
1581
|
-
|
|
1582
|
-
results = scanner.scan_directory(dir_path, exclude_paths=exclude, respect_gitignore=respect_gitignore)
|
|
1676
|
+
results = run_patterns()
|
|
1583
1677
|
# sable-yz0 — post-filter chokepoint. See _apply_exclude_paths docstring.
|
|
1584
1678
|
return _apply_exclude_paths(results, exclude, dir_path)
|
|
1585
1679
|
|
|
@@ -1605,7 +1699,9 @@ def _output_scan_results(
|
|
|
1605
1699
|
files_out = [
|
|
1606
1700
|
{"file": r.file, "matches": [
|
|
1607
1701
|
{"pattern": {"name": m.pattern.name, "severity": m.pattern.severity, "description": m.pattern.description or ""},
|
|
1608
|
-
"line": m.line, "column": m.column, "redacted": m.redacted
|
|
1702
|
+
"line": m.line, "column": m.column, "redacted": m.redacted,
|
|
1703
|
+
# sable-j85 — present only for auto-mode (both-engine) scans.
|
|
1704
|
+
**({"engines": m.engines} if m.engines else {})}
|
|
1609
1705
|
for m in r.matches
|
|
1610
1706
|
]}
|
|
1611
1707
|
for r in kept_results
|
|
@@ -1674,6 +1770,7 @@ def _watch_and_scan(
|
|
|
1674
1770
|
custom_patterns,
|
|
1675
1771
|
scan_cfg,
|
|
1676
1772
|
suppressions: list | None = None,
|
|
1773
|
+
auto_update: bool = True,
|
|
1677
1774
|
) -> None:
|
|
1678
1775
|
"""Watch a path for changes and re-scan on each change. Ctrl+C exits."""
|
|
1679
1776
|
try:
|
|
@@ -1684,7 +1781,7 @@ def _watch_and_scan(
|
|
|
1684
1781
|
raise typer.Exit(code=2)
|
|
1685
1782
|
|
|
1686
1783
|
logger = AuditLogger()
|
|
1687
|
-
eng = _select_engine(engine, quiet)
|
|
1784
|
+
eng = _select_engine(engine, quiet, auto_update)
|
|
1688
1785
|
|
|
1689
1786
|
if not quiet:
|
|
1690
1787
|
print_stderr(fmt.info(f"Watching {watch_path} for changes ({eng}). Press Ctrl+C to exit."))
|
|
@@ -1815,6 +1912,7 @@ def scan(
|
|
|
1815
1912
|
watch: bool = typer.Option(False, "--watch", help="Watch for file changes and re-scan on change"),
|
|
1816
1913
|
history: bool = typer.Option(False, "--history", help="Scan git history for secrets (requires betterleaks engine)"),
|
|
1817
1914
|
gitignore: bool = typer.Option(True, "--gitignore/--no-gitignore", help="Respect .gitignore when walking the scan target (default: on)"),
|
|
1915
|
+
auto_update: bool = typer.Option(True, "--auto-update/--no-auto-update", help="Auto-update a stale managed betterleaks binary; --no-auto-update falls back to the patterns engine instead (default: on)"),
|
|
1818
1916
|
):
|
|
1819
1917
|
"""Scan files or directories for secrets. [deprecated: use 'rafter secrets' instead]"""
|
|
1820
1918
|
print(
|
|
@@ -1831,6 +1929,10 @@ def scan(
|
|
|
1831
1929
|
if scan_cfg.custom_patterns else None
|
|
1832
1930
|
)
|
|
1833
1931
|
|
|
1932
|
+
# sable-o4k — stale-binary auto-update is on unless the CLI flag or the
|
|
1933
|
+
# scan.auto_update_betterleaks config key opts out. Either disables it.
|
|
1934
|
+
auto_update_enabled = auto_update and scan_cfg.auto_update_betterleaks
|
|
1935
|
+
|
|
1834
1936
|
from ..core.custom_patterns import load_suppressions, policy_ignore_to_suppressions
|
|
1835
1937
|
suppressions = policy_ignore_to_suppressions(scan_cfg.ignore) + load_suppressions()
|
|
1836
1938
|
|
|
@@ -1856,7 +1958,7 @@ def scan(
|
|
|
1856
1958
|
if not quiet:
|
|
1857
1959
|
print(f"Scanning {len(changed)} file(s) changed since {diff}...", file=sys.stderr)
|
|
1858
1960
|
|
|
1859
|
-
eng = _select_engine(engine, quiet)
|
|
1961
|
+
eng = _select_engine(engine, quiet, auto_update_enabled)
|
|
1860
1962
|
all_results: list[ScanResult] = []
|
|
1861
1963
|
for f in changed:
|
|
1862
1964
|
resolved = os.path.abspath(f)
|
|
@@ -1886,7 +1988,7 @@ def scan(
|
|
|
1886
1988
|
if not quiet:
|
|
1887
1989
|
print(f"Scanning {len(staged_files)} staged file(s)...", file=sys.stderr)
|
|
1888
1990
|
|
|
1889
|
-
eng = _select_engine(engine, quiet)
|
|
1991
|
+
eng = _select_engine(engine, quiet, auto_update_enabled)
|
|
1890
1992
|
all_results = []
|
|
1891
1993
|
for f in staged_files:
|
|
1892
1994
|
resolved = os.path.abspath(f)
|
|
@@ -1904,10 +2006,10 @@ def scan(
|
|
|
1904
2006
|
|
|
1905
2007
|
# --watch
|
|
1906
2008
|
if watch:
|
|
1907
|
-
_watch_and_scan(resolved_path, engine, quiet, json_output, format, custom_patterns, scan_cfg, suppressions)
|
|
2009
|
+
_watch_and_scan(resolved_path, engine, quiet, json_output, format, custom_patterns, scan_cfg, suppressions, auto_update_enabled)
|
|
1908
2010
|
return
|
|
1909
2011
|
|
|
1910
|
-
eng = _select_engine(engine, quiet)
|
|
2012
|
+
eng = _select_engine(engine, quiet, auto_update_enabled)
|
|
1911
2013
|
|
|
1912
2014
|
if os.path.isdir(resolved_path):
|
|
1913
2015
|
if not quiet:
|
|
@@ -3072,6 +3174,26 @@ def status(
|
|
|
3072
3174
|
else:
|
|
3073
3175
|
print(f"\nConfig: not found — run: rafter agent init")
|
|
3074
3176
|
|
|
3177
|
+
# --- Hook off-switch (trusted-source only: env / global config) ---
|
|
3178
|
+
from ..core.hook_control import resolve_hook_control
|
|
3179
|
+
|
|
3180
|
+
c = resolve_hook_control()
|
|
3181
|
+
|
|
3182
|
+
def _describe(on: bool, src: str) -> str:
|
|
3183
|
+
if on:
|
|
3184
|
+
return "active"
|
|
3185
|
+
via = "RAFTER_DISABLE_* env" if src == "env" else "global config"
|
|
3186
|
+
return f"DISABLED (via {via})"
|
|
3187
|
+
|
|
3188
|
+
if not c.hook_enabled:
|
|
3189
|
+
print(f"Hooks: {_describe(False, c.source_hook)}")
|
|
3190
|
+
elif not c.secret_scan_enabled or not c.command_policy_enabled:
|
|
3191
|
+
print("Hooks: active (partial)")
|
|
3192
|
+
print(f" secret scan: {_describe(c.secret_scan_enabled, c.source_secret_scan)}")
|
|
3193
|
+
print(f" command policy: {_describe(c.command_policy_enabled, c.source_command_policy)}")
|
|
3194
|
+
else:
|
|
3195
|
+
print("Hooks: active")
|
|
3196
|
+
|
|
3075
3197
|
# --- Betterleaks ---
|
|
3076
3198
|
bm = BinaryManager()
|
|
3077
3199
|
bl_local = bm.get_betterleaks_path()
|
|
@@ -3207,6 +3329,9 @@ def status(
|
|
|
3207
3329
|
|
|
3208
3330
|
|
|
3209
3331
|
def _agent_status_json(config_path: Path, audit_path: Path) -> dict[str, Any]:
|
|
3332
|
+
from ..core.hook_control import resolve_hook_control
|
|
3333
|
+
|
|
3334
|
+
c = resolve_hook_control()
|
|
3210
3335
|
return {
|
|
3211
3336
|
"installed": config_path.exists(),
|
|
3212
3337
|
"version": __version__,
|
|
@@ -3215,6 +3340,16 @@ def _agent_status_json(config_path: Path, audit_path: Path) -> dict[str, Any]:
|
|
|
3215
3340
|
"betterleaks_available": _betterleaks_available(),
|
|
3216
3341
|
"config_path": _format_home_path(config_path),
|
|
3217
3342
|
"audit_log_path": _format_home_path(audit_path),
|
|
3343
|
+
"hook_control": {
|
|
3344
|
+
"hook_enabled": c.hook_enabled,
|
|
3345
|
+
"secret_scan_enabled": c.secret_scan_enabled,
|
|
3346
|
+
"command_policy_enabled": c.command_policy_enabled,
|
|
3347
|
+
"source": {
|
|
3348
|
+
"hook": c.source_hook,
|
|
3349
|
+
"secret_scan": c.source_secret_scan,
|
|
3350
|
+
"command_policy": c.source_command_policy,
|
|
3351
|
+
},
|
|
3352
|
+
},
|
|
3218
3353
|
}
|
|
3219
3354
|
|
|
3220
3355
|
|
|
@@ -3359,7 +3494,7 @@ def baseline_create(
|
|
|
3359
3494
|
|
|
3360
3495
|
print(f"Scanning {resolved} to build baseline...", file=sys.stderr)
|
|
3361
3496
|
|
|
3362
|
-
eng = _select_engine(engine, quiet=False)
|
|
3497
|
+
eng = _select_engine(engine, quiet=False, auto_update=scan_cfg.auto_update_betterleaks)
|
|
3363
3498
|
if os.path.isdir(resolved):
|
|
3364
3499
|
results = _scan_directory(resolved, eng, scan_cfg)
|
|
3365
3500
|
else:
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
import json
|
|
5
|
+
import os
|
|
5
6
|
import subprocess
|
|
6
7
|
import sys
|
|
7
8
|
|
|
@@ -180,49 +181,171 @@ def _normalize_posttool_input(raw: dict, fmt: str) -> tuple[str, dict | None]:
|
|
|
180
181
|
return raw.get("tool_name", "unknown"), raw.get("tool_response")
|
|
181
182
|
|
|
182
183
|
|
|
184
|
+
def _load_scan_config():
|
|
185
|
+
"""Load the policy-merged scan config + suppression list the same way
|
|
186
|
+
scan.py does, so hook decisions match ``rafter secrets`` (sable-55u).
|
|
187
|
+
|
|
188
|
+
Returns ``(scan_cfg, suppressions, custom_patterns)``. The hook is
|
|
189
|
+
patterns-only by design (no betterleaks subprocess — it runs on every
|
|
190
|
+
tool call and must stay fast), so betterleaks version skew can never
|
|
191
|
+
affect it; it still honors custom patterns, ``exclude_paths``, and
|
|
192
|
+
``ignore`` from ``.rafter.yml``.
|
|
193
|
+
"""
|
|
194
|
+
from ..core.config_manager import ConfigManager
|
|
195
|
+
from ..core.custom_patterns import load_suppressions, policy_ignore_to_suppressions
|
|
196
|
+
|
|
197
|
+
cfg = ConfigManager().load_with_policy()
|
|
198
|
+
scan_cfg = cfg.agent.scan
|
|
199
|
+
custom_patterns = (
|
|
200
|
+
[{"name": p.name, "regex": p.regex, "severity": p.severity} for p in scan_cfg.custom_patterns]
|
|
201
|
+
if scan_cfg and scan_cfg.custom_patterns else None
|
|
202
|
+
)
|
|
203
|
+
# Policy ignore rules first so an explicit reason wins over a bare
|
|
204
|
+
# .rafterignore line covering the same finding.
|
|
205
|
+
suppressions = policy_ignore_to_suppressions(scan_cfg.ignore if scan_cfg else None) + load_suppressions()
|
|
206
|
+
return scan_cfg, suppressions, custom_patterns
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# Cap on individual findings listed in a deny reason before truncating.
|
|
210
|
+
_MAX_REASON_FINDINGS = 10
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _format_staged_secret_reason(result: dict) -> str:
|
|
214
|
+
"""Render a deny reason that names each offending file + pattern (+ line
|
|
215
|
+
when known) instead of a bare count, so the agent knows what to fix
|
|
216
|
+
without a second ``rafter secrets`` run. Truncates long lists.
|
|
217
|
+
"""
|
|
218
|
+
repo_root = result.get("repo_root") or os.getcwd()
|
|
219
|
+
lines: list[str] = []
|
|
220
|
+
shown = 0
|
|
221
|
+
for r in result["findings"]:
|
|
222
|
+
try:
|
|
223
|
+
rel = os.path.relpath(r.file, repo_root)
|
|
224
|
+
except ValueError:
|
|
225
|
+
rel = os.path.basename(r.file)
|
|
226
|
+
for m in r.matches:
|
|
227
|
+
if shown >= _MAX_REASON_FINDINGS:
|
|
228
|
+
lines.append(f" …and {result['count'] - shown} more")
|
|
229
|
+
break
|
|
230
|
+
loc = f":{m.line}" if m.line else ""
|
|
231
|
+
lines.append(f" {rel}{loc} — {m.pattern.name}")
|
|
232
|
+
shown += 1
|
|
233
|
+
else:
|
|
234
|
+
continue
|
|
235
|
+
break
|
|
236
|
+
return "\n".join([
|
|
237
|
+
f"{result['count']} secret(s) detected in {result['files']} staged file(s):",
|
|
238
|
+
*lines,
|
|
239
|
+
"Hook scan is pattern-only (betterleaks version is irrelevant). "
|
|
240
|
+
"Run 'rafter secrets --staged' for full detail, or add an exclude_paths/ignore "
|
|
241
|
+
"rule to .rafter.yml if this is a false positive.",
|
|
242
|
+
])
|
|
243
|
+
|
|
244
|
+
|
|
183
245
|
def _scan_staged_files() -> dict:
|
|
246
|
+
"""Scan git staged files, routed through the SAME config-aware pipeline
|
|
247
|
+
as ``rafter secrets --staged`` (sable-55u). Previously the hook ran
|
|
248
|
+
RegexScanner directly on the raw staged list with no config, so it
|
|
249
|
+
phantom-blocked commits on findings the CLI would suppress.
|
|
250
|
+
"""
|
|
251
|
+
empty = {"secrets_found": False, "count": 0, "files": 0, "findings": [], "repo_root": os.getcwd()}
|
|
184
252
|
try:
|
|
253
|
+
repo_root = subprocess.run(
|
|
254
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
255
|
+
capture_output=True, text=True,
|
|
256
|
+
).stdout.strip() or os.getcwd()
|
|
257
|
+
|
|
185
258
|
output = subprocess.run(
|
|
186
259
|
["git", "diff", "--cached", "--name-only", "--diff-filter=ACM"],
|
|
187
260
|
capture_output=True, text=True,
|
|
188
261
|
).stdout.strip()
|
|
189
262
|
if not output:
|
|
190
|
-
return {
|
|
263
|
+
return {**empty, "repo_root": repo_root}
|
|
191
264
|
staged = [f for f in output.split("\n") if f.strip()]
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
265
|
+
|
|
266
|
+
from ..core.custom_patterns import apply_suppressions
|
|
267
|
+
from .agent import _apply_exclude_paths
|
|
268
|
+
|
|
269
|
+
scan_cfg, suppressions, custom_patterns = _load_scan_config()
|
|
270
|
+
scanner = RegexScanner(custom_patterns)
|
|
271
|
+
|
|
272
|
+
raw = []
|
|
273
|
+
for f in staged:
|
|
274
|
+
resolved = os.path.join(repo_root, f)
|
|
275
|
+
if not os.path.isfile(resolved):
|
|
276
|
+
continue
|
|
277
|
+
r = scanner.scan_file(resolved)
|
|
278
|
+
if r.matches:
|
|
279
|
+
raw.append(r)
|
|
280
|
+
|
|
281
|
+
exclude = scan_cfg.exclude_paths if scan_cfg else None
|
|
282
|
+
after_exclude = _apply_exclude_paths(raw, exclude, repo_root)
|
|
283
|
+
kept, _suppressed = apply_suppressions(after_exclude, suppressions)
|
|
284
|
+
total = sum(len(r.matches) for r in kept)
|
|
285
|
+
return {
|
|
286
|
+
"secrets_found": len(kept) > 0,
|
|
287
|
+
"count": total,
|
|
288
|
+
"files": len(kept),
|
|
289
|
+
"findings": kept,
|
|
290
|
+
"repo_root": repo_root,
|
|
291
|
+
}
|
|
196
292
|
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError) as exc:
|
|
197
293
|
print(f"rafter: staged file scan failed: {exc}", file=sys.stderr)
|
|
198
|
-
return
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
def _evaluate_bash(command: str) -> dict:
|
|
202
|
-
|
|
294
|
+
return empty
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _evaluate_bash(command: str, control=None) -> dict:
|
|
298
|
+
# The production caller (the pretool dispatch) always passes a resolved
|
|
299
|
+
# control. `control=None` defaults to fully-enabled — fail-safe, and keeps
|
|
300
|
+
# the function callable from focused tests that exercise interception/scan
|
|
301
|
+
# without constructing a control object.
|
|
302
|
+
if control is None:
|
|
303
|
+
from ..core.hook_control import HookControl
|
|
304
|
+
|
|
305
|
+
control = HookControl(
|
|
306
|
+
hook_enabled=True,
|
|
307
|
+
secret_scan_enabled=True,
|
|
308
|
+
command_policy_enabled=True,
|
|
309
|
+
source_hook="default",
|
|
310
|
+
source_secret_scan="default",
|
|
311
|
+
source_command_policy="default",
|
|
312
|
+
)
|
|
203
313
|
audit = AuditLogger()
|
|
204
|
-
evaluation = interceptor.evaluate(command)
|
|
205
314
|
|
|
206
|
-
#
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
315
|
+
# Command-risk interception — gated by command_policy. When disabled, skip the
|
|
316
|
+
# block/approval logic but still fall through to the staged-secret scan below.
|
|
317
|
+
if control.command_policy_enabled:
|
|
318
|
+
interceptor = CommandInterceptor()
|
|
319
|
+
evaluation = interceptor.evaluate(command)
|
|
210
320
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
321
|
+
# Blocked — hard deny
|
|
322
|
+
if not evaluation.allowed and not evaluation.requires_approval:
|
|
323
|
+
audit.log_command_intercepted(command, False, "blocked", evaluation.reason)
|
|
324
|
+
return {"decision": "deny", "reason": _format_blocked_message(command, evaluation)}
|
|
215
325
|
|
|
216
|
-
|
|
326
|
+
# Requires approval — deny (hook can't prompt interactively)
|
|
327
|
+
if evaluation.requires_approval:
|
|
328
|
+
audit.log_command_intercepted(command, False, "blocked", evaluation.reason)
|
|
329
|
+
return {"decision": "deny", "reason": _format_approval_message(command, evaluation)}
|
|
330
|
+
|
|
331
|
+
# Git commit/push — scan staged files. Gated by secret_scan so the git-commit
|
|
332
|
+
# secret check survives command_policy being disabled on its own.
|
|
217
333
|
trimmed = command.strip()
|
|
218
|
-
if trimmed.startswith(("git commit", "git push")):
|
|
334
|
+
if control.secret_scan_enabled and trimmed.startswith(("git commit", "git push")):
|
|
219
335
|
result = _scan_staged_files()
|
|
220
336
|
if result["secrets_found"]:
|
|
221
|
-
|
|
337
|
+
# Audit per file so the log records WHICH file + pattern, not a bare count.
|
|
338
|
+
repo_root = result.get("repo_root") or os.getcwd()
|
|
339
|
+
for r in result["findings"]:
|
|
340
|
+
try:
|
|
341
|
+
rel = os.path.relpath(r.file, repo_root)
|
|
342
|
+
except ValueError:
|
|
343
|
+
rel = os.path.basename(r.file)
|
|
344
|
+
names = list({m.pattern.name for m in r.matches})
|
|
345
|
+
audit.log_secret_detected(rel, ", ".join(names), "blocked")
|
|
222
346
|
return {
|
|
223
347
|
"decision": "deny",
|
|
224
|
-
"reason":
|
|
225
|
-
"Run 'rafter secrets --staged' for details.",
|
|
348
|
+
"reason": _format_staged_secret_reason(result),
|
|
226
349
|
}
|
|
227
350
|
|
|
228
351
|
audit.log_command_intercepted(command, True, "allowed")
|
|
@@ -234,19 +357,32 @@ def _evaluate_write(tool_input: dict) -> dict:
|
|
|
234
357
|
if not content:
|
|
235
358
|
return {"decision": "allow"}
|
|
236
359
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
audit.log_secret_detected(
|
|
243
|
-
tool_input.get("file_path", "file content"),
|
|
244
|
-
", ".join(names),
|
|
245
|
-
"blocked",
|
|
246
|
-
)
|
|
247
|
-
return {"decision": "deny", "reason": f"Secret detected in file content: {', '.join(names)}"}
|
|
360
|
+
# Route through the same config pipeline as scan.py so the hook honors
|
|
361
|
+
# custom patterns, exclude_paths, and ignore rules from .rafter.yml.
|
|
362
|
+
from ..core.custom_patterns import apply_suppressions
|
|
363
|
+
from ..scanners.regex_scanner import ScanResult
|
|
364
|
+
from .agent import _apply_exclude_paths
|
|
248
365
|
|
|
249
|
-
|
|
366
|
+
scan_cfg, suppressions, custom_patterns = _load_scan_config()
|
|
367
|
+
scanner = RegexScanner(custom_patterns)
|
|
368
|
+
matches = scanner.scan_text(content)
|
|
369
|
+
if not matches:
|
|
370
|
+
return {"decision": "allow"}
|
|
371
|
+
|
|
372
|
+
file_path = tool_input.get("file_path", "file content")
|
|
373
|
+
# Apply exclude_paths + suppressions keyed on the target file path, so a
|
|
374
|
+
# write to a policy-excluded path is allowed (matching `rafter secrets`).
|
|
375
|
+
exclude = scan_cfg.exclude_paths if scan_cfg else None
|
|
376
|
+
after_exclude = _apply_exclude_paths([ScanResult(file=file_path, matches=matches)], exclude, os.getcwd())
|
|
377
|
+
kept, _suppressed = apply_suppressions(after_exclude, suppressions)
|
|
378
|
+
kept_matches = kept[0].matches if kept else []
|
|
379
|
+
if not kept_matches:
|
|
380
|
+
return {"decision": "allow"}
|
|
381
|
+
|
|
382
|
+
names = list({m.pattern.name for m in kept_matches})
|
|
383
|
+
audit = AuditLogger()
|
|
384
|
+
audit.log_secret_detected(file_path, ", ".join(names), "blocked")
|
|
385
|
+
return {"decision": "deny", "reason": f"Secret detected in {file_path}: {', '.join(names)}"}
|
|
250
386
|
|
|
251
387
|
|
|
252
388
|
@hook_app.command("pretool")
|
|
@@ -273,10 +409,21 @@ def pretool(
|
|
|
273
409
|
if not isinstance(tool_input, dict):
|
|
274
410
|
tool_input = {}
|
|
275
411
|
|
|
412
|
+
# Honor the (trusted-source-only) hook off-switch before doing any work.
|
|
413
|
+
from ..core.hook_control import resolve_hook_control
|
|
414
|
+
|
|
415
|
+
control = resolve_hook_control()
|
|
416
|
+
if not control.hook_enabled:
|
|
417
|
+
_write_pretool_decision({"decision": "allow"}, format)
|
|
418
|
+
return
|
|
419
|
+
|
|
276
420
|
if tool_name == "Bash":
|
|
277
|
-
decision = _evaluate_bash(tool_input.get("command", ""))
|
|
421
|
+
decision = _evaluate_bash(tool_input.get("command", ""), control)
|
|
278
422
|
elif tool_name in ("Write", "Edit"):
|
|
279
|
-
|
|
423
|
+
if not control.secret_scan_enabled:
|
|
424
|
+
decision = {"decision": "allow"}
|
|
425
|
+
else:
|
|
426
|
+
decision = _evaluate_write(tool_input)
|
|
280
427
|
else:
|
|
281
428
|
decision = {"decision": "allow"}
|
|
282
429
|
|