Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
6 changes: 3 additions & 3 deletions .github/workflows/security-release-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 120
env:
CANDIDATE_VERSION: 0.38.6
CANDIDATE_VERSION: 0.38.7
DAPPER_IMAGE: pasturestack-host-api-dapper:security-gate
TRIVY_IMAGE: ghcr.io/aquasecurity/trivy:0.73.0@sha256:7cced7cae583819fc7806d4cbc0dbbc7cad18b99f7d3e235192e6da8c091045c
COSIGN_IMAGE: ghcr.io/sigstore/cosign/cosign:v3.1.3@sha256:9e5c2f2edc34351160407ca3416c61855bdf9403c3c5936e0f0be7fc261611b8
Expand All @@ -36,8 +36,8 @@ jobs:
test -z "$(git status --porcelain)"
test "$(git rev-parse HEAD)" = "$GITHUB_SHA"
# Exactly one reviewed maintenance commit after the existing published main.
test "$(git rev-list --count b124a9cce56cc3e1082d2f72434592bccb15f6ef..HEAD)" = 1
test "$(git rev-list --count --merges b124a9cce56cc3e1082d2f72434592bccb15f6ef..HEAD)" = 0
test "$(git rev-list --count 67d1e58d1999730f106ba76c3ee3cf03bbf495d6..HEAD)" = 1
test "$(git rev-list --count --merges 67d1e58d1999730f106ba76c3ee3cf03bbf495d6..HEAD)" = 0
private_pattern=$(printf '%s|%s|%s' 'chen''21019' '10\.0\.0\.''125' 'C:\\''Users\\')
if git grep -n -E "$private_pattern" -- .; then
echo 'Private operator data is present in the public source tree.' >&2
Expand Down
4 changes: 3 additions & 1 deletion COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ Preferred settings use `platform-*` names. Historical `cattle-*` flags, legacy D

Operator lifecycle messages support `en-US` and `zh-TW`. HTTP response bodies, log streams, statistics payloads, Docker output, identifiers, and protocol errors are not translated.

The source package version is `0.38.6`, built with Go 1.27.3 on a digest-pinned Ubuntu 26.04 image, a frozen bootstrap package snapshot, and checksum-pinned official security fixes. The final builder inventory records installed versions separately from the bootstrap lock. A published package must retain its reviewed producer revision, byte-identical packaging evidence, and successful security checks of the unpacked binary. Server packaging consumes `host-api-0.38.6.tar.gz`; its filename, executable layout, architecture, legacy SHA-1 compatibility files, and SHA-256 integrity files are release contracts. Product versions must remain pure numeric `MAJOR.MINOR.PATCH` values without branding or maintenance suffixes.
The source package version is `0.38.7`, built with Go 1.27.3 on a digest-pinned Ubuntu 26.04 image, a frozen bootstrap package snapshot, and checksum-pinned official security fixes. The final builder inventory records installed versions separately from the bootstrap lock. A published package must retain its reviewed producer revision, byte-identical packaging evidence, and successful security checks of the unpacked binary. Server packaging consumes `host-api-0.38.7.tar.gz`; its filename, executable layout, architecture, legacy SHA-1 compatibility files, and SHA-256 integrity files are release contracts. Product versions must remain pure numeric `MAJOR.MINOR.PATCH` values without branding or maintenance suffixes.

The `key-audit-v1,key-delegation-v1` backend protocol and endpoint contracts remain unchanged. All non-allowed introspection results still close scoped streams. Confirmed authorization-loss evidence requires either expiry of the previously verified signed grant, or a configured-Engine 401/403 response carrying an allowlisted stable authorization code. Network failures, redirects, outages, malformed or mismatched responses are unknown, never confirmed revocation. Only authorized scoped transport cancellations receive a final recheck, inside the existing seven-second total report budget and without holding the audit mutex. A confirmed denial outranks a later disconnect or cancellation-induced Docker error; a completed success or genuine backend failure and the frozen first terminal record cannot be overwritten. Ordinary JWTs, Full/legacy traces, handshake admission, durable retry and ticket-bound agent/target/Host identities retain their contracts. No publication or native PASS is implied by the source version.

Before release, validate token acquisition, event forwarding, logs, stats, exec, console, Docker socket proxy, container proxy, disabled-proxy behavior, key parsing, and restart behavior.

Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ PastureStack is an independent community effort to preserve, audit, and moderniz

## Capabilities

