From 892f1de34fe6e5dce7f60b1af5fd3779f6248692 Mon Sep 17 00:00:00 2001 From: Guilherme Ferreira Date: Tue, 9 Jun 2026 19:43:11 -0300 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20phase=201=20=E2=80=94=20API=20core?= =?UTF-8?q?=20delta=20scanning=20(types,=20HandleCmd,=20huskykube)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/kubernetes/api.go | 33 ++++++------ api/kubernetes/huskykube.go | 14 ++++++ api/kubernetes/huskykube_test.go | 86 ++++++++++++++++++++++++++++++++ api/securitytest/securitytest.go | 4 +- api/types/types.go | 1 + api/util/handlecmd_delta_test.go | 73 +++++++++++++++++++++++++++ api/util/util.go | 6 ++- api/util/util_test.go | 10 ++-- api/util/util_wizcli_test.go | 10 ++-- 9 files changed, 209 insertions(+), 28 deletions(-) create mode 100644 api/kubernetes/huskykube_test.go create mode 100644 api/util/handlecmd_delta_test.go diff --git a/api/kubernetes/api.go b/api/kubernetes/api.go index fab10f6..7cdf1c6 100644 --- a/api/kubernetes/api.go +++ b/api/kubernetes/api.go @@ -83,6 +83,24 @@ func (k Kubernetes) CreatePod(image, cmd, podName, securityTestName string) (str ctx := goContext.Background() + envVars := []core.EnvVar{ + { + Name: "http_proxy", + Value: k.ProxyAddress, + }, + { + Name: "https_proxy", + Value: k.ProxyAddress, + }, + { + Name: "no_proxy", + Value: k.NoProxyAddresses, + }, + } + if isDeltaScanEnabled(securityTestName) { + envVars = append(envVars, core.EnvVar{Name: "HUSKYCI_DELTA_SCAN", Value: "true"}) + } + podToCreate := &core.Pod{ ObjectMeta: metav1.ObjectMeta{ Name: podName, @@ -102,20 +120,7 @@ func (k Kubernetes) CreatePod(image, cmd, podName, securityTestName string) (str "-c", cmd, }, - Env: []core.EnvVar{ - { - Name: "http_proxy", - Value: k.ProxyAddress, - }, - { - Name: "https_proxy", - Value: k.ProxyAddress, - }, - { - Name: "no_proxy", - Value: k.NoProxyAddresses, - }, - }, + Env: envVars, }, }, NodeSelector: k.NodeSelector, diff --git a/api/kubernetes/huskykube.go b/api/kubernetes/huskykube.go index 2432447..80ac73e 100644 --- a/api/kubernetes/huskykube.go +++ b/api/kubernetes/huskykube.go @@ -6,6 +6,7 @@ package kubernetes import ( "fmt" + "os" "regexp" "strings" @@ -31,6 +32,19 @@ func configureImagePath(image, tag string) (string, string) { return canonicalURL, fullContainerImage } +// getScannerConfig reads a scanner-specific configuration from environment variables. +// It looks for HUSKYCI_SCANNER__ (e.g. HUSKYCI_SCANNER_GITLEAKS_DELTA_SCAN). +func getScannerConfig(securityTestName, key string) string { + envVar := "HUSKYCI_SCANNER_" + strings.ToUpper(securityTestName) + "_" + key + return os.Getenv(envVar) +} + +// isDeltaScanEnabled returns true if delta scanning is enabled for the given security test +// via the HUSKYCI_SCANNER__DELTA_SCAN environment variable. +func isDeltaScanEnabled(securityTestName string) bool { + return strings.ToLower(getScannerConfig(securityTestName, "DELTA_SCAN")) == "true" +} + // KubeRun starts a new pod and returns its output and an error. func KubeRun(image, imageTag, cmd, securityTestName, id string, podSchedulingTimeoutInSeconds, timeOutInSeconds int) (string, string, error) { diff --git a/api/kubernetes/huskykube_test.go b/api/kubernetes/huskykube_test.go new file mode 100644 index 0000000..57d3bdd --- /dev/null +++ b/api/kubernetes/huskykube_test.go @@ -0,0 +1,86 @@ +package kubernetes + +import ( + "os" + "testing" +) + +func TestGetScannerConfig(t *testing.T) { + t.Setenv("HUSKYCI_SCANNER_GITLEAKS_DELTA_SCAN", "true") + t.Setenv("HUSKYCI_SCANNER_TFSEC_TIMEOUT", "300") + + result := getScannerConfig("gitleaks", "DELTA_SCAN") + if result != "true" { + t.Errorf("expected 'true', got '%s'", result) + } + + result = getScannerConfig("tfsec", "TIMEOUT") + if result != "300" { + t.Errorf("expected '300', got '%s'", result) + } +} + +func TestGetScannerConfig_Missing(t *testing.T) { + os.Unsetenv("HUSKYCI_SCANNER_NONEXISTENT_DELTA_SCAN") + + result := getScannerConfig("nonexistent", "DELTA_SCAN") + if result != "" { + t.Errorf("expected empty string for missing env var, got '%s'", result) + } +} + +func TestIsDeltaScanEnabled_True(t *testing.T) { + t.Setenv("HUSKYCI_SCANNER_GITLEAKS_DELTA_SCAN", "true") + + if !isDeltaScanEnabled("gitleaks") { + t.Error("expected isDeltaScanEnabled to return true when env var is 'true'") + } +} + +func TestIsDeltaScanEnabled_TrueCaseInsensitive(t *testing.T) { + t.Setenv("HUSKYCI_SCANNER_GITLEAKS_DELTA_SCAN", "TRUE") + + if !isDeltaScanEnabled("gitleaks") { + t.Error("expected isDeltaScanEnabled to return true when env var is 'TRUE' (case-insensitive)") + } +} + +func TestIsDeltaScanEnabled_TrueMixedCase(t *testing.T) { + t.Setenv("HUSKYCI_SCANNER_GITLEAKS_DELTA_SCAN", "True") + + if !isDeltaScanEnabled("gitleaks") { + t.Error("expected isDeltaScanEnabled to return true when env var is 'True'") + } +} + +func TestIsDeltaScanEnabled_False(t *testing.T) { + t.Setenv("HUSKYCI_SCANNER_GITLEAKS_DELTA_SCAN", "false") + + if isDeltaScanEnabled("gitleaks") { + t.Error("expected isDeltaScanEnabled to return false when env var is 'false'") + } +} + +func TestIsDeltaScanEnabled_Unset(t *testing.T) { + os.Unsetenv("HUSKYCI_SCANNER_GITLEAKS_DELTA_SCAN") + + if isDeltaScanEnabled("gitleaks") { + t.Error("expected isDeltaScanEnabled to return false when env var is unset") + } +} + +func TestIsDeltaScanEnabled_Empty(t *testing.T) { + t.Setenv("HUSKYCI_SCANNER_GITLEAKS_DELTA_SCAN", "") + + if isDeltaScanEnabled("gitleaks") { + t.Error("expected isDeltaScanEnabled to return false when env var is empty") + } +} + +func TestIsDeltaScanEnabled_WrongValue(t *testing.T) { + t.Setenv("HUSKYCI_SCANNER_GITLEAKS_DELTA_SCAN", "1") + + if isDeltaScanEnabled("gitleaks") { + t.Error("expected isDeltaScanEnabled to return false when env var is '1' (not 'true')") + } +} diff --git a/api/securitytest/securitytest.go b/api/securitytest/securitytest.go index d5e9215..edd19eb 100644 --- a/api/securitytest/securitytest.go +++ b/api/securitytest/securitytest.go @@ -119,7 +119,7 @@ func (scanInfo *SecTestScanInfo) Start() error { func (scanInfo *SecTestScanInfo) dockerRun(timeOutInSeconds int) error { image := scanInfo.Container.SecurityTest.Image imageTag := scanInfo.Container.SecurityTest.ImageTag - cmd := util.HandleCmd(scanInfo.URL, scanInfo.Branch, scanInfo.Container.SecurityTest.Cmd) + cmd := util.HandleCmd(scanInfo.URL, scanInfo.Branch, scanInfo.Container.SecurityTest.Cmd, "") cmd = util.HandleGitURLSubstitution(cmd) finalCMD := util.HandlePrivateSSHKey(cmd) CID, cOutput, err := huskydocker.DockerRun(image, imageTag, finalCMD, scanInfo.DockerHost, timeOutInSeconds) @@ -134,7 +134,7 @@ func (scanInfo *SecTestScanInfo) dockerRun(timeOutInSeconds int) error { func (scanInfo *SecTestScanInfo) kubeRun(timeOutInSeconds int) error { image := scanInfo.Container.SecurityTest.Image imageTag := scanInfo.Container.SecurityTest.ImageTag - cmd := util.HandleCmd(scanInfo.URL, scanInfo.Branch, scanInfo.Container.SecurityTest.Cmd) + cmd := util.HandleCmd(scanInfo.URL, scanInfo.Branch, scanInfo.Container.SecurityTest.Cmd, "") cmd = util.HandleGitURLSubstitution(cmd) finalCMD := util.HandlePrivateSSHKey(cmd) podSchedulingTimeoutInSeconds := apiContext.APIConfiguration.KubernetesConfig.PodSchedulingTimeout diff --git a/api/types/types.go b/api/types/types.go index f9f9e58..dec3f53 100644 --- a/api/types/types.go +++ b/api/types/types.go @@ -14,6 +14,7 @@ type Repository struct { Branch string `json:"repositoryBranch"` LanguageExclusions map[string]bool `json:"languageExclusions"` CreatedAt time.Time `bson:"createdAt" json:"createdAt"` + ChangedFiles string `json:"changedFiles"` } // SecurityTest is the struct that stores all data from the security tests to be executed. diff --git a/api/util/handlecmd_delta_test.go b/api/util/handlecmd_delta_test.go new file mode 100644 index 0000000..6a5fa0a --- /dev/null +++ b/api/util/handlecmd_delta_test.go @@ -0,0 +1,73 @@ +package util + +import ( + "strings" + "testing" +) + +// TestHandleCmd_ChangedFiles_Replacement verifies %CHANGED_FILES% is replaced +// with the provided changedFiles string. +func TestHandleCmd_ChangedFiles_Replacement(t *testing.T) { + changedFiles := "src/main.go\nsrc/utils.go\nREADME.md" + cmd := "echo '%CHANGED_FILES%' > /tmp/delta.txt" + result := HandleCmd("https://github.com/org/repo.git", "main", cmd, changedFiles) + + if !strings.Contains(result, "src/main.go") { + t.Errorf("expected result to contain 'src/main.go', got: %s", result) + } + if !strings.Contains(result, "src/utils.go") { + t.Errorf("expected result to contain 'src/utils.go', got: %s", result) + } + if strings.Contains(result, "%CHANGED_FILES%") { + t.Errorf("placeholder %%CHANGED_FILES%% was not replaced, got: %s", result) + } +} + +// TestHandleCmd_ChangedFiles_Empty verifies that an empty changedFiles results +// in the placeholder being replaced with an empty string. +func TestHandleCmd_ChangedFiles_Empty(t *testing.T) { + cmd := "echo '%CHANGED_FILES%' > /tmp/delta.txt" + result := HandleCmd("https://github.com/org/repo.git", "main", cmd, "") + + if strings.Contains(result, "%CHANGED_FILES%") { + t.Errorf("placeholder %%CHANGED_FILES%% was not replaced with empty string, got: %s", result) + } +} + +// TestHandleCmd_ChangedFiles_UnchangedPlaceholders verifies that existing +// placeholders (%GIT_REPO%, %GIT_BRANCH%) still work when changedFiles is provided. +func TestHandleCmd_ChangedFiles_UnchangedPlaceholders(t *testing.T) { + changedFiles := "src/main.go\nREADME.md" + cmd := "git clone -b %GIT_BRANCH% --single-branch %GIT_REPO% code && echo '%CHANGED_FILES%' > delta.txt" + result := HandleCmd("https://github.com/org/repo.git", "main", cmd, changedFiles) + + if strings.Contains(result, "%GIT_REPO%") { + t.Errorf("placeholder %%GIT_REPO%% was not replaced, got: %s", result) + } + if strings.Contains(result, "%GIT_BRANCH%") { + t.Errorf("placeholder %%GIT_BRANCH%% was not replaced, got: %s", result) + } + if strings.Contains(result, "%CHANGED_FILES%") { + t.Errorf("placeholder %%CHANGED_FILES%% was not replaced, got: %s", result) + } + if !strings.Contains(result, "https://github.com/org/repo.git") { + t.Errorf("expected result to contain repo URL, got: %s", result) + } + if !strings.Contains(result, "src/main.go") { + t.Errorf("expected result to contain 'src/main.go', got: %s", result) + } +} + +// TestHandleCmd_ChangedFiles_NoPlaceholder verifies that a command without +// %CHANGED_FILES% is not altered when changedFiles is provided. +func TestHandleCmd_ChangedFiles_NoPlaceholder(t *testing.T) { + cmd := "git clone -b %GIT_BRANCH% --single-branch %GIT_REPO% code" + result := HandleCmd("https://github.com/org/repo.git", "main", cmd, "shouldNotAppear") + + if strings.Contains(result, "shouldNotAppear") { + t.Errorf("changedFiles content unexpectedly appeared in result: %s", result) + } + if strings.Contains(result, "%GIT_REPO%") || strings.Contains(result, "%GIT_BRANCH%") { + t.Errorf("git placeholders were not replaced, got: %s", result) + } +} diff --git a/api/util/util.go b/api/util/util.go index 54bca53..5b75494 100644 --- a/api/util/util.go +++ b/api/util/util.go @@ -33,13 +33,15 @@ const logActionReceiveRequest = "ReceiveRequest" // HandleCmd will extract %GIT_REPO%, %GIT_BRANCH% from cmd and replace it with the proper repository URL. // Also replaces %WIZ_CLIENT_ID% and %WIZ_CLIENT_SECRET% with values from environment variables. -func HandleCmd(repositoryURL, repositoryBranch, cmd string) string { +// The changedFiles parameter replaces %CHANGED_FILES% (use empty string when delta scanning is not active). +func HandleCmd(repositoryURL, repositoryBranch, cmd, changedFiles string) string { if repositoryURL != "" && repositoryBranch != "" && cmd != "" { replace1 := strings.ReplaceAll(cmd, "%GIT_REPO%", repositoryURL) replace2 := strings.ReplaceAll(replace1, "%GIT_BRANCH%", repositoryBranch) replace3 := strings.ReplaceAll(replace2, "%WIZ_CLIENT_ID%", os.Getenv("HUSKYCI_API_WIZ_CLIENT_ID")) replace4 := strings.ReplaceAll(replace3, "%WIZ_CLIENT_SECRET%", os.Getenv("HUSKYCI_API_WIZ_CLIENT_SECRET")) - return replace4 + replace5 := strings.ReplaceAll(replace4, "%CHANGED_FILES%", changedFiles) + return replace5 } return "" } diff --git a/api/util/util_test.go b/api/util/util_test.go index 7755695..b3f5c4c 100644 --- a/api/util/util_test.go +++ b/api/util/util_test.go @@ -29,29 +29,29 @@ var _ = Describe("Util", func() { Context("When inputRepositoryURL, inputRepositoryBranch and inputCMD are not empty", func() { It("Should return a string based on these params", func() { - Expect(util.HandleCmd(inputRepositoryURL, inputRepositoryBranch, inputCMD)).To(Equal(expected)) + Expect(util.HandleCmd(inputRepositoryURL, inputRepositoryBranch, inputCMD, "")).To(Equal(expected)) }) }) Context("When inputRepositoryURL is empty", func() { It("Should return an empty string.", func() { - Expect(util.HandleCmd("", inputRepositoryBranch, inputCMD)).To(Equal("")) + Expect(util.HandleCmd("", inputRepositoryBranch, inputCMD, "")).To(Equal("")) }) }) Context("When inputRepositoryBranch is empty", func() { It("Should return an empty string.", func() { - Expect(util.HandleCmd(inputRepositoryURL, "", inputCMD)).To(Equal("")) + Expect(util.HandleCmd(inputRepositoryURL, "", inputCMD, "")).To(Equal("")) }) }) Context("When inputCMD is empty", func() { It("Should return an empty string.", func() { - Expect(util.HandleCmd(inputRepositoryURL, inputRepositoryBranch, "")).To(Equal("")) + Expect(util.HandleCmd(inputRepositoryURL, inputRepositoryBranch, "", "")).To(Equal("")) }) }) Context("When branch name contains shell metacharacters like parentheses", func() { It("Should substitute correctly when values are quoted in the template", func() { quotedCmd := `git clone -b "%GIT_BRANCH%" --single-branch --depth 1 "%GIT_REPO%" code` branchWithParens := "feat(ideia-intelligence)--add-service" - result := util.HandleCmd("git@github.com:org/repo.git", branchWithParens, quotedCmd) + result := util.HandleCmd("git@github.com:org/repo.git", branchWithParens, quotedCmd, "") expected := `git clone -b "feat(ideia-intelligence)--add-service" --single-branch --depth 1 "git@github.com:org/repo.git" code` Expect(result).To(Equal(expected)) }) diff --git a/api/util/util_wizcli_test.go b/api/util/util_wizcli_test.go index 968fb9e..3cdc4e1 100644 --- a/api/util/util_wizcli_test.go +++ b/api/util/util_wizcli_test.go @@ -12,7 +12,7 @@ func TestHandleCmd_WizClientIDSubstitution(t *testing.T) { t.Setenv("HUSKYCI_API_WIZ_CLIENT_SECRET", "") cmd := "wizcli auth --client-id %WIZ_CLIENT_ID%" - result := HandleCmd("https://github.com/org/repo.git", "main", cmd) + result := HandleCmd("https://github.com/org/repo.git", "main", cmd, "") if !strings.Contains(result, "testid") { t.Errorf("expected result to contain 'testid', got: %s", result) @@ -29,7 +29,7 @@ func TestHandleCmd_WizClientSecretSubstitution(t *testing.T) { t.Setenv("HUSKYCI_API_WIZ_CLIENT_SECRET", "testsecret") cmd := "wizcli auth --client-secret %WIZ_CLIENT_SECRET%" - result := HandleCmd("https://github.com/org/repo.git", "main", cmd) + result := HandleCmd("https://github.com/org/repo.git", "main", cmd, "") if !strings.Contains(result, "testsecret") { t.Errorf("expected result to contain 'testsecret', got: %s", result) @@ -46,7 +46,7 @@ func TestHandleCmd_WizPlaceholders_BothPresent(t *testing.T) { t.Setenv("HUSKYCI_API_WIZ_CLIENT_SECRET", "myClientSecret") cmd := "wizcli auth --client-id %WIZ_CLIENT_ID% --client-secret %WIZ_CLIENT_SECRET%" - result := HandleCmd("https://github.com/org/repo.git", "main", cmd) + result := HandleCmd("https://github.com/org/repo.git", "main", cmd, "") if !strings.Contains(result, "myClientID") { t.Errorf("expected result to contain 'myClientID', got: %s", result) @@ -66,7 +66,7 @@ func TestHandleCmd_WizPlaceholders_EmptyEnv(t *testing.T) { t.Setenv("HUSKYCI_API_WIZ_CLIENT_SECRET", "") cmd := "wizcli auth --client-id %WIZ_CLIENT_ID% --client-secret %WIZ_CLIENT_SECRET%" - result := HandleCmd("https://github.com/org/repo.git", "main", cmd) + result := HandleCmd("https://github.com/org/repo.git", "main", cmd, "") if strings.Contains(result, "%WIZ_CLIENT_ID%") { t.Errorf("placeholder %%WIZ_CLIENT_ID%% was not replaced with empty string, got: %s", result) @@ -83,7 +83,7 @@ func TestHandleCmd_WizPlaceholders_NotAffectedByOtherCmds(t *testing.T) { t.Setenv("HUSKYCI_API_WIZ_CLIENT_SECRET", "shouldNotAppear") cmd := "git clone -b %GIT_BRANCH% --single-branch %GIT_REPO% code" - result := HandleCmd("https://github.com/org/repo.git", "main", cmd) + result := HandleCmd("https://github.com/org/repo.git", "main", cmd, "") if strings.Contains(result, "shouldNotAppear") { t.Errorf("Wiz env values unexpectedly appeared in non-Wiz command: %s", result) From 3df89dbcd4811ad1aa5811cbe740af7844c3aac7 Mon Sep 17 00:00:00 2001 From: Guilherme Ferreira Date: Tue, 9 Jun 2026 19:50:22 -0300 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20phase=202=20=E2=80=94=20delta=20sca?= =?UTF-8?q?nning=20cmd=20scripts=20(7=20scanners=20sparse-checkout)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/config.yaml | 304 +++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 264 insertions(+), 40 deletions(-) diff --git a/api/config.yaml b/api/config.yaml index 6d3c1d7..5e25702 100644 --- a/api/config.yaml +++ b/api/config.yaml @@ -76,15 +76,37 @@ gosec: done fi cd src - GIT_TERMINAL_PROMPT=0 git clone -b "%GIT_BRANCH%" --single-branch --depth 1 "%GIT_REPO%" code --quiet 2> /tmp/errorGitCloneGosec - if [ $? -eq 0 ]; then - cd code - touch results.json - $(which gosec) -quiet -fmt=json -nosec-tag nohusky -log=log.txt -out=results.json ./... 2> /dev/null - jq -j -M -c . results.json + if [ "$HUSKYCI_DELTA_SCAN" = "true" ] && [ -n "%CHANGED_FILES%" ]; then + GIT_TERMINAL_PROMPT=0 git clone --no-checkout -b "%GIT_BRANCH%" --single-branch --depth 1 "%GIT_REPO%" code --quiet 2>/tmp/errorGitClone + if [ $? -eq 0 ]; then + cd code + git sparse-checkout init --cone + echo "%CHANGED_FILES%" | xargs git sparse-checkout set + git checkout 2>/tmp/errorSparseCheckout + if [ $? -ne 0 ]; then + echo "ERROR_SPARSE_CHECKOUT" + cat /tmp/errorSparseCheckout + exit 1 + fi + touch results.json + $(which gosec) -quiet -fmt=json -nosec-tag nohusky -log=log.txt -out=results.json ./... 2> /dev/null + jq -j -M -c . results.json + else + echo "ERROR_CLONING" + cat /tmp/errorGitClone + exit 1 + fi else - echo "ERROR_CLONING" - cat /tmp/errorGitCloneGosec + GIT_TERMINAL_PROMPT=0 git clone -b "%GIT_BRANCH%" --single-branch --depth 1 "%GIT_REPO%" code --quiet 2> /tmp/errorGitCloneGosec + if [ $? -eq 0 ]; then + cd code + touch results.json + $(which gosec) -quiet -fmt=json -nosec-tag nohusky -log=log.txt -out=results.json ./... 2> /dev/null + jq -j -M -c . results.json + else + echo "ERROR_CLONING" + cat /tmp/errorGitCloneGosec + fi fi type: Language language: Go @@ -101,16 +123,39 @@ bandit: chmod 600 ~/.ssh/huskyci_id_rsa && echo "IdentityFile ~/.ssh/huskyci_id_rsa" >> /etc/ssh/ssh_config && echo "StrictHostKeyChecking no" >> /etc/ssh/ssh_config && - GIT_TERMINAL_PROMPT=0 git clone -b "%GIT_BRANCH%" --single-branch --depth 1 "%GIT_REPO%" code --quiet 2> /tmp/errorGitCloneBandit - if [ $? -eq 0 ]; then - cd code - chmod +x /usr/local/bin/husky-file-ignore.sh - husky-file-ignore.sh 2> /tmp/errorBanditIgnoreScript 1> /dev/null - bandit -r . -f json 2> /dev/null > results.json - jq -j -M -c . results.json + if [ "$HUSKYCI_DELTA_SCAN" = "true" ] && [ -n "%CHANGED_FILES%" ]; then + GIT_TERMINAL_PROMPT=0 git clone --no-checkout -b "%GIT_BRANCH%" --single-branch --depth 1 "%GIT_REPO%" code --quiet 2>/tmp/errorGitClone + if [ $? -eq 0 ]; then + cd code + git sparse-checkout init --cone + echo "%CHANGED_FILES%" | xargs git sparse-checkout set + git checkout 2>/tmp/errorSparseCheckout + if [ $? -ne 0 ]; then + echo "ERROR_SPARSE_CHECKOUT" + cat /tmp/errorSparseCheckout + exit 1 + fi + chmod +x /usr/local/bin/husky-file-ignore.sh + husky-file-ignore.sh 2> /tmp/errorBanditIgnoreScript 1> /dev/null + bandit -r . -f json 2> /dev/null > results.json + jq -j -M -c . results.json + else + echo "ERROR_CLONING" + cat /tmp/errorGitClone + exit 1 + fi else - echo "ERROR_CLONING" - cat /tmp/errorGitCloneBandit + GIT_TERMINAL_PROMPT=0 git clone -b "%GIT_BRANCH%" --single-branch --depth 1 "%GIT_REPO%" code --quiet 2> /tmp/errorGitCloneBandit + if [ $? -eq 0 ]; then + cd code + chmod +x /usr/local/bin/husky-file-ignore.sh + husky-file-ignore.sh 2> /tmp/errorBanditIgnoreScript 1> /dev/null + bandit -r . -f json 2> /dev/null > results.json + jq -j -M -c . results.json + else + echo "ERROR_CLONING" + cat /tmp/errorGitCloneBandit + fi fi type: Language language: Python @@ -127,27 +172,63 @@ brakeman: chmod 600 ~/.ssh/huskyci_id_rsa && echo "IdentityFile ~/.ssh/huskyci_id_rsa" >> /etc/ssh/ssh_config && echo "StrictHostKeyChecking no" >> /etc/ssh/ssh_config && - GIT_TERMINAL_PROMPT=0 git clone -b "%GIT_BRANCH%" --single-branch --depth 1 "%GIT_REPO%" code --quiet 2> /tmp/errorGitCloneBrakeman - if [ $? -eq 0 ]; then - if [ -d /code/app ]; then - if [ -f /code/brakeman.ignore ]; then - brakeman -q -i /code/brakeman.ignore -o results.json /code + if [ "$HUSKYCI_DELTA_SCAN" = "true" ] && [ -n "%CHANGED_FILES%" ]; then + GIT_TERMINAL_PROMPT=0 git clone --no-checkout -b "%GIT_BRANCH%" --single-branch --depth 1 "%GIT_REPO%" code --quiet 2>/tmp/errorGitClone + if [ $? -eq 0 ]; then + cd code + git sparse-checkout init --cone + echo "%CHANGED_FILES%" | xargs git sparse-checkout set + git checkout 2>/tmp/errorSparseCheckout + if [ $? -ne 0 ]; then + echo "ERROR_SPARSE_CHECKOUT" + cat /tmp/errorSparseCheckout + exit 1 + fi + cd .. + if [ -d /code/app ]; then + if [ -f /code/brakeman.ignore ]; then + brakeman -q -i /code/brakeman.ignore -o results.json /code + else + brakeman -q -o results.json /code + fi + jq -j -M -c . results.json else - brakeman -q -o results.json /code + mv code app + if [ -f /app/brakeman.ignore ]; then + brakeman -q -i /app/brakeman.ignore -o results.json . + else + brakeman -q -o results.json . + fi + jq -j -M -c . results.json fi - jq -j -M -c . results.json else - mv code app - if [ -f /app/brakeman.ignore ]; then - brakeman -q -i /app/brakeman.ignore -o results.json . + echo "ERROR_CLONING" + cat /tmp/errorGitClone + exit 1 + fi + else + GIT_TERMINAL_PROMPT=0 git clone -b "%GIT_BRANCH%" --single-branch --depth 1 "%GIT_REPO%" code --quiet 2> /tmp/errorGitCloneBrakeman + if [ $? -eq 0 ]; then + if [ -d /code/app ]; then + if [ -f /code/brakeman.ignore ]; then + brakeman -q -i /code/brakeman.ignore -o results.json /code + else + brakeman -q -o results.json /code + fi + jq -j -M -c . results.json else - brakeman -q -o results.json . + mv code app + if [ -f /app/brakeman.ignore ]; then + brakeman -q -i /app/brakeman.ignore -o results.json . + else + brakeman -q -o results.json . + fi + jq -j -M -c . results.json fi - jq -j -M -c . results.json + else + echo "ERROR_CLONING" + cat /tmp/errorGitCloneBrakeman fi - else - echo "ERROR_CLONING" - cat /tmp/errorGitCloneBrakeman fi type: Language language: Ruby @@ -379,8 +460,19 @@ gitleaks: chmod 600 ~/.ssh/huskyci_id_rsa && echo "IdentityFile ~/.ssh/huskyci_id_rsa" >> /etc/ssh/ssh_config && echo "StrictHostKeyChecking no" >> /etc/ssh/ssh_config && - GIT_TERMINAL_PROMPT=0 git clone -b "%GIT_BRANCH%" --single-branch --depth 1 "%GIT_REPO%" code --quiet 2> /tmp/errorGitCloneGitleaks - if [ $? -eq 0 ]; then + if [ "$HUSKYCI_DELTA_SCAN" = "true" ] && [ -n "%CHANGED_FILES%" ]; then + GIT_TERMINAL_PROMPT=0 git clone --no-checkout -b "%GIT_BRANCH%" --single-branch --depth 1 "%GIT_REPO%" code --quiet 2>/tmp/errorGitClone + if [ $? -eq 0 ]; then + cd code + git sparse-checkout init --cone + echo "%CHANGED_FILES%" | xargs git sparse-checkout set + git checkout 2>/tmp/errorSparseCheckout + if [ $? -ne 0 ]; then + echo "ERROR_SPARSE_CHECKOUT" + cat /tmp/errorSparseCheckout + exit 1 + fi + cd .. touch /tmp/results.json $(which gitleaks) dir ./code -f json -r /tmp/results.json --no-banner -l error 2> /tmp/errorGitleaks gl_ex=$? @@ -393,9 +485,30 @@ gitleaks: echo 'ERROR_RUNNING_GITLEAKS' cat /tmp/errorGitleaks fi + else + echo "ERROR_CLONING" + cat /tmp/errorGitClone + exit 1 + fi else + GIT_TERMINAL_PROMPT=0 git clone -b "%GIT_BRANCH%" --single-branch --depth 1 "%GIT_REPO%" code --quiet 2> /tmp/errorGitCloneGitleaks + if [ $? -eq 0 ]; then + touch /tmp/results.json + $(which gitleaks) dir ./code -f json -r /tmp/results.json --no-banner -l error 2> /tmp/errorGitleaks + gl_ex=$? + if [ $gl_ex -eq 124 ] || [ $gl_ex -eq 143 ]; then + echo 'ERROR_TIMEOUT_GITLEAKS' + cat /tmp/errorGitleaks + elif jq -e . /tmp/results.json >/dev/null 2>&1; then + jq -j -M -c . /tmp/results.json + else + echo 'ERROR_RUNNING_GITLEAKS' + cat /tmp/errorGitleaks + fi + else echo "ERROR_CLONING" cat /tmp/errorGitCloneGitleaks + fi fi type: Generic default: true @@ -467,8 +580,19 @@ wizcli_secrets: chmod 600 ~/.ssh/huskyci_id_rsa && echo "IdentityFile ~/.ssh/huskyci_id_rsa" >> /etc/ssh/ssh_config && echo "StrictHostKeyChecking no" >> /etc/ssh/ssh_config && - GIT_TERMINAL_PROMPT=0 git clone -b "%GIT_BRANCH%" --single-branch --depth 1 "%GIT_REPO%" code --quiet 2>/tmp/errorGitCloneWiz - if [ $? -eq 0 ]; then + if [ "$HUSKYCI_DELTA_SCAN" = "true" ] && [ -n "%CHANGED_FILES%" ]; then + GIT_TERMINAL_PROMPT=0 git clone --no-checkout -b "%GIT_BRANCH%" --single-branch --depth 1 "%GIT_REPO%" code --quiet 2>/tmp/errorGitClone + if [ $? -eq 0 ]; then + cd code + git sparse-checkout init --cone + echo "%CHANGED_FILES%" | xargs git sparse-checkout set + git checkout 2>/tmp/errorSparseCheckout + if [ $? -ne 0 ]; then + echo "ERROR_SPARSE_CHECKOUT" + cat /tmp/errorSparseCheckout + exit 1 + fi + cd .. wizcli auth --id '%WIZ_CLIENT_ID%' --secret '%WIZ_CLIENT_SECRET%' > /tmp/errorWizAuth 2>&1 if [ $? -ne 0 ]; then echo 'ERROR_AUTH_WIZCLI' @@ -486,9 +610,35 @@ wizcli_secrets: cat /tmp/wizResult.json fi fi + else + echo "ERROR_CLONING" + cat /tmp/errorGitClone + exit 1 + fi else + GIT_TERMINAL_PROMPT=0 git clone -b "%GIT_BRANCH%" --single-branch --depth 1 "%GIT_REPO%" code --quiet 2>/tmp/errorGitCloneWiz + if [ $? -eq 0 ]; then + wizcli auth --id '%WIZ_CLIENT_ID%' --secret '%WIZ_CLIENT_SECRET%' > /tmp/errorWizAuth 2>&1 + if [ $? -ne 0 ]; then + echo 'ERROR_AUTH_WIZCLI' + cat /tmp/errorWizAuth + else + wizcli scan dir ./code \ + --disabled-scanners Vulnerability,Misconfiguration,SAST,Malware,AIModels,SoftwareSupplyChain \ + --by-policy-hits=DISABLED \ + --json-output-file=/tmp/wizResult.json > /dev/null 2> /tmp/errorWizScan + SCAN_RC=$? + if [ $SCAN_RC -ge 2 ]; then + echo "ERROR_RUNNING_WIZCLI_SCAN" + cat /tmp/errorWizScan + else + cat /tmp/wizResult.json + fi + fi + else echo "ERROR_CLONING" cat /tmp/errorGitCloneWiz + fi fi type: Generic default: true @@ -504,8 +654,19 @@ wizcli_iac: chmod 600 ~/.ssh/huskyci_id_rsa && echo "IdentityFile ~/.ssh/huskyci_id_rsa" >> /etc/ssh/ssh_config && echo "StrictHostKeyChecking no" >> /etc/ssh/ssh_config && - GIT_TERMINAL_PROMPT=0 git clone -b "%GIT_BRANCH%" --single-branch --depth 1 "%GIT_REPO%" code --quiet 2>/tmp/errorGitCloneWiz - if [ $? -eq 0 ]; then + if [ "$HUSKYCI_DELTA_SCAN" = "true" ] && [ -n "%CHANGED_FILES%" ]; then + GIT_TERMINAL_PROMPT=0 git clone --no-checkout -b "%GIT_BRANCH%" --single-branch --depth 1 "%GIT_REPO%" code --quiet 2>/tmp/errorGitClone + if [ $? -eq 0 ]; then + cd code + git sparse-checkout init --cone + echo "%CHANGED_FILES%" | xargs git sparse-checkout set + git checkout 2>/tmp/errorSparseCheckout + if [ $? -ne 0 ]; then + echo "ERROR_SPARSE_CHECKOUT" + cat /tmp/errorSparseCheckout + exit 1 + fi + cd .. wizcli auth --id '%WIZ_CLIENT_ID%' --secret '%WIZ_CLIENT_SECRET%' > /tmp/errorWizAuth 2>&1 if [ $? -ne 0 ]; then echo 'ERROR_AUTH_WIZCLI' @@ -523,9 +684,35 @@ wizcli_iac: cat /tmp/wizResult.json fi fi + else + echo "ERROR_CLONING" + cat /tmp/errorGitClone + exit 1 + fi else + GIT_TERMINAL_PROMPT=0 git clone -b "%GIT_BRANCH%" --single-branch --depth 1 "%GIT_REPO%" code --quiet 2>/tmp/errorGitCloneWiz + if [ $? -eq 0 ]; then + wizcli auth --id '%WIZ_CLIENT_ID%' --secret '%WIZ_CLIENT_SECRET%' > /tmp/errorWizAuth 2>&1 + if [ $? -ne 0 ]; then + echo 'ERROR_AUTH_WIZCLI' + cat /tmp/errorWizAuth + else + wizcli scan dir ./code \ + --disabled-scanners Vulnerability,Secret,SensitiveData,SAST,Malware,AIModels,SoftwareSupplyChain \ + --by-policy-hits=DISABLED \ + --json-output-file=/tmp/wizResult.json > /dev/null 2> /tmp/errorWizScan + SCAN_RC=$? + if [ $SCAN_RC -ge 2 ]; then + echo "ERROR_RUNNING_WIZCLI_SCAN" + cat /tmp/errorWizScan + else + cat /tmp/wizResult.json + fi + fi + else echo "ERROR_CLONING" cat /tmp/errorGitCloneWiz + fi fi type: Generic default: true @@ -541,8 +728,19 @@ wizcli_sast: chmod 600 ~/.ssh/huskyci_id_rsa && echo "IdentityFile ~/.ssh/huskyci_id_rsa" >> /etc/ssh/ssh_config && echo "StrictHostKeyChecking no" >> /etc/ssh/ssh_config && - GIT_TERMINAL_PROMPT=0 git clone -b "%GIT_BRANCH%" --single-branch --depth 1 "%GIT_REPO%" code --quiet 2>/tmp/errorGitCloneWiz - if [ $? -eq 0 ]; then + if [ "$HUSKYCI_DELTA_SCAN" = "true" ] && [ -n "%CHANGED_FILES%" ]; then + GIT_TERMINAL_PROMPT=0 git clone --no-checkout -b "%GIT_BRANCH%" --single-branch --depth 1 "%GIT_REPO%" code --quiet 2>/tmp/errorGitClone + if [ $? -eq 0 ]; then + cd code + git sparse-checkout init --cone + echo "%CHANGED_FILES%" | xargs git sparse-checkout set + git checkout 2>/tmp/errorSparseCheckout + if [ $? -ne 0 ]; then + echo "ERROR_SPARSE_CHECKOUT" + cat /tmp/errorSparseCheckout + exit 1 + fi + cd .. wizcli auth --id '%WIZ_CLIENT_ID%' --secret '%WIZ_CLIENT_SECRET%' > /tmp/errorWizAuth 2>&1 if [ $? -ne 0 ]; then echo 'ERROR_AUTH_WIZCLI' @@ -560,9 +758,35 @@ wizcli_sast: cat /tmp/wizResult.json fi fi + else + echo "ERROR_CLONING" + cat /tmp/errorGitClone + exit 1 + fi else + GIT_TERMINAL_PROMPT=0 git clone -b "%GIT_BRANCH%" --single-branch --depth 1 "%GIT_REPO%" code --quiet 2>/tmp/errorGitCloneWiz + if [ $? -eq 0 ]; then + wizcli auth --id '%WIZ_CLIENT_ID%' --secret '%WIZ_CLIENT_SECRET%' > /tmp/errorWizAuth 2>&1 + if [ $? -ne 0 ]; then + echo 'ERROR_AUTH_WIZCLI' + cat /tmp/errorWizAuth + else + wizcli scan dir ./code \ + --disabled-scanners Vulnerability,Secret,SensitiveData,Misconfiguration,Malware,AIModels,SoftwareSupplyChain \ + --by-policy-hits=DISABLED \ + --json-output-file=/tmp/wizResult.json > /dev/null 2> /tmp/errorWizScan + SCAN_RC=$? + if [ $SCAN_RC -ge 2 ]; then + echo "ERROR_RUNNING_WIZCLI_SCAN" + cat /tmp/errorWizScan + else + cat /tmp/wizResult.json + fi + fi + else echo "ERROR_CLONING" cat /tmp/errorGitCloneWiz + fi fi type: Generic default: true From ee7ae3131ed35245d28cec1c7db6cb8793d15e63 Mon Sep 17 00:00:00 2001 From: Guilherme Ferreira Date: Tue, 9 Jun 2026 19:58:23 -0300 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20phase=203=20=E2=80=94=20client=20Ch?= =?UTF-8?q?angedFiles=20pipeline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/analysis/analysis.go | 1 + client/analysis/analysis_test.go | 79 ++++++++++++++++++++++++++++++++ client/config/config.go | 4 ++ client/types/types.go | 1 + 4 files changed, 85 insertions(+) create mode 100644 client/analysis/analysis_test.go diff --git a/client/analysis/analysis.go b/client/analysis/analysis.go index 071264a..4f28f40 100644 --- a/client/analysis/analysis.go +++ b/client/analysis/analysis.go @@ -28,6 +28,7 @@ func StartAnalysis() (string, error) { RepositoryURL: config.RepositoryURL, RepositoryBranch: config.RepositoryBranch, LanguageExclusions: config.LanguageExclusions, + ChangedFiles: config.ChangedFiles, } marshalPayload, err := json.Marshal(requestPayload) diff --git a/client/analysis/analysis_test.go b/client/analysis/analysis_test.go new file mode 100644 index 0000000..35c4966 --- /dev/null +++ b/client/analysis/analysis_test.go @@ -0,0 +1,79 @@ +package analysis + +import ( + "encoding/json" + "os" + "testing" + + "github.com/githubanotaai/huskyci-api/client/config" + "github.com/githubanotaai/huskyci-api/client/types" +) + +func TestChangedFilesInPayload(t *testing.T) { + // Set the env var + os.Setenv("HUSKYCI_CLIENT_CHANGED_FILES", "src/main.go\nsrc/utils.go") + defer os.Unsetenv("HUSKYCI_CLIENT_CHANGED_FILES") + + config.SetConfigs() + + payload := types.JSONPayload{ + RepositoryURL: config.RepositoryURL, + RepositoryBranch: config.RepositoryBranch, + LanguageExclusions: config.LanguageExclusions, + ChangedFiles: config.ChangedFiles, + } + + marshalled, err := json.Marshal(payload) + if err != nil { + t.Fatalf("failed to marshal payload: %v", err) + } + + var result map[string]interface{} + err = json.Unmarshal(marshalled, &result) + if err != nil { + t.Fatalf("failed to unmarshal payload: %v", err) + } + + changedFiles, ok := result["changedFiles"].(string) + if !ok { + t.Fatal("changedFiles not found in payload") + } + + if changedFiles != "src/main.go\nsrc/utils.go" { + t.Fatalf("expected changedFiles to be 'src/main.go\\nsrc/utils.go', got '%s'", changedFiles) + } +} + +func TestChangedFilesEmpty(t *testing.T) { + // Ensure env var is not set + os.Unsetenv("HUSKYCI_CLIENT_CHANGED_FILES") + + config.SetConfigs() + + payload := types.JSONPayload{ + RepositoryURL: config.RepositoryURL, + RepositoryBranch: config.RepositoryBranch, + LanguageExclusions: config.LanguageExclusions, + ChangedFiles: config.ChangedFiles, + } + + marshalled, err := json.Marshal(payload) + if err != nil { + t.Fatalf("failed to marshal payload: %v", err) + } + + var result map[string]interface{} + err = json.Unmarshal(marshalled, &result) + if err != nil { + t.Fatalf("failed to unmarshal payload: %v", err) + } + + changedFiles, ok := result["changedFiles"].(string) + if !ok { + t.Fatal("changedFiles not found in payload") + } + + if changedFiles != "" { + t.Fatalf("expected changedFiles to be empty, got '%s'", changedFiles) + } +} diff --git a/client/config/config.go b/client/config/config.go index 3c9e11a..00663a5 100644 --- a/client/config/config.go +++ b/client/config/config.go @@ -26,6 +26,9 @@ var LanguageExclusions map[string]bool // HuskyUseTLS stores if huskyCI is to use an HTTPS connection. var HuskyUseTLS bool +// ChangedFiles stores the list of changed files for delta scanning. +var ChangedFiles string + // SetConfigs sets all configuration needed to start the client. func SetConfigs() { RepositoryURL = os.Getenv(`HUSKYCI_CLIENT_REPO_URL`) @@ -41,6 +44,7 @@ func SetConfigs() { } HuskyToken = os.Getenv(`HUSKYCI_CLIENT_TOKEN`) HuskyUseTLS = getUseTLS() + ChangedFiles = os.Getenv(`HUSKYCI_CLIENT_CHANGED_FILES`) } // CheckEnvVars checks if all environment vars are set. diff --git a/client/types/types.go b/client/types/types.go index 6cdce5c..4e97d1e 100644 --- a/client/types/types.go +++ b/client/types/types.go @@ -24,6 +24,7 @@ type JSONPayload struct { RepositoryURL string `json:"repositoryURL"` RepositoryBranch string `json:"repositoryBranch"` LanguageExclusions map[string]bool `json:"languageExclusions"` + ChangedFiles string `json:"changedFiles"` } // Target is the struct that represents HuskyCI API target From b3beb257542d0dffb8bee7084f2b8083b63e9190 Mon Sep 17 00:00:00 2001 From: Guilherme Ferreira Date: Tue, 9 Jun 2026 20:03:04 -0300 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20phase=203.5=20=E2=80=94=20thread=20?= =?UTF-8?q?ChangedFiles=20through=20API=20to=20HandleCmd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/analysis/analysis.go | 2 +- api/securitytest/run.go | 4 ++-- api/securitytest/run_test.go | 8 ++++---- api/securitytest/runner.go | 12 ++++++------ api/securitytest/securitytest.go | 8 +++++--- 5 files changed, 18 insertions(+), 16 deletions(-) diff --git a/api/analysis/analysis.go b/api/analysis/analysis.go index eddeeb9..053a19d 100644 --- a/api/analysis/analysis.go +++ b/api/analysis/analysis.go @@ -77,7 +77,7 @@ func StartAnalysis(RID string, repository types.Repository) { log.Info("StartAnalysisTest", apiHost, 2012, RID) - if err := enryScan.New(RID, repository.URL, repository.Branch, enryScan.SecurityTestName, repository.LanguageExclusions, apiHost); err != nil { + if err := enryScan.New(RID, repository.URL, repository.Branch, enryScan.SecurityTestName, repository.LanguageExclusions, repository.ChangedFiles, apiHost); err != nil { log.Error(logActionStart, logInfoAnalysis, 2011, err) return } diff --git a/api/securitytest/run.go b/api/securitytest/run.go index eebf571..19e5feb 100644 --- a/api/securitytest/run.go +++ b/api/securitytest/run.go @@ -104,7 +104,7 @@ func (results *RunAllInfo) runGenericScans(ctx context.Context, enryScan SecTest default: } - scan, err := runner.newScan(enryScan.RID, enryScan.URL, enryScan.Branch, testName, nil, enryScan.DockerHost) + scan, err := runner.newScan(enryScan.RID, enryScan.URL, enryScan.Branch, testName, nil, enryScan.ChangedFiles, enryScan.DockerHost) if err != nil { return err } @@ -153,7 +153,7 @@ func (results *RunAllInfo) runLanguageScans(ctx context.Context, enryScan SecTes default: } - scan, err := runner.newScan(enryScan.RID, enryScan.URL, enryScan.Branch, testName, nil, enryScan.DockerHost) + scan, err := runner.newScan(enryScan.RID, enryScan.URL, enryScan.Branch, testName, nil, enryScan.ChangedFiles, enryScan.DockerHost) if err != nil { return err } diff --git a/api/securitytest/run_test.go b/api/securitytest/run_test.go index 0c28a42..5a33966 100644 --- a/api/securitytest/run_test.go +++ b/api/securitytest/run_test.go @@ -115,7 +115,7 @@ func TestStart_FirstErrorCancelsRemaining(t *testing.T) { runner := &mockRunner{ genericTests: mockGenericTests, - newScanFunc: func(RID, URL, branch, name string, le map[string]bool, dh string) (*SecTestScanInfo, error) { + newScanFunc: func(RID, URL, branch, name string, le map[string]bool, cf, dh string) (*SecTestScanInfo, error) { return &SecTestScanInfo{ RID: RID, SecurityTestName: name, @@ -151,7 +151,7 @@ func TestStart_ConcurrentErrorsNoPanic(t *testing.T) { runner := &mockRunner{ genericTests: mockGenericTests, - newScanFunc: func(RID, URL, branch, name string, le map[string]bool, dh string) (*SecTestScanInfo, error) { + newScanFunc: func(RID, URL, branch, name string, le map[string]bool, cf, dh string) (*SecTestScanInfo, error) { return &SecTestScanInfo{ RID: RID, SecurityTestName: name, Container: types.Container{CID: "cid-" + name}, @@ -181,7 +181,7 @@ func TestStart_AllScansPass(t *testing.T) { runner := &mockRunner{ genericTests: mockGenericTests, - newScanFunc: func(RID, URL, branch, name string, le map[string]bool, dh string) (*SecTestScanInfo, error) { + newScanFunc: func(RID, URL, branch, name string, le map[string]bool, cf, dh string) (*SecTestScanInfo, error) { return &SecTestScanInfo{ RID: RID, SecurityTestName: name, Container: types.Container{CID: "cid-" + name, CResult: "passed", CStatus: "finished"}, @@ -209,7 +209,7 @@ func TestStart_ConcurrentWritesDataRace(t *testing.T) { runner := &mockRunner{ genericTests: mockGenericTests, - newScanFunc: func(RID, URL, branch, name string, le map[string]bool, dh string) (*SecTestScanInfo, error) { + newScanFunc: func(RID, URL, branch, name string, le map[string]bool, cf, dh string) (*SecTestScanInfo, error) { scan := &SecTestScanInfo{ RID: RID, SecurityTestName: name, diff --git a/api/securitytest/runner.go b/api/securitytest/runner.go index 0564ab1..ef254ca 100644 --- a/api/securitytest/runner.go +++ b/api/securitytest/runner.go @@ -7,7 +7,7 @@ import "github.com/githubanotaai/huskyci-api/api/types" type scanRunner interface { listGenericTests() ([]types.SecurityTest, error) listLanguageTests(language string) ([]types.SecurityTest, error) - newScan(RID, URL, branch, securityTestName string, languageExclusions map[string]bool, dockerHost string) (*SecTestScanInfo, error) + newScan(RID, URL, branch, securityTestName string, languageExclusions map[string]bool, changedFiles, dockerHost string) (*SecTestScanInfo, error) startScan(scan *SecTestScanInfo) error } @@ -22,9 +22,9 @@ func (realRunner) listLanguageTests(language string) ([]types.SecurityTest, erro return getAllDefaultSecurityTests("Language", language) } -func (realRunner) newScan(RID, URL, branch, securityTestName string, languageExclusions map[string]bool, dockerHost string) (*SecTestScanInfo, error) { +func (realRunner) newScan(RID, URL, branch, securityTestName string, languageExclusions map[string]bool, changedFiles, dockerHost string) (*SecTestScanInfo, error) { scan := &SecTestScanInfo{} - return scan, scan.New(RID, URL, branch, securityTestName, languageExclusions, dockerHost) + return scan, scan.New(RID, URL, branch, securityTestName, languageExclusions, changedFiles, dockerHost) } func (realRunner) startScan(scan *SecTestScanInfo) error { @@ -35,7 +35,7 @@ func (realRunner) startScan(scan *SecTestScanInfo) error { type mockRunner struct { genericTests []types.SecurityTest languageTests []types.SecurityTest - newScanFunc func(RID, URL, branch, securityTestName string, languageExclusions map[string]bool, dockerHost string) (*SecTestScanInfo, error) + newScanFunc func(RID, URL, branch, securityTestName string, languageExclusions map[string]bool, changedFiles, dockerHost string) (*SecTestScanInfo, error) startScanFunc func(scan *SecTestScanInfo) error } @@ -47,9 +47,9 @@ func (m *mockRunner) listLanguageTests(language string) ([]types.SecurityTest, e return m.languageTests, nil } -func (m *mockRunner) newScan(RID, URL, branch, securityTestName string, languageExclusions map[string]bool, dockerHost string) (*SecTestScanInfo, error) { +func (m *mockRunner) newScan(RID, URL, branch, securityTestName string, languageExclusions map[string]bool, changedFiles, dockerHost string) (*SecTestScanInfo, error) { if m.newScanFunc != nil { - return m.newScanFunc(RID, URL, branch, securityTestName, languageExclusions, dockerHost) + return m.newScanFunc(RID, URL, branch, securityTestName, languageExclusions, changedFiles, dockerHost) } return &SecTestScanInfo{}, nil } diff --git a/api/securitytest/securitytest.go b/api/securitytest/securitytest.go index edd19eb..d0db29c 100644 --- a/api/securitytest/securitytest.go +++ b/api/securitytest/securitytest.go @@ -45,6 +45,7 @@ type SecTestScanInfo struct { Branch string SecurityTestName string LanguageExclusions map[string]bool + ChangedFiles string ErrorFound error ReqNotFound bool WarningFound bool @@ -66,11 +67,12 @@ type SecTestScanInfo struct { } // New creates a new huskyCI scan based given RID, URL, Branch and a securityTest name and returns an error. -func (scanInfo *SecTestScanInfo) New(RID, URL, branch, securityTestName string, languageExclusions map[string]bool, dockerHost string) error { +func (scanInfo *SecTestScanInfo) New(RID, URL, branch, securityTestName string, languageExclusions map[string]bool, changedFiles, dockerHost string) error { scanInfo.RID = RID scanInfo.URL = URL scanInfo.Branch = branch scanInfo.LanguageExclusions = languageExclusions + scanInfo.ChangedFiles = changedFiles scanInfo.SecurityTestName = securityTestName scanInfo.DockerHost = dockerHost @@ -119,7 +121,7 @@ func (scanInfo *SecTestScanInfo) Start() error { func (scanInfo *SecTestScanInfo) dockerRun(timeOutInSeconds int) error { image := scanInfo.Container.SecurityTest.Image imageTag := scanInfo.Container.SecurityTest.ImageTag - cmd := util.HandleCmd(scanInfo.URL, scanInfo.Branch, scanInfo.Container.SecurityTest.Cmd, "") + cmd := util.HandleCmd(scanInfo.URL, scanInfo.Branch, scanInfo.Container.SecurityTest.Cmd, scanInfo.ChangedFiles) cmd = util.HandleGitURLSubstitution(cmd) finalCMD := util.HandlePrivateSSHKey(cmd) CID, cOutput, err := huskydocker.DockerRun(image, imageTag, finalCMD, scanInfo.DockerHost, timeOutInSeconds) @@ -134,7 +136,7 @@ func (scanInfo *SecTestScanInfo) dockerRun(timeOutInSeconds int) error { func (scanInfo *SecTestScanInfo) kubeRun(timeOutInSeconds int) error { image := scanInfo.Container.SecurityTest.Image imageTag := scanInfo.Container.SecurityTest.ImageTag - cmd := util.HandleCmd(scanInfo.URL, scanInfo.Branch, scanInfo.Container.SecurityTest.Cmd, "") + cmd := util.HandleCmd(scanInfo.URL, scanInfo.Branch, scanInfo.Container.SecurityTest.Cmd, scanInfo.ChangedFiles) cmd = util.HandleGitURLSubstitution(cmd) finalCMD := util.HandlePrivateSSHKey(cmd) podSchedulingTimeoutInSeconds := apiContext.APIConfiguration.KubernetesConfig.PodSchedulingTimeout From c83ff2d8db96b4deff46b7e8086bfd1870abf202 Mon Sep 17 00:00:00 2001 From: Guilherme Ferreira Date: Tue, 9 Jun 2026 20:47:46 -0300 Subject: [PATCH 5/8] fix: check os.Unsetenv return value in huskykube tests --- api/kubernetes/huskykube_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/kubernetes/huskykube_test.go b/api/kubernetes/huskykube_test.go index 57d3bdd..585cad0 100644 --- a/api/kubernetes/huskykube_test.go +++ b/api/kubernetes/huskykube_test.go @@ -21,7 +21,7 @@ func TestGetScannerConfig(t *testing.T) { } func TestGetScannerConfig_Missing(t *testing.T) { - os.Unsetenv("HUSKYCI_SCANNER_NONEXISTENT_DELTA_SCAN") + _ = os.Unsetenv("HUSKYCI_SCANNER_NONEXISTENT_DELTA_SCAN") result := getScannerConfig("nonexistent", "DELTA_SCAN") if result != "" { @@ -62,7 +62,7 @@ func TestIsDeltaScanEnabled_False(t *testing.T) { } func TestIsDeltaScanEnabled_Unset(t *testing.T) { - os.Unsetenv("HUSKYCI_SCANNER_GITLEAKS_DELTA_SCAN") + _ = os.Unsetenv("HUSKYCI_SCANNER_GITLEAKS_DELTA_SCAN") if isDeltaScanEnabled("gitleaks") { t.Error("expected isDeltaScanEnabled to return false when env var is unset") From d9833afdf5ddd6216cb0f31f8fd6b89b5a3962c9 Mon Sep 17 00:00:00 2001 From: Guilherme Ferreira Date: Tue, 9 Jun 2026 20:51:27 -0300 Subject: [PATCH 6/8] fix: check os.Setenv/Unsetenv return value in client analysis tests --- client/analysis/analysis_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/analysis/analysis_test.go b/client/analysis/analysis_test.go index 35c4966..a635455 100644 --- a/client/analysis/analysis_test.go +++ b/client/analysis/analysis_test.go @@ -11,8 +11,8 @@ import ( func TestChangedFilesInPayload(t *testing.T) { // Set the env var - os.Setenv("HUSKYCI_CLIENT_CHANGED_FILES", "src/main.go\nsrc/utils.go") - defer os.Unsetenv("HUSKYCI_CLIENT_CHANGED_FILES") + _ = os.Setenv("HUSKYCI_CLIENT_CHANGED_FILES", "src/main.go\nsrc/utils.go") + defer func() { _ = os.Unsetenv("HUSKYCI_CLIENT_CHANGED_FILES") }() config.SetConfigs() @@ -46,7 +46,7 @@ func TestChangedFilesInPayload(t *testing.T) { func TestChangedFilesEmpty(t *testing.T) { // Ensure env var is not set - os.Unsetenv("HUSKYCI_CLIENT_CHANGED_FILES") + _ = os.Unsetenv("HUSKYCI_CLIENT_CHANGED_FILES") config.SetConfigs() From e09a6163dc30516a839797e9730924c8fb1f2f07 Mon Sep 17 00:00:00 2001 From: Guilherme Ferreira Date: Tue, 9 Jun 2026 21:23:57 -0300 Subject: [PATCH 7/8] fix: handle ERROR_SPARSE_CHECKOUT in analyze(), use while-read for spaces in filenames --- api/config.yaml | 14 +++++++------- api/securitytest/securitytest.go | 5 ++++- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/api/config.yaml b/api/config.yaml index 5e25702..f5575ce 100644 --- a/api/config.yaml +++ b/api/config.yaml @@ -81,7 +81,7 @@ gosec: if [ $? -eq 0 ]; then cd code git sparse-checkout init --cone - echo "%CHANGED_FILES%" | xargs git sparse-checkout set + echo "%CHANGED_FILES%" | while IFS= read -r f; do [ -n "$f" ] && git sparse-checkout add "$f"; done git checkout 2>/tmp/errorSparseCheckout if [ $? -ne 0 ]; then echo "ERROR_SPARSE_CHECKOUT" @@ -128,7 +128,7 @@ bandit: if [ $? -eq 0 ]; then cd code git sparse-checkout init --cone - echo "%CHANGED_FILES%" | xargs git sparse-checkout set + echo "%CHANGED_FILES%" | while IFS= read -r f; do [ -n "$f" ] && git sparse-checkout add "$f"; done git checkout 2>/tmp/errorSparseCheckout if [ $? -ne 0 ]; then echo "ERROR_SPARSE_CHECKOUT" @@ -177,7 +177,7 @@ brakeman: if [ $? -eq 0 ]; then cd code git sparse-checkout init --cone - echo "%CHANGED_FILES%" | xargs git sparse-checkout set + echo "%CHANGED_FILES%" | while IFS= read -r f; do [ -n "$f" ] && git sparse-checkout add "$f"; done git checkout 2>/tmp/errorSparseCheckout if [ $? -ne 0 ]; then echo "ERROR_SPARSE_CHECKOUT" @@ -465,7 +465,7 @@ gitleaks: if [ $? -eq 0 ]; then cd code git sparse-checkout init --cone - echo "%CHANGED_FILES%" | xargs git sparse-checkout set + echo "%CHANGED_FILES%" | while IFS= read -r f; do [ -n "$f" ] && git sparse-checkout add "$f"; done git checkout 2>/tmp/errorSparseCheckout if [ $? -ne 0 ]; then echo "ERROR_SPARSE_CHECKOUT" @@ -585,7 +585,7 @@ wizcli_secrets: if [ $? -eq 0 ]; then cd code git sparse-checkout init --cone - echo "%CHANGED_FILES%" | xargs git sparse-checkout set + echo "%CHANGED_FILES%" | while IFS= read -r f; do [ -n "$f" ] && git sparse-checkout add "$f"; done git checkout 2>/tmp/errorSparseCheckout if [ $? -ne 0 ]; then echo "ERROR_SPARSE_CHECKOUT" @@ -659,7 +659,7 @@ wizcli_iac: if [ $? -eq 0 ]; then cd code git sparse-checkout init --cone - echo "%CHANGED_FILES%" | xargs git sparse-checkout set + echo "%CHANGED_FILES%" | while IFS= read -r f; do [ -n "$f" ] && git sparse-checkout add "$f"; done git checkout 2>/tmp/errorSparseCheckout if [ $? -ne 0 ]; then echo "ERROR_SPARSE_CHECKOUT" @@ -733,7 +733,7 @@ wizcli_sast: if [ $? -eq 0 ]; then cd code git sparse-checkout init --cone - echo "%CHANGED_FILES%" | xargs git sparse-checkout set + echo "%CHANGED_FILES%" | while IFS= read -r f; do [ -n "$f" ] && git sparse-checkout add "$f"; done git checkout 2>/tmp/errorSparseCheckout if [ $? -ne 0 ]; then echo "ERROR_SPARSE_CHECKOUT" diff --git a/api/securitytest/securitytest.go b/api/securitytest/securitytest.go index d0db29c..cf0163e 100644 --- a/api/securitytest/securitytest.go +++ b/api/securitytest/securitytest.go @@ -151,11 +151,14 @@ func (scanInfo *SecTestScanInfo) kubeRun(timeOutInSeconds int) error { func (scanInfo *SecTestScanInfo) analyze() error { errorCloning := strings.Contains(scanInfo.Container.COutput, "ERROR_CLONING") - if errorCloning { + errorSparseCheckout := strings.Contains(scanInfo.Container.COutput, "ERROR_SPARSE_CHECKOUT") + if errorCloning || errorSparseCheckout { hint := extractGitCloneFailureHint(scanInfo.Container.COutput) var errorMsg error if hint != "" { errorMsg = fmt.Errorf("error cloning: %s", hint) + } else if errorSparseCheckout { + errorMsg = errors.New("error during sparse checkout") } else { errorMsg = errors.New("error cloning") } From 4e307b9747ea8e499efa5fb36da6ff9f0c157d91 Mon Sep 17 00:00:00 2001 From: Guilherme Ferreira Date: Tue, 9 Jun 2026 21:27:25 -0300 Subject: [PATCH 8/8] fix: add ChangedFiles validation, git checkout -- safety, code review findings --- api/config.yaml | 14 +++++----- api/routes/analysis.go | 7 +++++ api/util/changedfiles_validation_test.go | 33 ++++++++++++++++++++++++ api/util/util.go | 21 +++++++++++++++ 4 files changed, 68 insertions(+), 7 deletions(-) create mode 100644 api/util/changedfiles_validation_test.go diff --git a/api/config.yaml b/api/config.yaml index f5575ce..b569af8 100644 --- a/api/config.yaml +++ b/api/config.yaml @@ -82,7 +82,7 @@ gosec: cd code git sparse-checkout init --cone echo "%CHANGED_FILES%" | while IFS= read -r f; do [ -n "$f" ] && git sparse-checkout add "$f"; done - git checkout 2>/tmp/errorSparseCheckout + git checkout -- 2>/tmp/errorSparseCheckout if [ $? -ne 0 ]; then echo "ERROR_SPARSE_CHECKOUT" cat /tmp/errorSparseCheckout @@ -129,7 +129,7 @@ bandit: cd code git sparse-checkout init --cone echo "%CHANGED_FILES%" | while IFS= read -r f; do [ -n "$f" ] && git sparse-checkout add "$f"; done - git checkout 2>/tmp/errorSparseCheckout + git checkout -- 2>/tmp/errorSparseCheckout if [ $? -ne 0 ]; then echo "ERROR_SPARSE_CHECKOUT" cat /tmp/errorSparseCheckout @@ -178,7 +178,7 @@ brakeman: cd code git sparse-checkout init --cone echo "%CHANGED_FILES%" | while IFS= read -r f; do [ -n "$f" ] && git sparse-checkout add "$f"; done - git checkout 2>/tmp/errorSparseCheckout + git checkout -- 2>/tmp/errorSparseCheckout if [ $? -ne 0 ]; then echo "ERROR_SPARSE_CHECKOUT" cat /tmp/errorSparseCheckout @@ -466,7 +466,7 @@ gitleaks: cd code git sparse-checkout init --cone echo "%CHANGED_FILES%" | while IFS= read -r f; do [ -n "$f" ] && git sparse-checkout add "$f"; done - git checkout 2>/tmp/errorSparseCheckout + git checkout -- 2>/tmp/errorSparseCheckout if [ $? -ne 0 ]; then echo "ERROR_SPARSE_CHECKOUT" cat /tmp/errorSparseCheckout @@ -586,7 +586,7 @@ wizcli_secrets: cd code git sparse-checkout init --cone echo "%CHANGED_FILES%" | while IFS= read -r f; do [ -n "$f" ] && git sparse-checkout add "$f"; done - git checkout 2>/tmp/errorSparseCheckout + git checkout -- 2>/tmp/errorSparseCheckout if [ $? -ne 0 ]; then echo "ERROR_SPARSE_CHECKOUT" cat /tmp/errorSparseCheckout @@ -660,7 +660,7 @@ wizcli_iac: cd code git sparse-checkout init --cone echo "%CHANGED_FILES%" | while IFS= read -r f; do [ -n "$f" ] && git sparse-checkout add "$f"; done - git checkout 2>/tmp/errorSparseCheckout + git checkout -- 2>/tmp/errorSparseCheckout if [ $? -ne 0 ]; then echo "ERROR_SPARSE_CHECKOUT" cat /tmp/errorSparseCheckout @@ -734,7 +734,7 @@ wizcli_sast: cd code git sparse-checkout init --cone echo "%CHANGED_FILES%" | while IFS= read -r f; do [ -n "$f" ] && git sparse-checkout add "$f"; done - git checkout 2>/tmp/errorSparseCheckout + git checkout -- 2>/tmp/errorSparseCheckout if [ $? -ne 0 ]; then echo "ERROR_SPARSE_CHECKOUT" cat /tmp/errorSparseCheckout diff --git a/api/routes/analysis.go b/api/routes/analysis.go index 4ca9287..9c855c5 100644 --- a/api/routes/analysis.go +++ b/api/routes/analysis.go @@ -106,6 +106,13 @@ func ReceiveRequest(c echo.Context) error { } repository.URL = sanitizedRepoURL + // step-01b: Validate changed files (no shell metacharacters) + if err := util.CheckMaliciousChangedFiles(repository.ChangedFiles); err != nil { + log.Error(logActionReceiveRequest, logInfoAnalysis, 1017, repository.ChangedFiles) + reply := map[string]interface{}{"success": false, "error": "invalid changed files"} + return c.JSON(http.StatusBadRequest, reply) + } + // step-02: is this repository already in MongoDB? repositoryQuery := map[string]interface{}{"repositoryURL": repository.URL} _, err = apiContext.APIConfiguration.DBInstance.FindOneDBRepository(repositoryQuery) diff --git a/api/util/changedfiles_validation_test.go b/api/util/changedfiles_validation_test.go new file mode 100644 index 0000000..e224792 --- /dev/null +++ b/api/util/changedfiles_validation_test.go @@ -0,0 +1,33 @@ +package util + +import "testing" + +func TestCheckMaliciousChangedFiles_Empty(t *testing.T) { + if err := CheckMaliciousChangedFiles(""); err != nil { + t.Errorf("expected nil for empty string, got: %v", err) + } +} + +func TestCheckMaliciousChangedFiles_Valid(t *testing.T) { + valid := "src/main.go\nsrc/utils.go\nREADME.md\npath/to/file_test.go" + if err := CheckMaliciousChangedFiles(valid); err != nil { + t.Errorf("expected nil for valid paths, got: %v", err) + } +} + +func TestCheckMaliciousChangedFiles_ShellInjection(t *testing.T) { + malicious := []string{ + "$(whoami)", + "`id`", + "file; rm -rf /", + "file | cat /etc/passwd", + "file&", + } + + for _, m := range malicious { + err := CheckMaliciousChangedFiles(m) + if err == nil { + t.Errorf("expected error for malicious input %q, got nil", m) + } + } +} diff --git a/api/util/util.go b/api/util/util.go index 5b75494..8e76f13 100644 --- a/api/util/util.go +++ b/api/util/util.go @@ -197,6 +197,27 @@ func CheckMaliciousRepoBranch(repositoryBranch string, c echo.Context) error { return nil } +// CheckMaliciousChangedFiles verifies that changed file paths don't contain +// shell metacharacters that could be exploited when substituted into scanner +// commands via %CHANGED_FILES%. Accepts only valid file path characters. +// Returns an error if invalid; empty string is valid (non-PR or no changed files). +func CheckMaliciousChangedFiles(changedFiles string) error { + if changedFiles == "" { + return nil + } + // Allow: alphanumeric, path separators, dots, hyphens, underscores, newlines + // Block: shell metacharacters ($, `, ;, |, &, <, >, (, ), {, }, !) + regexpFiles := `^[a-zA-Z0-9_/.\\\n-]*$` + valid, err := regexp.MatchString(regexpFiles, changedFiles) + if err != nil { + return err + } + if !valid { + return errors.New("invalid changed files: contains forbidden characters") + } + return nil +} + // CheckMaliciousRID verifies if a given RID is "malicious" or not func CheckMaliciousRID(RID string, c echo.Context) error { regexpRID := `^[-a-zA-Z0-9]*$`