Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions docs/security/regression-suite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Security regression suite

Status: executable evidence for issue #28 at the current `main` contract. This
report covers repository components that exist. It does not qualify future
production storage, queue, worker, provider-adapter, telemetry-export, or
support-bundle implementations.

## Public-route security matrix

[`TestPublicRouteSecurityMatrix`](../../test/security/security_regression_test.go)
derives the complete operation set from the validated OpenAPI artifact. The
test fails if a public operation is added without a matrix row. Its checked-in
result is
[`security-regression-v1alpha1.golden.json`](../../test/security/testdata/security-regression-v1alpha1.golden.json).

| Operation | Authenticated member | Authenticated outsider | Missing or invalid credential |
| --- | --- | --- | --- |
| `listWorkspaces` | `200`, authorized row returned | `200`, denied rows removed before pagination | `401` |
| `createWorkspace` | `403`, service-reserved tenant action | `403`, service-reserved tenant action | `401` |
| `getWorkspace` | `200` | `403` | `401` |
| `replaceWorkspace` | `202` | `403` | `401` |
| `deleteWorkspace` | Authorization passes; `409` protects the retained Policy child | `403` | `401` |
| `replaceWorkspaceStatus` | `403`, service-reserved tenant action | `403`, service-reserved tenant action | `401` |
| `getOperation` | `200` | `403` | `401` |

Workspace creation and status replacement intentionally have no tenant allow
case. The closed authorization matrix reserves both actions. Treating a `2xx`
response as required evidence would weaken the security contract. Their
positive path proves that a valid credential reaches the authorization layer;
their invariant is a tenant `403` for every role.

## Retained untrusted-input and fuzz corpus

The following fuzz targets run their seed corpus during ordinary `go test` and
remain available for time-bounded fuzzing:

| Boundary | Target | Retained input classes |
| --- | --- | --- |
| Public HTTP routes | `FuzzReferencePublicBoundary` | alternate token carrier, encoded path, duplicate JSON member, unknown credential-like field, trailing JSON, invalid UTF-8, depth bomb, webhook/remote-reference shape, oversized body, invalid credential |
| Bearer extraction | `FuzzExtractBearer`, `FuzzCredentialTrailerScrubbing` | header/query/cookie/trailer ambiguity, divergent query views, body and trailer aliasing, arbitrary casing |
| OIDC/JWKS | deterministic negative corpus in `internal/adapters/identity/oidc` | non-canonical JWT segments, duplicate members, forbidden remote key headers, claim failures, redirect/cookie response state, malformed and over-bound keys |
| Resource admission | `FuzzAdmitRaw` | every resource kind, duplicate and unknown fields, invalid Unicode, depth/node/byte bounds, trailing values |
| Closed domain values | fuzz targets under `internal/core/domain` | invalid sum types, identifiers, hierarchy, role/action vocabulary, operations, conditions, audit canonicalization, credentials, and reconciliation digests |

The public-boundary target asserts panic freedom, bounded JSON responses,
mandatory no-store/nosniff/correlation headers, and bearer-canary absence. The
OpenAPI validator independently rejects remote servers, remote references, and
webhook expansion. No provider response parser exists yet; provider-specific
malicious-response corpora belong with the adapters introduced by issues #38
and #41.

Run the public-boundary fuzzer for one minute with:

```sh
go test ./test/security -run '^$' -fuzz '^FuzzReferencePublicBoundary$' -fuzztime=1m
```

## Redaction report

