├── .gitignore ├── .pdm-python ├── README.md ├── img └── snowflakecli.png ├── pdm.lock ├── pyproject.toml ├── scratch └── sample.py ├── src └── cli │ ├── account.py │ ├── ask.py │ ├── configure.py │ ├── connection.py │ ├── core │ ├── config │ │ ├── parser.py │ │ └── sfcli.py │ ├── constants.py │ ├── fs.py │ ├── logging.py │ ├── security │ │ ├── playbooks │ │ │ ├── benchmarks.py │ │ │ └── unc5537_breach.py │ │ ├── runner.py │ │ └── types.py │ ├── snowflake │ │ ├── connection.py │ │ ├── query.py │ │ └── sql.py │ └── util │ │ ├── key.py │ │ └── time.py │ ├── database.py │ ├── io.py │ ├── keypair.py │ ├── main.py │ ├── recommend.py │ ├── schema.py │ ├── scrape.py │ ├── security.py │ ├── sql.py │ ├── table.py │ └── warehouse.py └── tests ├── __init__.py └── unit └── src └── core ├── test_constants.py └── test_fs.py /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | build/ 3 | **__pycache__** 4 | **.egg** 5 | notes/** 6 | -------------------------------------------------------------------------------- /.pdm-python: -------------------------------------------------------------------------------- 1 | /Users/jacobthomas/code/snowflakecli/.venv/bin/python -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # What is Snowflakecli? 2 | 3 | Snowflakecli is a [DuckDB](https://duckdb.org/)-powered command line interface for Snowflake **security**, **governance**, **operations**, and **cost optimization**. 4 | 5 | `Snowflakecli` is not endorsed or sponsored by [Snowflake](https://www.snowflake.com/en/) in any manner. It is vendor-neutral and entirely dedicated to the mission of making your cloud data warehouse **safer and more cost efficient**. 6 | 7 | 8 | ![snowflakecli](https://raw.githubusercontent.com/dbecorp/snowflakecli/main/img/snowflakecli.png) 9 | 10 | 11 | # Installation 12 | 13 | $ pip install snowflakecli 14 | 15 | 16 | # Who is Snowflakecli for? 17 | 18 | 19 | **Snowflakecli is built for:** 20 | 21 | * Security threat-hunting teams **still dealing with the [fallout of the UNC5537 breach](https://cloud.google.com/blog/topics/threat-intelligence/unc5537-snowflake-data-theft-extortion).** 22 | * Data and Ops teams looking to **proactively improve and continuously monitor their security posture**. 23 | * Operations teams looking to **optimize their [virtual warehouses](https://docs.snowflake.com/en/user-guide/warehouses) and workloads.** 24 | * Data engineers looking to **grasp the complexities of their Snowflake account.** 25 | 26 | 27 | # What does Snowflakecli do? 28 | 29 | 30 | **Snowflakecli includes:** 31 | 32 | * Key-Pair utilities so you can ***establish and maintain secure access to your Snowflake account***. 33 | * Customizable security threat hunting, with the [UNC5537](https://cloud.google.com/blog/topics/threat-intelligence/unc5537-snowflake-data-theft-extortion) threat hunt being the default. 34 | * Customizable security and auditing benchmarks, with well-known industry standards being the default. 35 | * CLI-based SQL execution 36 | * Simplified SQL migration management - think a lightweight, Python-based [Flyway](https://www.red-gate.com/products/flyway/community/) 37 | * Configuration management 38 | * Connection management 39 | 40 | 41 | **Snowflakecli is quickly growing to include:** 42 | 43 | * Data loading and unloading tools 44 | * Account snapshotting and state diff-ing 45 | * Declarative, idempotent resource management [with fewer dangerous surprises](https://github.com/Snowflake-Labs/terraform-provider-snowflake/issues?q=is%3Aopen+is%3Aissue+label%3Abug) 46 | * ACL exploration - "Can user X access Y? How?" 47 | * Virtual warehouse utilization and workload optimization tools 48 | * Tiered compute so local queries don't have to use a virtual warehouse to do local analytics 49 | * AI-powered PII governance 50 | * AI-powered account recommendations 51 | 52 | 53 | # Why are we building it? 54 | 55 | We first adopted Snowflake in 2017 and it was absolutely game-changing. The separation of compute and storage allowed our data teams to quickly implement analytical systems that would have taken months (or years) to roll out. 56 | 57 | But a series of common patterns have since emerged across industry: 58 | 59 | ### Snowflake accounts often fail to implement best-in-class security and data security practices... 60 | 61 | Which has lead to unfortunate situations like [UNC5537](https://cloud.google.com/blog/topics/threat-intelligence/unc5537-snowflake-data-theft-extortion). 62 | 63 | ### Snowflake usage and activity often goes unaudited... 64 | 65 | Which leads to a lack of insight into what is actually happening to organizations' data resources. 66 | 67 | ### Snowflake accounts often have [runaway costs](https://www.reddit.com/r/snowflake/comments/197mszg/solutions_to_manage_runaway_snowflake_costs/)... 68 | 69 | Which means either a dedicated hire (who quickly pays for themselves) or onboarding third-party software like [Select](https://select.dev/) or [Keebo](https://keebo.ai/). 70 | 71 | 72 | # Why are we the right people to build it? 73 | 74 | We have: 75 | 76 | * Helped companies get started on Snowflake by teaching [O'Reilly courses](https://www.oreilly.com/live-events/building-a-modern-data-platform-with-snowflake/0636920414971/) 77 | * Contributed to [Snowflake The Definitive Guide](https://www.amazon.com/Snowflake-Definitive-Architecting-Designing-Deploying/dp/1098103823) 78 | * Built and presented [Okta's next-gen SIEM on Snowflake](https://www.youtube.com/watch?v=h3MMQMyiXcw) at [Snowflake Summit](https://www.snowflake.com/summit/save-the-date/) 79 | * [Reduced Snowflake costs](https://www.youtube.com/watch?v=TrmJilG4GXk) by hundreds of thousands of dollars using embedded OLAP 80 | 81 | 82 | ..while helping many companies along the way. 83 | 84 | 85 | # Documentation 86 | 87 | Full documentation will be published shortly so stay tuned. 88 | 89 | In the meantime, `snowflakecli` is entirely self-documenting thanks to great tools like [Typer](https://typer.tiangolo.com/) and [Rich](https://github.com/Textualize/rich). 90 | 91 | 92 | # Q&A 93 | 94 | 95 | ### Can I contribute? 96 | 97 | Please do. 98 | 99 | We are readily accepting new contributions and understand the power of collective, collaborative knowledge. If you have thoughts, ideas, suggestions, or innovative use cases please create an issue and let's start the conversation. Or just pull a PR 😀. Or find one of us on LinkedIn 😀. 100 | 101 | 102 | ### Is it secure? 103 | 104 | 105 | Yes. The codebase is entirely open, MIT-licensed, and built with best-in-class Python tooling. 106 | 107 | Unlike other command line tools which promote insecure practices such as username and password-based authentication without MFA, Snowflakecli ***explicitly mandates key-pair authentication***. 108 | 109 | 110 | ### Can it be used to keep my Snowflake account more secure? 111 | 112 | 113 | Yes. 114 | 115 | Data security has never been more important and Snowflakecli was explicitly built to help Snowflake customers enhance the security of their accounts. 116 | 117 | Many Snowflake accounts have been set up quickly with less-than-ideal configuration. As these accounts grow they usually store increasingly-sensitive information and become targets for malicious activity. 118 | 119 | Snowflakecli helps automate the process of ***establishing and maintaining secure accounts.*** 120 | 121 | 122 | # License 123 | 124 | 125 | [MIT](https://opensource.org/license/mit) 126 | -------------------------------------------------------------------------------- /img/snowflakecli.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/silverton-io/snowflakecli/4a8c60a0ee3b2e1be69dd44b94d035476c2f8497/img/snowflakecli.png -------------------------------------------------------------------------------- /pdm.lock: -------------------------------------------------------------------------------- 1 | # This file is @generated by PDM. 2 | # It is not intended for manual editing. 3 | 4 | [metadata] 5 | groups = ["default", "dev"] 6 | strategy = ["cross_platform", "inherit_metadata"] 7 | lock_version = "4.4.1" 8 | content_hash = "sha256:8cd5f84ee612804237bb9cdf97bd6776b12a7096faeb31f2dbc5c49ea855b172" 9 | 10 | [[package]] 11 | name = "asn1crypto" 12 | version = "1.5.1" 13 | summary = "Fast ASN.1 parser and serializer with definitions for private keys, public keys, certificates, CRL, OCSP, CMS, PKCS#3, PKCS#7, PKCS#8, PKCS#12, PKCS#5, X.509 and TSP" 14 | groups = ["default"] 15 | files = [ 16 | {file = "asn1crypto-1.5.1-py2.py3-none-any.whl", hash = "sha256:db4e40728b728508912cbb3d44f19ce188f218e9eba635821bb4b68564f8fd67"}, 17 | {file = "asn1crypto-1.5.1.tar.gz", hash = "sha256:13ae38502be632115abf8a24cbe5f4da52e3b5231990aff31123c805306ccb9c"}, 18 | ] 19 | 20 | [[package]] 21 | name = "astroid" 22 | version = "3.2.4" 23 | requires_python = ">=3.8.0" 24 | summary = "An abstract syntax tree for Python with inference support." 25 | groups = ["dev"] 26 | files = [ 27 | {file = "astroid-3.2.4-py3-none-any.whl", hash = "sha256:413658a61eeca6202a59231abb473f932038fbcbf1666587f66d482083413a25"}, 28 | {file = "astroid-3.2.4.tar.gz", hash = "sha256:0e14202810b30da1b735827f78f5157be2bbd4a7a59b7707ca0bfc2fb4c0063a"}, 29 | ] 30 | 31 | [[package]] 32 | name = "asttokens" 33 | version = "2.4.1" 34 | summary = "Annotate AST trees with source code positions" 35 | groups = ["dev"] 36 | dependencies = [ 37 | "six>=1.12.0", 38 | ] 39 | files = [ 40 | {file = "asttokens-2.4.1-py2.py3-none-any.whl", hash = "sha256:051ed49c3dcae8913ea7cd08e46a606dba30b79993209636c4875bc1d637bc24"}, 41 | {file = "asttokens-2.4.1.tar.gz", hash = "sha256:b03869718ba9a6eb027e134bfdf69f38a236d681c83c160d510768af11254ba0"}, 42 | ] 43 | 44 | [[package]] 45 | name = "black" 46 | version = "24.4.2" 47 | requires_python = ">=3.8" 48 | summary = "The uncompromising code formatter." 49 | groups = ["dev"] 50 | dependencies = [ 51 | "click>=8.0.0", 52 | "mypy-extensions>=0.4.3", 53 | "packaging>=22.0", 54 | "pathspec>=0.9.0", 55 | "platformdirs>=2", 56 | ] 57 | files = [ 58 | {file = "black-24.4.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:257d724c2c9b1660f353b36c802ccece186a30accc7742c176d29c146df6e474"}, 59 | {file = "black-24.4.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bdde6f877a18f24844e381d45e9947a49e97933573ac9d4345399be37621e26c"}, 60 | {file = "black-24.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e151054aa00bad1f4e1f04919542885f89f5f7d086b8a59e5000e6c616896ffb"}, 61 | {file = "black-24.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:7e122b1c4fb252fd85df3ca93578732b4749d9be076593076ef4d07a0233c3e1"}, 62 | {file = "black-24.4.2-py3-none-any.whl", hash = "sha256:d36ed1124bb81b32f8614555b34cc4259c3fbc7eec17870e8ff8ded335b58d8c"}, 63 | {file = "black-24.4.2.tar.gz", hash = "sha256:c872b53057f000085da66a19c55d68f6f8ddcac2642392ad3a355878406fbd4d"}, 64 | ] 65 | 66 | [[package]] 67 | name = "certifi" 68 | version = "2024.6.2" 69 | requires_python = ">=3.6" 70 | summary = "Python package for providing Mozilla's CA Bundle." 71 | groups = ["default"] 72 | files = [ 73 | {file = "certifi-2024.6.2-py3-none-any.whl", hash = "sha256:ddc6c8ce995e6987e7faf5e3f1b02b302836a0e5d98ece18392cb1a36c72ad56"}, 74 | {file = "certifi-2024.6.2.tar.gz", hash = "sha256:3cd43f1c6fa7dedc5899d69d3ad0398fd018ad1a17fba83ddaf78aa46c747516"}, 75 | ] 76 | 77 | [[package]] 78 | name = "cffi" 79 | version = "1.16.0" 80 | requires_python = ">=3.8" 81 | summary = "Foreign Function Interface for Python calling C code." 82 | groups = ["default"] 83 | dependencies = [ 84 | "pycparser", 85 | ] 86 | files = [ 87 | {file = "cffi-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b84834d0cf97e7d27dd5b7f3aca7b6e9263c56308ab9dc8aae9784abb774d404"}, 88 | {file = "cffi-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b8ebc27c014c59692bb2664c7d13ce7a6e9a629be20e54e7271fa696ff2b417"}, 89 | {file = "cffi-1.16.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee07e47c12890ef248766a6e55bd38ebfb2bb8edd4142d56db91b21ea68b7627"}, 90 | {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8a9d3ebe49f084ad71f9269834ceccbf398253c9fac910c4fd7053ff1386936"}, 91 | {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e70f54f1796669ef691ca07d046cd81a29cb4deb1e5f942003f401c0c4a2695d"}, 92 | {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5bf44d66cdf9e893637896c7faa22298baebcd18d1ddb6d2626a6e39793a1d56"}, 93 | {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b78010e7b97fef4bee1e896df8a4bbb6712b7f05b7ef630f9d1da00f6444d2e"}, 94 | {file = "cffi-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c6a164aa47843fb1b01e941d385aab7215563bb8816d80ff3a363a9f8448a8dc"}, 95 | {file = "cffi-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e09f3ff613345df5e8c3667da1d918f9149bd623cd9070c983c013792a9a62eb"}, 96 | {file = "cffi-1.16.0-cp311-cp311-win32.whl", hash = "sha256:2c56b361916f390cd758a57f2e16233eb4f64bcbeee88a4881ea90fca14dc6ab"}, 97 | {file = "cffi-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:db8e577c19c0fda0beb7e0d4e09e0ba74b1e4c092e0e40bfa12fe05b6f6d75ba"}, 98 | {file = "cffi-1.16.0.tar.gz", hash = "sha256:bcb3ef43e58665bbda2fb198698fcae6776483e0c4a631aa5647806c25e02cc0"}, 99 | ] 100 | 101 | [[package]] 102 | name = "charset-normalizer" 103 | version = "3.3.2" 104 | requires_python = ">=3.7.0" 105 | summary = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." 106 | groups = ["default"] 107 | files = [ 108 | {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, 109 | {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"}, 110 | {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"}, 111 | {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"}, 112 | {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"}, 113 | {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"}, 114 | {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"}, 115 | {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"}, 116 | {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"}, 117 | {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"}, 118 | {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"}, 119 | {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"}, 120 | {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"}, 121 | {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"}, 122 | {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"}, 123 | {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"}, 124 | {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, 125 | ] 126 | 127 | [[package]] 128 | name = "click" 129 | version = "8.1.7" 130 | requires_python = ">=3.7" 131 | summary = "Composable command line interface toolkit" 132 | groups = ["default", "dev"] 133 | dependencies = [ 134 | "colorama; platform_system == \"Windows\"", 135 | ] 136 | files = [ 137 | {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, 138 | {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, 139 | ] 140 | 141 | [[package]] 142 | name = "colorama" 143 | version = "0.4.6" 144 | requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 145 | summary = "Cross-platform colored terminal text." 146 | groups = ["default", "dev"] 147 | marker = "sys_platform == \"win32\" or platform_system == \"Windows\"" 148 | files = [ 149 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 150 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 151 | ] 152 | 153 | [[package]] 154 | name = "cryptography" 155 | version = "42.0.8" 156 | requires_python = ">=3.7" 157 | summary = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." 158 | groups = ["default"] 159 | dependencies = [ 160 | "cffi>=1.12; platform_python_implementation != \"PyPy\"", 161 | ] 162 | files = [ 163 | {file = "cryptography-42.0.8-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:81d8a521705787afe7a18d5bfb47ea9d9cc068206270aad0b96a725022e18d2e"}, 164 | {file = "cryptography-42.0.8-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:961e61cefdcb06e0c6d7e3a1b22ebe8b996eb2bf50614e89384be54c48c6b63d"}, 165 | {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3ec3672626e1b9e55afd0df6d774ff0e953452886e06e0f1eb7eb0c832e8902"}, 166 | {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e599b53fd95357d92304510fb7bda8523ed1f79ca98dce2f43c115950aa78801"}, 167 | {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5226d5d21ab681f432a9c1cf8b658c0cb02533eece706b155e5fbd8a0cdd3949"}, 168 | {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6b7c4f03ce01afd3b76cf69a5455caa9cfa3de8c8f493e0d3ab7d20611c8dae9"}, 169 | {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:2346b911eb349ab547076f47f2e035fc8ff2c02380a7cbbf8d87114fa0f1c583"}, 170 | {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:ad803773e9df0b92e0a817d22fd8a3675493f690b96130a5e24f1b8fabbea9c7"}, 171 | {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2f66d9cd9147ee495a8374a45ca445819f8929a3efcd2e3df6428e46c3cbb10b"}, 172 | {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d45b940883a03e19e944456a558b67a41160e367a719833c53de6911cabba2b7"}, 173 | {file = "cryptography-42.0.8-cp37-abi3-win32.whl", hash = "sha256:a0c5b2b0585b6af82d7e385f55a8bc568abff8923af147ee3c07bd8b42cda8b2"}, 174 | {file = "cryptography-42.0.8-cp37-abi3-win_amd64.whl", hash = "sha256:57080dee41209e556a9a4ce60d229244f7a66ef52750f813bfbe18959770cfba"}, 175 | {file = "cryptography-42.0.8-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:dea567d1b0e8bc5764b9443858b673b734100c2871dc93163f58c46a97a83d28"}, 176 | {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4783183f7cb757b73b2ae9aed6599b96338eb957233c58ca8f49a49cc32fd5e"}, 177 | {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0608251135d0e03111152e41f0cc2392d1e74e35703960d4190b2e0f4ca9c70"}, 178 | {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dc0fdf6787f37b1c6b08e6dfc892d9d068b5bdb671198c72072828b80bd5fe4c"}, 179 | {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:9c0c1716c8447ee7dbf08d6db2e5c41c688544c61074b54fc4564196f55c25a7"}, 180 | {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:fff12c88a672ab9c9c1cf7b0c80e3ad9e2ebd9d828d955c126be4fd3e5578c9e"}, 181 | {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:cafb92b2bc622cd1aa6a1dce4b93307792633f4c5fe1f46c6b97cf67073ec961"}, 182 | {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:31f721658a29331f895a5a54e7e82075554ccfb8b163a18719d342f5ffe5ecb1"}, 183 | {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b297f90c5723d04bcc8265fc2a0f86d4ea2e0f7ab4b6994459548d3a6b992a14"}, 184 | {file = "cryptography-42.0.8-cp39-abi3-win32.whl", hash = "sha256:2f88d197e66c65be5e42cd72e5c18afbfae3f741742070e3019ac8f4ac57262c"}, 185 | {file = "cryptography-42.0.8-cp39-abi3-win_amd64.whl", hash = "sha256:fa76fbb7596cc5839320000cdd5d0955313696d9511debab7ee7278fc8b5c84a"}, 186 | {file = "cryptography-42.0.8-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ba4f0a211697362e89ad822e667d8d340b4d8d55fae72cdd619389fb5912eefe"}, 187 | {file = "cryptography-42.0.8-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:81884c4d096c272f00aeb1f11cf62ccd39763581645b0812e99a91505fa48e0c"}, 188 | {file = "cryptography-42.0.8-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c9bb2ae11bfbab395bdd072985abde58ea9860ed84e59dbc0463a5d0159f5b71"}, 189 | {file = "cryptography-42.0.8-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7016f837e15b0a1c119d27ecd89b3515f01f90a8615ed5e9427e30d9cdbfed3d"}, 190 | {file = "cryptography-42.0.8-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5a94eccb2a81a309806027e1670a358b99b8fe8bfe9f8d329f27d72c094dde8c"}, 191 | {file = "cryptography-42.0.8-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:dec9b018df185f08483f294cae6ccac29e7a6e0678996587363dc352dc65c842"}, 192 | {file = "cryptography-42.0.8-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:343728aac38decfdeecf55ecab3264b015be68fc2816ca800db649607aeee648"}, 193 | {file = "cryptography-42.0.8-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:013629ae70b40af70c9a7a5db40abe5d9054e6f4380e50ce769947b73bf3caad"}, 194 | {file = "cryptography-42.0.8.tar.gz", hash = "sha256:8d09d05439ce7baa8e9e95b07ec5b6c886f548deb7e0f69ef25f64b3bce842f2"}, 195 | ] 196 | 197 | [[package]] 198 | name = "datafusion" 199 | version = "38.0.1" 200 | requires_python = ">=3.6" 201 | summary = "Build and run queries against data" 202 | groups = ["default"] 203 | dependencies = [ 204 | "pyarrow>=11.0.0", 205 | ] 206 | files = [ 207 | {file = "datafusion-38.0.1-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:bbff9ce713586307286688cc0cdb5fec30d542580bf701105422a159f1142e19"}, 208 | {file = "datafusion-38.0.1-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c7eba54e866fd1f85cb83c9300f464c2327e5c54fb9f6f40ccc4bda53e8ad74"}, 209 | {file = "datafusion-38.0.1-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:bd1ccac3c16822e0c9956e00863328de2f15eae40b096256d349e070769892cc"}, 210 | {file = "datafusion-38.0.1-cp38-abi3-win_amd64.whl", hash = "sha256:acded6a4c892a6ec654fe426117a998aa5697b5b41fadc370bd78ecc82efd8f9"}, 211 | {file = "datafusion-38.0.1.tar.gz", hash = "sha256:d117c1670db39e15f66a4181a5cbc44b268ba5306fe97825aa2fbda6397580f2"}, 212 | ] 213 | 214 | [[package]] 215 | name = "decorator" 216 | version = "5.1.1" 217 | requires_python = ">=3.5" 218 | summary = "Decorators for Humans" 219 | groups = ["dev"] 220 | files = [ 221 | {file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"}, 222 | {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"}, 223 | ] 224 | 225 | [[package]] 226 | name = "dill" 227 | version = "0.3.8" 228 | requires_python = ">=3.8" 229 | summary = "serialize all of Python" 230 | groups = ["dev"] 231 | marker = "python_version >= \"3.11\"" 232 | files = [ 233 | {file = "dill-0.3.8-py3-none-any.whl", hash = "sha256:c36ca9ffb54365bdd2f8eb3eff7d2a21237f8452b57ace88b1ac615b7e815bd7"}, 234 | {file = "dill-0.3.8.tar.gz", hash = "sha256:3ebe3c479ad625c4553aca177444d89b486b1d84982eeacded644afc0cf797ca"}, 235 | ] 236 | 237 | [[package]] 238 | name = "duckdb" 239 | version = "1.0.0" 240 | requires_python = ">=3.7.0" 241 | summary = "DuckDB in-process database" 242 | groups = ["default"] 243 | files = [ 244 | {file = "duckdb-1.0.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:75586791ab2702719c284157b65ecefe12d0cca9041da474391896ddd9aa71a4"}, 245 | {file = "duckdb-1.0.0-cp311-cp311-macosx_12_0_universal2.whl", hash = "sha256:83bb415fc7994e641344f3489e40430ce083b78963cb1057bf714ac3a58da3ba"}, 246 | {file = "duckdb-1.0.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:bee2e0b415074e84c5a2cefd91f6b5ebeb4283e7196ba4ef65175a7cef298b57"}, 247 | {file = "duckdb-1.0.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fa5a4110d2a499312609544ad0be61e85a5cdad90e5b6d75ad16b300bf075b90"}, 248 | {file = "duckdb-1.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5fa389e6a382d4707b5f3d1bc2087895925ebb92b77e9fe3bfb23c9b98372fdc"}, 249 | {file = "duckdb-1.0.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7ede6f5277dd851f1a4586b0c78dc93f6c26da45e12b23ee0e88c76519cbdbe0"}, 250 | {file = "duckdb-1.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0b88cdbc0d5c3e3d7545a341784dc6cafd90fc035f17b2f04bf1e870c68456e5"}, 251 | {file = "duckdb-1.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:fd1693cdd15375156f7fff4745debc14e5c54928589f67b87fb8eace9880c370"}, 252 | {file = "duckdb-1.0.0.tar.gz", hash = "sha256:a2a059b77bc7d5b76ae9d88e267372deff19c291048d59450c431e166233d453"}, 253 | ] 254 | 255 | [[package]] 256 | name = "executing" 257 | version = "2.0.1" 258 | requires_python = ">=3.5" 259 | summary = "Get the currently executing AST node of a frame, and other information" 260 | groups = ["dev"] 261 | files = [ 262 | {file = "executing-2.0.1-py2.py3-none-any.whl", hash = "sha256:eac49ca94516ccc753f9fb5ce82603156e590b27525a8bc32cce8ae302eb61bc"}, 263 | {file = "executing-2.0.1.tar.gz", hash = "sha256:35afe2ce3affba8ee97f2d69927fa823b08b472b7b994e36a52a964b93d16147"}, 264 | ] 265 | 266 | [[package]] 267 | name = "filelock" 268 | version = "3.15.4" 269 | requires_python = ">=3.8" 270 | summary = "A platform independent file lock." 271 | groups = ["default"] 272 | files = [ 273 | {file = "filelock-3.15.4-py3-none-any.whl", hash = "sha256:6ca1fffae96225dab4c6eaf1c4f4f28cd2568d3ec2a44e15a08520504de468e7"}, 274 | {file = "filelock-3.15.4.tar.gz", hash = "sha256:2207938cbc1844345cb01a5a95524dae30f0ce089eba5b00378295a17e3e90cb"}, 275 | ] 276 | 277 | [[package]] 278 | name = "idna" 279 | version = "3.7" 280 | requires_python = ">=3.5" 281 | summary = "Internationalized Domain Names in Applications (IDNA)" 282 | groups = ["default"] 283 | files = [ 284 | {file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"}, 285 | {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, 286 | ] 287 | 288 | [[package]] 289 | name = "ipython" 290 | version = "8.25.0" 291 | requires_python = ">=3.10" 292 | summary = "IPython: Productive Interactive Computing" 293 | groups = ["dev"] 294 | dependencies = [ 295 | "colorama; sys_platform == \"win32\"", 296 | "decorator", 297 | "jedi>=0.16", 298 | "matplotlib-inline", 299 | "pexpect>4.3; sys_platform != \"win32\" and sys_platform != \"emscripten\"", 300 | "prompt-toolkit<3.1.0,>=3.0.41", 301 | "pygments>=2.4.0", 302 | "stack-data", 303 | "traitlets>=5.13.0", 304 | "typing-extensions>=4.6; python_version < \"3.12\"", 305 | ] 306 | files = [ 307 | {file = "ipython-8.25.0-py3-none-any.whl", hash = "sha256:53eee7ad44df903a06655871cbab66d156a051fd86f3ec6750470ac9604ac1ab"}, 308 | {file = "ipython-8.25.0.tar.gz", hash = "sha256:c6ed726a140b6e725b911528f80439c534fac915246af3efc39440a6b0f9d716"}, 309 | ] 310 | 311 | [[package]] 312 | name = "isort" 313 | version = "5.13.2" 314 | requires_python = ">=3.8.0" 315 | summary = "A Python utility / library to sort Python imports." 316 | groups = ["dev"] 317 | files = [ 318 | {file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"}, 319 | {file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"}, 320 | ] 321 | 322 | [[package]] 323 | name = "jedi" 324 | version = "0.19.1" 325 | requires_python = ">=3.6" 326 | summary = "An autocompletion tool for Python that can be used for text editors." 327 | groups = ["dev"] 328 | dependencies = [ 329 | "parso<0.9.0,>=0.8.3", 330 | ] 331 | files = [ 332 | {file = "jedi-0.19.1-py2.py3-none-any.whl", hash = "sha256:e983c654fe5c02867aef4cdfce5a2fbb4a50adc0af145f70504238f18ef5e7e0"}, 333 | {file = "jedi-0.19.1.tar.gz", hash = "sha256:cf0496f3651bc65d7174ac1b7d043eff454892c708a87d1b683e57b569927ffd"}, 334 | ] 335 | 336 | [[package]] 337 | name = "loguru" 338 | version = "0.7.2" 339 | requires_python = ">=3.5" 340 | summary = "Python logging made (stupidly) simple" 341 | groups = ["default"] 342 | dependencies = [ 343 | "colorama>=0.3.4; sys_platform == \"win32\"", 344 | "win32-setctime>=1.0.0; sys_platform == \"win32\"", 345 | ] 346 | files = [ 347 | {file = "loguru-0.7.2-py3-none-any.whl", hash = "sha256:003d71e3d3ed35f0f8984898359d65b79e5b21943f78af86aa5491210429b8eb"}, 348 | {file = "loguru-0.7.2.tar.gz", hash = "sha256:e671a53522515f34fd406340ee968cb9ecafbc4b36c679da03c18fd8d0bd51ac"}, 349 | ] 350 | 351 | [[package]] 352 | name = "markdown-it-py" 353 | version = "3.0.0" 354 | requires_python = ">=3.8" 355 | summary = "Python port of markdown-it. Markdown parsing, done right!" 356 | groups = ["default"] 357 | dependencies = [ 358 | "mdurl~=0.1", 359 | ] 360 | files = [ 361 | {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, 362 | {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, 363 | ] 364 | 365 | [[package]] 366 | name = "matplotlib-inline" 367 | version = "0.1.7" 368 | requires_python = ">=3.8" 369 | summary = "Inline Matplotlib backend for Jupyter" 370 | groups = ["dev"] 371 | dependencies = [ 372 | "traitlets", 373 | ] 374 | files = [ 375 | {file = "matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca"}, 376 | {file = "matplotlib_inline-0.1.7.tar.gz", hash = "sha256:8423b23ec666be3d16e16b60bdd8ac4e86e840ebd1dd11a30b9f117f2fa0ab90"}, 377 | ] 378 | 379 | [[package]] 380 | name = "mccabe" 381 | version = "0.7.0" 382 | requires_python = ">=3.6" 383 | summary = "McCabe checker, plugin for flake8" 384 | groups = ["dev"] 385 | files = [ 386 | {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, 387 | {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, 388 | ] 389 | 390 | [[package]] 391 | name = "mdurl" 392 | version = "0.1.2" 393 | requires_python = ">=3.7" 394 | summary = "Markdown URL utilities" 395 | groups = ["default"] 396 | files = [ 397 | {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, 398 | {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, 399 | ] 400 | 401 | [[package]] 402 | name = "mypy-extensions" 403 | version = "1.0.0" 404 | requires_python = ">=3.5" 405 | summary = "Type system extensions for programs checked with the mypy type checker." 406 | groups = ["dev"] 407 | files = [ 408 | {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, 409 | {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, 410 | ] 411 | 412 | [[package]] 413 | name = "numpy" 414 | version = "2.0.0" 415 | requires_python = ">=3.9" 416 | summary = "Fundamental package for array computing in Python" 417 | groups = ["default"] 418 | files = [ 419 | {file = "numpy-2.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ad0c86f3455fbd0de6c31a3056eb822fc939f81b1618f10ff3406971893b62a5"}, 420 | {file = "numpy-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e7f387600d424f91576af20518334df3d97bc76a300a755f9a8d6e4f5cadd289"}, 421 | {file = "numpy-2.0.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:34f003cb88b1ba38cb9a9a4a3161c1604973d7f9d5552c38bc2f04f829536609"}, 422 | {file = "numpy-2.0.0-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:b6f6a8f45d0313db07d6d1d37bd0b112f887e1369758a5419c0370ba915b3871"}, 423 | {file = "numpy-2.0.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f64641b42b2429f56ee08b4f427a4d2daf916ec59686061de751a55aafa22e4"}, 424 | {file = "numpy-2.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a7039a136017eaa92c1848152827e1424701532ca8e8967fe480fe1569dae581"}, 425 | {file = "numpy-2.0.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:46e161722e0f619749d1cd892167039015b2c2817296104487cd03ed4a955995"}, 426 | {file = "numpy-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0e50842b2295ba8414c8c1d9d957083d5dfe9e16828b37de883f51fc53c4016f"}, 427 | {file = "numpy-2.0.0-cp311-cp311-win32.whl", hash = "sha256:2ce46fd0b8a0c947ae047d222f7136fc4d55538741373107574271bc00e20e8f"}, 428 | {file = "numpy-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:fbd6acc766814ea6443628f4e6751d0da6593dae29c08c0b2606164db026970c"}, 429 | {file = "numpy-2.0.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:9416a5c2e92ace094e9f0082c5fd473502c91651fb896bc17690d6fc475128d6"}, 430 | {file = "numpy-2.0.0-pp39-pypy39_pp73-macosx_14_0_x86_64.whl", hash = "sha256:17067d097ed036636fa79f6a869ac26df7db1ba22039d962422506640314933a"}, 431 | {file = "numpy-2.0.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:38ecb5b0582cd125f67a629072fed6f83562d9dd04d7e03256c9829bdec027ad"}, 432 | {file = "numpy-2.0.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:cef04d068f5fb0518a77857953193b6bb94809a806bd0a14983a8f12ada060c9"}, 433 | {file = "numpy-2.0.0.tar.gz", hash = "sha256:cf5d1c9e6837f8af9f92b6bd3e86d513cdc11f60fd62185cc49ec7d1aba34864"}, 434 | ] 435 | 436 | [[package]] 437 | name = "packaging" 438 | version = "24.1" 439 | requires_python = ">=3.8" 440 | summary = "Core utilities for Python packages" 441 | groups = ["default", "dev"] 442 | files = [ 443 | {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, 444 | {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, 445 | ] 446 | 447 | [[package]] 448 | name = "parso" 449 | version = "0.8.4" 450 | requires_python = ">=3.6" 451 | summary = "A Python Parser" 452 | groups = ["dev"] 453 | files = [ 454 | {file = "parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18"}, 455 | {file = "parso-0.8.4.tar.gz", hash = "sha256:eb3a7b58240fb99099a345571deecc0f9540ea5f4dd2fe14c2a99d6b281ab92d"}, 456 | ] 457 | 458 | [[package]] 459 | name = "pathspec" 460 | version = "0.12.1" 461 | requires_python = ">=3.8" 462 | summary = "Utility library for gitignore style pattern matching of file paths." 463 | groups = ["dev"] 464 | files = [ 465 | {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, 466 | {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, 467 | ] 468 | 469 | [[package]] 470 | name = "pexpect" 471 | version = "4.9.0" 472 | summary = "Pexpect allows easy control of interactive console applications." 473 | groups = ["dev"] 474 | marker = "sys_platform != \"win32\" and sys_platform != \"emscripten\"" 475 | dependencies = [ 476 | "ptyprocess>=0.5", 477 | ] 478 | files = [ 479 | {file = "pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523"}, 480 | {file = "pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f"}, 481 | ] 482 | 483 | [[package]] 484 | name = "platformdirs" 485 | version = "4.2.2" 486 | requires_python = ">=3.8" 487 | summary = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." 488 | groups = ["default", "dev"] 489 | files = [ 490 | {file = "platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee"}, 491 | {file = "platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3"}, 492 | ] 493 | 494 | [[package]] 495 | name = "prompt-toolkit" 496 | version = "3.0.47" 497 | requires_python = ">=3.7.0" 498 | summary = "Library for building powerful interactive command lines in Python" 499 | groups = ["dev"] 500 | dependencies = [ 501 | "wcwidth", 502 | ] 503 | files = [ 504 | {file = "prompt_toolkit-3.0.47-py3-none-any.whl", hash = "sha256:0d7bfa67001d5e39d02c224b663abc33687405033a8c422d0d675a5a13361d10"}, 505 | {file = "prompt_toolkit-3.0.47.tar.gz", hash = "sha256:1e1b29cb58080b1e69f207c893a1a7bf16d127a5c30c9d17a25a5d77792e5360"}, 506 | ] 507 | 508 | [[package]] 509 | name = "ptyprocess" 510 | version = "0.7.0" 511 | summary = "Run a subprocess in a pseudo terminal" 512 | groups = ["dev"] 513 | marker = "sys_platform != \"win32\" and sys_platform != \"emscripten\"" 514 | files = [ 515 | {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"}, 516 | {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"}, 517 | ] 518 | 519 | [[package]] 520 | name = "pure-eval" 521 | version = "0.2.2" 522 | summary = "Safely evaluate AST nodes without side effects" 523 | groups = ["dev"] 524 | files = [ 525 | {file = "pure_eval-0.2.2-py3-none-any.whl", hash = "sha256:01eaab343580944bc56080ebe0a674b39ec44a945e6d09ba7db3cb8cec289350"}, 526 | {file = "pure_eval-0.2.2.tar.gz", hash = "sha256:2b45320af6dfaa1750f543d714b6d1c520a1688dec6fd24d339063ce0aaa9ac3"}, 527 | ] 528 | 529 | [[package]] 530 | name = "pyarrow" 531 | version = "16.1.0" 532 | requires_python = ">=3.8" 533 | summary = "Python library for Apache Arrow" 534 | groups = ["default"] 535 | dependencies = [ 536 | "numpy>=1.16.6", 537 | ] 538 | files = [ 539 | {file = "pyarrow-16.1.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:d0ebea336b535b37eee9eee31761813086d33ed06de9ab6fc6aaa0bace7b250c"}, 540 | {file = "pyarrow-16.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2e73cfc4a99e796727919c5541c65bb88b973377501e39b9842ea71401ca6c1c"}, 541 | {file = "pyarrow-16.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf9251264247ecfe93e5f5a0cd43b8ae834f1e61d1abca22da55b20c788417f6"}, 542 | {file = "pyarrow-16.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ddf5aace92d520d3d2a20031d8b0ec27b4395cab9f74e07cc95edf42a5cc0147"}, 543 | {file = "pyarrow-16.1.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:25233642583bf658f629eb230b9bb79d9af4d9f9229890b3c878699c82f7d11e"}, 544 | {file = "pyarrow-16.1.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a33a64576fddfbec0a44112eaf844c20853647ca833e9a647bfae0582b2ff94b"}, 545 | {file = "pyarrow-16.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:185d121b50836379fe012753cf15c4ba9638bda9645183ab36246923875f8d1b"}, 546 | {file = "pyarrow-16.1.0.tar.gz", hash = "sha256:15fbb22ea96d11f0b5768504a3f961edab25eaf4197c341720c4a387f6c60315"}, 547 | ] 548 | 549 | [[package]] 550 | name = "pycparser" 551 | version = "2.22" 552 | requires_python = ">=3.8" 553 | summary = "C parser in Python" 554 | groups = ["default"] 555 | files = [ 556 | {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, 557 | {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, 558 | ] 559 | 560 | [[package]] 561 | name = "pygments" 562 | version = "2.18.0" 563 | requires_python = ">=3.8" 564 | summary = "Pygments is a syntax highlighting package written in Python." 565 | groups = ["default", "dev"] 566 | files = [ 567 | {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"}, 568 | {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, 569 | ] 570 | 571 | [[package]] 572 | name = "pyjwt" 573 | version = "2.8.0" 574 | requires_python = ">=3.7" 575 | summary = "JSON Web Token implementation in Python" 576 | groups = ["default"] 577 | files = [ 578 | {file = "PyJWT-2.8.0-py3-none-any.whl", hash = "sha256:59127c392cc44c2da5bb3192169a91f429924e17aff6534d70fdc02ab3e04320"}, 579 | {file = "PyJWT-2.8.0.tar.gz", hash = "sha256:57e28d156e3d5c10088e0c68abb90bfac3df82b40a71bd0daa20c65ccd5c23de"}, 580 | ] 581 | 582 | [[package]] 583 | name = "pylint" 584 | version = "3.2.6" 585 | requires_python = ">=3.8.0" 586 | summary = "python code static checker" 587 | groups = ["dev"] 588 | dependencies = [ 589 | "astroid<=3.3.0-dev0,>=3.2.4", 590 | "colorama>=0.4.5; sys_platform == \"win32\"", 591 | "dill>=0.3.6; python_version >= \"3.11\"", 592 | "isort!=5.13.0,<6,>=4.2.5", 593 | "mccabe<0.8,>=0.6", 594 | "platformdirs>=2.2.0", 595 | "tomlkit>=0.10.1", 596 | ] 597 | files = [ 598 | {file = "pylint-3.2.6-py3-none-any.whl", hash = "sha256:03c8e3baa1d9fb995b12c1dbe00aa6c4bcef210c2a2634374aedeb22fb4a8f8f"}, 599 | {file = "pylint-3.2.6.tar.gz", hash = "sha256:a5d01678349454806cff6d886fb072294f56a58c4761278c97fb557d708e1eb3"}, 600 | ] 601 | 602 | [[package]] 603 | name = "pyopenssl" 604 | version = "24.1.0" 605 | requires_python = ">=3.7" 606 | summary = "Python wrapper module around the OpenSSL library" 607 | groups = ["default"] 608 | dependencies = [ 609 | "cryptography<43,>=41.0.5", 610 | ] 611 | files = [ 612 | {file = "pyOpenSSL-24.1.0-py3-none-any.whl", hash = "sha256:17ed5be5936449c5418d1cd269a1a9e9081bc54c17aed272b45856a3d3dc86ad"}, 613 | {file = "pyOpenSSL-24.1.0.tar.gz", hash = "sha256:cabed4bfaa5df9f1a16c0ef64a0cb65318b5cd077a7eda7d6970131ca2f41a6f"}, 614 | ] 615 | 616 | [[package]] 617 | name = "pyperclip" 618 | version = "1.9.0" 619 | summary = "A cross-platform clipboard module for Python. (Only handles plain text for now.)" 620 | groups = ["default"] 621 | files = [ 622 | {file = "pyperclip-1.9.0.tar.gz", hash = "sha256:b7de0142ddc81bfc5c7507eea19da920b92252b548b96186caf94a5e2527d310"}, 623 | ] 624 | 625 | [[package]] 626 | name = "pytz" 627 | version = "2024.1" 628 | summary = "World timezone definitions, modern and historical" 629 | groups = ["default"] 630 | files = [ 631 | {file = "pytz-2024.1-py2.py3-none-any.whl", hash = "sha256:328171f4e3623139da4983451950b28e95ac706e13f3f2630a879749e7a8b319"}, 632 | {file = "pytz-2024.1.tar.gz", hash = "sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812"}, 633 | ] 634 | 635 | [[package]] 636 | name = "requests" 637 | version = "2.32.3" 638 | requires_python = ">=3.8" 639 | summary = "Python HTTP for Humans." 640 | groups = ["default"] 641 | dependencies = [ 642 | "certifi>=2017.4.17", 643 | "charset-normalizer<4,>=2", 644 | "idna<4,>=2.5", 645 | "urllib3<3,>=1.21.1", 646 | ] 647 | files = [ 648 | {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, 649 | {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, 650 | ] 651 | 652 | [[package]] 653 | name = "rich" 654 | version = "13.7.1" 655 | requires_python = ">=3.7.0" 656 | summary = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" 657 | groups = ["default"] 658 | dependencies = [ 659 | "markdown-it-py>=2.2.0", 660 | "pygments<3.0.0,>=2.13.0", 661 | ] 662 | files = [ 663 | {file = "rich-13.7.1-py3-none-any.whl", hash = "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222"}, 664 | {file = "rich-13.7.1.tar.gz", hash = "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432"}, 665 | ] 666 | 667 | [[package]] 668 | name = "ruff" 669 | version = "0.6.1" 670 | requires_python = ">=3.7" 671 | summary = "An extremely fast Python linter and code formatter, written in Rust." 672 | groups = ["dev"] 673 | files = [ 674 | {file = "ruff-0.6.1-py3-none-linux_armv6l.whl", hash = "sha256:b4bb7de6a24169dc023f992718a9417380301b0c2da0fe85919f47264fb8add9"}, 675 | {file = "ruff-0.6.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:45efaae53b360c81043e311cdec8a7696420b3d3e8935202c2846e7a97d4edae"}, 676 | {file = "ruff-0.6.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:bc60c7d71b732c8fa73cf995efc0c836a2fd8b9810e115be8babb24ae87e0850"}, 677 | {file = "ruff-0.6.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c7477c3b9da822e2db0b4e0b59e61b8a23e87886e727b327e7dcaf06213c5cf"}, 678 | {file = "ruff-0.6.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a0af7ab3f86e3dc9f157a928e08e26c4b40707d0612b01cd577cc84b8905cc9"}, 679 | {file = "ruff-0.6.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:392688dbb50fecf1bf7126731c90c11a9df1c3a4cdc3f481b53e851da5634fa5"}, 680 | {file = "ruff-0.6.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:5278d3e095ccc8c30430bcc9bc550f778790acc211865520f3041910a28d0024"}, 681 | {file = "ruff-0.6.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fe6d5f65d6f276ee7a0fc50a0cecaccb362d30ef98a110f99cac1c7872df2f18"}, 682 | {file = "ruff-0.6.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2e0dd11e2ae553ee5c92a81731d88a9883af8db7408db47fc81887c1f8b672e"}, 683 | {file = "ruff-0.6.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d812615525a34ecfc07fd93f906ef5b93656be01dfae9a819e31caa6cfe758a1"}, 684 | {file = "ruff-0.6.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:faaa4060f4064c3b7aaaa27328080c932fa142786f8142aff095b42b6a2eb631"}, 685 | {file = "ruff-0.6.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:99d7ae0df47c62729d58765c593ea54c2546d5de213f2af2a19442d50a10cec9"}, 686 | {file = "ruff-0.6.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9eb18dfd7b613eec000e3738b3f0e4398bf0153cb80bfa3e351b3c1c2f6d7b15"}, 687 | {file = "ruff-0.6.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c62bc04c6723a81e25e71715aa59489f15034d69bf641df88cb38bdc32fd1dbb"}, 688 | {file = "ruff-0.6.1-py3-none-win32.whl", hash = "sha256:9fb4c4e8b83f19c9477a8745e56d2eeef07a7ff50b68a6998f7d9e2e3887bdc4"}, 689 | {file = "ruff-0.6.1-py3-none-win_amd64.whl", hash = "sha256:c2ebfc8f51ef4aca05dad4552bbcf6fe8d1f75b2f6af546cc47cc1c1ca916b5b"}, 690 | {file = "ruff-0.6.1-py3-none-win_arm64.whl", hash = "sha256:3bc81074971b0ffad1bd0c52284b22411f02a11a012082a76ac6da153536e014"}, 691 | {file = "ruff-0.6.1.tar.gz", hash = "sha256:af3ffd8c6563acb8848d33cd19a69b9bfe943667f0419ca083f8ebe4224a3436"}, 692 | ] 693 | 694 | [[package]] 695 | name = "shellingham" 696 | version = "1.5.4" 697 | requires_python = ">=3.7" 698 | summary = "Tool to Detect Surrounding Shell" 699 | groups = ["default"] 700 | files = [ 701 | {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"}, 702 | {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, 703 | ] 704 | 705 | [[package]] 706 | name = "six" 707 | version = "1.16.0" 708 | requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 709 | summary = "Python 2 and 3 compatibility utilities" 710 | groups = ["dev"] 711 | files = [ 712 | {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, 713 | {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, 714 | ] 715 | 716 | [[package]] 717 | name = "snowflake-connector-python" 718 | version = "3.11.0" 719 | requires_python = ">=3.8" 720 | summary = "Snowflake Connector for Python" 721 | groups = ["default"] 722 | dependencies = [ 723 | "asn1crypto<2.0.0,>0.24.0", 724 | "certifi>=2017.4.17", 725 | "cffi<2.0.0,>=1.9", 726 | "charset-normalizer<4,>=2", 727 | "cryptography<43.0.0,>=3.1.0", 728 | "filelock<4,>=3.5", 729 | "idna<4,>=2.5", 730 | "packaging", 731 | "platformdirs<5.0.0,>=2.6.0", 732 | "pyOpenSSL<25.0.0,>=16.2.0", 733 | "pyjwt<3.0.0", 734 | "pytz", 735 | "requests<3.0.0", 736 | "sortedcontainers>=2.4.0", 737 | "tomlkit", 738 | "typing-extensions<5,>=4.3", 739 | ] 740 | files = [ 741 | {file = "snowflake_connector_python-3.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:415992f074b51712770c3dbd7a6b5a95b5dd04ffe02fc51ac8446e193771436d"}, 742 | {file = "snowflake_connector_python-3.11.0-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:e55eca3ff74fb33ea21455369e171ad61ef31eb916cbbbdab7ccb90cb98ad8d0"}, 743 | {file = "snowflake_connector_python-3.11.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa48b1f2a124098745a33ee93e34d85a3dfb60fa3d2d7ec5efee4aa17bb05053"}, 744 | {file = "snowflake_connector_python-3.11.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:96b21a062fc7aacb49202c8502239c0728319a96834a9fca1b6666a51e515dcc"}, 745 | {file = "snowflake_connector_python-3.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:ae890352e9e09e2084fd13647a664a31343bfa58d9aa41770e9ec3b810f9bc2c"}, 746 | {file = "snowflake_connector_python-3.11.0.tar.gz", hash = "sha256:3169c014a03e5f5855112605e393897a552e558953c69f25a02e33b1998864d0"}, 747 | ] 748 | 749 | [[package]] 750 | name = "sortedcontainers" 751 | version = "2.4.0" 752 | summary = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" 753 | groups = ["default"] 754 | files = [ 755 | {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"}, 756 | {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"}, 757 | ] 758 | 759 | [[package]] 760 | name = "stack-data" 761 | version = "0.6.3" 762 | summary = "Extract data from python stack frames and tracebacks for informative displays" 763 | groups = ["dev"] 764 | dependencies = [ 765 | "asttokens>=2.1.0", 766 | "executing>=1.2.0", 767 | "pure-eval", 768 | ] 769 | files = [ 770 | {file = "stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695"}, 771 | {file = "stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9"}, 772 | ] 773 | 774 | [[package]] 775 | name = "tomlkit" 776 | version = "0.12.5" 777 | requires_python = ">=3.7" 778 | summary = "Style preserving TOML library" 779 | groups = ["default", "dev"] 780 | files = [ 781 | {file = "tomlkit-0.12.5-py3-none-any.whl", hash = "sha256:af914f5a9c59ed9d0762c7b64d3b5d5df007448eb9cd2edc8a46b1eafead172f"}, 782 | {file = "tomlkit-0.12.5.tar.gz", hash = "sha256:eef34fba39834d4d6b73c9ba7f3e4d1c417a4e56f89a7e96e090dd0d24b8fb3c"}, 783 | ] 784 | 785 | [[package]] 786 | name = "traitlets" 787 | version = "5.14.3" 788 | requires_python = ">=3.8" 789 | summary = "Traitlets Python configuration system" 790 | groups = ["dev"] 791 | files = [ 792 | {file = "traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f"}, 793 | {file = "traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7"}, 794 | ] 795 | 796 | [[package]] 797 | name = "typer" 798 | version = "0.12.3" 799 | requires_python = ">=3.7" 800 | summary = "Typer, build great CLIs. Easy to code. Based on Python type hints." 801 | groups = ["default"] 802 | dependencies = [ 803 | "click>=8.0.0", 804 | "rich>=10.11.0", 805 | "shellingham>=1.3.0", 806 | "typing-extensions>=3.7.4.3", 807 | ] 808 | files = [ 809 | {file = "typer-0.12.3-py3-none-any.whl", hash = "sha256:070d7ca53f785acbccba8e7d28b08dcd88f79f1fbda035ade0aecec71ca5c914"}, 810 | {file = "typer-0.12.3.tar.gz", hash = "sha256:49e73131481d804288ef62598d97a1ceef3058905aa536a1134f90891ba35482"}, 811 | ] 812 | 813 | [[package]] 814 | name = "typing-extensions" 815 | version = "4.12.2" 816 | requires_python = ">=3.8" 817 | summary = "Backported and Experimental Type Hints for Python 3.8+" 818 | groups = ["default", "dev"] 819 | files = [ 820 | {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, 821 | {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, 822 | ] 823 | 824 | [[package]] 825 | name = "urllib3" 826 | version = "2.2.2" 827 | requires_python = ">=3.8" 828 | summary = "HTTP library with thread-safe connection pooling, file post, and more." 829 | groups = ["default"] 830 | files = [ 831 | {file = "urllib3-2.2.2-py3-none-any.whl", hash = "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472"}, 832 | {file = "urllib3-2.2.2.tar.gz", hash = "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168"}, 833 | ] 834 | 835 | [[package]] 836 | name = "wcwidth" 837 | version = "0.2.13" 838 | summary = "Measures the displayed width of unicode strings in a terminal" 839 | groups = ["dev"] 840 | files = [ 841 | {file = "wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859"}, 842 | {file = "wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5"}, 843 | ] 844 | 845 | [[package]] 846 | name = "win32-setctime" 847 | version = "1.1.0" 848 | requires_python = ">=3.5" 849 | summary = "A small Python utility to set file creation time on Windows" 850 | groups = ["default"] 851 | marker = "sys_platform == \"win32\"" 852 | files = [ 853 | {file = "win32_setctime-1.1.0-py3-none-any.whl", hash = "sha256:231db239e959c2fe7eb1d7dc129f11172354f98361c4fa2d6d2d7e278baa8aad"}, 854 | {file = "win32_setctime-1.1.0.tar.gz", hash = "sha256:15cf5750465118d6929ae4de4eb46e8edae9a5634350c01ba582df868e932cb2"}, 855 | ] 856 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "snowflakecli" 3 | version = "0.4.4" 4 | description = "A DuckDB-powered command line interface for Snowflake security, governance, operations, and cost optimization." 5 | authors = [{ name = "jake", email = "jake@bostata.com" }] 6 | dependencies = [ 7 | "duckdb>=1.0.0", 8 | "snowflake-connector-python>=3.11.0", 9 | "typer>=0.12.3", 10 | "datafusion>=38.0.0", 11 | "loguru>=0.7.2", 12 | "pyperclip>=1.9.0", 13 | ] 14 | 15 | optional-dependencies = { dev = [ 16 | "ipython>=8.25.0", 17 | "black>=24.4.2", 18 | "pylint>=3.2.6", 19 | "ruff>=0.6.1", 20 | ] } 21 | 22 | 23 | requires-python = "==3.11.*" 24 | readme = "README.md" 25 | license = { text = "MIT" } 26 | 27 | [project.scripts] 28 | snowflakecli = "cli.main:main" 29 | sfcli = "cli.main:main" 30 | sf = "cli.main:main" 31 | 32 | [tool.pdm] 33 | distribution = true 34 | -------------------------------------------------------------------------------- /scratch/sample.py: -------------------------------------------------------------------------------- 1 | 2 | SecurityTask( 3 | name="ensure_monitoring_for_accountadmin_securityadmin_grants", 4 | description="Following the principle of least privilege that prescribes limiting user's privileges to those that are strictly required to do their jobs, the ACCOUNTADMIN and SECURITYADMIN roles should be assigned to a limited number of designated users. Any new ACCOUNTADMIN and SECURITYADMIN role grants should be scrutinized.", 5 | control="CIS", 6 | control_id="2.1", 7 | queries=[ 8 | Sql( 9 | statement="SELECT CREATED_ON, GRANTEE_NAME, GRANTED_TO, GRANTED_BY FROM SNOWFLAKE.ACCOUNT_USAGE.GRANTS_TO_ROLES WHERE NAME IN ('ACCOUNTADMIN', 'SECURITYADMIN') AND GRANTEE_NAME NOT IN ('ACCOUNTADMIN', 'SECURITYADMIN') AND DELETED_ON IS NULL ORDER BY CREATED_ON DESC;" 10 | ), 11 | ], 12 | required_privileges="Requires the SECURITY_VIEWER role on the Snowflake database.", 13 | results_expected=False, 14 | remediation=[ 15 | SecurityRemediation( 16 | name="Revoke ACCOUNTADMIN or SECURITYADMIN from custom role(s) if they were mistakenly granted such privileges.", 17 | action="", 18 | ) 19 | ], 20 | references=[ 21 | SecurityReference( 22 | url="", 23 | name="", 24 | ) 25 | ], 26 | ), 27 | 28 | 29 | SecurityTask( 30 | name="", 31 | description="", 32 | control="CIS", 33 | control_id="", 34 | queries=[ 35 | Sql( 36 | statement="" 37 | ), 38 | ], 39 | required_privileges="""""", 40 | results_expected=False, 41 | remediation=[ 42 | SecurityRemediation( 43 | name="", 44 | action="", 45 | ) 46 | ], 47 | references=[ 48 | SecurityReference( 49 | url="", 50 | name="", 51 | ) 52 | ], 53 | ), 54 | -------------------------------------------------------------------------------- /src/cli/account.py: -------------------------------------------------------------------------------- 1 | import typer 2 | 3 | app = typer.Typer(no_args_is_help=True) 4 | 5 | 6 | @app.command() 7 | def create(): 8 | """Create a Snowflake account""" 9 | return 10 | 11 | 12 | @app.command() 13 | def list(): 14 | """List Snowflake accounts in your Organization""" 15 | return 16 | 17 | 18 | @app.command() 19 | def drop(): 20 | """Drop a Snowflake account""" 21 | return 22 | 23 | 24 | @app.command() 25 | def analyze(): 26 | """Analyze Snowflake account usage for cost-savings and other optimizations.""" 27 | return 28 | -------------------------------------------------------------------------------- /src/cli/ask.py: -------------------------------------------------------------------------------- 1 | import typer 2 | 3 | app = typer.Typer(no_args_is_help=True) 4 | 5 | 6 | @app.command() 7 | def question(): 8 | """Ask the Snowflakecli LLM a question about your Snowflake resources""" 9 | return 10 | -------------------------------------------------------------------------------- /src/cli/configure.py: -------------------------------------------------------------------------------- 1 | import typer 2 | 3 | from cli.core.config.sfcli import ensure_config_file 4 | 5 | app = typer.Typer(no_args_is_help=True) 6 | 7 | 8 | @app.command() 9 | def cli(): 10 | """Configure SnowflakeCLI""" 11 | ensure_config_file() 12 | return 13 | -------------------------------------------------------------------------------- /src/cli/connection.py: -------------------------------------------------------------------------------- 1 | import typer 2 | from rich import print 3 | 4 | from cli.core.logging import logger 5 | 6 | app = typer.Typer(no_args_is_help=True) 7 | 8 | 9 | @app.command() 10 | def test(ctx: typer.Context): 11 | """Test SnowflakeCLI connection to your Snowflake account""" 12 | print("⚠️ Running connection test ⚠️") 13 | try: 14 | cursor = ctx.obj.cursor 15 | if not cursor: 16 | logger.debug("no connection established, failing test") 17 | print("❌ SnowflakeCLI connection test failed ❌") 18 | return False 19 | else: 20 | result = cursor.execute("select true as connected").fetchone() 21 | print("✨✨ SnowflakeCLI connection test successful ✨✨") 22 | return True 23 | except Exception as e: 24 | logger.debug(e) 25 | print("❌ SnowflakeCLI connection test failed ❌") 26 | return False 27 | 28 | 29 | @app.command() 30 | def add(): 31 | """Add a named Snowflakecli connection""" 32 | raise NotImplementedError 33 | return 34 | -------------------------------------------------------------------------------- /src/cli/core/config/parser.py: -------------------------------------------------------------------------------- 1 | from configparser import ConfigParser 2 | from dataclasses import dataclass 3 | from pathlib import Path 4 | from types import SimpleNamespace 5 | 6 | from cli.core.snowflake.connection import NamedConnection, ConnectionParams 7 | from cli.core.constants import SFCLI_CONFIG_FILE_PATH 8 | 9 | 10 | @dataclass 11 | class SfcliConfig: 12 | connections: dict[str, NamedConnection] 13 | variables: SimpleNamespace = None 14 | options: SimpleNamespace = None 15 | 16 | 17 | def get_config( 18 | config_file_path: Path = SFCLI_CONFIG_FILE_PATH, 19 | ) -> SfcliConfig: 20 | parser = ConfigParser() 21 | parser.read(config_file_path) 22 | connections = {} 23 | for idx, section in enumerate(parser.sections()): 24 | if "connections" in section: 25 | name = section.replace("connections.", "") 26 | params = ConnectionParams( 27 | accountname=parser.get(section, "accountname"), 28 | username=parser.get(section, "username"), 29 | private_key_path=parser.get(section, "private_key_path"), 30 | # warehouse=parser.get(section, "warehousename"), 31 | # role=parser.get(section, "rolename"), 32 | # query_tag=parser.get(section, "query_tag"), 33 | ) 34 | named_connection = NamedConnection(name=name, params=params) 35 | connections[name] = named_connection 36 | return SfcliConfig(connections=SimpleNamespace(**connections)) 37 | -------------------------------------------------------------------------------- /src/cli/core/config/sfcli.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | 5 | from cli.core.constants import ( 6 | SFCLI_CONFIG_FILE_PATH, 7 | SFCLI_KEYPAIR_PUB_NAME, 8 | SFCLI_KEYPAIR_PRIV_NAME, 9 | SFCLI_DIR, 10 | ) 11 | 12 | 13 | DEFAULT_CONFIG_FILE_CONTENTS = f""" 14 | [connections.default] 15 | username = $SNOWFLAKE_USERNAME 16 | accountname = $SNOWFLAKE_ACCOUNTNAME 17 | private_key_path = "{SFCLI_DIR}/{SFCLI_KEYPAIR_PRIV_NAME}" 18 | 19 | [options] 20 | log_file = "/.sfcli/sfcli.log" 21 | log_level = DEBUG 22 | """ 23 | 24 | 25 | def ensure_config_file() -> None: 26 | config_path = Path(SFCLI_CONFIG_FILE_PATH) 27 | if os.path.exists(config_path): 28 | print("path already exists") 29 | return 30 | Path(config_path).parent.mkdir(parents=True, exist_ok=True) 31 | with open(config_path, "w") as f: 32 | f.write(DEFAULT_CONFIG_FILE_CONTENTS) 33 | 34 | 35 | def configure_sfcli() -> None: 36 | username = input("What is your Snowflake username? [Required]\n") 37 | account = input("What is your Snowflake account? [Required]\n") 38 | sfcli_priv_key_path = input( 39 | "What is the path to your sfcli private key? (Defaults to ~/.ssh/sfcli.pem)\n" 40 | ) 41 | pass 42 | -------------------------------------------------------------------------------- /src/cli/core/constants.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | 5 | # Configuration 6 | USR_HOME_DIR = os.path.expanduser("~") 7 | SFCLI_DIR = Path(USR_HOME_DIR, ".sfcli") 8 | 9 | # Logging 10 | SFCLI_LOG_FILE_PATH = Path(SFCLI_DIR, "sfcli.log") 11 | 12 | # Key Pair Generation 13 | SSH_DIR = Path(USR_HOME_DIR, ".ssh") 14 | SFCLI_CONFIG_FILE_PATH = Path(USR_HOME_DIR, ".sfcli", "config") 15 | SFCLI_KEYPAIR_PRIV_NAME = "sfcli.p8" 16 | SFCLI_DEFAULT_PRIV_KEY_PATH = Path(SFCLI_DIR, SFCLI_KEYPAIR_PRIV_NAME) 17 | SFCLI_KEYPAIR_PUB_NAME = "sfcli.pub" 18 | SFCLI_DEFAULT_PUB_KEY_PATH = Path(SFCLI_DIR, SFCLI_KEYPAIR_PUB_NAME) 19 | 20 | 21 | # General 22 | SFCLI = "snowflakecli" 23 | -------------------------------------------------------------------------------- /src/cli/core/fs.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | 5 | def ensure_directory(directory: Path) -> None: 6 | os.mkdir(directory) 7 | 8 | 9 | def ensure_file(file: Path) -> None: 10 | os.mknod(file) 11 | 12 | 13 | def get_file_contents(file_path: Path) -> str: 14 | with open(file_path, "r") as f: 15 | return f.read() 16 | -------------------------------------------------------------------------------- /src/cli/core/logging.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from loguru import logger 3 | 4 | from cli.core.constants import SFCLI_LOG_FILE_PATH 5 | 6 | 7 | logger.remove() 8 | logger.add(SFCLI_LOG_FILE_PATH, level="DEBUG") # TODO -> Make this configurable 9 | -------------------------------------------------------------------------------- /src/cli/core/security/playbooks/benchmarks.py: -------------------------------------------------------------------------------- 1 | from cli.core.snowflake.sql import Sql 2 | from cli.core.security.types import ( 3 | SecurityPlaybook, 4 | SecurityTask, 5 | SecurityReference, 6 | SecurityRemediation, 7 | ) 8 | 9 | 10 | BENCHMARK_PLAYBOOK = SecurityPlaybook( 11 | name="Benchmarks", 12 | description="Snowflake Foundations Benchmarks", 13 | tasks=[ 14 | SecurityTask( 15 | name="sso_configured_security_integrations", 16 | description="Federated authentication enables users to connect to Snowflake using secure SSO (single sign-on). With SSO enabled, users authenticate through an external (SAML 2.0- compliant or OAuth 2.0) identity provider (IdP). Once authenticated by an IdP, users can access their Snowflake account for the duration of their IdP session without having to authenticate to Snowflake again. Users can choose to initiate their sessions from within the interface provided by the IdP or directly in Snowflake.", 17 | control="Foundations", 18 | control_id="1.1", 19 | queries=[ 20 | Sql(statement="SHOW SECURITY INTEGRATIONS;"), 21 | Sql( 22 | statement="""SELECT * FROM TABLE(RESULT_SCAN(LAST_QUERY_ID())) WHERE ("type" LIKE 'EXTERNAL_OAUTH%' OR "type" LIKE 'SAML2') AND "enabled" = TRUE;""" 23 | ), 24 | ], 25 | required_privileges="""Requires USAGE privilege on every security integration in your Snowflake account.""", 26 | results_expected=True, 27 | remediation=[ 28 | SecurityRemediation( 29 | description="Configure a security integration", 30 | action="The steps for configuring an IdP differ depending on whether you choose SAML2 or OAuth. They further differ depending on what identity provider you choose: Okta, AD FS, Ping Identity, Azure AD, or custom. For specific instructions, see Snowflake documentation on SAML and External OAuth. Note: If your SAML integration is configured using the deprecated account parameter SAML_IDENTITY_PROVIDER, you should migrate to creating a security integration using the system$migrate_saml_idp_registration function. For more information, see the Migrating to a SAML2 Security Integration documentation.", 31 | ) 32 | ], 33 | ), 34 | SecurityTask( 35 | name="ensure_scim_integration", 36 | description="The System for Cross-domain Identity Management (SCIM) is an open specification designed to help facilitate the automated management of user identities and groups (i.e. roles) in cloud applications using RESTful APIs. Snowflake supports SCIM 2.0 integration with Okta, Microsoft Azure AD and custom identity providers. Users and groups from the identity provider can be provisioned into Snowflake, which functions as the service provider.", 37 | control="Foundations", 38 | control_id="1.2", 39 | queries=[ 40 | Sql(statement="SHOW SECURITY INTEGRATIONS;"), 41 | Sql( 42 | statement="""SELECT * FROM TABLE(result_scan(last_query_id())) WHERE ("type" like 'SCIM%') AND "enabled" = true;""" 43 | ), 44 | ], 45 | required_privileges="""Requires USAGE privilege on every security integration in an account.""", 46 | results_expected=True, 47 | remediation=[], 48 | ), 49 | SecurityTask( 50 | name="ensure_snowflake_password_unset", 51 | description="Ensure that Snowflake password is unset for SSO users.", 52 | rationale="""Allowing users to sign in with Snowflake passwords in the presence of a configured third-party identity provider SSO may undermine mandatory security controls configured on the SSO and degrade the security posture of the account. For example, the SSO sign-in flow may be configured to require multi-factor authentication (MFA), whereas the Snowflake password sign-in flow may not.""", 53 | control="Foundations", 54 | control_id="1.3", 55 | queries=[ 56 | Sql( 57 | statement="""SELECT NAME, CREATED_ON, HAS_PASSWORD, HAS_RSA_PUBLIC_KEY FROM SNOWFLAKE.ACCOUNT_USAGE.USERS WHERE HAS_PASSWORD AND DELETED_ON IS NULL AND NOT DISABLED AND NAME NOT LIKE 'SNOWFLAKE';""" 58 | ) 59 | ], 60 | required_privileges="""Requires the SECURITY_VIEWER role on the Snowflake database.""", 61 | results_expected=False, 62 | remediation=[], 63 | references=[ 64 | SecurityReference( 65 | url="https://docs.snowflake.com/en/sql-reference/sql/create-user", 66 | name="Snowflake documentation on CREATE USER", 67 | ), 68 | SecurityReference( 69 | url="https://docs.snowflake.com/en/user-guide/scim-okta#features", 70 | name="Snowflake documentation on Okta SCIM integration", 71 | ), 72 | SecurityReference( 73 | url="https://docs.snowflake.com/en/user-guide/key-pair-auth", 74 | name="Snowflake documentation on key pair authentication", 75 | ), 76 | SecurityReference( 77 | url="https://community.snowflake.com/s/article/FAQ-User-and-Password-Management", 78 | name="Snowflake documentation on user and password management", 79 | ), 80 | ], 81 | ), 82 | SecurityTask( 83 | name="ensure_mfa_enabled_for_password_users", 84 | description="Multi-factor authentication (MFA) is a security control used to add an additional layer of login security. It works by requiring the user to present two or more proofs (factors) of user identity. An MFA example would be requiring a password and a verification code delivered to the user's phone during user sign-in.", 85 | control="Foundations", 86 | control_id="1.4", 87 | queries=[ 88 | Sql( 89 | statement="SELECT NAME, EXT_AUTHN_DUO AS MFA_ENABLED FROM SNOWFLAKE.ACCOUNT_USAGE.USERS WHERE DELETED_ON IS NULL AND NOT DISABLED AND HAS_PASSWORD;" 90 | ), 91 | ], 92 | required_privileges="""Requires the SECURITY_VIEWER role on the Snowflake database.""", 93 | results_expected=True, 94 | remediation=[], 95 | references=[ 96 | SecurityReference( 97 | url="https://docs.snowflake.com/en/user-guide/ui-snowsight-profile#enrolling-in-mfa-multi-factor-authentication", 98 | name="Snowflake documentation for enrolling in multi-factor authentication", 99 | ) 100 | ], 101 | ), 102 | SecurityTask( 103 | name="ensure_minimum_password_length_policy", 104 | description="Multi-factor authentication (MFA) is a security control used to add an additional layer of login security. It works by requiring the user to present two or more proofs (factors) of user identity. An MFA example would be requiring a password and a verification code delivered to the user's phone during user sign-in.", 105 | control="Foundations", 106 | control_id="1.5", 107 | queries=[ 108 | Sql( 109 | statement="WITH PWDS_WITH_MIN_LEN AS (SELECT ID FROM SNOWFLAKE.ACCOUNT_USAGE.PASSWORD_POLICIES WHERE PASSWORD_MIN_LENGTH >= 14 AND DELETED IS NULL)SELECT A.* FROM SNOWFLAKE.ACCOUNT_USAGE.POLICY_REFERENCES AS A LEFT JOIN PWDS_WITH_MIN_LEN AS B ON A.POLICY_ID = B.ID WHERE A.REF_ENTITY_DOMAIN = 'ACCOUNT' AND A.POLICY_KIND = 'PASSWORD_POLICY' AND A.POLICY_STATUS = 'ACTIVE' AND B.ID IS NOT NULL;" 110 | ), 111 | ], 112 | required_privileges="""Requires the SECURITY_VIEWER role on the Snowflake database.""", 113 | results_expected=True, 114 | remediation="", 115 | references=[ 116 | SecurityReference( 117 | url="https://docs.snowflake.com/en/user-guide/admin-user-management#password-policies", 118 | name="Snowflake documentation for password policies", 119 | ) 120 | ], 121 | ), 122 | SecurityTask( 123 | name="ensure_service_accounts_keypair_authentication", 124 | description="Password-based authentication has a set of disadvantages that increase probability of a security incident, especially when used without MFA. Using key-based authentication for service accounts helps to mitigate these risks.", 125 | control="Foundations", 126 | control_id="1.6", 127 | queries=[ 128 | # NOTE! This is not a complete list of service accounts and should be reviewed. 129 | Sql( 130 | statement="select name, created_on, has_password, has_rsa_public_key, disabled from snowflake.account_usage.users where (has_password = true or has_rsa_public_key = false) and disabled = false and (name ilike '%svc%' or name ilike '%service%' or name ilike '%dbt%' or name ilike '%airflow%' or name ilike '%airbyte%' or name ilike '%fivetran%');" 131 | ), 132 | ], 133 | required_privileges="""Requires the SECURITY_VIEWER role on the Snowflake database.""", 134 | results_expected=False, 135 | remediation="", 136 | references=[ 137 | SecurityReference( 138 | url="https://docs.snowflake.com/en/user-guide/key-pair-auth.html", 139 | name="Snowflake documentation for key pair authentication", 140 | ) 141 | ], 142 | ), 143 | SecurityTask( 144 | name="ensure_keypair_rotation_180_days", 145 | description="Snowflake supports using RSA key pair authentication as an alternative to password authentication and as a primary way to authenticate service accounts. Snowflake supports two active authentication key pairs to allow for uninterrupted key rotation. Rotate and replace your authentication key pairs based on the expiration schedule at least once every 180 days.", 146 | control="Foundations", 147 | control_id="1.7", 148 | queries=[ 149 | Sql( 150 | statement="WITH FILTERED_QUERY_HISTORY AS (SELECT END_TIME AS SET_TIME, UPPER(SPLIT_PART(QUERY_TEXT, ' ', 3)) AS PROCESSED_USERNAME, QUERY_TEXT FROM SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY WHERE EXECUTION_STATUS = 'SUCCESS' AND QUERY_TYPE IN ('ALTER_USER', 'CREATE_USER') AND TO_DATE(SET_TIME) < DATEADD(day, -180, CURRENT_DATE()) AND (QUERY_TEXT ILIKE '%rsa_public_key%' OR QUERY_TEXT ILIKE '%rsa_public_key_2%')), EXTRACTED_KEYS AS (SELECT SET_TIME, PROCESSED_USERNAME, CASE WHEN POSITION('rsa_public_key' IN LOWER(QUERY_TEXT)) > 0 THEN 'rsa_public_key' WHEN POSITION('rsa_public_key_2' IN LOWER(QUERY_TEXT)) > 0 THEN 'rsa_public_key_2' ELSE NULL END AS RSA_KEY_NAME FROM FILTERED_QUERY_HISTORY WHERE POSITION('rsa_public_key' IN LOWER(QUERY_TEXT)) > 0 OR POSITION('rsa_public_key_2' IN LOWER(QUERY_TEXT)) > 0), RECENT_KEYS AS ( SELECT EK.SET_TIME, EK.PROCESSED_USERNAME AS USERNAME, EK.RSA_KEY_NAME AS RSA_PUBLIC_KEY, ROW_NUMBER() OVER (PARTITION BY ek.processed_username, ek.rsa_key_name ORDER BY ek.set_time DESC) AS rnum FROM EXTRACTED_KEYS EK INNER JOIN SNOWFLAKE.ACCOUNT_USAGE.USERS AU ON EK.PROCESSED_USERNAME = AU.NAME WHERE AU.DELETED_ON IS NULL AND AU.DISABLED = FALSE AND EK.RSA_KEY_NAME IS NOT NULL) SELECT SET_TIME, USERNAME, RSA_PUBLIC_KEY FROM RECENT_KEYS WHERE RNUM = 1;" 151 | ), 152 | ], 153 | required_privileges="""Requires SECURITY_VIEWER and GOVERNANCE_VIEWER roles on the Snowflake database.""", 154 | results_expected=False, 155 | remediation="", 156 | references=[ 157 | SecurityReference( 158 | url="https://docs.snowflake.com/en/user-guide/key-pair-auth.html#configuring-key-pair-rotation", 159 | name="Snowflake documentation for configuring key pair rotation", 160 | ) 161 | ], 162 | ), 163 | SecurityTask( 164 | name="ensure_disabled_after_90_days_without_login", 165 | description="Access grants tend to accumulate over time unless explicitly set to expire. Regularly revoking unused access grants and disabling inactive user accounts is a good countermeasure to this dynamic.", 166 | control="Foundations", 167 | control_id="1.8", 168 | queries=[ 169 | Sql(statement="SHOW USERS;"), 170 | Sql( 171 | statement="""SELECT "name", "created_on", "last_success_login", "disabled" FROM TABLE(result_scan(last_query_id())) WHERE "last_success_login" < CURRENT_TIMESTAMP() - interval '90 days' and "disabled" = false""" 172 | ), 173 | ], 174 | required_privileges="""Requires USERADMIN role""", 175 | results_expected=False, 176 | remediation="Run the following query after 90 days of inactivity: ALTER USER SET DISABLED = true;", 177 | references=[ 178 | SecurityReference( 179 | url="https://docs.snowflake.com/en/user-guide/admin-user-management.html#disabling-enabling-a-user", 180 | name="Snowflake documentation for disabling a user", 181 | ) 182 | ], 183 | ), 184 | SecurityTask( 185 | name="ensure_idle_session_timeout_for_privileged_roles", 186 | description=" session is maintained indefinitely with continued user activity. After a period of inactivity in the session, known as the idle session timeout, the user must authenticate to Snowflake again. Session policies can be used to modify the idle session timeout period. The idle session timeout has a maximum value of four hours. Tightening up the idle session timeout reduces sensitive data exposure risk when users forget to sign out of Snowflake and an unauthorized person gains access to their device.", 187 | control="Foundations", 188 | control_id="1.9", 189 | queries=[ 190 | Sql( 191 | statement="WITH PRIV_USERS AS (SELECT DISTINCT GRANTEE_NAME FROM SNOWFLAKE.ACCOUNT_USAGE.GRANTS_TO_USERS WHERE DELETED_ON IS NULL AND ROLE IN ('ACCOUNTADMIN','SECURITYADMIN') AND DELETED_ON IS NULL), POLICY_REFS AS (SELECT * FROM SNOWFLAKE.ACCOUNT_USAGE.POLICY_REFERENCES AS A LEFT JOIN SNOWFLAKE.ACCOUNT_USAGE.SESSION_POLICIES AS B ON A.POLICY_ID = B.ID WHERE A.POLICY_KIND = 'SESSION_POLICY' AND A.POLICY_STATUS = 'ACTIVE' AND A.REF_ENTITY_DOMAIN = 'USER' AND B.DELETED IS NULL AND B.SESSION_IDLE_TIMEOUT_MINS <= 15) SELECT A.*, B.POLICY_ID, B.POLICY_KIND, B.POLICY_STATUS, B.SESSION_IDLE_TIMEOUT_MINS FROM PRIV_USERS AS A LEFT JOIN POLICY_REFS AS B ON A.GRANTEE_NAME = B.REF_ENTITY_NAME WHERE B.POLICY_ID IS NULL;" 192 | ), 193 | ], 194 | required_privileges="""Requires USERADMIN role""", 195 | results_expected=False, 196 | remediation="Run the following query after 90 days of inactivity: ALTER USER SET DISABLED = true;", 197 | references=[ 198 | SecurityReference( 199 | url="https://docs.snowflake.com/en/user-guide/session-policies", 200 | name="Snowflake documentation for session policies", 201 | ), 202 | SecurityReference( 203 | url="https://docs.snowflake.com/en/user-guide/session-policies#step-3-create-a-new-session-policy", 204 | name="Snowflake documentation for creating session policies", 205 | ), 206 | ], 207 | ), 208 | SecurityTask( 209 | name="limit_users_with_accountadmin_and_securityadmin", 210 | description="By default, ACCOUNTADMIN is the most powerful role in a Snowflake account. Users with the SECURITYADMIN role grant can trivially escalate their privileges to that of ACCOUNTADMIN. Following the principle of least privilege that prescribes limiting user's privileges to those that are strictly required to do their jobs, the ACCOUNTADMIN and SECURITYADMIN roles should be assigned to a limited number of designated users (e.g., less than 10, but at least 2 to ensure that access can be recovered if one ACCOUNTAMIN user is having login difficulties).", 211 | control="Foundations", 212 | control_id="1.10", 213 | queries=[ 214 | Sql( 215 | statement="SELECT DISTINCT A.GRANTEE_NAME AS NAME, A.ROLE FROM SNOWFLAKE.ACCOUNT_USAGE.GRANTS_TO_USERS AS A LEFT JOIN SNOWFLAKE.ACCOUNT_USAGE.USERS AS B ON A.GRANTEE_NAME = B.NAME WHERE A.ROLE IN ('ACCOUNTADMIN', 'SECURITYADMIN') AND A.DELETED_ON IS NULL AND B.DELETED_ON IS NULL AND NOT B.DISABLED ORDER BY A.ROLE;" 216 | ), 217 | ], 218 | required_privileges="""Requires SECURITY_VIEWER role on the Snowflake databases.""", 219 | results_expected=True, 220 | remediation="Run the following query after 90 days of inactivity: ALTER USER SET DISABLED = true;", 221 | references=[ 222 | SecurityReference( 223 | url="https://docs.snowflake.com/en/user-guide/security-access-control-considerations.html", 224 | name="Snowflake documentation for access control considerations", 225 | ) 226 | ], 227 | ), 228 | SecurityTask( 229 | name="limit_users_with_accountadmin_and_securityadmin", 230 | description="By default, ACCOUNTADMIN is the most powerful role in a Snowflake account. Users with the SECURITYADMIN role grant can trivially escalate their privileges to that of ACCOUNTADMIN. Following the principle of least privilege that prescribes limiting user's privileges to those that are strictly required to do their jobs, the ACCOUNTADMIN and SECURITYADMIN roles should be assigned to a limited number of designated users (e.g., less than 10, but at least 2 to ensure that access can be recovered if one ACCOUNTAMIN user is having login difficulties).", 231 | control="Foundations", 232 | control_id="1.10", 233 | queries=[ 234 | Sql( 235 | statement="SELECT DISTINCT A.GRANTEE_NAME AS NAME, A.ROLE FROM SNOWFLAKE.ACCOUNT_USAGE.GRANTS_TO_USERS AS A LEFT JOIN SNOWFLAKE.ACCOUNT_USAGE.USERS AS B ON A.GRANTEE_NAME = B.NAME WHERE A.ROLE IN ('ACCOUNTADMIN', 'SECURITYADMIN') AND A.DELETED_ON IS NULL AND B.DELETED_ON IS NULL AND NOT B.DISABLED ORDER BY A.ROLE;" 236 | ), 237 | ], 238 | required_privileges="""Requires SECURITY_VIEWER role on the Snowflake databases.""", 239 | results_expected=False, 240 | remediation="Run the following query after 90 days of inactivity: ALTER USER SET DISABLED = true;", 241 | references=[ 242 | SecurityReference( 243 | url="https://docs.snowflake.com/en/user-guide/security-access-control-considerations.html", 244 | name="Snowflake documentation for access control considerations", 245 | ) 246 | ], 247 | ), 248 | SecurityTask( 249 | name="limit_users_with_accountadmin_and_securityadmin", 250 | description="By default, ACCOUNTADMIN is the most powerful role in a Snowflake account. Users with the SECURITYADMIN role grant can trivially escalate their privileges to that of ACCOUNTADMIN. Following the principle of least privilege that prescribes limiting user's privileges to those that are strictly required to do their jobs, the ACCOUNTADMIN and SECURITYADMIN roles should be assigned to a limited number of designated users (e.g., less than 10, but at least 2 to ensure that access can be recovered if one ACCOUNTAMIN user is having login difficulties).", 251 | control="Foundations", 252 | control_id="1.10", 253 | queries=[ 254 | Sql( 255 | statement="SELECT DISTINCT A.GRANTEE_NAME AS NAME, A.ROLE FROM SNOWFLAKE.ACCOUNT_USAGE.GRANTS_TO_USERS AS A LEFT JOIN SNOWFLAKE.ACCOUNT_USAGE.USERS AS B ON A.GRANTEE_NAME = B.NAME WHERE A.ROLE IN ('ACCOUNTADMIN', 'SECURITYADMIN') AND A.DELETED_ON IS NULL AND B.DELETED_ON IS NULL AND NOT B.DISABLED ORDER BY A.ROLE;" 256 | ), 257 | ], 258 | required_privileges="""Requires SECURITY_VIEWER role on the Snowflake database.""", 259 | results_expected=True, 260 | remediation="REVOKE ROLE ACCOUNTADMIN FROM USER or REVOKE ROLE SECURITYADMIN FROM USER ", 261 | references=[ 262 | SecurityReference( 263 | url="https://docs.snowflake.com/en/user-guide/security-access-control-considerations.html", 264 | name="Snowflake documentation for access control considerations", 265 | ) 266 | ], 267 | ), 268 | SecurityTask( 269 | name="ensure_accountadmin_have_email_address", 270 | description="Every Snowflake user can be assigned an email address. The email addresses assigned to ACCOUNTADMIN users are used by Snowflake to notify administrators about important events related to their accounts. For example, ACCOUNTADMIN users are notified about impending expiration of SAML2 certificates or SCIM access tokens.", 271 | control="Foundations", 272 | control_id="1.11", 273 | queries=[ 274 | Sql( 275 | statement="""SELECT DISTINCT a.grantee_name as name, b.email FROM snowflake.account_usage.grants_to_users AS a LEFT JOIN snowflake.account_usage.users AS b ON a.grantee_name = b.name WHERE a.role = 'ACCOUNTADMIN' AND a.deleted_on IS NULL AND b.email IS NULL AND b.deleted_on IS NULL AND NOT b.disabled;""" 276 | ), 277 | ], 278 | required_privileges="Requires SECURITY_VIEWER role on the SNOWFLAKE database.", 279 | results_expected=False, 280 | remediation=[ 281 | SecurityRemediation( 282 | description="Add email address to ACCOUNTADMIN user.", 283 | action="ALTER USER SET EMAIL = ;", 284 | ) 285 | ], 286 | references=[ 287 | SecurityReference( 288 | url="https://docs.snowflake.com/en/user-guide/admin-user-management.html#resetting-the-password-for-an-administrator", 289 | name="Snowflake docs for modifying administrator accounts", 290 | ) 291 | ], 292 | ), 293 | SecurityTask( 294 | name="ensure_no_users_have_accountadmin_or_security_admin_as_default", 295 | description="The ACCOUNTADMIN system role is the most powerful role in a Snowflake account and is intended for performing initial setup and managing account-level objects. SECURITYADMIN role can trivially escalate their privileges to that of ACCOUNTADMIN. Neither of these roles should be used for performing daily non-administrative tasks in a Snowflake account.", 296 | control="Foundations", 297 | control_id="1.12", 298 | queries=[ 299 | Sql( 300 | statement="SELECT NAME, DEFAULT_ROLE FROM SNOWFLAKE.ACCOUNT_USAGE.USERS WHERE DEFAULT_ROLE IN ('ACCOUNTADMIN', 'SECURITYADMIN') AND DELETED_ON IS NULL AND NOT DISABLED;" 301 | ), 302 | ], 303 | required_privileges="Requires SECURITY_VIEWER role on the Snowflake database.", 304 | results_expected=False, 305 | remediation="ALTER USER SET DEFAULT_ROLE = ;", 306 | references=[ 307 | SecurityReference( 308 | url="https://docs.snowflake.com/en/user-guide/security-access-control-configure.html", 309 | name="Snowflake documentation for access control configuration", 310 | ) 311 | ], 312 | ), 313 | SecurityTask( 314 | name="ensure_custom_roles_not_granted_accountadmin_securityadmin", 315 | description="The principle of least privilege requires that every identity is only given privileges that are necessary to complete its tasks. The ACCOUNTADMIN system role is the most powerful role in a Snowflake account and is intended for performing initial setup and managing account-level objects. SECURITYADMIN role can trivially escalate their privileges to that of ACCOUNTADMIN. Neither of these roles should be used for performing daily non-administrative tasks in a Snowflake account.", 316 | control="Foundations", 317 | control_id="1.13", 318 | queries=[ 319 | Sql( 320 | statement="""SELECT GRANTEE_NAME, PRIVILEGE AS GRANTED_PRIVILEGE, NAME AS GRANTED_ROLE FROM SNOWFLAKE.ACCOUNT_USAGE.GRANTS_TO_ROLES WHERE GRANTED_ON = 'ROLE' AND NAME IN ('ACCOUNTADMIN','SECURITYADMIN') AND GRANTEE_NAME NOT IN ('ACCOUNTADMIN') AND DELETED_ON IS NULL;""" 321 | ), 322 | ], 323 | required_privileges="Requires SECURITY_VIEWER role on the Snowflake database.", 324 | results_expected=False, 325 | remediation=[ 326 | SecurityRemediation( 327 | description="Revoke ACCOUNTADMIN or SECURITYADMIN from custom role(s)", 328 | action="REVOKE SECURITYADMIN ON ACCOUNT FROM ROLE ; REVOKE ACCOUNTADMIN ON ACCOUNT FROM ROLE ;", 329 | ) 330 | ], 331 | references=[ 332 | SecurityReference( 333 | url="https://docs.snowflake.com/en/user-guide/security-access-control-configure.html", 334 | name="Snowflake documentation for access control configuration", 335 | ) 336 | ], 337 | ), 338 | SecurityTask( 339 | name="ensure_tasks_not_owned_by_accountadmin_securityadmin", 340 | description="The ACCOUNTADMIN system role is the most powerful role in a Snowflake account and is intended for performing initial setup and managing account-level objects. SECURITYADMIN role can trivially escalate their privileges to that of ACCOUNTADMIN. Neither of these roles should be used for running Snowflake tasks. A task should be running using a custom role containing only those privileges that are necessary for successful execution of the task. Snowflake executes tasks with the privileges of the task owner. The role that has OWNERSHIP privilege on the task owns the task.", 341 | control="Foundations", 342 | control_id="1.14", 343 | queries=[ 344 | Sql( 345 | statement="SELECT NAME AS STORED_PROCEDURE_NAME, GRANTED_TO, GRANTEE_NAME AS ROLE_NAME, PRIVILEGE FROM SNOWFLAKE.ACCOUNT_USAGE.GRANTS_TO_ROLES WHERE GRANTED_ON = 'TASK' AND DELETED_ON IS NULL AND GRANTED_TO = 'ROLE' AND PRIVILEGE = 'OWNERSHIP' AND GRANTEE_NAME IN ('ACCOUNTADMIN' , 'SECURITYADMIN');" 346 | ), 347 | ], 348 | required_privileges="Requires SECURITY_VIEWER role on the Snowflake database.", 349 | results_expected=False, 350 | remediation=[ 351 | SecurityRemediation( 352 | description="Create and assign a task-specific role to overprivileged tasks", 353 | action="CREATE ROLE ; GRANT OWNERSHIP ON TASK TO ROLE ;", 354 | ), 355 | SecurityRemediation( 356 | description="Revoke elevated privileges from tasks", 357 | action="REVOKE ALL PRIVILEGES ON TASK FROM ROLE ACCOUNTADMIN; REVOKE ALL PRIVILEGES ON TASK FROM ROLE SECURITYADMIN;", 358 | ), 359 | ], 360 | references=[ 361 | SecurityReference( 362 | url="https://docs.snowflake.com/en/user-guide/tasks-intro.html#task-security", 363 | name="Snowflake documentation for task security", 364 | ), 365 | SecurityReference( 366 | url="https://docs.snowflake.com/en/user-guide/security-access-control-configure.html", 367 | name="Snowflake documentation for access control configuration", 368 | ), 369 | SecurityReference( 370 | url="https://docs.snowflake.com/en/user-guide/security-access-control-considerations.html", 371 | name="Snowflake documentation for access control considerations", 372 | ), 373 | ], 374 | ), 375 | SecurityTask( 376 | name="ensure_tasks_do_not_run_with_accountadmin_securityadmin", 377 | description="The ACCOUNTADMIN system role is the most powerful role in a Snowflake account and is intended for performing initial setup and managing account-level objects. SECURITYADMIN role can trivially escalate their privileges to that of ACCOUNTADMIN. Neither of these roles should be used for running Snowflake tasks. A task should be running using a custom role containing only those privileges that are necessary for successful execution of the task.", 378 | control="Foundations", 379 | control_id="1.15", 380 | queries=[ 381 | Sql( 382 | statement="""SELECT NAME, GRANTED_TO, GRANTEE_NAME, PRIVILEGE FROM SNOWFLAKE.ACCOUNT_USAGE.GRANTS_TO_ROLES WHERE GRANTED_ON = 'TASK' AND DELETED_ON IS NULL AND GRANTED_TO = 'ROLE' AND GRANTEE_NAME IN ('ACCOUNTADMIN' , 'SECURITYADMIN');""", 383 | ), 384 | ], 385 | required_privileges="Requires the SECURITY_VIEWER role on the Snowflake database.", 386 | results_expected=False, 387 | remediation=[ 388 | SecurityRemediation( 389 | description="Create and assign a task-specific role to overprivileged tasks", 390 | action="CREATE ROLE ; GRANT OWNERSHIP ON TASK TO ROLE ;", 391 | ), 392 | SecurityRemediation( 393 | description="Revoke elevated privileges from tasks", 394 | action="REVOKE ALL PRIVILEGES ON TASK FROM ROLE ACCOUNTADMIN; REVOKE ALL PRIVILEGES ON TASK FROM ROLE SECURITYADMIN;", 395 | ), 396 | ], 397 | references=[ 398 | SecurityReference( 399 | url="https://docs.snowflake.com/en/user-guide/tasks-intro.html#task-security", 400 | name="Snowflake documentation for task security", 401 | ), 402 | SecurityReference( 403 | url="https://docs.snowflake.com/en/user-guide/security-access-control-configure.html", 404 | name="Snowflake documentation for access control configuration", 405 | ), 406 | SecurityReference( 407 | url="https://docs.snowflake.com/en/user-guide/security-access-control-considerations.html", 408 | name="Snowflake documentation for access control considerations", 409 | ), 410 | ], 411 | ), 412 | SecurityTask( 413 | name="ensure_stored_procedures_not_owned_by_accountadmin_securityadmin", 414 | description="The ACCOUNTADMIN system role is the most powerful role in a Snowflake account and is intended for performing initial setup and managing account-level objects. SECURITYADMIN role can trivially escalate their privileges to that of ACCOUNTADMIN. Neither of these roles should be used for running Snowflake stored procedures. A stored procedure should be running using a custom role containing only those privileges that are necessary for successful execution of the stored procedure.", 415 | control="Foundations", 416 | control_id="1.16", 417 | queries=[ 418 | Sql( 419 | statement="SELECT * FROM SNOWFLAKE.ACCOUNT_USAGE.PROCEDURES WHERE DELETED IS NULL AND PROCEDURE_OWNER IN ('ACCOUNTADMIN','SECURITYADMIN');" 420 | ), 421 | ], 422 | required_privileges="Requires the SECURITY_VIEWER role on the Snowflake database.", 423 | results_expected=False, 424 | remediation=[ 425 | SecurityRemediation( 426 | description="For each stored procedure that runs with ACCOUNTADMIN or SECURITYADMIN privileges, create a new role and assign it to the stored procedure.", 427 | action="CREATE ROLE ; GRANT OWNERSHIP ON PROCEDURE TO ROLE ;", 428 | ), 429 | SecurityRemediation( 430 | description="After creating a new role and granting ownership of each stored procedure to it, for each stored procedure that is owned by ACCOUNTADMIN or SECURITYADMIN roles, ensure all privileges on the stored procedure are revoked from the roles.", 431 | action="REVOKE ALL PRIVILEGES ON PROCEDURE FROM ROLE ACCOUNTADMIN; REVOKE ALL PRIVILEGES ON PROCEDURE FROM ROLE SECURITYADMIN;", 432 | ), 433 | ], 434 | references=[ 435 | SecurityReference( 436 | url="https://docs.snowflake.com/en/sql-reference/stored-procedures-rights", 437 | name="Snowflake documentation for stored procedure rights", 438 | ), 439 | SecurityReference( 440 | url="https://docs.snowflake.com/en/user-guide/security-access-control-configure.html", 441 | name="Snowflake documentation for access control configuration", 442 | ), 443 | SecurityReference( 444 | url="https://docs.snowflake.com/en/user-guide/security-access-control-considerations.html", 445 | name="Snowflake documentation for access control considerations", 446 | ), 447 | ], 448 | ), 449 | SecurityTask( 450 | name="ensure_stored_procedures_do_not_run_with_accountadmin_securityadmin", 451 | description="The ACCOUNTADMIN system role is the most powerful role in a Snowflake account; it is intended for performing initial setup and managing account-level objects. Users and stored procedures with the SECURITYADMIN role can escalate their privileges to ACCOUNTADMIN. Snowflake stored procedures should not run with the ACCOUNTADMIN or SECURITYADMIN roles. Instead, stored procedures should be run using a custom role containing only those privileges that are necessary for successful execution of the stored procedure.", 452 | control="Foundations", 453 | control_id="1.17", 454 | queries=[ 455 | Sql( 456 | statement="SELECT NAME, GRANTED_TO, GRANTEE_NAME FROM SNOWFLAKE.ACCOUNT_USAGE.GRANTS_TO_ROLES WHERE GRANTED_ON = 'PROCEDURE' AND DELETED_ON IS NULL AND GRANTED_TO = 'ROLE' AND GRANTEE_NAME IN ('ACCOUNTADMIN' , 'SECURITYADMIN');" 457 | ), 458 | ], 459 | required_privileges="Requires the SECURITY_VIEWER role on the Snowflake database.", 460 | results_expected=False, 461 | remediation=[ 462 | SecurityRemediation( 463 | description="Create and assign a task-specific role to overprivileged tasks", 464 | action="CREATE ROLE ; GRANT OWNERSHIP ON TASK TO ROLE ;", 465 | ), 466 | SecurityRemediation( 467 | description="Revoke elevated privileges from tasks", 468 | action="REVOKE ALL PRIVILEGES ON TASK FROM ROLE ACCOUNTADMIN; REVOKE ALL PRIVILEGES ON TASK FROM ROLE SECURITYADMIN;", 469 | ), 470 | ], 471 | references=[ 472 | SecurityReference( 473 | url="https://docs.snowflake.com/en/user-guide/tasks-intro.html#task-security", 474 | name="Snowflake documentation for task security", 475 | ), 476 | SecurityReference( 477 | url="https://docs.snowflake.com/en/user-guide/security-access-control-configure.html", 478 | name="Snowflake documentation for access control configuration", 479 | ), 480 | SecurityReference( 481 | url="https://docs.snowflake.com/en/user-guide/security-access-control-considerations.html", 482 | name="Snowflake documentation for access control considerations", 483 | ), 484 | ], 485 | ), 486 | ], 487 | ) 488 | -------------------------------------------------------------------------------- /src/cli/core/security/playbooks/unc5537_breach.py: -------------------------------------------------------------------------------- 1 | from cli.core.snowflake.sql import Sql 2 | from cli.core.security.types import ( 3 | SecurityPlaybook, 4 | SecurityTask, 5 | SecurityReference, 6 | SecurityRemediation, 7 | ) 8 | 9 | 10 | UNC5537_BREACH_PLAYBOOK = SecurityPlaybook( 11 | name="UNC5537 Snowflake Breach", 12 | description="Playbook for the UNC5537 breach threat hunting", 13 | tasks=[ 14 | SecurityTask( 15 | name="select_all_without_where", 16 | description="select * queries that don't contain a where predicate", 17 | queries=[ 18 | Sql( 19 | statement="""SELECT query_id, user_name, query_text FROM snowflake.account_usage.query_history WHERE query_text ILIKE '%SELECT *%' AND query_text NOT ILIKE '%WHERE%';""" 20 | ) 21 | ], 22 | ), 23 | SecurityTask( 24 | name="copy_into_select_all", 25 | description="COPY INTO and select * in a single query.", 26 | queries=[ 27 | Sql( 28 | statement="""SELECT query_id, user_name, query_text FROM snowflake.account_usage.query_history WHERE query_text ILIKE '%COPY INTO%' AND query_text ILIKE '%SELECT *%' and query_text not ilike '%account_usage.query_history%';""" 29 | ) 30 | ], 31 | ), 32 | SecurityTask( 33 | name="show_tables_executed", 34 | description="Instances of a SHOW TABLES query being executed", 35 | queries=[ 36 | Sql( 37 | statement="""SELECT query_id, start_time, user_name, query_text FROM snowflake.account_usage.query_history WHERE query_text ILIKE '%SHOW TABLES%' and query_text not ilike '%account_usage.query_history%'order by start_time desc;""" 38 | ) 39 | ], 40 | ), 41 | SecurityTask( 42 | name="dbeaver_used", 43 | description="dbeaver usage", 44 | queries=[ 45 | Sql( 46 | statement="""SELECT created_on, user_name, authentication_method, PARSE_JSON(client_environment) :APPLICATION :: STRING AS client_application, PARSE_JSON(client_environment) :OS :: STRING AS client_os, PARSE_JSON(client_environment) :OS_VERSION :: STRING AS client_os_version, session_id FROM snowflake.account_usage.sessions, WHERE PARSE_JSON(CLIENT_ENVIRONMENT):APPLICATION ilike '%DBeaver_DBeaverUltimate%' ORDER BY CREATED_ON;""" 47 | ) 48 | ], 49 | ), 50 | SecurityTask( 51 | name="create_temp_storage", 52 | description="Attackers often create a temp storage location as a staging location", 53 | queries=[ 54 | Sql( 55 | statement="""SELECT query_id, user_name, query_text FROM snowflake.account_usage.query_history WHERE query_text ilike '%create%temp%' and query_text not ilike '%account_usage.query_history%';""" 56 | ) 57 | ], 58 | ), 59 | SecurityTask( 60 | name="10_largest_queries", 61 | description="Returns the 10 largest queries by rows produced. These queries should be reviewed.", 62 | queries=[ 63 | Sql( 64 | statement="""SELECT query_id, user_name, query_text, rows_produced FROM snowflake.account_usage.query_history WHERE rows_produced > 2000 ORDER BY rows_produced DESC LIMIT 10;""" 65 | ) 66 | ], 67 | ), 68 | SecurityTask( 69 | name="grants_on_accountadmin_past_week", 70 | description="Grants to ACCOUNTADMIN (sudo) in the past week. These grants should be reviewed.", 71 | queries=[ 72 | Sql( 73 | statement="""select query_id, start_time, user_name || ' granted the ' || role_name || ' role on ' || end_time ||' [' || query_text ||']' as Grants from snowflake.account_usage.query_history where start_time >= current_timestamp() - interval '1 week' and execution_status = 'SUCCESS' and query_type = 'GRANT' and query_text ilike '%grant%accountadmin%to%' order by end_time desc;""" 74 | ) 75 | ], 76 | ), 77 | SecurityTask( 78 | name="impactful_modifications_past_week", 79 | description="A list of all impactful modifications to the Snowflake account. These modifications should be reviewed for suspicious activity.", 80 | queries=[ 81 | Sql( 82 | statement="""SELECT start_time, user_name, role_name, query_type, query_text FROM snowflake.account_usage.query_history WHERE start_time >= current_timestamp() - interval '1 week' and execution_status = 'SUCCESS' AND query_type NOT in ('SELECT') AND query_type NOT in ('SHOW') AND query_type NOT in ('DESCRIBE') AND (query_text ILIKE '%create role%' OR query_text ILIKE '%manage grants%' OR query_text ILIKE '%create integration%' OR query_text ILIKE '%alter integration%' OR query_text ILIKE '%create share%' OR query_text ILIKE '%create account%' OR query_text ILIKE '%moni or usage%' OR query_text ILIKE '%ownership%' OR query_text ILIKE '%drop table%' OR query_text ILIKE '%drop database%' OR query_text ILIKE '%create stage%' OR query_text ILIKE '%drop stage%' OR query_text ILIKE '%alter stage%' OR query_text ILIKE '%create user%' OR query_text ILIKE '%alter user%' OR query_text ILIKE '%drop user%' OR query_text ILIKE '%create_network_policy%' OR query_text ILIKE '%alter_network_policy%' OR query_text ILIKE '%drop_network_policy%' OR query_text ILIKE '%copy%') and query_text not ilike '%account_usage.query_history%' ORDER BY end_time desc;""" 83 | ) 84 | ], 85 | ), 86 | SecurityTask( 87 | name="copy_http", 88 | description="All instances of COPY INTO being run with an HTTP destination. Review for suspicious activity.", 89 | queries=[ 90 | Sql( 91 | statement="""SELECT *, FROM snowflake.account_usage.query_history where query_text ilike '%copy%into%http%' and query_text not ilike '%account_usage.query_history%';""" 92 | ) 93 | ], 94 | ), 95 | SecurityTask( 96 | name="get_file_from_stage", 97 | description="", 98 | queries=[ 99 | Sql( 100 | statement="""select query_id, start_time, user_name, query_text from snowflake.account_usage.query_history where query_text ilike '%get%file%' and query_text not ilike '%account_usage.query_history%' and user_name not ilike '%worksheets_app_user%' and query_text not ilike '%worksheet_data/metadata%';""" 101 | ) 102 | ], 103 | ), 104 | SecurityTask( 105 | name="least_common_applications_used_past_week", 106 | description="", 107 | queries=[ 108 | Sql( 109 | statement="""select count(*) as client_app_count, PARSE_JSON(client_environment) :APPLICATION :: STRING AS client_application, PARSE_JSON(client_environment) :OS :: STRING AS client_os, PARSE_JSON(client_environment) :OS_VERSION :: STRING AS client_os_version FROM snowflake.account_usage.sessions sessions WHERE created_on >= current_timestamp() - interval '1 week' group by all order by 1 asc limit 10;""" 110 | ) 111 | ], 112 | ), 113 | SecurityTask( 114 | name="brute_force_on_user_past_month", 115 | description="Identify instances of mass failed login attempts", 116 | control="MITRE ATT&CK", 117 | control_id="T1110", 118 | queries=[ 119 | Sql( 120 | statement="""select CLIENT_IP, USER_NAME, REPORTED_CLIENT_TYPE, count(*) as FAILED_ATTEMPTS, min(EVENT_TIMESTAMP) as FIRST_EVENT, max(EVENT_TIMESTAMP) as LAST_EVENT from SNOWFLAKE.ACCOUNT_USAGE.LOGIN_HISTORY where IS_SUCCESS = 'NO' and ERROR_MESSAGE in ('INCORRECT_USERNAME_PASSWORD', 'USER_LOCKED_TEMP') and FIRST_AUTHENTICATION_FACTOR='PASSWORD' and EVENT_TIMESTAMP >= DATEADD(MONTH, -1, CURRENT_TIMESTAMP()) group by 1,2,3 having FAILED_ATTEMPTS >= 5 order by 4 desc;""" 121 | ) 122 | ], 123 | remediation="""For each result check if the source IP successfully logged in as the target user after the lastEvent time""", 124 | ), 125 | SecurityTask( 126 | name="failed_login_on_disabled_user", 127 | description="Identify user logins that have failed due to the user being disabled", 128 | control="MITRE ATT&CK", 129 | control_id="T1110", 130 | queries=[ 131 | Sql( 132 | statement="""select * from SNOWFLAKE.ACCOUNT_USAGE.LOGIN_HISTORY where IS_SUCCESS = 'NO' and ERROR_MESSAGE = 'USER_ACCESS_DISABLED'""" 133 | ) 134 | ], 135 | ), 136 | SecurityTask( 137 | name="login_attempt_blocked_by_network_policy", 138 | description="Identify user logins that have failed due to the user being blocked by the network ip policy", 139 | queries=[ 140 | Sql( 141 | statement="""select * from SNOWFLAKE.ACCOUNT_USAGE.LOGIN_HISTORY where IS_SUCCESS = 'NO' and ERROR_MESSAGE = 'INCOMING_IP_BLOCKED' and EVENT_TIMESTAMP >= DATEADD(HOUR, -24, CURRENT_TIMESTAMP());""" 142 | ) 143 | ], 144 | ), 145 | SecurityTask( 146 | name="recently_created_shares_past_month", 147 | description="Identify instances of newly-created shares in the past month", 148 | control="MITRE ATT&CK", 149 | control_id="T1537", 150 | queries=[ 151 | Sql( 152 | statement="""select query_id, start_time, user_name, query_text from SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY where (QUERY_TEXT ilike '%create%share%' and QUERY_TEXT NOT ILIKE '%account_usage%') and START_TIME>= DATEADD(HOUR, -24, CURRENT_TIMESTAMP());""" 153 | ) 154 | ], 155 | ), 156 | SecurityTask( 157 | name="stages_created_past_24_hours", 158 | description="Identify all stages created in the last 24 hours", 159 | control="MITRE ATT&CK", 160 | control_id="T1537", 161 | queries=[ 162 | Sql( 163 | statement="""select * from SNOWFLAKE.ACCOUNT_USAGE.STAGES where CREATED>= DATEADD(HOUR, -24, CURRENT_TIMESTAMP());""" 164 | ) 165 | ], 166 | ), 167 | SecurityTask( 168 | name="tasks_created_past_24_hours", 169 | description="Identify all tasks created in the last 24 hours", 170 | queries=[ 171 | Sql( 172 | statement="""select * from SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY where QUERY_TEXT ilike '%create%task%' and QUERY_TEXT not ilike '%SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY%' and START_TIME >= DATEADD(HOUR, -24, CURRENT_TIMESTAMP());""" 173 | ) 174 | ], 175 | ), 176 | SecurityTask( 177 | name="procedures_created_past_24_hours", 178 | description="Identify all stored procedures created in the last 24 hours", 179 | queries=[ 180 | Sql( 181 | statement="""select * from SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY where QUERY_TEXT ilike '%create%procedure%' and QUERY_TEXT not ilike '%SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY%' and START_TIME >= DATEADD(HOUR, -24, CURRENT_TIMESTAMP());""" 182 | ) 183 | ], 184 | ), 185 | SecurityTask( 186 | name="login_failure_statistics", 187 | description="Summarize login failure statistics by user", 188 | queries=[ 189 | Sql( 190 | statement="""WITH error_stats AS (SELECT START_TIME::date as date, USER_NAME, COUNT(*) AS error_count FROM SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY WHERE error_code != 'NULL' GROUP BY date, USER_NAME), total_queries AS (SELECT START_TIME::date as date, USER_NAME, COUNT(*) AS total_queries FROM SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY GROUP BY date, USER_NAME), final_stats AS (SELECT tq.date, tq.USER_NAME, tq.total_queries, COALESCE(es.error_count, 0) AS error_count, (COALESCE(es.error_count, 0) / tq.total_queries) * 100 AS daily_error_percentage FROM total_queries tq LEFT JOIN error_stats es ON tq.date = es.date AND tq.USER_NAME = es.USER_NAME) SELECT * FROM final_stats order by date desc, user_name;""" 191 | ) 192 | ], 193 | ), 194 | ], 195 | ) 196 | -------------------------------------------------------------------------------- /src/cli/core/security/runner.py: -------------------------------------------------------------------------------- 1 | from rich import print 2 | from typing import Union 3 | from cli.core.security.types import SecurityTask, SecurityPlaybook 4 | from cli.core.snowflake.connection import SnowflakeCursor 5 | from cli.core.snowflake.query import query_all, tabulate_to_stdout 6 | from cli.core.logging import logger 7 | 8 | 9 | def run_security_playbook( 10 | cursor: SnowflakeCursor, 11 | playbook: SecurityPlaybook, 12 | task_name: str = None, 13 | verbose: bool = True, 14 | ): 15 | if task_name: 16 | task = playbook.get_task(task_name) 17 | results = run_security_task(cursor, task, verbose) 18 | tabulate_to_stdout(results) 19 | return 20 | for task in playbook.tasks: 21 | results = run_security_task(cursor, task, verbose) 22 | tabulate_to_stdout(results) 23 | print("\n\n") 24 | 25 | 26 | def run_security_task( 27 | cursor: SnowflakeCursor, task: SecurityTask, verbose: bool = True 28 | ) -> list[dict]: 29 | msg = f"[NAME] {task.name}\n[DESCRIPTION] {task.description}\n[CONTROL] {task.control}\n[CONTROL ID] {task.control_id}" 30 | result = None 31 | if verbose: 32 | print(msg) 33 | for query in task.queries: 34 | result = query_all(cursor, query) 35 | return result # Only return the last query result 36 | -------------------------------------------------------------------------------- /src/cli/core/security/types.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from functools import cached_property 3 | 4 | from cli.core.snowflake.sql import Sql 5 | 6 | 7 | @dataclass 8 | class SecurityReference: 9 | name: str 10 | url: str 11 | 12 | 13 | @dataclass 14 | class SecurityRemediation: 15 | description: str 16 | action: str 17 | 18 | 19 | @dataclass 20 | class SecurityTask: 21 | name: str 22 | description: str 23 | rationale: str = None 24 | control: str = None 25 | control_id: str = None 26 | severity: int = None 27 | queries: list[Sql] = None 28 | required_privileges: str = None 29 | results_expected: bool = False 30 | remediation: str = None 31 | references: list[SecurityReference] = None 32 | callback: callable = None 33 | 34 | 35 | @dataclass 36 | class SecurityPlaybook: 37 | name: str 38 | description: str 39 | tasks: list[SecurityTask] 40 | 41 | @cached_property 42 | def named_tasks(self) -> dict[str, SecurityTask]: 43 | return {task.name: task for task in self.tasks} 44 | 45 | def get_task(self, name: str) -> SecurityTask: 46 | return self.tasks[name] 47 | -------------------------------------------------------------------------------- /src/cli/core/snowflake/connection.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | from typing import Generator, Optional 3 | from uuid import uuid4 4 | from dataclasses import dataclass 5 | 6 | from snowflake.connector import DictCursor, connect 7 | from snowflake.connector.connection import SnowflakeConnection 8 | from snowflake.connector.cursor import SnowflakeCursor 9 | 10 | 11 | from cli.core.util.time import iso_now 12 | from cli.core.constants import SFCLI, SFCLI_DEFAULT_PRIV_KEY_PATH 13 | from cli.core.logging import logger 14 | from cli.core.util.key import get_private_key_contents 15 | 16 | 17 | @dataclass 18 | class ConnectionParams: 19 | """An object to represent Snowflake connection parameters""" 20 | 21 | accountname: str 22 | username: str 23 | private_key_path: str = SFCLI_DEFAULT_PRIV_KEY_PATH 24 | warehouse: str = None 25 | role: str = None 26 | query_tag: str = SFCLI 27 | 28 | 29 | @dataclass 30 | class NamedConnection: 31 | """An object to represent a named Snowflake connection""" 32 | 33 | name: str 34 | params: ConnectionParams 35 | 36 | 37 | def snowflake_connection(params: ConnectionParams) -> SnowflakeConnection: 38 | """Generate a Snowflake Connection""" 39 | private_key = get_private_key_contents() 40 | connection_id = str(uuid4()) 41 | query_tag = params.query_tag if params.query_tag else SFCLI 42 | logger.debug( 43 | f"opening connection with id: {connection_id}, query_tag: {query_tag}, params: {params}" 44 | ) 45 | connection = connect( 46 | account=params.accountname, 47 | user=params.username, 48 | private_key=private_key, 49 | warehouse=params.warehouse, 50 | role=params.role, 51 | client_telemetry_enabled=False, 52 | session_parameters={ 53 | "connection_id": connection_id, 54 | "connection_start": iso_now(), 55 | "query_tag": query_tag, 56 | }, 57 | client_sesion_keep_alive=True, 58 | login_timeout=5, 59 | ) 60 | connection._telemetry_enabled = False # pylint: disable=W0212 61 | connection._application = SFCLI # pylint: disable=W0212 62 | connection._internal_application_name = SFCLI # pylint: disable=W0212 63 | connection._internal_application_version = "beta" # pylint: disable=W0212 64 | connection.service_name = SFCLI # pylint: disable=W0212 65 | return connection 66 | 67 | 68 | def snowflake_cursor(params: ConnectionParams) -> SnowflakeCursor: 69 | """Generate a Snowflake Cursor""" 70 | connection = snowflake_connection(params) 71 | cursor = connection.cursor(DictCursor) 72 | return cursor 73 | 74 | 75 | @contextmanager 76 | def cursor(params: ConnectionParams) -> Generator[SnowflakeCursor, None, None]: 77 | """A helper context manager used to generate a cursor with some niceties""" 78 | cursor = snowflake_cursor(params) 79 | try: 80 | yield cursor 81 | cursor.connection.commit() 82 | except Exception: 83 | cursor.connection.rollback() 84 | raise 85 | cursor.close() 86 | return 87 | -------------------------------------------------------------------------------- /src/cli/core/snowflake/query.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from snowflake.connector.cursor import SnowflakeCursor 4 | from rich import print 5 | from rich.console import Console 6 | from rich.table import Table 7 | 8 | from cli.core.snowflake.sql import Sql 9 | from cli.core.logging import logger 10 | 11 | 12 | def execute(cursor: SnowflakeCursor, sql: Sql) -> None: 13 | """Execute a sql statement with the provided cursor""" 14 | logger.debug(f"executing sql: {sql.statement}") 15 | return cursor.execute(sql.statement) 16 | 17 | 18 | def query_all(cursor: SnowflakeCursor, sql: Sql) -> list[dict]: 19 | """Execute a sql statement with the provided cursor""" 20 | logger.debug(f"executing sql: {sql.statement}") 21 | return cursor.execute(sql.statement).fetchall() 22 | 23 | 24 | def query_first(cursor: SnowflakeCursor, sql: Sql) -> dict: 25 | """Execute a sql statement with the provided cursor""" 26 | logger.debug(f"executing sql: {sql.statement}") 27 | return cursor.execute(sql.statement).fetchone() 28 | 29 | 30 | def get_keys_from_results(results: Union[list[dict], dict]) -> list[str]: 31 | """Get the keys from a result""" 32 | if isinstance(results, list): 33 | columns = results[0].keys() 34 | if isinstance(results, dict): 35 | columns = results.keys() 36 | return list(columns) 37 | 38 | 39 | def tabulate_to_stdout(results: Union[list[dict], dict], table_name: str = None) -> str: 40 | """Formate results as a table to stdout""" 41 | tbl = Table(title=table_name) 42 | if isinstance(results, list): 43 | if len(results) == 0: 44 | # Short-circuit if no results are returned 45 | print("[bold green]No results found[/bold green]") 46 | return 47 | columns = get_keys_from_results(results) 48 | if isinstance(results, list): 49 | rows = results 50 | else: 51 | rows = [results] 52 | 53 | for column in columns: 54 | tbl.add_column(column, style="magenta", no_wrap=True) 55 | 56 | for row in rows: 57 | tbl.add_row(*[str(row[column]) for column in columns], style="cyan") 58 | 59 | console = Console() 60 | console.print(tbl) 61 | -------------------------------------------------------------------------------- /src/cli/core/snowflake/sql.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Union 3 | 4 | from cli.core.snowflake.connection import SnowflakeCursor 5 | from cli.core.logging import logger 6 | 7 | 8 | @dataclass 9 | class Sql: 10 | """A class to encapsulate a sql string. 11 | 12 | Useful when programmatically constructing sql statements 13 | and wanting to keep clean type annotations. 14 | """ 15 | 16 | statement: str 17 | 18 | def __post_init__(self): 19 | """TODO!! 20 | 21 | Validate the sql statement is valid. Sqlfluff it or something. 22 | """ 23 | return 24 | 25 | def __str__(self) -> str: 26 | return self.statement 27 | 28 | 29 | @dataclass 30 | class Fqn: 31 | """ 32 | A single, bi, or tri-level fully-qualified name to a Snowflake resource. 33 | """ 34 | 35 | namespace: str 36 | 37 | @property 38 | def fqn_parts(self) -> list: 39 | """The parts of a single, bi, or tri-level fqn""" 40 | return self.namespace.split(".") 41 | 42 | @property 43 | def database(self) -> str: 44 | """The database.""" 45 | return self.fqn_parts[0] 46 | 47 | @property 48 | def schema(self) -> str: 49 | """The schema.""" 50 | return self.fqn_parts[1] 51 | 52 | @property 53 | def resource(self) -> str: 54 | """The resource name.""" 55 | return self.fqn_parts[-1] 56 | 57 | @property 58 | def parent(self) -> Union[str, None]: 59 | """The parent resource name.""" 60 | if len(self.fqn_parts) == 1: 61 | return self.resource 62 | if len(self.fqn_parts) == 2: 63 | return self.database 64 | else: 65 | return f"{self.database}.{self.schema}" 66 | 67 | def __str__(self) -> str: 68 | return self.namespace 69 | -------------------------------------------------------------------------------- /src/cli/core/util/key.py: -------------------------------------------------------------------------------- 1 | from cryptography.hazmat.backends import default_backend 2 | from cryptography.hazmat.primitives.asymmetric import rsa 3 | from cryptography.hazmat.primitives.asymmetric import dsa 4 | from cryptography.hazmat.primitives import serialization 5 | import os 6 | from pathlib import Path 7 | import shutil 8 | import subprocess 9 | 10 | import pyperclip 11 | 12 | from cli.core.fs import get_file_contents 13 | from cli.core.logging import logger 14 | from cli.core.constants import ( 15 | SFCLI_DIR, 16 | SFCLI_KEYPAIR_PUB_NAME, 17 | SFCLI_KEYPAIR_PRIV_NAME, 18 | SFCLI_DEFAULT_PRIV_KEY_PATH, 19 | ) 20 | 21 | 22 | def get_private_key_contents( 23 | private_key_path: Path = SFCLI_DEFAULT_PRIV_KEY_PATH, 24 | ) -> str: 25 | private_key_passphrase = os.environ.get("PRIVATE_KEY_PASSPHRASE") 26 | if private_key_passphrase: 27 | private_key_passphrase = private_key_passphrase.encode() 28 | logger.debug(f"getting private key contents from file {private_key_path}") 29 | with open(private_key_path, "rb") as key: 30 | p_key = serialization.load_pem_private_key( 31 | key.read(), 32 | password=private_key_passphrase, 33 | backend=default_backend(), 34 | ) 35 | return p_key.private_bytes( 36 | encoding=serialization.Encoding.DER, 37 | format=serialization.PrivateFormat.PKCS8, 38 | encryption_algorithm=serialization.NoEncryption(), 39 | ) 40 | 41 | 42 | def generate_private_key() -> None: 43 | key = subprocess.Popen( 44 | [ 45 | "openssl", 46 | "genrsa", 47 | "2048", 48 | ], 49 | stdout=subprocess.PIPE, 50 | ) 51 | key.wait() 52 | out = subprocess.Popen( 53 | [ 54 | "openssl", 55 | "pkcs8", 56 | "-topk8", 57 | "-inform", 58 | "PEM", 59 | "-out", 60 | SFCLI_KEYPAIR_PRIV_NAME, 61 | "-nocrypt", # TODO -> Add encrypted key option 62 | ], 63 | stdin=key.stdout, 64 | ) 65 | out.wait() 66 | 67 | 68 | def generate_public_key() -> None: 69 | key = subprocess.Popen( 70 | [ 71 | "openssl", 72 | "rsa", 73 | "-in", 74 | SFCLI_KEYPAIR_PRIV_NAME, 75 | "-pubout", 76 | "-out", 77 | SFCLI_KEYPAIR_PUB_NAME, 78 | ], 79 | ) 80 | key.wait() 81 | 82 | 83 | def ensure_key_permissions( 84 | priv_key=SFCLI_KEYPAIR_PRIV_NAME, pub_key=SFCLI_KEYPAIR_PUB_NAME 85 | ) -> None: 86 | """Make sure keys are appropriately privileged, instead of simply saying "you're on you're own" """ 87 | os.chmod(priv_key, 0o600) 88 | os.chmod(pub_key, 0o644) 89 | 90 | 91 | def relocate_keys( 92 | target_directory=SFCLI_DIR, keys=[SFCLI_KEYPAIR_PRIV_NAME, SFCLI_KEYPAIR_PUB_NAME] 93 | ) -> None: 94 | for key in keys: 95 | src = Path(key) 96 | dest = Path(target_directory, key) 97 | shutil.move(src, dest) 98 | 99 | 100 | def generate_keypair(relocate_to_dir=None, copy_to_clipboard=True) -> None: 101 | generate_private_key() 102 | generate_public_key() 103 | ensure_key_permissions() 104 | pub_key = get_file_contents(Path(SFCLI_KEYPAIR_PUB_NAME)) 105 | if relocate_to_dir: 106 | relocate_keys() 107 | if copy_to_clipboard: 108 | pyperclip.copy(pub_key) 109 | -------------------------------------------------------------------------------- /src/cli/core/util/time.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, date, timezone 2 | 3 | 4 | def difference_seconds(start: datetime, end: datetime) -> float: 5 | """Returns the difference in seconds between two datetimes""" 6 | return abs((end - start).total_seconds()) 7 | 8 | 9 | def utc_now() -> datetime: 10 | """Returns the current datetime with tz info in UTC.""" 11 | return datetime.now(timezone.utc) 12 | 13 | 14 | def iso_now() -> str: 15 | """Returns the current timestamp in iso8601""" 16 | return utc_now().isoformat() 17 | 18 | 19 | def today() -> date: 20 | """Returns the current date""" 21 | return utc_now().date() 22 | -------------------------------------------------------------------------------- /src/cli/database.py: -------------------------------------------------------------------------------- 1 | import typer 2 | 3 | app = typer.Typer(no_args_is_help=True) 4 | 5 | 6 | @app.command() 7 | def list(): 8 | """List all Snowflake databases""" 9 | return 10 | 11 | 12 | @app.command() 13 | def create(): 14 | """Create a Snowflake database""" 15 | return 16 | 17 | 18 | @app.command() 19 | def delete(name: str): 20 | """Delete a Snowflake database""" 21 | return 22 | 23 | 24 | @app.command() 25 | def stats(database: str): 26 | """Get statistics about a Snowflake database""" 27 | return 28 | -------------------------------------------------------------------------------- /src/cli/io.py: -------------------------------------------------------------------------------- 1 | import typer 2 | 3 | app = typer.Typer(no_args_is_help=True) 4 | 5 | 6 | @app.command(name="import") 7 | def bulk_import(): 8 | """Quickly import data to Snowflake from a local path or object storage""" 9 | return 10 | 11 | 12 | @app.command(name="export") 13 | def bulk_export(): 14 | """Export data from Snowflake to a local path or object storage""" 15 | return 16 | -------------------------------------------------------------------------------- /src/cli/keypair.py: -------------------------------------------------------------------------------- 1 | import typer 2 | 3 | from cli.core.util.key import generate_keypair 4 | from cli.core.constants import SFCLI_DIR 5 | 6 | 7 | app = typer.Typer(no_args_is_help=True) 8 | 9 | 10 | @app.command() 11 | def generate( 12 | destination_dir=SFCLI_DIR, 13 | ): # TODO -> add destination dir cli flag, add pre-existing keys check (and don't regen if one already exists) 14 | """Generate a public/private key pair, ensure privileges, move to ssh directory, and copy the contents of the public key to the clipboard""" 15 | generate_keypair(relocate_to_dir=SFCLI_DIR, copy_to_clipboard=True) 16 | return 17 | 18 | 19 | @app.command() 20 | def rotate(): 21 | """Rotate the existing public/private key pair""" 22 | generate_keypair(relocate_to_dir=SFCLI_DIR, copy_to_clipboard=True) 23 | -------------------------------------------------------------------------------- /src/cli/main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import typer 4 | from types import SimpleNamespace 5 | import cli.keypair as keypair 6 | import cli.account as account 7 | import cli.ask as ask 8 | import cli.configure as configure 9 | import cli.connection as connection 10 | import cli.recommend as recommend 11 | import cli.scrape as scrape 12 | import cli.sql as sql 13 | import cli.io as io 14 | import cli.database as database 15 | import cli.warehouse as warehouse 16 | import cli.security as security 17 | 18 | from cli.core.config.parser import get_config 19 | from cli.core.constants import SFCLI_DIR 20 | from cli.core.fs import ensure_directory 21 | from cli.core.snowflake.connection import snowflake_cursor 22 | from cli.core.logging import logger 23 | 24 | 25 | app = typer.Typer(no_args_is_help=True) 26 | 27 | 28 | app.add_typer( 29 | keypair.app, 30 | name="keypair", 31 | help="Manage Local Snowflake Private/Pub Key Pair", 32 | ) 33 | app.add_typer( 34 | security.app, 35 | name="security", 36 | help="Audit The Security of Your Snowflake Account", 37 | ) 38 | app.add_typer( 39 | configure.app, 40 | name="configure", 41 | help="Configure Snowflakecli", 42 | ) 43 | app.add_typer( 44 | connection.app, 45 | name="connection", 46 | help="Test and Manage Snowflakecli Connections", 47 | ) 48 | app.add_typer( 49 | sql.app, 50 | name="sql", 51 | help="Execute, lint, and debug Snowflake SQL Statements", 52 | ) 53 | app.add_typer(account.app, name="account", help="Manage Snowflake Accounts") 54 | app.add_typer( 55 | warehouse.app, 56 | name="warehouse", 57 | help="Manage and Optimize Snowflake Virtual Warehouses", 58 | ) 59 | app.add_typer(database.app, name="database", help="Manage Snowflake Databases") 60 | app.add_typer(io.app, name="io", help="Bulk import and bulk export data") 61 | 62 | ######################################################################## 63 | # AI stuff 64 | ######################################################################## 65 | # app.add_typer( 66 | # ask.app, 67 | # name="ask", 68 | # help="[!WIP!] Ask Snowflakecli LLM about your Snowflake resources", 69 | # ) 70 | # app.add_typer( 71 | # recommend.app, 72 | # name="recommend", 73 | # help="[!WIP!] Recommend optimizations, resizing, and other operations for your Snowflake resources", 74 | # ) 75 | # app.add_typer( 76 | # scrape.app, 77 | # name="scrape", 78 | # help="[!WIP!] Generate vector embeddings from Snowflake statistics, metadata, and schemata", 79 | # ) 80 | 81 | 82 | # Top-level Commands 83 | 84 | 85 | @app.callback() 86 | def callback(ctx: typer.Context): 87 | config = get_config() 88 | # Ensure Configuration Directory 89 | logger.debug(f"initializing database cursor...") 90 | try: 91 | connection_params = ( 92 | config.connections.default.params 93 | ) # TODO: make this configurable 94 | cursor = snowflake_cursor(connection_params) 95 | ctx.obj = SimpleNamespace(cursor=cursor) 96 | except Exception as e: 97 | logger.debug(e) 98 | ctx.obj = SimpleNamespace(cursor=None) 99 | 100 | 101 | def main(): 102 | app() 103 | 104 | 105 | if __name__ == "__main__": 106 | main() 107 | -------------------------------------------------------------------------------- /src/cli/recommend.py: -------------------------------------------------------------------------------- 1 | import typer 2 | 3 | 4 | app = typer.Typer(no_args_is_help=True) 5 | 6 | 7 | @app.command() 8 | def optimizations(): 9 | """Recommend optimizations for your Snowflake account, such as using DuckDB or DataFusion instead of Snowflake""" 10 | return 11 | 12 | 13 | @app.command() 14 | def resizing(): 15 | """Recommend resizing operations for your Snowflake resources""" 16 | return 17 | -------------------------------------------------------------------------------- /src/cli/schema.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/silverton-io/snowflakecli/4a8c60a0ee3b2e1be69dd44b94d035476c2f8497/src/cli/schema.py -------------------------------------------------------------------------------- /src/cli/scrape.py: -------------------------------------------------------------------------------- 1 | import typer 2 | 3 | 4 | app = typer.Typer(no_args_is_help=True) 5 | 6 | 7 | @app.command() 8 | def all(): 9 | """Generate vector embeddings from Snowflake metadata, statistics, and schemata""" 10 | return 11 | 12 | 13 | @app.command() 14 | def statistics(): 15 | """Generate vector embeddings from Snowflake statistics""" 16 | return 17 | 18 | 19 | @app.command() 20 | def metadata(): 21 | """Generate vector embeddings from Snowflake metadata""" 22 | return 23 | 24 | 25 | @app.command() 26 | def schemata(): 27 | """Generate vector embeddings from Snowflake schemata""" 28 | return 29 | -------------------------------------------------------------------------------- /src/cli/security.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated, Optional 2 | 3 | import typer 4 | 5 | from cli.core.security.playbooks.benchmarks import BENCHMARK_PLAYBOOK 6 | from cli.core.security.playbooks.unc5537_breach import UNC5537_BREACH_PLAYBOOK 7 | from cli.core.security.runner import run_security_playbook 8 | 9 | app = typer.Typer(no_args_is_help=True) 10 | 11 | 12 | @app.command() 13 | def audit( 14 | ctx: typer.Context, 15 | file: Annotated[ 16 | Optional[str], 17 | typer.Option( 18 | "-f", 19 | help="The audit definition to use. If no file is passed snowflakecli will use the default benchmarks.", 20 | ), 21 | ] = None, 22 | task_name: Annotated[ 23 | Optional[str], 24 | typer.Option( 25 | "-n", 26 | help="The named audit query to execute. If no name is passed all audits from the supplied definition will be used.", 27 | ), 28 | ] = None, 29 | ): 30 | """Audit the security of your Snowflake account""" 31 | if file: 32 | run_security_playbook( 33 | ctx.obj.cursor, playbook=get_file_contents(Path(file)), task_name=task_name 34 | ) 35 | else: 36 | run_security_playbook( 37 | ctx.obj.cursor, playbook=BENCHMARK_PLAYBOOK, task_name=task_name 38 | ) 39 | return 40 | 41 | 42 | @app.command() 43 | def hunt( 44 | ctx: typer.Context, 45 | file: Annotated[ 46 | Optional[str], 47 | typer.Option( 48 | "-f", 49 | help="The hunting definition to use. If no file is passed it will use the hunt definition from the UNC5537 Snowflake breaches", 50 | ), 51 | ] = None, 52 | task_name: Annotated[ 53 | Optional[str], 54 | typer.Option( 55 | "-n", 56 | help="The named hunting query to execute. If no name is passed all hunting queries from the supplied definition will be used", 57 | ), 58 | ] = None, 59 | ): 60 | """Threat hunt via Snowflake activity logging""" 61 | if file: 62 | run_security_playbook( 63 | ctx.obj.cursor, get_file_contents(Path(file)), task_name=task_name 64 | ) 65 | else: 66 | run_security_playbook( 67 | ctx.obj.cursor, playbook=UNC5537_BREACH_PLAYBOOK, task_name=task_name 68 | ) 69 | -------------------------------------------------------------------------------- /src/cli/sql.py: -------------------------------------------------------------------------------- 1 | import typer 2 | from typing import Optional 3 | from typing_extensions import Annotated 4 | from pathlib import Path 5 | 6 | from cli.core.snowflake.sql import Sql 7 | from cli.core.snowflake.query import query_all, tabulate_to_stdout 8 | from cli.core.fs import get_file_contents 9 | 10 | 11 | app = typer.Typer(no_args_is_help=True) 12 | 13 | 14 | @app.command() 15 | def execute( 16 | ctx: typer.Context, 17 | query: Annotated[ 18 | Optional[str], typer.Option("-q", help="The sql query to execute") 19 | ] = None, 20 | file: Annotated[ 21 | Optional[str], typer.Option("-f", help="The sql file to execute") 22 | ] = None, 23 | ): 24 | """Execute Snowflake SQL statements""" 25 | if query: 26 | sql = Sql(statement=query) 27 | if file: 28 | sql = Sql(statement=get_file_contents(Path(file))) 29 | results = query_all(ctx.obj.cursor, sql) 30 | tabulate_to_stdout(results) 31 | return results 32 | 33 | 34 | @app.command() 35 | def lint( 36 | query: Annotated[ 37 | Optional[str], typer.Option("-q", help="The sql query to lint") 38 | ] = None, 39 | file: Annotated[ 40 | Optional[str], typer.Option("-f", help="The sql file to lint") 41 | ] = None, 42 | ): 43 | """Lint Snowflake SQL statements""" 44 | raise NotImplementedError 45 | -------------------------------------------------------------------------------- /src/cli/table.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/silverton-io/snowflakecli/4a8c60a0ee3b2e1be69dd44b94d035476c2f8497/src/cli/table.py -------------------------------------------------------------------------------- /src/cli/warehouse.py: -------------------------------------------------------------------------------- 1 | import typer 2 | 3 | 4 | app = typer.Typer(no_args_is_help=True) 5 | 6 | 7 | @app.command() 8 | def list(): 9 | """List all Snowflake Warehouses""" 10 | return 11 | 12 | 13 | @app.command() 14 | def create(): 15 | """Create a Snowflake Warehouse""" 16 | return 17 | 18 | 19 | @app.command() 20 | def drop(): 21 | """Drop a Snowflake Warehouse""" 22 | return 23 | 24 | 25 | @app.command() 26 | def analyze(): 27 | """[!WIP!] Analyze a Snowflake virtual warehouse for potential cost-savings and other optimizations.""" 28 | return 29 | 30 | 31 | @app.command() 32 | def optimize(): 33 | """[!WIP!] Analyze actual workloads on a particular warehouse and resize it according to actual needs.""" 34 | return 35 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/silverton-io/snowflakecli/4a8c60a0ee3b2e1be69dd44b94d035476c2f8497/tests/__init__.py -------------------------------------------------------------------------------- /tests/unit/src/core/test_constants.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/silverton-io/snowflakecli/4a8c60a0ee3b2e1be69dd44b94d035476c2f8497/tests/unit/src/core/test_constants.py -------------------------------------------------------------------------------- /tests/unit/src/core/test_fs.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/silverton-io/snowflakecli/4a8c60a0ee3b2e1be69dd44b94d035476c2f8497/tests/unit/src/core/test_fs.py --------------------------------------------------------------------------------