mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-13 00:34:43 +00:00
Harden secure runtime qualification provenance
This commit is contained in:
parent
770733fc92
commit
98e8f86806
8 changed files with 899 additions and 175 deletions
|
|
@ -26,6 +26,9 @@ package installtests
|
|||
// PULSE_SECURE_RUNTIME_HELPER="$repo/.lab-artifacts/pulse-agent-helper" \
|
||||
// PULSE_SECURE_RUNTIME_RUNNER="$repo/.lab-artifacts/pulse-agent-runner" \
|
||||
// PULSE_SECURE_RUNTIME_RECEIPT=/tmp/secure-runtime-receipt.json \
|
||||
// PULSE_SECURE_RUNTIME_RECEIPT_RECORD_PATH=docs/release-control/v6/internal/records/secure-agent-runtime-systemd-receipt-v4.json \
|
||||
// PULSE_SECURE_RUNTIME_TRANSCRIPT=/tmp/secure-runtime-transcript.jsonl \
|
||||
// PULSE_SECURE_RUNTIME_TRANSCRIPT_RECORD_PATH=docs/release-control/v6/internal/records/secure-agent-runtime-systemd-transcript-v4.jsonl \
|
||||
// sh -c 'cd "$1/scripts/installtests" && exec "$1/.lab-artifacts/installtests-linux-arm64.test" -test.run "^TestSecureRuntimeSystemdLab$" -test.count=1 -test.v' sh "$repo"
|
||||
//
|
||||
// Use the VM's GOARCH in place of arm64 when qualifying another architecture.
|
||||
|
|
@ -101,6 +104,31 @@ var secureRuntimeForbiddenReceiptKeys = map[string]struct{}{
|
|||
"token": {},
|
||||
}
|
||||
|
||||
var secureRuntimeScenarioClaims = map[string][]string{
|
||||
"legacy_root_command_capable_install": {"legacy_root_command_authority_observed"},
|
||||
"read_only_inspect": {"inspection_left_stable_files_unchanged"},
|
||||
"drop_in_fail_closed_rehearsal": {"drop_in_rejected_before_mutation"},
|
||||
"safe_profile_apply": {
|
||||
"collector_non_root",
|
||||
"collector_monitoring_only",
|
||||
"helper_protocol_healthy",
|
||||
"collector_authority_reduction_observed",
|
||||
},
|
||||
"explicit_safe_profile_rollback": {"explicit_rollback_preserved_reduced_authority"},
|
||||
"automatic_failure_rollback": {"failed_activation_restored_prior_runtime"},
|
||||
"ordinary_update_non_migration": {"ordinary_update_preserved_selected_profile"},
|
||||
"final_safe_profile_apply": {"collector_reporting_continued_after_migration"},
|
||||
"separate_action_runner_install": {"action_runner_registered_separately"},
|
||||
"typed_action_receipt": {
|
||||
"typed_mutation_verified",
|
||||
"terminal_receipt_replayed",
|
||||
"stale_precondition_refused",
|
||||
"generic_command_denied",
|
||||
},
|
||||
"action_runner_credential_rotation": {"fixture_credential_replacement_observed"},
|
||||
"action_runner_self_revoke": {"exact_runner_binding_revoked"},
|
||||
}
|
||||
|
||||
type secureRuntimeLabReport struct {
|
||||
ReceivedAt time.Time
|
||||
AgentID string
|
||||
|
|
@ -541,40 +569,136 @@ func secureRuntimeSeedAPTPackageCache(t *testing.T) (string, string) {
|
|||
}
|
||||
|
||||
type secureRuntimeScenarioResult struct {
|
||||
Name string `json:"name"`
|
||||
Passed bool `json:"passed"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Sequence int `json:"sequence"`
|
||||
Name string `json:"name"`
|
||||
Passed bool `json:"passed"`
|
||||
StartedAt string `json:"started_at"`
|
||||
CompletedAt string `json:"completed_at"`
|
||||
Evidence secureRuntimeScenarioEvidence `json:"evidence"`
|
||||
}
|
||||
|
||||
type secureRuntimeScenarioEvidence struct {
|
||||
Kind string `json:"kind"`
|
||||
Summary string `json:"summary"`
|
||||
Claims []string `json:"claims"`
|
||||
Observations map[string]any `json:"observations"`
|
||||
TranscriptEventIDs []string `json:"transcript_event_ids"`
|
||||
}
|
||||
|
||||
type secureRuntimeTranscriptEvent struct {
|
||||
Sequence int `json:"sequence"`
|
||||
EventID string `json:"event_id"`
|
||||
ObservedAt string `json:"observed_at"`
|
||||
Kind string `json:"kind"`
|
||||
Scenario string `json:"scenario,omitempty"`
|
||||
Claims []string `json:"claims,omitempty"`
|
||||
Observations map[string]any `json:"observations,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Operation string `json:"operation,omitempty"`
|
||||
Output string `json:"output,omitempty"`
|
||||
OutputSHA256 string `json:"output_sha256,omitempty"`
|
||||
}
|
||||
|
||||
type secureRuntimeSourceManifestBinding struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
ManifestID string `json:"manifest_id"`
|
||||
Path string `json:"path"`
|
||||
SHA256 string `json:"sha256"`
|
||||
TargetOS string `json:"target_os"`
|
||||
TargetArch string `json:"target_arch"`
|
||||
}
|
||||
|
||||
type secureRuntimeTranscriptBinding struct {
|
||||
Format string `json:"format"`
|
||||
RecordPath string `json:"record_path"`
|
||||
SHA256 string `json:"sha256"`
|
||||
EventCount int `json:"event_count"`
|
||||
}
|
||||
|
||||
type secureRuntimeSourceManifest struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
ManifestID string `json:"manifest_id"`
|
||||
TargetOS string `json:"target_os"`
|
||||
ExactPaths []string `json:"exact_paths"`
|
||||
RecursiveRoots []string `json:"recursive_roots"`
|
||||
IncludeSuffixes []string `json:"include_suffixes"`
|
||||
ExcludeSuffixes []string `json:"exclude_suffixes"`
|
||||
}
|
||||
|
||||
type secureRuntimeLabReceipt struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
CompletedAt string `json:"completed_at"`
|
||||
SourceHashes map[string]string `json:"source_hashes"`
|
||||
ArtifactHashes map[string]string `json:"artifact_hashes"`
|
||||
ArtifactVersions map[string]string `json:"artifact_versions"`
|
||||
DisposableVMGuardHash string `json:"disposable_vm_guard_sha256"`
|
||||
OSRelease string `json:"os_release"`
|
||||
Kernel string `json:"kernel"`
|
||||
SystemdVersion string `json:"systemd_version"`
|
||||
Architecture string `json:"architecture"`
|
||||
CollectorServiceUser string `json:"collector_service_user"`
|
||||
CollectorProcessUID int `json:"collector_process_uid"`
|
||||
CollectorAuthority string `json:"collector_authority"`
|
||||
AmbientCapabilitiesNone bool `json:"ambient_capabilities_none"`
|
||||
HelperProtocolHealthy bool `json:"helper_protocol_healthy"`
|
||||
StateIdentityPreserved bool `json:"state_identity_preserved"`
|
||||
DockerDegraded bool `json:"docker_degraded"`
|
||||
ActionRunnerQualified bool `json:"action_runner_qualified"`
|
||||
ActionMutationVerified bool `json:"action_mutation_verified"`
|
||||
CollectorAuthorityReductionRequestObserved bool `json:"collector_authority_reduction_request_observed"`
|
||||
ActionReceiptKind string `json:"action_receipt_kind,omitempty"`
|
||||
CredentialRotated bool `json:"credential_rotated"`
|
||||
SelfRevokeObserved bool `json:"self_revoke_observed"`
|
||||
CollectorContinuity bool `json:"collector_continuity"`
|
||||
ReportCount int `json:"report_count"`
|
||||
FirstReportAt string `json:"first_report_at"`
|
||||
LastReportAt string `json:"last_report_at"`
|
||||
Scenarios []secureRuntimeScenarioResult `json:"scenarios"`
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
RecordPath string `json:"record_path"`
|
||||
StartedAt string `json:"started_at"`
|
||||
CompletedAt string `json:"completed_at"`
|
||||
SourceManifest secureRuntimeSourceManifestBinding `json:"source_manifest"`
|
||||
SourceHashes map[string]string `json:"source_hashes"`
|
||||
ArtifactHashes map[string]string `json:"artifact_hashes"`
|
||||
ArtifactVersions map[string]string `json:"artifact_versions"`
|
||||
DisposableVMGuardHash string `json:"disposable_vm_guard_sha256"`
|
||||
OSRelease string `json:"os_release"`
|
||||
Kernel string `json:"kernel"`
|
||||
SystemdVersion string `json:"systemd_version"`
|
||||
Architecture string `json:"architecture"`
|
||||
CollectorServiceUser string `json:"collector_service_user"`
|
||||
CollectorProcessUID int `json:"collector_process_uid"`
|
||||
CollectorAuthority string `json:"collector_authority"`
|
||||
AmbientCapabilitiesNone bool `json:"ambient_capabilities_none"`
|
||||
HelperProtocolHealthy bool `json:"helper_protocol_healthy"`
|
||||
StateIdentityPreserved bool `json:"state_identity_preserved"`
|
||||
DockerDegraded bool `json:"docker_degraded"`
|
||||
ActionRunnerQualified bool `json:"action_runner_qualified"`
|
||||
ActionMutationVerified bool `json:"action_mutation_verified"`
|
||||
CollectorAuthorityReductionRequestObserved bool `json:"collector_authority_reduction_request_observed"`
|
||||
ActionReceiptKind string `json:"action_receipt_kind,omitempty"`
|
||||
CredentialRotated bool `json:"credential_rotated"`
|
||||
SelfRevokeObserved bool `json:"self_revoke_observed"`
|
||||
CollectorContinuity bool `json:"collector_continuity"`
|
||||
ReportCount int `json:"report_count"`
|
||||
FirstReportAt string `json:"first_report_at"`
|
||||
LastReportAt string `json:"last_report_at"`
|
||||
Transcript secureRuntimeTranscriptBinding `json:"transcript"`
|
||||
Scenarios []secureRuntimeScenarioResult `json:"scenarios"`
|
||||
}
|
||||
|
||||
var secureRuntimeTranscriptRecorder struct {
|
||||
sync.Mutex
|
||||
enabled bool
|
||||
events []secureRuntimeTranscriptEvent
|
||||
}
|
||||
|
||||
func secureRuntimeResetTranscript(enabled bool) {
|
||||
secureRuntimeTranscriptRecorder.Lock()
|
||||
defer secureRuntimeTranscriptRecorder.Unlock()
|
||||
secureRuntimeTranscriptRecorder.enabled = enabled
|
||||
secureRuntimeTranscriptRecorder.events = nil
|
||||
}
|
||||
|
||||
func secureRuntimeRecordTranscriptEvent(event secureRuntimeTranscriptEvent) secureRuntimeTranscriptEvent {
|
||||
secureRuntimeTranscriptRecorder.Lock()
|
||||
defer secureRuntimeTranscriptRecorder.Unlock()
|
||||
if !secureRuntimeTranscriptRecorder.enabled {
|
||||
return event
|
||||
}
|
||||
event.Sequence = len(secureRuntimeTranscriptRecorder.events) + 1
|
||||
event.EventID = fmt.Sprintf("event-%04d", event.Sequence)
|
||||
if event.ObservedAt == "" {
|
||||
event.ObservedAt = time.Now().UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
secureRuntimeTranscriptRecorder.events = append(secureRuntimeTranscriptRecorder.events, event)
|
||||
return event
|
||||
}
|
||||
|
||||
func secureRuntimeRecordCommandOutput(operation string, output []byte) {
|
||||
secureRuntimeRecordTranscriptEvent(secureRuntimeTranscriptEvent{
|
||||
Kind: "command_output", Operation: operation, Output: string(output),
|
||||
OutputSHA256: secureRuntimeHash(output),
|
||||
})
|
||||
}
|
||||
|
||||
func secureRuntimeTranscriptSnapshot() []secureRuntimeTranscriptEvent {
|
||||
secureRuntimeTranscriptRecorder.Lock()
|
||||
defer secureRuntimeTranscriptRecorder.Unlock()
|
||||
return append([]secureRuntimeTranscriptEvent(nil), secureRuntimeTranscriptRecorder.events...)
|
||||
}
|
||||
|
||||
func TestSecureRuntimeReceiptCredentialDetection(t *testing.T) {
|
||||
|
|
@ -601,6 +725,29 @@ func TestSecureRuntimeReceiptCredentialDetection(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestSecureRuntimeSourceManifestCoversTransitiveProviders(t *testing.T) {
|
||||
_, hashes := secureRuntimeLoadSourceBoundary(t, runtime.GOARCH)
|
||||
for _, required := range []string{
|
||||
"internal/agenthelper/providers.go",
|
||||
"internal/agenthelper/container_inventory.go",
|
||||
"internal/hostagent/action_runner_client.go",
|
||||
"internal/hostagent/action_runner_health_persistence_unix.go",
|
||||
"internal/hostagent/package_updates.go",
|
||||
"internal/api/action_runner_credentials.go",
|
||||
"internal/api/router_routes_registration.go",
|
||||
"internal/agentexec/server.go",
|
||||
} {
|
||||
if _, ok := hashes[required]; !ok {
|
||||
t.Fatalf("secure-runtime source manifest omitted transitive boundary source %s", required)
|
||||
}
|
||||
}
|
||||
for sourcePath := range hashes {
|
||||
if strings.HasSuffix(sourcePath, "_test.go") && sourcePath != "scripts/installtests/secure_runtime_systemd_lab_test.go" {
|
||||
t.Fatalf("secure-runtime production source manifest included test source %s", sourcePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecureRuntimeSystemdLab(t *testing.T) {
|
||||
if os.Getenv(secureRuntimeLabOptIn) != "1" {
|
||||
t.Skip("set PULSE_SECURE_RUNTIME_SYSTEMD_LAB=1 only inside a disposable systemd VM")
|
||||
|
|
@ -620,50 +767,57 @@ func TestSecureRuntimeSystemdLab(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("resolve installer path: %v", err)
|
||||
}
|
||||
installer := secureRuntimeReadFile(t, installerPath)
|
||||
|
||||
fixture := newSecureRuntimeLabFixture(collectorV1, helper, runner, collectorV1Version)
|
||||
defer fixture.actionServer.Shutdown()
|
||||
server := httptest.NewServer(fixture)
|
||||
defer server.Close()
|
||||
|
||||
recordPath := strings.TrimSpace(os.Getenv("PULSE_SECURE_RUNTIME_RECEIPT_RECORD_PATH"))
|
||||
if recordPath == "" || filepath.IsAbs(recordPath) || filepath.ToSlash(filepath.Clean(recordPath)) != recordPath || strings.Contains(recordPath, "..") {
|
||||
t.Fatalf("PULSE_SECURE_RUNTIME_RECEIPT_RECORD_PATH must be a canonical repository-relative path: %q", recordPath)
|
||||
}
|
||||
startedAt := time.Now().UTC()
|
||||
sourceManifest, sourceHashes := secureRuntimeLoadSourceBoundary(t, runtime.GOARCH)
|
||||
receipt := secureRuntimeLabReceipt{
|
||||
SchemaVersion: 3,
|
||||
SourceHashes: map[string]string{
|
||||
"scripts/install.sh": secureRuntimeHash(installer),
|
||||
"scripts/installtests/secure_runtime_systemd_lab_test.go": secureRuntimeHash(secureRuntimeReadFile(t, repoFile("scripts", "installtests", "secure_runtime_systemd_lab_test.go"))),
|
||||
"scripts/release_control/secure_runtime_attestation.py": secureRuntimeHash(secureRuntimeReadFile(t, repoFile("scripts", "release_control", "secure_runtime_attestation.py"))),
|
||||
"internal/agenthelper/update_activation.go": secureRuntimeHash(secureRuntimeReadFile(t, repoFile("internal", "agenthelper", "update_activation.go"))),
|
||||
"internal/agenthelper/server.go": secureRuntimeHash(secureRuntimeReadFile(t, repoFile("internal", "agenthelper", "server.go"))),
|
||||
"internal/agentexec/server.go": secureRuntimeHash(secureRuntimeReadFile(t, repoFile("internal", "agentexec", "server.go"))),
|
||||
"internal/agentexec/types.go": secureRuntimeHash(secureRuntimeReadFile(t, repoFile("internal", "agentexec", "types.go"))),
|
||||
"internal/agentexec/verifier_postconditions.go": secureRuntimeHash(secureRuntimeReadFile(t, repoFile("internal", "agentexec", "verifier_postconditions.go"))),
|
||||
"internal/api/collector_authority.go": secureRuntimeHash(secureRuntimeReadFile(t, repoFile("internal", "api", "collector_authority.go"))),
|
||||
"internal/api/action_runner_credentials.go": secureRuntimeHash(secureRuntimeReadFile(t, repoFile("internal", "api", "action_runner_credentials.go"))),
|
||||
"internal/api/agenttokens/install.go": secureRuntimeHash(secureRuntimeReadFile(t, repoFile("internal", "api", "agenttokens", "install.go"))),
|
||||
"internal/api/agent_exec_token_binding.go": secureRuntimeHash(secureRuntimeReadFile(t, repoFile("internal", "api", "agent_exec_token_binding.go"))),
|
||||
"internal/actionrunner/runner.go": secureRuntimeHash(secureRuntimeReadFile(t, repoFile("internal", "actionrunner", "runner.go"))),
|
||||
"internal/actionrunner/types.go": secureRuntimeHash(secureRuntimeReadFile(t, repoFile("internal", "actionrunner", "types.go"))),
|
||||
"internal/actionrunner/transport.go": secureRuntimeHash(secureRuntimeReadFile(t, repoFile("internal", "actionrunner", "transport.go"))),
|
||||
"internal/hostagent/storage_cleanup.go": secureRuntimeHash(secureRuntimeReadFile(t, repoFile("internal", "hostagent", "storage_cleanup.go"))),
|
||||
"internal/operationreceipt/store.go": secureRuntimeHash(secureRuntimeReadFile(t, repoFile("internal", "operationreceipt", "store.go"))),
|
||||
"internal/operationreceipt/types.go": secureRuntimeHash(secureRuntimeReadFile(t, repoFile("internal", "operationreceipt", "types.go"))),
|
||||
"internal/agentupdate/update.go": secureRuntimeHash(secureRuntimeReadFile(t, repoFile("internal", "agentupdate", "update.go"))),
|
||||
"internal/agentupdate/privileged_update.go": secureRuntimeHash(secureRuntimeReadFile(t, repoFile("internal", "agentupdate", "privileged_update.go"))),
|
||||
"cmd/pulse-agent/main.go": secureRuntimeHash(secureRuntimeReadFile(t, repoFile("cmd", "pulse-agent", "main.go"))),
|
||||
"cmd/pulse-agent-helper/main.go": secureRuntimeHash(secureRuntimeReadFile(t, repoFile("cmd", "pulse-agent-helper", "main.go"))),
|
||||
"cmd/pulse-agent-runner/main.go": secureRuntimeHash(secureRuntimeReadFile(t, repoFile("cmd", "pulse-agent-runner", "main.go"))),
|
||||
},
|
||||
SchemaVersion: 4,
|
||||
RecordPath: recordPath,
|
||||
StartedAt: startedAt.Format(time.RFC3339Nano),
|
||||
SourceManifest: sourceManifest,
|
||||
SourceHashes: sourceHashes,
|
||||
ArtifactHashes: map[string]string{"collector_v1": secureRuntimeHash(collectorV1), "collector_v2": secureRuntimeHash(collectorV2), "helper": secureRuntimeHash(helper), "runner": secureRuntimeHash(runner)},
|
||||
ArtifactVersions: map[string]string{"collector_v1": collectorV1Version, "collector_v2": collectorV2Version},
|
||||
DisposableVMGuardHash: secureRuntimeHash([]byte(secureRuntimeLabMarkerValue + "\n")),
|
||||
Architecture: runtime.GOARCH,
|
||||
}
|
||||
secureRuntimeResetTranscript(true)
|
||||
t.Cleanup(func() { secureRuntimeResetTranscript(false) })
|
||||
receipt.OSRelease = strings.TrimSpace(string(secureRuntimeReadFile(t, "/etc/os-release")))
|
||||
receipt.Kernel = secureRuntimeCommand(t, 10*time.Second, "uname", "-srvmo")
|
||||
receipt.SystemdVersion = strings.SplitN(secureRuntimeCommand(t, 10*time.Second, "systemctl", "--version"), "\n", 2)[0]
|
||||
pass := func(name, detail string) {
|
||||
receipt.Scenarios = append(receipt.Scenarios, secureRuntimeScenarioResult{Name: name, Passed: true, Detail: detail})
|
||||
scenarioStartedAt := startedAt
|
||||
pass := func(name, detail string, observations map[string]any) {
|
||||
claims, ok := secureRuntimeScenarioClaims[name]
|
||||
if !ok {
|
||||
t.Fatalf("scenario %q has no governed causal claims", name)
|
||||
}
|
||||
completedAt := time.Now().UTC()
|
||||
sequence := len(receipt.Scenarios) + 1
|
||||
claims = append([]string(nil), claims...)
|
||||
event := secureRuntimeRecordTranscriptEvent(secureRuntimeTranscriptEvent{
|
||||
ObservedAt: completedAt.Format(time.RFC3339Nano), Kind: "scenario_result",
|
||||
Scenario: name, Claims: claims, Observations: observations, Summary: detail,
|
||||
})
|
||||
receipt.Scenarios = append(receipt.Scenarios, secureRuntimeScenarioResult{
|
||||
Sequence: sequence, Name: name, Passed: true,
|
||||
StartedAt: scenarioStartedAt.Format(time.RFC3339Nano), CompletedAt: completedAt.Format(time.RFC3339Nano),
|
||||
Evidence: secureRuntimeScenarioEvidence{
|
||||
Kind: "runtime-observation-v1", Summary: detail, Claims: claims,
|
||||
Observations: observations,
|
||||
TranscriptEventIDs: []string{event.EventID},
|
||||
},
|
||||
})
|
||||
scenarioStartedAt = completedAt
|
||||
}
|
||||
|
||||
initialArgs := []string{"--enable-commands", "--command-authority", "command-capable"}
|
||||
|
|
@ -678,7 +832,7 @@ func TestSecureRuntimeSystemdLab(t *testing.T) {
|
|||
if dockerInitiallyAvailable && !dockerInitiallyEnabled {
|
||||
t.Fatal("rootful Docker was available but the requested legacy profile did not enable Docker monitoring")
|
||||
}
|
||||
pass("legacy_root_command_capable_install", fmt.Sprintf("root collector installed; docker_enabled=%t", dockerInitiallyEnabled))
|
||||
pass("legacy_root_command_capable_install", fmt.Sprintf("root collector installed; docker_enabled=%t", dockerInitiallyEnabled), map[string]any{"collector_process_uid": 0, "commands_enabled": true})
|
||||
|
||||
beforeInspect := secureRuntimeStableSnapshot(t)
|
||||
inspectOutput := secureRuntimeRunInstaller(t, installerPath, server.URL, "--safe-profile-inspect")
|
||||
|
|
@ -689,7 +843,7 @@ func TestSecureRuntimeSystemdLab(t *testing.T) {
|
|||
if afterInspect := secureRuntimeStableSnapshot(t); !secureRuntimeIdentitiesEqual(beforeInspect, afterInspect) {
|
||||
t.Fatal("--safe-profile-inspect mutated installer-owned stable files")
|
||||
}
|
||||
pass("read_only_inspect", "stable installer-owned files unchanged")
|
||||
pass("read_only_inspect", "stable installer-owned files unchanged", map[string]any{"stable_files_unchanged": true})
|
||||
|
||||
dropInDir := "/etc/systemd/system/pulse-agent.service.d"
|
||||
dropInPath := filepath.Join(dropInDir, "secure-runtime-lab.conf")
|
||||
|
|
@ -722,7 +876,7 @@ func TestSecureRuntimeSystemdLab(t *testing.T) {
|
|||
_ = os.Remove(dropInDir)
|
||||
dropInPresent = false
|
||||
secureRuntimeCommand(t, 20*time.Second, "systemctl", "daemon-reload")
|
||||
pass("drop_in_fail_closed_rehearsal", "migration rejected before stable installer-owned files changed")
|
||||
pass("drop_in_fail_closed_rehearsal", "migration rejected before stable installer-owned files changed", map[string]any{"rejected_before_mutation": true})
|
||||
|
||||
legacyBaseline := secureRuntimeStableSnapshot(t)
|
||||
reportsBeforeApply, preApplyLastSeen, _, _ := fixture.snapshot()
|
||||
|
|
@ -744,14 +898,14 @@ func TestSecureRuntimeSystemdLab(t *testing.T) {
|
|||
t.Fatalf("safe migration did not make rootful Docker degradation explicit:\n%s", applyOutput)
|
||||
}
|
||||
}
|
||||
pass("safe_profile_apply", "fresh server lastSeen, least-privilege identity, typed helper health")
|
||||
pass("safe_profile_apply", "fresh server lastSeen, least-privilege identity, typed helper health", map[string]any{"collector_service_user": "pulse-agent", "collector_authority": "monitoring-only", "helper_status": "ok"})
|
||||
|
||||
reportsBeforeRollback, _, _, _ := fixture.snapshot()
|
||||
secureRuntimeRunInstaller(t, installerPath, server.URL, "--safe-profile-rollback")
|
||||
secureRuntimeWaitForReports(t, fixture, len(reportsBeforeRollback)+1, 45*time.Second)
|
||||
secureRuntimeAssertRootMonitoringProfile(t)
|
||||
secureRuntimeWaitForStableIdentity(t, secureRuntimeWithoutCollectorUnit(legacyBaseline), 10*time.Second, "explicit rollback")
|
||||
pass("explicit_safe_profile_rollback", "legacy binary and service identity restored without resurrecting collector command authority")
|
||||
pass("explicit_safe_profile_rollback", "legacy binary and service identity restored without resurrecting collector command authority", map[string]any{"restored_profile": "root-monitoring", "commands_enabled": false})
|
||||
|
||||
automaticRollbackBaseline := secureRuntimeStableSnapshot(t)
|
||||
fixture.setFrozen(true)
|
||||
|
|
@ -762,7 +916,7 @@ func TestSecureRuntimeSystemdLab(t *testing.T) {
|
|||
}
|
||||
secureRuntimeWaitForStableIdentity(t, secureRuntimeWithoutCollectorUnit(automaticRollbackBaseline), 10*time.Second, "automatic failure rollback")
|
||||
secureRuntimeAssertRootMonitoringProfile(t)
|
||||
pass("automatic_failure_rollback", "frozen lastSeen prevented commit and restored binary/state identity without command authority")
|
||||
pass("automatic_failure_rollback", "frozen lastSeen prevented commit and restored binary/state identity without command authority", map[string]any{"activation_committed": false, "restored_profile": "root-monitoring"})
|
||||
|
||||
fixture.setCollector(collectorV2)
|
||||
reportsBeforeUpdate, _, _, _ := fixture.snapshot()
|
||||
|
|
@ -772,7 +926,7 @@ func TestSecureRuntimeSystemdLab(t *testing.T) {
|
|||
if got := secureRuntimeHash(secureRuntimeReadFile(t, "/usr/local/bin/pulse-agent")); got != secureRuntimeHash(collectorV2) {
|
||||
t.Fatalf("ordinary update collector hash = %s, want v2 hash", got)
|
||||
}
|
||||
pass("ordinary_update_non_migration", "binary updated while the downgraded root monitoring profile remained unchanged")
|
||||
pass("ordinary_update_non_migration", "binary updated while the downgraded root monitoring profile remained unchanged", map[string]any{"collector_v2_installed": true, "selected_profile": "root-monitoring"})
|
||||
|
||||
fixture.setServerVersion(collectorV2Version)
|
||||
reportsBeforeFinalApply, preFinalLastSeen, _, _ := fixture.snapshot()
|
||||
|
|
@ -789,7 +943,7 @@ func TestSecureRuntimeSystemdLab(t *testing.T) {
|
|||
secureRuntimeAssertHelperProtocol(t)
|
||||
reportsBeforeContinuity, _, _, _ := fixture.snapshot()
|
||||
secureRuntimeWaitForReports(t, fixture, len(reportsBeforeContinuity)+1, 20*time.Second)
|
||||
pass("final_safe_profile_apply", "collector continued reporting after committed migration")
|
||||
pass("final_safe_profile_apply", "collector continued reporting after committed migration", map[string]any{"collector_service_user": "pulse-agent", "continuity_report_observed": true})
|
||||
|
||||
reportsBeforeRunner, _, _, _ := fixture.snapshot()
|
||||
secureRuntimeRunInstallerWithActionCredential(t, installerPath, server.URL, secureRuntimeRunnerSecretV1,
|
||||
|
|
@ -797,7 +951,7 @@ func TestSecureRuntimeSystemdLab(t *testing.T) {
|
|||
secureRuntimeWaitForActionRunner(t, fixture, true, 30*time.Second)
|
||||
secureRuntimeAssertActionRunnerInstalled(t)
|
||||
secureRuntimeWaitForReports(t, fixture, len(reportsBeforeRunner)+1, 20*time.Second)
|
||||
pass("separate_action_runner_install", "root action runner registered independently while the collector remained non-root and reporting")
|
||||
pass("separate_action_runner_install", "root action runner registered independently while the collector remained non-root and reporting", map[string]any{"runner_service_user": "root", "collector_service_user": "pulse-agent"})
|
||||
|
||||
actionContext, cancelAction := context.WithTimeout(agentexec.WithOrganizationID(context.Background(), secureRuntimeLabOrgID), 30*time.Second)
|
||||
defer cancelAction()
|
||||
|
|
@ -843,7 +997,7 @@ func TestSecureRuntimeSystemdLab(t *testing.T) {
|
|||
}); err == nil || !strings.Contains(err.Error(), "typed action-runner") {
|
||||
t.Fatalf("action runner did not reject generic command dispatch: %v", err)
|
||||
}
|
||||
pass("typed_action_receipt", "verified apt cache mutation and durable terminal receipt; stale fingerprint refused before mutation; generic command dispatch denied")
|
||||
pass("typed_action_receipt", "verified apt cache mutation and durable terminal receipt; stale fingerprint refused before mutation; generic command dispatch denied", map[string]any{"action_receipt_kind": agentexec.HostStorageCleanupReceiptKind, "mutation_started": true, "verification": "verified", "stale_precondition_mutation_started": false, "generic_command_denied": true})
|
||||
|
||||
currentAdmission, _, _ := fixture.actionSnapshot()
|
||||
wrongAdmission := currentAdmission
|
||||
|
|
@ -863,7 +1017,7 @@ func TestSecureRuntimeSystemdLab(t *testing.T) {
|
|||
if revoked || rotatedAdmission.TokenID != secureRuntimeRunnerBindingV2 {
|
||||
t.Fatalf("rotated action-runner admission = %+v revoked=%t", rotatedAdmission, revoked)
|
||||
}
|
||||
pass("action_runner_credential_rotation", "mismatched invalidation was rejected, exact superseded session closed, replacement credential registered")
|
||||
pass("action_runner_credential_rotation", "mismatched invalidation was rejected, exact superseded session closed, replacement credential registered", map[string]any{"proof_scope": "in-memory-fixture", "superseded_session_invalidated": true, "replacement_registered": true})
|
||||
|
||||
reportsBeforeRemoval, _, _, _ := fixture.snapshot()
|
||||
uninstallOutput := secureRuntimeRunStandaloneInstaller(t, installerPath, "--uninstall-action-runner")
|
||||
|
|
@ -879,7 +1033,7 @@ func TestSecureRuntimeSystemdLab(t *testing.T) {
|
|||
secureRuntimeAssertSafeProfile(t)
|
||||
secureRuntimeAssertHelperProtocol(t)
|
||||
secureRuntimeWaitForReports(t, fixture, len(reportsBeforeRemoval)+1, 20*time.Second)
|
||||
pass("action_runner_self_revoke", "uninstall revoked the exact host binding and removed only runner state; collector/helper continuity remained healthy")
|
||||
pass("action_runner_self_revoke", "uninstall revoked the exact host binding and removed only runner state; collector/helper continuity remained healthy", map[string]any{"revocation_count": revokeCount, "collector_continuity": true})
|
||||
|
||||
reports, _, _, _ := fixture.snapshot()
|
||||
if len(reports) == 0 {
|
||||
|
|
@ -916,7 +1070,7 @@ func TestSecureRuntimeSystemdLab(t *testing.T) {
|
|||
receipt.ReportCount = len(reports)
|
||||
receipt.FirstReportAt = reports[0].ReceivedAt.Format(time.RFC3339Nano)
|
||||
receipt.LastReportAt = reports[len(reports)-1].ReceivedAt.Format(time.RFC3339Nano)
|
||||
secureRuntimeWriteReceipt(t, receipt)
|
||||
secureRuntimeWriteEvidence(t, &receipt, secureRuntimeTranscriptSnapshot())
|
||||
}
|
||||
|
||||
func secureRuntimeRequireDisposableHost(t *testing.T) {
|
||||
|
|
@ -995,6 +1149,81 @@ func secureRuntimeHash(data []byte) string {
|
|||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func secureRuntimeLoadSourceBoundary(t *testing.T, targetArch string) (secureRuntimeSourceManifestBinding, map[string]string) {
|
||||
t.Helper()
|
||||
repoRoot, err := filepath.Abs(repoFile())
|
||||
if err != nil {
|
||||
t.Fatalf("resolve repository root: %v", err)
|
||||
}
|
||||
manifestRelative := "scripts/release_control/secure_runtime_source_manifest_v4.json"
|
||||
manifestRaw := secureRuntimeReadFile(t, filepath.Join(repoRoot, filepath.FromSlash(manifestRelative)))
|
||||
var manifest secureRuntimeSourceManifest
|
||||
if err := json.Unmarshal(manifestRaw, &manifest); err != nil {
|
||||
t.Fatalf("decode secure-runtime source manifest: %v", err)
|
||||
}
|
||||
if manifest.SchemaVersion != 1 || manifest.ManifestID != "secure-runtime-linux-v4" || manifest.TargetOS != "linux" {
|
||||
t.Fatalf("unsupported secure-runtime source manifest: %+v", manifest)
|
||||
}
|
||||
sourceHashes := make(map[string]string)
|
||||
addSource := func(relative string) {
|
||||
if relative == "" || filepath.IsAbs(relative) || strings.Contains(relative, "..") || filepath.ToSlash(filepath.Clean(relative)) != relative {
|
||||
t.Fatalf("source manifest contains non-canonical path %q", relative)
|
||||
}
|
||||
sourceHashes[relative] = secureRuntimeHash(secureRuntimeReadFile(t, filepath.Join(repoRoot, filepath.FromSlash(relative))))
|
||||
}
|
||||
for _, relative := range manifest.ExactPaths {
|
||||
addSource(relative)
|
||||
}
|
||||
if _, ok := sourceHashes[manifestRelative]; !ok {
|
||||
t.Fatal("secure-runtime source manifest does not include itself")
|
||||
}
|
||||
for _, relativeRoot := range manifest.RecursiveRoots {
|
||||
root := filepath.Join(repoRoot, filepath.FromSlash(relativeRoot))
|
||||
matched := 0
|
||||
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
relative, err := filepath.Rel(repoRoot, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
relative = filepath.ToSlash(relative)
|
||||
included := false
|
||||
for _, suffix := range manifest.IncludeSuffixes {
|
||||
included = included || strings.HasSuffix(relative, suffix)
|
||||
}
|
||||
for _, suffix := range manifest.ExcludeSuffixes {
|
||||
if strings.HasSuffix(relative, suffix) {
|
||||
included = false
|
||||
}
|
||||
}
|
||||
if included {
|
||||
addSource(relative)
|
||||
matched++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expand secure-runtime source root %s: %v", relativeRoot, err)
|
||||
}
|
||||
if matched == 0 {
|
||||
t.Fatalf("secure-runtime source root %s matched no production source", relativeRoot)
|
||||
}
|
||||
}
|
||||
return secureRuntimeSourceManifestBinding{
|
||||
SchemaVersion: manifest.SchemaVersion,
|
||||
ManifestID: manifest.ManifestID,
|
||||
Path: manifestRelative,
|
||||
SHA256: secureRuntimeHash(manifestRaw),
|
||||
TargetOS: manifest.TargetOS,
|
||||
TargetArch: targetArch,
|
||||
}, sourceHashes
|
||||
}
|
||||
|
||||
func secureRuntimeRunInstaller(t *testing.T, installerPath, fixtureURL string, scenarioArgs ...string) string {
|
||||
t.Helper()
|
||||
out, err := secureRuntimeRunInstallerErrorWithActionCredential(t, installerPath, fixtureURL, "", scenarioArgs...)
|
||||
|
|
@ -1059,6 +1288,7 @@ func secureRuntimeRunInstallerErrorWithActionCredential(t *testing.T, installerP
|
|||
return string(out), fmt.Errorf("installer timed out: %w", ctx.Err())
|
||||
}
|
||||
secureRuntimeAssertNoCredentialExposure(t, out)
|
||||
secureRuntimeRecordCommandOutput("installer "+strings.Join(scenarioArgs, " "), out)
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
|
|
@ -1073,6 +1303,7 @@ func secureRuntimeRunStandaloneInstaller(t *testing.T, installerPath string, sce
|
|||
t.Fatalf("standalone installer timed out: %v\n%s", ctx.Err(), out)
|
||||
}
|
||||
secureRuntimeAssertNoCredentialExposure(t, out)
|
||||
secureRuntimeRecordCommandOutput("standalone installer "+strings.Join(scenarioArgs, " "), out)
|
||||
if err != nil {
|
||||
t.Fatalf("standalone installer %s failed: %v\n%s", strings.Join(scenarioArgs, " "), err, out)
|
||||
}
|
||||
|
|
@ -1122,6 +1353,8 @@ func secureRuntimeCommand(t *testing.T, timeout time.Duration, name string, args
|
|||
if err != nil {
|
||||
t.Fatalf("%s %s: %v\n%s", name, strings.Join(args, " "), err, out)
|
||||
}
|
||||
secureRuntimeAssertNoCredentialExposure(t, out)
|
||||
secureRuntimeRecordCommandOutput(name, out)
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
|
|
@ -1431,7 +1664,7 @@ func secureRuntimeCollectorOwnedRootlessAvailable(t *testing.T) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
func secureRuntimeWriteReceipt(t *testing.T, receipt secureRuntimeLabReceipt) {
|
||||
func secureRuntimeWriteEvidence(t *testing.T, receipt *secureRuntimeLabReceipt, events []secureRuntimeTranscriptEvent) {
|
||||
t.Helper()
|
||||
path := strings.TrimSpace(os.Getenv("PULSE_SECURE_RUNTIME_RECEIPT"))
|
||||
if path == "" {
|
||||
|
|
@ -1440,6 +1673,35 @@ func secureRuntimeWriteReceipt(t *testing.T, receipt secureRuntimeLabReceipt) {
|
|||
if !filepath.IsAbs(path) {
|
||||
t.Fatalf("PULSE_SECURE_RUNTIME_RECEIPT must be an absolute path: %s", path)
|
||||
}
|
||||
transcriptPath := strings.TrimSpace(os.Getenv("PULSE_SECURE_RUNTIME_TRANSCRIPT"))
|
||||
if transcriptPath == "" || !filepath.IsAbs(transcriptPath) {
|
||||
t.Fatalf("PULSE_SECURE_RUNTIME_TRANSCRIPT must be an absolute path: %s", transcriptPath)
|
||||
}
|
||||
transcriptRecordPath := strings.TrimSpace(os.Getenv("PULSE_SECURE_RUNTIME_TRANSCRIPT_RECORD_PATH"))
|
||||
if transcriptRecordPath == "" || filepath.IsAbs(transcriptRecordPath) || filepath.ToSlash(filepath.Clean(transcriptRecordPath)) != transcriptRecordPath || strings.Contains(transcriptRecordPath, "..") {
|
||||
t.Fatalf("PULSE_SECURE_RUNTIME_TRANSCRIPT_RECORD_PATH must be a canonical repository-relative path: %q", transcriptRecordPath)
|
||||
}
|
||||
var transcript bytes.Buffer
|
||||
for _, event := range events {
|
||||
encodedEvent, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal secure-runtime transcript event: %v", err)
|
||||
}
|
||||
if secureRuntimeReceiptContainsCredential(encodedEvent) {
|
||||
t.Fatal("refusing to write a transcript containing credential material or token-labelled fields")
|
||||
}
|
||||
transcript.Write(encodedEvent)
|
||||
transcript.WriteByte('\n')
|
||||
}
|
||||
transcriptBytes := transcript.Bytes()
|
||||
receipt.Transcript = secureRuntimeTranscriptBinding{
|
||||
Format: "jsonl-v1", RecordPath: transcriptRecordPath,
|
||||
SHA256: secureRuntimeHash(transcriptBytes), EventCount: len(events),
|
||||
}
|
||||
if err := secureRuntimeWritePrivateAtomic(transcriptPath, transcriptBytes); err != nil {
|
||||
t.Fatalf("publish secure-runtime transcript: %v", err)
|
||||
}
|
||||
|
||||
encoded, err := json.MarshalIndent(receipt, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal secure-runtime receipt: %v", err)
|
||||
|
|
@ -1448,18 +1710,22 @@ func secureRuntimeWriteReceipt(t *testing.T, receipt secureRuntimeLabReceipt) {
|
|||
t.Fatal("refusing to write a receipt containing credential material or token-labelled fields")
|
||||
}
|
||||
encoded = append(encoded, '\n')
|
||||
temporary := path + ".tmp"
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("create receipt directory: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(temporary, encoded, 0o600); err != nil {
|
||||
t.Fatalf("write receipt temporary file: %v", err)
|
||||
}
|
||||
if err := os.Rename(temporary, path); err != nil {
|
||||
if err := secureRuntimeWritePrivateAtomic(path, encoded); err != nil {
|
||||
t.Fatalf("publish receipt: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func secureRuntimeWritePrivateAtomic(path string, data []byte) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
temporary := path + ".tmp"
|
||||
if err := os.WriteFile(temporary, data, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(temporary, path)
|
||||
}
|
||||
|
||||
func secureRuntimeReceiptContainsCredential(encoded []byte) bool {
|
||||
for _, credential := range [][]byte{
|
||||
[]byte(secureRuntimeLabToken),
|
||||
|
|
|
|||
|
|
@ -10,12 +10,18 @@ import math
|
|||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any, Sequence
|
||||
|
||||
|
||||
COMMIT_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
RECEIPT_SCHEMA_VERSION = 4
|
||||
ATTESTATION_SCHEMA_VERSION = 4
|
||||
SOURCE_MANIFEST_PATH = "scripts/release_control/secure_runtime_source_manifest_v4.json"
|
||||
SOURCE_MANIFEST_SCHEMA_VERSION = 1
|
||||
SOURCE_MANIFEST_ID = "secure-runtime-linux-v4"
|
||||
REQUIRED_SCENARIOS = (
|
||||
"legacy_root_command_capable_install",
|
||||
"read_only_inspect",
|
||||
|
|
@ -30,6 +36,58 @@ REQUIRED_SCENARIOS = (
|
|||
"action_runner_credential_rotation",
|
||||
"action_runner_self_revoke",
|
||||
)
|
||||
SCENARIO_REQUIRED_CLAIMS = {
|
||||
"legacy_root_command_capable_install": {"legacy_root_command_authority_observed"},
|
||||
"read_only_inspect": {"inspection_left_stable_files_unchanged"},
|
||||
"drop_in_fail_closed_rehearsal": {"drop_in_rejected_before_mutation"},
|
||||
"safe_profile_apply": {
|
||||
"collector_non_root",
|
||||
"collector_monitoring_only",
|
||||
"helper_protocol_healthy",
|
||||
"collector_authority_reduction_observed",
|
||||
},
|
||||
"explicit_safe_profile_rollback": {"explicit_rollback_preserved_reduced_authority"},
|
||||
"automatic_failure_rollback": {"failed_activation_restored_prior_runtime"},
|
||||
"ordinary_update_non_migration": {"ordinary_update_preserved_selected_profile"},
|
||||
"final_safe_profile_apply": {"collector_reporting_continued_after_migration"},
|
||||
"separate_action_runner_install": {"action_runner_registered_separately"},
|
||||
"typed_action_receipt": {
|
||||
"typed_mutation_verified",
|
||||
"terminal_receipt_replayed",
|
||||
"stale_precondition_refused",
|
||||
"generic_command_denied",
|
||||
},
|
||||
"action_runner_credential_rotation": {"fixture_credential_replacement_observed"},
|
||||
"action_runner_self_revoke": {"exact_runner_binding_revoked"},
|
||||
}
|
||||
SCENARIO_REQUIRED_OBSERVATIONS = {
|
||||
"legacy_root_command_capable_install": {"collector_process_uid": 0, "commands_enabled": True},
|
||||
"read_only_inspect": {"stable_files_unchanged": True},
|
||||
"drop_in_fail_closed_rehearsal": {"rejected_before_mutation": True},
|
||||
"safe_profile_apply": {
|
||||
"collector_service_user": "pulse-agent",
|
||||
"collector_authority": "monitoring-only",
|
||||
"helper_status": "ok",
|
||||
},
|
||||
"explicit_safe_profile_rollback": {"restored_profile": "root-monitoring", "commands_enabled": False},
|
||||
"automatic_failure_rollback": {"activation_committed": False, "restored_profile": "root-monitoring"},
|
||||
"ordinary_update_non_migration": {"collector_v2_installed": True, "selected_profile": "root-monitoring"},
|
||||
"final_safe_profile_apply": {"collector_service_user": "pulse-agent", "continuity_report_observed": True},
|
||||
"separate_action_runner_install": {"runner_service_user": "root", "collector_service_user": "pulse-agent"},
|
||||
"typed_action_receipt": {
|
||||
"action_receipt_kind": "pulse.host_storage_cleanup_result",
|
||||
"mutation_started": True,
|
||||
"verification": "verified",
|
||||
"stale_precondition_mutation_started": False,
|
||||
"generic_command_denied": True,
|
||||
},
|
||||
"action_runner_credential_rotation": {
|
||||
"proof_scope": "in-memory-fixture",
|
||||
"superseded_session_invalidated": True,
|
||||
"replacement_registered": True,
|
||||
},
|
||||
"action_runner_self_revoke": {"revocation_count": 1, "collector_continuity": True},
|
||||
}
|
||||
ARTIFACT_ARGUMENTS = {
|
||||
"collector_v1": "collector_v1",
|
||||
"collector_v2": "collector_v2",
|
||||
|
|
@ -42,31 +100,6 @@ EXPECTED_ARTIFACT_PACKAGES = {
|
|||
"helper": "github.com/rcourtman/pulse-go-rewrite/cmd/pulse-agent-helper",
|
||||
"runner": "github.com/rcourtman/pulse-go-rewrite/cmd/pulse-agent-runner",
|
||||
}
|
||||
REQUIRED_SOURCE_PATHS = {
|
||||
"scripts/install.sh",
|
||||
"scripts/installtests/secure_runtime_systemd_lab_test.go",
|
||||
"scripts/release_control/secure_runtime_attestation.py",
|
||||
"internal/agenthelper/update_activation.go",
|
||||
"internal/agenthelper/server.go",
|
||||
"internal/agentexec/server.go",
|
||||
"internal/agentexec/types.go",
|
||||
"internal/agentexec/verifier_postconditions.go",
|
||||
"internal/api/collector_authority.go",
|
||||
"internal/api/action_runner_credentials.go",
|
||||
"internal/api/agenttokens/install.go",
|
||||
"internal/api/agent_exec_token_binding.go",
|
||||
"internal/actionrunner/runner.go",
|
||||
"internal/actionrunner/types.go",
|
||||
"internal/actionrunner/transport.go",
|
||||
"internal/hostagent/storage_cleanup.go",
|
||||
"internal/operationreceipt/store.go",
|
||||
"internal/operationreceipt/types.go",
|
||||
"internal/agentupdate/update.go",
|
||||
"internal/agentupdate/privileged_update.go",
|
||||
"cmd/pulse-agent/main.go",
|
||||
"cmd/pulse-agent-helper/main.go",
|
||||
"cmd/pulse-agent-runner/main.go",
|
||||
}
|
||||
DISPOSABLE_VM_GUARD_SHA256 = hashlib.sha256(
|
||||
b"PULSE_SECURE_RUNTIME_SYSTEMD_LAB=disposable-v1\n"
|
||||
).hexdigest()
|
||||
|
|
@ -171,48 +204,227 @@ def reject_sensitive_keys(value: Any, path: str = "receipt") -> None:
|
|||
reject_sensitive_keys(child, f"{path}[{index}]")
|
||||
|
||||
|
||||
def canonical_repo_path(value: Any, label: str) -> str:
|
||||
if not isinstance(value, str) or not value:
|
||||
raise AttestationError(f"{label} must be a non-empty repository-relative path")
|
||||
path = PurePosixPath(value)
|
||||
if path.is_absolute() or ".." in path.parts or str(path) != value:
|
||||
raise AttestationError(f"{label} must be a canonical repository-relative path")
|
||||
return value
|
||||
|
||||
|
||||
def load_source_manifest(checkout: Path, commit: str) -> tuple[dict[str, Any], bytes, set[str]]:
|
||||
raw = run_git(checkout, "show", f"{commit}:{SOURCE_MANIFEST_PATH}").stdout
|
||||
try:
|
||||
manifest = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise AttestationError(f"unable to decode {SOURCE_MANIFEST_PATH}: {exc}") from exc
|
||||
if not isinstance(manifest, dict):
|
||||
raise AttestationError("secure-runtime source manifest must be an object")
|
||||
if manifest.get("schema_version") != SOURCE_MANIFEST_SCHEMA_VERSION:
|
||||
raise AttestationError("secure-runtime source manifest schema is unsupported")
|
||||
if manifest.get("manifest_id") != SOURCE_MANIFEST_ID:
|
||||
raise AttestationError("secure-runtime source manifest identity is unsupported")
|
||||
if manifest.get("target_os") != "linux":
|
||||
raise AttestationError("secure-runtime source manifest must target Linux")
|
||||
|
||||
exact_paths = manifest.get("exact_paths")
|
||||
recursive_roots = manifest.get("recursive_roots")
|
||||
include_suffixes = manifest.get("include_suffixes")
|
||||
exclude_suffixes = manifest.get("exclude_suffixes")
|
||||
if not isinstance(exact_paths, list) or not exact_paths:
|
||||
raise AttestationError("secure-runtime source manifest exact_paths must be a non-empty list")
|
||||
if not isinstance(recursive_roots, list) or not recursive_roots:
|
||||
raise AttestationError("secure-runtime source manifest recursive_roots must be a non-empty list")
|
||||
if not isinstance(include_suffixes, list) or not include_suffixes:
|
||||
raise AttestationError("secure-runtime source manifest include_suffixes must be a non-empty list")
|
||||
if not isinstance(exclude_suffixes, list):
|
||||
raise AttestationError("secure-runtime source manifest exclude_suffixes must be a list")
|
||||
if any(not isinstance(suffix, str) or not suffix for suffix in include_suffixes + exclude_suffixes):
|
||||
raise AttestationError("secure-runtime source manifest suffixes must be non-empty strings")
|
||||
|
||||
expected = {canonical_repo_path(value, "source manifest exact path") for value in exact_paths}
|
||||
if SOURCE_MANIFEST_PATH not in expected:
|
||||
raise AttestationError("secure-runtime source manifest must include itself")
|
||||
for raw_root in recursive_roots:
|
||||
root = canonical_repo_path(raw_root, "source manifest recursive root").rstrip("/")
|
||||
tree = run_git(checkout, "ls-tree", "-r", "--name-only", commit, "--", root).stdout
|
||||
matched = 0
|
||||
for raw_path in tree.decode("utf-8", errors="strict").splitlines():
|
||||
source_path = canonical_repo_path(raw_path, "source manifest expanded path")
|
||||
if not any(source_path.endswith(suffix) for suffix in include_suffixes):
|
||||
continue
|
||||
if any(source_path.endswith(suffix) for suffix in exclude_suffixes):
|
||||
continue
|
||||
expected.add(source_path)
|
||||
matched += 1
|
||||
if matched == 0:
|
||||
raise AttestationError(f"secure-runtime source manifest root has no production sources: {root}")
|
||||
return manifest, raw, expected
|
||||
|
||||
|
||||
def load_receipt(path: Path) -> tuple[dict[str, Any], bytes]:
|
||||
try:
|
||||
raw = path.read_bytes()
|
||||
receipt = json.loads(raw)
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise AttestationError(f"unable to load receipt {path}: {exc}") from exc
|
||||
if not isinstance(receipt, dict) or receipt.get("schema_version") != 3:
|
||||
raise AttestationError("receipt must be a schema_version 3 object")
|
||||
if not isinstance(receipt, dict) or receipt.get("schema_version") != RECEIPT_SCHEMA_VERSION:
|
||||
raise AttestationError(f"receipt must be a schema_version {RECEIPT_SCHEMA_VERSION} object")
|
||||
reject_sensitive_keys(receipt)
|
||||
return receipt, raw
|
||||
|
||||
|
||||
def verify_scenarios(receipt: dict[str, Any]) -> None:
|
||||
def parse_utc_timestamp(value: Any, label: str) -> datetime:
|
||||
if not isinstance(value, str) or not value:
|
||||
raise AttestationError(f"{label} must be an RFC3339 timestamp")
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise AttestationError(f"{label} must be an RFC3339 timestamp") from exc
|
||||
if parsed.tzinfo is None or parsed.utcoffset() != timezone.utc.utcoffset(parsed):
|
||||
raise AttestationError(f"{label} must be UTC")
|
||||
return parsed
|
||||
|
||||
|
||||
def load_and_verify_transcript(
|
||||
path: Path, receipt: dict[str, Any]
|
||||
) -> tuple[list[dict[str, Any]], bytes]:
|
||||
try:
|
||||
raw = path.read_bytes()
|
||||
except OSError as exc:
|
||||
raise AttestationError(f"unable to read transcript {path}: {exc}") from exc
|
||||
transcript = receipt.get("transcript")
|
||||
if not isinstance(transcript, dict):
|
||||
raise AttestationError("receipt transcript binding must be an object")
|
||||
if transcript.get("format") != "jsonl-v1":
|
||||
raise AttestationError("receipt transcript format must be jsonl-v1")
|
||||
canonical_repo_path(transcript.get("record_path"), "transcript record path")
|
||||
expected_digest = transcript.get("sha256")
|
||||
if not isinstance(expected_digest, str) or not SHA256_RE.fullmatch(expected_digest):
|
||||
raise AttestationError("receipt transcript digest is invalid")
|
||||
if sha256_bytes(raw) != expected_digest:
|
||||
raise AttestationError("transcript digest mismatch")
|
||||
|
||||
events: list[dict[str, Any]] = []
|
||||
previous_time: datetime | None = None
|
||||
event_ids: set[str] = set()
|
||||
for line_number, raw_line in enumerate(raw.splitlines(), 1):
|
||||
if not raw_line.strip():
|
||||
raise AttestationError(f"transcript line {line_number} is empty")
|
||||
try:
|
||||
event = json.loads(raw_line)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise AttestationError(f"transcript line {line_number} is not JSON") from exc
|
||||
if not isinstance(event, dict):
|
||||
raise AttestationError(f"transcript line {line_number} must be an object")
|
||||
reject_sensitive_keys(event, f"transcript[{line_number}]")
|
||||
if event.get("sequence") != line_number:
|
||||
raise AttestationError("transcript event sequence is not contiguous")
|
||||
event_id = event.get("event_id")
|
||||
if not isinstance(event_id, str) or not event_id or event_id in event_ids:
|
||||
raise AttestationError("transcript event IDs must be non-empty and unique")
|
||||
event_ids.add(event_id)
|
||||
observed_at = parse_utc_timestamp(event.get("observed_at"), f"transcript event {event_id} observed_at")
|
||||
if previous_time is not None and observed_at < previous_time:
|
||||
raise AttestationError("transcript chronology is not monotonic")
|
||||
previous_time = observed_at
|
||||
kind = event.get("kind")
|
||||
if kind == "scenario_result":
|
||||
if not isinstance(event.get("scenario"), str):
|
||||
raise AttestationError(f"transcript event {event_id} has no scenario")
|
||||
claims = event.get("claims")
|
||||
if not isinstance(claims, list) or not claims or any(not isinstance(claim, str) or not claim for claim in claims):
|
||||
raise AttestationError(f"transcript event {event_id} claims are invalid")
|
||||
if len(set(claims)) != len(claims):
|
||||
raise AttestationError(f"transcript event {event_id} repeats a claim")
|
||||
observations = event.get("observations")
|
||||
if not isinstance(observations, dict) or not observations:
|
||||
raise AttestationError(f"transcript event {event_id} observations are invalid")
|
||||
if not isinstance(event.get("summary"), str) or not event["summary"].strip():
|
||||
raise AttestationError(f"transcript event {event_id} summary is invalid")
|
||||
elif kind == "command_output":
|
||||
if not isinstance(event.get("operation"), str) or not event["operation"].strip():
|
||||
raise AttestationError(f"transcript command event {event_id} operation is invalid")
|
||||
output = event.get("output")
|
||||
if not isinstance(output, str):
|
||||
raise AttestationError(f"transcript command event {event_id} output is invalid")
|
||||
output_digest = event.get("output_sha256")
|
||||
if not isinstance(output_digest, str) or output_digest != sha256_bytes(output.encode("utf-8")):
|
||||
raise AttestationError(f"transcript command event {event_id} output digest is invalid")
|
||||
else:
|
||||
raise AttestationError(f"transcript event {event_id} has an unsupported kind")
|
||||
events.append(event)
|
||||
scenario_event_count = sum(event.get("kind") == "scenario_result" for event in events)
|
||||
command_event_count = sum(event.get("kind") == "command_output" for event in events)
|
||||
if transcript.get("event_count") != len(events) or scenario_event_count != len(REQUIRED_SCENARIOS) or command_event_count < 1:
|
||||
raise AttestationError("transcript event count does not contain the canonical scenarios and raw command evidence")
|
||||
return events, raw
|
||||
|
||||
|
||||
def verify_scenarios(receipt: dict[str, Any], transcript_events: list[dict[str, Any]]) -> None:
|
||||
scenarios = receipt.get("scenarios")
|
||||
if not isinstance(scenarios, list):
|
||||
raise AttestationError("receipt scenarios must be a list")
|
||||
names: list[str] = []
|
||||
for scenario in scenarios:
|
||||
run_started = parse_utc_timestamp(receipt.get("started_at"), "receipt started_at")
|
||||
run_completed = parse_utc_timestamp(receipt.get("completed_at"), "receipt completed_at")
|
||||
if run_completed < run_started:
|
||||
raise AttestationError("receipt chronology ends before it starts")
|
||||
for event in transcript_events:
|
||||
observed = parse_utc_timestamp(event.get("observed_at"), f"transcript event {event.get('event_id')} observed_at")
|
||||
if observed < run_started or observed > run_completed:
|
||||
raise AttestationError("transcript event falls outside the receipt chronology")
|
||||
previous_completed = run_started
|
||||
transcript_by_id = {event["event_id"]: event for event in transcript_events}
|
||||
for index, scenario in enumerate(scenarios, 1):
|
||||
if not isinstance(scenario, dict) or not isinstance(scenario.get("name"), str):
|
||||
raise AttestationError("every receipt scenario must have a string name")
|
||||
if scenario.get("passed") is not True:
|
||||
raise AttestationError(f"scenario {scenario['name']} did not pass")
|
||||
if scenario.get("sequence") != index:
|
||||
raise AttestationError("receipt scenario sequence is not contiguous")
|
||||
started = parse_utc_timestamp(scenario.get("started_at"), f"scenario {scenario['name']} started_at")
|
||||
completed = parse_utc_timestamp(scenario.get("completed_at"), f"scenario {scenario['name']} completed_at")
|
||||
if started < previous_completed or completed < started or completed > run_completed:
|
||||
raise AttestationError(f"scenario {scenario['name']} chronology is invalid")
|
||||
previous_completed = completed
|
||||
evidence = scenario.get("evidence")
|
||||
if not isinstance(evidence, dict) or evidence.get("kind") != "runtime-observation-v1":
|
||||
raise AttestationError(f"scenario {scenario['name']} evidence kind is invalid")
|
||||
if not isinstance(evidence.get("summary"), str) or not evidence["summary"].strip():
|
||||
raise AttestationError(f"scenario {scenario['name']} evidence summary is invalid")
|
||||
claims = evidence.get("claims")
|
||||
if not isinstance(claims, list) or any(not isinstance(claim, str) or not claim for claim in claims):
|
||||
raise AttestationError(f"scenario {scenario['name']} evidence claims are invalid")
|
||||
required_claims = SCENARIO_REQUIRED_CLAIMS.get(scenario["name"], set())
|
||||
if not required_claims.issubset(set(claims)):
|
||||
raise AttestationError(f"scenario {scenario['name']} omits required causal claims")
|
||||
observations = evidence.get("observations")
|
||||
if not isinstance(observations, dict) or not observations:
|
||||
raise AttestationError(f"scenario {scenario['name']} observations are invalid")
|
||||
required_observations = SCENARIO_REQUIRED_OBSERVATIONS.get(scenario["name"], {})
|
||||
if any(observations.get(key) != value for key, value in required_observations.items()):
|
||||
raise AttestationError(f"scenario {scenario['name']} omits required typed observations")
|
||||
event_ids = evidence.get("transcript_event_ids")
|
||||
if not isinstance(event_ids, list) or len(event_ids) != 1 or not isinstance(event_ids[0], str):
|
||||
raise AttestationError(f"scenario {scenario['name']} transcript evidence is invalid")
|
||||
event = transcript_by_id.get(event_ids[0])
|
||||
if (
|
||||
event is None
|
||||
or event.get("scenario") != scenario["name"]
|
||||
or event.get("claims") != claims
|
||||
or event.get("observations") != observations
|
||||
):
|
||||
raise AttestationError(f"scenario {scenario['name']} is not causally bound to its transcript event")
|
||||
if parse_utc_timestamp(event.get("observed_at"), f"scenario {scenario['name']} transcript time") != completed:
|
||||
raise AttestationError(f"scenario {scenario['name']} completion is not bound to its transcript time")
|
||||
names.append(scenario["name"])
|
||||
if tuple(names) != REQUIRED_SCENARIOS:
|
||||
raise AttestationError("receipt scenario set or order does not match the canonical 12-scenario qualification")
|
||||
|
||||
|
||||
def verify_runtime_claims(receipt: dict[str, Any]) -> None:
|
||||
required_true = (
|
||||
"ambient_capabilities_none",
|
||||
"helper_protocol_healthy",
|
||||
"state_identity_preserved",
|
||||
"action_runner_qualified",
|
||||
"action_mutation_verified",
|
||||
"collector_authority_reduction_request_observed",
|
||||
"credential_rotated",
|
||||
"self_revoke_observed",
|
||||
"collector_continuity",
|
||||
)
|
||||
for key in required_true:
|
||||
if receipt.get(key) is not True:
|
||||
raise AttestationError(f"receipt does not prove required runtime claim {key}")
|
||||
if receipt.get("collector_service_user") != "pulse-agent":
|
||||
raise AttestationError("receipt collector service user is not pulse-agent")
|
||||
if not isinstance(receipt.get("collector_process_uid"), int) or receipt["collector_process_uid"] <= 0:
|
||||
|
|
@ -221,15 +433,48 @@ def verify_runtime_claims(receipt: dict[str, Any]) -> None:
|
|||
raise AttestationError("receipt collector authority is not monitoring-only")
|
||||
if receipt.get("disposable_vm_guard_sha256") != DISPOSABLE_VM_GUARD_SHA256:
|
||||
raise AttestationError("receipt disposable-VM guard claim is missing or invalid")
|
||||
if receipt.get("action_receipt_kind") != "pulse.host_storage_cleanup_result":
|
||||
raise AttestationError("receipt action result kind is not the qualified typed mutation")
|
||||
report_count = receipt.get("report_count")
|
||||
if not isinstance(report_count, int) or report_count < 2:
|
||||
raise AttestationError("receipt report count does not prove continuity")
|
||||
first_report = parse_utc_timestamp(receipt.get("first_report_at"), "receipt first_report_at")
|
||||
last_report = parse_utc_timestamp(receipt.get("last_report_at"), "receipt last_report_at")
|
||||
if last_report <= first_report:
|
||||
raise AttestationError("receipt report chronology does not prove continuity")
|
||||
run_started = parse_utc_timestamp(receipt.get("started_at"), "receipt started_at")
|
||||
run_completed = parse_utc_timestamp(receipt.get("completed_at"), "receipt completed_at")
|
||||
if first_report < run_started or last_report > run_completed:
|
||||
raise AttestationError("receipt reports fall outside the qualification run chronology")
|
||||
|
||||
|
||||
def verify_source_hashes(checkout: Path, commit: str, receipt: dict[str, Any]) -> dict[str, str]:
|
||||
def verify_source_hashes(
|
||||
checkout: Path, commit: str, receipt: dict[str, Any]
|
||||
) -> tuple[dict[str, str], dict[str, Any]]:
|
||||
manifest, manifest_raw, expected_paths = load_source_manifest(checkout, commit)
|
||||
binding = receipt.get("source_manifest")
|
||||
if not isinstance(binding, dict):
|
||||
raise AttestationError("receipt source_manifest binding must be an object")
|
||||
expected_binding = {
|
||||
"schema_version": SOURCE_MANIFEST_SCHEMA_VERSION,
|
||||
"manifest_id": SOURCE_MANIFEST_ID,
|
||||
"path": SOURCE_MANIFEST_PATH,
|
||||
"sha256": sha256_bytes(manifest_raw),
|
||||
"target_os": "linux",
|
||||
"target_arch": receipt.get("architecture"),
|
||||
}
|
||||
if binding != expected_binding:
|
||||
raise AttestationError("receipt source manifest binding does not match the qualified commit")
|
||||
source_hashes = receipt.get("source_hashes")
|
||||
if not isinstance(source_hashes, dict) or not source_hashes:
|
||||
raise AttestationError("receipt source_hashes must be a non-empty object")
|
||||
missing = REQUIRED_SOURCE_PATHS.difference(source_hashes)
|
||||
if missing:
|
||||
raise AttestationError(f"receipt source_hashes omit required boundary sources: {sorted(missing)}")
|
||||
actual_paths = set(source_hashes)
|
||||
if actual_paths != expected_paths:
|
||||
missing = sorted(expected_paths.difference(actual_paths))
|
||||
unexpected = sorted(actual_paths.difference(expected_paths))
|
||||
raise AttestationError(
|
||||
f"receipt source_hashes do not exactly match the governed boundary manifest; missing={missing} unexpected={unexpected}"
|
||||
)
|
||||
verified: dict[str, str] = {}
|
||||
for source_path in sorted(source_hashes):
|
||||
digest = source_hashes[source_path]
|
||||
|
|
@ -243,7 +488,15 @@ def verify_source_hashes(checkout: Path, commit: str, receipt: dict[str, Any]) -
|
|||
if actual != digest:
|
||||
raise AttestationError(f"source digest mismatch for {source_path}")
|
||||
verified[source_path] = actual
|
||||
return verified
|
||||
return verified, {
|
||||
"schema_version": manifest["schema_version"],
|
||||
"manifest_id": manifest["manifest_id"],
|
||||
"path": SOURCE_MANIFEST_PATH,
|
||||
"sha256": sha256_bytes(manifest_raw),
|
||||
"target_os": manifest["target_os"],
|
||||
"target_arch": receipt.get("architecture"),
|
||||
"source_count": len(verified),
|
||||
}
|
||||
|
||||
|
||||
def verify_artifacts(receipt: dict[str, Any], artifacts: dict[str, Path]) -> dict[str, str]:
|
||||
|
|
@ -314,6 +567,7 @@ def create_attestation(
|
|||
main_ref: str,
|
||||
receipt_path: Path,
|
||||
receipt_record_path: str,
|
||||
transcript_path: Path,
|
||||
artifacts: dict[str, Path],
|
||||
elapsed_seconds: float,
|
||||
release_candidate_ref: str | None = None,
|
||||
|
|
@ -330,21 +584,18 @@ def create_attestation(
|
|||
raise AttestationError("release candidate ref does not resolve to the qualified commit")
|
||||
|
||||
receipt, receipt_bytes = load_receipt(receipt_path)
|
||||
verify_scenarios(receipt)
|
||||
record_path = canonical_repo_path(receipt_record_path, "receipt record path")
|
||||
if receipt.get("record_path") != record_path:
|
||||
raise AttestationError("receipt record path is not bound inside the supplied receipt")
|
||||
transcript_events, transcript_bytes = load_and_verify_transcript(transcript_path, receipt)
|
||||
verify_scenarios(receipt, transcript_events)
|
||||
verify_runtime_claims(receipt)
|
||||
source_hashes = verify_source_hashes(checkout, qualified_commit, receipt)
|
||||
source_hashes, source_manifest = verify_source_hashes(checkout, qualified_commit, receipt)
|
||||
attestation_tool_hash = sha256_file(Path(__file__))
|
||||
if source_hashes["scripts/release_control/secure_runtime_attestation.py"] != attestation_tool_hash:
|
||||
raise AttestationError("executing attestation tool does not match the qualified commit")
|
||||
artifact_hashes = verify_artifacts(receipt, artifacts)
|
||||
artifact_build_identity = verify_artifact_build_identity(receipt, artifacts, qualified_commit)
|
||||
record_path = PurePosixPath(receipt_record_path)
|
||||
if (
|
||||
record_path.is_absolute()
|
||||
or ".." in record_path.parts
|
||||
or str(record_path) != receipt_record_path
|
||||
):
|
||||
raise AttestationError("receipt record path must be a canonical repository-relative path")
|
||||
if (
|
||||
not isinstance(elapsed_seconds, (float, int))
|
||||
or not math.isfinite(elapsed_seconds)
|
||||
|
|
@ -354,7 +605,7 @@ def create_attestation(
|
|||
|
||||
classification = "release-candidate-artifact-bound-self-attested-systemd" if release_candidate_ref else "committed-main-artifact-bound-self-attested-systemd"
|
||||
return {
|
||||
"schema_version": 3,
|
||||
"schema_version": ATTESTATION_SCHEMA_VERSION,
|
||||
"attestation_tool": "scripts/release_control/secure_runtime_attestation.py",
|
||||
"attestation_tool_sha256": attestation_tool_hash,
|
||||
"proof_classification": classification,
|
||||
|
|
@ -367,8 +618,20 @@ def create_attestation(
|
|||
"build_checkout_clean_except_lab_artifacts": True,
|
||||
"disposable_vm_guard_receipt_claim_validated": True,
|
||||
"execution_receipt_authentication": "none-secret-free-self-attestation",
|
||||
"receipt_path": receipt_record_path,
|
||||
"receipt_sha256": sha256_bytes(receipt_bytes),
|
||||
"receipt": {
|
||||
"record_path": record_path,
|
||||
"sha256": sha256_bytes(receipt_bytes),
|
||||
"path_bound_inside_receipt": True,
|
||||
},
|
||||
"transcript": {
|
||||
"record_path": receipt["transcript"]["record_path"],
|
||||
"sha256": sha256_bytes(transcript_bytes),
|
||||
"event_count": len(transcript_events),
|
||||
"scenario_event_count": sum(event.get("kind") == "scenario_result" for event in transcript_events),
|
||||
"command_output_event_count": sum(event.get("kind") == "command_output" for event in transcript_events),
|
||||
"format": "jsonl-v1",
|
||||
},
|
||||
"source_manifest": source_manifest,
|
||||
"source_hashes_match_commit": True,
|
||||
"source_hashes": source_hashes,
|
||||
"artifact_hashes_match_receipt": True,
|
||||
|
|
@ -413,6 +676,7 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
|||
parser.add_argument("--release-candidate-ref")
|
||||
parser.add_argument("--receipt", type=Path, required=True)
|
||||
parser.add_argument("--receipt-record-path", required=True)
|
||||
parser.add_argument("--transcript", type=Path, required=True)
|
||||
parser.add_argument("--collector-v1", type=Path, required=True)
|
||||
parser.add_argument("--collector-v2", type=Path, required=True)
|
||||
parser.add_argument("--helper", type=Path, required=True)
|
||||
|
|
@ -432,6 +696,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||
release_candidate_ref=args.release_candidate_ref,
|
||||
receipt_path=args.receipt,
|
||||
receipt_record_path=args.receipt_record_path,
|
||||
transcript_path=args.transcript,
|
||||
artifacts={name: getattr(args, argument) for name, argument in ARTIFACT_ARGUMENTS.items()},
|
||||
elapsed_seconds=args.elapsed_seconds,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -15,7 +15,11 @@ from secure_runtime_attestation import (
|
|||
DISPOSABLE_VM_GUARD_SHA256,
|
||||
EXPECTED_ARTIFACT_PACKAGES,
|
||||
REQUIRED_SCENARIOS,
|
||||
REQUIRED_SOURCE_PATHS,
|
||||
SCENARIO_REQUIRED_CLAIMS,
|
||||
SCENARIO_REQUIRED_OBSERVATIONS,
|
||||
SOURCE_MANIFEST_ID,
|
||||
SOURCE_MANIFEST_PATH,
|
||||
SOURCE_MANIFEST_SCHEMA_VERSION,
|
||||
create_attestation,
|
||||
sha256_bytes,
|
||||
verify_artifact_build_identity,
|
||||
|
|
@ -32,12 +36,30 @@ class SecureRuntimeAttestationTest(unittest.TestCase):
|
|||
self.git("init", "-b", "main")
|
||||
self.git("config", "user.name", "Pulse Test")
|
||||
self.git("config", "user.email", "pulse-test@example.invalid")
|
||||
manifest = {
|
||||
"schema_version": SOURCE_MANIFEST_SCHEMA_VERSION,
|
||||
"manifest_id": SOURCE_MANIFEST_ID,
|
||||
"target_os": "linux",
|
||||
"exact_paths": [
|
||||
"scripts/install.sh",
|
||||
"scripts/installtests/secure_runtime_systemd_lab_test.go",
|
||||
"scripts/release_control/secure_runtime_attestation.py",
|
||||
SOURCE_MANIFEST_PATH,
|
||||
],
|
||||
"recursive_roots": ["boundary"],
|
||||
"include_suffixes": [".go"],
|
||||
"exclude_suffixes": ["_test.go"],
|
||||
}
|
||||
manifest_raw = (json.dumps(manifest, indent=2) + "\n").encode()
|
||||
source_values = {
|
||||
"scripts/install.sh": b"fixture installer\n",
|
||||
"scripts/installtests/secure_runtime_systemd_lab_test.go": b"fixture harness\n",
|
||||
"scripts/release_control/secure_runtime_attestation.py": Path(__file__).with_name("secure_runtime_attestation.py").read_bytes(),
|
||||
SOURCE_MANIFEST_PATH: manifest_raw,
|
||||
"boundary/runtime.go": b"package boundary\n",
|
||||
}
|
||||
self.source_hashes = {}
|
||||
for relative in sorted(REQUIRED_SOURCE_PATHS):
|
||||
if relative == "scripts/release_control/secure_runtime_attestation.py":
|
||||
value = Path(__file__).with_name("secure_runtime_attestation.py").read_bytes()
|
||||
else:
|
||||
value = f"fixture:{relative}\n".encode()
|
||||
for relative, value in sorted(source_values.items()):
|
||||
path = self.repo / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(value)
|
||||
|
|
@ -57,10 +79,77 @@ class SecureRuntimeAttestationTest(unittest.TestCase):
|
|||
self.artifacts[name] = path
|
||||
artifact_hashes[name] = sha256_bytes(value)
|
||||
self.receipt = self.root / "receipt.json"
|
||||
self.transcript = self.root / "transcript.jsonl"
|
||||
started_at = "2026-08-30T10:00:00+00:00"
|
||||
scenarios = []
|
||||
command_output = "fixture command output\n"
|
||||
transcript_events = [
|
||||
{
|
||||
"sequence": 1,
|
||||
"event_id": "event-0001",
|
||||
"observed_at": started_at,
|
||||
"kind": "command_output",
|
||||
"operation": "fixture command",
|
||||
"output": command_output,
|
||||
"output_sha256": sha256_bytes(command_output.encode()),
|
||||
}
|
||||
]
|
||||
previous = started_at
|
||||
for index, name in enumerate(REQUIRED_SCENARIOS, 1):
|
||||
completed = f"2026-08-30T10:00:{index:02d}+00:00"
|
||||
event_sequence = index + 1
|
||||
event_id = f"event-{event_sequence:04d}"
|
||||
claims = sorted(SCENARIO_REQUIRED_CLAIMS[name])
|
||||
observations = dict(SCENARIO_REQUIRED_OBSERVATIONS[name])
|
||||
scenarios.append(
|
||||
{
|
||||
"sequence": index,
|
||||
"name": name,
|
||||
"passed": True,
|
||||
"started_at": previous,
|
||||
"completed_at": completed,
|
||||
"evidence": {
|
||||
"kind": "runtime-observation-v1",
|
||||
"summary": f"observed {name}",
|
||||
"claims": claims,
|
||||
"observations": observations,
|
||||
"transcript_event_ids": [event_id],
|
||||
},
|
||||
}
|
||||
)
|
||||
transcript_events.append(
|
||||
{
|
||||
"sequence": event_sequence,
|
||||
"event_id": event_id,
|
||||
"observed_at": completed,
|
||||
"kind": "scenario_result",
|
||||
"scenario": name,
|
||||
"claims": claims,
|
||||
"observations": observations,
|
||||
"summary": f"observed {name}",
|
||||
}
|
||||
)
|
||||
previous = completed
|
||||
transcript_raw = b"".join(
|
||||
(json.dumps(event, separators=(",", ":")) + "\n").encode()
|
||||
for event in transcript_events
|
||||
)
|
||||
self.transcript.write_bytes(transcript_raw)
|
||||
self.receipt.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": 3,
|
||||
"schema_version": 4,
|
||||
"record_path": "records/receipt.json",
|
||||
"started_at": started_at,
|
||||
"completed_at": "2026-08-30T10:00:13+00:00",
|
||||
"source_manifest": {
|
||||
"schema_version": SOURCE_MANIFEST_SCHEMA_VERSION,
|
||||
"manifest_id": SOURCE_MANIFEST_ID,
|
||||
"path": SOURCE_MANIFEST_PATH,
|
||||
"sha256": sha256_bytes(manifest_raw),
|
||||
"target_os": "linux",
|
||||
"target_arch": "arm64",
|
||||
},
|
||||
"source_hashes": self.source_hashes,
|
||||
"artifact_hashes": artifact_hashes,
|
||||
"artifact_versions": {"collector_v1": "1.0.0", "collector_v2": "1.1.0"},
|
||||
|
|
@ -69,22 +158,20 @@ class SecureRuntimeAttestationTest(unittest.TestCase):
|
|||
"kernel": "Linux fixture",
|
||||
"systemd_version": "systemd 255",
|
||||
"architecture": "arm64",
|
||||
"transcript": {
|
||||
"format": "jsonl-v1",
|
||||
"record_path": "records/transcript.jsonl",
|
||||
"sha256": sha256_bytes(transcript_raw),
|
||||
"event_count": len(transcript_events),
|
||||
},
|
||||
"collector_service_user": "pulse-agent",
|
||||
"collector_process_uid": 1000,
|
||||
"collector_authority": "monitoring-only",
|
||||
"ambient_capabilities_none": True,
|
||||
"helper_protocol_healthy": True,
|
||||
"state_identity_preserved": True,
|
||||
"action_runner_qualified": True,
|
||||
"action_mutation_verified": True,
|
||||
"collector_authority_reduction_request_observed": True,
|
||||
"credential_rotated": True,
|
||||
"self_revoke_observed": True,
|
||||
"collector_continuity": True,
|
||||
"scenarios": [
|
||||
{"name": name, "passed": True, "detail": "passed"}
|
||||
for name in REQUIRED_SCENARIOS
|
||||
],
|
||||
"action_receipt_kind": "pulse.host_storage_cleanup_result",
|
||||
"report_count": 3,
|
||||
"first_report_at": "2026-08-30T10:00:01+00:00",
|
||||
"last_report_at": "2026-08-30T10:00:12+00:00",
|
||||
"scenarios": scenarios,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
|
@ -121,6 +208,7 @@ class SecureRuntimeAttestationTest(unittest.TestCase):
|
|||
"main_ref": "origin/main",
|
||||
"receipt_path": self.receipt,
|
||||
"receipt_record_path": "records/receipt.json",
|
||||
"transcript_path": self.transcript,
|
||||
"artifacts": self.artifacts,
|
||||
"elapsed_seconds": 113.43,
|
||||
}
|
||||
|
|
@ -134,6 +222,9 @@ class SecureRuntimeAttestationTest(unittest.TestCase):
|
|||
self.assertTrue(result["source_hashes_match_commit"])
|
||||
self.assertTrue(result["artifact_hashes_match_receipt"])
|
||||
self.assertEqual(result["scenario_count"], 12)
|
||||
self.assertTrue(result["receipt"]["path_bound_inside_receipt"])
|
||||
self.assertEqual(result["transcript"]["event_count"], 13)
|
||||
self.assertEqual(result["source_manifest"]["manifest_id"], SOURCE_MANIFEST_ID)
|
||||
self.assertEqual(len(result["attestation_tool_sha256"]), 64)
|
||||
|
||||
def test_accepts_exact_release_candidate_ref(self) -> None:
|
||||
|
|
@ -164,6 +255,13 @@ class SecureRuntimeAttestationTest(unittest.TestCase):
|
|||
with self.assertRaisesRegex(AttestationError, "source digest mismatch"):
|
||||
self.attest()
|
||||
|
||||
def test_rejects_source_set_that_omits_manifest_expansion(self) -> None:
|
||||
receipt = json.loads(self.receipt.read_text(encoding="utf-8"))
|
||||
del receipt["source_hashes"]["boundary/runtime.go"]
|
||||
self.receipt.write_text(json.dumps(receipt), encoding="utf-8")
|
||||
with self.assertRaisesRegex(AttestationError, "exactly match the governed boundary manifest"):
|
||||
self.attest()
|
||||
|
||||
def test_rejects_artifact_digest_mismatch(self) -> None:
|
||||
self.artifacts["runner"].write_bytes(b"tampered\n")
|
||||
with self.assertRaisesRegex(AttestationError, "artifact digest mismatch"):
|
||||
|
|
@ -200,18 +298,39 @@ class SecureRuntimeAttestationTest(unittest.TestCase):
|
|||
with self.assertRaisesRegex(AttestationError, "did not pass"):
|
||||
self.attest()
|
||||
|
||||
def test_rejects_tampered_transcript(self) -> None:
|
||||
self.transcript.write_bytes(self.transcript.read_bytes() + b"{}\n")
|
||||
with self.assertRaisesRegex(AttestationError, "transcript digest mismatch"):
|
||||
self.attest()
|
||||
|
||||
def test_rejects_scenario_chronology_drift(self) -> None:
|
||||
receipt = json.loads(self.receipt.read_text(encoding="utf-8"))
|
||||
receipt["scenarios"][2]["started_at"] = "2026-08-30T09:59:00+00:00"
|
||||
self.receipt.write_text(json.dumps(receipt), encoding="utf-8")
|
||||
with self.assertRaisesRegex(AttestationError, "chronology is invalid"):
|
||||
self.attest()
|
||||
|
||||
def test_rejects_receipt_without_verified_mutation(self) -> None:
|
||||
receipt = json.loads(self.receipt.read_text(encoding="utf-8"))
|
||||
receipt["action_mutation_verified"] = False
|
||||
scenario = next(item for item in receipt["scenarios"] if item["name"] == "typed_action_receipt")
|
||||
scenario["evidence"]["claims"].remove("typed_mutation_verified")
|
||||
self.receipt.write_text(json.dumps(receipt), encoding="utf-8")
|
||||
with self.assertRaisesRegex(AttestationError, "action_mutation_verified"):
|
||||
with self.assertRaisesRegex(AttestationError, "required causal claims"):
|
||||
self.attest()
|
||||
|
||||
def test_rejects_scenario_without_typed_observation(self) -> None:
|
||||
receipt = json.loads(self.receipt.read_text(encoding="utf-8"))
|
||||
scenario = next(item for item in receipt["scenarios"] if item["name"] == "typed_action_receipt")
|
||||
del scenario["evidence"]["observations"]["verification"]
|
||||
self.receipt.write_text(json.dumps(receipt), encoding="utf-8")
|
||||
with self.assertRaisesRegex(AttestationError, "required typed observations"):
|
||||
self.attest()
|
||||
|
||||
def test_rejects_legacy_receipt_schema(self) -> None:
|
||||
receipt = json.loads(self.receipt.read_text(encoding="utf-8"))
|
||||
receipt["schema_version"] = 2
|
||||
receipt["schema_version"] = 3
|
||||
self.receipt.write_text(json.dumps(receipt), encoding="utf-8")
|
||||
with self.assertRaisesRegex(AttestationError, "schema_version 3"):
|
||||
with self.assertRaisesRegex(AttestationError, "schema_version 4"):
|
||||
self.attest()
|
||||
|
||||
def test_rejects_missing_scenario(self) -> None:
|
||||
|
|
@ -253,6 +372,10 @@ class SecureRuntimeAttestationTest(unittest.TestCase):
|
|||
with self.assertRaisesRegex(AttestationError, "canonical repository-relative"):
|
||||
self.attest(receipt_record_path="../receipt.json")
|
||||
|
||||
def test_rejects_receipt_record_path_not_bound_inside_blob(self) -> None:
|
||||
with self.assertRaisesRegex(AttestationError, "not bound inside"):
|
||||
self.attest(receipt_record_path="records/other.json")
|
||||
|
||||
def test_rejects_nonfinite_elapsed_time(self) -> None:
|
||||
with self.assertRaisesRegex(AttestationError, "must be positive"):
|
||||
self.attest(elapsed_seconds=float("nan"))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"schema_version": 1,
|
||||
"manifest_id": "secure-runtime-linux-v4",
|
||||
"target_os": "linux",
|
||||
"description": "Production source boundary for the secure collector, typed helper, action runner, update path, control-plane admission, and systemd qualification harness.",
|
||||
"exact_paths": [
|
||||
"scripts/install.sh",
|
||||
"scripts/installtests/secure_runtime_systemd_lab_test.go",
|
||||
"scripts/release_control/secure_runtime_attestation.py",
|
||||
"scripts/release_control/secure_runtime_source_manifest_v4.json"
|
||||
],
|
||||
"recursive_roots": [
|
||||
"cmd/pulse-agent",
|
||||
"cmd/pulse-agent-helper",
|
||||
"cmd/pulse-agent-runner",
|
||||
"internal/actionrunner",
|
||||
"internal/agentexec",
|
||||
"internal/agenthelper",
|
||||
"internal/agentupdate",
|
||||
"internal/api",
|
||||
"internal/config",
|
||||
"internal/hostagent",
|
||||
"internal/operationreceipt"
|
||||
],
|
||||
"include_suffixes": [
|
||||
".go",
|
||||
".tmpl"
|
||||
],
|
||||
"exclude_suffixes": [
|
||||
"_test.go"
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue