fix(thothctl): harden workspace result and candidate publication
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user