azkv-ssh-fetch 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- azkv_ssh_fetch-0.1.0/.github/workflows/ci.yml +44 -0
- azkv_ssh_fetch-0.1.0/.github/workflows/release.yml +50 -0
- azkv_ssh_fetch-0.1.0/.gitignore +52 -0
- azkv_ssh_fetch-0.1.0/.pre-commit-config.yaml +28 -0
- azkv_ssh_fetch-0.1.0/LICENSE +21 -0
- azkv_ssh_fetch-0.1.0/PKG-INFO +175 -0
- azkv_ssh_fetch-0.1.0/README.md +119 -0
- azkv_ssh_fetch-0.1.0/pyproject.toml +90 -0
- azkv_ssh_fetch-0.1.0/src/azkv_ssh_fetch/__about__.py +1 -0
- azkv_ssh_fetch-0.1.0/src/azkv_ssh_fetch/__init__.py +5 -0
- azkv_ssh_fetch-0.1.0/src/azkv_ssh_fetch/__main__.py +6 -0
- azkv_ssh_fetch-0.1.0/src/azkv_ssh_fetch/bastion.py +70 -0
- azkv_ssh_fetch-0.1.0/src/azkv_ssh_fetch/cli.py +191 -0
- azkv_ssh_fetch-0.1.0/src/azkv_ssh_fetch/errors.py +23 -0
- azkv_ssh_fetch-0.1.0/src/azkv_ssh_fetch/keyvault.py +78 -0
- azkv_ssh_fetch-0.1.0/src/azkv_ssh_fetch/ssh.py +78 -0
- azkv_ssh_fetch-0.1.0/tests/__init__.py +0 -0
- azkv_ssh_fetch-0.1.0/tests/test_cli.py +104 -0
- azkv_ssh_fetch-0.1.0/tests/test_keyvault.py +83 -0
- azkv_ssh_fetch-0.1.0/tests/test_ssh.py +77 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
permissions:
|
|
10
|
+
contents: read
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
test:
|
|
14
|
+
name: ${{ matrix.python-version }} on ${{ matrix.os }}
|
|
15
|
+
runs-on: ${{ matrix.os }}
|
|
16
|
+
strategy:
|
|
17
|
+
fail-fast: false
|
|
18
|
+
matrix:
|
|
19
|
+
os: [ubuntu-latest]
|
|
20
|
+
python-version: ["3.10", "3.11", "3.12"]
|
|
21
|
+
steps:
|
|
22
|
+
- uses: actions/checkout@v4
|
|
23
|
+
|
|
24
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
25
|
+
uses: actions/setup-python@v5
|
|
26
|
+
with:
|
|
27
|
+
python-version: ${{ matrix.python-version }}
|
|
28
|
+
cache: pip
|
|
29
|
+
|
|
30
|
+
- name: Install
|
|
31
|
+
run: |
|
|
32
|
+
python -m pip install --upgrade pip
|
|
33
|
+
pip install -e ".[dev]"
|
|
34
|
+
|
|
35
|
+
- name: Lint (ruff)
|
|
36
|
+
run: |
|
|
37
|
+
ruff check .
|
|
38
|
+
ruff format --check .
|
|
39
|
+
|
|
40
|
+
- name: Type-check (mypy)
|
|
41
|
+
run: mypy src
|
|
42
|
+
|
|
43
|
+
- name: Test (pytest)
|
|
44
|
+
run: pytest -q
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
build:
|
|
13
|
+
name: Build distribution
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
|
|
18
|
+
- uses: actions/setup-python@v5
|
|
19
|
+
with:
|
|
20
|
+
python-version: "3.12"
|
|
21
|
+
|
|
22
|
+
- name: Install build tooling
|
|
23
|
+
run: python -m pip install --upgrade pip build
|
|
24
|
+
|
|
25
|
+
- name: Build sdist + wheel
|
|
26
|
+
run: python -m build
|
|
27
|
+
|
|
28
|
+
- uses: actions/upload-artifact@v4
|
|
29
|
+
with:
|
|
30
|
+
name: dist
|
|
31
|
+
path: dist/
|
|
32
|
+
|
|
33
|
+
publish:
|
|
34
|
+
name: Publish to PyPI
|
|
35
|
+
needs: build
|
|
36
|
+
runs-on: ubuntu-latest
|
|
37
|
+
# PyPI trusted publishing (OIDC). Requires the project to be pre-registered
|
|
38
|
+
# on PyPI with this GitHub repo + workflow allow-listed.
|
|
39
|
+
environment:
|
|
40
|
+
name: pypi
|
|
41
|
+
url: https://pypi.org/p/azkv-ssh-fetch
|
|
42
|
+
permissions:
|
|
43
|
+
id-token: write
|
|
44
|
+
steps:
|
|
45
|
+
- uses: actions/download-artifact@v4
|
|
46
|
+
with:
|
|
47
|
+
name: dist
|
|
48
|
+
path: dist/
|
|
49
|
+
|
|
50
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Byte-compiled / optimized
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.so
|
|
6
|
+
|
|
7
|
+
# Distribution / packaging
|
|
8
|
+
.Python
|
|
9
|
+
build/
|
|
10
|
+
develop-eggs/
|
|
11
|
+
dist/
|
|
12
|
+
downloads/
|
|
13
|
+
eggs/
|
|
14
|
+
.eggs/
|
|
15
|
+
lib/
|
|
16
|
+
lib64/
|
|
17
|
+
parts/
|
|
18
|
+
sdist/
|
|
19
|
+
var/
|
|
20
|
+
wheels/
|
|
21
|
+
*.egg-info/
|
|
22
|
+
.installed.cfg
|
|
23
|
+
*.egg
|
|
24
|
+
|
|
25
|
+
# Virtual environments
|
|
26
|
+
.venv/
|
|
27
|
+
venv/
|
|
28
|
+
env/
|
|
29
|
+
.env
|
|
30
|
+
|
|
31
|
+
# Tooling caches
|
|
32
|
+
.tox/
|
|
33
|
+
.nox/
|
|
34
|
+
.coverage
|
|
35
|
+
.coverage.*
|
|
36
|
+
.cache
|
|
37
|
+
.pytest_cache/
|
|
38
|
+
.ruff_cache/
|
|
39
|
+
.mypy_cache/
|
|
40
|
+
htmlcov/
|
|
41
|
+
coverage.xml
|
|
42
|
+
*.cover
|
|
43
|
+
.hypothesis/
|
|
44
|
+
|
|
45
|
+
# IDE
|
|
46
|
+
.idea/
|
|
47
|
+
.vscode/
|
|
48
|
+
*.swp
|
|
49
|
+
|
|
50
|
+
# OS
|
|
51
|
+
.DS_Store
|
|
52
|
+
Thumbs.db
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
repos:
|
|
2
|
+
- repo: https://github.com/astral-sh/ruff-pre-commit
|
|
3
|
+
rev: v0.5.7
|
|
4
|
+
hooks:
|
|
5
|
+
- id: ruff
|
|
6
|
+
args: [--fix, --exit-non-zero-on-fix]
|
|
7
|
+
- id: ruff-format
|
|
8
|
+
|
|
9
|
+
- repo: https://github.com/pre-commit/mirrors-mypy
|
|
10
|
+
rev: v1.10.1
|
|
11
|
+
hooks:
|
|
12
|
+
- id: mypy
|
|
13
|
+
args: [--strict]
|
|
14
|
+
additional_dependencies:
|
|
15
|
+
- typer>=0.12
|
|
16
|
+
- rich>=13.7
|
|
17
|
+
- azure-identity>=1.17
|
|
18
|
+
- azure-keyvault-secrets>=4.8
|
|
19
|
+
|
|
20
|
+
- repo: https://github.com/pre-commit/pre-commit-hooks
|
|
21
|
+
rev: v4.6.0
|
|
22
|
+
hooks:
|
|
23
|
+
- id: check-yaml
|
|
24
|
+
- id: check-toml
|
|
25
|
+
- id: end-of-file-fixer
|
|
26
|
+
- id: trailing-whitespace
|
|
27
|
+
- id: check-merge-conflict
|
|
28
|
+
- id: check-added-large-files
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Naeem Hossain
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: azkv-ssh-fetch
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Fetch SSH private keys from Azure Key Vault and connect to VMs/VMSS via Bastion.
|
|
5
|
+
Project-URL: Homepage, https://github.com/NaeemH/azkv-ssh-fetch
|
|
6
|
+
Project-URL: Repository, https://github.com/NaeemH/azkv-ssh-fetch
|
|
7
|
+
Project-URL: Issues, https://github.com/NaeemH/azkv-ssh-fetch/issues
|
|
8
|
+
Author-email: Naeem Hossain <naeemhossain.mail@gmail.com>
|
|
9
|
+
License: MIT License
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2026 Naeem Hossain
|
|
12
|
+
|
|
13
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
14
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
15
|
+
in the Software without restriction, including without limitation the rights
|
|
16
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
17
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
18
|
+
furnished to do so, subject to the following conditions:
|
|
19
|
+
|
|
20
|
+
The above copyright notice and this permission notice shall be included in
|
|
21
|
+
all copies or substantial portions of the Software.
|
|
22
|
+
|
|
23
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
24
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
25
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
26
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
27
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
28
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
29
|
+
THE SOFTWARE.
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Keywords: azure,bastion,devops,key-vault,ssh,vmss
|
|
32
|
+
Classifier: Development Status :: 4 - Beta
|
|
33
|
+
Classifier: Environment :: Console
|
|
34
|
+
Classifier: Intended Audience :: System Administrators
|
|
35
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
36
|
+
Classifier: Operating System :: MacOS
|
|
37
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
38
|
+
Classifier: Programming Language :: Python :: 3
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
41
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
42
|
+
Classifier: Topic :: System :: Systems Administration
|
|
43
|
+
Requires-Python: >=3.10
|
|
44
|
+
Requires-Dist: azure-identity>=1.17
|
|
45
|
+
Requires-Dist: azure-keyvault-secrets>=4.8
|
|
46
|
+
Requires-Dist: rich>=13.7
|
|
47
|
+
Requires-Dist: typer>=0.12
|
|
48
|
+
Provides-Extra: dev
|
|
49
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
50
|
+
Requires-Dist: pre-commit>=3.7; extra == 'dev'
|
|
51
|
+
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
52
|
+
Requires-Dist: pytest-mock>=3.12; extra == 'dev'
|
|
53
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
54
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
55
|
+
Description-Content-Type: text/markdown
|
|
56
|
+
|
|
57
|
+
# azkv-ssh-fetch
|
|
58
|
+
|
|
59
|
+
[](https://github.com/NaeemH/azkv-ssh-fetch/actions/workflows/ci.yml)
|
|
60
|
+
[](https://pypi.org/project/azkv-ssh-fetch/)
|
|
61
|
+
[](https://pypi.org/project/azkv-ssh-fetch/)
|
|
62
|
+
[](LICENSE)
|
|
63
|
+
|
|
64
|
+
> Fetch SSH private keys from **Azure Key Vault** and connect to VMs / VMSS instances through **Azure Bastion** — in one command.
|
|
65
|
+
|
|
66
|
+
`azkv-ssh-fetch` (alias `akf`) wraps the boring half of the operator workflow:
|
|
67
|
+
|
|
68
|
+
1. Authenticate via `DefaultAzureCredential` (env vars → managed identity → `az login`).
|
|
69
|
+
2. Pull the named private key from a Key Vault.
|
|
70
|
+
3. Write it to `~/.ssh/<name>` with mode `0600` (atomic replace, parent dir tightened to `0700`).
|
|
71
|
+
4. Shell out to `az network bastion ssh` with the right flags.
|
|
72
|
+
5. Optionally shred the key on disconnect.
|
|
73
|
+
|
|
74
|
+
## Why this exists
|
|
75
|
+
|
|
76
|
+
If your operating model says "private keys live in Key Vault, humans get to them through RBAC, and the only path to the VM is through Bastion" — then operators end up running the same 6-line shell snippet over and over. This packages that snippet, types it, tests it, and removes the foot-guns (wrong perms, stale keys lingering in `/tmp`, mis-typed resource IDs).
|
|
77
|
+
|
|
78
|
+
## Install
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
pipx install azkv-ssh-fetch
|
|
82
|
+
# or
|
|
83
|
+
pip install --user azkv-ssh-fetch
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Requires Python 3.10+ and the `az` CLI on `PATH` for the `connect` subcommand.
|
|
87
|
+
|
|
88
|
+
## Quick start
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
# 1. List SSH-shaped secrets in a vault
|
|
92
|
+
akf list --vault pro-zks1-nagios-kv
|
|
93
|
+
|
|
94
|
+
# 2. Pull a key to ~/.ssh/nagios-ssh (mode 600, atomic)
|
|
95
|
+
akf fetch --vault pro-zks1-nagios-kv nagios-ssh
|
|
96
|
+
|
|
97
|
+
# 3. Fetch + Bastion SSH in one shot
|
|
98
|
+
akf connect \
|
|
99
|
+
--vault pro-zks1-nagios-kv \
|
|
100
|
+
--secret nagios-ssh \
|
|
101
|
+
--bastion my-bastion \
|
|
102
|
+
--bastion-rg my-bastion-rg \
|
|
103
|
+
--target-id "/subscriptions/.../virtualMachineScaleSets/zks1-nagios/virtualMachines/0" \
|
|
104
|
+
--username azureuser
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Configuration
|
|
108
|
+
|
|
109
|
+
| Variable | Meaning |
|
|
110
|
+
| ------------- | ------------------------------------------------------------- |
|
|
111
|
+
| `AZKV_VAULT` | Default Key Vault name (overridden by `--vault`). |
|
|
112
|
+
| `AZKV_DEBUG` | Set to `1` for verbose `azure-identity` logging on stderr. |
|
|
113
|
+
| Standard `AZURE_*` env vars are honored by `DefaultAzureCredential`. |
|
|
114
|
+
|
|
115
|
+
## Subcommands
|
|
116
|
+
|
|
117
|
+
### `list`
|
|
118
|
+
|
|
119
|
+
Show enabled secrets in the vault whose names look like SSH keys (match `ssh`, `key`, `id_rsa`, `id_ed25519`). Exits `1` if none found, `2` on access errors.
|
|
120
|
+
|
|
121
|
+
### `fetch`
|
|
122
|
+
|
|
123
|
+
Pull a single secret and write it to disk.
|
|
124
|
+
|
|
125
|
+
```
|
|
126
|
+
Usage: akf fetch [OPTIONS] SECRET
|
|
127
|
+
|
|
128
|
+
Pull SECRET from VAULT and write it locally (chmod 600).
|
|
129
|
+
|
|
130
|
+
Options:
|
|
131
|
+
-v, --vault TEXT Key Vault name. [env: AZKV_VAULT; required]
|
|
132
|
+
-o, --output PATH Destination path. Default: ~/.ssh/<secret>.
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### `connect`
|
|
136
|
+
|
|
137
|
+
Fetch the key, open a Bastion SSH session, and (unless `--keep-key`) remove the key file on disconnect.
|
|
138
|
+
|
|
139
|
+
```
|
|
140
|
+
Usage: akf connect [OPTIONS]
|
|
141
|
+
|
|
142
|
+
Options:
|
|
143
|
+
-v, --vault TEXT Key Vault name. [env: AZKV_VAULT; required]
|
|
144
|
+
-s, --secret TEXT KV secret name. [required]
|
|
145
|
+
-b, --bastion TEXT Bastion name. [required]
|
|
146
|
+
--bastion-rg TEXT Bastion's resource group. [required]
|
|
147
|
+
-t, --target-id TEXT Full ARM resource ID of target VM or VMSS instance. [required]
|
|
148
|
+
-u, --username TEXT SSH username on the target. [default: azureuser]
|
|
149
|
+
--keep-key/--shred-key
|
|
150
|
+
Leave the key on disk after disconnect. [default: shred-key]
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Security notes
|
|
154
|
+
|
|
155
|
+
- **Always 0600.** Keys are written through a sibling tempfile with `0600` set via `fchmod` *before* any bytes touch disk, then atomically renamed.
|
|
156
|
+
- **No shell interpolation.** The `az` invocation is a fixed `argv` list — no `shell=True`.
|
|
157
|
+
- **Key shredding.** `connect` removes the on-disk copy on disconnect by default. Pass `--keep-key` to retain it.
|
|
158
|
+
- **Trust chain.** Auth uses `DefaultAzureCredential`; nothing custom about token handling.
|
|
159
|
+
|
|
160
|
+
## Development
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
git clone https://github.com/NaeemH/azkv-ssh-fetch
|
|
164
|
+
cd azkv-ssh-fetch
|
|
165
|
+
python -m venv .venv && source .venv/bin/activate
|
|
166
|
+
pip install -e ".[dev]"
|
|
167
|
+
pre-commit install
|
|
168
|
+
pytest
|
|
169
|
+
ruff check . && ruff format --check .
|
|
170
|
+
mypy src
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## License
|
|
174
|
+
|
|
175
|
+
[MIT](LICENSE) © 2026 Naeem Hossain
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# azkv-ssh-fetch
|
|
2
|
+
|
|
3
|
+
[](https://github.com/NaeemH/azkv-ssh-fetch/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/azkv-ssh-fetch/)
|
|
5
|
+
[](https://pypi.org/project/azkv-ssh-fetch/)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
> Fetch SSH private keys from **Azure Key Vault** and connect to VMs / VMSS instances through **Azure Bastion** — in one command.
|
|
9
|
+
|
|
10
|
+
`azkv-ssh-fetch` (alias `akf`) wraps the boring half of the operator workflow:
|
|
11
|
+
|
|
12
|
+
1. Authenticate via `DefaultAzureCredential` (env vars → managed identity → `az login`).
|
|
13
|
+
2. Pull the named private key from a Key Vault.
|
|
14
|
+
3. Write it to `~/.ssh/<name>` with mode `0600` (atomic replace, parent dir tightened to `0700`).
|
|
15
|
+
4. Shell out to `az network bastion ssh` with the right flags.
|
|
16
|
+
5. Optionally shred the key on disconnect.
|
|
17
|
+
|
|
18
|
+
## Why this exists
|
|
19
|
+
|
|
20
|
+
If your operating model says "private keys live in Key Vault, humans get to them through RBAC, and the only path to the VM is through Bastion" — then operators end up running the same 6-line shell snippet over and over. This packages that snippet, types it, tests it, and removes the foot-guns (wrong perms, stale keys lingering in `/tmp`, mis-typed resource IDs).
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pipx install azkv-ssh-fetch
|
|
26
|
+
# or
|
|
27
|
+
pip install --user azkv-ssh-fetch
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Requires Python 3.10+ and the `az` CLI on `PATH` for the `connect` subcommand.
|
|
31
|
+
|
|
32
|
+
## Quick start
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
# 1. List SSH-shaped secrets in a vault
|
|
36
|
+
akf list --vault pro-zks1-nagios-kv
|
|
37
|
+
|
|
38
|
+
# 2. Pull a key to ~/.ssh/nagios-ssh (mode 600, atomic)
|
|
39
|
+
akf fetch --vault pro-zks1-nagios-kv nagios-ssh
|
|
40
|
+
|
|
41
|
+
# 3. Fetch + Bastion SSH in one shot
|
|
42
|
+
akf connect \
|
|
43
|
+
--vault pro-zks1-nagios-kv \
|
|
44
|
+
--secret nagios-ssh \
|
|
45
|
+
--bastion my-bastion \
|
|
46
|
+
--bastion-rg my-bastion-rg \
|
|
47
|
+
--target-id "/subscriptions/.../virtualMachineScaleSets/zks1-nagios/virtualMachines/0" \
|
|
48
|
+
--username azureuser
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Configuration
|
|
52
|
+
|
|
53
|
+
| Variable | Meaning |
|
|
54
|
+
| ------------- | ------------------------------------------------------------- |
|
|
55
|
+
| `AZKV_VAULT` | Default Key Vault name (overridden by `--vault`). |
|
|
56
|
+
| `AZKV_DEBUG` | Set to `1` for verbose `azure-identity` logging on stderr. |
|
|
57
|
+
| Standard `AZURE_*` env vars are honored by `DefaultAzureCredential`. |
|
|
58
|
+
|
|
59
|
+
## Subcommands
|
|
60
|
+
|
|
61
|
+
### `list`
|
|
62
|
+
|
|
63
|
+
Show enabled secrets in the vault whose names look like SSH keys (match `ssh`, `key`, `id_rsa`, `id_ed25519`). Exits `1` if none found, `2` on access errors.
|
|
64
|
+
|
|
65
|
+
### `fetch`
|
|
66
|
+
|
|
67
|
+
Pull a single secret and write it to disk.
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
Usage: akf fetch [OPTIONS] SECRET
|
|
71
|
+
|
|
72
|
+
Pull SECRET from VAULT and write it locally (chmod 600).
|
|
73
|
+
|
|
74
|
+
Options:
|
|
75
|
+
-v, --vault TEXT Key Vault name. [env: AZKV_VAULT; required]
|
|
76
|
+
-o, --output PATH Destination path. Default: ~/.ssh/<secret>.
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### `connect`
|
|
80
|
+
|
|
81
|
+
Fetch the key, open a Bastion SSH session, and (unless `--keep-key`) remove the key file on disconnect.
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
Usage: akf connect [OPTIONS]
|
|
85
|
+
|
|
86
|
+
Options:
|
|
87
|
+
-v, --vault TEXT Key Vault name. [env: AZKV_VAULT; required]
|
|
88
|
+
-s, --secret TEXT KV secret name. [required]
|
|
89
|
+
-b, --bastion TEXT Bastion name. [required]
|
|
90
|
+
--bastion-rg TEXT Bastion's resource group. [required]
|
|
91
|
+
-t, --target-id TEXT Full ARM resource ID of target VM or VMSS instance. [required]
|
|
92
|
+
-u, --username TEXT SSH username on the target. [default: azureuser]
|
|
93
|
+
--keep-key/--shred-key
|
|
94
|
+
Leave the key on disk after disconnect. [default: shred-key]
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Security notes
|
|
98
|
+
|
|
99
|
+
- **Always 0600.** Keys are written through a sibling tempfile with `0600` set via `fchmod` *before* any bytes touch disk, then atomically renamed.
|
|
100
|
+
- **No shell interpolation.** The `az` invocation is a fixed `argv` list — no `shell=True`.
|
|
101
|
+
- **Key shredding.** `connect` removes the on-disk copy on disconnect by default. Pass `--keep-key` to retain it.
|
|
102
|
+
- **Trust chain.** Auth uses `DefaultAzureCredential`; nothing custom about token handling.
|
|
103
|
+
|
|
104
|
+
## Development
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
git clone https://github.com/NaeemH/azkv-ssh-fetch
|
|
108
|
+
cd azkv-ssh-fetch
|
|
109
|
+
python -m venv .venv && source .venv/bin/activate
|
|
110
|
+
pip install -e ".[dev]"
|
|
111
|
+
pre-commit install
|
|
112
|
+
pytest
|
|
113
|
+
ruff check . && ruff format --check .
|
|
114
|
+
mypy src
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## License
|
|
118
|
+
|
|
119
|
+
[MIT](LICENSE) © 2026 Naeem Hossain
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.18"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "azkv-ssh-fetch"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Fetch SSH private keys from Azure Key Vault and connect to VMs/VMSS via Bastion."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { file = "LICENSE" }
|
|
12
|
+
authors = [{ name = "Naeem Hossain", email = "naeemhossain.mail@gmail.com" }]
|
|
13
|
+
keywords = ["azure", "ssh", "key-vault", "bastion", "vmss", "devops"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Intended Audience :: System Administrators",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Operating System :: POSIX :: Linux",
|
|
20
|
+
"Operating System :: MacOS",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Topic :: System :: Systems Administration",
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"typer>=0.12",
|
|
29
|
+
"rich>=13.7",
|
|
30
|
+
"azure-identity>=1.17",
|
|
31
|
+
"azure-keyvault-secrets>=4.8",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[project.optional-dependencies]
|
|
35
|
+
dev = [
|
|
36
|
+
"pytest>=8.0",
|
|
37
|
+
"pytest-cov>=5.0",
|
|
38
|
+
"pytest-mock>=3.12",
|
|
39
|
+
"ruff>=0.5",
|
|
40
|
+
"mypy>=1.10",
|
|
41
|
+
"pre-commit>=3.7",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[project.urls]
|
|
45
|
+
Homepage = "https://github.com/NaeemH/azkv-ssh-fetch"
|
|
46
|
+
Repository = "https://github.com/NaeemH/azkv-ssh-fetch"
|
|
47
|
+
Issues = "https://github.com/NaeemH/azkv-ssh-fetch/issues"
|
|
48
|
+
|
|
49
|
+
[project.scripts]
|
|
50
|
+
azkv-ssh-fetch = "azkv_ssh_fetch.cli:app"
|
|
51
|
+
akf = "azkv_ssh_fetch.cli:app"
|
|
52
|
+
|
|
53
|
+
[tool.hatch.build.targets.wheel]
|
|
54
|
+
packages = ["src/azkv_ssh_fetch"]
|
|
55
|
+
|
|
56
|
+
[tool.ruff]
|
|
57
|
+
line-length = 100
|
|
58
|
+
target-version = "py310"
|
|
59
|
+
src = ["src", "tests"]
|
|
60
|
+
|
|
61
|
+
[tool.ruff.lint]
|
|
62
|
+
select = [
|
|
63
|
+
"E", # pycodestyle errors
|
|
64
|
+
"W", # pycodestyle warnings
|
|
65
|
+
"F", # pyflakes
|
|
66
|
+
"I", # isort
|
|
67
|
+
"B", # flake8-bugbear
|
|
68
|
+
"C4", # flake8-comprehensions
|
|
69
|
+
"UP", # pyupgrade
|
|
70
|
+
"N", # pep8-naming
|
|
71
|
+
"ARG", # flake8-unused-arguments
|
|
72
|
+
"SIM", # flake8-simplify
|
|
73
|
+
]
|
|
74
|
+
ignore = ["E501"] # handled by formatter
|
|
75
|
+
|
|
76
|
+
[tool.ruff.format]
|
|
77
|
+
quote-style = "double"
|
|
78
|
+
|
|
79
|
+
[tool.mypy]
|
|
80
|
+
python_version = "3.10"
|
|
81
|
+
strict = true
|
|
82
|
+
warn_return_any = true
|
|
83
|
+
warn_unused_configs = true
|
|
84
|
+
disallow_untyped_defs = true
|
|
85
|
+
ignore_missing_imports = true
|
|
86
|
+
|
|
87
|
+
[tool.pytest.ini_options]
|
|
88
|
+
minversion = "8.0"
|
|
89
|
+
addopts = "-ra --strict-markers --cov=azkv_ssh_fetch --cov-report=term-missing"
|
|
90
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Azure Bastion native-client invocation.
|
|
2
|
+
|
|
3
|
+
Shells out to the `az network bastion ssh` command rather than reimplementing the
|
|
4
|
+
RDP/SSH tunnel protocol. Requires `az` CLI >= 2.32 with the `ssh` extension.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from azkv_ssh_fetch.errors import BastionInvocationError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class BastionTarget:
|
|
19
|
+
"""Connection coordinates for a Bastion-fronted VM/VMSS instance."""
|
|
20
|
+
|
|
21
|
+
bastion_name: str
|
|
22
|
+
bastion_resource_group: str
|
|
23
|
+
target_resource_id: str
|
|
24
|
+
"""Full ARM resource ID of the target VM or VMSS instance."""
|
|
25
|
+
username: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _require_az() -> str:
|
|
29
|
+
"""Return path to `az` or raise."""
|
|
30
|
+
az = shutil.which("az")
|
|
31
|
+
if az is None:
|
|
32
|
+
raise BastionInvocationError(
|
|
33
|
+
"the `az` CLI was not found on PATH; install from https://aka.ms/installazcli"
|
|
34
|
+
)
|
|
35
|
+
return az
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def ssh_via_bastion(target: BastionTarget, private_key: Path) -> int:
|
|
39
|
+
"""Open an interactive SSH session through Azure Bastion.
|
|
40
|
+
|
|
41
|
+
Blocks until the user exits. Returns the SSH exit code.
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
BastionInvocationError: `az` is missing or the command exited with a
|
|
45
|
+
non-SSH error (e.g., RBAC denial, unknown resource).
|
|
46
|
+
"""
|
|
47
|
+
az = _require_az()
|
|
48
|
+
cmd = [
|
|
49
|
+
az,
|
|
50
|
+
"network",
|
|
51
|
+
"bastion",
|
|
52
|
+
"ssh",
|
|
53
|
+
"--name",
|
|
54
|
+
target.bastion_name,
|
|
55
|
+
"--resource-group",
|
|
56
|
+
target.bastion_resource_group,
|
|
57
|
+
"--target-resource-id",
|
|
58
|
+
target.target_resource_id,
|
|
59
|
+
"--auth-type",
|
|
60
|
+
"ssh-key",
|
|
61
|
+
"--username",
|
|
62
|
+
target.username,
|
|
63
|
+
"--ssh-key",
|
|
64
|
+
str(private_key),
|
|
65
|
+
]
|
|
66
|
+
try:
|
|
67
|
+
completed = subprocess.run(cmd, check=False) # noqa: S603 - args are controlled
|
|
68
|
+
except OSError as exc:
|
|
69
|
+
raise BastionInvocationError(f"failed to launch az: {exc}") from exc
|
|
70
|
+
return completed.returncode
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Typer CLI: `azkv-ssh-fetch` (alias `akf`).
|
|
2
|
+
|
|
3
|
+
Subcommands:
|
|
4
|
+
list List SSH-shaped secrets in a Key Vault.
|
|
5
|
+
fetch Pull a private key from KV to a local file (chmod 600).
|
|
6
|
+
connect fetch + open Bastion SSH session in one command.
|
|
7
|
+
|
|
8
|
+
All commands accept --vault from --vault flag or AZKV_VAULT env var.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Annotated
|
|
17
|
+
|
|
18
|
+
import typer
|
|
19
|
+
from rich.console import Console
|
|
20
|
+
from rich.table import Table
|
|
21
|
+
|
|
22
|
+
from azkv_ssh_fetch import __version__
|
|
23
|
+
from azkv_ssh_fetch.bastion import BastionTarget, ssh_via_bastion
|
|
24
|
+
from azkv_ssh_fetch.errors import AzkvSshFetchError
|
|
25
|
+
from azkv_ssh_fetch.keyvault import fetch_secret, list_secrets
|
|
26
|
+
from azkv_ssh_fetch.ssh import default_ssh_dir, write_private_key
|
|
27
|
+
|
|
28
|
+
app = typer.Typer(
|
|
29
|
+
name="azkv-ssh-fetch",
|
|
30
|
+
help="Fetch SSH private keys from Azure Key Vault and connect via Bastion.",
|
|
31
|
+
no_args_is_help=True,
|
|
32
|
+
add_completion=True,
|
|
33
|
+
rich_markup_mode="rich",
|
|
34
|
+
)
|
|
35
|
+
stdout = Console()
|
|
36
|
+
stderr = Console(stderr=True)
|
|
37
|
+
|
|
38
|
+
VaultOpt = Annotated[
|
|
39
|
+
str,
|
|
40
|
+
typer.Option(
|
|
41
|
+
"--vault",
|
|
42
|
+
"-v",
|
|
43
|
+
envvar="AZKV_VAULT",
|
|
44
|
+
help="Key Vault name (not full URL). Required, or set [bold]AZKV_VAULT[/bold].",
|
|
45
|
+
),
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _version_callback(value: bool) -> None:
|
|
50
|
+
if value:
|
|
51
|
+
stdout.print(f"azkv-ssh-fetch [bold cyan]{__version__}[/bold cyan]")
|
|
52
|
+
raise typer.Exit()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@app.callback()
|
|
56
|
+
def _root(
|
|
57
|
+
_version: Annotated[ # noqa: ARG001 - typer needs the param for the flag
|
|
58
|
+
bool,
|
|
59
|
+
typer.Option(
|
|
60
|
+
"--version",
|
|
61
|
+
callback=_version_callback,
|
|
62
|
+
is_eager=True,
|
|
63
|
+
help="Show version and exit.",
|
|
64
|
+
),
|
|
65
|
+
] = False,
|
|
66
|
+
) -> None:
|
|
67
|
+
"""Common options."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@app.command("list")
|
|
71
|
+
def cmd_list(vault: VaultOpt) -> None:
|
|
72
|
+
"""List secrets in [bold]VAULT[/bold] that look like SSH keys."""
|
|
73
|
+
try:
|
|
74
|
+
secrets = list(list_secrets(vault))
|
|
75
|
+
except AzkvSshFetchError as exc:
|
|
76
|
+
stderr.print(f"[red]error:[/red] {exc}")
|
|
77
|
+
raise typer.Exit(code=2) from exc
|
|
78
|
+
|
|
79
|
+
table = Table(title=f"Secrets in {vault}", show_lines=False)
|
|
80
|
+
table.add_column("Name", style="cyan")
|
|
81
|
+
table.add_column("Enabled", justify="center")
|
|
82
|
+
table.add_column("Content-Type", style="dim")
|
|
83
|
+
|
|
84
|
+
shown = 0
|
|
85
|
+
for s in secrets:
|
|
86
|
+
if not s.enabled:
|
|
87
|
+
continue
|
|
88
|
+
# Best-effort filter: SSH keys are usually named *ssh*, *key*, *id_rsa*
|
|
89
|
+
lower = s.name.lower()
|
|
90
|
+
looks_like_key = any(tok in lower for tok in ("ssh", "key", "id_rsa", "id_ed25519"))
|
|
91
|
+
if not looks_like_key:
|
|
92
|
+
continue
|
|
93
|
+
table.add_row(s.name, "yes", s.content_type or "")
|
|
94
|
+
shown += 1
|
|
95
|
+
|
|
96
|
+
if shown == 0:
|
|
97
|
+
stderr.print(
|
|
98
|
+
f"[yellow]no SSH-shaped secrets found in {vault}.[/yellow]"
|
|
99
|
+
" Try [bold]az keyvault secret list[/bold] for the full list."
|
|
100
|
+
)
|
|
101
|
+
raise typer.Exit(code=1)
|
|
102
|
+
stdout.print(table)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@app.command("fetch")
|
|
106
|
+
def cmd_fetch(
|
|
107
|
+
vault: VaultOpt,
|
|
108
|
+
secret: Annotated[str, typer.Argument(help="Secret name in the vault.")],
|
|
109
|
+
output: Annotated[
|
|
110
|
+
Path | None,
|
|
111
|
+
typer.Option(
|
|
112
|
+
"--output",
|
|
113
|
+
"-o",
|
|
114
|
+
help="Destination path. Default: ~/.ssh/<secret>.",
|
|
115
|
+
),
|
|
116
|
+
] = None,
|
|
117
|
+
) -> None:
|
|
118
|
+
"""Pull [bold]SECRET[/bold] from [bold]VAULT[/bold] and write it locally (chmod 600)."""
|
|
119
|
+
dest = output if output is not None else default_ssh_dir() / secret
|
|
120
|
+
try:
|
|
121
|
+
material = fetch_secret(vault, secret)
|
|
122
|
+
written = write_private_key(material, dest)
|
|
123
|
+
except AzkvSshFetchError as exc:
|
|
124
|
+
stderr.print(f"[red]error:[/red] {exc}")
|
|
125
|
+
raise typer.Exit(code=2) from exc
|
|
126
|
+
stdout.print(f"[green]wrote[/green] {written} (mode 600)")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@app.command("connect")
|
|
130
|
+
def cmd_connect(
|
|
131
|
+
vault: VaultOpt,
|
|
132
|
+
secret: Annotated[str, typer.Option("--secret", "-s", help="KV secret name.")],
|
|
133
|
+
bastion_name: Annotated[str, typer.Option("--bastion", "-b", help="Bastion name.")],
|
|
134
|
+
bastion_rg: Annotated[str, typer.Option("--bastion-rg", help="Bastion's resource group.")],
|
|
135
|
+
target_id: Annotated[
|
|
136
|
+
str,
|
|
137
|
+
typer.Option(
|
|
138
|
+
"--target-id",
|
|
139
|
+
"-t",
|
|
140
|
+
help="Full ARM resource ID of target VM or VMSS instance.",
|
|
141
|
+
),
|
|
142
|
+
],
|
|
143
|
+
username: Annotated[
|
|
144
|
+
str, typer.Option("--username", "-u", help="SSH username on the target.")
|
|
145
|
+
] = "azureuser",
|
|
146
|
+
keep_key: Annotated[
|
|
147
|
+
bool,
|
|
148
|
+
typer.Option("--keep-key/--shred-key", help="Leave the key on disk after disconnect."),
|
|
149
|
+
] = False,
|
|
150
|
+
) -> None:
|
|
151
|
+
"""Fetch private key, then SSH into target through Bastion."""
|
|
152
|
+
dest = default_ssh_dir() / f"akf-{secret}"
|
|
153
|
+
try:
|
|
154
|
+
material = fetch_secret(vault, secret)
|
|
155
|
+
key_path = write_private_key(material, dest)
|
|
156
|
+
stdout.print(f"[green]\u2713[/green] key fetched to {key_path}")
|
|
157
|
+
rc = ssh_via_bastion(
|
|
158
|
+
BastionTarget(
|
|
159
|
+
bastion_name=bastion_name,
|
|
160
|
+
bastion_resource_group=bastion_rg,
|
|
161
|
+
target_resource_id=target_id,
|
|
162
|
+
username=username,
|
|
163
|
+
),
|
|
164
|
+
key_path,
|
|
165
|
+
)
|
|
166
|
+
except AzkvSshFetchError as exc:
|
|
167
|
+
stderr.print(f"[red]error:[/red] {exc}")
|
|
168
|
+
raise typer.Exit(code=2) from exc
|
|
169
|
+
finally:
|
|
170
|
+
if not keep_key:
|
|
171
|
+
try:
|
|
172
|
+
dest.unlink(missing_ok=True)
|
|
173
|
+
stdout.print(f"[dim]shredded {dest}[/dim]")
|
|
174
|
+
except OSError as exc:
|
|
175
|
+
stderr.print(f"[yellow]warning:[/yellow] could not remove {dest}: {exc}")
|
|
176
|
+
|
|
177
|
+
raise typer.Exit(code=rc)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def main() -> None:
|
|
181
|
+
"""Entry point for `python -m azkv_ssh_fetch` and the console-script."""
|
|
182
|
+
# Surface azure-identity verbose logs only when AZKV_DEBUG=1
|
|
183
|
+
if os.environ.get("AZKV_DEBUG") == "1":
|
|
184
|
+
import logging
|
|
185
|
+
|
|
186
|
+
logging.basicConfig(level=logging.DEBUG, stream=sys.stderr)
|
|
187
|
+
app()
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
if __name__ == "__main__":
|
|
191
|
+
main()
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Typed errors raised by the package."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AzkvSshFetchError(Exception):
|
|
7
|
+
"""Base class for all package errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class KeyVaultAccessError(AzkvSshFetchError):
|
|
11
|
+
"""Raised when Key Vault is unreachable or caller lacks permission."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SecretNotFoundError(AzkvSshFetchError):
|
|
15
|
+
"""Raised when the named secret does not exist in the vault."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SshKeyWriteError(AzkvSshFetchError):
|
|
19
|
+
"""Raised when the SSH key cannot be written to disk safely."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BastionInvocationError(AzkvSshFetchError):
|
|
23
|
+
"""Raised when `az network bastion` invocation fails."""
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Azure Key Vault interactions.
|
|
2
|
+
|
|
3
|
+
Thin wrapper around `azure-keyvault-secrets` + `azure-identity`. Centralizes auth
|
|
4
|
+
and provides typed return values so the CLI layer stays free of SDK boilerplate.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Iterator
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
from azure.core.exceptions import HttpResponseError, ResourceNotFoundError
|
|
13
|
+
from azure.identity import DefaultAzureCredential
|
|
14
|
+
from azure.keyvault.secrets import SecretClient
|
|
15
|
+
|
|
16
|
+
from azkv_ssh_fetch.errors import KeyVaultAccessError, SecretNotFoundError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class SecretSummary:
|
|
21
|
+
"""Lightweight view of a Key Vault secret (no value)."""
|
|
22
|
+
|
|
23
|
+
name: str
|
|
24
|
+
enabled: bool
|
|
25
|
+
content_type: str | None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _client(vault_name: str) -> SecretClient:
|
|
29
|
+
"""Build a SecretClient for `vault_name` using DefaultAzureCredential.
|
|
30
|
+
|
|
31
|
+
Auth precedence (azure-identity defaults): env vars, managed identity, az CLI,
|
|
32
|
+
VS Code, Azure PowerShell, interactive browser. The CLI relies on `az login`
|
|
33
|
+
in practice.
|
|
34
|
+
"""
|
|
35
|
+
url = f"https://{vault_name}.vault.azure.net"
|
|
36
|
+
return SecretClient(vault_url=url, credential=DefaultAzureCredential())
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def list_secrets(vault_name: str) -> Iterator[SecretSummary]:
|
|
40
|
+
"""Yield all enabled secrets in the vault.
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
KeyVaultAccessError: caller lacks `list` permission or vault is unreachable.
|
|
44
|
+
"""
|
|
45
|
+
client = _client(vault_name)
|
|
46
|
+
try:
|
|
47
|
+
for prop in client.list_properties_of_secrets():
|
|
48
|
+
yield SecretSummary(
|
|
49
|
+
name=prop.name or "",
|
|
50
|
+
enabled=bool(prop.enabled),
|
|
51
|
+
content_type=prop.content_type,
|
|
52
|
+
)
|
|
53
|
+
except HttpResponseError as exc:
|
|
54
|
+
raise KeyVaultAccessError(f"cannot list secrets in {vault_name!r}: {exc.message}") from exc
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def fetch_secret(vault_name: str, secret_name: str) -> str:
|
|
58
|
+
"""Fetch the value of `secret_name` from `vault_name`.
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
SecretNotFoundError: the secret does not exist (or caller cannot see it).
|
|
62
|
+
KeyVaultAccessError: any other access failure.
|
|
63
|
+
"""
|
|
64
|
+
client = _client(vault_name)
|
|
65
|
+
try:
|
|
66
|
+
secret = client.get_secret(secret_name)
|
|
67
|
+
except ResourceNotFoundError as exc:
|
|
68
|
+
raise SecretNotFoundError(
|
|
69
|
+
f"secret {secret_name!r} not found in vault {vault_name!r}"
|
|
70
|
+
) from exc
|
|
71
|
+
except HttpResponseError as exc:
|
|
72
|
+
raise KeyVaultAccessError(
|
|
73
|
+
f"cannot fetch {secret_name!r} from {vault_name!r}: {exc.message}"
|
|
74
|
+
) from exc
|
|
75
|
+
|
|
76
|
+
if secret.value is None:
|
|
77
|
+
raise SecretNotFoundError(f"secret {secret_name!r} has no value")
|
|
78
|
+
return secret.value
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Local SSH key management: safe writes, mode 0600, atomic replace.
|
|
2
|
+
|
|
3
|
+
Avoids common footguns:
|
|
4
|
+
- Never write keys to world-readable temp dirs
|
|
5
|
+
- Always set mode 0600 BEFORE writing key material
|
|
6
|
+
- Use atomic os.replace so partial writes don't leave half-keys behind
|
|
7
|
+
- Append trailing newline if missing (OpenSSH cares)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import stat
|
|
14
|
+
import tempfile
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from azkv_ssh_fetch.errors import SshKeyWriteError
|
|
18
|
+
|
|
19
|
+
DEFAULT_SSH_DIR = Path.home() / ".ssh"
|
|
20
|
+
KEY_MODE = 0o600
|
|
21
|
+
DIR_MODE = 0o700
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def default_ssh_dir() -> Path:
|
|
25
|
+
"""Return ~/.ssh, creating it with mode 0700 if missing."""
|
|
26
|
+
DEFAULT_SSH_DIR.mkdir(mode=DIR_MODE, exist_ok=True)
|
|
27
|
+
# Tighten perms even if it already existed with looser bits
|
|
28
|
+
DEFAULT_SSH_DIR.chmod(DIR_MODE)
|
|
29
|
+
return DEFAULT_SSH_DIR
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def write_private_key(key_material: str, destination: Path) -> Path:
|
|
33
|
+
"""Write `key_material` to `destination` with mode 0600, atomically.
|
|
34
|
+
|
|
35
|
+
Returns the resolved destination Path. Trailing newline is appended if missing.
|
|
36
|
+
|
|
37
|
+
Raises:
|
|
38
|
+
SshKeyWriteError: any IO problem (permissions, full disk, etc.).
|
|
39
|
+
"""
|
|
40
|
+
if not key_material:
|
|
41
|
+
raise SshKeyWriteError("empty key material; refusing to write")
|
|
42
|
+
|
|
43
|
+
payload = key_material if key_material.endswith("\n") else key_material + "\n"
|
|
44
|
+
|
|
45
|
+
dest = destination.expanduser().resolve()
|
|
46
|
+
try:
|
|
47
|
+
dest.parent.mkdir(mode=DIR_MODE, parents=True, exist_ok=True)
|
|
48
|
+
# Tighten parent perms if it already existed
|
|
49
|
+
dest.parent.chmod(DIR_MODE)
|
|
50
|
+
except OSError as exc:
|
|
51
|
+
raise SshKeyWriteError(f"cannot prepare {dest.parent}: {exc}") from exc
|
|
52
|
+
|
|
53
|
+
# Write to a sibling tempfile with mode 0600, then atomic replace
|
|
54
|
+
fd, tmp_path_str = tempfile.mkstemp(
|
|
55
|
+
prefix=dest.name + ".",
|
|
56
|
+
suffix=".tmp",
|
|
57
|
+
dir=dest.parent,
|
|
58
|
+
)
|
|
59
|
+
tmp_path = Path(tmp_path_str)
|
|
60
|
+
try:
|
|
61
|
+
os.fchmod(fd, KEY_MODE)
|
|
62
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
63
|
+
fh.write(payload)
|
|
64
|
+
os.replace(tmp_path, dest)
|
|
65
|
+
except OSError as exc:
|
|
66
|
+
tmp_path.unlink(missing_ok=True)
|
|
67
|
+
raise SshKeyWriteError(f"failed to write key to {dest}: {exc}") from exc
|
|
68
|
+
|
|
69
|
+
# Belt-and-suspenders: enforce final mode in case umask interfered earlier
|
|
70
|
+
dest.chmod(KEY_MODE)
|
|
71
|
+
return dest
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def assert_safe_mode(path: Path) -> None:
|
|
75
|
+
"""Raise if `path` is group/world readable (OpenSSH will reject)."""
|
|
76
|
+
st = path.stat()
|
|
77
|
+
if st.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
|
|
78
|
+
raise SshKeyWriteError(f"{path} is group/world accessible; chmod 600 required")
|
|
File without changes
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Tests for the CLI surface. Heavy mocking; no live Azure calls."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from pytest_mock import MockerFixture
|
|
9
|
+
from typer.testing import CliRunner
|
|
10
|
+
|
|
11
|
+
from azkv_ssh_fetch import __version__
|
|
12
|
+
from azkv_ssh_fetch.cli import app
|
|
13
|
+
from azkv_ssh_fetch.errors import KeyVaultAccessError, SecretNotFoundError
|
|
14
|
+
from azkv_ssh_fetch.keyvault import SecretSummary
|
|
15
|
+
|
|
16
|
+
runner = CliRunner()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_version_flag() -> None:
|
|
20
|
+
result = runner.invoke(app, ["--version"])
|
|
21
|
+
assert result.exit_code == 0
|
|
22
|
+
assert __version__ in result.stdout
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_help_lists_subcommands() -> None:
|
|
26
|
+
result = runner.invoke(app, ["--help"])
|
|
27
|
+
assert result.exit_code == 0
|
|
28
|
+
for cmd in ("list", "fetch", "connect"):
|
|
29
|
+
assert cmd in result.stdout
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_list_filters_to_ssh_shaped(mocker: MockerFixture) -> None:
|
|
33
|
+
mocker.patch(
|
|
34
|
+
"azkv_ssh_fetch.cli.list_secrets",
|
|
35
|
+
return_value=iter(
|
|
36
|
+
[
|
|
37
|
+
SecretSummary(name="alpha-ssh", enabled=True, content_type=None),
|
|
38
|
+
SecretSummary(name="db-password", enabled=True, content_type=None),
|
|
39
|
+
SecretSummary(name="id_rsa-prod", enabled=True, content_type=None),
|
|
40
|
+
SecretSummary(name="disabled-ssh", enabled=False, content_type=None),
|
|
41
|
+
]
|
|
42
|
+
),
|
|
43
|
+
)
|
|
44
|
+
result = runner.invoke(app, ["list", "--vault", "kv-test"])
|
|
45
|
+
assert result.exit_code == 0
|
|
46
|
+
assert "alpha-ssh" in result.stdout
|
|
47
|
+
assert "id_rsa-prod" in result.stdout
|
|
48
|
+
assert "db-password" not in result.stdout
|
|
49
|
+
assert "disabled-ssh" not in result.stdout
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_list_empty_exits_1(mocker: MockerFixture) -> None:
|
|
53
|
+
mocker.patch("azkv_ssh_fetch.cli.list_secrets", return_value=iter([]))
|
|
54
|
+
result = runner.invoke(app, ["list", "--vault", "kv-test"])
|
|
55
|
+
assert result.exit_code == 1
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_list_kv_error_exits_2(mocker: MockerFixture) -> None:
|
|
59
|
+
mocker.patch(
|
|
60
|
+
"azkv_ssh_fetch.cli.list_secrets",
|
|
61
|
+
side_effect=KeyVaultAccessError("nope"),
|
|
62
|
+
)
|
|
63
|
+
result = runner.invoke(app, ["list", "--vault", "kv-test"])
|
|
64
|
+
assert result.exit_code == 2
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_fetch_writes_to_output(tmp_path: Path, mocker: MockerFixture) -> None:
|
|
68
|
+
mocker.patch(
|
|
69
|
+
"azkv_ssh_fetch.cli.fetch_secret",
|
|
70
|
+
return_value="-----BEGIN OPENSSH PRIVATE KEY-----\ncontent\n-----END OPENSSH PRIVATE KEY-----",
|
|
71
|
+
)
|
|
72
|
+
out = tmp_path / "id_test"
|
|
73
|
+
result = runner.invoke(app, ["fetch", "--vault", "kv-test", "my-secret", "--output", str(out)])
|
|
74
|
+
assert result.exit_code == 0, result.stdout
|
|
75
|
+
assert out.exists()
|
|
76
|
+
import stat
|
|
77
|
+
|
|
78
|
+
assert stat.S_IMODE(out.stat().st_mode) == 0o600
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_fetch_missing_secret_exits_2(tmp_path: Path, mocker: MockerFixture) -> None:
|
|
82
|
+
mocker.patch(
|
|
83
|
+
"azkv_ssh_fetch.cli.fetch_secret",
|
|
84
|
+
side_effect=SecretNotFoundError("nope"),
|
|
85
|
+
)
|
|
86
|
+
result = runner.invoke(
|
|
87
|
+
app,
|
|
88
|
+
["fetch", "--vault", "kv-test", "ghost", "--output", str(tmp_path / "k")],
|
|
89
|
+
)
|
|
90
|
+
assert result.exit_code == 2
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_vault_from_env(monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture) -> None:
|
|
94
|
+
monkeypatch.setenv("AZKV_VAULT", "kv-from-env")
|
|
95
|
+
captured: dict[str, str] = {}
|
|
96
|
+
|
|
97
|
+
def fake_list(vault: str):
|
|
98
|
+
captured["vault"] = vault
|
|
99
|
+
return iter([SecretSummary(name="x-ssh", enabled=True, content_type=None)])
|
|
100
|
+
|
|
101
|
+
mocker.patch("azkv_ssh_fetch.cli.list_secrets", side_effect=fake_list)
|
|
102
|
+
result = runner.invoke(app, ["list"])
|
|
103
|
+
assert result.exit_code == 0
|
|
104
|
+
assert captured["vault"] == "kv-from-env"
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Tests for the Key Vault wrapper. SDK is mocked."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from unittest.mock import MagicMock
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from azure.core.exceptions import HttpResponseError, ResourceNotFoundError
|
|
9
|
+
from pytest_mock import MockerFixture
|
|
10
|
+
|
|
11
|
+
from azkv_ssh_fetch import keyvault
|
|
12
|
+
from azkv_ssh_fetch.errors import KeyVaultAccessError, SecretNotFoundError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _patch_client(mocker: MockerFixture) -> MagicMock:
|
|
16
|
+
"""Patch the SecretClient factory and return the mock client."""
|
|
17
|
+
client = MagicMock()
|
|
18
|
+
mocker.patch.object(keyvault, "_client", return_value=client)
|
|
19
|
+
return client
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_fetch_secret_returns_value(mocker: MockerFixture) -> None:
|
|
23
|
+
client = _patch_client(mocker)
|
|
24
|
+
secret = MagicMock()
|
|
25
|
+
secret.value = "PRIVATE-KEY-CONTENT"
|
|
26
|
+
client.get_secret.return_value = secret
|
|
27
|
+
|
|
28
|
+
value = keyvault.fetch_secret("kv-test", "my-key")
|
|
29
|
+
assert value == "PRIVATE-KEY-CONTENT"
|
|
30
|
+
client.get_secret.assert_called_once_with("my-key")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_fetch_secret_missing_raises(mocker: MockerFixture) -> None:
|
|
34
|
+
client = _patch_client(mocker)
|
|
35
|
+
client.get_secret.side_effect = ResourceNotFoundError(message="not found")
|
|
36
|
+
|
|
37
|
+
with pytest.raises(SecretNotFoundError, match="not found"):
|
|
38
|
+
keyvault.fetch_secret("kv-test", "missing")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_fetch_secret_http_error_raises_access(mocker: MockerFixture) -> None:
|
|
42
|
+
client = _patch_client(mocker)
|
|
43
|
+
client.get_secret.side_effect = HttpResponseError(message="forbidden")
|
|
44
|
+
|
|
45
|
+
with pytest.raises(KeyVaultAccessError, match="forbidden"):
|
|
46
|
+
keyvault.fetch_secret("kv-test", "any")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_fetch_secret_null_value_raises(mocker: MockerFixture) -> None:
|
|
50
|
+
client = _patch_client(mocker)
|
|
51
|
+
secret = MagicMock()
|
|
52
|
+
secret.value = None
|
|
53
|
+
client.get_secret.return_value = secret
|
|
54
|
+
|
|
55
|
+
with pytest.raises(SecretNotFoundError, match="no value"):
|
|
56
|
+
keyvault.fetch_secret("kv-test", "blank")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_list_secrets_yields_summaries(mocker: MockerFixture) -> None:
|
|
60
|
+
client = _patch_client(mocker)
|
|
61
|
+
p1 = MagicMock(name="prop1")
|
|
62
|
+
p1.name = "alpha-ssh"
|
|
63
|
+
p1.enabled = True
|
|
64
|
+
p1.content_type = "application/x-pem-file"
|
|
65
|
+
p2 = MagicMock(name="prop2")
|
|
66
|
+
p2.name = "beta"
|
|
67
|
+
p2.enabled = False
|
|
68
|
+
p2.content_type = None
|
|
69
|
+
client.list_properties_of_secrets.return_value = iter([p1, p2])
|
|
70
|
+
|
|
71
|
+
out = list(keyvault.list_secrets("kv-test"))
|
|
72
|
+
assert [s.name for s in out] == ["alpha-ssh", "beta"]
|
|
73
|
+
assert out[0].enabled is True
|
|
74
|
+
assert out[0].content_type == "application/x-pem-file"
|
|
75
|
+
assert out[1].enabled is False
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_list_secrets_http_error(mocker: MockerFixture) -> None:
|
|
79
|
+
client = _patch_client(mocker)
|
|
80
|
+
client.list_properties_of_secrets.side_effect = HttpResponseError(message="denied")
|
|
81
|
+
|
|
82
|
+
with pytest.raises(KeyVaultAccessError, match="denied"):
|
|
83
|
+
list(keyvault.list_secrets("kv-test"))
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Tests for the SSH key writer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from azkv_ssh_fetch.errors import SshKeyWriteError
|
|
12
|
+
from azkv_ssh_fetch.ssh import assert_safe_mode, write_private_key
|
|
13
|
+
|
|
14
|
+
SAMPLE_KEY = "-----BEGIN OPENSSH PRIVATE KEY-----\nfake-base64-payload-here\n-----END OPENSSH PRIVATE KEY-----"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_write_private_key_sets_mode_0600(tmp_path: Path) -> None:
|
|
18
|
+
dest = tmp_path / "ssh" / "id_test"
|
|
19
|
+
written = write_private_key(SAMPLE_KEY, dest)
|
|
20
|
+
|
|
21
|
+
assert written.exists()
|
|
22
|
+
mode = stat.S_IMODE(written.stat().st_mode)
|
|
23
|
+
assert mode == 0o600, f"expected 0600, got {oct(mode)}"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_write_private_key_appends_trailing_newline(tmp_path: Path) -> None:
|
|
27
|
+
dest = tmp_path / "id_test"
|
|
28
|
+
write_private_key(SAMPLE_KEY, dest)
|
|
29
|
+
assert dest.read_text(encoding="utf-8").endswith("\n")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_write_private_key_preserves_existing_newline(tmp_path: Path) -> None:
|
|
33
|
+
dest = tmp_path / "id_test"
|
|
34
|
+
write_private_key(SAMPLE_KEY + "\n", dest)
|
|
35
|
+
content = dest.read_text(encoding="utf-8")
|
|
36
|
+
assert content.endswith("\n")
|
|
37
|
+
assert not content.endswith("\n\n"), "should not double-newline"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_write_private_key_atomic_replace(tmp_path: Path) -> None:
|
|
41
|
+
"""A pre-existing key file should be replaced cleanly."""
|
|
42
|
+
dest = tmp_path / "id_test"
|
|
43
|
+
dest.write_text("OLD CONTENT\n", encoding="utf-8")
|
|
44
|
+
os.chmod(dest, 0o600)
|
|
45
|
+
|
|
46
|
+
write_private_key(SAMPLE_KEY, dest)
|
|
47
|
+
assert "OLD CONTENT" not in dest.read_text(encoding="utf-8")
|
|
48
|
+
assert SAMPLE_KEY in dest.read_text(encoding="utf-8")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_write_private_key_creates_missing_parent(tmp_path: Path) -> None:
|
|
52
|
+
dest = tmp_path / "nested" / "deeply" / "id_test"
|
|
53
|
+
write_private_key(SAMPLE_KEY, dest)
|
|
54
|
+
assert dest.exists()
|
|
55
|
+
# Parent should be 0700
|
|
56
|
+
parent_mode = stat.S_IMODE(dest.parent.stat().st_mode)
|
|
57
|
+
assert parent_mode == 0o700
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_write_private_key_empty_raises(tmp_path: Path) -> None:
|
|
61
|
+
with pytest.raises(SshKeyWriteError, match="empty"):
|
|
62
|
+
write_private_key("", tmp_path / "id_test")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_assert_safe_mode_passes_for_0600(tmp_path: Path) -> None:
|
|
66
|
+
p = tmp_path / "key"
|
|
67
|
+
p.write_text("x", encoding="utf-8")
|
|
68
|
+
p.chmod(0o600)
|
|
69
|
+
assert_safe_mode(p) # no exception
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_assert_safe_mode_rejects_world_readable(tmp_path: Path) -> None:
|
|
73
|
+
p = tmp_path / "key"
|
|
74
|
+
p.write_text("x", encoding="utf-8")
|
|
75
|
+
p.chmod(0o644)
|
|
76
|
+
with pytest.raises(SshKeyWriteError, match="group/world"):
|
|
77
|
+
assert_safe_mode(p)
|