From db51bf2b236d1bee03b348b15fec0e74d151c157 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Apr 2026 16:04:31 +0000 Subject: [PATCH 01/11] feat: add .gitlab-ci.yml example with scan-only, sync and security-gate jobs Agent-Logs-Url: https://github.com/ParanoiHack/ScopeGuardian/sessions/231c9a5e-5d90-4f75-8bb7-4ec5735cc9a4 Co-authored-by: Nitr4x <10544300+Nitr4x@users.noreply.github.com> --- .gitlab-ci.yml | 115 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 .gitlab-ci.yml diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..1636f73 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,115 @@ +# --------------------------------------------------------------------------- +# ScopeGuardian – GitLab CI example +# +# This file shows three typical usage patterns: +# 1. scan-only – run all scanners, display results, no DefectDojo sync +# 2. scan-and-sync – run all scanners and upload results to DefectDojo +# 3. security-gate – same as scan-and-sync but fail the pipeline when +# finding counts exceed the configured thresholds +# +# Required CI/CD variables (Settings → CI/CD → Variables): +# DD_URL – Base URL of your DefectDojo instance +# (e.g. https://defectdojo.example.com) +# DD_ACCESS_TOKEN – DefectDojo API v2 token +# (User → API v2 Key inside DefectDojo) +# +# The Docker image bundles ScopeGuardian together with KICS, OpenGrep, +# Grype and Syft so no extra installation step is needed. +# --------------------------------------------------------------------------- + +default: + image: ghcr.io/paranoihack/scopeguardian:latest + +variables: + # Base directory that ScopeGuardian uses to resolve scan paths defined in + # config.toml. GitLab clones the repository into CI_PROJECT_DIR, so set + # SCAN_DIR to the parent directory so that relative paths like "./my-repo" + # resolve correctly. + SCAN_DIR: $CI_PROJECT_DIR + +# --------------------------------------------------------------------------- +# Stage definitions +# --------------------------------------------------------------------------- +stages: + - security-scan + +# --------------------------------------------------------------------------- +# 1. Scan only (no DefectDojo sync) +# Useful for a quick local feedback loop on merge requests. +# --------------------------------------------------------------------------- +scan-only: + stage: security-scan + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + variables: + SCAN_DIR: $CI_PROJECT_DIR + script: + - /opt/ScopeGuardian/bin/ScopeGuardian + --projectName "$CI_PROJECT_NAME" + --branch "$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME" + --filter ACTIVE + -o $CI_PROJECT_DIR/scopeguardian-findings.json + $CI_PROJECT_DIR/config.toml + artifacts: + name: "scopeguardian-$CI_COMMIT_REF_SLUG" + when: always + expire_in: 7 days + paths: + - scopeguardian-findings.json + +# --------------------------------------------------------------------------- +# 2. Scan and sync to DefectDojo +# Uploads findings to DefectDojo on every push to the default branch. +# --------------------------------------------------------------------------- +scan-and-sync: + stage: security-scan + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + variables: + SCAN_DIR: $CI_PROJECT_DIR + # DD_URL and DD_ACCESS_TOKEN must be set as CI/CD variables (masked). + script: + - /opt/ScopeGuardian/bin/ScopeGuardian + --projectName "$CI_PROJECT_NAME" + --branch "$CI_COMMIT_BRANCH" + --sync + --filter ACTIVE + -o $CI_PROJECT_DIR/scopeguardian-findings.json + $CI_PROJECT_DIR/config.toml + artifacts: + name: "scopeguardian-$CI_COMMIT_REF_SLUG" + when: always + expire_in: 30 days + paths: + - scopeguardian-findings.json + +# --------------------------------------------------------------------------- +# 3. Scan, sync and enforce a security gate +# Fails the pipeline (exit -1) when active finding counts exceed the +# configured thresholds. Adjust the --threshold values to fit your +# security policy. +# --------------------------------------------------------------------------- +security-gate: + stage: security-scan + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + when: manual + allow_failure: false + variables: + SCAN_DIR: $CI_PROJECT_DIR + # DD_URL and DD_ACCESS_TOKEN must be set as CI/CD variables (masked). + script: + - /opt/ScopeGuardian/bin/ScopeGuardian + --projectName "$CI_PROJECT_NAME" + --branch "$CI_COMMIT_BRANCH" + --sync + --threshold critical=1,high=5 + --filter ACTIVE + -o $CI_PROJECT_DIR/scopeguardian-findings.json + $CI_PROJECT_DIR/config.toml + artifacts: + name: "scopeguardian-gate-$CI_COMMIT_REF_SLUG" + when: always + expire_in: 30 days + paths: + - scopeguardian-findings.json From 2402d4826928d468434d2a8b79301dbf31900d70 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Apr 2026 16:28:41 +0000 Subject: [PATCH 02/11] fix: override Docker entrypoint in .gitlab-ci.yml Agent-Logs-Url: https://github.com/ParanoiHack/ScopeGuardian/sessions/734b13c0-09da-42c8-868f-cc99fc32e894 Co-authored-by: Nitr4x <10544300+Nitr4x@users.noreply.github.com> --- .gitlab-ci.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 1636f73..fbc152b 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -18,7 +18,11 @@ # --------------------------------------------------------------------------- default: - image: ghcr.io/paranoihack/scopeguardian:latest + image: + name: ghcr.io/paranoihack/scopeguardian:latest + # The image defines an ENTRYPOINT; override it so the GitLab runner can + # start a shell and execute the script: commands normally. + entrypoint: [""] variables: # Base directory that ScopeGuardian uses to resolve scan paths defined in From 30ed70bc2984ddcaab0a4ca2f67d8bcddf33e735 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Apr 2026 16:37:44 +0000 Subject: [PATCH 03/11] fix: create results output directory before running Grype and OpenGrep Agent-Logs-Url: https://github.com/ParanoiHack/ScopeGuardian/sessions/a41f6b16-bfd6-4785-aec8-72969ebf9930 Co-authored-by: Nitr4x <10544300+Nitr4x@users.noreply.github.com> --- features/scans/grype/service.go | 5 +++++ features/scans/opengrep/service.go | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/features/scans/grype/service.go b/features/scans/grype/service.go index 04d8e30..3da7af6 100644 --- a/features/scans/grype/service.go +++ b/features/scans/grype/service.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "os" + "path/filepath" "ScopeGuardian/connectors/defectdojo" "ScopeGuardian/domains/interfaces" "ScopeGuardian/domains/models" @@ -40,6 +41,10 @@ func newGrypeService(config loader.Grype) interfaces.ScanServiceImpl { // to scan it for known vulnerabilities. It returns true on success or false and an // error if the SBOM is missing or the Grype process exits with a non-zero status. func (s *GrypeServiceImpl) Start() (bool, error) { + if err := os.MkdirAll(filepath.Dir(s.output), 0755); err != nil { + return false, err + } + if _, err := os.Stat(s.sbom); err != nil { logger.Error(fmt.Sprintf(logErrorSbomNotFound, s.sbom)) return false, errors.New(errSbomNotFound) diff --git a/features/scans/opengrep/service.go b/features/scans/opengrep/service.go index 6fbfc51..3f06165 100644 --- a/features/scans/opengrep/service.go +++ b/features/scans/opengrep/service.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "os" + "path/filepath" "ScopeGuardian/connectors/defectdojo" "ScopeGuardian/domains/interfaces" "ScopeGuardian/domains/models" @@ -55,6 +56,10 @@ func (s *OpenGrepServiceImpl) Start() (bool, error) { return ok, err } + if err := os.MkdirAll(filepath.Dir(s.output), 0755); err != nil { + return false, err + } + args := []string{ fmt.Sprintf("%s%s", jsonOutputArgument, s.output), ossOnlyArgument, From 167871a15ad810521cfab3242d0a23e568e6caf5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Apr 2026 16:46:25 +0000 Subject: [PATCH 04/11] fix: handle non-zero success exit codes for Grype (1) and OpenGrep (2) Agent-Logs-Url: https://github.com/ParanoiHack/ScopeGuardian/sessions/9d41cf10-0bb6-4662-88e8-c139b5a938a8 Co-authored-by: Nitr4x <10544300+Nitr4x@users.noreply.github.com> --- exec/exec.go | 17 +++++++++++++++++ exec/exec_test.go | 25 +++++++++++++++++++++++++ features/scans/grype/const.go | 6 ++++++ features/scans/grype/service.go | 2 +- features/scans/opengrep/const.go | 6 ++++++ features/scans/opengrep/service.go | 2 +- 6 files changed, 56 insertions(+), 2 deletions(-) diff --git a/exec/exec.go b/exec/exec.go index 9e0153e..6524ce6 100644 --- a/exec/exec.go +++ b/exec/exec.go @@ -14,6 +14,15 @@ import ( // Optional extraEnv entries (formatted as "KEY=VALUE") are appended to the // child process environment without affecting the parent process. func Wrap(binaryPath string, dirPath string, args []string, stdout io.Writer, stderr io.Writer, extraEnv ...string) (bool, error) { + return WrapAllowExitCodes(binaryPath, dirPath, args, stdout, stderr, nil, extraEnv...) +} + +// WrapAllowExitCodes is like Wrap but treats any exit code listed in +// successCodes as a successful execution in addition to the standard exit 0. +// This is needed for scanners such as Grype (exit 1 = vulnerabilities found) +// and OpenGrep (exit 2 = findings found) that use non-zero exit codes to +// signal normal "findings present" conditions rather than errors. +func WrapAllowExitCodes(binaryPath string, dirPath string, args []string, stdout io.Writer, stderr io.Writer, successCodes []int, extraEnv ...string) (bool, error) { cmd := exec.Command(binaryPath, args...) cmd.Dir = dirPath @@ -25,6 +34,14 @@ func Wrap(binaryPath string, dirPath string, args []string, stdout io.Writer, st } if err := cmd.Run(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + code := exitErr.ExitCode() + for _, allowed := range successCodes { + if code == allowed { + return true, nil + } + } + } return false, err } diff --git a/exec/exec_test.go b/exec/exec_test.go index 6751475..1653f72 100644 --- a/exec/exec_test.go +++ b/exec/exec_test.go @@ -23,3 +23,28 @@ func TestWrap(t *testing.T) { assert.False(t, ok) }) } + +func TestWrapAllowExitCodes(t *testing.T) { + t.Run("Should treat allowed non-zero exit code as success", func(t *testing.T) { + // 'false' exits with code 1 + ok, err := WrapAllowExitCodes("/bin/false", "/", []string{}, io.Discard, io.Discard, []int{1}) + + assert.Nil(t, err) + assert.True(t, ok) + }) + + t.Run("Should return error for disallowed non-zero exit code", func(t *testing.T) { + // 'false' exits with code 1; only code 2 is allowed here + ok, err := WrapAllowExitCodes("/bin/false", "/", []string{}, io.Discard, io.Discard, []int{2}) + + assert.NotNil(t, err) + assert.False(t, ok) + }) + + t.Run("Should succeed normally on exit 0", func(t *testing.T) { + ok, err := WrapAllowExitCodes("/bin/true", "/", []string{}, io.Discard, io.Discard, []int{1}) + + assert.Nil(t, err) + assert.True(t, ok) + }) +} diff --git a/features/scans/grype/const.go b/features/scans/grype/const.go index b8a8892..59785f8 100644 --- a/features/scans/grype/const.go +++ b/features/scans/grype/const.go @@ -51,6 +51,12 @@ const ( errSbomNotFound = "sbom not found" ) +const ( + // exitCodeFindings is the exit code returned by Grype when vulnerabilities + // are found. This is a normal termination state, not an error. + exitCodeFindings = 1 +) + const ( recommendationUpgrade = "Upgrade to %s" recommendationUpgradeMultiple = "Upgrade to one of: %s" diff --git a/features/scans/grype/service.go b/features/scans/grype/service.go index 3da7af6..a3003d7 100644 --- a/features/scans/grype/service.go +++ b/features/scans/grype/service.go @@ -69,7 +69,7 @@ func (s *GrypeServiceImpl) Start() (bool, error) { logger.Info(fmt.Sprintf(logInfoCommandLine, strings.Join(args, " "))) - return exec.Wrap(binaryPath, dirPath, args, os.Stdout, os.Stderr) + return exec.WrapAllowExitCodes(binaryPath, dirPath, args, os.Stdout, os.Stderr, []int{exitCodeFindings}) } // LoadFindings reads the Grype JSON output file and converts each vulnerability diff --git a/features/scans/opengrep/const.go b/features/scans/opengrep/const.go index 9590180..4121745 100644 --- a/features/scans/opengrep/const.go +++ b/features/scans/opengrep/const.go @@ -46,3 +46,9 @@ const ( const ( errDirectoryNotFound = "directory not found" ) + +const ( + // exitCodeFindings is the exit code returned by OpenGrep (Semgrep convention) + // when findings are present. This is a normal termination state, not an error. + exitCodeFindings = 2 +) diff --git a/features/scans/opengrep/service.go b/features/scans/opengrep/service.go index 3f06165..b677740 100644 --- a/features/scans/opengrep/service.go +++ b/features/scans/opengrep/service.go @@ -79,7 +79,7 @@ func (s *OpenGrepServiceImpl) Start() (bool, error) { logger.Info(fmt.Sprintf(logInfoCommandLine, strings.Join(args, " "))) - return exec.Wrap(binaryPath, dirPath, args, io.Discard, io.Discard) + return exec.WrapAllowExitCodes(binaryPath, dirPath, args, io.Discard, io.Discard, []int{exitCodeFindings}) } // LoadFindings reads the OpenGrep JSON output file and converts each result From 031158992d74f558ae43bfe7f7f225e172327a1b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Apr 2026 16:55:54 +0000 Subject: [PATCH 05/11] feat: add proxy configuration support for scanner sub-processes Agent-Logs-Url: https://github.com/ParanoiHack/ScopeGuardian/sessions/db46e029-2367-45a5-b96f-886ec177d6aa Co-authored-by: Nitr4x <10544300+Nitr4x@users.noreply.github.com> --- config.toml | 7 ++++ features/scans/grype/factory.go | 2 +- features/scans/grype/service.go | 8 +++- features/scans/grype/service_test.go | 22 +++++------ features/scans/kics/factory.go | 2 +- features/scans/kics/service.go | 8 +++- features/scans/kics/service_test.go | 22 +++++------ features/scans/opengrep/factory.go | 2 +- features/scans/opengrep/service.go | 8 +++- features/scans/opengrep/service_test.go | 24 ++++++------ features/scans/syft/factory.go | 2 +- features/scans/syft/service.go | 12 ++++-- features/scans/syft/service_test.go | 12 +++--- loader/dto.go | 33 ++++++++++++++++ loader/dto_test.go | 50 +++++++++++++++++++++++++ loader/loader_test.go | 10 +++++ loader/mocks/config_with_proxy.toml | 7 ++++ 17 files changed, 178 insertions(+), 53 deletions(-) create mode 100644 loader/dto_test.go create mode 100644 loader/mocks/config_with_proxy.toml diff --git a/config.toml b/config.toml index 03f4011..d13c0b1 100644 --- a/config.toml +++ b/config.toml @@ -13,3 +13,10 @@ transitive_libraries = false [opengrep] exclude = ["tests/**"] exclude_rule = [] + +# Optional proxy configuration forwarded to scanner sub-processes (Grype, OpenGrep, Syft, KICS). +# Uncomment and set the values below if the scanners must reach the internet through a proxy. +# [proxy] +# http_proxy = "http://proxy.company.com:3128" +# https_proxy = "http://proxy.company.com:3128" +# no_proxy = "localhost,127.0.0.1" diff --git a/features/scans/grype/factory.go b/features/scans/grype/factory.go index beb66ac..b1efbf2 100644 --- a/features/scans/grype/factory.go +++ b/features/scans/grype/factory.go @@ -8,5 +8,5 @@ import ( // GetGrypeService constructs and returns a ScanServiceImpl for the Grype vulnerability scanner // using the provided loader configuration. func GetGrypeService(config loader.Config) interfaces.ScanServiceImpl { - return newGrypeService(*config.Grype) + return newGrypeService(*config.Grype, config.Proxy.ToEnv()) } diff --git a/features/scans/grype/service.go b/features/scans/grype/service.go index a3003d7..2cc75e6 100644 --- a/features/scans/grype/service.go +++ b/features/scans/grype/service.go @@ -24,16 +24,20 @@ type GrypeServiceImpl struct { output string ignoreStates string exclude []string + proxyEnv []string } // newGrypeService builds a GrypeServiceImpl from the Grype loader configuration, // resolving the SBOM input and result output paths from the SCAN_DIR environment variable. -func newGrypeService(config loader.Grype) interfaces.ScanServiceImpl { +// proxyEnv is an optional list of "KEY=VALUE" proxy environment variable entries +// (see loader.Proxy.ToEnv) forwarded to the Grype process. +func newGrypeService(config loader.Grype, proxyEnv []string) interfaces.ScanServiceImpl { return &GrypeServiceImpl{ sbom: fmt.Sprintf("%s/%s/%s", environment_variable.EnvironmentVariable["SCAN_DIR"], outputFolder, sbomInputNameParameter), output: fmt.Sprintf("%s/%s/%s", environment_variable.EnvironmentVariable["SCAN_DIR"], outputFolder, outputNameParameter), ignoreStates: config.IgnoreStates, exclude: config.Exclude, + proxyEnv: proxyEnv, } } @@ -69,7 +73,7 @@ func (s *GrypeServiceImpl) Start() (bool, error) { logger.Info(fmt.Sprintf(logInfoCommandLine, strings.Join(args, " "))) - return exec.WrapAllowExitCodes(binaryPath, dirPath, args, os.Stdout, os.Stderr, []int{exitCodeFindings}) + return exec.WrapAllowExitCodes(binaryPath, dirPath, args, os.Stdout, os.Stderr, []int{exitCodeFindings}, s.proxyEnv...) } // LoadFindings reads the Grype JSON output file and converts each vulnerability diff --git a/features/scans/grype/service_test.go b/features/scans/grype/service_test.go index a718d0f..74d276d 100644 --- a/features/scans/grype/service_test.go +++ b/features/scans/grype/service_test.go @@ -69,7 +69,7 @@ var _ defectdojo.DefectDojoService = &mockDefectDojoService{} // value that satisfies the interfaces.ScanServiceImpl contract, enforcing that // all required scanner methods are present at compile and test time. func TestNewGrypeServiceImplementsInterface(t *testing.T) { - service := newGrypeService(loader.Grype{}) + service := newGrypeService(loader.Grype{}, nil) _, ok := service.(interfaces.ScanServiceImpl) assert.True(t, ok) @@ -80,7 +80,7 @@ func TestNewGrypeService(t *testing.T) { _ = os.Setenv("SCAN_DIR", fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["PWD"], "")) environment_variable.ReloadEnv() - service := newGrypeService(loader.Grype{}) + service := newGrypeService(loader.Grype{}, nil) ok, err := service.Start() @@ -95,7 +95,7 @@ func TestLoadFindings(t *testing.T) { _ = os.Setenv("SCAN_DIR", fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["PWD"], "./mocks/working_results")) environment_variable.ReloadEnv() - service := newGrypeService(loader.Grype{}) + service := newGrypeService(loader.Grype{}, nil) findings, err := service.LoadFindings() @@ -117,7 +117,7 @@ func TestLoadFindings(t *testing.T) { _ = os.Setenv("SCAN_DIR", fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["PWD"], "")) environment_variable.ReloadEnv() - service := newGrypeService(loader.Grype{}) + service := newGrypeService(loader.Grype{}, nil) findings, err := service.LoadFindings() @@ -129,7 +129,7 @@ func TestLoadFindings(t *testing.T) { _ = os.Setenv("SCAN_DIR", fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["PWD"], "./mocks/bad_format_results")) environment_variable.ReloadEnv() - service := newGrypeService(loader.Grype{}) + service := newGrypeService(loader.Grype{}, nil) findings, err := service.LoadFindings() @@ -143,7 +143,7 @@ func TestSync(t *testing.T) { _ = os.Setenv("SCAN_DIR", fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["PWD"], "./mocks/working_results")) environment_variable.ReloadEnv() - service := newGrypeService(loader.Grype{}) + service := newGrypeService(loader.Grype{}, nil) ddMock := &mockDefectDojoService{importScanOk: true, importScanErr: nil} err := service.Sync(1, "main", ddMock) @@ -155,7 +155,7 @@ func TestSync(t *testing.T) { _ = os.Setenv("SCAN_DIR", fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["PWD"], "./mocks/working_results")) environment_variable.ReloadEnv() - service := newGrypeService(loader.Grype{}) + service := newGrypeService(loader.Grype{}, nil) ddMock := &mockDefectDojoService{ testsToReturn: []defectdojo.Test{{Id: 3, ScanType: "Anchore Grype"}}, reimportScanOk: true, @@ -172,7 +172,7 @@ func TestSync(t *testing.T) { _ = os.Setenv("SCAN_DIR", fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["PWD"], "./mocks/working_results")) environment_variable.ReloadEnv() - service := newGrypeService(loader.Grype{}) + service := newGrypeService(loader.Grype{}, nil) ddMock := &mockDefectDojoService{importScanOk: false, importScanErr: fmt.Errorf("import failed")} err := service.Sync(1, "main", ddMock) @@ -184,7 +184,7 @@ func TestSync(t *testing.T) { _ = os.Setenv("SCAN_DIR", fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["PWD"], "./mocks/working_results")) environment_variable.ReloadEnv() - service := newGrypeService(loader.Grype{}) + service := newGrypeService(loader.Grype{}, nil) ddMock := &mockDefectDojoService{ testsToReturn: []defectdojo.Test{{Id: 3, ScanType: "Anchore Grype"}}, reimportScanOk: false, @@ -200,7 +200,7 @@ func TestSync(t *testing.T) { _ = os.Setenv("SCAN_DIR", fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["PWD"], "./mocks/working_results")) environment_variable.ReloadEnv() - service := newGrypeService(loader.Grype{}) + service := newGrypeService(loader.Grype{}, nil) ddMock := &mockDefectDojoService{getTestsErr: fmt.Errorf("cannot retrieve tests")} err := service.Sync(1, "main", ddMock) @@ -212,7 +212,7 @@ func TestSync(t *testing.T) { _ = os.Setenv("SCAN_DIR", fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["PWD"], "")) environment_variable.ReloadEnv() - service := newGrypeService(loader.Grype{}) + service := newGrypeService(loader.Grype{}, nil) ddMock := &mockDefectDojoService{} err := service.Sync(1, "main", ddMock) diff --git a/features/scans/kics/factory.go b/features/scans/kics/factory.go index e62419b..eb7b226 100644 --- a/features/scans/kics/factory.go +++ b/features/scans/kics/factory.go @@ -8,5 +8,5 @@ import ( // GetKicsService constructs and returns a ScanServiceImpl for the KICS scanner // using the provided loader configuration. func GetKicsService(config loader.Config) interfaces.ScanServiceImpl { - return newKicsService(config.Path, *config.Kics) + return newKicsService(config.Path, *config.Kics, config.Proxy.ToEnv()) } diff --git a/features/scans/kics/service.go b/features/scans/kics/service.go index 10de126..71c86d9 100644 --- a/features/scans/kics/service.go +++ b/features/scans/kics/service.go @@ -23,16 +23,20 @@ type KicsServiceImpl struct { platform string output string excludeQueries []string + proxyEnv []string } // newKicsService builds a KicsServiceImpl from the scan path and loader configuration, // resolving the scan path and output file path relative to the SCAN_DIR environment variable. -func newKicsService(path string, config loader.Kics) interfaces.ScanServiceImpl { +// proxyEnv is an optional list of "KEY=VALUE" proxy environment variable entries +// (see loader.Proxy.ToEnv) forwarded to the KICS process. +func newKicsService(path string, config loader.Kics, proxyEnv []string) interfaces.ScanServiceImpl { return &KicsServiceImpl{ path: fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["SCAN_DIR"], path), output: fmt.Sprintf("%s/%s/%s", environment_variable.EnvironmentVariable["SCAN_DIR"], outputFolder, outputNameParameter), platform: config.Platform, excludeQueries: config.ExcludeQueries, + proxyEnv: proxyEnv, } } @@ -83,7 +87,7 @@ func (s *KicsServiceImpl) Start() (bool, error) { logger.Info(fmt.Sprintf(logInfoCommandLine, strings.Join(args, " "))) - return exec.Wrap(binaryPath, dirPath, args, io.Discard, io.Discard) + return exec.Wrap(binaryPath, dirPath, args, io.Discard, io.Discard, s.proxyEnv...) } // LoadFindings reads the KICS JSON output file and converts each query result diff --git a/features/scans/kics/service_test.go b/features/scans/kics/service_test.go index e1cb942..48e207e 100644 --- a/features/scans/kics/service_test.go +++ b/features/scans/kics/service_test.go @@ -69,14 +69,14 @@ var _ defectdojo.DefectDojoService = &mockDefectDojoService{} // value that satisfies the interfaces.ScanServiceImpl contract, enforcing that // all required scanner methods are present at compile and test time. func TestNewKicsServiceImplementsInterface(t *testing.T) { - service := newKicsService("./test", loader.Kics{}) + service := newKicsService("./test", loader.Kics{}, nil) _, ok := service.(interfaces.ScanServiceImpl) assert.True(t, ok) } func TestNewKicsService(t *testing.T) { - service := newKicsService("./test", loader.Kics{ExcludeQueries: []string{"a227ec01-f97a-4084-91a4-47b350c1db54"}}) + service := newKicsService("./test", loader.Kics{ExcludeQueries: []string{"a227ec01-f97a-4084-91a4-47b350c1db54"}}, nil) impl, ok := service.(*KicsServiceImpl) assert.True(t, ok) @@ -102,7 +102,7 @@ func TestLoadFinding(t *testing.T) { _ = os.Setenv("SCAN_DIR", fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["PWD"], "./mocks/working_results")) environment_variable.ReloadEnv() - service := newKicsService("./test", loader.Kics{}) + service := newKicsService("./test", loader.Kics{}, nil) findings, err := service.LoadFindings() @@ -114,7 +114,7 @@ func TestLoadFinding(t *testing.T) { _ = os.Setenv("SCAN_DIR", fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["PWD"], "")) environment_variable.ReloadEnv() - service := newKicsService("./test", loader.Kics{}) + service := newKicsService("./test", loader.Kics{}, nil) findings, err := service.LoadFindings() @@ -126,7 +126,7 @@ func TestLoadFinding(t *testing.T) { _ = os.Setenv("SCAN_DIR", fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["PWD"], "./mocks/bad_format_results")) environment_variable.ReloadEnv() - service := newKicsService("./test", loader.Kics{}) + service := newKicsService("./test", loader.Kics{}, nil) findings, err := service.LoadFindings() @@ -148,7 +148,7 @@ func TestSync(t *testing.T) { _ = os.Setenv("SCAN_DIR", fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["PWD"], "./mocks/working_results")) environment_variable.ReloadEnv() - service := newKicsService("./test", loader.Kics{}) + service := newKicsService("./test", loader.Kics{}, nil) ddMock := &mockDefectDojoService{importScanOk: true, importScanErr: nil} err := service.Sync(1, "main", ddMock) @@ -160,7 +160,7 @@ func TestSync(t *testing.T) { _ = os.Setenv("SCAN_DIR", fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["PWD"], "./mocks/working_results")) environment_variable.ReloadEnv() - service := newKicsService("./test", loader.Kics{}) + service := newKicsService("./test", loader.Kics{}, nil) ddMock := &mockDefectDojoService{ testsToReturn: []defectdojo.Test{{Id: 5, ScanType: "KICS Scan"}}, reimportScanOk: true, @@ -177,7 +177,7 @@ func TestSync(t *testing.T) { _ = os.Setenv("SCAN_DIR", fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["PWD"], "./mocks/working_results")) environment_variable.ReloadEnv() - service := newKicsService("./test", loader.Kics{}) + service := newKicsService("./test", loader.Kics{}, nil) ddMock := &mockDefectDojoService{importScanOk: false, importScanErr: fmt.Errorf("import failed")} err := service.Sync(1, "main", ddMock) @@ -189,7 +189,7 @@ func TestSync(t *testing.T) { _ = os.Setenv("SCAN_DIR", fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["PWD"], "./mocks/working_results")) environment_variable.ReloadEnv() - service := newKicsService("./test", loader.Kics{}) + service := newKicsService("./test", loader.Kics{}, nil) ddMock := &mockDefectDojoService{ testsToReturn: []defectdojo.Test{{Id: 5, ScanType: "KICS Scan"}}, reimportScanOk: false, @@ -205,7 +205,7 @@ func TestSync(t *testing.T) { _ = os.Setenv("SCAN_DIR", fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["PWD"], "./mocks/working_results")) environment_variable.ReloadEnv() - service := newKicsService("./test", loader.Kics{}) + service := newKicsService("./test", loader.Kics{}, nil) ddMock := &mockDefectDojoService{getTestsErr: fmt.Errorf("cannot retrieve tests")} err := service.Sync(1, "main", ddMock) @@ -217,7 +217,7 @@ func TestSync(t *testing.T) { _ = os.Setenv("SCAN_DIR", fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["PWD"], "")) environment_variable.ReloadEnv() - service := newKicsService("./test", loader.Kics{}) + service := newKicsService("./test", loader.Kics{}, nil) ddMock := &mockDefectDojoService{} err := service.Sync(1, "main", ddMock) diff --git a/features/scans/opengrep/factory.go b/features/scans/opengrep/factory.go index e099878..c3fc302 100644 --- a/features/scans/opengrep/factory.go +++ b/features/scans/opengrep/factory.go @@ -8,5 +8,5 @@ import ( // GetOpenGrepService constructs and returns a ScanServiceImpl for the OpenGrep scanner // using the provided loader configuration. func GetOpenGrepService(config loader.Config) interfaces.ScanServiceImpl { - return newOpenGrepService(config.Path, *config.Opengrep) + return newOpenGrepService(config.Path, *config.Opengrep, config.Proxy.ToEnv()) } diff --git a/features/scans/opengrep/service.go b/features/scans/opengrep/service.go index b677740..705ec22 100644 --- a/features/scans/opengrep/service.go +++ b/features/scans/opengrep/service.go @@ -24,16 +24,20 @@ type OpenGrepServiceImpl struct { output string exclude []string excludeRule []string + proxyEnv []string } // newOpenGrepService builds an OpenGrepServiceImpl from the scan path and loader configuration, // resolving the scan path and output file path relative to the SCAN_DIR environment variable. -func newOpenGrepService(path string, config loader.Opengrep) interfaces.ScanServiceImpl { +// proxyEnv is an optional list of "KEY=VALUE" proxy environment variable entries +// (see loader.Proxy.ToEnv) forwarded to the OpenGrep process. +func newOpenGrepService(path string, config loader.Opengrep, proxyEnv []string) interfaces.ScanServiceImpl { return &OpenGrepServiceImpl{ path: fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["SCAN_DIR"], path), output: fmt.Sprintf("%s/%s/%s", environment_variable.EnvironmentVariable["SCAN_DIR"], outputFolder, outputNameParameter), exclude: config.Exclude, excludeRule: config.ExcludeRule, + proxyEnv: proxyEnv, } } @@ -79,7 +83,7 @@ func (s *OpenGrepServiceImpl) Start() (bool, error) { logger.Info(fmt.Sprintf(logInfoCommandLine, strings.Join(args, " "))) - return exec.WrapAllowExitCodes(binaryPath, dirPath, args, io.Discard, io.Discard, []int{exitCodeFindings}) + return exec.WrapAllowExitCodes(binaryPath, dirPath, args, io.Discard, io.Discard, []int{exitCodeFindings}, s.proxyEnv...) } // LoadFindings reads the OpenGrep JSON output file and converts each result diff --git a/features/scans/opengrep/service_test.go b/features/scans/opengrep/service_test.go index 5144668..84e9dac 100644 --- a/features/scans/opengrep/service_test.go +++ b/features/scans/opengrep/service_test.go @@ -71,7 +71,7 @@ var _ defectdojo.DefectDojoService = &mockDefectDojoService{} // a value that satisfies the interfaces.ScanServiceImpl contract, enforcing that // all required scanner methods are present at compile and test time. func TestNewOpenGrepServiceImplementsInterface(t *testing.T) { - service := newOpenGrepService("./test", loader.Opengrep{}) + service := newOpenGrepService("./test", loader.Opengrep{}, nil) _, ok := service.(interfaces.ScanServiceImpl) assert.True(t, ok) @@ -96,7 +96,7 @@ func TestOpenGrepStart(t *testing.T) { _ = os.Setenv("SCAN_DIR", fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["PWD"], "")) environment_variable.ReloadEnv() - service := newOpenGrepService("./nonexistent", loader.Opengrep{}) + service := newOpenGrepService("./nonexistent", loader.Opengrep{}, nil) ok, err := service.Start() @@ -111,7 +111,7 @@ func TestOpenGrepLoadFindings(t *testing.T) { _ = os.Setenv("SCAN_DIR", fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["PWD"], "./mocks/working_results")) environment_variable.ReloadEnv() - service := newOpenGrepService("./test", loader.Opengrep{}) + service := newOpenGrepService("./test", loader.Opengrep{}, nil) findings, err := service.LoadFindings() @@ -137,7 +137,7 @@ func TestOpenGrepLoadFindings(t *testing.T) { _ = os.Setenv("SCAN_DIR", fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["PWD"], "./mocks/string_metadata_results")) environment_variable.ReloadEnv() - service := newOpenGrepService("./test", loader.Opengrep{}) + service := newOpenGrepService("./test", loader.Opengrep{}, nil) findings, err := service.LoadFindings() @@ -152,7 +152,7 @@ func TestOpenGrepLoadFindings(t *testing.T) { _ = os.Setenv("SCAN_DIR", fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["PWD"], "")) environment_variable.ReloadEnv() - service := newOpenGrepService("./test", loader.Opengrep{}) + service := newOpenGrepService("./test", loader.Opengrep{}, nil) findings, err := service.LoadFindings() @@ -164,7 +164,7 @@ func TestOpenGrepLoadFindings(t *testing.T) { _ = os.Setenv("SCAN_DIR", fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["PWD"], "./mocks/bad_format_results")) environment_variable.ReloadEnv() - service := newOpenGrepService("./test", loader.Opengrep{}) + service := newOpenGrepService("./test", loader.Opengrep{}, nil) findings, err := service.LoadFindings() @@ -268,7 +268,7 @@ func TestOpenGrepSync(t *testing.T) { _ = os.Setenv("SCAN_DIR", fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["PWD"], "./mocks/working_results")) environment_variable.ReloadEnv() - service := newOpenGrepService("./test", loader.Opengrep{}) + service := newOpenGrepService("./test", loader.Opengrep{}, nil) ddMock := &mockDefectDojoService{importScanOk: true, importScanErr: nil} err := service.Sync(1, "main", ddMock) @@ -280,7 +280,7 @@ func TestOpenGrepSync(t *testing.T) { _ = os.Setenv("SCAN_DIR", fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["PWD"], "./mocks/working_results")) environment_variable.ReloadEnv() - service := newOpenGrepService("./test", loader.Opengrep{}) + service := newOpenGrepService("./test", loader.Opengrep{}, nil) ddMock := &mockDefectDojoService{ testsToReturn: []defectdojo.Test{{Id: 7, ScanType: "Semgrep JSON Report"}}, reimportScanOk: true, @@ -297,7 +297,7 @@ func TestOpenGrepSync(t *testing.T) { _ = os.Setenv("SCAN_DIR", fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["PWD"], "./mocks/working_results")) environment_variable.ReloadEnv() - service := newOpenGrepService("./test", loader.Opengrep{}) + service := newOpenGrepService("./test", loader.Opengrep{}, nil) ddMock := &mockDefectDojoService{importScanOk: false, importScanErr: fmt.Errorf("import failed")} err := service.Sync(1, "main", ddMock) @@ -309,7 +309,7 @@ func TestOpenGrepSync(t *testing.T) { _ = os.Setenv("SCAN_DIR", fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["PWD"], "./mocks/working_results")) environment_variable.ReloadEnv() - service := newOpenGrepService("./test", loader.Opengrep{}) + service := newOpenGrepService("./test", loader.Opengrep{}, nil) ddMock := &mockDefectDojoService{ testsToReturn: []defectdojo.Test{{Id: 7, ScanType: "Semgrep JSON Report"}}, reimportScanOk: false, @@ -325,7 +325,7 @@ func TestOpenGrepSync(t *testing.T) { _ = os.Setenv("SCAN_DIR", fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["PWD"], "./mocks/working_results")) environment_variable.ReloadEnv() - service := newOpenGrepService("./test", loader.Opengrep{}) + service := newOpenGrepService("./test", loader.Opengrep{}, nil) ddMock := &mockDefectDojoService{getTestsErr: fmt.Errorf("cannot retrieve tests")} err := service.Sync(1, "main", ddMock) @@ -337,7 +337,7 @@ func TestOpenGrepSync(t *testing.T) { _ = os.Setenv("SCAN_DIR", fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["PWD"], "")) environment_variable.ReloadEnv() - service := newOpenGrepService("./test", loader.Opengrep{}) + service := newOpenGrepService("./test", loader.Opengrep{}, nil) ddMock := &mockDefectDojoService{} err := service.Sync(1, "main", ddMock) diff --git a/features/scans/syft/factory.go b/features/scans/syft/factory.go index eebf42a..accbba2 100644 --- a/features/scans/syft/factory.go +++ b/features/scans/syft/factory.go @@ -14,5 +14,5 @@ func GetSyftService(config loader.Config) interfaces.ScanServiceImpl { if config.Grype != nil { transitiveLibraries = config.Grype.TransitiveLibraries } - return newSyftService(config.Path, transitiveLibraries) + return newSyftService(config.Path, transitiveLibraries, config.Proxy.ToEnv()) } diff --git a/features/scans/syft/service.go b/features/scans/syft/service.go index 86b9de4..37b027b 100644 --- a/features/scans/syft/service.go +++ b/features/scans/syft/service.go @@ -22,16 +22,20 @@ type execRunner func(binaryPath string, dirPath string, args []string, stdout io type SyftServiceImpl struct { path string transitiveLibraries bool + proxyEnv []string runner execRunner } // newSyftService builds a SyftServiceImpl from the scan path, resolving it // relative to the SCAN_DIR environment variable. transitiveLibraries controls // whether Syft resolves transitive Java dependencies from Maven Central. -func newSyftService(path string, transitiveLibraries bool) interfaces.ScanServiceImpl { +// proxyEnv is an optional list of "KEY=VALUE" proxy environment variable entries +// (see loader.Proxy.ToEnv) forwarded to the Syft process. +func newSyftService(path string, transitiveLibraries bool, proxyEnv []string) interfaces.ScanServiceImpl { return &SyftServiceImpl{ path: fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["SCAN_DIR"], path), transitiveLibraries: transitiveLibraries, + proxyEnv: proxyEnv, runner: exec.Wrap, } } @@ -60,10 +64,12 @@ func (s *SyftServiceImpl) Start() (bool, error) { logger.Info(fmt.Sprintf(logInfoCommandLine, strings.Join(args, " "))) transitiveValue := fmt.Sprintf("%v", s.transitiveLibraries) - return s.runner(binaryPath, dirPath, args, os.Stdout, os.Stderr, + extraEnv := []string{ fmt.Sprintf("%s=%s", envJavaUseNetwork, transitiveValue), fmt.Sprintf("%s=%s", envJavaResolveTransitiveDependencies, transitiveValue), - ) + } + extraEnv = append(extraEnv, s.proxyEnv...) + return s.runner(binaryPath, dirPath, args, os.Stdout, os.Stderr, extraEnv...) } // LoadFindings is intentionally empty: Syft is used only to produce the SBOM diff --git a/features/scans/syft/service_test.go b/features/scans/syft/service_test.go index 5426614..701a6d2 100644 --- a/features/scans/syft/service_test.go +++ b/features/scans/syft/service_test.go @@ -12,7 +12,7 @@ import ( ) func TestNewSyftService(t *testing.T) { - service := newSyftService("./test", false) + service := newSyftService("./test", false, nil) _, ok := service.(interfaces.ScanServiceImpl) assert.NotNil(t, service) @@ -24,7 +24,7 @@ func TestSyftStart(t *testing.T) { _ = os.Setenv("SCAN_DIR", fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["PWD"], "")) environment_variable.ReloadEnv() - service := newSyftService("./doesnotexist", false) + service := newSyftService("./doesnotexist", false, nil) ok, err := service.Start() @@ -37,7 +37,7 @@ func TestSyftStart(t *testing.T) { _ = os.Setenv("SCAN_DIR", os.TempDir()) environment_variable.ReloadEnv() - svc := newSyftService(".", false).(*SyftServiceImpl) + svc := newSyftService(".", false, nil).(*SyftServiceImpl) svc.runner = func(_ string, _ string, _ []string, _ io.Writer, _ io.Writer, _ ...string) (bool, error) { return false, fmt.Errorf("runner error") } @@ -52,7 +52,7 @@ func TestSyftStart(t *testing.T) { _ = os.Setenv("SCAN_DIR", os.TempDir()) environment_variable.ReloadEnv() - svc := newSyftService(".", true).(*SyftServiceImpl) + svc := newSyftService(".", true, nil).(*SyftServiceImpl) svc.runner = func(_ string, _ string, _ []string, _ io.Writer, _ io.Writer, _ ...string) (bool, error) { return true, nil } @@ -66,7 +66,7 @@ func TestSyftStart(t *testing.T) { func TestSyftLoadFindings(t *testing.T) { t.Run("Should return nil findings and nil error", func(t *testing.T) { - service := newSyftService("./test", false) + service := newSyftService("./test", false, nil) findings, err := service.LoadFindings() @@ -77,7 +77,7 @@ func TestSyftLoadFindings(t *testing.T) { func TestSyftSync(t *testing.T) { t.Run("Should return nil error", func(t *testing.T) { - service := newSyftService("./test", false) + service := newSyftService("./test", false, nil) err := service.Sync(1, "main", nil) diff --git a/loader/dto.go b/loader/dto.go index 4e956bd..e6731f4 100644 --- a/loader/dto.go +++ b/loader/dto.go @@ -8,6 +8,7 @@ type ( Kics *Kics Grype *Grype Opengrep *Opengrep + Proxy *Proxy ProtectedBranches []string `toml:"protected_branches"` } @@ -33,4 +34,36 @@ type ( Exclude []string `toml:"exclude"` ExcludeRule []string `toml:"exclude_rule"` } + + // Proxy holds optional HTTP proxy settings forwarded to scanner sub-processes + // as HTTP_PROXY, HTTPS_PROXY, and NO_PROXY environment variables. + // All three fields are optional and are omitted from the child environment when empty. + Proxy struct { + HttpProxy string `toml:"http_proxy"` + HttpsProxy string `toml:"https_proxy"` + NoProxy string `toml:"no_proxy"` + } ) + +// ToEnv converts the Proxy configuration into a list of "KEY=VALUE" environment +// variable entries suitable for passing as extraEnv to exec.Wrap / exec.WrapAllowExitCodes. +// Both the uppercase (HTTP_PROXY) and lowercase (http_proxy) variants are included +// for maximum compatibility across tools. Returns nil when the receiver is nil or +// all fields are empty. +func (p *Proxy) ToEnv() []string { + if p == nil { + return nil + } + + var env []string + if p.HttpProxy != "" { + env = append(env, "HTTP_PROXY="+p.HttpProxy, "http_proxy="+p.HttpProxy) + } + if p.HttpsProxy != "" { + env = append(env, "HTTPS_PROXY="+p.HttpsProxy, "https_proxy="+p.HttpsProxy) + } + if p.NoProxy != "" { + env = append(env, "NO_PROXY="+p.NoProxy, "no_proxy="+p.NoProxy) + } + return env +} diff --git a/loader/dto_test.go b/loader/dto_test.go new file mode 100644 index 0000000..63214c9 --- /dev/null +++ b/loader/dto_test.go @@ -0,0 +1,50 @@ +package loader + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestProxyToEnv(t *testing.T) { + t.Run("Should return nil for nil proxy", func(t *testing.T) { + var p *Proxy + assert.Nil(t, p.ToEnv()) + }) + + t.Run("Should return nil for empty proxy", func(t *testing.T) { + p := &Proxy{} + assert.Nil(t, p.ToEnv()) + }) + + t.Run("Should return HTTP_PROXY entries when http_proxy is set", func(t *testing.T) { + p := &Proxy{HttpProxy: "http://proxy.example.com:3128"} + env := p.ToEnv() + assert.Contains(t, env, "HTTP_PROXY=http://proxy.example.com:3128") + assert.Contains(t, env, "http_proxy=http://proxy.example.com:3128") + }) + + t.Run("Should return HTTPS_PROXY entries when https_proxy is set", func(t *testing.T) { + p := &Proxy{HttpsProxy: "http://proxy.example.com:3128"} + env := p.ToEnv() + assert.Contains(t, env, "HTTPS_PROXY=http://proxy.example.com:3128") + assert.Contains(t, env, "https_proxy=http://proxy.example.com:3128") + }) + + t.Run("Should return NO_PROXY entries when no_proxy is set", func(t *testing.T) { + p := &Proxy{NoProxy: "localhost,127.0.0.1"} + env := p.ToEnv() + assert.Contains(t, env, "NO_PROXY=localhost,127.0.0.1") + assert.Contains(t, env, "no_proxy=localhost,127.0.0.1") + }) + + t.Run("Should return all six entries when all fields are set", func(t *testing.T) { + p := &Proxy{ + HttpProxy: "http://proxy.example.com:3128", + HttpsProxy: "https://proxy.example.com:3128", + NoProxy: "localhost", + } + env := p.ToEnv() + assert.Len(t, env, 6) + }) +} diff --git a/loader/loader_test.go b/loader/loader_test.go index 049897c..a6b4f27 100644 --- a/loader/loader_test.go +++ b/loader/loader_test.go @@ -53,6 +53,16 @@ func TestLoad(t *testing.T) { assert.EqualValues(t, []string{"python.lang.correctness"}, config.Opengrep.ExcludeRule) }) + t.Run("Should load configuration file with proxy section", func(t *testing.T) { + config, err := Load("./mocks/config_with_proxy.toml") + + assert.Nil(t, err) + assert.NotNil(t, config.Proxy) + assert.EqualValues(t, "http://proxy.example.com:3128", config.Proxy.HttpProxy) + assert.EqualValues(t, "http://proxy.example.com:3128", config.Proxy.HttpsProxy) + assert.EqualValues(t, "localhost,127.0.0.1", config.Proxy.NoProxy) + }) + t.Run("Should not load configuration file cause wrong pathname", func(t *testing.T) { config, err := Load("./mocks/does_not_exist.toml") diff --git a/loader/mocks/config_with_proxy.toml b/loader/mocks/config_with_proxy.toml new file mode 100644 index 0000000..56e7033 --- /dev/null +++ b/loader/mocks/config_with_proxy.toml @@ -0,0 +1,7 @@ +title = "Scope-guardian configuration file" +path = "./" + +[proxy] +http_proxy = "http://proxy.example.com:3128" +https_proxy = "http://proxy.example.com:3128" +no_proxy = "localhost,127.0.0.1" From 75fd0f67bc7dcb63adad675e80d1c7daca6458d4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Apr 2026 17:07:30 +0000 Subject: [PATCH 06/11] revert: remove exit-code fix and gitlab-ci, keep proxy changes only Agent-Logs-Url: https://github.com/ParanoiHack/ScopeGuardian/sessions/40f43d9a-5370-4b44-86e2-ac31f8a9e7c5 Co-authored-by: Nitr4x <10544300+Nitr4x@users.noreply.github.com> --- .gitlab-ci.yml | 119 ----------------------------- exec/exec.go | 17 ----- exec/exec_test.go | 25 ------ features/scans/grype/const.go | 6 -- features/scans/grype/service.go | 2 +- features/scans/opengrep/const.go | 6 -- features/scans/opengrep/service.go | 2 +- 7 files changed, 2 insertions(+), 175 deletions(-) delete mode 100644 .gitlab-ci.yml diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml deleted file mode 100644 index fbc152b..0000000 --- a/.gitlab-ci.yml +++ /dev/null @@ -1,119 +0,0 @@ -# --------------------------------------------------------------------------- -# ScopeGuardian – GitLab CI example -# -# This file shows three typical usage patterns: -# 1. scan-only – run all scanners, display results, no DefectDojo sync -# 2. scan-and-sync – run all scanners and upload results to DefectDojo -# 3. security-gate – same as scan-and-sync but fail the pipeline when -# finding counts exceed the configured thresholds -# -# Required CI/CD variables (Settings → CI/CD → Variables): -# DD_URL – Base URL of your DefectDojo instance -# (e.g. https://defectdojo.example.com) -# DD_ACCESS_TOKEN – DefectDojo API v2 token -# (User → API v2 Key inside DefectDojo) -# -# The Docker image bundles ScopeGuardian together with KICS, OpenGrep, -# Grype and Syft so no extra installation step is needed. -# --------------------------------------------------------------------------- - -default: - image: - name: ghcr.io/paranoihack/scopeguardian:latest - # The image defines an ENTRYPOINT; override it so the GitLab runner can - # start a shell and execute the script: commands normally. - entrypoint: [""] - -variables: - # Base directory that ScopeGuardian uses to resolve scan paths defined in - # config.toml. GitLab clones the repository into CI_PROJECT_DIR, so set - # SCAN_DIR to the parent directory so that relative paths like "./my-repo" - # resolve correctly. - SCAN_DIR: $CI_PROJECT_DIR - -# --------------------------------------------------------------------------- -# Stage definitions -# --------------------------------------------------------------------------- -stages: - - security-scan - -# --------------------------------------------------------------------------- -# 1. Scan only (no DefectDojo sync) -# Useful for a quick local feedback loop on merge requests. -# --------------------------------------------------------------------------- -scan-only: - stage: security-scan - rules: - - if: $CI_PIPELINE_SOURCE == "merge_request_event" - variables: - SCAN_DIR: $CI_PROJECT_DIR - script: - - /opt/ScopeGuardian/bin/ScopeGuardian - --projectName "$CI_PROJECT_NAME" - --branch "$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME" - --filter ACTIVE - -o $CI_PROJECT_DIR/scopeguardian-findings.json - $CI_PROJECT_DIR/config.toml - artifacts: - name: "scopeguardian-$CI_COMMIT_REF_SLUG" - when: always - expire_in: 7 days - paths: - - scopeguardian-findings.json - -# --------------------------------------------------------------------------- -# 2. Scan and sync to DefectDojo -# Uploads findings to DefectDojo on every push to the default branch. -# --------------------------------------------------------------------------- -scan-and-sync: - stage: security-scan - rules: - - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH - variables: - SCAN_DIR: $CI_PROJECT_DIR - # DD_URL and DD_ACCESS_TOKEN must be set as CI/CD variables (masked). - script: - - /opt/ScopeGuardian/bin/ScopeGuardian - --projectName "$CI_PROJECT_NAME" - --branch "$CI_COMMIT_BRANCH" - --sync - --filter ACTIVE - -o $CI_PROJECT_DIR/scopeguardian-findings.json - $CI_PROJECT_DIR/config.toml - artifacts: - name: "scopeguardian-$CI_COMMIT_REF_SLUG" - when: always - expire_in: 30 days - paths: - - scopeguardian-findings.json - -# --------------------------------------------------------------------------- -# 3. Scan, sync and enforce a security gate -# Fails the pipeline (exit -1) when active finding counts exceed the -# configured thresholds. Adjust the --threshold values to fit your -# security policy. -# --------------------------------------------------------------------------- -security-gate: - stage: security-scan - rules: - - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH - when: manual - allow_failure: false - variables: - SCAN_DIR: $CI_PROJECT_DIR - # DD_URL and DD_ACCESS_TOKEN must be set as CI/CD variables (masked). - script: - - /opt/ScopeGuardian/bin/ScopeGuardian - --projectName "$CI_PROJECT_NAME" - --branch "$CI_COMMIT_BRANCH" - --sync - --threshold critical=1,high=5 - --filter ACTIVE - -o $CI_PROJECT_DIR/scopeguardian-findings.json - $CI_PROJECT_DIR/config.toml - artifacts: - name: "scopeguardian-gate-$CI_COMMIT_REF_SLUG" - when: always - expire_in: 30 days - paths: - - scopeguardian-findings.json diff --git a/exec/exec.go b/exec/exec.go index 6524ce6..9e0153e 100644 --- a/exec/exec.go +++ b/exec/exec.go @@ -14,15 +14,6 @@ import ( // Optional extraEnv entries (formatted as "KEY=VALUE") are appended to the // child process environment without affecting the parent process. func Wrap(binaryPath string, dirPath string, args []string, stdout io.Writer, stderr io.Writer, extraEnv ...string) (bool, error) { - return WrapAllowExitCodes(binaryPath, dirPath, args, stdout, stderr, nil, extraEnv...) -} - -// WrapAllowExitCodes is like Wrap but treats any exit code listed in -// successCodes as a successful execution in addition to the standard exit 0. -// This is needed for scanners such as Grype (exit 1 = vulnerabilities found) -// and OpenGrep (exit 2 = findings found) that use non-zero exit codes to -// signal normal "findings present" conditions rather than errors. -func WrapAllowExitCodes(binaryPath string, dirPath string, args []string, stdout io.Writer, stderr io.Writer, successCodes []int, extraEnv ...string) (bool, error) { cmd := exec.Command(binaryPath, args...) cmd.Dir = dirPath @@ -34,14 +25,6 @@ func WrapAllowExitCodes(binaryPath string, dirPath string, args []string, stdout } if err := cmd.Run(); err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - code := exitErr.ExitCode() - for _, allowed := range successCodes { - if code == allowed { - return true, nil - } - } - } return false, err } diff --git a/exec/exec_test.go b/exec/exec_test.go index 1653f72..6751475 100644 --- a/exec/exec_test.go +++ b/exec/exec_test.go @@ -23,28 +23,3 @@ func TestWrap(t *testing.T) { assert.False(t, ok) }) } - -func TestWrapAllowExitCodes(t *testing.T) { - t.Run("Should treat allowed non-zero exit code as success", func(t *testing.T) { - // 'false' exits with code 1 - ok, err := WrapAllowExitCodes("/bin/false", "/", []string{}, io.Discard, io.Discard, []int{1}) - - assert.Nil(t, err) - assert.True(t, ok) - }) - - t.Run("Should return error for disallowed non-zero exit code", func(t *testing.T) { - // 'false' exits with code 1; only code 2 is allowed here - ok, err := WrapAllowExitCodes("/bin/false", "/", []string{}, io.Discard, io.Discard, []int{2}) - - assert.NotNil(t, err) - assert.False(t, ok) - }) - - t.Run("Should succeed normally on exit 0", func(t *testing.T) { - ok, err := WrapAllowExitCodes("/bin/true", "/", []string{}, io.Discard, io.Discard, []int{1}) - - assert.Nil(t, err) - assert.True(t, ok) - }) -} diff --git a/features/scans/grype/const.go b/features/scans/grype/const.go index 59785f8..b8a8892 100644 --- a/features/scans/grype/const.go +++ b/features/scans/grype/const.go @@ -51,12 +51,6 @@ const ( errSbomNotFound = "sbom not found" ) -const ( - // exitCodeFindings is the exit code returned by Grype when vulnerabilities - // are found. This is a normal termination state, not an error. - exitCodeFindings = 1 -) - const ( recommendationUpgrade = "Upgrade to %s" recommendationUpgradeMultiple = "Upgrade to one of: %s" diff --git a/features/scans/grype/service.go b/features/scans/grype/service.go index 2cc75e6..899fd3f 100644 --- a/features/scans/grype/service.go +++ b/features/scans/grype/service.go @@ -73,7 +73,7 @@ func (s *GrypeServiceImpl) Start() (bool, error) { logger.Info(fmt.Sprintf(logInfoCommandLine, strings.Join(args, " "))) - return exec.WrapAllowExitCodes(binaryPath, dirPath, args, os.Stdout, os.Stderr, []int{exitCodeFindings}, s.proxyEnv...) + return exec.Wrap(binaryPath, dirPath, args, os.Stdout, os.Stderr, s.proxyEnv...) } // LoadFindings reads the Grype JSON output file and converts each vulnerability diff --git a/features/scans/opengrep/const.go b/features/scans/opengrep/const.go index 4121745..9590180 100644 --- a/features/scans/opengrep/const.go +++ b/features/scans/opengrep/const.go @@ -46,9 +46,3 @@ const ( const ( errDirectoryNotFound = "directory not found" ) - -const ( - // exitCodeFindings is the exit code returned by OpenGrep (Semgrep convention) - // when findings are present. This is a normal termination state, not an error. - exitCodeFindings = 2 -) diff --git a/features/scans/opengrep/service.go b/features/scans/opengrep/service.go index 705ec22..572cebe 100644 --- a/features/scans/opengrep/service.go +++ b/features/scans/opengrep/service.go @@ -83,7 +83,7 @@ func (s *OpenGrepServiceImpl) Start() (bool, error) { logger.Info(fmt.Sprintf(logInfoCommandLine, strings.Join(args, " "))) - return exec.WrapAllowExitCodes(binaryPath, dirPath, args, io.Discard, io.Discard, []int{exitCodeFindings}, s.proxyEnv...) + return exec.Wrap(binaryPath, dirPath, args, io.Discard, io.Discard, s.proxyEnv...) } // LoadFindings reads the OpenGrep JSON output file and converts each result From ae47f7a7b05a203ef5d7a063d026ae4fab576230 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:15:29 +0000 Subject: [PATCH 07/11] feat: create results dir in main.go; document proxy in README Agent-Logs-Url: https://github.com/ParanoiHack/ScopeGuardian/sessions/1319baa4-15ed-4d9a-ac67-a4dd402f4f83 Co-authored-by: Nitr4x <10544300+Nitr4x@users.noreply.github.com> --- README.md | 14 ++++++++++++++ features/scans/grype/service.go | 5 ----- features/scans/opengrep/service.go | 5 ----- main.go | 26 +++++++++++++++++++------- 4 files changed, 33 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 56a6d8c..fc1d8e4 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,13 @@ path = "./my-service" # exclude = ["**/vendor/**", "**/testdata/**"] # Optional list of rule IDs to skip. # exclude_rule = ["python.lang.security.audit.formatted-sql-query.formatted-sql-query"] + +# Proxy – optional HTTP/HTTPS proxy settings forwarded to all scanner sub-processes. +# All three fields are optional. Omit the entire section or leave fields empty to disable. +# [proxy] +# http_proxy = "http://proxy.company.com:3128" +# https_proxy = "http://proxy.company.com:3128" +# no_proxy = "localhost,127.0.0.1" ``` ### Fields Reference @@ -177,6 +184,9 @@ path = "./my-service" | `[opengrep].path` | string | yes* | Path to the directory to scan. Resolved as `$SCAN_DIR/`. | | `[opengrep].exclude` | string array | no | Path glob patterns to exclude from OpenGrep scanning (e.g. `["**/vendor/**"]`). | | `[opengrep].exclude_rule` | string array | no | OpenGrep rule IDs to skip (e.g. `["python.lang.security.audit.formatted-sql-query.formatted-sql-query"]`). | +| `[proxy].http_proxy` | string | no | HTTP proxy URL forwarded as `HTTP_PROXY` / `http_proxy` to all scanner sub-processes. | +| `[proxy].https_proxy` | string | no | HTTPS proxy URL forwarded as `HTTPS_PROXY` / `https_proxy` to all scanner sub-processes. | +| `[proxy].no_proxy` | string | no | Comma-separated list of hosts that bypass the proxy, forwarded as `NO_PROXY` / `no_proxy`. | \* Required only if you want KICS scanning to run. Omitting the entire `[kics]` section disables the scanner. @@ -184,6 +194,10 @@ Omitting the entire `[grype]` section disables both Grype and the Syft SBOM gene Omitting the entire `[opengrep]` section disables the SAST scanner. +Omitting the entire `[proxy]` section (or leaving all fields empty) disables proxy forwarding — scanner sub-processes inherit no proxy environment variables from this configuration. + +Both the uppercase (`HTTP_PROXY`) and lowercase (`http_proxy`) variants of each variable are set for maximum compatibility across tools. + --- ## Environment Variables diff --git a/features/scans/grype/service.go b/features/scans/grype/service.go index 899fd3f..9499675 100644 --- a/features/scans/grype/service.go +++ b/features/scans/grype/service.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "os" - "path/filepath" "ScopeGuardian/connectors/defectdojo" "ScopeGuardian/domains/interfaces" "ScopeGuardian/domains/models" @@ -45,10 +44,6 @@ func newGrypeService(config loader.Grype, proxyEnv []string) interfaces.ScanServ // to scan it for known vulnerabilities. It returns true on success or false and an // error if the SBOM is missing or the Grype process exits with a non-zero status. func (s *GrypeServiceImpl) Start() (bool, error) { - if err := os.MkdirAll(filepath.Dir(s.output), 0755); err != nil { - return false, err - } - if _, err := os.Stat(s.sbom); err != nil { logger.Error(fmt.Sprintf(logErrorSbomNotFound, s.sbom)) return false, errors.New(errSbomNotFound) diff --git a/features/scans/opengrep/service.go b/features/scans/opengrep/service.go index 572cebe..46cd526 100644 --- a/features/scans/opengrep/service.go +++ b/features/scans/opengrep/service.go @@ -6,7 +6,6 @@ import ( "fmt" "io" "os" - "path/filepath" "ScopeGuardian/connectors/defectdojo" "ScopeGuardian/domains/interfaces" "ScopeGuardian/domains/models" @@ -60,10 +59,6 @@ func (s *OpenGrepServiceImpl) Start() (bool, error) { return ok, err } - if err := os.MkdirAll(filepath.Dir(s.output), 0755); err != nil { - return false, err - } - args := []string{ fmt.Sprintf("%s%s", jsonOutputArgument, s.output), ossOnlyArgument, diff --git a/main.go b/main.go index dd58600..2b6a931 100644 --- a/main.go +++ b/main.go @@ -2,24 +2,28 @@ package main import ( "os" + "path/filepath" "ScopeGuardian/display" "ScopeGuardian/domains/models" "ScopeGuardian/engine" + environment_variable "ScopeGuardian/environnement_variable" + securitygate "ScopeGuardian/features/security-gate" "ScopeGuardian/loader" "ScopeGuardian/logger" "ScopeGuardian/parser" - securitygate "ScopeGuardian/features/security-gate" "golang.org/x/exp/slog" ) const ( - logInfoLoadConfigFile = "Loading configuration file" - logInfoDumpFindings = "Findings successfully written to output file" - logErrOutputFile = "Failed to create output file" - logErrCloseOutputFile = "Failed to close output file" - logErrDumpFindings = "Failed to write findings to output file" - logErrMarkByDD = "Failed to retrieve finding statuses from DefectDojo; all findings treated as active" + logInfoLoadConfigFile = "Loading configuration file" + logInfoDumpFindings = "Findings successfully written to output file" + logInfoCreateResultsDir = "Creating results directory" + logErrCreateResultsDir = "Failed to create results directory" + logErrOutputFile = "Failed to create output file" + logErrCloseOutputFile = "Failed to close output file" + logErrDumpFindings = "Failed to write findings to output file" + logErrMarkByDD = "Failed to retrieve finding statuses from DefectDojo; all findings treated as active" ) func main() { @@ -52,6 +56,14 @@ func main() { eng := engine.NewEngine() eng.Initialize(config) + + logger.Info(logInfoCreateResultsDir) + resultsDir := filepath.Join(environment_variable.EnvironmentVariable["SCAN_DIR"], "results") + if err := os.MkdirAll(resultsDir, 0755); err != nil { + logger.Error(logErrCreateResultsDir, logger.Err(err)) + os.Exit(1) + } + eng.Start() findings := eng.LoadFindings() From 6171823786f5133f1f4ffaf6a3667dcfbdf7ead2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:23:27 +0000 Subject: [PATCH 08/11] refactor: move results dir creation into engine.Initialize Agent-Logs-Url: https://github.com/ParanoiHack/ScopeGuardian/sessions/7ea85c3d-5d2e-4a3e-ad0b-aa4d375a5b04 Co-authored-by: Nitr4x <10544300+Nitr4x@users.noreply.github.com> --- engine/const.go | 2 ++ engine/engine.go | 16 +++++++++++++++- engine/engine_test.go | 12 ++++++++---- main.go | 24 ++++++++---------------- 4 files changed, 33 insertions(+), 21 deletions(-) diff --git a/engine/const.go b/engine/const.go index 2bff3f1..9b5f091 100644 --- a/engine/const.go +++ b/engine/const.go @@ -5,6 +5,7 @@ const ( logInfoSyftRegister = "Grype enabled. Registering Syft SBOM scanner for execution" logInfoGrypeRegister = "Grype enabled. Registering Grype vulnerability scanner for execution" logInfoOpenGrepRegister = "OpenGrep enabled. Registering OpenGrep SAST scanner for execution" + logInfoCreateResultsDir = "Creating results directory" logInfoScannerStarting = "Starting %s scanning engine" logInfoScannerSuccess = "%s scanner succeeded" logInfoFindingsLoaded = "%s findings loaded" @@ -19,6 +20,7 @@ const ( logErrorRetrieveEngagementId = "Cannot retrieve engagement ID for project [%s] branch [%s]" logErrorSkippingScanner = "Skipping %s scanner because prerequisite %s failed" logErrorSyncResult = "Failed to sync %s results to DefectDojo" + logErrorCreateResultsDir = "Failed to create results directory" ) const ( diff --git a/engine/engine.go b/engine/engine.go index bc942db..46d7a48 100644 --- a/engine/engine.go +++ b/engine/engine.go @@ -3,6 +3,8 @@ package engine import ( "fmt" "net/http" + "os" + "path/filepath" "ScopeGuardian/connectors/defectdojo" "ScopeGuardian/connectors/defectdojo/client" "ScopeGuardian/domains/interfaces" @@ -45,7 +47,10 @@ func NewEngine() *Engine { // Initialize reads the provided configuration and registers any scanner whose // section is present and non-empty. Syft is registered as a prerequisite for // Grype so that it always runs and completes before Grype starts. -func (e *Engine) Initialize(config loader.Config) { +// It also creates the results directory under SCAN_DIR so that all scanners +// can write their output without having to manage the folder themselves. +// Returns an error if the results directory cannot be created. +func (e *Engine) Initialize(config loader.Config) error { if config.Kics != nil { logger.Info(logInfoKicsRegister) e.registerScanner(kicsScannerName, kics.GetKicsService(config)) @@ -62,6 +67,15 @@ func (e *Engine) Initialize(config loader.Config) { logger.Info(logInfoOpenGrepRegister) e.registerScanner(opengrepScannerName, opengrep.GetOpenGrepService(config)) } + + logger.Info(logInfoCreateResultsDir) + resultsDir := filepath.Join(environment_variable.EnvironmentVariable["SCAN_DIR"], "results") + if err := os.MkdirAll(resultsDir, 0755); err != nil { + logger.Error(logErrorCreateResultsDir, logger.Err(err)) + return err + } + + return nil } // Start runs all registered scanners in two phases: diff --git a/engine/engine_test.go b/engine/engine_test.go index ecea793..4a81ae6 100644 --- a/engine/engine_test.go +++ b/engine/engine_test.go @@ -146,7 +146,8 @@ func TestInitialize(t *testing.T) { assert.Nil(t, err) assert.NotNil(t, config) - engine.Initialize(config) + err = engine.Initialize(config) + assert.Nil(t, err) assert.EqualValues(t, 1, len(engine.scanners)) }) @@ -158,7 +159,8 @@ func TestInitialize(t *testing.T) { assert.Nil(t, err) assert.NotNil(t, config) - engine.Initialize(config) + err = engine.Initialize(config) + assert.Nil(t, err) assert.EqualValues(t, 1, len(engine.prerequisites)) assert.EqualValues(t, 1, len(engine.scanners)) @@ -176,7 +178,8 @@ func TestInitialize(t *testing.T) { assert.Nil(t, err) assert.NotNil(t, config) - engine.Initialize(config) + err = engine.Initialize(config) + assert.Nil(t, err) assert.EqualValues(t, 0, len(engine.scanners)) }) @@ -188,7 +191,8 @@ func TestInitialize(t *testing.T) { assert.Nil(t, err) assert.NotNil(t, config) - engine.Initialize(config) + err = engine.Initialize(config) + assert.Nil(t, err) assert.EqualValues(t, 1, len(engine.scanners)) _, ok := engine.scanners[opengrepScannerName] diff --git a/main.go b/main.go index 2b6a931..4a38308 100644 --- a/main.go +++ b/main.go @@ -2,11 +2,9 @@ package main import ( "os" - "path/filepath" "ScopeGuardian/display" "ScopeGuardian/domains/models" "ScopeGuardian/engine" - environment_variable "ScopeGuardian/environnement_variable" securitygate "ScopeGuardian/features/security-gate" "ScopeGuardian/loader" "ScopeGuardian/logger" @@ -16,14 +14,12 @@ import ( ) const ( - logInfoLoadConfigFile = "Loading configuration file" - logInfoDumpFindings = "Findings successfully written to output file" - logInfoCreateResultsDir = "Creating results directory" - logErrCreateResultsDir = "Failed to create results directory" - logErrOutputFile = "Failed to create output file" - logErrCloseOutputFile = "Failed to close output file" - logErrDumpFindings = "Failed to write findings to output file" - logErrMarkByDD = "Failed to retrieve finding statuses from DefectDojo; all findings treated as active" + logInfoLoadConfigFile = "Loading configuration file" + logInfoDumpFindings = "Findings successfully written to output file" + logErrOutputFile = "Failed to create output file" + logErrCloseOutputFile = "Failed to close output file" + logErrDumpFindings = "Failed to write findings to output file" + logErrMarkByDD = "Failed to retrieve finding statuses from DefectDojo; all findings treated as active" ) func main() { @@ -55,12 +51,8 @@ func main() { eng := engine.NewEngine() - eng.Initialize(config) - - logger.Info(logInfoCreateResultsDir) - resultsDir := filepath.Join(environment_variable.EnvironmentVariable["SCAN_DIR"], "results") - if err := os.MkdirAll(resultsDir, 0755); err != nil { - logger.Error(logErrCreateResultsDir, logger.Err(err)) + if err := eng.Initialize(config); err != nil { + logger.Error(err.Error()) os.Exit(1) } From 2d5b935ffdab5560055014b998a48c56e093854c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 15:04:37 +0000 Subject: [PATCH 09/11] feat(loader): add ssl_cert_file support to Proxy config Agent-Logs-Url: https://github.com/ParanoiHack/ScopeGuardian/sessions/a11ee402-225f-4eed-a92b-c60f637b1a56 Co-authored-by: Nitr4x <10544300+Nitr4x@users.noreply.github.com> --- README.md | 3 ++- config.toml | 7 ++++--- loader/dto.go | 20 +++++++++++++------- loader/dto_test.go | 18 +++++++++++++----- loader/loader_test.go | 1 + loader/mocks/config_with_proxy.toml | 7 ++++--- 6 files changed, 37 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index fc1d8e4..6d251e7 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,7 @@ path = "./my-service" | `[proxy].http_proxy` | string | no | HTTP proxy URL forwarded as `HTTP_PROXY` / `http_proxy` to all scanner sub-processes. | | `[proxy].https_proxy` | string | no | HTTPS proxy URL forwarded as `HTTPS_PROXY` / `https_proxy` to all scanner sub-processes. | | `[proxy].no_proxy` | string | no | Comma-separated list of hosts that bypass the proxy, forwarded as `NO_PROXY` / `no_proxy`. | +| `[proxy].ssl_cert_file` | string | no | Path to a PEM-encoded CA certificate bundle forwarded as `SSL_CERT_FILE` (Go tools) and `REQUESTS_CA_BUNDLE` (Python tools such as OpenGrep) to all scanner sub-processes. Required when using an intercepting proxy (e.g. Burp Suite). | \* Required only if you want KICS scanning to run. Omitting the entire `[kics]` section disables the scanner. @@ -196,7 +197,7 @@ Omitting the entire `[opengrep]` section disables the SAST scanner. Omitting the entire `[proxy]` section (or leaving all fields empty) disables proxy forwarding — scanner sub-processes inherit no proxy environment variables from this configuration. -Both the uppercase (`HTTP_PROXY`) and lowercase (`http_proxy`) variants of each variable are set for maximum compatibility across tools. +Both the uppercase (`HTTP_PROXY`) and lowercase (`http_proxy`) variants of each proxy variable are set for maximum compatibility across tools. The `ssl_cert_file` value is emitted as both `SSL_CERT_FILE` (used by Go-based tools) and `REQUESTS_CA_BUNDLE` (used by Python-based tools such as OpenGrep). --- diff --git a/config.toml b/config.toml index d13c0b1..ed8b350 100644 --- a/config.toml +++ b/config.toml @@ -17,6 +17,7 @@ exclude_rule = [] # Optional proxy configuration forwarded to scanner sub-processes (Grype, OpenGrep, Syft, KICS). # Uncomment and set the values below if the scanners must reach the internet through a proxy. # [proxy] -# http_proxy = "http://proxy.company.com:3128" -# https_proxy = "http://proxy.company.com:3128" -# no_proxy = "localhost,127.0.0.1" +# http_proxy = "http://proxy.company.com:3128" +# https_proxy = "http://proxy.company.com:3128" +# no_proxy = "localhost,127.0.0.1" +# ssl_cert_file = "/path/to/ca.pem" # PEM-encoded CA certificate (e.g. Burp Suite CA) diff --git a/loader/dto.go b/loader/dto.go index e6731f4..fa0d3db 100644 --- a/loader/dto.go +++ b/loader/dto.go @@ -36,20 +36,23 @@ type ( } // Proxy holds optional HTTP proxy settings forwarded to scanner sub-processes - // as HTTP_PROXY, HTTPS_PROXY, and NO_PROXY environment variables. - // All three fields are optional and are omitted from the child environment when empty. + // as HTTP_PROXY, HTTPS_PROXY, NO_PROXY, SSL_CERT_FILE, and REQUESTS_CA_BUNDLE + // environment variables. All fields are optional and are omitted from the child + // environment when empty. Proxy struct { - HttpProxy string `toml:"http_proxy"` - HttpsProxy string `toml:"https_proxy"` - NoProxy string `toml:"no_proxy"` + HttpProxy string `toml:"http_proxy"` + HttpsProxy string `toml:"https_proxy"` + NoProxy string `toml:"no_proxy"` + SslCertFile string `toml:"ssl_cert_file"` } ) // ToEnv converts the Proxy configuration into a list of "KEY=VALUE" environment // variable entries suitable for passing as extraEnv to exec.Wrap / exec.WrapAllowExitCodes. // Both the uppercase (HTTP_PROXY) and lowercase (http_proxy) variants are included -// for maximum compatibility across tools. Returns nil when the receiver is nil or -// all fields are empty. +// for maximum compatibility across tools. SSL_CERT_FILE is also emitted as +// REQUESTS_CA_BUNDLE so that Python-based tools (e.g. OpenGrep) honour the same +// certificate. Returns nil when the receiver is nil or all fields are empty. func (p *Proxy) ToEnv() []string { if p == nil { return nil @@ -65,5 +68,8 @@ func (p *Proxy) ToEnv() []string { if p.NoProxy != "" { env = append(env, "NO_PROXY="+p.NoProxy, "no_proxy="+p.NoProxy) } + if p.SslCertFile != "" { + env = append(env, "SSL_CERT_FILE="+p.SslCertFile, "REQUESTS_CA_BUNDLE="+p.SslCertFile) + } return env } diff --git a/loader/dto_test.go b/loader/dto_test.go index 63214c9..1e25201 100644 --- a/loader/dto_test.go +++ b/loader/dto_test.go @@ -38,13 +38,21 @@ func TestProxyToEnv(t *testing.T) { assert.Contains(t, env, "no_proxy=localhost,127.0.0.1") }) - t.Run("Should return all six entries when all fields are set", func(t *testing.T) { + t.Run("Should return SSL_CERT_FILE and REQUESTS_CA_BUNDLE entries when ssl_cert_file is set", func(t *testing.T) { + p := &Proxy{SslCertFile: "/etc/ssl/certs/burp-ca.pem"} + env := p.ToEnv() + assert.Contains(t, env, "SSL_CERT_FILE=/etc/ssl/certs/burp-ca.pem") + assert.Contains(t, env, "REQUESTS_CA_BUNDLE=/etc/ssl/certs/burp-ca.pem") + }) + + t.Run("Should return all eight entries when all fields are set", func(t *testing.T) { p := &Proxy{ - HttpProxy: "http://proxy.example.com:3128", - HttpsProxy: "https://proxy.example.com:3128", - NoProxy: "localhost", + HttpProxy: "http://proxy.example.com:3128", + HttpsProxy: "https://proxy.example.com:3128", + NoProxy: "localhost", + SslCertFile: "/etc/ssl/certs/burp-ca.pem", } env := p.ToEnv() - assert.Len(t, env, 6) + assert.Len(t, env, 8) }) } diff --git a/loader/loader_test.go b/loader/loader_test.go index a6b4f27..6c2d95d 100644 --- a/loader/loader_test.go +++ b/loader/loader_test.go @@ -61,6 +61,7 @@ func TestLoad(t *testing.T) { assert.EqualValues(t, "http://proxy.example.com:3128", config.Proxy.HttpProxy) assert.EqualValues(t, "http://proxy.example.com:3128", config.Proxy.HttpsProxy) assert.EqualValues(t, "localhost,127.0.0.1", config.Proxy.NoProxy) + assert.EqualValues(t, "/etc/ssl/certs/burp-ca.pem", config.Proxy.SslCertFile) }) t.Run("Should not load configuration file cause wrong pathname", func(t *testing.T) { diff --git a/loader/mocks/config_with_proxy.toml b/loader/mocks/config_with_proxy.toml index 56e7033..56c4c43 100644 --- a/loader/mocks/config_with_proxy.toml +++ b/loader/mocks/config_with_proxy.toml @@ -2,6 +2,7 @@ title = "Scope-guardian configuration file" path = "./" [proxy] -http_proxy = "http://proxy.example.com:3128" -https_proxy = "http://proxy.example.com:3128" -no_proxy = "localhost,127.0.0.1" +http_proxy = "http://proxy.example.com:3128" +https_proxy = "http://proxy.example.com:3128" +no_proxy = "localhost,127.0.0.1" +ssl_cert_file = "/etc/ssl/certs/burp-ca.pem" From ad416e31c716dd368185f63217fb7851a73625e4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 15:29:42 +0000 Subject: [PATCH 10/11] docs: mention --cap-add SYS_PTRACE requirement in README for HTTPS proxy usage Agent-Logs-Url: https://github.com/ParanoiHack/ScopeGuardian/sessions/70408c73-0d9e-4d12-bcc3-270d9453638a Co-authored-by: Nitr4x <10544300+Nitr4x@users.noreply.github.com> --- README.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/README.md b/README.md index 6d251e7..a403ed4 100644 --- a/README.md +++ b/README.md @@ -422,6 +422,27 @@ docker run --rm \ Inside the container `SCAN_DIR` defaults to `/tmp/data`. +> **HTTPS proxy requirement — `--cap-add SYS_PTRACE`** +> +> OpenGrep bundles a Python interpreter inside its binary and reads `/proc/1/map_files` at startup to bootstrap it. In a Docker container this path requires the `CAP_SYS_PTRACE` Linux capability, which is dropped by default. If you run ScopeGuardian behind an HTTPS proxy (i.e. `[proxy].https_proxy` is set), you must add this capability so that OpenGrep can start: +> +> ```bash +> docker run --rm --cap-add SYS_PTRACE \ +> -v /path/to/your/project:/tmp/data/project \ +> -v /path/to/config.toml:/config.toml \ +> -e SCAN_DIR=/tmp/data \ +> -e DD_URL=http://host.docker.internal:8080 \ +> -e DD_ACCESS_TOKEN= \ +> ScopeGuardian \ +> --projectName my-service \ +> --branch main \ +> --sync \ +> /config.toml +> ``` +> +> With Docker Compose add `cap_add: [SYS_PTRACE]` to the ScopeGuardian service. +> With GitHub Actions use `options: --cap-add SYS_PTRACE` inside the `container:` block. + --- ## Local DefectDojo Setup From 0b20b494559bc9f4575573120b377f6d20b5e30e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 15:33:32 +0000 Subject: [PATCH 11/11] fix: exit -2 on engine initialization failure Agent-Logs-Url: https://github.com/ParanoiHack/ScopeGuardian/sessions/6acf3968-89b3-498c-aa70-631d39b4bf34 Co-authored-by: Nitr4x <10544300+Nitr4x@users.noreply.github.com> --- main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.go b/main.go index 4a38308..ba52ea9 100644 --- a/main.go +++ b/main.go @@ -53,7 +53,7 @@ func main() { if err := eng.Initialize(config); err != nil { logger.Error(err.Error()) - os.Exit(1) + os.Exit(-2) } eng.Start()