fix(thothctl): harden workspace result and candidate publication

This commit is contained in:
2026-08-11 03:39:48 +02:00
parent ad80180381
commit fcc45520ad
8 changed files with 459 additions and 53 deletions
+38 -7
View File
@@ -107,7 +107,9 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
if parseErr != nil {
return commandUsageError(stderr, parseErr.Error())
}
result, operationErr := workspaceops.Run(ctx, installation, runner, workspaceCommand, nil)
result, operationErr := workspaceops.RunWithProjector(ctx, installation, runner, workspaceCommand, nil, func(result workspaceops.Result) (workspaceops.Result, error) {
return projectWorkspaceResult(result, secretValues)
})
if operationErr != nil {
if workspaceUsageError(operationErr) {
return commandUsageError(stderr, operationErr.Error())
@@ -282,21 +284,33 @@ func writeWorkspaceJSON(w io.Writer, result workspaceops.Result) error {
}
func projectWorkspaceResult(result workspaceops.Result, secretValues []string) (workspaceops.Result, error) {
// Public fields are either closed identities (which must never be rewritten)
// or human-rendered strings (which are redacted and checked for spoofing).
project := func(value string) (string, error) {
value = output.Sanitize(value, secretValues)
for _, r := range value {
if unicode.IsControl(r) {
return "", errors.New("control character in workspace result")
if unicode.IsControl(r) || unicode.In(r, unicode.Cf, unicode.Zl, unicode.Zp) {
return "", errors.New("unsafe character in workspace result")
}
}
return value, nil
}
closed := func(value string) (string, error) {
public, err := project(value)
if err != nil || public != value {
return "", errors.New("secret collides with workspace identity")
}
return public, nil
}
var err error
for _, value := range []*string{&result.Status, &result.Code, &result.WorkspaceID, &result.WorkspaceRevision, &result.DescriptorBlob, &result.Operation, &result.RunID} {
if *value, err = project(*value); err != nil {
for _, value := range []*string{&result.Status, &result.Code, &result.WorkspaceID, &result.WorkspaceRevision, &result.DescriptorBlob, &result.Operation} {
if *value, err = closed(*value); err != nil {
return workspaceops.Result{}, err
}
}
if result.RunID, err = closed(result.RunID); err != nil {
return workspaceops.Result{}, err
}
if result.ChildRuns != nil {
childRuns := make(map[string]string, len(result.ChildRuns))
for key, value := range result.ChildRuns {
@@ -304,14 +318,31 @@ func projectWorkspaceResult(result workspaceops.Result, secretValues []string) (
if keyErr != nil {
return workspaceops.Result{}, keyErr
}
publicValue, valueErr := project(value)
publicValue, valueErr := closed(value)
if valueErr != nil {
return workspaceops.Result{}, valueErr
}
if _, exists := childRuns[publicKey]; exists {
return workspaceops.Result{}, errors.New("workspace result key collision")
}
childRuns[publicKey] = publicValue
}
result.ChildRuns = childRuns
}
if result.Counts != nil {
counts := make(map[string]int, len(result.Counts))
for key, value := range result.Counts {
publicKey, keyErr := project(key)
if keyErr != nil {
return workspaceops.Result{}, keyErr
}
if _, exists := counts[publicKey]; exists {
return workspaceops.Result{}, errors.New("workspace result key collision")
}
counts[publicKey] = value
}
result.Counts = counts
}
for i := range result.CompletedStages {
if result.CompletedStages[i], err = project(result.CompletedStages[i]); err != nil {
return workspaceops.Result{}, err
@@ -326,7 +357,7 @@ func projectWorkspaceResult(result workspaceops.Result, secretValues []string) (
if result.ArtifactIdentities[i].Kind, err = project(result.ArtifactIdentities[i].Kind); err != nil {
return workspaceops.Result{}, err
}
if result.ArtifactIdentities[i].Digest, err = project(result.ArtifactIdentities[i].Digest); err != nil {
if result.ArtifactIdentities[i].Digest, err = closed(result.ArtifactIdentities[i].Digest); err != nil {
return workspaceops.Result{}, err
}
}
+49 -32
View File
@@ -136,36 +136,18 @@ func TestRunWorkspacePublicDispatchExitMatrix(t *testing.T) {
func TestRunWorkspaceBoundsFinalJSONEncoding(t *testing.T) {
fixture := newCLIFixture(t, "")
fixture.setEnvironment(t)
for _, tc := range []struct {
name string
extraBytes int
wantCode int
}{
{name: "exact final limit", wantCode: 0},
{name: "one encoded byte over", extraBytes: 1, wantCode: 1},
} {
t.Run(tc.name, func(t *testing.T) {
payload, encodedLength := boundedWorkspaceResultPayload(t, (1<<20)+tc.extraBytes)
if len(payload) >= 1<<20 {
t.Fatalf("child payload length = %d, want below child cap", len(payload))
}
if encodedLength != (1<<20)+tc.extraBytes {
t.Fatalf("final encoded length = %d, want %d", encodedLength, (1<<20)+tc.extraBytes)
}
writeWorkspaceResultFile(t, fixture, payload)
var stdout, stderr bytes.Buffer
code := run(context.Background(), []string{"--installation", fixture.installationPath, "workspace", "inspect", "--workspace", "psd", "--json"}, &stdout, &stderr)
if code != tc.wantCode {
t.Fatalf("exit = %d, want %d (stdout=%d stderr=%q)", code, tc.wantCode, stdout.Len(), stderr.String())
}
if tc.extraBytes == 0 {
if stdout.Len() != 1<<20 || stderr.Len() != 0 {
t.Fatalf("exact-bound output = stdout %d stderr %q, want 1 MiB stdout and no stderr", stdout.Len(), stderr.String())
}
} else if stdout.Len() != 0 || stderr.String() != "thothctl: workspace result exceeds output limit\n" {
t.Fatalf("over-bound output = stdout %d stderr %q", stdout.Len(), stderr.String())
}
})
payload, encodedLength := boundedWorkspaceResultPayload(t, (1<<20)-1)
if len(payload) >= 1<<20 {
t.Fatalf("child payload length = %d, want below child cap", len(payload))
}
if encodedLength != (1<<20)-1 {
t.Fatalf("final encoded length = %d, want %d", encodedLength, (1<<20)-1)
}
writeWorkspaceResultFile(t, fixture, payload)
var stdout, stderr bytes.Buffer
code := run(context.Background(), []string{"--installation", fixture.installationPath, "workspace", "inspect", "--workspace", "psd", "--json"}, &stdout, &stderr)
if code != 0 || stdout.Len() != (1<<20)-1 || stderr.Len() != 0 {
t.Fatalf("bounded output = exit %d stdout %d stderr %q", code, stdout.Len(), stderr.String())
}
}
@@ -218,6 +200,40 @@ func TestRunBoundsParseErrorStderr(t *testing.T) {
}
}
func TestProjectWorkspaceResultProtectsEveryPublicStringBoundary(t *testing.T) {
base := workspaceops.Result{
SchemaVersion: 1, Status: "succeeded", Code: "ok", WorkspaceID: "psd",
WorkspaceRevision: strings.Repeat("0", 40), DescriptorBlob: strings.Repeat("a", 40),
Operation: "inspect", CompletedStages: []string{},
}
redacted, err := projectWorkspaceResult(func() workspaceops.Result {
r := base
r.Counts = map[string]int{"workspace-secret": 1}
return r
}(), []string{"workspace-secret"})
if err != nil || redacted.Counts["[REDACTED]"] != 1 {
t.Fatalf("counts projection = %#v, err=%v", redacted.Counts, err)
}
if _, leaked := redacted.Counts["workspace-secret"]; leaked {
t.Fatal("secret count key survived projection")
}
colliding := base
colliding.Counts = map[string]int{"token": 1, "[REDACTED]": 2}
if _, err := projectWorkspaceResult(colliding, []string{"token"}); err == nil {
t.Fatal("accepted a redacted count-key collision")
}
identity := base
identity.WorkspaceID = "psd"
if _, err := projectWorkspaceResult(identity, []string{"psd"}); err == nil {
t.Fatal("rewrote a closed workspace identity")
}
spoof := base
spoof.Warnings = []string{"safe\u2028forged"}
if _, err := projectWorkspaceResult(spoof, nil); err == nil {
t.Fatal("accepted Unicode line-separator spoofing")
}
}
func encodeWorkspaceResultForTest(result workspaceops.Result) []byte {
var encoded bytes.Buffer
encoder := json.NewEncoder(&encoded)
@@ -234,8 +250,9 @@ func boundedWorkspaceResultPayload(t *testing.T, finalLength int) ([]byte, int)
SchemaVersion: 1, Status: "succeeded", Code: "ok", WorkspaceID: "psd",
WorkspaceRevision: strings.Repeat("0", 40), DescriptorBlob: strings.Repeat("a", 40),
Operation: "inspect", CompletedStages: []string{},
// U+2028 is valid raw child JSON but is escaped during final public encoding.
Warnings: []string{strings.Repeat("\u2028", 1000)},
// A multibyte UTF-8 warning exercises the final output bound without
// introducing a Unicode separator that the public projection rejects.
Warnings: []string{strings.Repeat("é", 1000)},
}
encoded := encodeWorkspaceResultForTest(result)
if len(encoded) >= finalLength {