The source package version is `0.38.6`, built with Go 1.27.3. Use a published release only after its source, reproducibility, vulnerability, and packaged-binary checks pass; a source version is not publication evidence. Target-bound API Key tickets require matching Engine introspection at handshake and during logs, exec, and statistics streams. Revocation or expiry closes the stream; it does not stop its container or kill an already started exec process. Legacy and full-without-expiry tokens retain their authentication contract.
The source package version is `0.38.7`, built with Go 1.27.3. Use a published release only after its source, reproducibility, vulnerability, and packaged-binary checks pass; a source version is not publication evidence. Target-bound API Key tickets require matching Engine introspection at handshake and during logs, exec, and statistics streams. Revocation or expiry closes the stream; it does not stop its container or kill an already started exec process. Legacy and full-without-expiry tokens retain their authentication contract.

The authenticated backend connection declares the exact `key-audit-v1,key-delegation-v1` capabilities. A compatible Proxy requires that declaration for every verified API Key stream, including full keys, so an older embedded backend cannot bypass terminal audit. Ordinary non-Key streams retain their existing backend compatibility.

Terminal evidence is sent with the host agent's credential. A private persistent spool reserves a PENDING record before execution for every verified API Key trace, preserves the first terminal result, and retries only evidence after outages or process restarts. Interrupted PENDING streams become `CANCELLED`, never a fabricated success. Original API Key and agent secrets are not persisted. Full keys retain their permission payload and expiry contract, but do not bypass audit durability; unavailable admission closes with retryable `AuditUnavailable` (503 semantics), not a permission denial. Ordinary non-Key tokens have no new audit-admission requirement.

For an authorized scoped stream, terminal classification preserves confirmed authorization loss even when a later transport close arrives. A transport cancellation receives one bounded recheck of the original signed grant before the terminal record is frozen; an unavailable or unverifiable check remains `StreamCancelled`, not confirmed revocation. This records authorization at finalization, not proof of which peer first closed the connection. Completed EOF or backend failures, ordinary JWTs and Full/legacy permission contracts are unchanged.

## Configuration

