blc-ssh-mcp 0.6.1__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.
- blc_ssh_mcp-0.6.1/.dockerignore +81 -0
- blc_ssh_mcp-0.6.1/.github/workflows/audit.yml +74 -0
- blc_ssh_mcp-0.6.1/.github/workflows/ci.yml +344 -0
- blc_ssh_mcp-0.6.1/.github/workflows/release.yml +265 -0
- blc_ssh_mcp-0.6.1/.gitignore +79 -0
- blc_ssh_mcp-0.6.1/.gitleaksignore +53 -0
- blc_ssh_mcp-0.6.1/.trivyignore +34 -0
- blc_ssh_mcp-0.6.1/AGENTS.md +197 -0
- blc_ssh_mcp-0.6.1/CHANGELOG.md +457 -0
- blc_ssh_mcp-0.6.1/CLAUDE.md +9 -0
- blc_ssh_mcp-0.6.1/CONTRIBUTING.md +67 -0
- blc_ssh_mcp-0.6.1/Dockerfile +105 -0
- blc_ssh_mcp-0.6.1/LICENSE +373 -0
- blc_ssh_mcp-0.6.1/PKG-INFO +41 -0
- blc_ssh_mcp-0.6.1/README.md +459 -0
- blc_ssh_mcp-0.6.1/SECURITY.md +148 -0
- blc_ssh_mcp-0.6.1/compose.yaml +182 -0
- blc_ssh_mcp-0.6.1/config/servers.example.toml +105 -0
- blc_ssh_mcp-0.6.1/pyproject.toml +154 -0
- blc_ssh_mcp-0.6.1/src/ssh_mcp/__init__.py +3 -0
- blc_ssh_mcp-0.6.1/src/ssh_mcp/config.py +339 -0
- blc_ssh_mcp-0.6.1/src/ssh_mcp/formatting.py +268 -0
- blc_ssh_mcp-0.6.1/src/ssh_mcp/healthcheck.py +191 -0
- blc_ssh_mcp-0.6.1/src/ssh_mcp/models.py +248 -0
- blc_ssh_mcp-0.6.1/src/ssh_mcp/paths.py +338 -0
- blc_ssh_mcp-0.6.1/src/ssh_mcp/server.py +1201 -0
- blc_ssh_mcp-0.6.1/src/ssh_mcp/ssh.py +2724 -0
- blc_ssh_mcp-0.6.1/tests/conftest.py +254 -0
- blc_ssh_mcp-0.6.1/tests/test_ci_lint_determinism.py +572 -0
- blc_ssh_mcp-0.6.1/tests/test_config.py +403 -0
- blc_ssh_mcp-0.6.1/tests/test_dependency_floors.py +241 -0
- blc_ssh_mcp-0.6.1/tests/test_formatting.py +427 -0
- blc_ssh_mcp-0.6.1/tests/test_healthcheck.py +311 -0
- blc_ssh_mcp-0.6.1/tests/test_http_transport.py +1036 -0
- blc_ssh_mcp-0.6.1/tests/test_input_limits.py +383 -0
- blc_ssh_mcp-0.6.1/tests/test_logging.py +172 -0
- blc_ssh_mcp-0.6.1/tests/test_models.py +299 -0
- blc_ssh_mcp-0.6.1/tests/test_otel.py +334 -0
- blc_ssh_mcp-0.6.1/tests/test_paths.py +353 -0
- blc_ssh_mcp-0.6.1/tests/test_server.py +706 -0
- blc_ssh_mcp-0.6.1/tests/test_sftp_confinement.py +543 -0
- blc_ssh_mcp-0.6.1/tests/test_ssh.py +3873 -0
- blc_ssh_mcp-0.6.1/uv.lock +1804 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Git and version control
|
|
2
|
+
.git
|
|
3
|
+
.gitignore
|
|
4
|
+
.gitattributes
|
|
5
|
+
|
|
6
|
+
# Python virtual environments and caches
|
|
7
|
+
.venv
|
|
8
|
+
venv
|
|
9
|
+
ENV
|
|
10
|
+
env
|
|
11
|
+
__pycache__
|
|
12
|
+
*.py[cod]
|
|
13
|
+
*$py.class
|
|
14
|
+
.Python
|
|
15
|
+
*.egg-info/
|
|
16
|
+
dist/
|
|
17
|
+
build/
|
|
18
|
+
|
|
19
|
+
# Testing and coverage
|
|
20
|
+
tests/
|
|
21
|
+
pytest.ini
|
|
22
|
+
.pytest_cache/
|
|
23
|
+
.coverage
|
|
24
|
+
htmlcov/
|
|
25
|
+
.tox/
|
|
26
|
+
|
|
27
|
+
# IDE and editor files
|
|
28
|
+
.vscode/
|
|
29
|
+
.idea/
|
|
30
|
+
*.swp
|
|
31
|
+
*.swo
|
|
32
|
+
*~
|
|
33
|
+
.DS_Store
|
|
34
|
+
.sublime-project
|
|
35
|
+
.sublime-workspace
|
|
36
|
+
|
|
37
|
+
# Development tools
|
|
38
|
+
.claude/
|
|
39
|
+
.claude_code/
|
|
40
|
+
|
|
41
|
+
# CI/CD
|
|
42
|
+
.github/
|
|
43
|
+
.gitlab-ci.yml
|
|
44
|
+
.travis.yml
|
|
45
|
+
azure-pipelines.yml
|
|
46
|
+
|
|
47
|
+
# Documentation and changelog
|
|
48
|
+
docs/
|
|
49
|
+
CHANGELOG.md
|
|
50
|
+
CONTRIBUTING.md
|
|
51
|
+
SECURITY.md
|
|
52
|
+
PLAN.md
|
|
53
|
+
|
|
54
|
+
# Logs and temporary files
|
|
55
|
+
logs/
|
|
56
|
+
*.log
|
|
57
|
+
*.log.*
|
|
58
|
+
tmp/
|
|
59
|
+
temp/
|
|
60
|
+
|
|
61
|
+
# Build artifacts
|
|
62
|
+
*.tar.gz
|
|
63
|
+
*.zip
|
|
64
|
+
*.whl
|
|
65
|
+
|
|
66
|
+
# Dependencies and lock files (handled by uv during build)
|
|
67
|
+
# Note: pyproject.toml and uv.lock ARE copied and used
|
|
68
|
+
node_modules/
|
|
69
|
+
npm-debug.log
|
|
70
|
+
yarn-error.log
|
|
71
|
+
|
|
72
|
+
# SSH and sensitive files (use runtime volumes instead)
|
|
73
|
+
.ssh/
|
|
74
|
+
*.pem
|
|
75
|
+
*.key
|
|
76
|
+
config/servers.toml
|
|
77
|
+
|
|
78
|
+
# Miscellaneous
|
|
79
|
+
.env
|
|
80
|
+
.env.local
|
|
81
|
+
.env.*.local
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
name: Scheduled Audit
|
|
2
|
+
|
|
3
|
+
# Proactive advisory discovery, decoupled from PR activity.
|
|
4
|
+
#
|
|
5
|
+
# `pip-audit`'s input is the live OSV/PyPI advisory database, so its result is
|
|
6
|
+
# a function of world-state rather than of any commit. Running it ONLY on
|
|
7
|
+
# push/pull_request means a newly published CVE first surfaces as a red build
|
|
8
|
+
# on whatever unrelated PR happens to be open — which is how a Dependabot
|
|
9
|
+
# lockfile bump (PR #45) ended up blocked by an unrelated `click` advisory.
|
|
10
|
+
#
|
|
11
|
+
# This workflow adds a daily run against the default branch so new advisories
|
|
12
|
+
# get discovered on their own schedule and can be remediated in a dedicated
|
|
13
|
+
# PR. It does NOT replace either of the two commit-triggered `audit` jobs:
|
|
14
|
+
#
|
|
15
|
+
# * ci.yml's `audit` runs on pushes to `main`, on `v*` tags, and on PRs
|
|
16
|
+
# targeting `main` — note that a push to a feature branch gets no audit at
|
|
17
|
+
# all — and unconditionally gates the GHCR image publish via
|
|
18
|
+
# `docker: needs: [test, lint, audit]`.
|
|
19
|
+
# * release.yml's `audit` runs on `v*` tags only and unconditionally gates
|
|
20
|
+
# the PyPI publish via `build: needs: [test, lint, audit]`; it is the audit
|
|
21
|
+
# that guards the release path.
|
|
22
|
+
#
|
|
23
|
+
# Note that blocking a PR *merge* on ci.yml's job additionally requires
|
|
24
|
+
# `Dependency audit` to be listed in the repository's branch-protection
|
|
25
|
+
# required checks — that is a settings change, not something either workflow
|
|
26
|
+
# file can enforce. (release.yml deliberately names its audit job
|
|
27
|
+
# `Dependency audit` as well, so the two are indistinguishable by name.)
|
|
28
|
+
#
|
|
29
|
+
# Caveat on the "proactive" claim: this workflow's only failure signal is a red
|
|
30
|
+
# scheduled run on `main` — there is no notification step and no
|
|
31
|
+
# .github/dependabot.yml — and GitHub disables `schedule:` triggers after 60
|
|
32
|
+
# days of repository inactivity, silently, so on a quiet repo the daily gate
|
|
33
|
+
# stops running with no signal at all.
|
|
34
|
+
on:
|
|
35
|
+
schedule:
|
|
36
|
+
# 06:00 UTC daily. Cron in GitHub Actions is best-effort, not exact.
|
|
37
|
+
- cron: "0 6 * * *"
|
|
38
|
+
workflow_dispatch:
|
|
39
|
+
|
|
40
|
+
# Scheduled runs always use the default branch, so a `workflow_dispatch` on
|
|
41
|
+
# `main` shares this group and cancels an in-flight scheduled audit. Harmless —
|
|
42
|
+
# both runs do exactly the same thing.
|
|
43
|
+
concurrency:
|
|
44
|
+
group: ${{ github.workflow }}-${{ github.ref }}
|
|
45
|
+
cancel-in-progress: true
|
|
46
|
+
|
|
47
|
+
permissions: {}
|
|
48
|
+
|
|
49
|
+
jobs:
|
|
50
|
+
audit:
|
|
51
|
+
name: Scheduled dependency audit
|
|
52
|
+
runs-on: ubuntu-latest
|
|
53
|
+
permissions:
|
|
54
|
+
contents: read
|
|
55
|
+
|
|
56
|
+
steps:
|
|
57
|
+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
|
58
|
+
with:
|
|
59
|
+
persist-credentials: false
|
|
60
|
+
|
|
61
|
+
- name: Install uv
|
|
62
|
+
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
|
63
|
+
with:
|
|
64
|
+
version: "0.11.32"
|
|
65
|
+
enable-cache: true
|
|
66
|
+
|
|
67
|
+
- name: Install Python
|
|
68
|
+
run: uv python install 3.13
|
|
69
|
+
|
|
70
|
+
- name: Install dependencies
|
|
71
|
+
run: uv sync --locked --extra dev
|
|
72
|
+
|
|
73
|
+
- name: Audit dependencies
|
|
74
|
+
run: uv run pip-audit
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
# Branches AND `v*` tags. The tag path is not a dry run: `PUBLISH` (docker job)
|
|
4
|
+
# is true for it, so a tag push runs the full matrix and publishes a GHCR image
|
|
5
|
+
# — concurrently with release.yml, which re-runs its own copies of test/lint/
|
|
6
|
+
# audit on the same tag. release.yml's header comment and
|
|
7
|
+
# tests/test_ci_lint_determinism.py both still describe ci.yml as branches-only.
|
|
8
|
+
on:
|
|
9
|
+
push:
|
|
10
|
+
branches: [main]
|
|
11
|
+
tags: ["v*"]
|
|
12
|
+
pull_request:
|
|
13
|
+
branches: [main]
|
|
14
|
+
|
|
15
|
+
concurrency:
|
|
16
|
+
group: ${{ github.workflow }}-${{ github.ref }}
|
|
17
|
+
# Unlike release.yml (`cancel-in-progress: false`, "never cancel a
|
|
18
|
+
# half-finished publish"), this cancels in flight — on the tag and main paths
|
|
19
|
+
# too, where the docker job is publishing.
|
|
20
|
+
cancel-in-progress: true
|
|
21
|
+
|
|
22
|
+
permissions: {}
|
|
23
|
+
|
|
24
|
+
jobs:
|
|
25
|
+
test:
|
|
26
|
+
name: Test (Python ${{ matrix.python-version }})
|
|
27
|
+
runs-on: ubuntu-latest
|
|
28
|
+
permissions:
|
|
29
|
+
contents: read
|
|
30
|
+
strategy:
|
|
31
|
+
fail-fast: false
|
|
32
|
+
matrix:
|
|
33
|
+
python-version: ["3.11", "3.12", "3.13", "3.14"]
|
|
34
|
+
|
|
35
|
+
steps:
|
|
36
|
+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
|
37
|
+
with:
|
|
38
|
+
persist-credentials: false
|
|
39
|
+
|
|
40
|
+
- name: Install uv
|
|
41
|
+
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
|
42
|
+
with:
|
|
43
|
+
version: "0.11.32"
|
|
44
|
+
enable-cache: true
|
|
45
|
+
|
|
46
|
+
- name: Install Python ${{ matrix.python-version }}
|
|
47
|
+
run: uv python install ${{ matrix.python-version }}
|
|
48
|
+
|
|
49
|
+
- name: Install dependencies
|
|
50
|
+
run: uv sync --locked --extra dev
|
|
51
|
+
|
|
52
|
+
- name: Run tests
|
|
53
|
+
run: uv run python -m pytest tests/ -v --cov=ssh_mcp --cov-report=term-missing
|
|
54
|
+
|
|
55
|
+
lint:
|
|
56
|
+
name: Lint
|
|
57
|
+
runs-on: ubuntu-latest
|
|
58
|
+
permissions:
|
|
59
|
+
contents: read
|
|
60
|
+
|
|
61
|
+
steps:
|
|
62
|
+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
|
63
|
+
with:
|
|
64
|
+
persist-credentials: false
|
|
65
|
+
|
|
66
|
+
- name: Install uv
|
|
67
|
+
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
|
68
|
+
with:
|
|
69
|
+
version: "0.11.32"
|
|
70
|
+
enable-cache: true
|
|
71
|
+
|
|
72
|
+
- name: Install Python
|
|
73
|
+
run: uv python install 3.13
|
|
74
|
+
|
|
75
|
+
- name: Install dependencies
|
|
76
|
+
run: uv sync --locked --extra dev
|
|
77
|
+
|
|
78
|
+
# ruff and bandit come from the LOCKED dev env at a pinned version.
|
|
79
|
+
# `uvx` was used here previously; it floats to the newest release, so
|
|
80
|
+
# ruff 0.16.0's default-rule expansion (59 -> 413 on 2026-07-23) turned
|
|
81
|
+
# this job red on commits it had passed days earlier. See pyproject.toml
|
|
82
|
+
# [tool.ruff.lint] for the explicitly declared rule set.
|
|
83
|
+
#
|
|
84
|
+
# mypy also gates this job (below) but is NOT exactly pinned
|
|
85
|
+
# (`mypy>=1.15` in pyproject.toml); its determinism rests on uv.lock
|
|
86
|
+
# alone, not on the pin rationale above. Same blind spot in
|
|
87
|
+
# PINNED_LINT_TOOLS in tests/test_ci_lint_determinism.py.
|
|
88
|
+
- name: Check formatting with ruff
|
|
89
|
+
run: uv run ruff format --check src/ tests/
|
|
90
|
+
|
|
91
|
+
- name: Lint with ruff
|
|
92
|
+
run: uv run ruff check src/ tests/
|
|
93
|
+
|
|
94
|
+
- name: Type check with mypy
|
|
95
|
+
run: uv run mypy src/
|
|
96
|
+
|
|
97
|
+
- name: Security scan with bandit
|
|
98
|
+
run: uv run bandit -r src/ -q
|
|
99
|
+
|
|
100
|
+
# `pip-audit` lives in its own job rather than inside `lint` because its
|
|
101
|
+
# input is the LIVE advisory database, not the commit: a newly published
|
|
102
|
+
# CVE against any transitive dependency fails this job on every open PR at
|
|
103
|
+
# once. Keeping it separate makes that failure attributable instead of
|
|
104
|
+
# masquerading as a lint error.
|
|
105
|
+
#
|
|
106
|
+
# Two different guarantees, do not conflate them:
|
|
107
|
+
# * The GHCR image publish is gated UNCONDITIONALLY — `docker` lists this
|
|
108
|
+
# job in its `needs:` (see below), enforced by this workflow itself.
|
|
109
|
+
# * Blocking a PR merge is enforced by BRANCH PROTECTION, not by this
|
|
110
|
+
# file. Until `Dependency audit` is in the repository's required-checks
|
|
111
|
+
# list, this job runs on every PR but does not block merging.
|
|
112
|
+
#
|
|
113
|
+
# A scheduled counterpart runs daily in audit.yml.
|
|
114
|
+
audit:
|
|
115
|
+
name: Dependency audit
|
|
116
|
+
runs-on: ubuntu-latest
|
|
117
|
+
permissions:
|
|
118
|
+
contents: read
|
|
119
|
+
|
|
120
|
+
steps:
|
|
121
|
+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
|
122
|
+
with:
|
|
123
|
+
persist-credentials: false
|
|
124
|
+
|
|
125
|
+
- name: Install uv
|
|
126
|
+
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
|
127
|
+
with:
|
|
128
|
+
version: "0.11.32"
|
|
129
|
+
enable-cache: true
|
|
130
|
+
|
|
131
|
+
- name: Install Python
|
|
132
|
+
run: uv python install 3.13
|
|
133
|
+
|
|
134
|
+
- name: Install dependencies
|
|
135
|
+
run: uv sync --locked --extra dev
|
|
136
|
+
|
|
137
|
+
- name: Audit dependencies
|
|
138
|
+
run: uv run pip-audit
|
|
139
|
+
|
|
140
|
+
docker:
|
|
141
|
+
name: Docker Build & Push
|
|
142
|
+
runs-on: ubuntu-latest
|
|
143
|
+
# `audit` is REQUIRED here. When pip-audit lived inside `lint` this
|
|
144
|
+
# dependency existed only implicitly; making it explicit is what keeps a
|
|
145
|
+
# known-vulnerable image from reaching GHCR. Do not remove it — a
|
|
146
|
+
# regression test asserts this list contains `audit`.
|
|
147
|
+
needs: [test, lint, audit]
|
|
148
|
+
permissions:
|
|
149
|
+
contents: read
|
|
150
|
+
packages: write
|
|
151
|
+
id-token: write # sign the provenance attestation
|
|
152
|
+
attestations: write # record it
|
|
153
|
+
env:
|
|
154
|
+
# Single source of truth for the PUBLISH path: build, scan, promote and
|
|
155
|
+
# attest all derive their reference from it, so those four steps cannot
|
|
156
|
+
# drift apart. The PR path deliberately stays out of the registry and
|
|
157
|
+
# hardcodes `ssh-mcp:ci` in its build and scan steps instead.
|
|
158
|
+
IMAGE: ghcr.io/${{ github.repository }}
|
|
159
|
+
# Publishing is the exception, not the default. PRs build + scan only.
|
|
160
|
+
PUBLISH: ${{ github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) }}
|
|
161
|
+
steps:
|
|
162
|
+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
|
163
|
+
with:
|
|
164
|
+
persist-credentials: false
|
|
165
|
+
|
|
166
|
+
- name: Set up Docker Buildx
|
|
167
|
+
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
|
168
|
+
|
|
169
|
+
- name: Log in to GHCR
|
|
170
|
+
if: env.PUBLISH == 'true'
|
|
171
|
+
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
|
172
|
+
with:
|
|
173
|
+
registry: ghcr.io
|
|
174
|
+
username: ${{ github.actor }}
|
|
175
|
+
password: ${{ secrets.GITHUB_TOKEN }}
|
|
176
|
+
|
|
177
|
+
# Only this step's `tags` output is consumed (by promote + verify). Its
|
|
178
|
+
# `labels`/`annotations` outputs are computed and then used by nothing —
|
|
179
|
+
# the publish build passes no `labels:`, so published images carry none.
|
|
180
|
+
- name: Docker meta
|
|
181
|
+
id: meta
|
|
182
|
+
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
|
|
183
|
+
with:
|
|
184
|
+
images: ${{ env.IMAGE }}
|
|
185
|
+
tags: |
|
|
186
|
+
type=sha
|
|
187
|
+
type=semver,pattern={{version}}
|
|
188
|
+
type=semver,pattern={{major}}.{{minor}}
|
|
189
|
+
type=raw,value=latest,enable={{is_default_branch}}
|
|
190
|
+
|
|
191
|
+
# -----------------------------------------------------------------------
|
|
192
|
+
# ONE build per path. On the publish path it goes straight to GHCR *by
|
|
193
|
+
# digest* — no tag is attached, so nothing in the registry is reachable by
|
|
194
|
+
# name until Trivy's CRITICAL gate has passed. PRs take the second step
|
|
195
|
+
# below, which loads the image locally instead. The two are independent
|
|
196
|
+
# BuildKit invocations with different exporters, NOT the same build — see
|
|
197
|
+
# test_docker_job_builds_exactly_once in
|
|
198
|
+
# tests/test_ci_lint_determinism.py for why that distinction matters.
|
|
199
|
+
#
|
|
200
|
+
# On the publish step provenance/sbom are disabled on purpose: BuildKit's
|
|
201
|
+
# inline attestations wrap the image in an index, which complicates
|
|
202
|
+
# digest promotion, and GHCR's Referrers API support for them is
|
|
203
|
+
# incomplete. GitHub Artifact Attestations (below) are meant to cover the
|
|
204
|
+
# same ground with a working verifier — but see the verify step: that
|
|
205
|
+
# attest step does not currently run. The PR step sets neither input; it
|
|
206
|
+
# never leaves the runner. (The CycloneDX SBOM is a release.yml artifact,
|
|
207
|
+
# not something this job produces or attests.)
|
|
208
|
+
# -----------------------------------------------------------------------
|
|
209
|
+
- name: Build image (publish path — push by digest, no tags)
|
|
210
|
+
id: build
|
|
211
|
+
if: env.PUBLISH == 'true'
|
|
212
|
+
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
|
|
213
|
+
with:
|
|
214
|
+
context: .
|
|
215
|
+
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
|
|
216
|
+
provenance: false
|
|
217
|
+
sbom: false
|
|
218
|
+
cache-from: type=gha
|
|
219
|
+
cache-to: type=gha,mode=max
|
|
220
|
+
|
|
221
|
+
- name: Build image (PR path — local only)
|
|
222
|
+
id: build_local
|
|
223
|
+
if: env.PUBLISH != 'true'
|
|
224
|
+
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
|
|
225
|
+
with:
|
|
226
|
+
context: .
|
|
227
|
+
push: false
|
|
228
|
+
load: true
|
|
229
|
+
tags: ssh-mcp:ci
|
|
230
|
+
cache-from: type=gha
|
|
231
|
+
cache-to: type=gha,mode=max
|
|
232
|
+
|
|
233
|
+
# -----------------------------------------------------------------------
|
|
234
|
+
# Scan the EXACT digest that was pushed. `${IMAGE}@sha256:…` is content-
|
|
235
|
+
# addressed: there is no tag in the reference, so nothing can have been
|
|
236
|
+
# substituted between the build step and this one.
|
|
237
|
+
#
|
|
238
|
+
# Scope of the gate, deliberately narrow: `severity: CRITICAL` only, so
|
|
239
|
+
# HIGH image findings never fail the build, and `ignore-unfixed` is unset.
|
|
240
|
+
# Open posture decision, tracked in docs/fixes/05-followups.md §3.
|
|
241
|
+
# -----------------------------------------------------------------------
|
|
242
|
+
- name: Scan published digest with Trivy
|
|
243
|
+
if: env.PUBLISH == 'true'
|
|
244
|
+
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
|
245
|
+
with:
|
|
246
|
+
image-ref: ${{ env.IMAGE }}@${{ steps.build.outputs.digest }}
|
|
247
|
+
severity: CRITICAL
|
|
248
|
+
exit-code: 1
|
|
249
|
+
trivyignores: .trivyignore
|
|
250
|
+
|
|
251
|
+
- name: Scan local image with Trivy
|
|
252
|
+
if: env.PUBLISH != 'true'
|
|
253
|
+
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
|
254
|
+
with:
|
|
255
|
+
image-ref: ssh-mcp:ci
|
|
256
|
+
severity: CRITICAL
|
|
257
|
+
exit-code: 1
|
|
258
|
+
trivyignores: .trivyignore
|
|
259
|
+
|
|
260
|
+
# -----------------------------------------------------------------------
|
|
261
|
+
# Only now does a human-readable tag come into existence, and it is
|
|
262
|
+
# created FROM the scanned digest. No rebuild, no second BuildKit run.
|
|
263
|
+
# Mind the ordering: this step publishes the tags, and the verify step
|
|
264
|
+
# below only reports on them afterwards — a failing verify untags nothing.
|
|
265
|
+
# -----------------------------------------------------------------------
|
|
266
|
+
- name: Promote scanned digest to tags
|
|
267
|
+
if: env.PUBLISH == 'true'
|
|
268
|
+
env:
|
|
269
|
+
TAGS: ${{ steps.meta.outputs.tags }}
|
|
270
|
+
DIGEST: ${{ steps.build.outputs.digest }}
|
|
271
|
+
run: |
|
|
272
|
+
set -euo pipefail
|
|
273
|
+
TAG_ARGS=()
|
|
274
|
+
while IFS= read -r tag; do
|
|
275
|
+
[ -n "$tag" ] && TAG_ARGS+=(--tag "$tag")
|
|
276
|
+
done <<< "$TAGS"
|
|
277
|
+
docker buildx imagetools create "${TAG_ARGS[@]}" "${IMAGE}@${DIGEST}"
|
|
278
|
+
|
|
279
|
+
# Proof, in the job log, that every published tag serves the bytes Trivy
|
|
280
|
+
# cleared — nothing more.
|
|
281
|
+
#
|
|
282
|
+
# Production incident 2026-07-26: the original check compared
|
|
283
|
+
# `sha256sum` of `imagetools inspect --raw` against the build digest and
|
|
284
|
+
# failed on EVERY publish run, taking the attest step below down with it
|
|
285
|
+
# (images published between 2026-07-26 and this fix carry no provenance
|
|
286
|
+
# attestation). Confirmed cause: `imagetools create` does NOT reuse the
|
|
287
|
+
# source digest — it wraps the single-platform manifest in a NEW OCI
|
|
288
|
+
# index, so the tag's own digest legitimately differs from the scanned
|
|
289
|
+
# manifest's. Verified against the live registry: `:latest` was index
|
|
290
|
+
# sha256:8204263e… whose sole child was the scanned sha256:12db37ce…,
|
|
291
|
+
# and re-hashing those bytes reproduces 8204263e exactly.
|
|
292
|
+
# (Piping `inspect --raw` into `sha256sum` is separately fragile if the
|
|
293
|
+
# CLI appends a newline — not the cause here, but a reason not to go back
|
|
294
|
+
# to hashing stdout.)
|
|
295
|
+
#
|
|
296
|
+
# So compare the right thing: the set of image manifests the tag actually
|
|
297
|
+
# serves must be exactly {scanned digest}. Attestation manifests that
|
|
298
|
+
# buildx/attest may attach are excluded — they are metadata about the
|
|
299
|
+
# image, not another runnable image. Do not "simplify" this back to
|
|
300
|
+
# comparing the tag's own digest; that asserts an invariant the promote
|
|
301
|
+
# step deliberately does not hold.
|
|
302
|
+
- name: Verify every tag serves only the scanned digest
|
|
303
|
+
if: env.PUBLISH == 'true'
|
|
304
|
+
env:
|
|
305
|
+
TAGS: ${{ steps.meta.outputs.tags }}
|
|
306
|
+
DIGEST: ${{ steps.build.outputs.digest }}
|
|
307
|
+
run: |
|
|
308
|
+
set -euo pipefail
|
|
309
|
+
while IFS= read -r tag; do
|
|
310
|
+
[ -z "$tag" ] && continue
|
|
311
|
+
raw="$(docker buildx imagetools inspect --raw "$tag")"
|
|
312
|
+
# An index lists children under .manifests; a bare manifest has none.
|
|
313
|
+
served="$(printf '%s' "$raw" | jq -r '
|
|
314
|
+
if .manifests then
|
|
315
|
+
[ .manifests[]
|
|
316
|
+
| select((.annotations["vnd.docker.reference.type"] // "")
|
|
317
|
+
!= "attestation-manifest")
|
|
318
|
+
| .digest ] | join(" ")
|
|
319
|
+
else
|
|
320
|
+
empty
|
|
321
|
+
end')"
|
|
322
|
+
if [ -z "$served" ]; then
|
|
323
|
+
served="$(docker buildx imagetools inspect --format '{{.Manifest.Digest}}' "$tag")"
|
|
324
|
+
fi
|
|
325
|
+
echo "${tag} serves: ${served} (scanned ${DIGEST})"
|
|
326
|
+
for d in $served; do
|
|
327
|
+
if [ "$d" != "${DIGEST}" ]; then
|
|
328
|
+
echo "::error::${tag} serves ${d}, which is not the scanned digest ${DIGEST}" >&2
|
|
329
|
+
exit 1
|
|
330
|
+
fi
|
|
331
|
+
done
|
|
332
|
+
if [ -z "$served" ]; then
|
|
333
|
+
echo "::error::${tag} resolved to no image manifest at all" >&2
|
|
334
|
+
exit 1
|
|
335
|
+
fi
|
|
336
|
+
done <<< "$TAGS"
|
|
337
|
+
|
|
338
|
+
- name: Attest build provenance for the image
|
|
339
|
+
if: env.PUBLISH == 'true'
|
|
340
|
+
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
|
|
341
|
+
with:
|
|
342
|
+
subject-name: ${{ env.IMAGE }} # no tag — the digest identifies the subject
|
|
343
|
+
subject-digest: ${{ steps.build.outputs.digest }}
|
|
344
|
+
push-to-registry: true
|