Skip to content

Commit 4f79992

Browse files
committed
feat: Add digest validation support to HTTP resolver
This commit introduces content hash verification for the HTTP resolver by adding support for optional digest validation using SHA256 and SHA512 algorithms. Changes: - Add new 'digest' parameter accepting '<algorithm>:<hash>' format where algorithm can be 'sha256' or 'sha512' - Implement digest validation logic using constant-time comparison to prevent timing-based side-channel attacks - Add comprehensive unit tests covering valid matches, mismatches, invalid formats, and unsupported algorithms - Add E2E tests to verify digest validation in real cluster scenarios - Enable 'enable-http-resolver' feature flag in default configuration - Update documentation with digest parameter description, usage examples, and commands to calculate SHA256/SHA512 hashes Security considerations: - Uses constant-time comparison to prevent timing attacks - Digest validation is optional to maintain backward compatibility - Digest values are logged for debugging Fixes: #8759 Signed-off-by: Zaki Shaikh <[email protected]>
1 parent 55b74d7 commit 4f79992

File tree

5 files changed

+304
-6
lines changed

5 files changed

+304
-6
lines changed

config/resolvers/config-feature-flags.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,5 @@ data:
3030
enable-git-resolver: "true"
3131
# Setting this flag to "true" enables remote resolution of tasks and pipelines from other namespaces within the cluster.
3232
enable-cluster-resolver: "true"
33+
# Setting this flag to "true" enables remote resolution of tasks and pipelines from HTTP URLs.
34+
enable-http-resolver: "true"

docs/http-resolver.md

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,26 @@ This resolver responds to type `http`.
1212

1313
## Parameters
1414

15-
| Param Name | Description | Example Value | |
16-
|----------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------|---|
17-
| `url` | The URL to fetch from | <https://raw.githubusercontent.com/tektoncd-catalog/git-clone/main/task/git-clone/git-clone.yaml> | |
18-
| `http-username` | An optional username when fetching a task with credentials (need to be used in conjunction with `http-password-secret`) | `git` | |
19-
| `http-password-secret` | An optional secret in the PipelineRun namespace with a reference to a password when fetching a task with credentials (need to be used in conjunction with `http-username`) | `http-password` | |
20-
| `http-password-secret-key` | An optional key in the `http-password-secret` to be used when fetching a task with credentials | Default: `password` | |
15+
| Param Name | Description | Example Value | |
16+
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | --- |
17+
| `url` | The URL to fetch from | <https://raw.githubusercontent.com/tektoncd-catalog/git-clone/main/task/git-clone/git-clone.yaml> | |
18+
| `http-username` | An optional username when fetching a task with credentials (need to be used in conjunction with `http-password-secret`) | `git` | |
19+
| `http-password-secret` | An optional secret in the PipelineRun namespace with a reference to a password when fetching a task with credentials (need to be used in conjunction with `http-username`) | `http-password` | |
20+
| `http-password-secret-key` | An optional key in the `http-password-secret` to be used when fetching a task with credentials | Default: `password` | |
21+
| `digest` | An optional digest to verify the integrity of the fetched content. The value must be in the format `<algorithm>:<hash>`, where the supported algorithms are `sha256` and `sha512`. | `sha256:f37cdd0e86...` | |
22+
23+
You can calculate the hash of your Tekton resource using the following command:
24+
25+
```
26+
# Calculate sha256 digest
27+
curl -sL https://raw.githubusercontent.com/owner/private-repo/main/task/task.yaml | sha256sum
28+
29+
# Calculate sha512 digest
30+
curl -sL https://raw.githubusercontent.com/owner/private-repo/main/task/task.yaml | sha512sum
31+
32+
33+
`sha265sum` and `sha512sum` are available on all major Linux distributions and macOS
34+
```
2135

2236
A valid URL must be provided. Only HTTP or HTTPS URLs are supported.
2337