Preferred flags are `--platform-url`, `--platform-access-key`, and `--platform-secret-key`. Existing `--cattle-*` flags remain compatibility aliases. Set `PASTURESTACK_HOME` for the state root and `PASTURESTACK_LOCALE=en-US` or `zh-TW` for operator messages. `host-api --version` reports the pure numeric `MAJOR.MINOR.PATCH` build version.
Expand Down
29 changes: 25 additions & 4 deletions auth/delegation.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,27 @@ func verifyDelegation(raw, path string, token *jwt.Token) (*jwt.Token, bool) {

// The original signed token, not normalized claims, is rechecked for every tick.
func StartStreamWatch(parent context.Context, raw string, verified *jwt.Token, deny func()) context.CancelFunc {
return startStreamWatchResult(parent, raw, verified, func(delegation.Result) { deny() })
}

// Watch closes immediately on either a confirmed denial or an unknown check.
// Only the former may become AuthorizationRevoked terminal evidence.
func (a *StreamAudit) Watch(parent context.Context, raw string, verified *jwt.Token, deny func()) context.CancelFunc {
return startStreamWatchResult(parent, raw, verified, func(result delegation.Result) {
a.authorizationResult(result)
deny()
})
}

func (a *StreamAudit) authorizationResult(result delegation.Result) {
if result == delegation.ConfirmedDenied {
a.Cancel("AuthorizationRevoked")
} else if result != delegation.Allowed {
a.Cancel("StreamCancelled")
}
}

func startStreamWatchResult(parent context.Context, raw string, verified *jwt.Token, deny func(delegation.Result)) context.CancelFunc {
initial, ok := MapClaims(verified)
if ok {
if _, scoped := initial[delegation.Claim]; !scoped {
Expand All @@ -45,18 +66,18 @@ func StartStreamWatch(parent context.Context, raw string, verified *jwt.Token, d
}
token, err := ParseToken(raw, config.Config.ParsedPublicKey)
if err != nil {
deny()
deny(delegation.Unknown)
return func() {}
}
claims, ok := MapClaims(token)
if !ok {
deny()
deny(delegation.Unknown)
return func() {}
}
grant, err := delegation.Parse(map[string]interface{}(claims))
if err != nil {
deny()
deny(delegation.Unknown)
return func() {}
}
return delegation.New(config.Config.PlatformURL).Watch(parent, raw, grant, deny)
return delegation.New(config.Config.PlatformURL).WatchResult(parent, raw, grant, deny)
}
84 changes: 68 additions & 16 deletions auth/stream_audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ import (
type StreamAudit struct {
mu sync.Mutex
enabled, required, cancelled bool
authorized, completed, frozen bool
reportOnce sync.Once
recheck func(context.Context) delegation.Result
token, endpoint, accessKey, secretKey string
outcome, failureCode string
client *http.Client
Expand Down Expand Up @@ -68,6 +71,12 @@ func BeginStreamAudit(raw string) *StreamAudit {
return a
}
}
if a.required && a.enabled {
if grant, err := delegation.Parse(map[string]interface{}(claims)); err == nil && grant != nil {
verifier := delegation.New(config.Config.PlatformURL)
a.recheck = func(ctx context.Context) delegation.Result { return verifier.CheckResult(ctx, raw, grant) }
}
}
if envelope, ok := claims["apiKeyAudit"].(map[string]interface{}); ok {
a.expiresAt = receiptExpiry(envelope)
}
Expand Down Expand Up @@ -103,51 +112,94 @@ func (a *StreamAudit) Ready() bool {
func (a *StreamAudit) Enabled() bool { return a.enabled }
func (a *StreamAudit) Authorized() {
a.mu.Lock()
defer a.mu.Unlock()
if a.frozen || a.completed || a.cancelled {
return
}
a.authorized = true
a.outcome = "CANCELLED"
a.failureCode = "ClientDisconnected"
a.mu.Unlock()
}
func (a *StreamAudit) Succeed() {
a.mu.Lock()
defer a.mu.Unlock()
if !a.cancelled {
if !a.cancelled && !a.frozen && !a.completed {
a.outcome = "SUCCEEDED"
a.failureCode = ""
a.completed = true
}
}
func (a *StreamAudit) Fail(code string) {
a.mu.Lock()
defer a.mu.Unlock()
if !a.cancelled {
if !a.cancelled && !a.frozen && !a.completed {
a.outcome = "FAILED"
a.failureCode = code
a.completed = true
}
}
func (a *StreamAudit) Cancel(code string) {
a.mu.Lock()
defer a.mu.Unlock()
if a.frozen || a.completed || (a.cancelled && cancellationPriority(code) <= cancellationPriority(a.failureCode)) {
return
}
a.cancelled = true
a.outcome = "CANCELLED"
a.failureCode = code
}

func (a *StreamAudit) Report() {
if !a.enabled {
return
func cancellationPriority(code string) int {
switch code {
case "AuthorizationRevoked":
return 3
case "StreamCancelled":
return 2
default:
return 1
}
queue := a.queue
if queue == nil {
queue, _ = configuredCompletionSpool()
}

func (a *StreamAudit) finalize(ctx context.Context) {
a.mu.Lock()
check := a.recheck
transport := a.required && a.authorized && !a.completed && !a.frozen &&
a.outcome == "CANCELLED" && a.failureCode != "AuthorizationRevoked"
a.mu.Unlock()
if transport {
result := delegation.Unknown
if check != nil {
result = check(ctx) // Never hold the audit mutex during network I/O.
}
a.authorizationResult(result)
}
if queue == nil || queue.complete(a.record("TERMINAL")) != nil {
glog.Warning("API key stream terminal evidence could not be persisted")
a.mu.Lock()
a.frozen = true
a.mu.Unlock()
}

func (a *StreamAudit) Report() {
if !a.enabled {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 7*time.Second)
defer cancel()
if !queue.deliverOne(ctx, completionID(a.token)) {
glog.Warning("API key stream terminal audit remains pending delivery")
}
a.reportOnce.Do(func() {
// Rechecking and delivery share the existing total report budget. The
// stream is already closed; this cannot renew or reopen its authority.
ctx, cancel := context.WithTimeout(context.Background(), 7*time.Second)
defer cancel()
a.finalize(ctx)
queue := a.queue
if queue == nil {
queue, _ = configuredCompletionSpool()
}
if queue == nil || queue.complete(a.record("TERMINAL")) != nil {
glog.Warning("API key stream terminal evidence could not be persisted")
return
}
if !queue.deliverOne(ctx, completionID(a.token)) {
glog.Warning("API key stream terminal audit remains pending delivery")
}
})
}

func (a *StreamAudit) deliver(ctx context.Context) bool {
Expand Down
Loading
Loading