Compare commits
29
Commits
ce5ae6caa5
...
8786502b8c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8786502b8c | ||
|
|
446ec11954 | ||
|
|
ae6d83c3bd | ||
|
|
fed75b17c8 | ||
|
|
303bd9aa21 | ||
|
|
22c8157ca4 | ||
|
|
29b12bfe0d | ||
|
|
825c3c93e2 | ||
|
|
51c766b231 | ||
|
|
f86b9b529c | ||
|
|
d7cac91175 | ||
|
|
0f5f5cdd95 | ||
|
|
984673e8cf | ||
|
|
421a6507c0 | ||
|
|
fef80a5b21 | ||
|
|
766968fa42 | ||
|
|
6f7a3c0e5b | ||
|
|
d96c3ac604 | ||
|
|
87f048cc4d | ||
|
|
f57c237bed | ||
|
|
782d416e46 | ||
|
|
8be859243d | ||
|
|
79aa4f7fc0 | ||
|
|
7307214ab6 | ||
|
|
fd5e4b71f3 | ||
|
|
43eebd3089 | ||
|
|
5b622e64e2 | ||
|
|
d5ca530db3 | ||
|
|
5baf73f38b |
No files matched your search
@@ -34,6 +34,11 @@ GITEA_OAUTH_CLIENT_SECRET=
|
|||||||
# Encrypts stored user tokens. At least 32 characters. Generate with:
|
# Encrypts stored user tokens. At least 32 characters. Generate with:
|
||||||
# openssl rand -hex 32
|
# openssl rand -hex 32
|
||||||
TOKEN_ENCRYPTION_KEY=
|
TOKEN_ENCRYPTION_KEY=
|
||||||
|
# Optional allowlist of redirect URIs clients may register (comma separated): exact
|
||||||
|
# URIs, or https://host/ prefixes. Empty accepts any https URI (and http on
|
||||||
|
# loopback); the consent page is then the only check. For a public deployment list the
|
||||||
|
# hosted clients you use, e.g. https://claude.ai/,https://chat.mistral.ai/
|
||||||
|
OAUTH_REDIRECT_ALLOWLIST=
|
||||||
# Granular Gitea scopes only (read:/write: activitypub, admin, issue, misc,
|
# Granular Gitea scopes only (read:/write: activitypub, admin, issue, misc,
|
||||||
# notification, organization, package, repository, user). Unknown values are rejected.
|
# notification, organization, package, repository, user). Unknown values are rejected.
|
||||||
OAUTH_SCOPES=write:activitypub,write:admin,write:issue,write:misc,write:notification,write:organization,write:package,write:repository,write:user
|
OAUTH_SCOPES=write:activitypub,write:admin,write:issue,write:misc,write:notification,write:organization,write:package,write:repository,write:user
|
||||||
|
|||||||
+79
-29
@@ -4,13 +4,21 @@ name: ci
|
|||||||
#
|
#
|
||||||
# gitleaks secret scan over the full history
|
# gitleaks secret scan over the full history
|
||||||
# lint ruff check, ruff format --check, mypy
|
# lint ruff check, ruff format --check, mypy
|
||||||
# test-unit pytest (coverage gate from pyproject.toml)
|
# test-unit pytest (coverage gate from pyproject.toml: 90 percent)
|
||||||
# test-integration pytest against a throwaway Gitea in Docker
|
# test-integration pytest against a throwaway Gitea in Docker
|
||||||
# test-mcp MCP smoke tests, including against the freshly built image
|
# test-mcp MCP smoke tests, including against the freshly built image
|
||||||
# build-and-push build the image; push it on push events (needs lint + test-unit)
|
# build-and-push build and scan the image; push it on push events (needs lint + test-unit)
|
||||||
# docs-check docs drift check
|
# docs-check docs drift check
|
||||||
# security security test suite, pip-audit, ruff bandit rules
|
# security security test suite, pip-audit, ruff bandit rules
|
||||||
#
|
#
|
||||||
|
# Jobs that use the shared Docker daemon or secrets (test-integration, test-mcp,
|
||||||
|
# build-and-push) do not run for pull requests from forks: a fork controls the
|
||||||
|
# workflow and test code, and access to the daemon is root-equivalent on the
|
||||||
|
# host. Also enable "require approval for workflows from forks" in Gitea.
|
||||||
|
#
|
||||||
|
# Every third-party action is pinned to a full commit SHA (the version is in the
|
||||||
|
# trailing comment); bump deliberately.
|
||||||
|
#
|
||||||
# Docker hygiene on the shared runner: every resource this workflow creates has
|
# Docker hygiene on the shared runner: every resource this workflow creates has
|
||||||
# a per-run name (aegis-ci-<run id>-...) and is removed by an `if: always()`
|
# a per-run name (aegis-ci-<run id>-...) and is removed by an `if: always()`
|
||||||
# step that deletes exactly those names. Nothing here ever prunes.
|
# step that deletes exactly those names. Nothing here ever prunes.
|
||||||
@@ -37,9 +45,10 @@ jobs:
|
|||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install gitleaks
|
- name: Install gitleaks
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -60,13 +69,15 @@ jobs:
|
|||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
- name: Set up uv
|
- name: Set up uv
|
||||||
uses: astral-sh/setup-uv@v5
|
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
|
||||||
with:
|
with:
|
||||||
version: "0.12.5"
|
version: "0.12.5"
|
||||||
enable-cache: false
|
enable-cache: false
|
||||||
@@ -87,13 +98,15 @@ jobs:
|
|||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
- name: Set up uv
|
- name: Set up uv
|
||||||
uses: astral-sh/setup-uv@v5
|
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
|
||||||
with:
|
with:
|
||||||
version: "0.12.5"
|
version: "0.12.5"
|
||||||
enable-cache: false
|
enable-cache: false
|
||||||
@@ -105,7 +118,7 @@ jobs:
|
|||||||
# Best effort: some act_runner versions do not support the v4 artifact
|
# Best effort: some act_runner versions do not support the v4 artifact
|
||||||
# backend, and the coverage gate itself already ran above.
|
# backend, and the coverage gate itself already ran above.
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
with:
|
with:
|
||||||
name: coverage-${{ github.run_id }}
|
name: coverage-${{ github.run_id }}
|
||||||
path: coverage.xml
|
path: coverage.xml
|
||||||
@@ -117,17 +130,20 @@ jobs:
|
|||||||
# cleanup step is the safety net for cancelled runs.
|
# cleanup step is the safety net for cancelled runs.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
test-integration:
|
test-integration:
|
||||||
|
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 40
|
timeout-minutes: 40
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
- name: Set up uv
|
- name: Set up uv
|
||||||
uses: astral-sh/setup-uv@v5
|
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
|
||||||
with:
|
with:
|
||||||
version: "0.12.5"
|
version: "0.12.5"
|
||||||
enable-cache: false
|
enable-cache: false
|
||||||
@@ -151,19 +167,22 @@ jobs:
|
|||||||
# runners.
|
# runners.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
test-mcp:
|
test-mcp:
|
||||||
|
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 40
|
timeout-minutes: 40
|
||||||
env:
|
env:
|
||||||
MCP_IMAGE: aegis-ci-${{ github.run_id }}-mcp:test
|
MCP_IMAGE: aegis-ci-${{ github.run_id }}-mcp:test
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
- name: Set up uv
|
- name: Set up uv
|
||||||
uses: astral-sh/setup-uv@v5
|
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
|
||||||
with:
|
with:
|
||||||
version: "0.12.5"
|
version: "0.12.5"
|
||||||
enable-cache: false
|
enable-cache: false
|
||||||
@@ -196,6 +215,7 @@ jobs:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
build-and-push:
|
build-and-push:
|
||||||
needs: [lint, test-unit]
|
needs: [lint, test-unit]
|
||||||
|
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
outputs:
|
outputs:
|
||||||
@@ -203,7 +223,9 @@ jobs:
|
|||||||
digest: ${{ steps.digest.outputs.digest }}
|
digest: ${{ steps.digest.outputs.digest }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Compute image name and tags
|
- name: Compute image name and tags
|
||||||
id: meta
|
id: meta
|
||||||
@@ -237,15 +259,36 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: docker run --rm "${{ steps.meta.outputs.sha_tag }}" version
|
run: docker run --rm "${{ steps.meta.outputs.sha_tag }}" version
|
||||||
|
|
||||||
|
# Vulnerability scan of the image that is about to be pushed: pinned Trivy
|
||||||
|
# (sha256 verified), fails on fixable HIGH and CRITICAL findings. Waivers live
|
||||||
|
# in security/trivyignore.txt and docs/security.md.
|
||||||
|
- name: Install Trivy
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
bin_dir="${RUNNER_TEMP:-/tmp}/trivy-bin"
|
||||||
|
bash scripts/ci/install_trivy.sh "${bin_dir}"
|
||||||
|
echo "${bin_dir}" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Scan image
|
||||||
|
shell: bash
|
||||||
|
run: bash scripts/ci/scan_image.sh "${{ steps.meta.outputs.sha_tag }}"
|
||||||
|
|
||||||
|
# No third-party action sees the registry token: the CLI reads it from stdin. The
|
||||||
|
# credentials go to a per-job config directory (DOCKER_CONFIG), never into the shared
|
||||||
|
# credential store of the runner's Docker client.
|
||||||
- name: Log in to the registry
|
- name: Log in to the registry
|
||||||
if: github.event_name == 'push'
|
if: github.event_name == 'push'
|
||||||
uses: docker/login-action@v3
|
shell: bash
|
||||||
with:
|
env:
|
||||||
registry: git.hiddenden.cafe
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
# PAT with write:package scope. The built-in token lacks package-write
|
# PAT with write:package scope. The built-in token lacks package-write
|
||||||
# on this instance.
|
# on this instance.
|
||||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
REGISTRY_USER: ${{ github.actor }}
|
||||||
|
run: |
|
||||||
|
config_dir="$(mktemp -d)"
|
||||||
|
echo "DOCKER_CONFIG=${config_dir}" >> "$GITHUB_ENV"
|
||||||
|
printf '%s' "${REGISTRY_TOKEN}" | DOCKER_CONFIG="${config_dir}" \
|
||||||
|
docker login git.hiddenden.cafe --username "${REGISTRY_USER}" --password-stdin
|
||||||
|
|
||||||
- name: Tag and push
|
- name: Tag and push
|
||||||
if: github.event_name == 'push'
|
if: github.event_name == 'push'
|
||||||
@@ -284,7 +327,10 @@ jobs:
|
|||||||
if: always()
|
if: always()
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
docker logout git.hiddenden.cafe || true
|
# Only the per-job credential directory created above is removed.
|
||||||
|
if [ -n "${DOCKER_CONFIG:-}" ]; then
|
||||||
|
rm -rf "${DOCKER_CONFIG}"
|
||||||
|
fi
|
||||||
bash scripts/ci/cleanup_job_resources.sh \
|
bash scripts/ci/cleanup_job_resources.sh \
|
||||||
"${{ steps.meta.outputs.sha_tag }}" ${{ steps.meta.outputs.extra_tags }}
|
"${{ steps.meta.outputs.sha_tag }}" ${{ steps.meta.outputs.extra_tags }}
|
||||||
|
|
||||||
@@ -296,13 +342,15 @@ jobs:
|
|||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
- name: Set up uv
|
- name: Set up uv
|
||||||
uses: astral-sh/setup-uv@v5
|
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
|
||||||
with:
|
with:
|
||||||
version: "0.12.5"
|
version: "0.12.5"
|
||||||
enable-cache: false
|
enable-cache: false
|
||||||
@@ -321,13 +369,15 @@ jobs:
|
|||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
- name: Set up uv
|
- name: Set up uv
|
||||||
uses: astral-sh/setup-uv@v5
|
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
|
||||||
with:
|
with:
|
||||||
version: "0.12.5"
|
version: "0.12.5"
|
||||||
enable-cache: false
|
enable-cache: false
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ name: publish
|
|||||||
|
|
||||||
# Build the Python package with uv and publish it to the self-hosted Gitea PyPI
|
# Build the Python package with uv and publish it to the self-hosted Gitea PyPI
|
||||||
# registry on merge. dev -> a dev package (aegis-gitea-mcp-dev, .devN versions);
|
# registry on merge. dev -> a dev package (aegis-gitea-mcp-dev, .devN versions);
|
||||||
# main -> the stable package (aegis-gitea-mcp). Gated on lint + tests so a release
|
# main -> the stable package (aegis-gitea-mcp). Gated on lint, tests and the security
|
||||||
# can never ship red. Reuses the REGISTRY_TOKEN package secret (same one ci.yml
|
# checks so a release can never ship red. Reuses the REGISTRY_TOKEN package secret
|
||||||
# uses); if it is absent the job fails loudly instead of publishing anonymously.
|
# (same one ci.yml uses); if it is absent the job fails loudly instead of publishing
|
||||||
|
# anonymously.
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
@@ -19,13 +20,15 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
- name: Set up uv
|
- name: Set up uv
|
||||||
uses: astral-sh/setup-uv@v5
|
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
|
||||||
with:
|
with:
|
||||||
version: "0.12.5"
|
version: "0.12.5"
|
||||||
enable-cache: false
|
enable-cache: false
|
||||||
@@ -44,13 +47,15 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
- name: Set up uv
|
- name: Set up uv
|
||||||
uses: astral-sh/setup-uv@v5
|
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
|
||||||
with:
|
with:
|
||||||
version: "0.12.5"
|
version: "0.12.5"
|
||||||
enable-cache: false
|
enable-cache: false
|
||||||
@@ -60,7 +65,46 @@ jobs:
|
|||||||
run: uv run pytest
|
run: uv run pytest
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 3. Build with uv and publish to the Gitea PyPI registry.
|
# 3. Security: the same gates as ci.yml (secret scan over the history, security
|
||||||
|
# tests, dependency audit, bandit rules). Nothing is published without them.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
security:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
- name: Set up uv
|
||||||
|
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
|
||||||
|
with:
|
||||||
|
version: "0.12.5"
|
||||||
|
enable-cache: false
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync --locked
|
||||||
|
- name: Install gitleaks
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
bin_dir="${RUNNER_TEMP:-/tmp}/gitleaks-bin"
|
||||||
|
bash scripts/ci/install_gitleaks.sh "${bin_dir}"
|
||||||
|
echo "${bin_dir}" >> "$GITHUB_PATH"
|
||||||
|
- name: Scan history
|
||||||
|
shell: bash
|
||||||
|
run: gitleaks git --config .gitleaks.toml --redact --no-banner --verbose --exit-code 1 .
|
||||||
|
- name: Security tests
|
||||||
|
run: uv run pytest tests/security --no-cov
|
||||||
|
- name: Dependency audit
|
||||||
|
run: bash scripts/ci/pip_audit.sh
|
||||||
|
- name: Ruff security rules
|
||||||
|
run: uv run ruff check --select S .
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4. Build with uv and publish to the Gitea PyPI registry.
|
||||||
#
|
#
|
||||||
# dev -> aegis-gitea-mcp-dev X.Y.Z.dev<run_number> (always unique)
|
# dev -> aegis-gitea-mcp-dev X.Y.Z.dev<run_number> (always unique)
|
||||||
# main -> aegis-gitea-mcp X.Y.Z (no-op if already there)
|
# main -> aegis-gitea-mcp X.Y.Z (no-op if already there)
|
||||||
@@ -70,19 +114,21 @@ jobs:
|
|||||||
# and version "X.Y.Z".
|
# and version "X.Y.Z".
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
publish:
|
publish:
|
||||||
needs: [lint, test]
|
needs: [lint, test, security]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
|
|
||||||
- name: Set up uv
|
- name: Set up uv
|
||||||
uses: astral-sh/setup-uv@v5
|
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
|
||||||
with:
|
with:
|
||||||
version: "0.12.5"
|
version: "0.12.5"
|
||||||
enable-cache: false
|
enable-cache: false
|
||||||
@@ -135,7 +181,7 @@ jobs:
|
|||||||
# v4 artifact backend. The real deliverable is published to the registry
|
# v4 artifact backend. The real deliverable is published to the registry
|
||||||
# below, so a failed artifact upload must not fail the release.
|
# below, so a failed artifact upload must not fail the release.
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
with:
|
with:
|
||||||
name: dist-${{ steps.chan.outputs.channel }}
|
name: dist-${{ steps.chan.outputs.channel }}
|
||||||
path: dist/*
|
path: dist/*
|
||||||
@@ -144,25 +190,26 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
# Reuse the existing package secret (same one ci.yml uses). The
|
# Reuse the existing package secret (same one ci.yml uses). The
|
||||||
# token authenticates as its owning Gitea user, so GITHUB_ACTOR is the
|
# token authenticates as its owning Gitea user, so the actor is the
|
||||||
# username and the token is the password.
|
# username and the token is the password. uv reads the credentials from these variables, so the token never
|
||||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
# appears on a command line (process listings, shell traces).
|
||||||
|
UV_PUBLISH_USERNAME: ${{ github.actor }}
|
||||||
|
UV_PUBLISH_PASSWORD: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
# --check-url makes uv skip files already in the registry, so a main
|
# --check-url makes uv skip files already in the registry, so a main
|
||||||
# push that did not bump the version is a clean no-op instead of a 409.
|
# push that did not bump the version is a clean no-op instead of a 409.
|
||||||
uv publish \
|
uv publish \
|
||||||
--publish-url https://git.hiddenden.cafe/api/packages/Hiddenden/pypi \
|
--publish-url https://git.hiddenden.cafe/api/packages/Hiddenden/pypi \
|
||||||
--check-url https://git.hiddenden.cafe/api/packages/Hiddenden/pypi/simple/ \
|
--check-url https://git.hiddenden.cafe/api/packages/Hiddenden/pypi/simple/
|
||||||
--username "${GITHUB_ACTOR}" \
|
|
||||||
--password "${REGISTRY_TOKEN}"
|
|
||||||
|
|
||||||
# Optional second step to also publish to public PyPI lives behind its own
|
# Optional second step to also publish to public PyPI lives behind its own
|
||||||
# secret. Intentionally left as a disabled stub — this pass does NOT push
|
# secret. Intentionally left as a disabled stub: this pass does NOT push
|
||||||
# to public PyPI.
|
# to public PyPI.
|
||||||
#
|
#
|
||||||
# - name: Publish to public PyPI
|
# - name: Publish to public PyPI
|
||||||
# if: ${{ secrets.PYPI_TOKEN != '' }}
|
# if: ${{ secrets.PYPI_TOKEN != '' }}
|
||||||
# shell: bash
|
# shell: bash
|
||||||
# env:
|
# env:
|
||||||
# PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
|
# UV_PUBLISH_USERNAME: __token__
|
||||||
# run: uv publish --username __token__ --password "${PYPI_TOKEN}"
|
# UV_PUBLISH_PASSWORD: ${{ secrets.PYPI_TOKEN }}
|
||||||
|
# run: uv publish
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
name: scan
|
||||||
|
|
||||||
|
# Weekly vulnerability check that does not depend on a push. The base image digests
|
||||||
|
# in docker/Dockerfile are pinned and refreshed by hand, so a new fixable HIGH or
|
||||||
|
# CRITICAL advisory in the pinned image, or a new advisory in the locked
|
||||||
|
# dependencies, turns this run red: the reminder to refresh the digests (see the
|
||||||
|
# comment at the top of docker/Dockerfile) or bump the dependency.
|
||||||
|
#
|
||||||
|
# Same rules as ci.yml: third-party actions pinned to a commit SHA, resources named
|
||||||
|
# per run and removed by name, no pruning.
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '17 5 * * 1'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
UV_PYTHON_DOWNLOADS: never
|
||||||
|
UV_NO_PROGRESS: "1"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
image-scan:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
env:
|
||||||
|
SCAN_IMAGE: aegis-ci-${{ github.run_id }}-scan:test
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Build image
|
||||||
|
run: docker build -f docker/Dockerfile -t "${SCAN_IMAGE}" .
|
||||||
|
- name: Install Trivy
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
bin_dir="${RUNNER_TEMP:-/tmp}/trivy-bin"
|
||||||
|
bash scripts/ci/install_trivy.sh "${bin_dir}"
|
||||||
|
echo "${bin_dir}" >> "$GITHUB_PATH"
|
||||||
|
- name: Scan image
|
||||||
|
shell: bash
|
||||||
|
run: bash scripts/ci/scan_image.sh "${SCAN_IMAGE}"
|
||||||
|
- name: Cleanup
|
||||||
|
if: always()
|
||||||
|
shell: bash
|
||||||
|
run: bash scripts/ci/cleanup_job_resources.sh "${SCAN_IMAGE}"
|
||||||
|
|
||||||
|
dependency-audit:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
- name: Set up uv
|
||||||
|
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
|
||||||
|
with:
|
||||||
|
version: "0.12.5"
|
||||||
|
enable-cache: false
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync --locked
|
||||||
|
- name: Dependency audit
|
||||||
|
run: bash scripts/ci/pip_audit.sh
|
||||||
@@ -63,7 +63,9 @@ uv run aegis-gitea-mcp setup
|
|||||||
they are missing, and keeping values it does not know;
|
they are missing, and keeping values it does not know;
|
||||||
- prints ready-made client configurations and a docker-compose snippet.
|
- prints ready-made client configurations and a docker-compose snippet.
|
||||||
|
|
||||||
Secrets are only ever written to the env file, never printed.
|
Secrets are only ever written to the env file, never printed. `serve` reads `./.env` only
|
||||||
|
while it keeps the 0600 mode and your ownership; to keep the file elsewhere use
|
||||||
|
`--env-file PATH` (or `AEGIS_ENV_FILE`) on both commands.
|
||||||
|
|
||||||
Run it:
|
Run it:
|
||||||
|
|
||||||
@@ -124,9 +126,14 @@ Both modes can run on one HTTP deployment at the same time (details in
|
|||||||
calls then run with that user's own Gitea token, so Gitea enforces that user's permissions:
|
calls then run with that user's own Gitea token, so Gitea enforces that user's permissions:
|
||||||
admin tools fail with Gitea's own 403 for non-admins. Upstream tokens are refreshed
|
admin tools fail with Gitea's own 403 for non-admins. Upstream tokens are refreshed
|
||||||
automatically. Stored user tokens are encrypted (Fernet, key derived from
|
automatically. Stored user tokens are encrypted (Fernet, key derived from
|
||||||
`TOKEN_ENCRYPTION_KEY`) in `DATA_DIR/oauth-tokens.json` with mode 0600; the server's own
|
`TOKEN_ENCRYPTION_KEY`) in `DATA_DIR/oauth-store.json` with mode 0600; the server's own
|
||||||
tokens are stored only as SHA-256 hashes. The signed-in login is visible in the `identity`
|
tokens are stored only as SHA-256 hashes. A store written with another key stops startup until
|
||||||
field of every result.
|
the key is restored or the file is moved away. The signed-in login is visible in the
|
||||||
|
`identity` field of every result. Before the browser goes to Gitea, a consent page
|
||||||
|
(`/oauth/consent`) names the client and where the result goes and needs an explicit approval;
|
||||||
|
for public deployments set `OAUTH_REDIRECT_ALLOWLIST` to the hosted clients you use. An
|
||||||
|
OAuth-only deployment (no `MCP_BEARER_TOKEN`, no `GITEA_TOKEN`) is supported: it has no admin
|
||||||
|
identity to fall back to.
|
||||||
|
|
||||||
## Client configuration
|
## Client configuration
|
||||||
|
|
||||||
@@ -247,8 +254,10 @@ More in [docs/deployment.md](docs/deployment.md) and [docs/hardening.md](docs/ha
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
All settings are environment variables (or `.env` in the working directory), case-insensitive.
|
All settings are environment variables (or an env file), case-insensitive. The env file is
|
||||||
Core ones:
|
`--env-file PATH`, else `AEGIS_ENV_FILE`, else `./.env` only when it is a regular file owned by
|
||||||
|
you with mode 0600 (what `setup` writes); any other `./.env` is ignored with a warning (run
|
||||||
|
`chmod 600 .env` or pass `--env-file`). Real environment variables always win. Core ones:
|
||||||
|
|
||||||
| Variable | Default | Meaning |
|
| Variable | Default | Meaning |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|||||||
+12
-7
@@ -102,10 +102,12 @@ compared, so length does not leak). A match runs the call as `GITEA_TOKEN`, with
|
|||||||
resolved once from `GET /user`. stdio is always this mode.
|
resolved once from `GET /user`. stdio is always this mode.
|
||||||
|
|
||||||
Per-user OAuth mode: the server is an OAuth 2.1 authorization server for MCP clients (dynamic
|
Per-user OAuth mode: the server is an OAuth 2.1 authorization server for MCP clients (dynamic
|
||||||
client registration, PKCE) and a confidential OAuth client of Gitea. `/oauth/callback` receives
|
client registration, PKCE) and a confidential OAuth client of Gitea. `/authorize` parks the
|
||||||
Gitea's code, exchanges it, and issues the client opaque access and refresh tokens that map to
|
request and shows the consent page `/oauth/consent` (client name, redirect URI, approval with
|
||||||
the user's Gitea tokens. A call with such a token runs with the user's own Gitea access token,
|
CSRF and origin checks) before the browser goes to Gitea. `/oauth/callback` receives Gitea's
|
||||||
so Gitea enforces that user's permissions. The mapping lives in `DATA_DIR/oauth-tokens.json`
|
code in the same browser, exchanges it, and issues the client opaque access and refresh tokens
|
||||||
|
that map to the user's Gitea tokens. A call with such a token runs with the user's own Gitea access token,
|
||||||
|
so Gitea enforces that user's permissions. The mapping lives in `DATA_DIR/oauth-store.json`
|
||||||
(mode 0600). Gitea tokens in it are encrypted with Fernet (key derived from
|
(mode 0600). Gitea tokens in it are encrypted with Fernet (key derived from
|
||||||
`TOKEN_ENCRYPTION_KEY`); our own tokens are stored as SHA-256 hashes. Upstream refresh is
|
`TOKEN_ENCRYPTION_KEY`); our own tokens are stored as SHA-256 hashes. Upstream refresh is
|
||||||
automatic.
|
automatic.
|
||||||
@@ -115,8 +117,9 @@ One token verifier accepts either kind. The dispatcher turns the verified token
|
|||||||
|
|
||||||
## Audit
|
## Audit
|
||||||
|
|
||||||
Every tool call, including failed and invalid ones, produces exactly one event in the
|
Every tool call, including failed and invalid ones, produces exactly one result event in the
|
||||||
append-only, hash-chained audit log. Only argument names are recorded, never values. Details and
|
append-only, hash-chained audit log, after a write-ahead event that makes a call fail closed when
|
||||||
|
the log stops accepting writes. Only argument names are recorded, never values. Details and
|
||||||
integrity validation are in [audit-logging.md](audit-logging.md).
|
integrity validation are in [audit-logging.md](audit-logging.md).
|
||||||
|
|
||||||
## Request flow
|
## Request flow
|
||||||
@@ -127,7 +130,9 @@ integrity validation are in [audit-logging.md](audit-logging.md).
|
|||||||
schema, and resolves the identity.
|
schema, and resolves the identity.
|
||||||
4. The executor calls Gitea with the identity's token.
|
4. The executor calls Gitea with the identity's token.
|
||||||
5. The response is bounded, redacted and masked, and wrapped in the envelope.
|
5. The response is bounded, redacted and masked, and wrapped in the envelope.
|
||||||
6. One audit event is written and the result is returned.
|
6. One audit result event is written and the result is returned. (A write-ahead
|
||||||
|
`tool_call_started` event is written right after the tool lookup in step 3; when it cannot be
|
||||||
|
written the call is refused.)
|
||||||
|
|
||||||
## Module map
|
## Module map
|
||||||
|
|
||||||
|
|||||||
+26
-11
@@ -1,8 +1,10 @@
|
|||||||
# Audit logging
|
# Audit logging
|
||||||
|
|
||||||
Every tool call produces exactly one event in an append-only, hash-chained JSON-lines log,
|
Every tool call produces exactly one result event (`tool_invocation`) in an append-only,
|
||||||
whether it succeeded, failed at Gitea, failed validation or hit an internal error. Security
|
hash-chained JSON-lines log, whether it succeeded, failed at Gitea, failed validation or hit an
|
||||||
events are logged in the same chain.
|
internal error. A call to a known tool is preceded by a small write-ahead event
|
||||||
|
(`tool_call_started`, see Location and permissions). Security events are logged in the same
|
||||||
|
chain.
|
||||||
|
|
||||||
## What an event contains
|
## What an event contains
|
||||||
|
|
||||||
@@ -11,7 +13,7 @@ Each line is one JSON object:
|
|||||||
| Field | Meaning |
|
| Field | Meaning |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `timestamp` | UTC, ISO 8601 |
|
| `timestamp` | UTC, ISO 8601 |
|
||||||
| `event_type` | `tool_invocation` or `security_event` |
|
| `event_type` | `tool_invocation`, `tool_call_started`, `access_denied` or `security_event` |
|
||||||
| `request_id` | Correlation id of the request |
|
| `request_id` | Correlation id of the request |
|
||||||
| `payload` | The event data, see below |
|
| `payload` | The event data, see below |
|
||||||
| `prev_hash` | `entry_hash` of the previous line (`GENESIS` for the first) |
|
| `prev_hash` | `entry_hash` of the previous line (`GENESIS` for the first) |
|
||||||
@@ -26,8 +28,15 @@ A tool invocation payload records:
|
|||||||
- the HTTP status when Gitea answered, and the outcome: success, or the error kind
|
- the HTTP status when Gitea answered, and the outcome: success, or the error kind
|
||||||
(`not_found`, `forbidden`, `invalid_argument`, `internal_error`, ...);
|
(`not_found`, `forbidden`, `invalid_argument`, `internal_error`, ...);
|
||||||
- the duration in milliseconds and the correlation id;
|
- the duration in milliseconds and the correlation id;
|
||||||
- the argument names, sorted. Argument values are never logged, so request bodies, secret values
|
- who called: the MCP client id of the verified token (`aegis-static-bearer` for the static
|
||||||
and file contents do not reach the log.
|
bearer), a 12 character hash of the OAuth grant's session id (`session`, only for OAuth
|
||||||
|
tokens, never the id or a token) and the source address (`client_ip`, HTTP only; behind a
|
||||||
|
proxy it is the forwarded address only when the proxy is in `FORWARDED_ALLOW_IPS`);
|
||||||
|
- the argument names, sorted, and only names that are properties of the tool's schema; any other
|
||||||
|
name is counted in `rejected_argument_count`. Argument values are never logged, so request
|
||||||
|
bodies, secret values and file contents do not reach the log. A call to a tool that does not
|
||||||
|
exist is recorded as `(unknown tool, N chars)`: the caller's string is neither logged nor
|
||||||
|
echoed in the error.
|
||||||
|
|
||||||
All payloads also pass through secret masking before they are written
|
All payloads also pass through secret masking before they are written
|
||||||
(`SECRET_DETECTION_MODE` other than `off`).
|
(`SECRET_DETECTION_MODE` other than `off`).
|
||||||
@@ -54,13 +63,19 @@ keep elsewhere.
|
|||||||
|
|
||||||
The log is written to `AUDIT_LOG_PATH`, default `DATA_DIR/audit.log` (`/var/lib/aegis-mcp` in the
|
The log is written to `AUDIT_LOG_PATH`, default `DATA_DIR/audit.log` (`/var/lib/aegis-mcp` in the
|
||||||
image). Keep it on durable storage, readable and writable only by the service account.
|
image). Keep it on durable storage, readable and writable only by the service account.
|
||||||
|
The server creates a missing state directory with mode 0700 and the log with mode 0600, tightens
|
||||||
|
an older log it owns that is group or world readable, and refuses to open the log through a
|
||||||
|
symlink (startup exits with code 2). A keyed (HMAC) chain is not offered: the chain is
|
||||||
|
tamper-evident only, so ship the log off the host as the integrity control.
|
||||||
|
|
||||||
The server opens the file for append at startup and exits with code 2 (message on stderr) when
|
The server opens the file for append at startup and exits with code 2 (message on stderr) when
|
||||||
that fails, so it never runs unaudited. Each call also obtains the audit logger before it runs;
|
that fails, so it never runs unaudited. The check does not stop at startup: each call to a known
|
||||||
if that fails the call is refused with `audit_unavailable` and never reaches Gitea. A write
|
tool first writes a `tool_call_started` entry (tool name, domain and correlation id), and when
|
||||||
failure after a call has executed is only logged to stderr: the Gitea side effect already
|
that write fails (full disk, revoked mount, closed file) the call is refused with
|
||||||
happened and the caller must still receive the result. The `correlation_id` of a tool entry is
|
`audit_unavailable` and never reaches Gitea. Calls resume as soon as writes work again. A write
|
||||||
the request id, which is also the entry's `request_id`.
|
failure of the final `tool_invocation` entry after a call has executed is only logged to stderr:
|
||||||
|
the Gitea side effect already happened and the caller must still receive the result. The
|
||||||
|
`correlation_id` of both entries is the request id, which is also the entry's `request_id`.
|
||||||
|
|
||||||
## Rotation
|
## Rotation
|
||||||
|
|
||||||
|
|||||||
+44
-9
@@ -1,9 +1,28 @@
|
|||||||
# Configuration
|
# Configuration
|
||||||
|
|
||||||
All settings are environment variables. They are read case-insensitively from the process
|
All settings are environment variables. They are read case-insensitively from the process
|
||||||
environment and from a `.env` file in the working directory (the environment wins). Unknown
|
environment and from an env file (the environment always wins). Unknown variables are ignored.
|
||||||
variables are ignored. A blank value (`KEY=` in `.env`) counts as unset for the optional
|
A blank value (`KEY=` in the env file) counts as unset for the optional secrets, URLs and
|
||||||
secrets, URLs and paths. List values accept commas or whitespace as separators.
|
paths. List values accept commas or whitespace as separators.
|
||||||
|
|
||||||
|
## Which env file is read
|
||||||
|
|
||||||
|
The env file is never trusted just because it sits in the working directory: MCP clients start
|
||||||
|
stdio servers inside the project they work on, which can be a cloned repository carrying a
|
||||||
|
planted `.env` that switches off secret masking or the audit log or points `GITEA_URL` at
|
||||||
|
another host. The file is chosen in this order:
|
||||||
|
|
||||||
|
1. `--env-file PATH` of `serve` (and `setup`): used as given. It must exist and be a regular
|
||||||
|
file.
|
||||||
|
2. The `AEGIS_ENV_FILE` environment variable: same rules.
|
||||||
|
3. `./.env`, but only when it is a regular file (not a symlink), owned by the user running the
|
||||||
|
server, with no group or other permissions (mode 0600, which `setup` writes). Otherwise it is
|
||||||
|
ignored and one warning goes to stderr that says why and how to fix it:
|
||||||
|
`chmod 600 .env`, or pass `--env-file`.
|
||||||
|
|
||||||
|
Real environment variables take precedence over every file. A missing `./.env` is silent.
|
||||||
|
Containers pass settings as environment variables (`env_file:` in compose), not through the
|
||||||
|
working directory.
|
||||||
|
|
||||||
`.env.example` is the annotated template. `uv run aegis-gitea-mcp setup` fills one in for you
|
`.env.example` is the annotated template. `uv run aegis-gitea-mcp setup` fills one in for you
|
||||||
(see [getting-started.md](getting-started.md)). Secret settings never appear in logs, errors or
|
(see [getting-started.md](getting-started.md)). Secret settings never appear in logs, errors or
|
||||||
@@ -17,7 +36,7 @@ when OAuth is on.
|
|||||||
|
|
||||||
| Variable | Default | Rules |
|
| Variable | Default | Rules |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `GITEA_URL` | required | `http` or `https`, must have a host, no credentials, query or fragment. A trailing `/api/v1` is removed. |
|
| `GITEA_URL` | required | `http` or `https`, must have a host, no credentials, query or fragment, and no whitespace, backslash or control characters. A trailing `/api/v1` is removed and the URL is rebuilt from its parsed parts. Plain `http` to a host that is not loopback starts, but `serve` prints a warning on stderr because every token then travels unencrypted. |
|
||||||
| `GITEA_TOKEN` | unset | Token for stdio and for admin-token mode on HTTP. Its scopes bound what the server can do. Required for stdio (exit code 2 without it). |
|
| `GITEA_TOKEN` | unset | Token for stdio and for admin-token mode on HTTP. Its scopes bound what the server can do. Required for stdio (exit code 2 without it). |
|
||||||
|
|
||||||
## Transport
|
## Transport
|
||||||
@@ -31,26 +50,42 @@ when OAuth is on.
|
|||||||
| `MCP_BEARER_TOKEN` | unset | Static bearer token for HTTP (admin identity). At least 32 characters. Generate with `openssl rand -hex 32`. |
|
| `MCP_BEARER_TOKEN` | unset | Static bearer token for HTTP (admin identity). At least 32 characters. Generate with `openssl rand -hex 32`. |
|
||||||
| `PUBLIC_BASE_URL` | unset | Public URL behind a reverse proxy. Same URL rules as `GITEA_URL`, and `https` unless the host is loopback. Required for OAuth. |
|
| `PUBLIC_BASE_URL` | unset | Public URL behind a reverse proxy. Same URL rules as `GITEA_URL`, and `https` unless the host is loopback. Required for OAuth. |
|
||||||
| `ALLOWED_HOSTS` | empty | Extra `Host` header values accepted behind a proxy. The loopback names with the port and the host of `PUBLIC_BASE_URL` are always allowed. Other `Host` values are rejected (DNS-rebinding protection). A non-loopback `MCP_HOST` with neither `PUBLIC_BASE_URL` nor `ALLOWED_HOSTS` logs a startup warning on stderr, because remote clients then get 421. |
|
| `ALLOWED_HOSTS` | empty | Extra `Host` header values accepted behind a proxy. The loopback names with the port and the host of `PUBLIC_BASE_URL` are always allowed. Other `Host` values are rejected (DNS-rebinding protection). A non-loopback `MCP_HOST` with neither `PUBLIC_BASE_URL` nor `ALLOWED_HOSTS` logs a startup warning on stderr, because remote clients then get 421. |
|
||||||
| `FORWARDED_ALLOW_IPS` | `127.0.0.1` | Comma list of IP addresses or networks (or `*`) whose `X-Forwarded-For` and `X-Forwarded-Proto` headers are trusted. Set it to the address of the reverse proxy when it is not on the same host, for example a Docker network. Otherwise the per-IP rate limit sees the proxy address for every client. `*` trusts every peer and logs a warning: only use it when the port is reachable solely from the proxy, since clients could otherwise spoof their IP. |
|
| `FORWARDED_ALLOW_IPS` | `127.0.0.1` | Comma list of IP addresses or networks (or `*`) whose `X-Forwarded-For` and `X-Forwarded-Proto` headers are trusted. Set it to the address of the reverse proxy when it is not on the same host, for example a Docker network. Otherwise the per-IP rate limit sees the proxy address for every client: a request that carries `X-Forwarded-For` from a peer outside this list is not honoured and logs one warning ("add its address to FORWARDED_ALLOW_IPS"). `*` trusts every peer and logs a warning: only use it when the port is reachable solely from the proxy, since clients could otherwise spoof their IP. |
|
||||||
|
|
||||||
## Per-user OAuth (HTTP only)
|
## Per-user OAuth (HTTP only)
|
||||||
|
|
||||||
| Variable | Default | Rules |
|
| Variable | Default | Rules |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `OAUTH_ENABLED` | `false` | When true, the four settings below are required, or the server refuses to start (exit code 2, with the list of what is missing). |
|
| `OAUTH_ENABLED` | `false` | When true, `GITEA_OAUTH_CLIENT_ID`, `GITEA_OAUTH_CLIENT_SECRET`, `TOKEN_ENCRYPTION_KEY` and `PUBLIC_BASE_URL` are required, or the server refuses to start (exit code 2, with the list of what is missing). |
|
||||||
| `GITEA_OAUTH_CLIENT_ID` | unset | Client id of the Gitea OAuth application (created by `setup`). |
|
| `GITEA_OAUTH_CLIENT_ID` | unset | Client id of the Gitea OAuth application (created by `setup`). |
|
||||||
| `GITEA_OAUTH_CLIENT_SECRET` | unset | Its client secret. |
|
| `GITEA_OAUTH_CLIENT_SECRET` | unset | Its client secret. |
|
||||||
| `TOKEN_ENCRYPTION_KEY` | unset | At least 32 characters. Encrypts stored user tokens. Losing it invalidates every stored login. |
|
| `TOKEN_ENCRYPTION_KEY` | unset | At least 32 characters. Encrypts the token store. The key cannot be rotated in place: a store written with another key stops startup (exit code 2) instead of being wiped, until you restore the original key or move `DATA_DIR/oauth-store.json` away, which signs every user out. |
|
||||||
| `OAUTH_SCOPES` | all `write:<category>` scopes | Granular Gitea scopes only (`read:` or `write:` plus activitypub, admin, issue, misc, notification, organization, package, repository, user). Anything else is rejected, because Gitea grants full access for unknown scope strings. Must not be empty. |
|
| `OAUTH_SCOPES` | all `write:<category>` scopes | Granular Gitea scopes only (`read:` or `write:` plus activitypub, admin, issue, misc, notification, organization, package, repository, user). Anything else is rejected, because Gitea grants full access for unknown scope strings. Must not be empty. |
|
||||||
|
| `OAUTH_REDIRECT_ALLOWLIST` | empty | Comma list of redirect URIs MCP clients may register: exact URIs (`https://client.example/cb`) or prefixes ending in `/` that allow a whole host (`https://claude.ai/`). A bare origin without a path is refused. Empty accepts any https URI (and http on `localhost`, `127.0.0.1`, `[::1]`). With a list, https URIs must match it; loopback URIs stay allowed for native clients. Checked at registration and again at `/authorize`. |
|
||||||
|
|
||||||
`PUBLIC_BASE_URL` is also required for OAuth. The Gitea OAuth application's redirect URI is
|
`PUBLIC_BASE_URL` is also required for OAuth. The Gitea OAuth application's redirect URI is
|
||||||
`<PUBLIC_BASE_URL>/oauth/callback`.
|
`<PUBLIC_BASE_URL>/oauth/callback`.
|
||||||
|
|
||||||
|
OAuth-only deployments are supported: with `OAUTH_ENABLED=true` and neither `MCP_BEARER_TOKEN`
|
||||||
|
nor `GITEA_TOKEN`, every user brings their own Gitea token and admin-token mode is simply not
|
||||||
|
available (nothing falls back to it). `GITEA_TOKEN` stays required whenever admin-token mode is
|
||||||
|
reachable: with a static bearer, without any HTTP authentication, and on stdio.
|
||||||
|
|
||||||
|
Sign-in flow and limits: `/authorize` sends the browser to the consent page `/oauth/consent`,
|
||||||
|
which names the client and the redirect URI and needs an explicit approval (CSRF token, cookie
|
||||||
|
and an `Origin` that matches `PUBLIC_BASE_URL`) before the browser goes to Gitea; the callback
|
||||||
|
`/oauth/callback` completes only in that same browser. Issued `agm_` access tokens live 1 hour
|
||||||
|
and refresh tokens 30 days (rotated on use, reuse revokes the grant); authorization codes live 5
|
||||||
|
minutes and pending sign-ins 10 minutes. Bounds: 500 registered clients (10 redirect URIs and 8
|
||||||
|
KiB of metadata each), 1024 pending sign-ins and codes, 50 sessions per Gitea login, and per
|
||||||
|
client address 20 pending sign-ins and 20 registrations per hour. The consent form body is
|
||||||
|
limited to 16 KiB and must be `application/x-www-form-urlencoded`.
|
||||||
|
|
||||||
## State
|
## State
|
||||||
|
|
||||||
| Variable | Default | Rules |
|
| Variable | Default | Rules |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `DATA_DIR` | `~/.local/state/aegis-gitea-mcp` (`/var/lib/aegis-mcp` in the image) | `~` is expanded. Holds the token store (`oauth-tokens.json`, mode 0600) and the audit log. |
|
| `DATA_DIR` | `~/.local/state/aegis-gitea-mcp` (`/var/lib/aegis-mcp` in the image) | `~` is expanded. Holds the encrypted token store (`oauth-store.json`, mode 0600) and the audit log. A new directory is created with mode 0700; an existing one must belong to the running user and is tightened to 0700, otherwise OAuth startup fails. |
|
||||||
| `AUDIT_LOG_PATH` | `DATA_DIR/audit.log` | Path of the hash-chained audit log. See [audit-logging.md](audit-logging.md). The server opens it for append at startup and exits with code 2 and a message on stderr when it cannot. |
|
| `AUDIT_LOG_PATH` | `DATA_DIR/audit.log` | Path of the hash-chained audit log. See [audit-logging.md](audit-logging.md). The server opens it for append at startup and exits with code 2 and a message on stderr when it cannot. |
|
||||||
|
|
||||||
## Tool exposure
|
## Tool exposure
|
||||||
@@ -75,7 +110,7 @@ listed nor callable.
|
|||||||
| `SECRET_DETECTION_MODE` | `mask` | `off`, `mask` or `block`. See below. |
|
| `SECRET_DETECTION_MODE` | `mask` | `off`, `mask` or `block`. See below. |
|
||||||
| `LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR` or `CRITICAL`. Logs go to stderr. |
|
| `LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR` or `CRITICAL`. Logs go to stderr. |
|
||||||
| `EXPOSE_ERROR_DETAILS` | `false` | When true, the exception class name is added to internal and transport errors. Never a stack trace. Do not enable in production. |
|
| `EXPOSE_ERROR_DETAILS` | `false` | When true, the exception class name is added to internal and transport errors. Never a stack trace. Do not enable in production. |
|
||||||
| `RATE_LIMIT_PER_MINUTE` | `120` | At least 1. Per client on the HTTP MCP endpoint, keyed by a hash of the Authorization header, else the client IP. Excess requests get 429. Only the first rejected request per client and minute is written to the audit log. Tracking is in memory and bounded (idle clients are forgotten). |
|
| `RATE_LIMIT_PER_MINUTE` | `120` | At least 1. Per client on the HTTP MCP endpoint and on the unauthenticated OAuth endpoints (`/register`, `/authorize`, `/token`, `/revoke`, `/oauth/consent`, `/oauth/callback`), keyed by the client IP (an IPv6 client counts per /64) and, on the MCP endpoint, by a hash of the Authorization header. Excess requests get 429. Only the first rejected request per client and minute is written to the audit log. Tracking is in memory and bounded (idle clients are forgotten). |
|
||||||
|
|
||||||
Secret detection: `mask` replaces secret-like strings in results with a placeholder, `block`
|
Secret detection: `mask` replaces secret-like strings in results with a placeholder, `block`
|
||||||
withholds a whole result that contains one (`secret_blocked`), `off` disables pattern matching.
|
withholds a whole result that contains one (`secret_blocked`), `off` disables pattern matching.
|
||||||
|
|||||||
+38
-5
@@ -9,8 +9,12 @@ hands the power of its Gitea token to every client that can authenticate.
|
|||||||
- Admin-token only: set `GITEA_TOKEN` and `MCP_BEARER_TOKEN`. Every client with the bearer token
|
- Admin-token only: set `GITEA_TOKEN` and `MCP_BEARER_TOKEN`. Every client with the bearer token
|
||||||
acts as the token owner.
|
acts as the token owner.
|
||||||
- Per-user OAuth: additionally set `OAUTH_ENABLED=true` with the OAuth settings. Users sign in
|
- Per-user OAuth: additionally set `OAUTH_ENABLED=true` with the OAuth settings. Users sign in
|
||||||
with their own Gitea account and act under their own permissions. The static bearer keeps
|
with their own Gitea account (after a consent step on `/oauth/consent`) and act under their
|
||||||
working next to it as the admin identity; leave `MCP_BEARER_TOKEN` unset to turn that off.
|
own permissions. The static bearer keeps working next to it as the admin identity; leave
|
||||||
|
`MCP_BEARER_TOKEN` unset to turn that off.
|
||||||
|
- OAuth only: `OAUTH_ENABLED=true` with neither `MCP_BEARER_TOKEN` nor `GITEA_TOKEN`. The server
|
||||||
|
starts and serves signed-in users only; admin-token mode does not exist in this deployment, so
|
||||||
|
no request can ever fall back to an admin identity. Recommended for public deployments.
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
@@ -50,7 +54,7 @@ Points that matter:
|
|||||||
- The port is published on `127.0.0.1` only. The reverse proxy on the same host is the public
|
- The port is published on `127.0.0.1` only. The reverse proxy on the same host is the public
|
||||||
entry point.
|
entry point.
|
||||||
- `DATA_DIR` must be a persistent volume writable by the container user. It holds the audit log
|
- `DATA_DIR` must be a persistent volume writable by the container user. It holds the audit log
|
||||||
and the encrypted OAuth token store (`oauth-tokens.json`, mode 0600). Losing the volume loses
|
and the encrypted OAuth token store (`oauth-store.json`, mode 0600). Losing the volume loses
|
||||||
all OAuth logins and the audit history. Back it up, and protect the backup like the secrets it
|
all OAuth logins and the audit history. Back it up, and protect the backup like the secrets it
|
||||||
protects.
|
protects.
|
||||||
- Keep `.env` out of version control and out of the image (mode 0600; `setup` writes it that
|
- Keep `.env` out of version control and out of the image (mode 0600; `setup` writes it that
|
||||||
@@ -104,6 +108,12 @@ Gitea has no instance-wide OAuth application API, so the application belongs to
|
|||||||
that created it. Re-running setup reuses it; if the stored client secret is missing, Gitea has to
|
that created it. Re-running setup reuses it; if the stored client secret is missing, Gitea has to
|
||||||
regenerate it, and setup asks before doing so.
|
regenerate it, and setup asks before doing so.
|
||||||
|
|
||||||
|
Before the browser is sent to Gitea, the consent page `/oauth/consent` shows the client name and
|
||||||
|
the redirect URI, because Gitea remembers consent for this one application and any client can
|
||||||
|
register itself. For a public deployment also set `OAUTH_REDIRECT_ALLOWLIST` to the hosted
|
||||||
|
clients you use, for example `https://claude.ai/,https://chat.mistral.ai/` (see
|
||||||
|
[configuration.md](configuration.md)).
|
||||||
|
|
||||||
`OAUTH_SCOPES` is the scope list sent to Gitea. Keep it to granular scopes. Narrow it (for
|
`OAUTH_SCOPES` is the scope list sent to Gitea. Keep it to granular scopes. Narrow it (for
|
||||||
example to `read:repository,read:issue`) to limit what signed-in users' tokens can do through this
|
example to `read:repository,read:issue`) to limit what signed-in users' tokens can do through this
|
||||||
server regardless of their Gitea rights.
|
server regardless of their Gitea rights.
|
||||||
@@ -111,8 +121,9 @@ server regardless of their Gitea rights.
|
|||||||
## Upgrades
|
## Upgrades
|
||||||
|
|
||||||
- Application: pull the new image (or package) and restart. The data volume is kept.
|
- Application: pull the new image (or package) and restart. The data volume is kept.
|
||||||
- `TOKEN_ENCRYPTION_KEY` must stay the same, or stored OAuth logins can no longer be decrypted
|
- `TOKEN_ENCRYPTION_KEY` must stay the same. A store written with another key stops the server
|
||||||
and users have to sign in again.
|
at startup (exit code 2, the file is kept) instead of being wiped: restore the old key, or move
|
||||||
|
`DATA_DIR/oauth-store.json` away to start over, which signs every OAuth user out.
|
||||||
- Upgrading Gitea: tool names and schemas come from the pinned swagger, so a Gitea upgrade does
|
- Upgrading Gitea: tool names and schemas come from the pinned swagger, so a Gitea upgrade does
|
||||||
not change them. To pick up a newer API, re-pin the swagger and regenerate the catalog:
|
not change them. To pick up a newer API, re-pin the swagger and regenerate the catalog:
|
||||||
|
|
||||||
@@ -132,3 +143,25 @@ The workflows in `.gitea/workflows/` lint, test and build. The `docs-check` job
|
|||||||
`uv run python scripts/check_docs.py`, which fails when `docs/tools.md` or the README tool summary
|
`uv run python scripts/check_docs.py`, which fails when `docs/tools.md` or the README tool summary
|
||||||
is stale, or when a document names a tool that does not exist. `scripts/generate_catalog.py
|
is stale, or when a document names a tool that does not exist. `scripts/generate_catalog.py
|
||||||
--check` fails when `tools.json` does not match the pinned swagger.
|
--check` fails when `tools.json` does not match the pinned swagger.
|
||||||
|
|
||||||
|
Security rules of the pipeline:
|
||||||
|
|
||||||
|
- Jobs that use the runner's Docker daemon or a secret (`test-integration`, `test-mcp`,
|
||||||
|
`build-and-push`) do not run for pull requests from forks. Access to the daemon is
|
||||||
|
root-equivalent on the host, and a fork controls the workflow and test code. Also enable
|
||||||
|
"require approval for workflows from forks" (or the equivalent runner setting) in Gitea, and
|
||||||
|
keep the daemon of a shared runner free of services you cannot afford to lose.
|
||||||
|
- Every third-party action is pinned to a full commit SHA with the version in a trailing
|
||||||
|
comment. Bump a pin deliberately: look up the SHA of the new tag with
|
||||||
|
`git ls-remote https://github.com/<owner>/<action> refs/tags/<tag>` (add `^{}` for annotated
|
||||||
|
tags). The registry token never reaches a third-party action: the CLI reads it from stdin and
|
||||||
|
`uv publish` reads `UV_PUBLISH_PASSWORD`.
|
||||||
|
- `build-and-push` scans the built image with a pinned Trivy (`scripts/ci/install_trivy.sh`,
|
||||||
|
sha256 verified) before anything is pushed, and fails on HIGH or CRITICAL findings that have a
|
||||||
|
fix. `scan.yml` repeats the scan and the dependency audit every week, so a new advisory in a
|
||||||
|
pinned base image turns it red: refresh the digests in `docker/Dockerfile`. Accepted findings
|
||||||
|
go in `security/trivyignore.txt` with a waiver row in [security.md](security.md).
|
||||||
|
- `publish.yml` needs the `security` job (secret scan, security tests, dependency audit, bandit
|
||||||
|
rules) as well as lint and tests.
|
||||||
|
- The cleanup script removes containers only for per-run image names (`aegis-ci-<run id>-...`)
|
||||||
|
and immutable `:sha-` tags. Shared tags (`:latest`, `:main`, `:dev`) are only untagged.
|
||||||
@@ -29,7 +29,7 @@ Setup prompts for the Gitea URL and the token (hidden input). For unattended use
|
|||||||
| `--gitea-url` | Gitea base URL |
|
| `--gitea-url` | Gitea base URL |
|
||||||
| `--public-base-url` | Public https URL of this server. Enables registration of the OAuth application |
|
| `--public-base-url` | Public https URL of this server. Enables registration of the OAuth application |
|
||||||
| `--no-oauth` | Do not create the Gitea OAuth application |
|
| `--no-oauth` | Do not create the Gitea OAuth application |
|
||||||
| `--env-file` | Env file to create or update (default `./.env`) |
|
| `--env-file` | Env file to create or update (default `AEGIS_ENV_FILE`, else `./.env`) |
|
||||||
| `--template` | Env template (default `./.env.example`, else the copy built into the package) |
|
| `--template` | Env template (default `./.env.example`, else the copy built into the package) |
|
||||||
| `--yes` | Never prompt, and accept regenerating an OAuth client secret |
|
| `--yes` | Never prompt, and accept regenerating an OAuth client secret |
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -27,7 +27,13 @@ authentication), but it does not decide what is safe. This checklist is how you
|
|||||||
header).
|
header).
|
||||||
- `TOKEN_ENCRYPTION_KEY` protects the stored per-user tokens. Keep it separate from the data
|
- `TOKEN_ENCRYPTION_KEY` protects the stored per-user tokens. Keep it separate from the data
|
||||||
volume backups. `GITEA_OAUTH_CLIENT_SECRET` likewise.
|
volume backups. `GITEA_OAUTH_CLIENT_SECRET` likewise.
|
||||||
- Use `setup`, which writes the env file with mode 0600 and never prints secrets.
|
- Use `setup`, which writes the env file with mode 0600 and never prints secrets. The server
|
||||||
|
loads `./.env` only while it is a regular file you own with mode 0600; keep it elsewhere with
|
||||||
|
`--env-file` or `AEGIS_ENV_FILE`.
|
||||||
|
- For a public OAuth deployment set `OAUTH_REDIRECT_ALLOWLIST` (for example
|
||||||
|
`https://claude.ai/,https://chat.mistral.ai/`), so only the hosted clients you use can
|
||||||
|
register, and leave `MCP_BEARER_TOKEN` and `GITEA_TOKEN` unset when nobody needs the admin
|
||||||
|
identity.
|
||||||
- Do not paste tokens into chat with the assistant, and do not commit `.env`.
|
- Do not paste tokens into chat with the assistant, and do not commit `.env`.
|
||||||
|
|
||||||
## Network and transport
|
## Network and transport
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
# Integration coverage
|
||||||
|
|
||||||
|
Generated by the live-Gitea integration suite; do not edit by hand. Regenerate with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AEGIS_IT_WRITE_REPORT=1 AEGIS_INTEGRATION=1 uv run pytest tests/integration -m integration --no-cov
|
||||||
|
```
|
||||||
|
|
||||||
|
Target: Gitea 28.1.0. Every tool of the server (538) is called at least once through the MCP server against a seeded instance (see `tests/integration/test_sweep.py`). A call counts as covered when it reaches Gitea and answers 2xx, answers a status the swagger documents for that operation, or answers a status listed as an explicit allowance below. Scenario tests (`tests/integration/test_scenario_*.py`) add real state changes; a tool counts as 2xx when any call in the run got a 2xx.
|
||||||
|
|
||||||
|
| Domain | Tools | 2xx | Documented non-2xx | Allowances | Not covered |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| actions | 70 | 52 | 18 | 0 | 0 |
|
||||||
|
| admin | 33 | 27 | 4 | 2 | 0 |
|
||||||
|
| hook | 20 | 20 | 0 | 0 | 0 |
|
||||||
|
| issue | 73 | 73 | 0 | 0 | 0 |
|
||||||
|
| misc | 20 | 18 | 0 | 2 | 0 |
|
||||||
|
| notification | 7 | 7 | 0 | 0 | 0 |
|
||||||
|
| org | 45 | 45 | 0 | 0 | 0 |
|
||||||
|
| package | 9 | 9 | 0 | 0 | 0 |
|
||||||
|
| pull | 26 | 25 | 1 | 0 | 0 |
|
||||||
|
| release | 13 | 13 | 0 | 0 | 0 |
|
||||||
|
| repo | 124 | 118 | 4 | 2 | 0 |
|
||||||
|
| team | 15 | 15 | 0 | 0 | 0 |
|
||||||
|
| user | 77 | 71 | 3 | 3 | 0 |
|
||||||
|
| wiki | 6 | 6 | 0 | 0 | 0 |
|
||||||
|
| total | 538 | 499 | 30 | 9 | 0 |
|
||||||
|
|
||||||
|
Excluded from the generic sweep and covered by a dedicated test:
|
||||||
|
|
||||||
|
- `gitea_misc_delete_current_token`: deletes the token the sweep itself runs with; covered by test_sweep_isolated.py with a throwaway token
|
||||||
|
|
||||||
|
## actions
|
||||||
|
|
||||||
|
Tools with a 2xx (52):
|
||||||
|
|
||||||
|
`gitea_actions_cancel_workflow_run`, `gitea_actions_create_org_variable`, `gitea_actions_create_repo_variable`, `gitea_actions_create_user_variable`, `gitea_actions_delete_org_secret`, `gitea_actions_delete_org_variable`, `gitea_actions_delete_repo_secret`, `gitea_actions_delete_repo_variable`, `gitea_actions_delete_user_secret`, `gitea_actions_delete_user_variable`, `gitea_actions_disable_workflow`, `gitea_actions_dispatch_workflow`, `gitea_actions_download_run_job_logs`, `gitea_actions_enable_workflow`, `gitea_actions_get_artifacts`, `gitea_actions_get_artifacts_of_run`, `gitea_actions_get_org_runners`, `gitea_actions_get_org_variable`, `gitea_actions_get_org_variables_list`, `gitea_actions_get_org_workflow_jobs`, `gitea_actions_get_org_workflow_runs`, `gitea_actions_get_repo_runners`, `gitea_actions_get_repo_variable`, `gitea_actions_get_repo_variables_list`, `gitea_actions_get_user_runners`, `gitea_actions_get_user_variable`, `gitea_actions_get_user_variables_list`, `gitea_actions_get_user_workflow_jobs`, `gitea_actions_get_user_workflow_runs`, `gitea_actions_get_workflow`, `gitea_actions_get_workflow_job`, `gitea_actions_get_workflow_run`, `gitea_actions_get_workflow_run_attempt`, `gitea_actions_get_workflow_runs`, `gitea_actions_list_action_tasks`, `gitea_actions_list_repository_workflows`, `gitea_actions_list_workflow_jobs`, `gitea_actions_list_workflow_run_attempt_jobs`, `gitea_actions_list_workflow_run_jobs`, `gitea_actions_list_workflow_runs`, `gitea_actions_org_create_runner_registration_token`, `gitea_actions_org_list_secrets`, `gitea_actions_repo_create_runner_registration_token`, `gitea_actions_repo_list_secrets`, `gitea_actions_rerun_failed_workflow_run`, `gitea_actions_update_org_secret`, `gitea_actions_update_org_variable`, `gitea_actions_update_repo_secret`, `gitea_actions_update_repo_variable`, `gitea_actions_update_user_secret`, `gitea_actions_update_user_variable`, `gitea_actions_user_create_runner_registration_token`
|
||||||
|
|
||||||
|
Tools with a documented non-2xx status (18):
|
||||||
|
|
||||||
|
| Tool | Status |
|
||||||
|
| --- | --- |
|
||||||
|
| `gitea_actions_approve_workflow_run` | 409 |
|
||||||
|
| `gitea_actions_delete_action_run` | 400 |
|
||||||
|
| `gitea_actions_delete_artifact` | 404 |
|
||||||
|
| `gitea_actions_delete_org_runner` | 404 |
|
||||||
|
| `gitea_actions_delete_repo_runner` | 404 |
|
||||||
|
| `gitea_actions_delete_user_runner` | 404 |
|
||||||
|
| `gitea_actions_download_artifact` | 404 |
|
||||||
|
| `gitea_actions_force_cancel_workflow_run` | 409 |
|
||||||
|
| `gitea_actions_get_artifact` | 404 |
|
||||||
|
| `gitea_actions_get_org_runner` | 404 |
|
||||||
|
| `gitea_actions_get_repo_runner` | 404 |
|
||||||
|
| `gitea_actions_get_user_runner` | 404 |
|
||||||
|
| `gitea_actions_get_workflow_run_logs` | 404 |
|
||||||
|
| `gitea_actions_rerun_workflow_job` | 404 |
|
||||||
|
| `gitea_actions_rerun_workflow_run` | 400 |
|
||||||
|
| `gitea_actions_update_org_runner` | 404 |
|
||||||
|
| `gitea_actions_update_repo_runner` | 404 |
|
||||||
|
| `gitea_actions_update_user_runner` | 404 |
|
||||||
|
|
||||||
|
Allowances (0):
|
||||||
|
|
||||||
|
none
|
||||||
|
|
||||||
|
## admin
|
||||||
|
|
||||||
|
Tools with a 2xx (27):
|
||||||
|
|
||||||
|
`gitea_admin_create_hook`, `gitea_admin_create_org`, `gitea_admin_create_public_key`, `gitea_admin_create_repo`, `gitea_admin_create_runner_registration_token`, `gitea_admin_create_user`, `gitea_admin_cron_list`, `gitea_admin_cron_run`, `gitea_admin_delete_hook`, `gitea_admin_delete_user`, `gitea_admin_delete_user_badges`, `gitea_admin_delete_user_public_key`, `gitea_admin_edit_hook`, `gitea_admin_edit_user`, `gitea_admin_get_all_emails`, `gitea_admin_get_all_orgs`, `gitea_admin_get_hook`, `gitea_admin_get_runners`, `gitea_admin_list_hooks`, `gitea_admin_list_packages`, `gitea_admin_list_user_badges`, `gitea_admin_list_workflow_jobs`, `gitea_admin_list_workflow_runs`, `gitea_admin_rename_user`, `gitea_admin_search_emails`, `gitea_admin_search_users`, `gitea_admin_unadopted_list`
|
||||||
|
|
||||||
|
Tools with a documented non-2xx status (4):
|
||||||
|
|
||||||
|
| Tool | Status |
|
||||||
|
| --- | --- |
|
||||||
|
| `gitea_admin_adopt_repository` | 404 |
|
||||||
|
| `gitea_admin_delete_runner` | 404 |
|
||||||
|
| `gitea_admin_get_runner` | 404 |
|
||||||
|
| `gitea_admin_update_runner` | 404 |
|
||||||
|
|
||||||
|
Allowances (2):
|
||||||
|
|
||||||
|
| Tool | Status | Reason |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `gitea_admin_add_user_badges` | 500 | Gitea answers 500 for an unknown badge slug and the API cannot create badges |
|
||||||
|
| `gitea_admin_delete_unadopted_repository` | 404 | no unadopted repository exists on the instance; Gitea answers 404 although the swagger lists only 204 and 403 |
|
||||||
|
|
||||||
|
## hook
|
||||||
|
|
||||||
|
Tools with a 2xx (20):
|
||||||
|
|
||||||
|
`gitea_hook_org_create_hook`, `gitea_hook_org_delete_hook`, `gitea_hook_org_edit_hook`, `gitea_hook_org_get_hook`, `gitea_hook_org_list_hooks`, `gitea_hook_repo_create_hook`, `gitea_hook_repo_delete_git_hook`, `gitea_hook_repo_delete_hook`, `gitea_hook_repo_edit_git_hook`, `gitea_hook_repo_edit_hook`, `gitea_hook_repo_get_git_hook`, `gitea_hook_repo_get_hook`, `gitea_hook_repo_list_git_hooks`, `gitea_hook_repo_list_hooks`, `gitea_hook_repo_test_hook`, `gitea_hook_user_create_hook`, `gitea_hook_user_delete_hook`, `gitea_hook_user_edit_hook`, `gitea_hook_user_get_hook`, `gitea_hook_user_list_hooks`
|
||||||
|
|
||||||
|
Tools with a documented non-2xx status (0):
|
||||||
|
|
||||||
|
none
|
||||||
|
|
||||||
|
Allowances (0):
|
||||||
|
|
||||||
|
none
|
||||||
|
|
||||||
|
## issue
|
||||||
|
|
||||||
|
Tools with a 2xx (73):
|
||||||
|
|
||||||
|
`gitea_issue_add_assignees`, `gitea_issue_add_label`, `gitea_issue_add_subscription`, `gitea_issue_add_time`, `gitea_issue_check_assignee`, `gitea_issue_check_subscription`, `gitea_issue_clear_labels`, `gitea_issue_create_comment`, `gitea_issue_create_issue`, `gitea_issue_create_issue_attachment`, `gitea_issue_create_issue_blocking`, `gitea_issue_create_issue_comment_attachment`, `gitea_issue_create_issue_dependencies`, `gitea_issue_create_label`, `gitea_issue_create_milestone`, `gitea_issue_delete`, `gitea_issue_delete_comment`, `gitea_issue_delete_comment_deprecated`, `gitea_issue_delete_comment_reaction`, `gitea_issue_delete_issue_attachment`, `gitea_issue_delete_issue_comment_attachment`, `gitea_issue_delete_issue_reaction`, `gitea_issue_delete_label`, `gitea_issue_delete_milestone`, `gitea_issue_delete_stopwatch`, `gitea_issue_delete_subscription`, `gitea_issue_delete_time`, `gitea_issue_edit_comment`, `gitea_issue_edit_comment_deprecated`, `gitea_issue_edit_issue`, `gitea_issue_edit_issue_attachment`, `gitea_issue_edit_issue_comment_attachment`, `gitea_issue_edit_issue_deadline`, `gitea_issue_edit_label`, `gitea_issue_edit_milestone`, `gitea_issue_get_comment`, `gitea_issue_get_comment_reactions`, `gitea_issue_get_comments`, `gitea_issue_get_comments_and_timeline`, `gitea_issue_get_issue`, `gitea_issue_get_issue_attachment`, `gitea_issue_get_issue_comment_attachment`, `gitea_issue_get_issue_reactions`, `gitea_issue_get_label`, `gitea_issue_get_labels`, `gitea_issue_get_milestone`, `gitea_issue_get_milestones_list`, `gitea_issue_get_repo_comments`, `gitea_issue_list_blocks`, `gitea_issue_list_issue_attachments`, `gitea_issue_list_issue_comment_attachments`, `gitea_issue_list_issue_dependencies`, `gitea_issue_list_issues`, `gitea_issue_list_labels`, `gitea_issue_list_pinned_issues`, `gitea_issue_lock_issue`, `gitea_issue_move_issue_pin`, `gitea_issue_pin_issue`, `gitea_issue_post_comment_reaction`, `gitea_issue_post_issue_reaction`, `gitea_issue_remove_assignees`, `gitea_issue_remove_issue_blocking`, `gitea_issue_remove_issue_dependencies`, `gitea_issue_remove_label`, `gitea_issue_replace_labels`, `gitea_issue_reset_time`, `gitea_issue_search_issues`, `gitea_issue_start_stopwatch`, `gitea_issue_stop_stopwatch`, `gitea_issue_subscriptions`, `gitea_issue_tracked_times`, `gitea_issue_unlock_issue`, `gitea_issue_unpin_issue`
|
||||||
|
|
||||||
|
Tools with a documented non-2xx status (0):
|
||||||
|
|
||||||
|
none
|
||||||
|
|
||||||
|
Allowances (0):
|
||||||
|
|
||||||
|
none
|
||||||
|
|
||||||
|
## misc
|
||||||
|
|
||||||
|
Tools with a 2xx (18):
|
||||||
|
|
||||||
|
`gitea_api_request`, `gitea_misc_delete_current_token`, `gitea_misc_get_current_token`, `gitea_misc_get_general_api_settings`, `gitea_misc_get_general_attachment_settings`, `gitea_misc_get_general_repository_settings`, `gitea_misc_get_general_ui_settings`, `gitea_misc_get_gitignore_template_info`, `gitea_misc_get_label_template_info`, `gitea_misc_get_license_template_info`, `gitea_misc_get_version`, `gitea_misc_list_gitignores_templates`, `gitea_misc_list_label_templates`, `gitea_misc_list_license_templates`, `gitea_misc_render_markdown`, `gitea_misc_render_markdown_raw`, `gitea_misc_render_markup`, `gitea_misc_topic_search`
|
||||||
|
|
||||||
|
Tools with a documented non-2xx status (0):
|
||||||
|
|
||||||
|
none
|
||||||
|
|
||||||
|
Allowances (2):
|
||||||
|
|
||||||
|
| Tool | Status | Reason |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `gitea_misc_get_signing_key` | 404 | the throwaway instance has no commit signing key; Gitea answers 404 although the swagger lists no 404 for this operation |
|
||||||
|
| `gitea_misc_get_signing_key_ssh` | 404 | the throwaway instance has no commit signing key; Gitea answers 404 although the swagger lists no 404 for this operation |
|
||||||
|
|
||||||
|
## notification
|
||||||
|
|
||||||
|
Tools with a 2xx (7):
|
||||||
|
|
||||||
|
`gitea_notification_get_list`, `gitea_notification_get_repo_list`, `gitea_notification_get_thread`, `gitea_notification_new_available`, `gitea_notification_read_list`, `gitea_notification_read_repo_list`, `gitea_notification_read_thread`
|
||||||
|
|
||||||
|
Tools with a documented non-2xx status (0):
|
||||||
|
|
||||||
|
none
|
||||||
|
|
||||||
|
Allowances (0):
|
||||||
|
|
||||||
|
none
|
||||||
|
|
||||||
|
## org
|
||||||
|
|
||||||
|
Tools with a 2xx (45):
|
||||||
|
|
||||||
|
`gitea_org_add_issue_to_project_column`, `gitea_org_block_user`, `gitea_org_check_user_block`, `gitea_org_conceal_member`, `gitea_org_create`, `gitea_org_create_label`, `gitea_org_create_org_repo`, `gitea_org_create_org_repo_deprecated`, `gitea_org_create_project`, `gitea_org_create_project_column`, `gitea_org_delete`, `gitea_org_delete_avatar`, `gitea_org_delete_label`, `gitea_org_delete_member`, `gitea_org_delete_project`, `gitea_org_delete_project_column`, `gitea_org_delete_repos`, `gitea_org_edit`, `gitea_org_edit_label`, `gitea_org_edit_project`, `gitea_org_edit_project_column`, `gitea_org_get`, `gitea_org_get_all`, `gitea_org_get_label`, `gitea_org_get_project`, `gitea_org_get_project_column`, `gitea_org_is_member`, `gitea_org_is_public_member`, `gitea_org_list_activity_feeds`, `gitea_org_list_blocks`, `gitea_org_list_labels`, `gitea_org_list_members`, `gitea_org_list_project_column_issues`, `gitea_org_list_project_columns`, `gitea_org_list_projects`, `gitea_org_list_public_members`, `gitea_org_list_repos`, `gitea_org_move_project_columns`, `gitea_org_move_project_issue`, `gitea_org_publicize_member`, `gitea_org_remove_issue_from_project_column`, `gitea_org_rename`, `gitea_org_set_default_project_column`, `gitea_org_unblock_user`, `gitea_org_update_avatar`
|
||||||
|
|
||||||
|
Tools with a documented non-2xx status (0):
|
||||||
|
|
||||||
|
none
|
||||||
|
|
||||||
|
Allowances (0):
|
||||||
|
|
||||||
|
none
|
||||||
|
|
||||||
|
## package
|
||||||
|
|
||||||
|
Tools with a 2xx (9):
|
||||||
|
|
||||||
|
`gitea_package_delete_package`, `gitea_package_delete_package_version`, `gitea_package_get_latest_package_version`, `gitea_package_get_package`, `gitea_package_link_package`, `gitea_package_list_package_files`, `gitea_package_list_package_versions`, `gitea_package_list_packages`, `gitea_package_unlink_package`
|
||||||
|
|
||||||
|
Tools with a documented non-2xx status (0):
|
||||||
|
|
||||||
|
none
|
||||||
|
|
||||||
|
Allowances (0):
|
||||||
|
|
||||||
|
none
|
||||||
|
|
||||||
|
## pull
|
||||||
|
|
||||||
|
Tools with a 2xx (25):
|
||||||
|
|
||||||
|
`gitea_pull_create_pull_request`, `gitea_pull_create_pull_review`, `gitea_pull_create_pull_review_comment_reply`, `gitea_pull_create_pull_review_requests`, `gitea_pull_delete_pull_review`, `gitea_pull_delete_pull_review_requests`, `gitea_pull_dismiss_pull_review`, `gitea_pull_download_pull_diff_or_patch`, `gitea_pull_edit_pull_request`, `gitea_pull_get_pull_request`, `gitea_pull_get_pull_request_by_base_head`, `gitea_pull_get_pull_request_commits`, `gitea_pull_get_pull_request_files`, `gitea_pull_get_pull_review`, `gitea_pull_get_pull_review_comments`, `gitea_pull_list_pinned_pull_requests`, `gitea_pull_list_pull_requests`, `gitea_pull_list_pull_reviews`, `gitea_pull_merge_pull_request`, `gitea_pull_pull_request_is_merged`, `gitea_pull_resolve_pull_review_comment`, `gitea_pull_submit_pull_review`, `gitea_pull_undismiss_pull_review`, `gitea_pull_unresolve_pull_review_comment`, `gitea_pull_update_pull_request`
|
||||||
|
|
||||||
|
Tools with a documented non-2xx status (1):
|
||||||
|
|
||||||
|
| Tool | Status |
|
||||||
|
| --- | --- |
|
||||||
|
| `gitea_pull_cancel_scheduled_auto_merge` | 404 |
|
||||||
|
|
||||||
|
Allowances (0):
|
||||||
|
|
||||||
|
none
|
||||||
|
|
||||||
|
## release
|
||||||
|
|
||||||
|
Tools with a 2xx (13):
|
||||||
|
|
||||||
|
`gitea_release_create_release`, `gitea_release_create_release_attachment`, `gitea_release_delete_release`, `gitea_release_delete_release_attachment`, `gitea_release_delete_release_by_tag`, `gitea_release_edit_release`, `gitea_release_edit_release_attachment`, `gitea_release_get_latest_release`, `gitea_release_get_release`, `gitea_release_get_release_attachment`, `gitea_release_get_release_by_tag`, `gitea_release_list_release_attachments`, `gitea_release_list_releases`
|
||||||
|
|
||||||
|
Tools with a documented non-2xx status (0):
|
||||||
|
|
||||||
|
none
|
||||||
|
|
||||||
|
Allowances (0):
|
||||||
|
|
||||||
|
none
|
||||||
|
|
||||||
|
## repo
|
||||||
|
|
||||||
|
Tools with a 2xx (118):
|
||||||
|
|
||||||
|
`gitea_repo_add_collaborator`, `gitea_repo_add_issue_to_project_column`, `gitea_repo_add_push_mirror`, `gitea_repo_add_team`, `gitea_repo_add_topic`, `gitea_repo_apply_diff_patch`, `gitea_repo_change_files`, `gitea_repo_check_assignee`, `gitea_repo_check_collaborator`, `gitea_repo_check_subscription`, `gitea_repo_check_team`, `gitea_repo_compare_diff`, `gitea_repo_create_branch`, `gitea_repo_create_branch_protection`, `gitea_repo_create_deploy_token`, `gitea_repo_create_file`, `gitea_repo_create_fork`, `gitea_repo_create_key`, `gitea_repo_create_project`, `gitea_repo_create_project_column`, `gitea_repo_create_status`, `gitea_repo_create_tag`, `gitea_repo_create_tag_protection`, `gitea_repo_delete`, `gitea_repo_delete_avatar`, `gitea_repo_delete_branch`, `gitea_repo_delete_branch_protection`, `gitea_repo_delete_collaborator`, `gitea_repo_delete_file`, `gitea_repo_delete_key`, `gitea_repo_delete_project`, `gitea_repo_delete_project_column`, `gitea_repo_delete_push_mirror`, `gitea_repo_delete_subscription`, `gitea_repo_delete_tag`, `gitea_repo_delete_tag_protection`, `gitea_repo_delete_team`, `gitea_repo_delete_topic`, `gitea_repo_download_commit_diff_or_patch`, `gitea_repo_edit`, `gitea_repo_edit_branch_protection`, `gitea_repo_edit_project`, `gitea_repo_edit_project_column`, `gitea_repo_edit_tag_protection`, `gitea_repo_generate_repo`, `gitea_repo_get`, `gitea_repo_get_all_commits`, `gitea_repo_get_annotated_tag`, `gitea_repo_get_archive`, `gitea_repo_get_assignees`, `gitea_repo_get_blob`, `gitea_repo_get_branch`, `gitea_repo_get_branch_protection`, `gitea_repo_get_by_id`, `gitea_repo_get_combined_status_by_ref`, `gitea_repo_get_contents`, `gitea_repo_get_contents_ext`, `gitea_repo_get_contents_list`, `gitea_repo_get_editor_config`, `gitea_repo_get_file_contents`, `gitea_repo_get_file_contents_post`, `gitea_repo_get_issue_config`, `gitea_repo_get_issue_templates`, `gitea_repo_get_key`, `gitea_repo_get_languages`, `gitea_repo_get_licenses`, `gitea_repo_get_project`, `gitea_repo_get_project_column`, `gitea_repo_get_push_mirror_by_remote_name`, `gitea_repo_get_raw_file`, `gitea_repo_get_raw_file_or_lfs`, `gitea_repo_get_repo_permissions`, `gitea_repo_get_reviewers`, `gitea_repo_get_single_commit`, `gitea_repo_get_tag`, `gitea_repo_get_tag_protection`, `gitea_repo_get_tree`, `gitea_repo_list_activity_feeds`, `gitea_repo_list_all_git_refs`, `gitea_repo_list_branch_protection`, `gitea_repo_list_branches`, `gitea_repo_list_collaborators`, `gitea_repo_list_forks`, `gitea_repo_list_git_refs`, `gitea_repo_list_keys`, `gitea_repo_list_project_column_issues`, `gitea_repo_list_project_columns`, `gitea_repo_list_projects`, `gitea_repo_list_push_mirrors`, `gitea_repo_list_stargazers`, `gitea_repo_list_statuses`, `gitea_repo_list_statuses_by_ref`, `gitea_repo_list_subscribers`, `gitea_repo_list_tag_protection`, `gitea_repo_list_tags`, `gitea_repo_list_teams`, `gitea_repo_list_topics`, `gitea_repo_list_user_tracked_times`, `gitea_repo_merge_upstream`, `gitea_repo_migrate`, `gitea_repo_mirror_sync`, `gitea_repo_move_project_columns`, `gitea_repo_move_project_issue`, `gitea_repo_new_pin_allowed`, `gitea_repo_push_mirror_sync`, `gitea_repo_put_subscription`, `gitea_repo_remove_issue_from_project_column`, `gitea_repo_rename_branch`, `gitea_repo_search`, `gitea_repo_set_default_project_column`, `gitea_repo_tracked_times`, `gitea_repo_transfer`, `gitea_repo_update_avatar`, `gitea_repo_update_branch`, `gitea_repo_update_branch_protection_priorities`, `gitea_repo_update_file`, `gitea_repo_update_topics`, `gitea_repo_validate_issue_config`
|
||||||
|
|
||||||
|
Tools with a documented non-2xx status (4):
|
||||||
|
|
||||||
|
| Tool | Status |
|
||||||
|
| --- | --- |
|
||||||
|
| `gitea_repo_accept_repo_transfer` | 404 |
|
||||||
|
| `gitea_repo_get_commit_pull_request` | 404 |
|
||||||
|
| `gitea_repo_get_note` | 404 |
|
||||||
|
| `gitea_repo_reject_repo_transfer` | 404 |
|
||||||
|
|
||||||
|
Allowances (2):
|
||||||
|
|
||||||
|
| Tool | Status | Reason |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `gitea_repo_signing_key` | 404 | the throwaway instance has no commit signing key; Gitea answers 404 although the swagger lists no 404 for this operation |
|
||||||
|
| `gitea_repo_signing_key_ssh` | 404 | the throwaway instance has no commit signing key; Gitea answers 404 although the swagger lists no 404 for this operation |
|
||||||
|
|
||||||
|
## team
|
||||||
|
|
||||||
|
Tools with a 2xx (15):
|
||||||
|
|
||||||
|
`gitea_team_add_team_member`, `gitea_team_add_team_repository`, `gitea_team_create_team`, `gitea_team_delete_team`, `gitea_team_edit_team`, `gitea_team_get_team`, `gitea_team_list_team_activity_feeds`, `gitea_team_list_team_member`, `gitea_team_list_team_members`, `gitea_team_list_team_repo`, `gitea_team_list_team_repos`, `gitea_team_list_teams`, `gitea_team_remove_team_member`, `gitea_team_remove_team_repository`, `gitea_team_search`
|
||||||
|
|
||||||
|
Tools with a documented non-2xx status (0):
|
||||||
|
|
||||||
|
none
|
||||||
|
|
||||||
|
Allowances (0):
|
||||||
|
|
||||||
|
none
|
||||||
|
|
||||||
|
## user
|
||||||
|
|
||||||
|
Tools with a 2xx (71):
|
||||||
|
|
||||||
|
`gitea_user_add_email`, `gitea_user_block_user`, `gitea_user_check_following`, `gitea_user_check_user_block`, `gitea_user_create_oauth2_application`, `gitea_user_current_add_issue_to_project_column`, `gitea_user_current_check_following`, `gitea_user_current_check_starring`, `gitea_user_current_create_project`, `gitea_user_current_create_project_column`, `gitea_user_current_create_repo`, `gitea_user_current_delete_follow`, `gitea_user_current_delete_gpg_key`, `gitea_user_current_delete_key`, `gitea_user_current_delete_project`, `gitea_user_current_delete_project_column`, `gitea_user_current_delete_star`, `gitea_user_current_edit_project`, `gitea_user_current_edit_project_column`, `gitea_user_current_get_key`, `gitea_user_current_get_project`, `gitea_user_current_get_project_column`, `gitea_user_current_list_followers`, `gitea_user_current_list_following`, `gitea_user_current_list_gpg_keys`, `gitea_user_current_list_keys`, `gitea_user_current_list_orgs`, `gitea_user_current_list_project_column_issues`, `gitea_user_current_list_project_columns`, `gitea_user_current_list_projects`, `gitea_user_current_list_repos`, `gitea_user_current_list_starred`, `gitea_user_current_list_subscriptions`, `gitea_user_current_move_project_columns`, `gitea_user_current_move_project_issue`, `gitea_user_current_post_key`, `gitea_user_current_put_follow`, `gitea_user_current_put_star`, `gitea_user_current_remove_issue_from_project_column`, `gitea_user_current_set_default_project_column`, `gitea_user_current_tracked_times`, `gitea_user_delete_avatar`, `gitea_user_delete_email`, `gitea_user_delete_oauth2_application`, `gitea_user_get`, `gitea_user_get_current`, `gitea_user_get_heatmap_data`, `gitea_user_get_oauth2_application`, `gitea_user_get_org_permissions`, `gitea_user_get_settings`, `gitea_user_get_stopwatches`, `gitea_user_get_verification_token`, `gitea_user_list_activity_feeds`, `gitea_user_list_blocks`, `gitea_user_list_emails`, `gitea_user_list_followers`, `gitea_user_list_following`, `gitea_user_list_gpg_keys`, `gitea_user_list_keys`, `gitea_user_list_oauth2_applications`, `gitea_user_list_orgs`, `gitea_user_list_projects`, `gitea_user_list_repos`, `gitea_user_list_starred`, `gitea_user_list_subscriptions`, `gitea_user_list_teams`, `gitea_user_search`, `gitea_user_unblock_user`, `gitea_user_update_avatar`, `gitea_user_update_oauth2_application`, `gitea_user_update_settings`
|
||||||
|
|
||||||
|
Tools with a documented non-2xx status (3):
|
||||||
|
|
||||||
|
| Tool | Status |
|
||||||
|
| --- | --- |
|
||||||
|
| `gitea_user_current_get_gpg_key` | 404 |
|
||||||
|
| `gitea_user_current_post_gpg_key` | 422 |
|
||||||
|
| `gitea_user_verify_gpg_key` | 422 |
|
||||||
|
|
||||||
|
Allowances (3):
|
||||||
|
|
||||||
|
| Tool | Status | Reason |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `gitea_user_create_token` | 401 | Gitea only accepts basic auth here; the server always sends token authentication |
|
||||||
|
| `gitea_user_delete_access_token` | 401 | Gitea only accepts basic auth here; the server always sends token authentication |
|
||||||
|
| `gitea_user_get_tokens` | 401 | Gitea only accepts basic auth here; the server always sends token authentication |
|
||||||
|
|
||||||
|
## wiki
|
||||||
|
|
||||||
|
Tools with a 2xx (6):
|
||||||
|
|
||||||
|
`gitea_wiki_create_wiki_page`, `gitea_wiki_delete_wiki_page`, `gitea_wiki_edit_wiki_page`, `gitea_wiki_get_wiki_page`, `gitea_wiki_get_wiki_page_revisions`, `gitea_wiki_get_wiki_pages`
|
||||||
|
|
||||||
|
Tools with a documented non-2xx status (0):
|
||||||
|
|
||||||
|
none
|
||||||
|
|
||||||
|
Allowances (0):
|
||||||
|
|
||||||
|
none
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
# Security review findings (S1)
|
||||||
|
|
||||||
|
Review of commit `ce5ae6c` on `feat/full-api-coverage`, 2026-10-14. Scope: every path from an
|
||||||
|
HTTP request or a stdio message to a Gitea call (`transports.py`, `auth.py`, `server.py`,
|
||||||
|
`oauth/*`, `gitea/*`, `rate_limit.py`, `config.py`), audit and logging, `setup_cmd.py`, the
|
||||||
|
Docker image and CI. The threat model is in [security.md](security.md). S2 fixes these
|
||||||
|
findings and adds the negative tests to `tests/security/`.
|
||||||
|
|
||||||
|
Severity: critical, high, medium, low. Reproduction scripts referenced below were run in a
|
||||||
|
scratch directory against the code as committed; each fix lists the negative test that proves
|
||||||
|
it.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Id | Severity | Title | Status (S2) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| SEC-01 | high | Unbounded request body on the unauthenticated consent POST | fixed |
|
||||||
|
| SEC-02 | medium | Stored webhook `authorization_header` returned by hook read tools | fixed |
|
||||||
|
| SEC-03 | medium | Audit write failures after startup do not stop later calls | fixed |
|
||||||
|
| SEC-04 | medium | Settings read a `.env` file from the process working directory | fixed |
|
||||||
|
| SEC-05 | medium | Any https redirect URI can be registered; the consent page is the only barrier | fixed, default waived (W-10) |
|
||||||
|
| SEC-06 | medium | Fork pull requests run Docker-capable jobs on the shared runner | fixed |
|
||||||
|
| SEC-07 | medium | CI actions pinned by mutable tags, one of them receives the registry token | fixed |
|
||||||
|
| SEC-08 | low | Rate limiting gaps (`/token`, `/revoke`, IPv6, proxy address) | fixed |
|
||||||
|
| SEC-09 | low | OAuth pre-sign-in state can be flushed by unauthenticated floods | fixed |
|
||||||
|
| SEC-10 | low | `GITEA_URL` and `PUBLIC_BASE_URL` accept whitespace and control characters; plain http Gitea is silent | fixed |
|
||||||
|
| SEC-11 | low | Audit log created with umask defaults; unkeyed hash chain | fixed, keyed chain waived (W-11) |
|
||||||
|
| SEC-12 | low | Tool audit entries do not record the client, session or source IP | fixed |
|
||||||
|
| SEC-13 | low | Token store directory ownership is not verified | fixed |
|
||||||
|
| SEC-14 | low | CI cleanup and credential handling on the shared Docker daemon | fixed |
|
||||||
|
| SEC-15 | low | `tests/security` missing, so the CI `security` job fails; publish is not gated on it | fixed |
|
||||||
|
| SEC-16 | low | No container image vulnerability scan | fixed |
|
||||||
|
| SEC-17 | low | Consent POST accepts a missing `Origin` header, unlike ADR 0004 says | fixed |
|
||||||
|
| SEC-18 | low | Unvalidated argument names and unknown tool names reach the audit log | fixed |
|
||||||
|
| SEC-19 | low | One fixed cookie name for concurrent sign-ins in the same browser | fixed |
|
||||||
|
|
||||||
|
All 19 findings were fixed or waived in S2 (status column and per-finding status lines below); the
|
||||||
|
negative tests live in `tests/security/`. The fixes found four further defects that are fixed in
|
||||||
|
the same changeset: secrets inside JSON object keys were not pattern masked (`fef80a5`), a
|
||||||
|
trailing newline passed the `sudo` pattern (`984673e`), a `data` field on an Action secret
|
||||||
|
response was not redacted (`421a650`), and configuration errors for `OAUTH_SCOPES` and
|
||||||
|
`TOOLSETS` echoed the offending values (`0f5f5cd`).
|
||||||
|
|
||||||
|
No critical findings. Verified as sound (no finding): static bearer comparison, the identity
|
||||||
|
split between static bearer, OAuth and no-auth (including OAuth-only deployments), Sudo
|
||||||
|
handling for OAuth users, path, query and header encoding in the executor, redirect handling,
|
||||||
|
response size and JSON depth bounds, stdio stdout hygiene, error detail exposure, PKCE, code
|
||||||
|
and refresh replay handling, consent CSRF and clickjacking headers, the browser-binding
|
||||||
|
cookie, token store encryption and atomic writes, the setup command's env file handling, and
|
||||||
|
the Dockerfile (pinned digests, non-root, no secrets in layers).
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
### SEC-01 (high): unbounded request body on the unauthenticated consent POST
|
||||||
|
|
||||||
|
- Status: fixed in `d5ca530`: consent and callback routes wrapped in a 16 KiB body limit; 415 for any content type but `application/x-www-form-urlencoded` before a byte is read; form parsed with `max_files=0, max_fields=8, max_part_size=4096`. Tests: `tests/security/test_oauth_input_bounds.py`.
|
||||||
|
- Where: `src/aegis_gitea_mcp/oauth/callback.py:137-155` (`consent_submit` calls
|
||||||
|
`await request.form()`), routes at `callback.py:169-173`.
|
||||||
|
- Description: the browser routes are custom Starlette routes. Unlike the MCP endpoint and the
|
||||||
|
SDK's `/authorize`, `/token`, `/register` and `/revoke` (all wrapped in
|
||||||
|
`RequestBodyLimitMiddleware`), they have no body limit, and `uvicorn` sets none. Starlette
|
||||||
|
parses `multipart/form-data` with up to 1000 fields of up to 1 MiB each held in memory, and
|
||||||
|
file parts of unlimited size spooled to `/tmp`, which is a RAM-backed tmpfs in the shipped
|
||||||
|
`docker-compose.yml`. The `Origin` check passes when the header is absent, and the CSRF check
|
||||||
|
only runs after parsing. Reproduced: one unauthenticated POST with 500 one-megabyte multipart
|
||||||
|
fields raised the server's RSS from 93 MB to 593 MB; a single 200 MB file part was consumed
|
||||||
|
completely before the 400 answer. The per-IP limit (120 requests per minute) counts requests,
|
||||||
|
not bytes, so a single client can exhaust memory and get the process killed. OAuth mode only.
|
||||||
|
- Fix: wrap the consent and callback routes in `RequestBodyLimitMiddleware` with a small cap
|
||||||
|
(16 KiB is ample: the form has three short fields); answer 415 before reading the body when
|
||||||
|
the content type is not `application/x-www-form-urlencoded`; parse with
|
||||||
|
`request.form(max_files=0, max_fields=8, max_part_size=4096)`.
|
||||||
|
- Negative test (`tests/security/test_oauth_input_bounds.py`): drive the ASGI app with a
|
||||||
|
counting `receive`; a 1 MiB urlencoded POST to `/oauth/consent` gets 413 having read at most
|
||||||
|
the cap plus one chunk; a multipart POST gets 415 having read no body; a valid consent still
|
||||||
|
works.
|
||||||
|
|
||||||
|
### SEC-02 (medium): stored webhook `authorization_header` returned by hook read tools
|
||||||
|
|
||||||
|
- Status: fixed in `5b622e6`: `SENSITIVE_RESPONSE_KEYS` (the request-side key list) redacts non-empty string values by key name at any depth in every result except credential-issuing operations, in all `SECRET_DETECTION_MODE` values. Tests: `tests/security/test_secret_echo.py`.
|
||||||
|
- Where: `src/aegis_gitea_mcp/gitea/executor.py:339-356` and `603-625` (only values submitted
|
||||||
|
in the request body are redacted, via `sensitive_body_values` in
|
||||||
|
`src/aegis_gitea_mcp/gitea/encoding.py:387-405`).
|
||||||
|
- Description: the Gitea `Hook` schema includes `authorization_header`, and Gitea fills it on
|
||||||
|
list, get and edit. A create call redacts the header because the caller submitted it, but
|
||||||
|
`gitea_hook_repo_list_hooks`, `gitea_hook_repo_get_hook` and the org, user and admin
|
||||||
|
equivalents (and `gitea_api_request` on hook paths) return stored headers verbatim to the
|
||||||
|
model and the remote client. Reproduced with a mocked `repoListHooks` answer: the header
|
||||||
|
value appears in the result. Pattern masking does not catch `Basic ...` or most `Bearer ...`
|
||||||
|
values.
|
||||||
|
- Fix: redact known secret-bearing response fields in `_seal`, before pattern masking: a
|
||||||
|
non-empty `authorization_header` at any depth becomes `[REDACTED]` (presence stays visible).
|
||||||
|
Keep the set (`_SENSITIVE_RESPONSE_KEYS`) next to `_SENSITIVE_BODY_KEYS` so new fields are
|
||||||
|
added in one place. Credential-issuing operations are unaffected (no hook operation is one).
|
||||||
|
- Negative test (`tests/security/test_secret_echo.py`): list, get and edit hook responses
|
||||||
|
(repo, org, user, admin, and the raw tool on `/repos/o/r/hooks`) containing
|
||||||
|
`authorization_header` never contain the value in `content` or `structured_content`, with
|
||||||
|
`SECRET_DETECTION_MODE` set to `off`, `mask` and `block`.
|
||||||
|
|
||||||
|
### SEC-03 (medium): audit write failures after startup do not stop later calls
|
||||||
|
|
||||||
|
- Status: fixed in `43eebd3`: the write-ahead alternative: a `tool_call_started` entry is written before every known tool runs and the call is refused with `audit_unavailable` when it fails. Documented in `docs/audit-logging.md`. Tests: `tests/security/test_audit_fail_closed.py`.
|
||||||
|
- Where: `src/aegis_gitea_mcp/server.py:414-426` (only obtaining the logger is checked) and
|
||||||
|
`server.py:509-537` (write errors are logged and swallowed);
|
||||||
|
`src/aegis_gitea_mcp/audit.py:104-107`.
|
||||||
|
- Description: the fail-closed check only proves the file could be opened once. Once the
|
||||||
|
singleton exists, a full disk, a revoked mount or any other write error makes every later call
|
||||||
|
run unaudited, indefinitely, while AGENTS.md promises an audit event for every call. The
|
||||||
|
post-execution swallow is intentional for the call that already ran, but nothing stops the
|
||||||
|
next one.
|
||||||
|
- Fix: record audit health. On a write failure set `AuditLogger.healthy = False`; at the start
|
||||||
|
of `call_tool`, refuse with `audit_unavailable` while unhealthy, after one probe write
|
||||||
|
(a small `audit_probe` security event) fails. Alternatively write a `tool_invocation_started`
|
||||||
|
entry before execution and refuse when it fails. Either way, document the choice in
|
||||||
|
`docs/audit-logging.md`.
|
||||||
|
- Negative test (`tests/security/test_audit_fail_closed.py`): after one successful call, make
|
||||||
|
the log file's `write` raise `OSError`; the next call returns `audit_unavailable` and the
|
||||||
|
mocked Gitea receives no request; when writes work again, calls resume.
|
||||||
|
|
||||||
|
### SEC-04 (medium): settings read a `.env` file from the process working directory
|
||||||
|
|
||||||
|
- Status: fixed in `79aa4f7`: `./.env` loads only as a regular file owned by the user with mode 0600; `--env-file` and `AEGIS_ENV_FILE`; documented in README, configuration and troubleshooting. Tests: `tests/security/test_env_file.py`.
|
||||||
|
- Where: `src/aegis_gitea_mcp/config.py:109-114` (`env_file=".env"`).
|
||||||
|
- Description: pydantic-settings loads `./.env` for every setting not present in the
|
||||||
|
environment. Claude Code and other clients start stdio servers with the project directory as
|
||||||
|
the working directory, so a cloned repository can plant a `.env`. Reproduced: with
|
||||||
|
`GITEA_URL` and `GITEA_TOKEN` in the environment, a world-readable `.env` in the working
|
||||||
|
directory set `SECRET_DETECTION_MODE=off`, `EXPOSE_ERROR_DETAILS=true` and
|
||||||
|
`AUDIT_LOG_PATH=/dev/null` (all calls then go unaudited, and the startup audit check passes).
|
||||||
|
A client configuration without `GITEA_URL` in the environment would also send the token to an
|
||||||
|
attacker-chosen `GITEA_URL`.
|
||||||
|
- Fix: honour `./.env` only when it is a regular file (not a symlink) owned by the current user
|
||||||
|
and not readable or writable by group or others (the mode `setup` writes, 0600); otherwise
|
||||||
|
ignore it and print one warning to stderr. Add an explicit `--env-file` option (and
|
||||||
|
`AEGIS_ENV_FILE`) for other locations. Document it in `docs/configuration.md`.
|
||||||
|
- Negative test (`tests/security/test_env_file.py`): a 0644 `.env` in the working directory
|
||||||
|
with `SECRET_DETECTION_MODE=off` and `AUDIT_LOG_PATH=/dev/null` is ignored (mode stays
|
||||||
|
`mask`, the audit path stays under `DATA_DIR`); a symlinked `.env` is ignored; a 0600 `.env`
|
||||||
|
owned by the user is honoured; environment values still win.
|
||||||
|
|
||||||
|
### SEC-05 (medium): any https redirect URI can be registered; the consent page is the only barrier
|
||||||
|
|
||||||
|
- Status: fixed, default waived (W-10) in `8be8592`: owner decision: optional `OAUTH_REDIRECT_ALLOWLIST` (exact URIs or `https://host/` prefixes, checked at registration and `/authorize`); the open default is waived with the consent page as mitigation, and the allowlist is recommended for public deployments. Tests: `tests/security/test_oauth_redirects.py`.
|
||||||
|
- Where: `src/aegis_gitea_mcp/oauth/provider.py:99-115` (`redirect_uri_problem`),
|
||||||
|
`provider.py:306-343` (`register_client`), `src/aegis_gitea_mcp/oauth/callback.py:109-135`.
|
||||||
|
- Description: dynamic client registration is open to anyone and accepts any https redirect
|
||||||
|
URI and any `client_name`. Gitea remembers consent for our single application, so the only
|
||||||
|
thing between a phishing link and a victim's full-scope token (default `write:` on every
|
||||||
|
category, including admin for site admins) is a click on our consent page, which shows an
|
||||||
|
attacker-chosen name (for example "Claude") and the redirect URI. The design is documented in
|
||||||
|
ADR 0004; this is an owner decision on residual risk.
|
||||||
|
- Fix (owner to choose): add `OAUTH_REDIRECT_HOSTS`, an allowlist of redirect hosts accepted at
|
||||||
|
registration (default: loopback plus the hosted clients the owner uses, for example
|
||||||
|
`claude.ai` and `chat.mistral.ai`), with a clear registration error otherwise; on the consent
|
||||||
|
page show the redirect host first and prominently and the client's registration age. Or accept
|
||||||
|
the risk and record it as a waiver in `docs/security.md`.
|
||||||
|
- Negative test (`tests/security/test_oauth_redirects.py`): with the allowlist set,
|
||||||
|
registering `https://evil.example/cb` returns `invalid_redirect_uri`; allowlisted and loopback
|
||||||
|
URIs register; existing rules (http on non-loopback, fragments, credentials, custom schemes)
|
||||||
|
stay rejected.
|
||||||
|
|
||||||
|
### SEC-06 (medium): fork pull requests run Docker-capable jobs on the shared runner
|
||||||
|
|
||||||
|
- Status: fixed in `766968f`: `test-integration`, `test-mcp` and `build-and-push` carry the fork guard. The Gitea setting "require approval for workflows from forks" is a manual step recorded in `docs/deployment.md`. Tests: `tests/security/test_ci_workflows.py`.
|
||||||
|
- Where: `.gitea/workflows/ci.yml:22-25` (`pull_request` to `dev` and `main`), jobs
|
||||||
|
`test-integration` (`ci.yml:119-144`) and `test-mcp` (`ci.yml:153-185`).
|
||||||
|
- Description: a pull request from a fork runs its own workflow and test code, and these jobs
|
||||||
|
use the runner's Docker daemon (building images, starting containers, joining networks).
|
||||||
|
Access to the daemon is root-equivalent on the host that other tenants share. Secrets are not
|
||||||
|
passed to fork runs, but the host is.
|
||||||
|
- Fix: enable "require approval for workflows from forks" (or the equivalent runner setting) in
|
||||||
|
Gitea, and guard the Docker jobs with
|
||||||
|
`if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository`.
|
||||||
|
Record the setting in `docs/deployment.md` or the CI notes.
|
||||||
|
- Negative test (`tests/security/test_ci_workflows.py`): parse the workflow YAML; every job that
|
||||||
|
calls `docker` (or the integration and MCP smoke jobs by name) carries the fork guard.
|
||||||
|
|
||||||
|
### SEC-07 (medium): CI actions pinned by mutable tags, one of them receives the registry token
|
||||||
|
|
||||||
|
- Status: fixed in `766968f`: every `uses:` is pinned to a full commit SHA with a version comment; `docker/login-action` was replaced by `docker login --password-stdin` into a per-job `DOCKER_CONFIG`; `persist-credentials: false` on every checkout. Tests: `tests/security/test_ci_workflows.py`.
|
||||||
|
- Where: `.gitea/workflows/ci.yml:40, 63-69, 90-108, 124-130, 160-166, 206, 242, 299-305,
|
||||||
|
324-330`; `.gitea/workflows/publish.yml:22-28, 47-53, 77-85, 138`. `docker/login-action@v3`
|
||||||
|
receives `REGISTRY_TOKEN` (`ci.yml:242-248`).
|
||||||
|
- Description: `actions/checkout@v4`, `actions/setup-python@v5`, `astral-sh/setup-uv@v5`,
|
||||||
|
`actions/upload-artifact@v4` and `docker/login-action@v3` resolve to whatever the tag points to
|
||||||
|
at run time. A compromised or retagged action runs with the job's secrets and the Docker
|
||||||
|
daemon. `actions/checkout` also persists the job token in `.git/config` for later steps.
|
||||||
|
- Fix: pin every `uses:` to a full commit SHA with the version in a comment; replace
|
||||||
|
`docker/login-action` with `echo "$REGISTRY_TOKEN" | docker login --username ... --password-stdin`;
|
||||||
|
set `persist-credentials: false` on checkout where pushing is not needed.
|
||||||
|
- Negative test (`tests/security/test_ci_workflows.py`): every `uses:` value in
|
||||||
|
`.gitea/workflows/*.yml` matches `@[0-9a-f]{40}$`; no step passes `secrets.*` to a third-party
|
||||||
|
action's `with:`.
|
||||||
|
|
||||||
|
### SEC-08 (low): rate limiting gaps
|
||||||
|
|
||||||
|
- Status: fixed in `f57c237`: `/token` and `/revoke` rate limited; IPv6 clients keyed by /64; `ForwardedHeadersMiddleware` applies forwarded headers for `FORWARDED_ALLOW_IPS` peers and logs one warning for any other peer that sends `X-Forwarded-For`. Tests: `tests/security/test_rate_limits.py`.
|
||||||
|
- Where: `src/aegis_gitea_mcp/oauth/components.py:37` (`RATE_LIMITED_PATHS` lacks `/token` and
|
||||||
|
`/revoke`), `src/aegis_gitea_mcp/rate_limit.py:142-163` (per-IP key is the full address),
|
||||||
|
`src/aegis_gitea_mcp/transports.py:168-188`, `transports.py:400-419`.
|
||||||
|
- Description: `/token` and `/revoke` (client secret checks, store writes) are not limited. Each
|
||||||
|
IPv6 address is its own bucket, so a client with a /64 is effectively unlimited. Behind a
|
||||||
|
proxy whose address is not in `FORWARDED_ALLOW_IPS` (the default `127.0.0.1` does not match a
|
||||||
|
Docker network), every request shares the proxy's bucket, and because the limit runs before
|
||||||
|
authentication, any unauthenticated client can exhaust the budget of all authenticated users.
|
||||||
|
- Fix: add `/token` and `/revoke` to `RATE_LIMITED_PATHS`; key IPv6 clients by their /64; log a
|
||||||
|
one-time warning when a request carries `X-Forwarded-For` from a peer outside
|
||||||
|
`FORWARDED_ALLOW_IPS`.
|
||||||
|
- Negative test (`tests/security/test_rate_limits.py`): limit+1 requests to `/token` get 429;
|
||||||
|
two addresses in one IPv6 /64 share a bucket; a forwarded header from an untrusted peer logs
|
||||||
|
the warning once.
|
||||||
|
|
||||||
|
### SEC-09 (low): OAuth pre-sign-in state can be flushed by unauthenticated floods
|
||||||
|
|
||||||
|
- Status: fixed in `87f048c`: per client address at most 20 pending sign-ins and 20 registrations per hour; clients younger than `PENDING_TTL` are never evicted; the store write runs in a worker thread. Tests: `tests/security/test_oauth_input_bounds.py`.
|
||||||
|
- Where: `src/aegis_gitea_mcp/oauth/provider.py:139-159` (`_ExpiringMap.put` evicts the oldest
|
||||||
|
entry), `provider.py:80-85`, `provider.py:333-356` (client eviction),
|
||||||
|
`src/aegis_gitea_mcp/oauth/store.py:250-275` (synchronous fsync on the event loop).
|
||||||
|
- Description: 1024 `/authorize` requests (needs only a self-registered client) evict every
|
||||||
|
legitimate pending sign-in; 500 registrations evict recently registered clients that have
|
||||||
|
not finished signing in. Every registration writes and fsyncs the whole store on the event
|
||||||
|
loop. Impact: sign-in denial of service, no data exposure.
|
||||||
|
- Fix: per-IP quota on pending requests and registrations (for example 20 each); never evict a
|
||||||
|
client younger than `PENDING_TTL`; run `OAuthStore._write` in a worker thread
|
||||||
|
(`anyio.to_thread.run_sync`).
|
||||||
|
- Negative test (`tests/security/test_oauth_input_bounds.py`): 1100 `/authorize` calls from one
|
||||||
|
IP do not evict a pending request from another IP; a client registered a minute ago survives
|
||||||
|
a registration flood.
|
||||||
|
|
||||||
|
### SEC-10 (low): base URLs accept whitespace and control characters; plain http Gitea is silent
|
||||||
|
|
||||||
|
- Status: fixed in `d96c3ac`: whitespace, backslash and control characters rejected in both URLs, the URL is rebuilt from its parsed parts, `serve` warns for plain http to a non-loopback Gitea (a warning rather than a refusal, so existing lab setups keep working). Tests: `tests/security/test_base_url_validation.py`.
|
||||||
|
- Where: `src/aegis_gitea_mcp/config.py:87-103` (`_normalize_http_url` validates the parsed URL
|
||||||
|
but returns the raw string), `config.py:180-201`.
|
||||||
|
- Description: `https://gi\ttea.example.com`, `https://gitea.example.com/a\nb` and values with
|
||||||
|
spaces pass validation; `urlsplit` strips tabs and newlines for the check while the raw value
|
||||||
|
is used later, so the validated and the used URL can differ. A plain `http://` `GITEA_URL` to a
|
||||||
|
non-loopback host is accepted at serve time without any warning, although the admin token, the
|
||||||
|
OAuth client secret and user tokens then travel in clear text (setup warns, `serve` does not).
|
||||||
|
- Fix: reject any whitespace or non-printable character in both URLs; return the URL rebuilt
|
||||||
|
from its parsed parts; print a startup warning (or refuse unless an explicit
|
||||||
|
`ALLOW_INSECURE_GITEA_URL=true`) for http to a non-loopback host.
|
||||||
|
- Negative test (`tests/security/test_base_url_validation.py`): parametrized rejection of tab,
|
||||||
|
newline, carriage return, space, NUL and bidi controls in `GITEA_URL` and `PUBLIC_BASE_URL`;
|
||||||
|
existing rejections (scheme, credentials, query, fragment, bad port, missing host, http public
|
||||||
|
base URL) kept; `serve` with `GITEA_URL=http://gitea.internal` prints the warning.
|
||||||
|
|
||||||
|
### SEC-11 (low): audit log created with umask defaults; unkeyed hash chain
|
||||||
|
|
||||||
|
- Status: fixed, keyed chain waived (W-11) in `7307214`: directory 0700, log opened `O_NOFOLLOW` with mode 0600, an older loose log is tightened, startup exits 2 for a symlink. The optional `AUDIT_HMAC_KEY` was not added: off-host shipping is the documented integrity control. Tests: `tests/security/test_audit_file.py`.
|
||||||
|
- Where: `src/aegis_gitea_mcp/audit.py:36-39`, `audit.py:64-69`.
|
||||||
|
- Description: `DATA_DIR` is created with the default mode (0755 under a 022 umask) and the
|
||||||
|
audit log with 0644; only the OAuth store later tightens the directory, so in admin-token mode
|
||||||
|
other local users can read logins, paths and timing of every call. The audit path is opened
|
||||||
|
without `O_NOFOLLOW`. The SHA-256 chain has no key, so anyone with write access can rewrite
|
||||||
|
it consistently, and truncating the tail is not detectable.
|
||||||
|
- Fix: create the directory with 0700 and open the log with
|
||||||
|
`os.open(path, O_WRONLY | O_APPEND | O_CREAT | O_NOFOLLOW, 0o600)`; document off-host
|
||||||
|
shipping as the integrity control, optionally add an `AUDIT_HMAC_KEY` for a keyed chain.
|
||||||
|
- Negative test (`tests/security/test_audit_file.py`): after the first call the log is 0600 and a
|
||||||
|
newly created `DATA_DIR` is 0700; a symlink at the audit path makes startup exit 2.
|
||||||
|
|
||||||
|
### SEC-12 (low): tool audit entries do not record the client, session or source IP
|
||||||
|
|
||||||
|
- Status: fixed in `fd5e4b7`: tool entries carry `client_id`, a 12 character `session` hash and `client_ip`. Tests: `tests/security/test_audit_fail_closed.py`.
|
||||||
|
- Where: `src/aegis_gitea_mcp/server.py:509-537`.
|
||||||
|
- Description: in OAuth mode an entry names the Gitea login but not which MCP client or grant
|
||||||
|
made the call, nor the source address, which limits incident response (for example finding
|
||||||
|
which registered client a stolen token belonged to).
|
||||||
|
- Fix: add `client_id` from the verified access token, a short hash of the session id and the
|
||||||
|
client IP (when on HTTP) to the tool invocation entry.
|
||||||
|
- Negative test (`tests/security/test_audit_fail_closed.py` or a sibling): an OAuth call's entry
|
||||||
|
contains the client id and no token or session id in clear.
|
||||||
|
|
||||||
|
### SEC-13 (low): token store directory ownership is not verified
|
||||||
|
|
||||||
|
- Status: fixed in `6f7a3c0`: the store directory must be owned by the effective user and must not stay group or world writable; checked on load and on every save. Tests: `tests/security/test_token_store.py`.
|
||||||
|
- Where: `src/aegis_gitea_mcp/oauth/store.py:133-143`.
|
||||||
|
- Description: an existing `DATA_DIR` owned by another user, or one that stays group or world
|
||||||
|
writable after the chmod attempt, only produces a warning. Whoever controls the directory can
|
||||||
|
replace the store with an older copy (resurrecting revoked grants) or delete it.
|
||||||
|
- Fix: refuse (raise `OAuthStoreError`) when the directory's owner is not the effective user or
|
||||||
|
it is still writable by group or others.
|
||||||
|
- Negative test (`tests/security/test_token_store.py`): with a directory reported as owned by
|
||||||
|
another uid (monkeypatched `os.stat`) or mode 0777 that cannot be tightened, loading or saving
|
||||||
|
fails with a clear message.
|
||||||
|
|
||||||
|
### SEC-14 (low): CI cleanup and credential handling on the shared Docker daemon
|
||||||
|
|
||||||
|
- Status: fixed in `766968f`: cleanup removes containers only for per-run refs (`aegis-ci-*`, `:sha-*`) and only untags shared tags; the credential directory is per job; `uv publish` reads `UV_PUBLISH_PASSWORD`. Tests: `tests/security/test_ci_workflows.py` (fake `docker` on `PATH`).
|
||||||
|
- Where: `scripts/ci/cleanup_job_resources.sh:43-48`, `.gitea/workflows/ci.yml:281-290`,
|
||||||
|
`.gitea/workflows/publish.yml:150-157`.
|
||||||
|
- Description: the cleanup removes every container whose `ancestor` is one of the given refs,
|
||||||
|
and the build job passes the shared tags `:latest`, `:main` and `:dev`; on a daemon that also
|
||||||
|
runs a deployment of this image that can remove containers that are not the job's.
|
||||||
|
`docker logout` acts on the shared credential store when the job is not containerized.
|
||||||
|
`uv publish --password "${REGISTRY_TOKEN}"` puts the token on the command line.
|
||||||
|
- Fix: remove containers only for per-run refs (`aegis-ci-<run id>-*` and the job's own
|
||||||
|
`:sha-` tag); for shared tags only untag (`docker image rm` of the tag); pass the publish
|
||||||
|
token through `UV_PUBLISH_PASSWORD`.
|
||||||
|
- Negative test (`tests/security/test_ci_workflows.py`): run the cleanup script with a fake
|
||||||
|
`docker` on `PATH` that records argv; `rm -f` is only issued for containers of per-run refs;
|
||||||
|
`publish.yml` contains no `--password` argument.
|
||||||
|
|
||||||
|
### SEC-15 (low): `tests/security` missing; publish not gated on the security job
|
||||||
|
|
||||||
|
- Status: fixed in `766968f`: `tests/security` exists (this suite) and `publish.yml` has a `security` job that `publish` needs (gitleaks, security tests, pip-audit, ruff `S`). Tests: `tests/security/test_ci_workflows.py`.
|
||||||
|
- Where: `.gitea/workflows/ci.yml:337`, `.gitea/workflows/publish.yml` (needs only lint and
|
||||||
|
test).
|
||||||
|
- Description: `pytest tests/security` exits with code 4 (path not found), so the CI `security`
|
||||||
|
job fails today. The publish workflow ships packages without gitleaks, pip-audit or the
|
||||||
|
security tests.
|
||||||
|
- Fix: S2 creates the suite listed below; add a `security` job (pip-audit, ruff `S`, security
|
||||||
|
tests, gitleaks) to `publish.yml` and make `publish` need it.
|
||||||
|
- Negative test: the suite itself; a CI workflow test asserting `publish` needs the security
|
||||||
|
job.
|
||||||
|
|
||||||
|
### SEC-16 (low): no container image vulnerability scan
|
||||||
|
|
||||||
|
- Status: fixed in `766968f`: Trivy 0.76.0 pinned with sha256 (`scripts/ci/install_trivy.sh`) scans the built image in `build-and-push` before the push and fails on fixable HIGH or CRITICAL findings (`scripts/ci/scan_image.sh`, waivers in `security/trivyignore.txt`); `scan.yml` repeats it weekly as the digest refresh reminder. Verified locally against the built image (clean).
|
||||||
|
- Where: `docker/Dockerfile:6-7`, `.gitea/workflows/ci.yml` (`build-and-push`).
|
||||||
|
- Description: base image digests are pinned and refreshed by hand; OS package advisories in
|
||||||
|
the image are never checked.
|
||||||
|
- Fix: add a scan step (Trivy or Grype pinned by digest) on the built image, failing on high and
|
||||||
|
critical with a waiver file, and a scheduled digest refresh reminder.
|
||||||
|
- Negative test: CI only (the step runs and fails on a known vulnerable test image).
|
||||||
|
|
||||||
|
### SEC-17 (low): consent POST accepts a missing `Origin` header
|
||||||
|
|
||||||
|
- Status: fixed in `782d416`: consent POST needs an `Origin` equal to the public origin, with the `Referer` origin as fallback; missing, `null` or foreign values are refused. Tests: `tests/security/test_oauth_csrf.py`.
|
||||||
|
- Where: `src/aegis_gitea_mcp/oauth/callback.py:138-140`; ADR 0004 says approval needs "a
|
||||||
|
matching `Origin`".
|
||||||
|
- Description: the `SameSite=Strict` double-submit cookie is the effective CSRF control and is
|
||||||
|
sound, but the code and the ADR disagree. Browsers send `Origin` on form POSTs.
|
||||||
|
- Fix: require `Origin` to equal the public origin (fall back to `Referer` when `Origin` is
|
||||||
|
absent), or correct the ADR.
|
||||||
|
- Negative test (`tests/security/test_oauth_csrf.py`): POST without `Origin` and `Referer`,
|
||||||
|
with `Origin: null`, and with a foreign origin all get 400 and no redirect.
|
||||||
|
|
||||||
|
### SEC-18 (low): unvalidated argument names and unknown tool names reach the audit log
|
||||||
|
|
||||||
|
- Status: fixed in `fd5e4b7`: only argument names that are schema properties are recorded (rest counted as `rejected_argument_count`); unknown tool names are recorded as `(unknown tool, N chars)` and not echoed. Tests: `tests/security/test_audit_fail_closed.py`.
|
||||||
|
- Where: `src/aegis_gitea_mcp/server.py:412-413` (argument names recorded before validation),
|
||||||
|
`server.py:428-431` (unknown tool name echoed and audited).
|
||||||
|
- Description: validation messages carefully avoid caller-chosen argument names because "they
|
||||||
|
could carry a secret", but the audit entry records them before validation, and an unknown tool
|
||||||
|
name (up to 128 characters) is written to the audit log and echoed in the error. Pattern
|
||||||
|
masking still runs, so only non-pattern secrets are affected.
|
||||||
|
- Fix: record only argument names that are properties of the tool's schema (plus a count of
|
||||||
|
rejected ones); record unknown tool names as a fixed marker plus length, and do not echo them.
|
||||||
|
- Negative test (`tests/security/test_redaction.py`): a call with an extra argument named like a
|
||||||
|
40-hex token, and a call to a tool named like one, leave neither value in the audit file or
|
||||||
|
the error.
|
||||||
|
|
||||||
|
### SEC-19 (low): one fixed cookie name for concurrent sign-ins in the same browser
|
||||||
|
|
||||||
|
- Status: fixed in `782d416`: the binding cookie is named per flow (`aegis-oauth-flow-<8 hex of the state hash>`). Tests: `tests/security/test_oauth_csrf.py`.
|
||||||
|
- Where: `src/aegis_gitea_mcp/oauth/callback.py:56-60`, `callback.py:150-155`.
|
||||||
|
- Description: a second sign-in started in the same browser overwrites the first flow's binding
|
||||||
|
cookie. The first callback then fails and writes a high-severity
|
||||||
|
`oauth_callback_binding_mismatch` audit event, a false alarm that trains operators to ignore
|
||||||
|
the one event that signals a confused-deputy attempt.
|
||||||
|
- Fix: suffix the binding cookie name with a short hash of the Gitea `state`
|
||||||
|
(`__Host-aegis-oauth-flow-<8 hex>`), and look it up by the state on the callback.
|
||||||
|
- Negative test (`tests/security/test_oauth_csrf.py`): two interleaved flows in one browser
|
||||||
|
both complete; a callback without its own cookie still fails and is audited.
|
||||||
|
|
||||||
|
## Negative tests S2 added to `tests/security/`
|
||||||
|
|
||||||
|
All of the files below exist now (the list is kept as the specification they were written
|
||||||
|
from). Results are in the "Verification results" section of [security.md](security.md).
|
||||||
|
|
||||||
|
Some of these duplicate existing unit tests on purpose: the security suite is the one CI and
|
||||||
|
reviewers read, and it must stay green independently of refactors elsewhere. Reuse the helpers
|
||||||
|
in `tests/test_oauth_helpers.py` and `tests/test_executor_helpers.py`.
|
||||||
|
|
||||||
|
1. `test_auth_required.py`
|
||||||
|
- `/mcp` without `Authorization` gets 401 with `WWW-Authenticate` (bearer-only app and OAuth
|
||||||
|
app).
|
||||||
|
- Wrong bearer gets 401: same length, shorter, longer, empty, wrong scheme (`Basic`, `token`),
|
||||||
|
trailing whitespace, the bearer of another deployment.
|
||||||
|
- The static bearer is compared with `hmac.compare_digest` on digests (spy).
|
||||||
|
- OAuth: expired, revoked, other-resource and unknown `agm_` tokens get 401; an `agm_` token
|
||||||
|
never resolves to the admin identity (Gitea mock sees the user's token only).
|
||||||
|
- Route inventory: the set of routes reachable without authentication is exactly
|
||||||
|
`/`, `/health`, the `.well-known` documents and the OAuth routes; adding a route fails the
|
||||||
|
test.
|
||||||
|
2. `test_startup_fail_closed.py`
|
||||||
|
- Non-loopback binds (`0.0.0.0`, `::`, a public IP, a host name, `127.1`, `0`) without bearer
|
||||||
|
or OAuth exit 2 and never call `uvicorn.run`.
|
||||||
|
- Half-configured OAuth, a bearer shorter than 32 characters, an http `PUBLIC_BASE_URL` on a
|
||||||
|
public host, an unwritable audit log and (SEC-11) a symlinked audit path all exit 2.
|
||||||
|
3. `test_redaction.py`
|
||||||
|
- With all secrets configured (`GITEA_TOKEN`, `MCP_BEARER_TOKEN`, `GITEA_OAUTH_CLIENT_SECRET`,
|
||||||
|
`TOKEN_ENCRYPTION_KEY`, a user's Gitea access and refresh tokens, an issued `agm_` token),
|
||||||
|
drive a Gitea error that echoes each of them and a success body that contains each of them:
|
||||||
|
none appears in the result, the error message, the audit file, or captured logs at DEBUG.
|
||||||
|
- `uvicorn.access` records for `/oauth/callback?code=...&state=...` and
|
||||||
|
`/oauth/consent?request=...` contain `[redacted]` instead of the query.
|
||||||
|
- `repr` of `Settings`, `Identity`, `SessionRecord`, `UpstreamTokens` and
|
||||||
|
`PendingAuthorization` contains no secret.
|
||||||
|
- Validation errors never contain argument values (SEC-18 for names).
|
||||||
|
4. `test_secret_echo.py`
|
||||||
|
- `gitea_actions_repo_list_secrets` and `gitea_actions_org_list_secrets` return the names
|
||||||
|
Gitea sends; the update tools never echo the submitted `data`, including in a Gitea 422
|
||||||
|
error that repeats it.
|
||||||
|
- SEC-02: hook list, get and edit responses never contain `authorization_header` values, in
|
||||||
|
every `SECRET_DETECTION_MODE`.
|
||||||
|
- Submitted `password`, `secret`, `token`, `authorization_header` and migration credentials
|
||||||
|
are redacted from results.
|
||||||
|
- Credential-issuing operations return the new credential (documented exemption, W-07) but
|
||||||
|
the audit file and logs do not contain it.
|
||||||
|
5. `test_malformed_input.py`
|
||||||
|
- Unknown arguments, wrong types, missing required arguments rejected before Gitea is
|
||||||
|
contacted.
|
||||||
|
- Path values with `..`, `.`, empty segment, `\`, `?`, `#`, `%`, `%2e`, NUL, CR, LF, U+2028
|
||||||
|
rejected for catalog path parameters and for the raw tool path; a raw path starting `//`
|
||||||
|
rejected.
|
||||||
|
- Query names `token`, `access_token`, `sudo`, `_sudo` in any case rejected.
|
||||||
|
- `sudo` with CR/LF, spaces or over 255 characters rejected.
|
||||||
|
- Upload: oversized base64 rejected before decoding, invalid base64, filenames with quotes or
|
||||||
|
CR/LF, unknown file keys.
|
||||||
|
- Raw method outside the enum; `READ_ONLY` with a non-GET raw call.
|
||||||
|
- Deeply nested JSON arguments and bodies give an error result, never an exception.
|
||||||
|
- HTTP: body over the cap gets 413, non-JSON content type 400, foreign `Host` 421, foreign
|
||||||
|
`Origin` 403.
|
||||||
|
6. `test_base_url_validation.py`
|
||||||
|
- `GITEA_URL` and `PUBLIC_BASE_URL`: non-http schemes, credentials, query, fragment, bad port,
|
||||||
|
missing host, whitespace and control characters (SEC-10), http public base URL on a public
|
||||||
|
host; trailing `/api/v1` normalized.
|
||||||
|
- `MCP_PATH`, `FORWARDED_ALLOW_IPS` and `OAUTH_SCOPES` (unknown scope) rejections.
|
||||||
|
- Redirect URIs at registration: http on non-loopback, fragment, credentials, `javascript:`,
|
||||||
|
custom schemes rejected.
|
||||||
|
7. `test_oauth_csrf.py` (SEC-17, SEC-19): consent without the CSRF cookie, with a mismatched
|
||||||
|
token, without or with a foreign `Origin`; callback without the binding cookie, with a
|
||||||
|
replayed or expired state; none of them redirects.
|
||||||
|
8. `test_oauth_input_bounds.py` (SEC-01, SEC-09).
|
||||||
|
9. `test_audit_fail_closed.py` and `test_audit_file.py` (SEC-03, SEC-11, SEC-12).
|
||||||
|
10. `test_env_file.py` (SEC-04).
|
||||||
|
11. `test_token_store.py`: file mode 0600, directory 0700, symlink refused, wrong key stops
|
||||||
|
startup without wiping, store content is not plaintext, our tokens only as hashes, SEC-13.
|
||||||
|
12. `test_rate_limits.py` (SEC-08).
|
||||||
|
13. `test_ci_workflows.py` (SEC-06, SEC-07, SEC-14, SEC-15).
|
||||||
|
|
||||||
|
## B5 checker verdict
|
||||||
|
|
||||||
|
Verdict: CHANGES REQUIRED (at `ce5ae6c`; the blocking item was fixed in S2, `d5ca530`, and
|
||||||
|
every non-blocking item below was fixed or waived, see the status column above).
|
||||||
|
|
||||||
|
Blocking:
|
||||||
|
|
||||||
|
- SEC-01: the unauthenticated consent POST reads unbounded multipart bodies into memory and
|
||||||
|
tmpfs. This is B5 code and reproducible with one request. Fix and its negative test are
|
||||||
|
required before B5 is accepted.
|
||||||
|
|
||||||
|
Non-blocking (fix in S2, or record as waivers): SEC-05 (redirect allowlist, owner decision),
|
||||||
|
SEC-09, SEC-12, SEC-13, SEC-17 (code or ADR), SEC-19.
|
||||||
|
|
||||||
|
What was checked and holds:
|
||||||
|
|
||||||
|
- Identity separation: an access token with the OAuth marker always resolves to its own
|
||||||
|
session or fails; it never falls back to the admin token, even when the session ends or the
|
||||||
|
upstream refresh fails. Only the static bearer (or no token on stdio and unauthenticated
|
||||||
|
loopback) reaches admin-token mode; an OAuth-only deployment fails closed for it. The session
|
||||||
|
login is checked against the token subject.
|
||||||
|
- Authorization server: DCR client ids are minted by the SDK; PKCE S256 is mandatory towards
|
||||||
|
clients and used towards Gitea; redirect URIs are validated at registration and matched by the
|
||||||
|
SDK; codes are single use, bound to the client, replay revokes the grant; refresh tokens rotate
|
||||||
|
and reuse revokes the grant; revocation is limited to the owning client by the SDK; tokens are
|
||||||
|
bound to the MCP resource and scope.
|
||||||
|
- Browser flow: consent CSRF double submit with a `SameSite=Strict` `__Host-` cookie, a
|
||||||
|
browser-binding cookie on the callback, single-use states, no redirect on any invalid callback,
|
||||||
|
`no-store`, `no-referrer`, `DENY` framing and CSP on every page, HTML escaping of the client
|
||||||
|
name and redirect URI, no request parameter echoed.
|
||||||
|
- Upstream: confidential client with form-encoded credentials, no redirects followed, coarse
|
||||||
|
error kinds only, login validated against a strict pattern, token type and length checked,
|
||||||
|
unknown Gitea scopes rejected at configuration.
|
||||||
|
- Store: Fernet with an HKDF-SHA256 key, mode 0600 through `O_EXCL` temporary files and atomic
|
||||||
|
rename, symlinks refused, a wrong key or corrupt file stops startup without wiping, our tokens
|
||||||
|
stored only as hashes, pruning of expired tokens and abandoned sessions, bounded clients and
|
||||||
|
sessions per login.
|
||||||
|
- Secret handling: the user's refresh token, the store key and the client secret are redacted
|
||||||
|
from results; issued `agm_` tokens are masked by pattern; access-log query strings are dropped.
|
||||||
|
- Quality gates at `ce5ae6c`: 157 OAuth tests and the whole unit suite pass (846 passed, 23
|
||||||
|
skipped), coverage 98.2 percent, `ruff check`, `ruff format --check` and `mypy` clean. The docs
|
||||||
|
drift check failed only on the stale `docs/security.md`, replaced in this changeset.
|
||||||
+238
-82
@@ -1,108 +1,264 @@
|
|||||||
# Security
|
# Security
|
||||||
|
|
||||||
## Core Controls
|
AegisGitea-MCP is a full-access bridge into Gitea (ADR 0001): whoever can call it holds the
|
||||||
|
power of the Gitea token it uses, which for the admin token means the whole instance. The
|
||||||
|
server does not decide which operations are safe. It guarantees a small set of invariants
|
||||||
|
(authentication off loopback, identity separation, audit, secret redaction, bounded and
|
||||||
|
validated input and output, untrusted-content labelling) and leaves exposure to the owner
|
||||||
|
(`READ_ONLY`, `TOOLSETS`, `DISABLED_TOOLS`, token scopes). The operator checklist is
|
||||||
|
[hardening.md](hardening.md); open review findings are in
|
||||||
|
[security-findings.md](security-findings.md) (all 19 findings of the S2 review are fixed or
|
||||||
|
waived).
|
||||||
|
|
||||||
- OAuth2/OIDC bearer-token authentication for MCP tool execution.
|
## Threat model
|
||||||
- OIDC discovery + JWKS validation cache for JWT tokens.
|
|
||||||
- Userinfo validation fallback for opaque OAuth tokens.
|
|
||||||
- Scope enforcement:
|
|
||||||
- `read:repository` for read tools.
|
|
||||||
- `write:repository` for write tools.
|
|
||||||
- Policy engine checks before tool execution.
|
|
||||||
- Per-IP and per-token rate limiting.
|
|
||||||
- Strict schema validation (`extra=forbid`).
|
|
||||||
- Tamper-evident audit logging with hash chaining.
|
|
||||||
- Secret sanitization for logs and tool output.
|
|
||||||
- Production-safe error responses (no internal stack traces).
|
|
||||||
|
|
||||||
## Threat Model
|
### Assets
|
||||||
|
|
||||||
### Why shared bot tokens are dangerous
|
| Asset | Where it lives | Impact if lost |
|
||||||
|
|---|---|---|
|
||||||
|
| Admin Gitea token (`GITEA_TOKEN`) | env file or container env; process memory | Full control of the instance, including Sudo as any user |
|
||||||
|
| MCP bearer (`MCP_BEARER_TOKEN`) | env file; remote clients' configuration | Equivalent to the admin token over HTTP |
|
||||||
|
| OAuth client secret (`GITEA_OAUTH_CLIENT_SECRET`) | env file | Impersonating this server towards Gitea's OAuth endpoints |
|
||||||
|
| Per-user Gitea access and refresh tokens | `DATA_DIR/oauth-store.json` (encrypted), process memory | Acting as each signed-in user, for as long as Gitea accepts the refresh token |
|
||||||
|
| Our issued `agm_` access and refresh tokens | clients; only SHA-256 hashes in the store | Acting as one user through this server until expiry or revocation |
|
||||||
|
| Token store and its key (`TOKEN_ENCRYPTION_KEY`) | store in `DATA_DIR`, key in env | Store plus key discloses every user's Gitea tokens and every client secret |
|
||||||
|
| Action secret values, webhook auth headers, minted credentials | in transit through tool calls | Credentials of CI, webhooks and other systems |
|
||||||
|
| Audit log (`DATA_DIR/audit.log`) | `DATA_DIR` | Loss of accountability; integrity is the asset, not secrecy |
|
||||||
|
|
||||||
- A single leaked bot token can expose all repositories that bot can access.
|
### Actors
|
||||||
- Access is not naturally bounded per end user.
|
|
||||||
- Blast radius is large and cross-tenant.
|
|
||||||
|
|
||||||
### Why token-in-URL is insecure
|
- Owner: configures the server, holds the admin token. Trusted.
|
||||||
|
- OAuth users: Gitea accounts that sign in through a client. Trusted with their own Gitea
|
||||||
|
permissions only.
|
||||||
|
- Remote MCP clients, including hosted ones (claude.ai, Mistral Le Chat): hold a bearer or an
|
||||||
|
OAuth token and act through a model that reads untrusted content.
|
||||||
|
- Malicious Gitea content authors: anyone who can write an issue, comment, file, commit
|
||||||
|
message, release note or wiki page the model may read.
|
||||||
|
- Network attackers: anyone who can reach the HTTP port or sit on the path to it or to Gitea.
|
||||||
|
- Other tenants of the shared CI host: jobs and services on the same runner and Docker daemon.
|
||||||
|
|
||||||
- URLs can be captured by reverse proxy logs, browser history, referer headers, and monitoring pipelines.
|
### Trust boundaries
|
||||||
- Bearer tokens must be passed in `Authorization` headers only.
|
|
||||||
|
|
||||||
### Why per-user OAuth reduces lateral access
|
1. MCP client to HTTP endpoint (`transports.py`): unauthenticated network input until a bearer
|
||||||
|
or `agm_` token is verified (`auth.py`, `oauth/provider.py`).
|
||||||
|
2. Browser to OAuth routes (`oauth/callback.py`, SDK `/authorize`, `/token`, `/register`,
|
||||||
|
`/revoke`): unauthenticated, cross-site capable.
|
||||||
|
3. Verified token to identity (`oauth/resolver.py`, `auth.py`): decides which Gitea token a
|
||||||
|
call uses.
|
||||||
|
4. Server to Gitea (`gitea/executor.py`, `oauth/upstream.py`): Gitea is trusted for
|
||||||
|
authorization decisions, its content is not trusted.
|
||||||
|
5. Tool result to model: everything Gitea returns crosses into a model context.
|
||||||
|
6. Process to disk (`audit.py`, `oauth/store.py`, `config.py` reading `.env`).
|
||||||
|
7. Repository to CI runner (`.gitea/workflows/*.yml`, `scripts/ci/*`): code under test runs
|
||||||
|
with access to the runner's Docker daemon.
|
||||||
|
|
||||||
- Each MCP request executes with the signed-in user token.
|
### Threats and mitigations
|
||||||
- Gitea authorization stays source-of-truth for repository visibility.
|
|
||||||
- A compromised token is limited to that user�s permissions.
|
|
||||||
|
|
||||||
## Resource-type-aware authorization
|
Spoofing
|
||||||
|
|
||||||
The public server runs in *service-PAT mode*: a privileged bot token makes the
|
| Threat | Mitigation | Implemented in |
|
||||||
actual Gitea calls while the per-user OAuth identity decides what the user may
|
|---|---|---|
|
||||||
reach. Repository calls are gated by the user's collaborator permission on
|
| Calling the endpoint without credentials | Non-loopback bind refuses to start without a bearer or OAuth; SDK `RequireAuthMiddleware` answers 401 | `transports.py` (`http_startup_errors`, `build_http_app`) |
|
||||||
`owner/repo`. The rest of the Gitea surface — reachable through the
|
| Guessing the bearer | 32 character minimum; constant-time comparison of SHA-256 digests; per-IP and per-token rate limit before auth | `config.py`, `auth.py` (`StaticBearerVerifier`), `rate_limit.py` |
|
||||||
`gitea_request` escape hatch — is gated by **resource-type-aware authorization**
|
| Using a token minted for another resource | RFC 8707 resource binding and required scope `gitea` enforced by the SDK; `/authorize` with a foreign `resource` gets `invalid_target` | `transports.py` (`_auth_settings`), `oauth/provider.py` |
|
||||||
(`authz.py`). Every call is classified by `(method, path)` and enforced against
|
| Confused deputy through the shared Gitea OAuth app | Own consent page naming client and redirect URI, CSRF double submit with `SameSite=Strict` cookie, browser-binding cookie on the callback | `oauth/callback.py`, `oauth/provider.py` (residual: W-10, optional `OAUTH_REDIRECT_ALLOWLIST`) |
|
||||||
a type-specific rule. **Every decision fails closed**: a call that cannot be
|
| Code or refresh token replay | Single-use codes; replay or reuse of a rotated refresh token revokes the grant | `oauth/provider.py` |
|
||||||
classified, or whose permission cannot be positively verified against Gitea, is
|
|
||||||
denied and audited.
|
|
||||||
|
|
||||||
| Resource type | Rule (service-PAT mode) |
|
Tampering
|
||||||
|---------------|--------------------------|
|
|
||||||
| `repository` | Per-user collaborator permission on `owner/repo` (existing check). A repo path that cannot be parsed to `owner/repo` is denied. |
|
|
||||||
| `org` | The signed-in user must be a **verified member** of the target org (checked against Gitea, fail closed). |
|
|
||||||
| `user_owned` | A resource owned by a named user/org (`/users/{name}`, `/packages/{owner}`): allowed only when the owner is the caller, or the caller is a verified member of the owning org. |
|
|
||||||
| `user_self` | Token-owner-scoped endpoints (`/user`, `/notifications`): **denied** — in service-PAT mode the data belongs to the bot, not the caller. |
|
|
||||||
| `misc_global` | Instance-wide read-only utilities (markdown render, version, gitignore templates): reads allowed; writes denied. |
|
|
||||||
| `admin` | **Default deny.** Allowed only when the operator opts in (`RAW_API_ALLOW_SENSITIVE=true`) **and** the signed-in user is a verified Gitea site administrator. |
|
|
||||||
| `unknown` | Denied. |
|
|
||||||
|
|
||||||
This gate runs *in addition to* the policy engine and the `WRITE_MODE` gate — a
|
| Threat | Mitigation | Implemented in |
|
||||||
write call is denied unless write mode is on, policy allows it, and the
|
|---|---|---|
|
||||||
resource-type rule passes. In pure-OAuth mode (no service PAT) the user's own
|
| Path traversal or path reshaping into other Gitea routes | Path values rejected on `..`, `.`, empty segment, `\`, `?`, `#`, `%`, control characters; percent-encoded with `safe=""` except verified wildcard routes | `gitea/encoding.py` |
|
||||||
token already scopes every call at Gitea, so the extra gate is unnecessary.
|
| Header injection (Sudo, multipart) | Sudo restricted to `[A-Za-z0-9_.-]{1,255}`; filenames and content types single line, no quotes; header parameters of the spec never forwarded | `gitea/encoding.py`, `catalog/build.py` |
|
||||||
|
| Query smuggling of credentials or Sudo | `token`, `access_token`, `sudo`, `_sudo` refused as query names (any case) | `gitea/encoding.py` |
|
||||||
|
| Malformed arguments | JSON Schema validation (Draft 2020-12, `additionalProperties: false`) before anything runs; messages never echo values | `server.py` |
|
||||||
|
| Store tampering or wrong key | Fernet (authenticated encryption) with an HKDF-SHA256 key; undecryptable store stops startup instead of being wiped; symlinks refused | `oauth/store.py` (the directory must be owned by the running user and not group or world writable) |
|
||||||
|
| Settings injection from a planted `.env` | `./.env` is loaded only when it is a regular file, not a symlink, owned by the user, mode 0600; otherwise ignored with a warning; `--env-file` or `AEGIS_ENV_FILE` name a file explicitly; the environment wins | `config.py` (`select_env_file`), `cli.py` |
|
||||||
|
|
||||||
Positive verification results (org membership, site-admin) are cached briefly
|
Repudiation
|
||||||
and bounded; only successful checks are cached, so a transient failure never
|
|
||||||
grants access.
|
|
||||||
|
|
||||||
## Full-API coverage: classified `gitea_request`
|
| Threat | Mitigation | Implemented in |
|
||||||
|
|---|---|---|
|
||||||
|
| Unrecorded tool calls | Exactly one hash-chained result entry per call, preceded by a write-ahead `tool_call_started` entry; a call is refused when either the logger or that write fails; startup fails on an unwritable log | `server.py`, `audit.py`, `transports.py` |
|
||||||
|
| Undetected edits of the log | SHA-256 hash chain, `scripts/validate_audit_log.py` | `audit.py` (unkeyed chain: waiver W-11) |
|
||||||
|
|
||||||
`gitea_request` exposes the long tail of the Gitea API that the curated typed
|
Information disclosure
|
||||||
tools do not cover, safely:
|
|
||||||
|
|
||||||
- **Deterministic read/write classifier.** `GET`/`HEAD` are reads; everything
|
| Threat | Mitigation | Implemented in |
|
||||||
else is a write. A small, explicit override table may only *downgrade*
|
|---|---|---|
|
||||||
provably side-effect-free render endpoints (markdown/markup) to reads — never
|
| Tokens in results or errors | Exact redaction of the identity token, refresh token, admin token, bearer, client secret, store key and submitted secret fields in every result and error; pattern masking per `SECRET_DETECTION_MODE` | `gitea/executor.py`, `gitea/redaction.py`, `security.py` |
|
||||||
the reverse — so a mutating call can never be misclassified as a read and slip
|
| Tokens in logs | JSON log formatter without tracebacks; query strings dropped from access logs; upstream errors reduced to coarse kinds | `logging_utils.py`, `transports.py` (`AccessLogQueryFilter`), `oauth/upstream.py` |
|
||||||
past the `WRITE_MODE` gate.
|
| Secret values echoed back | Submitted `password`, `secret`, `token` style fields and Action secret `data` redacted from the result; secret-bearing response fields (stored webhook `authorization_header`, `secret`, `password` and the rest of `SENSITIVE_RESPONSE_KEYS`, and `data` on Action secret operations) redacted by key name; secrets in object keys masked | `gitea/encoding.py` (`sensitive_body_values`, `SENSITIVE_RESPONSE_KEYS`), `gitea/executor.py` |
|
||||||
- **Known-path gate.** A request whose top path segment is not a recognized
|
| Leaks across identities | No cookie jar, no redirects followed, a fresh upstream client per OAuth exchange | `gitea/executor.py`, `oauth/upstream.py` |
|
||||||
Gitea `/api/v1` route prefix is denied (fail closed): unknown paths are never
|
| Stack traces | `EXPOSE_ERROR_DETAILS=false` by default; even when on, only the exception class name | `server.py`, `gitea/executor.py` |
|
||||||
passed straight through.
|
|
||||||
- **Admin/credential denylist.** `/admin`, `*tokens*`, `*secrets*`, `*hooks*`,
|
|
||||||
`*keys*`, `applications/oauth2`, and runner registration tokens are blocked for
|
|
||||||
every method (including `GET`) and cannot be re-opened from `policy.yaml` —
|
|
||||||
only `RAW_API_ALLOW_SENSITIVE=true` overrides them, and admin then still
|
|
||||||
requires a verified site administrator (see above).
|
|
||||||
|
|
||||||
## Prompt Injection Hardening
|
Denial of service
|
||||||
|
|
||||||
Repository content is treated as untrusted data.
|
| Threat | Mitigation | Implemented in |
|
||||||
|
|---|---|---|
|
||||||
|
| Large request bodies | SDK body limit on the MCP endpoint and SDK OAuth routes; upload cap checked before decoding | `server.py` (`max_request_body_size`), `gitea/encoding.py`, `oauth/callback.py` (16 KiB cap, form content type only) |
|
||||||
|
| Large or hostile responses | 16 MiB text cap, `MAX_BINARY_BYTES`, 64 KiB error bodies, JSON depth 100, item and character limits | `gitea/executor.py` |
|
||||||
|
| Request floods | Sliding window per IP and per token hash, bounded key sets, rate-limit events audited once per window | `rate_limit.py`, `transports.py` (`ForwardedHeadersMiddleware`) |
|
||||||
|
| Unbounded OAuth state | Caps on clients (500), pending sign-ins (1024), codes (1024), sessions per login (50); per client address at most 20 pending sign-ins and 20 registrations per hour; clients younger than the pending window are never evicted; store pruning; the store write (fsync) runs in a worker thread | `oauth/provider.py`, `oauth/store.py` |
|
||||||
|
|
||||||
- Tool outputs are bounded and sanitized.
|
Elevation of privilege
|
||||||
- No instructions from repository text are executed.
|
|
||||||
- Text fields are size-limited before returning to LLM clients.
|
|
||||||
|
|
||||||
## Secret Detection
|
| Threat | Mitigation | Implemented in |
|
||||||
|
|---|---|---|
|
||||||
|
| OAuth user reaching the admin token | Tokens with the OAuth marker only ever resolve to their session; only the static bearer (or no token on stdio and unauthenticated loopback) reaches admin-token mode; OAuth-only deployments fail closed | `oauth/resolver.py`, `auth.py` |
|
||||||
|
| OAuth user using Sudo | Sudo is sent with the caller's own token; Gitea honours it only for site admins | `gitea/executor.py` |
|
||||||
|
| Unknown Gitea scopes granting full access | Only granular `read:`/`write:` scopes accepted for `OAUTH_SCOPES` | `config.py` |
|
||||||
|
| Model tricked by injected content | Untrusted-content labelling and annotations (see below); exposure narrowed by configuration | `server.py`, `gitea/executor.py` |
|
||||||
|
| Untrusted code on the shared CI host | Per-run resource names and targeted cleanup | `.gitea/workflows/ci.yml`, `scripts/ci/cleanup_job_resources.sh`; fork pull requests do not run the Docker jobs |
|
||||||
|
|
||||||
Detected classes include:
|
### Residual risks and owner decisions
|
||||||
|
|
||||||
- API key and token patterns.
|
- Full access by default (ADR 0001). A leaked bearer equals the admin token. Sudo, admin,
|
||||||
- JWT-like tokens.
|
secrets and delete tools are enabled unless the owner narrows them.
|
||||||
- Private key block markers.
|
- Unauthenticated loopback mode is allowed for development. Any local user or process on the
|
||||||
- Common provider credential formats.
|
same host then acts with the admin token. Set a bearer on shared hosts.
|
||||||
|
- Hosted clients (claude.ai, Le Chat) hold the bearer or an OAuth refresh token outside the
|
||||||
|
owner's control. Prefer per-user OAuth and narrow `OAUTH_SCOPES`; the default grants
|
||||||
|
`write:` on every category, including admin for site admins.
|
||||||
|
- Credential-issuing operations (`gitea_user_create_token`, runner registration tokens such as
|
||||||
|
`gitea_admin_create_runner_registration_token`, OAuth2 application secrets,
|
||||||
|
`gitea_repo_create_deploy_token`) return the new credential to the model by design; only the
|
||||||
|
caller's own tokens are still redacted.
|
||||||
|
- Secret pattern masking is heuristic. Content containing third-party credentials (for example a
|
||||||
|
Gitea token committed to a file) is returned unless it matches a pattern.
|
||||||
|
- SSRF through Gitea features (migrations, webhooks, mirrors) is Gitea's responsibility; this
|
||||||
|
server only ever contacts `GITEA_URL`.
|
||||||
|
- With the default `OAUTH_REDIRECT_ALLOWLIST` (empty), the consent page is the only barrier
|
||||||
|
against phishing with a self-registered client (waiver W-10). For a public deployment set the
|
||||||
|
allowlist to the hosted clients you use, for example
|
||||||
|
`OAUTH_REDIRECT_ALLOWLIST=https://claude.ai/,https://chat.mistral.ai/`.
|
||||||
|
- Key rotation for `TOKEN_ENCRYPTION_KEY` is not supported: a new key means moving the store
|
||||||
|
away and signing every user out.
|
||||||
|
- The audit chain is tamper-evident, not tamper-proof: anyone with write access to the file can
|
||||||
|
rewrite it consistently. Ship it off the host.
|
||||||
|
|
||||||
Behavior:
|
## Prompt injection
|
||||||
|
|
||||||
- `SECRET_DETECTION_MODE=mask`: redact in place.
|
Everything returned from Gitea is untrusted data. Every success envelope carries the fixed
|
||||||
- `SECRET_DETECTION_MODE=block`: replace secret-bearing values.
|
`untrusted_content` notice, the server instructions tell the model never to follow
|
||||||
- `SECRET_DETECTION_MODE=off`: disable sanitization (not recommended).
|
instructions found in Gitea content, and the server itself never interprets content (it does
|
||||||
|
not follow links, render, execute or chain calls). Tool annotations mark read-only and
|
||||||
|
destructive operations so clients can require confirmation. The realistic attack is a model
|
||||||
|
that reads a hostile issue and then calls a destructive tool; the defences are client-side
|
||||||
|
confirmation, `READ_ONLY`, `TOOLSETS`, `DISABLED_TOOLS` and narrow token scopes
|
||||||
|
([hardening.md](hardening.md)).
|
||||||
|
|
||||||
|
## Secret hygiene guarantees
|
||||||
|
|
||||||
|
- Settings hold secrets as `SecretStr`; `Identity`, `SessionRecord`, `UpstreamTokens` and the
|
||||||
|
pending authorization exclude tokens from `repr`.
|
||||||
|
- Results and errors: exact redaction of every known secret (identity token and refresh token,
|
||||||
|
`GITEA_TOKEN`, `MCP_BEARER_TOKEN`, `GITEA_OAUTH_CLIENT_SECRET`, `TOKEN_ENCRYPTION_KEY`,
|
||||||
|
secret fields submitted in the request), then pattern masking or blocking.
|
||||||
|
- Validation errors and argument errors never contain argument values.
|
||||||
|
- Audit entries carry argument names, never values, and are masked again before writing.
|
||||||
|
- Logs: JSON lines, exception type only, access-log query strings replaced by `[redacted]`.
|
||||||
|
- Our own tokens are stored only as SHA-256 hashes; the store file is encrypted, mode 0600,
|
||||||
|
written through `O_EXCL` temporary files and an atomic rename.
|
||||||
|
- Action secrets: Gitea's list endpoints (`gitea_actions_repo_list_secrets`,
|
||||||
|
`gitea_actions_org_list_secrets`) return names and descriptions only (a `data` field in an
|
||||||
|
Action secret response is redacted should Gitea ever send one), and a submitted value
|
||||||
|
(`gitea_actions_update_repo_secret` and siblings) is redacted from the result at any length.
|
||||||
|
- A webhook's stored `authorization_header` (and any other field in `SENSITIVE_RESPONSE_KEYS`)
|
||||||
|
is redacted by key name in every result, whatever `SECRET_DETECTION_MODE` says; an empty
|
||||||
|
value stays visible as empty. Credential-issuing operations are the only exemption (W-07).
|
||||||
|
- With `SECRET_DETECTION_MODE=off` pattern matching is disabled, which also removes the only
|
||||||
|
protection of our own issued `agm_` tokens and of the MCP client secrets (only hashes of the
|
||||||
|
tokens exist on the server, so they cannot be redacted exactly). Exact redaction of every
|
||||||
|
other known secret stays on in all modes.
|
||||||
|
- Configuration errors name the setting and the rule and never the value (including unknown
|
||||||
|
`OAUTH_SCOPES` and `TOOLSETS` entries).
|
||||||
|
|
||||||
|
## Transport guarantees
|
||||||
|
|
||||||
|
- Fail-closed bind: `MCP_HOST` defaults to `127.0.0.1`; any non-loopback bind without a bearer
|
||||||
|
or a working OAuth setup exits with code 2. A half-configured OAuth setup also exits 2.
|
||||||
|
- Constant-time bearer check on SHA-256 digests, so neither content nor length leaks.
|
||||||
|
- DNS-rebinding protection on the MCP endpoint: `Host` must be a loopback name, the bind
|
||||||
|
address, the `PUBLIC_BASE_URL` host or an `ALLOWED_HOSTS` entry (421 otherwise); a foreign
|
||||||
|
`Origin` gets 403; POST must be `application/json`.
|
||||||
|
- Rate limits: sliding window per client IP (IPv6 per /64) and per Authorization hash on the
|
||||||
|
MCP path and the unauthenticated OAuth paths (`/register`, `/authorize`, `/token`, `/revoke`,
|
||||||
|
consent, callback), before authentication. Client IPs come from `X-Forwarded-For` only for
|
||||||
|
`FORWARDED_ALLOW_IPS`; a forwarded header from any other peer is ignored and logged once.
|
||||||
|
- `PUBLIC_BASE_URL` must be https unless loopback. `GITEA_URL` and `PUBLIC_BASE_URL` reject
|
||||||
|
whitespace, backslashes and control characters and are rebuilt from their parsed parts;
|
||||||
|
plain http `GITEA_URL` to a non-loopback host prints a startup warning. Gitea redirects are
|
||||||
|
never followed.
|
||||||
|
- stdio: stdout carries JSON-RPC only; all logging goes to stderr.
|
||||||
|
|
||||||
|
### Per-user OAuth design
|
||||||
|
|
||||||
|
The server is an OAuth 2.1 authorization server for MCP clients (SDK provider, dynamic client
|
||||||
|
registration, PKCE S256 required) and a confidential PKCE client of Gitea. Flow: `/authorize`
|
||||||
|
parks the request and shows our consent page (client name, redirect URI, warning). Approval
|
||||||
|
needs the form CSRF token, the matching `SameSite=Strict` cookie and a request `Origin` equal
|
||||||
|
to the public origin (the `Referer` origin when `Origin` is absent; neither, or `null`, is
|
||||||
|
refused). It sets a browser-binding cookie named per flow (`aegis-oauth-flow-<8 hex of the
|
||||||
|
state hash>`, `__Host-` prefixed and `Secure` on https, `HttpOnly`), so concurrent sign-ins in
|
||||||
|
one browser do not overwrite each other. The Gitea
|
||||||
|
callback is completed only in the browser holding that cookie; unknown, expired or replayed
|
||||||
|
states never redirect. Pages send `Cache-Control: no-store`, `Referrer-Policy: no-referrer`,
|
||||||
|
`X-Frame-Options: DENY` and a CSP with `frame-ancestors 'none'`. Redirect URIs must be https,
|
||||||
|
or http on loopback, without credentials or fragments. Issued tokens are opaque (`agm_` plus 256
|
||||||
|
random bits), hashed at rest, bound to the MCP resource; access tokens live 1 hour, refresh
|
||||||
|
tokens 30 days with rotation. Revocation ends the whole grant.
|
||||||
|
|
||||||
|
## Supply chain
|
||||||
|
|
||||||
|
- Base images pinned by digest (`python:3.12-slim`, `ghcr.io/astral-sh/uv`), non-root runtime
|
||||||
|
user, root-owned virtualenv, no build arguments carrying secrets, `.dockerignore` excludes
|
||||||
|
`.env`.
|
||||||
|
- Dependencies locked in `uv.lock` and installed with `uv sync --locked`.
|
||||||
|
- `pip-audit` over the exported lock (`scripts/ci/pip_audit.sh`, waivers in
|
||||||
|
`security/pip-audit-ignore.txt`).
|
||||||
|
- gitleaks pinned to 8.30.1 with sha256 verification, full-history scan with
|
||||||
|
`.gitleaks.toml`.
|
||||||
|
- ruff `S` (bandit) rules enabled in the normal lint and run separately in the `security` job.
|
||||||
|
- Every third-party action in `.gitea/workflows/*.yml` is pinned to a full commit SHA with the
|
||||||
|
version in a comment; no secret is passed to a third-party action (the registry login uses the
|
||||||
|
Docker CLI with the token on stdin in a per-job `DOCKER_CONFIG`, `uv publish` reads
|
||||||
|
`UV_PUBLISH_PASSWORD`); checkout does not persist the job token.
|
||||||
|
- Docker and secret jobs do not run for pull requests from forks.
|
||||||
|
- The image built in `build-and-push` is scanned with a pinned Trivy (0.76.0, sha256 verified)
|
||||||
|
before it is pushed; fixable HIGH and CRITICAL findings fail the job (waivers in
|
||||||
|
`security/trivyignore.txt`). `scan.yml` repeats the image scan and the dependency audit weekly.
|
||||||
|
- `publish.yml` needs the same `security` gates (gitleaks, security tests, pip-audit, ruff `S`).
|
||||||
|
|
||||||
|
## Verification results
|
||||||
|
|
||||||
|
Run on 2026-10-14 on branch `feat/full-api-coverage` after the S2 commits (`git log
|
||||||
|
5baf73f..HEAD`).
|
||||||
|
|
||||||
|
| Check | Command | Result |
|
||||||
|
|---|---|---|
|
||||||
|
| Security test suite | `uv run pytest tests/security --no-cov` | 683 passed, 0 failed (15 files: auth required, startup fail closed, redaction, secret echo, malformed input, base URL validation, OAuth CSRF, OAuth input bounds, OAuth redirects, token store, audit file, audit fail closed, rate limits, env file, CI workflows). |
|
||||||
|
| Dependency audit | `bash scripts/ci/pip_audit.sh` (pip-audit 2.10.1, `--strict`) | No known vulnerabilities in 83 locked packages (runtime and dev groups). No ignores in use. |
|
||||||
|
| Bandit rules | `uv run ruff check --select S .` (ruff 0.17.0) | All checks passed, with the inline waivers listed below. |
|
||||||
|
| Secret scan | `scripts/ci/install_gitleaks.sh` into a scratch directory, then `gitleaks git --config .gitleaks.toml --redact --no-banner --verbose --exit-code 1 .` (gitleaks 8.30.1) | No leaks found, 137 commits scanned (about 6.4 MB). |
|
||||||
|
| Image scan | `scripts/ci/install_trivy.sh`, then `scripts/ci/scan_image.sh <image built from docker/Dockerfile>` (Trivy 0.76.0, HIGH and CRITICAL, fixable only) | Clean: no findings in the Debian 13 base or the Python packages. |
|
||||||
|
| Unit suite | `uv run pytest` (coverage gate 90) | 1529 passed, 23 skipped (live Gitea and Docker tests), coverage 97.8 percent. |
|
||||||
|
|
||||||
|
## Waivers
|
||||||
|
|
||||||
|
Accepted findings, each with its reason. A new waiver needs a reason here in the same
|
||||||
|
changeset.
|
||||||
|
|
||||||
|
| Id | Finding | Where | Reason |
|
||||||
|
|---|---|---|---|
|
||||||
|
| W-01 | S104 (bind to all interfaces) | `transports.py` `_WILDCARD_HOSTS`, `tests/test_server_helpers.py`, `tests/test_config.py` | The addresses are compared or used as test input, never bound. The container binds 0.0.0.0 through `MCP_HOST`, which requires authentication. |
|
||||||
|
| W-02 | S105/S106 (hardcoded password) | `auth.py` (`token="static"`), `oauth/provider.py` (`TOKEN_PREFIX`, `token_type="Bearer"`), `audit.py` (mode string), `setup_snippets.py` (`TOKEN_PLACEHOLDER`) | Placeholders, public prefixes and type names, not secrets. |
|
||||||
|
| W-03 | S101 (assert) | `setup_cmd.py` (two narrowing asserts) | Type narrowing after an explicit check; behaviour does not depend on the assert. |
|
||||||
|
| W-04 | S101, S105, S106, S108 in `tests/**`; S603, S607 in `tests/integration/harness.py` and `tests/mcp/helpers.py` | `pyproject.toml` per-file ignores, inline noqa | Test fixtures use fake tokens and temporary paths; the harness drives the trusted local Docker CLI with fixed argv. |
|
||||||
|
| W-05 | gitleaks allowlists | `.gitleaks.toml` | Only `generic-api-key` and `curl-auth-header`, only in `tests/`, `docs/`, removed legacy guides and one removed source file; every match was reviewed as a fake or placeholder. |
|
||||||
|
| W-06 | pip-audit | `security/pip-audit-ignore.txt` | Empty: no advisory is ignored. |
|
||||||
|
| W-07 | Credential-issuing operations bypass pattern masking | `gitea/executor.py` (`CREDENTIAL_ISSUING_OPERATIONS`) | Their purpose is to return a new credential; exact redaction of the caller's own tokens still applies. |
|
||||||
|
| W-08 | Unauthenticated `/health` and `/` report version and tool count, `/health` reports Gitea reachability | `transports.py` | Needed by container health checks and clients; reveals no configuration or secret. The Gitea probe is cached for 10 seconds. |
|
||||||
|
| W-09 | Pattern masking keeps the first and last 4 characters of a matched secret | `security.py` (`mask_secret`) | Applies only to pattern matches, which are at least 16 characters; the remainder keeps enough entropy, and the context helps users recognise the value. |
|
||||||
|
| W-10 | Any https redirect URI can be registered when `OAUTH_REDIRECT_ALLOWLIST` is empty (SEC-05) | `oauth/provider.py` (`redirect_uri_problem`), `config.py` | Owner decision: the default stays open so any MCP client can register without configuration. The mitigation is the consent page (client name, redirect host and URI, explicit approval, CSRF and browser binding) and ADR 0004. The optional allowlist closes the gap: exact URIs or `https://host/` prefixes, checked at registration and at `/authorize`; recommended for every public deployment. |
|
||||||
|
| W-11 | Audit chain is unkeyed (SEC-11) | `audit.py` | The chain is tamper-evident, not tamper-proof, and an `AUDIT_HMAC_KEY` kept next to the log would not change that for an attacker with host access. The integrity control is shipping the log off the host and comparing the latest `entry_hash` (see audit-logging.md). The directory is 0700 and the file 0600, and symlinks are refused. |
|
||||||
+12
-3
@@ -8,9 +8,13 @@ Gitea's sanitized message, and the audit log has one event per call (see
|
|||||||
|
|
||||||
| Message or exit | Cause and fix |
|
| Message or exit | Cause and fix |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Exit code 2, "GITEA_TOKEN" required (stdio) | stdio needs `GITEA_TOKEN`. Put it in the client's `env` block or in `.env` in the working directory the client starts the server in. |
|
| Exit code 2, "GITEA_TOKEN" required (stdio) | stdio needs `GITEA_TOKEN`. Put it in the client's `env` block, or in an env file passed with `--env-file` (or `AEGIS_ENV_FILE`). A `.env` in the working directory counts only when it is a regular file you own with mode 0600. |
|
||||||
|
| Warning "ignoring ./.env: ..." | The working directory's `.env` is a symlink, owned by someone else or accessible to group or others, so its settings are not loaded. Run `chmod 600 .env`, or pass `--env-file PATH`. See "Which env file is read" in [configuration.md](configuration.md). |
|
||||||
|
| Exit code 2, "env file ... does not exist" | The file named by `--env-file` or `AEGIS_ENV_FILE` is missing or not a regular file. |
|
||||||
| Exit code 2, refuses a non-loopback bind | `MCP_HOST` is not loopback and there is no `MCP_BEARER_TOKEN` or complete OAuth setup. Set one of them, or bind `127.0.0.1` behind a reverse proxy. |
|
| Exit code 2, refuses a non-loopback bind | `MCP_HOST` is not loopback and there is no `MCP_BEARER_TOKEN` or complete OAuth setup. Set one of them, or bind `127.0.0.1` behind a reverse proxy. |
|
||||||
| Exit code 2, lists OAuth settings | `OAUTH_ENABLED=true` needs `GITEA_OAUTH_CLIENT_ID`, `GITEA_OAUTH_CLIENT_SECRET`, `TOKEN_ENCRYPTION_KEY` and `PUBLIC_BASE_URL`. |
|
| Exit code 2, lists OAuth settings | `OAUTH_ENABLED=true` needs `GITEA_OAUTH_CLIENT_ID`, `GITEA_OAUTH_CLIENT_SECRET`, `TOKEN_ENCRYPTION_KEY` and `PUBLIC_BASE_URL`. |
|
||||||
|
| Exit code 2, "cannot decrypt .../oauth-store.json" | `TOKEN_ENCRYPTION_KEY` differs from the key the store was written with (or the file is damaged). The server does not wipe it. Restore the original key, or move `DATA_DIR/oauth-store.json` away to start over (signs every OAuth user out). |
|
||||||
|
| Exit code 2, "owned by another user" or "writable by group or others" (OAuth) | `DATA_DIR` must belong to the user the server runs as and not be group or world writable. Fix the owner or `chmod 700`. |
|
||||||
| Validation error naming a setting | See the rules in [configuration.md](configuration.md): URLs must be http(s) without credentials, `PUBLIC_BASE_URL` must be https, secrets need 32 characters, `OAUTH_SCOPES` accepts only granular scopes. |
|
| Validation error naming a setting | See the rules in [configuration.md](configuration.md): URLs must be http(s) without credentials, `PUBLIC_BASE_URL` must be https, secrets need 32 characters, `OAUTH_SCOPES` accepts only granular scopes. |
|
||||||
| Startup error listing tool names | `DISABLED_TOOLS` contains names that are not registered. Check [tools.md](tools.md). |
|
| Startup error listing tool names | `DISABLED_TOOLS` contains names that are not registered. Check [tools.md](tools.md). |
|
||||||
|
|
||||||
@@ -67,8 +71,13 @@ A tool the client cannot see is probably filtered by `TOOLSETS`, `READ_ONLY` or
|
|||||||
- Gitea shows a redirect URI error: the OAuth application's redirect URI must be exactly
|
- Gitea shows a redirect URI error: the OAuth application's redirect URI must be exactly
|
||||||
`<PUBLIC_BASE_URL>/oauth/callback`. Re-run `setup --public-base-url ...` or fix it in Gitea.
|
`<PUBLIC_BASE_URL>/oauth/callback`. Re-run `setup --public-base-url ...` or fix it in Gitea.
|
||||||
- Signed in, but admin tools return 403: the user is not a site administrator. That is intended.
|
- Signed in, but admin tools return 403: the user is not a site administrator. That is intended.
|
||||||
- Users are asked to sign in again after a restart: `TOKEN_ENCRYPTION_KEY` or the `DATA_DIR`
|
- Users are asked to sign in again after a restart: the `DATA_DIR` volume changed or was not
|
||||||
volume changed. The key must stay stable and the volume persistent.
|
persistent (a changed `TOKEN_ENCRYPTION_KEY` stops startup instead, see above). Keep the key
|
||||||
|
stable and the volume persistent.
|
||||||
|
- The sign-in page says the request is invalid or expired: the consent page lives 10 minutes
|
||||||
|
and a form is single use. Start the sign-in again from the client. Registration or sign-in
|
||||||
|
refused with "too many": one client address may hold 20 pending sign-ins and register 20
|
||||||
|
clients per hour.
|
||||||
- After fixing OAuth settings the client still fails: some clients cache tokens. Disconnect the
|
- After fixing OAuth settings the client still fails: some clients cache tokens. Disconnect the
|
||||||
server in the client and connect again, which starts a fresh flow. Users can also revoke the
|
server in the client and connect again, which starts a fresh flow. Users can also revoke the
|
||||||
application under Settings, Applications, Authorized OAuth2 Applications in Gitea.
|
application under Settings, Applications, Authorized OAuth2 Applications in Gitea.
|
||||||
+2
-2
@@ -127,7 +127,7 @@ disallow_untyped_defs = false
|
|||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
minversion = "8.0"
|
minversion = "8.0"
|
||||||
addopts = "-ra -q --strict-markers --cov=aegis_gitea_mcp --cov-report=term-missing --cov-fail-under=80"
|
addopts = "-ra -q --strict-markers --cov=aegis_gitea_mcp --cov-report=term-missing --cov-fail-under=90"
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
pythonpath = ["src"]
|
pythonpath = ["src"]
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
@@ -141,7 +141,7 @@ source = ["src"]
|
|||||||
omit = ["tests/*", "**/__pycache__/*"]
|
omit = ["tests/*", "**/__pycache__/*"]
|
||||||
|
|
||||||
[tool.coverage.report]
|
[tool.coverage.report]
|
||||||
fail_under = 80
|
fail_under = 90
|
||||||
exclude_lines = [
|
exclude_lines = [
|
||||||
"pragma: no cover",
|
"pragma: no cover",
|
||||||
"def __repr__",
|
"def __repr__",
|
||||||
|
|||||||
@@ -8,8 +8,12 @@
|
|||||||
# * per-run networks named aegis-it-* that the job container is attached to
|
# * per-run networks named aegis-it-* that the job container is attached to
|
||||||
# (the integration harness joins the job container to its own per-run
|
# (the integration harness joins the job container to its own per-run
|
||||||
# network only), plus the containers still attached to those networks;
|
# network only), plus the containers still attached to those networks;
|
||||||
# * containers started from the exact image refs given as arguments;
|
# * containers started from the per-run image refs given as arguments, and
|
||||||
# * the exact image refs given as arguments.
|
# those refs. Per-run refs are names containing "aegis-ci-" (named after the
|
||||||
|
# run id) and immutable ":sha-<commit>" tags;
|
||||||
|
# * for any other ref (shared tags such as :latest, :main, :dev, which a
|
||||||
|
# deployment on the same daemon may use) only the tag is removed. Containers
|
||||||
|
# are never touched, and an image still used by a container simply stays.
|
||||||
# Everything is best effort (a failing removal never fails the job).
|
# Everything is best effort (a failing removal never fails the job).
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
@@ -40,12 +44,24 @@ if [ -n "${job_ref}" ] && docker container inspect "${job_ref}" > /dev/null 2>&1
|
|||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
is_per_run_ref() {
|
||||||
|
case "$1" in
|
||||||
|
*aegis-ci-* | *:sha-*) return 0 ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
for image in "$@"; do
|
for image in "$@"; do
|
||||||
for cid in $(docker ps -aq --filter "ancestor=${image}" 2> /dev/null); do
|
if is_per_run_ref "${image}"; then
|
||||||
docker rm -f -v "${cid}" > /dev/null 2>&1 || true
|
for cid in $(docker ps -aq --filter "ancestor=${image}" 2> /dev/null); do
|
||||||
done
|
docker rm -f -v "${cid}" > /dev/null 2>&1 || true
|
||||||
docker rmi "${image}" > /dev/null 2>&1 || true
|
done
|
||||||
echo "removed image ${image}"
|
docker rmi "${image}" > /dev/null 2>&1 || true
|
||||||
|
echo "removed image ${image}"
|
||||||
|
else
|
||||||
|
docker image rm "${image}" > /dev/null 2>&1 || true
|
||||||
|
echo "untagged shared ref ${image}"
|
||||||
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
exit 0
|
exit 0
|
||||||
Executable
+46
@@ -0,0 +1,46 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Install a pinned Trivy release into a directory, verifying its sha256.
|
||||||
|
#
|
||||||
|
# Usage: scripts/ci/install_trivy.sh <dest-dir>
|
||||||
|
#
|
||||||
|
# The binary ends up at <dest-dir>/trivy. To upgrade, bump TRIVY_VERSION and both
|
||||||
|
# digests from the release's trivy_<version>_checksums.txt.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
TRIVY_VERSION="0.76.0"
|
||||||
|
SHA256_X64="e24b1c51293c1b15e4ba8d45bbc7891ba053f90278ec2698f3562c993e145fe7"
|
||||||
|
SHA256_ARM64="aab21e677a7b7071fc79f1711480840754c18a1b9d9cefa7ccd06af63a0e8f4c"
|
||||||
|
|
||||||
|
dest="${1:?usage: install_trivy.sh <dest-dir>}"
|
||||||
|
|
||||||
|
case "$(uname -m)" in
|
||||||
|
x86_64 | amd64)
|
||||||
|
arch="64bit"
|
||||||
|
expected="${SHA256_X64}"
|
||||||
|
;;
|
||||||
|
aarch64 | arm64)
|
||||||
|
arch="ARM64"
|
||||||
|
expected="${SHA256_ARM64}"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "unsupported architecture: $(uname -m)" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
archive="trivy_${TRIVY_VERSION}_Linux-${arch}.tar.gz"
|
||||||
|
url="https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/${archive}"
|
||||||
|
|
||||||
|
work="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "${work}"' EXIT
|
||||||
|
|
||||||
|
curl --fail --silent --show-error --location --retry 3 --output "${work}/${archive}" "${url}"
|
||||||
|
echo "${expected} ${work}/${archive}" | sha256sum --check --status || {
|
||||||
|
echo "sha256 mismatch for ${archive}" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdir -p "${dest}"
|
||||||
|
tar -xzf "${work}/${archive}" -C "${dest}" trivy
|
||||||
|
chmod 0755 "${dest}/trivy"
|
||||||
|
"${dest}/trivy" version
|
||||||
Executable
+39
@@ -0,0 +1,39 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Scan a locally built image for known vulnerabilities with Trivy.
|
||||||
|
#
|
||||||
|
# Usage: scripts/ci/scan_image.sh <image-ref>
|
||||||
|
#
|
||||||
|
# Fails (exit 1) on HIGH or CRITICAL findings that have a fixed version. Findings
|
||||||
|
# without a fix are reported by Trivy but do not fail the scan (nothing can be done
|
||||||
|
# about them yet). Accepted findings are listed in security/trivyignore.txt, one
|
||||||
|
# vulnerability id per line with a reason, and in docs/security.md. The image is
|
||||||
|
# exported with `docker save` so Trivy needs no access to the Docker socket.
|
||||||
|
#
|
||||||
|
# Trivy itself is pinned (scripts/ci/install_trivy.sh); its vulnerability database
|
||||||
|
# is fetched fresh on every run, which is the point of scanning.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
image="${1:?usage: scan_image.sh <image-ref>}"
|
||||||
|
ignore_file="security/trivyignore.txt"
|
||||||
|
|
||||||
|
work="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "${work}"' EXIT
|
||||||
|
|
||||||
|
docker save --output "${work}/image.tar" "${image}"
|
||||||
|
|
||||||
|
args=(
|
||||||
|
image
|
||||||
|
--input "${work}/image.tar"
|
||||||
|
--cache-dir "${work}/cache"
|
||||||
|
--scanners vuln
|
||||||
|
--severity HIGH,CRITICAL
|
||||||
|
--ignore-unfixed
|
||||||
|
--exit-code 1
|
||||||
|
--no-progress
|
||||||
|
--db-repository "ghcr.io/aquasecurity/trivy-db:2,public.ecr.aws/aquasecurity/trivy-db:2"
|
||||||
|
)
|
||||||
|
if [ -f "${ignore_file}" ]; then
|
||||||
|
args+=(--ignorefile "${ignore_file}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
trivy "${args[@]}"
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Accepted container image vulnerabilities (Trivy ignore file).
|
||||||
|
#
|
||||||
|
# One vulnerability id per line (CVE-...). Every entry needs a comment above it with
|
||||||
|
# the reason and the date it must be reviewed, and a row in the waiver table of
|
||||||
|
# docs/security.md. Empty means nothing is ignored.
|
||||||
@@ -5,6 +5,8 @@ from __future__ import annotations
|
|||||||
import contextlib
|
import contextlib
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
import threading
|
import threading
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -33,12 +35,37 @@ class AuditLogger:
|
|||||||
"""
|
"""
|
||||||
self.settings = get_settings()
|
self.settings = get_settings()
|
||||||
self.log_path = log_path or self.settings.resolved_audit_log_path
|
self.log_path = log_path or self.settings.resolved_audit_log_path
|
||||||
self.log_path.parent.mkdir(parents=True, exist_ok=True)
|
# A new state directory is private (0700); an existing one is left alone.
|
||||||
|
self.log_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
# Held open for the process lifetime; closed in close().
|
# Held open for the process lifetime; closed in close().
|
||||||
self._log_file = open(self.log_path, "a+", encoding="utf-8") # noqa: SIM115
|
self._log_file = self._open_log()
|
||||||
self._last_hash = self._read_last_hash()
|
self._last_hash = self._read_last_hash()
|
||||||
|
|
||||||
|
def _open_log(self) -> Any:
|
||||||
|
"""Open the log for append: mode 0600 when created, and never through a symlink.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
OSError: The path cannot be opened (a symlink at the path gives ``ELOOP``).
|
||||||
|
"""
|
||||||
|
flags = os.O_WRONLY | os.O_APPEND | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0)
|
||||||
|
descriptor = os.open(self.log_path, flags, 0o600)
|
||||||
|
try:
|
||||||
|
info = os.fstat(descriptor)
|
||||||
|
if not stat.S_ISREG(info.st_mode):
|
||||||
|
raise OSError(f"{self.log_path} is not a regular file")
|
||||||
|
if (
|
||||||
|
info.st_uid == getattr(os, "geteuid", lambda: info.st_uid)()
|
||||||
|
and stat.S_IMODE(info.st_mode) & 0o077
|
||||||
|
):
|
||||||
|
# An older log created under a permissive umask: logins and paths of
|
||||||
|
# every call must not stay readable by other local users.
|
||||||
|
os.fchmod(descriptor, 0o600)
|
||||||
|
return os.fdopen(descriptor, "a", encoding="utf-8")
|
||||||
|
except BaseException:
|
||||||
|
os.close(descriptor)
|
||||||
|
raise
|
||||||
|
|
||||||
def _read_last_hash(self) -> str:
|
def _read_last_hash(self) -> str:
|
||||||
"""Read the previous hash from the last log entry."""
|
"""Read the previous hash from the last log entry."""
|
||||||
try:
|
try:
|
||||||
@@ -129,13 +156,17 @@ class AuditLogger:
|
|||||||
identity_login: str | None = None,
|
identity_login: str | None = None,
|
||||||
identity_mode: str | None = None,
|
identity_mode: str | None = None,
|
||||||
sudo: str | None = None,
|
sudo: str | None = None,
|
||||||
|
client_id: str | None = None,
|
||||||
|
session: str | None = None,
|
||||||
|
client_ip: str | None = None,
|
||||||
outcome: str | None = None,
|
outcome: str | None = None,
|
||||||
duration_ms: float | None = None,
|
duration_ms: float | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Log an MCP tool invocation.
|
"""Log an MCP tool invocation.
|
||||||
|
|
||||||
The keyword-only fields describe a server tool call (tool domain, HTTP
|
The keyword-only fields describe a server tool call (tool domain, HTTP
|
||||||
method, path template, identity, sudo target, outcome and duration). They
|
method, path template, identity, sudo target, the MCP client id, a short hash
|
||||||
|
of the grant's session and the source address, outcome and duration). They
|
||||||
are only written when given. Callers must pass argument names, never
|
are only written when given. Callers must pass argument names, never
|
||||||
argument values, in ``params``.
|
argument values, in ``params``.
|
||||||
"""
|
"""
|
||||||
@@ -154,6 +185,9 @@ class AuditLogger:
|
|||||||
"identity_login": identity_login,
|
"identity_login": identity_login,
|
||||||
"identity_mode": identity_mode,
|
"identity_mode": identity_mode,
|
||||||
"sudo": sudo,
|
"sudo": sudo,
|
||||||
|
"client_id": client_id,
|
||||||
|
"session": session,
|
||||||
|
"client_ip": client_ip,
|
||||||
"outcome": outcome,
|
"outcome": outcome,
|
||||||
"duration_ms": duration_ms,
|
"duration_ms": duration_ms,
|
||||||
}
|
}
|
||||||
@@ -162,6 +196,21 @@ class AuditLogger:
|
|||||||
payload["error"] = error
|
payload["error"] = error
|
||||||
return self._append_entry("tool_invocation", payload)
|
return self._append_entry("tool_invocation", payload)
|
||||||
|
|
||||||
|
def log_tool_call_started(
|
||||||
|
self, tool_name: str, correlation_id: str, domain: str | None = None
|
||||||
|
) -> str:
|
||||||
|
"""Write the write-ahead entry of a call that is about to run.
|
||||||
|
|
||||||
|
The dispatcher writes it before executing a known tool and refuses the call
|
||||||
|
when it cannot be written, so an audit log that stopped accepting writes
|
||||||
|
after startup (full disk, revoked mount) stops the calls instead of letting
|
||||||
|
them run unrecorded. The final ``tool_invocation`` entry carries the result.
|
||||||
|
"""
|
||||||
|
payload: dict[str, Any] = {"correlation_id": correlation_id, "tool_name": tool_name}
|
||||||
|
if domain is not None:
|
||||||
|
payload["domain"] = domain
|
||||||
|
return self._append_entry("tool_call_started", payload)
|
||||||
|
|
||||||
def log_access_denied(
|
def log_access_denied(
|
||||||
self,
|
self,
|
||||||
tool_name: str,
|
tool_name: str,
|
||||||
|
|||||||
@@ -13,7 +13,14 @@ from collections.abc import Sequence
|
|||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from aegis_gitea_mcp import __version__, setup_cmd
|
from aegis_gitea_mcp import __version__, setup_cmd
|
||||||
from aegis_gitea_mcp.config import Settings, get_settings, reset_settings
|
from aegis_gitea_mcp.config import (
|
||||||
|
ENV_FILE_VARIABLE,
|
||||||
|
EnvFileError,
|
||||||
|
Settings,
|
||||||
|
get_settings,
|
||||||
|
reset_settings,
|
||||||
|
set_env_file,
|
||||||
|
)
|
||||||
from aegis_gitea_mcp.logging_utils import configure_logging
|
from aegis_gitea_mcp.logging_utils import configure_logging
|
||||||
|
|
||||||
|
|
||||||
@@ -51,6 +58,15 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
metavar="PORT",
|
metavar="PORT",
|
||||||
help="HTTP port (default: MCP_PORT)",
|
help="HTTP port (default: MCP_PORT)",
|
||||||
)
|
)
|
||||||
|
serve.add_argument(
|
||||||
|
"--env-file",
|
||||||
|
default=None,
|
||||||
|
metavar="PATH",
|
||||||
|
help=(
|
||||||
|
f"env file to load (default: {ENV_FILE_VARIABLE}, else ./.env when it is a regular "
|
||||||
|
"file owned by you with mode 0600; real environment variables always win)"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
setup = subparsers.add_parser("setup", help="verify a Gitea instance and write the env file")
|
setup = subparsers.add_parser("setup", help="verify a Gitea instance and write the env file")
|
||||||
setup_cmd.add_arguments(setup)
|
setup_cmd.add_arguments(setup)
|
||||||
@@ -69,9 +85,13 @@ def _load_settings(args: argparse.Namespace) -> Settings | None:
|
|||||||
for name, value in overrides.items():
|
for name, value in overrides.items():
|
||||||
if value is not None:
|
if value is not None:
|
||||||
os.environ[name] = value
|
os.environ[name] = value
|
||||||
|
set_env_file(getattr(args, "env_file", None))
|
||||||
reset_settings()
|
reset_settings()
|
||||||
try:
|
try:
|
||||||
return get_settings()
|
return get_settings()
|
||||||
|
except EnvFileError as exc:
|
||||||
|
print(f"aegis-gitea-mcp: {exc}", file=sys.stderr)
|
||||||
|
return None
|
||||||
except ValidationError as exc:
|
except ValidationError as exc:
|
||||||
# Print field names and messages only: str(exc) would echo the offending values.
|
# Print field names and messages only: str(exc) would echo the offending values.
|
||||||
print("aegis-gitea-mcp: invalid configuration:", file=sys.stderr)
|
print("aegis-gitea-mcp: invalid configuration:", file=sys.stderr)
|
||||||
@@ -89,6 +109,13 @@ def run_serve(args: argparse.Namespace) -> int:
|
|||||||
if settings is None:
|
if settings is None:
|
||||||
return 2
|
return 2
|
||||||
configure_logging(settings.log_level)
|
configure_logging(settings.log_level)
|
||||||
|
if settings.insecure_gitea_url():
|
||||||
|
print(
|
||||||
|
"aegis-gitea-mcp: warning: GITEA_URL uses plain http to a host that is not loopback: "
|
||||||
|
"the Gitea token, the OAuth client secret and every user's token travel unencrypted. "
|
||||||
|
"Use https",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
if settings.mcp_transport == "http":
|
if settings.mcp_transport == "http":
|
||||||
return transports.run_http(settings)
|
return transports.run_http(settings)
|
||||||
return transports.run_stdio(settings)
|
return transports.run_stdio(settings)
|
||||||
|
|||||||
+201
-16
@@ -1,17 +1,21 @@
|
|||||||
"""Configuration management for AegisGitea MCP.
|
"""Configuration management for AegisGitea MCP.
|
||||||
|
|
||||||
All settings come from environment variables (case-insensitive) or a ``.env``
|
All settings come from environment variables (case-insensitive) or an env file.
|
||||||
file in the working directory. Secrets are ``SecretStr`` so they never appear in
|
The env file is never trusted implicitly: see :func:`select_env_file`. Secrets
|
||||||
``repr()`` or model dumps. Requirements that depend on how the server is started
|
are ``SecretStr`` so they never appear in ``repr()`` or model dumps. Requirements that depend on how the server is started
|
||||||
(for example ``GITEA_TOKEN`` for stdio) are checked at serve time, not on import.
|
(for example ``GITEA_TOKEN`` for stdio) are checked at serve time, not on import.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import ipaddress
|
import ipaddress
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import sys
|
||||||
|
from collections.abc import Mapping
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Annotated, Any, Literal
|
from typing import Annotated, Any, Literal
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit, urlunsplit
|
||||||
|
|
||||||
from pydantic import Field, SecretStr, field_validator
|
from pydantic import Field, SecretStr, field_validator
|
||||||
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
|
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
|
||||||
@@ -84,9 +88,75 @@ def _split_list(value: Any) -> Any:
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def redirect_allowlist_entry_problem(entry: str) -> str | None:
|
||||||
|
"""Return why ``entry`` cannot be an ``OAUTH_REDIRECT_ALLOWLIST`` item, or None when it can.
|
||||||
|
|
||||||
|
An item is an exact redirect URI (``https://client.example/cb``) or a prefix
|
||||||
|
that ends in ``/`` (``https://claude.ai/``, which allows every path on that
|
||||||
|
host). A bare origin without a path is refused as ambiguous.
|
||||||
|
"""
|
||||||
|
if any(ch.isspace() or not ch.isprintable() for ch in entry):
|
||||||
|
return "whitespace or control characters"
|
||||||
|
try:
|
||||||
|
parts = urlsplit(entry)
|
||||||
|
parts.port # noqa: B018 (accessing .port validates it)
|
||||||
|
except ValueError:
|
||||||
|
return "not a valid URL"
|
||||||
|
host = parts.hostname or ""
|
||||||
|
if not host:
|
||||||
|
return "no host"
|
||||||
|
if parts.username is not None or parts.password is not None:
|
||||||
|
return "credentials in the URL"
|
||||||
|
if parts.query or parts.fragment or "?" in entry or "#" in entry:
|
||||||
|
return "query string or fragment"
|
||||||
|
if parts.scheme == "http" and not is_loopback_host(host):
|
||||||
|
return "http is only allowed for loopback hosts"
|
||||||
|
if parts.scheme not in {"http", "https"}:
|
||||||
|
return "scheme must be https (or http for loopback)"
|
||||||
|
if not parts.path:
|
||||||
|
return f"no path: write {entry}/ to allow the whole host or give the full callback URL"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def redirect_matches_allowlist(uri: str, allowlist: list[str]) -> bool:
|
||||||
|
"""True when ``uri`` equals an allowlist item or lies under one of its ``/`` prefixes.
|
||||||
|
|
||||||
|
An empty allowlist allows every acceptable redirect URI. The comparison of a
|
||||||
|
prefix is by scheme, host (case-insensitive) and path prefix, so
|
||||||
|
``https://claude.ai/`` never matches ``https://claude.ai.evil.example/``.
|
||||||
|
"""
|
||||||
|
if not allowlist:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
target = urlsplit(uri)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
for entry in allowlist:
|
||||||
|
if uri == entry:
|
||||||
|
return True
|
||||||
|
if not entry.endswith("/"):
|
||||||
|
continue
|
||||||
|
prefix = urlsplit(entry)
|
||||||
|
if (
|
||||||
|
target.scheme == prefix.scheme
|
||||||
|
and (target.netloc.lower() == prefix.netloc.lower())
|
||||||
|
and target.path.startswith(prefix.path)
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _normalize_http_url(value: str, *, field: str) -> str:
|
def _normalize_http_url(value: str, *, field: str) -> str:
|
||||||
"""Validate an http(s) URL and return it without query, fragment or trailing slash."""
|
"""Validate an http(s) URL and return it rebuilt without query, fragment or trailing slash.
|
||||||
|
|
||||||
|
The result is assembled from the parsed parts, so the string that is validated
|
||||||
|
is the string that is used. ``urlsplit`` silently drops tabs and newlines
|
||||||
|
before parsing, so any whitespace, backslash or non-printable character
|
||||||
|
(control and bidi characters included) is rejected up front.
|
||||||
|
"""
|
||||||
candidate = value.strip()
|
candidate = value.strip()
|
||||||
|
if any(ch.isspace() or not ch.isprintable() or ch == "\\" for ch in candidate):
|
||||||
|
raise ValueError(f"{field} must not contain whitespace, backslashes or control characters")
|
||||||
parts = urlsplit(candidate)
|
parts = urlsplit(candidate)
|
||||||
if parts.scheme not in {"http", "https"}:
|
if parts.scheme not in {"http", "https"}:
|
||||||
raise ValueError(f"{field} must start with http:// or https://")
|
raise ValueError(f"{field} must start with http:// or https://")
|
||||||
@@ -100,14 +170,14 @@ def _normalize_http_url(value: str, *, field: str) -> str:
|
|||||||
parts.port # noqa: B018 (accessing .port validates it)
|
parts.port # noqa: B018 (accessing .port validates it)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise ValueError(f"{field} has an invalid port") from exc
|
raise ValueError(f"{field} has an invalid port") from exc
|
||||||
return candidate.rstrip("/")
|
return urlunsplit((parts.scheme, parts.netloc, parts.path.rstrip("/"), "", ""))
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
"""Application settings loaded from environment variables."""
|
"""Application settings loaded from environment variables."""
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env",
|
# No implicit env_file: get_settings() decides which file to load (SEC-04).
|
||||||
env_file_encoding="utf-8",
|
env_file_encoding="utf-8",
|
||||||
case_sensitive=False,
|
case_sensitive=False,
|
||||||
extra="ignore",
|
extra="ignore",
|
||||||
@@ -138,6 +208,7 @@ class Settings(BaseSettings):
|
|||||||
oauth_scopes: Annotated[list[str], NoDecode] = Field(
|
oauth_scopes: Annotated[list[str], NoDecode] = Field(
|
||||||
default_factory=lambda: DEFAULT_OAUTH_SCOPES.split(",")
|
default_factory=lambda: DEFAULT_OAUTH_SCOPES.split(",")
|
||||||
)
|
)
|
||||||
|
oauth_redirect_allowlist: Annotated[list[str], NoDecode] = Field(default_factory=list)
|
||||||
|
|
||||||
# State
|
# State
|
||||||
data_dir: Path = Field(default_factory=default_data_dir)
|
data_dir: Path = Field(default_factory=default_data_dir)
|
||||||
@@ -246,7 +317,14 @@ class Settings(BaseSettings):
|
|||||||
)
|
)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
@field_validator("allowed_hosts", "oauth_scopes", "toolsets", "disabled_tools", mode="before")
|
@field_validator(
|
||||||
|
"allowed_hosts",
|
||||||
|
"oauth_scopes",
|
||||||
|
"oauth_redirect_allowlist",
|
||||||
|
"toolsets",
|
||||||
|
"disabled_tools",
|
||||||
|
mode="before",
|
||||||
|
)
|
||||||
@classmethod
|
@classmethod
|
||||||
def _parse_list(cls, value: Any) -> Any:
|
def _parse_list(cls, value: Any) -> Any:
|
||||||
"""Accept comma or whitespace separated strings for list settings."""
|
"""Accept comma or whitespace separated strings for list settings."""
|
||||||
@@ -258,15 +336,28 @@ class Settings(BaseSettings):
|
|||||||
"""Accept only granular Gitea scopes (unknown scopes make Gitea grant full access)."""
|
"""Accept only granular Gitea scopes (unknown scopes make Gitea grant full access)."""
|
||||||
unknown = sorted({scope for scope in value if scope not in GITEA_SCOPES})
|
unknown = sorted({scope for scope in value if scope not in GITEA_SCOPES})
|
||||||
if unknown:
|
if unknown:
|
||||||
|
# The offending values are not echoed: a misplaced secret must not reach stderr.
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"OAUTH_SCOPES contains unknown scope(s): {', '.join(unknown)}. "
|
f"OAUTH_SCOPES contains {len(unknown)} unknown scope(s). Gitea grants full "
|
||||||
"Gitea grants full access for unknown scopes, so only granular "
|
"access for unknown scopes, so only granular read:/write: scopes are accepted "
|
||||||
"read:/write: scopes are accepted"
|
f"({', '.join(sorted(GITEA_SCOPES))})"
|
||||||
)
|
)
|
||||||
if not value:
|
if not value:
|
||||||
raise ValueError("OAUTH_SCOPES must not be empty")
|
raise ValueError("OAUTH_SCOPES must not be empty")
|
||||||
return list(dict.fromkeys(value))
|
return list(dict.fromkeys(value))
|
||||||
|
|
||||||
|
@field_validator("oauth_redirect_allowlist")
|
||||||
|
@classmethod
|
||||||
|
def _validate_redirect_allowlist(cls, value: list[str]) -> list[str]:
|
||||||
|
"""Accept exact redirect URIs and ``https://host/`` prefixes, nothing looser."""
|
||||||
|
checked: list[str] = []
|
||||||
|
for entry in value:
|
||||||
|
problem = redirect_allowlist_entry_problem(entry)
|
||||||
|
if problem is not None:
|
||||||
|
raise ValueError(f"OAUTH_REDIRECT_ALLOWLIST entry is not acceptable: {problem}")
|
||||||
|
checked.append(entry)
|
||||||
|
return list(dict.fromkeys(checked))
|
||||||
|
|
||||||
@field_validator("toolsets")
|
@field_validator("toolsets")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _validate_toolsets(cls, value: list[str]) -> list[str]:
|
def _validate_toolsets(cls, value: list[str]) -> list[str]:
|
||||||
@@ -275,9 +366,7 @@ class Settings(BaseSettings):
|
|||||||
unknown = sorted({item for item in lowered if item != "all" and item not in TOOL_DOMAINS})
|
unknown = sorted({item for item in lowered if item != "all" and item not in TOOL_DOMAINS})
|
||||||
if unknown:
|
if unknown:
|
||||||
allowed = ", ".join(sorted(TOOL_DOMAINS | {"all"}))
|
allowed = ", ".join(sorted(TOOL_DOMAINS | {"all"}))
|
||||||
raise ValueError(
|
raise ValueError(f"TOOLSETS contains {len(unknown)} unknown domain(s); use {allowed}")
|
||||||
f"TOOLSETS contains unknown domain(s): {', '.join(unknown)}; use {allowed}"
|
|
||||||
)
|
|
||||||
if not lowered:
|
if not lowered:
|
||||||
raise ValueError("TOOLSETS must not be empty (use 'all')")
|
raise ValueError("TOOLSETS must not be empty (use 'all')")
|
||||||
return list(dict.fromkeys(lowered))
|
return list(dict.fromkeys(lowered))
|
||||||
@@ -321,6 +410,11 @@ class Settings(BaseSettings):
|
|||||||
"""Audit log path, defaulting to ``DATA_DIR/audit.log``."""
|
"""Audit log path, defaulting to ``DATA_DIR/audit.log``."""
|
||||||
return self.audit_log_path or (self.data_dir / "audit.log")
|
return self.audit_log_path or (self.data_dir / "audit.log")
|
||||||
|
|
||||||
|
def insecure_gitea_url(self) -> bool:
|
||||||
|
"""True when ``GITEA_URL`` is plain http to a host that is not loopback."""
|
||||||
|
parts = urlsplit(self.gitea_url)
|
||||||
|
return parts.scheme == "http" and not is_loopback_host(parts.hostname or "")
|
||||||
|
|
||||||
def is_loopback_bind(self) -> bool:
|
def is_loopback_bind(self) -> bool:
|
||||||
"""Return True when ``MCP_HOST`` is a loopback address (127.0.0.0/8, ::1, localhost)."""
|
"""Return True when ``MCP_HOST`` is a loopback address (127.0.0.0/8, ::1, localhost)."""
|
||||||
return is_loopback_host(self.mcp_host)
|
return is_loopback_host(self.mcp_host)
|
||||||
@@ -347,14 +441,105 @@ class Settings(BaseSettings):
|
|||||||
return self.oauth_enabled and not self.oauth_config_errors()
|
return self.oauth_enabled and not self.oauth_config_errors()
|
||||||
|
|
||||||
|
|
||||||
|
#: Environment variable naming an env file (same as ``--env-file``).
|
||||||
|
ENV_FILE_VARIABLE = "AEGIS_ENV_FILE"
|
||||||
|
DEFAULT_ENV_FILE = ".env"
|
||||||
|
|
||||||
|
|
||||||
|
class EnvFileError(ValueError):
|
||||||
|
"""An explicitly requested env file cannot be used (the message is safe to print)."""
|
||||||
|
|
||||||
|
|
||||||
|
def env_file_problem(path: Path) -> str | None:
|
||||||
|
"""Return why the implicit env file ``path`` is not trusted, or None when it may be loaded.
|
||||||
|
|
||||||
|
Trusted means: a regular file (not a symlink) owned by the current user that
|
||||||
|
group and others cannot access (mode 0600, which ``setup`` writes). A missing
|
||||||
|
file is not a problem here.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
info = os.lstat(path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
|
except OSError as exc:
|
||||||
|
return f"it cannot be inspected ({exc.strerror or type(exc).__name__})"
|
||||||
|
if stat.S_ISLNK(info.st_mode):
|
||||||
|
return "it is a symlink"
|
||||||
|
if not stat.S_ISREG(info.st_mode):
|
||||||
|
return "it is not a regular file"
|
||||||
|
if info.st_uid != getattr(os, "geteuid", lambda: info.st_uid)():
|
||||||
|
return "it is owned by another user"
|
||||||
|
mode = stat.S_IMODE(info.st_mode)
|
||||||
|
if mode & 0o077:
|
||||||
|
return f"its permissions are {mode:04o} (group or others have access)"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def select_env_file(
|
||||||
|
explicit: str | Path | None = None,
|
||||||
|
*,
|
||||||
|
environ: Mapping[str, str] | None = None,
|
||||||
|
directory: Path | None = None,
|
||||||
|
) -> tuple[Path | None, str | None]:
|
||||||
|
"""Choose the env file to load; returns ``(path, warning)``.
|
||||||
|
|
||||||
|
Order: ``explicit`` (``--env-file``), then ``AEGIS_ENV_FILE``, then ``./.env``.
|
||||||
|
A file named explicitly is the operator's choice and is used as is. The
|
||||||
|
implicit ``./.env`` is loaded only when :func:`env_file_problem` finds nothing
|
||||||
|
wrong; otherwise it is ignored and ``warning`` says why and how to fix it. A
|
||||||
|
working directory is not trustworthy by itself: clients start stdio servers
|
||||||
|
inside the project (possibly a cloned repository) they are working on, and a
|
||||||
|
planted ``.env`` could switch off redaction or audit, or redirect the token.
|
||||||
|
Real environment variables always take precedence over the file.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
EnvFileError: An explicitly named file is missing or not a regular file.
|
||||||
|
"""
|
||||||
|
env = os.environ if environ is None else environ
|
||||||
|
named = explicit or env.get(ENV_FILE_VARIABLE, "").strip() or None
|
||||||
|
if named is not None:
|
||||||
|
path = Path(named).expanduser()
|
||||||
|
try:
|
||||||
|
regular = stat.S_ISREG(os.stat(path).st_mode)
|
||||||
|
except OSError:
|
||||||
|
regular = False
|
||||||
|
if not regular:
|
||||||
|
raise EnvFileError(f"env file {path} does not exist or is not a regular file")
|
||||||
|
return path, None
|
||||||
|
path = (directory or Path.cwd()) / DEFAULT_ENV_FILE
|
||||||
|
problem = env_file_problem(path)
|
||||||
|
if problem is not None:
|
||||||
|
return None, (
|
||||||
|
f"ignoring ./{DEFAULT_ENV_FILE}: {problem}. Run 'chmod 600 {DEFAULT_ENV_FILE}' "
|
||||||
|
f"(and make sure you own it), or pass --env-file PATH (or set {ENV_FILE_VARIABLE}) "
|
||||||
|
"to load a file explicitly"
|
||||||
|
)
|
||||||
|
return (path if path.exists() else None), None
|
||||||
|
|
||||||
|
|
||||||
_settings: Settings | None = None
|
_settings: Settings | None = None
|
||||||
|
_env_file_choice: str | Path | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def set_env_file(path: str | Path | None) -> None:
|
||||||
|
"""Choose the env file for the next :func:`get_settings` call (``--env-file``)."""
|
||||||
|
global _env_file_choice
|
||||||
|
_env_file_choice = path
|
||||||
|
reset_settings()
|
||||||
|
|
||||||
|
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
"""Get or create the global settings instance."""
|
"""Get or create the global settings instance.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
EnvFileError: An explicitly named env file cannot be used.
|
||||||
|
"""
|
||||||
global _settings
|
global _settings
|
||||||
if _settings is None:
|
if _settings is None:
|
||||||
_settings = Settings() # type: ignore[call-arg]
|
path, warning = select_env_file(_env_file_choice)
|
||||||
|
if warning:
|
||||||
|
print(f"aegis-gitea-mcp: warning: {warning}", file=sys.stderr)
|
||||||
|
_settings = Settings(_env_file=path) # type: ignore[call-arg]
|
||||||
return _settings
|
return _settings
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,14 @@ _SENSITIVE_BODY_KEYS = frozenset(
|
|||||||
)
|
)
|
||||||
_MIN_SENSITIVE_VALUE_CHARS = 4
|
_MIN_SENSITIVE_VALUE_CHARS = 4
|
||||||
|
|
||||||
|
#: Response fields whose non-empty string values are replaced with ``[REDACTED]``
|
||||||
|
#: in every result except those of credential-issuing operations (whose purpose
|
||||||
|
#: is to return a credential). Gitea fills ``authorization_header`` on every
|
||||||
|
#: webhook read, so a stored header would otherwise reach the model. Keep this
|
||||||
|
#: next to :data:`_SENSITIVE_BODY_KEYS` so a new secret-bearing field is added
|
||||||
|
#: in one place.
|
||||||
|
SENSITIVE_RESPONSE_KEYS = _SENSITIVE_BODY_KEYS
|
||||||
|
|
||||||
|
|
||||||
class ArgumentError(ValueError):
|
class ArgumentError(ValueError):
|
||||||
"""An argument cannot be turned into a safe request (message never echoes the value)."""
|
"""An argument cannot be turned into a safe request (message never echoes the value)."""
|
||||||
@@ -265,7 +273,8 @@ def validate_sudo(sudo: Any) -> str | None:
|
|||||||
"""Validate the optional ``sudo`` login (None when not given)."""
|
"""Validate the optional ``sudo`` login (None when not given)."""
|
||||||
if sudo is None:
|
if sudo is None:
|
||||||
return None
|
return None
|
||||||
if not isinstance(sudo, str) or not _SUDO_PATTERN.match(sudo):
|
# fullmatch: ``$`` would also accept a trailing newline.
|
||||||
|
if not isinstance(sudo, str) or not _SUDO_PATTERN.fullmatch(sudo):
|
||||||
raise ArgumentError("sudo must be a Gitea username (letters, digits, '-', '_', '.')")
|
raise ArgumentError("sudo must be a Gitea username (letters, digits, '-', '_', '.')")
|
||||||
return sudo
|
return sudo
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ Security properties (all covered by tests):
|
|||||||
* the identity token and ``Identity.redact``, the configured Gitea token, the MCP bearer token, the
|
* the identity token and ``Identity.redact``, the configured Gitea token, the MCP bearer token, the
|
||||||
OAuth client secret, ``extra_secrets`` and secret values submitted in a
|
OAuth client secret, ``extra_secrets`` and secret values submitted in a
|
||||||
request body are replaced with ``[REDACTED]`` in every result, always;
|
request body are replaced with ``[REDACTED]`` in every result, always;
|
||||||
|
* secret-bearing response fields (:data:`~.encoding.SENSITIVE_RESPONSE_KEYS`, for example a
|
||||||
|
webhook's stored ``authorization_header``, and ``data`` on Action secret operations) are
|
||||||
|
redacted by key name in every result except
|
||||||
|
those of :data:`CREDENTIAL_ISSUING_OPERATIONS`, whatever ``SECRET_DETECTION_MODE`` says;
|
||||||
* secret-pattern masking/blocking follows ``SECRET_DETECTION_MODE``, except for
|
* secret-pattern masking/blocking follows ``SECRET_DETECTION_MODE``, except for
|
||||||
:data:`CREDENTIAL_ISSUING_OPERATIONS` whose purpose is to return a freshly
|
:data:`CREDENTIAL_ISSUING_OPERATIONS` whose purpose is to return a freshly
|
||||||
minted credential (exact redaction still applies there);
|
minted credential (exact redaction still applies there);
|
||||||
@@ -23,6 +27,7 @@ Security properties (all covered by tests):
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
import itertools
|
||||||
import json
|
import json
|
||||||
from collections.abc import Iterable, Mapping
|
from collections.abc import Iterable, Mapping
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
@@ -38,6 +43,7 @@ from aegis_gitea_mcp.config import Settings
|
|||||||
from aegis_gitea_mcp.errors import kind_for_status, sanitize_message
|
from aegis_gitea_mcp.errors import kind_for_status, sanitize_message
|
||||||
from aegis_gitea_mcp.gitea.encoding import (
|
from aegis_gitea_mcp.gitea.encoding import (
|
||||||
RAW_METHODS,
|
RAW_METHODS,
|
||||||
|
SENSITIVE_RESPONSE_KEYS,
|
||||||
ArgumentError,
|
ArgumentError,
|
||||||
PayloadTooLargeError,
|
PayloadTooLargeError,
|
||||||
RequestBody,
|
RequestBody,
|
||||||
@@ -50,7 +56,12 @@ from aegis_gitea_mcp.gitea.encoding import (
|
|||||||
validate_sudo,
|
validate_sudo,
|
||||||
)
|
)
|
||||||
from aegis_gitea_mcp.gitea.pagination import build_pagination
|
from aegis_gitea_mcp.gitea.pagination import build_pagination
|
||||||
from aegis_gitea_mcp.gitea.redaction import normalize_secrets, redact_exact, redact_text
|
from aegis_gitea_mcp.gitea.redaction import (
|
||||||
|
normalize_secrets,
|
||||||
|
redact_exact,
|
||||||
|
redact_fields,
|
||||||
|
redact_text,
|
||||||
|
)
|
||||||
from aegis_gitea_mcp.identity import Identity
|
from aegis_gitea_mcp.identity import Identity
|
||||||
from aegis_gitea_mcp.security import sanitize_data
|
from aegis_gitea_mcp.security import sanitize_data
|
||||||
|
|
||||||
@@ -162,6 +173,8 @@ class _Call:
|
|||||||
sudo: str | None = None
|
sudo: str | None = None
|
||||||
paginated: bool = False
|
paginated: bool = False
|
||||||
credential_issuing: bool = False
|
credential_issuing: bool = False
|
||||||
|
#: Action secret operations: a ``data`` field in the response is a secret value.
|
||||||
|
action_secret: bool = False
|
||||||
header_data: str | None = None
|
header_data: str | None = None
|
||||||
call_secrets: tuple[str, ...] = ()
|
call_secrets: tuple[str, ...] = ()
|
||||||
|
|
||||||
@@ -174,6 +187,13 @@ class _Call:
|
|||||||
return next((value for key, value in self.query if key == name), None)
|
return next((value for key, value in self.query if key == name), None)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_action_secret_path(segments: list[str]) -> bool:
|
||||||
|
"""True for ``.../actions/secrets[/...]`` raw paths (an Action secrets endpoint)."""
|
||||||
|
return any(
|
||||||
|
first == "actions" and second == "secrets" for first, second in itertools.pairwise(segments)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _classify(media_type: str) -> str:
|
def _classify(media_type: str) -> str:
|
||||||
"""Return ``json``, ``text`` or ``binary`` for a response media type."""
|
"""Return ``json``, ``text`` or ``binary`` for a response media type."""
|
||||||
if not media_type or media_type == "application/json" or media_type.endswith("+json"):
|
if not media_type or media_type == "application/json" or media_type.endswith("+json"):
|
||||||
@@ -349,6 +369,7 @@ class GiteaExecutor:
|
|||||||
sudo=sudo,
|
sudo=sudo,
|
||||||
paginated=operation.paginated,
|
paginated=operation.paginated,
|
||||||
credential_issuing=credential_issuing,
|
credential_issuing=credential_issuing,
|
||||||
|
action_secret="Secret" in operation.operation_id,
|
||||||
header_data=HEADER_DATA_OPERATIONS.get(operation.operation_id),
|
header_data=HEADER_DATA_OPERATIONS.get(operation.operation_id),
|
||||||
call_secrets=tuple(
|
call_secrets=tuple(
|
||||||
sensitive_body_values(submitted, secret_data="Secret" in operation.operation_id)
|
sensitive_body_values(submitted, secret_data="Secret" in operation.operation_id)
|
||||||
@@ -406,6 +427,7 @@ class GiteaExecutor:
|
|||||||
call.body = RequestBody(json=body, has_json=True)
|
call.body = RequestBody(json=body, has_json=True)
|
||||||
segments = call.display_path.split("/")
|
segments = call.display_path.split("/")
|
||||||
call.call_secrets = tuple(sensitive_body_values(body, secret_data="secrets" in segments))
|
call.call_secrets = tuple(sensitive_body_values(body, secret_data="secrets" in segments))
|
||||||
|
call.action_secret = _is_action_secret_path(segments)
|
||||||
call.paginated = any(key in {"page", "limit"} for key, _ in call.query)
|
call.paginated = any(key in {"page", "limit"} for key, _ in call.query)
|
||||||
return await self._send(call, identity)
|
return await self._send(call, identity)
|
||||||
|
|
||||||
@@ -613,6 +635,17 @@ class GiteaExecutor:
|
|||||||
"""Redact, mask or block, and bound the payload size."""
|
"""Redact, mask or block, and bound the payload size."""
|
||||||
secrets = self._secrets(identity, call)
|
secrets = self._secrets(identity, call)
|
||||||
payload = redact_exact(payload, secrets)
|
payload = redact_exact(payload, secrets)
|
||||||
|
if not exempt and "data" in payload:
|
||||||
|
# Stored secrets (webhook authorization headers, ...) by field name: pattern
|
||||||
|
# masking cannot recognise them and SECRET_DETECTION_MODE may be off. Gitea
|
||||||
|
# never returns an Action secret's value; should a response carry a ``data``
|
||||||
|
# field on a secret operation, it is the value and is redacted too.
|
||||||
|
keys = (
|
||||||
|
SENSITIVE_RESPONSE_KEYS | {"data"}
|
||||||
|
if call.action_secret
|
||||||
|
else SENSITIVE_RESPONSE_KEYS
|
||||||
|
)
|
||||||
|
payload = {**payload, "data": redact_fields(payload["data"], keys)}
|
||||||
mode = self._settings.secret_detection_mode
|
mode = self._settings.secret_detection_mode
|
||||||
if mode != "off" and not exempt:
|
if mode != "off" and not exempt:
|
||||||
if mode == "block":
|
if mode == "block":
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ they have, in every string of a payload (values and dict keys).
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Iterable
|
from collections.abc import Collection, Iterable
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
REDACTED = "[REDACTED]"
|
REDACTED = "[REDACTED]"
|
||||||
@@ -41,3 +41,36 @@ def redact_exact(value: Any, secrets: tuple[str, ...]) -> Any:
|
|||||||
if isinstance(value, (list, tuple)):
|
if isinstance(value, (list, tuple)):
|
||||||
return [redact_exact(item, secrets) for item in value]
|
return [redact_exact(item, secrets) for item in value]
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def redact_fields(value: Any, keys: Collection[str], *, max_depth: int = 128) -> Any:
|
||||||
|
"""Replace the string values of secret-bearing fields, by key name, at any depth.
|
||||||
|
|
||||||
|
A non-empty string under a key in ``keys`` (compared lower-cased) becomes
|
||||||
|
``[REDACTED]``; an empty value stays so the presence of the field is still
|
||||||
|
visible. Containers under such a key are searched like any other value.
|
||||||
|
Nesting deeper than ``max_depth`` is returned unchanged (the executor bounds
|
||||||
|
JSON depth before this runs).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def walk(item: Any, depth: int) -> Any:
|
||||||
|
if depth > max_depth:
|
||||||
|
return item
|
||||||
|
if isinstance(item, dict):
|
||||||
|
return {
|
||||||
|
key: (
|
||||||
|
REDACTED
|
||||||
|
if isinstance(key, str) and key.lower() in keys and _is_secret_value(sub)
|
||||||
|
else walk(sub, depth + 1)
|
||||||
|
)
|
||||||
|
for key, sub in item.items()
|
||||||
|
}
|
||||||
|
if isinstance(item, (list, tuple)):
|
||||||
|
return [walk(sub, depth + 1) for sub in item]
|
||||||
|
return item
|
||||||
|
|
||||||
|
return walk(value, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_secret_value(value: Any) -> bool:
|
||||||
|
return isinstance(value, str) and bool(value)
|
||||||
@@ -16,14 +16,21 @@ import html
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
from mcp.server.transport_security import RequestBodyLimitMiddleware
|
||||||
|
from starlette.datastructures import Headers
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from starlette.responses import HTMLResponse, RedirectResponse, Response
|
from starlette.responses import HTMLResponse, RedirectResponse, Response
|
||||||
from starlette.routing import Route
|
from starlette.routing import Route, request_response
|
||||||
|
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||||
|
|
||||||
from aegis_gitea_mcp.oauth.provider import CONSENT_PATH, GiteaOAuthProvider
|
from aegis_gitea_mcp.oauth.provider import CONSENT_PATH, GiteaOAuthProvider, flow_tag
|
||||||
from aegis_gitea_mcp.oauth.upstream import CALLBACK_PATH
|
from aegis_gitea_mcp.oauth.upstream import CALLBACK_PATH
|
||||||
|
|
||||||
COOKIE_TTL_SECONDS = 600
|
COOKIE_TTL_SECONDS = 600
|
||||||
|
#: Largest request body the consent form may send (three short fields).
|
||||||
|
MAX_CONSENT_BODY_BYTES = 16 * 1024
|
||||||
|
_MAX_FORM_FIELDS = 8
|
||||||
|
_MAX_FORM_PART_BYTES = 4096
|
||||||
_INVALID_MESSAGE = (
|
_INVALID_MESSAGE = (
|
||||||
"This sign-in request is invalid or has expired. Start the sign-in again from your MCP client."
|
"This sign-in request is invalid or has expired. Start the sign-in again from your MCP client."
|
||||||
)
|
)
|
||||||
@@ -57,7 +64,11 @@ class _Cookies:
|
|||||||
self.secure = urlsplit(base_url).scheme == "https"
|
self.secure = urlsplit(base_url).scheme == "https"
|
||||||
prefix = "__Host-" if self.secure else ""
|
prefix = "__Host-" if self.secure else ""
|
||||||
self.csrf = f"{prefix}aegis-oauth-csrf"
|
self.csrf = f"{prefix}aegis-oauth-csrf"
|
||||||
self.binding = f"{prefix}aegis-oauth-flow"
|
self._binding_prefix = f"{prefix}aegis-oauth-flow-"
|
||||||
|
|
||||||
|
def binding(self, tag: str) -> str:
|
||||||
|
"""Name of the binding cookie of the flow with the given tag (one per Gitea state)."""
|
||||||
|
return f"{self._binding_prefix}{tag}"
|
||||||
|
|
||||||
def set(self, response: Response, name: str, value: str, *, same_site: str) -> None:
|
def set(self, response: Response, name: str, value: str, *, same_site: str) -> None:
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
@@ -96,10 +107,52 @@ def _redirect(location: str, status_code: int) -> RedirectResponse:
|
|||||||
return RedirectResponse(location, status_code=status_code, headers=_SECURITY_HEADERS)
|
return RedirectResponse(location, status_code=status_code, headers=_SECURITY_HEADERS)
|
||||||
|
|
||||||
|
|
||||||
|
def _request_origin(request: Request) -> str | None:
|
||||||
|
"""Origin of a form POST: the ``Origin`` header, else the origin of ``Referer``.
|
||||||
|
|
||||||
|
Browsers send ``Origin`` on form POSTs. Without it (or with ``Origin: null``) the
|
||||||
|
``Referer`` is the fallback; when neither names an origin the result is None
|
||||||
|
and the request is refused.
|
||||||
|
"""
|
||||||
|
origin = request.headers.get("origin")
|
||||||
|
if origin is not None:
|
||||||
|
return origin
|
||||||
|
referer = request.headers.get("referer")
|
||||||
|
if not referer:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parts = urlsplit(referer)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return f"{parts.scheme}://{parts.netloc}" if parts.scheme and parts.netloc else None
|
||||||
|
|
||||||
|
|
||||||
def _text(value: Any, limit: int = 200) -> str:
|
def _text(value: Any, limit: int = 200) -> str:
|
||||||
return value[:limit] if isinstance(value, str) else ""
|
return value[:limit] if isinstance(value, str) else ""
|
||||||
|
|
||||||
|
|
||||||
|
class _UrlencodedFormOnly:
|
||||||
|
"""ASGI guard for the consent POST, applied before any body is read.
|
||||||
|
|
||||||
|
The route is reachable without authentication, so it must not parse
|
||||||
|
arbitrary bodies: anything but ``application/x-www-form-urlencoded`` gets
|
||||||
|
415 without reading a byte, and the body is capped at
|
||||||
|
:data:`MAX_CONSENT_BODY_BYTES` (413 beyond it).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, app: ASGIApp) -> None:
|
||||||
|
self._limited = RequestBodyLimitMiddleware(app, MAX_CONSENT_BODY_BYTES)
|
||||||
|
|
||||||
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||||
|
if scope["type"] == "http":
|
||||||
|
content_type = Headers(scope=scope).get("content-type", "")
|
||||||
|
if content_type.split(";")[0].strip().lower() != "application/x-www-form-urlencoded":
|
||||||
|
response = Response("Unsupported media type", status_code=415)
|
||||||
|
await response(scope, receive, send)
|
||||||
|
return
|
||||||
|
await self._limited(scope, receive, send)
|
||||||
|
|
||||||
|
|
||||||
def build_browser_routes(provider: GiteaOAuthProvider) -> list[Route]:
|
def build_browser_routes(provider: GiteaOAuthProvider) -> list[Route]:
|
||||||
"""Return the consent and callback routes for ``provider``."""
|
"""Return the consent and callback routes for ``provider``."""
|
||||||
cookies = _Cookies(provider.base_url)
|
cookies = _Cookies(provider.base_url)
|
||||||
@@ -135,10 +188,14 @@ def build_browser_routes(provider: GiteaOAuthProvider) -> list[Route]:
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
async def consent_submit(request: Request) -> Response:
|
async def consent_submit(request: Request) -> Response:
|
||||||
origin = request.headers.get("origin")
|
if _request_origin(request) != expected_origin:
|
||||||
if origin is not None and origin != expected_origin:
|
return _invalid()
|
||||||
|
try:
|
||||||
|
form = await request.form(
|
||||||
|
max_files=0, max_fields=_MAX_FORM_FIELDS, max_part_size=_MAX_FORM_PART_BYTES
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
return _invalid()
|
return _invalid()
|
||||||
form = await request.form()
|
|
||||||
decision = provider.finish_consent(
|
decision = provider.finish_consent(
|
||||||
_text(form.get("request")),
|
_text(form.get("request")),
|
||||||
_text(form.get("csrf")),
|
_text(form.get("csrf")),
|
||||||
@@ -149,25 +206,38 @@ def build_browser_routes(provider: GiteaOAuthProvider) -> list[Route]:
|
|||||||
return _invalid()
|
return _invalid()
|
||||||
response = _redirect(decision.redirect, 303)
|
response = _redirect(decision.redirect, 303)
|
||||||
cookies.clear(response, cookies.csrf, same_site="strict")
|
cookies.clear(response, cookies.csrf, same_site="strict")
|
||||||
if decision.binding_token is not None:
|
if decision.binding_token is not None and decision.flow_tag is not None:
|
||||||
# Lax: the cookie must come back on Gitea's top-level redirect to us.
|
# Lax: the cookie must come back on Gitea's top-level redirect to us.
|
||||||
cookies.set(response, cookies.binding, decision.binding_token, same_site="lax")
|
cookies.set(
|
||||||
|
response,
|
||||||
|
cookies.binding(decision.flow_tag),
|
||||||
|
decision.binding_token,
|
||||||
|
same_site="lax",
|
||||||
|
)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
async def callback(request: Request) -> Response:
|
async def callback(request: Request) -> Response:
|
||||||
params = request.query_params
|
params = request.query_params
|
||||||
|
state = _text(params.get("state"))
|
||||||
|
cookie_name = cookies.binding(flow_tag(state))
|
||||||
location = await provider.complete_callback(
|
location = await provider.complete_callback(
|
||||||
_text(params.get("state")),
|
state,
|
||||||
_text(request.cookies.get(cookies.binding)),
|
_text(request.cookies.get(cookie_name)),
|
||||||
_text(params.get("code"), 4096),
|
_text(params.get("code"), 4096),
|
||||||
_text(params.get("error")),
|
_text(params.get("error")),
|
||||||
)
|
)
|
||||||
response = _invalid() if location is None else _redirect(location, 302)
|
response = _invalid() if location is None else _redirect(location, 302)
|
||||||
cookies.clear(response, cookies.binding, same_site="lax")
|
cookies.clear(response, cookie_name, same_site="lax")
|
||||||
return response
|
return response
|
||||||
|
|
||||||
return [
|
return [
|
||||||
Route(CONSENT_PATH, consent_page, methods=["GET"]),
|
Route(CONSENT_PATH, consent_page, methods=["GET"]),
|
||||||
Route(CONSENT_PATH, consent_submit, methods=["POST"]),
|
Route(
|
||||||
Route(CALLBACK_PATH, callback, methods=["GET"]),
|
CONSENT_PATH, _UrlencodedFormOnly(request_response(consent_submit)), methods=["POST"]
|
||||||
|
),
|
||||||
|
Route(
|
||||||
|
CALLBACK_PATH,
|
||||||
|
RequestBodyLimitMiddleware(request_response(callback), MAX_CONSENT_BODY_BYTES),
|
||||||
|
methods=["GET"],
|
||||||
|
),
|
||||||
]
|
]
|
||||||
@@ -25,7 +25,14 @@ from aegis_gitea_mcp.oauth.upstream import CALLBACK_PATH, GiteaUpstream
|
|||||||
from aegis_gitea_mcp.transports import HttpAuthExtras
|
from aegis_gitea_mcp.transports import HttpAuthExtras
|
||||||
|
|
||||||
#: Unauthenticated OAuth endpoints that get the per-IP rate limit.
|
#: Unauthenticated OAuth endpoints that get the per-IP rate limit.
|
||||||
RATE_LIMITED_PATHS = ("/register", "/authorize", CONSENT_PATH, CALLBACK_PATH)
|
RATE_LIMITED_PATHS = (
|
||||||
|
"/register",
|
||||||
|
"/authorize",
|
||||||
|
"/token",
|
||||||
|
"/revoke",
|
||||||
|
CONSENT_PATH,
|
||||||
|
CALLBACK_PATH,
|
||||||
|
)
|
||||||
PROTECTED_RESOURCE_PATH = "/.well-known/oauth-protected-resource"
|
PROTECTED_RESOURCE_PATH = "/.well-known/oauth-protected-resource"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ from pydantic import AnyHttpUrl, ValidationError
|
|||||||
|
|
||||||
from aegis_gitea_mcp.audit import AuditLogger, get_audit_logger
|
from aegis_gitea_mcp.audit import AuditLogger, get_audit_logger
|
||||||
from aegis_gitea_mcp.auth import MCP_SCOPE
|
from aegis_gitea_mcp.auth import MCP_SCOPE
|
||||||
from aegis_gitea_mcp.config import Settings
|
from aegis_gitea_mcp.config import Settings, redirect_matches_allowlist
|
||||||
from aegis_gitea_mcp.oauth.store import (
|
from aegis_gitea_mcp.oauth.store import (
|
||||||
ClientRecord,
|
ClientRecord,
|
||||||
OAuthStore,
|
OAuthStore,
|
||||||
@@ -69,6 +69,7 @@ from aegis_gitea_mcp.oauth.store import (
|
|||||||
token_hash,
|
token_hash,
|
||||||
)
|
)
|
||||||
from aegis_gitea_mcp.oauth.upstream import GiteaUpstream, UpstreamError
|
from aegis_gitea_mcp.oauth.upstream import GiteaUpstream, UpstreamError
|
||||||
|
from aegis_gitea_mcp.request_context import get_client_address
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -83,10 +84,15 @@ MAX_CLIENTS = 500
|
|||||||
MAX_CLIENT_METADATA_BYTES = 8192
|
MAX_CLIENT_METADATA_BYTES = 8192
|
||||||
MAX_REDIRECT_URIS = 10
|
MAX_REDIRECT_URIS = 10
|
||||||
MAX_SESSIONS_PER_LOGIN = 50
|
MAX_SESSIONS_PER_LOGIN = 50
|
||||||
|
#: Per client address: sign-ins waiting for consent or Gitea, and registrations per hour.
|
||||||
|
MAX_PENDING_PER_SOURCE = 20
|
||||||
|
MAX_REGISTRATIONS_PER_SOURCE = 20
|
||||||
|
REGISTRATION_WINDOW = 3600
|
||||||
CONSENT_PATH = "/oauth/consent"
|
CONSENT_PATH = "/oauth/consent"
|
||||||
|
|
||||||
_LOOPBACK_REDIRECT_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
|
_LOOPBACK_REDIRECT_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
|
||||||
_CLIENT_AUTH_METHODS = frozenset({"none", "client_secret_post", "client_secret_basic"})
|
_CLIENT_AUTH_METHODS = frozenset({"none", "client_secret_post", "client_secret_basic"})
|
||||||
|
_NOT_ALLOWED = "redirect URI is not on this server's allowlist (OAUTH_REDIRECT_ALLOWLIST)"
|
||||||
_REDIRECT_RULE = (
|
_REDIRECT_RULE = (
|
||||||
"redirect URIs must use https://, or http:// on localhost, 127.0.0.1 or [::1], "
|
"redirect URIs must use https://, or http:// on localhost, 127.0.0.1 or [::1], "
|
||||||
"without credentials or a fragment"
|
"without credentials or a fragment"
|
||||||
@@ -96,8 +102,12 @@ V = TypeVar("V")
|
|||||||
Clock = Callable[[], float]
|
Clock = Callable[[], float]
|
||||||
|
|
||||||
|
|
||||||
def redirect_uri_problem(uri: str) -> str | None:
|
def redirect_uri_problem(uri: str, allowlist: list[str] | None = None) -> str | None:
|
||||||
"""Return why ``uri`` is not an acceptable redirect URI, or None when it is."""
|
"""Return why ``uri`` is not an acceptable redirect URI, or None when it is.
|
||||||
|
|
||||||
|
With a non-empty ``allowlist`` an https URI must also match it; http loopback
|
||||||
|
URIs (native clients on the user's own machine) stay allowed.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
parts = urlsplit(uri)
|
parts = urlsplit(uri)
|
||||||
parts.port # noqa: B018 (accessing .port validates it)
|
parts.port # noqa: B018 (accessing .port validates it)
|
||||||
@@ -108,10 +118,10 @@ def redirect_uri_problem(uri: str) -> str | None:
|
|||||||
host = (parts.hostname or "").lower()
|
host = (parts.hostname or "").lower()
|
||||||
if not host:
|
if not host:
|
||||||
return _REDIRECT_RULE
|
return _REDIRECT_RULE
|
||||||
if parts.scheme == "https":
|
|
||||||
return None
|
|
||||||
if parts.scheme == "http" and host in _LOOPBACK_REDIRECT_HOSTS:
|
if parts.scheme == "http" and host in _LOOPBACK_REDIRECT_HOSTS:
|
||||||
return None
|
return None
|
||||||
|
if parts.scheme == "https":
|
||||||
|
return None if redirect_matches_allowlist(uri, allowlist or []) else _NOT_ALLOWED
|
||||||
return _REDIRECT_RULE
|
return _REDIRECT_RULE
|
||||||
|
|
||||||
|
|
||||||
@@ -124,6 +134,16 @@ def same_resource(left: str, right: str) -> bool:
|
|||||||
return normalized[0] == normalized[1]
|
return normalized[0] == normalized[1]
|
||||||
|
|
||||||
|
|
||||||
|
def flow_tag(state: str) -> str:
|
||||||
|
"""Eight hex characters derived from a Gitea ``state`` (names the browser-binding cookie).
|
||||||
|
|
||||||
|
Concurrent sign-ins in one browser each get their own cookie, so a second flow
|
||||||
|
cannot overwrite the binding of the first. The tag is public (it is a hash
|
||||||
|
prefix of a value that is in the callback URL anyway) and grants nothing.
|
||||||
|
"""
|
||||||
|
return token_hash(state)[:8]
|
||||||
|
|
||||||
|
|
||||||
def s256(verifier: str) -> str:
|
def s256(verifier: str) -> str:
|
||||||
"""PKCE S256 challenge for ``verifier``."""
|
"""PKCE S256 challenge for ``verifier``."""
|
||||||
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
||||||
@@ -158,6 +178,11 @@ class _ExpiringMap(Generic[V]):
|
|||||||
del self._entries[next(iter(self._entries))]
|
del self._entries[next(iter(self._entries))]
|
||||||
self._entries[key] = (self._clock() + ttl, value)
|
self._entries[key] = (self._clock() + ttl, value)
|
||||||
|
|
||||||
|
def count_where(self, predicate: Callable[[V], bool]) -> int:
|
||||||
|
"""Number of live entries whose value satisfies ``predicate``."""
|
||||||
|
self._purge()
|
||||||
|
return sum(1 for _, value in self._entries.values() if predicate(value))
|
||||||
|
|
||||||
def get(self, key: str) -> V | None:
|
def get(self, key: str) -> V | None:
|
||||||
entry = self._entries.get(key)
|
entry = self._entries.get(key)
|
||||||
if entry is None:
|
if entry is None:
|
||||||
@@ -188,6 +213,8 @@ class PendingAuthorization:
|
|||||||
code_verifier: str = field(repr=False)
|
code_verifier: str = field(repr=False)
|
||||||
csrf_hash: str | None = field(default=None, repr=False)
|
csrf_hash: str | None = field(default=None, repr=False)
|
||||||
binding_hash: str | None = field(default=None, repr=False)
|
binding_hash: str | None = field(default=None, repr=False)
|
||||||
|
#: Rate-limit bucket of the client address that started the request (quota key).
|
||||||
|
source: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class StoredAuthorizationCode(AuthorizationCode):
|
class StoredAuthorizationCode(AuthorizationCode):
|
||||||
@@ -209,6 +236,8 @@ class ConsentDecision:
|
|||||||
|
|
||||||
redirect: str = field(repr=False)
|
redirect: str = field(repr=False)
|
||||||
binding_token: str | None = field(default=None, repr=False)
|
binding_token: str | None = field(default=None, repr=False)
|
||||||
|
#: Short public tag of the Gitea state, used to name this flow's binding cookie.
|
||||||
|
flow_tag: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class GiteaOAuthProvider(
|
class GiteaOAuthProvider(
|
||||||
@@ -247,6 +276,8 @@ class GiteaOAuthProvider(
|
|||||||
self._codes: _ExpiringMap[StoredAuthorizationCode] = _ExpiringMap(MAX_CODES, clock)
|
self._codes: _ExpiringMap[StoredAuthorizationCode] = _ExpiringMap(MAX_CODES, clock)
|
||||||
# Hashes of exchanged codes -> session id, to detect replays (RFC 6749 4.1.2).
|
# Hashes of exchanged codes -> session id, to detect replays (RFC 6749 4.1.2).
|
||||||
self._used_codes: _ExpiringMap[str] = _ExpiringMap(MAX_CODES, clock)
|
self._used_codes: _ExpiringMap[str] = _ExpiringMap(MAX_CODES, clock)
|
||||||
|
# Registration times per client address (in memory), for the registration quota.
|
||||||
|
self._registrations: dict[str, list[float]] = {}
|
||||||
|
|
||||||
# Helpers
|
# Helpers
|
||||||
|
|
||||||
@@ -306,7 +337,8 @@ class GiteaOAuthProvider(
|
|||||||
async def register_client(self, client_info: OAuthClientInformationFull) -> None:
|
async def register_client(self, client_info: OAuthClientInformationFull) -> None:
|
||||||
"""Validate and persist a dynamically registered client.
|
"""Validate and persist a dynamically registered client.
|
||||||
|
|
||||||
Redirect URIs must be https, or http on a loopback host. When
|
Redirect URIs must be https, or http on a loopback host, and match
|
||||||
|
``OAUTH_REDIRECT_ALLOWLIST`` when that is set. When
|
||||||
:data:`MAX_CLIENTS` is reached, the least recently used client without
|
:data:`MAX_CLIENTS` is reached, the least recently used client without
|
||||||
any session is evicted; when every client has a session, registration
|
any session is evicted; when every client has a session, registration
|
||||||
is refused.
|
is refused.
|
||||||
@@ -320,7 +352,7 @@ class GiteaOAuthProvider(
|
|||||||
"invalid_redirect_uri", f"between 1 and {MAX_REDIRECT_URIS} redirect URIs required"
|
"invalid_redirect_uri", f"between 1 and {MAX_REDIRECT_URIS} redirect URIs required"
|
||||||
)
|
)
|
||||||
for uri in uris:
|
for uri in uris:
|
||||||
problem = redirect_uri_problem(uri)
|
problem = redirect_uri_problem(uri, self._settings.oauth_redirect_allowlist)
|
||||||
if problem is not None:
|
if problem is not None:
|
||||||
raise RegistrationError("invalid_redirect_uri", problem)
|
raise RegistrationError("invalid_redirect_uri", problem)
|
||||||
if client_info.token_endpoint_auth_method not in _CLIENT_AUTH_METHODS:
|
if client_info.token_endpoint_auth_method not in _CLIENT_AUTH_METHODS:
|
||||||
@@ -330,6 +362,7 @@ class GiteaOAuthProvider(
|
|||||||
info = client_info.model_dump(mode="json", exclude_none=True)
|
info = client_info.model_dump(mode="json", exclude_none=True)
|
||||||
if len(json.dumps(info)) > MAX_CLIENT_METADATA_BYTES:
|
if len(json.dumps(info)) > MAX_CLIENT_METADATA_BYTES:
|
||||||
raise RegistrationError("invalid_client_metadata", "client metadata is too large")
|
raise RegistrationError("invalid_client_metadata", "client metadata is too large")
|
||||||
|
self._check_registration_quota()
|
||||||
if len(self._store.clients) >= MAX_CLIENTS and not self._evict_one_client():
|
if len(self._store.clients) >= MAX_CLIENTS and not self._evict_one_client():
|
||||||
logger.warning("oauth client registration refused: client limit reached")
|
logger.warning("oauth client registration refused: client limit reached")
|
||||||
raise RegistrationError(
|
raise RegistrationError(
|
||||||
@@ -342,11 +375,41 @@ class GiteaOAuthProvider(
|
|||||||
await self._store.save()
|
await self._store.save()
|
||||||
logger.info("oauth client registered: %s", client_info.client_id)
|
logger.info("oauth client registered: %s", client_info.client_id)
|
||||||
|
|
||||||
|
def _check_registration_quota(self) -> None:
|
||||||
|
"""Refuse a registration when this address registered too many clients recently."""
|
||||||
|
source = get_client_address()
|
||||||
|
if source is None:
|
||||||
|
return
|
||||||
|
now = self._clock()
|
||||||
|
recent = [t for t in self._registrations.get(source, []) if t > now - REGISTRATION_WINDOW]
|
||||||
|
if len(recent) >= MAX_REGISTRATIONS_PER_SOURCE:
|
||||||
|
self._registrations[source] = recent
|
||||||
|
logger.warning("oauth client registration refused: quota of one address reached")
|
||||||
|
raise RegistrationError(
|
||||||
|
"invalid_client_metadata", "too many registrations from this address; try later"
|
||||||
|
)
|
||||||
|
recent.append(now)
|
||||||
|
self._registrations[source] = recent
|
||||||
|
if len(self._registrations) > MAX_CLIENTS * 4:
|
||||||
|
for key in [
|
||||||
|
k
|
||||||
|
for k, times in self._registrations.items()
|
||||||
|
if times[-1] <= now - REGISTRATION_WINDOW
|
||||||
|
]:
|
||||||
|
del self._registrations[key]
|
||||||
|
|
||||||
def _evict_one_client(self) -> bool:
|
def _evict_one_client(self) -> bool:
|
||||||
|
"""Evict the least recently used client without sessions, never a young one.
|
||||||
|
|
||||||
|
A client younger than :data:`PENDING_TTL` may still be on its way to its first
|
||||||
|
sign-in; evicting it would let a registration flood cancel those sign-ins.
|
||||||
|
"""
|
||||||
|
now = self._clock()
|
||||||
candidates = [
|
candidates = [
|
||||||
(record.last_used_at, client_id)
|
(record.last_used_at, client_id)
|
||||||
for client_id, record in self._store.clients.items()
|
for client_id, record in self._store.clients.items()
|
||||||
if not self._store.client_has_sessions(client_id)
|
if not self._store.client_has_sessions(client_id)
|
||||||
|
and record.registered_at + PENDING_TTL <= now
|
||||||
]
|
]
|
||||||
if not candidates:
|
if not candidates:
|
||||||
return False
|
return False
|
||||||
@@ -368,6 +431,20 @@ class GiteaOAuthProvider(
|
|||||||
"""
|
"""
|
||||||
if params.resource is not None and not same_resource(params.resource, self.resource):
|
if params.resource is not None and not same_resource(params.resource, self.resource):
|
||||||
raise AuthorizeError("invalid_target", "unknown resource")
|
raise AuthorizeError("invalid_target", "unknown resource")
|
||||||
|
# A client registered before the allowlist was set must not slip through it.
|
||||||
|
if (
|
||||||
|
redirect_uri_problem(str(params.redirect_uri), self._settings.oauth_redirect_allowlist)
|
||||||
|
is not None
|
||||||
|
):
|
||||||
|
raise AuthorizeError("invalid_request", "redirect URI is not allowed")
|
||||||
|
source = get_client_address()
|
||||||
|
if (
|
||||||
|
source is not None
|
||||||
|
and self._requests.count_where(lambda item: item.source == source)
|
||||||
|
>= MAX_PENDING_PER_SOURCE
|
||||||
|
):
|
||||||
|
logger.warning("oauth authorize refused: pending sign-in quota of one address reached")
|
||||||
|
raise AuthorizeError("temporarily_unavailable", "too many pending sign-ins")
|
||||||
request_id = secrets.token_urlsafe(32)
|
request_id = secrets.token_urlsafe(32)
|
||||||
pending = PendingAuthorization(
|
pending = PendingAuthorization(
|
||||||
client_id=client.client_id,
|
client_id=client.client_id,
|
||||||
@@ -379,6 +456,7 @@ class GiteaOAuthProvider(
|
|||||||
scopes=list(params.scopes or [MCP_SCOPE]),
|
scopes=list(params.scopes or [MCP_SCOPE]),
|
||||||
resource=self.resource,
|
resource=self.resource,
|
||||||
code_verifier=secrets.token_urlsafe(64),
|
code_verifier=secrets.token_urlsafe(64),
|
||||||
|
source=source,
|
||||||
)
|
)
|
||||||
self._requests.put(token_hash(request_id), pending, PENDING_TTL)
|
self._requests.put(token_hash(request_id), pending, PENDING_TTL)
|
||||||
return f"{self.base_url}{CONSENT_PATH}?{urlencode({'request': request_id})}"
|
return f"{self.base_url}{CONSENT_PATH}?{urlencode({'request': request_id})}"
|
||||||
@@ -426,7 +504,7 @@ class GiteaOAuthProvider(
|
|||||||
location = self._upstream.authorize_url(
|
location = self._upstream.authorize_url(
|
||||||
state=state, code_challenge=s256(pending.code_verifier)
|
state=state, code_challenge=s256(pending.code_verifier)
|
||||||
)
|
)
|
||||||
return ConsentDecision(redirect=location, binding_token=binding)
|
return ConsentDecision(redirect=location, binding_token=binding, flow_tag=flow_tag(state))
|
||||||
|
|
||||||
async def complete_callback(
|
async def complete_callback(
|
||||||
self, state: str, binding: str, code: str, error: str
|
self, state: str, binding: str, code: str, error: str
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ What is stored:
|
|||||||
* sessions: the Gitea login and the user's Gitea access and refresh tokens;
|
* sessions: the Gitea login and the user's Gitea access and refresh tokens;
|
||||||
* our own access and refresh tokens, only as SHA-256 hashes.
|
* our own access and refresh tokens, only as SHA-256 hashes.
|
||||||
|
|
||||||
File safety: the directory is created with mode 0700 and the file is written
|
File safety: the directory is created with mode 0700 (an existing one must be owned by the
|
||||||
|
running user and is tightened to 0700, otherwise startup stops) and the file is written
|
||||||
with mode 0600 through a fresh ``O_EXCL`` temporary file and an atomic
|
with mode 0600 through a fresh ``O_EXCL`` temporary file and an atomic
|
||||||
``os.replace``. A symlink at the store path is refused. A file that cannot be
|
``os.replace``. A symlink at the store path is refused. A file that cannot be
|
||||||
decrypted or parsed stops startup with a clear message instead of being wiped,
|
decrypted or parsed stops startup with a clear message instead of being wiped,
|
||||||
@@ -40,6 +41,7 @@ from dataclasses import asdict, dataclass, field
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
import anyio.to_thread
|
||||||
from cryptography.fernet import Fernet, InvalidToken
|
from cryptography.fernet import Fernet, InvalidToken
|
||||||
from cryptography.hazmat.primitives import hashes
|
from cryptography.hazmat.primitives import hashes
|
||||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||||
@@ -131,16 +133,40 @@ class TokenRecord:
|
|||||||
|
|
||||||
|
|
||||||
def _restrict_directory(directory: Path) -> None:
|
def _restrict_directory(directory: Path) -> None:
|
||||||
"""Create the state directory with mode 0700, or tighten an existing one we own."""
|
"""Create the state directory with mode 0700, or check and tighten an existing one.
|
||||||
|
|
||||||
|
Whoever controls the directory can swap the store for an older copy (which
|
||||||
|
resurrects revoked grants) or delete it, so an existing directory must belong
|
||||||
|
to the effective user and must not stay writable by group or others.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
OAuthStoreError: The directory is owned by another user or remains writable
|
||||||
|
by group or others after an attempt to tighten it.
|
||||||
|
"""
|
||||||
if not directory.exists():
|
if not directory.exists():
|
||||||
directory.mkdir(mode=0o700, parents=True, exist_ok=True)
|
directory.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
return
|
return
|
||||||
mode = stat.S_IMODE(directory.stat().st_mode)
|
info = os.stat(directory)
|
||||||
|
if not stat.S_ISDIR(info.st_mode):
|
||||||
|
raise OAuthStoreError(f"{directory} is not a directory")
|
||||||
|
owner = getattr(os, "geteuid", lambda: info.st_uid)()
|
||||||
|
if info.st_uid != owner:
|
||||||
|
raise OAuthStoreError(
|
||||||
|
f"{directory} is owned by another user (uid {info.st_uid}, this process runs as "
|
||||||
|
f"uid {owner}); refusing to keep the OAuth token store there. Use a DATA_DIR "
|
||||||
|
"owned by the service user"
|
||||||
|
)
|
||||||
|
mode = stat.S_IMODE(info.st_mode)
|
||||||
if mode & 0o077:
|
if mode & 0o077:
|
||||||
try:
|
try:
|
||||||
directory.chmod(0o700)
|
directory.chmod(0o700)
|
||||||
except OSError:
|
except OSError:
|
||||||
logger.warning("could not restrict the permissions of the state directory to 0700")
|
logger.warning("could not restrict the permissions of the state directory to 0700")
|
||||||
|
if stat.S_IMODE(os.stat(directory).st_mode) & 0o022:
|
||||||
|
raise OAuthStoreError(
|
||||||
|
f"{directory} is writable by group or others and could not be restricted; "
|
||||||
|
"refusing to keep the OAuth token store there. Run 'chmod 700' on it"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _parse_document(document: Any) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
|
def _parse_document(document: Any) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
|
||||||
@@ -190,10 +216,12 @@ class OAuthStore:
|
|||||||
"""Open the store, reading and decrypting the file when it exists.
|
"""Open the store, reading and decrypting the file when it exists.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
OAuthStoreError: The path is a symlink or not a regular file, or the
|
OAuthStoreError: The directory belongs to another user or stays group or
|
||||||
|
world writable, the path is a symlink or not a regular file, or the
|
||||||
file cannot be decrypted or parsed.
|
file cannot be decrypted or parsed.
|
||||||
"""
|
"""
|
||||||
store = cls(directory, secret, clock)
|
store = cls(directory, secret, clock)
|
||||||
|
_restrict_directory(directory)
|
||||||
store._read()
|
store._read()
|
||||||
store.prune()
|
store.prune()
|
||||||
return store
|
return store
|
||||||
@@ -272,7 +300,10 @@ class OAuthStore:
|
|||||||
"""Prune expired entries and persist the state atomically (encrypted, mode 0600)."""
|
"""Prune expired entries and persist the state atomically (encrypted, mode 0600)."""
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
self.prune()
|
self.prune()
|
||||||
self._write(self._fernet.encrypt(self._document()))
|
data = self._fernet.encrypt(self._document())
|
||||||
|
# The write fsyncs: keep that off the event loop (the bytes are already built,
|
||||||
|
# so the thread touches no shared state).
|
||||||
|
await anyio.to_thread.run_sync(self._write, data)
|
||||||
|
|
||||||
# Maintenance
|
# Maintenance
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ keyed by a hash of the Authorization header and is only a second line.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import ipaddress
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from collections import OrderedDict, deque
|
from collections import OrderedDict, deque
|
||||||
@@ -26,6 +27,24 @@ MAX_IP_KEYS = 100_000
|
|||||||
MAX_TOKEN_KEYS = 10_000
|
MAX_TOKEN_KEYS = 10_000
|
||||||
|
|
||||||
|
|
||||||
|
def client_bucket(client_ip: str) -> str:
|
||||||
|
"""Rate-limit key of a client address: IPv6 clients share one bucket per /64.
|
||||||
|
|
||||||
|
A single IPv6 subscriber holds a whole /64, so keying by the full address would
|
||||||
|
give it an unlimited supply of buckets. IPv4-mapped IPv6 addresses count as
|
||||||
|
their IPv4 address; anything that is not an IP address is used as is.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
address = ipaddress.ip_address(client_ip.split("%")[0])
|
||||||
|
except ValueError:
|
||||||
|
return client_ip
|
||||||
|
if isinstance(address, ipaddress.IPv6Address):
|
||||||
|
if address.ipv4_mapped is not None:
|
||||||
|
return str(address.ipv4_mapped)
|
||||||
|
return str(ipaddress.IPv6Network((address, 64), strict=False))
|
||||||
|
return str(address)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class RateLimitDecision:
|
class RateLimitDecision:
|
||||||
"""Result of request rate-limit checks."""
|
"""Result of request rate-limit checks."""
|
||||||
@@ -139,8 +158,9 @@ class RequestRateLimiter:
|
|||||||
Returns:
|
Returns:
|
||||||
Rate limit decision.
|
Rate limit decision.
|
||||||
"""
|
"""
|
||||||
if not self._ip_limiter.allow(client_ip):
|
bucket = client_bucket(client_ip)
|
||||||
if self._ip_limiter.first_excess(client_ip):
|
if not self._ip_limiter.allow(bucket):
|
||||||
|
if self._ip_limiter.first_excess(bucket):
|
||||||
self._audit_excess(
|
self._audit_excess(
|
||||||
"rate_limit_ip_exceeded",
|
"rate_limit_ip_exceeded",
|
||||||
"Per-IP request rate limit exceeded",
|
"Per-IP request rate limit exceeded",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from contextvars import ContextVar
|
|||||||
_REQUEST_ID: ContextVar[str] = ContextVar("request_id", default="-")
|
_REQUEST_ID: ContextVar[str] = ContextVar("request_id", default="-")
|
||||||
_GITEA_USER_TOKEN: ContextVar[str | None] = ContextVar("gitea_user_token", default=None)
|
_GITEA_USER_TOKEN: ContextVar[str | None] = ContextVar("gitea_user_token", default=None)
|
||||||
_GITEA_USER_LOGIN: ContextVar[str | None] = ContextVar("gitea_user_login", default=None)
|
_GITEA_USER_LOGIN: ContextVar[str | None] = ContextVar("gitea_user_login", default=None)
|
||||||
|
_CLIENT_ADDRESS: ContextVar[str | None] = ContextVar("client_address", default=None)
|
||||||
_GITEA_USER_SCOPES: ContextVar[tuple[str, ...]] = ContextVar("gitea_user_scopes", default=())
|
_GITEA_USER_SCOPES: ContextVar[tuple[str, ...]] = ContextVar("gitea_user_scopes", default=())
|
||||||
|
|
||||||
|
|
||||||
@@ -20,6 +21,16 @@ def get_request_id() -> str:
|
|||||||
return _REQUEST_ID.get()
|
return _REQUEST_ID.get()
|
||||||
|
|
||||||
|
|
||||||
|
def set_client_address(address: str | None) -> None:
|
||||||
|
"""Store the rate-limit bucket of the client address (set by the HTTP middleware)."""
|
||||||
|
_CLIENT_ADDRESS.set(address)
|
||||||
|
|
||||||
|
|
||||||
|
def get_client_address() -> str | None:
|
||||||
|
"""Rate-limit bucket of the current request's client address (None outside HTTP)."""
|
||||||
|
return _CLIENT_ADDRESS.get()
|
||||||
|
|
||||||
|
|
||||||
def set_gitea_user_token(token: str) -> None:
|
def set_gitea_user_token(token: str) -> None:
|
||||||
"""Store the per-request Gitea OAuth user token in context-local state."""
|
"""Store the per-request Gitea OAuth user token in context-local state."""
|
||||||
_GITEA_USER_TOKEN.set(token)
|
_GITEA_USER_TOKEN.set(token)
|
||||||
|
|||||||
@@ -90,7 +90,11 @@ def sanitize_data(value: Any, mode: str = "mask") -> Any:
|
|||||||
Sanitized payload value.
|
Sanitized payload value.
|
||||||
"""
|
"""
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
return {str(key): sanitize_data(item, mode=mode) for key, item in value.items()}
|
# Keys are data too: a secret can sit in an object key (``{"<token>": 1}``).
|
||||||
|
return {
|
||||||
|
sanitize_data(str(key), mode=mode): sanitize_data(item, mode=mode)
|
||||||
|
for key, item in value.items()
|
||||||
|
}
|
||||||
|
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
return [sanitize_data(item, mode=mode) for item in value]
|
return [sanitize_data(item, mode=mode) for item in value]
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ values are never logged or echoed in validation errors.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
@@ -78,7 +80,7 @@ _MAX_ALLOWED_NAMES = 40
|
|||||||
_MAX_NAME_CHARS = 64
|
_MAX_NAME_CHARS = 64
|
||||||
_MAX_AUDIT_ARGUMENT_NAMES = 64
|
_MAX_AUDIT_ARGUMENT_NAMES = 64
|
||||||
_MAX_AUDIT_PATH_CHARS = 200
|
_MAX_AUDIT_PATH_CHARS = 200
|
||||||
_MAX_UNKNOWN_NAME_CHARS = 128
|
_MAX_CLIENT_ID_CHARS = 128
|
||||||
#: Slack added to the upload-derived HTTP body cap for JSON framing and other arguments.
|
#: Slack added to the upload-derived HTTP body cap for JSON framing and other arguments.
|
||||||
BODY_OVERHEAD_BYTES = 1024 * 1024
|
BODY_OVERHEAD_BYTES = 1024 * 1024
|
||||||
|
|
||||||
@@ -357,6 +359,34 @@ class _Record:
|
|||||||
is_error: bool = True
|
is_error: bool = True
|
||||||
http_status: int | None = None
|
http_status: int | None = None
|
||||||
argument_names: list[str] = field(default_factory=list)
|
argument_names: list[str] = field(default_factory=list)
|
||||||
|
rejected_arguments: int = 0
|
||||||
|
client_id: str | None = None
|
||||||
|
session: str | None = None
|
||||||
|
client_ip: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _source_ip(ctx: ServerRequestContext[dict[str, Any]]) -> str | None:
|
||||||
|
"""Client address of an HTTP request (None on stdio); only a valid IP address is recorded."""
|
||||||
|
client = getattr(getattr(ctx, "request", None), "client", None)
|
||||||
|
host = getattr(client, "host", None)
|
||||||
|
if not isinstance(host, str):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return str(ipaddress.ip_address(host.split("%")[0]))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _caller_fields(record: _Record, ctx: ServerRequestContext[dict[str, Any]]) -> None:
|
||||||
|
"""Fill the client, session and source address of the call from the verified token."""
|
||||||
|
access = get_access_token()
|
||||||
|
if access is not None:
|
||||||
|
record.client_id = access.client_id[:_MAX_CLIENT_ID_CHARS]
|
||||||
|
session_id = (access.claims or {}).get("session_id")
|
||||||
|
if isinstance(session_id, str) and session_id:
|
||||||
|
# A short hash: enough to correlate entries of one grant, useless as a credential.
|
||||||
|
record.session = hashlib.sha256(session_id.encode()).hexdigest()[:12]
|
||||||
|
record.client_ip = _source_ip(ctx)
|
||||||
|
|
||||||
|
|
||||||
def _error_payload(kind: str, message: str, **extra: str) -> dict[str, Any]:
|
def _error_payload(kind: str, message: str, **extra: str) -> dict[str, Any]:
|
||||||
@@ -409,8 +439,12 @@ class _Dispatcher:
|
|||||||
request_id = uuid.uuid4().hex
|
request_id = uuid.uuid4().hex
|
||||||
set_request_id(request_id)
|
set_request_id(request_id)
|
||||||
arguments = params.arguments or {}
|
arguments = params.arguments or {}
|
||||||
record = _Record(tool=params.name[:_MAX_UNKNOWN_NAME_CHARS], request_id=request_id)
|
# The tool name and the argument names come from the caller: only names the
|
||||||
record.argument_names = sorted(_clip(key) for key in arguments)[:_MAX_AUDIT_ARGUMENT_NAMES]
|
# registry knows are recorded, as the audit entry must never hold a secret a
|
||||||
|
# model pasted into a name.
|
||||||
|
record = _Record(tool=f"(unknown tool, {len(params.name)} chars)", request_id=request_id)
|
||||||
|
record.rejected_arguments = len(arguments)
|
||||||
|
_caller_fields(record, ctx)
|
||||||
# Fail closed: the audit logger is obtained BEFORE anything runs, so a call is
|
# Fail closed: the audit logger is obtained BEFORE anything runs, so a call is
|
||||||
# never executed (and its side effects never happen) without a way to record it.
|
# never executed (and its side effects never happen) without a way to record it.
|
||||||
try:
|
try:
|
||||||
@@ -428,8 +462,27 @@ class _Dispatcher:
|
|||||||
entry = self._registry.get(params.name)
|
entry = self._registry.get(params.name)
|
||||||
if entry is None:
|
if entry is None:
|
||||||
record.outcome = "unknown_tool"
|
record.outcome = "unknown_tool"
|
||||||
raise MCPError(types.INVALID_PARAMS, f"Unknown tool: {record.tool}")
|
raise MCPError(types.INVALID_PARAMS, "Unknown tool")
|
||||||
|
record.tool = entry.tool.name
|
||||||
record.domain = entry.domain
|
record.domain = entry.domain
|
||||||
|
known = set(entry.tool.input_schema.get("properties", {}))
|
||||||
|
record.argument_names = sorted(key for key in arguments if key in known)[
|
||||||
|
:_MAX_AUDIT_ARGUMENT_NAMES
|
||||||
|
]
|
||||||
|
record.rejected_arguments = len(arguments) - len(record.argument_names)
|
||||||
|
# Write-ahead entry: a call whose start cannot be recorded does not run.
|
||||||
|
try:
|
||||||
|
audit.log_tool_call_started(entry.tool.name, request_id, entry.domain)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("audit log write failed, refusing the call: %s", type(exc).__name__)
|
||||||
|
record.outcome = "audit_unavailable"
|
||||||
|
return _result(
|
||||||
|
_error_payload(
|
||||||
|
"audit_unavailable",
|
||||||
|
"The audit log is not writable, so the call was not executed",
|
||||||
|
),
|
||||||
|
is_error=True,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
return await self._dispatch(entry, arguments, record)
|
return await self._dispatch(entry, arguments, record)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -516,6 +569,8 @@ class _Dispatcher:
|
|||||||
execution, in :meth:`call_tool`.
|
execution, in :meth:`call_tool`.
|
||||||
"""
|
"""
|
||||||
params: dict[str, Any] = {"argument_names": record.argument_names}
|
params: dict[str, Any] = {"argument_names": record.argument_names}
|
||||||
|
if record.rejected_arguments:
|
||||||
|
params["rejected_argument_count"] = record.rejected_arguments
|
||||||
if record.http_status is not None:
|
if record.http_status is not None:
|
||||||
params["http_status"] = record.http_status
|
params["http_status"] = record.http_status
|
||||||
try:
|
try:
|
||||||
@@ -530,6 +585,9 @@ class _Dispatcher:
|
|||||||
identity_login=record.identity.login if record.identity else None,
|
identity_login=record.identity.login if record.identity else None,
|
||||||
identity_mode=record.identity.mode if record.identity else None,
|
identity_mode=record.identity.mode if record.identity else None,
|
||||||
sudo=record.sudo,
|
sudo=record.sudo,
|
||||||
|
client_id=record.client_id,
|
||||||
|
session=record.session,
|
||||||
|
client_ip=record.client_ip,
|
||||||
outcome=record.outcome,
|
outcome=record.outcome,
|
||||||
duration_ms=round(duration_ms, 2),
|
duration_ms=round(duration_ms, 2),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -744,7 +744,10 @@ def add_arguments(parser: argparse.ArgumentParser) -> None:
|
|||||||
help="public https URL of this MCP server (enables the OAuth app)",
|
help="public https URL of this MCP server (enables the OAuth app)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--env-file", default=".env", help="env file to create or update (default: ./.env)"
|
"--env-file",
|
||||||
|
default=None,
|
||||||
|
metavar="PATH",
|
||||||
|
help="env file to create or update (default: AEGIS_ENV_FILE, else ./.env)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--template",
|
"--template",
|
||||||
@@ -798,7 +801,7 @@ def _generated(existing: dict[str, EnvEntry], key: str, values: dict[str, str])
|
|||||||
|
|
||||||
|
|
||||||
def _run(args: argparse.Namespace) -> int:
|
def _run(args: argparse.Namespace) -> int:
|
||||||
env_file = Path(args.env_file)
|
env_file = Path(args.env_file or os.environ.get("AEGIS_ENV_FILE", "").strip() or ".env")
|
||||||
check_env_target(env_file)
|
check_env_target(env_file)
|
||||||
template = _load_template(args.template)
|
template = _load_template(args.template)
|
||||||
existing, old_mode = _read_existing(env_file)
|
existing, old_mode = _read_existing(env_file)
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ GITEA_OAUTH_CLIENT_SECRET=
|
|||||||
# Encrypts stored user tokens. At least 32 characters. Generate with:
|
# Encrypts stored user tokens. At least 32 characters. Generate with:
|
||||||
# openssl rand -hex 32
|
# openssl rand -hex 32
|
||||||
TOKEN_ENCRYPTION_KEY=
|
TOKEN_ENCRYPTION_KEY=
|
||||||
|
# Optional allowlist of redirect URIs clients may register (comma separated): exact
|
||||||
|
# URIs, or https://host/ prefixes. Empty accepts any https URI (and http on
|
||||||
|
# loopback); the consent page is then the only check. For a public deployment list the
|
||||||
|
# hosted clients you use, e.g. https://claude.ai/,https://chat.mistral.ai/
|
||||||
|
OAUTH_REDIRECT_ALLOWLIST=
|
||||||
# Granular Gitea scopes only (read:/write: activitypub, admin, issue, misc,
|
# Granular Gitea scopes only (read:/write: activitypub, admin, issue, misc,
|
||||||
# notification, organization, package, repository, user). Unknown values are rejected.
|
# notification, organization, package, repository, user). Unknown values are rejected.
|
||||||
OAUTH_SCOPES=write:activitypub,write:admin,write:issue,write:misc,write:notification,write:organization,write:package,write:repository,write:user
|
OAUTH_SCOPES=write:activitypub,write:admin,write:issue,write:misc,write:notification,write:organization,write:package,write:repository,write:user
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ actionable message on stderr (never on stdout, which carries JSON-RPC on stdio).
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import errno
|
||||||
|
import ipaddress
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
@@ -29,6 +31,7 @@ from starlette.requests import Request
|
|||||||
from starlette.responses import JSONResponse, Response
|
from starlette.responses import JSONResponse, Response
|
||||||
from starlette.routing import Route
|
from starlette.routing import Route
|
||||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||||
|
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
|
||||||
|
|
||||||
from aegis_gitea_mcp import __version__
|
from aegis_gitea_mcp import __version__
|
||||||
from aegis_gitea_mcp.audit import get_audit_logger
|
from aegis_gitea_mcp.audit import get_audit_logger
|
||||||
@@ -41,7 +44,8 @@ from aegis_gitea_mcp.auth import (
|
|||||||
from aegis_gitea_mcp.config import Settings, is_loopback_host
|
from aegis_gitea_mcp.config import Settings, is_loopback_host
|
||||||
from aegis_gitea_mcp.gitea import GiteaExecutor
|
from aegis_gitea_mcp.gitea import GiteaExecutor
|
||||||
from aegis_gitea_mcp.oauth import OAuthSetupError
|
from aegis_gitea_mcp.oauth import OAuthSetupError
|
||||||
from aegis_gitea_mcp.rate_limit import RequestRateLimiter
|
from aegis_gitea_mcp.rate_limit import RequestRateLimiter, client_bucket
|
||||||
|
from aegis_gitea_mcp.request_context import set_client_address
|
||||||
from aegis_gitea_mcp.server import (
|
from aegis_gitea_mcp.server import (
|
||||||
SERVER_NAME,
|
SERVER_NAME,
|
||||||
AegisServer,
|
AegisServer,
|
||||||
@@ -178,6 +182,8 @@ class RateLimitMiddleware:
|
|||||||
break
|
break
|
||||||
decision = self._limiter.check(client_ip, authorization)
|
decision = self._limiter.check(client_ip, authorization)
|
||||||
if decision.allowed:
|
if decision.allowed:
|
||||||
|
# Lets the OAuth provider quota pending sign-ins and registrations per address.
|
||||||
|
set_client_address(client_bucket(client_ip))
|
||||||
await self.app(scope, receive, send)
|
await self.app(scope, receive, send)
|
||||||
return
|
return
|
||||||
response = JSONResponse(
|
response = JSONResponse(
|
||||||
@@ -188,6 +194,62 @@ class RateLimitMiddleware:
|
|||||||
await response(scope, receive, send)
|
await response(scope, receive, send)
|
||||||
|
|
||||||
|
|
||||||
|
# Forwarded headers
|
||||||
|
|
||||||
|
|
||||||
|
class ForwardedHeadersMiddleware:
|
||||||
|
"""Applies ``X-Forwarded-For`` and ``-Proto`` from trusted peers and warns about the rest.
|
||||||
|
|
||||||
|
Replaces uvicorn's own proxy-header handling (the runner passes
|
||||||
|
``proxy_headers=False``) so the original peer address is still known here:
|
||||||
|
a request that carries ``X-Forwarded-For`` from a peer outside
|
||||||
|
``FORWARDED_ALLOW_IPS`` is not honoured (the peer's own address is used) and
|
||||||
|
logs one warning, because that usually means the reverse proxy is not listed
|
||||||
|
(for example a Docker network address) and every client then shares the
|
||||||
|
proxy's rate-limit bucket.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
app: The ASGI application.
|
||||||
|
trusted: ``FORWARDED_ALLOW_IPS`` (``*`` or a comma list of IPs and networks).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, app: ASGIApp, trusted: str) -> None:
|
||||||
|
self.trusted = trusted
|
||||||
|
self._all = trusted.strip() == "*"
|
||||||
|
self._networks = [
|
||||||
|
ipaddress.ip_network(item.strip(), strict=False)
|
||||||
|
for item in trusted.split(",")
|
||||||
|
if item.strip() and item.strip() != "*"
|
||||||
|
]
|
||||||
|
self._app = ProxyHeadersMiddleware(app, trusted_hosts=trusted) # type: ignore[arg-type]
|
||||||
|
self._warned = False
|
||||||
|
|
||||||
|
def _peer_trusted(self, host: str) -> bool:
|
||||||
|
if self._all:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
address = ipaddress.ip_address(host.split("%")[0])
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return any(
|
||||||
|
address in network for network in self._networks if network.version == address.version
|
||||||
|
)
|
||||||
|
|
||||||
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||||
|
if scope["type"] == "http" and not self._warned:
|
||||||
|
client = scope.get("client")
|
||||||
|
has_forwarded = any(name == b"x-forwarded-for" for name, _ in scope.get("headers", []))
|
||||||
|
if has_forwarded and client and not self._peer_trusted(client[0]):
|
||||||
|
self._warned = True
|
||||||
|
logger.warning(
|
||||||
|
"a request carried X-Forwarded-For from a peer that is not in "
|
||||||
|
"FORWARDED_ALLOW_IPS, so the header is ignored and the peer address is used "
|
||||||
|
"as the client address; if this peer is your reverse proxy, add its address "
|
||||||
|
"to FORWARDED_ALLOW_IPS (all clients otherwise share one rate-limit bucket)"
|
||||||
|
)
|
||||||
|
await self._app(scope, receive, send) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
# HTTP app
|
# HTTP app
|
||||||
|
|
||||||
|
|
||||||
@@ -428,7 +490,9 @@ def _audit_startup_error(settings: Settings) -> str | None:
|
|||||||
try:
|
try:
|
||||||
get_audit_logger()
|
get_audit_logger()
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
reason = exc.strerror or type(exc).__name__
|
reason = exc.strerror or str(exc) or type(exc).__name__
|
||||||
|
if exc.errno == errno.ELOOP:
|
||||||
|
reason = "the path is a symbolic link, which is refused"
|
||||||
return (
|
return (
|
||||||
f"cannot open the audit log {settings.resolved_audit_log_path} for append "
|
f"cannot open the audit log {settings.resolved_audit_log_path} for append "
|
||||||
f"({reason}). Fix the permissions or point AUDIT_LOG_PATH (or DATA_DIR) at a "
|
f"({reason}). Fix the permissions or point AUDIT_LOG_PATH (or DATA_DIR) at a "
|
||||||
@@ -498,12 +562,11 @@ def run_http(settings: Settings) -> int:
|
|||||||
if not any(isinstance(item, AccessLogQueryFilter) for item in access_logger.filters):
|
if not any(isinstance(item, AccessLogQueryFilter) for item in access_logger.filters):
|
||||||
access_logger.addFilter(AccessLogQueryFilter())
|
access_logger.addFilter(AccessLogQueryFilter())
|
||||||
uvicorn.run(
|
uvicorn.run(
|
||||||
app,
|
ForwardedHeadersMiddleware(app, settings.forwarded_allow_ips),
|
||||||
host=settings.mcp_host,
|
host=settings.mcp_host,
|
||||||
port=settings.mcp_port,
|
port=settings.mcp_port,
|
||||||
log_level=settings.log_level.lower(),
|
log_level=settings.log_level.lower(),
|
||||||
proxy_headers=True,
|
proxy_headers=False, # ForwardedHeadersMiddleware applies them (and warns)
|
||||||
forwarded_allow_ips=settings.forwarded_allow_ips,
|
|
||||||
server_header=False,
|
server_header=False,
|
||||||
)
|
)
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
+3
-1
@@ -13,12 +13,13 @@ from pathlib import Path
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from aegis_gitea_mcp.audit import reset_audit_logger
|
from aegis_gitea_mcp.audit import reset_audit_logger
|
||||||
from aegis_gitea_mcp.config import Settings, reset_settings
|
from aegis_gitea_mcp.config import Settings, reset_settings, set_env_file
|
||||||
from aegis_gitea_mcp.request_context import clear_gitea_auth_context
|
from aegis_gitea_mcp.request_context import clear_gitea_auth_context
|
||||||
|
|
||||||
|
|
||||||
def _reset_singletons() -> None:
|
def _reset_singletons() -> None:
|
||||||
"""Reset every module-level singleton."""
|
"""Reset every module-level singleton."""
|
||||||
|
set_env_file(None)
|
||||||
reset_settings()
|
reset_settings()
|
||||||
reset_audit_logger()
|
reset_audit_logger()
|
||||||
clear_gitea_auth_context()
|
clear_gitea_auth_context()
|
||||||
@@ -29,6 +30,7 @@ def reset_globals(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Generator[
|
|||||||
"""Isolate each test: clean env, tmp cwd, tmp state paths, fresh singletons."""
|
"""Isolate each test: clean env, tmp cwd, tmp state paths, fresh singletons."""
|
||||||
for field_name in Settings.model_fields:
|
for field_name in Settings.model_fields:
|
||||||
monkeypatch.delenv(field_name.upper(), raising=False)
|
monkeypatch.delenv(field_name.upper(), raising=False)
|
||||||
|
monkeypatch.delenv("AEGIS_ENV_FILE", raising=False)
|
||||||
monkeypatch.chdir(tmp_path)
|
monkeypatch.chdir(tmp_path)
|
||||||
|
|
||||||
data_dir = tmp_path / "data"
|
data_dir = tmp_path / "data"
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ message instead of skipping.
|
|||||||
- Gitea is configured through `GITEA__section__KEY` env vars (sqlite3,
|
- Gitea is configured through `GITEA__section__KEY` env vars (sqlite3,
|
||||||
`INSTALL_LOCK`, random `SECRET_KEY`/`INTERNAL_TOKEN`/OAuth2 JWT secret,
|
`INSTALL_LOCK`, random `SECRET_KEY`/`INTERNAL_TOKEN`/OAuth2 JWT secret,
|
||||||
Actions, packages and OAuth2 enabled, mailer off, webhook allowed host list
|
Actions, packages and OAuth2 enabled, mailer off, webhook allowed host list
|
||||||
`*`, `MAX_RESPONSE_ITEMS=50`).
|
`*`, local network migrations allowed so the instance can mirror itself, repository
|
||||||
|
hook scripts editable, `MAX_RESPONSE_ITEMS=50`).
|
||||||
- Readiness: polls `GET /api/v1/version` for up to 90 s. On failure the last 100
|
- Readiness: polls `GET /api/v1/version` for up to 90 s. On failure the last 100
|
||||||
log lines of the container are written to stderr.
|
log lines of the container are written to stderr.
|
||||||
- Seeding: admin `aegis-admin` (random password) via `gitea admin user create`,
|
- Seeding: admin `aegis-admin` (random password) via `gitea admin user create`,
|
||||||
@@ -50,3 +51,40 @@ message instead of skipping.
|
|||||||
`create_org`, `create_repo`, `unique`.
|
`create_org`, `create_repo`, `unique`.
|
||||||
- `regular_user` (session): non-admin user with its own full-scope token.
|
- `regular_user` (session): non-admin user with its own full-scope token.
|
||||||
- `unique_name` (function): a unique name derived from the test name.
|
- `unique_name` (function): a unique name derived from the test name.
|
||||||
|
|
||||||
|
## The suite
|
||||||
|
|
||||||
|
Tests call tools through the MCP server (an in-memory `Client` on `build_server`, run as
|
||||||
|
the admin of the throwaway instance), so schema validation, the result envelope, the audit
|
||||||
|
log and the executor are all exercised. State lives in `tmp_path`.
|
||||||
|
|
||||||
|
- `test_scenario_*.py`: happy paths with real state changes (org and team, repository
|
||||||
|
files, branches and tags, issues, pull requests and reviews, releases and attachments,
|
||||||
|
secrets and variables, webhooks, admin and sudo, notifications, current-user features,
|
||||||
|
generic packages, and a workflow run on an `act_runner` container).
|
||||||
|
- `test_sweep.py`: every one of the 538 tools is called once against a seeded instance
|
||||||
|
(`sweep_seed.py` creates the data, `sweep_plan.py` builds arguments and expectations).
|
||||||
|
Order: read-only first, then creates, updates and deletes (leaf resources first). A call
|
||||||
|
passes with a 2xx, a status the swagger documents for that operation, or an entry of
|
||||||
|
`ALLOWANCES` (each with a reason). All failing tools are listed at once.
|
||||||
|
`test_sweep_isolated.py` covers the tools the sweep must not call.
|
||||||
|
- `test_per_user_oauth.py`: the HTTP app with per-user OAuth, driven in process with a
|
||||||
|
seeded OAuth store; calls run as the signed-in user, admin tools and private repositories
|
||||||
|
of others are refused by Gitea, the static bearer still runs as admin.
|
||||||
|
- `test_setup_live.py`: `aegis-gitea-mcp setup` against the instance (OAuth app, env file
|
||||||
|
mode, idempotence, non-admin token).
|
||||||
|
- `tests/test_sweep_plan.py` (no Docker) keeps the sweep plan consistent with the catalog.
|
||||||
|
|
||||||
|
`AEGIS_IT_WRITE_REPORT=1` makes the session write `docs/integration-coverage.md` (per
|
||||||
|
domain: tools with a 2xx, documented non-2xx statuses, allowances).
|
||||||
|
|
||||||
|
### Actions runner
|
||||||
|
|
||||||
|
`start_runner` starts `gitea/act_runner` (digest-pinned in `ACT_RUNNER_IMAGE`) on the run's
|
||||||
|
network, registered with a token from `gitea_actions_repo_create_runner_registration_token`,
|
||||||
|
label `aegis-host:host`. Jobs run inside the runner container itself: no Docker socket, no
|
||||||
|
bind mounts. The runner reaches Gitea by container name, so it works in both network modes.
|
||||||
|
It is removed with the rest of the run. If it cannot start or come online, the test is
|
||||||
|
marked xfail with the reason; a failure after the run completed is a real failure.
|
||||||
|
|
||||||
|
Runtime: about two minutes locally (Gitea start 15 s, sweep 40 s, runner 10 s).
|
||||||
@@ -6,14 +6,22 @@ Tests in this directory are marked ``integration`` and skipped unless
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from collections.abc import Iterator
|
from collections.abc import AsyncIterator, Iterator
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from mcp import Client
|
||||||
|
|
||||||
|
from aegis_gitea_mcp.audit import AuditLogger
|
||||||
|
from aegis_gitea_mcp.config import Settings
|
||||||
|
from aegis_gitea_mcp.server import build_server
|
||||||
from tests.integration.harness import (
|
from tests.integration.harness import (
|
||||||
GiteaInstance,
|
GiteaInstance,
|
||||||
HarnessError,
|
HarnessError,
|
||||||
@@ -21,6 +29,7 @@ from tests.integration.harness import (
|
|||||||
stop_gitea,
|
stop_gitea,
|
||||||
unique,
|
unique,
|
||||||
)
|
)
|
||||||
|
from tests.integration.sweep_plan import LOG, write_report_if_enabled
|
||||||
|
|
||||||
_HERE = Path(__file__).parent.resolve()
|
_HERE = Path(__file__).parent.resolve()
|
||||||
ENABLE_ENV = "AEGIS_INTEGRATION"
|
ENABLE_ENV = "AEGIS_INTEGRATION"
|
||||||
@@ -62,6 +71,14 @@ def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item
|
|||||||
namespace.cov_fail_under = 0
|
namespace.cov_fail_under = 0
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None:
|
||||||
|
"""Write ``docs/integration-coverage.md`` when ``AEGIS_IT_WRITE_REPORT=1`` and the sweep ran."""
|
||||||
|
from aegis_gitea_mcp.catalog import load_catalog
|
||||||
|
|
||||||
|
catalog = load_catalog()
|
||||||
|
write_report_if_enabled(catalog, catalog.spec_version)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def gitea() -> Iterator[GiteaInstance]:
|
def gitea() -> Iterator[GiteaInstance]:
|
||||||
"""A throwaway Gitea in Docker, shared by the whole session."""
|
"""A throwaway Gitea in Docker, shared by the whole session."""
|
||||||
@@ -97,3 +114,233 @@ def regular_user(gitea: GiteaInstance) -> RegularUser:
|
|||||||
login, password = gitea.create_user(gitea.unique("it-user"))
|
login, password = gitea.create_user(gitea.unique("it-user"))
|
||||||
token = gitea.mint_token(login, password, ["all"])
|
token = gitea.mint_token(login, password, ["all"])
|
||||||
return RegularUser(login=login, password=password, token=token)
|
return RegularUser(login=login, password=password, token=token)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Calling tools through the MCP server
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ToolResult:
|
||||||
|
"""One tool call result: the parsed envelope plus the raw text."""
|
||||||
|
|
||||||
|
tool: str
|
||||||
|
is_error: bool
|
||||||
|
payload: dict[str, Any]
|
||||||
|
text: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status(self) -> int | None:
|
||||||
|
"""HTTP status Gitea answered with (None when the call never reached Gitea)."""
|
||||||
|
error = self.payload.get("error")
|
||||||
|
if isinstance(error, dict):
|
||||||
|
status = error.get("status")
|
||||||
|
return status if isinstance(status, int) else None
|
||||||
|
status = self.payload.get("status")
|
||||||
|
return status if isinstance(status, int) else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def data(self) -> Any:
|
||||||
|
"""The ``data`` member of a success envelope."""
|
||||||
|
return self.payload.get("data")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def error_kind(self) -> str | None:
|
||||||
|
"""The ``error.kind`` of a failed call."""
|
||||||
|
error = self.payload.get("error")
|
||||||
|
return error.get("kind") if isinstance(error, dict) else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def identity(self) -> dict[str, Any]:
|
||||||
|
"""The ``identity`` object of the envelope."""
|
||||||
|
identity = self.payload.get("identity")
|
||||||
|
return identity if isinstance(identity, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
class McpSession:
|
||||||
|
"""A connected in-memory MCP client plus bookkeeping for secret-leak assertions.
|
||||||
|
|
||||||
|
Every result text is kept in :attr:`texts` so a test can assert that a secret value
|
||||||
|
never came back, whatever tool returned it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, client: Client, audit_path: Path) -> None:
|
||||||
|
self.client = client
|
||||||
|
self.audit_path = audit_path
|
||||||
|
self.texts: list[str] = []
|
||||||
|
|
||||||
|
async def call(self, tool: str, /, **arguments: Any) -> ToolResult:
|
||||||
|
"""Call ``tool`` with ``arguments`` and return the parsed result (never raises)."""
|
||||||
|
raw = await self.client.call_tool(tool, arguments)
|
||||||
|
assert raw.content, f"{tool}: empty result"
|
||||||
|
block = raw.content[0]
|
||||||
|
text = getattr(block, "text", None)
|
||||||
|
assert isinstance(text, str), f"{tool}: result is not text"
|
||||||
|
self.texts.append(text)
|
||||||
|
payload = json.loads(text)
|
||||||
|
assert isinstance(payload, dict)
|
||||||
|
result = ToolResult(tool=tool, is_error=bool(raw.is_error), payload=payload, text=text)
|
||||||
|
LOG.add(tool, result.status, result.is_error)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def ok(
|
||||||
|
self, tool: str, /, *, status: int | tuple[int, ...] = (200, 201, 204), **arguments: Any
|
||||||
|
) -> ToolResult:
|
||||||
|
"""Call ``tool`` and assert it succeeded with one of the expected statuses."""
|
||||||
|
result = await self.call(tool, **arguments)
|
||||||
|
allowed = (status,) if isinstance(status, int) else status
|
||||||
|
assert not result.is_error and result.status in allowed, f"{tool} failed: {_brief(result)}"
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def fail(self, tool: str, /, status: int, **arguments: Any) -> ToolResult:
|
||||||
|
"""Call ``tool`` and assert Gitea refused it with ``status``."""
|
||||||
|
result = await self.call(tool, **arguments)
|
||||||
|
assert result.is_error and result.status == status, (
|
||||||
|
f"{tool} expected HTTP {status}: {_brief(result)}"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def audit_entries(self) -> list[dict[str, Any]]:
|
||||||
|
"""Payloads of the ``tool_invocation`` entries written to the audit log so far."""
|
||||||
|
lines = self.audit_path.read_text(encoding="utf-8").splitlines()
|
||||||
|
entries = [json.loads(line) for line in lines if line.strip()]
|
||||||
|
return [e["payload"] for e in entries if e.get("event_type") == "tool_invocation"]
|
||||||
|
|
||||||
|
def audit_text(self) -> str:
|
||||||
|
"""The whole audit log file as text."""
|
||||||
|
return self.audit_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _brief(result: ToolResult) -> str:
|
||||||
|
"""A short, secret-free description of a result for assertion messages."""
|
||||||
|
error = result.payload.get("error")
|
||||||
|
detail = error if isinstance(error, dict) else {"status": result.status}
|
||||||
|
return json.dumps(detail, ensure_ascii=False)[:400]
|
||||||
|
|
||||||
|
|
||||||
|
def integration_settings(gitea: GiteaInstance, data_dir: Path, **overrides: Any) -> Settings:
|
||||||
|
"""Server settings for the live instance: admin token, state under ``data_dir``."""
|
||||||
|
values: dict[str, Any] = {
|
||||||
|
"gitea_url": gitea.base_url,
|
||||||
|
"gitea_token": gitea.admin_token,
|
||||||
|
"data_dir": data_dir,
|
||||||
|
"audit_log_path": data_dir / "audit.log",
|
||||||
|
"request_timeout_seconds": 60,
|
||||||
|
}
|
||||||
|
values.update(overrides)
|
||||||
|
return Settings(**values)
|
||||||
|
|
||||||
|
|
||||||
|
class BackgroundContext:
|
||||||
|
"""Enter an async context manager in its own task and leave it from that same task.
|
||||||
|
|
||||||
|
The MCP client uses anyio task groups, which must be exited by the task that entered
|
||||||
|
them. Async pytest fixtures set up and tear down in different tasks, so the context
|
||||||
|
lives in a helper task instead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, manager: Any) -> None:
|
||||||
|
self._manager = manager
|
||||||
|
self._ready = asyncio.Event()
|
||||||
|
self._stop = asyncio.Event()
|
||||||
|
self._value: Any = None
|
||||||
|
self._error: BaseException | None = None
|
||||||
|
self._task: asyncio.Task[None] | None = None
|
||||||
|
|
||||||
|
async def _run(self) -> None:
|
||||||
|
try:
|
||||||
|
async with self._manager as value:
|
||||||
|
self._value = value
|
||||||
|
self._ready.set()
|
||||||
|
await self._stop.wait()
|
||||||
|
except BaseException as exc:
|
||||||
|
self._error = exc
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
self._ready.set()
|
||||||
|
|
||||||
|
async def open(self) -> Any:
|
||||||
|
"""Enter the context and return its value (raises what entering raised)."""
|
||||||
|
self._task = asyncio.create_task(self._run())
|
||||||
|
await self._ready.wait()
|
||||||
|
if self._error is not None:
|
||||||
|
raise self._error
|
||||||
|
return self._value
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Leave the context from the task that entered it."""
|
||||||
|
self._stop.set()
|
||||||
|
if self._task is not None:
|
||||||
|
await self._task
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def mcp(
|
||||||
|
gitea: GiteaInstance, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> AsyncIterator[McpSession]:
|
||||||
|
"""An in-memory MCP client on a server running as the admin of the live instance."""
|
||||||
|
monkeypatch.setenv("GITEA_URL", gitea.base_url)
|
||||||
|
settings = integration_settings(gitea, tmp_path / "mcp-state")
|
||||||
|
audit = AuditLogger(settings.resolved_audit_log_path)
|
||||||
|
server = build_server(settings, audit=audit)
|
||||||
|
holder = BackgroundContext(Client(server))
|
||||||
|
try:
|
||||||
|
client = await holder.open()
|
||||||
|
yield McpSession(client, settings.resolved_audit_log_path)
|
||||||
|
finally:
|
||||||
|
await holder.close()
|
||||||
|
audit.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Scenario helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def b64(text: str | bytes) -> str:
|
||||||
|
"""Base64 text of ``text`` (the contents API and file arguments take base64)."""
|
||||||
|
raw = text.encode("utf-8") if isinstance(text, str) else text
|
||||||
|
return base64.b64encode(raw).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RepoRef:
|
||||||
|
"""A repository created for one test."""
|
||||||
|
|
||||||
|
owner: str
|
||||||
|
name: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def args(self) -> dict[str, str]:
|
||||||
|
"""The ``owner`` and ``repo`` tool arguments."""
|
||||||
|
return {"owner": self.owner, "repo": self.name}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def full(self) -> str:
|
||||||
|
"""``owner/name``."""
|
||||||
|
return f"{self.owner}/{self.name}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fresh_repo(gitea: GiteaInstance, unique_name: str) -> Iterator[RepoRef]:
|
||||||
|
"""A public repository with a README on ``main`` owned by the admin, deleted afterwards."""
|
||||||
|
gitea.create_repo(gitea.admin_login, unique_name)
|
||||||
|
try:
|
||||||
|
yield RepoRef(gitea.admin_login, unique_name)
|
||||||
|
finally:
|
||||||
|
gitea.api("DELETE", f"/repos/{gitea.admin_login}/{unique_name}")
|
||||||
|
|
||||||
|
|
||||||
|
def drop_org(gitea: GiteaInstance, org: str) -> None:
|
||||||
|
"""Best effort: delete an organization together with its repositories."""
|
||||||
|
listing = gitea.api("GET", f"/orgs/{org}/repos", params={"limit": 50})
|
||||||
|
if listing.status_code == 200:
|
||||||
|
for repo in listing.json():
|
||||||
|
gitea.api("DELETE", f"/repos/{org}/{repo['name']}")
|
||||||
|
gitea.api("DELETE", f"/orgs/{org}")
|
||||||
|
|
||||||
|
|
||||||
|
def drop_user(gitea: GiteaInstance, login: str) -> None:
|
||||||
|
"""Best effort: purge a user created by a test."""
|
||||||
|
gitea.api("DELETE", f"/admin/users/{login}", params={"purge": "true"})
|
||||||
@@ -34,6 +34,13 @@ import httpx
|
|||||||
GITEA_IMAGE = (
|
GITEA_IMAGE = (
|
||||||
"gitea/gitea:28.1.0@sha256:f505a0d3a41323b786e05942fc7a4075e07ae7c169fd9485649aa55ee543a21a"
|
"gitea/gitea:28.1.0@sha256:f505a0d3a41323b786e05942fc7a4075e07ae7c169fd9485649aa55ee543a21a"
|
||||||
)
|
)
|
||||||
|
# Actions runner image (digest-pinned, tag 0.6.1 at the time of writing). Jobs run in
|
||||||
|
# host mode inside this very container, so no Docker socket and no bind mounts are needed.
|
||||||
|
ACT_RUNNER_IMAGE = (
|
||||||
|
"gitea/act_runner:0.6.1@sha256:b5c35d6bdbb9bb25e531230bfc7cc663cb751406cbec90a2a891b85fea54de86"
|
||||||
|
)
|
||||||
|
RUNNER_LABEL = "aegis-host"
|
||||||
|
RUNNER_REGISTRATION_ENV = "GITEA_RUNNER_REGISTRATION_TOKEN"
|
||||||
NAME_PREFIX = "aegis-it-"
|
NAME_PREFIX = "aegis-it-"
|
||||||
GITEA_PORT = 3000
|
GITEA_PORT = 3000
|
||||||
ADMIN_LOGIN = "aegis-admin"
|
ADMIN_LOGIN = "aegis-admin"
|
||||||
@@ -176,6 +183,7 @@ class RunResources:
|
|||||||
container: str
|
container: str
|
||||||
network: str
|
network: str
|
||||||
attached_container: str | None = None # job container joined in CI mode
|
attached_container: str | None = None # job container joined in CI mode
|
||||||
|
extra_containers: list[str] = field(default_factory=list) # e.g. the act_runner
|
||||||
cleaned: bool = False
|
cleaned: bool = False
|
||||||
|
|
||||||
|
|
||||||
@@ -194,6 +202,8 @@ def cleanup_resources(res: RunResources) -> None:
|
|||||||
_ACTIVE.pop(res.run_id, None)
|
_ACTIVE.pop(res.run_id, None)
|
||||||
if res.attached_container:
|
if res.attached_container:
|
||||||
run_docker("network", "disconnect", "-f", res.network, res.attached_container, check=False)
|
run_docker("network", "disconnect", "-f", res.network, res.attached_container, check=False)
|
||||||
|
for extra in res.extra_containers:
|
||||||
|
run_docker("rm", "-f", "-v", extra, check=False)
|
||||||
run_docker("rm", "-f", "-v", res.container, check=False)
|
run_docker("rm", "-f", "-v", res.container, check=False)
|
||||||
run_docker("network", "rm", res.network, check=False)
|
run_docker("network", "rm", res.network, check=False)
|
||||||
|
|
||||||
@@ -262,6 +272,8 @@ def _gitea_env(root_url: str) -> dict[str, str]:
|
|||||||
"GITEA__security__INSTALL_LOCK": "true",
|
"GITEA__security__INSTALL_LOCK": "true",
|
||||||
"GITEA__security__SECRET_KEY": secrets.token_hex(32),
|
"GITEA__security__SECRET_KEY": secrets.token_hex(32),
|
||||||
"GITEA__security__INTERNAL_TOKEN": secrets.token_hex(32),
|
"GITEA__security__INTERNAL_TOKEN": secrets.token_hex(32),
|
||||||
|
# Repository hook scripts are editable through the API (site admins only) when enabled.
|
||||||
|
"GITEA__security__DISABLE_GIT_HOOKS": "false",
|
||||||
"GITEA__server__ROOT_URL": root_url,
|
"GITEA__server__ROOT_URL": root_url,
|
||||||
"GITEA__server__HTTP_PORT": str(GITEA_PORT),
|
"GITEA__server__HTTP_PORT": str(GITEA_PORT),
|
||||||
"GITEA__server__DISABLE_SSH": "true",
|
"GITEA__server__DISABLE_SSH": "true",
|
||||||
@@ -275,6 +287,8 @@ def _gitea_env(root_url: str) -> dict[str, str]:
|
|||||||
"GITEA__repository__ENABLE_PUSH_CREATE_ORG": "false",
|
"GITEA__repository__ENABLE_PUSH_CREATE_ORG": "false",
|
||||||
"GITEA__mailer__ENABLED": "false",
|
"GITEA__mailer__ENABLED": "false",
|
||||||
"GITEA__webhook__ALLOWED_HOST_LIST": "*",
|
"GITEA__webhook__ALLOWED_HOST_LIST": "*",
|
||||||
|
# Lets repository migration and mirrors use this very instance as their remote.
|
||||||
|
"GITEA__migrations__ALLOW_LOCALNETWORKS": "true",
|
||||||
"GITEA__packages__ENABLED": "true",
|
"GITEA__packages__ENABLED": "true",
|
||||||
"GITEA__api__MAX_RESPONSE_ITEMS": "50",
|
"GITEA__api__MAX_RESPONSE_ITEMS": "50",
|
||||||
}
|
}
|
||||||
@@ -311,11 +325,27 @@ class GiteaInstance:
|
|||||||
``token`` defaults to the admin token; pass ``anonymous=True`` for an
|
``token`` defaults to the admin token; pass ``anonymous=True`` for an
|
||||||
unauthenticated call, or ``auth=(login, password)`` for basic auth.
|
unauthenticated call, or ``auth=(login, password)`` for basic auth.
|
||||||
"""
|
"""
|
||||||
|
return self.request(
|
||||||
|
method, f"/api/v1/{path.lstrip('/')}", token, anonymous=anonymous, **kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
def request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
token: str | None = None,
|
||||||
|
*,
|
||||||
|
anonymous: bool = False,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> httpx.Response:
|
||||||
|
"""Call any absolute ``path`` of the instance (for example ``/api/packages/...``).
|
||||||
|
|
||||||
|
Authentication works like :meth:`api`.
|
||||||
|
"""
|
||||||
headers: dict[str, str] = dict(kwargs.pop("headers", None) or {})
|
headers: dict[str, str] = dict(kwargs.pop("headers", None) or {})
|
||||||
if not anonymous and "auth" not in kwargs:
|
if not anonymous and "auth" not in kwargs:
|
||||||
headers["Authorization"] = f"token {token or self.admin_token}"
|
headers["Authorization"] = f"token {token or self.admin_token}"
|
||||||
url = f"{self.base_url}/api/v1/{path.lstrip('/')}"
|
return self._client.request(method, f"{self.base_url}{path}", headers=headers, **kwargs)
|
||||||
return self._client.request(method, url, headers=headers, **kwargs)
|
|
||||||
|
|
||||||
def unique(self, prefix: str) -> str:
|
def unique(self, prefix: str) -> str:
|
||||||
"""Return ``<prefix>-<8 hex>``, valid as a Gitea user, org or repo name."""
|
"""Return ``<prefix>-<8 hex>``, valid as a Gitea user, org or repo name."""
|
||||||
@@ -529,3 +559,48 @@ def stop_gitea(instance: GiteaInstance) -> None:
|
|||||||
res = _ACTIVE.get(instance.run_id)
|
res = _ACTIVE.get(instance.run_id)
|
||||||
if res is not None:
|
if res is not None:
|
||||||
cleanup_resources(res)
|
cleanup_resources(res)
|
||||||
|
|
||||||
|
|
||||||
|
def start_runner(instance: GiteaInstance, registration_token: str) -> str:
|
||||||
|
"""Start an ``act_runner`` container registered with ``registration_token``.
|
||||||
|
|
||||||
|
The runner joins the run's private network, reaches Gitea by its container name
|
||||||
|
(so it works in local and in container network mode) and runs jobs labelled
|
||||||
|
``aegis-host`` in host mode inside its own container. The token travels in the
|
||||||
|
environment of the docker CLI and is forwarded with ``-e NAME`` (never in argv).
|
||||||
|
The container is registered for cleanup before it is created. Returns its name.
|
||||||
|
"""
|
||||||
|
with _LOCK:
|
||||||
|
res = _ACTIVE.get(instance.run_id)
|
||||||
|
if res is None:
|
||||||
|
raise HarnessError("the Gitea instance is not tracked (already stopped?)")
|
||||||
|
ensure_image(ACT_RUNNER_IMAGE)
|
||||||
|
name = f"{NAME_PREFIX}{instance.run_id}-runner"
|
||||||
|
res.extra_containers.append(name)
|
||||||
|
run_docker(
|
||||||
|
"run",
|
||||||
|
"-d",
|
||||||
|
"--name",
|
||||||
|
name,
|
||||||
|
"--network",
|
||||||
|
instance.network,
|
||||||
|
"--label",
|
||||||
|
"aegis-it=1",
|
||||||
|
"-e",
|
||||||
|
RUNNER_REGISTRATION_ENV,
|
||||||
|
"-e",
|
||||||
|
f"GITEA_INSTANCE_URL=http://{instance.container}:{GITEA_PORT}",
|
||||||
|
"-e",
|
||||||
|
f"GITEA_RUNNER_NAME={name}",
|
||||||
|
"-e",
|
||||||
|
f"GITEA_RUNNER_LABELS={RUNNER_LABEL}:host",
|
||||||
|
ACT_RUNNER_IMAGE,
|
||||||
|
env={RUNNER_REGISTRATION_ENV: registration_token},
|
||||||
|
timeout=300,
|
||||||
|
)
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def runner_logs(name: str, lines: int = 60) -> str:
|
||||||
|
"""Return the last log lines of a runner container (for failure diagnostics)."""
|
||||||
|
return _container_logs_tail(name, lines)
|
||||||
@@ -0,0 +1,928 @@
|
|||||||
|
"""Plan for the all-tools sweep: arguments, ordering, expectations and the coverage report.
|
||||||
|
|
||||||
|
The sweep calls every tool (538 with ``gitea_api_request``) once through the MCP server
|
||||||
|
against a seeded Gitea (:mod:`tests.integration.sweep_seed`). This module holds everything
|
||||||
|
that is data or pure logic, so it can be checked without Docker
|
||||||
|
(``tests/test_sweep_plan.py``):
|
||||||
|
|
||||||
|
* :func:`build_arguments` turns a catalog operation plus the seed context into tool
|
||||||
|
arguments: path values from the context, required bodies generated from their schema,
|
||||||
|
and an override table (:data:`OVERRIDES`) for tools that need specific bodies or ids.
|
||||||
|
* :func:`ordered_operations` sorts the calls: read-only first, then creates and updates,
|
||||||
|
then deletes (leaf resources before their parents).
|
||||||
|
* :func:`classify` decides whether a result is acceptable: a 2xx, a status the swagger
|
||||||
|
documents for that operation, or an explicit entry of :data:`ALLOWANCES` with a reason.
|
||||||
|
* :func:`render_report` writes ``docs/integration-coverage.md``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
from collections.abc import Callable, Iterable, Sequence
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from aegis_gitea_mcp.catalog import Catalog, Operation
|
||||||
|
from tests.integration.sweep_seed import PNG_1X1, Ctx, b64, ssh_public_key
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
SPEC_PATH = REPO_ROOT / "spec" / "gitea-swagger.v1.json"
|
||||||
|
REPORT_PATH = REPO_ROOT / "docs" / "integration-coverage.md"
|
||||||
|
REPORT_ENV = "AEGIS_IT_WRITE_REPORT"
|
||||||
|
RAW_TOOL = "gitea_api_request"
|
||||||
|
|
||||||
|
#: Tools the generic sweep never calls, with the test that covers them in isolation.
|
||||||
|
EXCLUDED: dict[str, str] = {
|
||||||
|
"gitea_misc_delete_current_token": (
|
||||||
|
"deletes the token the sweep itself runs with; covered by test_sweep_isolated.py "
|
||||||
|
"with a throwaway token"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Allowance:
|
||||||
|
"""A status that is accepted for a tool although the swagger does not document it."""
|
||||||
|
|
||||||
|
statuses: frozenset[int]
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
def allow(reason: str, *statuses: int) -> Allowance:
|
||||||
|
"""Build an :class:`Allowance`."""
|
||||||
|
return Allowance(frozenset(statuses), reason)
|
||||||
|
|
||||||
|
|
||||||
|
#: Tools whose result may be a status the swagger does not list, each with its reason.
|
||||||
|
_NO_SIGNING_KEY = allow(
|
||||||
|
"the throwaway instance has no commit signing key; Gitea answers 404 although the "
|
||||||
|
"swagger lists no 404 for this operation",
|
||||||
|
404,
|
||||||
|
)
|
||||||
|
ALLOWANCES: dict[str, Allowance] = {
|
||||||
|
"gitea_misc_get_signing_key": _NO_SIGNING_KEY,
|
||||||
|
"gitea_misc_get_signing_key_ssh": _NO_SIGNING_KEY,
|
||||||
|
"gitea_repo_signing_key": _NO_SIGNING_KEY,
|
||||||
|
"gitea_repo_signing_key_ssh": _NO_SIGNING_KEY,
|
||||||
|
"gitea_admin_add_user_badges": allow(
|
||||||
|
"Gitea answers 500 for an unknown badge slug and the API cannot create badges", 500
|
||||||
|
),
|
||||||
|
"gitea_admin_delete_unadopted_repository": allow(
|
||||||
|
"no unadopted repository exists on the instance; Gitea answers 404 although the swagger "
|
||||||
|
"lists only 204 and 403",
|
||||||
|
404,
|
||||||
|
),
|
||||||
|
"gitea_user_create_token": allow(
|
||||||
|
"Gitea only accepts basic auth here; the server always sends token authentication", 401
|
||||||
|
),
|
||||||
|
"gitea_user_get_tokens": allow(
|
||||||
|
"Gitea only accepts basic auth here; the server always sends token authentication", 401
|
||||||
|
),
|
||||||
|
"gitea_user_delete_access_token": allow(
|
||||||
|
"Gitea only accepts basic auth here; the server always sends token authentication", 401
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Spec helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def load_spec() -> dict[str, Any]:
|
||||||
|
"""Load the pinned swagger."""
|
||||||
|
data: dict[str, Any] = json.loads(SPEC_PATH.read_text(encoding="utf-8"))
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def documented_statuses(spec: dict[str, Any], operation: Operation) -> frozenset[int]:
|
||||||
|
"""Status codes the swagger lists in ``responses`` of this operation."""
|
||||||
|
item = spec["paths"][operation.path][operation.method.lower()]
|
||||||
|
return frozenset(int(code) for code in item["responses"] if code.isdigit())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Argument synthesis
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
DEFAULT_PATH_VALUES: dict[str, str] = {
|
||||||
|
"owner": "owner",
|
||||||
|
"repo": "repo",
|
||||||
|
"org": "org",
|
||||||
|
"username": "user",
|
||||||
|
"collaborator": "user",
|
||||||
|
"assignee": "user",
|
||||||
|
"target": "user",
|
||||||
|
"user": "user",
|
||||||
|
"template_owner": "owner",
|
||||||
|
"template_repo": "template_repo",
|
||||||
|
"repo_name": "repo",
|
||||||
|
"index": "issue",
|
||||||
|
"sha": "sha",
|
||||||
|
"run": "run",
|
||||||
|
"job_id": "job_id",
|
||||||
|
"pageName": "page_name",
|
||||||
|
"team": "team_name",
|
||||||
|
"task": "cron_task",
|
||||||
|
}
|
||||||
|
|
||||||
|
CONSTANT_PATH_VALUES: dict[str, Any] = {
|
||||||
|
"ref": "main",
|
||||||
|
"branch": "main",
|
||||||
|
"filepath": "README.md",
|
||||||
|
"archive": "main.zip",
|
||||||
|
"basehead": "main...feat",
|
||||||
|
"diffType": "diff",
|
||||||
|
"base": "main",
|
||||||
|
"head": "feat",
|
||||||
|
"type": "generic",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"attempt": 1,
|
||||||
|
"artifact_id": 1,
|
||||||
|
"runner_id": 1,
|
||||||
|
"position": 1,
|
||||||
|
"workflow_id": "sweep.yml",
|
||||||
|
"topic": "extra",
|
||||||
|
"token": "none",
|
||||||
|
}
|
||||||
|
|
||||||
|
# (regex over the path template, context key) for the ``{id}`` segment, first match wins.
|
||||||
|
ID_RULES: list[tuple[str, str]] = [
|
||||||
|
(r"^/repos/\{owner\}/\{repo\}/pulls/comments/\{id\}", "review_comment"),
|
||||||
|
(r"^/repos/\{owner\}/\{repo\}/pulls/\{index\}/comments/\{id\}", "review_comment"),
|
||||||
|
(r"^/repos/\{owner\}/\{repo\}/pulls/\{index\}/reviews/\{id\}", "review"),
|
||||||
|
(r"^/repos/\{owner\}/\{repo\}/issues/comments/\{id\}", "comment"),
|
||||||
|
(r"^/repos/\{owner\}/\{repo\}/issues/\{index\}/comments/\{id\}", "comment"),
|
||||||
|
(r"^/repos/\{owner\}/\{repo\}/issues/\{index\}/labels/\{id\}", "label"),
|
||||||
|
(r"^/repos/\{owner\}/\{repo\}/issues/\{index\}/times/\{id\}", "time_id"),
|
||||||
|
(r"^/repos/\{owner\}/\{repo\}/labels/\{id\}", "label"),
|
||||||
|
(r"^/repos/\{owner\}/\{repo\}/milestones/\{id\}", "milestone"),
|
||||||
|
(r"^/repos/\{owner\}/\{repo\}/hooks/git/\{id\}", "git_hook"),
|
||||||
|
(r"^/repos/\{owner\}/\{repo\}/hooks/\{id\}", "repo_hook"),
|
||||||
|
(r"^/repos/\{owner\}/\{repo\}/keys/\{id\}", "deploy_key"),
|
||||||
|
(r"^/repos/\{owner\}/\{repo\}/releases/\{id\}", "release"),
|
||||||
|
(r"^/repos/\{owner\}/\{repo\}/tag_protections/\{id\}", "tag_protection"),
|
||||||
|
(r"^/repos/\{owner\}/\{repo\}/projects/\{id\}", "repo_project"),
|
||||||
|
(r"^/orgs/\{org\}/hooks/\{id\}", "org_hook"),
|
||||||
|
(r"^/orgs/\{org\}/labels/\{id\}", "org_label"),
|
||||||
|
(r"^/orgs/\{org\}/projects/\{id\}", "org_project"),
|
||||||
|
(r"^/user/hooks/\{id\}", "user_hook"),
|
||||||
|
(r"^/user/keys/\{id\}", "user_key"),
|
||||||
|
(r"^/user/projects/\{id\}", "user_project"),
|
||||||
|
(r"^/user/applications/oauth2/\{id\}", "oauth_app"),
|
||||||
|
(r"^/admin/hooks/\{id\}", "admin_hook"),
|
||||||
|
(r"^/admin/users/\{username\}/keys/\{id\}", "admin_user_key"),
|
||||||
|
(r"^/teams/\{id\}", "team_id"),
|
||||||
|
(r"^/repositories/\{id\}", "repo_id"),
|
||||||
|
(r"^/notifications/threads/\{id\}", "notification"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _path_value(operation: Operation, name: str, c: Ctx) -> Any:
|
||||||
|
"""Value of the path parameter ``name`` of ``operation`` from the context."""
|
||||||
|
path = operation.path
|
||||||
|
if name == "index":
|
||||||
|
return c["pr"] if "/pulls/" in path else c["issue"]
|
||||||
|
if name == "id":
|
||||||
|
for pattern, key in ID_RULES:
|
||||||
|
if re.match(pattern, path):
|
||||||
|
return c[key]
|
||||||
|
return 1
|
||||||
|
if name == "attachment_id":
|
||||||
|
if "/releases/" in path:
|
||||||
|
return c["release_asset"]
|
||||||
|
return c["comment_attachment"] if "/issues/comments/" in path else c["issue_attachment"]
|
||||||
|
if name == "column_id":
|
||||||
|
if path.startswith("/orgs/"):
|
||||||
|
return c["org_column"]
|
||||||
|
return c["user_column"] if path.startswith("/user/") else c["repo_column"]
|
||||||
|
if name == "issue_id":
|
||||||
|
return c["org_issue_id"] if path.startswith("/orgs/") else c["issue1_id"]
|
||||||
|
if name == "secretname":
|
||||||
|
scope = path.split("/")[1]
|
||||||
|
return {"orgs": "ORG_SECRET", "user": c["user_secret"]}.get(scope, "REPO_SECRET")
|
||||||
|
if name == "variablename":
|
||||||
|
scope = path.split("/")[1]
|
||||||
|
return {"orgs": "ORG_VAR", "user": c["user_var"]}.get(scope, "REPO_VAR")
|
||||||
|
if name == "name":
|
||||||
|
if path.startswith("/gitignore/"):
|
||||||
|
return "Go"
|
||||||
|
if path.startswith("/label/"):
|
||||||
|
return "Advanced"
|
||||||
|
if path.startswith("/licenses/"):
|
||||||
|
return "MIT"
|
||||||
|
if path.startswith("/packages/"):
|
||||||
|
return c["pkg"]
|
||||||
|
if "branch_protections" in path:
|
||||||
|
return c["protection"]
|
||||||
|
return "none"
|
||||||
|
if name == "tag":
|
||||||
|
return c["release_tag"] if "/releases/" in path else "v0.0.1"
|
||||||
|
if name in DEFAULT_PATH_VALUES:
|
||||||
|
return c[DEFAULT_PATH_VALUES[name]]
|
||||||
|
if name in CONSTANT_PATH_VALUES:
|
||||||
|
return CONSTANT_PATH_VALUES[name]
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce(value: Any, schema: dict[str, Any]) -> Any:
|
||||||
|
"""Convert ``value`` to the JSON type the argument schema asks for."""
|
||||||
|
kind = schema.get("type")
|
||||||
|
if kind == "string":
|
||||||
|
return str(value)
|
||||||
|
if kind == "integer" and isinstance(value, str) and value.isdigit():
|
||||||
|
return int(value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def sample(schema: dict[str, Any], name: str = "") -> Any:
|
||||||
|
"""A minimal valid value for ``schema`` (required properties only)."""
|
||||||
|
if "enum" in schema:
|
||||||
|
return schema["enum"][0]
|
||||||
|
if "anyOf" in schema:
|
||||||
|
return sample(schema["anyOf"][0], name)
|
||||||
|
kind = schema.get("type")
|
||||||
|
if kind == "object" or "properties" in schema:
|
||||||
|
required = schema.get("required", [])
|
||||||
|
props = schema.get("properties", {})
|
||||||
|
return {key: sample(props[key], key) for key in required if key in props}
|
||||||
|
if kind == "array":
|
||||||
|
return []
|
||||||
|
if kind in ("integer", "number"):
|
||||||
|
return 1
|
||||||
|
if kind == "boolean":
|
||||||
|
return False
|
||||||
|
lowered = name.lower()
|
||||||
|
if "email" in lowered:
|
||||||
|
return f"sweep-{secrets.token_hex(3)}@example.test"
|
||||||
|
if "url" in lowered or "addr" in lowered:
|
||||||
|
return "http://127.0.0.1:9/sweep"
|
||||||
|
if "color" in lowered:
|
||||||
|
return "#336699"
|
||||||
|
if "base64" in lowered or lowered == "content":
|
||||||
|
return b64("sweep")
|
||||||
|
return f"sweep-{secrets.token_hex(3)}"
|
||||||
|
|
||||||
|
|
||||||
|
def fresh(c: Ctx, prefix: str = "sw") -> str:
|
||||||
|
"""A fresh name for one more resource."""
|
||||||
|
return f"{prefix}-{c['sfx']}-{secrets.token_hex(2)}"
|
||||||
|
|
||||||
|
|
||||||
|
def hook_body(c: Ctx) -> dict[str, Any]:
|
||||||
|
"""A webhook body of type gitea."""
|
||||||
|
return {
|
||||||
|
"type": "gitea",
|
||||||
|
"config": {"url": "http://127.0.0.1:9/sweep", "content_type": "json"},
|
||||||
|
"events": ["push"],
|
||||||
|
"active": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def local_url(c: Ctx) -> str:
|
||||||
|
"""Base URL of the admin's repositories as seen from inside the Gitea container."""
|
||||||
|
return f"http://127.0.0.1:3000/{c['admin']}"
|
||||||
|
|
||||||
|
|
||||||
|
def _project_overrides() -> dict[str, Override]:
|
||||||
|
"""Column and issue handling of org, repo and user projects (all three work alike)."""
|
||||||
|
result: dict[str, Override] = {}
|
||||||
|
for scope, tool_scope in (("org", "org"), ("repo", "repo"), ("user", "user_current")):
|
||||||
|
result[f"gitea_{tool_scope}_move_project_columns"] = lambda c, s=scope: {
|
||||||
|
"body": {"column_ids": c[f"{s}_columns"]}
|
||||||
|
}
|
||||||
|
result[f"gitea_{tool_scope}_move_project_issue"] = lambda c, s=scope: {
|
||||||
|
"body": {"column_id": c[f"{s}_column2"]}
|
||||||
|
}
|
||||||
|
result[f"gitea_{tool_scope}_delete_project_column"] = lambda c, s=scope: {
|
||||||
|
"column_id": c[f"{s}_column2"]
|
||||||
|
}
|
||||||
|
result[f"gitea_{tool_scope}_remove_issue_from_project_column"] = lambda c, s=scope: {
|
||||||
|
"column_id": c[f"{s}_column2"]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _org_repo(c: Ctx) -> dict[str, Any]:
|
||||||
|
return {"owner": c["org"], "repo": c["org_repo"]}
|
||||||
|
|
||||||
|
|
||||||
|
def _org_repo_team(c: Ctx) -> dict[str, Any]:
|
||||||
|
return {"owner": c["org"], "repo": c["org_repo"], "team": c["team_name"]}
|
||||||
|
|
||||||
|
|
||||||
|
def _team_repo(c: Ctx) -> dict[str, Any]:
|
||||||
|
return {"org": c["org"], "repo": c["org_repo"]}
|
||||||
|
|
||||||
|
|
||||||
|
PATCH_TEXT = """\
|
||||||
|
diff --git a/applied-{name}.txt b/applied-{name}.txt
|
||||||
|
new file mode 100644
|
||||||
|
index 0000000..e69de29
|
||||||
|
--- /dev/null
|
||||||
|
+++ b/applied-{name}.txt
|
||||||
|
@@ -0,0 +1 @@
|
||||||
|
+applied
|
||||||
|
"""
|
||||||
|
|
||||||
|
Override = Callable[[Ctx], dict[str, Any]]
|
||||||
|
|
||||||
|
OVERRIDES: dict[str, Override] = {
|
||||||
|
# ------------------------------------------------------------------ reads
|
||||||
|
"gitea_repo_get_annotated_tag": lambda c: {"sha": c["tag_sha"]},
|
||||||
|
"gitea_repo_get_push_mirror_by_remote_name": lambda c: {"name": c["push_mirror"]},
|
||||||
|
"gitea_repo_delete_push_mirror": lambda c: {"name": c["push_mirror"]},
|
||||||
|
"gitea_repo_add_push_mirror": lambda c: {
|
||||||
|
"body": {
|
||||||
|
"remote_address": f"{local_url(c)}/{c['push_target2']}.git",
|
||||||
|
"remote_username": c["admin"],
|
||||||
|
"remote_password": c["admin_password"],
|
||||||
|
"interval": "8h0m0s",
|
||||||
|
"sync_on_commit": False,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"gitea_repo_mirror_sync": lambda c: {"repo": c["mirror_repo"]},
|
||||||
|
"gitea_repo_merge_upstream": lambda c: {
|
||||||
|
"owner": c["org"],
|
||||||
|
"repo": c["fork_repo"],
|
||||||
|
"body": {"branch": "main"},
|
||||||
|
},
|
||||||
|
"gitea_repo_migrate": lambda c: {
|
||||||
|
"body": {
|
||||||
|
"clone_addr": f"{local_url(c)}/{c['template_repo']}.git",
|
||||||
|
"repo_name": fresh(c, "mig"),
|
||||||
|
"service": "git",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"gitea_repo_add_team": lambda c: {**_org_repo(c), "team": c["team2_name"]},
|
||||||
|
"gitea_repo_delete_team": lambda c: {**_org_repo(c), "team": c["team2_name"]},
|
||||||
|
"gitea_repo_update_topics": lambda c: {"body": {"topics": ["sweep", "seed", "extra"]}},
|
||||||
|
"gitea_issue_delete_stopwatch": lambda c: {"sudo": c["user"], "index": c["issue2"]},
|
||||||
|
"gitea_org_block_user": lambda c: {"username": c["user3"]},
|
||||||
|
"gitea_user_block_user": lambda c: {"username": c["user3"]},
|
||||||
|
"gitea_repo_get_file_contents": lambda c: {"query_body": json.dumps({"files": ["README.md"]})},
|
||||||
|
"gitea_repo_get_file_contents_post": lambda c: {"body": {"files": ["README.md"]}},
|
||||||
|
"gitea_repo_list_git_refs": lambda c: {"ref": "heads/main"},
|
||||||
|
"gitea_repo_get_blob": lambda c: {"sha": c["blob_sha"]},
|
||||||
|
"gitea_repo_check_team": _org_repo_team,
|
||||||
|
"gitea_repo_list_teams": _org_repo,
|
||||||
|
"gitea_team_list_team_repo": _team_repo,
|
||||||
|
"gitea_team_add_team_repository": _team_repo,
|
||||||
|
"gitea_team_remove_team_repository": _team_repo,
|
||||||
|
"gitea_user_check_following": lambda c: {"username": c["admin"], "target": c["user"]},
|
||||||
|
"gitea_misc_render_markdown": lambda c: {"body": {"Text": "# sweep", "Mode": "markdown"}},
|
||||||
|
"gitea_misc_render_markup": lambda c: {
|
||||||
|
"body": {"Text": "# sweep", "Mode": "markdown", "Context": ""}
|
||||||
|
},
|
||||||
|
"gitea_misc_render_markdown_raw": lambda c: {"body": "# sweep"},
|
||||||
|
"gitea_notification_read_thread": lambda c: {"id": str(c["notification"])},
|
||||||
|
"gitea_notification_get_thread": lambda c: {"id": str(c["notification"])},
|
||||||
|
"gitea_user_get_tokens": lambda c: {"username": c["admin"]},
|
||||||
|
# ---------------------------------------------------------------- actions
|
||||||
|
"gitea_actions_create_org_variable": lambda c: {
|
||||||
|
"variablename": "SW_NEW",
|
||||||
|
"body": {"value": "1"},
|
||||||
|
},
|
||||||
|
"gitea_actions_create_repo_variable": lambda c: {
|
||||||
|
"variablename": "SW_NEW",
|
||||||
|
"body": {"value": "1"},
|
||||||
|
},
|
||||||
|
"gitea_actions_create_user_variable": lambda c: {
|
||||||
|
"variablename": f"SW_NEW_{c['sfx'].upper()}",
|
||||||
|
"body": {"value": "1"},
|
||||||
|
},
|
||||||
|
"gitea_actions_dispatch_workflow": lambda c: {"body": {"ref": "main"}},
|
||||||
|
"gitea_actions_update_org_secret": lambda c: {"body": {"data": "rotated-org-value"}},
|
||||||
|
"gitea_actions_update_repo_secret": lambda c: {"body": {"data": "rotated-repo-value"}},
|
||||||
|
"gitea_actions_update_user_secret": lambda c: {"body": {"data": "rotated-user-value"}},
|
||||||
|
# ------------------------------------------------------------------ admin
|
||||||
|
"gitea_admin_add_user_badges": lambda c: {"body": {"badge_slugs": ["sweep"]}},
|
||||||
|
"gitea_admin_delete_user_badges": lambda c: {"body": {"badge_slugs": ["sweep"]}},
|
||||||
|
"gitea_admin_create_hook": lambda c: {"body": hook_body(c)},
|
||||||
|
"gitea_admin_create_org": lambda c: {"body": {"username": fresh(c, "sw-aorg")}},
|
||||||
|
"gitea_admin_create_public_key": lambda c: {
|
||||||
|
"body": {"title": fresh(c, "key"), "key": ssh_public_key()}
|
||||||
|
},
|
||||||
|
"gitea_admin_create_repo": lambda c: {"body": {"name": fresh(c, "sw-arepo")}},
|
||||||
|
"gitea_admin_create_user": lambda c: _new_user(c),
|
||||||
|
"gitea_admin_edit_user": lambda c: {
|
||||||
|
"username": c["user2"],
|
||||||
|
"body": {"source_id": 0, "login_name": c["user2"], "full_name": "Sweep Two"},
|
||||||
|
},
|
||||||
|
"gitea_admin_rename_user": lambda c: {
|
||||||
|
"username": c["rename_user"],
|
||||||
|
"body": {"new_username": fresh(c, "sw-renamed")},
|
||||||
|
},
|
||||||
|
"gitea_admin_delete_user": lambda c: {"username": c["del_user"]},
|
||||||
|
# ------------------------------------------------------------------ hooks
|
||||||
|
"gitea_hook_org_create_hook": lambda c: {"body": hook_body(c)},
|
||||||
|
"gitea_hook_repo_create_hook": lambda c: {"body": hook_body(c)},
|
||||||
|
"gitea_hook_user_create_hook": lambda c: {"body": hook_body(c)},
|
||||||
|
"gitea_hook_repo_edit_git_hook": lambda c: {"body": {"content": "#!/bin/sh\nexit 0\n"}},
|
||||||
|
# ----------------------------------------------------------------- issues
|
||||||
|
"gitea_issue_create_issue": lambda c: {"body": {"title": fresh(c, "issue")}},
|
||||||
|
"gitea_issue_create_comment": lambda c: {"body": {"body": "sweep comment"}},
|
||||||
|
"gitea_issue_create_label": lambda c: {"body": {"name": fresh(c, "lbl"), "color": "#00aa00"}},
|
||||||
|
"gitea_issue_create_milestone": lambda c: {"body": {"title": fresh(c, "ms")}},
|
||||||
|
"gitea_issue_add_label": lambda c: {"body": {"labels": [c["label"]]}},
|
||||||
|
"gitea_issue_replace_labels": lambda c: {"body": {"labels": [c["label"]]}},
|
||||||
|
"gitea_issue_add_assignees": lambda c: {"body": {"assignees": [c["user"]]}},
|
||||||
|
"gitea_issue_remove_assignees": lambda c: {"body": {"assignees": [c["admin"]]}},
|
||||||
|
"gitea_issue_add_time": lambda c: {"body": {"time": 30}},
|
||||||
|
"gitea_issue_edit_issue": lambda c: {"body": {"title": "Sweep main edited"}},
|
||||||
|
"gitea_issue_edit_comment": lambda c: {"body": {"body": "edited"}},
|
||||||
|
"gitea_issue_edit_comment_deprecated": lambda c: {"body": {"body": "edited again"}},
|
||||||
|
"gitea_issue_edit_label": lambda c: {"body": {"name": "bug-renamed"}},
|
||||||
|
"gitea_issue_edit_milestone": lambda c: {"body": {"title": "m1-renamed"}},
|
||||||
|
"gitea_issue_edit_issue_attachment": lambda c: {"body": {"name": "renamed.txt"}},
|
||||||
|
"gitea_issue_edit_issue_comment_attachment": lambda c: {"body": {"name": "renamed.txt"}},
|
||||||
|
"gitea_issue_edit_issue_deadline": lambda c: {"body": {"due_date": "2030-01-01T00:00:00Z"}},
|
||||||
|
"gitea_issue_lock_issue": lambda c: {"body": {"lock_reason": "too heated"}},
|
||||||
|
"gitea_issue_move_issue_pin": lambda c: {"index": c["issue3"]},
|
||||||
|
"gitea_issue_unpin_issue": lambda c: {"index": c["issue3"]},
|
||||||
|
"gitea_issue_post_issue_reaction": lambda c: {"body": {"content": "heart"}},
|
||||||
|
"gitea_issue_post_comment_reaction": lambda c: {"body": {"content": "heart"}},
|
||||||
|
"gitea_issue_delete_issue_reaction": lambda c: {"body": {"content": "+1"}},
|
||||||
|
"gitea_issue_delete_comment_reaction": lambda c: {"body": {"content": "+1"}},
|
||||||
|
"gitea_issue_create_issue_blocking": lambda c: {
|
||||||
|
"body": {"index": c["issue5"], "owner": c["owner"], "repo": c["repo"]}
|
||||||
|
},
|
||||||
|
"gitea_issue_remove_issue_blocking": lambda c: {
|
||||||
|
"body": {"index": c["issue3"], "owner": c["owner"], "repo": c["repo"]}
|
||||||
|
},
|
||||||
|
"gitea_issue_create_issue_dependencies": lambda c: {
|
||||||
|
"body": {"index": c["issue4"], "owner": c["owner"], "repo": c["repo"]}
|
||||||
|
},
|
||||||
|
"gitea_issue_remove_issue_dependencies": lambda c: {
|
||||||
|
"body": {"index": c["issue2"], "owner": c["owner"], "repo": c["repo"]}
|
||||||
|
},
|
||||||
|
"gitea_issue_delete_comment_deprecated": lambda c: {"id": c["comment2"]},
|
||||||
|
# ------------------------------------------------------------------- orgs
|
||||||
|
"gitea_org_create": lambda c: {"body": {"username": fresh(c, "sw-neworg")}},
|
||||||
|
"gitea_org_create_label": lambda c: {"body": {"name": fresh(c, "lbl"), "color": "#00aa00"}},
|
||||||
|
"gitea_org_create_org_repo": lambda c: {"body": {"name": fresh(c, "sw-orgrepo")}},
|
||||||
|
"gitea_org_create_org_repo_deprecated": lambda c: {"body": {"name": fresh(c, "sw-orgrepo")}},
|
||||||
|
"gitea_org_create_project": lambda c: {"body": {"title": fresh(c, "proj")}},
|
||||||
|
"gitea_org_create_project_column": lambda c: {
|
||||||
|
"id": c["org_project"],
|
||||||
|
"body": {"title": fresh(c, "col")},
|
||||||
|
},
|
||||||
|
"gitea_org_edit": lambda c: {"body": {"description": "edited by the sweep"}},
|
||||||
|
"gitea_org_rename": lambda c: {
|
||||||
|
"org": c["rename_org"],
|
||||||
|
"body": {"new_name": fresh(c, "sw-renorg")},
|
||||||
|
},
|
||||||
|
"gitea_org_delete": lambda c: {"org": c["del_org2"]},
|
||||||
|
"gitea_org_delete_repos": lambda c: {"org": c["del_org"]},
|
||||||
|
"gitea_org_update_avatar": lambda c: {"body": {"image": PNG_1X1}},
|
||||||
|
"gitea_org_check_user_block": lambda c: {"username": c["user2"]},
|
||||||
|
"gitea_org_unblock_user": lambda c: {"username": c["user2"]},
|
||||||
|
# ---------------------------------------------------------------- package
|
||||||
|
"gitea_package_delete_package": lambda c: {"name": c["pkg2"]},
|
||||||
|
# ------------------------------------------------------------------ pulls
|
||||||
|
"gitea_pull_create_pull_request": lambda c: {
|
||||||
|
"body": {"title": "Sweep PR three", "base": "main", "head": "feat3"}
|
||||||
|
},
|
||||||
|
"gitea_pull_create_pull_review": lambda c: {"body": {"event": "COMMENT", "body": "sweep"}},
|
||||||
|
"gitea_pull_submit_pull_review": lambda c: {
|
||||||
|
"sudo": c["user"],
|
||||||
|
"id": c["pending_review"],
|
||||||
|
"body": {"event": "COMMENT", "body": "submitted"},
|
||||||
|
},
|
||||||
|
"gitea_pull_delete_pull_review": lambda c: {"id": c["review2"]},
|
||||||
|
"gitea_pull_dismiss_pull_review": lambda c: {"body": {"message": "stale", "priors": False}},
|
||||||
|
"gitea_pull_create_pull_review_comment_reply": lambda c: {"body": {"body": "reply"}},
|
||||||
|
"gitea_pull_create_pull_review_requests": lambda c: {"body": {"reviewers": [c["user2"]]}},
|
||||||
|
"gitea_pull_delete_pull_review_requests": lambda c: {"body": {"reviewers": [c["user2"]]}},
|
||||||
|
"gitea_pull_merge_pull_request": lambda c: {"index": c["pr2"], "body": {"do": "merge"}},
|
||||||
|
"gitea_pull_edit_pull_request": lambda c: {"body": {"title": "Sweep PR edited"}},
|
||||||
|
# --------------------------------------------------------------- releases
|
||||||
|
"gitea_release_create_release": lambda c: {
|
||||||
|
"body": {"tag_name": "v3.0.0", "target_commitish": "main"}
|
||||||
|
},
|
||||||
|
"gitea_release_edit_release": lambda c: {"body": {"name": "renamed"}},
|
||||||
|
"gitea_release_edit_release_attachment": lambda c: {"body": {"name": "renamed.bin"}},
|
||||||
|
"gitea_release_delete_release_by_tag": lambda c: {"tag": c["release_tag2"]},
|
||||||
|
# ------------------------------------------------------------------ repos
|
||||||
|
"gitea_repo_create_file": lambda c: {
|
||||||
|
"filepath": f"new-{fresh(c)}.txt",
|
||||||
|
"body": {"content": b64("new"), "message": "sweep new file"},
|
||||||
|
},
|
||||||
|
"gitea_repo_update_file": lambda c: {
|
||||||
|
"filepath": "to-update.txt",
|
||||||
|
"body": {"content": b64("after"), "sha": c["update_file_sha"], "message": "sweep update"},
|
||||||
|
},
|
||||||
|
"gitea_repo_delete_file": lambda c: {
|
||||||
|
"filepath": "to-delete.txt",
|
||||||
|
"body": {"sha": c["delete_file_sha"], "message": "sweep delete"},
|
||||||
|
},
|
||||||
|
"gitea_repo_change_files": lambda c: {
|
||||||
|
"body": {
|
||||||
|
"files": [{"operation": "create", "path": f"cf-{fresh(c)}.txt", "content": b64("c")}],
|
||||||
|
"message": "sweep change files",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"gitea_repo_apply_diff_patch": lambda c: {
|
||||||
|
"body": {"content": PATCH_TEXT.format(name=fresh(c)), "message": "sweep patch"}
|
||||||
|
},
|
||||||
|
"gitea_repo_create_branch": lambda c: {
|
||||||
|
"body": {"new_branch_name": fresh(c, "br"), "old_branch_name": "main"}
|
||||||
|
},
|
||||||
|
"gitea_repo_create_branch_protection": lambda c: {"body": {"rule_name": "hotfix/*"}},
|
||||||
|
"gitea_repo_create_tag": lambda c: {"body": {"tag_name": "v0.0.2", "target": "main"}},
|
||||||
|
"gitea_repo_create_tag_protection": lambda c: {
|
||||||
|
"body": {"name_pattern": "w*", "whitelist_usernames": [c["admin"]]}
|
||||||
|
},
|
||||||
|
"gitea_repo_create_fork": lambda c: {
|
||||||
|
"body": {"organization": c["del_org"], "name": fresh(c, "fork")}
|
||||||
|
},
|
||||||
|
"gitea_repo_create_key": lambda c: {
|
||||||
|
"body": {"title": fresh(c, "key"), "key": ssh_public_key()}
|
||||||
|
},
|
||||||
|
"gitea_repo_create_deploy_token": lambda c: {"body": {"title": fresh(c, "dt")}},
|
||||||
|
"gitea_repo_create_status": lambda c: {"body": {"state": "success", "context": "ci/two"}},
|
||||||
|
"gitea_repo_create_project": lambda c: {"body": {"title": fresh(c, "proj")}},
|
||||||
|
"gitea_repo_create_project_column": lambda c: {
|
||||||
|
"id": c["repo_project"],
|
||||||
|
"body": {"title": fresh(c, "col")},
|
||||||
|
},
|
||||||
|
"gitea_repo_edit": lambda c: {"body": {"description": "edited by the sweep"}},
|
||||||
|
"gitea_repo_edit_branch_protection": lambda c: {"body": {"required_approvals": 0}},
|
||||||
|
"gitea_repo_edit_tag_protection": lambda c: {
|
||||||
|
"body": {"name_pattern": "v9*", "whitelist_usernames": [c["admin"]]}
|
||||||
|
},
|
||||||
|
"gitea_repo_edit_project": lambda c: {"body": {"title": "renamed project"}},
|
||||||
|
"gitea_repo_edit_project_column": lambda c: {"body": {"title": "renamed column"}},
|
||||||
|
"gitea_repo_generate_repo": lambda c: {
|
||||||
|
"body": {"name": fresh(c, "gen"), "owner": c["admin"], "git_content": True}
|
||||||
|
},
|
||||||
|
"gitea_repo_rename_branch": lambda c: {"branch": "rn", "body": {"name": "rn2"}},
|
||||||
|
"gitea_repo_transfer": lambda c: {"repo": c["xfer_repo"], "body": {"new_owner": c["org"]}},
|
||||||
|
"gitea_repo_update_avatar": lambda c: {"body": {"image": PNG_1X1}},
|
||||||
|
"gitea_repo_update_branch": lambda c: {
|
||||||
|
"branch": "feat3",
|
||||||
|
"body": {"new_commit_id": c["sha"], "force": True},
|
||||||
|
},
|
||||||
|
"gitea_repo_update_branch_protection_priorities": lambda c: {"body": {"ids": []}},
|
||||||
|
"gitea_repo_delete": lambda c: {"repo": c["del_repo"]},
|
||||||
|
"gitea_repo_delete_branch": lambda c: {"branch": "del-branch"},
|
||||||
|
"gitea_repo_delete_collaborator": lambda c: {"collaborator": c["user2"]},
|
||||||
|
# ------------------------------------------------------------------- wiki
|
||||||
|
"gitea_wiki_create_wiki_page": lambda c: {
|
||||||
|
"body": {"title": "Page2", "content_base64": b64("two"), "message": "page two"}
|
||||||
|
},
|
||||||
|
"gitea_wiki_edit_wiki_page": lambda c: {
|
||||||
|
"body": {"title": "Home", "content_base64": b64("# home v2\n"), "message": "edit"}
|
||||||
|
},
|
||||||
|
# ------------------------------------------------------------------ teams
|
||||||
|
"gitea_team_create_team": lambda c: {
|
||||||
|
"body": {"name": fresh(c, "team"), "permission": "read", "units": ["repo.code"]}
|
||||||
|
},
|
||||||
|
"gitea_team_edit_team": lambda c: {
|
||||||
|
"body": {"name": c["team_name"], "description": "edited by the sweep"}
|
||||||
|
},
|
||||||
|
# ------------------------------------------------------------------ users
|
||||||
|
"gitea_user_add_email": lambda c: {"body": {"emails": [f"sw-{fresh(c)}@example.test"]}},
|
||||||
|
"gitea_user_delete_email": lambda c: {"body": {"emails": [c["email"]]}},
|
||||||
|
"gitea_user_check_user_block": lambda c: {"username": c["user2"]},
|
||||||
|
"gitea_user_unblock_user": lambda c: {"username": c["user2"]},
|
||||||
|
"gitea_user_create_oauth2_application": lambda c: {
|
||||||
|
"body": {"name": fresh(c, "app"), "redirect_uris": ["http://127.0.0.1:1/cb"]}
|
||||||
|
},
|
||||||
|
"gitea_user_update_oauth2_application": lambda c: {
|
||||||
|
"body": {"name": fresh(c, "app2"), "redirect_uris": ["http://127.0.0.1:1/cb2"]}
|
||||||
|
},
|
||||||
|
"gitea_user_current_create_repo": lambda c: {"body": {"name": fresh(c, "sw-urepo")}},
|
||||||
|
"gitea_user_current_create_project": lambda c: {"body": {"title": fresh(c, "proj")}},
|
||||||
|
"gitea_user_current_create_project_column": lambda c: {
|
||||||
|
"id": c["user_project"],
|
||||||
|
"body": {"title": fresh(c, "col")},
|
||||||
|
},
|
||||||
|
"gitea_user_current_post_key": lambda c: {
|
||||||
|
"body": {"title": fresh(c, "key"), "key": ssh_public_key()}
|
||||||
|
},
|
||||||
|
"gitea_user_current_post_gpg_key": lambda c: {"body": {"armored_public_key": "not a key"}},
|
||||||
|
"gitea_user_delete_access_token": lambda c: {
|
||||||
|
"username": c["admin"],
|
||||||
|
"token": c["throwaway_token"],
|
||||||
|
},
|
||||||
|
"gitea_user_update_avatar": lambda c: {"body": {"image": PNG_1X1}},
|
||||||
|
"gitea_user_update_settings": lambda c: {"body": {"description": "sweep"}},
|
||||||
|
# -------------------------------------------------------------------- raw
|
||||||
|
RAW_TOOL: lambda c: {"method": "GET", "path": "/version"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
OVERRIDES.update(_project_overrides())
|
||||||
|
|
||||||
|
|
||||||
|
def _new_user(c: Ctx) -> dict[str, Any]:
|
||||||
|
name = fresh(c, "sw-new")
|
||||||
|
return {
|
||||||
|
"body": {
|
||||||
|
"username": name,
|
||||||
|
"email": f"{name}@example.test",
|
||||||
|
"password": secrets.token_urlsafe(18),
|
||||||
|
"must_change_password": False,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_arguments(operation: Operation, c: Ctx) -> dict[str, Any]:
|
||||||
|
"""Synthesize the arguments of one catalog operation from the seed context."""
|
||||||
|
args: dict[str, Any] = {}
|
||||||
|
for parameter in operation.parameters:
|
||||||
|
if parameter.location == "path":
|
||||||
|
value = _path_value(operation, parameter.name, c)
|
||||||
|
args[parameter.arg_name] = _coerce(value, parameter.schema)
|
||||||
|
elif parameter.is_file:
|
||||||
|
args[parameter.arg_name] = {
|
||||||
|
"filename": "sweep.txt",
|
||||||
|
"content_base64": b64("sweep"),
|
||||||
|
"content_type": "text/plain",
|
||||||
|
}
|
||||||
|
elif parameter.location == "body":
|
||||||
|
needs_members = bool(parameter.schema.get("required"))
|
||||||
|
args[parameter.arg_name] = (
|
||||||
|
sample(parameter.schema) if parameter.required or needs_members else {}
|
||||||
|
)
|
||||||
|
elif parameter.required:
|
||||||
|
args[parameter.arg_name] = sample(parameter.schema, parameter.name)
|
||||||
|
override = OVERRIDES.get(operation.tool_name)
|
||||||
|
if override is not None:
|
||||||
|
args.update(override(c))
|
||||||
|
for name, value in list(args.items()):
|
||||||
|
parameter = next((p for p in operation.parameters if p.arg_name == name), None)
|
||||||
|
if parameter is not None and parameter.location == "path":
|
||||||
|
args[name] = _coerce(value, parameter.schema)
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Ordering and classification
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
PHASE_BY_METHOD = {"GET": 1, "HEAD": 1, "POST": 2, "PUT": 3, "PATCH": 4, "DELETE": 5}
|
||||||
|
#: Explicit priorities inside a phase (higher runs later; default 50).
|
||||||
|
PRIORITY: dict[str, int] = {
|
||||||
|
"gitea_pull_merge_pull_request": 90,
|
||||||
|
# Moving columns must list every column, so it runs before any column is created.
|
||||||
|
"gitea_org_move_project_columns": 10,
|
||||||
|
"gitea_repo_move_project_columns": 10,
|
||||||
|
"gitea_user_current_move_project_columns": 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def phase_of(operation: Operation) -> int:
|
||||||
|
"""0 for read-only operations, then 2 (POST), 3 (PUT), 4 (PATCH) and 5 (DELETE)."""
|
||||||
|
if operation.read_only:
|
||||||
|
return 0
|
||||||
|
return PHASE_BY_METHOD.get(operation.method, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def sort_key(operation: Operation) -> tuple[int, int, int, str]:
|
||||||
|
"""Sort key: phase, priority, leaf-first depth for deletes, name."""
|
||||||
|
phase = phase_of(operation)
|
||||||
|
depth = -operation.path.count("/") if phase == 5 else 0
|
||||||
|
return (phase, PRIORITY.get(operation.tool_name, 50), depth, operation.tool_name)
|
||||||
|
|
||||||
|
|
||||||
|
def ordered_operations(catalog: Catalog) -> list[Operation]:
|
||||||
|
"""Every operation that the generic sweep calls, in execution order."""
|
||||||
|
operations = [op for op in catalog.operations if op.tool_name not in EXCLUDED]
|
||||||
|
return sorted(operations, key=sort_key)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SweepRecord:
|
||||||
|
"""The outcome of one sweep call."""
|
||||||
|
|
||||||
|
tool: str
|
||||||
|
domain: str
|
||||||
|
method: str
|
||||||
|
path: str
|
||||||
|
status: int | None
|
||||||
|
is_error: bool
|
||||||
|
kind: str | None
|
||||||
|
message: str
|
||||||
|
outcome: str
|
||||||
|
reason: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def classify(
|
||||||
|
tool: str,
|
||||||
|
status: int | None,
|
||||||
|
is_error: bool,
|
||||||
|
documented: frozenset[int],
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
"""Return ``(outcome, reason)``: success, documented, allowance or unexpected."""
|
||||||
|
if status is not None and 200 <= status < 300 and not is_error:
|
||||||
|
return "success", ""
|
||||||
|
if status is not None and status in documented:
|
||||||
|
return "documented", ""
|
||||||
|
allowance = ALLOWANCES.get(tool)
|
||||||
|
if allowance is not None and status in allowance.statuses:
|
||||||
|
return "allowance", allowance.reason
|
||||||
|
return "unexpected", ""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Results and report
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CallLog:
|
||||||
|
"""Every ``(tool, status, is_error)`` seen during the session, from every test."""
|
||||||
|
|
||||||
|
calls: list[tuple[str, int | None, bool]] = field(default_factory=list)
|
||||||
|
sweep: list[SweepRecord] = field(default_factory=list)
|
||||||
|
|
||||||
|
def add(self, tool: str, status: int | None, is_error: bool) -> None:
|
||||||
|
"""Record one call."""
|
||||||
|
self.calls.append((tool, status, is_error))
|
||||||
|
|
||||||
|
|
||||||
|
LOG = CallLog()
|
||||||
|
|
||||||
|
|
||||||
|
def _bucket(tool: str, log: CallLog) -> str:
|
||||||
|
"""``success`` when any call of the session got a 2xx, else the sweep outcome."""
|
||||||
|
for name, status, is_error in log.calls:
|
||||||
|
if name == tool and status is not None and 200 <= status < 300 and not is_error:
|
||||||
|
return "success"
|
||||||
|
for record in log.sweep:
|
||||||
|
if record.tool == tool:
|
||||||
|
return record.outcome
|
||||||
|
return "not called"
|
||||||
|
|
||||||
|
|
||||||
|
def render_report(catalog: Catalog, log: CallLog, gitea_version: str) -> str:
|
||||||
|
"""Render the coverage report as Markdown (no em dashes, no bold)."""
|
||||||
|
tool_domain = {op.tool_name: op.domain for op in catalog.operations}
|
||||||
|
tool_domain[RAW_TOOL] = "misc"
|
||||||
|
for name in EXCLUDED:
|
||||||
|
tool_domain.setdefault(name, "misc")
|
||||||
|
by_domain: dict[str, list[str]] = {}
|
||||||
|
for tool, domain in sorted(tool_domain.items()):
|
||||||
|
by_domain.setdefault(domain, []).append(tool)
|
||||||
|
sweep = {record.tool: record for record in log.sweep}
|
||||||
|
totals = {"success": 0, "documented": 0, "allowance": 0, "other": 0}
|
||||||
|
sections: list[str] = []
|
||||||
|
rows: list[str] = []
|
||||||
|
for domain in sorted(by_domain):
|
||||||
|
tools = by_domain[domain]
|
||||||
|
buckets = {tool: _bucket(tool, log) for tool in tools}
|
||||||
|
counts = {key: sum(1 for b in buckets.values() if b == key) for key in totals}
|
||||||
|
counts["other"] = (
|
||||||
|
len(tools) - counts["success"] - counts["documented"] - counts["allowance"]
|
||||||
|
)
|
||||||
|
for key in totals:
|
||||||
|
totals[key] += counts[key]
|
||||||
|
rows.append(
|
||||||
|
f"| {domain} | {len(tools)} | {counts['success']} | {counts['documented']} | "
|
||||||
|
f"{counts['allowance']} | {counts['other']} |"
|
||||||
|
)
|
||||||
|
lines = [f"## {domain}", ""]
|
||||||
|
ok = [t for t in tools if buckets[t] == "success"]
|
||||||
|
lines.append(f"Tools with a 2xx ({len(ok)}):")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(", ".join(f"`{t}`" for t in ok) if ok else "none")
|
||||||
|
lines.append("")
|
||||||
|
documented = [t for t in tools if buckets[t] == "documented"]
|
||||||
|
lines.append(f"Tools with a documented non-2xx status ({len(documented)}):")
|
||||||
|
lines.append("")
|
||||||
|
if documented:
|
||||||
|
lines.extend(["| Tool | Status |", "| --- | --- |"])
|
||||||
|
lines.extend(f"| `{t}` | {sweep[t].status} |" for t in documented)
|
||||||
|
else:
|
||||||
|
lines.append("none")
|
||||||
|
lines.append("")
|
||||||
|
allowed = [t for t in tools if buckets[t] == "allowance"]
|
||||||
|
lines.append(f"Allowances ({len(allowed)}):")
|
||||||
|
lines.append("")
|
||||||
|
if allowed:
|
||||||
|
lines.extend(["| Tool | Status | Reason |", "| --- | --- | --- |"])
|
||||||
|
lines.extend(f"| `{t}` | {sweep[t].status} | {sweep[t].reason} |" for t in allowed)
|
||||||
|
else:
|
||||||
|
lines.append("none")
|
||||||
|
lines.append("")
|
||||||
|
other = [t for t in tools if buckets[t] not in ("success", "documented", "allowance")]
|
||||||
|
if other:
|
||||||
|
lines.append(f"Not covered by this run ({len(other)}):")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(", ".join(f"`{t}`" for t in other))
|
||||||
|
lines.append("")
|
||||||
|
sections.append("\n".join(lines))
|
||||||
|
header = [
|
||||||
|
"# Integration coverage",
|
||||||
|
"",
|
||||||
|
"Generated by the live-Gitea integration suite; do not edit by hand. Regenerate with:",
|
||||||
|
"",
|
||||||
|
"```bash",
|
||||||
|
f"{REPORT_ENV}=1 AEGIS_INTEGRATION=1 uv run pytest tests/integration -m integration --no-cov",
|
||||||
|
"```",
|
||||||
|
"",
|
||||||
|
f"Target: Gitea {gitea_version}. Every tool of the server ({len(tool_domain)}) is called "
|
||||||
|
"at least once through the MCP server against a seeded instance (see "
|
||||||
|
"`tests/integration/test_sweep.py`). A call counts as covered when it reaches Gitea and "
|
||||||
|
"answers 2xx, answers a status the swagger documents for that operation, or answers a "
|
||||||
|
"status listed as an explicit allowance below. Scenario tests "
|
||||||
|
"(`tests/integration/test_scenario_*.py`) add real state changes; a tool counts as 2xx "
|
||||||
|
"when any call in the run got a 2xx.",
|
||||||
|
"",
|
||||||
|
"| Domain | Tools | 2xx | Documented non-2xx | Allowances | Not covered |",
|
||||||
|
"| --- | --- | --- | --- | --- | --- |",
|
||||||
|
*rows,
|
||||||
|
f"| total | {len(tool_domain)} | {totals['success']} | {totals['documented']} | "
|
||||||
|
f"{totals['allowance']} | {totals['other']} |",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
if EXCLUDED:
|
||||||
|
header.append("Excluded from the generic sweep and covered by a dedicated test:")
|
||||||
|
header.append("")
|
||||||
|
header.extend(f"- `{tool}`: {reason}" for tool, reason in sorted(EXCLUDED.items()))
|
||||||
|
header.append("")
|
||||||
|
return "\n".join(header) + "\n" + "\n".join(sections)
|
||||||
|
|
||||||
|
|
||||||
|
def write_report_if_enabled(catalog: Catalog, gitea_version: str) -> bool:
|
||||||
|
"""Write the report when ``AEGIS_IT_WRITE_REPORT=1`` and the sweep ran."""
|
||||||
|
if os.environ.get(REPORT_ENV) != "1" or not LOG.sweep:
|
||||||
|
return False
|
||||||
|
REPORT_PATH.write_text(render_report(catalog, LOG, gitea_version), encoding="utf-8")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def failing(records: Iterable[SweepRecord]) -> list[SweepRecord]:
|
||||||
|
"""The records whose outcome is ``unexpected``."""
|
||||||
|
return [record for record in records if record.outcome == "unexpected"]
|
||||||
|
|
||||||
|
|
||||||
|
def summarize(records: Sequence[SweepRecord]) -> dict[str, int]:
|
||||||
|
"""Counts per outcome."""
|
||||||
|
result: dict[str, int] = {}
|
||||||
|
for record in records:
|
||||||
|
result[record.outcome] = result.get(record.outcome, 0) + 1
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The sweep itself
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
CallTool = Callable[..., Any]
|
||||||
|
|
||||||
|
|
||||||
|
def _short(text: str, limit: int = 160) -> str:
|
||||||
|
return " ".join(text.split())[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
async def run_sweep(
|
||||||
|
call: CallTool, catalog: Catalog, spec: dict[str, Any], c: Ctx
|
||||||
|
) -> list[SweepRecord]:
|
||||||
|
"""Call every tool once (in plan order) and classify the results.
|
||||||
|
|
||||||
|
``call(tool, **arguments)`` must return an object with ``status``, ``is_error``,
|
||||||
|
``error_kind`` and ``payload`` (a :class:`tests.integration.conftest.ToolResult`).
|
||||||
|
"""
|
||||||
|
records: list[SweepRecord] = []
|
||||||
|
jobs: list[tuple[str, str, str, str, frozenset[int], dict[str, Any]]] = []
|
||||||
|
for operation in ordered_operations(catalog):
|
||||||
|
jobs.append(
|
||||||
|
(
|
||||||
|
operation.tool_name,
|
||||||
|
operation.domain,
|
||||||
|
operation.method,
|
||||||
|
operation.path,
|
||||||
|
documented_statuses(spec, operation),
|
||||||
|
build_arguments(operation, c),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
jobs.append((RAW_TOOL, "misc", "GET", "/version", frozenset({200}), OVERRIDES[RAW_TOOL](c)))
|
||||||
|
for tool, domain, method, path, documented, arguments in jobs:
|
||||||
|
result = await call(tool, **arguments)
|
||||||
|
error = result.payload.get("error") if result.is_error else None
|
||||||
|
message = ""
|
||||||
|
if isinstance(error, dict):
|
||||||
|
message = _short(str(error.get("message", "")))
|
||||||
|
outcome, reason = classify(tool, result.status, result.is_error, documented)
|
||||||
|
records.append(
|
||||||
|
SweepRecord(
|
||||||
|
tool=tool,
|
||||||
|
domain=domain,
|
||||||
|
method=method,
|
||||||
|
path=path,
|
||||||
|
status=result.status,
|
||||||
|
is_error=result.is_error,
|
||||||
|
kind=result.error_kind,
|
||||||
|
message=message,
|
||||||
|
outcome=outcome,
|
||||||
|
reason=reason,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return records
|
||||||
@@ -0,0 +1,445 @@
|
|||||||
|
"""Seed data for the all-tools sweep.
|
||||||
|
|
||||||
|
:func:`seed` creates the resources the sweep's arguments point at (through the Gitea API,
|
||||||
|
not through the tools under test) and returns the context dictionary. Everything created
|
||||||
|
here is fake data in a disposable instance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||||
|
|
||||||
|
from tests.integration.harness import GiteaInstance, HarnessError
|
||||||
|
|
||||||
|
Ctx = dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Small helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def b64(text: str | bytes) -> str:
|
||||||
|
"""Base64 text of ``text``."""
|
||||||
|
raw = text.encode("utf-8") if isinstance(text, str) else text
|
||||||
|
return base64.b64encode(raw).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def ssh_public_key() -> str:
|
||||||
|
"""A fresh ed25519 public key in OpenSSH format (throwaway)."""
|
||||||
|
key = Ed25519PrivateKey.generate().public_key()
|
||||||
|
raw = key.public_bytes(serialization.Encoding.OpenSSH, serialization.PublicFormat.OpenSSH)
|
||||||
|
return raw.decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
# A 1x1 transparent PNG, for the avatar endpoints.
|
||||||
|
PNG_1X1 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Seeding
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class Seeder:
|
||||||
|
"""Creates the sweep's seed data through the Gitea API (not through the tools)."""
|
||||||
|
|
||||||
|
def __init__(self, gitea: GiteaInstance, user: str, user_token: str) -> None:
|
||||||
|
self.gitea = gitea
|
||||||
|
self.user = user
|
||||||
|
self.user_token = user_token
|
||||||
|
|
||||||
|
def call(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
token: str | None = None,
|
||||||
|
ok: tuple[int, ...] = (200, 201, 202, 204),
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> Any:
|
||||||
|
"""Call the API, require a status in ``ok`` and return the JSON body (or None)."""
|
||||||
|
response = self.gitea.api(method, path, token=token, **kwargs)
|
||||||
|
if response.status_code not in ok:
|
||||||
|
raise HarnessError(
|
||||||
|
f"seed {method} {path} failed: HTTP {response.status_code}: {response.text[:200]}"
|
||||||
|
)
|
||||||
|
if response.content and "json" in response.headers.get("content-type", ""):
|
||||||
|
return response.json()
|
||||||
|
return None
|
||||||
|
|
||||||
|
def put_file(self, repo: str, path: str, text: str, branch: str = "main") -> dict[str, Any]:
|
||||||
|
"""Create a file through the contents API."""
|
||||||
|
owner = self.gitea.admin_login
|
||||||
|
body = {"content": b64(text), "message": f"add {path}", "branch": branch}
|
||||||
|
result: dict[str, Any] = self.call(
|
||||||
|
"POST", f"/repos/{owner}/{repo}/contents/{path}", json=body
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
WORKFLOW = """\
|
||||||
|
name: sweep
|
||||||
|
on: [push, workflow_dispatch]
|
||||||
|
jobs:
|
||||||
|
hello:
|
||||||
|
runs-on: aegis-sweep-nonexistent
|
||||||
|
steps:
|
||||||
|
- run: echo hello
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def seed(gitea: GiteaInstance, user: str, user_token: str) -> Ctx:
|
||||||
|
"""Create the sweep's seed data and return the context the arguments are built from."""
|
||||||
|
s = Seeder(gitea, user, user_token)
|
||||||
|
admin = gitea.admin_login
|
||||||
|
sfx = secrets.token_hex(3)
|
||||||
|
c: Ctx = {"admin": admin, "user": user, "owner": admin, "sfx": sfx}
|
||||||
|
c["admin_password"] = gitea.admin_password
|
||||||
|
|
||||||
|
# A second user plus throwaway users for the destructive admin tools.
|
||||||
|
c["user2"] = gitea.create_user(f"sw-u2-{sfx}")[0]
|
||||||
|
c["user3"] = gitea.create_user(f"sw-u3-{sfx}")[0]
|
||||||
|
c["del_user"] = gitea.create_user(f"sw-del-{sfx}")[0]
|
||||||
|
c["rename_user"] = gitea.create_user(f"sw-ren-{sfx}")[0]
|
||||||
|
|
||||||
|
# Organization with a team and a repository.
|
||||||
|
c["org"] = org = f"sw-org-{sfx}"
|
||||||
|
s.call("POST", "/orgs", json={"username": org, "visibility": "public"})
|
||||||
|
c["org_repo"] = f"sw-orgrepo-{sfx}"
|
||||||
|
s.call("POST", f"/orgs/{org}/repos", json={"name": c["org_repo"], "auto_init": True})
|
||||||
|
team = s.call(
|
||||||
|
"POST",
|
||||||
|
f"/orgs/{org}/teams",
|
||||||
|
json={
|
||||||
|
"name": "sweepers",
|
||||||
|
"permission": "write",
|
||||||
|
"includes_all_repositories": False,
|
||||||
|
"units": ["repo.code", "repo.issues", "repo.pulls"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
c["team_id"], c["team_name"] = team["id"], team["name"]
|
||||||
|
team2 = s.call(
|
||||||
|
"POST",
|
||||||
|
f"/orgs/{org}/teams",
|
||||||
|
json={"name": "sweepers2", "permission": "read", "units": ["repo.code"]},
|
||||||
|
)
|
||||||
|
c["team2_name"] = team2["name"]
|
||||||
|
s.call("PUT", f"/teams/{c['team_id']}/members/{user}")
|
||||||
|
s.call("PUT", f"/teams/{c['team_id']}/repos/{org}/{c['org_repo']}")
|
||||||
|
s.call("PUT", f"/orgs/{org}/public_members/{user}")
|
||||||
|
s.call("PUT", f"/orgs/{org}/blocks/{c['user2']}")
|
||||||
|
c["org_label"] = s.call(
|
||||||
|
"POST", f"/orgs/{org}/labels", json={"name": "org-bug", "color": "#ff0000"}
|
||||||
|
)["id"]
|
||||||
|
c["org_hook"] = s.call(
|
||||||
|
"POST",
|
||||||
|
f"/orgs/{org}/hooks",
|
||||||
|
json={"type": "gitea", "config": {"url": "http://127.0.0.1:9/h", "content_type": "json"}},
|
||||||
|
)["id"]
|
||||||
|
s.call("PUT", f"/orgs/{org}/actions/secrets/ORG_SECRET", json={"data": "org-secret-value"})
|
||||||
|
s.call("POST", f"/orgs/{org}/actions/variables/ORG_VAR", json={"value": "1"})
|
||||||
|
c["org_issue_id"] = s.call(
|
||||||
|
"POST", f"/repos/{org}/{c['org_repo']}/issues", json={"title": "org issue"}
|
||||||
|
)["id"]
|
||||||
|
_project(s, c, "org", f"/orgs/{org}/projects")
|
||||||
|
|
||||||
|
# Throwaway org and repository for the destructive tools.
|
||||||
|
c["del_org"] = f"sw-delorg-{sfx}"
|
||||||
|
s.call("POST", "/orgs", json={"username": c["del_org"]})
|
||||||
|
c["del_org2"] = f"sw-delorg2-{sfx}"
|
||||||
|
s.call("POST", "/orgs", json={"username": c["del_org2"]})
|
||||||
|
c["rename_org"] = f"sw-renorg-{sfx}"
|
||||||
|
s.call("POST", "/orgs", json={"username": c["rename_org"]})
|
||||||
|
c["del_repo"] = f"sw-delrepo-{sfx}"
|
||||||
|
gitea.create_repo(admin, c["del_repo"])
|
||||||
|
c["xfer_repo"] = f"sw-xfer-{sfx}"
|
||||||
|
gitea.create_repo(admin, c["xfer_repo"])
|
||||||
|
c["template_repo"] = f"sw-tpl-{sfx}"
|
||||||
|
gitea.create_repo(admin, c["template_repo"])
|
||||||
|
s.call("PATCH", f"/repos/{admin}/{c['template_repo']}", json={"template": True})
|
||||||
|
|
||||||
|
# The main seed repository.
|
||||||
|
c["repo"] = repo = f"sw-repo-{sfx}"
|
||||||
|
created = gitea.create_repo(admin, repo)
|
||||||
|
c["repo_id"] = created["id"]
|
||||||
|
base = f"/repos/{admin}/{repo}"
|
||||||
|
branch = s.call("GET", f"{base}/branches/main")
|
||||||
|
c["sha"] = branch["commit"]["id"]
|
||||||
|
s.put_file(repo, ".editorconfig", "root = true\n")
|
||||||
|
s.put_file(repo, "docs/a.txt", "alpha\n")
|
||||||
|
s.put_file(repo, ".gitea/workflows/sweep.yml", WORKFLOW)
|
||||||
|
c["sha"] = s.call("GET", f"{base}/branches/main")["commit"]["id"]
|
||||||
|
s.call("PUT", f"{base}/collaborators/{user}", json={"permission": "write"})
|
||||||
|
s.call("PUT", f"{base}/collaborators/{c['user2']}", json={"permission": "write"})
|
||||||
|
s.call("PUT", f"{base}/topics", json={"topics": ["sweep", "seed"]})
|
||||||
|
s.call("PUT", f"{base}/topics/extra")
|
||||||
|
# A fork (for merge-upstream), a pull mirror and push-mirror targets, all from this instance.
|
||||||
|
local = f"http://127.0.0.1:3000/{admin}"
|
||||||
|
c["fork_repo"] = f"sw-fork-{sfx}"
|
||||||
|
s.call("POST", f"{base}/forks", json={"organization": org, "name": c["fork_repo"]})
|
||||||
|
c["mirror_repo"] = f"sw-mirror-{sfx}"
|
||||||
|
s.call(
|
||||||
|
"POST",
|
||||||
|
"/repos/migrate",
|
||||||
|
json={
|
||||||
|
"clone_addr": f"{local}/{c['template_repo']}.git",
|
||||||
|
"repo_name": c["mirror_repo"],
|
||||||
|
"service": "git",
|
||||||
|
"mirror": True,
|
||||||
|
"mirror_interval": "8h0m0s",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
c["push_target"] = f"sw-ptarget-{sfx}"
|
||||||
|
gitea.create_repo(admin, c["push_target"], auto_init=False)
|
||||||
|
c["push_target2"] = f"sw-ptarget2-{sfx}"
|
||||||
|
gitea.create_repo(admin, c["push_target2"], auto_init=False)
|
||||||
|
c["push_mirror"] = s.call(
|
||||||
|
"POST",
|
||||||
|
f"{base}/push_mirrors",
|
||||||
|
json={
|
||||||
|
"remote_address": f"{local}/{c['push_target']}.git",
|
||||||
|
"remote_username": admin,
|
||||||
|
"remote_password": gitea.admin_password,
|
||||||
|
"interval": "8h0m0s",
|
||||||
|
"sync_on_commit": False,
|
||||||
|
},
|
||||||
|
)["remote_name"]
|
||||||
|
s.call(
|
||||||
|
"POST",
|
||||||
|
f"{base}/statuses/{c['sha']}",
|
||||||
|
json={"state": "success", "context": "ci/sweep"},
|
||||||
|
)
|
||||||
|
s.call("POST", f"{base}/tags", json={"tag_name": "v0.0.1", "target": "main"})
|
||||||
|
annotated = s.call(
|
||||||
|
"POST", f"{base}/tags", json={"tag_name": "v0.0.3", "target": "main", "message": "note"}
|
||||||
|
)
|
||||||
|
c["tag_sha"] = annotated["id"]
|
||||||
|
for name in ("feat", "feat2", "feat3", "rn", "del-branch"):
|
||||||
|
s.call(
|
||||||
|
"POST", f"{base}/branches", json={"new_branch_name": name, "old_branch_name": "main"}
|
||||||
|
)
|
||||||
|
s.put_file(repo, "feat.txt", "feature\n", branch="feat")
|
||||||
|
s.put_file(repo, "feat2.txt", "feature two\n", branch="feat2")
|
||||||
|
s.put_file(repo, "feat3.txt", "feature three\n", branch="feat3")
|
||||||
|
s.call("POST", f"{base}/branch_protections", json={"rule_name": "release/*"})
|
||||||
|
c["protection"] = "release/*"
|
||||||
|
s.call(
|
||||||
|
"POST",
|
||||||
|
f"{base}/tag_protections",
|
||||||
|
json={"name_pattern": "v9*", "whitelist_usernames": [admin]},
|
||||||
|
)
|
||||||
|
c["tag_protection"] = s.call("GET", f"{base}/tag_protections")[0]["id"]
|
||||||
|
s.put_file(repo, "to-delete.txt", "bye\n")
|
||||||
|
s.put_file(repo, "to-update.txt", "before\n")
|
||||||
|
c["delete_file_sha"] = s.call("GET", f"{base}/contents/to-delete.txt")["sha"]
|
||||||
|
c["update_file_sha"] = s.call("GET", f"{base}/contents/to-update.txt")["sha"]
|
||||||
|
c["blob_sha"] = s.call("GET", f"{base}/contents/README.md")["sha"]
|
||||||
|
c["page_name"] = "Home"
|
||||||
|
c["cron_task"] = "cleanup_hook_task_table"
|
||||||
|
|
||||||
|
# Issues: 1 main, 2 dependency target, 3 pinned, 4/5 more targets.
|
||||||
|
for number, title in enumerate(("main", "dependency", "pinned", "target four", "target five")):
|
||||||
|
issue = s.call("POST", f"{base}/issues", json={"title": f"Sweep {title}", "body": "x"})
|
||||||
|
assert issue["number"] == number + 1
|
||||||
|
c["issue"], c["issue2"], c["issue3"], c["issue4"], c["issue5"] = 1, 2, 3, 4, 5
|
||||||
|
c["issue1_id"] = s.call("GET", f"{base}/issues/1")["id"]
|
||||||
|
c["comment"] = s.call("POST", f"{base}/issues/1/comments", json={"body": "one"})["id"]
|
||||||
|
c["comment2"] = s.call("POST", f"{base}/issues/1/comments", json={"body": "two"})["id"]
|
||||||
|
c["label"] = s.call("POST", f"{base}/labels", json={"name": "bug", "color": "#d73a4a"})["id"]
|
||||||
|
s.call("POST", f"{base}/issues/1/labels", json={"labels": [c["label"]]})
|
||||||
|
c["milestone"] = s.call("POST", f"{base}/milestones", json={"title": "m1"})["id"]
|
||||||
|
c["milestone_name"] = "m1"
|
||||||
|
s.call("POST", f"{base}/issues/1/assignees", json={"assignees": [admin]})
|
||||||
|
s.call("PUT", f"{base}/issues/1/subscriptions/{user}")
|
||||||
|
s.call("POST", f"{base}/issues/1/reactions", json={"content": "+1"})
|
||||||
|
s.call("POST", f"{base}/issues/comments/{c['comment']}/reactions", json={"content": "+1"})
|
||||||
|
s.call("POST", f"{base}/issues/1/dependencies", json={"index": 2, "owner": admin, "repo": repo})
|
||||||
|
s.call("POST", f"{base}/issues/1/blocks", json={"index": 3, "owner": admin, "repo": repo})
|
||||||
|
# A comment by another user notifies the admin (author and assignee of issue 1).
|
||||||
|
s.call("POST", f"{base}/issues/1/comments", token=user_token, json={"body": "ping"})
|
||||||
|
s.call("POST", f"{base}/issues/3/pin")
|
||||||
|
s.call("POST", f"{base}/issues/2/stopwatch/start", token=user_token)
|
||||||
|
time_entry = s.call("POST", f"{base}/issues/1/times", json={"time": 60})
|
||||||
|
c["time_id"] = time_entry["id"]
|
||||||
|
attachment = s.call(
|
||||||
|
"POST",
|
||||||
|
f"{base}/issues/1/assets",
|
||||||
|
files={"attachment": ("issue.txt", b"issue attachment", "text/plain")},
|
||||||
|
params={"name": "issue.txt"},
|
||||||
|
)
|
||||||
|
c["issue_attachment"] = attachment["id"]
|
||||||
|
attachment = s.call(
|
||||||
|
"POST",
|
||||||
|
f"{base}/issues/comments/{c['comment']}/assets",
|
||||||
|
files={"attachment": ("comment.txt", b"comment attachment", "text/plain")},
|
||||||
|
params={"name": "comment.txt"},
|
||||||
|
)
|
||||||
|
c["comment_attachment"] = attachment["id"]
|
||||||
|
|
||||||
|
# Pull requests: 6 is the main one, 7 is merged by the sweep.
|
||||||
|
pr = s.call("POST", f"{base}/pulls", json={"title": "Sweep PR", "base": "main", "head": "feat"})
|
||||||
|
c["pr"] = pr["number"]
|
||||||
|
pr2 = s.call(
|
||||||
|
"POST", f"{base}/pulls", json={"title": "Sweep PR two", "base": "main", "head": "feat2"}
|
||||||
|
)
|
||||||
|
c["pr2"] = pr2["number"]
|
||||||
|
pr_path = f"{base}/pulls/{c['pr']}"
|
||||||
|
review = s.call(
|
||||||
|
"POST",
|
||||||
|
f"{pr_path}/reviews",
|
||||||
|
token=user_token,
|
||||||
|
json={
|
||||||
|
"event": "REQUEST_CHANGES",
|
||||||
|
"body": "please change",
|
||||||
|
"comments": [{"path": "feat.txt", "new_position": 1, "body": "line note"}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
c["review"] = review["id"]
|
||||||
|
c["review_comment"] = s.call("GET", f"{pr_path}/reviews/{review['id']}/comments")[0]["id"]
|
||||||
|
c["review2"] = s.call(
|
||||||
|
"POST", f"{pr_path}/reviews", token=user_token, json={"event": "COMMENT", "body": "fyi"}
|
||||||
|
)["id"]
|
||||||
|
c["pending_review"] = s.call(
|
||||||
|
"POST", f"{pr_path}/reviews", token=user_token, json={"body": "draft"}
|
||||||
|
)["id"]
|
||||||
|
s.call(
|
||||||
|
"POST", f"{pr_path}/requested_reviewers", json={"reviewers": [c["user2"]]}, ok=(201, 422)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Release with an asset (and a second release removed by tag).
|
||||||
|
release = s.call(
|
||||||
|
"POST", f"{base}/releases", json={"tag_name": "v1.0.0", "target_commitish": "main"}
|
||||||
|
)
|
||||||
|
c["release"], c["release_tag"] = release["id"], "v1.0.0"
|
||||||
|
asset = s.call(
|
||||||
|
"POST",
|
||||||
|
f"{base}/releases/{release['id']}/assets",
|
||||||
|
files={"attachment": ("rel.bin", b"release bytes", "application/octet-stream")},
|
||||||
|
params={"name": "rel.bin"},
|
||||||
|
)
|
||||||
|
c["release_asset"] = asset["id"]
|
||||||
|
s.call("POST", f"{base}/releases", json={"tag_name": "v2.0.0", "target_commitish": "main"})
|
||||||
|
c["release_tag2"] = "v2.0.0"
|
||||||
|
|
||||||
|
# Hooks, keys, secrets, variables, wiki, projects.
|
||||||
|
hook_body = {"type": "gitea", "config": {"url": "http://127.0.0.1:9/h", "content_type": "json"}}
|
||||||
|
c["repo_hook"] = s.call("POST", f"{base}/hooks", json=hook_body)["id"]
|
||||||
|
s.call(
|
||||||
|
"PATCH",
|
||||||
|
f"{base}/hooks/git/pre-receive",
|
||||||
|
json={"content": "#!/bin/sh\nexit 0\n"},
|
||||||
|
ok=(200,),
|
||||||
|
)
|
||||||
|
c["git_hook"] = "pre-receive"
|
||||||
|
c["user_hook"] = s.call("POST", "/user/hooks", json=hook_body)["id"]
|
||||||
|
c["admin_hook"] = s.call("POST", "/admin/hooks", json=hook_body)["id"]
|
||||||
|
c["deploy_key"] = s.call(
|
||||||
|
"POST", f"{base}/keys", json={"title": f"deploy-{sfx}", "key": ssh_public_key()}
|
||||||
|
)["id"]
|
||||||
|
c["user_key"] = s.call(
|
||||||
|
"POST", "/user/keys", json={"title": f"mine-{sfx}", "key": ssh_public_key()}
|
||||||
|
)["id"]
|
||||||
|
c["admin_user_key"] = s.call(
|
||||||
|
"POST", f"/admin/users/{user}/keys", json={"title": f"k-{sfx}", "key": ssh_public_key()}
|
||||||
|
)["id"]
|
||||||
|
s.call("PUT", f"{base}/actions/secrets/REPO_SECRET", json={"data": "repo-secret-value"})
|
||||||
|
c["user_secret"] = f"USER_SECRET_{sfx.upper()}"
|
||||||
|
c["user_var"] = f"USER_VAR_{sfx.upper()}"
|
||||||
|
s.call("PUT", f"/user/actions/secrets/{c['user_secret']}", json={"data": "user-secret-value"})
|
||||||
|
s.call("POST", f"{base}/actions/variables/REPO_VAR", json={"value": "1"})
|
||||||
|
s.call("POST", f"/user/actions/variables/{c['user_var']}", json={"value": "1"})
|
||||||
|
s.call(
|
||||||
|
"POST",
|
||||||
|
f"{base}/wiki/new",
|
||||||
|
json={"title": "Home", "content_base64": b64("# home\n"), "message": "home"},
|
||||||
|
)
|
||||||
|
_project(s, c, "repo", f"{base}/projects")
|
||||||
|
_project(s, c, "user", "/user/projects")
|
||||||
|
c["oauth_app"] = s.call(
|
||||||
|
"POST",
|
||||||
|
"/user/applications/oauth2",
|
||||||
|
json={"name": f"sweep-{sfx}", "redirect_uris": ["http://127.0.0.1:1/cb"]},
|
||||||
|
)["id"]
|
||||||
|
|
||||||
|
# Current user: email, follow, star, watch, block.
|
||||||
|
c["email"] = f"sweep-{sfx}@example.test"
|
||||||
|
s.call("POST", "/user/emails", json={"emails": [c["email"]]})
|
||||||
|
s.call("PUT", f"/user/following/{user}")
|
||||||
|
s.call("PUT", f"/user/starred/{admin}/{repo}")
|
||||||
|
s.call("PUT", f"{base}/subscription")
|
||||||
|
s.call("PUT", f"/user/blocks/{c['user2']}")
|
||||||
|
# A throwaway access token of the admin (basic auth), deleted by name.
|
||||||
|
token_name = f"sweep-{sfx}"
|
||||||
|
s.call(
|
||||||
|
"POST",
|
||||||
|
f"/users/{admin}/tokens",
|
||||||
|
auth=(admin, gitea.admin_password),
|
||||||
|
json={"name": token_name, "scopes": ["read:user"]},
|
||||||
|
)
|
||||||
|
c["throwaway_token"] = token_name
|
||||||
|
|
||||||
|
# A generic package.
|
||||||
|
c["pkg"] = pkg = f"sweep-pkg-{sfx}"
|
||||||
|
upload = gitea.request(
|
||||||
|
"PUT", f"/api/packages/{admin}/generic/{pkg}/1.0.0/payload.bin", content=b"package"
|
||||||
|
)
|
||||||
|
if upload.status_code != 201:
|
||||||
|
raise HarnessError(f"seed package upload failed: HTTP {upload.status_code}")
|
||||||
|
c["pkg_version"] = "1.0.0"
|
||||||
|
c["pkg2"] = f"sweep-pkg2-{sfx}"
|
||||||
|
upload = gitea.request(
|
||||||
|
"PUT", f"/api/packages/{admin}/generic/{c['pkg2']}/1.0.0/payload.bin", content=b"package"
|
||||||
|
)
|
||||||
|
if upload.status_code != 201:
|
||||||
|
raise HarnessError(f"seed package upload failed: HTTP {upload.status_code}")
|
||||||
|
|
||||||
|
# The workflow run created by the push of the workflow file (queued, no runner).
|
||||||
|
c["run"], c["job_id"] = _find_run(s, base)
|
||||||
|
c["notification"] = _find_notification(s, admin)
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
def _project(s: Seeder, c: Ctx, scope: str, path: str) -> None:
|
||||||
|
"""Create a project with two extra columns; store ids as ``<scope>_project/_column...``."""
|
||||||
|
project = s.call("POST", path, json={"title": f"{scope} project"})["id"]
|
||||||
|
c[f"{scope}_project"] = project
|
||||||
|
ids: dict[str, int] = {}
|
||||||
|
for title in ("todo", "done"):
|
||||||
|
ids[title] = s.call("POST", f"{path}/{project}/columns", json={"title": title})["id"]
|
||||||
|
c[f"{scope}_column"], c[f"{scope}_column2"] = ids["todo"], ids["done"]
|
||||||
|
columns = s.call("GET", f"{path}/{project}/columns")
|
||||||
|
c[f"{scope}_columns"] = [column["id"] for column in columns]
|
||||||
|
|
||||||
|
|
||||||
|
def _find_run(s: Seeder, base: str) -> tuple[Any, Any]:
|
||||||
|
"""Return ``(run id, job id)`` of the first workflow run, or ``(1, 1)`` if none appeared."""
|
||||||
|
deadline = time.monotonic() + 10
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
runs = s.call("GET", f"{base}/actions/runs")
|
||||||
|
entries = runs.get("workflow_runs") or []
|
||||||
|
if entries:
|
||||||
|
run_id = entries[0]["id"]
|
||||||
|
jobs = s.call("GET", f"{base}/actions/runs/{run_id}/jobs")
|
||||||
|
job_entries = jobs.get("jobs") or []
|
||||||
|
if job_entries:
|
||||||
|
return run_id, job_entries[0]["id"]
|
||||||
|
time.sleep(0.5)
|
||||||
|
return 1, 1
|
||||||
|
|
||||||
|
|
||||||
|
def _find_notification(s: Seeder, admin: str) -> Any:
|
||||||
|
"""Return the id of a notification of the admin if one exists, else 1."""
|
||||||
|
deadline = time.monotonic() + 5
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
listing = s.call("GET", "/notifications", params={"all": "true"})
|
||||||
|
if listing:
|
||||||
|
return listing[0]["id"]
|
||||||
|
time.sleep(0.5)
|
||||||
|
return 1
|
||||||
@@ -127,7 +127,10 @@ def test_cleanup_removes_container_and_network() -> None:
|
|||||||
assert run_id not in harness._ACTIVE
|
assert run_id not in harness._ACTIVE
|
||||||
|
|
||||||
|
|
||||||
def test_cleanup_all_handles_registered_runs() -> None:
|
def test_cleanup_all_handles_registered_runs(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
# cleanup_all removes every tracked run, including the session's own Gitea, so this
|
||||||
|
# test works on a private registry instead of the real one.
|
||||||
|
monkeypatch.setattr(harness, "_ACTIVE", {})
|
||||||
run_id = harness.secrets.token_hex(4)
|
run_id = harness.secrets.token_hex(4)
|
||||||
name = f"{harness.NAME_PREFIX}{run_id}"
|
name = f"{harness.NAME_PREFIX}{run_id}"
|
||||||
harness.register_resources(RunResources(run_id=run_id, container=name, network=name))
|
harness.register_resources(RunResources(run_id=run_id, container=name, network=name))
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
"""Per-user mode against the live instance: calls run as the signed-in Gitea user.
|
||||||
|
|
||||||
|
The OAuth store is seeded directly (a session for a non-admin user whose Gitea token is a
|
||||||
|
personal access token, plus our own issued access token) instead of running the browser
|
||||||
|
flow, then the real HTTP app is driven in process with the MCP SDK client.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx2
|
||||||
|
import pytest
|
||||||
|
from mcp import Client
|
||||||
|
from mcp.client.streamable_http import streamable_http_client
|
||||||
|
from starlette.applications import Starlette
|
||||||
|
|
||||||
|
from aegis_gitea_mcp.audit import AuditLogger
|
||||||
|
from aegis_gitea_mcp.auth import MCP_SCOPE
|
||||||
|
from aegis_gitea_mcp.config import Settings
|
||||||
|
from aegis_gitea_mcp.oauth.components import build_oauth_components
|
||||||
|
from aegis_gitea_mcp.oauth.provider import ACCESS_TOKEN_TTL, TOKEN_PREFIX
|
||||||
|
from aegis_gitea_mcp.oauth.store import OAuthStore, SessionRecord, TokenRecord, token_hash
|
||||||
|
from aegis_gitea_mcp.server import build_server
|
||||||
|
from aegis_gitea_mcp.transports import build_http_app
|
||||||
|
from tests.integration.conftest import McpSession, RegularUser
|
||||||
|
from tests.integration.harness import GiteaInstance
|
||||||
|
|
||||||
|
PUBLIC_BASE = "https://mcp.example.test"
|
||||||
|
RESOURCE = f"{PUBLIC_BASE}/mcp"
|
||||||
|
ENCRYPTION_KEY = secrets.token_urlsafe(48)
|
||||||
|
OAUTH_CLIENT_ID = "it-oauth-client"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OAuthStack:
|
||||||
|
"""The HTTP app with its seeded tokens and the secrets the tests must never see."""
|
||||||
|
|
||||||
|
app: Starlette
|
||||||
|
audit_path: Path
|
||||||
|
bearer: str
|
||||||
|
user_login: str
|
||||||
|
user_access_token: str
|
||||||
|
secrets: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
async def build_stack(
|
||||||
|
gitea: GiteaInstance, user: RegularUser, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> OAuthStack:
|
||||||
|
"""Build the app with per-user OAuth enabled and a signed-in session for ``user``."""
|
||||||
|
monkeypatch.setenv("GITEA_URL", gitea.base_url)
|
||||||
|
data_dir = tmp_path / "oauth-state"
|
||||||
|
bearer = secrets.token_urlsafe(36)
|
||||||
|
oauth_client_secret = secrets.token_urlsafe(36)
|
||||||
|
settings = Settings(
|
||||||
|
gitea_url=gitea.base_url,
|
||||||
|
gitea_token=gitea.admin_token,
|
||||||
|
mcp_bearer_token=bearer,
|
||||||
|
oauth_enabled=True,
|
||||||
|
gitea_oauth_client_id=OAUTH_CLIENT_ID,
|
||||||
|
gitea_oauth_client_secret=oauth_client_secret,
|
||||||
|
token_encryption_key=ENCRYPTION_KEY,
|
||||||
|
public_base_url=PUBLIC_BASE,
|
||||||
|
data_dir=data_dir,
|
||||||
|
audit_log_path=data_dir / "audit.log",
|
||||||
|
request_timeout_seconds=60,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Seed the encrypted store: one session (the user's own Gitea token) and one of our
|
||||||
|
# access tokens (stored only as a hash), exactly what the browser flow would leave.
|
||||||
|
store = OAuthStore.load(data_dir, ENCRYPTION_KEY)
|
||||||
|
session_id = secrets.token_hex(16)
|
||||||
|
store.sessions[session_id] = SessionRecord(
|
||||||
|
session_id=session_id,
|
||||||
|
client_id=OAUTH_CLIENT_ID,
|
||||||
|
login=user.login,
|
||||||
|
is_admin=False,
|
||||||
|
gitea_access_token=user.token,
|
||||||
|
gitea_refresh_token=None,
|
||||||
|
gitea_expires_at=None,
|
||||||
|
scopes=[MCP_SCOPE],
|
||||||
|
resource=RESOURCE,
|
||||||
|
created_at=time.time(),
|
||||||
|
)
|
||||||
|
access = TOKEN_PREFIX + secrets.token_urlsafe(32)
|
||||||
|
store.tokens[token_hash(access)] = TokenRecord(
|
||||||
|
kind="access",
|
||||||
|
session_id=session_id,
|
||||||
|
client_id=OAUTH_CLIENT_ID,
|
||||||
|
scopes=[MCP_SCOPE],
|
||||||
|
expires_at=time.time() + ACCESS_TOKEN_TTL,
|
||||||
|
)
|
||||||
|
await store.save()
|
||||||
|
|
||||||
|
audit = AuditLogger(settings.resolved_audit_log_path)
|
||||||
|
extras = build_oauth_components(settings, audit=audit)
|
||||||
|
server = build_server(settings, resolver=extras.resolver, executor=extras.executor, audit=audit)
|
||||||
|
app = build_http_app(
|
||||||
|
settings,
|
||||||
|
server=server,
|
||||||
|
token_verifier=extras.token_verifier,
|
||||||
|
auth_server_provider=extras.auth_server_provider,
|
||||||
|
extra_routes=extras.extra_routes,
|
||||||
|
client_registration_options=extras.client_registration_options,
|
||||||
|
revocation_options=extras.revocation_options,
|
||||||
|
rate_limited_paths=extras.rate_limited_paths,
|
||||||
|
)
|
||||||
|
return OAuthStack(
|
||||||
|
app=app,
|
||||||
|
audit_path=settings.resolved_audit_log_path,
|
||||||
|
bearer=bearer,
|
||||||
|
user_login=user.login,
|
||||||
|
user_access_token=access,
|
||||||
|
secrets=[
|
||||||
|
bearer,
|
||||||
|
access,
|
||||||
|
user.token,
|
||||||
|
gitea.admin_token,
|
||||||
|
oauth_client_secret,
|
||||||
|
ENCRYPTION_KEY,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def http_session(app: Starlette, token: str, audit_path: Path) -> AsyncIterator[McpSession]:
|
||||||
|
"""An MCP session over the real HTTP app (in process) with ``token`` as bearer."""
|
||||||
|
async with (
|
||||||
|
app.router.lifespan_context(app),
|
||||||
|
httpx2.AsyncClient(
|
||||||
|
transport=httpx2.ASGITransport(app=app),
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
base_url=PUBLIC_BASE,
|
||||||
|
timeout=60.0,
|
||||||
|
) as http_client,
|
||||||
|
Client(streamable_http_client(RESOURCE, http_client=http_client)) as client,
|
||||||
|
):
|
||||||
|
yield McpSession(client, audit_path)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def stack(
|
||||||
|
gitea: GiteaInstance,
|
||||||
|
regular_user: RegularUser,
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> OAuthStack:
|
||||||
|
return await build_stack(gitea, regular_user, tmp_path, monkeypatch)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_calls_run_as_the_signed_in_user(
|
||||||
|
gitea: GiteaInstance, regular_user: RegularUser, stack: OAuthStack
|
||||||
|
) -> None:
|
||||||
|
private_repo = gitea.unique("it-private")
|
||||||
|
created = gitea.api(
|
||||||
|
"POST",
|
||||||
|
f"/admin/users/{gitea.admin_login}/repos",
|
||||||
|
json={"name": private_repo, "private": True, "auto_init": True},
|
||||||
|
)
|
||||||
|
assert created.status_code == 201
|
||||||
|
try:
|
||||||
|
async with http_session(stack.app, stack.user_access_token, stack.audit_path) as mcp:
|
||||||
|
me = await mcp.ok("gitea_user_get_current")
|
||||||
|
assert me.identity == {"login": regular_user.login, "mode": "oauth", "sudo": None}
|
||||||
|
assert me.data["login"] == regular_user.login
|
||||||
|
assert me.data["is_admin"] is False
|
||||||
|
|
||||||
|
# The user can create and read their own repository ...
|
||||||
|
own = gitea.unique("it-own")
|
||||||
|
await mcp.ok("gitea_user_current_create_repo", status=201, body={"name": own})
|
||||||
|
assert (await mcp.ok("gitea_repo_get", owner=regular_user.login, repo=own)).data[
|
||||||
|
"owner"
|
||||||
|
]["login"] == regular_user.login
|
||||||
|
|
||||||
|
# ... but not another user's private repository: Gitea hides it.
|
||||||
|
hidden = await mcp.call("gitea_repo_get", owner=gitea.admin_login, repo=private_repo)
|
||||||
|
assert hidden.is_error
|
||||||
|
assert hidden.status in (403, 404)
|
||||||
|
assert hidden.error_kind in ("forbidden", "not_found")
|
||||||
|
assert (
|
||||||
|
await mcp.call(
|
||||||
|
"gitea_repo_get_contents",
|
||||||
|
owner=gitea.admin_login,
|
||||||
|
repo=private_repo,
|
||||||
|
filepath="README.md",
|
||||||
|
)
|
||||||
|
).status in (403, 404)
|
||||||
|
|
||||||
|
# Admin tools and sudo are refused by Gitea with 403, not by the server.
|
||||||
|
admin_only = await mcp.call("gitea_admin_cron_list")
|
||||||
|
assert admin_only.is_error and admin_only.status == 403
|
||||||
|
assert admin_only.error_kind == "forbidden"
|
||||||
|
assert admin_only.identity["mode"] == "oauth"
|
||||||
|
sudo = await mcp.call("gitea_user_get_current", sudo=gitea.admin_login)
|
||||||
|
assert sudo.is_error and sudo.status == 403
|
||||||
|
|
||||||
|
entries = mcp.audit_entries()
|
||||||
|
assert entries and all(e["identity_mode"] == "oauth" for e in entries)
|
||||||
|
assert all(e["identity_login"] == regular_user.login for e in entries)
|
||||||
|
assert {e["tool_name"] for e in entries} >= {
|
||||||
|
"gitea_user_get_current",
|
||||||
|
"gitea_admin_cron_list",
|
||||||
|
}
|
||||||
|
gitea.api("DELETE", f"/repos/{regular_user.login}/{own}")
|
||||||
|
|
||||||
|
texts = "\n".join(mcp.texts) + stack.audit_path.read_text(encoding="utf-8")
|
||||||
|
for secret in stack.secrets:
|
||||||
|
assert secret not in texts
|
||||||
|
finally:
|
||||||
|
gitea.api("DELETE", f"/repos/{gitea.admin_login}/{private_repo}")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_static_bearer_in_the_same_app_runs_as_admin(
|
||||||
|
gitea: GiteaInstance, stack: OAuthStack
|
||||||
|
) -> None:
|
||||||
|
async with http_session(stack.app, stack.bearer, stack.audit_path) as mcp:
|
||||||
|
me = await mcp.ok("gitea_user_get_current")
|
||||||
|
assert me.identity == {"login": gitea.admin_login, "mode": "admin-token", "sudo": None}
|
||||||
|
assert me.data["is_admin"] is True
|
||||||
|
cron = await mcp.ok("gitea_admin_cron_list")
|
||||||
|
assert cron.data
|
||||||
|
entries = mcp.audit_entries()
|
||||||
|
assert {e["identity_mode"] for e in entries} == {"admin-token"}
|
||||||
|
texts = "\n".join(mcp.texts)
|
||||||
|
for secret in stack.secrets:
|
||||||
|
assert secret not in texts
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unknown_and_expired_tokens_never_reach_gitea(
|
||||||
|
stack: OAuthStack, regular_user: RegularUser
|
||||||
|
) -> None:
|
||||||
|
body: dict[str, Any] = {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"method": "initialize",
|
||||||
|
"params": {
|
||||||
|
"protocolVersion": "2025-11-25",
|
||||||
|
"capabilities": {},
|
||||||
|
"clientInfo": {"name": "it", "version": "0"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
headers = {"Accept": "application/json, text/event-stream"}
|
||||||
|
async with (
|
||||||
|
stack.app.router.lifespan_context(stack.app),
|
||||||
|
httpx2.AsyncClient(
|
||||||
|
transport=httpx2.ASGITransport(app=stack.app), base_url=PUBLIC_BASE
|
||||||
|
) as http_client,
|
||||||
|
):
|
||||||
|
for token in (
|
||||||
|
TOKEN_PREFIX + secrets.token_urlsafe(32), # looks like ours, unknown
|
||||||
|
regular_user.token, # the user's own Gitea token is not an MCP token
|
||||||
|
secrets.token_urlsafe(36),
|
||||||
|
):
|
||||||
|
response = await http_client.post(
|
||||||
|
"/mcp", json=body, headers={**headers, "Authorization": f"Bearer {token}"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert token not in response.text
|
||||||
|
anonymous = await http_client.post("/mcp", json=body, headers=headers)
|
||||||
|
assert anonymous.status_code == 401
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"""Scenario: a real Actions run on an act_runner container, read back through the tools."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.integration.conftest import McpSession, RepoRef, b64
|
||||||
|
from tests.integration.harness import GiteaInstance, HarnessError, runner_logs, start_runner
|
||||||
|
|
||||||
|
WORKFLOW_PATH = ".gitea/workflows/it.yml"
|
||||||
|
WORKFLOW = """\
|
||||||
|
name: it
|
||||||
|
on: [push, workflow_dispatch]
|
||||||
|
jobs:
|
||||||
|
hello:
|
||||||
|
runs-on: aegis-host
|
||||||
|
steps:
|
||||||
|
- name: Say hello
|
||||||
|
run: echo hello
|
||||||
|
"""
|
||||||
|
RUNNER_ONLINE_TIMEOUT = 90.0
|
||||||
|
RUN_TIMEOUT = 180.0
|
||||||
|
|
||||||
|
|
||||||
|
async def _wait_for(probe: Any, *, timeout: float, interval: float = 2.0) -> Any:
|
||||||
|
"""Await ``probe()`` until it returns something truthy; return it (or None on timeout)."""
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while True:
|
||||||
|
value = await probe()
|
||||||
|
if value or time.monotonic() >= deadline:
|
||||||
|
return value
|
||||||
|
await asyncio.sleep(interval)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_workflow_run_on_act_runner(
|
||||||
|
mcp: McpSession, gitea: GiteaInstance, fresh_repo: RepoRef
|
||||||
|
) -> None:
|
||||||
|
repo = fresh_repo.args
|
||||||
|
token = await mcp.ok("gitea_actions_repo_create_runner_registration_token", **repo)
|
||||||
|
assert isinstance(token.data["token"], str) and token.data["token"]
|
||||||
|
try:
|
||||||
|
runner = start_runner(gitea, token.data["token"])
|
||||||
|
except HarnessError as exc:
|
||||||
|
pytest.xfail(f"the act_runner container could not be started: {exc}")
|
||||||
|
|
||||||
|
async def online() -> dict[str, Any] | None:
|
||||||
|
runners = await mcp.ok("gitea_actions_get_repo_runners", **repo)
|
||||||
|
entries = runners.data["runners"] if isinstance(runners.data, dict) else runners.data
|
||||||
|
for entry in entries or []:
|
||||||
|
if entry.get("status") in ("online", "idle", "active"):
|
||||||
|
found: dict[str, Any] = entry
|
||||||
|
return found
|
||||||
|
return None
|
||||||
|
|
||||||
|
registered = await _wait_for(online, timeout=RUNNER_ONLINE_TIMEOUT)
|
||||||
|
if not registered:
|
||||||
|
pytest.xfail(
|
||||||
|
"the act_runner did not come online within "
|
||||||
|
f"{RUNNER_ONLINE_TIMEOUT:.0f}s; runner log tail: {runner_logs(runner, 15)}"
|
||||||
|
)
|
||||||
|
assert token.data["token"] not in runner_logs(runner, 200)
|
||||||
|
|
||||||
|
# Commit the workflow through the contents tool, then dispatch it explicitly.
|
||||||
|
await mcp.ok(
|
||||||
|
"gitea_repo_create_file",
|
||||||
|
status=201,
|
||||||
|
filepath=WORKFLOW_PATH,
|
||||||
|
body={"content": b64(WORKFLOW), "message": "add it workflow", "branch": "main"},
|
||||||
|
**repo,
|
||||||
|
)
|
||||||
|
workflows = await mcp.ok("gitea_actions_list_repository_workflows", **repo)
|
||||||
|
assert any(w["path"].endswith("it.yml") for w in workflows.data["workflows"])
|
||||||
|
await mcp.ok(
|
||||||
|
"gitea_actions_dispatch_workflow",
|
||||||
|
status=(200, 204),
|
||||||
|
workflow_id="it.yml",
|
||||||
|
body={"ref": "main"},
|
||||||
|
**repo,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def finished() -> list[dict[str, Any]] | None:
|
||||||
|
runs = await mcp.ok("gitea_actions_get_workflow_runs", **repo)
|
||||||
|
entries = runs.data["workflow_runs"]
|
||||||
|
if len(entries) >= 2 and all(r["status"] == "completed" for r in entries):
|
||||||
|
done: list[dict[str, Any]] = entries
|
||||||
|
return done
|
||||||
|
return None
|
||||||
|
|
||||||
|
runs = await _wait_for(finished, timeout=RUN_TIMEOUT)
|
||||||
|
assert runs, (
|
||||||
|
f"workflow runs did not complete within {RUN_TIMEOUT:.0f}s; "
|
||||||
|
f"runner log tail: {runner_logs(runner, 20)}"
|
||||||
|
)
|
||||||
|
assert {r["event"] for r in runs} == {"push", "workflow_dispatch"}
|
||||||
|
assert [r["conclusion"] for r in runs] == ["success", "success"]
|
||||||
|
|
||||||
|
run_id = runs[0]["id"]
|
||||||
|
one = await mcp.ok("gitea_actions_get_workflow_run", run=run_id, **repo)
|
||||||
|
assert one.data["status"] == "completed"
|
||||||
|
jobs = await mcp.ok("gitea_actions_list_workflow_run_jobs", run=run_id, **repo)
|
||||||
|
job = jobs.data["jobs"][0]
|
||||||
|
assert job["conclusion"] == "success"
|
||||||
|
job_id = str(job["id"]) # the job id is a string argument
|
||||||
|
fetched = await mcp.ok("gitea_actions_get_workflow_job", job_id=job_id, **repo)
|
||||||
|
assert fetched.data["id"] == job["id"]
|
||||||
|
logs = await mcp.ok("gitea_actions_download_run_job_logs", job_id=job["id"], **repo)
|
||||||
|
assert isinstance(logs.data, str)
|
||||||
|
assert "hello" in logs.data
|
||||||
|
artifacts = await mcp.ok("gitea_actions_get_artifacts_of_run", run=run_id, **repo)
|
||||||
|
assert artifacts.data["artifacts"] == []
|
||||||
|
assert (await mcp.ok("gitea_actions_get_artifacts", **repo)).data["total_count"] == 0
|
||||||
|
tasks = await mcp.ok("gitea_actions_list_action_tasks", **repo)
|
||||||
|
assert tasks.data["total_count"] >= 2
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
"""Scenarios: site administration, sudo, notifications, current-user features and packages."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||||
|
|
||||||
|
from tests.integration.conftest import McpSession, RegularUser, RepoRef, drop_org, drop_user
|
||||||
|
from tests.integration.harness import GiteaInstance
|
||||||
|
|
||||||
|
|
||||||
|
def ssh_public_key() -> str:
|
||||||
|
"""A fresh ed25519 public key in OpenSSH format (throwaway, the private half is dropped)."""
|
||||||
|
key = Ed25519PrivateKey.generate().public_key()
|
||||||
|
raw = key.public_bytes(serialization.Encoding.OpenSSH, serialization.PublicFormat.OpenSSH)
|
||||||
|
return raw.decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def logins(items: Any, key: str = "login") -> list[str]:
|
||||||
|
"""The ``key`` member of every dict in a list result."""
|
||||||
|
return [item[key] for item in items]
|
||||||
|
|
||||||
|
|
||||||
|
# Cron tasks that are safe to trigger on a throwaway instance.
|
||||||
|
SAFE_CRON_TASKS = ("cleanup_hook_task_table", "cleanup_expired_actions_assets", "check_repo_stats")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_admin_manages_users_orgs_keys_and_cron(
|
||||||
|
mcp: McpSession, gitea: GiteaInstance, fresh_repo: RepoRef
|
||||||
|
) -> None:
|
||||||
|
# fresh_repo makes sure the repository root exists (the unadopted scan needs it).
|
||||||
|
login = gitea.unique("it-adm")
|
||||||
|
org = gitea.unique("it-adm-org")
|
||||||
|
email = f"{login}@example.test"
|
||||||
|
try:
|
||||||
|
created = await mcp.ok(
|
||||||
|
"gitea_admin_create_user",
|
||||||
|
status=201,
|
||||||
|
body={
|
||||||
|
"username": login,
|
||||||
|
"email": email,
|
||||||
|
"password": secrets.token_urlsafe(18),
|
||||||
|
"must_change_password": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert created.data["login"] == login
|
||||||
|
edited = await mcp.ok(
|
||||||
|
"gitea_admin_edit_user",
|
||||||
|
username=login,
|
||||||
|
body={"source_id": 0, "login_name": login, "full_name": "Integration User"},
|
||||||
|
)
|
||||||
|
assert edited.data["full_name"] == "Integration User"
|
||||||
|
|
||||||
|
found = await mcp.ok("gitea_admin_search_users", q=login)
|
||||||
|
assert login in logins(found.data)
|
||||||
|
assert email in logins(
|
||||||
|
(await mcp.ok("gitea_admin_get_all_emails")).data, "email"
|
||||||
|
) or email in logins((await mcp.ok("gitea_admin_search_emails", q=login)).data, "email")
|
||||||
|
|
||||||
|
# SSH key for the user, listed as the user, then removed.
|
||||||
|
key = await mcp.ok(
|
||||||
|
"gitea_admin_create_public_key",
|
||||||
|
status=201,
|
||||||
|
username=login,
|
||||||
|
body={"title": "it key", "key": ssh_public_key()},
|
||||||
|
)
|
||||||
|
listed = await mcp.ok("gitea_user_list_keys", username=login)
|
||||||
|
assert key.data["id"] in [item["id"] for item in listed.data]
|
||||||
|
await mcp.ok("gitea_admin_delete_user_public_key", username=login, id=key.data["id"])
|
||||||
|
assert (await mcp.ok("gitea_user_list_keys", username=login)).data == []
|
||||||
|
|
||||||
|
# An organization created on behalf of the user.
|
||||||
|
await mcp.ok("gitea_admin_create_org", status=201, username=login, body={"username": org})
|
||||||
|
assert org in logins((await mcp.ok("gitea_admin_get_all_orgs")).data, "name")
|
||||||
|
assert org in logins((await mcp.ok("gitea_user_list_orgs", username=login)).data, "name")
|
||||||
|
await mcp.ok("gitea_org_delete", org=org)
|
||||||
|
|
||||||
|
# Cron: list the tasks and run one safe task.
|
||||||
|
tasks = await mcp.ok("gitea_admin_cron_list")
|
||||||
|
task_names = logins(tasks.data, "name")
|
||||||
|
assert task_names
|
||||||
|
chosen = next((name for name in SAFE_CRON_TASKS if name in task_names), task_names[0])
|
||||||
|
await mcp.ok("gitea_admin_cron_run", task=chosen)
|
||||||
|
|
||||||
|
# Unadopted repositories and runner registration token.
|
||||||
|
await mcp.ok("gitea_admin_unadopted_list")
|
||||||
|
token = await mcp.ok("gitea_admin_create_runner_registration_token", status=(200, 201))
|
||||||
|
assert isinstance(token.data["token"], str) and token.data["token"]
|
||||||
|
assert await mcp.ok("gitea_admin_get_runners")
|
||||||
|
|
||||||
|
await mcp.ok("gitea_admin_delete_user", username=login, purge=True)
|
||||||
|
await mcp.fail("gitea_user_get", 404, username=login)
|
||||||
|
finally:
|
||||||
|
drop_org(gitea, org)
|
||||||
|
drop_user(gitea, login)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_sudo_acts_as_the_named_user(
|
||||||
|
mcp: McpSession, gitea: GiteaInstance, regular_user: RegularUser
|
||||||
|
) -> None:
|
||||||
|
me = regular_user.login
|
||||||
|
plain = await mcp.ok("gitea_user_get_current")
|
||||||
|
assert plain.data["login"] == gitea.admin_login
|
||||||
|
assert plain.identity == {"login": gitea.admin_login, "mode": "admin-token", "sudo": None}
|
||||||
|
|
||||||
|
sudoed = await mcp.ok("gitea_user_get_current", sudo=me)
|
||||||
|
assert sudoed.data["login"] == me
|
||||||
|
assert sudoed.identity["sudo"] == me
|
||||||
|
assert sudoed.identity["login"] == gitea.admin_login
|
||||||
|
|
||||||
|
repo = gitea.unique("sudo-repo")
|
||||||
|
try:
|
||||||
|
made = await mcp.ok(
|
||||||
|
"gitea_user_current_create_repo", status=201, sudo=me, body={"name": repo}
|
||||||
|
)
|
||||||
|
assert made.data["owner"]["login"] == me
|
||||||
|
assert gitea.api("GET", f"/repos/{me}/{repo}").status_code == 200
|
||||||
|
finally:
|
||||||
|
gitea.api("DELETE", f"/repos/{me}/{repo}")
|
||||||
|
|
||||||
|
# The audit log records who acted and who they acted as.
|
||||||
|
entries = [e for e in mcp.audit_entries() if e.get("sudo") == me]
|
||||||
|
assert {e["tool_name"] for e in entries} == {
|
||||||
|
"gitea_user_get_current",
|
||||||
|
"gitea_user_current_create_repo",
|
||||||
|
}
|
||||||
|
assert all(e["identity_login"] == gitea.admin_login for e in entries)
|
||||||
|
assert all(e["identity_mode"] == "admin-token" for e in entries)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_notifications_list_and_mark_read(
|
||||||
|
mcp: McpSession, gitea: GiteaInstance, regular_user: RegularUser, fresh_repo: RepoRef
|
||||||
|
) -> None:
|
||||||
|
repo = fresh_repo.args
|
||||||
|
# A second user assigns the admin to a new issue, which notifies the admin.
|
||||||
|
await mcp.ok(
|
||||||
|
"gitea_repo_add_collaborator",
|
||||||
|
collaborator=regular_user.login,
|
||||||
|
body={"permission": "write"},
|
||||||
|
**repo,
|
||||||
|
)
|
||||||
|
await mcp.ok(
|
||||||
|
"gitea_issue_create_issue",
|
||||||
|
status=201,
|
||||||
|
sudo=regular_user.login,
|
||||||
|
body={"title": "Ping", "assignees": [gitea.admin_login]},
|
||||||
|
**repo,
|
||||||
|
)
|
||||||
|
deadline = time.monotonic() + 30
|
||||||
|
notices: list[dict[str, Any]] = []
|
||||||
|
while time.monotonic() < deadline and not notices:
|
||||||
|
notices = (await mcp.ok("gitea_notification_get_list", **{"status-types": ["unread"]})).data
|
||||||
|
if not notices:
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
assert notices, "no notification arrived within 30 seconds"
|
||||||
|
assert (await mcp.ok("gitea_notification_new_available")).data["new"] >= 1
|
||||||
|
repo_list = await mcp.ok("gitea_notification_get_repo_list", **repo)
|
||||||
|
assert repo_list.data
|
||||||
|
|
||||||
|
thread_id = str(notices[0]["id"])
|
||||||
|
thread = await mcp.ok("gitea_notification_get_thread", id=thread_id)
|
||||||
|
assert thread.data["unread"] is True
|
||||||
|
await mcp.ok("gitea_notification_read_thread", status=(200, 205), id=thread_id)
|
||||||
|
assert (await mcp.ok("gitea_notification_get_thread", id=thread_id)).data["unread"] is False
|
||||||
|
|
||||||
|
await mcp.ok("gitea_notification_read_list", status=(200, 205))
|
||||||
|
assert (await mcp.ok("gitea_notification_get_list", **{"status-types": ["unread"]})).data == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_current_user_features(
|
||||||
|
mcp: McpSession, gitea: GiteaInstance, regular_user: RegularUser, fresh_repo: RepoRef
|
||||||
|
) -> None:
|
||||||
|
repo = fresh_repo.args
|
||||||
|
other = regular_user.login
|
||||||
|
me = await mcp.ok("gitea_user_get_current")
|
||||||
|
assert me.data["is_admin"] is True
|
||||||
|
assert (await mcp.ok("gitea_user_get", username=other)).data["login"] == other
|
||||||
|
assert (await mcp.ok("gitea_user_get_settings")).data is not None
|
||||||
|
|
||||||
|
# Email addresses.
|
||||||
|
address = f"extra-{secrets.token_hex(3)}@example.test"
|
||||||
|
await mcp.ok("gitea_user_add_email", status=201, body={"emails": [address]})
|
||||||
|
assert address in logins((await mcp.ok("gitea_user_list_emails")).data, "email")
|
||||||
|
await mcp.ok("gitea_user_delete_email", body={"emails": [address]})
|
||||||
|
assert address not in logins((await mcp.ok("gitea_user_list_emails")).data, "email")
|
||||||
|
|
||||||
|
# SSH keys and GPG keys.
|
||||||
|
key = await mcp.ok(
|
||||||
|
"gitea_user_current_post_key",
|
||||||
|
status=201,
|
||||||
|
body={"title": "it key", "key": ssh_public_key()},
|
||||||
|
)
|
||||||
|
key_id = key.data["id"]
|
||||||
|
assert key_id in [k["id"] for k in (await mcp.ok("gitea_user_current_list_keys")).data]
|
||||||
|
assert (await mcp.ok("gitea_user_current_get_key", id=key_id)).data["title"] == "it key"
|
||||||
|
await mcp.ok("gitea_user_current_delete_key", id=key_id)
|
||||||
|
assert (await mcp.ok("gitea_user_current_list_gpg_keys")).data == []
|
||||||
|
assert (await mcp.ok("gitea_user_list_gpg_keys", username=other)).data == []
|
||||||
|
|
||||||
|
# Follow and unfollow.
|
||||||
|
await mcp.ok("gitea_user_current_put_follow", username=other)
|
||||||
|
await mcp.ok("gitea_user_current_check_following", username=other)
|
||||||
|
await mcp.ok("gitea_user_check_following", username=gitea.admin_login, target=other)
|
||||||
|
assert other in logins((await mcp.ok("gitea_user_current_list_following")).data)
|
||||||
|
assert gitea.admin_login in logins(
|
||||||
|
(await mcp.ok("gitea_user_list_followers", username=other)).data
|
||||||
|
)
|
||||||
|
await mcp.ok("gitea_user_current_delete_follow", username=other)
|
||||||
|
await mcp.fail("gitea_user_current_check_following", 404, username=other)
|
||||||
|
|
||||||
|
# Block and unblock.
|
||||||
|
await mcp.ok("gitea_user_block_user", username=other)
|
||||||
|
await mcp.ok("gitea_user_check_user_block", username=other)
|
||||||
|
assert other in logins((await mcp.ok("gitea_user_list_blocks")).data)
|
||||||
|
await mcp.ok("gitea_user_unblock_user", username=other)
|
||||||
|
await mcp.fail("gitea_user_check_user_block", 404, username=other)
|
||||||
|
|
||||||
|
# Starred and watched repositories.
|
||||||
|
await mcp.ok("gitea_user_current_put_star", **repo)
|
||||||
|
await mcp.ok("gitea_user_current_check_starring", **repo)
|
||||||
|
assert fresh_repo.name in logins((await mcp.ok("gitea_user_current_list_starred")).data, "name")
|
||||||
|
await mcp.ok("gitea_user_current_delete_star", **repo)
|
||||||
|
await mcp.ok("gitea_repo_put_subscription", **repo)
|
||||||
|
watched = await mcp.ok("gitea_user_current_list_subscriptions")
|
||||||
|
assert fresh_repo.name in logins(watched.data, "name")
|
||||||
|
await mcp.ok("gitea_repo_delete_subscription", **repo)
|
||||||
|
|
||||||
|
# Search.
|
||||||
|
assert other in logins((await mcp.ok("gitea_user_search", q=other)).data["data"])
|
||||||
|
|
||||||
|
|
||||||
|
async def test_generic_package_roundtrip(mcp: McpSession, gitea: GiteaInstance) -> None:
|
||||||
|
owner = gitea.admin_login
|
||||||
|
name = gitea.unique("it-pkg")
|
||||||
|
content = b"package bytes " + secrets.token_bytes(16)
|
||||||
|
upload = gitea.request(
|
||||||
|
"PUT", f"/api/packages/{owner}/generic/{name}/1.0.0/payload.bin", content=content
|
||||||
|
)
|
||||||
|
assert upload.status_code == 201, upload.text
|
||||||
|
|
||||||
|
deadline = time.monotonic() + 15
|
||||||
|
listed: list[Any] = []
|
||||||
|
while time.monotonic() < deadline and not listed:
|
||||||
|
listed = (
|
||||||
|
await mcp.ok("gitea_package_list_packages", owner=owner, type="generic", q=name)
|
||||||
|
).data
|
||||||
|
if not listed:
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
assert [item["name"] for item in listed] == [name]
|
||||||
|
assert listed[0]["version"] == "1.0.0"
|
||||||
|
|
||||||
|
pkg = {"owner": owner, "type": "generic", "name": name}
|
||||||
|
got = await mcp.ok("gitea_package_get_package", version="1.0.0", **pkg)
|
||||||
|
assert got.data["name"] == name
|
||||||
|
files = await mcp.ok("gitea_package_list_package_files", version="1.0.0", **pkg)
|
||||||
|
assert [item["name"] for item in files.data] == ["payload.bin"]
|
||||||
|
assert files.data[0]["size"] == len(content)
|
||||||
|
latest = await mcp.ok("gitea_package_get_latest_package_version", **pkg)
|
||||||
|
assert latest.data["version"] == "1.0.0"
|
||||||
|
assert [
|
||||||
|
v["version"] for v in (await mcp.ok("gitea_package_list_package_versions", **pkg)).data
|
||||||
|
] == ["1.0.0"]
|
||||||
|
|
||||||
|
await mcp.ok("gitea_package_delete_package_version", version="1.0.0", **pkg)
|
||||||
|
await mcp.fail("gitea_package_get_package", 404, version="1.0.0", **pkg)
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
"""Scenarios: issues (labels, milestones, time tracking, reactions) and pull requests."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
|
||||||
|
from tests.integration.conftest import McpSession, RegularUser, RepoRef, b64
|
||||||
|
from tests.integration.harness import GiteaInstance
|
||||||
|
|
||||||
|
|
||||||
|
async def test_issue_lifecycle(
|
||||||
|
mcp: McpSession, regular_user: RegularUser, fresh_repo: RepoRef
|
||||||
|
) -> None:
|
||||||
|
repo = fresh_repo.args
|
||||||
|
watcher = regular_user.login
|
||||||
|
|
||||||
|
issue = await mcp.ok(
|
||||||
|
"gitea_issue_create_issue",
|
||||||
|
status=201,
|
||||||
|
body={"title": "Broken widget", "body": "It does not widget."},
|
||||||
|
**repo,
|
||||||
|
)
|
||||||
|
index = issue.data["number"]
|
||||||
|
assert issue.data["state"] == "open"
|
||||||
|
|
||||||
|
# Comments.
|
||||||
|
comment = await mcp.ok(
|
||||||
|
"gitea_issue_create_comment", status=201, index=index, body={"body": "first"}, **repo
|
||||||
|
)
|
||||||
|
comment_id = comment.data["id"]
|
||||||
|
edited = await mcp.ok(
|
||||||
|
"gitea_issue_edit_comment", id=comment_id, body={"body": "first, edited"}, **repo
|
||||||
|
)
|
||||||
|
assert edited.data["body"] == "first, edited"
|
||||||
|
comments = await mcp.ok("gitea_issue_get_comments", index=index, **repo)
|
||||||
|
assert [item["body"] for item in comments.data] == ["first, edited"]
|
||||||
|
|
||||||
|
# Labels: create, add, remove.
|
||||||
|
label = await mcp.ok(
|
||||||
|
"gitea_issue_create_label",
|
||||||
|
status=201,
|
||||||
|
body={"name": "bug", "color": "#d73a4a", "description": "something is broken"},
|
||||||
|
**repo,
|
||||||
|
)
|
||||||
|
label_id = label.data["id"]
|
||||||
|
added = await mcp.ok("gitea_issue_add_label", index=index, body={"labels": [label_id]}, **repo)
|
||||||
|
assert [item["name"] for item in added.data] == ["bug"]
|
||||||
|
assert (await mcp.ok("gitea_issue_get_labels", index=index, **repo)).data
|
||||||
|
await mcp.ok("gitea_issue_remove_label", index=index, id=label_id, **repo)
|
||||||
|
assert (await mcp.ok("gitea_issue_get_labels", index=index, **repo)).data == []
|
||||||
|
await mcp.ok("gitea_issue_replace_labels", index=index, body={"labels": [label_id]}, **repo)
|
||||||
|
await mcp.ok("gitea_issue_clear_labels", index=index, **repo)
|
||||||
|
|
||||||
|
# Milestone.
|
||||||
|
milestone = await mcp.ok(
|
||||||
|
"gitea_issue_create_milestone", status=201, body={"title": "v1"}, **repo
|
||||||
|
)
|
||||||
|
await mcp.ok(
|
||||||
|
"gitea_issue_edit_issue", index=index, body={"milestone": milestone.data["id"]}, **repo
|
||||||
|
)
|
||||||
|
assert (await mcp.ok("gitea_issue_get_issue", index=index, **repo)).data["milestone"][
|
||||||
|
"title"
|
||||||
|
] == "v1"
|
||||||
|
|
||||||
|
# Pin and unpin.
|
||||||
|
await mcp.ok("gitea_issue_pin_issue", index=index, **repo)
|
||||||
|
pinned = await mcp.ok("gitea_issue_list_pinned_issues", **repo)
|
||||||
|
assert [item["number"] for item in pinned.data] == [index]
|
||||||
|
await mcp.ok("gitea_issue_unpin_issue", index=index, **repo)
|
||||||
|
assert (await mcp.ok("gitea_issue_list_pinned_issues", **repo)).data == []
|
||||||
|
|
||||||
|
# Subscriptions.
|
||||||
|
await mcp.ok(
|
||||||
|
"gitea_issue_add_subscription", status=(200, 201), index=index, user=watcher, **repo
|
||||||
|
)
|
||||||
|
subscribers = await mcp.ok("gitea_issue_subscriptions", index=index, **repo)
|
||||||
|
assert watcher in [item["login"] for item in subscribers.data]
|
||||||
|
await mcp.ok("gitea_issue_delete_subscription", index=index, user=watcher, **repo)
|
||||||
|
assert (await mcp.ok("gitea_issue_subscriptions", index=index, **repo)).data == []
|
||||||
|
|
||||||
|
# Time tracking and the stopwatch.
|
||||||
|
await mcp.ok("gitea_issue_add_time", index=index, body={"time": 3600}, **repo)
|
||||||
|
times = await mcp.ok("gitea_issue_tracked_times", index=index, **repo)
|
||||||
|
assert [item["time"] for item in times.data] == [3600]
|
||||||
|
await mcp.ok("gitea_issue_delete_time", index=index, id=times.data[0]["id"], **repo)
|
||||||
|
assert (await mcp.ok("gitea_issue_tracked_times", index=index, **repo)).data == []
|
||||||
|
await mcp.ok("gitea_issue_start_stopwatch", status=201, index=index, **repo)
|
||||||
|
running = await mcp.ok("gitea_user_get_stopwatches")
|
||||||
|
assert index in [item["issue_index"] for item in running.data]
|
||||||
|
await mcp.ok("gitea_issue_stop_stopwatch", status=201, index=index, **repo)
|
||||||
|
assert (await mcp.ok("gitea_issue_tracked_times", index=index, **repo)).data
|
||||||
|
|
||||||
|
# Reactions.
|
||||||
|
await mcp.ok(
|
||||||
|
"gitea_issue_post_issue_reaction",
|
||||||
|
status=(200, 201),
|
||||||
|
index=index,
|
||||||
|
body={"content": "+1"},
|
||||||
|
**repo,
|
||||||
|
)
|
||||||
|
reactions = await mcp.ok("gitea_issue_get_issue_reactions", index=index, **repo)
|
||||||
|
assert [item["content"] for item in reactions.data] == ["+1"]
|
||||||
|
await mcp.ok("gitea_issue_delete_issue_reaction", index=index, body={"content": "+1"}, **repo)
|
||||||
|
|
||||||
|
# Search finds the issue (indexing is asynchronous, so poll briefly).
|
||||||
|
deadline = time.monotonic() + 20
|
||||||
|
hits: list[int] = []
|
||||||
|
while time.monotonic() < deadline and index not in hits:
|
||||||
|
found = await mcp.ok("gitea_issue_list_issues", q="widget", **repo)
|
||||||
|
hits = [item["number"] for item in found.data]
|
||||||
|
if index not in hits:
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
assert index in hits
|
||||||
|
assert (await mcp.ok("gitea_issue_search_issues", q="Broken widget")).data is not None
|
||||||
|
|
||||||
|
# Close.
|
||||||
|
closed = await mcp.ok("gitea_issue_edit_issue", index=index, body={"state": "closed"}, **repo)
|
||||||
|
assert closed.data["state"] == "closed"
|
||||||
|
assert (await mcp.ok("gitea_issue_get_issue", index=index, **repo)).data["state"] == "closed"
|
||||||
|
await mcp.ok("gitea_issue_delete_comment", id=comment_id, **repo)
|
||||||
|
await mcp.ok("gitea_issue_delete_label", id=label_id, **repo)
|
||||||
|
await mcp.ok("gitea_issue_delete_milestone", id=str(milestone.data["id"]), **repo)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pull_request_review_and_merge(
|
||||||
|
mcp: McpSession, gitea: GiteaInstance, regular_user: RegularUser, fresh_repo: RepoRef
|
||||||
|
) -> None:
|
||||||
|
repo = fresh_repo.args
|
||||||
|
reviewer = regular_user.login
|
||||||
|
await mcp.ok(
|
||||||
|
"gitea_repo_add_collaborator",
|
||||||
|
collaborator=reviewer,
|
||||||
|
body={"permission": "write"},
|
||||||
|
**repo,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
# A branch with a change, pushed through the contents API.
|
||||||
|
await mcp.ok(
|
||||||
|
"gitea_repo_create_branch",
|
||||||
|
status=201,
|
||||||
|
body={"new_branch_name": "feat/greeting", "old_branch_name": "main"},
|
||||||
|
**repo,
|
||||||
|
)
|
||||||
|
await mcp.ok(
|
||||||
|
"gitea_repo_create_file",
|
||||||
|
status=201,
|
||||||
|
filepath="greeting.txt",
|
||||||
|
body={
|
||||||
|
"content": b64("hello pull request\n"),
|
||||||
|
"message": "add greeting",
|
||||||
|
"branch": "feat/greeting",
|
||||||
|
},
|
||||||
|
**repo,
|
||||||
|
)
|
||||||
|
pull = await mcp.ok(
|
||||||
|
"gitea_pull_create_pull_request",
|
||||||
|
status=201,
|
||||||
|
body={"title": "Add greeting", "base": "main", "head": "feat/greeting"},
|
||||||
|
**repo,
|
||||||
|
)
|
||||||
|
index = pull.data["number"]
|
||||||
|
assert pull.data["state"] == "open"
|
||||||
|
assert not pull.data["merged"]
|
||||||
|
|
||||||
|
files = await mcp.ok("gitea_pull_get_pull_request_files", index=index, **repo)
|
||||||
|
assert [item["filename"] for item in files.data] == ["greeting.txt"]
|
||||||
|
commits = await mcp.ok("gitea_pull_get_pull_request_commits", index=index, **repo)
|
||||||
|
assert len(commits.data) == 1
|
||||||
|
|
||||||
|
diff = await mcp.ok(
|
||||||
|
"gitea_pull_download_pull_diff_or_patch", index=index, diffType="diff", **repo
|
||||||
|
)
|
||||||
|
assert isinstance(diff.data, str)
|
||||||
|
assert "+hello pull request" in diff.data
|
||||||
|
|
||||||
|
# Requested reviewer, then a pending review with an inline comment (as the
|
||||||
|
# reviewer, through sudo), submitted as an approval.
|
||||||
|
await mcp.ok(
|
||||||
|
"gitea_pull_create_pull_review_requests",
|
||||||
|
status=201,
|
||||||
|
index=index,
|
||||||
|
body={"reviewers": [reviewer]},
|
||||||
|
**repo,
|
||||||
|
)
|
||||||
|
pending = await mcp.ok(
|
||||||
|
"gitea_pull_create_pull_review",
|
||||||
|
sudo=reviewer,
|
||||||
|
index=index,
|
||||||
|
body={
|
||||||
|
"body": "looks fine",
|
||||||
|
"comments": [{"path": "greeting.txt", "new_position": 1, "body": "nice"}],
|
||||||
|
},
|
||||||
|
**repo,
|
||||||
|
)
|
||||||
|
assert pending.identity["sudo"] == reviewer
|
||||||
|
review_id = pending.data["id"]
|
||||||
|
assert pending.data["state"] == "PENDING"
|
||||||
|
submitted = await mcp.ok(
|
||||||
|
"gitea_pull_submit_pull_review",
|
||||||
|
sudo=reviewer,
|
||||||
|
index=index,
|
||||||
|
id=review_id,
|
||||||
|
body={"event": "APPROVED", "body": "approved"},
|
||||||
|
**repo,
|
||||||
|
)
|
||||||
|
assert submitted.data["state"] == "APPROVED"
|
||||||
|
reviews = await mcp.ok("gitea_pull_list_pull_reviews", index=index, **repo)
|
||||||
|
assert "APPROVED" in [item["state"] for item in reviews.data]
|
||||||
|
comments = await mcp.ok(
|
||||||
|
"gitea_pull_get_pull_review_comments", index=index, id=review_id, **repo
|
||||||
|
)
|
||||||
|
assert [item["body"] for item in comments.data] == ["nice"]
|
||||||
|
|
||||||
|
# Not merged yet, then merged.
|
||||||
|
await mcp.fail("gitea_pull_pull_request_is_merged", 404, index=index, **repo)
|
||||||
|
await mcp.ok("gitea_pull_merge_pull_request", index=index, body={"do": "merge"}, **repo)
|
||||||
|
await mcp.ok("gitea_pull_pull_request_is_merged", index=index, **repo)
|
||||||
|
merged = await mcp.ok("gitea_pull_get_pull_request", index=index, **repo)
|
||||||
|
assert merged.data["merged"] is True
|
||||||
|
assert merged.data["state"] == "closed"
|
||||||
|
readme = await mcp.ok("gitea_repo_get_contents", filepath="greeting.txt", **repo)
|
||||||
|
assert readme.data["path"] == "greeting.txt"
|
||||||
|
finally:
|
||||||
|
gitea.api("DELETE", f"/repos/{fresh_repo.owner}/{fresh_repo.name}/collaborators/{reviewer}")
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
"""Scenarios: organizations, teams and repositories, driven through the MCP server."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from tests.integration.conftest import McpSession, RegularUser, RepoRef, b64, drop_org
|
||||||
|
from tests.integration.harness import GiteaInstance
|
||||||
|
|
||||||
|
|
||||||
|
def names(items: Any, key: str = "name") -> list[str]:
|
||||||
|
"""The ``key`` member of every dict in a list result."""
|
||||||
|
return [item[key] for item in items]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_org_and_team_lifecycle(
|
||||||
|
mcp: McpSession, gitea: GiteaInstance, regular_user: RegularUser
|
||||||
|
) -> None:
|
||||||
|
org = gitea.unique("it-org")
|
||||||
|
repo = gitea.unique("team-repo")
|
||||||
|
try:
|
||||||
|
created = await mcp.ok(
|
||||||
|
"gitea_org_create", status=201, body={"username": org, "visibility": "public"}
|
||||||
|
)
|
||||||
|
assert created.data["name"] == org
|
||||||
|
|
||||||
|
fetched = await mcp.ok("gitea_org_get", org=org)
|
||||||
|
assert fetched.data["name"] == org
|
||||||
|
|
||||||
|
team = await mcp.ok(
|
||||||
|
"gitea_team_create_team",
|
||||||
|
status=201,
|
||||||
|
org=org,
|
||||||
|
body={
|
||||||
|
"name": "devs",
|
||||||
|
"permission": "write",
|
||||||
|
"includes_all_repositories": False,
|
||||||
|
"units": ["repo.code", "repo.issues", "repo.pulls"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
team_id = team.data["id"]
|
||||||
|
assert team.data["name"] == "devs"
|
||||||
|
|
||||||
|
listed = await mcp.ok("gitea_team_list_teams", org=org)
|
||||||
|
assert {"devs", "Owners"} <= set(names(listed.data))
|
||||||
|
found = await mcp.ok("gitea_team_search", org=org, q="dev")
|
||||||
|
assert "devs" in names(found.data["data"])
|
||||||
|
|
||||||
|
await mcp.ok("gitea_team_add_team_member", id=team_id, username=regular_user.login)
|
||||||
|
members = await mcp.ok("gitea_team_list_team_members", id=team_id)
|
||||||
|
assert regular_user.login in names(members.data, "login")
|
||||||
|
await mcp.ok("gitea_team_list_team_member", id=team_id, username=regular_user.login)
|
||||||
|
assert regular_user.login in names(
|
||||||
|
(await mcp.ok("gitea_org_list_members", org=org)).data, "login"
|
||||||
|
)
|
||||||
|
|
||||||
|
await mcp.ok("gitea_org_create_org_repo", status=201, org=org, body={"name": repo})
|
||||||
|
await mcp.ok("gitea_team_add_team_repository", id=team_id, org=org, repo=repo)
|
||||||
|
repos = await mcp.ok("gitea_team_list_team_repos", id=team_id)
|
||||||
|
assert repo in names(repos.data)
|
||||||
|
await mcp.ok("gitea_team_list_team_repo", id=team_id, org=org, repo=repo)
|
||||||
|
assert (await mcp.ok("gitea_org_list_repos", org=org)).data
|
||||||
|
|
||||||
|
edited = await mcp.ok(
|
||||||
|
"gitea_team_edit_team", id=team_id, body={"name": "devs", "description": "the devs"}
|
||||||
|
)
|
||||||
|
assert edited.data["description"] == "the devs"
|
||||||
|
|
||||||
|
await mcp.ok("gitea_team_remove_team_repository", id=team_id, org=org, repo=repo)
|
||||||
|
await mcp.fail("gitea_team_list_team_repo", 404, id=team_id, org=org, repo=repo)
|
||||||
|
await mcp.ok("gitea_team_remove_team_member", id=team_id, username=regular_user.login)
|
||||||
|
await mcp.fail("gitea_team_list_team_member", 404, id=team_id, username=regular_user.login)
|
||||||
|
|
||||||
|
await mcp.ok("gitea_team_delete_team", id=team_id)
|
||||||
|
await mcp.fail("gitea_team_get_team", 404, id=team_id)
|
||||||
|
|
||||||
|
await mcp.ok("gitea_repo_delete", owner=org, repo=repo)
|
||||||
|
await mcp.ok("gitea_org_delete", org=org)
|
||||||
|
await mcp.fail("gitea_org_get", 404, org=org)
|
||||||
|
finally:
|
||||||
|
drop_org(gitea, org)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_repo_files_branches_tags_and_topics(
|
||||||
|
mcp: McpSession, gitea: GiteaInstance, fresh_repo: RepoRef
|
||||||
|
) -> None:
|
||||||
|
repo = fresh_repo.args
|
||||||
|
info = await mcp.ok("gitea_repo_get", **repo)
|
||||||
|
assert info.data["full_name"] == fresh_repo.full
|
||||||
|
assert info.data["default_branch"] == "main"
|
||||||
|
|
||||||
|
# Files through the contents API: create, read, update, delete.
|
||||||
|
path = "docs/hello.txt"
|
||||||
|
created = await mcp.ok(
|
||||||
|
"gitea_repo_create_file",
|
||||||
|
status=201,
|
||||||
|
filepath=path,
|
||||||
|
body={"content": b64("hello"), "message": "add hello", "branch": "main"},
|
||||||
|
**repo,
|
||||||
|
)
|
||||||
|
assert created.data["content"]["path"] == path
|
||||||
|
got = await mcp.ok("gitea_repo_get_contents", filepath=path, **repo)
|
||||||
|
assert base64.b64decode(got.data["content"]) == b"hello"
|
||||||
|
sha = got.data["sha"]
|
||||||
|
updated = await mcp.ok(
|
||||||
|
"gitea_repo_update_file",
|
||||||
|
filepath=path,
|
||||||
|
body={"content": b64("hello again"), "sha": sha, "message": "update hello"},
|
||||||
|
**repo,
|
||||||
|
)
|
||||||
|
new_sha = updated.data["content"]["sha"]
|
||||||
|
assert new_sha != sha
|
||||||
|
raw = await mcp.ok("gitea_repo_get_raw_file", filepath=path, **repo)
|
||||||
|
body = raw.data
|
||||||
|
if isinstance(body, dict): # served as a binary type: base64 envelope
|
||||||
|
body = base64.b64decode(body["content_base64"]).decode("utf-8")
|
||||||
|
assert body == "hello again"
|
||||||
|
listing = await mcp.ok("gitea_repo_get_contents_list", **repo)
|
||||||
|
assert "README.md" in names(listing.data)
|
||||||
|
deleted = await mcp.ok(
|
||||||
|
"gitea_repo_delete_file",
|
||||||
|
filepath=path,
|
||||||
|
body={"sha": new_sha, "message": "remove hello"},
|
||||||
|
**repo,
|
||||||
|
)
|
||||||
|
assert deleted.data["commit"]["message"].startswith("remove hello")
|
||||||
|
await mcp.fail("gitea_repo_get_contents", 404, filepath=path, **repo)
|
||||||
|
|
||||||
|
# Branches.
|
||||||
|
await mcp.ok(
|
||||||
|
"gitea_repo_create_branch",
|
||||||
|
status=201,
|
||||||
|
body={"new_branch_name": "feature", "old_branch_name": "main"},
|
||||||
|
**repo,
|
||||||
|
)
|
||||||
|
assert "feature" in names((await mcp.ok("gitea_repo_list_branches", **repo)).data)
|
||||||
|
assert (await mcp.ok("gitea_repo_get_branch", branch="feature", **repo)).data["name"] == (
|
||||||
|
"feature"
|
||||||
|
)
|
||||||
|
await mcp.ok("gitea_repo_delete_branch", branch="feature", **repo)
|
||||||
|
await mcp.fail("gitea_repo_get_branch", 404, branch="feature", **repo)
|
||||||
|
|
||||||
|
# Tags.
|
||||||
|
tag = await mcp.ok(
|
||||||
|
"gitea_repo_create_tag",
|
||||||
|
status=201,
|
||||||
|
body={"tag_name": "v0.1.0", "target": "main", "message": "first"},
|
||||||
|
**repo,
|
||||||
|
)
|
||||||
|
assert tag.data["name"] == "v0.1.0"
|
||||||
|
assert "v0.1.0" in names((await mcp.ok("gitea_repo_list_tags", **repo)).data)
|
||||||
|
await mcp.ok("gitea_repo_get_tag", tag="v0.1.0", **repo)
|
||||||
|
await mcp.ok("gitea_repo_delete_tag", tag="v0.1.0", **repo)
|
||||||
|
await mcp.fail("gitea_repo_get_tag", 404, tag="v0.1.0", **repo)
|
||||||
|
|
||||||
|
# Topics.
|
||||||
|
await mcp.ok("gitea_repo_update_topics", body={"topics": ["aegis", "integration"]}, **repo)
|
||||||
|
assert set((await mcp.ok("gitea_repo_list_topics", **repo)).data["topics"]) == {
|
||||||
|
"aegis",
|
||||||
|
"integration",
|
||||||
|
}
|
||||||
|
await mcp.ok("gitea_repo_add_topic", topic="extra", **repo)
|
||||||
|
await mcp.ok("gitea_repo_delete_topic", topic="aegis", **repo)
|
||||||
|
assert set((await mcp.ok("gitea_repo_list_topics", **repo)).data["topics"]) == {
|
||||||
|
"integration",
|
||||||
|
"extra",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Edit and read back.
|
||||||
|
edited = await mcp.ok("gitea_repo_edit", body={"description": "edited by it"}, **repo)
|
||||||
|
assert edited.data["description"] == "edited by it"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_repo_collaborators_fork_transfer_archive_search(
|
||||||
|
mcp: McpSession, gitea: GiteaInstance, regular_user: RegularUser, fresh_repo: RepoRef
|
||||||
|
) -> None:
|
||||||
|
repo = fresh_repo.args
|
||||||
|
me = regular_user.login
|
||||||
|
|
||||||
|
# Collaborators.
|
||||||
|
await mcp.ok(
|
||||||
|
"gitea_repo_add_collaborator", collaborator=me, body={"permission": "write"}, **repo
|
||||||
|
)
|
||||||
|
await mcp.ok("gitea_repo_check_collaborator", collaborator=me, **repo)
|
||||||
|
assert me in names((await mcp.ok("gitea_repo_list_collaborators", **repo)).data, "login")
|
||||||
|
permission = await mcp.ok("gitea_repo_get_repo_permissions", collaborator=me, **repo)
|
||||||
|
assert permission.data["permission"] == "write"
|
||||||
|
await mcp.ok("gitea_repo_delete_collaborator", collaborator=me, **repo)
|
||||||
|
await mcp.fail("gitea_repo_check_collaborator", 404, collaborator=me, **repo)
|
||||||
|
|
||||||
|
# Archive: a binary envelope (base64, content type, size).
|
||||||
|
archive = await mcp.ok("gitea_repo_get_archive", archive="main.zip", **repo)
|
||||||
|
blob = base64.b64decode(archive.data["content_base64"])
|
||||||
|
assert blob.startswith(b"PK")
|
||||||
|
assert archive.data["size"] == len(blob)
|
||||||
|
assert archive.data["content_type"].startswith("application/")
|
||||||
|
|
||||||
|
# Search finds the repository by name.
|
||||||
|
deadline = time.monotonic() + 15
|
||||||
|
found: list[str] = []
|
||||||
|
while time.monotonic() < deadline and fresh_repo.name not in found:
|
||||||
|
search = await mcp.ok("gitea_repo_search", q=fresh_repo.name)
|
||||||
|
found = names(search.data["data"])
|
||||||
|
if fresh_repo.name not in found:
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
assert fresh_repo.name in found
|
||||||
|
|
||||||
|
# Fork as another user (admin sudo), then list forks.
|
||||||
|
fork = await mcp.ok("gitea_repo_create_fork", status=(201, 202), sudo=me, body={}, **repo)
|
||||||
|
assert fork.identity["sudo"] == me
|
||||||
|
assert fork.data["owner"]["login"] == me
|
||||||
|
forks = await mcp.ok("gitea_repo_list_forks", **repo)
|
||||||
|
assert me in [item["owner"]["login"] for item in forks.data]
|
||||||
|
await mcp.ok("gitea_repo_delete", owner=me, repo=fresh_repo.name)
|
||||||
|
|
||||||
|
# Transfer to an organization created for the purpose.
|
||||||
|
org = gitea.unique("it-xfer")
|
||||||
|
try:
|
||||||
|
await mcp.ok("gitea_org_create", status=201, body={"username": org})
|
||||||
|
await mcp.ok("gitea_repo_transfer", status=(201, 202), body={"new_owner": org}, **repo)
|
||||||
|
moved = await mcp.ok("gitea_repo_get", owner=org, repo=fresh_repo.name)
|
||||||
|
assert moved.data["owner"]["login"] == org
|
||||||
|
# Gitea redirects the old path to the new owner; redirects are never followed.
|
||||||
|
redirected = await mcp.fail("gitea_repo_get", 301, **repo)
|
||||||
|
assert redirected.error_kind == "redirect"
|
||||||
|
await mcp.ok("gitea_repo_edit", owner=org, repo=fresh_repo.name, body={"archived": True})
|
||||||
|
assert (await mcp.ok("gitea_repo_get", owner=org, repo=fresh_repo.name)).data["archived"]
|
||||||
|
finally:
|
||||||
|
drop_org(gitea, org)
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
"""Scenarios: releases and attachments, Action secrets and variables, webhooks."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.integration.conftest import McpSession, RepoRef, b64, drop_org
|
||||||
|
from tests.integration.harness import GiteaInstance
|
||||||
|
|
||||||
|
HOOK_CONFIG = {"url": "http://127.0.0.1:9/hook", "content_type": "json"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_release_with_attachment(
|
||||||
|
mcp: McpSession, gitea: GiteaInstance, fresh_repo: RepoRef
|
||||||
|
) -> None:
|
||||||
|
repo = fresh_repo.args
|
||||||
|
release = await mcp.ok(
|
||||||
|
"gitea_release_create_release",
|
||||||
|
status=201,
|
||||||
|
body={
|
||||||
|
"tag_name": "v1.0.0",
|
||||||
|
"target_commitish": "main",
|
||||||
|
"name": "One point oh",
|
||||||
|
"body": "first release",
|
||||||
|
},
|
||||||
|
**repo,
|
||||||
|
)
|
||||||
|
release_id = release.data["id"]
|
||||||
|
assert release.data["tag_name"] == "v1.0.0"
|
||||||
|
assert (await mcp.ok("gitea_release_get_latest_release", **repo)).data["id"] == release_id
|
||||||
|
assert (await mcp.ok("gitea_release_get_release_by_tag", tag="v1.0.0", **repo)).data["id"] == (
|
||||||
|
release_id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Upload an attachment through the file argument (base64), then list and download it.
|
||||||
|
payload = b"release asset " + secrets.token_bytes(24)
|
||||||
|
asset = await mcp.ok(
|
||||||
|
"gitea_release_create_release_attachment",
|
||||||
|
status=201,
|
||||||
|
id=release_id,
|
||||||
|
name="notes.bin",
|
||||||
|
attachment={
|
||||||
|
"filename": "notes.bin",
|
||||||
|
"content_base64": b64(payload),
|
||||||
|
"content_type": "application/octet-stream",
|
||||||
|
},
|
||||||
|
**repo,
|
||||||
|
)
|
||||||
|
attachment_id = asset.data["id"]
|
||||||
|
assert asset.data["name"] == "notes.bin"
|
||||||
|
assert asset.data["size"] == len(payload)
|
||||||
|
listed = await mcp.ok("gitea_release_list_release_attachments", id=release_id, **repo)
|
||||||
|
assert [item["name"] for item in listed.data] == ["notes.bin"]
|
||||||
|
meta = await mcp.ok(
|
||||||
|
"gitea_release_get_release_attachment", id=release_id, attachment_id=attachment_id, **repo
|
||||||
|
)
|
||||||
|
download = gitea.request("GET", meta.data["browser_download_url"].removeprefix(gitea.base_url))
|
||||||
|
assert download.status_code == 200
|
||||||
|
assert download.content == payload
|
||||||
|
|
||||||
|
renamed = await mcp.ok(
|
||||||
|
"gitea_release_edit_release_attachment",
|
||||||
|
status=(200, 201),
|
||||||
|
id=release_id,
|
||||||
|
attachment_id=attachment_id,
|
||||||
|
body={"name": "renamed.bin"},
|
||||||
|
**repo,
|
||||||
|
)
|
||||||
|
assert renamed.data["name"] == "renamed.bin"
|
||||||
|
edited = await mcp.ok(
|
||||||
|
"gitea_release_edit_release",
|
||||||
|
id=release_id,
|
||||||
|
body={"name": "Renamed", "draft": False},
|
||||||
|
**repo,
|
||||||
|
)
|
||||||
|
assert edited.data["name"] == "Renamed"
|
||||||
|
assert [r["id"] for r in (await mcp.ok("gitea_release_list_releases", **repo)).data] == [
|
||||||
|
release_id
|
||||||
|
]
|
||||||
|
|
||||||
|
await mcp.ok(
|
||||||
|
"gitea_release_delete_release_attachment",
|
||||||
|
id=release_id,
|
||||||
|
attachment_id=attachment_id,
|
||||||
|
**repo,
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
await mcp.ok("gitea_release_list_release_attachments", id=release_id, **repo)
|
||||||
|
).data == []
|
||||||
|
await mcp.ok("gitea_release_delete_release", id=release_id, **repo)
|
||||||
|
await mcp.fail("gitea_release_get_release", 404, id=release_id, **repo)
|
||||||
|
|
||||||
|
# A second release is removed through its tag.
|
||||||
|
await mcp.ok(
|
||||||
|
"gitea_release_create_release",
|
||||||
|
status=201,
|
||||||
|
body={"tag_name": "v1.1.0", "target_commitish": "main"},
|
||||||
|
**repo,
|
||||||
|
)
|
||||||
|
await mcp.ok("gitea_release_delete_release_by_tag", tag="v1.1.0", **repo)
|
||||||
|
|
||||||
|
|
||||||
|
def _names(result: Any) -> list[str]:
|
||||||
|
return [item["name"] for item in result.data]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_secrets_and_variables_never_leak_values(
|
||||||
|
mcp: McpSession, gitea: GiteaInstance, fresh_repo: RepoRef
|
||||||
|
) -> None:
|
||||||
|
repo = fresh_repo.args
|
||||||
|
org = gitea.unique("it-secrets")
|
||||||
|
values = [f"S3cr3t-{secrets.token_hex(12)}" for _ in range(4)]
|
||||||
|
repo_secret, org_secret, user_secret, probe = values
|
||||||
|
try:
|
||||||
|
await mcp.ok("gitea_org_create", status=201, body={"username": org})
|
||||||
|
|
||||||
|
# Repository, organization and user-level secrets: set, list (names only), delete.
|
||||||
|
await mcp.ok(
|
||||||
|
"gitea_actions_update_repo_secret",
|
||||||
|
secretname="REPO_KEY",
|
||||||
|
body={"data": repo_secret, "description": "repo key"},
|
||||||
|
**repo,
|
||||||
|
)
|
||||||
|
await mcp.ok(
|
||||||
|
"gitea_actions_update_repo_secret",
|
||||||
|
secretname="REPO_KEY",
|
||||||
|
body={"data": repo_secret + "x"},
|
||||||
|
**repo,
|
||||||
|
)
|
||||||
|
listed = await mcp.ok("gitea_actions_repo_list_secrets", **repo)
|
||||||
|
assert _names(listed) == ["REPO_KEY"]
|
||||||
|
assert set(listed.data[0]) <= {"name", "description", "created_at"}
|
||||||
|
await mcp.ok(
|
||||||
|
"gitea_actions_update_org_secret",
|
||||||
|
org=org,
|
||||||
|
secretname="ORG_KEY",
|
||||||
|
body={"data": org_secret},
|
||||||
|
)
|
||||||
|
org_listed = await mcp.ok("gitea_actions_org_list_secrets", org=org)
|
||||||
|
assert _names(org_listed) == ["ORG_KEY"]
|
||||||
|
assert set(org_listed.data[0]) <= {"name", "description", "created_at"}
|
||||||
|
await mcp.ok(
|
||||||
|
"gitea_actions_update_user_secret", secretname="USER_KEY", body={"data": user_secret}
|
||||||
|
)
|
||||||
|
|
||||||
|
# A failing call carrying a secret must not echo it either.
|
||||||
|
denied = await mcp.fail(
|
||||||
|
"gitea_actions_update_repo_secret",
|
||||||
|
404,
|
||||||
|
owner=gitea.admin_login,
|
||||||
|
repo="does-not-exist-" + secrets.token_hex(3),
|
||||||
|
secretname="NOPE",
|
||||||
|
body={"data": probe},
|
||||||
|
)
|
||||||
|
assert probe not in denied.text
|
||||||
|
|
||||||
|
await mcp.ok("gitea_actions_delete_repo_secret", secretname="REPO_KEY", **repo)
|
||||||
|
await mcp.ok("gitea_actions_delete_org_secret", org=org, secretname="ORG_KEY")
|
||||||
|
await mcp.ok("gitea_actions_delete_user_secret", secretname="USER_KEY")
|
||||||
|
assert (await mcp.ok("gitea_actions_repo_list_secrets", **repo)).data == []
|
||||||
|
|
||||||
|
# Variables are not secret: create, get, update, list, delete at every level.
|
||||||
|
scopes: list[tuple[str, dict[str, str]]] = [
|
||||||
|
("repo", repo),
|
||||||
|
("org", {"org": org}),
|
||||||
|
("user", {}),
|
||||||
|
]
|
||||||
|
for level, scope in scopes:
|
||||||
|
create = f"gitea_actions_create_{level}_variable"
|
||||||
|
await mcp.ok(
|
||||||
|
create,
|
||||||
|
status=(200, 201, 204),
|
||||||
|
variablename="GREETING",
|
||||||
|
body={"value": "one"},
|
||||||
|
**scope,
|
||||||
|
)
|
||||||
|
got = await mcp.ok(
|
||||||
|
f"gitea_actions_get_{level}_variable", variablename="GREETING", **scope
|
||||||
|
)
|
||||||
|
assert got.data["data"] == "one"
|
||||||
|
await mcp.ok(
|
||||||
|
f"gitea_actions_update_{level}_variable",
|
||||||
|
variablename="GREETING",
|
||||||
|
body={"value": "two"},
|
||||||
|
**scope,
|
||||||
|
)
|
||||||
|
changed = await mcp.ok(
|
||||||
|
f"gitea_actions_get_{level}_variable", variablename="GREETING", **scope
|
||||||
|
)
|
||||||
|
assert changed.data["data"] == "two"
|
||||||
|
names = await mcp.ok(f"gitea_actions_get_{level}_variables_list", **scope)
|
||||||
|
assert "GREETING" in _names(names)
|
||||||
|
await mcp.ok(f"gitea_actions_delete_{level}_variable", variablename="GREETING", **scope)
|
||||||
|
await mcp.fail(
|
||||||
|
f"gitea_actions_get_{level}_variable", 404, variablename="GREETING", **scope
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
drop_org(gitea, org)
|
||||||
|
gitea.api("DELETE", "/user/actions/secrets/USER_KEY")
|
||||||
|
|
||||||
|
# No secret value in any tool result, and none in the audit log.
|
||||||
|
for value in values:
|
||||||
|
assert all(value not in text for text in mcp.texts)
|
||||||
|
assert value not in mcp.audit_text()
|
||||||
|
audited = [entry["tool_name"] for entry in mcp.audit_entries()]
|
||||||
|
assert "gitea_actions_update_repo_secret" in audited
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("level", ["repo", "org", "user", "admin"])
|
||||||
|
async def test_webhook_lifecycle(
|
||||||
|
mcp: McpSession, gitea: GiteaInstance, fresh_repo: RepoRef, level: str
|
||||||
|
) -> None:
|
||||||
|
org = gitea.unique("it-hooks")
|
||||||
|
scope: dict[str, str] = {}
|
||||||
|
prefix = f"gitea_hook_{level}"
|
||||||
|
try:
|
||||||
|
if level == "repo":
|
||||||
|
scope = fresh_repo.args
|
||||||
|
elif level == "org":
|
||||||
|
await mcp.ok("gitea_org_create", status=201, body={"username": org})
|
||||||
|
scope = {"org": org}
|
||||||
|
if level == "admin":
|
||||||
|
create, get, listing, edit, delete = (
|
||||||
|
"gitea_admin_create_hook",
|
||||||
|
"gitea_admin_get_hook",
|
||||||
|
"gitea_admin_list_hooks",
|
||||||
|
"gitea_admin_edit_hook",
|
||||||
|
"gitea_admin_delete_hook",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
create, get, listing, edit, delete = (
|
||||||
|
f"{prefix}_create_hook",
|
||||||
|
f"{prefix}_get_hook",
|
||||||
|
f"{prefix}_list_hooks",
|
||||||
|
f"{prefix}_edit_hook",
|
||||||
|
f"{prefix}_delete_hook",
|
||||||
|
)
|
||||||
|
hook = await mcp.ok(
|
||||||
|
create,
|
||||||
|
status=201,
|
||||||
|
body={"type": "gitea", "config": HOOK_CONFIG, "events": ["push"], "active": True},
|
||||||
|
**scope,
|
||||||
|
)
|
||||||
|
hook_id = hook.data["id"]
|
||||||
|
assert hook.data["type"] == "gitea"
|
||||||
|
# Hooks created through /admin/hooks count as "default" hooks, so list both kinds.
|
||||||
|
query = {"type": "all"} if level == "admin" else {}
|
||||||
|
hooks = await mcp.ok(listing, **scope, **query)
|
||||||
|
assert hook_id in [item["id"] for item in hooks.data]
|
||||||
|
assert (await mcp.ok(get, id=hook_id, **scope)).data["config"]["url"] == HOOK_CONFIG["url"]
|
||||||
|
edited = await mcp.ok(
|
||||||
|
edit, id=hook_id, body={"active": False, "events": ["push", "issues"]}, **scope
|
||||||
|
)
|
||||||
|
assert edited.data["active"] is False
|
||||||
|
assert "issues" in edited.data["events"]
|
||||||
|
if level == "repo":
|
||||||
|
await mcp.ok("gitea_hook_repo_test_hook", id=hook_id, **scope)
|
||||||
|
await mcp.ok(delete, id=hook_id, **scope)
|
||||||
|
await mcp.fail(get, 404, id=hook_id, **scope)
|
||||||
|
finally:
|
||||||
|
if level == "org":
|
||||||
|
drop_org(gitea, org)
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"""The ``aegis-gitea-mcp setup`` command against the live instance."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from aegis_gitea_mcp.config import Settings
|
||||||
|
from aegis_gitea_mcp.setup_cmd import OAUTH_APP_NAME, parse_env_text
|
||||||
|
from tests.integration.conftest import RegularUser
|
||||||
|
from tests.integration.harness import GiteaInstance
|
||||||
|
|
||||||
|
PUBLIC_URL = "https://mcp.example.test"
|
||||||
|
REDIRECT_URI = f"{PUBLIC_URL}/oauth/callback"
|
||||||
|
SECRET_KEYS = (
|
||||||
|
"GITEA_TOKEN",
|
||||||
|
"MCP_BEARER_TOKEN",
|
||||||
|
"TOKEN_ENCRYPTION_KEY",
|
||||||
|
"GITEA_OAUTH_CLIENT_SECRET",
|
||||||
|
)
|
||||||
|
_PASSTHROUGH_ENV = ("PATH", "HOME", "LANG", "LC_ALL", "TMPDIR")
|
||||||
|
|
||||||
|
|
||||||
|
def run_setup(
|
||||||
|
gitea: GiteaInstance, token: str, env_file: Path, cwd: Path
|
||||||
|
) -> subprocess.CompletedProcess[str]:
|
||||||
|
"""Run the setup command in a subprocess; the Gitea token goes in the environment only."""
|
||||||
|
env = {name: os.environ[name] for name in _PASSTHROUGH_ENV if name in os.environ}
|
||||||
|
env["GITEA_TOKEN"] = token
|
||||||
|
return subprocess.run( # noqa: S603
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
"-m",
|
||||||
|
"aegis_gitea_mcp.cli",
|
||||||
|
"setup",
|
||||||
|
"--gitea-url",
|
||||||
|
gitea.base_url,
|
||||||
|
"--public-base-url",
|
||||||
|
PUBLIC_URL,
|
||||||
|
"--env-file",
|
||||||
|
str(env_file),
|
||||||
|
"--yes",
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=120,
|
||||||
|
check=False,
|
||||||
|
env=env,
|
||||||
|
cwd=cwd,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def oauth_apps(gitea: GiteaInstance) -> list[dict[str, object]]:
|
||||||
|
"""The admin's OAuth2 applications named like the one setup registers."""
|
||||||
|
response = gitea.api("GET", "/user/applications/oauth2", params={"limit": 50})
|
||||||
|
assert response.status_code == 200
|
||||||
|
return [app for app in response.json() if app["name"] == OAUTH_APP_NAME]
|
||||||
|
|
||||||
|
|
||||||
|
def read_env(path: Path) -> dict[str, str]:
|
||||||
|
"""Key/value pairs of an env file."""
|
||||||
|
return {key: entry.value for key, entry in parse_env_text(path.read_text("utf-8")).items()}
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_registers_the_oauth_app_and_writes_a_private_env_file(
|
||||||
|
gitea: GiteaInstance, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
env_file = tmp_path / ".env"
|
||||||
|
assert oauth_apps(gitea) == []
|
||||||
|
|
||||||
|
first = run_setup(gitea, gitea.admin_token, env_file, tmp_path)
|
||||||
|
assert first.returncode == 0, first.stderr
|
||||||
|
assert "setup failed" not in first.stderr
|
||||||
|
|
||||||
|
# The OAuth2 application exists with the callback of the public URL.
|
||||||
|
apps = oauth_apps(gitea)
|
||||||
|
assert len(apps) == 1
|
||||||
|
assert apps[0]["redirect_uris"] == [REDIRECT_URI]
|
||||||
|
assert apps[0]["confidential_client"] is True
|
||||||
|
|
||||||
|
# The env file is private, complete and loads without errors.
|
||||||
|
assert stat.S_IMODE(env_file.stat().st_mode) == 0o600
|
||||||
|
values = read_env(env_file)
|
||||||
|
assert values["GITEA_URL"] == gitea.base_url
|
||||||
|
assert values["PUBLIC_BASE_URL"] == PUBLIC_URL
|
||||||
|
assert values["OAUTH_ENABLED"] == "true"
|
||||||
|
assert values["GITEA_OAUTH_CLIENT_ID"] == apps[0]["client_id"]
|
||||||
|
settings = Settings(_env_file=str(env_file)) # type: ignore[call-arg]
|
||||||
|
assert settings.oauth_enabled
|
||||||
|
assert settings.oauth_config_errors() == []
|
||||||
|
assert settings.gitea_token is not None
|
||||||
|
assert settings.gitea_token.get_secret_value() == gitea.admin_token
|
||||||
|
assert settings.mcp_bearer_token is not None
|
||||||
|
|
||||||
|
# No secret value reaches stdout or stderr.
|
||||||
|
for key in SECRET_KEYS:
|
||||||
|
assert values[key], key
|
||||||
|
assert values[key] not in first.stdout, f"{key} leaked to stdout"
|
||||||
|
assert values[key] not in first.stderr, f"{key} leaked to stderr"
|
||||||
|
assert gitea.admin_token not in first.stdout + first.stderr
|
||||||
|
|
||||||
|
# Running it again is idempotent: same app, same secrets, no duplicate.
|
||||||
|
second = run_setup(gitea, gitea.admin_token, env_file, tmp_path)
|
||||||
|
assert second.returncode == 0, second.stderr
|
||||||
|
assert len(oauth_apps(gitea)) == 1
|
||||||
|
assert read_env(env_file) == values
|
||||||
|
assert stat.S_IMODE(env_file.stat().st_mode) == 0o600
|
||||||
|
for key in SECRET_KEYS:
|
||||||
|
assert values[key] not in second.stdout + second.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_with_a_non_admin_token_fails_with_an_actionable_message(
|
||||||
|
gitea: GiteaInstance, regular_user: RegularUser, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
env_file = tmp_path / "non-admin.env"
|
||||||
|
result = run_setup(gitea, regular_user.token, env_file, tmp_path)
|
||||||
|
|
||||||
|
assert result.returncode == 1
|
||||||
|
assert "not a site administrator" in result.stderr
|
||||||
|
assert "Generate New Token" in result.stderr
|
||||||
|
assert not env_file.exists()
|
||||||
|
assert regular_user.token not in result.stdout + result.stderr
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""The sweep: every tool is called at least once through the MCP server."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from aegis_gitea_mcp.audit import validate_audit_log_integrity
|
||||||
|
from aegis_gitea_mcp.catalog import load_catalog
|
||||||
|
from tests.integration.conftest import McpSession, RegularUser
|
||||||
|
from tests.integration.harness import GiteaInstance
|
||||||
|
from tests.integration.sweep_plan import (
|
||||||
|
EXCLUDED,
|
||||||
|
LOG,
|
||||||
|
RAW_TOOL,
|
||||||
|
failing,
|
||||||
|
load_spec,
|
||||||
|
run_sweep,
|
||||||
|
summarize,
|
||||||
|
)
|
||||||
|
from tests.integration.sweep_seed import Ctx, seed
|
||||||
|
|
||||||
|
#: At least this share of the tools must get a real 2xx, so a systematic argument problem
|
||||||
|
#: cannot hide behind documented 404 responses.
|
||||||
|
MIN_SUCCESS_SHARE = 0.55
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def sweep_context(gitea: GiteaInstance, regular_user: RegularUser) -> Ctx:
|
||||||
|
"""The seeded resources the sweep's arguments point at."""
|
||||||
|
return seed(gitea, regular_user.login, regular_user.token)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_every_tool_reaches_gitea(
|
||||||
|
mcp: McpSession, sweep_context: Ctx, gitea: GiteaInstance, regular_user: RegularUser
|
||||||
|
) -> None:
|
||||||
|
catalog = load_catalog()
|
||||||
|
listed = {tool.name for tool in (await mcp.client.list_tools()).tools}
|
||||||
|
expected = {op.tool_name for op in catalog.operations} | {RAW_TOOL}
|
||||||
|
assert listed == expected
|
||||||
|
assert len(expected) == 538
|
||||||
|
|
||||||
|
records = await run_sweep(mcp.call, catalog, load_spec(), sweep_context)
|
||||||
|
LOG.sweep = records
|
||||||
|
|
||||||
|
called = [record.tool for record in records]
|
||||||
|
assert len(called) == len(set(called)), "a tool was called twice"
|
||||||
|
assert set(called) == expected - set(EXCLUDED), "tools missing from the sweep"
|
||||||
|
|
||||||
|
bad = failing(records)
|
||||||
|
summary = summarize(records)
|
||||||
|
report = "\n".join(
|
||||||
|
f" {r.tool}: status={r.status} kind={r.kind} {r.method} {r.path} :: {r.message}"
|
||||||
|
for r in bad
|
||||||
|
)
|
||||||
|
assert not bad, f"{len(bad)} tool(s) with an unexpected result ({summary}):\n{report}"
|
||||||
|
success_share = summary.get("success", 0) / len(records)
|
||||||
|
assert success_share >= MIN_SUCCESS_SHARE, f"only {success_share:.0%} of tools got a 2xx"
|
||||||
|
|
||||||
|
# Whatever the 538 calls did, no credential or secret value came back in a result.
|
||||||
|
hidden = [
|
||||||
|
gitea.admin_token,
|
||||||
|
gitea.admin_password,
|
||||||
|
regular_user.token,
|
||||||
|
regular_user.password,
|
||||||
|
"rotated-org-value",
|
||||||
|
"rotated-repo-value",
|
||||||
|
"rotated-user-value",
|
||||||
|
"org-secret-value",
|
||||||
|
"repo-secret-value",
|
||||||
|
"user-secret-value",
|
||||||
|
]
|
||||||
|
for value in hidden:
|
||||||
|
assert all(value not in text for text in mcp.texts), "a secret leaked into a result"
|
||||||
|
assert value not in mcp.audit_text(), "a secret leaked into the audit log"
|
||||||
|
|
||||||
|
# One audit entry per call, and the hash chain over all of them is intact.
|
||||||
|
assert len(mcp.audit_entries()) == len(records)
|
||||||
|
valid, errors = validate_audit_log_integrity(mcp.audit_path)
|
||||||
|
assert valid, errors[:3]
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""Tools the generic sweep must not call because they would break the run itself."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from mcp import Client
|
||||||
|
|
||||||
|
from aegis_gitea_mcp.audit import AuditLogger
|
||||||
|
from aegis_gitea_mcp.server import build_server
|
||||||
|
from tests.integration.conftest import McpSession, integration_settings
|
||||||
|
from tests.integration.harness import GiteaInstance
|
||||||
|
from tests.integration.sweep_plan import EXCLUDED
|
||||||
|
|
||||||
|
|
||||||
|
async def test_delete_current_token_deletes_only_a_throwaway_token(
|
||||||
|
gitea: GiteaInstance, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
assert set(EXCLUDED) == {"gitea_misc_delete_current_token"}
|
||||||
|
monkeypatch.setenv("GITEA_URL", gitea.base_url)
|
||||||
|
throwaway = gitea.mint_token(gitea.admin_login, gitea.admin_password, ["all"])
|
||||||
|
settings = integration_settings(gitea, tmp_path / "state", gitea_token=throwaway)
|
||||||
|
audit = AuditLogger(settings.resolved_audit_log_path)
|
||||||
|
try:
|
||||||
|
async with Client(build_server(settings, audit=audit)) as client:
|
||||||
|
session = McpSession(client, settings.resolved_audit_log_path)
|
||||||
|
current = await session.ok("gitea_misc_get_current_token")
|
||||||
|
assert "all" in current.data["scopes"]
|
||||||
|
assert gitea.api("GET", "/user", token=throwaway).status_code == 200
|
||||||
|
|
||||||
|
deleted = await session.ok("gitea_misc_delete_current_token")
|
||||||
|
assert deleted.data is None
|
||||||
|
assert throwaway not in session.audit_text()
|
||||||
|
|
||||||
|
# The token is gone: Gitea now refuses it, while the harness admin token lives on.
|
||||||
|
assert gitea.api("GET", "/user", token=throwaway).status_code == 401
|
||||||
|
assert gitea.api("GET", "/user").status_code == 200
|
||||||
|
again = await session.call("gitea_misc_get_current_token")
|
||||||
|
assert again.is_error and again.status == 401
|
||||||
|
assert throwaway not in again.text
|
||||||
|
finally:
|
||||||
|
audit.close()
|
||||||
Whitespace-only changes.
@@ -0,0 +1,77 @@
|
|||||||
|
"""Helpers shared by the security tests."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from starlette.types import ASGIApp, Message, Scope
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AsgiResult:
|
||||||
|
"""What one raw ASGI call produced and how much of the request body it consumed."""
|
||||||
|
|
||||||
|
status: int = 0
|
||||||
|
body: bytes = b""
|
||||||
|
headers: dict[str, str] = field(default_factory=dict)
|
||||||
|
bytes_read: int = 0
|
||||||
|
receive_calls: int = 0
|
||||||
|
|
||||||
|
def json(self) -> Any:
|
||||||
|
return json.loads(self.body)
|
||||||
|
|
||||||
|
|
||||||
|
async def asgi_request(
|
||||||
|
app: ASGIApp,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
headers: dict[str, str] | None = None,
|
||||||
|
chunks: Iterable[bytes] = (),
|
||||||
|
host: str = "mcp.example.com",
|
||||||
|
scheme: str = "https",
|
||||||
|
client: tuple[str, int] = ("198.51.100.7", 4000),
|
||||||
|
) -> AsgiResult:
|
||||||
|
"""Drive ``app`` with a counting ``receive`` so tests can assert how much body was read."""
|
||||||
|
result = AsgiResult()
|
||||||
|
pending = list(chunks)
|
||||||
|
raw_headers = [(b"host", host.encode())]
|
||||||
|
for name, value in (headers or {}).items():
|
||||||
|
raw_headers.append((name.lower().encode(), value.encode()))
|
||||||
|
scope: Scope = {
|
||||||
|
"type": "http",
|
||||||
|
"asgi": {"version": "3.0"},
|
||||||
|
"http_version": "1.1",
|
||||||
|
"method": method,
|
||||||
|
"scheme": scheme,
|
||||||
|
"path": path,
|
||||||
|
"raw_path": path.encode(),
|
||||||
|
"query_string": b"",
|
||||||
|
"root_path": "",
|
||||||
|
"headers": raw_headers,
|
||||||
|
"client": client,
|
||||||
|
"server": (host, 443),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def receive() -> Message:
|
||||||
|
result.receive_calls += 1
|
||||||
|
if not pending:
|
||||||
|
return {"type": "http.disconnect"}
|
||||||
|
chunk = pending.pop(0)
|
||||||
|
result.bytes_read += len(chunk)
|
||||||
|
return {"type": "http.request", "body": chunk, "more_body": bool(pending)}
|
||||||
|
|
||||||
|
async def send(message: Message) -> None:
|
||||||
|
if message["type"] == "http.response.start":
|
||||||
|
result.status = message["status"]
|
||||||
|
result.headers = {
|
||||||
|
key.decode().lower(): value.decode() for key, value in message["headers"]
|
||||||
|
}
|
||||||
|
elif message["type"] == "http.response.body":
|
||||||
|
result.body += message.get("body", b"")
|
||||||
|
|
||||||
|
await app(scope, receive, send)
|
||||||
|
return result
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"""The audit log is fail closed after startup too (SEC-03) and names the client (SEC-12)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from mcp import MCPError
|
||||||
|
from pytest_httpx import HTTPXMock
|
||||||
|
|
||||||
|
from aegis_gitea_mcp.audit import get_audit_logger
|
||||||
|
from tests.test_oauth_helpers import BEARER_TOKEN, browser, build_oauth_app, mcp_client, sign_in
|
||||||
|
from tests.test_server_helpers import API, audit_entries, connected, mock_user
|
||||||
|
|
||||||
|
REPO_ARGS = {"owner": "octo", "repo": "demo"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _env(mock_env: None) -> None:
|
||||||
|
"""The audit logger reads the process-wide settings."""
|
||||||
|
|
||||||
|
|
||||||
|
class FailingFile:
|
||||||
|
"""Stands in for the open audit file and fails every write like a full disk."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.attempts = 0
|
||||||
|
|
||||||
|
def write(self, data: str) -> int:
|
||||||
|
self.attempts += 1
|
||||||
|
raise OSError(28, "No space left on device")
|
||||||
|
|
||||||
|
def flush(self) -> None: # pragma: no cover - never reached
|
||||||
|
raise OSError(28, "No space left on device")
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def payload_of(result: Any) -> dict[str, Any]:
|
||||||
|
decoded: dict[str, Any] = json.loads(result.content[0].text)
|
||||||
|
return decoded
|
||||||
|
|
||||||
|
|
||||||
|
async def test_calls_stop_when_the_audit_log_stops_accepting_writes(
|
||||||
|
httpx_mock: HTTPXMock,
|
||||||
|
) -> None:
|
||||||
|
mock_user(httpx_mock)
|
||||||
|
httpx_mock.add_response(url=f"{API}/repos/octo/demo", json={"name": "demo"}, is_reusable=True)
|
||||||
|
async with connected() as client:
|
||||||
|
first = await client.call_tool("gitea_repo_get", REPO_ARGS)
|
||||||
|
assert not first.is_error
|
||||||
|
sent_before = len(httpx_mock.get_requests())
|
||||||
|
|
||||||
|
audit = get_audit_logger()
|
||||||
|
healthy_file = audit._log_file
|
||||||
|
audit._log_file = FailingFile() # type: ignore[assignment]
|
||||||
|
second = await client.call_tool("gitea_repo_get", REPO_ARGS)
|
||||||
|
mutation = await client.call_tool(
|
||||||
|
"gitea_issue_create_issue", {**REPO_ARGS, "body": {"title": "x"}}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert second.is_error and mutation.is_error
|
||||||
|
assert payload_of(second)["error"]["kind"] == "audit_unavailable"
|
||||||
|
assert payload_of(mutation)["error"]["kind"] == "audit_unavailable"
|
||||||
|
# Gitea was not contacted at all while the log was broken.
|
||||||
|
assert len(httpx_mock.get_requests()) == sent_before
|
||||||
|
|
||||||
|
audit._log_file = healthy_file
|
||||||
|
resumed = await client.call_tool("gitea_repo_get", REPO_ARGS)
|
||||||
|
assert not resumed.is_error
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_refusal_does_not_leak_the_failure_reason() -> None:
|
||||||
|
async with connected() as client:
|
||||||
|
get_audit_logger()._log_file = FailingFile() # type: ignore[assignment]
|
||||||
|
result = await client.call_tool("gitea_repo_get", REPO_ARGS)
|
||||||
|
|
||||||
|
text = result.content[0].text # type: ignore[union-attr]
|
||||||
|
assert "No space left" not in text and "OSError" not in text
|
||||||
|
|
||||||
|
|
||||||
|
# SEC-12: who made the call
|
||||||
|
|
||||||
|
|
||||||
|
async def test_oauth_call_entry_names_the_client_and_never_a_token_or_session_id(
|
||||||
|
httpx_mock: HTTPXMock,
|
||||||
|
) -> None:
|
||||||
|
oauth = build_oauth_app()
|
||||||
|
flow = sign_in(browser(oauth.app), httpx_mock)
|
||||||
|
session_ids = list(oauth.store.sessions)
|
||||||
|
httpx_mock.add_response(url=f"{API}/repos/octo/demo", json={"full_name": "octo/demo"})
|
||||||
|
access = flow.tokens["access_token"]
|
||||||
|
|
||||||
|
async with mcp_client(oauth.app, access) as mcp:
|
||||||
|
result = await mcp.call_tool("gitea_repo_get", REPO_ARGS)
|
||||||
|
assert not result.is_error
|
||||||
|
|
||||||
|
audit_text = get_audit_logger().log_path.read_text(encoding="utf-8")
|
||||||
|
(entry,) = audit_entries()
|
||||||
|
assert entry["client_id"] == flow.client_id
|
||||||
|
assert re.fullmatch(r"[0-9a-f]{12}", entry["session"])
|
||||||
|
assert entry["client_ip"] == "127.0.0.1"
|
||||||
|
assert entry["identity_login"] == "alice"
|
||||||
|
assert access not in audit_text
|
||||||
|
assert flow.tokens["refresh_token"] not in audit_text
|
||||||
|
assert all(session_id not in audit_text for session_id in session_ids)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_static_bearer_call_entry_names_the_static_client(httpx_mock: HTTPXMock) -> None:
|
||||||
|
mock_user(httpx_mock)
|
||||||
|
httpx_mock.add_response(url=f"{API}/repos/octo/demo", json={})
|
||||||
|
oauth = build_oauth_app()
|
||||||
|
async with mcp_client(oauth.app, BEARER_TOKEN) as mcp:
|
||||||
|
await mcp.call_tool("gitea_repo_get", REPO_ARGS)
|
||||||
|
|
||||||
|
(entry,) = audit_entries()
|
||||||
|
assert entry["client_id"] == "aegis-static-bearer"
|
||||||
|
assert "session" not in entry
|
||||||
|
assert BEARER_TOKEN not in get_audit_logger().log_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
# SEC-18: caller-chosen names
|
||||||
|
|
||||||
|
TOKEN_LIKE = "0123456789abcdef0123456789abcdef01234567"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_extra_argument_names_are_counted_not_recorded() -> None:
|
||||||
|
async with connected() as client:
|
||||||
|
result = await client.call_tool("gitea_repo_get", {**REPO_ARGS, TOKEN_LIKE: "x"})
|
||||||
|
|
||||||
|
assert result.is_error
|
||||||
|
assert TOKEN_LIKE not in result.content[0].text # type: ignore[union-attr]
|
||||||
|
(entry,) = audit_entries()
|
||||||
|
assert entry["params"] == {"argument_names": ["owner", "repo"], "rejected_argument_count": 1}
|
||||||
|
assert TOKEN_LIKE not in get_audit_logger().log_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unknown_tool_names_are_neither_echoed_nor_recorded() -> None:
|
||||||
|
name = "gitea_" + TOKEN_LIKE
|
||||||
|
async with connected() as client:
|
||||||
|
with pytest.raises(MCPError) as caught:
|
||||||
|
await client.call_tool(name, {TOKEN_LIKE: 1})
|
||||||
|
|
||||||
|
assert TOKEN_LIKE not in caught.value.message
|
||||||
|
(entry,) = audit_entries()
|
||||||
|
assert entry["tool_name"] == f"(unknown tool, {len(name)} chars)"
|
||||||
|
assert entry["params"] == {"argument_names": [], "rejected_argument_count": 1}
|
||||||
|
assert TOKEN_LIKE not in get_audit_logger().log_path.read_text(encoding="utf-8")
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"""The audit file is private and is never reached through a symlink (SEC-11)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import errno
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pytest_httpx import HTTPXMock
|
||||||
|
|
||||||
|
from aegis_gitea_mcp import transports
|
||||||
|
from aegis_gitea_mcp.audit import AuditLogger, get_audit_logger, validate_audit_log_integrity
|
||||||
|
from aegis_gitea_mcp.transports import run_http
|
||||||
|
from tests.test_server_helpers import API, connected, mock_user, server_settings
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _env(mock_env: None, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""The audit logger reads the process-wide settings; a startup that passes must not serve."""
|
||||||
|
|
||||||
|
def refuse(*args: object, **kwargs: object) -> None:
|
||||||
|
raise AssertionError("uvicorn.run must not be reached")
|
||||||
|
|
||||||
|
monkeypatch.setattr(transports.uvicorn, "run", refuse)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def open_umask() -> object:
|
||||||
|
"""A permissive umask: what an unprepared host or container would hand out."""
|
||||||
|
previous = os.umask(0)
|
||||||
|
yield None
|
||||||
|
os.umask(previous)
|
||||||
|
|
||||||
|
|
||||||
|
def mode_of(path: Path) -> int:
|
||||||
|
return stat.S_IMODE(path.stat().st_mode)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_audit_log_is_0600_and_a_new_state_directory_is_0700(
|
||||||
|
httpx_mock: HTTPXMock, open_umask: object
|
||||||
|
) -> None:
|
||||||
|
mock_user(httpx_mock)
|
||||||
|
httpx_mock.add_response(url=f"{API}/repos/octo/demo", json={})
|
||||||
|
async with connected() as client:
|
||||||
|
await client.call_tool("gitea_repo_get", {"owner": "octo", "repo": "demo"})
|
||||||
|
|
||||||
|
path = get_audit_logger().log_path
|
||||||
|
assert mode_of(path) == 0o600
|
||||||
|
assert mode_of(path.parent) == 0o700
|
||||||
|
assert validate_audit_log_integrity(path) == (True, [])
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_older_world_readable_log_is_tightened(tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "audit.log"
|
||||||
|
path.write_text("")
|
||||||
|
path.chmod(0o644)
|
||||||
|
|
||||||
|
AuditLogger(path).close()
|
||||||
|
|
||||||
|
assert mode_of(path) == 0o600
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_symlink_at_the_audit_path_is_not_followed(tmp_path: Path) -> None:
|
||||||
|
target = tmp_path / "elsewhere.log"
|
||||||
|
target.write_text("")
|
||||||
|
link = tmp_path / "audit.log"
|
||||||
|
link.symlink_to(target)
|
||||||
|
|
||||||
|
with pytest.raises(OSError) as caught:
|
||||||
|
AuditLogger(link)
|
||||||
|
assert caught.value.errno == errno.ELOOP
|
||||||
|
assert target.read_text() == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_startup_exits_2_for_a_symlinked_audit_path(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
target = tmp_path / "elsewhere.log"
|
||||||
|
target.write_text("")
|
||||||
|
link = tmp_path / "state" / "audit.log"
|
||||||
|
link.parent.mkdir()
|
||||||
|
link.symlink_to(target)
|
||||||
|
monkeypatch.setenv("AUDIT_LOG_PATH", str(link))
|
||||||
|
|
||||||
|
assert run_http(server_settings(audit_log_path=link)) == 2
|
||||||
|
|
||||||
|
assert "symbolic link" in capsys.readouterr().err
|
||||||
|
assert target.read_text() == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_startup_exits_2_for_an_audit_path_that_is_a_directory(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
directory = tmp_path / "audit.log"
|
||||||
|
directory.mkdir()
|
||||||
|
monkeypatch.setenv("AUDIT_LOG_PATH", str(directory))
|
||||||
|
|
||||||
|
assert run_http(server_settings(audit_log_path=directory)) == 2
|
||||||
|
assert "audit log" in capsys.readouterr().err
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
"""The MCP endpoint rejects every request that does not carry a valid credential."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pytest_httpx import HTTPXMock
|
||||||
|
from starlette.applications import Starlette
|
||||||
|
from starlette.routing import Route
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
from aegis_gitea_mcp import auth as auth_module
|
||||||
|
from aegis_gitea_mcp.auth import StaticBearerVerifier
|
||||||
|
from aegis_gitea_mcp.oauth.provider import TOKEN_PREFIX
|
||||||
|
from aegis_gitea_mcp.transports import build_http_app
|
||||||
|
from tests.test_oauth_helpers import (
|
||||||
|
API,
|
||||||
|
GITEA_TOKEN,
|
||||||
|
USER_TOKEN,
|
||||||
|
FakeClock,
|
||||||
|
browser,
|
||||||
|
build_oauth_app,
|
||||||
|
mcp_client,
|
||||||
|
mcp_status_lifespan,
|
||||||
|
sign_in,
|
||||||
|
)
|
||||||
|
from tests.test_server_helpers import BEARER_TOKEN, server_settings
|
||||||
|
|
||||||
|
MCP_BODY = {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}
|
||||||
|
HOST = "http://127.0.0.1:8080"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _env(mock_env: None) -> None:
|
||||||
|
"""The audit logger and the rate limiter read the process-wide settings."""
|
||||||
|
|
||||||
|
|
||||||
|
def bearer_app() -> Starlette:
|
||||||
|
return build_http_app(server_settings(mcp_bearer_token=BEARER_TOKEN))
|
||||||
|
|
||||||
|
|
||||||
|
def post_mcp(client: TestClient, authorization: str | None) -> Any:
|
||||||
|
headers = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"}
|
||||||
|
if authorization is not None:
|
||||||
|
headers["Authorization"] = authorization
|
||||||
|
return client.post("/mcp", json=MCP_BODY, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
# No credential, wrong credential
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_without_a_bearer_is_401_with_a_challenge_on_the_bearer_app() -> None:
|
||||||
|
client = TestClient(bearer_app(), base_url=HOST)
|
||||||
|
|
||||||
|
response = post_mcp(client, None)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert response.headers["www-authenticate"].lower().startswith("bearer")
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_without_a_bearer_is_401_with_a_challenge_on_the_oauth_app() -> None:
|
||||||
|
client = browser(build_oauth_app().app)
|
||||||
|
|
||||||
|
response = post_mcp(client, None)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
challenge = response.headers["www-authenticate"]
|
||||||
|
assert challenge.lower().startswith("bearer")
|
||||||
|
assert "resource_metadata" in challenge # tells clients where to find the OAuth server
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("method", ["GET", "POST", "DELETE"])
|
||||||
|
def test_every_method_on_the_endpoint_needs_authentication(method: str) -> None:
|
||||||
|
client = TestClient(bearer_app(), base_url=HOST)
|
||||||
|
|
||||||
|
assert client.request(method, "/mcp").status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
WRONG_BEARERS = {
|
||||||
|
"same length": "x" * len(BEARER_TOKEN),
|
||||||
|
"one character off": BEARER_TOKEN[:-1] + ("A" if BEARER_TOKEN[-1] != "A" else "B"),
|
||||||
|
"shorter": BEARER_TOKEN[:-1],
|
||||||
|
"longer": BEARER_TOKEN + "x",
|
||||||
|
"prefix only": BEARER_TOKEN[:8],
|
||||||
|
"doubled": BEARER_TOKEN * 2,
|
||||||
|
"upper case": BEARER_TOKEN.upper(),
|
||||||
|
"trailing space": BEARER_TOKEN + " ",
|
||||||
|
"trailing newline": BEARER_TOKEN + "\n",
|
||||||
|
"another deployment": "another-deployments-bearer-0123456789abcdef",
|
||||||
|
"admin gitea token": GITEA_TOKEN,
|
||||||
|
"null byte": BEARER_TOKEN + "\x00",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name", WRONG_BEARERS)
|
||||||
|
def test_a_wrong_bearer_is_401(name: str) -> None:
|
||||||
|
client = TestClient(bearer_app(), base_url=HOST)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = post_mcp(client, f"Bearer {WRONG_BEARERS[name]}")
|
||||||
|
except Exception as exc: # an invalid header value is refused before it leaves the client
|
||||||
|
assert "header" in str(exc).lower() or isinstance(exc, (ValueError, UnicodeError))
|
||||||
|
return
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"header",
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"Bearer",
|
||||||
|
"Bearer ",
|
||||||
|
f"Basic {BEARER_TOKEN}",
|
||||||
|
f"token {BEARER_TOKEN}",
|
||||||
|
f"Token {BEARER_TOKEN}",
|
||||||
|
BEARER_TOKEN,
|
||||||
|
f"Bearer {BEARER_TOKEN} extra",
|
||||||
|
f"Digest {BEARER_TOKEN}",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_wrong_scheme_or_shape_of_the_authorization_header_is_401(header: str) -> None:
|
||||||
|
client = TestClient(bearer_app(), base_url=HOST)
|
||||||
|
|
||||||
|
assert post_mcp(client, header).status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_right_bearer_is_not_401() -> None:
|
||||||
|
assert mcp_status_lifespan(bearer_app(), BEARER_TOKEN) != 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_bearer_is_never_accepted_from_a_query_string_or_a_cookie() -> None:
|
||||||
|
client = TestClient(bearer_app(), base_url=HOST)
|
||||||
|
|
||||||
|
by_query = client.post(f"/mcp?access_token={BEARER_TOKEN}&token={BEARER_TOKEN}", json=MCP_BODY)
|
||||||
|
client.cookies.set("token", BEARER_TOKEN)
|
||||||
|
by_cookie = client.post("/mcp", json=MCP_BODY)
|
||||||
|
|
||||||
|
assert by_query.status_code == 401 and by_cookie.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# Constant-time comparison
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_static_bearer_is_compared_in_constant_time_on_digests(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
seen: list[tuple[Any, Any]] = []
|
||||||
|
real = hmac.compare_digest
|
||||||
|
|
||||||
|
def spy(a: Any, b: Any) -> bool:
|
||||||
|
seen.append((a, b))
|
||||||
|
return real(a, b)
|
||||||
|
|
||||||
|
monkeypatch.setattr(auth_module.hmac, "compare_digest", spy)
|
||||||
|
verifier = StaticBearerVerifier(BEARER_TOKEN)
|
||||||
|
|
||||||
|
for candidate in ("", "x", BEARER_TOKEN[:-1], BEARER_TOKEN, BEARER_TOKEN + "x" * 500):
|
||||||
|
accepted = await verifier.verify_token(candidate)
|
||||||
|
assert (accepted is not None) == (candidate == BEARER_TOKEN)
|
||||||
|
|
||||||
|
assert len(seen) == 5
|
||||||
|
# Digests of equal length whatever the candidate length: nothing about the length leaks.
|
||||||
|
assert {len(a) for a, _ in seen} == {len(b) for _, b in seen} == {32}
|
||||||
|
assert all(isinstance(a, bytes) and isinstance(b, bytes) for a, b in seen)
|
||||||
|
assert seen[3][0] == hashlib.sha256(BEARER_TOKEN.encode()).digest()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_source_has_no_plain_equality_on_the_secret() -> None:
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
source = inspect.getsource(StaticBearerVerifier)
|
||||||
|
|
||||||
|
assert "compare_digest" in source
|
||||||
|
assert "== self._token" not in source and "self._token ==" not in source
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_access_token_of_the_static_bearer_never_carries_the_secret() -> None:
|
||||||
|
access = await StaticBearerVerifier(BEARER_TOKEN).verify_token(BEARER_TOKEN)
|
||||||
|
|
||||||
|
assert access is not None
|
||||||
|
assert BEARER_TOKEN not in repr(access) and BEARER_TOKEN not in access.model_dump_json()
|
||||||
|
|
||||||
|
|
||||||
|
# OAuth tokens
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_expired_revoked_and_foreign_oauth_tokens_are_401(httpx_mock: HTTPXMock) -> None:
|
||||||
|
clock = FakeClock()
|
||||||
|
oauth = build_oauth_app(clock=clock)
|
||||||
|
web = browser(oauth.app)
|
||||||
|
flow = sign_in(web, httpx_mock)
|
||||||
|
access = flow.tokens["access_token"]
|
||||||
|
assert access.startswith(TOKEN_PREFIX)
|
||||||
|
assert mcp_status_lifespan(oauth.app, access) != 401
|
||||||
|
|
||||||
|
unknown = TOKEN_PREFIX + "A" * 43
|
||||||
|
assert post_mcp(web, f"Bearer {unknown}").status_code == 401
|
||||||
|
assert post_mcp(web, f"Bearer {TOKEN_PREFIX}").status_code == 401
|
||||||
|
assert post_mcp(web, f"Bearer {access}x").status_code == 401
|
||||||
|
assert post_mcp(web, f"Bearer {access[:-1]}").status_code == 401
|
||||||
|
|
||||||
|
# Bound to another resource: the token does not work here.
|
||||||
|
(session,) = oauth.store.sessions.values()
|
||||||
|
real_resource = session.resource
|
||||||
|
session.resource = "https://other.example.com/mcp"
|
||||||
|
assert post_mcp(web, f"Bearer {access}").status_code == 401
|
||||||
|
session.resource = real_resource
|
||||||
|
|
||||||
|
# Revoked by its client.
|
||||||
|
revoked = web.post(
|
||||||
|
"/revoke",
|
||||||
|
data={"token": access, "client_id": flow.client_id, "client_secret": flow.client_secret},
|
||||||
|
)
|
||||||
|
assert revoked.status_code == 200
|
||||||
|
assert post_mcp(web, f"Bearer {access}").status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_expired_oauth_token_is_401(httpx_mock: HTTPXMock) -> None:
|
||||||
|
clock = FakeClock()
|
||||||
|
oauth = build_oauth_app(clock=clock)
|
||||||
|
web = browser(oauth.app)
|
||||||
|
flow = sign_in(web, httpx_mock)
|
||||||
|
access = flow.tokens["access_token"]
|
||||||
|
|
||||||
|
clock.advance(3600 + 1)
|
||||||
|
|
||||||
|
assert post_mcp(web, f"Bearer {access}").status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_an_oauth_token_never_resolves_to_the_admin_identity(
|
||||||
|
httpx_mock: HTTPXMock,
|
||||||
|
) -> None:
|
||||||
|
oauth = build_oauth_app()
|
||||||
|
flow = sign_in(browser(oauth.app), httpx_mock)
|
||||||
|
httpx_mock.add_response(url=f"{API}/repos/octo/demo", json={})
|
||||||
|
httpx_mock.add_response(url=f"{API}/admin/users", status_code=403, json={"message": "no"})
|
||||||
|
|
||||||
|
async with mcp_client(oauth.app, flow.tokens["access_token"]) as mcp:
|
||||||
|
await mcp.call_tool("gitea_repo_get", {"owner": "octo", "repo": "demo"})
|
||||||
|
await mcp.call_tool("gitea_admin_search_users", {})
|
||||||
|
|
||||||
|
calls = [
|
||||||
|
r for r in httpx_mock.get_requests() if "/repos/" in str(r.url) or "/admin/" in str(r.url)
|
||||||
|
]
|
||||||
|
assert len(calls) == 2
|
||||||
|
for request in calls:
|
||||||
|
assert request.headers["Authorization"] == f"Bearer {USER_TOKEN}"
|
||||||
|
assert GITEA_TOKEN not in request.headers["Authorization"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_oauth_only_deployment_has_no_static_bearer_to_guess(httpx_mock: HTTPXMock) -> None:
|
||||||
|
from tests.test_oauth_helpers import oauth_settings
|
||||||
|
|
||||||
|
oauth = build_oauth_app(oauth_settings(gitea_token=None, mcp_bearer_token=None))
|
||||||
|
web = browser(oauth.app)
|
||||||
|
|
||||||
|
assert post_mcp(web, f"Bearer {BEARER_TOKEN}").status_code == 401
|
||||||
|
assert post_mcp(web, None).status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# Route inventory
|
||||||
|
|
||||||
|
#: Routes reachable without authentication, with the reason each one exists.
|
||||||
|
PUBLIC_ROUTES = {
|
||||||
|
"/": "service banner",
|
||||||
|
"/health": "container and client health check",
|
||||||
|
"/.well-known/oauth-protected-resource": "RFC 9728 discovery (bare path, OAuth only)",
|
||||||
|
"/.well-known/oauth-protected-resource/mcp": "RFC 9728 discovery",
|
||||||
|
"/.well-known/oauth-authorization-server": "RFC 8414 discovery (OAuth only)",
|
||||||
|
"/authorize": "OAuth authorization endpoint (OAuth only)",
|
||||||
|
"/token": "OAuth token endpoint (OAuth only)",
|
||||||
|
"/register": "OAuth dynamic client registration (OAuth only)",
|
||||||
|
"/revoke": "OAuth revocation (OAuth only)",
|
||||||
|
"/oauth/consent": "consent page (OAuth only)",
|
||||||
|
"/oauth/callback": "Gitea callback (OAuth only)",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def inventory(app: Starlette) -> tuple[set[str], set[str]]:
|
||||||
|
"""Paths without authentication, and paths guarded by the SDK's bearer middleware."""
|
||||||
|
open_paths: set[str] = set()
|
||||||
|
guarded: set[str] = set()
|
||||||
|
for route in app.routes:
|
||||||
|
assert isinstance(route, Route)
|
||||||
|
endpoint = type(route.endpoint).__name__
|
||||||
|
(guarded if endpoint == "RequireAuthMiddleware" else open_paths).add(route.path)
|
||||||
|
return open_paths, guarded
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_routes_reachable_without_authentication_are_exactly_the_known_ones() -> None:
|
||||||
|
oauth_open, oauth_guarded = inventory(build_oauth_app().app)
|
||||||
|
bearer_open, bearer_guarded = inventory(bearer_app())
|
||||||
|
|
||||||
|
assert oauth_open == set(PUBLIC_ROUTES)
|
||||||
|
assert bearer_open <= set(PUBLIC_ROUTES)
|
||||||
|
assert {"/", "/health"} <= bearer_open
|
||||||
|
# The MCP endpoint is the only guarded route, and it is guarded in both modes.
|
||||||
|
assert oauth_guarded == bearer_guarded == {"/mcp"}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
"""``GITEA_URL``, ``PUBLIC_BASE_URL`` and the other path-like settings are validated strictly (SEC-10)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from aegis_gitea_mcp import transports
|
||||||
|
from aegis_gitea_mcp.cli import main
|
||||||
|
from aegis_gitea_mcp.config import Settings
|
||||||
|
from aegis_gitea_mcp.oauth.provider import redirect_uri_problem
|
||||||
|
|
||||||
|
|
||||||
|
def make(**values: Any) -> Settings:
|
||||||
|
base: dict[str, Any] = {"gitea_url": "https://gitea.example.com"}
|
||||||
|
base.update(values)
|
||||||
|
return Settings(**base)
|
||||||
|
|
||||||
|
|
||||||
|
BAD_CHARACTERS = [
|
||||||
|
("tab", "\t"),
|
||||||
|
("newline", "\n"),
|
||||||
|
("carriage return", "\r"),
|
||||||
|
("space", " "),
|
||||||
|
("nul", "\x00"),
|
||||||
|
("vertical tab", "\x0b"),
|
||||||
|
("line separator", chr(0x2028)),
|
||||||
|
("no-break space", chr(0xA0)),
|
||||||
|
("bidi override", chr(0x202E)),
|
||||||
|
("bidi isolate", chr(0x2066)),
|
||||||
|
("zero width space", chr(0x200B)),
|
||||||
|
("backslash", "\\"),
|
||||||
|
]
|
||||||
|
POSITIONS = [
|
||||||
|
"https://gi{c}tea.example.com",
|
||||||
|
"https://gitea.example.com/a{c}b",
|
||||||
|
"https://gitea.example.com:44{c}3",
|
||||||
|
"https://gitea.example.com/{c}x",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("template", POSITIONS)
|
||||||
|
@pytest.mark.parametrize(("name", "char"), BAD_CHARACTERS, ids=[n for n, _ in BAD_CHARACTERS])
|
||||||
|
def test_gitea_url_rejects_whitespace_and_control_characters(
|
||||||
|
template: str, name: str, char: str
|
||||||
|
) -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
make(gitea_url=template.format(c=char))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("template", POSITIONS)
|
||||||
|
@pytest.mark.parametrize(("name", "char"), BAD_CHARACTERS, ids=[n for n, _ in BAD_CHARACTERS])
|
||||||
|
def test_public_base_url_rejects_whitespace_and_control_characters(
|
||||||
|
template: str, name: str, char: str
|
||||||
|
) -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
make(public_base_url=template.format(c=char).replace("gitea", "mcp"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"url",
|
||||||
|
[
|
||||||
|
"ftp://gitea.example.com",
|
||||||
|
"file:///etc/passwd",
|
||||||
|
"javascript:alert(1)",
|
||||||
|
"gitea.example.com",
|
||||||
|
"//gitea.example.com",
|
||||||
|
"https://",
|
||||||
|
"https:///path",
|
||||||
|
"https://user:pass@gitea.example.com",
|
||||||
|
"https://user@gitea.example.com",
|
||||||
|
"https://gitea.example.com?x=1",
|
||||||
|
"https://gitea.example.com/?",
|
||||||
|
"https://gitea.example.com#frag",
|
||||||
|
"https://gitea.example.com:99999",
|
||||||
|
"https://gitea.example.com:abc",
|
||||||
|
"",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_gitea_url_rejects_bad_schemes_credentials_query_fragment_port_and_missing_host(
|
||||||
|
url: str,
|
||||||
|
) -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
make(gitea_url=url)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"url",
|
||||||
|
[
|
||||||
|
"http://mcp.example.com",
|
||||||
|
"http://203.0.113.5",
|
||||||
|
"ftp://mcp.example.com",
|
||||||
|
"https://user:pass@mcp.example.com",
|
||||||
|
"https://mcp.example.com?x=1",
|
||||||
|
"https://mcp.example.com#f",
|
||||||
|
"https://mcp.example.com:99999",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_public_base_url_rejects_http_on_public_hosts_and_the_shared_url_rules(url: str) -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
make(public_base_url=url)
|
||||||
|
|
||||||
|
|
||||||
|
def test_http_public_base_url_is_only_allowed_on_loopback() -> None:
|
||||||
|
assert make(public_base_url="http://localhost:8080").public_base_url == "http://localhost:8080"
|
||||||
|
assert make(public_base_url="http://127.0.0.1:8080").public_base_url == "http://127.0.0.1:8080"
|
||||||
|
assert make(public_base_url="http://[::1]:8080").public_base_url == "http://[::1]:8080"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("given", "used"),
|
||||||
|
[
|
||||||
|
("https://gitea.example.com/", "https://gitea.example.com"),
|
||||||
|
("https://gitea.example.com/api/v1", "https://gitea.example.com"),
|
||||||
|
("https://gitea.example.com/api/v1/", "https://gitea.example.com"),
|
||||||
|
("https://gitea.example.com/git/api/v1", "https://gitea.example.com/git"),
|
||||||
|
(" https://gitea.example.com ", "https://gitea.example.com"),
|
||||||
|
("HTTPS://gitea.example.com:3000/sub/", "https://gitea.example.com:3000/sub"),
|
||||||
|
("http://127.0.0.1:3000", "http://127.0.0.1:3000"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_the_validated_url_is_the_url_that_is_used(given: str, used: str) -> None:
|
||||||
|
settings = make(gitea_url=given)
|
||||||
|
|
||||||
|
assert settings.gitea_url == used
|
||||||
|
assert settings.gitea_api_url == f"{used}/api/v1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_serve_warns_about_plain_http_gitea_on_a_non_loopback_host(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
from aegis_gitea_mcp import cli
|
||||||
|
|
||||||
|
monkeypatch.setenv("GITEA_URL", "http://gitea.internal")
|
||||||
|
monkeypatch.setenv("GITEA_TOKEN", "t" * 20)
|
||||||
|
monkeypatch.setattr(cli, "configure_logging", lambda level: None)
|
||||||
|
monkeypatch.setattr(transports, "run_stdio", lambda settings: 0)
|
||||||
|
|
||||||
|
assert main(["serve"]) == 0
|
||||||
|
|
||||||
|
err = capsys.readouterr().err
|
||||||
|
assert "plain http" in err and "unencrypted" in err
|
||||||
|
assert "t" * 20 not in err
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("url", ["https://gitea.example.com", "http://localhost:3000"])
|
||||||
|
def test_serve_does_not_warn_for_https_or_loopback(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], url: str
|
||||||
|
) -> None:
|
||||||
|
from aegis_gitea_mcp import cli
|
||||||
|
|
||||||
|
monkeypatch.setenv("GITEA_URL", url)
|
||||||
|
monkeypatch.setenv("GITEA_TOKEN", "t" * 20)
|
||||||
|
monkeypatch.setattr(cli, "configure_logging", lambda level: None)
|
||||||
|
monkeypatch.setattr(transports, "run_stdio", lambda settings: 0)
|
||||||
|
|
||||||
|
assert main(["serve"]) == 0
|
||||||
|
|
||||||
|
assert "plain http" not in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
# Other path-like and list settings
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"path",
|
||||||
|
["mcp", "//mcp", "/", "//", "/mcp?x=1", "/mcp#f", "/m cp", ""],
|
||||||
|
)
|
||||||
|
def test_mcp_path_rejections(path: str) -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
make(mcp_path=path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_path_trailing_slash_is_normalized() -> None:
|
||||||
|
assert make(mcp_path="/mcp/").mcp_path == "/mcp"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"value",
|
||||||
|
["not-an-ip", "10.0.0.0/33", "*,10.0.0.1", "10.0.0.1,*", "10.0.0.1;10.0.0.2", "10.0.0.256"],
|
||||||
|
)
|
||||||
|
def test_forwarded_allow_ips_rejections(value: str) -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
make(forwarded_allow_ips=value)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"scopes",
|
||||||
|
[
|
||||||
|
["all"],
|
||||||
|
["read:unknown"],
|
||||||
|
["repo"],
|
||||||
|
["write:repository", "sudo"],
|
||||||
|
["admin"],
|
||||||
|
[""],
|
||||||
|
[],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_oauth_scopes_reject_unknown_scopes_because_gitea_would_grant_everything(
|
||||||
|
scopes: list[str],
|
||||||
|
) -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
make(oauth_scopes=scopes)
|
||||||
|
|
||||||
|
|
||||||
|
def test_oauth_scopes_accept_granular_scopes_only() -> None:
|
||||||
|
assert make(oauth_scopes=["read:repository", "write:issue"]).oauth_scopes == [
|
||||||
|
"read:repository",
|
||||||
|
"write:issue",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_secret_length_minimums() -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
make(mcp_bearer_token="short")
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
make(token_encryption_key="x" * 31)
|
||||||
|
|
||||||
|
|
||||||
|
# Redirect URIs at client registration
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"uri",
|
||||||
|
[
|
||||||
|
"http://example.com/cb",
|
||||||
|
"http://localhost.evil.example/cb",
|
||||||
|
"https://example.com/cb#frag",
|
||||||
|
"https://user:pass@example.com/cb",
|
||||||
|
"javascript:alert(1)",
|
||||||
|
"data:text/html,hi",
|
||||||
|
"file:///etc/passwd",
|
||||||
|
"myapp://callback",
|
||||||
|
"claude://callback",
|
||||||
|
"https:///cb",
|
||||||
|
"https://example.com:99999/cb",
|
||||||
|
"",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_redirect_uri_rules(uri: str) -> None:
|
||||||
|
assert redirect_uri_problem(uri) is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"uri",
|
||||||
|
[
|
||||||
|
"https://claude.ai/api/mcp/auth_callback",
|
||||||
|
"http://localhost:3000/callback",
|
||||||
|
"http://127.0.0.1/cb",
|
||||||
|
"http://[::1]:5000/cb",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_acceptable_redirect_uris(uri: str) -> None:
|
||||||
|
assert redirect_uri_problem(uri) is None
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
"""CI hygiene: pinned actions, fork guards, cleanup scope and the security gates (SEC-06/07/14/15/16).
|
||||||
|
|
||||||
|
The workflow files are simple enough to read line by line, so no YAML library is needed
|
||||||
|
(none is a dependency of the project).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
WORKFLOWS = sorted((ROOT / ".gitea" / "workflows").glob("*.yml"))
|
||||||
|
FORK_GUARD = (
|
||||||
|
"github.event_name != 'pull_request' || "
|
||||||
|
"github.event.pull_request.head.repo.full_name == github.repository"
|
||||||
|
)
|
||||||
|
PINNED = re.compile(r"^[\w.-]+/[\w.-]+@[0-9a-f]{40}$")
|
||||||
|
VERSION_COMMENT = re.compile(r"#\s*v\d+\.\d+\.\d+\s*$")
|
||||||
|
DOCKER_USE = re.compile(
|
||||||
|
r"\bdocker\s+(build|run|push|pull|login|save|tag|rmi|rm|image|ps|network|compose)\b"
|
||||||
|
r"|cleanup_job_resources\.sh|scan_image\.sh|tests/integration|tests/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Step:
|
||||||
|
lines: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def text(self) -> str:
|
||||||
|
return "\n".join(self.lines)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def uses(self) -> str | None:
|
||||||
|
for line in self.lines:
|
||||||
|
match = re.match(r"\s*(?:-\s*)?uses:\s*(.*)$", line)
|
||||||
|
if match:
|
||||||
|
return match.group(1).strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
def with_block(self) -> str:
|
||||||
|
collected: list[str] = []
|
||||||
|
inside = False
|
||||||
|
for line in self.lines:
|
||||||
|
if re.match(r"^\s{8}with:\s*$", line):
|
||||||
|
inside = True
|
||||||
|
continue
|
||||||
|
if inside and re.match(r"^\s{8}\S", line):
|
||||||
|
break
|
||||||
|
if inside:
|
||||||
|
collected.append(line)
|
||||||
|
return "\n".join(collected)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Job:
|
||||||
|
name: str
|
||||||
|
lines: list[str]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def text(self) -> str:
|
||||||
|
return "\n".join(self.lines)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def condition(self) -> str | None:
|
||||||
|
for line in self.lines:
|
||||||
|
match = re.match(r"^ {4}if:\s*(.*)$", line)
|
||||||
|
if match:
|
||||||
|
return match.group(1).strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def needs(self) -> list[str]:
|
||||||
|
for line in self.lines:
|
||||||
|
match = re.match(r"^ {4}needs:\s*\[(.*)\]\s*$", line)
|
||||||
|
if match:
|
||||||
|
return [item.strip() for item in match.group(1).split(",")]
|
||||||
|
return []
|
||||||
|
|
||||||
|
def steps(self) -> list[Step]:
|
||||||
|
steps: list[Step] = []
|
||||||
|
in_steps = False
|
||||||
|
for line in self.lines:
|
||||||
|
if re.match(r"^ {4}steps:\s*$", line):
|
||||||
|
in_steps = True
|
||||||
|
continue
|
||||||
|
if not in_steps:
|
||||||
|
continue
|
||||||
|
if re.match(r"^ {6}-\s", line):
|
||||||
|
steps.append(Step())
|
||||||
|
if steps:
|
||||||
|
steps[-1].lines.append(line)
|
||||||
|
return steps
|
||||||
|
|
||||||
|
|
||||||
|
def jobs_of(path: Path) -> list[Job]:
|
||||||
|
jobs: list[Job] = []
|
||||||
|
in_jobs = False
|
||||||
|
for line in path.read_text(encoding="utf-8").splitlines():
|
||||||
|
if line.startswith("jobs:"):
|
||||||
|
in_jobs = True
|
||||||
|
continue
|
||||||
|
if not in_jobs:
|
||||||
|
continue
|
||||||
|
if line.lstrip().startswith("#"):
|
||||||
|
continue # comment lines (the banner of the next job sits at the end of a block)
|
||||||
|
match = re.match(r"^ {2}([\w-]+):\s*$", line)
|
||||||
|
if match:
|
||||||
|
jobs.append(Job(match.group(1), []))
|
||||||
|
if jobs:
|
||||||
|
jobs[-1].lines.append(line)
|
||||||
|
return jobs
|
||||||
|
|
||||||
|
|
||||||
|
def has_pull_request_trigger(path: Path) -> bool:
|
||||||
|
return (
|
||||||
|
re.search(r"^ {2}pull_request:", path.read_text(encoding="utf-8"), re.MULTILINE) is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_there_are_workflows_to_check() -> None:
|
||||||
|
assert {path.name for path in WORKFLOWS} >= {"ci.yml", "publish.yml"}
|
||||||
|
for path in WORKFLOWS:
|
||||||
|
assert jobs_of(path), path.name
|
||||||
|
|
||||||
|
|
||||||
|
# SEC-07
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("path", WORKFLOWS, ids=lambda p: p.name)
|
||||||
|
def test_every_action_is_pinned_to_a_full_commit_sha_with_a_version_comment(path: Path) -> None:
|
||||||
|
seen = 0
|
||||||
|
for line in path.read_text(encoding="utf-8").splitlines():
|
||||||
|
match = re.match(r"^\s*(?:-\s*)?uses:\s*(\S+)(.*)$", line)
|
||||||
|
if match is None or line.lstrip().startswith("#"):
|
||||||
|
continue
|
||||||
|
seen += 1
|
||||||
|
assert PINNED.match(match.group(1)), f"{path.name}: {line.strip()}"
|
||||||
|
assert VERSION_COMMENT.search(match.group(2)), f"{path.name}: no version comment: {line}"
|
||||||
|
assert seen, f"{path.name} uses no action, so the pin check proved nothing"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("path", WORKFLOWS, ids=lambda p: p.name)
|
||||||
|
def test_no_secret_is_handed_to_a_third_party_action(path: Path) -> None:
|
||||||
|
for job in jobs_of(path):
|
||||||
|
for step in job.steps():
|
||||||
|
if step.uses is not None:
|
||||||
|
assert "secrets." not in step.with_block(), f"{path.name}/{job.name}"
|
||||||
|
assert "secrets." not in step.text, f"{path.name}/{job.name}: {step.text}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("path", WORKFLOWS, ids=lambda p: p.name)
|
||||||
|
def test_checkout_does_not_persist_the_job_token(path: Path) -> None:
|
||||||
|
for job in jobs_of(path):
|
||||||
|
for step in job.steps():
|
||||||
|
if step.uses and step.uses.startswith("actions/checkout@"):
|
||||||
|
assert "persist-credentials: false" in step.with_block(), f"{path.name}/{job.name}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_registry_login_uses_the_cli_with_the_token_on_stdin() -> None:
|
||||||
|
text = (ROOT / ".gitea" / "workflows" / "ci.yml").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "docker/login-action" not in text
|
||||||
|
assert "--password-stdin" in text
|
||||||
|
assert not re.search(r"docker login[^\n]*--password(?!-stdin)", text)
|
||||||
|
|
||||||
|
|
||||||
|
# SEC-06
|
||||||
|
|
||||||
|
|
||||||
|
def test_docker_and_secret_jobs_do_not_run_for_fork_pull_requests() -> None:
|
||||||
|
path = ROOT / ".gitea" / "workflows" / "ci.yml"
|
||||||
|
assert has_pull_request_trigger(path)
|
||||||
|
guarded = []
|
||||||
|
for job in jobs_of(path):
|
||||||
|
if DOCKER_USE.search(job.text) or "secrets." in job.text:
|
||||||
|
guarded.append(job.name)
|
||||||
|
assert job.condition == FORK_GUARD, f"job {job.name} runs docker or uses secrets"
|
||||||
|
assert {"test-integration", "test-mcp", "build-and-push"} <= set(guarded)
|
||||||
|
|
||||||
|
|
||||||
|
def test_workflows_without_a_pull_request_trigger_cannot_run_fork_code() -> None:
|
||||||
|
for path in WORKFLOWS:
|
||||||
|
if not has_pull_request_trigger(path):
|
||||||
|
assert "pull_request_target" not in path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
# SEC-15
|
||||||
|
|
||||||
|
|
||||||
|
def test_publish_needs_the_security_job_with_every_gate() -> None:
|
||||||
|
path = ROOT / ".gitea" / "workflows" / "publish.yml"
|
||||||
|
jobs = {job.name: job for job in jobs_of(path)}
|
||||||
|
|
||||||
|
assert "security" in jobs["publish"].needs
|
||||||
|
security = jobs["security"].text
|
||||||
|
for gate in (
|
||||||
|
"scripts/ci/install_gitleaks.sh",
|
||||||
|
"gitleaks git",
|
||||||
|
"tests/security",
|
||||||
|
"scripts/ci/pip_audit.sh",
|
||||||
|
"ruff check --select S",
|
||||||
|
):
|
||||||
|
assert gate in security
|
||||||
|
assert "fetch-depth: 0" in security # the history scan needs it
|
||||||
|
|
||||||
|
|
||||||
|
def test_ci_security_job_runs_the_security_suite() -> None:
|
||||||
|
jobs = {job.name: job for job in jobs_of(ROOT / ".gitea" / "workflows" / "ci.yml")}
|
||||||
|
|
||||||
|
assert "uv run pytest tests/security --no-cov" in jobs["security"].text
|
||||||
|
|
||||||
|
|
||||||
|
# SEC-14
|
||||||
|
|
||||||
|
|
||||||
|
def test_publish_passes_the_token_through_the_environment_not_the_command_line() -> None:
|
||||||
|
text = (ROOT / ".gitea" / "workflows" / "publish.yml").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "--password" not in text
|
||||||
|
assert "UV_PUBLISH_PASSWORD: ${{ secrets.REGISTRY_TOKEN }}" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_build_job_logs_out_by_deleting_its_own_credential_directory() -> None:
|
||||||
|
text = (ROOT / ".gitea" / "workflows" / "ci.yml").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "docker logout" not in text
|
||||||
|
assert "DOCKER_CONFIG" in text
|
||||||
|
|
||||||
|
|
||||||
|
FAKE_DOCKER = """#!/usr/bin/env bash
|
||||||
|
echo "$*" >> "$DOCKER_LOG"
|
||||||
|
if [ "$1" = "ps" ]; then
|
||||||
|
# one container per ancestor filter value
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in ancestor=*) echo "cid-of-${arg#ancestor=}" ;; esac
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def run_cleanup(tmp_path: Path, *refs: str) -> list[str]:
|
||||||
|
bin_dir = tmp_path / "bin"
|
||||||
|
bin_dir.mkdir()
|
||||||
|
fake = bin_dir / "docker"
|
||||||
|
fake.write_text(FAKE_DOCKER)
|
||||||
|
fake.chmod(fake.stat().st_mode | stat.S_IXUSR)
|
||||||
|
log = tmp_path / "docker.log"
|
||||||
|
log.write_text("")
|
||||||
|
env = {
|
||||||
|
"PATH": f"{bin_dir}:/usr/bin:/bin",
|
||||||
|
"DOCKER_LOG": str(log),
|
||||||
|
"HOME": str(tmp_path),
|
||||||
|
}
|
||||||
|
work = tmp_path / "work"
|
||||||
|
work.mkdir()
|
||||||
|
subprocess.run( # noqa: S603 (fixed script path, fake docker on PATH)
|
||||||
|
["bash", str(ROOT / "scripts" / "ci" / "cleanup_job_resources.sh"), *refs], # noqa: S607
|
||||||
|
cwd=work,
|
||||||
|
env=env,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
return log.read_text().splitlines()
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_removes_containers_only_for_per_run_refs(tmp_path: Path) -> None:
|
||||||
|
registry = "git.hiddenden.cafe/hiddenden/aegisgitea-mcp"
|
||||||
|
per_run = f"{registry}:sha-0123456789ab"
|
||||||
|
shared = [f"{registry}:latest", f"{registry}:main", f"{registry}:dev"]
|
||||||
|
|
||||||
|
calls = run_cleanup(tmp_path, per_run, *shared)
|
||||||
|
|
||||||
|
removed = [call for call in calls if call.startswith("rm -f")]
|
||||||
|
assert removed == [f"rm -f -v cid-of-{per_run}"]
|
||||||
|
queried = [call for call in calls if call.startswith("ps ")]
|
||||||
|
assert all("sha-0123456789ab" in call for call in queried) and len(queried) == 1
|
||||||
|
# Shared tags are only untagged.
|
||||||
|
for ref in shared:
|
||||||
|
assert f"image rm {ref}" in calls
|
||||||
|
assert f"rmi {ref}" not in calls
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_treats_names_with_the_run_prefix_as_per_run(tmp_path: Path) -> None:
|
||||||
|
calls = run_cleanup(tmp_path, "aegis-ci-4242-mcp:test")
|
||||||
|
|
||||||
|
assert "rm -f -v cid-of-aegis-ci-4242-mcp:test" in calls
|
||||||
|
assert "rmi aegis-ci-4242-mcp:test" in calls
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_without_arguments_touches_nothing(tmp_path: Path) -> None:
|
||||||
|
assert not any(call.startswith(("rm", "rmi", "image rm")) for call in run_cleanup(tmp_path))
|
||||||
|
|
||||||
|
|
||||||
|
# SEC-16
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_image_scan_is_pinned_verified_and_gates_the_push() -> None:
|
||||||
|
install = (ROOT / "scripts" / "ci" / "install_trivy.sh").read_text(encoding="utf-8")
|
||||||
|
scan = (ROOT / "scripts" / "ci" / "scan_image.sh").read_text(encoding="utf-8")
|
||||||
|
ci = (ROOT / ".gitea" / "workflows" / "ci.yml").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert re.search(r'^TRIVY_VERSION="\d+\.\d+\.\d+"$', install, re.MULTILINE)
|
||||||
|
assert len(re.findall(r'^SHA256_\w+="[0-9a-f]{64}"$', install, re.MULTILINE)) == 2
|
||||||
|
assert "sha256sum --check" in install
|
||||||
|
assert "--severity HIGH,CRITICAL" in scan and "--exit-code 1" in scan
|
||||||
|
assert "--ignore-unfixed" in scan
|
||||||
|
jobs = {job.name: job for job in jobs_of(ROOT / ".gitea" / "workflows" / "ci.yml")}
|
||||||
|
build = jobs["build-and-push"].text
|
||||||
|
assert build.index("scripts/ci/scan_image.sh") < build.index("docker push")
|
||||||
|
assert "scripts/ci/install_trivy.sh" in ci
|
||||||
|
|
||||||
|
|
||||||
|
def test_scripts_are_executable_and_parse() -> None:
|
||||||
|
for script in (ROOT / "scripts" / "ci").glob("*.sh"):
|
||||||
|
assert os.access(script, os.X_OK), script.name
|
||||||
|
subprocess.run(["bash", "-n", str(script)], check=True, timeout=30) # noqa: S603, S607
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
"""``./.env`` is not trusted implicitly (SEC-04).
|
||||||
|
|
||||||
|
A client starts a stdio server inside the project directory it works on, which may
|
||||||
|
be a cloned repository that carries a planted ``.env``. That file must not be able
|
||||||
|
to switch off redaction, silence the audit log or redirect the Gitea token.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from aegis_gitea_mcp.cli import main
|
||||||
|
from aegis_gitea_mcp.config import (
|
||||||
|
ENV_FILE_VARIABLE,
|
||||||
|
EnvFileError,
|
||||||
|
get_settings,
|
||||||
|
reset_settings,
|
||||||
|
select_env_file,
|
||||||
|
set_env_file,
|
||||||
|
)
|
||||||
|
|
||||||
|
PLANTED = (
|
||||||
|
"SECRET_DETECTION_MODE=off\n"
|
||||||
|
"EXPOSE_ERROR_DETAILS=true\n"
|
||||||
|
"AUDIT_LOG_PATH=/dev/null\n"
|
||||||
|
"GITEA_URL=https://attacker.example.com\n"
|
||||||
|
"READ_ONLY=true\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _env(mock_env: None) -> None:
|
||||||
|
"""GITEA_URL and GITEA_TOKEN come from the real environment."""
|
||||||
|
|
||||||
|
|
||||||
|
def write_env(path: Path, text: str = PLANTED, mode: int = 0o600) -> Path:
|
||||||
|
path.write_text(text)
|
||||||
|
path.chmod(mode)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("mode", [0o644, 0o640, 0o604, 0o666, 0o660])
|
||||||
|
def test_group_or_world_accessible_dotenv_is_ignored_with_a_warning(
|
||||||
|
tmp_path: Path, mode: int, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
write_env(tmp_path / ".env", mode=mode)
|
||||||
|
os.environ.pop("AUDIT_LOG_PATH")
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
assert settings.secret_detection_mode == "mask"
|
||||||
|
assert settings.expose_error_details is False
|
||||||
|
assert settings.read_only is False
|
||||||
|
assert settings.gitea_url == "https://gitea.example.com"
|
||||||
|
assert settings.resolved_audit_log_path == settings.data_dir / "audit.log"
|
||||||
|
err = capsys.readouterr().err
|
||||||
|
assert "ignoring ./.env" in err
|
||||||
|
assert "chmod 600 .env" in err and "--env-file" in err
|
||||||
|
assert "attacker" not in err # the warning never prints file content
|
||||||
|
|
||||||
|
|
||||||
|
def test_symlinked_dotenv_is_ignored(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
target = write_env(tmp_path / "real.env")
|
||||||
|
(tmp_path / ".env").symlink_to(target)
|
||||||
|
|
||||||
|
assert get_settings().secret_detection_mode == "mask"
|
||||||
|
assert "it is a symlink" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
def test_dotenv_that_is_not_a_regular_file_is_ignored(
|
||||||
|
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
(tmp_path / ".env").mkdir()
|
||||||
|
|
||||||
|
assert get_settings().secret_detection_mode == "mask"
|
||||||
|
assert "not a regular file" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
def test_dotenv_owned_by_another_user_is_ignored(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
write_env(tmp_path / ".env")
|
||||||
|
monkeypatch.setattr(os, "geteuid", lambda: os.stat(tmp_path / ".env").st_uid + 1)
|
||||||
|
|
||||||
|
assert get_settings().secret_detection_mode == "mask"
|
||||||
|
assert "owned by another user" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
def test_private_dotenv_owned_by_the_user_is_honoured(
|
||||||
|
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
write_env(tmp_path / ".env", "READ_ONLY=true\nMCP_PORT=9191\n")
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
assert settings.read_only is True and settings.mcp_port == 9191
|
||||||
|
assert capsys.readouterr().err == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_environment_variables_win_over_the_env_file(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
write_env(tmp_path / ".env", "MCP_PORT=9191\nGITEA_URL=https://file.example.com\n")
|
||||||
|
monkeypatch.setenv("MCP_PORT", "9000")
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
assert settings.mcp_port == 9000
|
||||||
|
assert settings.gitea_url == "https://gitea.example.com"
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_dotenv_is_silent(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
get_settings()
|
||||||
|
|
||||||
|
assert capsys.readouterr().err == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_env_file_is_used_even_outside_the_working_directory(tmp_path: Path) -> None:
|
||||||
|
elsewhere = tmp_path / "conf" / "aegis.env"
|
||||||
|
elsewhere.parent.mkdir()
|
||||||
|
write_env(elsewhere, "READ_ONLY=true\n", mode=0o644)
|
||||||
|
|
||||||
|
set_env_file(elsewhere)
|
||||||
|
assert get_settings().read_only is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_env_file_variable_selects_the_file(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
chosen = write_env(tmp_path / "chosen.env", "READ_ONLY=true\n")
|
||||||
|
# The (untrusted) ./.env must not matter when a file is named.
|
||||||
|
write_env(tmp_path / ".env", mode=0o644)
|
||||||
|
monkeypatch.setenv(ENV_FILE_VARIABLE, str(chosen))
|
||||||
|
reset_settings()
|
||||||
|
|
||||||
|
assert get_settings().read_only is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_option_beats_the_variable(tmp_path: Path) -> None:
|
||||||
|
option = write_env(tmp_path / "option.env", "MCP_PORT=9001\n")
|
||||||
|
variable = write_env(tmp_path / "variable.env", "MCP_PORT=9002\n")
|
||||||
|
|
||||||
|
path, warning = select_env_file(option, environ={ENV_FILE_VARIABLE: str(variable)})
|
||||||
|
|
||||||
|
assert path == option and warning is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("kind", ["missing", "directory"])
|
||||||
|
def test_explicit_env_file_that_cannot_be_used_is_an_error(tmp_path: Path, kind: str) -> None:
|
||||||
|
target = tmp_path / "nope.env"
|
||||||
|
if kind == "directory":
|
||||||
|
target.mkdir()
|
||||||
|
|
||||||
|
with pytest.raises(EnvFileError):
|
||||||
|
select_env_file(target)
|
||||||
|
|
||||||
|
|
||||||
|
def test_serve_with_a_missing_env_file_exits_2(
|
||||||
|
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
assert main(["serve", "--env-file", str(tmp_path / "nope.env")]) == 2
|
||||||
|
assert "does not exist" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
def test_serve_ignores_a_planted_dotenv_and_audits_to_the_real_location(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
from aegis_gitea_mcp import cli, transports
|
||||||
|
|
||||||
|
monkeypatch.setattr(cli, "configure_logging", lambda level: None)
|
||||||
|
write_env(tmp_path / ".env", mode=0o644)
|
||||||
|
seen: list[object] = []
|
||||||
|
monkeypatch.setattr(transports, "run_stdio", lambda settings: seen.append(settings) or 0)
|
||||||
|
|
||||||
|
assert main(["serve"]) == 0
|
||||||
|
|
||||||
|
(settings,) = seen
|
||||||
|
assert settings.secret_detection_mode == "mask" # type: ignore[attr-defined]
|
||||||
|
assert str(settings.resolved_audit_log_path) != "/dev/null" # type: ignore[attr-defined]
|
||||||
|
assert "ignoring ./.env" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_takes_env_file_from_the_variable_and_the_option() -> None:
|
||||||
|
from aegis_gitea_mcp.cli import build_parser
|
||||||
|
|
||||||
|
args = build_parser().parse_args(["setup", "--env-file", "/tmp/x.env"])
|
||||||
|
assert args.env_file == "/tmp/x.env"
|
||||||
|
assert build_parser().parse_args(["setup"]).env_file is None
|
||||||
@@ -0,0 +1,498 @@
|
|||||||
|
"""Malformed input is rejected at the boundary, before Gitea is contacted."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from mcp import Client, MCPError
|
||||||
|
from pytest_httpx import HTTPXMock
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
from aegis_gitea_mcp import server as server_module
|
||||||
|
from aegis_gitea_mcp.server import max_request_body_size
|
||||||
|
from aegis_gitea_mcp.transports import build_http_app
|
||||||
|
from tests.test_server_helpers import API, BEARER_TOKEN, connected, mock_user, server_settings
|
||||||
|
|
||||||
|
# Many cases are refused before anything is sent, others only after the admin identity lookup:
|
||||||
|
# what matters here is which requests reach Gitea, asserted explicitly in every test.
|
||||||
|
pytestmark = pytest.mark.httpx_mock(
|
||||||
|
assert_all_responses_were_requested=False, assert_all_requests_were_expected=False
|
||||||
|
)
|
||||||
|
|
||||||
|
REPO = {"owner": "octo", "repo": "demo"}
|
||||||
|
UPLOAD = "gitea_release_create_release_attachment"
|
||||||
|
UPLOAD_ARGS = {**REPO, "id": 1}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _env(mock_env: None) -> None:
|
||||||
|
"""The audit logger reads the process-wide settings."""
|
||||||
|
|
||||||
|
|
||||||
|
def error_of(result: Any) -> dict[str, Any]:
|
||||||
|
assert result.is_error, result.content[0].text
|
||||||
|
error: dict[str, Any] = json.loads(result.content[0].text)["error"]
|
||||||
|
return error
|
||||||
|
|
||||||
|
|
||||||
|
def gitea_calls(httpx_mock: HTTPXMock) -> list[str]:
|
||||||
|
"""Requests that reached Gitea, without the admin identity lookup."""
|
||||||
|
return [
|
||||||
|
str(r.url) for r in httpx_mock.get_requests() if not str(r.url).endswith("/api/v1/user")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# Schema: unknown arguments, wrong types, missing arguments
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("tool", "arguments", "fragment"),
|
||||||
|
[
|
||||||
|
("gitea_repo_get", {**REPO, "unexpected": 1}, "unexpected argument"),
|
||||||
|
("gitea_repo_get", {"owner": "octo"}, "missing required"),
|
||||||
|
("gitea_repo_get", {}, "missing required"),
|
||||||
|
("gitea_repo_get", {"owner": 7, "repo": "demo"}, "type"),
|
||||||
|
("gitea_repo_get", {"owner": ["octo"], "repo": "demo"}, "type"),
|
||||||
|
("gitea_repo_get", {"owner": None, "repo": "demo"}, "type"),
|
||||||
|
("gitea_repo_get", {"owner": "", "repo": "demo"}, "minLength"),
|
||||||
|
("gitea_repo_get", {"owner": "a" * 256, "repo": "demo"}, "maxLength"),
|
||||||
|
("gitea_repo_get", {"owner": "bad owner", "repo": "demo"}, "pattern"),
|
||||||
|
("gitea_repo_get", {"owner": "octo/evil", "repo": "demo"}, "pattern"),
|
||||||
|
("gitea_repo_get", {"owner": "octo", "repo": "de%6Dmo"}, "pattern"),
|
||||||
|
("gitea_repo_get", {**REPO, "sudo": 1}, "type"),
|
||||||
|
("gitea_issue_get_issue", {**REPO, "index": "one"}, "type"),
|
||||||
|
("gitea_issue_get_issue", {**REPO, "index": 1.5}, "type"),
|
||||||
|
("gitea_issue_get_issue", {**REPO, "index": True}, "type"),
|
||||||
|
(
|
||||||
|
"gitea_issue_create_issue",
|
||||||
|
{**REPO, "body": {"title": "x", "labels": ["a"]}},
|
||||||
|
"type",
|
||||||
|
),
|
||||||
|
("gitea_issue_create_issue", {**REPO, "body": {"title": "x", "extra": 1}}, "unexpected"),
|
||||||
|
("gitea_issue_create_issue", {**REPO, "body": {"body": "no title"}}, "missing required"),
|
||||||
|
("gitea_issue_create_issue", {**REPO, "body": "not an object"}, "type"),
|
||||||
|
("gitea_api_request", {"method": "GET"}, "missing required"),
|
||||||
|
("gitea_api_request", {"method": "GET", "path": "/x", "extra": 1}, "unexpected"),
|
||||||
|
("gitea_api_request", {"method": "GET", "path": 5}, "type"),
|
||||||
|
("gitea_api_request", {"method": "GET", "path": ""}, "minLength"),
|
||||||
|
("gitea_api_request", {"method": "GET", "path": "x"}, "pattern"),
|
||||||
|
("gitea_api_request", {"method": "GET", "path": "/" + "a" * 2048}, "maxLength"),
|
||||||
|
("gitea_api_request", {"method": "GET", "path": "/x", "query": [1]}, "type"),
|
||||||
|
("gitea_api_request", {"method": "GET", "path": "/x", "query": {"a": {"b": 1}}}, ""),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_invalid_arguments_are_rejected_before_gitea(
|
||||||
|
httpx_mock: HTTPXMock, tool: str, arguments: dict[str, Any], fragment: str
|
||||||
|
) -> None:
|
||||||
|
async with connected() as client:
|
||||||
|
result = await client.call_tool(tool, arguments)
|
||||||
|
|
||||||
|
error = error_of(result)
|
||||||
|
assert error["kind"] == "invalid_argument"
|
||||||
|
assert fragment in error["message"]
|
||||||
|
assert httpx_mock.get_requests() == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_trailing_newline_passes_the_schema_but_not_the_executor(
|
||||||
|
httpx_mock: HTTPXMock,
|
||||||
|
) -> None:
|
||||||
|
# Python's `$` also matches before a trailing newline, so the schema pattern lets
|
||||||
|
# this through and the executor's own character check is what refuses it.
|
||||||
|
mock_user(httpx_mock)
|
||||||
|
async with connected() as client:
|
||||||
|
result = await client.call_tool("gitea_repo_get", {"owner": "octo", "repo": "demo\n"})
|
||||||
|
|
||||||
|
assert error_of(result)["kind"] == "invalid_argument"
|
||||||
|
assert gitea_calls(httpx_mock) == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_rejection_messages_never_echo_the_values() -> None:
|
||||||
|
planted = "planted-secret-value-123456"
|
||||||
|
async with connected() as client:
|
||||||
|
results = [
|
||||||
|
await client.call_tool("gitea_repo_get", {"owner": planted + " x", "repo": "demo"}),
|
||||||
|
await client.call_tool("gitea_repo_get", {"owner": 1, "repo": planted}),
|
||||||
|
await client.call_tool("gitea_repo_get", {**REPO, planted: planted}),
|
||||||
|
]
|
||||||
|
|
||||||
|
for result in results:
|
||||||
|
assert planted not in result.content[0].text
|
||||||
|
|
||||||
|
|
||||||
|
# Path values (catalog tools and the raw tool)
|
||||||
|
|
||||||
|
BAD_PATH_VALUES = [
|
||||||
|
"..",
|
||||||
|
"a/../b",
|
||||||
|
"../a",
|
||||||
|
"a/..",
|
||||||
|
".",
|
||||||
|
"./a",
|
||||||
|
"a/./b",
|
||||||
|
"a//b",
|
||||||
|
"/a",
|
||||||
|
"a/",
|
||||||
|
"a\\b",
|
||||||
|
"a?b",
|
||||||
|
"a#b",
|
||||||
|
"a%b",
|
||||||
|
"a%2e%2e/b",
|
||||||
|
"%2e%2e",
|
||||||
|
"a%2Fb",
|
||||||
|
"a\x00b",
|
||||||
|
"a\rb",
|
||||||
|
"a\nb",
|
||||||
|
"a" + chr(0x2028) + "b",
|
||||||
|
"a" + chr(0x2029) + "b",
|
||||||
|
"a\tb",
|
||||||
|
"a\x7fb",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", BAD_PATH_VALUES, ids=lambda v: repr(v))
|
||||||
|
async def test_unsafe_path_values_are_rejected_for_catalog_tools(
|
||||||
|
httpx_mock: HTTPXMock, value: str
|
||||||
|
) -> None:
|
||||||
|
mock_user(httpx_mock)
|
||||||
|
async with connected() as client:
|
||||||
|
result = await client.call_tool("gitea_repo_get_raw_file", {**REPO, "filepath": value})
|
||||||
|
|
||||||
|
assert error_of(result)["kind"] == "invalid_argument"
|
||||||
|
assert gitea_calls(httpx_mock) == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", [v for v in BAD_PATH_VALUES if v not in {"/a"}], ids=repr)
|
||||||
|
async def test_unsafe_path_values_are_rejected_for_the_raw_tool(
|
||||||
|
httpx_mock: HTTPXMock, value: str
|
||||||
|
) -> None:
|
||||||
|
mock_user(httpx_mock)
|
||||||
|
async with connected() as client:
|
||||||
|
result = await client.call_tool(
|
||||||
|
"gitea_api_request", {"method": "GET", "path": f"/repos/{value}/x"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert error_of(result)["kind"] == "invalid_argument"
|
||||||
|
assert gitea_calls(httpx_mock) == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("path", ["//evil.example/x", "///x", "//", "/a/../b", "/.", "/.."])
|
||||||
|
async def test_raw_paths_that_reshape_the_url_are_rejected(
|
||||||
|
httpx_mock: HTTPXMock, path: str
|
||||||
|
) -> None:
|
||||||
|
mock_user(httpx_mock)
|
||||||
|
async with connected() as client:
|
||||||
|
result = await client.call_tool("gitea_api_request", {"method": "GET", "path": path})
|
||||||
|
|
||||||
|
assert error_of(result)["kind"] == "invalid_argument"
|
||||||
|
assert gitea_calls(httpx_mock) == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_safe_path_values_are_encoded_not_interpreted(httpx_mock: HTTPXMock) -> None:
|
||||||
|
mock_user(httpx_mock)
|
||||||
|
httpx_mock.add_response(url=f"{API}/repos/octo/demo/raw/docs/a%20b.md", text="ok")
|
||||||
|
async with connected() as client:
|
||||||
|
result = await client.call_tool(
|
||||||
|
"gitea_repo_get_raw_file", {**REPO, "filepath": "docs/a b.md"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not result.is_error
|
||||||
|
|
||||||
|
|
||||||
|
# Query names and sudo
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"name", ["token", "access_token", "sudo", "_sudo", "TOKEN", "Access_Token", "SuDo", "_SUDO"]
|
||||||
|
)
|
||||||
|
async def test_credential_query_names_are_refused_on_the_raw_tool(
|
||||||
|
httpx_mock: HTTPXMock, name: str
|
||||||
|
) -> None:
|
||||||
|
mock_user(httpx_mock)
|
||||||
|
async with connected() as client:
|
||||||
|
result = await client.call_tool(
|
||||||
|
"gitea_api_request", {"method": "GET", "path": "/user", "query": {name: "x"}}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert error_of(result)["kind"] == "invalid_argument"
|
||||||
|
assert gitea_calls(httpx_mock) == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"sudo",
|
||||||
|
[
|
||||||
|
"bob\r\nX-Injected: 1",
|
||||||
|
"bob\n",
|
||||||
|
"bob ",
|
||||||
|
" bob",
|
||||||
|
"bo b",
|
||||||
|
"a" * 256,
|
||||||
|
"",
|
||||||
|
"b" + chr(0xF6) + "b",
|
||||||
|
"bob;",
|
||||||
|
"bob\x00",
|
||||||
|
],
|
||||||
|
ids=repr,
|
||||||
|
)
|
||||||
|
async def test_sudo_values_that_could_inject_headers_are_rejected(
|
||||||
|
httpx_mock: HTTPXMock, sudo: str
|
||||||
|
) -> None:
|
||||||
|
async with connected() as client:
|
||||||
|
for tool, arguments in (
|
||||||
|
("gitea_repo_get", {**REPO, "sudo": sudo}),
|
||||||
|
("gitea_api_request", {"method": "GET", "path": "/user", "sudo": sudo}),
|
||||||
|
):
|
||||||
|
result = await client.call_tool(tool, arguments)
|
||||||
|
assert error_of(result)["kind"] == "invalid_argument"
|
||||||
|
|
||||||
|
assert gitea_calls(httpx_mock) == []
|
||||||
|
|
||||||
|
|
||||||
|
# Uploads
|
||||||
|
|
||||||
|
|
||||||
|
async def test_an_oversized_upload_is_rejected_before_it_is_decoded(
|
||||||
|
httpx_mock: HTTPXMock,
|
||||||
|
) -> None:
|
||||||
|
# Not valid base64 at all: only a size check that runs before decoding reports "limit".
|
||||||
|
huge = "!" * 4000
|
||||||
|
async with connected(server_settings(max_binary_bytes=1000)) as client:
|
||||||
|
result = await client.call_tool(
|
||||||
|
UPLOAD, {**UPLOAD_ARGS, "attachment": {"filename": "a.bin", "content_base64": huge}}
|
||||||
|
)
|
||||||
|
|
||||||
|
error = error_of(result)
|
||||||
|
assert error["kind"] == "invalid_argument"
|
||||||
|
assert "limit" in error["message"] and "MAX_BINARY_BYTES" in error["message"]
|
||||||
|
assert httpx_mock.get_requests() == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_an_upload_at_the_limit_is_accepted(httpx_mock: HTTPXMock) -> None:
|
||||||
|
mock_user(httpx_mock)
|
||||||
|
httpx_mock.add_response(
|
||||||
|
method="POST", url=f"{API}/repos/octo/demo/releases/1/assets", status_code=201, json={}
|
||||||
|
)
|
||||||
|
content = base64.b64encode(b"x" * 1000).decode()
|
||||||
|
async with connected(server_settings(max_binary_bytes=1000)) as client:
|
||||||
|
result = await client.call_tool(
|
||||||
|
UPLOAD, {**UPLOAD_ARGS, "attachment": {"filename": "a.bin", "content_base64": content}}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not result.is_error
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"attachment",
|
||||||
|
[
|
||||||
|
{"filename": "a.bin", "content_base64": "not base64!!"},
|
||||||
|
{"filename": "a.bin", "content_base64": "YQ"}, # missing padding
|
||||||
|
{"filename": 'a".bin', "content_base64": "YQ=="},
|
||||||
|
{"filename": "a\r\nb.bin", "content_base64": "YQ=="},
|
||||||
|
{"filename": "a\nb.bin", "content_base64": "YQ=="},
|
||||||
|
{"filename": "a" + chr(0x2028) + "b.bin", "content_base64": "YQ=="},
|
||||||
|
{"filename": "", "content_base64": "YQ=="},
|
||||||
|
{"filename": "x" * 300, "content_base64": "YQ=="},
|
||||||
|
{"filename": "a.bin", "content_base64": "YQ==", "content_type": "text/plain\r\nX: 1"},
|
||||||
|
{"filename": "a.bin", "content_base64": "YQ==", "content_type": 5},
|
||||||
|
{"filename": "a.bin", "content_base64": "YQ==", "path": "/etc/passwd"},
|
||||||
|
{"filename": "a.bin", "content_base64": "YQ==", "unknown": 1},
|
||||||
|
{"filename": "a.bin"},
|
||||||
|
{"content_base64": "YQ=="},
|
||||||
|
{"filename": 1, "content_base64": "YQ=="},
|
||||||
|
{"filename": "a.bin", "content_base64": 1},
|
||||||
|
"just a string",
|
||||||
|
["a.bin", "YQ=="],
|
||||||
|
],
|
||||||
|
ids=lambda a: json.dumps(a)[:60],
|
||||||
|
)
|
||||||
|
async def test_malformed_uploads_are_rejected_before_gitea(
|
||||||
|
httpx_mock: HTTPXMock, attachment: Any
|
||||||
|
) -> None:
|
||||||
|
mock_user(httpx_mock)
|
||||||
|
async with connected() as client:
|
||||||
|
result = await client.call_tool(UPLOAD, {**UPLOAD_ARGS, "attachment": attachment})
|
||||||
|
|
||||||
|
assert error_of(result)["kind"] == "invalid_argument"
|
||||||
|
assert gitea_calls(httpx_mock) == []
|
||||||
|
|
||||||
|
|
||||||
|
# Raw method and READ_ONLY
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"method", ["TRACE", "CONNECT", "OPTIONS", "HEAD", "get", "G ET", "", "GET\r\n"]
|
||||||
|
)
|
||||||
|
async def test_raw_methods_outside_the_enum_are_rejected(
|
||||||
|
httpx_mock: HTTPXMock, method: str
|
||||||
|
) -> None:
|
||||||
|
async with connected() as client:
|
||||||
|
result = await client.call_tool("gitea_api_request", {"method": method, "path": "/user"})
|
||||||
|
|
||||||
|
assert error_of(result)["kind"] == "invalid_argument"
|
||||||
|
assert httpx_mock.get_requests() == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("method", ["POST", "PUT", "PATCH", "DELETE"])
|
||||||
|
async def test_read_only_mode_refuses_every_non_get_raw_call(
|
||||||
|
httpx_mock: HTTPXMock, method: str
|
||||||
|
) -> None:
|
||||||
|
async with connected(server_settings(read_only=True)) as client:
|
||||||
|
result = await client.call_tool(
|
||||||
|
"gitea_api_request", {"method": method, "path": "/repos/o/r/issues", "body": {}}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert error_of(result)["kind"] == "read_only"
|
||||||
|
assert httpx_mock.get_requests() == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_read_only_mode_does_not_list_or_run_write_tools(httpx_mock: HTTPXMock) -> None:
|
||||||
|
async with connected(server_settings(read_only=True)) as client:
|
||||||
|
names = {tool.name for tool in (await client.list_tools()).tools}
|
||||||
|
with pytest.raises(MCPError):
|
||||||
|
await client.call_tool("gitea_issue_create_issue", {**REPO, "body": {"title": "x"}})
|
||||||
|
with pytest.raises(MCPError):
|
||||||
|
await client.call_tool("gitea_repo_delete", REPO)
|
||||||
|
|
||||||
|
assert "gitea_issue_create_issue" not in names and "gitea_repo_delete" not in names
|
||||||
|
assert "gitea_repo_get" in names
|
||||||
|
assert httpx_mock.get_requests() == []
|
||||||
|
|
||||||
|
|
||||||
|
# Deep JSON
|
||||||
|
|
||||||
|
|
||||||
|
def nest(depth: int, leaf: Any = "x") -> Any:
|
||||||
|
value = leaf
|
||||||
|
for _ in range(depth):
|
||||||
|
value = {"a": value}
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("depth", [10, 60, 95])
|
||||||
|
async def test_deeply_nested_arguments_give_a_result_not_an_exception(
|
||||||
|
httpx_mock: HTTPXMock, depth: int
|
||||||
|
) -> None:
|
||||||
|
mock_user(httpx_mock)
|
||||||
|
httpx_mock.add_response(
|
||||||
|
method="POST", url=f"{API}/repos/o/r/issues", status_code=422, json={"message": "no"}
|
||||||
|
)
|
||||||
|
async with connected() as client:
|
||||||
|
result = await client.call_tool(
|
||||||
|
"gitea_api_request",
|
||||||
|
{"method": "POST", "path": "/repos/o/r/issues", "body": nest(depth)},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.is_error
|
||||||
|
assert json.loads(result.content[0].text)["error"]["kind"] == "validation_failed"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_recursion_error_during_validation_is_a_generic_error_result(
|
||||||
|
httpx_mock: HTTPXMock, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
def explode(*args: Any, **kwargs: Any) -> None:
|
||||||
|
raise RecursionError("maximum recursion depth exceeded")
|
||||||
|
|
||||||
|
monkeypatch.setattr(server_module, "validate_arguments", explode)
|
||||||
|
async with connected() as client:
|
||||||
|
result = await client.call_tool("gitea_repo_get", REPO)
|
||||||
|
|
||||||
|
assert error_of(result)["kind"] == "internal_error"
|
||||||
|
assert "RecursionError" not in result.content[0].text
|
||||||
|
assert httpx_mock.get_requests() == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("depth", [101, 2000, 50_000])
|
||||||
|
async def test_deeply_nested_gitea_responses_are_returned_as_text(
|
||||||
|
httpx_mock: HTTPXMock, depth: int
|
||||||
|
) -> None:
|
||||||
|
mock_user(httpx_mock)
|
||||||
|
httpx_mock.add_response(
|
||||||
|
url=f"{API}/repos/octo/demo",
|
||||||
|
content=("[" * depth + "]" * depth).encode(),
|
||||||
|
headers={"content-type": "application/json"},
|
||||||
|
)
|
||||||
|
async with connected() as client:
|
||||||
|
result = await client.call_tool("gitea_repo_get", REPO)
|
||||||
|
|
||||||
|
assert not result.is_error
|
||||||
|
assert isinstance(result.structured_content["data"], str) # type: ignore[index]
|
||||||
|
|
||||||
|
|
||||||
|
# HTTP boundary
|
||||||
|
|
||||||
|
|
||||||
|
def http_client(app: Any) -> TestClient:
|
||||||
|
return TestClient(app, base_url="http://127.0.0.1:8080")
|
||||||
|
|
||||||
|
|
||||||
|
def mcp_headers(**extra: str) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"Authorization": f"Bearer {BEARER_TOKEN}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json, text/event-stream",
|
||||||
|
**extra,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_body_over_the_cap_gets_413() -> None:
|
||||||
|
settings = server_settings(mcp_bearer_token=BEARER_TOKEN)
|
||||||
|
with http_client(build_http_app(settings)) as client:
|
||||||
|
response = client.post(
|
||||||
|
"/mcp", content=b"x" * (max_request_body_size(settings) + 1), headers=mcp_headers()
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 413
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_non_json_content_type_gets_400() -> None:
|
||||||
|
with http_client(build_http_app(server_settings(mcp_bearer_token=BEARER_TOKEN))) as client:
|
||||||
|
for content_type in (
|
||||||
|
"text/plain",
|
||||||
|
"application/x-www-form-urlencoded",
|
||||||
|
"multipart/form-data",
|
||||||
|
):
|
||||||
|
response = client.post(
|
||||||
|
"/mcp", content=b"{}", headers=mcp_headers(**{"Content-Type": content_type})
|
||||||
|
)
|
||||||
|
assert response.status_code == 400, content_type
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_foreign_host_header_gets_421() -> None:
|
||||||
|
with http_client(build_http_app(server_settings(mcp_bearer_token=BEARER_TOKEN))) as client:
|
||||||
|
for host in ("evil.example.com", "evil.example.com:8080", "127.0.0.1.evil.example.com"):
|
||||||
|
response = client.post("/mcp", content=b"{}", headers=mcp_headers(Host=host))
|
||||||
|
assert response.status_code == 421, host
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_foreign_origin_gets_403() -> None:
|
||||||
|
with http_client(build_http_app(server_settings(mcp_bearer_token=BEARER_TOKEN))) as client:
|
||||||
|
for origin in ("https://evil.example.com", "http://127.0.0.1.evil.example.com", "null"):
|
||||||
|
response = client.post("/mcp", content=b"{}", headers=mcp_headers(Origin=origin))
|
||||||
|
assert response.status_code == 403, origin
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_and_deeply_nested_json_rpc_gets_a_parse_error_not_a_crash() -> None:
|
||||||
|
deep = "[" * 5000 + "]" * 5000
|
||||||
|
bodies = [
|
||||||
|
b"not json",
|
||||||
|
b"{",
|
||||||
|
b"[]",
|
||||||
|
b'{"jsonrpc":"2.0"}',
|
||||||
|
(
|
||||||
|
'{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"gitea_api_request",'
|
||||||
|
f'"arguments":{{"method":"POST","path":"/x","body":{deep}}}}}}}'
|
||||||
|
).encode(),
|
||||||
|
]
|
||||||
|
for body in bodies:
|
||||||
|
with http_client(build_http_app(server_settings(mcp_bearer_token=BEARER_TOKEN))) as client:
|
||||||
|
response = client.post("/mcp", content=body, headers=mcp_headers())
|
||||||
|
assert response.status_code in {400, 422}, body[:20]
|
||||||
|
assert "Traceback" not in response.text
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_listing_is_unaffected_by_all_of_the_above() -> None:
|
||||||
|
async with connected() as client:
|
||||||
|
assert isinstance(client, Client)
|
||||||
|
assert len((await client.list_tools()).tools) == 538
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
"""CSRF, origin and browser-binding checks of the consent and callback routes (SEC-17, SEC-19)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pytest_httpx import HTTPXMock
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
from aegis_gitea_mcp.oauth.provider import PENDING_TTL
|
||||||
|
from tests.test_oauth_helpers import (
|
||||||
|
BASE,
|
||||||
|
CLIENT_STATE,
|
||||||
|
CSRF_FIELD,
|
||||||
|
GITEA_CODE,
|
||||||
|
REDIRECT,
|
||||||
|
FakeClock,
|
||||||
|
browser,
|
||||||
|
build_oauth_app,
|
||||||
|
exchange_code,
|
||||||
|
gitea_requests,
|
||||||
|
mock_gitea_login,
|
||||||
|
pkce,
|
||||||
|
query_of,
|
||||||
|
register,
|
||||||
|
start_flow,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _env(mock_env: None) -> None:
|
||||||
|
"""The audit logger and the rate limiter read the process-wide settings."""
|
||||||
|
|
||||||
|
|
||||||
|
def open_consent(client: TestClient, registration: dict[str, str]) -> tuple[dict[str, str], str]:
|
||||||
|
"""Register and call /authorize, open the consent page; return (form fields, consent URL)."""
|
||||||
|
authorize = client.get(
|
||||||
|
"/authorize",
|
||||||
|
params={
|
||||||
|
"client_id": registration["client_id"],
|
||||||
|
"redirect_uri": REDIRECT,
|
||||||
|
"response_type": "code",
|
||||||
|
"code_challenge": pkce()[1],
|
||||||
|
"code_challenge_method": "S256",
|
||||||
|
"state": CLIENT_STATE,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
consent_url = authorize.headers["location"]
|
||||||
|
match = CSRF_FIELD.search(client.get(consent_url).text)
|
||||||
|
assert match is not None
|
||||||
|
form = {"request": query_of(consent_url)["request"], "csrf": match.group(1)}
|
||||||
|
return {**form, "decision": "approve"}, consent_url
|
||||||
|
|
||||||
|
|
||||||
|
def assert_refused(response: object) -> None:
|
||||||
|
assert response.status_code == 400 # type: ignore[attr-defined]
|
||||||
|
assert "location" not in response.headers # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
|
||||||
|
# Consent POST (SEC-17)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"headers",
|
||||||
|
[
|
||||||
|
{},
|
||||||
|
{"Origin": "null"},
|
||||||
|
{"Origin": "https://evil.example"},
|
||||||
|
{"Origin": "http://mcp.example.com"},
|
||||||
|
{"Origin": "https://mcp.example.com.evil.example"},
|
||||||
|
{"Referer": "https://evil.example/page"},
|
||||||
|
{"Referer": "not a url"},
|
||||||
|
{"Origin": "https://evil.example", "Referer": f"{BASE}/oauth/consent"},
|
||||||
|
],
|
||||||
|
ids=lambda headers: ",".join(f"{k}={v}" for k, v in headers.items()) or "no-origin-no-referer",
|
||||||
|
)
|
||||||
|
def test_consent_post_without_a_matching_origin_is_refused(headers: dict[str, str]) -> None:
|
||||||
|
oauth = build_oauth_app()
|
||||||
|
client = browser(oauth.app)
|
||||||
|
form, _ = open_consent(client, register(client).json())
|
||||||
|
|
||||||
|
response = client.post("/oauth/consent", data=form, headers=headers)
|
||||||
|
|
||||||
|
assert_refused(response)
|
||||||
|
# The request was not consumed: the real form still works afterwards.
|
||||||
|
assert client.post("/oauth/consent", data=form, headers={"Origin": BASE}).status_code == 303
|
||||||
|
|
||||||
|
|
||||||
|
def test_consent_post_falls_back_to_the_referer_origin() -> None:
|
||||||
|
client = browser(build_oauth_app().app)
|
||||||
|
form, consent_url = open_consent(client, register(client).json())
|
||||||
|
|
||||||
|
response = client.post("/oauth/consent", data=form, headers={"Referer": consent_url})
|
||||||
|
|
||||||
|
assert response.status_code == 303
|
||||||
|
|
||||||
|
|
||||||
|
def test_consent_post_without_the_csrf_cookie_is_refused() -> None:
|
||||||
|
oauth = build_oauth_app()
|
||||||
|
client = browser(oauth.app)
|
||||||
|
form, _ = open_consent(client, register(client).json())
|
||||||
|
|
||||||
|
assert_refused(browser(oauth.app).post("/oauth/consent", data=form, headers={"Origin": BASE}))
|
||||||
|
|
||||||
|
|
||||||
|
def test_consent_post_with_a_mismatched_csrf_token_is_refused() -> None:
|
||||||
|
client = browser(build_oauth_app().app)
|
||||||
|
form, _ = open_consent(client, register(client).json())
|
||||||
|
|
||||||
|
assert_refused(
|
||||||
|
client.post("/oauth/consent", data={**form, "csrf": "x" * 43}, headers={"Origin": BASE})
|
||||||
|
)
|
||||||
|
assert_refused(
|
||||||
|
client.post("/oauth/consent", data={**form, "csrf": ""}, headers={"Origin": BASE})
|
||||||
|
)
|
||||||
|
missing = {key: value for key, value in form.items() if key != "csrf"}
|
||||||
|
assert_refused(client.post("/oauth/consent", data=missing, headers={"Origin": BASE}))
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_valid_consent_still_works_and_is_single_use() -> None:
|
||||||
|
client = browser(build_oauth_app().app)
|
||||||
|
form, _ = open_consent(client, register(client).json())
|
||||||
|
|
||||||
|
approved = client.post("/oauth/consent", data=form, headers={"Origin": BASE})
|
||||||
|
replay = client.post("/oauth/consent", data=form, headers={"Origin": BASE})
|
||||||
|
|
||||||
|
assert approved.status_code == 303
|
||||||
|
assert approved.headers["location"].startswith(
|
||||||
|
"https://gitea.example.com/login/oauth/authorize"
|
||||||
|
)
|
||||||
|
assert_refused(replay)
|
||||||
|
|
||||||
|
|
||||||
|
# Callback
|
||||||
|
|
||||||
|
|
||||||
|
def test_callback_without_the_binding_cookie_is_refused_and_audited(
|
||||||
|
httpx_mock: HTTPXMock,
|
||||||
|
) -> None:
|
||||||
|
oauth = build_oauth_app()
|
||||||
|
client = browser(oauth.app)
|
||||||
|
gitea_url = start_flow(client, register(client).json(), pkce()[1])
|
||||||
|
|
||||||
|
response = browser(oauth.app).get(
|
||||||
|
"/oauth/callback", params={"code": GITEA_CODE, "state": query_of(gitea_url)["state"]}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_refused(response)
|
||||||
|
assert gitea_requests(httpx_mock, "https://gitea.example.com") == []
|
||||||
|
audit = oauth.settings.resolved_audit_log_path.read_text("utf-8")
|
||||||
|
assert "oauth_callback_binding_mismatch" in audit
|
||||||
|
|
||||||
|
|
||||||
|
def test_callback_with_another_flows_cookie_is_refused(httpx_mock: HTTPXMock) -> None:
|
||||||
|
oauth = build_oauth_app()
|
||||||
|
client = browser(oauth.app)
|
||||||
|
registration = register(client).json()
|
||||||
|
first = start_flow(client, registration, pkce()[1])
|
||||||
|
cookies = dict(client.cookies)
|
||||||
|
second_client = browser(oauth.app)
|
||||||
|
second = start_flow(second_client, register(second_client).json(), pkce()[1])
|
||||||
|
|
||||||
|
# The cookie of flow one is presented for flow two's state.
|
||||||
|
attacker = browser(oauth.app)
|
||||||
|
for name, value in cookies.items():
|
||||||
|
attacker.cookies.set(name, value)
|
||||||
|
response = attacker.get(
|
||||||
|
"/oauth/callback", params={"code": GITEA_CODE, "state": query_of(second)["state"]}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_refused(response)
|
||||||
|
assert query_of(first)["state"] != query_of(second)["state"]
|
||||||
|
assert gitea_requests(httpx_mock, "https://gitea.example.com") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_callback_with_a_replayed_state_is_refused(httpx_mock: HTTPXMock) -> None:
|
||||||
|
oauth = build_oauth_app()
|
||||||
|
client = browser(oauth.app)
|
||||||
|
state = query_of(start_flow(client, register(client).json(), pkce()[1]))["state"]
|
||||||
|
mock_gitea_login(httpx_mock)
|
||||||
|
|
||||||
|
first = client.get("/oauth/callback", params={"code": GITEA_CODE, "state": state})
|
||||||
|
replay = client.get("/oauth/callback", params={"code": GITEA_CODE, "state": state})
|
||||||
|
|
||||||
|
assert first.status_code == 302
|
||||||
|
assert_refused(replay)
|
||||||
|
|
||||||
|
|
||||||
|
def test_callback_with_an_expired_or_unknown_state_is_refused() -> None:
|
||||||
|
clock = FakeClock()
|
||||||
|
oauth = build_oauth_app(clock=clock)
|
||||||
|
client = browser(oauth.app)
|
||||||
|
state = query_of(start_flow(client, register(client).json(), pkce()[1]))["state"]
|
||||||
|
clock.advance(PENDING_TTL + 1)
|
||||||
|
|
||||||
|
assert_refused(client.get("/oauth/callback", params={"code": GITEA_CODE, "state": state}))
|
||||||
|
assert_refused(client.get("/oauth/callback", params={"code": GITEA_CODE, "state": "unknown"}))
|
||||||
|
assert_refused(client.get("/oauth/callback", params={"code": GITEA_CODE}))
|
||||||
|
assert_refused(client.get("/oauth/callback"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_callback_never_echoes_its_parameters() -> None:
|
||||||
|
client = browser(build_oauth_app().app)
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
"/oauth/callback", params={"code": "code-value-123", "state": "state-value-123"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "code-value-123" not in response.text and "state-value-123" not in response.text
|
||||||
|
|
||||||
|
|
||||||
|
# Concurrent sign-ins in one browser (SEC-19)
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_interleaved_flows_in_one_browser_both_complete(httpx_mock: HTTPXMock) -> None:
|
||||||
|
oauth = build_oauth_app()
|
||||||
|
client = browser(oauth.app)
|
||||||
|
registration = register(client).json()
|
||||||
|
verifier_one, challenge_one = pkce()
|
||||||
|
verifier_two, challenge_two = pkce()
|
||||||
|
first = start_flow(client, registration, challenge_one)
|
||||||
|
second = start_flow(client, registration, challenge_two)
|
||||||
|
assert len({name for name in client.cookies if "oauth-flow" in name}) == 2
|
||||||
|
|
||||||
|
mock_gitea_login(httpx_mock, login="alice")
|
||||||
|
mock_gitea_login(httpx_mock, login="alice")
|
||||||
|
done_first = client.get(
|
||||||
|
"/oauth/callback", params={"code": GITEA_CODE, "state": query_of(first)["state"]}
|
||||||
|
)
|
||||||
|
done_second = client.get(
|
||||||
|
"/oauth/callback", params={"code": GITEA_CODE, "state": query_of(second)["state"]}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert done_first.status_code == 302 and done_second.status_code == 302
|
||||||
|
for done, verifier in ((done_first, verifier_one), (done_second, verifier_two)):
|
||||||
|
code = query_of(done.headers["location"])["code"]
|
||||||
|
assert exchange_code(client, registration, code, verifier).status_code == 200
|
||||||
|
audit = oauth.settings.resolved_audit_log_path.read_text("utf-8")
|
||||||
|
assert "oauth_callback_binding_mismatch" not in audit
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
"""Input bounds of the unauthenticated OAuth routes (SEC-01, SEC-09)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from mcp.server.auth.provider import RegistrationError
|
||||||
|
from starlette.applications import Starlette
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
from aegis_gitea_mcp.oauth import provider as provider_module
|
||||||
|
from aegis_gitea_mcp.oauth.callback import MAX_CONSENT_BODY_BYTES
|
||||||
|
from aegis_gitea_mcp.oauth.provider import MAX_PENDING_PER_SOURCE, MAX_REGISTRATIONS_PER_SOURCE
|
||||||
|
from aegis_gitea_mcp.request_context import set_client_address
|
||||||
|
from tests.security.helpers import asgi_request
|
||||||
|
from tests.test_oauth_helpers import (
|
||||||
|
BASE,
|
||||||
|
REDIRECT,
|
||||||
|
build_oauth_app,
|
||||||
|
oauth_settings,
|
||||||
|
pkce,
|
||||||
|
register,
|
||||||
|
)
|
||||||
|
from tests.test_oauth_provider import client_info
|
||||||
|
|
||||||
|
CHUNK = 64 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _env(mock_env: None) -> None:
|
||||||
|
"""The audit logger and the rate limiter read the process-wide settings."""
|
||||||
|
|
||||||
|
|
||||||
|
def chunks_of(total: int) -> list[bytes]:
|
||||||
|
return [b"a" * CHUNK for _ in range(total // CHUNK)]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_consent_post_over_the_cap_is_413_without_reading_the_body() -> None:
|
||||||
|
app = build_oauth_app().app
|
||||||
|
result = await asgi_request(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
"/oauth/consent",
|
||||||
|
headers={
|
||||||
|
"content-type": "application/x-www-form-urlencoded",
|
||||||
|
"content-length": str(1024 * 1024),
|
||||||
|
"origin": BASE,
|
||||||
|
},
|
||||||
|
chunks=chunks_of(1024 * 1024),
|
||||||
|
)
|
||||||
|
assert result.status == 413
|
||||||
|
assert result.bytes_read == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_consent_post_without_declared_length_stops_reading_at_the_cap() -> None:
|
||||||
|
app = build_oauth_app().app
|
||||||
|
result = await asgi_request(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
"/oauth/consent",
|
||||||
|
headers={"content-type": "application/x-www-form-urlencoded", "origin": BASE},
|
||||||
|
chunks=chunks_of(1024 * 1024),
|
||||||
|
)
|
||||||
|
assert result.status == 413
|
||||||
|
assert result.bytes_read <= MAX_CONSENT_BODY_BYTES + CHUNK
|
||||||
|
|
||||||
|
|
||||||
|
async def test_consent_multipart_is_415_without_reading_any_body() -> None:
|
||||||
|
app = build_oauth_app().app
|
||||||
|
result = await asgi_request(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
"/oauth/consent",
|
||||||
|
headers={"content-type": "multipart/form-data; boundary=x", "origin": BASE},
|
||||||
|
chunks=chunks_of(512 * 1024),
|
||||||
|
)
|
||||||
|
assert result.status == 415
|
||||||
|
assert result.bytes_read == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_consent_json_body_is_415() -> None:
|
||||||
|
app = build_oauth_app().app
|
||||||
|
result = await asgi_request(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
"/oauth/consent",
|
||||||
|
headers={"content-type": "application/json", "origin": BASE},
|
||||||
|
chunks=[b"{}"],
|
||||||
|
)
|
||||||
|
assert result.status == 415
|
||||||
|
|
||||||
|
|
||||||
|
async def test_small_urlencoded_consent_post_still_reaches_the_handler() -> None:
|
||||||
|
app = build_oauth_app().app
|
||||||
|
result = await asgi_request(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
"/oauth/consent",
|
||||||
|
headers={"content-type": "application/x-www-form-urlencoded", "origin": BASE},
|
||||||
|
chunks=[b"request=unknown&csrf=x&decision=approve"],
|
||||||
|
)
|
||||||
|
# Reaches the handler, which answers its fixed 400 page for an unknown request.
|
||||||
|
assert result.status == 400
|
||||||
|
assert b"invalid or has expired" in result.body
|
||||||
|
|
||||||
|
|
||||||
|
# SEC-09: pre-sign-in state cannot be flushed by one address
|
||||||
|
|
||||||
|
|
||||||
|
def client_at(app: Starlette, address: str) -> TestClient:
|
||||||
|
"""A browser-like client whose requests come from ``address``."""
|
||||||
|
return TestClient(app, base_url=BASE, client=(address, 50000), follow_redirects=False)
|
||||||
|
|
||||||
|
|
||||||
|
def authorize(client: TestClient, registration: dict[str, str]) -> Any:
|
||||||
|
return client.get(
|
||||||
|
"/authorize",
|
||||||
|
params={
|
||||||
|
"client_id": registration["client_id"],
|
||||||
|
"redirect_uri": REDIRECT,
|
||||||
|
"response_type": "code",
|
||||||
|
"code_challenge": pkce()[1],
|
||||||
|
"code_challenge_method": "S256",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_flood_of_authorize_calls_from_one_address_does_not_evict_another_users_request() -> None:
|
||||||
|
app = build_oauth_app(oauth_settings(rate_limit_per_minute=1_000_000)).app
|
||||||
|
victim = client_at(app, "198.51.100.20")
|
||||||
|
consent_url = authorize(victim, register(victim).json()).headers["location"]
|
||||||
|
assert victim.get(consent_url).status_code == 200
|
||||||
|
|
||||||
|
attacker = client_at(app, "203.0.113.66")
|
||||||
|
registration = register(attacker).json()
|
||||||
|
accepted = refused = 0
|
||||||
|
for _ in range(1100):
|
||||||
|
location = authorize(attacker, registration).headers["location"]
|
||||||
|
if "/oauth/consent" in location:
|
||||||
|
accepted += 1
|
||||||
|
else:
|
||||||
|
refused += 1
|
||||||
|
assert "error=temporarily_unavailable" in location
|
||||||
|
|
||||||
|
assert accepted == MAX_PENDING_PER_SOURCE
|
||||||
|
assert refused == 1100 - MAX_PENDING_PER_SOURCE
|
||||||
|
assert victim.get(consent_url).status_code == 200 # still pending
|
||||||
|
|
||||||
|
|
||||||
|
def test_registrations_per_address_are_limited() -> None:
|
||||||
|
app = build_oauth_app(oauth_settings(rate_limit_per_minute=1_000_000)).app
|
||||||
|
flooder = client_at(app, "203.0.113.66")
|
||||||
|
|
||||||
|
statuses = [register(flooder).status_code for _ in range(MAX_REGISTRATIONS_PER_SOURCE + 5)]
|
||||||
|
|
||||||
|
assert statuses[:MAX_REGISTRATIONS_PER_SOURCE] == [201] * MAX_REGISTRATIONS_PER_SOURCE
|
||||||
|
assert statuses[MAX_REGISTRATIONS_PER_SOURCE:] == [400] * 5
|
||||||
|
# Another address is unaffected.
|
||||||
|
assert register(client_at(app, "198.51.100.20")).status_code == 201
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_young_client_survives_a_registration_flood(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(provider_module, "MAX_CLIENTS", 10)
|
||||||
|
oauth = build_oauth_app()
|
||||||
|
provider = oauth.provider
|
||||||
|
set_client_address("198.51.100.20")
|
||||||
|
await provider.register_client(client_info("legit"))
|
||||||
|
oauth.clock.advance(60)
|
||||||
|
|
||||||
|
refused = 0
|
||||||
|
for number in range(40):
|
||||||
|
set_client_address(f"203.0.113.{number}")
|
||||||
|
try:
|
||||||
|
await provider.register_client(client_info(f"flood-{number}"))
|
||||||
|
except RegistrationError:
|
||||||
|
refused += 1
|
||||||
|
|
||||||
|
assert "legit" in provider._store.clients
|
||||||
|
assert refused > 0 and len(provider._store.clients) == 10
|
||||||
|
|
||||||
|
# Once clients are older than the pending window, old ones can be evicted again.
|
||||||
|
oauth.clock.advance(provider_module.PENDING_TTL + 1)
|
||||||
|
set_client_address("198.51.100.99")
|
||||||
|
await provider.register_client(client_info("later"))
|
||||||
|
assert "later" in provider._store.clients
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_store_write_runs_off_the_event_loop(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
store = build_oauth_app().store
|
||||||
|
threads: list[str] = []
|
||||||
|
original = store._write
|
||||||
|
|
||||||
|
def recording(data: bytes) -> None:
|
||||||
|
threads.append(threading.current_thread().name)
|
||||||
|
original(data)
|
||||||
|
|
||||||
|
monkeypatch.setattr(store, "_write", recording)
|
||||||
|
|
||||||
|
await store.save()
|
||||||
|
|
||||||
|
assert threads and threads[0] != threading.main_thread().name
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
"""Redirect URIs accepted at dynamic client registration (SEC-05 and the base rules)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
from aegis_gitea_mcp.config import redirect_matches_allowlist
|
||||||
|
from tests.test_oauth_helpers import browser, build_oauth_app, oauth_settings, register
|
||||||
|
|
||||||
|
ALLOWLIST = ["https://claude.ai/", "https://chat.mistral.ai/", "https://client.example/cb"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _env(mock_env: None) -> None:
|
||||||
|
"""The audit logger and the rate limiter read the process-wide settings."""
|
||||||
|
|
||||||
|
|
||||||
|
def web(**settings: object) -> TestClient:
|
||||||
|
return browser(build_oauth_app(oauth_settings(**settings)).app)
|
||||||
|
|
||||||
|
|
||||||
|
def error_of(response: object) -> str:
|
||||||
|
assert response.status_code == 400 # type: ignore[attr-defined]
|
||||||
|
return str(response.json()["error"]) # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"uri",
|
||||||
|
[
|
||||||
|
"https://evil.example/cb",
|
||||||
|
"https://claude.ai.evil.example/api/mcp/auth_callback",
|
||||||
|
"https://evilclaude.ai/cb",
|
||||||
|
"https://claude.ai@evil.example/cb",
|
||||||
|
"https://client.example/cb2",
|
||||||
|
"https://client.example/other",
|
||||||
|
"http://claude.ai/api/mcp/auth_callback",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_redirect_outside_the_allowlist_is_refused(uri: str) -> None:
|
||||||
|
response = register(web(oauth_redirect_allowlist=ALLOWLIST), [uri])
|
||||||
|
|
||||||
|
assert error_of(response) == "invalid_redirect_uri"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"uri",
|
||||||
|
[
|
||||||
|
"https://claude.ai/api/mcp/auth_callback",
|
||||||
|
"https://CLAUDE.ai/api/mcp/auth_callback",
|
||||||
|
"https://chat.mistral.ai/oauth/callback",
|
||||||
|
"https://client.example/cb",
|
||||||
|
"http://localhost:3000/callback",
|
||||||
|
"http://127.0.0.1:8123/cb",
|
||||||
|
"http://[::1]:8123/cb",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_allowlisted_and_loopback_redirects_register(uri: str) -> None:
|
||||||
|
response = register(web(oauth_redirect_allowlist=ALLOWLIST), [uri])
|
||||||
|
|
||||||
|
assert response.status_code == 201, response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_bad_uri_among_good_ones_refuses_the_registration() -> None:
|
||||||
|
response = register(
|
||||||
|
web(oauth_redirect_allowlist=ALLOWLIST),
|
||||||
|
["https://claude.ai/cb", "https://evil.example/cb"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert error_of(response) == "invalid_redirect_uri"
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_allowlist_is_empty_and_accepts_any_https_redirect() -> None:
|
||||||
|
assert oauth_settings().oauth_redirect_allowlist == []
|
||||||
|
assert register(web(), ["https://anything.example/cb"]).status_code == 201
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"uri",
|
||||||
|
[
|
||||||
|
"http://example.com/cb",
|
||||||
|
"https://example.com/cb#frag",
|
||||||
|
"https://user:pass@example.com/cb",
|
||||||
|
"javascript:alert(1)",
|
||||||
|
"custom-app://callback",
|
||||||
|
"file:///etc/passwd",
|
||||||
|
"data:text/html,x",
|
||||||
|
"ftp://example.com/cb",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize("allowlist", [[], ALLOWLIST])
|
||||||
|
def test_the_base_redirect_rules_hold_with_and_without_an_allowlist(
|
||||||
|
uri: str, allowlist: list[str]
|
||||||
|
) -> None:
|
||||||
|
response = register(web(oauth_redirect_allowlist=allowlist), [uri])
|
||||||
|
|
||||||
|
assert response.status_code == 400, response.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"entry",
|
||||||
|
[
|
||||||
|
"https://claude.ai",
|
||||||
|
"claude.ai/",
|
||||||
|
"http://claude.ai/",
|
||||||
|
"https://user@claude.ai/",
|
||||||
|
"https://claude.ai/?x=1",
|
||||||
|
"https://claude.ai/#f",
|
||||||
|
"https://claude .ai/",
|
||||||
|
"ftp://claude.ai/",
|
||||||
|
"https:///",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_malformed_allowlist_entries_stop_startup(entry: str) -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
oauth_settings(oauth_redirect_allowlist=[entry])
|
||||||
|
|
||||||
|
|
||||||
|
def test_allowlist_is_read_from_a_comma_separated_variable(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
from aegis_gitea_mcp.config import Settings
|
||||||
|
|
||||||
|
monkeypatch.setenv("OAUTH_REDIRECT_ALLOWLIST", "https://claude.ai/, https://chat.mistral.ai/")
|
||||||
|
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
||||||
|
|
||||||
|
assert Settings().oauth_redirect_allowlist == ["https://claude.ai/", "https://chat.mistral.ai/"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_prefix_matching_is_by_host_not_by_string_prefix() -> None:
|
||||||
|
assert redirect_matches_allowlist("https://claude.ai/x", ["https://claude.ai/"])
|
||||||
|
assert not redirect_matches_allowlist(
|
||||||
|
"https://claude.ai.evil.example/x", ["https://claude.ai/"]
|
||||||
|
)
|
||||||
|
assert not redirect_matches_allowlist("https://claude.ai:8443/x", ["https://claude.ai/"])
|
||||||
|
assert redirect_matches_allowlist("https://anything/x", [])
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_client_registered_before_the_allowlist_cannot_authorize() -> None:
|
||||||
|
from tests.test_oauth_helpers import pkce
|
||||||
|
|
||||||
|
unrestricted = build_oauth_app(oauth_settings())
|
||||||
|
client = browser(unrestricted.app)
|
||||||
|
registration = register(client, ["https://evil.example/cb"]).json()
|
||||||
|
assert registration["client_id"]
|
||||||
|
|
||||||
|
# Same store, allowlist now set: /authorize must refuse the old client.
|
||||||
|
restricted = build_oauth_app(
|
||||||
|
oauth_settings(oauth_redirect_allowlist=ALLOWLIST), clock=unrestricted.clock
|
||||||
|
)
|
||||||
|
restricted.store.clients.update(unrestricted.store.clients)
|
||||||
|
response = browser(restricted.app).get(
|
||||||
|
"/authorize",
|
||||||
|
params={
|
||||||
|
"client_id": registration["client_id"],
|
||||||
|
"redirect_uri": "https://evil.example/cb",
|
||||||
|
"response_type": "code",
|
||||||
|
"code_challenge": pkce()[1],
|
||||||
|
"code_challenge_method": "S256",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
location = response.headers["location"]
|
||||||
|
assert response.status_code == 302
|
||||||
|
assert "error=invalid_request" in location
|
||||||
|
assert "/oauth/consent" not in location and "code=" not in location
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"""Rate limiting covers the OAuth endpoints, IPv6 subscribers and untrusted proxies (SEC-08)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from starlette.types import Message, Receive, Scope, Send
|
||||||
|
|
||||||
|
from aegis_gitea_mcp.rate_limit import RequestRateLimiter, client_bucket
|
||||||
|
from aegis_gitea_mcp.transports import ForwardedHeadersMiddleware
|
||||||
|
from tests.test_executor_helpers import make_settings
|
||||||
|
from tests.test_oauth_helpers import browser, build_oauth_app, oauth_settings
|
||||||
|
|
||||||
|
LIMIT = 3
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _env(mock_env: None) -> None:
|
||||||
|
"""The audit logger and the rate limiter read the process-wide settings."""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("method", "path"),
|
||||||
|
[
|
||||||
|
("POST", "/token"),
|
||||||
|
("POST", "/revoke"),
|
||||||
|
("POST", "/register"),
|
||||||
|
("GET", "/authorize"),
|
||||||
|
("GET", "/oauth/callback"),
|
||||||
|
("POST", "/oauth/consent"),
|
||||||
|
("POST", "/mcp"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_every_unauthenticated_endpoint_is_rate_limited(method: str, path: str) -> None:
|
||||||
|
client = browser(build_oauth_app(oauth_settings(rate_limit_per_minute=LIMIT)).app)
|
||||||
|
|
||||||
|
statuses = [client.request(method, path).status_code for _ in range(LIMIT + 1)]
|
||||||
|
|
||||||
|
assert statuses[LIMIT] == 429
|
||||||
|
assert 429 not in statuses[:LIMIT]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_429_answer_has_retry_after_and_no_detail() -> None:
|
||||||
|
client = browser(build_oauth_app(oauth_settings(rate_limit_per_minute=1)).app)
|
||||||
|
client.post("/token")
|
||||||
|
|
||||||
|
response = client.post("/token")
|
||||||
|
|
||||||
|
assert response.status_code == 429
|
||||||
|
assert response.headers["retry-after"] == "60"
|
||||||
|
assert response.json() == {"error": {"kind": "rate_limited", "message": "Too many requests"}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_ipv6_addresses_of_one_slash_64_share_a_bucket() -> None:
|
||||||
|
limiter = RequestRateLimiter(make_settings(rate_limit_per_minute=2))
|
||||||
|
|
||||||
|
first = limiter.check("2001:db8:1:2::1", None)
|
||||||
|
second = limiter.check("2001:db8:1:2:ffff:ffff:ffff:ffff", None)
|
||||||
|
third = limiter.check("2001:db8:1:2::abcd", None)
|
||||||
|
other_network = limiter.check("2001:db8:1:3::1", None)
|
||||||
|
|
||||||
|
assert first.allowed and second.allowed
|
||||||
|
assert not third.allowed
|
||||||
|
assert other_network.allowed
|
||||||
|
|
||||||
|
|
||||||
|
def test_ipv4_clients_and_mapped_addresses_keep_their_own_bucket() -> None:
|
||||||
|
assert client_bucket("192.0.2.10") == "192.0.2.10"
|
||||||
|
assert client_bucket("::ffff:192.0.2.10") == "192.0.2.10"
|
||||||
|
assert client_bucket("2001:db8:1:2::1") == "2001:db8:1:2::/64"
|
||||||
|
assert client_bucket("fe80::1%eth0") == "fe80::/64"
|
||||||
|
assert client_bucket("testclient") == "testclient"
|
||||||
|
limiter = RequestRateLimiter(make_settings(rate_limit_per_minute=1))
|
||||||
|
assert limiter.check("192.0.2.10", None).allowed
|
||||||
|
assert limiter.check("192.0.2.11", None).allowed
|
||||||
|
assert not limiter.check("192.0.2.10", None).allowed
|
||||||
|
|
||||||
|
|
||||||
|
# Forwarded headers
|
||||||
|
|
||||||
|
|
||||||
|
class Recorder:
|
||||||
|
"""Downstream ASGI app that remembers the client address it saw."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.clients: list[Any] = []
|
||||||
|
|
||||||
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||||
|
self.clients.append(scope.get("client"))
|
||||||
|
|
||||||
|
|
||||||
|
def http_scope(peer: str, forwarded: str | None) -> Scope:
|
||||||
|
headers = [(b"host", b"mcp.example.com")]
|
||||||
|
if forwarded is not None:
|
||||||
|
headers.append((b"x-forwarded-for", forwarded.encode()))
|
||||||
|
return {
|
||||||
|
"type": "http",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/",
|
||||||
|
"scheme": "http",
|
||||||
|
"headers": headers,
|
||||||
|
"client": (peer, 5000),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def noop_receive() -> Message:
|
||||||
|
return {"type": "http.disconnect"}
|
||||||
|
|
||||||
|
|
||||||
|
async def noop_send(message: Message) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_forwarded_for_from_an_untrusted_peer_is_ignored_and_warned_about_once(
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
app = Recorder()
|
||||||
|
middleware = ForwardedHeadersMiddleware(app, "127.0.0.1")
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING, logger="aegis_gitea_mcp.transports"):
|
||||||
|
for _ in range(3):
|
||||||
|
await middleware(http_scope("172.18.0.5", "203.0.113.9"), noop_receive, noop_send)
|
||||||
|
|
||||||
|
# Spoofed header: the peer address stays the client address.
|
||||||
|
assert app.clients == [("172.18.0.5", 5000)] * 3
|
||||||
|
warnings = [r for r in caplog.records if "FORWARDED_ALLOW_IPS" in r.getMessage()]
|
||||||
|
assert len(warnings) == 1
|
||||||
|
assert "203.0.113.9" not in warnings[0].getMessage()
|
||||||
|
assert "172.18.0.5" not in warnings[0].getMessage()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_forwarded_for_from_a_trusted_peer_is_applied_without_a_warning(
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
app = Recorder()
|
||||||
|
middleware = ForwardedHeadersMiddleware(app, "172.18.0.0/16, 127.0.0.1")
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING, logger="aegis_gitea_mcp.transports"):
|
||||||
|
await middleware(http_scope("172.18.0.5", "203.0.113.9"), noop_receive, noop_send)
|
||||||
|
await middleware(http_scope("127.0.0.1", "203.0.113.10"), noop_receive, noop_send)
|
||||||
|
|
||||||
|
assert [client[0] for client in app.clients] == ["203.0.113.9", "203.0.113.10"]
|
||||||
|
assert caplog.records == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_requests_without_forwarded_for_never_warn(caplog: pytest.LogCaptureFixture) -> None:
|
||||||
|
app = Recorder()
|
||||||
|
middleware = ForwardedHeadersMiddleware(app, "127.0.0.1")
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING, logger="aegis_gitea_mcp.transports"):
|
||||||
|
await middleware(http_scope("198.51.100.7", None), noop_receive, noop_send)
|
||||||
|
|
||||||
|
assert caplog.records == []
|
||||||
|
assert app.clients == [("198.51.100.7", 5000)]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_wildcard_trusts_every_peer_without_a_warning(
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
app = Recorder()
|
||||||
|
middleware = ForwardedHeadersMiddleware(app, "*")
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING, logger="aegis_gitea_mcp.transports"):
|
||||||
|
await middleware(http_scope("198.51.100.7", "203.0.113.9"), noop_receive, noop_send)
|
||||||
|
|
||||||
|
assert app.clients[0][0] == "203.0.113.9"
|
||||||
|
assert caplog.records == []
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
"""Credentials never reach results, errors, logs, the audit file or ``repr`` output.
|
||||||
|
|
||||||
|
Every secret the server knows (the Gitea token, the MCP bearer, the OAuth client secret,
|
||||||
|
the store key, a user's Gitea access and refresh tokens and our own issued tokens) is
|
||||||
|
planted in Gitea's answers, successful and failed, and in the incoming requests.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pytest_httpx import HTTPXMock
|
||||||
|
|
||||||
|
from aegis_gitea_mcp.audit import get_audit_logger
|
||||||
|
from aegis_gitea_mcp.identity import Identity
|
||||||
|
from aegis_gitea_mcp.oauth.provider import PendingAuthorization
|
||||||
|
from aegis_gitea_mcp.oauth.store import SessionRecord
|
||||||
|
from aegis_gitea_mcp.oauth.upstream import UpstreamTokens
|
||||||
|
from aegis_gitea_mcp.transports import AccessLogQueryFilter, run_http
|
||||||
|
from tests.test_oauth_helpers import (
|
||||||
|
API,
|
||||||
|
APP_CLIENT_SECRET,
|
||||||
|
BEARER_TOKEN,
|
||||||
|
ENCRYPTION_KEY,
|
||||||
|
GITEA_TOKEN,
|
||||||
|
USER_REFRESH,
|
||||||
|
USER_TOKEN,
|
||||||
|
browser,
|
||||||
|
build_oauth_app,
|
||||||
|
mcp_client,
|
||||||
|
oauth_settings,
|
||||||
|
sign_in,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _env(mock_env: None) -> None:
|
||||||
|
"""The audit logger and the rate limiter read the process-wide settings."""
|
||||||
|
|
||||||
|
|
||||||
|
def static_secrets() -> list[str]:
|
||||||
|
return [GITEA_TOKEN, BEARER_TOKEN, APP_CLIENT_SECRET, ENCRYPTION_KEY, USER_TOKEN, USER_REFRESH]
|
||||||
|
|
||||||
|
|
||||||
|
def everything_secret(extra: list[str]) -> str:
|
||||||
|
return " ".join([*static_secrets(), *extra])
|
||||||
|
|
||||||
|
|
||||||
|
def assert_clean(text: str, secrets: list[str]) -> None:
|
||||||
|
for secret in secrets:
|
||||||
|
assert secret not in text, f"secret leaked: {secret[:6]}..."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("mode", ["off", "mask", "block"])
|
||||||
|
async def test_no_secret_reaches_a_result_the_audit_file_or_the_logs(
|
||||||
|
httpx_mock: HTTPXMock,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
mode: str,
|
||||||
|
) -> None:
|
||||||
|
caplog.set_level(logging.DEBUG)
|
||||||
|
oauth = build_oauth_app(oauth_settings(secret_detection_mode=mode))
|
||||||
|
flow = sign_in(browser(oauth.app), httpx_mock)
|
||||||
|
# The client secret the SDK issues at registration is plain hex without a recognisable
|
||||||
|
# prefix: it is stored (encrypted) but cannot be pattern masked, and Gitea never sees it.
|
||||||
|
issued = [flow.tokens["access_token"], flow.tokens["refresh_token"]]
|
||||||
|
# SECRET_DETECTION_MODE=off disables pattern matching, which is what protects our own
|
||||||
|
# issued agm_ tokens (only hashes of them live on the server, so they cannot be redacted
|
||||||
|
# exactly); every other secret is redacted exactly in all modes.
|
||||||
|
secrets = static_secrets() if mode == "off" else [*static_secrets(), *issued]
|
||||||
|
# The admin identity is not the OAuth user: Gitea never shows it that user's tokens, so
|
||||||
|
# only the server's own secrets (and, with pattern masking, our issued tokens) apply.
|
||||||
|
admin_secrets = [GITEA_TOKEN, BEARER_TOKEN, APP_CLIENT_SECRET, ENCRYPTION_KEY]
|
||||||
|
blob = everything_secret(issued)
|
||||||
|
|
||||||
|
# The admin identity's lookup, then success and failure bodies that echo every secret.
|
||||||
|
httpx_mock.add_response(url=f"{API}/user", json={"login": "admin"}, is_reusable=True)
|
||||||
|
echo = {"is_reusable": True}
|
||||||
|
httpx_mock.add_response(
|
||||||
|
url=f"{API}/repos/octo/ok", json={"description": blob, blob: "key"}, **echo
|
||||||
|
)
|
||||||
|
httpx_mock.add_response(
|
||||||
|
url=f"{API}/repos/octo/bad",
|
||||||
|
status_code=422,
|
||||||
|
json={"message": blob, "errors": [blob]},
|
||||||
|
**echo,
|
||||||
|
)
|
||||||
|
httpx_mock.add_response(
|
||||||
|
url=f"{API}/repos/octo/text", text=blob, headers={"content-type": "text/plain"}, **echo
|
||||||
|
)
|
||||||
|
httpx_mock.add_response(url=f"{API}/repos/octo/boom", status_code=500, text=blob, **echo)
|
||||||
|
|
||||||
|
results: list[Any] = []
|
||||||
|
# One app per identity: the SDK's session manager can only run once per app.
|
||||||
|
apps = [
|
||||||
|
(oauth.app, flow.tokens["access_token"]),
|
||||||
|
(build_oauth_app(oauth.settings).app, BEARER_TOKEN),
|
||||||
|
]
|
||||||
|
for app, token in apps:
|
||||||
|
async with mcp_client(app, token) as mcp:
|
||||||
|
for repo in ("ok", "bad", "boom"):
|
||||||
|
results.append(
|
||||||
|
await mcp.call_tool("gitea_repo_get", {"owner": "octo", "repo": repo})
|
||||||
|
)
|
||||||
|
results.append(
|
||||||
|
await mcp.call_tool(
|
||||||
|
"gitea_api_request", {"method": "GET", "path": "/repos/octo/text"}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
texts = [result.content[0].text for result in results]
|
||||||
|
per_identity = [
|
||||||
|
(results[:4], secrets),
|
||||||
|
(results[4:], [*admin_secrets, *(issued if mode != "off" else [])]),
|
||||||
|
]
|
||||||
|
for group, forbidden in per_identity:
|
||||||
|
for result in group:
|
||||||
|
assert_clean(result.content[0].text, forbidden)
|
||||||
|
assert_clean(json.dumps(result.structured_content), forbidden)
|
||||||
|
assert any("[REDACTED]" in text for text in texts) # the redaction happened, not an empty body
|
||||||
|
everything = [*secrets, *admin_secrets]
|
||||||
|
assert_clean(get_audit_logger().log_path.read_text(encoding="utf-8"), everything)
|
||||||
|
assert_clean(caplog.text, everything)
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert_clean(captured.out + captured.err, everything)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_exceptions_during_a_call_do_not_leak_secrets(
|
||||||
|
httpx_mock: HTTPXMock, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
caplog.set_level(logging.DEBUG)
|
||||||
|
oauth = build_oauth_app()
|
||||||
|
secrets = static_secrets()
|
||||||
|
|
||||||
|
async def explode(*args: Any, **kwargs: Any) -> None:
|
||||||
|
raise RuntimeError("failure carrying " + " ".join(secrets))
|
||||||
|
|
||||||
|
monkeypatch.setattr(oauth.extras.executor, "execute", explode)
|
||||||
|
httpx_mock.add_response(url=f"{API}/user", json={"login": "admin"}, is_reusable=True)
|
||||||
|
|
||||||
|
async with mcp_client(oauth.app, BEARER_TOKEN) as mcp:
|
||||||
|
result = await mcp.call_tool("gitea_repo_get", {"owner": "o", "repo": "r"})
|
||||||
|
|
||||||
|
assert result.is_error
|
||||||
|
assert_clean(result.content[0].text, secrets)
|
||||||
|
assert_clean(caplog.text, secrets)
|
||||||
|
assert_clean(get_audit_logger().log_path.read_text(encoding="utf-8"), secrets)
|
||||||
|
assert "RuntimeError" not in result.content[0].text # no exception details by default
|
||||||
|
|
||||||
|
|
||||||
|
async def test_validation_errors_never_contain_argument_values(httpx_mock: HTTPXMock) -> None:
|
||||||
|
oauth = build_oauth_app()
|
||||||
|
planted = "sekret-value-" + GITEA_TOKEN
|
||||||
|
|
||||||
|
async with mcp_client(oauth.app, BEARER_TOKEN) as mcp:
|
||||||
|
results = [
|
||||||
|
await mcp.call_tool("gitea_repo_get", {"owner": planted + "?x#y", "repo": "r"}),
|
||||||
|
await mcp.call_tool("gitea_repo_get", {"owner": 12345, "repo": planted}),
|
||||||
|
await mcp.call_tool("gitea_repo_get", {"owner": "o", "repo": "r", "extra": planted}),
|
||||||
|
await mcp.call_tool("gitea_api_request", {"method": planted, "path": "/x"}),
|
||||||
|
await mcp.call_tool("gitea_api_request", {"method": "GET", "path": planted}),
|
||||||
|
await mcp.call_tool(
|
||||||
|
"gitea_api_request",
|
||||||
|
{"method": "GET", "path": "/user", "sudo": "not valid " + planted},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
for result in results:
|
||||||
|
assert result.is_error
|
||||||
|
assert planted not in result.content[0].text
|
||||||
|
assert GITEA_TOKEN not in result.content[0].text
|
||||||
|
assert planted not in get_audit_logger().log_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
# Access logs
|
||||||
|
|
||||||
|
|
||||||
|
def uvicorn_access_record(path_with_query: str) -> logging.LogRecord:
|
||||||
|
return logging.LogRecord(
|
||||||
|
name="uvicorn.access",
|
||||||
|
level=logging.INFO,
|
||||||
|
pathname=__file__,
|
||||||
|
lineno=1,
|
||||||
|
msg='%s - "%s %s HTTP/%s" %d',
|
||||||
|
args=("127.0.0.1:5000", "GET", path_with_query, "1.1", 302),
|
||||||
|
exc_info=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"path",
|
||||||
|
[
|
||||||
|
"/oauth/callback?code=one-time-gitea-code&state=state-secret-value",
|
||||||
|
"/oauth/consent?request=pending-request-secret",
|
||||||
|
"/authorize?client_id=c&code_challenge=abc&state=client-state-secret",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_access_log_lines_drop_the_query_string(path: str) -> None:
|
||||||
|
record = uvicorn_access_record(path)
|
||||||
|
|
||||||
|
assert AccessLogQueryFilter().filter(record)
|
||||||
|
|
||||||
|
line = record.getMessage()
|
||||||
|
assert "[redacted]" in line
|
||||||
|
assert path.split("?")[0] in line
|
||||||
|
for secret in (
|
||||||
|
"one-time-gitea-code",
|
||||||
|
"state-secret-value",
|
||||||
|
"pending-request-secret",
|
||||||
|
"client-state-secret",
|
||||||
|
"code_challenge",
|
||||||
|
):
|
||||||
|
assert secret not in line
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_runner_installs_the_access_log_filter(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
from aegis_gitea_mcp import transports
|
||||||
|
|
||||||
|
access_logger = logging.getLogger("uvicorn.access")
|
||||||
|
monkeypatch.setattr(access_logger, "filters", [])
|
||||||
|
monkeypatch.setattr(transports.uvicorn, "run", lambda *a, **k: None)
|
||||||
|
caplog.set_level(logging.INFO, logger="uvicorn.access")
|
||||||
|
|
||||||
|
assert run_http(oauth_settings()) == 0
|
||||||
|
access_logger.info(
|
||||||
|
'%s - "%s %s HTTP/%s" %d',
|
||||||
|
"127.0.0.1:5000",
|
||||||
|
"GET",
|
||||||
|
"/oauth/callback?code=one-time-gitea-code&state=state-secret-value",
|
||||||
|
"1.1",
|
||||||
|
302,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "one-time-gitea-code" not in caplog.text
|
||||||
|
assert "[redacted]" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
# repr
|
||||||
|
|
||||||
|
|
||||||
|
def test_repr_of_settings_and_secret_holding_objects_contains_no_secret() -> None:
|
||||||
|
settings = oauth_settings()
|
||||||
|
identity = Identity(login="alice", mode="oauth", token=USER_TOKEN, redact=(USER_REFRESH,))
|
||||||
|
session = SessionRecord(
|
||||||
|
session_id="session-id-value",
|
||||||
|
client_id="c1",
|
||||||
|
login="alice",
|
||||||
|
is_admin=False,
|
||||||
|
gitea_access_token=USER_TOKEN,
|
||||||
|
gitea_refresh_token=USER_REFRESH,
|
||||||
|
gitea_expires_at=None,
|
||||||
|
scopes=["gitea"],
|
||||||
|
resource="https://mcp.example.com/mcp",
|
||||||
|
created_at=0.0,
|
||||||
|
previous_refresh_hash="hash-of-previous-refresh",
|
||||||
|
)
|
||||||
|
upstream = UpstreamTokens(access_token=USER_TOKEN, refresh_token=USER_REFRESH, expires_at=None)
|
||||||
|
pending = PendingAuthorization(
|
||||||
|
client_id="c1",
|
||||||
|
client_name="Client",
|
||||||
|
redirect_uri="https://client.example/cb",
|
||||||
|
redirect_uri_provided_explicitly=True,
|
||||||
|
code_challenge="challenge",
|
||||||
|
client_state="client-state-secret",
|
||||||
|
scopes=["gitea"],
|
||||||
|
resource="https://mcp.example.com/mcp",
|
||||||
|
code_verifier="pkce-verifier-secret",
|
||||||
|
csrf_hash="csrf-hash-secret",
|
||||||
|
binding_hash="binding-hash-secret",
|
||||||
|
)
|
||||||
|
|
||||||
|
rendered = [
|
||||||
|
repr(settings),
|
||||||
|
str(settings),
|
||||||
|
repr(identity),
|
||||||
|
repr(session),
|
||||||
|
repr(upstream),
|
||||||
|
repr(pending),
|
||||||
|
repr(settings.gitea_token),
|
||||||
|
repr(settings.model_dump()),
|
||||||
|
]
|
||||||
|
|
||||||
|
for text in rendered:
|
||||||
|
assert_clean(text, [*static_secrets(), "client-state-secret", "pkce-verifier-secret"])
|
||||||
|
assert "csrf-hash-secret" not in text and "binding-hash-secret" not in text
|
||||||
|
assert "hash-of-previous-refresh" not in text
|
||||||
|
assert "**********" in repr(settings.gitea_token)
|
||||||
|
|
||||||
|
|
||||||
|
def test_secrets_in_object_keys_are_masked_like_values() -> None:
|
||||||
|
from aegis_gitea_mcp.security import sanitize_data
|
||||||
|
|
||||||
|
token = "agm_" + "A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8S9t0U1v"
|
||||||
|
nested = {"outer": [{token: "value", "plain": token}]}
|
||||||
|
|
||||||
|
masked = sanitize_data(nested, mode="mask")
|
||||||
|
blocked = sanitize_data(nested, mode="block")
|
||||||
|
|
||||||
|
assert token not in json.dumps(masked) and token not in json.dumps(blocked)
|
||||||
|
assert masked["outer"][0]["plain"] != token
|
||||||
|
assert blocked != nested
|
||||||
|
|
||||||
|
|
||||||
|
# Configuration errors and a real stdio process
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("variable", "value"),
|
||||||
|
[
|
||||||
|
("GITEA_URL", "https://user:" + GITEA_TOKEN + "@gitea.example.com"),
|
||||||
|
("GITEA_URL", "https://gitea.example.com?token=" + GITEA_TOKEN),
|
||||||
|
("MCP_BEARER_TOKEN", "short-" + "x" * 5),
|
||||||
|
("PUBLIC_BASE_URL", "http://mcp.example.com/" + GITEA_TOKEN),
|
||||||
|
("OAUTH_SCOPES", GITEA_TOKEN),
|
||||||
|
("TOKEN_ENCRYPTION_KEY", "tooshort-" + ENCRYPTION_KEY[:5]),
|
||||||
|
("MCP_PORT", GITEA_TOKEN),
|
||||||
|
("MCP_PATH", GITEA_TOKEN),
|
||||||
|
("FORWARDED_ALLOW_IPS", GITEA_TOKEN),
|
||||||
|
("LOG_LEVEL", GITEA_TOKEN),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_configuration_errors_name_the_setting_but_never_echo_its_value(
|
||||||
|
variable: str,
|
||||||
|
value: str,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
from aegis_gitea_mcp import cli
|
||||||
|
from aegis_gitea_mcp.cli import main
|
||||||
|
|
||||||
|
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
||||||
|
monkeypatch.setenv("GITEA_TOKEN", GITEA_TOKEN)
|
||||||
|
monkeypatch.setenv(variable, value)
|
||||||
|
monkeypatch.setattr(cli, "configure_logging", lambda level: None)
|
||||||
|
|
||||||
|
assert main(["serve", "--transport", "http"]) == 2
|
||||||
|
|
||||||
|
err = capsys.readouterr().err
|
||||||
|
assert variable in err
|
||||||
|
assert value not in err and GITEA_TOKEN not in err
|
||||||
|
assert "Traceback" not in err
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_real_stdio_process_keeps_every_secret_out_of_stdout_stderr_and_the_audit_file(
|
||||||
|
tmp_path: Any,
|
||||||
|
) -> None:
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from mcp import Client
|
||||||
|
from mcp.client.stdio import StdioServerParameters, stdio_client
|
||||||
|
|
||||||
|
secrets = [GITEA_TOKEN, BEARER_TOKEN, APP_CLIENT_SECRET, ENCRYPTION_KEY]
|
||||||
|
env = {
|
||||||
|
"GITEA_URL": "http://127.0.0.1:9", # nothing listens: every call fails
|
||||||
|
"GITEA_TOKEN": GITEA_TOKEN,
|
||||||
|
"MCP_BEARER_TOKEN": BEARER_TOKEN,
|
||||||
|
"GITEA_OAUTH_CLIENT_SECRET": APP_CLIENT_SECRET,
|
||||||
|
"TOKEN_ENCRYPTION_KEY": ENCRYPTION_KEY,
|
||||||
|
"DATA_DIR": str(tmp_path / "state"),
|
||||||
|
"AUDIT_LOG_PATH": str(tmp_path / "state" / "audit.log"),
|
||||||
|
"REQUEST_TIMEOUT_SECONDS": "2",
|
||||||
|
"LOG_LEVEL": "DEBUG",
|
||||||
|
}
|
||||||
|
params = StdioServerParameters(
|
||||||
|
command=sys.executable,
|
||||||
|
args=["-m", "aegis_gitea_mcp.cli", "serve", "--transport", "stdio"],
|
||||||
|
env=env,
|
||||||
|
cwd=str(tmp_path),
|
||||||
|
)
|
||||||
|
stderr_path = tmp_path / "stderr.log"
|
||||||
|
with stderr_path.open("w", encoding="utf-8") as errlog:
|
||||||
|
async with Client(stdio_client(params, errlog=errlog)) as client:
|
||||||
|
results = [
|
||||||
|
await client.call_tool("gitea_repo_get", {"owner": "o", "repo": "r"}),
|
||||||
|
await client.call_tool("gitea_repo_get", {"owner": 5}),
|
||||||
|
await client.call_tool("gitea_api_request", {"method": "GET", "path": "/user"}),
|
||||||
|
]
|
||||||
|
|
||||||
|
for result in results:
|
||||||
|
assert result.is_error
|
||||||
|
assert_clean(result.content[0].text, secrets) # type: ignore[union-attr]
|
||||||
|
assert_clean(stderr_path.read_text(encoding="utf-8"), secrets)
|
||||||
|
assert_clean((tmp_path / "state" / "audit.log").read_text(encoding="utf-8"), secrets)
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
"""Secret values that pass through tool calls are never echoed back to the model.
|
||||||
|
|
||||||
|
Covers stored webhook authorization headers (SEC-02), Action secrets and
|
||||||
|
submitted credential fields. Credential-issuing operations are the documented
|
||||||
|
exception: they return the credential they just minted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from mcp import Client
|
||||||
|
from pytest_httpx import HTTPXMock
|
||||||
|
|
||||||
|
from aegis_gitea_mcp.gitea.executor import CREDENTIAL_ISSUING_OPERATIONS
|
||||||
|
from tests.test_server_helpers import API, audit_entries, connected, mock_user, server_settings
|
||||||
|
|
||||||
|
STORED_HEADER = "Bearer webhook-target-secret-0123456789"
|
||||||
|
BASIC_HEADER = "Basic dXNlcjpwYXNzd29yZC1vZi10aGUtd2ViaG9vaw=="
|
||||||
|
MODES = ["off", "mask", "block"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _env(mock_env: None) -> None:
|
||||||
|
"""The audit logger reads the process-wide settings."""
|
||||||
|
|
||||||
|
|
||||||
|
def hook(header: str | None = STORED_HEADER) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": 3,
|
||||||
|
"type": "gitea",
|
||||||
|
"active": True,
|
||||||
|
"config": {"url": "https://ci.example.org/hook", "content_type": "json"},
|
||||||
|
"authorization_header": header,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def call(client: Client, name: str, arguments: dict[str, Any]) -> tuple[str, Any]:
|
||||||
|
result = await client.call_tool(name, arguments)
|
||||||
|
return result.content[0].text, result.structured_content # type: ignore[union-attr]
|
||||||
|
|
||||||
|
|
||||||
|
HOOK_CASES = [
|
||||||
|
("gitea_hook_repo_list_hooks", {"owner": "o", "repo": "r"}, "GET", "/repos/o/r/hooks", True),
|
||||||
|
(
|
||||||
|
"gitea_hook_repo_get_hook",
|
||||||
|
{"owner": "o", "repo": "r", "id": 3},
|
||||||
|
"GET",
|
||||||
|
"/repos/o/r/hooks/3",
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"gitea_hook_repo_edit_hook",
|
||||||
|
{"owner": "o", "repo": "r", "id": 3, "body": {"active": True}},
|
||||||
|
"PATCH",
|
||||||
|
"/repos/o/r/hooks/3",
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
("gitea_hook_org_list_hooks", {"org": "acme"}, "GET", "/orgs/acme/hooks", True),
|
||||||
|
("gitea_hook_org_get_hook", {"org": "acme", "id": 3}, "GET", "/orgs/acme/hooks/3", False),
|
||||||
|
("gitea_hook_user_list_hooks", {}, "GET", "/user/hooks", True),
|
||||||
|
("gitea_hook_user_get_hook", {"id": 3}, "GET", "/user/hooks/3", False),
|
||||||
|
("gitea_admin_list_hooks", {}, "GET", "/admin/hooks", True),
|
||||||
|
("gitea_admin_get_hook", {"id": 3}, "GET", "/admin/hooks/3", False),
|
||||||
|
(
|
||||||
|
"gitea_api_request",
|
||||||
|
{"method": "GET", "path": "/repos/o/r/hooks"},
|
||||||
|
"GET",
|
||||||
|
"/repos/o/r/hooks",
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("mode", MODES)
|
||||||
|
@pytest.mark.parametrize(("tool", "arguments", "method", "path", "is_list"), HOOK_CASES)
|
||||||
|
@pytest.mark.parametrize("header", [STORED_HEADER, BASIC_HEADER])
|
||||||
|
async def test_stored_webhook_authorization_header_is_never_returned(
|
||||||
|
httpx_mock: HTTPXMock,
|
||||||
|
mode: str,
|
||||||
|
tool: str,
|
||||||
|
arguments: dict[str, Any],
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
is_list: bool,
|
||||||
|
header: str,
|
||||||
|
) -> None:
|
||||||
|
mock_user(httpx_mock)
|
||||||
|
stored = hook(header)
|
||||||
|
httpx_mock.add_response(
|
||||||
|
method=method, url=f"{API}{path}", json=[stored, hook(None)] if is_list else stored
|
||||||
|
)
|
||||||
|
async with connected(server_settings(secret_detection_mode=mode)) as client:
|
||||||
|
text, structured = await call(client, tool, arguments)
|
||||||
|
|
||||||
|
assert header not in text
|
||||||
|
assert header not in json.dumps(structured)
|
||||||
|
assert "[REDACTED]" in text
|
||||||
|
# The rest of the hook stays readable.
|
||||||
|
assert "ci.example.org" in text
|
||||||
|
assert header not in json.dumps(audit_entries())
|
||||||
|
|
||||||
|
|
||||||
|
async def test_empty_authorization_header_stays_visible_as_empty(httpx_mock: HTTPXMock) -> None:
|
||||||
|
mock_user(httpx_mock)
|
||||||
|
httpx_mock.add_response(url=f"{API}/repos/o/r/hooks/3", json=hook(""))
|
||||||
|
async with connected() as client:
|
||||||
|
_, structured = await call(
|
||||||
|
client, "gitea_hook_repo_get_hook", {"owner": "o", "repo": "r", "id": 3}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert structured["data"]["authorization_header"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
async def test_nested_secret_fields_are_redacted_by_key_name(httpx_mock: HTTPXMock) -> None:
|
||||||
|
mock_user(httpx_mock)
|
||||||
|
httpx_mock.add_response(
|
||||||
|
url=f"{API}/repos/o/r/hooks",
|
||||||
|
json=[{"config": {"url": "https://x.example", "secret": "plain-shared-secret"}}],
|
||||||
|
)
|
||||||
|
async with connected(server_settings(secret_detection_mode="off")) as client:
|
||||||
|
text, _ = await call(client, "gitea_hook_repo_list_hooks", {"owner": "o", "repo": "r"})
|
||||||
|
|
||||||
|
assert "plain-shared-secret" not in text
|
||||||
|
|
||||||
|
|
||||||
|
async def test_credential_issuing_operations_still_return_their_credential(
|
||||||
|
httpx_mock: HTTPXMock,
|
||||||
|
) -> None:
|
||||||
|
assert "userCreateToken" in CREDENTIAL_ISSUING_OPERATIONS
|
||||||
|
mock_user(httpx_mock)
|
||||||
|
minted = "a" * 20 + "0123456789abcdef"
|
||||||
|
httpx_mock.add_response(
|
||||||
|
method="POST", url=f"{API}/users/alice/tokens", json={"id": 1, "name": "ci", "sha1": minted}
|
||||||
|
)
|
||||||
|
async with connected() as client:
|
||||||
|
text, _ = await call(
|
||||||
|
client, "gitea_user_create_token", {"username": "alice", "body": {"name": "ci"}}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert minted in text
|
||||||
|
# ... but the audit file never contains it.
|
||||||
|
assert minted not in json.dumps(audit_entries())
|
||||||
|
|
||||||
|
|
||||||
|
# Action secrets: names and metadata only, never values
|
||||||
|
|
||||||
|
VALUE = "Sup3r-Secret-Action-Value!"
|
||||||
|
SHORT_VALUE = "pw"
|
||||||
|
|
||||||
|
|
||||||
|
def listing_with_values() -> list[dict[str, Any]]:
|
||||||
|
"""What Gitea sends (names and creation times), plus a hostile ``data`` field."""
|
||||||
|
return [
|
||||||
|
{"name": "DEPLOY_KEY", "created": "2026-01-01T00:00:00Z", "data": VALUE},
|
||||||
|
{"name": "NPM_TOKEN", "created": "2026-01-02T00:00:00Z", "description": "ci"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
SECRET_LISTINGS = [
|
||||||
|
("gitea_actions_repo_list_secrets", {"owner": "o", "repo": "r"}, "/repos/o/r/actions/secrets"),
|
||||||
|
("gitea_actions_org_list_secrets", {"org": "acme"}, "/orgs/acme/actions/secrets"),
|
||||||
|
(
|
||||||
|
"gitea_api_request",
|
||||||
|
{"method": "GET", "path": "/repos/o/r/actions/secrets"},
|
||||||
|
"/repos/o/r/actions/secrets",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"gitea_api_request",
|
||||||
|
{"method": "GET", "path": "/user/actions/secrets"},
|
||||||
|
"/user/actions/secrets",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("mode", MODES)
|
||||||
|
@pytest.mark.parametrize(("tool", "arguments", "path"), SECRET_LISTINGS)
|
||||||
|
async def test_listing_secrets_returns_names_and_metadata_never_values(
|
||||||
|
httpx_mock: HTTPXMock, mode: str, tool: str, arguments: dict[str, Any], path: str
|
||||||
|
) -> None:
|
||||||
|
mock_user(httpx_mock)
|
||||||
|
httpx_mock.add_response(url=f"{API}{path}", json=listing_with_values())
|
||||||
|
async with connected(server_settings(secret_detection_mode=mode)) as client:
|
||||||
|
text, structured = await call(client, tool, arguments)
|
||||||
|
|
||||||
|
assert VALUE not in text and VALUE not in json.dumps(structured)
|
||||||
|
names = [item["name"] for item in structured["data"]]
|
||||||
|
assert names == ["DEPLOY_KEY", "NPM_TOKEN"]
|
||||||
|
assert structured["data"][0]["created"] == "2026-01-01T00:00:00Z"
|
||||||
|
assert structured["data"][1]["description"] == "ci"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_secret_path_in_a_repository_is_not_mistaken_for_action_secrets(
|
||||||
|
httpx_mock: HTTPXMock,
|
||||||
|
) -> None:
|
||||||
|
mock_user(httpx_mock)
|
||||||
|
httpx_mock.add_response(
|
||||||
|
url=f"{API}/repos/o/r/contents/secrets", json=[{"name": "a", "data": "kept"}]
|
||||||
|
)
|
||||||
|
async with connected() as client:
|
||||||
|
_, structured = await call(
|
||||||
|
client, "gitea_api_request", {"method": "GET", "path": "/repos/o/r/contents/secrets"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert structured["data"][0]["data"] == "kept"
|
||||||
|
|
||||||
|
|
||||||
|
SECRET_WRITES = [
|
||||||
|
(
|
||||||
|
"gitea_actions_update_repo_secret",
|
||||||
|
{"owner": "o", "repo": "r", "secretname": "DEPLOY_KEY"},
|
||||||
|
"/repos/o/r/actions/secrets/DEPLOY_KEY",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"gitea_actions_update_org_secret",
|
||||||
|
{"org": "acme", "secretname": "DEPLOY_KEY"},
|
||||||
|
"/orgs/acme/actions/secrets/DEPLOY_KEY",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"gitea_actions_update_user_secret",
|
||||||
|
{"secretname": "DEPLOY_KEY"},
|
||||||
|
"/user/actions/secrets/DEPLOY_KEY",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("mode", MODES)
|
||||||
|
@pytest.mark.parametrize("value", [VALUE, SHORT_VALUE])
|
||||||
|
@pytest.mark.parametrize(("tool", "arguments", "path"), SECRET_WRITES)
|
||||||
|
async def test_updating_a_secret_never_echoes_the_submitted_value(
|
||||||
|
httpx_mock: HTTPXMock,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
mode: str,
|
||||||
|
value: str,
|
||||||
|
tool: str,
|
||||||
|
arguments: dict[str, Any],
|
||||||
|
path: str,
|
||||||
|
) -> None:
|
||||||
|
caplog.set_level("DEBUG")
|
||||||
|
mock_user(httpx_mock)
|
||||||
|
# Success echoing the value, then a validation error repeating it (as Gitea 422s do).
|
||||||
|
httpx_mock.add_response(
|
||||||
|
method="PUT",
|
||||||
|
url=f"{API}{path}",
|
||||||
|
status_code=201,
|
||||||
|
json={"echo": value, "name": "DEPLOY_KEY"},
|
||||||
|
)
|
||||||
|
httpx_mock.add_response(
|
||||||
|
method="PUT",
|
||||||
|
url=f"{API}{path}",
|
||||||
|
status_code=422,
|
||||||
|
json={"message": f"invalid secret data: {value}", "errors": [f"data {value} too long"]},
|
||||||
|
)
|
||||||
|
async with connected(server_settings(secret_detection_mode=mode)) as client:
|
||||||
|
ok = await call(client, tool, {**arguments, "body": {"data": value}})
|
||||||
|
failed = await call(client, tool, {**arguments, "body": {"data": value}})
|
||||||
|
|
||||||
|
for text, structured in (ok, failed):
|
||||||
|
assert value not in text
|
||||||
|
assert value not in json.dumps(structured)
|
||||||
|
assert "[REDACTED]" in ok[0] and "[REDACTED]" in failed[0]
|
||||||
|
assert value not in json.dumps(audit_entries()) and value not in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_raw_tool_also_hides_a_submitted_secret_value(httpx_mock: HTTPXMock) -> None:
|
||||||
|
mock_user(httpx_mock)
|
||||||
|
httpx_mock.add_response(
|
||||||
|
method="PUT",
|
||||||
|
url=f"{API}/repos/o/r/actions/secrets/K",
|
||||||
|
status_code=422,
|
||||||
|
json={"message": f"bad {VALUE}"},
|
||||||
|
)
|
||||||
|
async with connected() as client:
|
||||||
|
text, _ = await call(
|
||||||
|
client,
|
||||||
|
"gitea_api_request",
|
||||||
|
{
|
||||||
|
"method": "PUT",
|
||||||
|
"path": "/repos/o/r/actions/secrets/K",
|
||||||
|
"body": {"data": VALUE},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert VALUE not in text and "[REDACTED]" in text
|
||||||
|
|
||||||
|
|
||||||
|
# Other submitted credential fields
|
||||||
|
|
||||||
|
CREDENTIAL_BODIES = [
|
||||||
|
(
|
||||||
|
"gitea_admin_create_user",
|
||||||
|
{},
|
||||||
|
"POST",
|
||||||
|
"/admin/users",
|
||||||
|
{"username": "bob", "email": "bob@example.com", "password": "Pa55word-Of-Bob"},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"gitea_hook_repo_create_hook",
|
||||||
|
{"owner": "o", "repo": "r"},
|
||||||
|
"POST",
|
||||||
|
"/repos/o/r/hooks",
|
||||||
|
{"type": "gitea", "authorization_header": "Bearer hook-target-token-123", "config": {}},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"gitea_repo_migrate",
|
||||||
|
{},
|
||||||
|
"POST",
|
||||||
|
"/repos/migrate",
|
||||||
|
{
|
||||||
|
"clone_addr": "https://example.org/x.git",
|
||||||
|
"repo_name": "x",
|
||||||
|
"auth_token": "migration-source-token-123",
|
||||||
|
"auth_password": "migration-source-pass-123",
|
||||||
|
"aws_secret_access_key": "aws-secret-access-key-value-123",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"gitea_api_request",
|
||||||
|
{},
|
||||||
|
"POST",
|
||||||
|
"/orgs/acme/hooks",
|
||||||
|
{"config": {"secret": "shared-hook-secret-123", "url": "https://x.example"}},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def secret_values(body: Any) -> list[str]:
|
||||||
|
found: list[str] = []
|
||||||
|
if isinstance(body, dict):
|
||||||
|
for key, item in body.items():
|
||||||
|
if isinstance(item, str) and key in {
|
||||||
|
"password",
|
||||||
|
"authorization_header",
|
||||||
|
"auth_token",
|
||||||
|
"auth_password",
|
||||||
|
"aws_secret_access_key",
|
||||||
|
"secret",
|
||||||
|
}:
|
||||||
|
found.append(item)
|
||||||
|
else:
|
||||||
|
found.extend(secret_values(item))
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("mode", MODES)
|
||||||
|
@pytest.mark.parametrize(("tool", "arguments", "method", "path", "body"), CREDENTIAL_BODIES)
|
||||||
|
async def test_submitted_credentials_are_redacted_from_results(
|
||||||
|
httpx_mock: HTTPXMock,
|
||||||
|
mode: str,
|
||||||
|
tool: str,
|
||||||
|
arguments: dict[str, Any],
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
body: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
mock_user(httpx_mock)
|
||||||
|
secrets = secret_values(body)
|
||||||
|
assert secrets
|
||||||
|
httpx_mock.add_response(
|
||||||
|
method=method,
|
||||||
|
url=f"{API}{path}",
|
||||||
|
status_code=422,
|
||||||
|
json={"message": "rejected: " + " ".join(secrets), "errors": secrets},
|
||||||
|
)
|
||||||
|
request = (
|
||||||
|
{"method": method, "path": path, "body": body}
|
||||||
|
if tool == "gitea_api_request"
|
||||||
|
else {**arguments, "body": body}
|
||||||
|
)
|
||||||
|
async with connected(server_settings(secret_detection_mode=mode)) as client:
|
||||||
|
text, structured = await call(client, tool, request)
|
||||||
|
|
||||||
|
for secret in secrets:
|
||||||
|
assert secret not in text and secret not in json.dumps(structured)
|
||||||
|
assert secret not in json.dumps(audit_entries())
|
||||||
|
assert "[REDACTED]" in text
|
||||||
|
|
||||||
|
|
||||||
|
# Credential-issuing operations: the credential goes to the caller, nowhere else
|
||||||
|
|
||||||
|
|
||||||
|
async def test_issued_credentials_reach_the_caller_but_not_the_audit_file_or_the_logs(
|
||||||
|
httpx_mock: HTTPXMock, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
caplog.set_level("DEBUG")
|
||||||
|
mock_user(httpx_mock)
|
||||||
|
token = "9f8e7d6c5b4a39281706f5e4d3c2b1a099887766"
|
||||||
|
registration = "runner-registration-token-0123456789ABCDEF"
|
||||||
|
httpx_mock.add_response(
|
||||||
|
method="POST", url=f"{API}/users/alice/tokens", json={"id": 1, "name": "ci", "sha1": token}
|
||||||
|
)
|
||||||
|
httpx_mock.add_response(
|
||||||
|
method="POST",
|
||||||
|
url=f"{API}/admin/actions/runners/registration-token",
|
||||||
|
headers={"token": registration},
|
||||||
|
status_code=200,
|
||||||
|
)
|
||||||
|
async with connected(server_settings(secret_detection_mode="block")) as client:
|
||||||
|
created, _ = await call(
|
||||||
|
client, "gitea_user_create_token", {"username": "alice", "body": {"name": "ci"}}
|
||||||
|
)
|
||||||
|
runner, _ = await call(client, "gitea_admin_create_runner_registration_token", {})
|
||||||
|
|
||||||
|
assert token in created and registration in runner # the documented exemption (W-07)
|
||||||
|
audit_text = json.dumps(audit_entries())
|
||||||
|
for secret in (token, registration):
|
||||||
|
assert secret not in audit_text
|
||||||
|
assert secret not in caplog.text
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
"""The server refuses to start unless it is safe to: authentication off loopback, valid settings."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from aegis_gitea_mcp import cli, transports
|
||||||
|
from aegis_gitea_mcp.cli import main
|
||||||
|
from aegis_gitea_mcp.transports import run_http, run_stdio
|
||||||
|
from tests.test_oauth_helpers import oauth_settings
|
||||||
|
from tests.test_server_helpers import BEARER_TOKEN, GITEA_TOKEN, server_settings
|
||||||
|
|
||||||
|
WILDCARD_V4 = "0.0.0.0" # noqa: S104 (a bind address used as test input, never bound)
|
||||||
|
WILDCARD_V6 = "::"
|
||||||
|
NON_LOOPBACK = [
|
||||||
|
WILDCARD_V4,
|
||||||
|
WILDCARD_V6,
|
||||||
|
"[::]",
|
||||||
|
"192.168.1.10",
|
||||||
|
"10.0.0.5",
|
||||||
|
"203.0.113.5",
|
||||||
|
"2001:db8::1",
|
||||||
|
"mcp.example.com",
|
||||||
|
"localhost.evil.example",
|
||||||
|
"gitea.internal",
|
||||||
|
"127.1", # not a canonical loopback address: refused rather than guessed
|
||||||
|
"0",
|
||||||
|
"128.0.0.1",
|
||||||
|
]
|
||||||
|
LOOPBACK = ["127.0.0.1", "127.0.0.2", "127.255.255.254", "::1", "[::1]", "localhost", "LOCALHOST"]
|
||||||
|
|
||||||
|
|
||||||
|
class Served:
|
||||||
|
"""Replaces ``uvicorn.run`` and records whether anything was started."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
def __call__(self, app: Any, **kwargs: Any) -> None:
|
||||||
|
self.calls.append(kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def served(mock_env: None, monkeypatch: pytest.MonkeyPatch) -> Served:
|
||||||
|
spy = Served()
|
||||||
|
monkeypatch.setattr(transports.uvicorn, "run", spy)
|
||||||
|
monkeypatch.setattr(cli, "configure_logging", lambda level: None)
|
||||||
|
return spy
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("host", NON_LOOPBACK)
|
||||||
|
def test_a_non_loopback_bind_without_authentication_exits_2(
|
||||||
|
host: str, served: Served, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
assert run_http(server_settings(mcp_host=host)) == 2
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert served.calls == []
|
||||||
|
assert "without HTTP authentication" in captured.err
|
||||||
|
assert captured.out == ""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("host", NON_LOOPBACK)
|
||||||
|
def test_the_serve_command_refuses_the_same_binds(
|
||||||
|
host: str, served: Served, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
||||||
|
monkeypatch.setenv("GITEA_TOKEN", GITEA_TOKEN)
|
||||||
|
|
||||||
|
assert main(["serve", "--transport", "http", "--host", host]) == 2
|
||||||
|
|
||||||
|
assert served.calls == []
|
||||||
|
assert "without HTTP authentication" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("host", NON_LOOPBACK[:4])
|
||||||
|
def test_a_bearer_is_enough_to_bind_publicly(host: str, served: Served) -> None:
|
||||||
|
assert run_http(server_settings(mcp_host=host, mcp_bearer_token=BEARER_TOKEN)) == 0
|
||||||
|
|
||||||
|
assert len(served.calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("host", NON_LOOPBACK[:4])
|
||||||
|
def test_complete_oauth_is_enough_to_bind_publicly(host: str, served: Served) -> None:
|
||||||
|
settings = oauth_settings(mcp_host=host, mcp_bearer_token=None, gitea_token=None)
|
||||||
|
|
||||||
|
assert run_http(settings) == 0
|
||||||
|
assert len(served.calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("host", LOOPBACK)
|
||||||
|
def test_loopback_binds_may_run_without_authentication(host: str, served: Served) -> None:
|
||||||
|
assert run_http(server_settings(mcp_host=host)) == 0
|
||||||
|
|
||||||
|
assert len(served.calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# Half-configured authentication
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"missing",
|
||||||
|
[
|
||||||
|
"gitea_oauth_client_id",
|
||||||
|
"gitea_oauth_client_secret",
|
||||||
|
"token_encryption_key",
|
||||||
|
"public_base_url",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_half_configured_oauth_exits_2_and_never_falls_back_to_no_auth(
|
||||||
|
missing: str, served: Served, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
settings = oauth_settings(mcp_host=WILDCARD_V4, mcp_bearer_token=None, **{missing: None})
|
||||||
|
|
||||||
|
assert run_http(settings) == 2
|
||||||
|
|
||||||
|
assert served.calls == []
|
||||||
|
assert missing.upper() in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
def test_oauth_with_a_bearer_but_no_admin_token_exits_2(
|
||||||
|
served: Served, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
assert run_http(oauth_settings(gitea_token=None)) == 2
|
||||||
|
|
||||||
|
assert served.calls == []
|
||||||
|
assert "GITEA_TOKEN" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bearer", ["", "short", "x" * 31])
|
||||||
|
def test_a_bearer_shorter_than_32_characters_is_rejected_at_configuration(
|
||||||
|
bearer: str, served: Served, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
||||||
|
monkeypatch.setenv("GITEA_TOKEN", GITEA_TOKEN)
|
||||||
|
monkeypatch.setenv("MCP_BEARER_TOKEN", bearer)
|
||||||
|
monkeypatch.setenv("MCP_HOST", WILDCARD_V4)
|
||||||
|
|
||||||
|
code = main(["serve", "--transport", "http"])
|
||||||
|
|
||||||
|
if bearer == "":
|
||||||
|
# A blank value counts as unset, so the unauthenticated public bind is refused instead.
|
||||||
|
assert code == 2 and "without HTTP authentication" in capsys.readouterr().err
|
||||||
|
else:
|
||||||
|
assert code == 2
|
||||||
|
assert "invalid configuration" in capsys.readouterr().err
|
||||||
|
assert served.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_http_public_base_url_on_a_public_host_is_rejected(
|
||||||
|
served: Served, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
||||||
|
monkeypatch.setenv("GITEA_TOKEN", GITEA_TOKEN)
|
||||||
|
monkeypatch.setenv("PUBLIC_BASE_URL", "http://mcp.example.com")
|
||||||
|
|
||||||
|
assert main(["serve", "--transport", "http"]) == 2
|
||||||
|
|
||||||
|
assert served.calls == []
|
||||||
|
assert "PUBLIC_BASE_URL" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_missing_gitea_token_exits_2_for_admin_token_mode_and_stdio(
|
||||||
|
served: Served, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
assert run_http(server_settings(gitea_token=None, mcp_bearer_token=BEARER_TOKEN)) == 2
|
||||||
|
assert run_http(server_settings(gitea_token=None)) == 2
|
||||||
|
assert run_stdio(server_settings(gitea_token=None)) == 2
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert served.calls == []
|
||||||
|
assert "GITEA_TOKEN" in captured.err
|
||||||
|
assert captured.out == "" # stdout is reserved for JSON-RPC
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unwritable_audit_log_exits_2_before_serving(
|
||||||
|
tmp_path: Path,
|
||||||
|
served: Served,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
blocker = tmp_path / "blocker"
|
||||||
|
blocker.write_text("a file where a directory is needed")
|
||||||
|
path = blocker / "audit.log"
|
||||||
|
monkeypatch.setenv("AUDIT_LOG_PATH", str(path))
|
||||||
|
|
||||||
|
assert run_http(server_settings(audit_log_path=path)) == 2
|
||||||
|
assert run_stdio(server_settings(audit_log_path=path)) == 2
|
||||||
|
|
||||||
|
assert served.calls == []
|
||||||
|
assert "audit log" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_symlinked_audit_path_exits_2(
|
||||||
|
tmp_path: Path,
|
||||||
|
served: Served,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
target = tmp_path / "elsewhere.log"
|
||||||
|
target.write_text("")
|
||||||
|
link = tmp_path / "audit.log"
|
||||||
|
link.symlink_to(target)
|
||||||
|
monkeypatch.setenv("AUDIT_LOG_PATH", str(link))
|
||||||
|
|
||||||
|
assert run_http(server_settings(audit_log_path=link)) == 2
|
||||||
|
|
||||||
|
assert served.calls == []
|
||||||
|
assert "symbolic link" in capsys.readouterr().err
|
||||||
|
assert target.read_text() == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_disabled_tools_stop_startup(
|
||||||
|
served: Served, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
assert run_http(server_settings(disabled_tools=["gitea_no_such_tool"])) == 2
|
||||||
|
assert run_stdio(server_settings(disabled_tools=["gitea_no_such_tool"])) == 2
|
||||||
|
|
||||||
|
assert served.calls == []
|
||||||
|
assert "gitea_no_such_tool" in capsys.readouterr().err
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
"""The encrypted OAuth token store: permissions, encryption, key handling and ownership (SEC-13)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
from pytest_httpx import HTTPXMock
|
||||||
|
|
||||||
|
from aegis_gitea_mcp import transports
|
||||||
|
from aegis_gitea_mcp.oauth.store import (
|
||||||
|
STORE_FILENAME,
|
||||||
|
OAuthStore,
|
||||||
|
OAuthStoreError,
|
||||||
|
SessionRecord,
|
||||||
|
derive_fernet_key,
|
||||||
|
token_hash,
|
||||||
|
)
|
||||||
|
from aegis_gitea_mcp.transports import run_http
|
||||||
|
from tests.test_oauth_helpers import (
|
||||||
|
APP_CLIENT_SECRET,
|
||||||
|
ENCRYPTION_KEY,
|
||||||
|
USER_REFRESH,
|
||||||
|
USER_TOKEN,
|
||||||
|
browser,
|
||||||
|
build_oauth_app,
|
||||||
|
oauth_settings,
|
||||||
|
sign_in,
|
||||||
|
)
|
||||||
|
|
||||||
|
OTHER_KEY = "another-key-0123456789abcdef0123456789"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _env(mock_env: None) -> None:
|
||||||
|
"""The audit logger reads the process-wide settings."""
|
||||||
|
|
||||||
|
|
||||||
|
def a_session() -> SessionRecord:
|
||||||
|
return SessionRecord(
|
||||||
|
session_id="s1",
|
||||||
|
client_id="c1",
|
||||||
|
login="alice",
|
||||||
|
is_admin=False,
|
||||||
|
gitea_access_token=USER_TOKEN,
|
||||||
|
gitea_refresh_token=USER_REFRESH,
|
||||||
|
gitea_expires_at=None,
|
||||||
|
scopes=["gitea"],
|
||||||
|
resource="https://mcp.example.com/mcp",
|
||||||
|
created_at=1e12,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_file_is_0600_and_a_new_directory_is_0700(tmp_path: Path) -> None:
|
||||||
|
previous = os.umask(0)
|
||||||
|
try:
|
||||||
|
directory = tmp_path / "new" / "state"
|
||||||
|
store = OAuthStore.load(directory, ENCRYPTION_KEY)
|
||||||
|
store.sessions["s1"] = a_session()
|
||||||
|
await store.save()
|
||||||
|
finally:
|
||||||
|
os.umask(previous)
|
||||||
|
|
||||||
|
assert stat.S_IMODE(directory.stat().st_mode) == 0o700
|
||||||
|
assert stat.S_IMODE((directory / STORE_FILENAME).stat().st_mode) == 0o600
|
||||||
|
assert [entry.name for entry in directory.iterdir()] == [STORE_FILENAME]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_symlinked_store_is_refused_and_its_target_untouched(tmp_path: Path) -> None:
|
||||||
|
target = tmp_path / "elsewhere"
|
||||||
|
target.write_bytes(b"keep me")
|
||||||
|
(tmp_path / STORE_FILENAME).symlink_to(target)
|
||||||
|
|
||||||
|
with pytest.raises(OAuthStoreError, match="symlink"):
|
||||||
|
OAuthStore.load(tmp_path, ENCRYPTION_KEY)
|
||||||
|
assert target.read_bytes() == b"keep me"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_symlink_swapped_in_later_is_not_written_through(tmp_path: Path) -> None:
|
||||||
|
store = OAuthStore.load(tmp_path, ENCRYPTION_KEY)
|
||||||
|
target = tmp_path / "elsewhere"
|
||||||
|
target.write_bytes(b"keep me")
|
||||||
|
(tmp_path / STORE_FILENAME).symlink_to(target)
|
||||||
|
|
||||||
|
with pytest.raises(OAuthStoreError, match="symlink"):
|
||||||
|
await store.save()
|
||||||
|
assert target.read_bytes() == b"keep me"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_file_holds_no_plaintext_and_our_tokens_only_as_hashes(
|
||||||
|
tmp_path: Path, httpx_mock: HTTPXMock
|
||||||
|
) -> None:
|
||||||
|
oauth = build_oauth_app(oauth_settings(data_dir=tmp_path))
|
||||||
|
flow = sign_in(browser(oauth.app), httpx_mock, login="alice-the-user")
|
||||||
|
|
||||||
|
raw = (tmp_path / STORE_FILENAME).read_bytes()
|
||||||
|
|
||||||
|
for secret in (
|
||||||
|
USER_TOKEN,
|
||||||
|
USER_REFRESH,
|
||||||
|
flow.client_secret,
|
||||||
|
flow.tokens["access_token"],
|
||||||
|
flow.tokens["refresh_token"],
|
||||||
|
"alice-the-user",
|
||||||
|
APP_CLIENT_SECRET,
|
||||||
|
ENCRYPTION_KEY,
|
||||||
|
):
|
||||||
|
assert secret.encode() not in raw
|
||||||
|
plain = Fernet(derive_fernet_key(ENCRYPTION_KEY)).decrypt(raw).decode()
|
||||||
|
assert flow.tokens["access_token"] not in plain
|
||||||
|
assert flow.tokens["refresh_token"] not in plain
|
||||||
|
assert token_hash(flow.tokens["access_token"]) in plain
|
||||||
|
assert token_hash(flow.tokens["refresh_token"]) in plain
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_wrong_key_stops_startup_without_wiping_the_store(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
store = OAuthStore.load(tmp_path, ENCRYPTION_KEY)
|
||||||
|
store.sessions["s1"] = a_session()
|
||||||
|
await store.save()
|
||||||
|
before = (tmp_path / STORE_FILENAME).read_bytes()
|
||||||
|
served: list[Any] = []
|
||||||
|
monkeypatch.setattr(transports.uvicorn, "run", lambda *a, **k: served.append(a))
|
||||||
|
|
||||||
|
code = run_http(oauth_settings(data_dir=tmp_path, token_encryption_key=OTHER_KEY))
|
||||||
|
|
||||||
|
assert code == 2 and served == []
|
||||||
|
err = capsys.readouterr().err
|
||||||
|
assert "TOKEN_ENCRYPTION_KEY" in err and "move the file away" in err
|
||||||
|
assert OTHER_KEY not in err and ENCRYPTION_KEY not in err
|
||||||
|
assert (tmp_path / STORE_FILENAME).read_bytes() == before
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_garbage_store_stops_startup_and_stays_on_disk(tmp_path: Path) -> None:
|
||||||
|
(tmp_path / STORE_FILENAME).write_bytes(b"not a fernet token")
|
||||||
|
|
||||||
|
with pytest.raises(OAuthStoreError):
|
||||||
|
OAuthStore.load(tmp_path, ENCRYPTION_KEY)
|
||||||
|
assert (tmp_path / STORE_FILENAME).read_bytes() == b"not a fernet token"
|
||||||
|
|
||||||
|
|
||||||
|
# SEC-13: who owns the directory
|
||||||
|
|
||||||
|
|
||||||
|
def fake_owner(monkeypatch: pytest.MonkeyPatch, directory: Path) -> None:
|
||||||
|
"""Report ``directory`` as owned by a different uid than the running process."""
|
||||||
|
real_stat = os.stat
|
||||||
|
|
||||||
|
def stat_other(path: Any, *args: Any, **kwargs: Any) -> os.stat_result:
|
||||||
|
info = real_stat(path, *args, **kwargs)
|
||||||
|
if Path(path) == directory:
|
||||||
|
fields = list(info)
|
||||||
|
fields[stat.ST_UID] = info.st_uid + 1
|
||||||
|
return os.stat_result(fields)
|
||||||
|
return info
|
||||||
|
|
||||||
|
monkeypatch.setattr(os, "stat", stat_other)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_directory_owned_by_another_user_is_refused_on_load_and_save(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
directory = tmp_path / "state"
|
||||||
|
directory.mkdir(mode=0o700)
|
||||||
|
store = OAuthStore(directory, ENCRYPTION_KEY)
|
||||||
|
fake_owner(monkeypatch, directory)
|
||||||
|
|
||||||
|
with pytest.raises(OAuthStoreError, match="owned by another user"):
|
||||||
|
OAuthStore.load(directory, ENCRYPTION_KEY)
|
||||||
|
with pytest.raises(OAuthStoreError, match="owned by another user"):
|
||||||
|
await store.save()
|
||||||
|
assert list(directory.iterdir()) == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_group_or_world_writable_directory_that_cannot_be_tightened_is_refused(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
directory = tmp_path / "state"
|
||||||
|
directory.mkdir()
|
||||||
|
directory.chmod(0o777)
|
||||||
|
|
||||||
|
def refuse(self: Path, mode: int) -> None:
|
||||||
|
raise PermissionError("not permitted")
|
||||||
|
|
||||||
|
monkeypatch.setattr(Path, "chmod", refuse)
|
||||||
|
|
||||||
|
with pytest.raises(OAuthStoreError, match="writable by group or others"):
|
||||||
|
OAuthStore.load(directory, ENCRYPTION_KEY)
|
||||||
|
with pytest.raises(OAuthStoreError, match="writable by group or others"):
|
||||||
|
await OAuthStore(directory, ENCRYPTION_KEY).save()
|
||||||
|
assert list(directory.iterdir()) == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_loose_directory_we_own_is_tightened_not_refused(tmp_path: Path) -> None:
|
||||||
|
directory = tmp_path / "state"
|
||||||
|
directory.mkdir()
|
||||||
|
directory.chmod(0o777)
|
||||||
|
|
||||||
|
await OAuthStore.load(directory, ENCRYPTION_KEY).save()
|
||||||
|
|
||||||
|
assert stat.S_IMODE(directory.stat().st_mode) == 0o700
|
||||||
|
|
||||||
|
|
||||||
|
def test_startup_exits_2_for_a_directory_owned_by_someone_else(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
directory = tmp_path / "state"
|
||||||
|
directory.mkdir(mode=0o700)
|
||||||
|
fake_owner(monkeypatch, directory)
|
||||||
|
served: list[Any] = []
|
||||||
|
monkeypatch.setattr(transports.uvicorn, "run", lambda *a, **k: served.append(a))
|
||||||
|
|
||||||
|
assert run_http(oauth_settings(data_dir=directory)) == 2
|
||||||
|
|
||||||
|
assert served == []
|
||||||
|
assert "owned by another user" in capsys.readouterr().err
|
||||||
@@ -91,7 +91,9 @@ def test_env_names_are_case_insensitive(monkeypatch: pytest.MonkeyPatch) -> None
|
|||||||
|
|
||||||
|
|
||||||
def test_reads_dotenv_file(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
|
def test_reads_dotenv_file(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
|
||||||
(tmp_path / ".env").write_text("GITEA_URL=https://dotenv.example.com\nREAD_ONLY=true\n")
|
dotenv = tmp_path / ".env"
|
||||||
|
dotenv.write_text("GITEA_URL=https://dotenv.example.com\nREAD_ONLY=true\n")
|
||||||
|
dotenv.chmod(0o600) # an implicit ./.env is only trusted with mode 0600 (SEC-04)
|
||||||
reset_settings()
|
reset_settings()
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
|
|||||||
@@ -393,6 +393,7 @@ def test_callback_in_a_browser_that_did_not_start_the_flow_is_refused(
|
|||||||
"csrf": match.group(1),
|
"csrf": match.group(1),
|
||||||
"decision": "approve",
|
"decision": "approve",
|
||||||
},
|
},
|
||||||
|
headers={"Origin": BASE},
|
||||||
)
|
)
|
||||||
state = query_of(approved.headers["location"])["state"]
|
state = query_of(approved.headers["location"])["state"]
|
||||||
|
|
||||||
@@ -440,20 +441,21 @@ def test_consent_post_needs_the_csrf_cookie_token_and_origin() -> None:
|
|||||||
request_id, csrf = consent_form(client)
|
request_id, csrf = consent_form(client)
|
||||||
form = {"request": request_id, "csrf": csrf, "decision": "approve"}
|
form = {"request": request_id, "csrf": csrf, "decision": "approve"}
|
||||||
|
|
||||||
cross_site = browser(oauth.app).post("/oauth/consent", data=form) # no Strict cookie
|
origin = {"Origin": BASE}
|
||||||
wrong_token = client.post("/oauth/consent", data={**form, "csrf": "nope"})
|
cross_site = browser(oauth.app).post("/oauth/consent", data=form, headers=origin) # no cookie
|
||||||
|
wrong_token = client.post("/oauth/consent", data={**form, "csrf": "nope"}, headers=origin)
|
||||||
bad_origin = client.post(
|
bad_origin = client.post(
|
||||||
"/oauth/consent", data=form, headers={"Origin": "https://evil.example"}
|
"/oauth/consent", data=form, headers={"Origin": "https://evil.example"}
|
||||||
)
|
)
|
||||||
unknown = client.post("/oauth/consent", data={**form, "request": "unknown"})
|
unknown = client.post("/oauth/consent", data={**form, "request": "unknown"}, headers=origin)
|
||||||
good = client.post("/oauth/consent", data=form, headers={"Origin": BASE})
|
good = client.post("/oauth/consent", data=form, headers=origin)
|
||||||
again = client.post("/oauth/consent", data=form)
|
again = client.post("/oauth/consent", data=form, headers=origin)
|
||||||
|
|
||||||
assert [r.status_code for r in (cross_site, wrong_token, bad_origin, unknown)] == [400] * 4
|
assert [r.status_code for r in (cross_site, wrong_token, bad_origin, unknown)] == [400] * 4
|
||||||
assert good.status_code == 303
|
assert good.status_code == 303
|
||||||
assert again.status_code == 400 # single use
|
assert again.status_code == 400 # single use
|
||||||
binding = good.headers["set-cookie"].lower()
|
binding = good.headers["set-cookie"].lower()
|
||||||
assert "__host-aegis-oauth-flow=" in binding and "samesite=lax" in binding
|
assert "__host-aegis-oauth-flow-" in binding and "samesite=lax" in binding
|
||||||
|
|
||||||
|
|
||||||
def test_unknown_consent_request_page_is_a_400() -> None:
|
def test_unknown_consent_request_page_is_a_400() -> None:
|
||||||
@@ -468,7 +470,9 @@ def test_denying_consent_returns_access_denied_to_the_client() -> None:
|
|||||||
request_id, csrf = consent_form(client)
|
request_id, csrf = consent_form(client)
|
||||||
|
|
||||||
denied = client.post(
|
denied = client.post(
|
||||||
"/oauth/consent", data={"request": request_id, "csrf": csrf, "decision": "deny"}
|
"/oauth/consent",
|
||||||
|
data={"request": request_id, "csrf": csrf, "decision": "deny"},
|
||||||
|
headers={"Origin": BASE},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert denied.status_code == 303
|
assert denied.status_code == 303
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ async def test_client_cap_evicts_the_least_recently_used_client_without_sessions
|
|||||||
await provider.register_client(client_info("newer"))
|
await provider.register_client(client_info("newer"))
|
||||||
oauth.clock.advance(5)
|
oauth.clock.advance(5)
|
||||||
await provider.get_client("old") # used recently: "newer" is now least recently used
|
await provider.get_client("old") # used recently: "newer" is now least recently used
|
||||||
|
oauth.clock.advance(provider_module.PENDING_TTL) # none of them is young any more
|
||||||
|
|
||||||
await provider.register_client(client_info("newest"))
|
await provider.register_client(client_info("newest"))
|
||||||
|
|
||||||
|
|||||||
@@ -354,9 +354,9 @@ async def test_unknown_tool_is_a_protocol_error_and_is_audited() -> None:
|
|||||||
await client.call_tool("gitea_does_not_exist", {"a": 1})
|
await client.call_tool("gitea_does_not_exist", {"a": 1})
|
||||||
|
|
||||||
assert caught.value.code == -32602
|
assert caught.value.code == -32602
|
||||||
assert "gitea_does_not_exist" in caught.value.message
|
assert "gitea_does_not_exist" not in caught.value.message
|
||||||
entry = audit_entries()[-1]
|
entry = audit_entries()[-1]
|
||||||
assert entry["tool_name"] == "gitea_does_not_exist"
|
assert entry["tool_name"] == "(unknown tool, 20 chars)"
|
||||||
assert entry["outcome"] == "unknown_tool"
|
assert entry["outcome"] == "unknown_tool"
|
||||||
|
|
||||||
|
|
||||||
@@ -476,6 +476,9 @@ async def test_audit_write_failure_after_execution_does_not_break_the_call(
|
|||||||
httpx_mock.add_response(url=f"{API}/repos/octo/demo", json={})
|
httpx_mock.add_response(url=f"{API}/repos/octo/demo", json={})
|
||||||
|
|
||||||
class BrokenAudit:
|
class BrokenAudit:
|
||||||
|
def log_tool_call_started(self, *args: Any, **kwargs: Any) -> str:
|
||||||
|
return "started"
|
||||||
|
|
||||||
def log_tool_invocation(self, *args: Any, **kwargs: Any) -> str:
|
def log_tool_invocation(self, *args: Any, **kwargs: Any) -> str:
|
||||||
raise OSError("disk full: " + GITEA_TOKEN)
|
raise OSError("disk full: " + GITEA_TOKEN)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
"""Checks of the integration sweep plan that need no Docker.
|
||||||
|
|
||||||
|
The sweep itself runs against a live Gitea (``tests/integration/test_sweep.py``). These
|
||||||
|
tests make sure the plan stays consistent with the catalog: every tool gets arguments that
|
||||||
|
pass its schema, every tool is called exactly once in a sensible order, and every override,
|
||||||
|
allowance and exclusion names a real tool.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from jsonschema import Draft202012Validator
|
||||||
|
|
||||||
|
from aegis_gitea_mcp.catalog import load_catalog
|
||||||
|
from aegis_gitea_mcp.server import RAW_INPUT_SCHEMA
|
||||||
|
from tests.integration import sweep_plan
|
||||||
|
from tests.integration.sweep_plan import (
|
||||||
|
ALLOWANCES,
|
||||||
|
EXCLUDED,
|
||||||
|
OVERRIDES,
|
||||||
|
RAW_TOOL,
|
||||||
|
CallLog,
|
||||||
|
SweepRecord,
|
||||||
|
build_arguments,
|
||||||
|
classify,
|
||||||
|
documented_statuses,
|
||||||
|
load_spec,
|
||||||
|
ordered_operations,
|
||||||
|
phase_of,
|
||||||
|
render_report,
|
||||||
|
sort_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
CATALOG = load_catalog()
|
||||||
|
SPEC = load_spec()
|
||||||
|
|
||||||
|
#: Context keys that hold strings; every other key is an integer id in the fake context.
|
||||||
|
STRING_SUFFIXES = ("_name", "_repo", "_user", "_sha", "_tag", "_token", "_task")
|
||||||
|
STRING_KEYS = {
|
||||||
|
"admin",
|
||||||
|
"user",
|
||||||
|
"user2",
|
||||||
|
"user3",
|
||||||
|
"owner",
|
||||||
|
"repo",
|
||||||
|
"org",
|
||||||
|
"sfx",
|
||||||
|
"email",
|
||||||
|
"protection",
|
||||||
|
"page_name",
|
||||||
|
"pkg",
|
||||||
|
"pkg2",
|
||||||
|
"pkg_version",
|
||||||
|
"sha",
|
||||||
|
"blob_sha",
|
||||||
|
"tag_sha",
|
||||||
|
"delete_file_sha",
|
||||||
|
"update_file_sha",
|
||||||
|
"milestone_name",
|
||||||
|
"release_tag",
|
||||||
|
"release_tag2",
|
||||||
|
"git_hook",
|
||||||
|
"cron_task",
|
||||||
|
"push_mirror",
|
||||||
|
"admin_password",
|
||||||
|
"throwaway_token",
|
||||||
|
"user_secret",
|
||||||
|
"user_var",
|
||||||
|
"del_org",
|
||||||
|
"del_org2",
|
||||||
|
"rename_org",
|
||||||
|
"org_repo",
|
||||||
|
"template_repo",
|
||||||
|
"xfer_repo",
|
||||||
|
"del_repo",
|
||||||
|
"fork_repo",
|
||||||
|
"mirror_repo",
|
||||||
|
"push_target",
|
||||||
|
"push_target2",
|
||||||
|
"del_user",
|
||||||
|
"rename_user",
|
||||||
|
"team_name",
|
||||||
|
"team2_name",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeContext(dict[str, Any]):
|
||||||
|
"""A seed context for tests: strings for the known string keys, 1 for everything else."""
|
||||||
|
|
||||||
|
def __missing__(self, key: str) -> Any:
|
||||||
|
if key in STRING_KEYS or key.endswith(STRING_SUFFIXES):
|
||||||
|
return "x1"
|
||||||
|
if key.endswith("_columns"):
|
||||||
|
return [1, 2, 3]
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def fake_context() -> FakeContext:
|
||||||
|
return FakeContext(sfx="abc123", admin="admin", user="alice", owner="admin")
|
||||||
|
|
||||||
|
|
||||||
|
def all_tool_names() -> set[str]:
|
||||||
|
return {op.tool_name for op in CATALOG.operations} | {RAW_TOOL}
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_operation_gets_arguments_that_pass_its_schema() -> None:
|
||||||
|
context = fake_context()
|
||||||
|
problems: list[str] = []
|
||||||
|
for operation in CATALOG.operations:
|
||||||
|
arguments = build_arguments(operation, context)
|
||||||
|
validator = Draft202012Validator(operation.input_schema)
|
||||||
|
errors = sorted(error.message[:80] for error in validator.iter_errors(arguments))
|
||||||
|
if errors:
|
||||||
|
problems.append(f"{operation.tool_name}: {errors[0]}")
|
||||||
|
assert not problems, "\n".join(problems)
|
||||||
|
|
||||||
|
|
||||||
|
def test_raw_tool_arguments_pass_its_schema() -> None:
|
||||||
|
arguments = OVERRIDES[RAW_TOOL](fake_context())
|
||||||
|
assert not list(Draft202012Validator(RAW_INPUT_SCHEMA).iter_errors(arguments))
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_covers_every_tool_except_the_excluded_ones_exactly_once() -> None:
|
||||||
|
planned = [op.tool_name for op in ordered_operations(CATALOG)]
|
||||||
|
assert len(planned) == len(set(planned))
|
||||||
|
assert set(planned) == {op.tool_name for op in CATALOG.operations} - set(EXCLUDED)
|
||||||
|
assert len(CATALOG.operations) + 1 == 538
|
||||||
|
|
||||||
|
|
||||||
|
def test_overrides_allowances_and_exclusions_name_real_tools() -> None:
|
||||||
|
names = all_tool_names()
|
||||||
|
assert set(OVERRIDES) <= names
|
||||||
|
assert set(ALLOWANCES) <= names
|
||||||
|
assert set(EXCLUDED) <= names
|
||||||
|
assert all(allowance.reason for allowance in ALLOWANCES.values())
|
||||||
|
assert all(reason for reason in EXCLUDED.values())
|
||||||
|
|
||||||
|
|
||||||
|
def test_order_is_reads_then_creates_then_updates_then_deletes() -> None:
|
||||||
|
operations = ordered_operations(CATALOG)
|
||||||
|
phases = [phase_of(op) for op in operations]
|
||||||
|
assert phases == sorted(phases)
|
||||||
|
assert phase_of(next(op for op in operations if op.tool_name == "gitea_repo_get")) == 0
|
||||||
|
first_delete = next(i for i, p in enumerate(phases) if p == 5)
|
||||||
|
assert all(op.method == "DELETE" for op in operations[first_delete:])
|
||||||
|
assert all(op.read_only for op in operations if phase_of(op) == 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_deletes_run_leaf_resources_before_their_parents() -> None:
|
||||||
|
names = [op.tool_name for op in ordered_operations(CATALOG)]
|
||||||
|
leaf_first = [
|
||||||
|
("gitea_issue_delete_comment", "gitea_issue_delete"),
|
||||||
|
("gitea_issue_delete_label", "gitea_issue_delete_milestone"),
|
||||||
|
("gitea_release_delete_release_attachment", "gitea_release_delete_release"),
|
||||||
|
("gitea_org_delete_repos", "gitea_org_delete"),
|
||||||
|
]
|
||||||
|
for leaf, parent in leaf_first:
|
||||||
|
assert names.index(leaf) < names.index(parent), (leaf, parent)
|
||||||
|
assert names.index("gitea_pull_merge_pull_request") > names.index(
|
||||||
|
"gitea_pull_create_pull_review"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_destructive_tools_target_throwaway_resources() -> None:
|
||||||
|
by_name = {op.tool_name: op for op in CATALOG.operations}
|
||||||
|
context = fake_context()
|
||||||
|
context.update(
|
||||||
|
repo="seed-repo",
|
||||||
|
org="seed-org",
|
||||||
|
user="seed-user",
|
||||||
|
del_repo="throwaway-repo",
|
||||||
|
del_org="throwaway-org",
|
||||||
|
del_org2="throwaway-org-two",
|
||||||
|
del_user="throwaway-user",
|
||||||
|
)
|
||||||
|
assert build_arguments(by_name["gitea_repo_delete"], context)["repo"] == "throwaway-repo"
|
||||||
|
assert build_arguments(by_name["gitea_org_delete"], context)["org"] == "throwaway-org-two"
|
||||||
|
assert build_arguments(by_name["gitea_org_delete_repos"], context)["org"] == "throwaway-org"
|
||||||
|
assert (
|
||||||
|
build_arguments(by_name["gitea_admin_delete_user"], context)["username"] == "throwaway-user"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_documented_statuses_come_from_the_swagger() -> None:
|
||||||
|
operation = next(op for op in CATALOG.operations if op.tool_name == "gitea_repo_get")
|
||||||
|
assert documented_statuses(SPEC, operation) >= {200, 404}
|
||||||
|
for operation in CATALOG.operations:
|
||||||
|
assert documented_statuses(SPEC, operation), operation.tool_name
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("status", "is_error", "documented", "expected"),
|
||||||
|
[
|
||||||
|
(200, False, frozenset(), "success"),
|
||||||
|
(204, False, frozenset(), "success"),
|
||||||
|
(404, True, frozenset({404}), "documented"),
|
||||||
|
(404, True, frozenset({403}), "unexpected"),
|
||||||
|
(None, True, frozenset({404}), "unexpected"),
|
||||||
|
(500, True, frozenset({404}), "unexpected"),
|
||||||
|
(301, True, frozenset({200}), "unexpected"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_classify(
|
||||||
|
status: int | None, is_error: bool, documented: frozenset[int], expected: str
|
||||||
|
) -> None:
|
||||||
|
assert classify("gitea_repo_get", status, is_error, documented)[0] == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_accepts_listed_allowances_only_for_their_status() -> None:
|
||||||
|
tool = "gitea_user_create_token"
|
||||||
|
outcome, reason = classify(tool, 401, True, frozenset({201}))
|
||||||
|
assert outcome == "allowance"
|
||||||
|
assert "basic auth" in reason
|
||||||
|
assert classify(tool, 500, True, frozenset({201}))[0] == "unexpected"
|
||||||
|
assert classify("gitea_repo_get", 401, True, frozenset({200}))[0] == "unexpected"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sort_key_orders_phase_before_name() -> None:
|
||||||
|
by_name = {op.tool_name: op for op in CATALOG.operations}
|
||||||
|
assert sort_key(by_name["gitea_repo_get"]) < sort_key(by_name["gitea_issue_create_issue"])
|
||||||
|
assert sort_key(by_name["gitea_issue_create_issue"]) < sort_key(by_name["gitea_repo_edit"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_groups_by_domain_and_has_no_em_dash_or_bold() -> None:
|
||||||
|
log = CallLog()
|
||||||
|
log.add("gitea_repo_get", 200, False)
|
||||||
|
log.add("gitea_repo_delete", 204, False)
|
||||||
|
log.sweep = [
|
||||||
|
SweepRecord("gitea_repo_get", "repo", "GET", "/x", 200, False, None, "", "success"),
|
||||||
|
SweepRecord(
|
||||||
|
"gitea_repo_list_forks", "repo", "GET", "/y", 404, True, "not_found", "", "documented"
|
||||||
|
),
|
||||||
|
SweepRecord(
|
||||||
|
"gitea_misc_get_signing_key",
|
||||||
|
"misc",
|
||||||
|
"GET",
|
||||||
|
"/z",
|
||||||
|
404,
|
||||||
|
True,
|
||||||
|
"not_found",
|
||||||
|
"",
|
||||||
|
"allowance",
|
||||||
|
"no signing key",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
text = render_report(CATALOG, log, "28.1.0")
|
||||||
|
assert "# Integration coverage" in text
|
||||||
|
assert "## repo" in text
|
||||||
|
assert "`gitea_repo_get`" in text
|
||||||
|
assert "| `gitea_repo_list_forks` | 404 |" in text
|
||||||
|
assert "no signing key" in text
|
||||||
|
assert "\u2014" not in text
|
||||||
|
assert "**" not in text
|
||||||
|
assert "Not covered by this run" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_is_only_written_when_enabled(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Any
|
||||||
|
) -> None:
|
||||||
|
target = tmp_path / "coverage.md"
|
||||||
|
monkeypatch.setattr(sweep_plan, "REPORT_PATH", target)
|
||||||
|
monkeypatch.setattr(sweep_plan, "LOG", CallLog())
|
||||||
|
monkeypatch.delenv(sweep_plan.REPORT_ENV, raising=False)
|
||||||
|
assert sweep_plan.write_report_if_enabled(CATALOG, "28.1.0") is False
|
||||||
|
monkeypatch.setenv(sweep_plan.REPORT_ENV, "1")
|
||||||
|
assert sweep_plan.write_report_if_enabled(CATALOG, "28.1.0") is False # sweep did not run
|
||||||
|
sweep_plan.LOG.sweep = [
|
||||||
|
SweepRecord("gitea_repo_get", "repo", "GET", "/x", 200, False, None, "", "success")
|
||||||
|
]
|
||||||
|
assert sweep_plan.write_report_if_enabled(CATALOG, "28.1.0") is True
|
||||||
|
assert target.read_text(encoding="utf-8").startswith("# Integration coverage")
|
||||||
@@ -16,6 +16,7 @@ from aegis_gitea_mcp.cli import main
|
|||||||
from aegis_gitea_mcp.config import Settings
|
from aegis_gitea_mcp.config import Settings
|
||||||
from aegis_gitea_mcp.oauth import OAuthSetupError
|
from aegis_gitea_mcp.oauth import OAuthSetupError
|
||||||
from aegis_gitea_mcp.transports import (
|
from aegis_gitea_mcp.transports import (
|
||||||
|
ForwardedHeadersMiddleware,
|
||||||
HttpAuthExtras,
|
HttpAuthExtras,
|
||||||
http_startup_errors,
|
http_startup_errors,
|
||||||
load_oauth_components,
|
load_oauth_components,
|
||||||
@@ -83,8 +84,10 @@ def test_non_loopback_bind_with_bearer_is_allowed(uvicorn_spy: UvicornSpy) -> No
|
|||||||
assert callable(app)
|
assert callable(app)
|
||||||
assert kwargs["host"] == ANY_HOST
|
assert kwargs["host"] == ANY_HOST
|
||||||
assert kwargs["port"] == 9000
|
assert kwargs["port"] == 9000
|
||||||
assert kwargs["proxy_headers"] is True
|
# Proxy headers are applied by ForwardedHeadersMiddleware, not by uvicorn.
|
||||||
assert kwargs["forwarded_allow_ips"] == "127.0.0.1"
|
assert kwargs["proxy_headers"] is False
|
||||||
|
assert isinstance(app, ForwardedHeadersMiddleware)
|
||||||
|
assert app.trusted == "127.0.0.1"
|
||||||
assert kwargs["log_level"] == "info"
|
assert kwargs["log_level"] == "info"
|
||||||
assert kwargs["server_header"] is False
|
assert kwargs["server_header"] is False
|
||||||
|
|
||||||
@@ -99,7 +102,7 @@ def test_loopback_without_auth_runs(uvicorn_spy: UvicornSpy) -> None:
|
|||||||
def test_forwarded_allow_ips_setting_is_passed_to_uvicorn(uvicorn_spy: UvicornSpy) -> None:
|
def test_forwarded_allow_ips_setting_is_passed_to_uvicorn(uvicorn_spy: UvicornSpy) -> None:
|
||||||
assert run_http(server_settings(forwarded_allow_ips="10.0.0.0/8, 172.18.0.2")) == 0
|
assert run_http(server_settings(forwarded_allow_ips="10.0.0.0/8, 172.18.0.2")) == 0
|
||||||
|
|
||||||
assert uvicorn_spy.calls[0][1]["forwarded_allow_ips"] == "10.0.0.0/8,172.18.0.2"
|
assert uvicorn_spy.calls[0][0].trusted == "10.0.0.0/8,172.18.0.2"
|
||||||
|
|
||||||
|
|
||||||
def test_forwarded_allow_ips_wildcard_needs_an_explicit_value_and_warns(
|
def test_forwarded_allow_ips_wildcard_needs_an_explicit_value_and_warns(
|
||||||
@@ -109,7 +112,7 @@ def test_forwarded_allow_ips_wildcard_needs_an_explicit_value_and_warns(
|
|||||||
|
|
||||||
assert run_http(server_settings(forwarded_allow_ips="*")) == 0
|
assert run_http(server_settings(forwarded_allow_ips="*")) == 0
|
||||||
|
|
||||||
assert uvicorn_spy.calls[0][1]["forwarded_allow_ips"] == "*"
|
assert uvicorn_spy.calls[0][0].trusted == "*"
|
||||||
assert "FORWARDED_ALLOW_IPS=*" in capsys.readouterr().err
|
assert "FORWARDED_ALLOW_IPS=*" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
@@ -341,8 +344,10 @@ async def test_real_stdio_subprocess_serves_the_catalog(tmp_path: Path) -> None:
|
|||||||
payload = json.loads(result.content[0].text) # type: ignore[union-attr]
|
payload = json.loads(result.content[0].text) # type: ignore[union-attr]
|
||||||
assert "error" in payload and GITEA_TOKEN not in json.dumps(payload)
|
assert "error" in payload and GITEA_TOKEN not in json.dumps(payload)
|
||||||
audit = (tmp_path / "state" / "audit.log").read_text(encoding="utf-8").splitlines()
|
audit = (tmp_path / "state" / "audit.log").read_text(encoding="utf-8").splitlines()
|
||||||
assert len(audit) == 1
|
events = [json.loads(line)["event_type"] for line in audit]
|
||||||
assert GITEA_TOKEN not in audit[0]
|
# A write-ahead entry, then the one tool_invocation entry with the result.
|
||||||
|
assert events == ["tool_call_started", "tool_invocation"]
|
||||||
|
assert all(GITEA_TOKEN not in line for line in audit)
|
||||||
|
|
||||||
|
|
||||||
# serve command
|
# serve command
|
||||||
|
|||||||
Reference in new issue
Block a user