gitwise-cli 0.30.0__tar.gz → 0.31.0__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.
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/CHANGELOG.md +6 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/PKG-INFO +1 -1
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/__init__.py +1 -1
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/_cli_dispatch.py +2 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/_cli_parser.py +10 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/_i18n_data.json +32 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/commit.py +69 -1
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/diff.py +50 -0
- gitwise_cli-0.31.0/gitwise/utils/secret_scan.py +201 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/pyproject.toml +1 -1
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/commit.json +5 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/diff.json +5 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_commit.py +58 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_diff.py +24 -0
- gitwise_cli-0.31.0/tests/test_secret_scan.py +177 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/.gitignore +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/LICENSE +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/README.md +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/bin/gitwise +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/__main__.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/_cli_completions.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/_cli_introspection.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/_cli_setup_agents.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/_paths.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/_runtime_config.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/audit.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/branches.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/clean.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/conflicts.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/context.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/design.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/doctor.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/git.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/health.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/i18n.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/log.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/merge.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/optimize.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/output.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/pick.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/pr.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/py.typed +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/schema.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/setup.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/setup_agents/__init__.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/setup_agents/adapters/__init__.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/setup_agents/adapters/aider.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/setup_agents/adapters/base.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/setup_agents/adapters/codex.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/setup_agents/adapters/continue_adapter.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/setup_agents/adapters/cursor.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/setup_agents/adapters/opencode.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/setup_agents/adapters/pi.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/setup_agents/exec.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/setup_agents/format.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/setup_agents/plan.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/setup_agents/plan_gitfiles.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/setup_agents/plan_skills.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/setup_agents/providers/__init__.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/setup_agents/providers/aider.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/setup_agents/providers/base.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/setup_agents/providers/claude.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/setup_agents/providers/codex.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/setup_agents/providers/continue_adapter.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/setup_agents/providers/cursor.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/setup_agents/providers/opencode.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/setup_agents/providers/pi.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/setup_agents/state.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/setup_agents/types.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/show.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/snapshot.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/stash.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/status.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/suggest.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/summarize.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/sync.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/tag.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/undo.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/update.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/utils/__init__.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/utils/git_output.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/utils/in_progress.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/utils/json_envelope.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/utils/parsing.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/gitwise/worktree.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/install.sh +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/agents/skills/git-audit/SKILL.md +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/agents/skills/git-clean/SKILL.md +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/agents/skills/git-optimize/SKILL.md +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/aider/CONVENTIONS.md.template +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/aider/aider.conf.yml.template +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/claude/CLAUDE.md.template +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/claude/rules/gitwise.md +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/claude/settings.json.template +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/claude/skills/git-audit/SKILL.md +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/claude/skills/git-clean/SKILL.md +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/claude/skills/git-optimize/SKILL.md +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/codex/agents/gitwise.toml.template +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/continue/rules/gitwise.md.template +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/cursor/rules/gitwise.mdc.template +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/git-config-modern.txt +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/hooks/commit-msg +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/hooks/pre-commit +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/opencode/agents/gitwise.md.template +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/pi/skills/gitwise.md.template +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/audit.json +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/branches.json +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/clean.json +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/commands.json +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/completions.json +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/conflicts.json +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/context.json +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/doctor.json +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/health.json +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/log.json +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/merge.json +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/optimize.json +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/pick.json +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/pr.json +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/schema.json +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/setup-agents.json +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/setup.json +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/show.json +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/snapshot.json +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/stash.json +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/status.json +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/suggest.json +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/summarize.json +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/sync.json +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/tag.json +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/undo.json +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/update.json +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/share/schemas/v1/input/worktree.json +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/__init__.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/conftest.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_adapters.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_audit.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_branches.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_clean.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_conflicts.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_context.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_design.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_doctor.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_edge_cases.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_git.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_git_output_utils.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_health.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_i18n.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_in_progress.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_json_envelope_utils.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_log.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_main.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_md_links_script.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_merge.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_optimize.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_output.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_parsing_utils.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_pick.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_pr.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_roadmap_baseline_script.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_sa_plan.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_sa_unit.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_schema_catalog.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_setup.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_setup_agents.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_show.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_snapshot.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_stash.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_status.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_suggest.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_summarize.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_sync.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_sync_changelog_es_script.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_tag.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_undo.py +0 -0
- {gitwise_cli-0.30.0 → gitwise_cli-0.31.0}/tests/test_worktree.py +0 -0
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
4
4
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
5
5
|
|
|
6
|
+
## v0.31.0 (2026-06-19)
|
|
7
|
+
|
|
8
|
+
### Feat
|
|
9
|
+
|
|
10
|
+
- **secret-scan**: commit-time guard and diff --scan-secrets (Sprint 2 D3) (#68)
|
|
11
|
+
|
|
6
12
|
## v0.30.0 (2026-06-19)
|
|
7
13
|
|
|
8
14
|
### Feat
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitwise-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.31.0
|
|
4
4
|
Summary: Python CLI for optimizing git workflows and Claude Code integration
|
|
5
5
|
Project-URL: Homepage, https://github.com/drzioner/gitwise
|
|
6
6
|
Project-URL: Repository, https://github.com/drzioner/gitwise
|
|
@@ -193,6 +193,7 @@ def _run_diff(args: argparse.Namespace) -> int:
|
|
|
193
193
|
name_only=args.name_only,
|
|
194
194
|
full=args.full,
|
|
195
195
|
summary=args.summary,
|
|
196
|
+
scan_secrets=args.scan_secrets,
|
|
196
197
|
as_json=args.json,
|
|
197
198
|
)
|
|
198
199
|
|
|
@@ -232,6 +233,7 @@ def _run_commit(args: argparse.Namespace) -> int:
|
|
|
232
233
|
breaking=args.breaking,
|
|
233
234
|
amend=args.amend,
|
|
234
235
|
dry_run=args.dry_run,
|
|
236
|
+
allow_secret=args.allow_secret,
|
|
235
237
|
as_json=args.json,
|
|
236
238
|
)
|
|
237
239
|
|
|
@@ -184,6 +184,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
184
184
|
help="compact summary: additions/deletions per file, no patch",
|
|
185
185
|
)
|
|
186
186
|
p.add_argument("--stat", action="store_true", help="show diffstat (default behavior)")
|
|
187
|
+
p.add_argument(
|
|
188
|
+
"--scan-secrets",
|
|
189
|
+
action="store_true",
|
|
190
|
+
help="scan the diff for leaked credentials (advisory, opt-in)",
|
|
191
|
+
)
|
|
187
192
|
p.add_argument(
|
|
188
193
|
"refspec",
|
|
189
194
|
nargs="?",
|
|
@@ -218,6 +223,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
218
223
|
p.add_argument("--breaking", action="store_true", help="breaking change (!)")
|
|
219
224
|
p.add_argument("--amend", action="store_true", help="amend last commit")
|
|
220
225
|
p.add_argument("--dry-run", action="store_true", help="show without committing")
|
|
226
|
+
p.add_argument(
|
|
227
|
+
"--allow-secret",
|
|
228
|
+
action="store_true",
|
|
229
|
+
help="proceed past a high-severity secret finding (with confirmation)",
|
|
230
|
+
)
|
|
221
231
|
|
|
222
232
|
p = sub.add_parser("branches", help="branch intelligence dashboard", parents=[parent])
|
|
223
233
|
p.add_argument("--stale", action="store_true", help="show stale [gone] branches only")
|
|
@@ -463,6 +463,38 @@
|
|
|
463
463
|
"es": "demasiados argumentos posicionales antes de '--' ({count}); gitwise diff acepta un unico refspec (usa a..b para un rango)",
|
|
464
464
|
"en": "too many positional arguments before '--' ({count}); gitwise diff accepts a single refspec (use a..b for a range)"
|
|
465
465
|
},
|
|
466
|
+
"secret_scan_clean": {
|
|
467
|
+
"es": "no se detectaron secretos en el diff",
|
|
468
|
+
"en": "no secrets detected in the diff"
|
|
469
|
+
},
|
|
470
|
+
"secret_scan_found": {
|
|
471
|
+
"es": "{rule} en {path}:{line} ({preview})",
|
|
472
|
+
"en": "{rule} at {path}:{line} ({preview})"
|
|
473
|
+
},
|
|
474
|
+
"secret_scan_found_header": {
|
|
475
|
+
"es": "Secretos detectados -- {count} hallazgo(s):",
|
|
476
|
+
"en": "Secrets detected -- {count} finding(s):"
|
|
477
|
+
},
|
|
478
|
+
"secret_scan_blocked_high": {
|
|
479
|
+
"es": "commit bloqueado: {count} secreto(s) de alta severidad en el área de staging",
|
|
480
|
+
"en": "commit blocked: {count} high-severity secret(s) in the staging area"
|
|
481
|
+
},
|
|
482
|
+
"secret_scan_blocked_hint": {
|
|
483
|
+
"es": "revoque o rote el secreto, remuévalo del stage, o use --allow-secret (con confirmación) para commitear de todos modos",
|
|
484
|
+
"en": "revoke/rotate the secret, unstage it, or use --allow-secret (with confirmation) to commit anyway"
|
|
485
|
+
},
|
|
486
|
+
"secret_scan_allow_confirm": {
|
|
487
|
+
"es": "Se detectaron {count} secreto(s) de alta severidad. ¿Commitear de todos modos?",
|
|
488
|
+
"en": "{count} high-severity secret(s) detected. Commit anyway?"
|
|
489
|
+
},
|
|
490
|
+
"secret_scan_medium_warning": {
|
|
491
|
+
"es": "advertencia: {rule} en {path}:{line} ({preview})",
|
|
492
|
+
"en": "warning: {rule} at {path}:{line} ({preview})"
|
|
493
|
+
},
|
|
494
|
+
"secret_scan_unavailable": {
|
|
495
|
+
"es": "no se pudo escanear el área de staging (fail-closed): {error}",
|
|
496
|
+
"en": "could not scan the staging area (fail-closed): {error}"
|
|
497
|
+
},
|
|
466
498
|
"directory_exists": {
|
|
467
499
|
"es": "el directorio ya existe: {path}",
|
|
468
500
|
"en": "directory already exists: {path}"
|
|
@@ -6,9 +6,10 @@ from pathlib import Path
|
|
|
6
6
|
from .git import PROTECTED_BRANCHES, current_branch, gpg_status, require_root
|
|
7
7
|
from .git import run as git_run
|
|
8
8
|
from .i18n import t
|
|
9
|
-
from .output import error, print_bracket, print_header, print_json
|
|
9
|
+
from .output import confirm, error, print_bracket, print_header, print_json, warn
|
|
10
10
|
from .utils.in_progress import detect_in_progress, in_progress_hint
|
|
11
11
|
from .utils.json_envelope import error_envelope, ok_envelope
|
|
12
|
+
from .utils.secret_scan import SecretScanUnavailable, scan_staged_diff
|
|
12
13
|
|
|
13
14
|
_CONVENTIONAL_RE = re.compile(
|
|
14
15
|
r"^(feat|fix|refactor|docs|chore|test|style|perf|ci|build|revert)(\(.+\))?!?: .{1,72}"
|
|
@@ -110,6 +111,68 @@ def _report_commit_error(*, as_json: bool, err: str) -> int:
|
|
|
110
111
|
return 1
|
|
111
112
|
|
|
112
113
|
|
|
114
|
+
def _enforce_secret_guard(*, root: Path, allow_secret: bool, as_json: bool) -> int | None:
|
|
115
|
+
"""Scan staged content for leaked credentials; return 1 to block, None to proceed.
|
|
116
|
+
|
|
117
|
+
Runs by default on every commit. High-severity findings block unless
|
|
118
|
+
``allow_secret`` is set (human mode still asks for confirmation); medium
|
|
119
|
+
findings only warn. The full secret is never printed -- previews are redacted.
|
|
120
|
+
Fails closed: if the staged diff cannot be read, the commit is blocked
|
|
121
|
+
rather than allowed through unscanned.
|
|
122
|
+
"""
|
|
123
|
+
try:
|
|
124
|
+
findings = scan_staged_diff(root)
|
|
125
|
+
except SecretScanUnavailable as exc:
|
|
126
|
+
unavailable = t("secret_scan_unavailable", error=str(exc))
|
|
127
|
+
if as_json:
|
|
128
|
+
print_json(error_envelope(error=unavailable, code="secret_scan_unavailable"))
|
|
129
|
+
else:
|
|
130
|
+
error(unavailable)
|
|
131
|
+
return 1
|
|
132
|
+
if not findings:
|
|
133
|
+
return None
|
|
134
|
+
high = [f for f in findings if f["severity"] == "high"]
|
|
135
|
+
medium = [f for f in findings if f["severity"] == "medium"]
|
|
136
|
+
for f in medium:
|
|
137
|
+
warn(
|
|
138
|
+
t(
|
|
139
|
+
"secret_scan_medium_warning",
|
|
140
|
+
rule=f["rule"],
|
|
141
|
+
path=f["path"],
|
|
142
|
+
line=str(f["line"]),
|
|
143
|
+
preview=f["preview"],
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
if not high:
|
|
147
|
+
return None
|
|
148
|
+
if not allow_secret:
|
|
149
|
+
if as_json:
|
|
150
|
+
print_json(
|
|
151
|
+
error_envelope(
|
|
152
|
+
error=t("secret_scan_blocked_high", count=str(len(high))),
|
|
153
|
+
code="secret_leak_high",
|
|
154
|
+
hint=t("secret_scan_blocked_hint"),
|
|
155
|
+
findings=high,
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
else:
|
|
159
|
+
for f in high:
|
|
160
|
+
error(
|
|
161
|
+
t(
|
|
162
|
+
"secret_scan_found",
|
|
163
|
+
rule=f["rule"],
|
|
164
|
+
path=f["path"],
|
|
165
|
+
line=str(f["line"]),
|
|
166
|
+
preview=f["preview"],
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
error(t("secret_scan_blocked_hint"))
|
|
170
|
+
return 1
|
|
171
|
+
if not as_json and not confirm(t("secret_scan_allow_confirm", count=str(len(high)))):
|
|
172
|
+
return 1
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
|
|
113
176
|
def run_commit(
|
|
114
177
|
*,
|
|
115
178
|
message: str | None = None,
|
|
@@ -118,6 +181,7 @@ def run_commit(
|
|
|
118
181
|
breaking: bool = False,
|
|
119
182
|
amend: bool = False,
|
|
120
183
|
dry_run: bool = False,
|
|
184
|
+
allow_secret: bool = False,
|
|
121
185
|
as_json: bool = False,
|
|
122
186
|
) -> int:
|
|
123
187
|
"""Validate a conventional-commit message and create a GPG-signed commit.
|
|
@@ -164,6 +228,10 @@ def run_commit(
|
|
|
164
228
|
if not _validate_gpg_ready(root):
|
|
165
229
|
return 1
|
|
166
230
|
|
|
231
|
+
secret_rc = _enforce_secret_guard(root=root, allow_secret=allow_secret, as_json=as_json)
|
|
232
|
+
if secret_rc is not None:
|
|
233
|
+
return secret_rc
|
|
234
|
+
|
|
167
235
|
if dry_run:
|
|
168
236
|
if as_json:
|
|
169
237
|
print_json(ok_envelope(message=full_msg, amend=amend, dry_run=True))
|
|
@@ -20,6 +20,7 @@ from .output import (
|
|
|
20
20
|
)
|
|
21
21
|
from .utils.git_output import parse_name_status_entries, status_label
|
|
22
22
|
from .utils.json_envelope import ok_envelope
|
|
23
|
+
from .utils.secret_scan import secret_scan
|
|
23
24
|
|
|
24
25
|
DiffValue = str | int | bool
|
|
25
26
|
DiffFileEntry = dict[str, DiffValue]
|
|
@@ -414,6 +415,51 @@ def _render_summary_output(
|
|
|
414
415
|
return 0
|
|
415
416
|
|
|
416
417
|
|
|
418
|
+
def _render_secret_scan_output(
|
|
419
|
+
*, root: Path, refspec: str | None, paths: list[str] | None, as_json: bool
|
|
420
|
+
) -> int:
|
|
421
|
+
"""Scan the diff patch for leaked credentials and report findings (opt-in).
|
|
422
|
+
|
|
423
|
+
The diff flags defeat user config that could bypass the scanner
|
|
424
|
+
(``color.ui=always`` ANSI, external/textconv drivers) -- same reasoning as
|
|
425
|
+
the commit-time guard in ``_staged_diff_text``.
|
|
426
|
+
"""
|
|
427
|
+
args = ["--no-pager", "diff", "--no-color", "--no-ext-diff", "--no-textconv"]
|
|
428
|
+
if refspec:
|
|
429
|
+
args.append(refspec)
|
|
430
|
+
else:
|
|
431
|
+
args.append("HEAD")
|
|
432
|
+
if paths:
|
|
433
|
+
args.append("--")
|
|
434
|
+
args.extend(paths)
|
|
435
|
+
result = git_run(args, cwd=root, check=False)
|
|
436
|
+
if result.returncode != 0:
|
|
437
|
+
error(t("git_diff_failed", error=result.stderr.strip()))
|
|
438
|
+
return 1
|
|
439
|
+
findings = secret_scan(result.stdout)
|
|
440
|
+
if as_json:
|
|
441
|
+
print_json(ok_envelope(findings=findings, count=len(findings)))
|
|
442
|
+
return 1 if any(f["severity"] == "high" for f in findings) else 0
|
|
443
|
+
if not findings:
|
|
444
|
+
info(t("secret_scan_clean"))
|
|
445
|
+
return 0
|
|
446
|
+
print_header(t("secret_scan_found_header", count=str(len(findings))))
|
|
447
|
+
for f in findings:
|
|
448
|
+
sev = f["severity"]
|
|
449
|
+
line = t(
|
|
450
|
+
"secret_scan_found",
|
|
451
|
+
rule=f["rule"],
|
|
452
|
+
path=f["path"],
|
|
453
|
+
line=str(f["line"]),
|
|
454
|
+
preview=f["preview"],
|
|
455
|
+
)
|
|
456
|
+
if sev == "high":
|
|
457
|
+
error(line)
|
|
458
|
+
else:
|
|
459
|
+
warn(line)
|
|
460
|
+
return 1 if any(f["severity"] == "high" for f in findings) else 0
|
|
461
|
+
|
|
462
|
+
|
|
417
463
|
def run_diff(
|
|
418
464
|
*,
|
|
419
465
|
refspec: str | None = None,
|
|
@@ -423,6 +469,7 @@ def run_diff(
|
|
|
423
469
|
name_only: bool = False,
|
|
424
470
|
full: bool = False,
|
|
425
471
|
summary: bool = False,
|
|
472
|
+
scan_secrets: bool = False,
|
|
426
473
|
as_json: bool = False,
|
|
427
474
|
) -> int:
|
|
428
475
|
"""Render a diff of changed files, a refspec, or a range.
|
|
@@ -447,6 +494,9 @@ def run_diff(
|
|
|
447
494
|
info(t("no_commits_yet"))
|
|
448
495
|
return 0
|
|
449
496
|
|
|
497
|
+
if scan_secrets:
|
|
498
|
+
return _render_secret_scan_output(root=cwd, refspec=refspec, paths=paths, as_json=as_json)
|
|
499
|
+
|
|
450
500
|
if summary:
|
|
451
501
|
numstat_details = _numstat_details(cwd, staged=staged, refspec=refspec, paths=paths)
|
|
452
502
|
summary_files = list(numstat_details.values())
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Detect leaked credentials in diffs before they reach the object database.
|
|
2
|
+
|
|
3
|
+
Scans added lines (`+`, excluding the `+++` header) of a diff for high-signal
|
|
4
|
+
credential patterns. Designed for the commit-time guard (block on high
|
|
5
|
+
severity) and `diff --scan-secrets` (advisory). False-positive avoidance is a
|
|
6
|
+
hard requirement: the ruleset favours prefixed, vendor-documented formats over
|
|
7
|
+
generic "looks like a key" heuristics, so the project's own test corpus scans
|
|
8
|
+
clean.
|
|
9
|
+
|
|
10
|
+
Verified sources (token formats change over time; the GitHub `ghs_` rule in
|
|
11
|
+
particular was rewritten in 2026 to match the new stateless long-form):
|
|
12
|
+
- GitHub credential prefixes and the 2026 stateless `ghs_APPID_JWT` rollout:
|
|
13
|
+
github.blog/changelog/2026-04-24-notice-about-upcoming-new-format-for-github-app-installation-tokens
|
|
14
|
+
and github/docs content/organizations/.../github-credential-types.md
|
|
15
|
+
- Dual `ghs_` regex (legacy 36-char + long-form dotted) ported from
|
|
16
|
+
github/gh-aw PR #34737 (2026-05-25)
|
|
17
|
+
- GitLab PAT length 27-300: docs.gitlab.com/security/tokens/ via GitLab MR 169322
|
|
18
|
+
- AWS access key id format `AKIA[0-9A-Z]{16}`: docs.aws.amazon.com/IAM/latest/.../reference_identifiers.html
|
|
19
|
+
- OpenSSH new-format magic `b3BlbnNzaC1rZXktdjE` (base64 of "openssh-key-v1"):
|
|
20
|
+
protSPEC openssh/PROTOCOL.key
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import re
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Literal, TypedDict
|
|
28
|
+
|
|
29
|
+
from ..git import run as git_run
|
|
30
|
+
|
|
31
|
+
SecretSeverity = Literal["high", "medium"]
|
|
32
|
+
|
|
33
|
+
_DIFF_ADDED_LINE_RE = re.compile(r"^\+(?!\+\+)")
|
|
34
|
+
_HUNK_HEADER_RE = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SecretScanUnavailable(RuntimeError):
|
|
38
|
+
"""Raised when the staged diff cannot be read, so a scan cannot run."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class SecretFinding(TypedDict):
|
|
42
|
+
"""One credential match located in a scanned diff."""
|
|
43
|
+
|
|
44
|
+
rule: str
|
|
45
|
+
path: str
|
|
46
|
+
line: int
|
|
47
|
+
preview: str
|
|
48
|
+
severity: SecretSeverity
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# Each rule: (compiled pattern, human rule name, severity). Patterns target the
|
|
52
|
+
# credential itself; surrounding assignment context is handled where it raises
|
|
53
|
+
# precision (e.g. AWS secret key).
|
|
54
|
+
_GITHUB_TOKEN_RE = re.compile(
|
|
55
|
+
r"(?:"
|
|
56
|
+
r"gh[pou]_[A-Za-z0-9]{36,}"
|
|
57
|
+
r"|ghr_[A-Za-z0-9]{36,}"
|
|
58
|
+
r"|ghs_(?:[0-9A-Za-z]{36}(?![0-9A-Za-z._-])|[0-9A-Za-z_-]{10,}(?:\.[0-9A-Za-z_-]{10,}){2,})"
|
|
59
|
+
r"|github_pat_[A-Za-z0-9_]{80,}"
|
|
60
|
+
r")"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
_RULES: list[tuple[re.Pattern[str], str, SecretSeverity]] = [
|
|
64
|
+
(re.compile(r"AKIA[0-9A-Z]{16}"), "aws_access_key_id", "high"),
|
|
65
|
+
(
|
|
66
|
+
re.compile(r"(?i)aws_secret_access_key['\"]?\s*[:=]\s*['\"]?([A-Za-z0-9/+=]{40})"),
|
|
67
|
+
"aws_secret_access_key",
|
|
68
|
+
"high",
|
|
69
|
+
),
|
|
70
|
+
(_GITHUB_TOKEN_RE, "github_token", "high"),
|
|
71
|
+
(re.compile(r"glpat-[A-Za-z0-9_-]{20,300}"), "gitlab_pat", "high"),
|
|
72
|
+
(
|
|
73
|
+
re.compile(r"-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED |)PRIVATE KEY-----"),
|
|
74
|
+
"private_key_pem",
|
|
75
|
+
"high",
|
|
76
|
+
),
|
|
77
|
+
(re.compile(r"b3BlbnNzaC1rZXktdjE[A-Za-z0-9+/=]*"), "openssh_private_key", "high"),
|
|
78
|
+
(
|
|
79
|
+
re.compile(
|
|
80
|
+
r"(?i)(?:api[_-]?key|secret|token|password)['\"]?\s*[:=]\s*['\"]([A-Za-z0-9_\-]{24,})['\"]"
|
|
81
|
+
),
|
|
82
|
+
"generic_secret_assignment",
|
|
83
|
+
"medium",
|
|
84
|
+
),
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
_PREVIEW_KEEP_PREFIX = 8
|
|
88
|
+
_PREVIEW_KEEP_SUFFIX = 4
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _redact(match: str) -> str:
|
|
92
|
+
"""Truncate a credential for display so the full secret is never printed."""
|
|
93
|
+
if len(match) <= _PREVIEW_KEEP_PREFIX + _PREVIEW_KEEP_SUFFIX:
|
|
94
|
+
return "***"
|
|
95
|
+
return f"{match[:_PREVIEW_KEEP_PREFIX]}...{match[-_PREVIEW_KEEP_SUFFIX:]}"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _staged_diff_text(root: Path) -> str:
|
|
99
|
+
"""Return the full cached diff text.
|
|
100
|
+
|
|
101
|
+
Raises ``SecretScanUnavailable`` when git itself reports a failure, so the
|
|
102
|
+
commit guard fails closed (block) instead of silently treating an
|
|
103
|
+
unscannable index as clean. The diff flags defeat user config that could
|
|
104
|
+
otherwise bypass the scanner: ``--no-color`` strips ANSI that breaks line
|
|
105
|
+
parsing, ``--no-ext-diff`` and ``--no-textconv`` ignore external/textconv
|
|
106
|
+
drivers that could hide added content while still exiting 0.
|
|
107
|
+
"""
|
|
108
|
+
result = git_run(
|
|
109
|
+
["--no-pager", "diff", "--cached", "--no-color", "--no-ext-diff", "--no-textconv"],
|
|
110
|
+
cwd=root,
|
|
111
|
+
check=False,
|
|
112
|
+
)
|
|
113
|
+
if result.returncode != 0:
|
|
114
|
+
raise SecretScanUnavailable(result.stderr.strip() or "git diff --cached failed")
|
|
115
|
+
return result.stdout
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _parse_diff_hunk_for_path(line: str) -> str | None:
|
|
119
|
+
"""Return the path from a `+++ <path>` diff header, or None.
|
|
120
|
+
|
|
121
|
+
Handles both the default `+++ b/<path>` prefix and the prefixless form a
|
|
122
|
+
user gets with `diff.noprefix=true`, plus `/dev/null` (file absent on the
|
|
123
|
+
new side of a delete).
|
|
124
|
+
"""
|
|
125
|
+
if not line.startswith("+++ "):
|
|
126
|
+
return None
|
|
127
|
+
rest = line[4:]
|
|
128
|
+
if rest == "/dev/null":
|
|
129
|
+
return None
|
|
130
|
+
if rest.startswith("b/"):
|
|
131
|
+
return rest[2:]
|
|
132
|
+
return rest
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def secret_scan(diff_text: str) -> list[SecretFinding]:
|
|
136
|
+
"""Scan added lines of a unified diff for credential patterns.
|
|
137
|
+
|
|
138
|
+
Only lines introduced by the diff (leading `+`, excluding the `+++` header)
|
|
139
|
+
are inspected, so pre-existing content is never flagged. Reported line
|
|
140
|
+
numbers are the true new-file line numbers parsed from each
|
|
141
|
+
``@@ -old,+new @@`` hunk header (so a finding points at the exact line to
|
|
142
|
+
remediate even across multi-hunk or delete-heavy diffs). Each match becomes
|
|
143
|
+
a ``SecretFinding`` with a redacted preview.
|
|
144
|
+
"""
|
|
145
|
+
findings: list[SecretFinding] = []
|
|
146
|
+
current_path = ""
|
|
147
|
+
line_no = 0
|
|
148
|
+
for raw in diff_text.splitlines():
|
|
149
|
+
path_from_header = _parse_diff_hunk_for_path(raw)
|
|
150
|
+
if path_from_header is not None:
|
|
151
|
+
current_path = path_from_header
|
|
152
|
+
line_no = 0
|
|
153
|
+
continue
|
|
154
|
+
hunk = _HUNK_HEADER_RE.match(raw)
|
|
155
|
+
if hunk:
|
|
156
|
+
# Next context/added line in the new file is +new_start; decrement
|
|
157
|
+
# so the increment below lands on the right number.
|
|
158
|
+
line_no = int(hunk.group(1)) - 1
|
|
159
|
+
continue
|
|
160
|
+
if raw.startswith("-"):
|
|
161
|
+
# Removed lines belong to the old file only and do not advance the
|
|
162
|
+
# new-file line counter.
|
|
163
|
+
continue
|
|
164
|
+
if not _DIFF_ADDED_LINE_RE.match(raw):
|
|
165
|
+
if raw.startswith(" "):
|
|
166
|
+
line_no += 1
|
|
167
|
+
continue
|
|
168
|
+
line_no += 1
|
|
169
|
+
payload = raw[1:]
|
|
170
|
+
for pattern, rule, severity in _RULES:
|
|
171
|
+
for match in pattern.finditer(payload):
|
|
172
|
+
findings.append(
|
|
173
|
+
SecretFinding(
|
|
174
|
+
rule=rule,
|
|
175
|
+
path=current_path,
|
|
176
|
+
line=line_no,
|
|
177
|
+
preview=_redact(match.group(0)),
|
|
178
|
+
severity=severity,
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
# Suppress the generic rule when a vendor-specific rule already flagged the
|
|
182
|
+
# same line: "ghp_..." should report github_token (high), not also a noisy
|
|
183
|
+
# medium generic_secret_assignment on top of it.
|
|
184
|
+
specific_keys = {
|
|
185
|
+
(f["path"], f["line"]) for f in findings if f["rule"] != "generic_secret_assignment"
|
|
186
|
+
}
|
|
187
|
+
return [
|
|
188
|
+
f
|
|
189
|
+
for f in findings
|
|
190
|
+
if f["rule"] != "generic_secret_assignment" or (f["path"], f["line"]) not in specific_keys
|
|
191
|
+
]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def scan_staged_diff(root: Path) -> list[SecretFinding]:
|
|
195
|
+
"""Convenience wrapper: scan the index (staged) diff of `root`."""
|
|
196
|
+
return secret_scan(_staged_diff_text(root))
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def has_high_severity(findings: list[SecretFinding]) -> bool:
|
|
200
|
+
"""Return True if any finding is high severity (commit-blocking)."""
|
|
201
|
+
return any(finding["severity"] == "high" for finding in findings)
|
|
@@ -58,6 +58,11 @@
|
|
|
58
58
|
"type": "boolean",
|
|
59
59
|
"description": "show without committing",
|
|
60
60
|
"default": false
|
|
61
|
+
},
|
|
62
|
+
"allow_secret": {
|
|
63
|
+
"type": "boolean",
|
|
64
|
+
"description": "proceed past a high-severity secret finding (with confirmation)",
|
|
65
|
+
"default": false
|
|
61
66
|
}
|
|
62
67
|
}
|
|
63
68
|
}
|
|
@@ -57,6 +57,11 @@
|
|
|
57
57
|
"description": "show diffstat (default behavior)",
|
|
58
58
|
"default": false
|
|
59
59
|
},
|
|
60
|
+
"scan_secrets": {
|
|
61
|
+
"type": "boolean",
|
|
62
|
+
"description": "scan the diff for leaked credentials (advisory, opt-in)",
|
|
63
|
+
"default": false
|
|
64
|
+
},
|
|
60
65
|
"refspec": {
|
|
61
66
|
"type": "string",
|
|
62
67
|
"description": "commit, branch, or range to diff (e.g. HEAD~3, main..feat, main...feat)"
|
|
@@ -98,3 +98,61 @@ def test_commit_no_duplicate_type(tmp_git_repo: Path) -> None:
|
|
|
98
98
|
r = run_gitwise("commit", "-m", "feat: already typed", "--dry-run", cwd=tmp_git_repo)
|
|
99
99
|
assert r.returncode == 0
|
|
100
100
|
assert "feat: feat: already typed" not in r.stdout
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# -- secret-scan guard (D3) ---------------------------------------------------
|
|
104
|
+
# Sample secrets are assembled at runtime from fragments so the test source
|
|
105
|
+
# stays scanner-clean; otherwise the commit guard would flag this file.
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _aws_key() -> str:
|
|
109
|
+
return "AK" + "IAIOSFODNN7EXAMPLE"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_commit_blocked_on_secret_high(tmp_git_repo: Path) -> None:
|
|
113
|
+
(tmp_git_repo / "config.py").write_text(f"aws_key = '{_aws_key()}'\n")
|
|
114
|
+
_git(["add", "config.py"], tmp_git_repo)
|
|
115
|
+
r = run_gitwise("commit", "-m", "feat: add config", cwd=tmp_git_repo)
|
|
116
|
+
assert r.returncode == 1
|
|
117
|
+
assert "secret" in r.stderr.lower() or "blocked" in r.stderr.lower()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_commit_blocked_on_secret_high_json(tmp_git_repo: Path) -> None:
|
|
121
|
+
(tmp_git_repo / "config.py").write_text(f"key = '{_aws_key()}'\n")
|
|
122
|
+
_git(["add", "config.py"], tmp_git_repo)
|
|
123
|
+
r = run_gitwise("commit", "-m", "feat: add config", "--json", cwd=tmp_git_repo)
|
|
124
|
+
assert r.returncode == 1
|
|
125
|
+
import json
|
|
126
|
+
|
|
127
|
+
data = json.loads(r.stdout)
|
|
128
|
+
assert data["errors"][0]["code"] == "secret_leak_high"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_commit_allow_secret_json_proceeds(tmp_git_repo: Path) -> None:
|
|
132
|
+
(tmp_git_repo / "config.py").write_text(f"key = '{_aws_key()}'\n")
|
|
133
|
+
_git(["add", "config.py"], tmp_git_repo)
|
|
134
|
+
r = run_gitwise(
|
|
135
|
+
"commit", "-m", "feat: add config", "--allow-secret", "--json", cwd=tmp_git_repo
|
|
136
|
+
)
|
|
137
|
+
assert r.returncode == 0
|
|
138
|
+
# The commit was actually created despite the high-severity finding.
|
|
139
|
+
log = _git(["--no-pager", "log", "--oneline"], tmp_git_repo).stdout.decode()
|
|
140
|
+
assert "add config" in log
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_commit_warns_on_secret_medium(tmp_git_repo: Path) -> None:
|
|
144
|
+
body = "a" * 30
|
|
145
|
+
(tmp_git_repo / "config.py").write_text(f'api_key = "{body}"\n')
|
|
146
|
+
_git(["add", "config.py"], tmp_git_repo)
|
|
147
|
+
r = run_gitwise("commit", "-m", "feat: add config", cwd=tmp_git_repo)
|
|
148
|
+
assert r.returncode == 0
|
|
149
|
+
assert "warning" in r.stderr.lower() or "secret" in r.stderr.lower()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_commit_blocked_on_secret_despite_color_always(tmp_git_repo: Path) -> None:
|
|
153
|
+
"""color.ui=always must not bypass the guard via ANSI-wrapped diff lines."""
|
|
154
|
+
_git(["config", "color.ui", "always"], tmp_git_repo)
|
|
155
|
+
(tmp_git_repo / "config.py").write_text(f"key = '{_aws_key()}'\n")
|
|
156
|
+
_git(["add", "config.py"], tmp_git_repo)
|
|
157
|
+
r = run_gitwise("commit", "-m", "feat: add config", cwd=tmp_git_repo)
|
|
158
|
+
assert r.returncode == 1
|
|
@@ -325,3 +325,27 @@ def test_diff_binary_lfs_warning_json(tmp_git_repo):
|
|
|
325
325
|
data = json.loads(result.stdout)
|
|
326
326
|
assert "binary_warnings" in data
|
|
327
327
|
assert len(data["binary_warnings"]) >= 1
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
# ── D3: --scan-secrets (opt-in advisory scan) ────────────────────────────────
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def test_diff_scan_secrets_detects_high(tmp_git_repo):
|
|
334
|
+
"""--scan-secrets flags a staged high-severity credential."""
|
|
335
|
+
aws = "AK" + "IAIOSFODNN7EXAMPLE"
|
|
336
|
+
(tmp_git_repo / "config.py").write_text(f"key = '{aws}'\n")
|
|
337
|
+
_git(["add", "config.py"], tmp_git_repo)
|
|
338
|
+
result = _run("diff", "--scan-secrets", "--json", cwd=tmp_git_repo)
|
|
339
|
+
assert result.returncode == 1
|
|
340
|
+
data = json.loads(result.stdout)
|
|
341
|
+
assert data["count"] >= 1
|
|
342
|
+
assert any(f["severity"] == "high" for f in data["findings"])
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def test_diff_scan_secrets_clean(tmp_git_repo):
|
|
346
|
+
"""--scan-secrets on a clean diff reports no findings and exits 0."""
|
|
347
|
+
(tmp_git_repo / "README.md").write_text("hello world\n")
|
|
348
|
+
_git(["add", "README.md"], tmp_git_repo)
|
|
349
|
+
result = _run("diff", "--scan-secrets", cwd=tmp_git_repo)
|
|
350
|
+
assert result.returncode == 0
|
|
351
|
+
assert "no secrets" in result.stdout.lower()
|