Add versioned ephemeral custody lifecycle
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a02669-87ee-7a31-b111-edc95a16e0fa
This commit is contained in:
parent
985cef2572
commit
30e6edc236
17 changed files with 2855 additions and 6 deletions
|
|
@ -23,6 +23,7 @@
|
|||
| workplan | RAILIANCE-WP-0022 | finished | — | workplans/RAILIANCE-WP-0022-agent-high-risk-boundary-coverage.md |
|
||||
| workplan | RAILIANCE-WP-0023 | finished | — | workplans/RAILIANCE-WP-0023-hub-core-candidate-credential-lanes.md |
|
||||
| workplan | RAILIANCE-WP-0024 | active | — | workplans/RAILIANCE-WP-0024-audit-core-custody-and-recovery-coordination.md |
|
||||
| workplan | RAILIANCE-WP-0025 | finished | — | workplans/RAILIANCE-WP-0025-versioned-ephemeral-custody-lifecycle.md |
|
||||
| workplan | RPF-WP-0018 | finished | — | workplans/RPF-WP-0018-policy-surface-alignment.md |
|
||||
| workplan | RPF-WP-0019 | finished | — | workplans/RPF-WP-0019-apps-pg-recoverability-and-controls.md |
|
||||
| workplan | RPF-WP-0020 | finished | — | workplans/RPF-WP-0020-ccr-schema-drift.md |
|
||||
|
|
@ -97,10 +98,15 @@
|
|||
| task | RAILIANCE-WP-0023-T01 | done | — | workplans/RAILIANCE-WP-0023-hub-core-candidate-credential-lanes.md |
|
||||
| task | RAILIANCE-WP-0023-T02 | done | — | workplans/RAILIANCE-WP-0023-hub-core-candidate-credential-lanes.md |
|
||||
| task | RAILIANCE-WP-0023-T03 | done | — | workplans/RAILIANCE-WP-0023-hub-core-candidate-credential-lanes.md |
|
||||
| task | RAILIANCE-WP-0024-T01 | wait | — | workplans/RAILIANCE-WP-0024-audit-core-custody-and-recovery-coordination.md |
|
||||
| task | RAILIANCE-WP-0024-T01 | done | — | workplans/RAILIANCE-WP-0024-audit-core-custody-and-recovery-coordination.md |
|
||||
| task | RAILIANCE-WP-0024-T02 | progress | — | workplans/RAILIANCE-WP-0024-audit-core-custody-and-recovery-coordination.md |
|
||||
| task | RAILIANCE-WP-0024-T03 | progress | — | workplans/RAILIANCE-WP-0024-audit-core-custody-and-recovery-coordination.md |
|
||||
| task | RAILIANCE-WP-0024-T04 | done | — | workplans/RAILIANCE-WP-0024-audit-core-custody-and-recovery-coordination.md |
|
||||
| task | RAILIANCE-WP-0025-T01 | done | — | workplans/RAILIANCE-WP-0025-versioned-ephemeral-custody-lifecycle.md |
|
||||
| task | RAILIANCE-WP-0025-T02 | done | — | workplans/RAILIANCE-WP-0025-versioned-ephemeral-custody-lifecycle.md |
|
||||
| task | RAILIANCE-WP-0025-T03 | done | — | workplans/RAILIANCE-WP-0025-versioned-ephemeral-custody-lifecycle.md |
|
||||
| task | RAILIANCE-WP-0025-T04 | done | — | workplans/RAILIANCE-WP-0025-versioned-ephemeral-custody-lifecycle.md |
|
||||
| task | RAILIANCE-WP-0025-T05 | done | — | workplans/RAILIANCE-WP-0025-versioned-ephemeral-custody-lifecycle.md |
|
||||
| task | RPF-WP-0018-T01 | done | — | workplans/RPF-WP-0018-policy-surface-alignment.md |
|
||||
| task | RPF-WP-0018-T02 | done | — | workplans/RPF-WP-0018-policy-surface-alignment.md |
|
||||
| task | RPF-WP-0018-T03 | done | — | workplans/RPF-WP-0018-policy-surface-alignment.md |
|
||||
|
|
|
|||
59
docs/custody-projection-contract.example.json
Normal file
59
docs/custody-projection-contract.example.json
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
{
|
||||
"interface": "railiance.custody-projection-contract",
|
||||
"version": 1,
|
||||
"workplan_id": "RAILIANCE-WP-0025",
|
||||
"engagement_id": "REPLACE-WITH-NEW-TERMINAL-UNIQUE-ID",
|
||||
"status": "replace-with-explicit-approval",
|
||||
"engagement_contract_sha256": "replace-with-64-lowercase-hex",
|
||||
"target": {
|
||||
"id": "replace-with-target-id",
|
||||
"namespace": "replace-with-target-namespace",
|
||||
"deployment": "replace-with-target-deployment",
|
||||
"container": "replace-with-target-container",
|
||||
"sender_external_secret": "replace-with-sender-external-secret",
|
||||
"revision": "replace-with-full-40-character-git-revision",
|
||||
"image_digest": "sha256:replace-with-64-lowercase-hex",
|
||||
"contract_sha256": "replace-with-64-lowercase-hex"
|
||||
},
|
||||
"runner": {
|
||||
"namespace": "whitehat",
|
||||
"service_account": "whitehat-runner",
|
||||
"secret_name": "replace-with-exact-mounted-secret-name",
|
||||
"mount_root": "/var/run/secrets/whitehat",
|
||||
"manifest_sha256": "replace-with-64-lowercase-hex"
|
||||
},
|
||||
"window": {
|
||||
"starts_at": "replace-with-RFC3339-start",
|
||||
"projection_cutoff": "replace-with-RFC3339-projection-cutoff",
|
||||
"expires_at": "replace-with-RFC3339-expiry-at-most-900-seconds-after-start"
|
||||
},
|
||||
"authority": {
|
||||
"remote": "railiance01",
|
||||
"registry_path": "replace-with-exact-platform-registry-path",
|
||||
"registry_field": "senders.json",
|
||||
"kv_mount": "platform",
|
||||
"kv_prefix": "engagements/REPLACE-WITH-NEW-TERMINAL-UNIQUE-ID/replace-with-target-id",
|
||||
"eso_service_account": "external-secrets",
|
||||
"eso_namespace": "external-secrets"
|
||||
},
|
||||
"identities": [
|
||||
{
|
||||
"handle": "token-a",
|
||||
"role": "attacker",
|
||||
"sender_name": "replace-with-new-attacker-sender",
|
||||
"tenant": "replace-with-new-attacker-fixture-tenant",
|
||||
"mount_path": "/var/run/secrets/whitehat/token-a",
|
||||
"may_read": true,
|
||||
"may_write": true
|
||||
},
|
||||
{
|
||||
"handle": "token-b",
|
||||
"role": "owner",
|
||||
"sender_name": "replace-with-new-owner-sender",
|
||||
"tenant": "replace-with-new-owner-fixture-tenant",
|
||||
"mount_path": "/var/run/secrets/whitehat/token-b",
|
||||
"may_read": true,
|
||||
"may_write": true
|
||||
}
|
||||
]
|
||||
}
|
||||
132
docs/ephemeral-custody-lifecycle.md
Normal file
132
docs/ephemeral-custody-lifecycle.md
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
# Ephemeral custody projection lifecycle
|
||||
|
||||
`scripts/custody-projection.py` is the platform-owned interface for future
|
||||
short-lived, mount-only credential exercises. It replaces copying the dated
|
||||
`audit-core-whitehat-e2-credentials*.py`, HCL, and YAML files. Those dated
|
||||
artifacts are retained only as historical evidence and cleanup references for
|
||||
their terminal engagement ids.
|
||||
|
||||
The lifecycle does not authorize an engagement. A projection contract is
|
||||
accepted only when it is `approved`, lasts at most 900 seconds, binds exact
|
||||
target and runner revisions, names exactly two distinct sender identities, and
|
||||
uses engagement- and target-bound KV paths. Kubernetes and OpenBao resource
|
||||
names are derived from the SHA-256 of the engagement id; no previous engagement
|
||||
name is copied into a new manifest.
|
||||
|
||||
## Versioned interfaces
|
||||
|
||||
| Interface | Schema | Producer | Consumer |
|
||||
| --- | --- | --- | --- |
|
||||
| `railiance.custody-projection-contract` v1 | `schemas/custody-projection-contract.schema.json` | engagement/platform owners | platform lifecycle and Whitehat review |
|
||||
| `railiance.custody-broker-readiness` v1 | `schemas/custody-broker-readiness.schema.json` | `whitehat-security` | platform pre-projection gate |
|
||||
| `railiance.custody-projection-receipt` v1 | `schemas/custody-projection-receipt.schema.json` | platform lifecycle | Whitehat broker adapter and operator |
|
||||
| `railiance.custody-cleanup-receipt` v1 | `schemas/custody-cleanup-receipt.schema.json` | platform lifecycle | engagement owner and evidence record |
|
||||
|
||||
Receipts contain no bearer, password, value-derived fingerprint, Secret data,
|
||||
or registry body. The projection receipt carries an opaque lease id, canonical
|
||||
receipt id, exact identity roles and mount paths, contract digests, times,
|
||||
resource names, and Kubernetes UIDs. Changing any canonical field invalidates
|
||||
the receipt id.
|
||||
|
||||
## Broker gate owned directly by Whitehat
|
||||
|
||||
The owner does not need a coding agent to translate the request. Given the
|
||||
final projection contract, it can run:
|
||||
|
||||
```sh
|
||||
python3 scripts/wp0025-broker-readiness.py show \
|
||||
--contract /path/to/final-projection-contract.json
|
||||
|
||||
python3 scripts/wp0025-broker-readiness.py verify \
|
||||
--contract /path/to/final-projection-contract.json \
|
||||
--consumer-root /path/to/whitehat-security
|
||||
|
||||
python3 scripts/wp0025-broker-readiness.py approve \
|
||||
--contract /path/to/final-projection-contract.json \
|
||||
--consumer-root /path/to/whitehat-security \
|
||||
--reviewer whitehat-owner
|
||||
```
|
||||
|
||||
Verification is closed to
|
||||
`src/whitehat_security/platform_custody.py` and
|
||||
`tests/test_platform_custody_adapter.py`. It records the full consumer commit,
|
||||
adapter SHA-256, focused-test result, exact platform schema hashes, mount paths,
|
||||
and cleanup support. Approval posts the canonical receipt directly to State
|
||||
Hub as `from_agent=whitehat-security`. A stale schema, changed projection
|
||||
contract, wrong engagement, wrong target, absent adapter, or failing focused
|
||||
test does not count.
|
||||
|
||||
The platform lifecycle queries that receipt before token generation and before
|
||||
the first mutation. State Hub coordination identity is not a secret transport;
|
||||
the receipt contains no secret.
|
||||
|
||||
## Platform commands
|
||||
|
||||
The template at `docs/custody-projection-contract.example.json` intentionally
|
||||
fails validation until every placeholder and the approval status are replaced
|
||||
by the owners.
|
||||
|
||||
```sh
|
||||
python3 scripts/custody-projection.py validate \
|
||||
--contract /path/to/final-projection-contract.json
|
||||
|
||||
python3 scripts/custody-projection.py render \
|
||||
--contract /path/to/final-projection-contract.json
|
||||
|
||||
python3 scripts/custody-projection.py preflight \
|
||||
--contract /path/to/final-projection-contract.json
|
||||
```
|
||||
|
||||
`render` is value-free and creates no resource. `preflight` reads only status,
|
||||
metadata, names, image/readiness, and the broker receipt. It never reads a
|
||||
Kubernetes Secret value.
|
||||
|
||||
Inside the approved projection interval, an attached custody operator may run:
|
||||
|
||||
```sh
|
||||
python3 scripts/custody-projection.py project \
|
||||
--contract /path/to/final-projection-contract.json \
|
||||
--confirm '<engagement-id>:attended' \
|
||||
--receipt-out /approved/non-repo/path/projection.json
|
||||
```
|
||||
|
||||
If any mutation fails, the transaction invokes exact-scope cleanup. The
|
||||
operator must not release the consumer until the returned projection receipt
|
||||
passes its adapter. The receipt file is written mode `0600` even though it is
|
||||
value-safe.
|
||||
|
||||
Status and normal attended cleanup require that receipt:
|
||||
|
||||
```sh
|
||||
python3 scripts/custody-projection.py status \
|
||||
--contract /path/to/final-projection-contract.json \
|
||||
--receipt /approved/non-repo/path/projection.json
|
||||
|
||||
python3 scripts/custody-projection.py cleanup \
|
||||
--contract /path/to/final-projection-contract.json \
|
||||
--receipt /approved/non-repo/path/projection.json \
|
||||
--confirm '<engagement-id>:cleanup' \
|
||||
--receipt-out /approved/non-repo/path/cleanup.json
|
||||
```
|
||||
|
||||
Status reports `active`, `absent`, `partial`, or `mismatched`. It compares live
|
||||
resource UIDs with the receipt and does not interpret a connection error as
|
||||
absence.
|
||||
|
||||
## Expired cleanup entry point
|
||||
|
||||
`cleanup-expired` refuses until the contract expiry and still requires the
|
||||
exact projection receipt and confirmation:
|
||||
|
||||
```sh
|
||||
python3 scripts/custody-projection.py cleanup-expired \
|
||||
--contract /path/to/final-projection-contract.json \
|
||||
--receipt /approved/non-repo/path/projection.json \
|
||||
--confirm '<engagement-id>:expired' \
|
||||
--receipt-out /approved/non-repo/path/cleanup.json
|
||||
```
|
||||
|
||||
This is an approval-ready reaper entry point, not a deployed scheduler. A
|
||||
future controller still needs an owner-reviewed credential route, runtime
|
||||
identity, alerting, and rollout decision. Application `expires_at` remains the
|
||||
acceptance backstop; evidenced resource deletion remains the cleanup result.
|
||||
55
schemas/custody-broker-readiness.schema.json
Normal file
55
schemas/custody-broker-readiness.schema.json
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://coulomb.social/schemas/railiance/custody-broker-readiness.v1.json",
|
||||
"title": "Railiance custody broker readiness receipt v1",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"interface", "version", "workplan_id", "owner", "reviewer", "decision",
|
||||
"created_at", "engagement_id", "target_id", "projection_contract_digest",
|
||||
"projection_receipt_interface", "interface_artifacts", "required_roles", "mount_paths",
|
||||
"secret_values_observed"
|
||||
],
|
||||
"properties": {
|
||||
"interface": {"const": "railiance.custody-broker-readiness"},
|
||||
"version": {"const": 1},
|
||||
"workplan_id": {"const": "RAILIANCE-WP-0025"},
|
||||
"owner": {"const": "whitehat-security"},
|
||||
"reviewer": {"type": "string", "minLength": 2, "maxLength": 128},
|
||||
"decision": {"enum": ["approve", "request-changes"]},
|
||||
"created_at": {"type": "string", "format": "date-time"},
|
||||
"engagement_id": {"type": "string"},
|
||||
"target_id": {"type": "string"},
|
||||
"projection_contract_digest": {"type": "string", "pattern": "^[0-9a-f]{64}$"},
|
||||
"projection_receipt_interface": {"const": "railiance.custody-projection-receipt"},
|
||||
"interface_artifacts": {
|
||||
"type": "object",
|
||||
"minProperties": 4,
|
||||
"additionalProperties": {"type": "string", "pattern": "^[0-9a-f]{64}$"}
|
||||
},
|
||||
"required_roles": {"const": ["attacker", "owner"]},
|
||||
"mount_paths": {"type": "array", "minItems": 2, "maxItems": 2, "items": {"type": "string", "pattern": "^/"}},
|
||||
"adapter": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["repo", "revision", "path", "sha256", "tests_passed"],
|
||||
"properties": {
|
||||
"repo": {"const": "whitehat-security"},
|
||||
"revision": {"type": "string", "pattern": "^[0-9a-f]{40}$"},
|
||||
"path": {"type": "string", "minLength": 1},
|
||||
"sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"},
|
||||
"tests_passed": {"const": true}
|
||||
}
|
||||
},
|
||||
"cleanup_request_supported": {"const": true},
|
||||
"note": {"type": "string", "minLength": 1, "maxLength": 2000},
|
||||
"secret_values_observed": {"const": false}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {"properties": {"decision": {"const": "approve"}}},
|
||||
"then": {"required": ["adapter", "cleanup_request_supported"]},
|
||||
"else": {"required": ["note"]}
|
||||
}
|
||||
]
|
||||
}
|
||||
25
schemas/custody-cleanup-receipt.schema.json
Normal file
25
schemas/custody-cleanup-receipt.schema.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://coulomb.social/schemas/railiance/custody-cleanup-receipt.v1.json",
|
||||
"title": "Railiance custody cleanup receipt v1",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"interface", "version", "workplan_id", "state", "lease_id", "engagement_id",
|
||||
"projection_receipt_id", "cleaned_at", "removed_resources", "target_ready",
|
||||
"secret_values_observed"
|
||||
],
|
||||
"properties": {
|
||||
"interface": {"const": "railiance.custody-cleanup-receipt"},
|
||||
"version": {"const": 1},
|
||||
"workplan_id": {"const": "RAILIANCE-WP-0025"},
|
||||
"state": {"const": "cleaned"},
|
||||
"lease_id": {"type": "string", "pattern": "^custody:[0-9a-f]{32}$"},
|
||||
"engagement_id": {"type": "string"},
|
||||
"projection_receipt_id": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"},
|
||||
"cleaned_at": {"type": "string", "format": "date-time"},
|
||||
"removed_resources": {"type": "array", "items": {"type": "string"}},
|
||||
"target_ready": {"const": true},
|
||||
"secret_values_observed": {"const": false}
|
||||
}
|
||||
}
|
||||
100
schemas/custody-projection-contract.schema.json
Normal file
100
schemas/custody-projection-contract.schema.json
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://coulomb.social/schemas/railiance/custody-projection-contract.v1.json",
|
||||
"title": "Railiance ephemeral custody projection contract v1",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"interface", "version", "workplan_id", "engagement_id", "status",
|
||||
"engagement_contract_sha256", "target", "runner", "window", "authority",
|
||||
"identities"
|
||||
],
|
||||
"properties": {
|
||||
"interface": {"const": "railiance.custody-projection-contract"},
|
||||
"version": {"const": 1},
|
||||
"workplan_id": {"const": "RAILIANCE-WP-0025"},
|
||||
"engagement_id": {"type": "string", "pattern": "^[A-Z0-9][A-Z0-9._-]{7,127}$"},
|
||||
"status": {"const": "approved"},
|
||||
"engagement_contract_sha256": {"$ref": "#/$defs/sha256"},
|
||||
"target": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id", "namespace", "deployment", "container", "sender_external_secret", "revision",
|
||||
"image_digest", "contract_sha256"
|
||||
],
|
||||
"properties": {
|
||||
"id": {"type": "string", "minLength": 1},
|
||||
"namespace": {"$ref": "#/$defs/dnsLabel"},
|
||||
"deployment": {"$ref": "#/$defs/dnsLabel"},
|
||||
"container": {"$ref": "#/$defs/dnsLabel"},
|
||||
"sender_external_secret": {"$ref": "#/$defs/dnsLabel"},
|
||||
"revision": {"type": "string", "pattern": "^[0-9a-f]{40}$"},
|
||||
"image_digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"},
|
||||
"contract_sha256": {"$ref": "#/$defs/sha256"}
|
||||
}
|
||||
},
|
||||
"runner": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["namespace", "service_account", "secret_name", "mount_root", "manifest_sha256"],
|
||||
"properties": {
|
||||
"namespace": {"$ref": "#/$defs/dnsLabel"},
|
||||
"service_account": {"$ref": "#/$defs/dnsLabel"},
|
||||
"secret_name": {"$ref": "#/$defs/dnsLabel"},
|
||||
"mount_root": {"type": "string", "pattern": "^/"},
|
||||
"manifest_sha256": {"$ref": "#/$defs/sha256"}
|
||||
}
|
||||
},
|
||||
"window": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["starts_at", "projection_cutoff", "expires_at"],
|
||||
"properties": {
|
||||
"starts_at": {"type": "string", "format": "date-time"},
|
||||
"projection_cutoff": {"type": "string", "format": "date-time"},
|
||||
"expires_at": {"type": "string", "format": "date-time"}
|
||||
}
|
||||
},
|
||||
"authority": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"remote", "registry_path", "registry_field", "kv_mount", "kv_prefix",
|
||||
"eso_service_account", "eso_namespace"
|
||||
],
|
||||
"properties": {
|
||||
"remote": {"type": "string", "pattern": "^\\S+$"},
|
||||
"registry_path": {"type": "string", "minLength": 1},
|
||||
"registry_field": {"type": "string", "minLength": 1},
|
||||
"kv_mount": {"type": "string", "pattern": "^[^/]+$"},
|
||||
"kv_prefix": {"type": "string", "minLength": 1},
|
||||
"eso_service_account": {"$ref": "#/$defs/dnsLabel"},
|
||||
"eso_namespace": {"$ref": "#/$defs/dnsLabel"}
|
||||
}
|
||||
},
|
||||
"identities": {
|
||||
"type": "array",
|
||||
"minItems": 2,
|
||||
"maxItems": 2,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["handle", "role", "sender_name", "tenant", "mount_path", "may_read", "may_write"],
|
||||
"properties": {
|
||||
"handle": {"enum": ["token-a", "token-b"]},
|
||||
"role": {"enum": ["attacker", "owner"]},
|
||||
"sender_name": {"type": "string", "minLength": 1},
|
||||
"tenant": {"type": "string", "minLength": 1},
|
||||
"mount_path": {"type": "string", "pattern": "^/"},
|
||||
"may_read": {"const": true},
|
||||
"may_write": {"const": true}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"sha256": {"type": "string", "pattern": "^(sha256:)?[0-9a-f]{64}$"},
|
||||
"dnsLabel": {"type": "string", "pattern": "^[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?$"}
|
||||
}
|
||||
}
|
||||
31
schemas/custody-projection-receipt.schema.json
Normal file
31
schemas/custody-projection-receipt.schema.json
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://coulomb.social/schemas/railiance/custody-projection-receipt.v1.json",
|
||||
"title": "Railiance active custody projection receipt v1",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"interface", "version", "workplan_id", "state", "receipt_id", "lease_id",
|
||||
"engagement_id", "target", "projection_contract_digest", "broker_receipt_digest",
|
||||
"projected_at", "expires_at", "identities", "resources", "cleanup_authority",
|
||||
"secret_values_observed"
|
||||
],
|
||||
"properties": {
|
||||
"interface": {"const": "railiance.custody-projection-receipt"},
|
||||
"version": {"const": 1},
|
||||
"workplan_id": {"const": "RAILIANCE-WP-0025"},
|
||||
"state": {"const": "projected"},
|
||||
"receipt_id": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"},
|
||||
"lease_id": {"type": "string", "pattern": "^custody:[0-9a-f]{32}$"},
|
||||
"engagement_id": {"type": "string"},
|
||||
"target": {"type": "object"},
|
||||
"projection_contract_digest": {"type": "string", "pattern": "^[0-9a-f]{64}$"},
|
||||
"broker_receipt_digest": {"type": "string", "pattern": "^[0-9a-f]{64}$"},
|
||||
"projected_at": {"type": "string", "format": "date-time"},
|
||||
"expires_at": {"type": "string", "format": "date-time"},
|
||||
"identities": {"type": "array", "minItems": 2, "maxItems": 2},
|
||||
"resources": {"type": "object"},
|
||||
"cleanup_authority": {"const": "railiance-platform"},
|
||||
"secret_values_observed": {"const": false}
|
||||
}
|
||||
}
|
||||
948
scripts/custody-projection.py
Executable file
948
scripts/custody-projection.py
Executable file
|
|
@ -0,0 +1,948 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Data-driven, attended ephemeral credential custody lifecycle.
|
||||
|
||||
The command never prints or persists bearer values. Live projection requires a
|
||||
current Whitehat broker-readiness receipt in State Hub and an exact attended
|
||||
confirmation. Cleanup is scoped only by the validated projection contract and
|
||||
receipt.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
if str(SCRIPT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPT_DIR))
|
||||
|
||||
from custody_contract import ( # noqa: E402
|
||||
CLEANUP_INTERFACE,
|
||||
PROJECTION_INTERFACE,
|
||||
WORKPLAN_ID,
|
||||
ContractError,
|
||||
canonical_json,
|
||||
contract_digest,
|
||||
current_broker_receipt,
|
||||
digest,
|
||||
load_json,
|
||||
parse_time,
|
||||
resource_names,
|
||||
validate_cleanup_receipt,
|
||||
validate_projection_contract,
|
||||
validate_projection_receipt,
|
||||
)
|
||||
from remote_exec import RemoteExecutionError, run_remote # noqa: E402
|
||||
|
||||
|
||||
DEFAULT_API_BASE = os.environ.get("STATE_HUB_URL", "http://127.0.0.1:8000")
|
||||
|
||||
|
||||
class ProcedureError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
def rfc3339(value: datetime) -> str:
|
||||
return value.astimezone(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def safe_run(
|
||||
command: list[str],
|
||||
*,
|
||||
label: str,
|
||||
env: dict[str, str] | None = None,
|
||||
input_text: str | None = None,
|
||||
allow_missing: bool = False,
|
||||
runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
completed = runner(
|
||||
command,
|
||||
text=True,
|
||||
input=input_text,
|
||||
capture_output=True,
|
||||
env=env,
|
||||
check=False,
|
||||
)
|
||||
if completed.returncode and not allow_missing:
|
||||
raise ProcedureError(f"{label} failed (exit {completed.returncode})")
|
||||
return completed
|
||||
|
||||
|
||||
def secure_unlink(path: Path) -> None:
|
||||
if not path.exists():
|
||||
return
|
||||
size = path.stat().st_size
|
||||
with path.open("r+b", buffering=0) as handle:
|
||||
handle.write(b"\0" * size)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
path.unlink()
|
||||
|
||||
|
||||
def write_receipt(path: Path, value: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
||||
temporary = Path(temporary_name)
|
||||
try:
|
||||
os.fchmod(descriptor, 0o600)
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(value, indent=2, sort_keys=True) + "\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temporary, path)
|
||||
os.chmod(path, 0o600)
|
||||
finally:
|
||||
if temporary.exists():
|
||||
temporary.unlink()
|
||||
|
||||
|
||||
class Operator:
|
||||
def __init__(self, contract: dict[str, Any], token_file: Path) -> None:
|
||||
mode = stat.S_IMODE(token_file.stat().st_mode) if token_file.exists() else None
|
||||
if mode != 0o600:
|
||||
raise ProcedureError(f"OpenBao token file must exist with mode 0600: {token_file}")
|
||||
token = token_file.read_text(encoding="utf-8").splitlines()[0].strip()
|
||||
if not token:
|
||||
raise ProcedureError("OpenBao token file is empty")
|
||||
self.contract = contract
|
||||
self.remote = contract["authority"]["remote"]
|
||||
self.bao_env = dict(
|
||||
os.environ,
|
||||
BAO_ADDR=os.environ.get("BAO_ADDR", "https://bao.coulomb.social"),
|
||||
BAO_TOKEN=token,
|
||||
)
|
||||
|
||||
def bao(
|
||||
self,
|
||||
args: list[str],
|
||||
*,
|
||||
label: str,
|
||||
input_text: str | None = None,
|
||||
allow_missing: bool = False,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
return safe_run(
|
||||
["bao", *args],
|
||||
label=label,
|
||||
env=self.bao_env,
|
||||
input_text=input_text,
|
||||
allow_missing=allow_missing,
|
||||
)
|
||||
|
||||
def kubectl(
|
||||
self,
|
||||
args: list[str],
|
||||
*,
|
||||
label: str,
|
||||
input_text: str | None = None,
|
||||
allow_missing: bool = False,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
try:
|
||||
return run_remote(
|
||||
self.remote,
|
||||
["kubectl", *args],
|
||||
label=label,
|
||||
input_text=input_text,
|
||||
allow_missing=allow_missing,
|
||||
)
|
||||
except RemoteExecutionError as exc:
|
||||
raise ProcedureError(str(exc)) from exc
|
||||
|
||||
def bao_exists(self, args: list[str], *, label: str) -> bool:
|
||||
result = self.bao(args, label=label, allow_missing=True)
|
||||
if result.returncode == 0:
|
||||
return True
|
||||
error = result.stderr.lower()
|
||||
if "404" in error or "no value found" in error or "no policy named" in error:
|
||||
return False
|
||||
raise ProcedureError(f"{label} failed without a confirmed not-found response")
|
||||
|
||||
def kubectl_exists(self, args: list[str], *, label: str) -> bool:
|
||||
result = self.kubectl(args, label=label, allow_missing=True)
|
||||
return result.returncode == 0 and bool(result.stdout.strip())
|
||||
|
||||
def registry(self) -> list[dict[str, Any]]:
|
||||
authority = self.contract["authority"]
|
||||
result = self.bao(
|
||||
["kv", "get", f"-field={authority['registry_field']}", authority["registry_path"]],
|
||||
label="read sender registry",
|
||||
)
|
||||
try:
|
||||
value = json.loads(result.stdout)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ProcedureError("sender registry is not valid JSON") from exc
|
||||
if not isinstance(value, list):
|
||||
raise ProcedureError("sender registry must be a list")
|
||||
return value
|
||||
|
||||
def write_registry(self, registry: list[dict[str, Any]]) -> None:
|
||||
authority = self.contract["authority"]
|
||||
with tempfile.TemporaryDirectory(prefix="railiance-custody-registry-") as directory:
|
||||
root = Path(directory)
|
||||
os.chmod(root, 0o700)
|
||||
document = root / "registry.json"
|
||||
document.write_text(canonical_json(registry), encoding="utf-8")
|
||||
os.chmod(document, 0o600)
|
||||
try:
|
||||
self.bao(
|
||||
[
|
||||
"kv",
|
||||
"patch",
|
||||
authority["registry_path"],
|
||||
f"{authority['registry_field']}=@{document}",
|
||||
],
|
||||
label="write sender registry",
|
||||
)
|
||||
finally:
|
||||
secure_unlink(document)
|
||||
|
||||
|
||||
def identity_names(contract: dict[str, Any]) -> set[str]:
|
||||
return {item["sender_name"] for item in contract["identities"]}
|
||||
|
||||
|
||||
def token_paths(contract: dict[str, Any]) -> dict[str, str]:
|
||||
authority = contract["authority"]
|
||||
prefix = authority["kv_prefix"].strip("/")
|
||||
return {
|
||||
item["handle"]: f"{authority['kv_mount']}/{prefix}/{item['handle']}"
|
||||
for item in contract["identities"]
|
||||
}
|
||||
|
||||
|
||||
def add_temporary_identities(
|
||||
registry: list[dict[str, Any]], contract: dict[str, Any], values: dict[str, str]
|
||||
) -> list[dict[str, Any]]:
|
||||
names = identity_names(contract)
|
||||
present = {str(item.get("name")) for item in registry if isinstance(item, dict)}
|
||||
if present & names:
|
||||
raise ProcedureError(f"temporary identities already exist: {sorted(present & names)}")
|
||||
updated = list(registry)
|
||||
expires = contract["window"]["expires_at"]
|
||||
for identity in contract["identities"]:
|
||||
updated.append(
|
||||
{
|
||||
"name": identity["sender_name"],
|
||||
"tokens": [values[identity["handle"]]],
|
||||
"sources": ["whitehat-security"],
|
||||
"tenants": [identity["tenant"]],
|
||||
"may_write": True,
|
||||
"may_read": True,
|
||||
"expires_at": expires,
|
||||
}
|
||||
)
|
||||
return updated
|
||||
|
||||
|
||||
def remove_temporary_identities(
|
||||
registry: list[dict[str, Any]], contract: dict[str, Any]
|
||||
) -> tuple[list[dict[str, Any]], list[str]]:
|
||||
names = identity_names(contract)
|
||||
removed = sorted(
|
||||
str(item.get("name"))
|
||||
for item in registry
|
||||
if isinstance(item, dict) and item.get("name") in names
|
||||
)
|
||||
updated = [
|
||||
item
|
||||
for item in registry
|
||||
if not isinstance(item, dict) or item.get("name") not in names
|
||||
]
|
||||
return updated, removed
|
||||
|
||||
|
||||
def build_policy(contract: dict[str, Any]) -> str:
|
||||
paths = token_paths(contract)
|
||||
blocks: list[str] = []
|
||||
for path in paths.values():
|
||||
mount, relative = path.split("/", 1)
|
||||
blocks.extend(
|
||||
[
|
||||
f'path "{mount}/data/{relative}" {{',
|
||||
' capabilities = ["read"]',
|
||||
"}",
|
||||
"",
|
||||
f'path "{mount}/metadata/{relative}" {{',
|
||||
' capabilities = ["read"]',
|
||||
"}",
|
||||
"",
|
||||
]
|
||||
)
|
||||
return "\n".join(blocks).rstrip() + "\n"
|
||||
|
||||
|
||||
def build_manifest(contract: dict[str, Any], lease_id: str) -> str:
|
||||
names = resource_names(contract)
|
||||
authority = contract["authority"]
|
||||
runner = contract["runner"]
|
||||
engagement = contract["engagement_id"]
|
||||
annotations = {
|
||||
"custody.railiance/engagement": engagement,
|
||||
"custody.railiance/lease-id": lease_id,
|
||||
"custody.railiance/contract-sha256": contract_digest(contract),
|
||||
}
|
||||
document = {
|
||||
"apiVersion": "v1",
|
||||
"kind": "List",
|
||||
"items": [
|
||||
{
|
||||
"apiVersion": "external-secrets.io/v1",
|
||||
"kind": "ClusterSecretStore",
|
||||
"metadata": {"name": names["store"], "annotations": annotations},
|
||||
"spec": {
|
||||
"provider": {
|
||||
"vault": {
|
||||
"server": "http://openbao.openbao.svc:8200",
|
||||
"path": authority["kv_mount"],
|
||||
"version": "v2",
|
||||
"auth": {
|
||||
"kubernetes": {
|
||||
"mountPath": "kubernetes",
|
||||
"role": names["role"],
|
||||
"serviceAccountRef": {
|
||||
"name": authority["eso_service_account"],
|
||||
"namespace": authority["eso_namespace"],
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
"conditions": [{"namespaces": [runner["namespace"]]}],
|
||||
},
|
||||
},
|
||||
{
|
||||
"apiVersion": "external-secrets.io/v1",
|
||||
"kind": "ExternalSecret",
|
||||
"metadata": {
|
||||
"name": names["external_secret"],
|
||||
"namespace": runner["namespace"],
|
||||
"annotations": annotations,
|
||||
},
|
||||
"spec": {
|
||||
"refreshInterval": "1m",
|
||||
"secretStoreRef": {"kind": "ClusterSecretStore", "name": names["store"]},
|
||||
"target": {
|
||||
"name": runner["secret_name"],
|
||||
"creationPolicy": "Owner",
|
||||
"deletionPolicy": "Delete",
|
||||
},
|
||||
"data": [
|
||||
{
|
||||
"secretKey": identity["handle"],
|
||||
"remoteRef": {
|
||||
"key": f"{authority['kv_prefix'].strip('/')}/{identity['handle']}",
|
||||
"property": "token",
|
||||
},
|
||||
}
|
||||
for identity in sorted(contract["identities"], key=lambda item: item["handle"])
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
return canonical_json(document)
|
||||
|
||||
|
||||
def time_state(contract: dict[str, Any], now: datetime) -> str:
|
||||
starts = parse_time(contract["window"]["starts_at"], "window.starts_at")
|
||||
cutoff = parse_time(contract["window"]["projection_cutoff"], "window.projection_cutoff")
|
||||
if now < starts:
|
||||
return "before-window"
|
||||
if now > cutoff:
|
||||
return "projection-cutoff-passed"
|
||||
return "projection-window-open"
|
||||
|
||||
|
||||
def target_ready(operator: Operator, contract: dict[str, Any]) -> str:
|
||||
target = contract["target"]
|
||||
image = operator.kubectl(
|
||||
[
|
||||
"-n",
|
||||
target["namespace"],
|
||||
"get",
|
||||
"deploy",
|
||||
target["deployment"],
|
||||
"-o",
|
||||
f"jsonpath={{.spec.template.spec.containers[?(@.name==\"{target['container']}\")].image}}",
|
||||
],
|
||||
label="read target image",
|
||||
).stdout.strip()
|
||||
if not image.endswith("@" + target["image_digest"]):
|
||||
raise ProcedureError("target is not on the contract-approved image digest")
|
||||
ready = operator.kubectl(
|
||||
[
|
||||
"-n",
|
||||
target["namespace"],
|
||||
"get",
|
||||
"deploy",
|
||||
target["deployment"],
|
||||
"-o",
|
||||
"jsonpath={.status.readyReplicas}/{.status.replicas}",
|
||||
],
|
||||
label="read target readiness",
|
||||
).stdout.strip()
|
||||
if ready != "1/1":
|
||||
raise ProcedureError(f"target is not 1/1 Ready (observed {ready or 'unknown'})")
|
||||
return ready
|
||||
|
||||
|
||||
def resource_presence(operator: Operator, contract: dict[str, Any]) -> dict[str, Any]:
|
||||
names = resource_names(contract)
|
||||
runner = contract["runner"]
|
||||
paths = token_paths(contract)
|
||||
identities = sorted(
|
||||
identity_names(contract)
|
||||
& {
|
||||
str(item.get("name"))
|
||||
for item in operator.registry()
|
||||
if isinstance(item, dict)
|
||||
}
|
||||
)
|
||||
exact_paths = sorted(
|
||||
handle
|
||||
for handle, path in paths.items()
|
||||
if operator.bao_exists(
|
||||
["kv", "metadata", "get", "-format=json", path],
|
||||
label=f"check exact {handle} KV metadata",
|
||||
)
|
||||
)
|
||||
resources: list[str] = []
|
||||
checks = (
|
||||
("store", ["get", "clustersecretstore", names["store"], "-o", "name"]),
|
||||
(
|
||||
"external_secret",
|
||||
["-n", runner["namespace"], "get", "externalsecret", names["external_secret"], "-o", "name"],
|
||||
),
|
||||
(
|
||||
"mounted_secret",
|
||||
["-n", runner["namespace"], "get", "secret", runner["secret_name"], "-o", "name"],
|
||||
),
|
||||
)
|
||||
for label, args in checks:
|
||||
if operator.kubectl_exists(args, label=f"check exact {label}"):
|
||||
resources.append(label)
|
||||
return {
|
||||
"temporary_identities_present": identities,
|
||||
"exact_token_paths_present": exact_paths,
|
||||
"projection_resources_present": resources,
|
||||
}
|
||||
|
||||
|
||||
def live_preflight(
|
||||
operator: Operator,
|
||||
contract: dict[str, Any],
|
||||
*,
|
||||
api_base: str = DEFAULT_API_BASE,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
current = (now or utc_now()).astimezone(UTC)
|
||||
operator.bao(["status", "-format=json"], label="read OpenBao status")
|
||||
target = target_ready(operator, contract)
|
||||
runner = contract["runner"]
|
||||
authority = contract["authority"]
|
||||
operator.kubectl(["get", "ns", runner["namespace"], "-o", "name"], label="verify runner namespace")
|
||||
operator.kubectl(
|
||||
["-n", authority["eso_namespace"], "get", "sa", authority["eso_service_account"], "-o", "name"],
|
||||
label="verify ESO service account",
|
||||
)
|
||||
presence = resource_presence(operator, contract)
|
||||
broker_ready = True
|
||||
broker_reason = None
|
||||
try:
|
||||
current_broker_receipt(contract, api_base, now=current)
|
||||
except ContractError as exc:
|
||||
broker_ready = False
|
||||
broker_reason = str(exc)
|
||||
return {
|
||||
"interface": "railiance.custody-preflight",
|
||||
"version": 1,
|
||||
"engagement_id": contract["engagement_id"],
|
||||
"projection_contract_digest": contract_digest(contract),
|
||||
"time_state": time_state(contract, current),
|
||||
"broker_ready": broker_ready,
|
||||
"broker_gate": "pass" if broker_ready else "blocked",
|
||||
"broker_reason": broker_reason,
|
||||
"target_image_matches": True,
|
||||
"target_ready": target,
|
||||
"openbao_initialized": True,
|
||||
"openbao_sealed": False,
|
||||
**presence,
|
||||
"secret_values_observed": False,
|
||||
}
|
||||
|
||||
|
||||
def write_exact_values(operator: Operator, contract: dict[str, Any], values: dict[str, str]) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="railiance-custody-values-") as directory:
|
||||
root = Path(directory)
|
||||
os.chmod(root, 0o700)
|
||||
files: list[Path] = []
|
||||
try:
|
||||
for handle, path in token_paths(contract).items():
|
||||
document = root / handle
|
||||
document.write_text(values[handle], encoding="utf-8")
|
||||
os.chmod(document, 0o600)
|
||||
files.append(document)
|
||||
operator.bao(
|
||||
[
|
||||
"kv",
|
||||
"put",
|
||||
path,
|
||||
f"token=@{document}",
|
||||
f"engagement_id={contract['engagement_id']}",
|
||||
f"expires_at={contract['window']['expires_at']}",
|
||||
],
|
||||
label=f"write exact {handle} KV path",
|
||||
)
|
||||
finally:
|
||||
for document in files:
|
||||
secure_unlink(document)
|
||||
|
||||
|
||||
def configure_projection(operator: Operator, contract: dict[str, Any], lease_id: str) -> None:
|
||||
names = resource_names(contract)
|
||||
authority = contract["authority"]
|
||||
runner = contract["runner"]
|
||||
operator.bao(
|
||||
["policy", "write", names["policy"], "-"],
|
||||
label="write exact ESO policy",
|
||||
input_text=build_policy(contract),
|
||||
)
|
||||
operator.bao(
|
||||
[
|
||||
"write",
|
||||
f"auth/kubernetes/role/{names['role']}",
|
||||
f"bound_service_account_names={authority['eso_service_account']}",
|
||||
f"bound_service_account_namespaces={authority['eso_namespace']}",
|
||||
f"policies={names['policy']}",
|
||||
"ttl=5m",
|
||||
"max_ttl=15m",
|
||||
],
|
||||
label="write exact ESO Kubernetes role",
|
||||
)
|
||||
operator.kubectl(
|
||||
["apply", "-f", "-"],
|
||||
label="apply exact projection manifest",
|
||||
input_text=build_manifest(contract, lease_id),
|
||||
)
|
||||
operator.kubectl(
|
||||
["wait", "--for=condition=Ready", f"clustersecretstore/{names['store']}", "--timeout=90s"],
|
||||
label="wait for exact store",
|
||||
)
|
||||
operator.kubectl(
|
||||
[
|
||||
"-n",
|
||||
runner["namespace"],
|
||||
"wait",
|
||||
"--for=condition=Ready",
|
||||
f"externalsecret/{names['external_secret']}",
|
||||
"--timeout=90s",
|
||||
],
|
||||
label="wait for exact ExternalSecret",
|
||||
)
|
||||
keys = operator.kubectl(
|
||||
[
|
||||
"-n",
|
||||
runner["namespace"],
|
||||
"get",
|
||||
"secret",
|
||||
runner["secret_name"],
|
||||
"-o",
|
||||
'go-template={{range $k, $_ := .data}}{{$k}}{{"\\n"}}{{end}}',
|
||||
],
|
||||
label="verify projected key names",
|
||||
).stdout.splitlines()
|
||||
expected = sorted(identity["handle"] for identity in contract["identities"])
|
||||
if sorted(keys) != expected:
|
||||
raise ProcedureError(f"projected Secret has unexpected key names: {sorted(keys)}")
|
||||
|
||||
|
||||
def force_sender_sync_and_restart(operator: Operator, contract: dict[str, Any]) -> None:
|
||||
target = contract["target"]
|
||||
namespace = target["namespace"]
|
||||
external_secret = target["sender_external_secret"]
|
||||
before = operator.kubectl(
|
||||
["-n", namespace, "get", "secret", external_secret, "-o", "jsonpath={.metadata.resourceVersion}"],
|
||||
label="read sender Secret resource version",
|
||||
).stdout.strip()
|
||||
operator.kubectl(
|
||||
[
|
||||
"-n",
|
||||
namespace,
|
||||
"annotate",
|
||||
"externalsecret",
|
||||
external_secret,
|
||||
f"force-sync={int(time.time())}",
|
||||
"--overwrite",
|
||||
],
|
||||
label="force sender registry sync",
|
||||
)
|
||||
deadline = time.monotonic() + 120
|
||||
while time.monotonic() < deadline:
|
||||
current = operator.kubectl(
|
||||
["-n", namespace, "get", "secret", external_secret, "-o", "jsonpath={.metadata.resourceVersion}"],
|
||||
label="poll sender Secret resource version",
|
||||
).stdout.strip()
|
||||
if current and current != before:
|
||||
break
|
||||
time.sleep(2)
|
||||
else:
|
||||
raise ProcedureError("sender Secret did not refresh within 120 seconds")
|
||||
operator.kubectl(
|
||||
["-n", namespace, "rollout", "restart", f"deploy/{target['deployment']}"],
|
||||
label="restart sender registry reader",
|
||||
)
|
||||
operator.kubectl(
|
||||
["-n", namespace, "rollout", "status", f"deploy/{target['deployment']}", "--timeout=120s"],
|
||||
label="wait for target rollout",
|
||||
)
|
||||
|
||||
|
||||
def delete_projection(operator: Operator, contract: dict[str, Any]) -> None:
|
||||
names = resource_names(contract)
|
||||
runner = contract["runner"]
|
||||
operator.kubectl(
|
||||
["-n", runner["namespace"], "delete", "externalsecret", names["external_secret"], "--ignore-not-found"],
|
||||
label="delete exact ExternalSecret",
|
||||
)
|
||||
operator.kubectl(
|
||||
["-n", runner["namespace"], "delete", "secret", runner["secret_name"], "--ignore-not-found"],
|
||||
label="delete exact mounted Secret",
|
||||
)
|
||||
operator.kubectl(
|
||||
["delete", "clustersecretstore", names["store"], "--ignore-not-found"],
|
||||
label="delete exact store",
|
||||
)
|
||||
for handle, path in token_paths(contract).items():
|
||||
if operator.bao_exists(
|
||||
["kv", "metadata", "get", "-format=json", path],
|
||||
label=f"check exact {handle} metadata before delete",
|
||||
):
|
||||
operator.bao(["kv", "metadata", "delete", path], label=f"delete exact {handle} KV path")
|
||||
if operator.bao_exists(["read", f"auth/kubernetes/role/{names['role']}"] , label="check exact role"):
|
||||
operator.bao(["delete", f"auth/kubernetes/role/{names['role']}"] , label="delete exact role")
|
||||
if operator.bao_exists(["policy", "read", names["policy"]], label="check exact policy"):
|
||||
operator.bao(["policy", "delete", names["policy"]], label="delete exact policy")
|
||||
|
||||
|
||||
def verify_cleanup_absent(operator: Operator, contract: dict[str, Any]) -> None:
|
||||
operator.bao(["status", "-format=json"], label="verify OpenBao after cleanup")
|
||||
operator.kubectl(["get", "ns", contract["runner"]["namespace"], "-o", "name"], label="verify Kubernetes after cleanup")
|
||||
presence = resource_presence(operator, contract)
|
||||
if any(presence.values()):
|
||||
raise ProcedureError(f"engagement resources remain after cleanup: {presence}")
|
||||
|
||||
|
||||
def cleanup_scope(operator: Operator, contract: dict[str, Any]) -> dict[str, Any]:
|
||||
registry = operator.registry()
|
||||
updated, removed = remove_temporary_identities(registry, contract)
|
||||
if removed:
|
||||
operator.write_registry(updated)
|
||||
force_sender_sync_and_restart(operator, contract)
|
||||
delete_projection(operator, contract)
|
||||
verify_cleanup_absent(operator, contract)
|
||||
target_ready(operator, contract)
|
||||
return {
|
||||
"removed_identities": removed,
|
||||
"removed_resources": sorted(
|
||||
[
|
||||
*token_paths(contract).values(),
|
||||
*resource_names(contract).values(),
|
||||
]
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def transactional(
|
||||
steps: list[Callable[[], None]], rollback: Callable[[], Any]
|
||||
) -> None:
|
||||
try:
|
||||
for step in steps:
|
||||
step()
|
||||
except Exception as original:
|
||||
try:
|
||||
rollback()
|
||||
except Exception as cleanup_error:
|
||||
raise ProcedureError(
|
||||
"projection failed and exact cleanup could not be proven"
|
||||
) from cleanup_error
|
||||
raise original
|
||||
|
||||
|
||||
def resource_uids(
|
||||
operator: Operator, contract: dict[str, Any], *, require_all: bool = True
|
||||
) -> dict[str, str]:
|
||||
names = resource_names(contract)
|
||||
runner = contract["runner"]
|
||||
requests = {
|
||||
"store": [
|
||||
"get", "clustersecretstore", names["store"], "--ignore-not-found",
|
||||
"-o", "jsonpath={.metadata.uid}",
|
||||
],
|
||||
"external_secret": [
|
||||
"-n", runner["namespace"], "get", "externalsecret", names["external_secret"],
|
||||
"--ignore-not-found",
|
||||
"-o", "jsonpath={.metadata.uid}",
|
||||
],
|
||||
"mounted_secret": [
|
||||
"-n", runner["namespace"], "get", "secret", runner["secret_name"],
|
||||
"--ignore-not-found",
|
||||
"-o", "jsonpath={.metadata.uid}",
|
||||
],
|
||||
}
|
||||
result = {
|
||||
key: operator.kubectl(args, label=f"read exact {key} UID").stdout.strip()
|
||||
for key, args in requests.items()
|
||||
}
|
||||
if require_all and any(not value for value in result.values()):
|
||||
raise ProcedureError("projection resource UID evidence is incomplete")
|
||||
return {key: value for key, value in result.items() if value}
|
||||
|
||||
|
||||
def verify_receipt_resource_scope(
|
||||
operator: Operator, contract: dict[str, Any], receipt: dict[str, Any]
|
||||
) -> dict[str, str]:
|
||||
present = resource_uids(operator, contract, require_all=False)
|
||||
expected = receipt["resources"]["uids"]
|
||||
mismatched = {
|
||||
key: value
|
||||
for key, value in present.items()
|
||||
if expected.get(key) != value
|
||||
}
|
||||
if mismatched:
|
||||
raise ProcedureError(
|
||||
"live projection resource UID differs from the cleanup receipt; refusing deletion"
|
||||
)
|
||||
return present
|
||||
|
||||
|
||||
def project(
|
||||
operator: Operator,
|
||||
contract: dict[str, Any],
|
||||
*,
|
||||
api_base: str = DEFAULT_API_BASE,
|
||||
now: datetime | None = None,
|
||||
lease_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
current = (now or utc_now()).astimezone(UTC)
|
||||
if time_state(contract, current) != "projection-window-open":
|
||||
raise ProcedureError("projection is outside the approved projection window")
|
||||
# This is intentionally before token generation and every mutation.
|
||||
broker_receipt = current_broker_receipt(contract, api_base, now=current)
|
||||
preflight = live_preflight(operator, contract, api_base=api_base, now=current)
|
||||
if not preflight["broker_ready"]:
|
||||
raise ProcedureError("consumer broker is not ready")
|
||||
if any(
|
||||
preflight[key]
|
||||
for key in (
|
||||
"temporary_identities_present",
|
||||
"exact_token_paths_present",
|
||||
"projection_resources_present",
|
||||
)
|
||||
):
|
||||
raise ProcedureError("cleanup is required before a new projection")
|
||||
active_lease = lease_id or f"custody:{secrets.token_hex(16)}"
|
||||
values = {identity["handle"]: secrets.token_urlsafe(48) for identity in contract["identities"]}
|
||||
if len(set(values.values())) != len(values):
|
||||
raise ProcedureError("credential generator returned duplicate values")
|
||||
try:
|
||||
transactional(
|
||||
[
|
||||
lambda: write_exact_values(operator, contract, values),
|
||||
lambda: operator.write_registry(
|
||||
add_temporary_identities(operator.registry(), contract, values)
|
||||
),
|
||||
lambda: configure_projection(operator, contract, active_lease),
|
||||
lambda: force_sender_sync_and_restart(operator, contract),
|
||||
],
|
||||
lambda: cleanup_scope(operator, contract),
|
||||
)
|
||||
finally:
|
||||
values.clear()
|
||||
projected_at = rfc3339(utc_now())
|
||||
base = {
|
||||
"interface": PROJECTION_INTERFACE,
|
||||
"version": 1,
|
||||
"workplan_id": WORKPLAN_ID,
|
||||
"state": "projected",
|
||||
"lease_id": active_lease,
|
||||
"engagement_id": contract["engagement_id"],
|
||||
"target": {
|
||||
"id": contract["target"]["id"],
|
||||
"revision": contract["target"]["revision"],
|
||||
"image_digest": contract["target"]["image_digest"],
|
||||
},
|
||||
"projection_contract_digest": contract_digest(contract),
|
||||
"broker_receipt_digest": digest(broker_receipt),
|
||||
"projected_at": projected_at,
|
||||
"expires_at": contract["window"]["expires_at"],
|
||||
"identities": sorted(
|
||||
(
|
||||
{
|
||||
"handle": item["handle"],
|
||||
"role": item["role"],
|
||||
"sender_name": item["sender_name"],
|
||||
"mount_path": item["mount_path"],
|
||||
}
|
||||
for item in contract["identities"]
|
||||
),
|
||||
key=lambda item: item["handle"],
|
||||
),
|
||||
"resources": {
|
||||
"names": resource_names(contract),
|
||||
"uids": resource_uids(operator, contract),
|
||||
},
|
||||
"cleanup_authority": "railiance-platform",
|
||||
"secret_values_observed": False,
|
||||
}
|
||||
receipt = {**base, "receipt_id": f"sha256:{digest(base)}"}
|
||||
validate_projection_receipt(receipt, contract)
|
||||
return receipt
|
||||
|
||||
|
||||
def projection_status(
|
||||
operator: Operator, contract: dict[str, Any], receipt: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
validate_projection_receipt(receipt, contract)
|
||||
presence = resource_presence(operator, contract)
|
||||
counts = [
|
||||
len(presence["temporary_identities_present"]),
|
||||
len(presence["exact_token_paths_present"]),
|
||||
len(presence["projection_resources_present"]),
|
||||
]
|
||||
state = "active" if counts == [2, 2, 3] else "absent" if counts == [0, 0, 0] else "partial"
|
||||
uid_match = False
|
||||
present_uids = resource_uids(operator, contract, require_all=False)
|
||||
uid_match = bool(present_uids) and all(
|
||||
receipt["resources"]["uids"].get(key) == value
|
||||
for key, value in present_uids.items()
|
||||
)
|
||||
if present_uids and not uid_match:
|
||||
state = "mismatched"
|
||||
return {
|
||||
"interface": "railiance.custody-projection-status",
|
||||
"version": 1,
|
||||
"engagement_id": contract["engagement_id"],
|
||||
"lease_id": receipt["lease_id"],
|
||||
"state": state,
|
||||
"resource_uids_match": uid_match,
|
||||
**presence,
|
||||
"target_ready": target_ready(operator, contract),
|
||||
"secret_values_observed": False,
|
||||
}
|
||||
|
||||
|
||||
def cleanup(
|
||||
operator: Operator, contract: dict[str, Any], projection: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
validate_projection_receipt(projection, contract)
|
||||
verify_receipt_resource_scope(operator, contract, projection)
|
||||
result = cleanup_scope(operator, contract)
|
||||
receipt = {
|
||||
"interface": CLEANUP_INTERFACE,
|
||||
"version": 1,
|
||||
"workplan_id": WORKPLAN_ID,
|
||||
"state": "cleaned",
|
||||
"lease_id": projection["lease_id"],
|
||||
"engagement_id": contract["engagement_id"],
|
||||
"projection_receipt_id": projection["receipt_id"],
|
||||
"cleaned_at": rfc3339(utc_now()),
|
||||
"removed_resources": result["removed_resources"],
|
||||
"target_ready": True,
|
||||
"secret_values_observed": False,
|
||||
}
|
||||
validate_cleanup_receipt(receipt, projection, contract)
|
||||
return receipt
|
||||
|
||||
|
||||
def assert_confirm(value: str | None, expected: str) -> None:
|
||||
if value != expected:
|
||||
raise ProcedureError(f"live command requires --confirm {expected}")
|
||||
|
||||
|
||||
def assert_expired_cleanup(contract: dict[str, Any], now: datetime) -> None:
|
||||
expires = parse_time(contract["window"]["expires_at"], "window.expires_at")
|
||||
if now.astimezone(UTC) <= expires:
|
||||
raise ProcedureError("cleanup-expired refuses before receipt expiry")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"command",
|
||||
choices=["validate", "render", "preflight", "project", "status", "cleanup", "cleanup-expired"],
|
||||
)
|
||||
parser.add_argument("--contract", type=Path, required=True)
|
||||
parser.add_argument("--receipt", type=Path)
|
||||
parser.add_argument("--receipt-out", type=Path)
|
||||
parser.add_argument("--token-file", type=Path, default=Path.home() / ".local/openbao/platform-admin.token")
|
||||
parser.add_argument("--state-hub", default=DEFAULT_API_BASE)
|
||||
parser.add_argument("--confirm")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
contract = validate_projection_contract(load_json(args.contract))
|
||||
if args.command == "validate":
|
||||
result = {
|
||||
"valid": True,
|
||||
"projection_contract_digest": contract_digest(contract),
|
||||
"resource_names": resource_names(contract),
|
||||
"secret_values_observed": False,
|
||||
}
|
||||
elif args.command == "render":
|
||||
result = {
|
||||
"policy": build_policy(contract),
|
||||
"manifest": json.loads(build_manifest(contract, "custody:" + "0" * 32)),
|
||||
"secret_values_observed": False,
|
||||
}
|
||||
else:
|
||||
projection = None
|
||||
if args.command in {"status", "cleanup", "cleanup-expired"}:
|
||||
if not args.receipt:
|
||||
raise ProcedureError(f"{args.command} requires --receipt")
|
||||
projection = validate_projection_receipt(load_json(args.receipt), contract)
|
||||
if args.command == "cleanup-expired":
|
||||
assert_expired_cleanup(contract, utc_now())
|
||||
if args.command == "project":
|
||||
# Reject an unready consumer before reading platform-admin
|
||||
# custody material. project() repeats this immutable gate.
|
||||
current_broker_receipt(contract, args.state_hub)
|
||||
operator = Operator(contract, args.token_file)
|
||||
if args.command == "preflight":
|
||||
result = live_preflight(operator, contract, api_base=args.state_hub)
|
||||
elif args.command == "project":
|
||||
assert_confirm(args.confirm, f"{contract['engagement_id']}:attended")
|
||||
result = project(operator, contract, api_base=args.state_hub)
|
||||
else:
|
||||
assert projection is not None
|
||||
if args.command == "status":
|
||||
result = projection_status(operator, contract, projection)
|
||||
else:
|
||||
if args.command == "cleanup-expired":
|
||||
assert_confirm(args.confirm, f"{contract['engagement_id']}:expired")
|
||||
else:
|
||||
assert_confirm(args.confirm, f"{contract['engagement_id']}:cleanup")
|
||||
result = cleanup(operator, contract, projection)
|
||||
if args.receipt_out:
|
||||
write_receipt(args.receipt_out, result)
|
||||
print(json.dumps(result, indent=2, sort_keys=True))
|
||||
return 0
|
||||
except (ContractError, OSError, ProcedureError, RemoteExecutionError) as exc:
|
||||
print(f"custody projection failed: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
444
scripts/custody_contract.py
Executable file
444
scripts/custody_contract.py
Executable file
|
|
@ -0,0 +1,444 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Canonical, value-safe contracts for ephemeral credential projection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
CONTRACT_INTERFACE = "railiance.custody-projection-contract"
|
||||
BROKER_INTERFACE = "railiance.custody-broker-readiness"
|
||||
PROJECTION_INTERFACE = "railiance.custody-projection-receipt"
|
||||
CLEANUP_INTERFACE = "railiance.custody-cleanup-receipt"
|
||||
WORKPLAN_ID = "RAILIANCE-WP-0025"
|
||||
BROKER_OWNER = "whitehat-security"
|
||||
BROKER_SUBJECT_PREFIX = "WP0025-BROKER-READINESS"
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
INTERFACE_ARTIFACTS = (
|
||||
"schemas/custody-projection-contract.schema.json",
|
||||
"schemas/custody-broker-readiness.schema.json",
|
||||
"schemas/custody-projection-receipt.schema.json",
|
||||
"schemas/custody-cleanup-receipt.schema.json",
|
||||
)
|
||||
SHA256 = re.compile(r"(?:sha256:)?[0-9a-f]{64}\Z")
|
||||
GIT_REVISION = re.compile(r"[0-9a-f]{40}\Z")
|
||||
DNS_LABEL = re.compile(r"[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?\Z")
|
||||
|
||||
|
||||
class ContractError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def canonical_json(value: Any) -> str:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
|
||||
|
||||
|
||||
def digest(value: Any) -> str:
|
||||
return hashlib.sha256(canonical_json(value).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def file_digest(path: Path) -> str:
|
||||
result = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for block in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
result.update(block)
|
||||
return result.hexdigest()
|
||||
|
||||
|
||||
def parse_time(value: Any, field: str) -> datetime:
|
||||
if not isinstance(value, str):
|
||||
raise ContractError(f"{field} must be an RFC3339 timestamp")
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise ContractError(f"{field} must be an RFC3339 timestamp") from exc
|
||||
if parsed.tzinfo is None:
|
||||
raise ContractError(f"{field} must include a timezone")
|
||||
return parsed.astimezone(UTC)
|
||||
|
||||
|
||||
def load_json(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise ContractError(f"JSON document is unavailable or invalid: {path}") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise ContractError("JSON document must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def _require_string(value: dict[str, Any], key: str) -> str:
|
||||
observed = value.get(key)
|
||||
if not isinstance(observed, str) or not observed:
|
||||
raise ContractError(f"{key} must be a non-empty string")
|
||||
return observed
|
||||
|
||||
|
||||
def _require_sha(value: dict[str, Any], key: str) -> str:
|
||||
observed = _require_string(value, key)
|
||||
if not SHA256.fullmatch(observed):
|
||||
raise ContractError(f"{key} must be a SHA-256 digest")
|
||||
return observed.removeprefix("sha256:")
|
||||
|
||||
|
||||
def resource_suffix(engagement_id: str) -> str:
|
||||
return hashlib.sha256(engagement_id.encode("utf-8")).hexdigest()[:12]
|
||||
|
||||
|
||||
def interface_artifacts(root: Path = REPO_ROOT) -> dict[str, str]:
|
||||
return {path: file_digest(root / path) for path in INTERFACE_ARTIFACTS}
|
||||
|
||||
|
||||
def resource_names(contract: dict[str, Any]) -> dict[str, str]:
|
||||
suffix = resource_suffix(contract["engagement_id"])
|
||||
return {
|
||||
"policy": f"custody-{suffix}",
|
||||
"role": f"custody-{suffix}",
|
||||
"store": f"custody-{suffix}",
|
||||
"external_secret": contract["runner"]["secret_name"],
|
||||
}
|
||||
|
||||
|
||||
def validate_projection_contract(value: dict[str, Any]) -> dict[str, Any]:
|
||||
if value.get("interface") != CONTRACT_INTERFACE or value.get("version") != 1:
|
||||
raise ContractError("unsupported projection contract interface/version")
|
||||
if value.get("workplan_id") != WORKPLAN_ID:
|
||||
raise ContractError("projection contract has the wrong workplan")
|
||||
engagement_id = _require_string(value, "engagement_id")
|
||||
if not re.fullmatch(r"[A-Z0-9][A-Z0-9._-]{7,127}", engagement_id):
|
||||
raise ContractError("engagement_id has an invalid shape")
|
||||
if value.get("status") != "approved":
|
||||
raise ContractError("engagement must be approved")
|
||||
|
||||
target = value.get("target")
|
||||
runner = value.get("runner")
|
||||
window = value.get("window")
|
||||
authority = value.get("authority")
|
||||
identities = value.get("identities")
|
||||
if not all(isinstance(item, dict) for item in (target, runner, window, authority)):
|
||||
raise ContractError("target, runner, window and authority must be objects")
|
||||
if not isinstance(identities, list) or len(identities) != 2:
|
||||
raise ContractError("exactly two identity handles are required")
|
||||
|
||||
target_id = _require_string(target, "id")
|
||||
target_namespace = _require_string(target, "namespace")
|
||||
target_deployment = _require_string(target, "deployment")
|
||||
_require_string(target, "container")
|
||||
_require_string(target, "sender_external_secret")
|
||||
if not DNS_LABEL.fullmatch(target_namespace) or not DNS_LABEL.fullmatch(target_deployment):
|
||||
raise ContractError("target namespace and deployment must be DNS labels")
|
||||
revision = _require_string(target, "revision")
|
||||
if not GIT_REVISION.fullmatch(revision):
|
||||
raise ContractError("target.revision must be a full Git revision")
|
||||
image = _require_string(target, "image_digest")
|
||||
if not re.fullmatch(r"sha256:[0-9a-f]{64}", image):
|
||||
raise ContractError("target.image_digest must be a SHA-256 image digest")
|
||||
_require_sha(target, "contract_sha256")
|
||||
_require_sha(runner, "manifest_sha256")
|
||||
_require_sha(value, "engagement_contract_sha256")
|
||||
|
||||
namespace = _require_string(runner, "namespace")
|
||||
secret_name = _require_string(runner, "secret_name")
|
||||
_require_string(runner, "service_account")
|
||||
mount_root = _require_string(runner, "mount_root").rstrip("/")
|
||||
if not DNS_LABEL.fullmatch(namespace) or not DNS_LABEL.fullmatch(secret_name):
|
||||
raise ContractError("runner namespace and secret_name must be DNS labels")
|
||||
if not mount_root.startswith("/") or ".." in Path(mount_root).parts:
|
||||
raise ContractError("runner.mount_root must be an absolute safe path")
|
||||
|
||||
starts = parse_time(window.get("starts_at"), "window.starts_at")
|
||||
cutoff = parse_time(window.get("projection_cutoff"), "window.projection_cutoff")
|
||||
expires = parse_time(window.get("expires_at"), "window.expires_at")
|
||||
if not starts < cutoff < expires:
|
||||
raise ContractError("window must satisfy starts_at < projection_cutoff < expires_at")
|
||||
if (expires - starts).total_seconds() > 900:
|
||||
raise ContractError("ephemeral custody window may not exceed 900 seconds")
|
||||
|
||||
remote = _require_string(authority, "remote")
|
||||
if any(character.isspace() for character in remote):
|
||||
raise ContractError("authority.remote must be one SSH destination")
|
||||
registry_path = _require_string(authority, "registry_path")
|
||||
registry_field = _require_string(authority, "registry_field")
|
||||
kv_mount = _require_string(authority, "kv_mount")
|
||||
kv_prefix = _require_string(authority, "kv_prefix").strip("/")
|
||||
_require_string(authority, "eso_service_account")
|
||||
_require_string(authority, "eso_namespace")
|
||||
if engagement_id not in kv_prefix or target_id not in kv_prefix:
|
||||
raise ContractError("authority.kv_prefix must bind engagement_id and target id")
|
||||
if "/" in kv_mount or not registry_path or "/" in registry_field:
|
||||
raise ContractError("invalid KV mount or registry field")
|
||||
|
||||
expected_handles = {"token-a", "token-b"}
|
||||
handles: set[str] = set()
|
||||
roles: set[str] = set()
|
||||
senders: set[str] = set()
|
||||
tenants: set[str] = set()
|
||||
for identity in identities:
|
||||
if not isinstance(identity, dict):
|
||||
raise ContractError("identity entries must be objects")
|
||||
handle = _require_string(identity, "handle")
|
||||
role = _require_string(identity, "role")
|
||||
sender = _require_string(identity, "sender_name")
|
||||
tenant = _require_string(identity, "tenant")
|
||||
mount_path = _require_string(identity, "mount_path")
|
||||
if identity.get("may_read") is not True or identity.get("may_write") is not True:
|
||||
raise ContractError("temporary identities require may_read=true and may_write=true")
|
||||
if mount_path != f"{mount_root}/{handle}":
|
||||
raise ContractError("identity mount_path must be mount_root plus its handle")
|
||||
handles.add(handle)
|
||||
roles.add(role)
|
||||
senders.add(sender)
|
||||
tenants.add(tenant)
|
||||
if handles != expected_handles or roles != {"attacker", "owner"}:
|
||||
raise ContractError("identities must provide token-a/token-b and attacker/owner roles")
|
||||
if len(senders) != 2 or len(tenants) != 2:
|
||||
raise ContractError("sender names and tenants must be distinct")
|
||||
|
||||
names = resource_names(value)
|
||||
if any(not DNS_LABEL.fullmatch(name) for name in names.values()):
|
||||
raise ContractError("derived resource name is not a DNS label")
|
||||
return value
|
||||
|
||||
|
||||
def contract_digest(contract: dict[str, Any]) -> str:
|
||||
validate_projection_contract(contract)
|
||||
return digest(contract)
|
||||
|
||||
|
||||
def assert_value_safe(value: Any) -> None:
|
||||
forbidden_keys = {
|
||||
"token",
|
||||
"tokens",
|
||||
"password",
|
||||
"secret_value",
|
||||
"secret_values",
|
||||
"bearer",
|
||||
"private_key",
|
||||
}
|
||||
if isinstance(value, dict):
|
||||
for key, item in value.items():
|
||||
if str(key).lower() in forbidden_keys:
|
||||
raise ContractError(f"value-bearing field is forbidden: {key}")
|
||||
assert_value_safe(item)
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
assert_value_safe(item)
|
||||
|
||||
|
||||
def broker_subject(receipt: dict[str, Any]) -> str:
|
||||
return "/".join(
|
||||
(
|
||||
BROKER_SUBJECT_PREFIX,
|
||||
"v1",
|
||||
receipt["engagement_id"],
|
||||
receipt["decision"],
|
||||
receipt["projection_contract_digest"],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def validate_broker_receipt(
|
||||
receipt: dict[str, Any], contract: dict[str, Any], *, now: datetime | None = None
|
||||
) -> dict[str, Any]:
|
||||
validate_projection_contract(contract)
|
||||
assert_value_safe(receipt)
|
||||
if receipt.get("interface") != BROKER_INTERFACE or receipt.get("version") != 1:
|
||||
raise ContractError("unsupported broker receipt interface/version")
|
||||
if receipt.get("workplan_id") != WORKPLAN_ID or receipt.get("owner") != BROKER_OWNER:
|
||||
raise ContractError("broker receipt has the wrong workplan or owner")
|
||||
if receipt.get("decision") != "approve":
|
||||
raise ContractError("broker receipt is not approved")
|
||||
if receipt.get("engagement_id") != contract["engagement_id"]:
|
||||
raise ContractError("broker receipt engagement does not match")
|
||||
if receipt.get("target_id") != contract["target"]["id"]:
|
||||
raise ContractError("broker receipt target does not match")
|
||||
if receipt.get("projection_contract_digest") != contract_digest(contract):
|
||||
raise ContractError("broker receipt projection contract is stale")
|
||||
if receipt.get("projection_receipt_interface") != PROJECTION_INTERFACE:
|
||||
raise ContractError("broker receipt cannot consume projection receipt v1")
|
||||
if receipt.get("interface_artifacts") != interface_artifacts():
|
||||
raise ContractError("broker receipt interface artifacts are stale")
|
||||
if receipt.get("required_roles") != ["attacker", "owner"]:
|
||||
raise ContractError("broker receipt has the wrong identity roles")
|
||||
expected_mounts = sorted(identity["mount_path"] for identity in contract["identities"])
|
||||
if receipt.get("mount_paths") != expected_mounts:
|
||||
raise ContractError("broker receipt mount paths do not match")
|
||||
adapter = receipt.get("adapter")
|
||||
if not isinstance(adapter, dict):
|
||||
raise ContractError("broker receipt requires adapter evidence")
|
||||
adapter_path = adapter.get("path")
|
||||
if (
|
||||
adapter.get("repo") != "whitehat-security"
|
||||
or not isinstance(adapter_path, str)
|
||||
or not adapter_path
|
||||
or Path(adapter_path).is_absolute()
|
||||
or ".." in Path(adapter_path).parts
|
||||
):
|
||||
raise ContractError("broker adapter repo/path is invalid")
|
||||
if not GIT_REVISION.fullmatch(str(adapter.get("revision", ""))):
|
||||
raise ContractError("broker adapter revision must be a full Git revision")
|
||||
if not SHA256.fullmatch(str(adapter.get("sha256", ""))):
|
||||
raise ContractError("broker adapter requires a SHA-256 digest")
|
||||
if adapter.get("tests_passed") is not True or receipt.get("cleanup_request_supported") is not True:
|
||||
raise ContractError("broker adapter tests and cleanup support are required")
|
||||
if receipt.get("secret_values_observed") is not False:
|
||||
raise ContractError("broker receipt is not value-safe")
|
||||
created = parse_time(receipt.get("created_at"), "broker.created_at")
|
||||
current = (now or datetime.now(UTC)).astimezone(UTC)
|
||||
starts = parse_time(contract["window"]["starts_at"], "window.starts_at")
|
||||
expires = parse_time(contract["window"]["expires_at"], "window.expires_at")
|
||||
if created > current or created > expires or created < starts.replace(hour=0, minute=0, second=0, microsecond=0):
|
||||
raise ContractError("broker receipt timestamp is outside the engagement day")
|
||||
return receipt
|
||||
|
||||
|
||||
def parse_broker_message(
|
||||
message: dict[str, Any], contract: dict[str, Any], *, now: datetime | None = None
|
||||
) -> dict[str, Any] | None:
|
||||
if message.get("from_agent") != BROKER_OWNER or message.get("to_agent") != "railiance-platform":
|
||||
return None
|
||||
body = message.get("body")
|
||||
if not isinstance(body, str):
|
||||
return None
|
||||
try:
|
||||
receipt = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
if not isinstance(receipt, dict):
|
||||
return None
|
||||
try:
|
||||
if message.get("subject") != broker_subject(receipt):
|
||||
return None
|
||||
return validate_broker_receipt(receipt, contract, now=now)
|
||||
except (ContractError, KeyError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def http_json(
|
||||
method: str,
|
||||
url: str,
|
||||
payload: dict[str, Any] | None = None,
|
||||
*,
|
||||
opener: Callable[..., Any] = urllib.request.urlopen,
|
||||
) -> Any:
|
||||
data = canonical_json(payload).encode("utf-8") if payload is not None else None
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
data=data,
|
||||
method=method,
|
||||
headers={"Content-Type": "application/json"} if data else {},
|
||||
)
|
||||
try:
|
||||
with opener(request, timeout=10) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
except (OSError, urllib.error.URLError, json.JSONDecodeError) as exc:
|
||||
raise ContractError(f"State Hub request failed: {method} {url}") from exc
|
||||
|
||||
|
||||
def current_broker_receipt(
|
||||
contract: dict[str, Any], api_base: str, *, now: datetime | None = None
|
||||
) -> dict[str, Any]:
|
||||
query = urllib.parse.urlencode(
|
||||
{"to_agent": "railiance-platform", "unread_only": "false"}
|
||||
)
|
||||
messages = http_json("GET", f"{api_base.rstrip('/')}/messages/?{query}")
|
||||
if not isinstance(messages, list):
|
||||
raise ContractError("State Hub returned an invalid message list")
|
||||
receipts = [
|
||||
parsed
|
||||
for message in messages
|
||||
if isinstance(message, dict)
|
||||
and (parsed := parse_broker_message(message, contract, now=now)) is not None
|
||||
]
|
||||
if not receipts:
|
||||
raise ContractError("no current Whitehat broker-readiness receipt exists")
|
||||
return max(receipts, key=lambda item: item["created_at"])
|
||||
|
||||
|
||||
def validate_projection_receipt(
|
||||
receipt: dict[str, Any], contract: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
validate_projection_contract(contract)
|
||||
assert_value_safe(receipt)
|
||||
if receipt.get("interface") != PROJECTION_INTERFACE or receipt.get("version") != 1:
|
||||
raise ContractError("unsupported projection receipt interface/version")
|
||||
if receipt.get("workplan_id") != WORKPLAN_ID or receipt.get("state") != "projected":
|
||||
raise ContractError("projection receipt has the wrong workplan or state")
|
||||
if receipt.get("engagement_id") != contract["engagement_id"]:
|
||||
raise ContractError("projection receipt engagement does not match")
|
||||
if receipt.get("projection_contract_digest") != contract_digest(contract):
|
||||
raise ContractError("projection receipt contract is stale")
|
||||
expected_target = {
|
||||
"id": contract["target"]["id"],
|
||||
"revision": contract["target"]["revision"],
|
||||
"image_digest": contract["target"]["image_digest"],
|
||||
}
|
||||
if receipt.get("target") != expected_target:
|
||||
raise ContractError("projection receipt target does not match")
|
||||
lease_id = receipt.get("lease_id")
|
||||
if not isinstance(lease_id, str) or not re.fullmatch(r"custody:[0-9a-f]{32}", lease_id):
|
||||
raise ContractError("projection receipt lease_id is invalid")
|
||||
if receipt.get("secret_values_observed") is not False:
|
||||
raise ContractError("projection receipt is not value-safe")
|
||||
projected = parse_time(receipt.get("projected_at"), "projection.projected_at")
|
||||
expires = parse_time(receipt.get("expires_at"), "projection.expires_at")
|
||||
contract_expires = parse_time(contract["window"]["expires_at"], "window.expires_at")
|
||||
if expires != contract_expires or projected >= expires:
|
||||
raise ContractError("projection receipt has invalid projection/expiry times")
|
||||
identities = receipt.get("identities")
|
||||
expected_identities = sorted(
|
||||
(
|
||||
{
|
||||
"handle": item["handle"],
|
||||
"role": item["role"],
|
||||
"sender_name": item["sender_name"],
|
||||
"mount_path": item["mount_path"],
|
||||
}
|
||||
for item in contract["identities"]
|
||||
),
|
||||
key=lambda item: item["handle"],
|
||||
)
|
||||
if identities != expected_identities:
|
||||
raise ContractError("projection receipt identity handles do not match")
|
||||
resources = receipt.get("resources")
|
||||
if not isinstance(resources, dict) or resources.get("names") != resource_names(contract):
|
||||
raise ContractError("projection receipt resources do not match")
|
||||
uids = resources.get("uids")
|
||||
if not isinstance(uids, dict) or set(uids) != {"store", "external_secret", "mounted_secret"}:
|
||||
raise ContractError("projection receipt requires exact Kubernetes UIDs")
|
||||
if any(not isinstance(uid, str) or not uid for uid in uids.values()):
|
||||
raise ContractError("projection receipt contains an invalid Kubernetes UID")
|
||||
base = dict(receipt)
|
||||
observed_id = base.pop("receipt_id", None)
|
||||
if observed_id != f"sha256:{digest(base)}":
|
||||
raise ContractError("projection receipt id does not match its canonical content")
|
||||
return receipt
|
||||
|
||||
|
||||
def validate_cleanup_receipt(
|
||||
receipt: dict[str, Any], projection: dict[str, Any], contract: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
validate_projection_receipt(projection, contract)
|
||||
assert_value_safe(receipt)
|
||||
if receipt.get("interface") != CLEANUP_INTERFACE or receipt.get("version") != 1:
|
||||
raise ContractError("unsupported cleanup receipt interface/version")
|
||||
if receipt.get("workplan_id") != WORKPLAN_ID or receipt.get("state") != "cleaned":
|
||||
raise ContractError("cleanup receipt has the wrong workplan or state")
|
||||
if receipt.get("engagement_id") != contract["engagement_id"]:
|
||||
raise ContractError("cleanup receipt engagement does not match")
|
||||
if receipt.get("lease_id") != projection["lease_id"]:
|
||||
raise ContractError("cleanup receipt lease does not match")
|
||||
if receipt.get("projection_receipt_id") != projection["receipt_id"]:
|
||||
raise ContractError("cleanup receipt projection id does not match")
|
||||
parse_time(receipt.get("cleaned_at"), "cleanup.cleaned_at")
|
||||
if receipt.get("target_ready") is not True or receipt.get("secret_values_observed") is not False:
|
||||
raise ContractError("cleanup receipt does not prove safe target recovery")
|
||||
return receipt
|
||||
43
scripts/remote_exec.py
Executable file
43
scripts/remote_exec.py
Executable file
|
|
@ -0,0 +1,43 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Small, value-safe SSH argv transport shared by attended procedures."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shlex
|
||||
import subprocess
|
||||
from typing import Callable
|
||||
|
||||
|
||||
class RemoteExecutionError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def remote_command(argv: list[str]) -> str:
|
||||
if not argv or any(not isinstance(part, str) or "\0" in part for part in argv):
|
||||
raise RemoteExecutionError("remote argv must contain non-NUL strings")
|
||||
return shlex.join(argv)
|
||||
|
||||
|
||||
def run_remote(
|
||||
host: str,
|
||||
argv: list[str],
|
||||
*,
|
||||
label: str,
|
||||
input_text: str | None = None,
|
||||
allow_missing: bool = False,
|
||||
runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
if not host or any(character.isspace() for character in host):
|
||||
raise RemoteExecutionError("remote host must be a single SSH destination")
|
||||
completed = runner(
|
||||
["ssh", "-o", "BatchMode=yes", host, remote_command(argv)],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
input=input_text,
|
||||
check=False,
|
||||
)
|
||||
if completed.returncode and not (allow_missing and completed.returncode == 1):
|
||||
# Remote output may contain provider or application material. Keep the
|
||||
# durable error value-safe and let an attended operator inspect locally.
|
||||
raise RemoteExecutionError(f"{label} failed (exit {completed.returncode})")
|
||||
return completed
|
||||
257
scripts/wp0025-broker-readiness.py
Executable file
257
scripts/wp0025-broker-readiness.py
Executable file
|
|
@ -0,0 +1,257 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Direct Whitehat owner interface for WP-0025 broker readiness."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
if str(SCRIPT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPT_DIR))
|
||||
|
||||
from custody_contract import ( # noqa: E402
|
||||
BROKER_INTERFACE,
|
||||
BROKER_OWNER,
|
||||
PROJECTION_INTERFACE,
|
||||
WORKPLAN_ID,
|
||||
ContractError,
|
||||
broker_subject,
|
||||
canonical_json,
|
||||
contract_digest,
|
||||
current_broker_receipt,
|
||||
file_digest,
|
||||
http_json,
|
||||
interface_artifacts,
|
||||
load_json,
|
||||
validate_broker_receipt,
|
||||
validate_projection_contract,
|
||||
)
|
||||
|
||||
|
||||
DEFAULT_ADAPTER = Path("src/whitehat_security/platform_custody.py")
|
||||
DEFAULT_TEST = Path("tests/test_platform_custody_adapter.py")
|
||||
|
||||
|
||||
class ReadinessError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def validate_reviewer(value: str) -> str:
|
||||
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:@/+\-]{1,127}", value):
|
||||
raise ReadinessError("reviewer must be a stable 2-128 character identifier")
|
||||
return value
|
||||
|
||||
|
||||
def validate_note(value: str) -> str:
|
||||
note = value.strip()
|
||||
if not note or len(note) > 2000:
|
||||
raise ReadinessError("request-changes requires a note of 1-2000 characters")
|
||||
forbidden = ("BEGIN PRIVATE KEY", "BEGIN OPENSSH PRIVATE KEY", "AGE-SECRET-KEY-1", "hvs.")
|
||||
if any(marker in note for marker in forbidden):
|
||||
raise ReadinessError("note appears to contain credential material")
|
||||
return note
|
||||
|
||||
|
||||
def safe_child(root: Path, relative: Path) -> Path:
|
||||
if relative.is_absolute():
|
||||
raise ReadinessError("adapter and test paths must be repository-relative")
|
||||
target = (root / relative).resolve()
|
||||
try:
|
||||
target.relative_to(root.resolve())
|
||||
except ValueError as exc:
|
||||
raise ReadinessError("consumer artifact escapes repository root") from exc
|
||||
return target
|
||||
|
||||
|
||||
def verify_adapter(
|
||||
consumer_root: Path,
|
||||
*,
|
||||
adapter_path: Path = DEFAULT_ADAPTER,
|
||||
test_path: Path = DEFAULT_TEST,
|
||||
runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
|
||||
) -> dict[str, Any]:
|
||||
root = consumer_root.resolve()
|
||||
adapter = safe_child(root, adapter_path)
|
||||
test = safe_child(root, test_path)
|
||||
if not adapter.is_file() or not test.is_file():
|
||||
return {
|
||||
"passed": False,
|
||||
"adapter_present": adapter.is_file(),
|
||||
"test_present": test.is_file(),
|
||||
"secret_values_observed": False,
|
||||
}
|
||||
revision_result = runner(
|
||||
["git", "rev-parse", "HEAD"],
|
||||
cwd=root,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
revision = revision_result.stdout.strip() if revision_result.returncode == 0 else ""
|
||||
tests = runner(
|
||||
[sys.executable, "-m", "pytest", "-q", str(test_path)],
|
||||
cwd=root,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
passed = bool(re.fullmatch(r"[0-9a-f]{40}", revision)) and tests.returncode == 0
|
||||
return {
|
||||
"passed": passed,
|
||||
"adapter_present": True,
|
||||
"test_present": True,
|
||||
"adapter": {
|
||||
"repo": "whitehat-security",
|
||||
"revision": revision,
|
||||
"path": str(adapter_path),
|
||||
"sha256": file_digest(adapter),
|
||||
"tests_passed": tests.returncode == 0,
|
||||
},
|
||||
"test_exit_code": tests.returncode,
|
||||
"secret_values_observed": False,
|
||||
}
|
||||
|
||||
|
||||
def receipt_base(contract: dict[str, Any], reviewer: str, decision: str) -> dict[str, Any]:
|
||||
return {
|
||||
"interface": BROKER_INTERFACE,
|
||||
"version": 1,
|
||||
"workplan_id": WORKPLAN_ID,
|
||||
"owner": BROKER_OWNER,
|
||||
"reviewer": validate_reviewer(reviewer),
|
||||
"decision": decision,
|
||||
"created_at": datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
|
||||
"engagement_id": contract["engagement_id"],
|
||||
"target_id": contract["target"]["id"],
|
||||
"projection_contract_digest": contract_digest(contract),
|
||||
"projection_receipt_interface": PROJECTION_INTERFACE,
|
||||
"interface_artifacts": interface_artifacts(),
|
||||
"required_roles": ["attacker", "owner"],
|
||||
"mount_paths": sorted(item["mount_path"] for item in contract["identities"]),
|
||||
"secret_values_observed": False,
|
||||
}
|
||||
|
||||
|
||||
def build_approval(
|
||||
contract: dict[str, Any], reviewer: str, verification: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
if verification.get("passed") is not True or verification.get("secret_values_observed") is not False:
|
||||
raise ReadinessError("consumer adapter verification did not pass")
|
||||
receipt = {
|
||||
**receipt_base(contract, reviewer, "approve"),
|
||||
"adapter": verification["adapter"],
|
||||
"cleanup_request_supported": True,
|
||||
}
|
||||
validate_broker_receipt(receipt, contract)
|
||||
return receipt
|
||||
|
||||
|
||||
def build_change_request(contract: dict[str, Any], reviewer: str, note: str) -> dict[str, Any]:
|
||||
return {
|
||||
**receipt_base(contract, reviewer, "request-changes"),
|
||||
"note": validate_note(note),
|
||||
}
|
||||
|
||||
|
||||
def post_receipt(receipt: dict[str, Any], api_base: str) -> dict[str, Any]:
|
||||
response = http_json(
|
||||
"POST",
|
||||
f"{api_base.rstrip('/')}/messages/",
|
||||
{
|
||||
"from_agent": BROKER_OWNER,
|
||||
"to_agent": "railiance-platform",
|
||||
"subject": broker_subject(receipt),
|
||||
"body": canonical_json(receipt),
|
||||
},
|
||||
)
|
||||
if not isinstance(response, dict) or not response.get("id"):
|
||||
raise ReadinessError("State Hub returned an invalid message receipt")
|
||||
return response
|
||||
|
||||
|
||||
def show(contract: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"interface": BROKER_INTERFACE,
|
||||
"version": 1,
|
||||
"owner": BROKER_OWNER,
|
||||
"engagement_id": contract["engagement_id"],
|
||||
"target_id": contract["target"]["id"],
|
||||
"projection_contract_digest": contract_digest(contract),
|
||||
"projection_receipt_interface": PROJECTION_INTERFACE,
|
||||
"required_roles": ["attacker", "owner"],
|
||||
"mount_paths": sorted(item["mount_path"] for item in contract["identities"]),
|
||||
"adapter_path": str(DEFAULT_ADAPTER),
|
||||
"test_path": str(DEFAULT_TEST),
|
||||
"approve_command": (
|
||||
"python3 scripts/wp0025-broker-readiness.py approve --contract <contract.json> "
|
||||
"--consumer-root <whitehat-security> --reviewer <stable-id>"
|
||||
),
|
||||
"live_mutation_authorized": False,
|
||||
"secret_values_observed": False,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("command", choices=["show", "verify", "approve", "request-changes", "status"])
|
||||
parser.add_argument("--contract", type=Path, required=True)
|
||||
parser.add_argument("--consumer-root", type=Path)
|
||||
parser.add_argument("--reviewer")
|
||||
parser.add_argument("--note")
|
||||
parser.add_argument("--state-hub", default="http://127.0.0.1:8000")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
contract = validate_projection_contract(load_json(args.contract))
|
||||
if args.command == "show":
|
||||
result = show(contract)
|
||||
elif args.command == "status":
|
||||
try:
|
||||
receipt = current_broker_receipt(contract, args.state_hub)
|
||||
except ContractError as exc:
|
||||
result = {
|
||||
"ready": False,
|
||||
"engagement_id": contract["engagement_id"],
|
||||
"reason": str(exc),
|
||||
"secret_values_observed": False,
|
||||
}
|
||||
else:
|
||||
result = {
|
||||
"ready": True,
|
||||
"engagement_id": contract["engagement_id"],
|
||||
"receipt": receipt,
|
||||
"secret_values_observed": False,
|
||||
}
|
||||
elif args.command == "verify":
|
||||
if not args.consumer_root:
|
||||
raise ReadinessError("verify requires --consumer-root")
|
||||
result = verify_adapter(args.consumer_root)
|
||||
elif args.command == "approve":
|
||||
if not args.consumer_root or not args.reviewer:
|
||||
raise ReadinessError("approve requires --consumer-root and --reviewer")
|
||||
verification = verify_adapter(args.consumer_root)
|
||||
receipt = build_approval(contract, args.reviewer, verification)
|
||||
posted = post_receipt(receipt, args.state_hub)
|
||||
result = {"submitted": True, "message_id": posted["id"], "receipt": receipt}
|
||||
else:
|
||||
if not args.reviewer or not args.note:
|
||||
raise ReadinessError("request-changes requires --reviewer and --note")
|
||||
receipt = build_change_request(contract, args.reviewer, args.note)
|
||||
posted = post_receipt(receipt, args.state_hub)
|
||||
result = {"submitted": True, "message_id": posted["id"], "receipt": receipt}
|
||||
print(json.dumps(result, indent=2, sort_keys=True))
|
||||
return 0
|
||||
except (ContractError, OSError, ReadinessError) as exc:
|
||||
print(f"broker readiness failed: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
248
tests/test_custody_contract.py
Normal file
248
tests/test_custody_contract.py
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPTS = ROOT / "scripts"
|
||||
if str(SCRIPTS) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
import custody_contract as module
|
||||
|
||||
|
||||
NOW = datetime(2026, 8, 22, 22, 1, tzinfo=UTC)
|
||||
|
||||
|
||||
def projection_contract() -> dict:
|
||||
return {
|
||||
"interface": module.CONTRACT_INTERFACE,
|
||||
"version": 1,
|
||||
"workplan_id": module.WORKPLAN_ID,
|
||||
"engagement_id": "WH-ENG-20260822-AUDIT-E2-03",
|
||||
"status": "approved",
|
||||
"engagement_contract_sha256": "1" * 64,
|
||||
"target": {
|
||||
"id": "audit-core",
|
||||
"namespace": "audit-core",
|
||||
"deployment": "audit-core",
|
||||
"container": "audit-core",
|
||||
"sender_external_secret": "audit-core-senders",
|
||||
"revision": "2" * 40,
|
||||
"image_digest": "sha256:" + "3" * 64,
|
||||
"contract_sha256": "4" * 64,
|
||||
},
|
||||
"runner": {
|
||||
"namespace": "whitehat",
|
||||
"service_account": "whitehat-runner",
|
||||
"secret_name": "whitehat-e2-audit-credentials",
|
||||
"mount_root": "/var/run/secrets/whitehat",
|
||||
"manifest_sha256": "5" * 64,
|
||||
},
|
||||
"window": {
|
||||
"starts_at": "2026-08-22T22:00:00Z",
|
||||
"projection_cutoff": "2026-08-22T22:03:00Z",
|
||||
"expires_at": "2026-08-22T22:15:00Z",
|
||||
},
|
||||
"authority": {
|
||||
"remote": "railiance01",
|
||||
"registry_path": "platform/workloads/audit-core/senders",
|
||||
"registry_field": "senders.json",
|
||||
"kv_mount": "platform",
|
||||
"kv_prefix": "engagements/WH-ENG-20260822-AUDIT-E2-03/audit-core",
|
||||
"eso_service_account": "external-secrets",
|
||||
"eso_namespace": "external-secrets",
|
||||
},
|
||||
"identities": [
|
||||
{
|
||||
"handle": "token-a",
|
||||
"role": "attacker",
|
||||
"sender_name": "whitehat-e2-a-20260822-03",
|
||||
"tenant": "tenant:trial:whitehat-a-20260822-03",
|
||||
"mount_path": "/var/run/secrets/whitehat/token-a",
|
||||
"may_read": True,
|
||||
"may_write": True,
|
||||
},
|
||||
{
|
||||
"handle": "token-b",
|
||||
"role": "owner",
|
||||
"sender_name": "whitehat-e2-b-20260822-03",
|
||||
"tenant": "tenant:trial:whitehat-b-20260822-03",
|
||||
"mount_path": "/var/run/secrets/whitehat/token-b",
|
||||
"may_read": True,
|
||||
"may_write": True,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def broker_receipt(contract: dict) -> dict:
|
||||
return {
|
||||
"interface": module.BROKER_INTERFACE,
|
||||
"version": 1,
|
||||
"workplan_id": module.WORKPLAN_ID,
|
||||
"owner": module.BROKER_OWNER,
|
||||
"reviewer": "whitehat-owner",
|
||||
"decision": "approve",
|
||||
"created_at": "2026-08-22T21:50:00Z",
|
||||
"engagement_id": contract["engagement_id"],
|
||||
"target_id": contract["target"]["id"],
|
||||
"projection_contract_digest": module.contract_digest(contract),
|
||||
"projection_receipt_interface": module.PROJECTION_INTERFACE,
|
||||
"interface_artifacts": module.interface_artifacts(),
|
||||
"required_roles": ["attacker", "owner"],
|
||||
"mount_paths": sorted(item["mount_path"] for item in contract["identities"]),
|
||||
"adapter": {
|
||||
"repo": "whitehat-security",
|
||||
"revision": "6" * 40,
|
||||
"path": "src/whitehat_security/platform_custody.py",
|
||||
"sha256": "7" * 64,
|
||||
"tests_passed": True,
|
||||
},
|
||||
"cleanup_request_supported": True,
|
||||
"secret_values_observed": False,
|
||||
}
|
||||
|
||||
|
||||
def projection_receipt(contract: dict) -> dict:
|
||||
base = {
|
||||
"interface": module.PROJECTION_INTERFACE,
|
||||
"version": 1,
|
||||
"workplan_id": module.WORKPLAN_ID,
|
||||
"state": "projected",
|
||||
"lease_id": "custody:" + "8" * 32,
|
||||
"engagement_id": contract["engagement_id"],
|
||||
"target": {
|
||||
"id": contract["target"]["id"],
|
||||
"revision": contract["target"]["revision"],
|
||||
"image_digest": contract["target"]["image_digest"],
|
||||
},
|
||||
"projection_contract_digest": module.contract_digest(contract),
|
||||
"broker_receipt_digest": module.digest(broker_receipt(contract)),
|
||||
"projected_at": "2026-08-22T22:02:00Z",
|
||||
"expires_at": contract["window"]["expires_at"],
|
||||
"identities": sorted(
|
||||
(
|
||||
{
|
||||
"handle": item["handle"],
|
||||
"role": item["role"],
|
||||
"sender_name": item["sender_name"],
|
||||
"mount_path": item["mount_path"],
|
||||
}
|
||||
for item in contract["identities"]
|
||||
),
|
||||
key=lambda item: item["handle"],
|
||||
),
|
||||
"resources": {
|
||||
"names": module.resource_names(contract),
|
||||
"uids": {"store": "uid-store", "external_secret": "uid-es", "mounted_secret": "uid-secret"},
|
||||
},
|
||||
"cleanup_authority": "railiance-platform",
|
||||
"secret_values_observed": False,
|
||||
}
|
||||
return {**base, "receipt_id": "sha256:" + module.digest(base)}
|
||||
|
||||
|
||||
class CustodyContractTests(unittest.TestCase):
|
||||
def test_contract_validates_and_derives_hash_only_resource_names(self) -> None:
|
||||
contract = projection_contract()
|
||||
self.assertIs(contract, module.validate_projection_contract(contract))
|
||||
names = module.resource_names(contract)
|
||||
self.assertTrue(all(name.startswith("custody-") for key, name in names.items() if key != "external_secret"))
|
||||
self.assertNotIn("E2-03", json.dumps(names))
|
||||
self.assertEqual(64, len(module.contract_digest(contract)))
|
||||
|
||||
def test_contract_rejects_stale_prefix_duplicate_tenant_and_long_window(self) -> None:
|
||||
contract = projection_contract()
|
||||
contract["authority"]["kv_prefix"] = "engagements/WH-ENG-20260822-AUDIT-E2-02/audit-core"
|
||||
with self.assertRaises(module.ContractError):
|
||||
module.validate_projection_contract(contract)
|
||||
contract = projection_contract()
|
||||
contract["identities"][1]["tenant"] = contract["identities"][0]["tenant"]
|
||||
with self.assertRaises(module.ContractError):
|
||||
module.validate_projection_contract(contract)
|
||||
contract = projection_contract()
|
||||
contract["window"]["expires_at"] = "2026-08-22T22:16:00Z"
|
||||
with self.assertRaises(module.ContractError):
|
||||
module.validate_projection_contract(contract)
|
||||
|
||||
def test_broker_receipt_is_bound_to_contract_and_current_artifacts(self) -> None:
|
||||
contract = projection_contract()
|
||||
receipt = broker_receipt(contract)
|
||||
self.assertIs(receipt, module.validate_broker_receipt(receipt, contract, now=NOW))
|
||||
stale = copy.deepcopy(receipt)
|
||||
stale["projection_contract_digest"] = "0" * 64
|
||||
with self.assertRaises(module.ContractError):
|
||||
module.validate_broker_receipt(stale, contract, now=NOW)
|
||||
stale = copy.deepcopy(receipt)
|
||||
stale["interface_artifacts"][next(iter(stale["interface_artifacts"]))] = "0" * 64
|
||||
with self.assertRaises(module.ContractError):
|
||||
module.validate_broker_receipt(stale, contract, now=NOW)
|
||||
|
||||
def test_broker_message_requires_whitehat_origin_and_exact_subject(self) -> None:
|
||||
contract = projection_contract()
|
||||
receipt = broker_receipt(contract)
|
||||
message = {
|
||||
"from_agent": module.BROKER_OWNER,
|
||||
"to_agent": "railiance-platform",
|
||||
"subject": module.broker_subject(receipt),
|
||||
"body": module.canonical_json(receipt),
|
||||
}
|
||||
self.assertEqual(receipt, module.parse_broker_message(message, contract, now=NOW))
|
||||
message["from_agent"] = "coding-agent"
|
||||
self.assertIsNone(module.parse_broker_message(message, contract, now=NOW))
|
||||
malformed = {
|
||||
"from_agent": module.BROKER_OWNER,
|
||||
"to_agent": "railiance-platform",
|
||||
"subject": module.BROKER_SUBJECT_PREFIX + "/malformed",
|
||||
"body": "{}",
|
||||
}
|
||||
self.assertIsNone(module.parse_broker_message(malformed, contract, now=NOW))
|
||||
|
||||
def test_projection_receipt_canonical_id_and_scope_are_enforced(self) -> None:
|
||||
contract = projection_contract()
|
||||
receipt = projection_receipt(contract)
|
||||
self.assertIs(receipt, module.validate_projection_receipt(receipt, contract))
|
||||
changed = copy.deepcopy(receipt)
|
||||
changed["resources"]["uids"]["mounted_secret"] = "replacement"
|
||||
with self.assertRaises(module.ContractError):
|
||||
module.validate_projection_receipt(changed, contract)
|
||||
value_bearing = copy.deepcopy(receipt)
|
||||
value_bearing["token"] = "never-allowed"
|
||||
with self.assertRaises(module.ContractError):
|
||||
module.validate_projection_receipt(value_bearing, contract)
|
||||
|
||||
def test_cleanup_receipt_is_bound_to_projection_lease(self) -> None:
|
||||
contract = projection_contract()
|
||||
projection = projection_receipt(contract)
|
||||
cleanup = {
|
||||
"interface": module.CLEANUP_INTERFACE,
|
||||
"version": 1,
|
||||
"workplan_id": module.WORKPLAN_ID,
|
||||
"state": "cleaned",
|
||||
"lease_id": projection["lease_id"],
|
||||
"engagement_id": contract["engagement_id"],
|
||||
"projection_receipt_id": projection["receipt_id"],
|
||||
"cleaned_at": "2026-08-22T22:14:00Z",
|
||||
"removed_resources": ["custody-resource"],
|
||||
"target_ready": True,
|
||||
"secret_values_observed": False,
|
||||
}
|
||||
self.assertIs(cleanup, module.validate_cleanup_receipt(cleanup, projection, contract))
|
||||
cleanup["lease_id"] = "custody:" + "0" * 32
|
||||
with self.assertRaises(module.ContractError):
|
||||
module.validate_cleanup_receipt(cleanup, projection, contract)
|
||||
|
||||
def test_all_published_schemas_are_valid_json(self) -> None:
|
||||
for relative in module.INTERFACE_ARTIFACTS:
|
||||
value = json.loads((ROOT / relative).read_text(encoding="utf-8"))
|
||||
self.assertEqual("https://json-schema.org/draft/2020-12/schema", value["$schema"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
186
tests/test_custody_projection.py
Normal file
186
tests/test_custody_projection.py
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import stat
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
from io import StringIO
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPTS = ROOT / "scripts"
|
||||
TESTS = ROOT / "tests"
|
||||
for path in (SCRIPTS, TESTS):
|
||||
if str(path) not in sys.path:
|
||||
sys.path.insert(0, str(path))
|
||||
|
||||
from test_custody_contract import broker_receipt, projection_contract, projection_receipt
|
||||
|
||||
SPEC = importlib.util.spec_from_file_location(
|
||||
"custody_projection", SCRIPTS / "custody-projection.py"
|
||||
)
|
||||
assert SPEC and SPEC.loader
|
||||
module = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(module)
|
||||
|
||||
|
||||
NOW = datetime(2026, 8, 22, 22, 1, tzinfo=UTC)
|
||||
|
||||
|
||||
class CustodyProjectionTests(unittest.TestCase):
|
||||
def test_policy_and_manifest_are_derived_from_one_contract(self) -> None:
|
||||
contract = projection_contract()
|
||||
policy = module.build_policy(contract)
|
||||
manifest = json.loads(module.build_manifest(contract, "custody:" + "9" * 32))
|
||||
rendered = json.dumps(manifest, sort_keys=True)
|
||||
self.assertEqual(4, policy.count('capabilities = ["read"]'))
|
||||
self.assertIn(contract["engagement_id"], policy)
|
||||
self.assertIn(contract["engagement_id"], rendered)
|
||||
self.assertNotIn("WH-ENG-20260822-AUDIT-E2-01", policy + rendered)
|
||||
self.assertEqual(
|
||||
["token-a", "token-b"],
|
||||
[item["secretKey"] for item in manifest["items"][1]["spec"]["data"]],
|
||||
)
|
||||
|
||||
def test_identity_overlay_is_exact_and_removable(self) -> None:
|
||||
contract = projection_contract()
|
||||
existing = [{"name": "production", "tokens": ["not-inspected"]}]
|
||||
updated = module.add_temporary_identities(
|
||||
existing, contract, {"token-a": "a", "token-b": "b"}
|
||||
)
|
||||
self.assertEqual(3, len(updated))
|
||||
temporary = updated[1:]
|
||||
self.assertTrue(all(item["expires_at"] == contract["window"]["expires_at"] for item in temporary))
|
||||
restored, removed = module.remove_temporary_identities(updated, contract)
|
||||
self.assertEqual(existing, restored)
|
||||
self.assertEqual(sorted(module.identity_names(contract)), removed)
|
||||
|
||||
def test_broker_gate_runs_before_operator_or_token_generation(self) -> None:
|
||||
contract = projection_contract()
|
||||
operator = object()
|
||||
with mock.patch.object(
|
||||
module,
|
||||
"current_broker_receipt",
|
||||
side_effect=module.ContractError("not connected"),
|
||||
), mock.patch.object(module.secrets, "token_urlsafe") as token_urlsafe:
|
||||
with self.assertRaises(module.ContractError):
|
||||
module.project(operator, contract, now=NOW)
|
||||
token_urlsafe.assert_not_called()
|
||||
|
||||
def test_cli_broker_gate_runs_before_platform_authority_is_opened(self) -> None:
|
||||
contract = projection_contract()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "contract.json"
|
||||
path.write_text(json.dumps(contract), encoding="utf-8")
|
||||
argv = [
|
||||
"custody-projection.py",
|
||||
"project",
|
||||
"--contract",
|
||||
str(path),
|
||||
"--confirm",
|
||||
f"{contract['engagement_id']}:attended",
|
||||
]
|
||||
with mock.patch.object(sys, "argv", argv), mock.patch.object(
|
||||
module,
|
||||
"current_broker_receipt",
|
||||
side_effect=module.ContractError("broker missing"),
|
||||
), mock.patch.object(module, "Operator") as operator, redirect_stderr(StringIO()):
|
||||
self.assertEqual(1, module.main())
|
||||
operator.assert_not_called()
|
||||
|
||||
def test_validate_and_render_cli_require_no_authority(self) -> None:
|
||||
contract = projection_contract()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "contract.json"
|
||||
path.write_text(json.dumps(contract), encoding="utf-8")
|
||||
for command in ("validate", "render"):
|
||||
with mock.patch.object(
|
||||
sys, "argv", ["custody-projection.py", command, "--contract", str(path)]
|
||||
), mock.patch.object(module, "Operator") as operator, redirect_stdout(StringIO()):
|
||||
self.assertEqual(0, module.main())
|
||||
operator.assert_not_called()
|
||||
|
||||
def test_transaction_rolls_back_after_every_mutation_boundary(self) -> None:
|
||||
for failed_index in range(4):
|
||||
events: list[str] = []
|
||||
steps = []
|
||||
for index in range(4):
|
||||
def step(index=index) -> None:
|
||||
events.append(f"step-{index}")
|
||||
if index == failed_index:
|
||||
raise module.ProcedureError(f"fail-{index}")
|
||||
steps.append(step)
|
||||
with self.assertRaisesRegex(module.ProcedureError, f"fail-{failed_index}"):
|
||||
module.transactional(steps, lambda: events.append("rollback"))
|
||||
self.assertEqual("rollback", events[-1])
|
||||
self.assertEqual(1, events.count("rollback"))
|
||||
self.assertNotIn(f"step-{failed_index + 1}", events)
|
||||
|
||||
def test_cleanup_failure_is_reported_without_original_output(self) -> None:
|
||||
def fail() -> None:
|
||||
raise module.ProcedureError("projection-stage")
|
||||
|
||||
def cleanup_fail() -> None:
|
||||
raise module.ProcedureError("provider-secret-output")
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
module.ProcedureError, "cleanup could not be proven"
|
||||
) as caught:
|
||||
module.transactional([fail], cleanup_fail)
|
||||
self.assertNotIn("provider-secret-output", str(caught.exception))
|
||||
|
||||
def test_receipt_write_is_mode_0600_and_canonical_receipt_validates(self) -> None:
|
||||
contract = projection_contract()
|
||||
receipt = projection_receipt(contract)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "projection.json"
|
||||
module.write_receipt(path, receipt)
|
||||
self.assertEqual(0o600, stat.S_IMODE(path.stat().st_mode))
|
||||
self.assertEqual(receipt, json.loads(path.read_text(encoding="utf-8")))
|
||||
self.assertIs(receipt, module.validate_projection_receipt(receipt, contract))
|
||||
|
||||
def test_status_cannot_infer_absence_after_connectivity_failure(self) -> None:
|
||||
class Disconnected:
|
||||
def registry(self):
|
||||
raise module.ProcedureError("registry connectivity failed")
|
||||
|
||||
with self.assertRaisesRegex(module.ProcedureError, "connectivity"):
|
||||
module.projection_status(
|
||||
Disconnected(), projection_contract(), projection_receipt(projection_contract())
|
||||
)
|
||||
|
||||
def test_cleanup_refuses_recreated_resource_uid(self) -> None:
|
||||
contract = projection_contract()
|
||||
receipt = projection_receipt(contract)
|
||||
|
||||
class RecreatedResource:
|
||||
def kubectl(self, args, *, label, **kwargs):
|
||||
if "clustersecretstore" in args:
|
||||
value = "replacement-store-uid"
|
||||
else:
|
||||
value = ""
|
||||
return subprocess.CompletedProcess(args, 0, stdout=value, stderr="")
|
||||
|
||||
with self.assertRaisesRegex(module.ProcedureError, "refusing deletion"):
|
||||
module.verify_receipt_resource_scope(RecreatedResource(), contract, receipt)
|
||||
|
||||
def test_expiry_state_is_fail_closed(self) -> None:
|
||||
contract = projection_contract()
|
||||
self.assertEqual("before-window", module.time_state(contract, datetime(2026, 8, 22, 21, 59, tzinfo=UTC)))
|
||||
self.assertEqual("projection-window-open", module.time_state(contract, NOW))
|
||||
self.assertEqual("projection-cutoff-passed", module.time_state(contract, datetime(2026, 8, 22, 22, 4, tzinfo=UTC)))
|
||||
with self.assertRaisesRegex(module.ProcedureError, "before receipt expiry"):
|
||||
module.assert_expired_cleanup(contract, NOW)
|
||||
module.assert_expired_cleanup(contract, datetime(2026, 8, 22, 22, 16, tzinfo=UTC))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
49
tests/test_remote_exec.py
Normal file
49
tests/test_remote_exec.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
|
||||
if str(SCRIPTS) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
import remote_exec as module
|
||||
|
||||
|
||||
class RemoteExecTests(unittest.TestCase):
|
||||
def test_shell_hostile_argv_is_one_quoted_remote_command(self) -> None:
|
||||
template = 'go-template={{range $k, $_ := .data}}{{$k}}{{"\\n"}}{{end}}'
|
||||
completed = subprocess.CompletedProcess([], 0, stdout="token-a\ntoken-b\n", stderr="")
|
||||
runner = mock.Mock(return_value=completed)
|
||||
module.run_remote(
|
||||
"railiance01",
|
||||
["kubectl", "get", "secret", "example", "-o", template, "literal;$(false)", "a'b"],
|
||||
label="hostile argv",
|
||||
runner=runner,
|
||||
)
|
||||
command = runner.call_args.args[0]
|
||||
self.assertEqual(["ssh", "-o", "BatchMode=yes", "railiance01"], command[:4])
|
||||
self.assertIn("'go-template={{range $k, $_ := .data}}", command[4])
|
||||
self.assertIn("'literal;$(false)'", command[4])
|
||||
self.assertIn("'a'\"'\"'b'", command[4])
|
||||
|
||||
def test_invalid_host_and_nul_fail_closed(self) -> None:
|
||||
with self.assertRaises(module.RemoteExecutionError):
|
||||
module.run_remote("bad host", ["true"], label="invalid")
|
||||
with self.assertRaises(module.RemoteExecutionError):
|
||||
module.remote_command(["bad\0value"])
|
||||
|
||||
def test_error_does_not_include_remote_output(self) -> None:
|
||||
runner = mock.Mock(
|
||||
return_value=subprocess.CompletedProcess([], 9, stdout="sensitive", stderr="also-sensitive")
|
||||
)
|
||||
with self.assertRaisesRegex(module.RemoteExecutionError, "exit 9") as caught:
|
||||
module.run_remote("railiance01", ["false"], label="safe failure", runner=runner)
|
||||
self.assertNotIn("sensitive", str(caught.exception))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
89
tests/test_wp0025_broker_readiness.py
Normal file
89
tests/test_wp0025_broker_readiness.py
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPTS = ROOT / "scripts"
|
||||
TESTS = ROOT / "tests"
|
||||
for path in (SCRIPTS, TESTS):
|
||||
if str(path) not in sys.path:
|
||||
sys.path.insert(0, str(path))
|
||||
|
||||
from test_custody_contract import projection_contract
|
||||
|
||||
SPEC = importlib.util.spec_from_file_location(
|
||||
"wp0025_broker_readiness", SCRIPTS / "wp0025-broker-readiness.py"
|
||||
)
|
||||
assert SPEC and SPEC.loader
|
||||
module = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(module)
|
||||
|
||||
|
||||
class BrokerReadinessTests(unittest.TestCase):
|
||||
def test_show_is_direct_and_authorizes_no_live_mutation(self) -> None:
|
||||
result = module.show(projection_contract())
|
||||
self.assertEqual("whitehat-security", result["owner"])
|
||||
self.assertIn("approve", result["approve_command"])
|
||||
self.assertFalse(result["live_mutation_authorized"])
|
||||
self.assertFalse(result["secret_values_observed"])
|
||||
|
||||
def test_adapter_verification_pins_revision_file_and_focused_test(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
adapter = root / module.DEFAULT_ADAPTER
|
||||
test = root / module.DEFAULT_TEST
|
||||
adapter.parent.mkdir(parents=True)
|
||||
test.parent.mkdir(parents=True)
|
||||
adapter.write_text("class PlatformCustodyBroker: pass\n", encoding="utf-8")
|
||||
test.write_text("def test_adapter(): assert True\n", encoding="utf-8")
|
||||
|
||||
def runner(command, **kwargs):
|
||||
if command[:2] == ["git", "rev-parse"]:
|
||||
return subprocess.CompletedProcess(command, 0, stdout="a" * 40 + "\n", stderr="")
|
||||
return subprocess.CompletedProcess(command, 0, stdout="1 passed\n", stderr="")
|
||||
|
||||
result = module.verify_adapter(root, runner=runner)
|
||||
self.assertTrue(result["passed"])
|
||||
self.assertEqual("a" * 40, result["adapter"]["revision"])
|
||||
self.assertEqual(64, len(result["adapter"]["sha256"]))
|
||||
self.assertTrue(result["adapter"]["tests_passed"])
|
||||
|
||||
def test_missing_adapter_fails_without_running_commands(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
result = module.verify_adapter(
|
||||
Path(directory),
|
||||
runner=lambda *args, **kwargs: self.fail("runner must not execute"),
|
||||
)
|
||||
self.assertFalse(result["passed"])
|
||||
self.assertFalse(result["secret_values_observed"])
|
||||
|
||||
def test_approval_round_trip_and_change_request(self) -> None:
|
||||
contract = projection_contract()
|
||||
verification = {
|
||||
"passed": True,
|
||||
"adapter": {
|
||||
"repo": "whitehat-security",
|
||||
"revision": "a" * 40,
|
||||
"path": str(module.DEFAULT_ADAPTER),
|
||||
"sha256": "b" * 64,
|
||||
"tests_passed": True,
|
||||
},
|
||||
"secret_values_observed": False,
|
||||
}
|
||||
receipt = module.build_approval(contract, "whitehat-owner", verification)
|
||||
self.assertEqual("approve", receipt["decision"])
|
||||
self.assertTrue(receipt["cleanup_request_supported"])
|
||||
change = module.build_change_request(contract, "whitehat-owner", "Bind cleanup acknowledgement.")
|
||||
self.assertEqual("request-changes", change["decision"])
|
||||
with self.assertRaises(module.ReadinessError):
|
||||
module.build_change_request(contract, "whitehat-owner", "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -13,6 +13,7 @@ related:
|
|||
- AUDIT-WP-0008
|
||||
- WH-ENG-20260822-AUDIT-E2-01
|
||||
- WH-ENG-20260822-AUDIT-E2-02
|
||||
- RAILIANCE-WP-0025
|
||||
origin: routed
|
||||
origin_ref: "State Hub messages 10f80080-4c83-42ba-8590-f23c582d9f05 and a93fa88f-a9c5-4539-93ae-0c8f8490f53d"
|
||||
state_hub_workstream_id: "88c4ef7f-0af8-580e-90dc-a2bae2675a4d"
|
||||
|
|
@ -47,7 +48,7 @@ lease revocation, firewall change, or host reboot.
|
|||
|
||||
```task
|
||||
id: RAILIANCE-WP-0024-T01
|
||||
status: wait
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "c4da371a-f35d-5377-9f6a-d34e274c98d0"
|
||||
```
|
||||
|
|
@ -93,8 +94,26 @@ projection gate is 19:15Z–19:18Z and cleanup must finish by 19:30Z. Platform
|
|||
tests (114), Whitehat tests (53), both Kubernetes server-side dry-runs, and a
|
||||
live value-safe preflight passed. At preflight there were no temporary token
|
||||
paths, projection resources, runner pod, or temporary sender identities, and
|
||||
no secret value was observed. This task remains `wait` until an attended
|
||||
operator invokes projection inside the gate and completes the cleanup receipt.
|
||||
no secret value was observed. At that pre-run point the task remained `wait`
|
||||
until an attended operator invoked projection and completed the cleanup receipt.
|
||||
|
||||
**Terminal result (2026-08-22):** projection succeeded at 19:17:54Z with
|
||||
exactly two bounded identities and the two declared mounted keys. Whitehat
|
||||
failed closed before traffic because its live broker could not consume the
|
||||
value-safe platform receipt; the runner sent zero target packets and was
|
||||
deleted at 19:21:07Z. Exact cleanup completed at 19:21:39Z and independent
|
||||
preflight proved no temporary identity, KV path, ESO resource, mounted Secret,
|
||||
or runner remained; audit-core was `1/1` Ready and no secret value was
|
||||
observed. The first attempt also proved automatic rollback when SSH shell
|
||||
expansion corrupted a Go-template argument; commit `d239ed3` fixes that defect.
|
||||
|
||||
T01 is done because the mount-only projection, application expiry, reload,
|
||||
abort, and cleanup lane is implemented and live-proven. The missing consumable
|
||||
broker contract is not carried as prose in this completed task: versioned
|
||||
receipts, the pre-projection broker gate, generic lifecycle, and future expired
|
||||
cleanup interface are implemented under `RAILIANCE-WP-0025`. The dated `-01`
|
||||
and `-02` procedures are terminal evidence/cleanup references and must not be
|
||||
copied for another engagement.
|
||||
|
||||
## T02 — Define the runtime database lease recovery exercise
|
||||
|
||||
|
|
@ -260,7 +279,7 @@ each task.
|
|||
|
||||
## Acceptance
|
||||
|
||||
- [ ] E2 cannot proceed without enforced sender expiry and exact-path projection.
|
||||
- [x] E2 cannot proceed without enforced sender expiry and exact-path projection.
|
||||
- [ ] Database recovery is restart-free and evidenced without credential values.
|
||||
- [ ] Reboot recovery has an ordered, owner-signed checklist and abort path.
|
||||
- [ ] No live action is implied by completing this design workplan.
|
||||
- [x] No live action is implied by completing this design workplan.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,158 @@
|
|||
---
|
||||
id: RAILIANCE-WP-0025
|
||||
type: workplan
|
||||
title: "Version ephemeral custody projection and broker handoff"
|
||||
domain: financials
|
||||
repo: railiance-platform
|
||||
status: finished
|
||||
owner: codex
|
||||
topic_slug: railiance
|
||||
created: "2026-08-22"
|
||||
updated: "2026-08-22"
|
||||
related:
|
||||
- RAILIANCE-WP-0024
|
||||
- WHITEHAT-WP-0001
|
||||
- AUDIT-WP-0008
|
||||
origin: run-review
|
||||
origin_ref: "WH-ENG-20260822-AUDIT-E2-02 terminal clean admission abort"
|
||||
---
|
||||
|
||||
# RAILIANCE-WP-0025 — Version ephemeral custody projection and broker handoff
|
||||
|
||||
## Goal
|
||||
|
||||
Replace engagement-specific credential scripts and prose handoffs with one
|
||||
versioned, value-safe, fail-closed lifecycle. A consumer must prove it can
|
||||
consume the projection receipt before platform custody mints anything. The
|
||||
platform then exposes direct `preflight`, `project`, `status`, `cleanup`, and
|
||||
expired-lease cleanup interfaces without transferring bearer values or
|
||||
collapsing owner approvals.
|
||||
|
||||
The implementation is repository code and contracts only. It does not
|
||||
authorize or schedule a live credential projection, workload, probe, or
|
||||
unattended cleanup controller.
|
||||
|
||||
## T01 — Define projection, broker-readiness, and cleanup receipts
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0025-T01
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
Publish versioned JSON Schemas and canonical validation for the projection
|
||||
contract, broker-readiness receipt, active projection receipt, and cleanup
|
||||
receipt. Bind every receipt to the exact engagement, target revision and image,
|
||||
runner and target contract digests, mounted identity handles, expiry, opaque
|
||||
lease id, and cleanup authority. Values and value-derived fingerprints are
|
||||
forbidden.
|
||||
|
||||
Done when malformed, stale, mismatched, expired, and value-bearing documents
|
||||
fail closed and consumers can implement without interpreting prose.
|
||||
|
||||
Implemented in four JSON Schemas plus `scripts/custody_contract.py`. Canonical
|
||||
projection ids cover all receipt content; validators bind target, contract,
|
||||
roles, mount paths, resource names and UIDs, and reject value-bearing fields.
|
||||
Broker approvals also pin the current hashes of all four interface schemas.
|
||||
|
||||
## T02 — Implement a data-driven custody lifecycle
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0025-T02
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
Replace copied per-engagement code and static manifests with one lifecycle that
|
||||
derives exact OpenBao and ESO resources from a validated engagement contract.
|
||||
Expose value-safe `preflight`, `project`, `status`, and idempotent `cleanup`
|
||||
commands. Preserve the existing two-identity sender overlay, application-level
|
||||
expiry, exact-key mount, readiness reload, and exact-scope rollback behavior.
|
||||
|
||||
Done when a new engagement requires data only, not copied Python, HCL, or YAML,
|
||||
and an old engagement identifier cannot leak into generated resources.
|
||||
|
||||
Implemented by `scripts/custody-projection.py` and the intentionally invalid
|
||||
placeholder template `docs/custody-projection-contract.example.json`. Policy,
|
||||
role, store, KV paths, ExternalSecret data mappings, sender overlays, expiry,
|
||||
and cleanup scope derive from one validated contract. Resource names use an
|
||||
engagement-id digest rather than a copied run suffix.
|
||||
|
||||
## T03 — Gate projection on a direct broker capability receipt
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0025-T03
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
Provide a direct owner interface for `whitehat-security` to inspect, verify,
|
||||
approve, or request changes to the contract. Approval must identify the tested
|
||||
adapter revision and artifact digest and be posted directly to State Hub. The
|
||||
platform lifecycle must find a current matching approval before generating a
|
||||
credential.
|
||||
|
||||
Done when an unconnected, stale, wrong-engagement, or wrong-contract broker is
|
||||
rejected before the first OpenBao or sender-registry mutation.
|
||||
|
||||
Implemented by `scripts/wp0025-broker-readiness.py`. Its closed verification
|
||||
surface pins Whitehat's adapter and focused test paths, computes their commit
|
||||
and digest, runs only the named focused test, and posts the canonical receipt
|
||||
directly as `whitehat-security`. `project` checks State Hub before reading the
|
||||
platform-admin token file, generating bearer values, or touching live state.
|
||||
No adapter approval is fabricated by this implementation; a future Whitehat
|
||||
adapter must pass the interface itself.
|
||||
|
||||
## T04 — Harden execution and transactional cleanup
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0025-T04
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
Centralize remote argv quoting, test shell-hostile arguments, and inject
|
||||
failures at each mutation boundary. Prove that partial projection invokes exact
|
||||
cleanup and that status never infers absence after a connectivity failure.
|
||||
Retire the copied `-01`/`-02` procedure from future-run instructions.
|
||||
|
||||
Done when the SSH expansion defect from the `-02` run and every modeled partial
|
||||
failure have regression coverage.
|
||||
|
||||
Implemented by `scripts/remote_exec.py` and focused tests covering `$`, shell
|
||||
substitution, semicolons, quotes, NUL refusal, and value-safe failure messages.
|
||||
The lifecycle uses a transaction whose rollback is exercised after every
|
||||
modeled mutation boundary. Connectivity failure cannot be interpreted as
|
||||
absence, and cleanup refuses a recreated same-name Kubernetes resource whose
|
||||
UID differs from the receipt.
|
||||
|
||||
## T05 — Add an approval-ready expired-lease cleanup entry point
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0025-T05
|
||||
status: done
|
||||
priority: medium
|
||||
```
|
||||
|
||||
Add a fail-closed `cleanup-expired` interface that accepts only a valid
|
||||
projection receipt after its expiry, removes only the receipt-bound resources,
|
||||
and emits the normal cleanup receipt. Do not install a scheduler or controller;
|
||||
deployment of unattended cleanup remains a separate owner decision.
|
||||
|
||||
Done when an approved future reaper can call a deterministic command without
|
||||
receiving projected bearer values or reconstructing cleanup scope from chat.
|
||||
|
||||
Implemented as `cleanup-expired`. It refuses before expiry, requires the exact
|
||||
canonical projection receipt and engagement confirmation, verifies live UIDs,
|
||||
uses the ordinary exact cleanup transaction, and emits the versioned cleanup
|
||||
receipt. No scheduler, controller, runtime identity, or unattended authority
|
||||
was deployed; those remain a separate owner decision.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [x] No projection can begin without a current consumer broker receipt.
|
||||
- [x] A new engagement is configuration, not copied executable code.
|
||||
- [x] Projection and cleanup receipts are versioned, canonical, and value-safe.
|
||||
- [x] Remote command arguments survive shell-hostile templates unchanged.
|
||||
- [x] Every modeled partial mutation cleans only its receipt-bound scope.
|
||||
- [x] Expired cleanup is executable but no unattended scheduler is deployed.
|
||||
Loading…
Add table
Add a link
Reference in a new issue