Skip to content

Commit 2aba608

Browse files
Merge branch 'main' into fix/#8834
2 parents 5466993 + 144c2b7 commit 2aba608

278 files changed

Lines changed: 86634 additions & 11798 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/codespell.yml renamed to .github/workflows/codespell.yml.action-blocked-by-asf

File renamed without changes.

backend/.golangci.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,7 @@ linters:
243243
- third_party$
244244
- builtin$
245245
- examples$
246+
- mocks$
246247
formatters:
247248
enable:
248249
- gofmt

backend/core/plugin/plugin_utils.go

Lines changed: 45 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -21,18 +21,23 @@ import (
2121
"bytes"
2222
"crypto/aes"
2323
"crypto/cipher"
24+
"crypto/rand"
2425
"crypto/sha256"
2526
"encoding/base64"
2627
"fmt"
28+
"io"
2729

2830
"github.com/apache/incubator-devlake/core/errors"
2931
"github.com/apache/incubator-devlake/core/utils"
3032
)
3133

3234
const EncodeKeyEnvStr = "ENCRYPTION_SECRET"
3335

34-
// TODO: maybe move encryption/decryption into helper?
35-
// AES + Base64 encryption using ENCRYPTION_SECRET in .env as key
36+
// gcmNonceSize is the standard nonce size for AES-GCM.
37+
const gcmNonceSize = 12
38+
39+
// Encrypt AES-GCM encrypts plaintext using ENCRYPTION_SECRET, then base64-encodes the result.
40+
// The output format is: base64(nonce || ciphertext || tag).
3641
func Encrypt(encryptionSecret, plainText string) (string, errors.Error) {
3742
// add suffix to the data part
3843
inputBytes := append([]byte(plainText), 123, 110, 100, 100, 116, 102, 125)
@@ -45,7 +50,8 @@ func Encrypt(encryptionSecret, plainText string) (string, errors.Error) {
4550
return base64.StdEncoding.EncodeToString(output), nil
4651
}
4752

48-
// Base64 + AES decryption using ENCRYPTION_SECRET in .env as key
53+
// Decrypt base64-decodes then AES-GCM decrypts ciphertext using ENCRYPTION_SECRET.
54+
// For backward compatibility, it also attempts AES-CBC decryption if the data looks like legacy format.
4955
func Decrypt(encryptionSecret, encryptedText string) (string, errors.Error) {
5056
// when encryption key is not set
5157
if encryptionSecret == "" {
@@ -98,41 +104,59 @@ func PKCS7UnPadding(origData []byte) []byte {
98104
return origData[:(length - unpadding)]
99105
}
100106

101-
// AesEncrypt AES encryption, CBC
107+
// AesEncrypt AES-256-GCM encrypts origData using key.
108+
// The returned bytes are: nonce (12 bytes) || ciphertext || tag.
102109
func AesEncrypt(origData, key []byte) ([]byte, errors.Error) {
103-
// data alignment fill and encryption
104110
sha256Key := sha256.Sum256(key)
105-
key = sha256Key[:]
106-
block, err := aes.NewCipher(key)
111+
block, err := aes.NewCipher(sha256Key[:])
107112
if err != nil {
108113
return nil, errors.Convert(err)
109114
}
110-
// data alignment fill and encryption
111-
blockSize := block.BlockSize()
112-
origData = PKCS7Padding(origData, blockSize)
113-
blockMode := cipher.NewCBCEncrypter(block, key[:blockSize])
114-
crypted := make([]byte, len(origData))
115-
blockMode.CryptBlocks(crypted, origData)
116-
return crypted, nil
115+
gcm, err := cipher.NewGCM(block)
116+
if err != nil {
117+
return nil, errors.Convert(err)
118+
}
119+
nonce := make([]byte, gcmNonceSize)
120+
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
121+
return nil, errors.Convert(err)
122+
}
123+
ciphertext := gcm.Seal(nonce, nonce, origData, nil)
124+
return ciphertext, nil
117125
}
118126

119-
// AesDecrypt AES decryption
127+
// AesDecrypt decrypts crypted data using key.
128+
// It first tries AES-256-GCM (expects a 12-byte nonce prefix).
129+
// If that fails and the data length is a multiple of the AES block size (legacy CBC format),
130+
// it falls back to AES-256-CBC for backward compatibility.
120131
func AesDecrypt(crypted, key []byte) ([]byte, errors.Error) {
121-
// Uniformly use sha256 to process as 32-bit Byte (256-bit bit)
122132
sha256Key := sha256.Sum256(key)
123-
key = sha256Key[:]
124-
block, err := aes.NewCipher(key)
133+
block, err := aes.NewCipher(sha256Key[:])
125134
if err != nil {
126135
return nil, errors.Convert(err)
127136
}
128-
// Get the block size and check whether the ciphertext length is legal
129137
blockSize := block.BlockSize()
138+
139+
// Try GCM first if the data is long enough to contain a nonce.
140+
if len(crypted) >= gcmNonceSize+blockSize {
141+
gcm, err := cipher.NewGCM(block)
142+
if err != nil {
143+
return nil, errors.Convert(err)
144+
}
145+
nonce := crypted[:gcmNonceSize]
146+
ciphertext := crypted[gcmNonceSize:]
147+
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
148+
if err == nil {
149+
return plaintext, nil
150+
}
151+
// GCM decryption failed; fall through to try legacy CBC.
152+
}
153+
154+
// Legacy CBC fallback.
130155
if len(crypted)%blockSize != 0 {
131156
return nil, errors.Default.New(fmt.Sprintf("The length of the data to be decrypted is [%d], so cannot match the required block size [%d]", len(crypted), blockSize))
132157
}
133158

134-
// Decrypt and unalign data
135-
blockMode := cipher.NewCBCDecrypter(block, key[:blockSize])
159+
blockMode := cipher.NewCBCDecrypter(block, sha256Key[:blockSize])
136160
origData := make([]byte, len(crypted))
137161
blockMode.CryptBlocks(origData, crypted)
138162
origData = PKCS7UnPadding(origData)

backend/core/plugin/plugin_utils_test.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,13 @@ limitations under the License.
1818
package plugin
1919

2020
import (
21+
"crypto/aes"
22+
"crypto/cipher"
23+
"crypto/sha256"
24+
"encoding/base64"
2125
"testing"
2226

27+
"github.com/apache/incubator-devlake/core/errors"
2328
"github.com/stretchr/testify/assert"
2429
)
2530

@@ -70,3 +75,67 @@ func TestEncode(t *testing.T) {
7075
})
7176
}
7277
}
78+
79+
func TestGCMEncDec(t *testing.T) {
80+
TestStr := "The string for testing"
81+
encryptionSecret, _ := RandomEncryptionSecret()
82+
83+
// Encrypt with the new GCM format.
84+
newCiphertext, err := Encrypt(encryptionSecret, TestStr)
85+
assert.Empty(t, err)
86+
87+
// Decrypt the new format.
88+
decodedNew, err := Decrypt(encryptionSecret, newCiphertext)
89+
assert.Empty(t, err)
90+
assert.Equal(t, TestStr, decodedNew)
91+
92+
// Ensure two encryptions of the same plaintext produce different ciphertexts (random nonce).
93+
newCiphertext2, err := Encrypt(encryptionSecret, TestStr)
94+
assert.Empty(t, err)
95+
assert.NotEqual(t, newCiphertext, newCiphertext2)
96+
}
97+
98+
// AesEncrypt AES encryption, CBC
99+
func oldAesEncrypt(origData, key []byte) ([]byte, errors.Error) {
100+
// data alignment fill and encryption
101+
sha256Key := sha256.Sum256(key)
102+
key = sha256Key[:]
103+
block, err := aes.NewCipher(key)
104+
if err != nil {
105+
return nil, errors.Convert(err)
106+
}
107+
// data alignment fill and encryption
108+
blockSize := block.BlockSize()
109+
origData = PKCS7Padding(origData, blockSize)
110+
blockMode := cipher.NewCBCEncrypter(block, key[:blockSize])
111+
crypted := make([]byte, len(origData))
112+
blockMode.CryptBlocks(crypted, origData)
113+
return crypted, nil
114+
}
115+
116+
func oldEncrypt(encryptionSecret, plainText string) (string, errors.Error) {
117+
// add suffix to the data part
118+
inputBytes := append([]byte(plainText), 123, 110, 100, 100, 116, 102, 125)
119+
// perform encryption
120+
output, err := oldAesEncrypt(inputBytes, []byte(encryptionSecret))
121+
if err != nil {
122+
return plainText, err
123+
}
124+
// Return the result after Base64 processing
125+
return base64.StdEncoding.EncodeToString(output), nil
126+
}
127+
128+
func TestBackwardCompatibility(t *testing.T) {
129+
TestStr := "The string for testing"
130+
encryptionSecret, _ := RandomEncryptionSecret()
131+
132+
// Encrypt with the new GCM format.
133+
newCiphertext, err := oldEncrypt(encryptionSecret, TestStr)
134+
assert.Empty(t, err)
135+
136+
// Decrypt the new format.
137+
decodedNew, err := Decrypt(encryptionSecret, newCiphertext)
138+
assert.Empty(t, err)
139+
assert.Equal(t, TestStr, decodedNew)
140+
141+
}

