Skip to content

Commit 076f32b

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 <zashaikh@redhat.com>
1 parent a3b0033 commit 076f32b

File tree

5 files changed

+294
-6
lines changed

5 files changed

+294
-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: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,22 @@ 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 using sha512sum CLI available on all major Linux distributions and macOS
30+
```
2131

2232
A valid URL must be provided. Only HTTP or HTTPS URLs are supported.
2333

@@ -94,6 +104,23 @@ spec:
94104
value: https://raw.githubusercontent.com/tektoncd/catalog/main/pipeline/build-push-gke-deploy/0.1/build-push-gke-deploy.yaml
95105
```
96106
107+
### Pipeline Resolution with Digest
108+
109+
```yaml
110+
apiVersion: tekton.dev/v1beta1
111+
kind: PipelineRun
112+
metadata:
113+
name: http-demo
114+
spec:
115+
pipelineRef:
116+
resolver: http
117+
params:
118+
- name: url
119+
value: https://raw.githubusercontent.com/tektoncd/catalog/main/pipeline/build-push-gke-deploy/0.1/build-push-gke-deploy.yaml
120+
- name: digest
121+
value: sha256:e1a86b942e85ce5558fc737a3b4a82d7425ca392741d20afa3b7fb426e96c66b
122+
```
123+
97124
---
98125
99126
Except as otherwise noted, the content of this page is licensed under the

pkg/resolution/resolver/http/resolver.go

Lines changed: 59 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,46 @@ 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("Comparing %s with value %s to the content", digestAlgo, digestValue)
248+
switch digestAlgo {
249+
case sha512Algo:
250+
sha512Hash := sha512.Sum512(body)
251+
return compareSHA(digestValue, sha512Hash[:])
252+
case sha256Algo:
253+
sha256Hash := sha256.Sum256(body)
254+
return compareSHA(digestValue, sha256Hash[:])
255+
}
256+
257+
return nil
258+
}
259+
209260
func FetchHttpResource(ctx context.Context, params map[string]string, kubeclient kubernetes.Interface, logger *zap.SugaredLogger) (framework.ResolvedResource, error) {
210261
var targetURL string
211262
var ok bool
@@ -248,6 +299,14 @@ func FetchHttpResource(ctx context.Context, params map[string]string, kubeclient
248299
return nil, fmt.Errorf("error reading response body: %w", err)
249300
}
250301

302+
digest, ok := params[digestParam]
303+
if ok {
304+
err = validateDigest(digest, body, logger)
305+
if err != nil {
306+
return nil, fmt.Errorf("error validating digest: %w", err)
307+
}
308+
}
309+
251310
return &resolvedHttpResource{
252311
Content: body,
253312
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
func TestHubResolver(t *testing.T) {
@@ -457,3 +464,80 @@ spec:
457464
t.Fatalf("Error waiting for PipelineRun to finish with expected error: %s", err)
458465
}
459466
}
467+
468+
func TestHttpResolver(t *testing.T) {
469+
ctx := t.Context()
470+
c, namespace := setup(ctx, t, httpFeatureFlags)
471+
472+
t.Parallel()
473+
474+
knativetest.CleanupOnInterrupt(func() { tearDown(ctx, t, c, namespace) }, t.Logf)
475+
defer tearDown(ctx, t, c, namespace)
476+
477+
prName := helpers.ObjectNameForTest(t)
478+
479+
pipelineRun := parse.MustParseV1PipelineRun(t, fmt.Sprintf(`
480+
metadata:
481+
name: %s
482+
namespace: %s
483+
spec:
484+
pipelineRef:
485+
resolver: "http"
486+
params:
487+
- name: url
488+
value: %s
489+
- name: digest
490+
value: %s
491+
`, prName, namespace, remotePipelineURL, remotePipelineDigest))
492+
493+
_, err := c.V1PipelineRunClient.Create(ctx, pipelineRun, metav1.CreateOptions{})
494+
if err != nil {
495+
t.Fatalf("Failed to create PipelineRun `%s`: %s", prName, err)
496+
}
497+
498+
t.Logf("Waiting for PipelineRun %s in namespace %s to complete", prName, namespace)
499+
if err := WaitForPipelineRunState(ctx, c, prName, timeout, PipelineRunSucceed(prName), "PipelineRunSuccess", v1Version); err != nil {
500+
t.Fatalf("Error waiting for PipelineRun %s to finish: %s", prName, err)
501+
}
502+
}
503+
504+
func TestHttpResolver_Failure(t *testing.T) {
505+
ctx := t.Context()
506+
c, namespace := setup(ctx, t, httpFeatureFlags)
507+
508+
t.Parallel()
509+
510+
knativetest.CleanupOnInterrupt(func() { tearDown(ctx, t, c, namespace) }, t.Logf)
511+
defer tearDown(ctx, t, c, namespace)
512+
513+
prName := helpers.ObjectNameForTest(t)
514+
// A digest that is definitely wrong
515+
digestValue := "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
516+
digest := "sha256:" + digestValue
517+
518+
pipelineRun := parse.MustParseV1PipelineRun(t, fmt.Sprintf(`
519+
metadata:
520+
name: %s
521+
namespace: %s
522+
spec:
523+
pipelineRef:
524+
resolver: "http"
525+
params:
526+
- name: url
527+
value: %s
528+
- name: digest
529+
value: %s
530+
`, prName, namespace, remotePipelineURL, digest))
531+
532+
_, err := c.V1PipelineRunClient.Create(ctx, pipelineRun, metav1.CreateOptions{})
533+
if err != nil {
534+
t.Fatalf("Failed to create PipelineRun `%s`: %s", prName, err)
535+
}
536+
537+
t.Logf("Waiting for PipelineRun %s in namespace %s to complete", prName, namespace)
538+
if err := WaitForPipelineRunState(ctx, c, prName, timeout,
539+
FailedWithReason(v1.PipelineRunReasonCouldntGetPipeline.String(), prName),
540+
"PipelineRunFailed", v1Version); err != nil {
541+
t.Fatalf("Error waiting for PipelineRun to finish with expected error: %s", err)
542+
}
543+
}

0 commit comments

Comments
 (0)