chore: update configuration and Docker setup for JWT and test results
ci/woodpecker/push/build-arm Pipeline was successful

Enhanced the .gitignore to exclude test results and updated the Dockerfile to include a new entrypoint script for improved container initialization. Refactored JWT configuration to support additional parameters for automatic refresh intervals, ensuring better control over token management. Updated the ConfigurationResolver to enforce required environment variables without hardcoded fallbacks, enhancing security and flexibility.
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-15 03:23:23 +03:00
parent 7025f4d075
commit 78dea8ebab
40 changed files with 1990 additions and 510 deletions
+129 -22
View File
@@ -1,8 +1,8 @@
# Security Tests
> **Status**: produced by autodev `/test-spec` Phase 2 (2026-05-14).
> **Status**: produced by autodev `/test-spec` Phase 2 (2026-05-14), revised in cycle-update mode (drift findings Phase 2) for the ECDSA+JWKS JWT model.
> **Naming**: post-rename target. Security tests focus on the JWT bearer + Authz boundary defined in AC-5 and AC-9.
> **Out-of-scope (suite-tracked)**: the `iss` / `aud` validation gap (AC-5.3, CMMC L2 row 3, AZ-487 / AZ-494) is documented but NOT enforced today. Tests assert today's behaviour (AC-5.3 returns 200) — when the suite-wide remediation lands, update NFT-SEC-04.
> **Auth model**: ECDSA-SHA256 with JWKS retrieved from `admin` (mocked via `jwks-mock` in tests), iss + aud + alg-pin validated, 30s clock skew. The CMMC L2 row 3 (`iss`/`aud`) finding is now structurally fixed in code; the corresponding NFT-SEC scenarios now assert REJECTION, not acceptance.
---
@@ -26,51 +26,70 @@
### NFT-SEC-02: Invalid signature → 401
**Summary**: Verifies AC-5.5 — token signed with a different secret is rejected.
**Summary**: Verifies AC-5.5 — token whose ECDSA signature doesn't verify against any cached JWKS public key is rejected.
**Traces to**: AC-5.5
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | Mint token `T_bad` with `WRONG_SECRET=other-secret-32-chars-min!!!!!!`, otherwise valid (`exp = now + 1h`, `permissions=FL`) | |
| 1 | Acquire valid signed token `T_good` from `jwks-mock POST /sign`, then flip a single byte in the token's signature segment to produce `T_bad` | |
| 2 | `GET /vehicles` with `Authorization: Bearer T_bad` | `401` |
| 3 | Acquire token from a SEPARATE ECDSA keypair not present in the mock's published JWKS (via an out-of-band test helper) and call `GET /vehicles` | `401` |
**Pass criteria**: `401`.
**Pass criteria**: both bad-signature cases return `401`.
---
### NFT-SEC-03: Expired token outside skew → 401; inside skew → 200
**Summary**: Verifies AC-5.6 + AC-5.2 (1-min skew tighter than .NET's 5-min default).
**Summary**: Verifies AC-5.6 + AC-5.2 (**30s** clock skew, tighter than .NET's 5-min default and the legacy 1-min setting).
**Traces to**: AC-5.2, AC-5.6
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | Mint token `T_exp` with `exp = now - 120s` (outside 60s skew); `permissions=FL` | |
| 1 | Request token via `POST /sign { "exp_offset_seconds": -60 }` (exp = now - 60s; outside the 30s skew) | mock returns signed JWT |
| 2 | `GET /vehicles` with `Authorization: Bearer T_exp` | `401` |
| 3 | Mint token `T_skew` with `exp = now - 30s` (inside 60s skew); `permissions=FL` | |
| 3 | Request token via `POST /sign { "exp_offset_seconds": -15 }` (exp = now - 15s; inside the 30s skew) | mock returns signed JWT |
| 4 | `GET /vehicles` with `Authorization: Bearer T_skew` | `200` |
**Pass criteria**: `T_exp` rejected; `T_skew` accepted.
---
### NFT-SEC-04: Missing `iss` and `aud` claims accepted (today's behavior, AC-5.3)
### NFT-SEC-04: Token with `iss` ≠ `JWT_ISSUER` → 401
**Summary**: Verifies the `ValidateIssuer = false` and `ValidateAudience = false` configuration. This test will FAIL once the suite-wide remediation (AZ-487 / AZ-494) lands — that's good news; update the test then.
**Traces to**: AC-5.3
**Summary**: Verifies AC-5.11 — `ValidateIssuer = true` with `ValidIssuer = <resolved JWT_ISSUER>`. This is the structural fix for CMMC L2 row 3 row (issuer validation half).
**Traces to**: AC-5.3, AC-5.11
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | Mint token with NO `iss` and NO `aud` claim, valid signature + lifetime, `permissions=FL` | |
| 2 | `GET /vehicles` with that token | `200` |
| 1 | Request token via `POST /sign { "iss": "https://attacker.example.com" }` (every other claim valid) | mock returns signed JWT — the signature is correct, only `iss` is wrong |
| 2 | `GET /vehicles` with that token | `401` |
| 3 | Request token via `POST /sign { }` (iss defaults to the mock's `JWT_ISSUER` env, which matches `missions`'s configured `ValidIssuer`) | mock returns signed JWT |
| 4 | `GET /vehicles` with that token | `200` |
**Pass criteria**: `200` today; will become `401` post-remediation.
**Pass criteria**: wrong-`iss` rejected with 401; matching-`iss` accepted.
---
### NFT-SEC-04b: Token with `aud` ≠ `JWT_AUDIENCE` → 401
**Summary**: Verifies AC-5.12 — `ValidateAudience = true` with `ValidAudience = <resolved JWT_AUDIENCE>`. This is the structural fix for CMMC L2 row 3 (audience validation half).
**Traces to**: AC-5.3, AC-5.12
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | Request token via `POST /sign { "aud": "wrong-audience" }` (every other claim valid) | mock returns signed JWT |
| 2 | `GET /vehicles` with that token | `401` |
**Pass criteria**: wrong-`aud` rejected with 401.
---
@@ -90,23 +109,25 @@
---
### NFT-SEC-06: Wrong `permissions` claim value → 403
### NFT-SEC-06: Wrong `permissions` claim value → 403; multi-permission token accepted
**Summary**: Verifies AC-9.2 — the policy is exact-string match, hardcoded.
**Traces to**: AC-9.2
**Summary**: Verifies AC-9.1 + AC-9.2 — the policy is `RequireClaim("permissions", "FL")` (contains-match, not exact body-match). Wrong values → 403; a multi-permission token where one value equals `"FL"` → 200.
**Traces to**: AC-9.1, AC-9.2
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | Mint token with `permissions="ADMIN"`, valid otherwise | |
| 1 | Acquire token with `permissions="ADMIN"`, valid otherwise | mock returns signed JWT |
| 2 | `GET /vehicles` | `403` |
| 3 | Mint token with `permissions="fl"` (lowercase), valid otherwise | |
| 3 | Acquire token with `permissions="fl"` (lowercase) | mock returns signed JWT |
| 4 | `GET /vehicles` | `403` |
| 5 | Mint token with `permissions="FLight"`, valid otherwise | |
| 5 | Acquire token with `permissions="FLight"` | mock returns signed JWT |
| 6 | `GET /vehicles` | `403` |
| 7 | Acquire token with `permissions: ["FL", "ADMIN"]` (multi-permission array) | mock returns signed JWT |
| 8 | `GET /vehicles` | `200` (contains-match accepts `"FL"` among the values) |
**Pass criteria**: `403` for every wrong-value case.
**Pass criteria**: rows 2, 4, 6 → 403; row 8 → 200.
---
@@ -159,8 +180,94 @@
---
### NFT-SEC-10: Algorithm-pin defends against HS256-confusion → 401
**Summary**: Verifies AC-5.1 + AC-5.10 — `ValidAlgorithms = [SecurityAlgorithms.EcdsaSha256]` defends against the HS256-confusion attack where an attacker who learns a JWKS public key (which is, by definition, public) attempts to forge a token signed with that public key as the HMAC secret under `alg: HS256`.
**Traces to**: AC-5.1, AC-5.10
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | Acquire the published JWKS public key bytes from `GET https://jwks-mock:8443/.well-known/jwks.json` (or use the mock's helper that returns those bytes for the test) | — |
| 2 | Acquire token via `POST /sign { "alg_override": "HS256" }` — the mock signs the JWT body with the public-key bytes as the HMAC secret (mimicking an attack) | mock returns HS256-signed JWT |
| 3 | `GET /vehicles` with `Authorization: Bearer T_hs256` | `401` |
| 4 | Acquire token via `POST /sign { "alg_override": "none" }` (mock emits unsigned JWT) | mock returns unsigned JWT |
| 5 | `GET /vehicles` with that token | `401` |
**Pass criteria**: both HS256-confusion attack and unsigned token are rejected with `401`.
---
### NFT-SEC-11: Unknown `kid` (rotation lag) → 401 until JWKS refresh
**Summary**: Verifies AC-5.7 — a token signed with a key whose `kid` is not in the cached JWKS is rejected; once the JWKS refreshes and includes the new `kid`, the same `kid` becomes accepted.
**Traces to**: AC-5.7
**Preconditions**:
- `missions` has a warm JWKS cache (any previous protected request succeeded).
- `jwks-mock` `OldKeyGraceSeconds = 5`.
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | `POST https://jwks-mock:8443/rotate-key {}` | mock returns `{ "newKid": "new-kid" }` |
| 2 | Immediately request token via `POST /sign {}` (signs with NEW kid, before `missions` JWKS cache refreshes) | mock returns signed JWT with header `kid: new-kid` |
| 3 | `GET /vehicles` with that token | `401` (cached JWKS doesn't yet contain `new-kid`) |
| 4 | Wait for `missions`'s JWKS cache to refresh (≤ 90s; the mock sets `Cache-Control: max-age=60` so the refresh tick is at most ~60s) | — |
| 5 | `GET /vehicles` with the same token (still valid lifetime) | `200` |
| 6 | Request token signed with the PREVIOUS `kid` within the `OldKeyGraceSeconds=5` window | mock signs with the old key |
| 7 | `GET /vehicles` with that token | `200` (both keys are in the JWKS during grace) |
| 8 | Wait > 5s, then request token signed with the OLD `kid` — mock should refuse (key already evicted from its sign-pool) | mock returns 400/410 |
**Pass criteria**: rotation completes transparently within the cache-refresh window; tokens minted with the new `kid` are rejected during the lag, accepted after.
**Max execution time**: 120s.
---
### NFT-SEC-12: Missing `JWT_JWKS_URL`/`JWT_ISSUER`/`JWT_AUDIENCE` → startup throws
**Summary**: Verifies AC-6.1 / AC-6.2 — `Infrastructure/ConfigurationResolver.ResolveRequiredOrThrow` fail-fast for each of the four required env vars eliminates the legacy "silent dev fallback" failure mode.
**Traces to**: AC-6.1, AC-6.2, E1, E3
**Steps** (run as four separate `docker run` invocations):
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | `docker run` `missions` with `DATABASE_URL` unset and the three JWT vars set | container exits non-zero within 5s; logs contain `InvalidOperationException` mentioning `DATABASE_URL` (or `Database:Url`) |
| 2 | `docker run` with `JWT_ISSUER` unset | container exits non-zero; logs mention `JWT_ISSUER` (or `Jwt:Issuer`) |
| 3 | `docker run` with `JWT_AUDIENCE` unset | container exits non-zero; logs mention `JWT_AUDIENCE` (or `Jwt:Audience`) |
| 4 | `docker run` with `JWT_JWKS_URL` unset | container exits non-zero; logs mention `JWT_JWKS_URL` (or `Jwt:JwksUrl`) |
| 5 | `docker run` with `JWT_JWKS_URL=http://jwks-mock:8443/...` (HTTP not HTTPS) and the other three set | container STARTS (config resolution passes); first protected request returns 500 with a log line mentioning HTTPS / `RequireHttps` |
**Pass criteria**: rows 14 → process exits before HTTP server binds; row 5 → process starts but the first protected request fails at JWKS fetch time.
**Max execution time**: 60s (4 docker-run cycles).
---
### NFT-SEC-13: CORS Production-gate fail-fast (E9 lock test)
**Summary**: Verifies AC-6.11 — in `ASPNETCORE_ENVIRONMENT=Production` with empty `CorsConfig:AllowedOrigins` and `CorsConfig:AllowAnyOrigin != true`, `CorsConfigurationValidator.EnsureSafeForEnvironment` throws and the process exits non-zero.
**Traces to**: AC-6.11, E9
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | `docker run` `missions` with `ASPNETCORE_ENVIRONMENT=Production` and **no** `CorsConfig` env vars | container exits non-zero within 5s; logs contain `InvalidOperationException` mentioning `CorsConfig` / `AllowedOrigins` / Production |
| 2 | Same as 1 but with `CorsConfig__AllowAnyOrigin=true` set | container starts; logs contain a warning that CORS is permissive in Production (recommend listing explicit origins) but NO throw |
| 3 | Same as 1 but with `CorsConfig__AllowedOrigins__0=https://operator.example.com` set | container starts; `OPTIONS /vehicles` preflight from `https://operator.example.com` returns `200` with the corresponding `Access-Control-Allow-Origin` echo |
| 4 | Same as 3, preflight from `https://attacker.example.com` | preflight responds without the allow-origin echo (the policy refuses the disallowed origin) |
| 5 | `docker run` with `ASPNETCORE_ENVIRONMENT=Development` (or unset → defaults to `Production`-no, actually unset defaults to `Production` per ASP.NET Core; use `Test` here) and no `CorsConfig` | container starts; logs contain `PermissiveDefaultWarning` |
**Pass criteria**: row 1 → fail-fast; rows 24 → start with the expected CORS posture; row 5 → start with the documented permissive fallback + warning.
**Max execution time**: 90s.
---
## Notes
- Tests that drop tables (NFT-SEC-08) run in a per-class fixture that recreates the schema before subsequent tests.
- The CMMC L2 row 3 (`iss` / `aud`) gap is acknowledged but NOT remediated in this Epic; NFT-SEC-04 documents today's permissive behavior so a future enforcement change is detected.
- NFT-SEC-04 / NFT-SEC-04b / NFT-SEC-10 / NFT-SEC-11 / NFT-SEC-12 / NFT-SEC-13 are NEW scenarios added in the 2026-05-14 drift re-verification cycle. They replace / extend the old "permissive iss/aud" + "shared-secret rotation" + "dev-fallback footgun" assumptions of the pre-revision spec.
- No fuzz testing today (recommended follow-up under a separate refactor cycle).