├── LICENSE ├── Makefile ├── README.md ├── git-deny-patterns.json ├── install-for-project.sh └── safe-commit-hook.py /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Jen Andre 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | install: 2 | mkdir -p ~/.safe-commit-hook 3 | cp -r * ~/.safe-commit-hook 4 | git config --global alias.init-safe-commit '!~/.safe-commit-hook/install-for-project.sh' 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Safe Commit Hook 2 | 3 | This is a git [pre-commit hook](https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks) that is inspired by the [Gitrob project](https://github.com/michenriksen/gitrob). 4 | 5 | It adds an automatic check to prevent developers from checking in suspicious files (as defined by Gitrob's [signatures.json](https://github.com/michenriksen/gitrob/blob/master/signatures.json)) 6 | 7 | # Installation 8 | 9 | ```bash 10 | git clone https://github.com/jandre/safe-commit-hook.git 11 | cd safe-commit-hook 12 | make install 13 | ``` 14 | 15 | This will do the following: 16 | 17 | * Create a `~/.safe-commit-hook` directory and copy the files from this repo there. 18 | * Create a git alias so you can do `git init-safe-commit` in a project directory, which will create `.git/hooks/pre-commit` (WARNING: will blow away 19 | any other pre-commit hooks). 20 | 21 | Now you will get an error if you try to do anything fishy! 22 | 23 | [![asciicast](https://asciinema.org/a/0uqf6dcaautz599xru1kefa6b.png)](https://asciinema.org/a/0uqf6dcaautz599xru1kefa6b) 24 | 25 | # Editing the rules 26 | 27 | They are currently in JSON format at `~/.safe-commit-hook/git-deny-patterns.json`. 28 | 29 | Just remove the rules you wish to ignore. In the future, would nice to have a `.git-safe-commit-ignore` file for a repo. 30 | 31 | # TODO 32 | 33 | * [ ] Allow project specific exceptions for safe commit checks. 34 | * [ ] Don't blow away any other git pre-commit hooks in `git init-safe-commit`. 35 | * [ ] Extend the JSON spec to allow for searching for body of modified files. 36 | -------------------------------------------------------------------------------- /git-deny-patterns.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "part": "filename", 4 | "type": "regex", 5 | "pattern": "\\A.*_rsa\\Z", 6 | "caption": "Private SSH key", 7 | "description": null 8 | }, 9 | { 10 | "part": "filename", 11 | "type": "regex", 12 | "pattern": "\\A.*_dsa\\Z", 13 | "caption": "Private SSH key", 14 | "description": null 15 | }, 16 | { 17 | "part": "filename", 18 | "type": "regex", 19 | "pattern": "\\A.*_ed25519\\Z", 20 | "caption": "Private SSH key", 21 | "description": null 22 | }, 23 | { 24 | "part": "filename", 25 | "type": "regex", 26 | "pattern": "\\A.*_ecdsa\\Z", 27 | "caption": "Private SSH key", 28 | "description": null 29 | }, 30 | { 31 | "part": "extension", 32 | "type": "match", 33 | "pattern": "pem", 34 | "caption": "Potential cryptographic private key", 35 | "description": null 36 | }, 37 | { 38 | "part": "extension", 39 | "type": "match", 40 | "pattern": "ppk", 41 | "caption": "Potential cryptographic private key", 42 | "description": null 43 | }, 44 | { 45 | "part": "extension", 46 | "type": "regex", 47 | "pattern": "\\Akey(pair)?\\Z", 48 | "caption": "Potential cryptographic private key", 49 | "description": null 50 | }, 51 | { 52 | "part": "extension", 53 | "type": "match", 54 | "pattern": "pkcs12", 55 | "caption": "Potential cryptographic key bundle", 56 | "description": null 57 | }, 58 | { 59 | "part": "extension", 60 | "type": "match", 61 | "pattern": "pfx", 62 | "caption": "Potential cryptographic key bundle", 63 | "description": null 64 | }, 65 | { 66 | "part": "extension", 67 | "type": "match", 68 | "pattern": "p12", 69 | "caption": "Potential cryptographic key bundle", 70 | "description": null 71 | }, 72 | { 73 | "part": "extension", 74 | "type": "match", 75 | "pattern": "asc", 76 | "caption": "Potential cryptographic key bundle", 77 | "description": null 78 | }, 79 | { 80 | "part": "filename", 81 | "type": "match", 82 | "pattern": "otr.private_key", 83 | "caption": "Pidgin OTR private key", 84 | "description": null 85 | }, 86 | { 87 | "part": "filename", 88 | "type": "regex", 89 | "pattern": "\\A\\.?(bash_|zsh_|z)?history\\Z", 90 | "caption": "Shell command history file", 91 | "description": null 92 | }, 93 | { 94 | "part": "filename", 95 | "type": "regex", 96 | "pattern": "\\A\\.?mysql_history\\Z", 97 | "caption": "MySQL client command history file", 98 | "description": null 99 | }, 100 | { 101 | "part": "filename", 102 | "type": "regex", 103 | "pattern": "\\A\\.?psql_history\\Z", 104 | "caption": "PostgreSQL client command history file", 105 | "description": null 106 | }, 107 | { 108 | "part": "filename", 109 | "type": "regex", 110 | "pattern": "\\A\\.?irb_history\\Z", 111 | "caption": "Ruby IRB console history file", 112 | "description": null 113 | }, 114 | { 115 | "part": "path", 116 | "type": "regex", 117 | "pattern": "\\.?purple\\/accounts\\.xml\\Z", 118 | "caption": "Pidgin chat client account configuration file", 119 | "description": null 120 | }, 121 | { 122 | "part": "path", 123 | "type": "regex", 124 | "pattern": "\\.?xchat2?\\/servlist_?\\.conf\\Z", 125 | "caption": "Hexchat/XChat IRC client server list configuration file", 126 | "description": null 127 | }, 128 | { 129 | "part": "path", 130 | "type": "regex", 131 | "pattern": "\\.?irssi\\/config\\Z", 132 | "caption": "Irssi IRC client configuration file", 133 | "description": null 134 | }, 135 | { 136 | "part": "path", 137 | "type": "regex", 138 | "pattern": "\\.?recon-ng\\/keys\\.db\\Z", 139 | "caption": "Recon-ng web reconnaissance framework API key database", 140 | "description": null 141 | }, 142 | { 143 | "part": "filename", 144 | "type": "regex", 145 | "pattern": "\\A\\.?dbeaver-data-sources.xml\\Z", 146 | "caption": "DBeaver SQL database manager configuration file", 147 | "description": null 148 | }, 149 | { 150 | "part": "filename", 151 | "type": "regex", 152 | "pattern": "\\A\\.?muttrc\\Z", 153 | "caption": "Mutt e-mail client configuration file", 154 | "description": null 155 | }, 156 | { 157 | "part": "filename", 158 | "type": "regex", 159 | "pattern": "\\A\\.?s3cfg\\Z", 160 | "caption": "S3cmd configuration file", 161 | "description": null 162 | }, 163 | { 164 | "part": "filename", 165 | "type": "regex", 166 | "pattern": "\\A\\.?trc\\Z", 167 | "caption": "T command-line Twitter client configuration file", 168 | "description": null 169 | }, 170 | { 171 | "part": "extension", 172 | "type": "match", 173 | "pattern": "ovpn", 174 | "caption": "OpenVPN client configuration file", 175 | "description": null 176 | }, 177 | { 178 | "part": "filename", 179 | "type": "regex", 180 | "pattern": "\\A\\.?gitrobrc\\Z", 181 | "caption": "Well, this is awkward... Gitrob configuration file", 182 | "description": null 183 | }, 184 | { 185 | "part": "filename", 186 | "type": "regex", 187 | "pattern": "\\A\\.?(bash|zsh)rc\\Z", 188 | "caption": "Shell configuration file", 189 | "description": "Shell configuration files might contain information such as server hostnames, passwords and API keys." 190 | }, 191 | { 192 | "part": "filename", 193 | "type": "regex", 194 | "pattern": "\\A\\.?(bash_|zsh_)?profile\\Z", 195 | "caption": "Shell profile configuration file", 196 | "description": "Shell configuration files might contain information such as server hostnames, passwords and API keys." 197 | }, 198 | { 199 | "part": "filename", 200 | "type": "regex", 201 | "pattern": "\\A\\.?(bash_|zsh_)?aliases\\Z", 202 | "caption": "Shell command alias configuration file", 203 | "description": "Shell configuration files might contain information such as server hostnames, passwords and API keys." 204 | }, 205 | { 206 | "part": "filename", 207 | "type": "match", 208 | "pattern": "secret_token.rb", 209 | "caption": "Ruby On Rails secret token configuration file", 210 | "description": "If the Rails secret token is known, it can allow for remote code execution. (http://www.exploit-db.com/exploits/27527/)" 211 | }, 212 | { 213 | "part": "filename", 214 | "type": "match", 215 | "pattern": "omniauth.rb", 216 | "caption": "OmniAuth configuration file", 217 | "description": "The OmniAuth configuration file might contain client application secrets." 218 | }, 219 | { 220 | "part": "filename", 221 | "type": "match", 222 | "pattern": "carrierwave.rb", 223 | "caption": "Carrierwave configuration file", 224 | "description": "Can contain credentials for online storage systems such as Amazon S3 and Google Storage." 225 | }, 226 | { 227 | "part": "filename", 228 | "type": "match", 229 | "pattern": "schema.rb", 230 | "caption": "Ruby On Rails database schema file", 231 | "description": "Contains information on the database schema of a Ruby On Rails application." 232 | }, 233 | { 234 | "part": "filename", 235 | "type": "match", 236 | "pattern": "database.yml", 237 | "caption": "Potential Ruby On Rails database configuration file", 238 | "description": "Might contain database credentials." 239 | }, 240 | { 241 | "part": "filename", 242 | "type": "match", 243 | "pattern": "settings.py", 244 | "caption": "Django configuration file", 245 | "description": "Might contain database credentials, online storage system credentials, secret keys, etc." 246 | }, 247 | { 248 | "part": "filename", 249 | "type": "regex", 250 | "pattern": "\\A(.*)?config(\\.inc)?\\.php\\Z", 251 | "caption": "PHP configuration file", 252 | "description": "Might contain credentials and keys." 253 | }, 254 | { 255 | "part": "extension", 256 | "type": "match", 257 | "pattern": "kdb", 258 | "caption": "KeePass password manager database file", 259 | "description": null 260 | }, 261 | { 262 | "part": "extension", 263 | "type": "match", 264 | "pattern": "agilekeychain", 265 | "caption": "1Password password manager database file", 266 | "description": null 267 | }, 268 | { 269 | "part": "extension", 270 | "type": "match", 271 | "pattern": "keychain", 272 | "caption": "Apple Keychain database file", 273 | "description": null 274 | }, 275 | { 276 | "part": "extension", 277 | "type": "regex", 278 | "pattern": "\\Akey(store|ring)\\Z", 279 | "caption": "GNOME Keyring database file", 280 | "description": null 281 | }, 282 | { 283 | "part": "extension", 284 | "type": "match", 285 | "pattern": "log", 286 | "caption": "Log file", 287 | "description": "Log files might contain information such as references to secret HTTP endpoints, session IDs, user information, passwords and API keys." 288 | }, 289 | { 290 | "part": "extension", 291 | "type": "match", 292 | "pattern": "pcap", 293 | "caption": "Network traffic capture file", 294 | "description": null 295 | }, 296 | { 297 | "part": "extension", 298 | "type": "regex", 299 | "pattern": "\\Asql(dump)?\\Z", 300 | "caption": "SQL dump file", 301 | "description": null 302 | }, 303 | { 304 | "part": "extension", 305 | "type": "match", 306 | "pattern": "gnucash", 307 | "caption": "GnuCash database file", 308 | "description": null 309 | }, 310 | { 311 | "part": "filename", 312 | "type": "regex", 313 | "pattern": "backup", 314 | "caption": "Contains word: backup", 315 | "description": null 316 | }, 317 | { 318 | "part": "filename", 319 | "type": "regex", 320 | "pattern": "dump", 321 | "caption": "Contains word: dump", 322 | "description": null 323 | }, 324 | { 325 | "part": "filename", 326 | "type": "regex", 327 | "pattern": "password", 328 | "caption": "Contains word: password", 329 | "description": null 330 | }, 331 | { 332 | "part": "filename", 333 | "type": "regex", 334 | "pattern": "private.*key", 335 | "caption": "Contains words: private, key", 336 | "description": null 337 | }, 338 | { 339 | "part": "filename", 340 | "type": "match", 341 | "pattern": "jenkins.plugins.publish_over_ssh.BapSshPublisherPlugin.xml", 342 | "caption": "Jenkins publish over SSH plugin file", 343 | "description": null 344 | }, 345 | { 346 | "part": "filename", 347 | "type": "match", 348 | "pattern": "credentials.xml", 349 | "caption": "Potential Jenkins credentials file", 350 | "description": null 351 | }, 352 | { 353 | "part": "filename", 354 | "type": "regex", 355 | "pattern": "\\A\\.?htpasswd\\Z", 356 | "caption": "Apache htpasswd file", 357 | "description": null 358 | }, 359 | { 360 | "part": "filename", 361 | "type": "regex", 362 | "pattern": "\\A\\.?netrc\\Z", 363 | "caption": "Configuration file for auto-login process", 364 | "description": "Might contain username and password." 365 | }, 366 | { 367 | "part": "extension", 368 | "type": "match", 369 | "pattern": "kwallet", 370 | "caption": "KDE Wallet Manager database file", 371 | "description": null 372 | }, 373 | { 374 | "part": "filename", 375 | "type": "match", 376 | "pattern": "LocalSettings.php", 377 | "caption": "Potential MediaWiki configuration file", 378 | "description": null 379 | }, 380 | { 381 | "part": "extension", 382 | "type": "match", 383 | "pattern": "tblk", 384 | "caption": "Tunnelblick VPN configuration file", 385 | "description": null 386 | }, 387 | { 388 | "part": "path", 389 | "type": "regex", 390 | "pattern": "\\A\\.?gem/credentials\\Z", 391 | "caption": "Rubygems credentials file", 392 | "description": "Might contain API key for a rubygems.org account." 393 | }, 394 | { 395 | "part": "filename", 396 | "type": "regex", 397 | "pattern": "\\A.*\\.pubxml(\\.user)?\\Z", 398 | "caption": "Potential MSBuild publish profile", 399 | "description": null 400 | }, 401 | { 402 | "part": "filename", 403 | "type": "match", 404 | "pattern": ".env", 405 | "caption": "PHP dotenv", 406 | "description": "Environment file that contains sensitive data" 407 | } 408 | ] 409 | -------------------------------------------------------------------------------- /install-for-project.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | srcFile="$HOME/.safe-commit-hook/safe-commit-hook.py" 4 | dstFile=".git/hooks/pre-commit" 5 | 6 | function combineHooks() { 7 | local hookCmd="\nexec ${dstFile}_safe\n" 8 | 9 | if [[ $(tail -n 3 ${dstFile}) =~ ${dstFile} ]]; then 10 | echo "[!] safe commit hook is already installed." 11 | exit 12 | fi 13 | 14 | # if "exit" exists, prepend `hookCmd` before it. 15 | if [[ $(tail -n 1 ${dstFile}) =~ "exit".* ]]; then 16 | sed --in-place=".bak-$(date +%s)" '$ i \ '"${hookCmd}"'' "${dstFile}" 17 | else 18 | echo -e "${hookCmd}" >> "${dstFile}" 19 | fi 20 | } 21 | 22 | if [[ ! -e "${dstFile}" ]]; then 23 | cp "${srcFile}" "${dstFile}" 24 | else 25 | cp "${srcFile}" "${dstFile}_safe" 26 | combineHooks 27 | fi 28 | 29 | echo "[-] Installed git safe commit hook. You will not be able to commit suspicious files." 30 | -------------------------------------------------------------------------------- /safe-commit-hook.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import json 5 | import subprocess 6 | import re 7 | 8 | def get_repo_root(): 9 | output = subprocess.check_output('git rev-parse --show-toplevel', shell=True) 10 | repo_root = output.split("\n")[0] 11 | return os.path.join(repo_root) 12 | 13 | def make_exact_matcher(str): 14 | def m(target): 15 | return str == target 16 | return m 17 | 18 | 19 | def make_regex_matcher(pattern): 20 | prog = re.compile(pattern) 21 | def m(target): 22 | return prog.match(target) 23 | return m 24 | 25 | 26 | def make_str_matcher(p): 27 | if p['type'] == 'regex': 28 | return make_regex_matcher(p['pattern']) 29 | elif p['type'] == 'match': 30 | return make_exact_matcher(p['pattern']) 31 | 32 | 33 | def make_filename_matcher(p): 34 | def m(target_filename): 35 | t = os.path.basename(target_filename) 36 | return p['_match'](t) 37 | return m 38 | 39 | 40 | def make_extension_matcher(p): 41 | def m(target_filename): 42 | _, file_extension = os.path.splitext(target_filename) 43 | file_extension = file_extension[1:] 44 | return p['_match'](file_extension) 45 | return m 46 | 47 | 48 | def make_path_matcher(p): 49 | def m(target_filename): 50 | return p['_match'](target_filename) 51 | return m 52 | 53 | 54 | def make_matcher(p): 55 | p['_match'] = make_str_matcher(p) 56 | 57 | if p['part'] == 'filename': 58 | return make_filename_matcher(p) 59 | if p['part'] == 'extension': 60 | return make_extension_matcher(p) 61 | if p['part'] == 'path': 62 | return make_path_matcher(p) 63 | 64 | 65 | def read_patterns(): 66 | matchers = [] 67 | with open(DEFAULT_PATTERNS) as data_file: 68 | data = json.load(data_file) 69 | for p in data: 70 | matcher = make_matcher(p) 71 | if matcher: 72 | p['matcher'] = matcher 73 | matchers.append(p) 74 | return matchers 75 | 76 | 77 | def load_whitelist(): 78 | ignore = [] 79 | with open(WHITELIST) as wl: 80 | for line in wl.readlines(): 81 | line = line.strip('\n') 82 | path = os.path.join(REPO_ROOT, line) 83 | ignore.append(path) 84 | return ignore 85 | 86 | 87 | def match_patterns(patterns, files, whitelist=None): 88 | commit_safe = True 89 | for f in files: 90 | file_path = os.path.join(REPO_ROOT, f) 91 | if whitelist and file_path in whitelist: 92 | continue 93 | else: 94 | for p in patterns: 95 | if p['matcher'](f): 96 | if commit_safe: 97 | print '\033[91m' + "[ERROR] Unable to complete git commit." + '\033[0m' 98 | commit_safe = False 99 | print "%s: %s" % (f, p['caption']) 100 | if p['description']: 101 | print p['description'] 102 | if not commit_safe: 103 | exit(1) 104 | 105 | def main(): 106 | global DEFAULT_PATTERNS, REPO_ROOT, WHITELIST 107 | DEFAULT_PATTERNS = os.path.expanduser('~/.safe-commit-hook/git-deny-patterns.json') 108 | REPO_ROOT = get_repo_root() 109 | WHITELIST = os.path.join(REPO_ROOT, '.git-safe-commit-ignore') 110 | 111 | cmd = 'git diff --name-only --cached' 112 | result = subprocess.check_output(cmd, shell=True) 113 | files = result.split("\n") 114 | patterns = read_patterns() 115 | 116 | if os.path.exists(WHITELIST): 117 | whitelist = load_whitelist() 118 | match_patterns(patterns, files, whitelist) 119 | else: 120 | match_patterns(patterns, files) 121 | 122 | if __name__ == "__main__": 123 | main() 124 | --------------------------------------------------------------------------------