| Surface named by issue #28 | Implemented evidence | Result |
| --- | --- | --- |
| Plans and reconciliation state | `TestOpaqueValuesRejectGenericSerializationAndRedactDiagnostics` covers plans, evidence, effects, attempts, leases, queue/idempotency state, proofs, and `slog` output | Secret and identity canaries absent; generic serialization rejected |
| Credential state | `TestCredentialValuesRedactAndRejectSerialization`, `FuzzSourceMaterialSafety`, and broker lease/lifecycle tests cover source/session material, requests, brokers, leases, rotation, errors, and destruction | Raw material remains callback-bounded; diagnostics redact; serialization rejected |
| Identity and bearer state | Identity diagnostic/serialization tests, bearer canary tests, OIDC negative corpus, and the route matrix cover principals, request carriers, challenges, problems, and headers | Raw token and identity claims absent from output; rejected request carriers scrubbed |
| Audit and privileged state | `TestOpaqueRuntimeValuesForbidLossyGenericSerialization` and `TestAdministrationDiagnosticsAndSerializationAreSafe` cover audit/elevation values, nested containers, errors, formatting, and `slog` | Canaries absent; opaque runtime values reject lossy serialization |
| HTTP errors | Route matrix and `FuzzReferencePublicBoundary` exercise every public route plus malformed/bounded input | Only closed problem codes and bounded field paths are returned; bearer canaries absent |
| Logs | Package redaction tests send every sensitive value through `fmt` and `slog` | Canaries absent from implemented logging surfaces |
| Traces | No tracer or trace exporter exists | Not applicable at this revision; issue #63 must add canaries with the first implementation |
| Metrics | No metrics registry or exporter exists | Not applicable at this revision; issue #63 must add label/value canaries and cardinality bounds |
| Support output | No support-bundle or diagnostic export exists | Not applicable at this revision; the implementing issue must add a closed allowlist and canary corpus |

This report proves current executable behavior only. A future component does
not inherit an “issue #28 passed” claim: its pull request must extend this
matrix, fuzz corpus, and redaction table before merge.

## Verification

```sh
go test ./test/security -count=1
go test ./internal/transport/http ./internal/adapters/identity/oidc \
./internal/core/domain/admission ./internal/core/domain/credential \
./internal/core/service/credentialbroker ./internal/core/domain/reconciliation \
./internal/core/domain/audit ./internal/core/domain/administration -count=1
./hack/dev security
./hack/dev check
./hack/dev race
```