@@ -94,6 +108,23 @@ spec:
94108
value: https://raw.githubusercontent.com/tektoncd/catalog/main/pipeline/build-push-gke-deploy/0.1/build-push-gke-deploy.yaml
95109
```
96110
111+
### Pipeline Resolution with Digest
112+
113+
```yaml
114+
apiVersion: tekton.dev/v1beta1
115+
kind: PipelineRun
116+
metadata:
117+
name: http-demo
118+
spec:
119+
pipelineRef:
120+
resolver: http
121+
params:
122+
- name: url
123+
value: https://raw.githubusercontent.com/tektoncd/catalog/main/pipeline/build-push-gke-deploy/0.1/build-push-gke-deploy.yaml
124+
- name: digest
125+
value: sha256:e1a86b942e85ce5558fc737a3b4a82d7425ca392741d20afa3b7fb426e96c66b
126+
```
127+
97128
---
98129
99130
Except as otherwise noted, the content of this page is licensed under the

pkg/resolution/resolver/http/resolver.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ package http
1616
import (
1717
"context"
1818
"crypto/sha256"
19+
"crypto/sha512"
20+
"crypto/subtle"
1921
"encoding/base64"
2022
"encoding/hex"
2123
"errors"
@@ -56,6 +58,15 @@ const (
5658

5759
// default key in the HTTP password secret
5860
defaultBasicAuthSecretKey = "password"
61+
62+
// digestParam is the parameter name for the digest of the content
63+
digestParam = "digest"
64+
65+
// sha512Algo is the prefix name for the sha512sum value
66+
sha512Algo = "sha512"
67+
68+
// sha256Algo is the prefix name for the sha256sum value
69+
sha256Algo = "sha256"
5970
)
6071

6172
// Resolver implements a framework.Resolver that can fetch files from an HTTP URL
@@ -206,6 +217,52 @@ func makeHttpClient(ctx context.Context) (*http.Client, error) {
206217
}, nil
207218
}
208219

220+
// compareSHA compares two hexadecimal SHA strings in constant time.
221+
func compareSHA(expectedSHA string, computedSHA []byte) error {
222+
expectedBytes, err := hex.DecodeString(expectedSHA)
223+
if err != nil {
224+
return fmt.Errorf("error decoding expected SHA string: %w", err)
225+
}
226+
227+
match := subtle.ConstantTimeCompare(expectedBytes, computedSHA)
228+
if match != 1 {
229+
return fmt.Errorf("SHA mismatch, expected %s, got %s", expectedSHA, hex.EncodeToString(computedSHA))
230+
}
231+
232+
return nil
233+
}
234+
235+
func validateDigest(digest string, body []byte, logger *zap.SugaredLogger) error {
236+
digestValues := strings.SplitN(digest, ":", 2)
237+
if len(digestValues) != 2 {
238+
return fmt.Errorf("invalid digest format: %s", digest)
239+
}
240+
digestAlgo := digestValues[0]
241+
if digestAlgo != sha512Algo && digestAlgo != sha256Algo {
242+
return fmt.Errorf("invalid digest algorithm: %s", digestAlgo)
243+
}
244+
245+
digestValue := digestValues[1]
246+
247+
logger.Infof("Validating %s with value %s to the content", digestAlgo, digestValue)
248+
switch digestAlgo {
249+
case sha512Algo:
250+
sha512Hash := sha512.Sum512(body)
251+
if len(digestValue) != 128 {
252+
return fmt.Errorf("invalid sha512 digest value, expected length: 128, got: %d", len(digestValue))
253+
}
254+
return compareSHA(digestValue, sha512Hash[:])
255+
case sha256Algo:
256+
sha256Hash := sha256.Sum256(body)
257+
if len(digestValue) != 64 {
258+
return fmt.Errorf("invalid sha256 digest value, expected length: 64, got: %d", len(digestValue))
259+
}
260+
return compareSHA(digestValue, sha256Hash[:])
261+
}
262+
263+
return nil
264+
}
265+
209266
func FetchHttpResource(ctx context.Context, params map[string]string, kubeclient kubernetes.Interface, logger *zap.SugaredLogger) (framework.ResolvedResource, error) {
210267
var targetURL string
211268
var ok bool
@@ -248,6 +305,14 @@ func FetchHttpResource(ctx context.Context, params map[string]string, kubeclient
248305
return nil, fmt.Errorf("error reading response body: %w", err)
249306
}
250307

308+
digest, ok := params[digestParam]
309+
if ok {
310+
err = validateDigest(digest, body, logger)
311+
if err != nil {
312+
return nil, fmt.Errorf("error validating digest: %w", err)
313+
}
314+
}
315+
251316
return &resolvedHttpResource{
252317
Content: body,
253318
URL: targetURL,

pkg/resolution/resolver/http/resolver_test.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,15 @@ package http
1919
import (
2020
"context"
2121
"crypto/sha256"
22+
"crypto/sha512"
2223
"encoding/base64"
2324
"encoding/hex"
2425
"errors"
2526
"fmt"
2627
"net/http"
2728
"net/http/httptest"
2829
"regexp"
30+
"strings"
2931
"testing"
3032
"time"
3133

@@ -42,6 +44,7 @@ import (
4244
"github.com/tektoncd/pipeline/test/diff"
4345
corev1 "k8s.io/api/core/v1"
4446
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
47+
"knative.dev/pkg/logging"
4548
"knative.dev/pkg/system"
4649
_ "knative.dev/pkg/system/testing"
4750
)
@@ -524,3 +527,116 @@ func checkExpectedErr(t *testing.T, expectedErr, actualErr error) {
524527
t.Fatalf("expected err '%v' but got '%v'", expectedErr, actualErr)
525528
}
526529
}
530+
531+
func TestCompareSHA(t *testing.T) {
532+
tests := []struct {
533+
name string
534+
expectedSHA string
535+
computedSHA []byte
536+
expectedErr string
537+
}{
538+
{
539+
name: "valid/match",
540+
expectedSHA: "666f6f", // hex for "foo"
541+
computedSHA: []byte("foo"),
542+
},
543+
{
544+
name: "valid/mismatch",
545+
expectedSHA: "666f6f", // hex for "foo"
546+
computedSHA: []byte("bar"),
547+
expectedErr: "SHA mismatch, expected 666f6f, got 626172",
548+
},
549+
{
550+
name: "invalid/expected hex",
551+
expectedSHA: "not-hex",
552+
computedSHA: []byte("foo"),
553+
expectedErr: "error decoding expected SHA string",
554+
},
555+
}
556+
557+
for _, tc := range tests {
558+
t.Run(tc.name, func(t *testing.T) {
559+
err := compareSHA(tc.expectedSHA, tc.computedSHA)
560+
if tc.expectedErr != "" {
561+
if err == nil {
562+
t.Fatalf("expected error '%v' but got nil", tc.expectedErr)
563+
}
564+
re := regexp.MustCompile(tc.expectedErr)
565+
if !re.MatchString(err.Error()) {
566+
t.Fatalf("expected error to match '%v' but got '%v'", tc.expectedErr, err)
567+
}
568+
} else if err != nil {
569+
t.Fatalf("unexpected error: %v", err)
570+
}
571+
})
572+
}
573+
}
574+
575+
func TestValidateDigest(t *testing.T) {
576+
ctx, _ := ttesting.SetupFakeContext(t)
577+
logger := logging.FromContext(ctx)
578+
content := []byte("some content")
579+
580+
// Calculate valid hashes
581+
s256 := sha256.Sum256(content)
582+
hex256 := hex.EncodeToString(s256[:])
583+
584+
s512 := sha512.Sum512(content)
585+
hex512 := hex.EncodeToString(s512[:])
586+
587+
tests := []struct {
588+
name string
589+
digest string
590+
expectedErr string
591+
}{
592+
{
593+
name: "valid/sha256",
594+
digest: "sha256:" + hex256,
595+
},
596+
{
597+
name: "valid/sha512",
598+
digest: "sha512:" + hex512,
599+
},
600+
{
601+
name: "invalid/format_no_separator",
602+
digest: "sha256" + hex256,
603+
expectedErr: "invalid digest format",
604+
},
605+
{
606+
name: "invalid/format_empty",
607+
digest: "",
608+
expectedErr: "invalid digest format",
609+
},
610+
{
611+
name: "invalid/algorithm",
612+
digest: "sha1:" + hex256,
613+
expectedErr: "invalid digest algorithm: sha1",
614+
},
615+
{
616+
name: "invalid/mismatch_sha256",
617+
digest: "sha256:deadbeef",
618+
expectedErr: "SHA mismatch",
619+
},
620+
{
621+
name: "invalid/mismatch_sha512",
622+
digest: "sha512:deadbeef",
623+
expectedErr: "SHA mismatch",
624+
},
625+
}
626+
627+
for _, tc := range tests {
628+
t.Run(tc.name, func(t *testing.T) {
629+
err := validateDigest(tc.digest, content, logger)
630+
if tc.expectedErr != "" {
631+
if err == nil {
632+
t.Fatalf("expected error '%v' but got nil", tc.expectedErr)
633+
}
634+
if !strings.Contains(err.Error(), tc.expectedErr) {
635+
t.Fatalf("expected error to contain '%v' but got '%v'", tc.expectedErr, err)
636+
}
637+
} else if err != nil {
638+
t.Fatalf("unexpected error: %v", err)
639+
}
640+
})
641+
}
642+
}

test/resolvers_test.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ const (
4444
// Defined in git-resolver/gitea.yaml's "gitea" StatefulSet, in the env for the "configure-gitea" init container
4545
scmGiteaAdminPassword = "giteaPassword1234"
4646
systemNamespace = "tekton-pipelines"
47+
remotePipelineURL = "https://raw.githubusercontent.com/zakisk/yaml-repo/refs/heads/main/pipeline.yaml"
48+
remotePipelineDigest = "sha256:e1a86b942e85ce5558fc737a3b4a82d7425ca392741d20afa3b7fb426e96c66b"
4749
)
4850

4951
var (
@@ -63,6 +65,11 @@ var (
6365
"enable-cluster-resolver": "true",
6466
"enable-api-fields": "beta",
6567
})
68+
69+
httpFeatureFlags = requireAllGates(map[string]string{
70+
"enable-http-resolver": "true",
71+
"enable-api-fields": "beta",
72+
})
6673
)
6774

6875
// @test:execution=parallel
@@ -463,3 +470,80 @@ spec:
463470
t.Fatalf("Error waiting for PipelineRun to finish with expected error: %s", err)
464471
}
465472
}
473+
474+
func TestHttpResolver(t *testing.T) {
475+
ctx := t.Context()
476+
c, namespace := setup(ctx, t, httpFeatureFlags)
477+
478+
t.Parallel()
479+
480+
knativetest.CleanupOnInterrupt(func() { tearDown(ctx, t, c, namespace) }, t.Logf)
481+
defer tearDown(ctx, t, c, namespace)
482+
483+
prName := helpers.ObjectNameForTest(t)
484+
485+
pipelineRun := parse.MustParseV1PipelineRun(t, fmt.Sprintf(`
486+
metadata:
487+
name: %s
488+
namespace: %s
489+
spec:
490+
pipelineRef:
491+
resolver: "http"
492+
params:
493+
- name: url
494+
value: %s
495+
- name: digest
496+
value: %s
497+
`, prName, namespace, remotePipelineURL, remotePipelineDigest))
498+
499+
_, err := c.V1PipelineRunClient.Create(ctx, pipelineRun, metav1.CreateOptions{})
500+
if err != nil {
501+
t.Fatalf("Failed to create PipelineRun `%s`: %s", prName, err)
502+
}
503+
504+
t.Logf("Waiting for PipelineRun %s in namespace %s to complete", prName, namespace)
505+
if err := WaitForPipelineRunState(ctx, c, prName, timeout, PipelineRunSucceed(prName), "PipelineRunSuccess", v1Version); err != nil {
506+
t.Fatalf("Error waiting for PipelineRun %s to finish: %s", prName, err)
507+
}
508+
}
509+
510+
func TestHttpResolver_Failure(t *testing.T) {
511+
ctx := t.Context()
512+
c, namespace := setup(ctx, t, httpFeatureFlags)
513+
514+
t.Parallel()
515+
516+
knativetest.CleanupOnInterrupt(func() { tearDown(ctx, t, c, namespace) }, t.Logf)
517+
defer tearDown(ctx, t, c, namespace)
518+
519+
prName := helpers.ObjectNameForTest(t)
520+
// A digest that is definitely wrong
521+
digestValue := "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
522+
digest := "sha256:" + digestValue
523+
524+
pipelineRun := parse.MustParseV1PipelineRun(t, fmt.Sprintf(`
525+
metadata:
526+
name: %s
527+
namespace: %s
528+
spec:
529+
pipelineRef:
530+
resolver: "http"
531+
params:
532+
- name: url
533+
value: %s
534+
- name: digest
535+
value: %s
536+
`, prName, namespace, remotePipelineURL, digest))
537+
538+
_, err := c.V1PipelineRunClient.Create(ctx, pipelineRun, metav1.CreateOptions{})
539+
if err != nil {
540+
t.Fatalf("Failed to create PipelineRun `%s`: %s", prName, err)
541+
}
542+
543+
t.Logf("Waiting for PipelineRun %s in namespace %s to complete", prName, namespace)
544+
if err := WaitForPipelineRunState(ctx, c, prName, timeout,
545+
FailedWithReason(v1.PipelineRunReasonCouldntGetPipeline.String(), prName),
546+
"PipelineRunFailed", v1Version); err != nil {
547+
t.Fatalf("Error waiting for PipelineRun to finish with expected error: %s", err)
548+
}
549+
}

0 commit comments

Comments
 (0)