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
+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