Fix migration workflow: upload as artifact instead of scp to practice EC2

This commit is contained in:
2026-08-13 11:14:25 +07:00
parent 7ebbe1f309
commit a4819b8653
51 changed files with 6830 additions and 8 deletions
+196
View File
@@ -0,0 +1,196 @@
# 16 — Security
Findings from reading the code, not a penetration test. Each row states what
exists and what does not; nothing here should be read as an assurance.
## Summary
| Control | State |
|---|---|
| Authentication | **Not implemented** — no login, no token, anywhere |
| Authorization | **Not implemented** — no roles, no per-user scoping |
| TLS in transit (public edge) | **Implemented** — Caddy with automatic ACME |
| TLS inside the Compose network | **Not implemented** — plain HTTP between containers |
| Input validation | **Partially implemented** |
| Prompt-injection handling | **Implemented** (input fencing + output verification) |
| Rate limiting | **Partially implemented** — frontend only, in-memory, two routes |
| Secrets management | **Partially implemented** — IAM role for AWS; a default DB credential is committed |
| Metrics endpoint auth | **Configured but unset** |
| Container hardening | **Not implemented** — root user, no read-only FS, no capability drop |
| K8s security context / NetworkPolicy | **Not found** in the Helm chart |
| Data retention / redaction | **Not implemented** |
| Audit logging | **Partially implemented** — every answer is traced; no auth identity to attach |
| Dependency scanning | **Not found** — no Dependabot, no `pip-audit`, no `npm audit` in CI |
## Authentication and authorization
There is none. `POST /api/chat` accepts an unauthenticated request from anyone
who can reach `https://realvuxbaro.me`, and each turn spends AWS Bedrock credit
on a personal account. `middleware.ts` states this plainly:
> It is a cost and abuse guard, not a security control. It does not
> authenticate anyone and must not be described as if it does.
`apps/auth-service` and `apps/user-service` contain no source. The pre-existing
`docs/architecture.md` assigns JWT validation to `api-gateway`, which does not
exist.
### Conversation isolation
`conversation_id` is an arbitrary client-supplied string, at most 128
characters, with no ownership check. `PostgresConversationStore.recent()`
returns the last lines for **whatever id is sent**, and those lines are placed
into the understanding prompt. Anyone who knows or guesses another session's id
can read its conversation history into their own turn's model context. There is
no entropy requirement on the id.
## Transport
- Caddy terminates TLS for `realvuxbaro.me` with automatic certificates and
proxies `/grafana/*``grafana:3000` and everything else → `web:3000`.
- `ai-service`, `postgres`, `qdrant`, `prometheus`, `tempo` and
`otel-collector` publish **no host ports** in the production files;
Prometheus and Grafana bind to `127.0.0.1` only in the observability overlay.
Reaching `ai-service` therefore requires being on the Compose network.
- No HSTS, CSP, `X-Frame-Options` or other security headers are set — the
`Caddyfile` has no `header` directive and `next.config.js` defines no
`headers()`.
- **CORS is not configured** on the FastAPI app: no `CORSMiddleware` is added,
so the browser's default same-origin policy is what protects it. That is
adequate only because the browser never talks to `ai-service` directly.
## Input validation
| Surface | Validation |
|---|---|
| `POST /v1/rag/query` | Pydantic: `query` 14000 chars, `conversation_id` ≤128, `subject_scope`/`intent` enum-constrained |
| `POST /v1/rag/feedback` | `trace_id` must parse as a UUID, `rating` literal-constrained, `comment` ≤2000 |
| `GET /v1/rag/suggest` | **`q` is a bare string with no max length** |
| `POST /api/chat` (web) | `content` non-empty and ≤4000, `conversationId` ≤128 |
| `X-Correlation-ID` | Regex-validated, regenerated when malformed — on both sides |
| Model output | `_parse_claims`, `_sanitize_quick_replies`, `_clean_enum`, `_clean_float` (0 < kg ≤ 500) — every field validated, fail-closed |
SQL access uses parameterised `psycopg` queries throughout; no string
interpolation into SQL was found.
## Prompt injection
Two layers, both described in [11](11-generation-and-grounding.md):
- **Input** — the user's text is fenced in markers stripped from the input
first, and all three system prompts carry `_UNTRUSTED_RULE` telling the model
the fenced text is data.
- **Output** — a fabricated figure cannot survive `grounding.verify`, citations
are assembled from retrieved metadata rather than from model prose, and a
claim the entailment judge does not confirm is discarded.
Tested by `tests/test_prompt_untrusted_input.py`.
Residual exposure: the understanding prompt embeds raw conversation history, and
the `drug_id` labels in `_prompt_evidence_texts` come from corpus payloads
(trusted). A user cannot inject into the evidence section.
## Rate limiting
`apps/web/middleware.ts`, in-process, keyed by the left-most `X-Forwarded-For`
entry:
| Route prefix | Rules |
|---|---|
| `/api/chat` | 12 per minute **and** 120 per hour |
| `/api/suggest` | 120 per minute |
| everything else under `/api/*` | **no limit** — including `/api/pdf` (37 MB per request) and `/api/feedback` |
Stated limitations, from the source comments: counters are per process (a second
`web` replica doubles the allowance), the key is an IP so a shared NAT is
throttled as one caller, and the correct home is Redis or the unbuilt gateway.
A rejected request is deliberately not recorded, so a hammering client cannot
extend its own lockout.
An unknown IP falls back to the shared key `"unknown"` rather than to
unlimited — the comment notes that mattering.
## Secrets
See the inventory in [15-configuration.md](15-configuration.md#secrets-inventory).
The concrete issue: **PostgreSQL credentials `duoc_thu` / `duoc_thu` are
committed** in `infra/docker/docker-compose.prod.yml` (as
`POSTGRES_USER`/`POSTGRES_PASSWORD`) and as the Helm default
`secret.postgresPassword`. Exposure today is bounded because PostgreSQL
publishes no host port in production, so the credential is only usable from
inside the Compose network — but it is a default credential in version control,
and the Helm path would carry it into a cluster where the blast radius is larger.
`infra/helm/.../values.yaml` also ships `grafanaAdminPassword: change-me`. The
deploy workflow requires a real `GRAFANA_ADMIN_PASSWORD` and fails fast if it is
empty (`test -n "${GRAFANA_ADMIN_PASSWORD:-}"`).
AWS access is via the EC2 instance role — no keys in any file. The two policy
documents under `infra/aws/iam/` scope Bedrock invocation.
## Metrics endpoint
`GET /metrics` supports an optional bearer token compared with
`hmac.compare_digest` (constant time — a `==` on a shared secret leaks its
prefix through timing). `METRICS_TOKEN` defaults to empty, i.e. **no auth**.
`main.py` explains the trade: the endpoint is unreachable from the internet
today because Caddy proxies only `web` and `ai-service` publishes no host port,
and it "stops being safe the moment the service is exposed through an Ingress,
which the Helm chart now makes possible". Metrics carry query volumes, provider
failure counts and abstain reasons.
## Grafana exposure
Grafana **is** internet-reachable at `https://realvuxbaro.me/grafana/`. The
overlay sets `GF_AUTH_ANONYMOUS_ENABLED=false` and a real admin password from
the environment, with `GF_SERVER_ROOT_URL` and `GF_SERVER_SERVE_FROM_SUB_PATH`
for the subpath. The local-dev Compose file enables anonymous admin access, with
a comment forbidding carrying that into a deployed stack.
## Container and cluster hardening
`apps/ai-service/Dockerfile`:
- runs as **root** (no `USER` directive);
- installs `gcc` into the runtime image rather than using a build stage;
- pins dependency ranges inline instead of installing from `pyproject.toml`, so
the image's dependency set can drift from the project's;
- has no `HEALTHCHECK`.
`apps/web/Dockerfile` runs as root and copies the entire `/repo` (source and
`node_modules`) into the runtime stage rather than using Next's standalone
output.
In `infra/helm/medical-chatbot/`: no `securityContext`, no
`runAsNonRoot`, no `readOnlyRootFilesystem`, no `NetworkPolicy`, no
`PodDisruptionBudget`. A `ServiceAccount` is created but no RBAC is bound to it.
Probes are configured (`/ready`, `/health`, plus a startup probe).
## Data privacy
The product invites clinicians to type patient context — age, weight,
comorbidities, allergies, previous ADRs, current medications, eGFR/CrCl/CKD
stage, Child-Pugh, pregnancy status, lab values (`rag/clinical.py`).
Consequences, all currently unaddressed:
- `rag_retrieval_trace.query_text` and `rag_conversation_turn.line` store that
text verbatim, forever. No retention, no deletion path, no redaction.
- The same text is sent to AWS Bedrock on every turn.
- `agent.py` logs turn timings at WARNING level; `understanding.py` logs the
model's raw output on a parse failure (`logger.warning("… returned
unparseable JSON: %r", raw_text)`) and `answer.py` logs claims and repair
verdicts — so fragments of user and model text can reach container logs.
- There is no consent flow, no DPA, no anonymisation, and no access control on
the database.
## Dependency and supply-chain risk
- No `Dependabot`, no `pip-audit`, no `npm audit`, no SBOM, no image scanning
anywhere in `.github/`.
- `pnpm-lock.yaml` is committed; there is **no** Python lockfile — the
Dockerfile installs unpinned ranges (`"fastapi>=0.115,<1"`, `"boto3"` with no
bound at all), so two builds of the same commit can differ.
- `qdrant/qdrant:latest` is unpinned.
- CI runs no tests before deploying (see [22-ci-cd.md](22-ci-cd.md)).