docs: record S2 results, mark findings fixed or waived, update the security model
This commit is contained in:
1 parent
d7cac91175
commit
f86b9b529c
2 files changed
+89
-40
No files matched your search
+53
-23
@@ -12,27 +12,34 @@ it.
|
||||
|
||||
## Summary
|
||||
|
||||
| Id | Severity | Title |
|
||||
|---|---|---|
|
||||
| SEC-01 | high | Unbounded request body on the unauthenticated consent POST |
|
||||
| SEC-02 | medium | Stored webhook `authorization_header` returned by hook read tools |
|
||||
| SEC-03 | medium | Audit write failures after startup do not stop later calls |
|
||||
| SEC-04 | medium | Settings read a `.env` file from the process working directory |
|
||||
| SEC-05 | medium | Any https redirect URI can be registered; the consent page is the only barrier |
|
||||
| SEC-06 | medium | Fork pull requests run Docker-capable jobs on the shared runner |
|
||||
| SEC-07 | medium | CI actions pinned by mutable tags, one of them receives the registry token |
|
||||
| SEC-08 | low | Rate limiting gaps (`/token`, `/revoke`, IPv6, proxy address) |
|
||||
| SEC-09 | low | OAuth pre-sign-in state can be flushed by unauthenticated floods |
|
||||
| SEC-10 | low | `GITEA_URL` and `PUBLIC_BASE_URL` accept whitespace and control characters; plain http Gitea is silent |
|
||||
| SEC-11 | low | Audit log created with umask defaults; unkeyed hash chain |
|
||||
| SEC-12 | low | Tool audit entries do not record the client, session or source IP |
|
||||
| SEC-13 | low | Token store directory ownership is not verified |
|
||||
| SEC-14 | low | CI cleanup and credential handling on the shared Docker daemon |
|
||||
| SEC-15 | low | `tests/security` missing, so the CI `security` job fails; publish is not gated on it |
|
||||
| SEC-16 | low | No container image vulnerability scan |
|
||||
| SEC-17 | low | Consent POST accepts a missing `Origin` header, unlike ADR 0004 says |
|
||||
| SEC-18 | low | Unvalidated argument names and unknown tool names reach the audit log |
|
||||
| SEC-19 | low | One fixed cookie name for concurrent sign-ins in the same browser |
|
||||
| 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
|
||||
@@ -46,6 +53,7 @@ the Dockerfile (pinned digests, non-root, no secrets in layers).
|
||||
|
||||
### 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
|
||||
@@ -69,6 +77,7 @@ the Dockerfile (pinned digests, non-root, no secrets in layers).
|
||||
|
||||
### 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`).
|
||||
@@ -90,6 +99,7 @@ the Dockerfile (pinned digests, non-root, no secrets in layers).
|
||||
|
||||
### 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`.
|
||||
@@ -109,6 +119,7 @@ the Dockerfile (pinned digests, non-root, no secrets in layers).
|
||||
|
||||
### 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
|
||||
@@ -129,6 +140,7 @@ the Dockerfile (pinned digests, non-root, no secrets in layers).
|
||||
|
||||
### 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
|
||||
@@ -149,6 +161,7 @@ the Dockerfile (pinned digests, non-root, no secrets in layers).
|
||||
|
||||
### 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
|
||||
@@ -164,6 +177,7 @@ the Dockerfile (pinned digests, non-root, no secrets in layers).
|
||||
|
||||
### 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`).
|
||||
@@ -180,6 +194,7 @@ the Dockerfile (pinned digests, non-root, no secrets in layers).
|
||||
|
||||
### 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`.
|
||||
@@ -197,6 +212,7 @@ the Dockerfile (pinned digests, non-root, no secrets in layers).
|
||||
|
||||
### 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).
|
||||
@@ -213,6 +229,7 @@ the Dockerfile (pinned digests, non-root, no secrets in layers).
|
||||
|
||||
### 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
|
||||
@@ -230,6 +247,7 @@ the Dockerfile (pinned digests, non-root, no secrets in layers).
|
||||
|
||||
### 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
|
||||
@@ -244,6 +262,7 @@ the Dockerfile (pinned digests, non-root, no secrets in layers).
|
||||
|
||||
### 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
|
||||
@@ -255,6 +274,7 @@ the Dockerfile (pinned digests, non-root, no secrets in layers).
|
||||
|
||||
### 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
|
||||
@@ -267,6 +287,7 @@ the Dockerfile (pinned digests, non-root, no secrets in layers).
|
||||
|
||||
### 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,
|
||||
@@ -283,6 +304,7 @@ the Dockerfile (pinned digests, non-root, no secrets in layers).
|
||||
|
||||
### 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`
|
||||
@@ -295,6 +317,7 @@ the Dockerfile (pinned digests, non-root, no secrets in layers).
|
||||
|
||||
### 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.
|
||||
@@ -304,6 +327,7 @@ the Dockerfile (pinned digests, non-root, no secrets in layers).
|
||||
|
||||
### 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
|
||||
@@ -315,6 +339,7 @@ the Dockerfile (pinned digests, non-root, no secrets in layers).
|
||||
|
||||
### 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
|
||||
@@ -329,6 +354,7 @@ the Dockerfile (pinned digests, non-root, no secrets in layers).
|
||||
|
||||
### 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
|
||||
@@ -339,7 +365,10 @@ the Dockerfile (pinned digests, non-root, no secrets in layers).
|
||||
- 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 must add to `tests/security/`
|
||||
## 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
|
||||
@@ -415,7 +444,8 @@ in `tests/test_oauth_helpers.py` and `tests/test_executor_helpers.py`.
|
||||
|
||||
## B5 checker verdict
|
||||
|
||||
Verdict: CHANGES REQUIRED.
|
||||
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:
|
||||
|
||||
|
||||
+36
-17
@@ -7,7 +7,8 @@ server does not decide which operations are safe. It guarantees a small set of i
|
||||
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).
|
||||
[security-findings.md](security-findings.md) (all 19 findings of the S2 review are fixed or
|
||||
waived).
|
||||
|
||||
## Threat model
|
||||
|
||||
@@ -71,7 +72,7 @@ Tampering
|
||||
| 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` (rollback residual: SEC-13) |
|
||||
| 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` |
|
||||
|
||||
Repudiation
|
||||
@@ -79,7 +80,7 @@ Repudiation
|
||||
| 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: SEC-11) |
|
||||
| Undetected edits of the log | SHA-256 hash chain, `scripts/validate_audit_log.py` | `audit.py` (unkeyed chain: waiver W-11) |
|
||||
|
||||
Information disclosure
|
||||
|
||||
@@ -87,7 +88,7 @@ Information disclosure
|
||||
|---|---|---|
|
||||
| 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` |
|
||||
| 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` |
|
||||
| Secret values echoed back | Submitted `password`, `secret`, `token` style fields and Action secret `data` redacted from the result | `gitea/encoding.py` (`sensitive_body_values`) (stored webhook auth headers on read: SEC-02) |
|
||||
| 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` |
|
||||
| Leaks across identities | No cookie jar, no redirects followed, a fresh upstream client per OAuth exchange | `gitea/executor.py`, `oauth/upstream.py` |
|
||||
| Stack traces | `EXPOSE_ERROR_DETAILS=false` by default; even when on, only the exception class name | `server.py`, `gitea/executor.py` |
|
||||
|
||||
@@ -95,7 +96,7 @@ Denial of service
|
||||
|
||||
| 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` (consent route: SEC-01) |
|
||||
| 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` |
|
||||
@@ -108,7 +109,7 @@ Elevation of privilege
|
||||
| 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 PRs: SEC-06) |
|
||||
| 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 |
|
||||
|
||||
### Residual risks and owner decisions
|
||||
|
||||
@@ -160,10 +161,18 @@ confirmation, `READ_ONLY`, `TOOLSETS`, `DISABLED_TOOLS` and narrow token scopes
|
||||
- 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, and a submitted value
|
||||
(`gitea_actions_update_repo_secret` and siblings) is redacted from the result.
|
||||
- Known gap: a webhook's stored `authorization_header` is returned by hook read tools such as
|
||||
`gitea_hook_repo_list_hooks` (SEC-02).
|
||||
`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
|
||||
|
||||
@@ -211,20 +220,29 @@ tokens 30 days with rotation. Revocation ends the whole grant.
|
||||
- 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.
|
||||
- Gaps: CI actions are pinned by tag, not commit SHA (SEC-07); no container image scan
|
||||
(SEC-16).
|
||||
- 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 against commit `ce5ae6c` (branch `feat/full-api-coverage`).
|
||||
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` | Pending: written in S2 (`tests/security/` does not exist yet, so the CI `security` job currently fails, SEC-15). Required tests are listed in [security-findings.md](security-findings.md). |
|
||||
| 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. 131 commits in history (103 non-merge); gitleaks scanned 109 commits with content, about 6 MB. |
|
||||
| Unit suite (context) | `uv run pytest` | 846 passed, 23 skipped (live Gitea and Docker tests), coverage 98.2 percent. |
|
||||
| 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
|
||||
|
||||
@@ -235,7 +253,7 @@ changeset.
|
||||
|---|---|---|---|
|
||||
| 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` lines 357 and 822 | Type narrowing after an explicit check; behaviour does not depend on the assert. |
|
||||
| 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. |
|
||||
@@ -243,3 +261,4 @@ changeset.
|
||||
| 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. |
|
||||
Reference in new issue
Block a user