This is an automated email from the ASF dual-hosted git repository.

klesh pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/devlake.git


The following commit(s) were added to refs/heads/main by this push:
     new 56826a576 fix(blueprint): keep the scheduled plan unmasked after PATCH 
/blueprints (#9247)
56826a576 is described below

commit 56826a576d4fdb5ebb694ec09c51e36ddeaad21c
Author: Yashvardhan Thanvi <[email protected]>
AuthorDate: Mon Oct 26 21:11:58 2026 +0530

    fix(blueprint): keep the scheduled plan unmasked after PATCH /blueprints 
(#9247)
    
    PatchBlueprint sanitized the blueprint it had just handed to the cron
    job, and the gitextractor sanitizer rewrote the options map in place.
    The next scheduled pipeline then cloned with a masked URL until the
    server restarted. Normal-mode blueprints with credentials in beforePlan
    or afterPlan were hit too, since those tasks are shared with the
    generated plan.
    
    Sanitizing now works on copies: SanitizePluginOption masks a clone of
    the options, SanitizeTask returns a new task, and SanitizeBlueprint and
    SanitizePipeline build a new plan. PatchBlueprint sanitizes a copy of
    the saved blueprint for its response, so the plan it returns is still
    masked.
    
    Closes #9243
    
    Signed-off-by: Yashvardhan Thanvi <[email protected]>
---
 backend/server/services/blueprint.go      |  6 ++-
 backend/server/services/blueprint_test.go | 63 +++++++++++++++++++++++++++++++
 backend/server/services/pipeline.go       | 52 +++++++++++++++++--------
 3 files changed, 103 insertions(+), 18 deletions(-)

diff --git a/backend/server/services/blueprint.go 
b/backend/server/services/blueprint.go
index 6acc95849..39d93189e 100644
--- a/backend/server/services/blueprint.go
+++ b/backend/server/services/blueprint.go
@@ -230,10 +230,12 @@ func PatchBlueprint(id uint64, body 
map[string]interface{}) (*models.Blueprint,
        if err != nil {
                return nil, err
        }
-       if err := SanitizeBlueprint(blueprint); err != nil {
+       // the cron job registered by saveBlueprint holds this blueprint, so 
sanitize a copy for the response
+       sanitized := *blueprint
+       if err := SanitizeBlueprint(&sanitized); err != nil {
                return nil, errors.Convert(err)
        }
-       return blueprint, nil
+       return &sanitized, nil
 }
 
 // DeleteBlueprint FIXME ...
diff --git a/backend/server/services/blueprint_test.go 
b/backend/server/services/blueprint_test.go
index d5f80ae0f..218ad336c 100644
--- a/backend/server/services/blueprint_test.go
+++ b/backend/server/services/blueprint_test.go
@@ -21,9 +21,72 @@ import (
        "testing"
 
        coreModels "github.com/apache/devlake/core/models"
+       "github.com/apache/devlake/impls/logruslog"
+       mockdal "github.com/apache/devlake/mocks/core/dal"
+       "github.com/robfig/cron/v3"
        "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/mock"
+       "github.com/stretchr/testify/require"
 )
 
+const (
+       testGitUrl       = "https://git:[email protected]/group/repo.git";
+       testMaskedGitUrl = "https://git:************@example.com/group/repo.git";
+)
+
+func TestSanitizeBlueprintLeavesTheSourcePlanUnchanged(t *testing.T) {
+       bp := &coreModels.Blueprint{
+               Plan: coreModels.PipelinePlan{{
+                       {Plugin: "gitextractor", Options: 
map[string]interface{}{"url": testGitUrl, "name": "group/repo"}},
+               }},
+       }
+
+       sanitized := *bp
+       require.NoError(t, SanitizeBlueprint(&sanitized))
+
+       assert.Equal(t, map[string]interface{}{"url": testMaskedGitUrl, "name": 
"group/repo"}, sanitized.Plan[0][0].Options)
+       assert.Equal(t, map[string]interface{}{"url": testGitUrl, "name": 
"group/repo"}, bp.Plan[0][0].Options)
+}
+
+func TestPatchBlueprintKeepsTheScheduledPlanUnsanitized(t *testing.T) {
+       withProjectTestValidator(t)
+       testDB := mockdal.NewDal(t)
+       withProjectTestDatabase(t, testDB)
+       previousLogger, previousCron, previousCronIds := logger, cronManager, 
bpCronIdMap
+       logger, cronManager, bpCronIdMap = logruslog.Global, cron.New(), 
map[uint64]cron.EntryID{}
+       t.Cleanup(func() {
+               logger, cronManager, bpCronIdMap = previousLogger, 
previousCron, previousCronIds
+       })
+
+       testDB.On("First", mock.Anything, mock.Anything).Run(func(args 
mock.Arguments) {
+               bp := args.Get(0).(*coreModels.Blueprint)
+               bp.ID = 9
+               bp.Name = "advanced"
+               bp.Mode = coreModels.BLUEPRINT_MODE_ADVANCED
+               bp.Enable = true
+               bp.CronConfig = "0 0 * * *"
+               bp.Plan = coreModels.PipelinePlan{{
+                       {Plugin: "gitextractor", Options: 
map[string]interface{}{"url": testGitUrl}},
+               }}
+       }).Return(nil).Once()
+       testDB.On("Pluck", "name", mock.Anything, 
mock.Anything).Return(nil).Once()
+       testDB.On("All", mock.Anything, mock.Anything).Return(nil).Once()
+       testDB.On("Update", mock.Anything).Return(nil).Once()
+       testDB.On("Delete", mock.Anything, mock.Anything).Return(nil).Times(3)
+
+       patched, err := PatchBlueprint(9, map[string]interface{}{"cronConfig": 
"*/5 * * * *"})
+       require.NoError(t, err)
+       assert.Equal(t, testMaskedGitUrl, patched.Plan[0][0].Options["url"])
+
+       // the cron job must still run the stored plan, not the one sanitized 
for the response
+       entries := cronManager.Entries()
+       require.Len(t, entries, 1)
+       job, ok := entries[0].Job.(*BlueprintJob)
+       require.True(t, ok)
+       assert.Equal(t, "*/5 * * * *", job.Blueprint.CronConfig)
+       assert.Equal(t, testGitUrl, job.Blueprint.Plan[0][0].Options["url"])
+}
+
 func TestParallelizePipelineTasks(t *testing.T) {
        plan1 := coreModels.PipelinePlan{
                {
diff --git a/backend/server/services/pipeline.go 
b/backend/server/services/pipeline.go
index bc7b6f9ce..059d72c2d 100644
--- a/backend/server/services/pipeline.go
+++ b/backend/server/services/pipeline.go
@@ -20,9 +20,11 @@ package services
 import (
        "context"
        "fmt"
+       "maps"
        "net/url"
        "os"
        "path/filepath"
+       "slices"
        "strings"
        "sync"
        "time"
@@ -44,6 +46,10 @@ import (
 
 var defaultNotificationService *DefaultPipelineNotificationService
 var globalPipelineLog = logruslog.Global.Nested("pipeline service")
+
+// pluginOptionSanitizers mask secrets in plugin options. Each one receives a
+// shallow copy of the options, so it may replace top-level values but must not
+// modify nested maps or slices in place.
 var pluginOptionSanitizers = map[string]func(map[string]interface{}){
        "gitextractor": func(options map[string]interface{}) {
                if v, ok := options["url"]; ok {
@@ -144,45 +150,59 @@ func CreatePipeline(newPipeline *models.NewPipeline, 
shouldSanitize bool) (*mode
        return pipeline, nil
 }
 
+// SanitizeBlueprint replaces blueprint.Plan with a sanitized copy. The 
original
+// plan is left untouched because it may be shared, e.g. with a scheduled job.
 func SanitizeBlueprint(blueprint *models.Blueprint) error {
-       for planStageIdx, pipelineStage := range blueprint.Plan {
-               for planTaskIdx := range pipelineStage {
-                       pipelineTask, err := 
SanitizeTask(blueprint.Plan[planStageIdx][planTaskIdx])
-                       if err != nil {
-                               return err
-                       }
-                       blueprint.Plan[planStageIdx][planTaskIdx] = pipelineTask
-               }
+       plan, err := sanitizePlan(blueprint.Plan)
+       if err != nil {
+               return err
        }
+       blueprint.Plan = plan
        return nil
 }
 
+// SanitizePipeline replaces pipeline.Plan with a sanitized copy.
 func SanitizePipeline(pipeline *models.Pipeline) error {
-       for planStageIdx, pipelineStage := range pipeline.Plan {
-               for planTaskIdx := range pipelineStage {
-                       task := pipeline.Plan[planStageIdx][planTaskIdx]
+       plan, err := sanitizePlan(pipeline.Plan)
+       if err != nil {
+               return err
+       }
+       pipeline.Plan = plan
+       return nil
+}
+
+func sanitizePlan(plan models.PipelinePlan) (models.PipelinePlan, error) {
+       sanitized := slices.Clone(plan)
+       for planStageIdx, pipelineStage := range plan {
+               sanitized[planStageIdx] = slices.Clone(pipelineStage)
+               for planTaskIdx, task := range pipelineStage {
                        pipelineTask, err := SanitizeTask(task)
                        if err != nil {
-                               return err
+                               return nil, err
                        }
-                       pipeline.Plan[planStageIdx][planTaskIdx] = pipelineTask
+                       sanitized[planStageIdx][planTaskIdx] = pipelineTask
                }
        }
-       return nil
+       return sanitized, nil
 }
 
+// SanitizeTask returns a copy of the task with sanitized options.
 func SanitizeTask(pipelineTask *models.PipelineTask) (*models.PipelineTask, 
error) {
        pluginName := pipelineTask.Plugin
        options, err := SanitizePluginOption(pluginName, pipelineTask.Options)
        if err != nil {
                return pipelineTask, err
        }
-       pipelineTask.Options = options
-       return pipelineTask, nil
+       sanitized := *pipelineTask
+       sanitized.Options = options
+       return &sanitized, nil
 }
 
+// SanitizePluginOption returns the sanitized options. The sanitizer works on a
+// shallow copy, so the given map is never modified.
 func SanitizePluginOption(pluginName string, option map[string]interface{}) 
(map[string]interface{}, error) {
        if sanitizer, ok := pluginOptionSanitizers[pluginName]; ok {
+               option = maps.Clone(option)
                sanitizer(option)
        }
        return option, nil

Reply via email to