backend/plugins/bitbucket/api/connection_api_test.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -206,22 +206,22 @@ func TestMergeFromRequest_HandlesUsesApiToken(t *testing.T) {
206206
// After merge, UsesApiToken should be updated
207207
// This is a structural test - actual merge logic is in the connection.go MergeFromRequest method
208208
assert.True(t, connection.UsesApiToken, "Initial value should be true")
209-
209+
210210
// If we were to apply the merge:
211211
connection.UsesApiToken = newValues["usesApiToken"].(bool)
212212
connection.Username = newValues["username"].(string)
213-
213+
214214
assert.False(t, connection.UsesApiToken, "After merge, should be false")
215215
assert.Equal(t, "new_username", connection.Username)
216216
}
217217

218218
func TestConnectionStatusCodes(t *testing.T) {
219219
// Test expected status code handling
220220
tests := []struct {
221-
name string
222-
statusCode int
223-
expectedError bool
224-
errorType string
221+
name string
222+
statusCode int
223+
expectedError bool
224+
errorType string
225225
}{
226226
{
227227
name: "Success - 200 OK",

backend/plugins/bitbucket/api/remote_api.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,15 @@ func listBitbucketWorkspaces(
8080
if err != nil {
8181
return
8282
}
83+
if res.StatusCode > 299 {
84+
body, e := io.ReadAll(res.Body)
85+
if e != nil {
86+
err = errors.BadInput.Wrap(e, "failed to read response body")
87+
return
88+
}
89+
err = errors.HttpStatus(res.StatusCode).New(string(body))
90+
return
91+
}
8392

8493
resBody := &models.WorkspaceResponse{}
8594
err = api.UnmarshalResponse(res, resBody)
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/*
2+
Licensed to the Apache Software Foundation (ASF) under one or more
3+
contributor license agreements. See the NOTICE file distributed with
4+
this work for additional information regarding copyright ownership.
5+
The ASF licenses this file to You under the Apache License, Version 2.0
6+
(the "License"); you may not use this file except in compliance with
7+
the License. You may obtain a copy of the License at
8+
9+
http://www.apache.org/licenses/LICENSE-2.0
10+
11+
Unless required by applicable law or agreed to in writing, software
12+
distributed under the License is distributed on an "AS IS" BASIS,
13+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
See the License for the specific language governing permissions and
15+
limitations under the License.
16+
*/
17+
18+
package e2e
19+
20+
import (
21+
"reflect"
22+
"sort"
23+
"testing"
24+
25+
"github.com/apache/incubator-devlake/helpers/e2ehelper"
26+
"github.com/apache/incubator-devlake/helpers/pluginhelper/api"
27+
"github.com/apache/incubator-devlake/plugins/circleci/impl"
28+
"github.com/apache/incubator-devlake/plugins/circleci/models"
29+
"github.com/apache/incubator-devlake/plugins/circleci/tasks"
30+
"github.com/stretchr/testify/assert"
31+
)
32+
33+
// TestCircleciUnfinishedJobsInputIterator is a regression test for
34+
// https://github.com/apache/devlake/issues/8907. The "collect unfinished job
35+
// details" collector builds its URL from "/v2/workflow/{{ .Input.Id }}/job" while
36+
// scanning rows into a models.CircleciJob. Its input query must therefore expose the
37+
// workflow id in the row's Id field; a bare "DISTINCT workflow_id" left Id empty and
38+
// produced "/v2/workflow//job" (HTTP 500). This test runs the production query
39+
// (tasks.UnfinishedJobsInputClauses) through the real iterator and asserts each
40+
// yielded row's Id is the workflow id, that results are DISTINCT, and that the
41+
// status/connection filters hold.
42+
func TestCircleciUnfinishedJobsInputIterator(t *testing.T) {
43+
var circleci impl.Circleci
44+
dataflowTester := e2ehelper.NewDataFlowTester(t, "circleci", circleci)
45+
46+
const projectSlug = "github/test/repo"
47+
dataflowTester.FlushTabler(&models.CircleciJob{})
48+
49+
seed := []models.CircleciJob{
50+
{ConnectionId: 1, WorkflowId: "wf-onhold", Id: "job-1", ProjectSlug: projectSlug, Status: "on_hold"},
51+
{ConnectionId: 1, WorkflowId: "wf-onhold", Id: "job-2", ProjectSlug: projectSlug, Status: "running"}, // same workflow -> DISTINCT
52+
{ConnectionId: 1, WorkflowId: "wf-queued", Id: "job-3", ProjectSlug: projectSlug, Status: "queued"},
53+
{ConnectionId: 1, WorkflowId: "wf-success", Id: "job-4", ProjectSlug: projectSlug, Status: "success"}, // terminal -> excluded
54+
{ConnectionId: 2, WorkflowId: "wf-otherconn", Id: "job-5", ProjectSlug: projectSlug, Status: "on_hold"}, // other connection -> excluded
55+
}
56+
for i := range seed {
57+
assert.Nil(t, dataflowTester.Dal.Create(&seed[i]))
58+
}
59+
60+
cursor, err := dataflowTester.Dal.Cursor(tasks.UnfinishedJobsInputClauses(1, projectSlug)...)
61+
assert.Nil(t, err)
62+
iter, err := api.NewDalCursorIterator(dataflowTester.Dal, cursor, reflect.TypeOf(models.CircleciJob{}))
63+
assert.Nil(t, err)
64+
defer iter.Close()
65+
66+
var ids []string
67+
for iter.HasNext() {
68+
item, err := iter.Fetch()
69+
assert.Nil(t, err)
70+
job := item.(*models.CircleciJob)
71+
ids = append(ids, job.Id)
72+
}
73+
sort.Strings(ids)
74+
75+
// Distinct workflow ids for connection 1's non-terminal jobs, with Id populated
76+
// (the URL template reads .Input.Id). wf-success (terminal) and wf-otherconn
77+
// (connection 2) are excluded.
78+
assert.Equal(t, []string{"wf-onhold", "wf-queued"}, ids)
79+
for _, id := range ids {
80+
assert.NotEmpty(t, id, "Input.Id must be the workflow id, not empty (#8907)")
81+
}
82+
}

backend/plugins/circleci/tasks/job_collector.go

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,20 @@ var CollectJobsMeta = plugin.SubTaskMeta{
4141
DomainTypes: []string{plugin.DOMAIN_TYPE_CICD},
4242
}
4343

44+
// UnfinishedJobsInputClauses returns the DAL clauses that select the workflows whose
45+
// jobs are still in a non-terminal status and therefore need their job details
46+
// recollected by the CollectJobs "unfinished details" collector.
47+
func UnfinishedJobsInputClauses(connectionId uint64, projectSlug string) []dal.Clause {
48+
return []dal.Clause{
49+
dal.Select("DISTINCT workflow_id AS id"), // #8907: alias to id so {{ .Input.Id }} resolves when scanned into CircleciJob
50+
dal.From(&models.CircleciJob{}),
51+
dal.Where(
52+
"connection_id = ? AND project_slug = ? AND status IN ('running', 'not_running', 'queued', 'on_hold')",
53+
connectionId, projectSlug,
54+
),
55+
}
56+
}
57+
4458
func CollectJobs(taskCtx plugin.SubTaskContext) errors.Error {
4559
rawDataSubTaskArgs, data := CreateRawDataSubTaskArgs(taskCtx, RAW_JOB_TABLE)
4660
logger := taskCtx.GetLogger()
@@ -94,14 +108,8 @@ func CollectJobs(taskCtx plugin.SubTaskContext) errors.Error {
94108
AfterResponse: ignoreDeletedBuilds,
95109
},
96110
BuildInputIterator: func() (api.Iterator, errors.Error) {
97-
clauses := []dal.Clause{
98-
dal.Select("DISTINCT workflow_id"), // Only need to recollect jobs for a workflow once
99-
dal.From(&models.CircleciJob{}),
100-
dal.Where("connection_id = ? AND project_slug = ? AND status IN ('running', 'not_running', 'queued', 'on_hold')", data.Options.ConnectionId, data.Options.ProjectSlug),
101-
}
102-
103111
db := taskCtx.GetDal()
104-
cursor, err := db.Cursor(clauses...)
112+
cursor, err := db.Cursor(UnfinishedJobsInputClauses(data.Options.ConnectionId, data.Options.ProjectSlug)...)
105113
if err != nil {
106114
return nil, err
107115
}

0 commit comments

Comments
 (0)