Script 'mail_helper' called by obssrc
Hello community,

here is the log from the commit of package semaphore for openSUSE:Factory 
checked in at 2026-07-06 12:34:33
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/semaphore (Old)
 and      /work/SRC/openSUSE:Factory/.semaphore.new.1982 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "semaphore"

Mon Jul  6 12:34:33 2026 rev:50 rq:1363945 version:2.18.20

Changes:
--------
--- /work/SRC/openSUSE:Factory/semaphore/semaphore.changes      2026-06-28 
21:11:14.591612335 +0200
+++ /work/SRC/openSUSE:Factory/.semaphore.new.1982/semaphore.changes    
2026-07-06 12:37:15.389672924 +0200
@@ -1,0 +2,36 @@
+Mon Jul 06 05:19:50 UTC 2026 - Johannes Kastl 
<[email protected]>
+
+- Update to version 2.18.20:
+  * Bugfixes
+    - Validate playbook path
+- Update to version 2.18.19 (.17 and .18 were not released):
+  This patch release includes several important security and
+  validation fixes.
+  * Security fixes
+    - Added validation for Git repository URLs to prevent Git
+      option injection.
+    - Added --end-of-options to Git commands to make repository URL
+      and branch handling safer.
+    - Fixed branch override handling: task-level Git branch
+      override is now applied only when the template explicitly
+      allows it.
+    - Prevented custom roles from shadowing built-in role slugs
+      such as owner or manager.
+    - Fixed permission resolution so built-in roles always use
+      their built-in permissions instead of database-defined custom
+      roles.
+    - Added validation to prevent access keys from being updated
+      with a different ID or moved to another project.
+  * Reliability and tests
+    - Added tests for Git URL validation and Git command injection
+      protection.
+    - Added tests for access key update validation.
+    - Added tests for custom role validation and reserved role
+      slugs.
+    - Refactored Git branch resolution into a dedicated helper to
+      make task behavior more consistent.
+- Update to version 2.18.16 (.15 was not released):
+  * Bugfixes
+    - Fix variable groups sync functionality
+
+-------------------------------------------------------------------

Old:
----
  semaphore-2.18.13.obscpio
  web-2.18.13.tar.gz

New:
----
  semaphore-2.18.20.obscpio
  web-2.18.20.tar.gz

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ semaphore.spec ++++++
--- /var/tmp/diff_new_pack.zr1Bme/_old  2026-07-06 12:37:18.553782867 +0200
+++ /var/tmp/diff_new_pack.zr1Bme/_new  2026-07-06 12:37:18.565783284 +0200
@@ -17,7 +17,7 @@
 
 
 Name:           semaphore
-Version:        2.18.13
+Version:        2.18.20
 Release:        0
 Summary:        Modern UI for Ansible
 License:        MIT

++++++ _service ++++++
--- /var/tmp/diff_new_pack.zr1Bme/_old  2026-07-06 12:37:19.113802326 +0200
+++ /var/tmp/diff_new_pack.zr1Bme/_new  2026-07-06 12:37:19.153803716 +0200
@@ -1,9 +1,9 @@
 <services>
   <service name="obs_scm" mode="manual">
-    <param name="url">https://github.com/ansible-semaphore/semaphore</param>
+    <param 
name="url">https://github.com/ansible-semaphore/semaphore.git</param>
     <param name="scm">git</param>
     <param name="exclude">.git</param>
-    <param name="revision">v2.18.13</param>
+    <param name="revision">refs/tags/v2.18.20</param>
     <param name="versionformat">@PARENT_TAG@</param>
     <param name="versionrewrite-pattern">v(.*)</param>
     <param name="changesgenerate">enable</param>

++++++ _servicedata ++++++
--- /var/tmp/diff_new_pack.zr1Bme/_old  2026-07-06 12:37:19.453814140 +0200
+++ /var/tmp/diff_new_pack.zr1Bme/_new  2026-07-06 12:37:19.489815391 +0200
@@ -1,6 +1,8 @@
 <servicedata>
 <service name="tar_scm">
                 <param 
name="url">https://github.com/ansible-semaphore/semaphore</param>
-              <param 
name="changesrevision">8a4dcf0868af718aaa5871368a3247dd622521f4</param></service></servicedata>
+              <param 
name="changesrevision">8a4dcf0868af718aaa5871368a3247dd622521f4</param></service><service
 name="tar_scm">
+                <param 
name="url">https://github.com/ansible-semaphore/semaphore.git</param>
+              <param 
name="changesrevision">5d87656a680600125fe78edec1f7a0d10484b52c</param></service></servicedata>
 (No newline at EOF)
 

++++++ semaphore-2.18.13.obscpio -> semaphore-2.18.20.obscpio ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/semaphore-2.18.13/api/projects/keys.go 
new/semaphore-2.18.20/api/projects/keys.go
--- old/semaphore-2.18.13/api/projects/keys.go  2026-06-08 19:58:47.000000000 
+0200
+++ new/semaphore-2.18.20/api/projects/keys.go  2026-07-05 21:21:33.000000000 
+0200
@@ -139,6 +139,21 @@
                return
        }
 