The OAuth token restrictions are cross-checked against
[RFC 9700](https://www.rfc-editor.org/rfc/rfc9700.html), and the retained
regression practice follows the verification intent of
[NIST SP 800-218 SSDF](https://csrc.nist.gov/pubs/sp/800/218/final).
80 changes: 80 additions & 0 deletions test/security/security_fuzz_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package security_test

import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/ArdurAI/veer/internal/core/domain/resource"
"github.com/ArdurAI/veer/internal/core/service/reference"
)

func FuzzReferencePublicBoundary(f *testing.F) {
seeds := []struct {
method, target, authorization, contentType, idempotencyKey, ifMatch, requestID string
body []byte
}{
{http.MethodGet, "/api/v1alpha1/workspaces", "Bearer " + securityBearerCanary, "", "", "", "req-fuzz-list", nil},
{http.MethodGet, "/api/v1alpha1/workspaces?access_token=" + invalidBearerCanary, "Bearer " + securityBearerCanary, "", "", "", "req-fuzz-query-token", nil},
{http.MethodGet, "/api/v1alpha1/workspaces/%2e%2e", "Bearer " + securityBearerCanary, "", "", "", "req-fuzz-encoded-path", nil},
{http.MethodPost, "/api/v1alpha1/workspaces", "Bearer " + securityBearerCanary, "application/json", "fuzz-duplicate-0001", "", "req-fuzz-duplicate", []byte(`{"apiVersion":"v1alpha1","apiVersion":"v2","kind":"Workspace","metadata":{"displayName":"x"},"spec":{}}`)},
{http.MethodPost, "/api/v1alpha1/workspaces", "Bearer " + securityBearerCanary, "application/json", "fuzz-unknown-0001", "", "req-fuzz-unknown", []byte(`{"apiVersion":"v1alpha1","kind":"Workspace","metadata":{"displayName":"x"},"spec":{"credential":"` + invalidBearerCanary + `"}}`)},
{http.MethodPost, "/api/v1alpha1/workspaces", "Bearer " + securityBearerCanary, "application/json", "fuzz-trailing-0001", "", "req-fuzz-trailing", []byte(`{} {}`)},
{http.MethodPost, "/api/v1alpha1/workspaces", "Bearer " + securityBearerCanary, "application/json", "fuzz-unicode-0001", "", "req-fuzz-unicode", []byte{'{', '"', 0xff, '"', ':', '0', '}'}},
{http.MethodPost, "/api/v1alpha1/workspaces", "Bearer " + securityBearerCanary, "application/json", "fuzz-depth-0000001", "", "req-fuzz-depth", []byte(strings.Repeat("[", 65) + "0" + strings.Repeat("]", 65))},
Comment thread
gnanirahulnutakki marked this conversation as resolved.
Resolved
{http.MethodPost, "/api/v1alpha1/workspaces", "Bearer " + securityBearerCanary, "application/json", "fuzz-webhook-0001", "", "req-fuzz-webhook", []byte(`{"webhooks":{"escape":{"post":{"requestBody":{"$ref":"https://attacker.invalid/schema"}}}}}`)},
{http.MethodPut, "/api/v1alpha1/workspaces/wsp_0000000000000001/status", "Bearer " + securityBearerCanary, "application/json", "fuzz-status-00001", `"rv_0000000000000001"`, "req-fuzz-status", bytes.Repeat([]byte{'x'}, resource.MaxCanonicalBytes+1)},
{http.MethodDelete, "/api/v1alpha1/workspaces/wsp_0000000000000001", "Bearer " + invalidBearerCanary, "", "fuzz-delete-00001", `"rv_0000000000000001"`, "req-fuzz-delete", nil},
}
for _, seed := range seeds {
f.Add(seed.method, seed.target, seed.authorization, seed.contentType, seed.idempotencyKey, seed.ifMatch, seed.requestID, seed.body)
}

f.Fuzz(func(
t *testing.T,
method, target, authorization, contentType, idempotencyKey, ifMatch, requestID string,
body []byte,
) {
if len(method) > 32 || len(target) > 4*1024 || len(authorization) > 16*1024 ||
len(contentType) > 512 || len(idempotencyKey) > 2*1024 || len(ifMatch) > 512 ||
len(requestID) > 512 || len(body) > 2*resource.MaxCanonicalBytes {
t.Skip()
}
fixture := newSecurityFixture(t)
request, err := http.NewRequestWithContext(context.Background(), method, target, bytes.NewReader(body))
if err != nil {
return
}
request.RequestURI = target
for name, value := range map[string]string{
"Authorization": authorization,
"Content-Type": contentType,
"Idempotency-Key": idempotencyKey,
"If-Match": ifMatch,
"Veer-Request-Id": requestID,
} {
if value != "" {
request.Header.Set(name, value)
}
}
response := httptest.NewRecorder()
fixture.memberHandler.ServeHTTP(response, request)

if response.Code < 100 || response.Code > 599 {
t.Fatalf("invalid HTTP status %d", response.Code)
}
if response.Header().Get("Cache-Control") != "no-store" ||
response.Header().Get("X-Content-Type-Options") != "nosniff" ||
response.Header().Get("Veer-Request-Id") == "" {
t.Fatalf("security headers = %#v", response.Header())
Comment thread
gnanirahulnutakki marked this conversation as resolved.
Resolved
}
if response.Body.Len() > reference.MaxPageBytes || !json.Valid(response.Body.Bytes()) {
t.Fatalf("response is unbounded or invalid JSON: bytes=%d body=%q", response.Body.Len(), response.Body.Bytes())
Comment thread
gnanirahulnutakki marked this conversation as resolved.
Resolved
}
assertNoBearerCanary(t, response)
Comment thread
gnanirahulnutakki marked this conversation as resolved.
Resolved
})
}
Loading