sanity-guard 0.1.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.
- sanity_guard-0.1.0/.env.example +5 -0
- sanity_guard-0.1.0/.gitignore +32 -0
- sanity_guard-0.1.0/.sanity/override_log.jsonl +1 -0
- sanity_guard-0.1.0/PKG-INFO +67 -0
- sanity_guard-0.1.0/README.md +51 -0
- sanity_guard-0.1.0/pyproject.toml +34 -0
- sanity_guard-0.1.0/s -ExecutionPolicy RemoteSigned) ; (& c/357/200/272UsersJinangDocumentsantigravityblissful-volta.venvScriptsActivate.ps1)" +156 -0
- sanity_guard-0.1.0/setup.cfg +4 -0
- sanity_guard-0.1.0/src/sanity/__init__.py +3 -0
- sanity_guard-0.1.0/src/sanity/__main__.py +3 -0
- sanity_guard-0.1.0/src/sanity/checks.py +278 -0
- sanity_guard-0.1.0/src/sanity/cli.py +174 -0
- sanity_guard-0.1.0/src/sanity/config.py +130 -0
- sanity_guard-0.1.0/src/sanity/diff.py +113 -0
- sanity_guard-0.1.0/src/sanity/display.py +81 -0
- sanity_guard-0.1.0/src/sanity/llm_check.py +111 -0
- sanity_guard-0.1.0/src/sanity/models.py +41 -0
- sanity_guard-0.1.0/src/sanity/scorer.py +27 -0
- sanity_guard-0.1.0/src/sanity/snapshot.py +178 -0
- sanity_guard-0.1.0/src/sanity_guard.egg-info/PKG-INFO +67 -0
- sanity_guard-0.1.0/src/sanity_guard.egg-info/SOURCES.txt +33 -0
- sanity_guard-0.1.0/src/sanity_guard.egg-info/dependency_links.txt +1 -0
- sanity_guard-0.1.0/src/sanity_guard.egg-info/entry_points.txt +2 -0
- sanity_guard-0.1.0/src/sanity_guard.egg-info/requires.txt +8 -0
- sanity_guard-0.1.0/src/sanity_guard.egg-info/top_level.txt +1 -0
- sanity_guard-0.1.0/test_groq_api.py +43 -0
- sanity_guard-0.1.0/tests/__init__.py +1 -0
- sanity_guard-0.1.0/tests/test_checks.py +543 -0
- sanity_guard-0.1.0/tests/test_config.py +196 -0
- sanity_guard-0.1.0/tests/test_diff.py +114 -0
- sanity_guard-0.1.0/tests/test_init.py +81 -0
- sanity_guard-0.1.0/tests/test_llm_check.py +208 -0
- sanity_guard-0.1.0/tests/test_models.py +48 -0
- sanity_guard-0.1.0/tests/test_scorer.py +72 -0
- sanity_guard-0.1.0/tests/test_snapshot.py +162 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.egg-info/
|
|
6
|
+
dist/
|
|
7
|
+
build/
|
|
8
|
+
*.egg
|
|
9
|
+
.eggs/
|
|
10
|
+
|
|
11
|
+
# Virtual environments
|
|
12
|
+
.venv/
|
|
13
|
+
venv/
|
|
14
|
+
ENV/
|
|
15
|
+
|
|
16
|
+
# IDE
|
|
17
|
+
.vscode/
|
|
18
|
+
.idea/
|
|
19
|
+
*.swp
|
|
20
|
+
*.swo
|
|
21
|
+
|
|
22
|
+
# Environment
|
|
23
|
+
.env
|
|
24
|
+
|
|
25
|
+
# OS
|
|
26
|
+
.DS_Store
|
|
27
|
+
Thumbs.db
|
|
28
|
+
|
|
29
|
+
# Testing
|
|
30
|
+
.pytest_cache/
|
|
31
|
+
htmlcov/
|
|
32
|
+
.coverage
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"timestamp": 1787317708.6539652, "files_changed": ["danger.sql"], "overridden_checks": ["dangerous_commands"]}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sanity-guard
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pre-commit risk checker for AI-generated code changes
|
|
5
|
+
Author: Jinang
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: click>=8.1
|
|
10
|
+
Requires-Dist: gitpython>=3.1
|
|
11
|
+
Requires-Dist: python-dotenv>=1.0
|
|
12
|
+
Requires-Dist: groq>=0.9
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest>=7.4; extra == "dev"
|
|
15
|
+
Requires-Dist: pytest-cov>=4.1; extra == "dev"
|
|
16
|
+
|
|
17
|
+
# Sanity - Pre-commit Risk Checker
|
|
18
|
+
|
|
19
|
+
Sanity acts as a pre-commit risk checker for AI-generated code changes. It evaluates code additions for dangerous statements, exposed secrets, and scope drift, and safely blocks risky commits.
|
|
20
|
+
|
|
21
|
+
## Safety Nets & Snapshots
|
|
22
|
+
Whenever a commit is allowed (whether deemed safe, or overridden via `--force`), Sanity automatically creates a lightweight local git tag (e.g. `sanity-snapshot-20260823-153012`) pointing to the commit *before* the new changes. This ensures that even if an AI agent makes a destructive commit, you can easily view your history and roll it back using `sanity rollback <number>`.
|
|
23
|
+
|
|
24
|
+
> **Note**: `sanity-snapshot-*` tags are intended strictly as local safety nets. Git naturally keeps tags local during a standard `git push`, so they won't clutter your remote repository unless you explicitly run `git push --tags`.
|
|
25
|
+
|
|
26
|
+
## What it catches
|
|
27
|
+
|
|
28
|
+
- **Mass deletions** -- flags commits that delete far more code than they add
|
|
29
|
+
- **Leaked secrets** -- detects API keys, AWS credentials, private keys
|
|
30
|
+
- **Dangerous commands** -- catches `rm -rf`, `DROP TABLE`, force-push scripts
|
|
31
|
+
- **Scope creep** -- uses LLM analysis to verify changes match the stated task
|
|
32
|
+
- **Auto-snapshots** -- every commit gets a safety snapshot for easy rollback
|
|
33
|
+
|
|
34
|
+
## Quick start
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
# Install
|
|
38
|
+
pip install -e .
|
|
39
|
+
|
|
40
|
+
# Set up your Groq API key (free tier)
|
|
41
|
+
cp .env.example .env
|
|
42
|
+
# Edit .env with your key from https://console.groq.com
|
|
43
|
+
|
|
44
|
+
# Install the pre-commit hook in your repo
|
|
45
|
+
sanity init
|
|
46
|
+
|
|
47
|
+
# Manually check staged changes
|
|
48
|
+
sanity check
|
|
49
|
+
|
|
50
|
+
# Rollback to a previous safe state
|
|
51
|
+
sanity rollback
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Commands
|
|
55
|
+
|
|
56
|
+
| Command | Description |
|
|
57
|
+
|---------|-------------|
|
|
58
|
+
| `sanity init` | Install pre-commit hook in current repo |
|
|
59
|
+
| `sanity check` | Run safety checks on staged diff |
|
|
60
|
+
| `sanity rollback` | Revert to a previous safe snapshot |
|
|
61
|
+
| `sanity config` | Configure thresholds and toggle checks |
|
|
62
|
+
|
|
63
|
+
## Requirements
|
|
64
|
+
|
|
65
|
+
- Python 3.11+
|
|
66
|
+
- Git
|
|
67
|
+
- Groq API key (free, no credit card needed)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Sanity - Pre-commit Risk Checker
|
|
2
|
+
|
|
3
|
+
Sanity acts as a pre-commit risk checker for AI-generated code changes. It evaluates code additions for dangerous statements, exposed secrets, and scope drift, and safely blocks risky commits.
|
|
4
|
+
|
|
5
|
+
## Safety Nets & Snapshots
|
|
6
|
+
Whenever a commit is allowed (whether deemed safe, or overridden via `--force`), Sanity automatically creates a lightweight local git tag (e.g. `sanity-snapshot-20260823-153012`) pointing to the commit *before* the new changes. This ensures that even if an AI agent makes a destructive commit, you can easily view your history and roll it back using `sanity rollback <number>`.
|
|
7
|
+
|
|
8
|
+
> **Note**: `sanity-snapshot-*` tags are intended strictly as local safety nets. Git naturally keeps tags local during a standard `git push`, so they won't clutter your remote repository unless you explicitly run `git push --tags`.
|
|
9
|
+
|
|
10
|
+
## What it catches
|
|
11
|
+
|
|
12
|
+
- **Mass deletions** -- flags commits that delete far more code than they add
|
|
13
|
+
- **Leaked secrets** -- detects API keys, AWS credentials, private keys
|
|
14
|
+
- **Dangerous commands** -- catches `rm -rf`, `DROP TABLE`, force-push scripts
|
|
15
|
+
- **Scope creep** -- uses LLM analysis to verify changes match the stated task
|
|
16
|
+
- **Auto-snapshots** -- every commit gets a safety snapshot for easy rollback
|
|
17
|
+
|
|
18
|
+
## Quick start
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
# Install
|
|
22
|
+
pip install -e .
|
|
23
|
+
|
|
24
|
+
# Set up your Groq API key (free tier)
|
|
25
|
+
cp .env.example .env
|
|
26
|
+
# Edit .env with your key from https://console.groq.com
|
|
27
|
+
|
|
28
|
+
# Install the pre-commit hook in your repo
|
|
29
|
+
sanity init
|
|
30
|
+
|
|
31
|
+
# Manually check staged changes
|
|
32
|
+
sanity check
|
|
33
|
+
|
|
34
|
+
# Rollback to a previous safe state
|
|
35
|
+
sanity rollback
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Commands
|
|
39
|
+
|
|
40
|
+
| Command | Description |
|
|
41
|
+
|---------|-------------|
|
|
42
|
+
| `sanity init` | Install pre-commit hook in current repo |
|
|
43
|
+
| `sanity check` | Run safety checks on staged diff |
|
|
44
|
+
| `sanity rollback` | Revert to a previous safe snapshot |
|
|
45
|
+
| `sanity config` | Configure thresholds and toggle checks |
|
|
46
|
+
|
|
47
|
+
## Requirements
|
|
48
|
+
|
|
49
|
+
- Python 3.11+
|
|
50
|
+
- Git
|
|
51
|
+
- Groq API key (free, no credit card needed)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "setuptools-scm>=8.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "sanity-guard"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Pre-commit risk checker for AI-generated code changes"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
authors = [{name = "Jinang"}]
|
|
13
|
+
|
|
14
|
+
dependencies = [
|
|
15
|
+
"click>=8.1",
|
|
16
|
+
"gitpython>=3.1",
|
|
17
|
+
"python-dotenv>=1.0",
|
|
18
|
+
"groq>=0.9",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.optional-dependencies]
|
|
22
|
+
dev = [
|
|
23
|
+
"pytest>=7.4",
|
|
24
|
+
"pytest-cov>=4.1",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.scripts]
|
|
28
|
+
sanity = "sanity.cli:main"
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.packages.find]
|
|
31
|
+
where = ["src"]
|
|
32
|
+
|
|
33
|
+
[tool.pytest.ini_options]
|
|
34
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
warning: in the working copy of 'src/sanity/checks.py', LF will be replaced by CRLF the next time Git touches it
|
|
2
|
+
[1mdiff --git a/src/sanity/checks.py b/src/sanity/checks.py[m
|
|
3
|
+
[1mindex b4193af..1421f14 100644[m
|
|
4
|
+
[1m--- a/src/sanity/checks.py[m
|
|
5
|
+
[1m+++ b/src/sanity/checks.py[m
|
|
6
|
+
[36m@@ -1,4 +1,6 @@[m
|
|
7
|
+
[32m+[m[32mimport math[m
|
|
8
|
+
import re[m
|
|
9
|
+
[32m+[m[32mfrom collections import Counter[m
|
|
10
|
+
[m
|
|
11
|
+
from sanity.config import IGNORE_MARKER, SanityConfig[m
|
|
12
|
+
from sanity.models import CheckResult, DiffResult[m
|
|
13
|
+
[36m@@ -72,6 +74,7 @@[m [mdef check_deletion_ratio(diff: DiffResult, threshold: float = 3.0) -> CheckResul[m
|
|
14
|
+
SECRET_PATTERNS: list[tuple[str, re.Pattern[str]]] = [[m
|
|
15
|
+
("AWS access key", re.compile(r"AKIA[0-9A-Z]{16}")),[m
|
|
16
|
+
("AWS secret key", re.compile(r"(?i)(aws_secret_access_key|aws_secret)\s*[=:]\s*\S{20,}")),[m
|
|
17
|
+
[32m+[m[32m ("Stripe API key", re.compile(r"(?i)(sk_live_|sk_test_|pk_live_|pk_test_)[A-Za-z0-9]{20,}")),[m
|
|
18
|
+
("Private key header", re.compile(r"-----BEGIN\s+(RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----")),[m
|
|
19
|
+
("Generic API key assignment", re.compile(r"(?i)(api[_-]?key|apikey|api[_-]?secret)\s*[=:]\s*['\"]?[A-Za-z0-9_\-]{16,}['\"]?")),[m
|
|
20
|
+
("Generic secret assignment", re.compile(r"(?i)(secret[_-]?key|secret)\s*[=:]\s*['\"]?[A-Za-z0-9_\-]{16,}['\"]?")),[m
|
|
21
|
+
[36m@@ -127,13 +130,45 @@[m [mdef check_dangerous_commands([m
|
|
22
|
+
diff: DiffResult,[m
|
|
23
|
+
config: SanityConfig | None = None,[m
|
|
24
|
+
) -> CheckResult:[m
|
|
25
|
+
[31m- added_lines = _extract_added_lines(diff, config)[m
|
|
26
|
+
[32m+[m[32m cfg = config or SanityConfig()[m
|
|
27
|
+
findings: list[str] = [][m
|
|
28
|
+
[m
|
|
29
|
+
[31m- for file_path, line in added_lines:[m
|
|
30
|
+
[31m- for pattern_name, pattern in DANGEROUS_PATTERNS:[m
|
|
31
|
+
[31m- if pattern.search(line):[m
|
|
32
|
+
[31m- findings.append(f"{pattern_name} in {file_path}")[m
|
|
33
|
+
[32m+[m[32m standard_patterns = [p for p in DANGEROUS_PATTERNS if p[0] != "DELETE without WHERE"][m
|
|
34
|
+
[32m+[m
|
|
35
|
+
[32m+[m[32m for f in diff.files:[m
|
|
36
|
+
[32m+[m[32m if cfg.is_excluded(f.path):[m
|
|
37
|
+
[32m+[m[32m continue[m
|
|
38
|
+
[32m+[m
|
|
39
|
+
[32m+[m[32m lines = f.diff_text.splitlines()[m
|
|
40
|
+
[32m+[m[32m for i, line in enumerate(lines):[m
|
|
41
|
+
[32m+[m[32m if line.startswith("+") and not line.startswith("+++"):[m
|
|
42
|
+
[32m+[m[32m content = line[1:][m
|
|
43
|
+
[32m+[m[32m if IGNORE_MARKER in content:[m
|
|
44
|
+
[32m+[m[32m continue[m
|
|
45
|
+
[32m+[m
|
|
46
|
+
[32m+[m[32m for pattern_name, pattern in standard_patterns:[m
|
|
47
|
+
[32m+[m[32m if pattern.search(content):[m
|
|
48
|
+
[32m+[m[32m findings.append(f"{pattern_name} in {f.path}")[m
|
|
49
|
+
[32m+[m
|
|
50
|
+
[32m+[m[32m match = re.search(r"(?i)\bDELETE\s+FROM\s+\w+", content)[m
|
|
51
|
+
[32m+[m[32m if match:[m
|
|
52
|
+
[32m+[m[32m has_where = False[m
|
|
53
|
+
[32m+[m[32m if re.search(r"(?i)\bWHERE\b", content[match.end():]):[m
|
|
54
|
+
[32m+[m[32m has_where = True[m
|
|
55
|
+
[32m+[m[32m else:[m
|
|
56
|
+
[32m+[m[32m for j in range(i + 1, min(i + 10, len(lines))):[m
|
|
57
|
+
[32m+[m[32m next_line = lines[j][m
|
|
58
|
+
[32m+[m[32m if next_line.startswith(("-", "---")):[m
|
|
59
|
+
[32m+[m[32m continue[m
|
|
60
|
+
[32m+[m[32m if next_line.startswith(("+", " ")):[m
|
|
61
|
+
[32m+[m[32m next_content = next_line[1:][m
|
|
62
|
+
[32m+[m[32m if re.search(r"(?i)\bWHERE\b", next_content):[m
|
|
63
|
+
[32m+[m[32m has_where = True[m
|
|
64
|
+
[32m+[m[32m break[m
|
|
65
|
+
[32m+[m[32m if ";" in next_content:[m
|
|
66
|
+
[32m+[m[32m break[m
|
|
67
|
+
[32m+[m[32m if not has_where:[m
|
|
68
|
+
[32m+[m[32m findings.append(f"DELETE without WHERE in {f.path}")[m
|
|
69
|
+
[m
|
|
70
|
+
if findings:[m
|
|
71
|
+
detail = "; ".join(findings[:5])[m
|
|
72
|
+
[36m@@ -153,6 +188,74 @@[m [mdef check_dangerous_commands([m
|
|
73
|
+
)[m
|
|
74
|
+
[m
|
|
75
|
+
[m
|
|
76
|
+
[32m+[m[32mdef shannon_entropy(s: str) -> float:[m
|
|
77
|
+
[32m+[m[32m if not s:[m
|
|
78
|
+
[32m+[m[32m return 0.0[m
|
|
79
|
+
[32m+[m[32m freqs = Counter(s)[m
|
|
80
|
+
[32m+[m[32m entropy = 0.0[m
|
|
81
|
+
[32m+[m[32m for count in freqs.values():[m
|
|
82
|
+
[32m+[m[32m p = count / len(s)[m
|
|
83
|
+
[32m+[m[32m entropy -= p * math.log2(p)[m
|
|
84
|
+
[32m+[m[32m return entropy[m
|
|
85
|
+
[32m+[m
|
|
86
|
+
[32m+[m
|
|
87
|
+
[32m+[m[32mdef looks_like_high_entropy_secret(s: str, threshold: float = 3.8) -> bool:[m
|
|
88
|
+
[32m+[m[32m if len(s) < 20:[m
|
|
89
|
+
[32m+[m[32m return False[m
|
|
90
|
+
[32m+[m[32m if " " in s:[m
|
|
91
|
+
[32m+[m[32m return False[m
|
|
92
|
+
[32m+[m
|
|
93
|
+
[32m+[m[32m lower = s.lower()[m
|
|
94
|
+
[32m+[m[32m placeholders = ["your", "here", "xxx", "changeme", "example", "placeholder", "insert", "dummy", "key", "token"][m
|
|
95
|
+
[32m+[m[32m if any(p in lower for p in placeholders):[m
|
|
96
|
+
[32m+[m[32m return False[m
|
|
97
|
+
[32m+[m
|
|
98
|
+
[32m+[m[32m return shannon_entropy(s) > threshold[m
|
|
99
|
+
[32m+[m
|
|
100
|
+
[32m+[m
|
|
101
|
+
[32m+[m[32mdef check_high_entropy_secrets([m
|
|
102
|
+
[32m+[m[32m diff: DiffResult,[m
|
|
103
|
+
[32m+[m[32m config: SanityConfig | None = None,[m
|
|
104
|
+
[32m+[m[32m) -> CheckResult:[m
|
|
105
|
+
[32m+[m[32m cfg = config or SanityConfig()[m
|
|
106
|
+
[32m+[m[32m added_lines = _extract_added_lines(diff, cfg)[m
|
|
107
|
+
[32m+[m[32m findings: list[str] = [][m
|
|
108
|
+
[32m+[m
|
|
109
|
+
[32m+[m[32m threshold = cfg.entropy_threshold[m
|
|
110
|
+
[32m+[m
|
|
111
|
+
[32m+[m[32m pattern = re.compile([m
|
|
112
|
+
[32m+[m[32m r"(?i)(?:['\"]?)\b([a-z0-9_]*(?:key|token|secret|password|credential|api|auth)[a-z0-9_]*)\b(?:['\"]?)\s*[=:]\s*(['\"])([^'\"]{20,})\2"[m
|
|
113
|
+
[32m+[m[32m )[m
|
|
114
|
+
[32m+[m
|
|
115
|
+
[32m+[m[32m for file_path, line in added_lines:[m
|
|
116
|
+
[32m+[m[32m if any(p.search(line) for _, p in SECRET_PATTERNS):[m
|
|
117
|
+
[32m+[m[32m continue[m
|
|
118
|
+
[32m+[m
|
|
119
|
+
[32m+[m[32m for match in pattern.finditer(line):[m
|
|
120
|
+
[32m+[m[32m val = match.group(3)[m
|
|
121
|
+
[32m+[m[32m if looks_like_high_entropy_secret(val, threshold):[m
|
|
122
|
+
[32m+[m[32m var_name = match.group(1)[m
|
|
123
|
+
[32m+[m[32m findings.append(f"High entropy secret ({var_name}) in {file_path}")[m
|
|
124
|
+
[32m+[m[32m break[m
|
|
125
|
+
[32m+[m
|
|
126
|
+
[32m+[m[32m if findings:[m
|
|
127
|
+
[32m+[m[32m detail = "; ".join(findings[:5])[m
|
|
128
|
+
[32m+[m[32m extra = f" (+{len(findings) - 5} more)" if len(findings) > 5 else ""[m
|
|
129
|
+
[32m+[m[32m return CheckResult([m
|
|
130
|
+
[32m+[m[32m check_name="high_entropy_secrets",[m
|
|
131
|
+
[32m+[m[32m passed=False,[m
|
|
132
|
+
[32m+[m[32m severity="HIGH",[m
|
|
133
|
+
[32m+[m[32m reason=f"Potential high-entropy secrets detected: {detail}{extra}",[m
|
|
134
|
+
[32m+[m[32m )[m
|
|
135
|
+
[32m+[m
|
|
136
|
+
[32m+[m[32m return CheckResult([m
|
|
137
|
+
[32m+[m[32m check_name="high_entropy_secrets",[m
|
|
138
|
+
[32m+[m[32m passed=True,[m
|
|
139
|
+
[32m+[m[32m severity="LOW",[m
|
|
140
|
+
[32m+[m[32m reason="No high-entropy secrets detected.",[m
|
|
141
|
+
[32m+[m[32m )[m
|
|
142
|
+
[32m+[m
|
|
143
|
+
[32m+[m
|
|
144
|
+
def run_all_checks([m
|
|
145
|
+
diff: DiffResult,[m
|
|
146
|
+
config: SanityConfig | None = None,[m
|
|
147
|
+
[36m@@ -166,6 +269,9 @@[m [mdef run_all_checks([m
|
|
148
|
+
if "secret_detection" not in cfg.disabled_checks:[m
|
|
149
|
+
results.append(check_secrets(diff, config=cfg))[m
|
|
150
|
+
[m
|
|
151
|
+
[32m+[m[32m if "high_entropy_secrets" not in cfg.disabled_checks:[m
|
|
152
|
+
[32m+[m[32m results.append(check_high_entropy_secrets(diff, config=cfg))[m
|
|
153
|
+
[32m+[m
|
|
154
|
+
if "dangerous_commands" not in cfg.disabled_checks:[m
|
|
155
|
+
results.append(check_dangerous_commands(diff, config=cfg))[m
|
|
156
|
+
[m
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import re
|
|
3
|
+
from collections import Counter
|
|
4
|
+
|
|
5
|
+
from sanity.config import IGNORE_MARKER, SanityConfig
|
|
6
|
+
from sanity.models import CheckResult, DiffResult
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _extract_added_lines(
|
|
10
|
+
diff: DiffResult,
|
|
11
|
+
config: SanityConfig | None = None,
|
|
12
|
+
) -> list[tuple[str, str]]:
|
|
13
|
+
"""Extract added lines from diff, returning (file_path, line_content) pairs.
|
|
14
|
+
|
|
15
|
+
Skips files matching exclusion patterns and lines with the inline ignore marker.
|
|
16
|
+
"""
|
|
17
|
+
results = []
|
|
18
|
+
for f in diff.files:
|
|
19
|
+
if config and config.is_excluded(f.path):
|
|
20
|
+
continue
|
|
21
|
+
for line in f.diff_text.splitlines():
|
|
22
|
+
if line.startswith("+") and not line.startswith("+++"):
|
|
23
|
+
content = line[1:]
|
|
24
|
+
if IGNORE_MARKER in content:
|
|
25
|
+
continue
|
|
26
|
+
results.append((f.path, content))
|
|
27
|
+
return results
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def check_deletion_ratio(diff: DiffResult, threshold: float = 3.0) -> CheckResult:
|
|
31
|
+
if diff.total_added == 0 and diff.total_deleted == 0:
|
|
32
|
+
return CheckResult(
|
|
33
|
+
check_name="deletion_ratio",
|
|
34
|
+
passed=True,
|
|
35
|
+
severity="LOW",
|
|
36
|
+
reason="No line changes to evaluate.",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
if diff.total_added == 0:
|
|
40
|
+
if diff.total_deleted > 0:
|
|
41
|
+
return CheckResult(
|
|
42
|
+
check_name="deletion_ratio",
|
|
43
|
+
passed=False,
|
|
44
|
+
severity="HIGH",
|
|
45
|
+
reason=f"Pure deletion: {diff.total_deleted} lines deleted with 0 lines added.",
|
|
46
|
+
)
|
|
47
|
+
return CheckResult(
|
|
48
|
+
check_name="deletion_ratio",
|
|
49
|
+
passed=True,
|
|
50
|
+
severity="LOW",
|
|
51
|
+
reason="No meaningful changes.",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
ratio = diff.total_deleted / diff.total_added
|
|
55
|
+
if ratio > threshold:
|
|
56
|
+
return CheckResult(
|
|
57
|
+
check_name="deletion_ratio",
|
|
58
|
+
passed=False,
|
|
59
|
+
severity="HIGH",
|
|
60
|
+
reason=(
|
|
61
|
+
f"Deletion ratio {ratio:.1f}x exceeds threshold {threshold}x "
|
|
62
|
+
f"({diff.total_deleted} deleted / {diff.total_added} added)."
|
|
63
|
+
),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
return CheckResult(
|
|
67
|
+
check_name="deletion_ratio",
|
|
68
|
+
passed=True,
|
|
69
|
+
severity="LOW",
|
|
70
|
+
reason=f"Deletion ratio {ratio:.1f}x is within threshold {threshold}x.",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
SECRET_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
|
|
75
|
+
("AWS access key", re.compile(r"AKIA[0-9A-Z]{16}")),
|
|
76
|
+
("AWS secret key", re.compile(r"(?i)(aws_secret_access_key|aws_secret)\s*[=:]\s*\S{20,}")),
|
|
77
|
+
("Stripe API key", re.compile(r"(?i)(sk_live_|sk_test_|pk_live_|pk_test_)[A-Za-z0-9]{20,}")),
|
|
78
|
+
("Private key header", re.compile(r"-----BEGIN\s+(RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----")),
|
|
79
|
+
("Generic API key assignment", re.compile(r"(?i)(api[_-]?key|apikey|api[_-]?secret)\s*[=:]\s*['\"]?[A-Za-z0-9_\-]{16,}['\"]?")),
|
|
80
|
+
("Generic secret assignment", re.compile(r"(?i)(secret[_-]?key|secret)\s*[=:]\s*['\"]?[A-Za-z0-9_\-]{16,}['\"]?")),
|
|
81
|
+
("Generic token assignment", re.compile(r"(?i)(auth[_-]?token|access[_-]?token|token)\s*[=:]\s*['\"]?[A-Za-z0-9_\-]{16,}['\"]?")),
|
|
82
|
+
("Password assignment", re.compile(r"(?i)(password|passwd|pwd)\s*[=:]\s*['\"]?[^\s'\"]{8,}['\"]?")),
|
|
83
|
+
("Connection string", re.compile(r"(?i)(mysql|postgres|mongodb|redis)://\S+:\S+@")),
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def check_secrets(
|
|
88
|
+
diff: DiffResult,
|
|
89
|
+
config: SanityConfig | None = None,
|
|
90
|
+
) -> CheckResult:
|
|
91
|
+
added_lines = _extract_added_lines(diff, config)
|
|
92
|
+
findings: list[str] = []
|
|
93
|
+
|
|
94
|
+
for file_path, line in added_lines:
|
|
95
|
+
for pattern_name, pattern in SECRET_PATTERNS:
|
|
96
|
+
if pattern.search(line):
|
|
97
|
+
findings.append(f"{pattern_name} in {file_path}")
|
|
98
|
+
break # one finding per line is enough
|
|
99
|
+
|
|
100
|
+
if findings:
|
|
101
|
+
detail = "; ".join(findings[:5])
|
|
102
|
+
extra = f" (+{len(findings) - 5} more)" if len(findings) > 5 else ""
|
|
103
|
+
return CheckResult(
|
|
104
|
+
check_name="secret_detection",
|
|
105
|
+
passed=False,
|
|
106
|
+
severity="HIGH",
|
|
107
|
+
reason=f"Potential secrets detected: {detail}{extra}",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
return CheckResult(
|
|
111
|
+
check_name="secret_detection",
|
|
112
|
+
passed=True,
|
|
113
|
+
severity="LOW",
|
|
114
|
+
reason="No secrets detected in added lines.",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
DANGEROUS_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
|
|
119
|
+
("rm -rf", re.compile(r"\brm\s+(-[a-zA-Z]*r[a-zA-Z]*f|--recursive\s+--force|-[a-zA-Z]*f[a-zA-Z]*r)\b")), # sanity: ignore
|
|
120
|
+
("DROP TABLE", re.compile(r"(?i)\bDROP\s+TABLE\b")), # sanity: ignore
|
|
121
|
+
("DELETE without WHERE", re.compile(r"(?i)\bDELETE\s+FROM\s+\w+\s*;")),
|
|
122
|
+
("TRUNCATE", re.compile(r"(?i)\bTRUNCATE\s+(TABLE\s+)?\w+")),
|
|
123
|
+
("git force push", re.compile(r"git\s+push\s+.*(-f|--force)\b")),
|
|
124
|
+
("os.remove", re.compile(r"\bos\s*\.\s*remove\s*\(")),
|
|
125
|
+
("shutil.rmtree", re.compile(r"\bshutil\s*\.\s*rmtree\s*\(")),
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def check_dangerous_commands(
|
|
130
|
+
diff: DiffResult,
|
|
131
|
+
config: SanityConfig | None = None,
|
|
132
|
+
) -> CheckResult:
|
|
133
|
+
cfg = config or SanityConfig()
|
|
134
|
+
findings: list[str] = []
|
|
135
|
+
|
|
136
|
+
standard_patterns = [p for p in DANGEROUS_PATTERNS if p[0] != "DELETE without WHERE"]
|
|
137
|
+
|
|
138
|
+
for f in diff.files:
|
|
139
|
+
if cfg.is_excluded(f.path):
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
lines = f.diff_text.splitlines()
|
|
143
|
+
for i, line in enumerate(lines):
|
|
144
|
+
if line.startswith("+") and not line.startswith("+++"):
|
|
145
|
+
content = line[1:]
|
|
146
|
+
if IGNORE_MARKER in content:
|
|
147
|
+
continue
|
|
148
|
+
|
|
149
|
+
for pattern_name, pattern in standard_patterns:
|
|
150
|
+
if pattern.search(content):
|
|
151
|
+
findings.append(f"{pattern_name} in {f.path}")
|
|
152
|
+
|
|
153
|
+
match = re.search(r"(?i)\bDELETE\s+FROM\s+\w+", content)
|
|
154
|
+
if match:
|
|
155
|
+
has_where = False
|
|
156
|
+
if re.search(r"(?i)\bWHERE\b", content[match.end():]):
|
|
157
|
+
has_where = True
|
|
158
|
+
else:
|
|
159
|
+
for j in range(i + 1, min(i + 10, len(lines))):
|
|
160
|
+
next_line = lines[j]
|
|
161
|
+
if next_line.startswith(("-", "---")):
|
|
162
|
+
continue
|
|
163
|
+
if next_line.startswith(("+", " ")):
|
|
164
|
+
next_content = next_line[1:]
|
|
165
|
+
if re.search(r"(?i)\bWHERE\b", next_content):
|
|
166
|
+
has_where = True
|
|
167
|
+
break
|
|
168
|
+
if ";" in next_content:
|
|
169
|
+
break
|
|
170
|
+
if not has_where:
|
|
171
|
+
findings.append(f"DELETE without WHERE in {f.path}")
|
|
172
|
+
|
|
173
|
+
if findings:
|
|
174
|
+
detail = "; ".join(findings[:5])
|
|
175
|
+
extra = f" (+{len(findings) - 5} more)" if len(findings) > 5 else ""
|
|
176
|
+
return CheckResult(
|
|
177
|
+
check_name="dangerous_commands",
|
|
178
|
+
passed=False,
|
|
179
|
+
severity="HIGH",
|
|
180
|
+
reason=f"Dangerous commands detected: {detail}{extra}",
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
return CheckResult(
|
|
184
|
+
check_name="dangerous_commands",
|
|
185
|
+
passed=True,
|
|
186
|
+
severity="LOW",
|
|
187
|
+
reason="No dangerous commands detected in added lines.",
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def shannon_entropy(s: str) -> float:
|
|
192
|
+
if not s:
|
|
193
|
+
return 0.0
|
|
194
|
+
freqs = Counter(s)
|
|
195
|
+
entropy = 0.0
|
|
196
|
+
for count in freqs.values():
|
|
197
|
+
p = count / len(s)
|
|
198
|
+
entropy -= p * math.log2(p)
|
|
199
|
+
return entropy
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def looks_like_high_entropy_secret(s: str, threshold: float = 3.8) -> bool:
|
|
203
|
+
if len(s) < 20:
|
|
204
|
+
return False
|
|
205
|
+
if " " in s:
|
|
206
|
+
return False
|
|
207
|
+
|
|
208
|
+
lower = s.lower()
|
|
209
|
+
placeholders = ["your", "here", "xxx", "changeme", "example", "placeholder", "insert", "dummy", "key", "token"]
|
|
210
|
+
if any(p in lower for p in placeholders):
|
|
211
|
+
return False
|
|
212
|
+
|
|
213
|
+
return shannon_entropy(s) > threshold
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def check_high_entropy_secrets(
|
|
217
|
+
diff: DiffResult,
|
|
218
|
+
config: SanityConfig | None = None,
|
|
219
|
+
) -> CheckResult:
|
|
220
|
+
cfg = config or SanityConfig()
|
|
221
|
+
added_lines = _extract_added_lines(diff, cfg)
|
|
222
|
+
findings: list[str] = []
|
|
223
|
+
|
|
224
|
+
threshold = cfg.entropy_threshold
|
|
225
|
+
|
|
226
|
+
pattern = re.compile(
|
|
227
|
+
r"(?i)(?:['\"]?)\b([a-z0-9_]*(?:key|token|secret|password|credential|api|auth)[a-z0-9_]*)\b(?:['\"]?)\s*[=:]\s*(['\"])([^'\"]{20,})\2"
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
for file_path, line in added_lines:
|
|
231
|
+
if any(p.search(line) for _, p in SECRET_PATTERNS):
|
|
232
|
+
continue
|
|
233
|
+
|
|
234
|
+
for match in pattern.finditer(line):
|
|
235
|
+
val = match.group(3)
|
|
236
|
+
if looks_like_high_entropy_secret(val, threshold):
|
|
237
|
+
var_name = match.group(1)
|
|
238
|
+
findings.append(f"High entropy secret ({var_name}) in {file_path}")
|
|
239
|
+
break
|
|
240
|
+
|
|
241
|
+
if findings:
|
|
242
|
+
detail = "; ".join(findings[:5])
|
|
243
|
+
extra = f" (+{len(findings) - 5} more)" if len(findings) > 5 else ""
|
|
244
|
+
return CheckResult(
|
|
245
|
+
check_name="high_entropy_secrets",
|
|
246
|
+
passed=False,
|
|
247
|
+
severity="HIGH",
|
|
248
|
+
reason=f"Potential high-entropy secrets detected: {detail}{extra}",
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
return CheckResult(
|
|
252
|
+
check_name="high_entropy_secrets",
|
|
253
|
+
passed=True,
|
|
254
|
+
severity="LOW",
|
|
255
|
+
reason="No high-entropy secrets detected.",
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def run_all_checks(
|
|
260
|
+
diff: DiffResult,
|
|
261
|
+
config: SanityConfig | None = None,
|
|
262
|
+
) -> list[CheckResult]:
|
|
263
|
+
cfg = config or SanityConfig()
|
|
264
|
+
results: list[CheckResult] = []
|
|
265
|
+
|
|
266
|
+
if "deletion_ratio" not in cfg.disabled_checks:
|
|
267
|
+
results.append(check_deletion_ratio(diff, threshold=cfg.deletion_ratio_threshold))
|
|
268
|
+
|
|
269
|
+
if "secret_detection" not in cfg.disabled_checks:
|
|
270
|
+
results.append(check_secrets(diff, config=cfg))
|
|
271
|
+
|
|
272
|
+
if "high_entropy_secrets" not in cfg.disabled_checks:
|
|
273
|
+
results.append(check_high_entropy_secrets(diff, config=cfg))
|
|
274
|
+
|
|
275
|
+
if "dangerous_commands" not in cfg.disabled_checks:
|
|
276
|
+
results.append(check_dangerous_commands(diff, config=cfg))
|
|
277
|
+
|
|
278
|
+
return results
|