+       // access key ID and project ID in the body and the path must be the 
same
+       if key.ID != oldKey.ID {
+               helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{
+                       "error": "Access key id in URL and in body must be the 
same",
+               })
+               return
+       }
+
+       if oldKey.ProjectID == nil || key.ProjectID == nil || *key.ProjectID != 
*oldKey.ProjectID {
+               helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{
+                       "error": "You can not move access key to other project",
+               })
+               return
+       }
+
        if oldKey.Synchronized {
                if key.Name != oldKey.Name || key.Type != oldKey.Type {
                        helpers.WriteJSON(w, http.StatusBadRequest, 
map[string]string{
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/semaphore-2.18.13/api/projects/keys_test.go 
new/semaphore-2.18.20/api/projects/keys_test.go
--- old/semaphore-2.18.13/api/projects/keys_test.go     1970-01-01 
01:00:00.000000000 +0100
+++ new/semaphore-2.18.20/api/projects/keys_test.go     2026-07-05 
21:21:33.000000000 +0200
@@ -0,0 +1,71 @@
+package projects
+
+import (
+       "net/http"
+       "net/http/httptest"
+       "strings"
+       "testing"
+
+       "github.com/semaphoreui/semaphore/api/helpers"
+       "github.com/semaphoreui/semaphore/db"
+       "github.com/stretchr/testify/assert"
+)
+
+func intPtr(v int) *int { return &v }
+
+// newUpdateKeyRequest builds a request with the URL-resolved oldKey already in
+// context (as KeyMiddleware would set it) and the given JSON body.
+func newUpdateKeyRequest(oldKey db.AccessKey, body string) (*http.Request, 
*httptest.ResponseRecorder) {
+       req := httptest.NewRequest(http.MethodPut, "/api/project/1/keys/1", 
strings.NewReader(body))
+       req = helpers.SetContextValue(req, "accessKey", oldKey)
+       return req, httptest.NewRecorder()
+}
+
+func TestUpdateKey_RejectsBodyIDMismatch(t *testing.T) {
+
+       svc := &mockAccessKeyService{}
+       ctrl := NewKeyController(svc)
+
+       oldKey := db.AccessKey{ID: 10, ProjectID: intPtr(1)}
+       // Body targets a different key id than the one resolved from the URL.
+       body := `{"id":42,"name":"x","type":"none","project_id":1}`
+       req, w := newUpdateKeyRequest(oldKey, body)
+
+       ctrl.UpdateKey(w, req)
+
+       assert.Equal(t, http.StatusBadRequest, w.Code)
+       assert.Contains(t, w.Body.String(), "must be the same")
+       assert.Empty(t, svc.updated, "store must not be touched on rejection")
+}
+
+func TestUpdateKey_RejectsCrossProjectMove(t *testing.T) {
+       svc := &mockAccessKeyService{}
+       ctrl := NewKeyController(svc)
+
+       oldKey := db.AccessKey{ID: 10, ProjectID: intPtr(1)}
+       // Body keeps the same key id but points project_id at a foreign 
project.
+       body := `{"id":10,"name":"x","type":"none","project_id":999}`
+       req, w := newUpdateKeyRequest(oldKey, body)
+
+       ctrl.UpdateKey(w, req)
+
+       assert.Equal(t, http.StatusBadRequest, w.Code)
+       assert.Contains(t, w.Body.String(), "other project")
+       assert.Empty(t, svc.updated, "store must not be touched on rejection")
+}
+
+func TestUpdateKey_RejectsNilBodyProjectID(t *testing.T) {
+       svc := &mockAccessKeyService{}
+       ctrl := NewKeyController(svc)
+
+       oldKey := db.AccessKey{ID: 10, ProjectID: intPtr(1)}
+       // Body omits project_id entirely.
+       body := `{"id":10,"name":"x","type":"none"}`
+       req, w := newUpdateKeyRequest(oldKey, body)
+
+       ctrl.UpdateKey(w, req)
+
+       assert.Equal(t, http.StatusBadRequest, w.Code)
+       assert.Contains(t, w.Body.String(), "other project")
+       assert.Empty(t, svc.updated, "store must not be touched on rejection")
+}
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/semaphore-2.18.13/api/projects/project.go 
new/semaphore-2.18.20/api/projects/project.go
--- old/semaphore-2.18.13/api/projects/project.go       2026-06-08 
19:58:47.000000000 +0200
+++ new/semaphore-2.18.20/api/projects/project.go       2026-07-05 
21:21:33.000000000 +0200
@@ -46,14 +46,20 @@
 
                permissions := roleSlug.GetPermissions()
 
-               role, err := 
helpers.Store(r).GetProjectOrGlobalRoleBySlug(projectID, 
string(projectUser.Role))
+               // Built-in roles are defined in code and are the source of 
truth for their
+               // permissions. Only custom roles are resolved from the 
database, otherwise a
+               // project role sharing a built-in slug (e.g. "manager") could 
override the
+               // built-in permissions and escalate privileges.
+               if !roleSlug.IsValid() {
+                       role, err := 
helpers.Store(r).GetProjectOrGlobalRoleBySlug(projectID, 
string(projectUser.Role))
 
-               if err == nil {
-                       roleSlug = db.ProjectUserRole(role.Slug)
-                       permissions = role.Permissions
-               } else if !errors.Is(err, db.ErrNotFound) {
-                       helpers.WriteError(w, err)
-                       return
+                       if err == nil {
+                               roleSlug = db.ProjectUserRole(role.Slug)
+                               permissions = role.Permissions
+                       } else if !errors.Is(err, db.ErrNotFound) {
+                               helpers.WriteError(w, err)
+                               return
+                       }
                }
 
                if helpers.HasParam("template_id", r) {
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/semaphore-2.18.13/db/Repository.go 
new/semaphore-2.18.20/db/Repository.go
--- old/semaphore-2.18.13/db/Repository.go      2026-06-08 19:58:47.000000000 
+0200
+++ new/semaphore-2.18.20/db/Repository.go      2026-07-05 21:21:33.000000000 
+0200
@@ -144,6 +144,10 @@
                return &ValidationError{"repository url can't be empty"}
        }
 
+       if err := ValidateGitURL(r.GitURL, "repository"); err != nil {
+               return err
+       }
+
        if r.GetType() != RepositoryLocal && r.GitBranch == "" {
                return &ValidationError{"repository branch can't be empty"}
        }
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/semaphore-2.18.13/db/Role.go 
new/semaphore-2.18.20/db/Role.go
--- old/semaphore-2.18.13/db/Role.go    2026-06-08 19:58:47.000000000 +0200
+++ new/semaphore-2.18.20/db/Role.go    2026-07-05 21:21:33.000000000 +0200
@@ -11,6 +11,14 @@
        if role.Name == "" {
                return &ValidationError{Message: "Role name cannot be empty"}
        }
+       if role.Slug == "" {
+               return &ValidationError{Message: "Role slug cannot be empty"}
+       }
+       // Built-in role slugs are reserved. Allowing a custom role to reuse 
one lets
+       // it shadow the built-in role and escalate the permissions of its 
members.
+       if ProjectUserRole(role.Slug).IsValid() {
+               return &ValidationError{Message: "Role slug is reserved and 
cannot be used: " + role.Slug}
+       }
        return nil
 }
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/semaphore-2.18.13/db/Role_test.go 
new/semaphore-2.18.20/db/Role_test.go
--- old/semaphore-2.18.13/db/Role_test.go       1970-01-01 01:00:00.000000000 
+0100
+++ new/semaphore-2.18.20/db/Role_test.go       2026-07-05 21:21:33.000000000 
+0200
@@ -0,0 +1,73 @@
+package db
+
+import (
+       "testing"
+
+       "github.com/stretchr/testify/assert"
+)
+
+func TestValidateRole(t *testing.T) {
+       projectID := 1
+
+       tests := []struct {
+               name    string
+               role    Role
+               wantErr bool
+       }{
+               {
+                       name:    "valid custom role",
+                       role:    Role{Slug: "deployer", Name: "Deployer", 
ProjectID: &projectID},
+                       wantErr: false,
+               },
+               {
+                       name:    "empty name",
+                       role:    Role{Slug: "deployer", Name: "", ProjectID: 
&projectID},
+                       wantErr: true,
+               },
+               {
+                       name:    "empty slug",
+                       role:    Role{Slug: "", Name: "Deployer", ProjectID: 
&projectID},
+                       wantErr: true,
+               },
+               {
+                       name:    "reserved slug owner",
+                       role:    Role{Slug: string(ProjectOwner), Name: "pwn", 
ProjectID: &projectID},
+                       wantErr: true,
+               },
+               {
+                       name:    "reserved slug manager",
+                       role:    Role{Slug: string(ProjectManager), Name: 
"pwn", ProjectID: &projectID},
+                       wantErr: true,
+               },
+               {
+                       name:    "reserved slug task_runner",
+                       role:    Role{Slug: string(ProjectTaskRunner), Name: 
"pwn", ProjectID: &projectID},
+                       wantErr: true,
+               },
+               {
+                       name:    "reserved slug guest",
+                       role:    Role{Slug: string(ProjectGuest), Name: "pwn", 
ProjectID: &projectID},
+                       wantErr: true,
+               },
+       }
+
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       err := ValidateRole(tt.role)
+                       if tt.wantErr {
+                               assert.Error(t, err)
+                       } else {
+                               assert.NoError(t, err)
+                       }
+               })
+       }
+}
+
+// TestValidateRole_ReservedSlugsMatchBuiltins guards against a built-in role
+// being added later without also reserving its slug in ValidateRole.
+func TestValidateRole_ReservedSlugsMatchBuiltins(t *testing.T) {
+       for role := range rolePermissions {
+               err := ValidateRole(Role{Slug: string(role), Name: "custom"})
+               assert.Error(t, err, "built-in slug %q must be rejected by 
ValidateRole", role)
+       }
+}
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/semaphore-2.18.13/db/Task.go 
new/semaphore-2.18.20/db/Task.go
--- old/semaphore-2.18.13/db/Task.go    2026-06-08 19:58:47.000000000 +0200
+++ new/semaphore-2.18.20/db/Task.go    2026-07-05 21:21:33.000000000 +0200
@@ -169,6 +169,10 @@
                }
        }
 
+       if err := ValidatePlaybookPath(task.Playbook, "task"); err != nil {
+               return err
+       }
+
        var params any
        switch template.App {
        case AppAnsible:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/semaphore-2.18.13/db/Template.go 
new/semaphore-2.18.20/db/Template.go
--- old/semaphore-2.18.13/db/Template.go        2026-06-08 19:58:47.000000000 
+0200
+++ new/semaphore-2.18.20/db/Template.go        2026-07-05 21:21:33.000000000 
+0200
@@ -227,6 +227,10 @@
                return &ValidationError{"template playbook can not be empty"}
        }
 
+       if err := ValidatePlaybookPath(tpl.Playbook, "template"); err != nil {
+               return err
+       }
+
        if tpl.Arguments != nil {
                if !json.Valid([]byte(*tpl.Arguments)) {
                        return &ValidationError{"template arguments must be 
valid JSON"}
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/semaphore-2.18.13/db/git_url.go 
new/semaphore-2.18.20/db/git_url.go
--- old/semaphore-2.18.13/db/git_url.go 1970-01-01 01:00:00.000000000 +0100
+++ new/semaphore-2.18.20/db/git_url.go 2026-07-05 21:21:33.000000000 +0200
@@ -0,0 +1,19 @@
+package db
+
+import "strings"
+
+// ValidateGitURL rejects repository URLs that git would interpret as a
+// command-line option instead of a repository location. The CmdGitClient
+// passes the URL to the git binary as a positional argument, so a value
+// beginning with "-" (e.g. "--upload-pack=/path/to/script") would be parsed
+// by git as an option and could lead to arbitrary command execution
+// (git option injection). Legitimate git URLs (https://, ssh://, git://,
+// file://, scp-like user@host:path, or local filesystem paths) never begin
+// with "-", so rejecting them here is safe.
+func ValidateGitURL(url string, objectName string) error {
+       if strings.HasPrefix(strings.TrimSpace(url), "-") {
+               return NewValidationError(objectName + " url is invalid")
+       }
+
+       return nil
+}
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/semaphore-2.18.13/db/git_url_test.go 
new/semaphore-2.18.20/db/git_url_test.go
--- old/semaphore-2.18.13/db/git_url_test.go    1970-01-01 01:00:00.000000000 
+0100
+++ new/semaphore-2.18.20/db/git_url_test.go    2026-07-05 21:21:33.000000000 
+0200
@@ -0,0 +1,65 @@
+package db
+
+import (
+       "testing"
+
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+)
+
+func TestValidateGitURL(t *testing.T) {
+       tests := []struct {
+               name    string
+               url     string
+               wantErr bool
+       }{
+               {"https", "https://github.com/semaphoreui/semaphore.git";, 
false},
+               {"http", "http://example.com/repo.git";, false},
+               {"ssh scheme", "ssh://[email protected]/repo.git", false},
+               {"scp-like ssh", "[email protected]:semaphoreui/semaphore.git", 
false},
+               {"git scheme", "git://example.com/repo.git", false},
+               {"file scheme", "file:///srv/git/repo.git", false},
+               {"local absolute path", "/srv/git/repo.git", false},
+               {"empty", "", false}, // emptiness is handled separately by 
Repository.Validate
+
+               {"upload-pack option injection", "--upload-pack=/tmp/evil.sh", 
true},
+               {"single dash option", "-oProxyCommand=evil", true},
+               {"leading whitespace then dash", "  
--upload-pack=/tmp/evil.sh", true},
+       }
+
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       err := ValidateGitURL(tt.url, "repository")
+                       if tt.wantErr {
+                               require.Error(t, err)
+                               assert.ErrorContains(t, err, "repository url is 
invalid")
+                       } else {
+                               assert.NoError(t, err)
+                       }
+               })
+       }
+}
+
+func TestRepositoryValidate_RejectsOptionInjectionURL(t *testing.T) {
+       repo := Repository{
+               Name:      "rce",
+               GitURL:    "--upload-pack=/tmp/evil.sh",
+               GitBranch: "main",
+               SSHKeyID:  1,
+       }
+
+       err := repo.Validate()
+       require.Error(t, err)
+       assert.ErrorContains(t, err, "repository url is invalid")
+}
+
+func TestRepositoryValidate_AcceptsNormalURL(t *testing.T) {
+       repo := Repository{
+               Name:      "ok",
+               GitURL:    "https://github.com/semaphoreui/semaphore.git";,
+               GitBranch: "main",
+               SSHKeyID:  1,
+       }
+
+       assert.NoError(t, repo.Validate())
+}
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/semaphore-2.18.13/db/playbook_path.go 
new/semaphore-2.18.20/db/playbook_path.go
--- old/semaphore-2.18.13/db/playbook_path.go   1970-01-01 01:00:00.000000000 
+0100
+++ new/semaphore-2.18.20/db/playbook_path.go   2026-07-05 21:21:33.000000000 
+0200
@@ -0,0 +1,36 @@
+package db
+
+import (
+       "path"
+       "strings"
+)
+
+// ValidatePlaybookPath checks that a playbook (or script/subdirectory for
+// non-Ansible apps) path is relative and stays inside the repository bound
+// to the template. Absolute paths and paths escaping the repository via ".."
+// are rejected to prevent execution of arbitrary files on the host.
+func ValidatePlaybookPath(playbook string, objectName string) error {
+       if playbook == "" {
+               return nil
+       }
+
+       // Treat backslashes as path separators so Windows-style paths
+       // (C:\..., ..\..\evil.ps1) can not bypass the checks below.
+       p := strings.ReplaceAll(playbook, "\\", "/")
+
+       if path.IsAbs(p) {
+               return NewValidationError(objectName + " playbook must be a 
relative path inside the repository")
+       }
+
+       // Windows absolute paths like "C:/..." are not caught by path.IsAbs.
+       if len(p) >= 2 && p[1] == ':' {
+               return NewValidationError(objectName + " playbook must be a 
relative path inside the repository")
+       }
+
+       cleaned := path.Clean(p)
+       if cleaned == ".." || strings.HasPrefix(cleaned, "../") {
+               return NewValidationError(objectName + " playbook must not 
point outside the repository")
+       }
+
+       return nil
+}
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/semaphore-2.18.13/db/playbook_path_test.go 
new/semaphore-2.18.20/db/playbook_path_test.go
--- old/semaphore-2.18.13/db/playbook_path_test.go      1970-01-01 
01:00:00.000000000 +0100
+++ new/semaphore-2.18.20/db/playbook_path_test.go      2026-07-05 
21:21:33.000000000 +0200
@@ -0,0 +1,97 @@
+package db
+
+import (
+       "testing"
+
+       "github.com/stretchr/testify/assert"
+)
+
+func TestValidatePlaybookPath(t *testing.T) {
+       tests := []struct {
+               name     string
+               playbook string
+               valid    bool
+       }{
+               {"empty", "", true},
+               {"simple filename", "site.yml", true},
+               {"subdirectory", "playbooks/site.yml", true},
+               {"dot-slash prefix", "./site.yml", true},
+               {"internal dot-dot staying inside", "playbooks/../site.yml", 
true},
+               {"terraform subdirectory", "environments/prod", true},
+
+               {"absolute path", "/etc/cron.d/evil.yml", false},
+               {"absolute script", "/usr/bin/script.sh", false},
+               {"parent escape", "../outside.yml", false},
+               {"deep parent escape", "playbooks/../../outside.yml", false},
+               {"hidden parent escape", "./../outside.yml", false},
+               {"only dot-dot", "..", false},
+               {"windows absolute path", "C:\\Windows\\evil.ps1", false},
+               {"windows drive forward slashes", "c:/windows/evil.ps1", false},
+               {"windows parent escape", "..\\evil.ps1", false},
+               {"windows unc path", "\\\\server\\share\\evil.ps1", false},
+       }
+
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       err := ValidatePlaybookPath(tt.playbook, "template")
+                       if tt.valid {
+                               assert.NoError(t, err)
+                       } else {
+                               assert.Error(t, err)
+                       }
+               })
+       }
+}
+
+func TestTemplate_Validate_AbsolutePlaybook_ReturnsError(t *testing.T) {
+       tpl := &Template{
+               Name:     "Deploy",
+               Playbook: "/etc/evil.yml",
+       }
+
+       err := tpl.Validate()
+
+       assert.EqualError(t, err, "template playbook must be a relative path 
inside the repository")
+}
+
+func TestTemplate_Validate_PlaybookOutsideRepo_ReturnsError(t *testing.T) {
+       tpl := &Template{
+               Name:     "Deploy",
+               Playbook: "../outside.yml",
+       }
+
+       err := tpl.Validate()
+
+       assert.EqualError(t, err, "template playbook must not point outside the 
repository")
+}
+
+func TestTemplate_Validate_RelativePlaybook_ReturnsNoError(t *testing.T) {
+       tpl := &Template{
+               Name:     "Deploy",
+               Playbook: "playbooks/site.yml",
+       }
+
+       err := tpl.Validate()
+
+       assert.NoError(t, err)
+}
+
+func TestTask_ValidateNewTask_AbsolutePlaybook_ReturnsError(t *testing.T) {
+       task := &Task{
+               Playbook: "/usr/bin/evil.sh",
+       }
+
+       err := task.ValidateNewTask(Template{})
+
+       assert.EqualError(t, err, "task playbook must be a relative path inside 
the repository")
+}
+
+func TestTask_ValidateNewTask_RelativePlaybook_ReturnsNoError(t *testing.T) {
+       task := &Task{
+               Playbook: "site.yml",
+       }
+
+       err := task.ValidateNewTask(Template{})
+
+       assert.NoError(t, err)
+}
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/semaphore-2.18.13/db/sql/access_key.go 
new/semaphore-2.18.20/db/sql/access_key.go
--- old/semaphore-2.18.13/db/sql/access_key.go  2026-06-08 19:58:47.000000000 
+0200
+++ new/semaphore-2.18.20/db/sql/access_key.go  2026-07-05 21:21:33.000000000 
+0200
@@ -34,6 +34,8 @@
                case db.AccessKeySecretStorage:
                        q = q.Where(squirrel.Eq{"pe.storage_id": 
options.StorageID})
                }
+       } else if options.EnvironmentID != nil {
+               q = q.Where(squirrel.Eq{"pe.environment_id": 
*options.EnvironmentID})
        }
 
        if options.SourceStorageID != nil {
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/semaphore-2.18.13/db/sql/role.go 
new/semaphore-2.18.20/db/sql/role.go
--- old/semaphore-2.18.13/db/sql/role.go        2026-06-08 19:58:47.000000000 
+0200
+++ new/semaphore-2.18.20/db/sql/role.go        2026-07-05 21:21:33.000000000 
+0200
@@ -58,6 +58,10 @@
 
 func (d *SqlDb) GetProjectOrGlobalRoleBySlug(projectID int, slug string) 
(db.Role, error) {
        var role db.Role
-       err := d.selectOne(&role, "select * from `role` where slug=?", slug)
+       err := d.selectOne(
+               &role,
+               "select * from `role` where slug=? and (project_id=? or 
project_id is null)",
+               slug,
+               projectID)
        return role, err
 }
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/semaphore-2.18.13/db/sql/template.go 
new/semaphore-2.18.20/db/sql/template.go
--- old/semaphore-2.18.13/db/sql/template.go    2026-06-08 19:58:47.000000000 
+0200
+++ new/semaphore-2.18.20/db/sql/template.go    2026-07-05 21:21:33.000000000 
+0200
@@ -494,22 +494,31 @@
 
        perm = projectUser.Role.GetPermissions()
 
-       role, err := d.GetProjectOrGlobalRoleBySlug(projectUser.ProjectID, 
string(projectUser.Role))
+       roleSlug := string(projectUser.Role)
 
-       if errors.Is(err, db.ErrNotFound) {
-               err = nil
-               return
-       }
+       // Only custom roles are resolved from the database; built-in roles use 
their
+       // own slug directly so a same-named custom role cannot shadow them.
+       if !projectUser.Role.IsValid() {
+               var role db.Role
+               role, err = 
d.GetProjectOrGlobalRoleBySlug(projectUser.ProjectID, string(projectUser.Role))
+
+               if errors.Is(err, db.ErrNotFound) {
+                       err = nil
+                       return
+               }
+
+               if err != nil {
+                       return
+               }
 
-       if err != nil {
-               return
+               roleSlug = role.Slug
        }
 
        query, args, err := squirrel.Select("permissions").
                From("project__template_role").
                Where("project_id = ?", projectID).
                Where("template_id = ?", templateID).
-               Where("role_slug = ?", role.Slug).
+               Where("role_slug = ?", roleSlug).
                ToSql()
 
        if err != nil {
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/semaphore-2.18.13/db_lib/CmdGitClient.go 
new/semaphore-2.18.20/db_lib/CmdGitClient.go
--- old/semaphore-2.18.13/db_lib/CmdGitClient.go        2026-06-08 
19:58:47.000000000 +0200
+++ new/semaphore-2.18.20/db_lib/CmdGitClient.go        2026-07-05 
21:21:33.000000000 +0200
@@ -115,6 +115,7 @@
                "--recursive",
                "--branch",
                r.Repository.GitBranch,
+               "--end-of-options",
                r.Repository.GetGitURL(false),
                dirName)
 }
@@ -122,7 +123,7 @@
 func (c CmdGitClient) Pull(r GitRepository) error {
        r.Logger.Log("Updating Repository " + r.Repository.GitURL)
 
-       err := c.run(r, GitRepositoryFullPath, "pull", "origin", 
r.Repository.GitBranch)
+       err := c.run(r, GitRepositoryFullPath, "pull", "origin", 
"--end-of-options", r.Repository.GitBranch)
        if err != nil {
                return err
        }
@@ -167,7 +168,7 @@
 }
 
 func (c CmdGitClient) GetLastRemoteCommitHash(r GitRepository) (hash string, 
err error) {
-       out, err := c.output(r, GitRepositoryTmpPath, "ls-remote", 
r.Repository.GetGitURL(false), r.Repository.GitBranch)
+       out, err := c.output(r, GitRepositoryTmpPath, "ls-remote", 
"--end-of-options", r.Repository.GetGitURL(false), r.Repository.GitBranch)
        if err != nil {
                return
        }
@@ -185,7 +186,7 @@
 }
 
 func (c CmdGitClient) GetRemoteBranches(r GitRepository) ([]string, error) {
-       out, err := c.output(r, GitRepositoryTmpPath, "ls-remote", "--heads", 
r.Repository.GetGitURL(false))
+       out, err := c.output(r, GitRepositoryTmpPath, "ls-remote", "--heads", 
"--end-of-options", r.Repository.GetGitURL(false))
        if err != nil {
                return nil, err
        }
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/semaphore-2.18.13/db_lib/CmdGitClient_injection_test.go 
new/semaphore-2.18.20/db_lib/CmdGitClient_injection_test.go
--- old/semaphore-2.18.13/db_lib/CmdGitClient_injection_test.go 1970-01-01 
01:00:00.000000000 +0100
+++ new/semaphore-2.18.20/db_lib/CmdGitClient_injection_test.go 2026-07-05 
21:21:33.000000000 +0200
@@ -0,0 +1,128 @@
+package db_lib
+
+import (
+       "os"
+       "os/exec"
+       "path/filepath"
+       "testing"
+       "time"
+
+       "github.com/semaphoreui/semaphore/db"
+       "github.com/semaphoreui/semaphore/pkg/ssh"
+       "github.com/semaphoreui/semaphore/pkg/task_logger"
+       "github.com/semaphoreui/semaphore/util"
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+)
+
+// nopKeyInstaller is an AccessKeyInstaller that installs nothing (as for 
AccessKeyNone).
+type nopKeyInstaller struct{}
+
+func (nopKeyInstaller) Install(key db.AccessKey, usage db.AccessKeyRole, 
logger task_logger.Logger) (ssh.AccessKeyInstallation, error) {
+       return ssh.AccessKeyInstallation{}, nil
+}
+
+// nopLogger is a no-op task_logger.Logger for tests.
+type nopLogger struct{}
+
+func (nopLogger) Log(string)                                   {}
+func (nopLogger) Logf(string, ...any)                          {}
+func (nopLogger) LogWithTime(time.Time, string)                {}
+func (nopLogger) LogfWithTime(time.Time, string, ...any)       {}
+func (nopLogger) LogCmd(*exec.Cmd)                             {}
+func (nopLogger) SetStatus(task_logger.TaskStatus)             {}
+func (nopLogger) AddStatusListener(task_logger.StatusListener) {}
+func (nopLogger) AddLogListener(task_logger.LogListener)       {}
+func (nopLogger) SetCommit(string, string)                     {}
+func (nopLogger) WaitLog()                                     {}
+
+func gitInit(t *testing.T, dir string) {
+       t.Helper()
+       run := func(args ...string) {
+               cmd := exec.Command("git", args...)
+               cmd.Dir = dir
+               out, err := cmd.CombinedOutput()
+               require.NoError(t, err, string(out))
+       }
+       run("init", "-q", "-b", "main")
+       run("config", "user.email", "t@t")
+       run("config", "user.name", "t")
+       require.NoError(t, os.WriteFile(filepath.Join(dir, "f"), []byte("hi"), 
0644))
+       run("add", "f")
+       run("commit", "-qm", "init")
+}
+
+func newTestGitRepo(t *testing.T, gitURL, gitBranch string) GitRepository {
+       t.Helper()
+       return GitRepository{
+               Repository: db.Repository{
+                       ProjectID: 1,
+                       GitURL:    gitURL,
+                       GitBranch: gitBranch,
+                       SSHKey:    db.AccessKey{Type: db.AccessKeyNone},
+               },
+               Logger: nopLogger{},
+       }
+}
+
+// setupGitClientTest initializes util.Config with a temp dir and restores it 
afterwards.
+func setupGitClientTest(t *testing.T) {
+       t.Helper()
+       original := util.Config
+       t.Cleanup(func() { util.Config = original })
+       util.Config = &util.ConfigType{TmpPath: t.TempDir(), Process: 
&util.ConfigProcess{}}
+}
+
+// TestCmdGitClient_OptionInjectionNeutralized proves that a GitURL crafted as 
a git
+// option (e.g. "--upload-pack=/path/to/script") is NOT executed by the remote 
git
+// operations, because "--end-of-options" is inserted before the URL 
positional.
+func TestCmdGitClient_OptionInjectionNeutralized(t *testing.T) {
+       setupGitClientTest(t)
+
+       tmp := t.TempDir()
+       marker := filepath.Join(tmp, "PWNED")
+       evil := filepath.Join(tmp, "evil.sh")
+       require.NoError(t, os.WriteFile(evil,
+               []byte("#!/bin/sh\necho pwned > "+marker+"\n"), 0755))
+
+       client := CreateCmdGitClient(nopKeyInstaller{})
+       repo := newTestGitRepo(t, "--upload-pack="+evil, "main")
+
+       t.Run("GetLastRemoteCommitHash", func(t *testing.T) {
+               _, err := client.GetLastRemoteCommitHash(repo)
+               assert.Error(t, err, "injected upload-pack must not succeed")
+               assert.NoFileExists(t, marker, "payload must not be executed")
+       })
+
+       require.NoError(t, os.RemoveAll(marker))
+
+       t.Run("GetRemoteBranches", func(t *testing.T) {
+               _, err := client.GetRemoteBranches(repo)
+               assert.Error(t, err, "injected upload-pack must not succeed")
+               assert.NoFileExists(t, marker, "payload must not be executed")
+       })
+}
+
+// TestCmdGitClient_LegitRemoteOperations makes sure "--end-of-options" does 
not break
+// normal remote operations against a valid repository.
+func TestCmdGitClient_LegitRemoteOperations(t *testing.T) {
+       setupGitClientTest(t)
+
+       upstream := t.TempDir()
+       gitInit(t, upstream)
+
+       client := CreateCmdGitClient(nopKeyInstaller{})
+       repo := newTestGitRepo(t, upstream, "main")
+
+       t.Run("GetLastRemoteCommitHash", func(t *testing.T) {
+               hash, err := client.GetLastRemoteCommitHash(repo)
+               require.NoError(t, err)
+               assert.Len(t, hash, 40, "expected a full commit hash")
+       })
+
+       t.Run("GetRemoteBranches", func(t *testing.T) {
+               branches, err := client.GetRemoteBranches(repo)
+               require.NoError(t, err)
+               assert.Equal(t, []string{"main"}, branches)
+       })
+}
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/semaphore-2.18.13/deployment/docker/runner/Dockerfile 
new/semaphore-2.18.20/deployment/docker/runner/Dockerfile
--- old/semaphore-2.18.13/deployment/docker/runner/Dockerfile   2026-06-08 
19:58:47.000000000 +0200
+++ new/semaphore-2.18.20/deployment/docker/runner/Dockerfile   2026-07-05 
21:21:33.000000000 +0200
@@ -19,7 +19,7 @@
 ARG GH_TOKEN
 
 RUN if [ -n "$APP_BUILD_TYPE" ]; then \
-      git clone -b main 
https://${GH_TOKEN}@github.com/semaphoreui/semaphorepro-module.git pro_impl && \
+      git clone -b 2-18-stable 
https://${GH_TOKEN}@github.com/semaphoreui/semaphorepro-module.git pro_impl && \
       go work init . ./pro_impl; \
     fi
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/semaphore-2.18.13/deployment/docker/server/Dockerfile 
new/semaphore-2.18.20/deployment/docker/server/Dockerfile
--- old/semaphore-2.18.13/deployment/docker/server/Dockerfile   2026-06-08 
19:58:47.000000000 +0200
+++ new/semaphore-2.18.20/deployment/docker/server/Dockerfile   2026-07-05 
21:21:33.000000000 +0200
@@ -19,7 +19,7 @@
 ARG GH_TOKEN
 
 RUN if [ -n "$APP_BUILD_TYPE" ]; then \
-      git clone -b main 
https://${GH_TOKEN}@github.com/semaphoreui/semaphorepro-module.git pro_impl && \
+      git clone -b 2-18-stable 
https://${GH_TOKEN}@github.com/semaphoreui/semaphorepro-module.git pro_impl && \
       go work init . ./pro_impl; \
     fi
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/semaphore-2.18.13/services/tasks/LocalJob.go 
new/semaphore-2.18.20/services/tasks/LocalJob.go
--- old/semaphore-2.18.13/services/tasks/LocalJob.go    2026-06-08 
19:58:47.000000000 +0200
+++ new/semaphore-2.18.20/services/tasks/LocalJob.go    2026-07-05 
21:21:33.000000000 +0200
@@ -39,6 +39,28 @@
        KeyInstaller db_lib.AccessKeyInstaller
 }
 
+// resolveGitBranch computes the effective git branch for a run, applying the
+// template-level and task-level overrides on top of the repository's 
configured
+// branch. The task-supplied branch is honored only when the template 
explicitly
+// permits it via AllowOverrideBranchInTask; otherwise it is ignored. This 
gate is
+// what prevents a user who may only run tasks (Task Runner) from redirecting a
+// branch-pinned template to an arbitrary branch of the repository.
+func resolveGitBranch(repoBranch string, template db.Template, task db.Task) 
string {
+       branch := repoBranch
+
+       // Override git branch from template if set.
+       if template.GitBranch != nil && *template.GitBranch != "" {
+               branch = *template.GitBranch
+       }
+
+       // Override git branch from task only if the template allows it.
+       if template.AllowOverrideBranchInTask && task.GitBranch != nil && 
*task.GitBranch != "" {
+               branch = *task.GitBranch
+       }
+
+       return branch
+}
+
 func (t *LocalJob) IsKilled() bool {
        return t.killed
 }
@@ -647,6 +669,17 @@
 
        t.SetStatus(task_logger.TaskRunningStatus) // It is required for local 
mode. Don't delete
 
+       // Defense in depth: reject playbook paths pointing outside the 
repository
+       // even if they were stored before validation was added.
+       if err = db.ValidatePlaybookPath(t.Template.Playbook, "template"); err 
!= nil {
+               t.Log(err.Error())
+               return
+       }
+       if err = db.ValidatePlaybookPath(t.Task.Playbook, "task"); err != nil {
+               t.Log(err.Error())
+               return
+       }
+
        environmentVariables, err := t.getEnvironmentENV()
        if err != nil {
                return
@@ -808,15 +841,7 @@
                }
        }
 
-       // Override git branch from template if set
-       if t.Template.GitBranch != nil && *t.Template.GitBranch != "" {
-               t.Repository.GitBranch = *t.Template.GitBranch
-       }
-
-       // Override git branch from task if set
-       if t.Task.GitBranch != nil && *t.Task.GitBranch != "" {
-               t.Repository.GitBranch = *t.Task.GitBranch
-       }
+       t.Repository.GitBranch = resolveGitBranch(t.Repository.GitBranch, 
t.Template, t.Task)
 
        if t.Repository.GetType() == db.RepositoryLocal {
                localPath := t.Repository.GetGitURL(true)
@@ -873,15 +898,7 @@
                }
        }
 
-       // Override git branch from template if set
-       if t.Template.GitBranch != nil && *t.Template.GitBranch != "" {
-               t.Repository.GitBranch = *t.Template.GitBranch
-       }
-
-       // Override git branch from task if set
-       if t.Task.GitBranch != nil && *t.Task.GitBranch != "" {
-               t.Repository.GitBranch = *t.Task.GitBranch
-       }
+       t.Repository.GitBranch = resolveGitBranch(t.Repository.GitBranch, 
t.Template, t.Task)
 
        if t.Repository.GetType() == db.RepositoryLocal {
                localPath := t.Repository.GetGitURL(true)

++++++ semaphore.obsinfo ++++++
--- /var/tmp/diff_new_pack.zr1Bme/_old  2026-07-06 12:37:23.073939928 +0200
+++ /var/tmp/diff_new_pack.zr1Bme/_new  2026-07-06 12:37:23.105941040 +0200
@@ -1,5 +1,5 @@
 name: semaphore
-version: 2.18.13
-mtime: 1780941527
-commit: 8a4dcf0868af718aaa5871368a3247dd622521f4
+version: 2.18.20
+mtime: 1783279293
+commit: 5d87656a680600125fe78edec1f7a0d10484b52c
 

++++++ vendor.tar.gz ++++++
/work/SRC/openSUSE:Factory/semaphore/vendor.tar.gz 
/work/SRC/openSUSE:Factory/.semaphore.new.1982/vendor.tar.gz differ: char 13, 
line 1

++++++ web-2.18.13.tar.gz -> web-2.18.20.tar.gz ++++++
/work/SRC/openSUSE:Factory/semaphore/web-2.18.13.tar.gz 
/work/SRC/openSUSE:Factory/.semaphore.new.1982/web-2.18.20.tar.gz differ: char 
31, line 1

Reply via email to