From 0525459945bb5cabd66a298a736661308e1e2eae Mon Sep 17 00:00:00 2001 From: EdgarPsda Date: Sun, 29 Mar 2026 18:28:28 -0700 Subject: [PATCH] Add multi-CI support for GitLab CI and Bitbucket Pipelines - Add --ci flag to devsecops init (github|gitlab|bitbucket, default: github) - Generate .gitlab-ci.yml for GitLab CI with security scanning stages - Generate bitbucket-pipelines.yml for Bitbucket with equivalent pipeline steps - Add CIProvider field to InitConfig - Support all 4 languages (nodejs, golang, python, java) in both new CI targets - Wire CI provider selection into wizard mode - Add tests for all language/CI combinations (11 tests, all passing) --- cli/cmd/init.go | 72 ++++++--- cli/generators/types.go | 1 + cli/generators/workflow_bitbucket.go | 44 ++++++ cli/generators/workflow_gitlab.go | 44 ++++++ cli/generators/workflow_gitlab_test.go | 132 ++++++++++++++++ .../ci/bitbucket/go_security.yml.tmpl | 124 +++++++++++++++ .../ci/bitbucket/java_security.yml.tmpl | 131 ++++++++++++++++ .../ci/bitbucket/node_security.yml.tmpl | 124 +++++++++++++++ .../ci/bitbucket/python_security.yml.tmpl | 124 +++++++++++++++ cli/templates/ci/gitlab/go_security.yml.tmpl | 136 +++++++++++++++++ .../ci/gitlab/java_security.yml.tmpl | 143 ++++++++++++++++++ .../ci/gitlab/node_security.yml.tmpl | 136 +++++++++++++++++ .../ci/gitlab/python_security.yml.tmpl | 138 +++++++++++++++++ cli/templates/templates.go | 2 + 14 files changed, 1333 insertions(+), 18 deletions(-) create mode 100644 cli/generators/workflow_bitbucket.go create mode 100644 cli/generators/workflow_gitlab.go create mode 100644 cli/generators/workflow_gitlab_test.go create mode 100644 cli/templates/ci/bitbucket/go_security.yml.tmpl create mode 100644 cli/templates/ci/bitbucket/java_security.yml.tmpl create mode 100644 cli/templates/ci/bitbucket/node_security.yml.tmpl create mode 100644 cli/templates/ci/bitbucket/python_security.yml.tmpl create mode 100644 cli/templates/ci/gitlab/go_security.yml.tmpl create mode 100644 cli/templates/ci/gitlab/java_security.yml.tmpl create mode 100644 cli/templates/ci/gitlab/node_security.yml.tmpl create mode 100644 cli/templates/ci/gitlab/python_security.yml.tmpl diff --git a/cli/cmd/init.go b/cli/cmd/init.go index 7bf2bce..badf01b 100644 --- a/cli/cmd/init.go +++ b/cli/cmd/init.go @@ -18,6 +18,7 @@ var ( noTrivyFlag bool noGitleaksFlag bool wizardFlag bool + ciFlag string ) var initCmd = &cobra.Command{ @@ -63,9 +64,22 @@ or run interactively with --wizard. severity = "high" } + // Normalize CI provider + ci := strings.ToLower(ciFlag) + switch ci { + case "github", "gitlab", "bitbucket": + // ok + default: + if ci != "" { + fmt.Printf("⚠️ Unknown CI provider '%s', defaulting to 'github'\n", ciFlag) + } + ci = "github" + } + cfg := &generators.InitConfig{ Project: project, SeverityThreshold: severity, + CIProvider: ci, Tools: generators.ToolsConfig{ Semgrep: !noSemgrepFlag, Trivy: !noTrivyFlag, @@ -73,15 +87,9 @@ or run interactively with --wizard. }, } - // Ensure .github/workflows exists - wfDir := filepath.Join(dir, ".github", "workflows") - if err := os.MkdirAll(wfDir, 0o755); err != nil { - return fmt.Errorf("failed to create workflows directory: %w", err) - } - fmt.Println("⚙️ Generating workflow + config files...") - if err := generators.GenerateGithubActions(cfg); err != nil { + if err := generateCIWorkflow(cfg, dir); err != nil { return err } if err := generators.GenerateSecurityConfig(cfg); err != nil { @@ -89,9 +97,7 @@ or run interactively with --wizard. } fmt.Println("\n🎉 Done! DevSecOps Kit initialized.") - fmt.Println("Files created:") - fmt.Println(" - .github/workflows/security.yml") - fmt.Println(" - security-config.yml") + printGeneratedFiles(ci) return nil }, } @@ -104,6 +110,7 @@ func init() { initCmd.Flags().BoolVar(&noTrivyFlag, "no-trivy", false, "Disable Trivy in generated workflow") initCmd.Flags().BoolVar(&noGitleaksFlag, "no-gitleaks", false, "Disable Gitleaks in generated workflow") initCmd.Flags().BoolVar(&wizardFlag, "wizard", false, "Run interactive guided setup") + initCmd.Flags().StringVar(&ciFlag, "ci", "github", "CI provider: github, gitlab, bitbucket") } // @@ -161,10 +168,18 @@ func runInitWizard() error { return nil } + // Select CI provider + fmt.Println("\n🔧 Select CI provider:") + ciProvider := askChoice(reader, "github | gitlab | bitbucket [default: github]: ", + []string{"github", "gitlab", "bitbucket"}, + "github", + ) + // Generate config cfg := &generators.InitConfig{ Project: project, SeverityThreshold: severity, + CIProvider: ciProvider, Tools: generators.ToolsConfig{ Semgrep: enableSemgrep, Gitleaks: enableGitleaks, @@ -172,14 +187,9 @@ func runInitWizard() error { }, } - wfDir := filepath.Join(dir, ".github", "workflows") - if err := os.MkdirAll(wfDir, 0o755); err != nil { - return fmt.Errorf("failed to create workflows directory: %w", err) - } - fmt.Println("\n⚙️ Generating workflow + config files...") - if err := generators.GenerateGithubActions(cfg); err != nil { + if err := generateCIWorkflow(cfg, dir); err != nil { return err } if err := generators.GenerateSecurityConfig(cfg); err != nil { @@ -188,12 +198,38 @@ func runInitWizard() error { fmt.Println("\n🎉 Setup complete!") fmt.Println("Generated:") - fmt.Println(" - .github/workflows/security.yml") - fmt.Println(" - security-config.yml") + printGeneratedFiles(ciProvider) return nil } +func generateCIWorkflow(cfg *generators.InitConfig, dir string) error { + switch cfg.CIProvider { + case "gitlab": + return generators.GenerateGitLabCI(cfg) + case "bitbucket": + return generators.GenerateBitbucketPipelines(cfg) + default: + wfDir := filepath.Join(dir, ".github", "workflows") + if err := os.MkdirAll(wfDir, 0o755); err != nil { + return fmt.Errorf("failed to create workflows directory: %w", err) + } + return generators.GenerateGithubActions(cfg) + } +} + +func printGeneratedFiles(ci string) { + switch ci { + case "gitlab": + fmt.Println(" - .gitlab-ci.yml") + case "bitbucket": + fmt.Println(" - bitbucket-pipelines.yml") + default: + fmt.Println(" - .github/workflows/security.yml") + } + fmt.Println(" - security-config.yml") +} + // // ----------------------------- // HELPER FUNCTIONS diff --git a/cli/generators/types.go b/cli/generators/types.go index ad61dbd..3706f1c 100644 --- a/cli/generators/types.go +++ b/cli/generators/types.go @@ -15,4 +15,5 @@ type InitConfig struct { Tools ToolsConfig ExcludePaths []string FailOn map[string]int + CIProvider string // "github", "gitlab", "bitbucket" } diff --git a/cli/generators/workflow_bitbucket.go b/cli/generators/workflow_bitbucket.go new file mode 100644 index 0000000..6fbd8ae --- /dev/null +++ b/cli/generators/workflow_bitbucket.go @@ -0,0 +1,44 @@ +package generators + +import ( + "fmt" + "os" + "text/template" + + "github.com/edgarpsda/devsecops-kit/cli/templates" +) + +func GenerateBitbucketPipelines(cfg *InitConfig) error { + var tmplName string + + switch cfg.Project.Language { + case "nodejs": + tmplName = "ci/bitbucket/node_security.yml.tmpl" + case "golang": + tmplName = "ci/bitbucket/go_security.yml.tmpl" + case "python": + tmplName = "ci/bitbucket/python_security.yml.tmpl" + case "java": + tmplName = "ci/bitbucket/java_security.yml.tmpl" + default: + return fmt.Errorf("no Bitbucket Pipelines template for language: %s", cfg.Project.Language) + } + + tmplData, err := templates.TemplateFS.ReadFile(tmplName) + if err != nil { + return fmt.Errorf("failed reading embedded template %s: %w", tmplName, err) + } + + tmpl, err := template.New("bitbucket-pipelines").Parse(string(tmplData)) + if err != nil { + return fmt.Errorf("failed parsing template: %w", err) + } + + f, err := os.Create("bitbucket-pipelines.yml") + if err != nil { + return fmt.Errorf("failed to create bitbucket-pipelines.yml: %w", err) + } + defer f.Close() + + return tmpl.Execute(f, cfg) +} diff --git a/cli/generators/workflow_gitlab.go b/cli/generators/workflow_gitlab.go new file mode 100644 index 0000000..0ad8ac9 --- /dev/null +++ b/cli/generators/workflow_gitlab.go @@ -0,0 +1,44 @@ +package generators + +import ( + "fmt" + "os" + "text/template" + + "github.com/edgarpsda/devsecops-kit/cli/templates" +) + +func GenerateGitLabCI(cfg *InitConfig) error { + var tmplName string + + switch cfg.Project.Language { + case "nodejs": + tmplName = "ci/gitlab/node_security.yml.tmpl" + case "golang": + tmplName = "ci/gitlab/go_security.yml.tmpl" + case "python": + tmplName = "ci/gitlab/python_security.yml.tmpl" + case "java": + tmplName = "ci/gitlab/java_security.yml.tmpl" + default: + return fmt.Errorf("no GitLab CI template for language: %s", cfg.Project.Language) + } + + tmplData, err := templates.TemplateFS.ReadFile(tmplName) + if err != nil { + return fmt.Errorf("failed reading embedded template %s: %w", tmplName, err) + } + + tmpl, err := template.New("gitlab-ci").Parse(string(tmplData)) + if err != nil { + return fmt.Errorf("failed parsing template: %w", err) + } + + f, err := os.Create(".gitlab-ci.yml") + if err != nil { + return fmt.Errorf("failed to create .gitlab-ci.yml: %w", err) + } + defer f.Close() + + return tmpl.Execute(f, cfg) +} diff --git a/cli/generators/workflow_gitlab_test.go b/cli/generators/workflow_gitlab_test.go new file mode 100644 index 0000000..79a93c7 --- /dev/null +++ b/cli/generators/workflow_gitlab_test.go @@ -0,0 +1,132 @@ +package generators_test + +import ( + "os" + "strings" + "testing" + + "github.com/edgarpsda/devsecops-kit/cli/detectors" + "github.com/edgarpsda/devsecops-kit/cli/generators" +) + +func TestGenerateGitLabCI(t *testing.T) { + languages := []string{"nodejs", "golang", "python", "java"} + + for _, lang := range languages { + t.Run(lang, func(t *testing.T) { + dir := t.TempDir() + orig, _ := os.Getwd() + defer os.Chdir(orig) + os.Chdir(dir) + + cfg := &generators.InitConfig{ + Project: &detectors.ProjectInfo{ + Language: lang, + Framework: "test", + }, + CIProvider: "gitlab", + SeverityThreshold: "high", + Tools: generators.ToolsConfig{ + Semgrep: true, + Trivy: true, + Gitleaks: true, + }, + } + + err := generators.GenerateGitLabCI(cfg) + if err != nil { + t.Fatalf("GenerateGitLabCI(%s) failed: %v", lang, err) + } + + data, err := os.ReadFile(".gitlab-ci.yml") + if err != nil { + t.Fatalf("failed to read .gitlab-ci.yml: %v", err) + } + + content := string(data) + if !strings.Contains(content, "stages:") { + t.Error("expected 'stages:' in .gitlab-ci.yml") + } + if !strings.Contains(content, "security-scan:") { + t.Error("expected 'security-scan:' job in .gitlab-ci.yml") + } + if !strings.Contains(content, "semgrep") { + t.Error("expected semgrep step in .gitlab-ci.yml") + } + if !strings.Contains(content, "gitleaks") { + t.Error("expected gitleaks step in .gitlab-ci.yml") + } + if !strings.Contains(content, "trivy") { + t.Error("expected trivy step in .gitlab-ci.yml") + } + }) + } +} + +func TestGenerateBitbucketPipelines(t *testing.T) { + languages := []string{"nodejs", "golang", "python", "java"} + + for _, lang := range languages { + t.Run(lang, func(t *testing.T) { + dir := t.TempDir() + orig, _ := os.Getwd() + defer os.Chdir(orig) + os.Chdir(dir) + + cfg := &generators.InitConfig{ + Project: &detectors.ProjectInfo{ + Language: lang, + Framework: "test", + }, + CIProvider: "bitbucket", + SeverityThreshold: "high", + Tools: generators.ToolsConfig{ + Semgrep: true, + Trivy: true, + Gitleaks: true, + }, + } + + err := generators.GenerateBitbucketPipelines(cfg) + if err != nil { + t.Fatalf("GenerateBitbucketPipelines(%s) failed: %v", lang, err) + } + + data, err := os.ReadFile("bitbucket-pipelines.yml") + if err != nil { + t.Fatalf("failed to read bitbucket-pipelines.yml: %v", err) + } + + content := string(data) + if !strings.Contains(content, "pipelines:") { + t.Error("expected 'pipelines:' in bitbucket-pipelines.yml") + } + if !strings.Contains(content, "Security Scan") { + t.Error("expected 'Security Scan' step in bitbucket-pipelines.yml") + } + if !strings.Contains(content, "semgrep") { + t.Error("expected semgrep step in bitbucket-pipelines.yml") + } + }) + } +} + +func TestUnsupportedCILanguage(t *testing.T) { + dir := t.TempDir() + orig, _ := os.Getwd() + defer os.Chdir(orig) + os.Chdir(dir) + + cfg := &generators.InitConfig{ + Project: &detectors.ProjectInfo{ + Language: "ruby", + Framework: "rails", + }, + CIProvider: "gitlab", + } + + err := generators.GenerateGitLabCI(cfg) + if err == nil { + t.Error("expected error for unsupported language, got nil") + } +} diff --git a/cli/templates/ci/bitbucket/go_security.yml.tmpl b/cli/templates/ci/bitbucket/go_security.yml.tmpl new file mode 100644 index 0000000..848fc70 --- /dev/null +++ b/cli/templates/ci/bitbucket/go_security.yml.tmpl @@ -0,0 +1,124 @@ +image: golang:1.22 + +pipelines: + default: + - step: + name: Security Scan + caches: + - go + artifacts: + - artifacts/security/** + script: + - apt-get update -qq && apt-get install -y -qq curl python3 python3-pip + - pip3 install pyyaml --quiet + - mkdir -p artifacts/security + + - | + python3 << 'EOF' + import yaml, json, os + exclude_paths = [] + try: + if os.path.exists('security-config.yml'): + with open('security-config.yml') as f: + config = yaml.safe_load(f) + if config and 'exclude_paths' in config: + exclude_paths = config['exclude_paths'] or [] + except Exception as e: + print(f"Error reading config: {e}") + with open('/tmp/semgrep_excludes', 'w') as f: + f.write(' '.join([f'--exclude {p}' for p in exclude_paths])) + with open('/tmp/gitleaks_excludes', 'w') as f: + f.write(','.join(exclude_paths)) + EOF + + - go mod download 2>/dev/null || true + + {{- if .Tools.Semgrep }} + - pip3 install semgrep --quiet + - SEMGREP_EXCLUDES=$(cat /tmp/semgrep_excludes) + - semgrep --config p/ci $SEMGREP_EXCLUDES --json > artifacts/security/semgrep-report.json || true + {{- end }} + + {{- if .Tools.Gitleaks }} + - | + GITLEAKS_EXCLUDES=$(cat /tmp/gitleaks_excludes) + curl -sSfL https://github.com/gitleaks/gitleaks/releases/download/v8.18.1/gitleaks_8.18.1_linux_x64.tar.gz | tar -xz + mv gitleaks /usr/local/bin/ + if [ -n "$GITLEAKS_EXCLUDES" ]; then + printf '[allowlist]\npaths = ["%s"]\n' "$GITLEAKS_EXCLUDES" > .gitleaks.toml + gitleaks detect --report-format json --report-path artifacts/security/gitleaks-report.json --config .gitleaks.toml --exit-code 0 || true + else + gitleaks detect --report-format json --report-path artifacts/security/gitleaks-report.json --exit-code 0 || true + fi + {{- end }} + + {{- if .Tools.Trivy }} + - | + curl -sSfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin + SKIP_DIRS=$(cat /tmp/gitleaks_excludes) + trivy fs . --format json --output artifacts/security/trivy-fs.json --severity HIGH,CRITICAL ${SKIP_DIRS:+--skip-dirs $SKIP_DIRS} || true + {{- end }} + + - | + python3 << 'EOF' + import yaml, json, os + fail_on = {'gitleaks': 0, 'semgrep': 10, 'trivy_critical': 0, 'trivy_high': 5, 'trivy_medium': -1, 'trivy_low': -1} + try: + if os.path.exists('security-config.yml'): + with open('security-config.yml') as f: + config = yaml.safe_load(f) + if config and 'fail_on' in config: + fail_on.update(config['fail_on']) + except Exception as e: + print(f"Error reading config: {e}") + os.makedirs('artifacts/security', exist_ok=True) + with open('artifacts/security/fail-on.json', 'w') as out: + json.dump(fail_on, out) + EOF + + - | + python3 << 'EOF' + import json, os + d = 'artifacts/security' + def read_json(f): + if not os.path.exists(f): return None + try: return json.load(open(f)) + except: return None + + fail_on = read_json(f'{d}/fail-on.json') or {'gitleaks': 0, 'semgrep': 10, 'trivy_critical': 0, 'trivy_high': 5} + result = {'version': '0.6.0', 'status': 'PASS', 'blocking_count': 0, 'summary': {}} + + gitleaks = read_json(f'{d}/gitleaks-report.json') + if gitleaks is not None: + count = len(gitleaks) if isinstance(gitleaks, list) else len(gitleaks.get('findings', [])) + result['summary']['gitleaks'] = {'total': count} + if fail_on.get('gitleaks', 0) >= 0 and count > fail_on.get('gitleaks', 0): + result['blocking_count'] += count - fail_on['gitleaks'] + + trivy = read_json(f'{d}/trivy-fs.json') + if trivy and trivy.get('Results'): + counts = {'critical': 0, 'high': 0, 'medium': 0, 'low': 0} + for r in trivy['Results']: + for v in (r.get('Vulnerabilities') or []): + s = v.get('Severity', '').lower() + if s in counts: counts[s] += 1 + result['summary']['trivy_fs'] = counts + for s, k in [('critical','trivy_critical'),('high','trivy_high'),('medium','trivy_medium'),('low','trivy_low')]: + if fail_on.get(k, -1) >= 0 and counts[s] > fail_on[k]: + result['blocking_count'] += counts[s] - fail_on[k] + + semgrep = read_json(f'{d}/semgrep-report.json') + if semgrep is not None: + items = semgrep if isinstance(semgrep, list) else semgrep.get('results', []) + count = len(items) + result['summary']['semgrep'] = {'total': count} + if fail_on.get('semgrep', 10) >= 0 and count > fail_on.get('semgrep', 10): + result['blocking_count'] += count - fail_on['semgrep'] + + result['status'] = 'FAIL' if result['blocking_count'] > 0 else 'PASS' + with open(f'{d}/summary.json', 'w') as out: + json.dump(result, out, indent=2) + print(f"Status: {result['status']} | Blocking: {result['blocking_count']}") + if result['status'] == 'FAIL': + raise SystemExit(1) + EOF diff --git a/cli/templates/ci/bitbucket/java_security.yml.tmpl b/cli/templates/ci/bitbucket/java_security.yml.tmpl new file mode 100644 index 0000000..6878ba7 --- /dev/null +++ b/cli/templates/ci/bitbucket/java_security.yml.tmpl @@ -0,0 +1,131 @@ +image: eclipse-temurin:17 + +definitions: + caches: + gradle: ~/.gradle/caches + maven: ~/.m2 + +pipelines: + default: + - step: + name: Security Scan + caches: + - gradle + - maven + artifacts: + - artifacts/security/** + script: + - apt-get update -qq && apt-get install -y -qq curl python3 python3-pip + - pip3 install pyyaml --quiet + - mkdir -p artifacts/security + + - | + python3 << 'EOF' + import yaml, json, os + exclude_paths = [] + try: + if os.path.exists('security-config.yml'): + with open('security-config.yml') as f: + config = yaml.safe_load(f) + if config and 'exclude_paths' in config: + exclude_paths = config['exclude_paths'] or [] + except Exception as e: + print(f"Error reading config: {e}") + with open('/tmp/semgrep_excludes', 'w') as f: + f.write(' '.join([f'--exclude {p}' for p in exclude_paths])) + with open('/tmp/gitleaks_excludes', 'w') as f: + f.write(','.join(exclude_paths)) + EOF + + - '[ -f pom.xml ] && mvn dependency:resolve -q 2>/dev/null || true' + - '[ -f build.gradle ] && (./gradlew dependencies -q 2>/dev/null || gradle dependencies -q 2>/dev/null) || true' + + {{- if .Tools.Semgrep }} + - pip3 install semgrep --quiet + - SEMGREP_EXCLUDES=$(cat /tmp/semgrep_excludes) + - semgrep --config p/ci $SEMGREP_EXCLUDES --json > artifacts/security/semgrep-report.json || true + {{- end }} + + {{- if .Tools.Gitleaks }} + - | + GITLEAKS_EXCLUDES=$(cat /tmp/gitleaks_excludes) + curl -sSfL https://github.com/gitleaks/gitleaks/releases/download/v8.18.1/gitleaks_8.18.1_linux_x64.tar.gz | tar -xz + mv gitleaks /usr/local/bin/ + if [ -n "$GITLEAKS_EXCLUDES" ]; then + printf '[allowlist]\npaths = ["%s"]\n' "$GITLEAKS_EXCLUDES" > .gitleaks.toml + gitleaks detect --report-format json --report-path artifacts/security/gitleaks-report.json --config .gitleaks.toml --exit-code 0 || true + else + gitleaks detect --report-format json --report-path artifacts/security/gitleaks-report.json --exit-code 0 || true + fi + {{- end }} + + {{- if .Tools.Trivy }} + - | + curl -sSfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin + SKIP_DIRS=$(cat /tmp/gitleaks_excludes) + trivy fs . --format json --output artifacts/security/trivy-fs.json --severity HIGH,CRITICAL ${SKIP_DIRS:+--skip-dirs $SKIP_DIRS} || true + {{- end }} + + - | + python3 << 'EOF' + import yaml, json, os + fail_on = {'gitleaks': 0, 'semgrep': 10, 'trivy_critical': 0, 'trivy_high': 5, 'trivy_medium': -1, 'trivy_low': -1} + try: + if os.path.exists('security-config.yml'): + with open('security-config.yml') as f: + config = yaml.safe_load(f) + if config and 'fail_on' in config: + fail_on.update(config['fail_on']) + except Exception as e: + print(f"Error reading config: {e}") + os.makedirs('artifacts/security', exist_ok=True) + with open('artifacts/security/fail-on.json', 'w') as out: + json.dump(fail_on, out) + EOF + + - | + python3 << 'EOF' + import json, os + d = 'artifacts/security' + def read_json(f): + if not os.path.exists(f): return None + try: return json.load(open(f)) + except: return None + + fail_on = read_json(f'{d}/fail-on.json') or {'gitleaks': 0, 'semgrep': 10, 'trivy_critical': 0, 'trivy_high': 5} + result = {'version': '0.6.0', 'status': 'PASS', 'blocking_count': 0, 'summary': {}} + + gitleaks = read_json(f'{d}/gitleaks-report.json') + if gitleaks is not None: + count = len(gitleaks) if isinstance(gitleaks, list) else len(gitleaks.get('findings', [])) + result['summary']['gitleaks'] = {'total': count} + if fail_on.get('gitleaks', 0) >= 0 and count > fail_on.get('gitleaks', 0): + result['blocking_count'] += count - fail_on['gitleaks'] + + trivy = read_json(f'{d}/trivy-fs.json') + if trivy and trivy.get('Results'): + counts = {'critical': 0, 'high': 0, 'medium': 0, 'low': 0} + for r in trivy['Results']: + for v in (r.get('Vulnerabilities') or []): + s = v.get('Severity', '').lower() + if s in counts: counts[s] += 1 + result['summary']['trivy_fs'] = counts + for s, k in [('critical','trivy_critical'),('high','trivy_high'),('medium','trivy_medium'),('low','trivy_low')]: + if fail_on.get(k, -1) >= 0 and counts[s] > fail_on[k]: + result['blocking_count'] += counts[s] - fail_on[k] + + semgrep = read_json(f'{d}/semgrep-report.json') + if semgrep is not None: + items = semgrep if isinstance(semgrep, list) else semgrep.get('results', []) + count = len(items) + result['summary']['semgrep'] = {'total': count} + if fail_on.get('semgrep', 10) >= 0 and count > fail_on.get('semgrep', 10): + result['blocking_count'] += count - fail_on['semgrep'] + + result['status'] = 'FAIL' if result['blocking_count'] > 0 else 'PASS' + with open(f'{d}/summary.json', 'w') as out: + json.dump(result, out, indent=2) + print(f"Status: {result['status']} | Blocking: {result['blocking_count']}") + if result['status'] == 'FAIL': + raise SystemExit(1) + EOF diff --git a/cli/templates/ci/bitbucket/node_security.yml.tmpl b/cli/templates/ci/bitbucket/node_security.yml.tmpl new file mode 100644 index 0000000..4a0fa0c --- /dev/null +++ b/cli/templates/ci/bitbucket/node_security.yml.tmpl @@ -0,0 +1,124 @@ +image: node:18 + +pipelines: + default: + - step: + name: Security Scan + caches: + - node + artifacts: + - artifacts/security/** + script: + - apt-get update -qq && apt-get install -y -qq curl python3 python3-pip + - pip3 install pyyaml --quiet + - mkdir -p artifacts/security + + - | + python3 << 'EOF' + import yaml, json, os + exclude_paths = [] + try: + if os.path.exists('security-config.yml'): + with open('security-config.yml') as f: + config = yaml.safe_load(f) + if config and 'exclude_paths' in config: + exclude_paths = config['exclude_paths'] or [] + except Exception as e: + print(f"Error reading config: {e}") + with open('/tmp/semgrep_excludes', 'w') as f: + f.write(' '.join([f'--exclude {p}' for p in exclude_paths])) + with open('/tmp/gitleaks_excludes', 'w') as f: + f.write(','.join(exclude_paths)) + EOF + + - npm install --ignore-scripts 2>/dev/null || true + + {{- if .Tools.Semgrep }} + - pip3 install semgrep --quiet + - SEMGREP_EXCLUDES=$(cat /tmp/semgrep_excludes) + - semgrep --config p/ci $SEMGREP_EXCLUDES --json > artifacts/security/semgrep-report.json || true + {{- end }} + + {{- if .Tools.Gitleaks }} + - | + GITLEAKS_EXCLUDES=$(cat /tmp/gitleaks_excludes) + curl -sSfL https://github.com/gitleaks/gitleaks/releases/download/v8.18.1/gitleaks_8.18.1_linux_x64.tar.gz | tar -xz + mv gitleaks /usr/local/bin/ + if [ -n "$GITLEAKS_EXCLUDES" ]; then + printf '[allowlist]\npaths = ["%s"]\n' "$GITLEAKS_EXCLUDES" > .gitleaks.toml + gitleaks detect --report-format json --report-path artifacts/security/gitleaks-report.json --config .gitleaks.toml --exit-code 0 || true + else + gitleaks detect --report-format json --report-path artifacts/security/gitleaks-report.json --exit-code 0 || true + fi + {{- end }} + + {{- if .Tools.Trivy }} + - | + curl -sSfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin + SKIP_DIRS=$(cat /tmp/gitleaks_excludes) + trivy fs . --format json --output artifacts/security/trivy-fs.json --severity HIGH,CRITICAL ${SKIP_DIRS:+--skip-dirs $SKIP_DIRS} || true + {{- end }} + + - | + python3 << 'EOF' + import yaml, json, os + fail_on = {'gitleaks': 0, 'semgrep': 10, 'trivy_critical': 0, 'trivy_high': 5, 'trivy_medium': -1, 'trivy_low': -1} + try: + if os.path.exists('security-config.yml'): + with open('security-config.yml') as f: + config = yaml.safe_load(f) + if config and 'fail_on' in config: + fail_on.update(config['fail_on']) + except Exception as e: + print(f"Error reading config: {e}") + os.makedirs('artifacts/security', exist_ok=True) + with open('artifacts/security/fail-on.json', 'w') as out: + json.dump(fail_on, out) + EOF + + - | + python3 << 'EOF' + import json, os + d = 'artifacts/security' + def read_json(f): + if not os.path.exists(f): return None + try: return json.load(open(f)) + except: return None + + fail_on = read_json(f'{d}/fail-on.json') or {'gitleaks': 0, 'semgrep': 10, 'trivy_critical': 0, 'trivy_high': 5} + result = {'version': '0.6.0', 'status': 'PASS', 'blocking_count': 0, 'summary': {}} + + gitleaks = read_json(f'{d}/gitleaks-report.json') + if gitleaks is not None: + count = len(gitleaks) if isinstance(gitleaks, list) else len(gitleaks.get('findings', [])) + result['summary']['gitleaks'] = {'total': count} + if fail_on.get('gitleaks', 0) >= 0 and count > fail_on.get('gitleaks', 0): + result['blocking_count'] += count - fail_on['gitleaks'] + + trivy = read_json(f'{d}/trivy-fs.json') + if trivy and trivy.get('Results'): + counts = {'critical': 0, 'high': 0, 'medium': 0, 'low': 0} + for r in trivy['Results']: + for v in (r.get('Vulnerabilities') or []): + s = v.get('Severity', '').lower() + if s in counts: counts[s] += 1 + result['summary']['trivy_fs'] = counts + for s, k in [('critical','trivy_critical'),('high','trivy_high'),('medium','trivy_medium'),('low','trivy_low')]: + if fail_on.get(k, -1) >= 0 and counts[s] > fail_on[k]: + result['blocking_count'] += counts[s] - fail_on[k] + + semgrep = read_json(f'{d}/semgrep-report.json') + if semgrep is not None: + items = semgrep if isinstance(semgrep, list) else semgrep.get('results', []) + count = len(items) + result['summary']['semgrep'] = {'total': count} + if fail_on.get('semgrep', 10) >= 0 and count > fail_on.get('semgrep', 10): + result['blocking_count'] += count - fail_on['semgrep'] + + result['status'] = 'FAIL' if result['blocking_count'] > 0 else 'PASS' + with open(f'{d}/summary.json', 'w') as out: + json.dump(result, out, indent=2) + print(f"Status: {result['status']} | Blocking: {result['blocking_count']}") + if result['status'] == 'FAIL': + raise SystemExit(1) + EOF diff --git a/cli/templates/ci/bitbucket/python_security.yml.tmpl b/cli/templates/ci/bitbucket/python_security.yml.tmpl new file mode 100644 index 0000000..10e683a --- /dev/null +++ b/cli/templates/ci/bitbucket/python_security.yml.tmpl @@ -0,0 +1,124 @@ +image: python:3.11 + +pipelines: + default: + - step: + name: Security Scan + artifacts: + - artifacts/security/** + script: + - apt-get update -qq && apt-get install -y -qq curl + - pip install pyyaml --quiet + - mkdir -p artifacts/security + + - | + python3 << 'EOF' + import yaml, json, os + exclude_paths = [] + try: + if os.path.exists('security-config.yml'): + with open('security-config.yml') as f: + config = yaml.safe_load(f) + if config and 'exclude_paths' in config: + exclude_paths = config['exclude_paths'] or [] + except Exception as e: + print(f"Error reading config: {e}") + with open('/tmp/semgrep_excludes', 'w') as f: + f.write(' '.join([f'--exclude {p}' for p in exclude_paths])) + with open('/tmp/gitleaks_excludes', 'w') as f: + f.write(','.join(exclude_paths)) + EOF + + - pip install -r requirements.txt --quiet 2>/dev/null || true + - '[ -f Pipfile ] && pip install pipenv && pipenv install --quiet 2>/dev/null || true' + - '[ -f pyproject.toml ] && pip install . --quiet 2>/dev/null || true' + + {{- if .Tools.Semgrep }} + - pip install semgrep --quiet + - SEMGREP_EXCLUDES=$(cat /tmp/semgrep_excludes) + - semgrep --config p/ci $SEMGREP_EXCLUDES --json > artifacts/security/semgrep-report.json || true + {{- end }} + + {{- if .Tools.Gitleaks }} + - | + GITLEAKS_EXCLUDES=$(cat /tmp/gitleaks_excludes) + curl -sSfL https://github.com/gitleaks/gitleaks/releases/download/v8.18.1/gitleaks_8.18.1_linux_x64.tar.gz | tar -xz + mv gitleaks /usr/local/bin/ + if [ -n "$GITLEAKS_EXCLUDES" ]; then + printf '[allowlist]\npaths = ["%s"]\n' "$GITLEAKS_EXCLUDES" > .gitleaks.toml + gitleaks detect --report-format json --report-path artifacts/security/gitleaks-report.json --config .gitleaks.toml --exit-code 0 || true + else + gitleaks detect --report-format json --report-path artifacts/security/gitleaks-report.json --exit-code 0 || true + fi + {{- end }} + + {{- if .Tools.Trivy }} + - | + curl -sSfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin + SKIP_DIRS=$(cat /tmp/gitleaks_excludes) + trivy fs . --format json --output artifacts/security/trivy-fs.json --severity HIGH,CRITICAL ${SKIP_DIRS:+--skip-dirs $SKIP_DIRS} || true + {{- end }} + + - | + python3 << 'EOF' + import yaml, json, os + fail_on = {'gitleaks': 0, 'semgrep': 10, 'trivy_critical': 0, 'trivy_high': 5, 'trivy_medium': -1, 'trivy_low': -1} + try: + if os.path.exists('security-config.yml'): + with open('security-config.yml') as f: + config = yaml.safe_load(f) + if config and 'fail_on' in config: + fail_on.update(config['fail_on']) + except Exception as e: + print(f"Error reading config: {e}") + os.makedirs('artifacts/security', exist_ok=True) + with open('artifacts/security/fail-on.json', 'w') as out: + json.dump(fail_on, out) + EOF + + - | + python3 << 'EOF' + import json, os + d = 'artifacts/security' + def read_json(f): + if not os.path.exists(f): return None + try: return json.load(open(f)) + except: return None + + fail_on = read_json(f'{d}/fail-on.json') or {'gitleaks': 0, 'semgrep': 10, 'trivy_critical': 0, 'trivy_high': 5} + result = {'version': '0.6.0', 'status': 'PASS', 'blocking_count': 0, 'summary': {}} + + gitleaks = read_json(f'{d}/gitleaks-report.json') + if gitleaks is not None: + count = len(gitleaks) if isinstance(gitleaks, list) else len(gitleaks.get('findings', [])) + result['summary']['gitleaks'] = {'total': count} + if fail_on.get('gitleaks', 0) >= 0 and count > fail_on.get('gitleaks', 0): + result['blocking_count'] += count - fail_on['gitleaks'] + + trivy = read_json(f'{d}/trivy-fs.json') + if trivy and trivy.get('Results'): + counts = {'critical': 0, 'high': 0, 'medium': 0, 'low': 0} + for r in trivy['Results']: + for v in (r.get('Vulnerabilities') or []): + s = v.get('Severity', '').lower() + if s in counts: counts[s] += 1 + result['summary']['trivy_fs'] = counts + for s, k in [('critical','trivy_critical'),('high','trivy_high'),('medium','trivy_medium'),('low','trivy_low')]: + if fail_on.get(k, -1) >= 0 and counts[s] > fail_on[k]: + result['blocking_count'] += counts[s] - fail_on[k] + + semgrep = read_json(f'{d}/semgrep-report.json') + if semgrep is not None: + items = semgrep if isinstance(semgrep, list) else semgrep.get('results', []) + count = len(items) + result['summary']['semgrep'] = {'total': count} + if fail_on.get('semgrep', 10) >= 0 and count > fail_on.get('semgrep', 10): + result['blocking_count'] += count - fail_on['semgrep'] + + result['status'] = 'FAIL' if result['blocking_count'] > 0 else 'PASS' + with open(f'{d}/summary.json', 'w') as out: + json.dump(result, out, indent=2) + print(f"Status: {result['status']} | Blocking: {result['blocking_count']}") + if result['status'] == 'FAIL': + raise SystemExit(1) + EOF diff --git a/cli/templates/ci/gitlab/go_security.yml.tmpl b/cli/templates/ci/gitlab/go_security.yml.tmpl new file mode 100644 index 0000000..20450d8 --- /dev/null +++ b/cli/templates/ci/gitlab/go_security.yml.tmpl @@ -0,0 +1,136 @@ +stages: + - security + +variables: + ARTIFACTS_DIR: "artifacts/security" + +security-scan: + stage: security + image: golang:1.22 + timeout: 15 minutes + + artifacts: + when: always + paths: + - artifacts/security/ + expire_in: 30 days + + before_script: + - apt-get update -qq && apt-get install -y -qq curl python3 python3-pip + - pip3 install pyyaml --quiet + - mkdir -p $ARTIFACTS_DIR + + script: + - | + python3 << 'EOF' + import yaml, json, os + exclude_paths = [] + try: + if os.path.exists('security-config.yml'): + with open('security-config.yml') as f: + config = yaml.safe_load(f) + if config and 'exclude_paths' in config: + exclude_paths = config['exclude_paths'] or [] + except Exception as e: + print(f"Error reading config: {e}") + with open('/tmp/semgrep_excludes', 'w') as f: + f.write(' '.join([f'--exclude {p}' for p in exclude_paths])) + with open('/tmp/gitleaks_excludes', 'w') as f: + f.write(','.join(exclude_paths)) + EOF + + - go mod download 2>/dev/null || true + + {{- if .Tools.Semgrep }} + - pip3 install semgrep --quiet + - SEMGREP_EXCLUDES=$(cat /tmp/semgrep_excludes) + - semgrep --config p/ci $SEMGREP_EXCLUDES --json > $ARTIFACTS_DIR/semgrep-report.json || true + {{- end }} + + {{- if .Tools.Gitleaks }} + - | + GITLEAKS_EXCLUDES=$(cat /tmp/gitleaks_excludes) + curl -sSfL https://github.com/gitleaks/gitleaks/releases/download/v8.18.1/gitleaks_8.18.1_linux_x64.tar.gz | tar -xz + mv gitleaks /usr/local/bin/ + if [ -n "$GITLEAKS_EXCLUDES" ]; then + cat > .gitleaks.toml << TOMLEOF + [allowlist] + paths = ["$GITLEAKS_EXCLUDES"] + TOMLEOF + gitleaks detect --report-format json --report-path $ARTIFACTS_DIR/gitleaks-report.json --config .gitleaks.toml --exit-code 0 || true + else + gitleaks detect --report-format json --report-path $ARTIFACTS_DIR/gitleaks-report.json --exit-code 0 || true + fi + {{- end }} + + {{- if .Tools.Trivy }} + - | + curl -sSfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin + SKIP_DIRS=$(cat /tmp/gitleaks_excludes) + trivy fs . --format json --output $ARTIFACTS_DIR/trivy-fs.json --severity HIGH,CRITICAL ${SKIP_DIRS:+--skip-dirs $SKIP_DIRS} || true + {{- end }} + + - | + python3 << 'EOF' + import yaml, json, os + fail_on = {'gitleaks': 0, 'semgrep': 10, 'trivy_critical': 0, 'trivy_high': 5, 'trivy_medium': -1, 'trivy_low': -1} + try: + if os.path.exists('security-config.yml'): + with open('security-config.yml') as f: + config = yaml.safe_load(f) + if config and 'fail_on' in config: + fail_on.update(config['fail_on']) + except Exception as e: + print(f"Error reading config: {e}") + os.makedirs('artifacts/security', exist_ok=True) + with open('artifacts/security/fail-on.json', 'w') as out: + json.dump(fail_on, out) + EOF + + - | + python3 << 'EOF' + import json, os + dir = 'artifacts/security' + def read_json(f): + if not os.path.exists(f): return None + try: + return json.load(open(f)) + except: return None + + fail_on = read_json(f'{dir}/fail-on.json') or {'gitleaks': 0, 'semgrep': 10, 'trivy_critical': 0, 'trivy_high': 5} + result = {'version': '0.6.0', 'status': 'PASS', 'blocking_count': 0, 'summary': {}} + + gitleaks = read_json(f'{dir}/gitleaks-report.json') + if gitleaks is not None: + count = len(gitleaks) if isinstance(gitleaks, list) else len(gitleaks.get('findings', [])) + result['summary']['gitleaks'] = {'total': count} + if fail_on.get('gitleaks', 0) >= 0 and count > fail_on.get('gitleaks', 0): + result['blocking_count'] += count - fail_on['gitleaks'] + + trivy = read_json(f'{dir}/trivy-fs.json') + if trivy and trivy.get('Results'): + counts = {'critical': 0, 'high': 0, 'medium': 0, 'low': 0} + for r in trivy['Results']: + for v in (r.get('Vulnerabilities') or []): + s = v.get('Severity', '').lower() + if s in counts: counts[s] += 1 + result['summary']['trivy_fs'] = counts + for s, k in [('critical','trivy_critical'),('high','trivy_high'),('medium','trivy_medium'),('low','trivy_low')]: + if fail_on.get(k, -1) >= 0 and counts[s] > fail_on[k]: + result['blocking_count'] += counts[s] - fail_on[k] + + semgrep = read_json(f'{dir}/semgrep-report.json') + if semgrep is not None: + items = semgrep if isinstance(semgrep, list) else semgrep.get('results', []) + count = len(items) + result['summary']['semgrep'] = {'total': count} + if fail_on.get('semgrep', 10) >= 0 and count > fail_on.get('semgrep', 10): + result['blocking_count'] += count - fail_on['semgrep'] + + result['status'] = 'FAIL' if result['blocking_count'] > 0 else 'PASS' + with open(f'{dir}/summary.json', 'w') as out: + json.dump(result, out, indent=2) + print(f"Status: {result['status']} | Blocking: {result['blocking_count']}") + if result['status'] == 'FAIL': + raise SystemExit(1) + EOF diff --git a/cli/templates/ci/gitlab/java_security.yml.tmpl b/cli/templates/ci/gitlab/java_security.yml.tmpl new file mode 100644 index 0000000..f491db6 --- /dev/null +++ b/cli/templates/ci/gitlab/java_security.yml.tmpl @@ -0,0 +1,143 @@ +stages: + - security + +variables: + ARTIFACTS_DIR: "artifacts/security" + MAVEN_OPTS: "-Dmaven.repo.local=.m2/repository" + +security-scan: + stage: security + image: eclipse-temurin:17 + timeout: 15 minutes + + cache: + paths: + - .m2/repository + - .gradle/caches + + artifacts: + when: always + paths: + - artifacts/security/ + expire_in: 30 days + + before_script: + - apt-get update -qq && apt-get install -y -qq curl python3 python3-pip + - pip3 install pyyaml --quiet + - mkdir -p $ARTIFACTS_DIR + + script: + - | + python3 << 'EOF' + import yaml, json, os + exclude_paths = [] + try: + if os.path.exists('security-config.yml'): + with open('security-config.yml') as f: + config = yaml.safe_load(f) + if config and 'exclude_paths' in config: + exclude_paths = config['exclude_paths'] or [] + except Exception as e: + print(f"Error reading config: {e}") + with open('/tmp/semgrep_excludes', 'w') as f: + f.write(' '.join([f'--exclude {p}' for p in exclude_paths])) + with open('/tmp/gitleaks_excludes', 'w') as f: + f.write(','.join(exclude_paths)) + EOF + + - '[ -f pom.xml ] && mvn dependency:resolve -q 2>/dev/null || true' + - '[ -f build.gradle ] && (./gradlew dependencies -q 2>/dev/null || gradle dependencies -q 2>/dev/null) || true' + + {{- if .Tools.Semgrep }} + - pip3 install semgrep --quiet + - SEMGREP_EXCLUDES=$(cat /tmp/semgrep_excludes) + - semgrep --config p/ci $SEMGREP_EXCLUDES --json > $ARTIFACTS_DIR/semgrep-report.json || true + {{- end }} + + {{- if .Tools.Gitleaks }} + - | + GITLEAKS_EXCLUDES=$(cat /tmp/gitleaks_excludes) + curl -sSfL https://github.com/gitleaks/gitleaks/releases/download/v8.18.1/gitleaks_8.18.1_linux_x64.tar.gz | tar -xz + mv gitleaks /usr/local/bin/ + if [ -n "$GITLEAKS_EXCLUDES" ]; then + cat > .gitleaks.toml << TOMLEOF + [allowlist] + paths = ["$GITLEAKS_EXCLUDES"] + TOMLEOF + gitleaks detect --report-format json --report-path $ARTIFACTS_DIR/gitleaks-report.json --config .gitleaks.toml --exit-code 0 || true + else + gitleaks detect --report-format json --report-path $ARTIFACTS_DIR/gitleaks-report.json --exit-code 0 || true + fi + {{- end }} + + {{- if .Tools.Trivy }} + - | + curl -sSfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin + SKIP_DIRS=$(cat /tmp/gitleaks_excludes) + trivy fs . --format json --output $ARTIFACTS_DIR/trivy-fs.json --severity HIGH,CRITICAL ${SKIP_DIRS:+--skip-dirs $SKIP_DIRS} || true + {{- end }} + + - | + python3 << 'EOF' + import yaml, json, os + fail_on = {'gitleaks': 0, 'semgrep': 10, 'trivy_critical': 0, 'trivy_high': 5, 'trivy_medium': -1, 'trivy_low': -1} + try: + if os.path.exists('security-config.yml'): + with open('security-config.yml') as f: + config = yaml.safe_load(f) + if config and 'fail_on' in config: + fail_on.update(config['fail_on']) + except Exception as e: + print(f"Error reading config: {e}") + os.makedirs('artifacts/security', exist_ok=True) + with open('artifacts/security/fail-on.json', 'w') as out: + json.dump(fail_on, out) + EOF + + - | + python3 << 'EOF' + import json, os + dir = 'artifacts/security' + def read_json(f): + if not os.path.exists(f): return None + try: + return json.load(open(f)) + except: return None + + fail_on = read_json(f'{dir}/fail-on.json') or {'gitleaks': 0, 'semgrep': 10, 'trivy_critical': 0, 'trivy_high': 5} + result = {'version': '0.6.0', 'status': 'PASS', 'blocking_count': 0, 'summary': {}} + + gitleaks = read_json(f'{dir}/gitleaks-report.json') + if gitleaks is not None: + count = len(gitleaks) if isinstance(gitleaks, list) else len(gitleaks.get('findings', [])) + result['summary']['gitleaks'] = {'total': count} + if fail_on.get('gitleaks', 0) >= 0 and count > fail_on.get('gitleaks', 0): + result['blocking_count'] += count - fail_on['gitleaks'] + + trivy = read_json(f'{dir}/trivy-fs.json') + if trivy and trivy.get('Results'): + counts = {'critical': 0, 'high': 0, 'medium': 0, 'low': 0} + for r in trivy['Results']: + for v in (r.get('Vulnerabilities') or []): + s = v.get('Severity', '').lower() + if s in counts: counts[s] += 1 + result['summary']['trivy_fs'] = counts + for s, k in [('critical','trivy_critical'),('high','trivy_high'),('medium','trivy_medium'),('low','trivy_low')]: + if fail_on.get(k, -1) >= 0 and counts[s] > fail_on[k]: + result['blocking_count'] += counts[s] - fail_on[k] + + semgrep = read_json(f'{dir}/semgrep-report.json') + if semgrep is not None: + items = semgrep if isinstance(semgrep, list) else semgrep.get('results', []) + count = len(items) + result['summary']['semgrep'] = {'total': count} + if fail_on.get('semgrep', 10) >= 0 and count > fail_on.get('semgrep', 10): + result['blocking_count'] += count - fail_on['semgrep'] + + result['status'] = 'FAIL' if result['blocking_count'] > 0 else 'PASS' + with open(f'{dir}/summary.json', 'w') as out: + json.dump(result, out, indent=2) + print(f"Status: {result['status']} | Blocking: {result['blocking_count']}") + if result['status'] == 'FAIL': + raise SystemExit(1) + EOF diff --git a/cli/templates/ci/gitlab/node_security.yml.tmpl b/cli/templates/ci/gitlab/node_security.yml.tmpl new file mode 100644 index 0000000..28066bf --- /dev/null +++ b/cli/templates/ci/gitlab/node_security.yml.tmpl @@ -0,0 +1,136 @@ +stages: + - security + +variables: + ARTIFACTS_DIR: "artifacts/security" + +security-scan: + stage: security + image: ubuntu:22.04 + timeout: 15 minutes + + artifacts: + when: always + paths: + - artifacts/security/ + expire_in: 30 days + + before_script: + - apt-get update -qq && apt-get install -y -qq curl python3 python3-pip nodejs npm + - pip3 install pyyaml --quiet + - mkdir -p $ARTIFACTS_DIR + + script: + - | + python3 << 'EOF' + import yaml, json, os + exclude_paths = [] + try: + if os.path.exists('security-config.yml'): + with open('security-config.yml') as f: + config = yaml.safe_load(f) + if config and 'exclude_paths' in config: + exclude_paths = config['exclude_paths'] or [] + except Exception as e: + print(f"Error reading config: {e}") + with open('/tmp/semgrep_excludes', 'w') as f: + f.write(' '.join([f'--exclude {p}' for p in exclude_paths])) + with open('/tmp/gitleaks_excludes', 'w') as f: + f.write(','.join(exclude_paths)) + EOF + + - npm install --prefix . --ignore-scripts 2>/dev/null || true + + {{- if .Tools.Semgrep }} + - pip3 install semgrep --quiet + - SEMGREP_EXCLUDES=$(cat /tmp/semgrep_excludes) + - semgrep --config p/ci $SEMGREP_EXCLUDES --json > $ARTIFACTS_DIR/semgrep-report.json || true + {{- end }} + + {{- if .Tools.Gitleaks }} + - | + GITLEAKS_EXCLUDES=$(cat /tmp/gitleaks_excludes) + curl -sSfL https://github.com/gitleaks/gitleaks/releases/download/v8.18.1/gitleaks_8.18.1_linux_x64.tar.gz | tar -xz + mv gitleaks /usr/local/bin/ + if [ -n "$GITLEAKS_EXCLUDES" ]; then + cat > .gitleaks.toml << TOMLEOF + [allowlist] + paths = ["$GITLEAKS_EXCLUDES"] + TOMLEOF + gitleaks detect --report-format json --report-path $ARTIFACTS_DIR/gitleaks-report.json --config .gitleaks.toml --exit-code 0 || true + else + gitleaks detect --report-format json --report-path $ARTIFACTS_DIR/gitleaks-report.json --exit-code 0 || true + fi + {{- end }} + + {{- if .Tools.Trivy }} + - | + curl -sSfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin + SKIP_DIRS=$(cat /tmp/gitleaks_excludes) + trivy fs . --format json --output $ARTIFACTS_DIR/trivy-fs.json --severity HIGH,CRITICAL ${SKIP_DIRS:+--skip-dirs $SKIP_DIRS} || true + {{- end }} + + - | + python3 << 'EOF' + import yaml, json, os + fail_on = {'gitleaks': 0, 'semgrep': 10, 'trivy_critical': 0, 'trivy_high': 5, 'trivy_medium': -1, 'trivy_low': -1} + try: + if os.path.exists('security-config.yml'): + with open('security-config.yml') as f: + config = yaml.safe_load(f) + if config and 'fail_on' in config: + fail_on.update(config['fail_on']) + except Exception as e: + print(f"Error reading config: {e}") + os.makedirs('artifacts/security', exist_ok=True) + with open('artifacts/security/fail-on.json', 'w') as out: + json.dump(fail_on, out) + EOF + + - | + python3 << 'EOF' + import json, os + dir = 'artifacts/security' + def read_json(f): + if not os.path.exists(f): return None + try: + return json.load(open(f)) + except: return None + + fail_on = read_json(f'{dir}/fail-on.json') or {'gitleaks': 0, 'semgrep': 10, 'trivy_critical': 0, 'trivy_high': 5} + result = {'version': '0.6.0', 'status': 'PASS', 'blocking_count': 0, 'summary': {}} + + gitleaks = read_json(f'{dir}/gitleaks-report.json') + if gitleaks is not None: + count = len(gitleaks) if isinstance(gitleaks, list) else len(gitleaks.get('findings', [])) + result['summary']['gitleaks'] = {'total': count} + if fail_on.get('gitleaks', 0) >= 0 and count > fail_on.get('gitleaks', 0): + result['blocking_count'] += count - fail_on['gitleaks'] + + trivy = read_json(f'{dir}/trivy-fs.json') + if trivy and trivy.get('Results'): + counts = {'critical': 0, 'high': 0, 'medium': 0, 'low': 0} + for r in trivy['Results']: + for v in (r.get('Vulnerabilities') or []): + s = v.get('Severity', '').lower() + if s in counts: counts[s] += 1 + result['summary']['trivy_fs'] = counts + for s, k in [('critical','trivy_critical'),('high','trivy_high'),('medium','trivy_medium'),('low','trivy_low')]: + if fail_on.get(k, -1) >= 0 and counts[s] > fail_on[k]: + result['blocking_count'] += counts[s] - fail_on[k] + + semgrep = read_json(f'{dir}/semgrep-report.json') + if semgrep is not None: + items = semgrep if isinstance(semgrep, list) else semgrep.get('results', []) + count = len(items) + result['summary']['semgrep'] = {'total': count} + if fail_on.get('semgrep', 10) >= 0 and count > fail_on.get('semgrep', 10): + result['blocking_count'] += count - fail_on['semgrep'] + + result['status'] = 'FAIL' if result['blocking_count'] > 0 else 'PASS' + with open(f'{dir}/summary.json', 'w') as out: + json.dump(result, out, indent=2) + print(f"Status: {result['status']} | Blocking: {result['blocking_count']}") + if result['status'] == 'FAIL': + raise SystemExit(1) + EOF diff --git a/cli/templates/ci/gitlab/python_security.yml.tmpl b/cli/templates/ci/gitlab/python_security.yml.tmpl new file mode 100644 index 0000000..3e25800 --- /dev/null +++ b/cli/templates/ci/gitlab/python_security.yml.tmpl @@ -0,0 +1,138 @@ +stages: + - security + +variables: + ARTIFACTS_DIR: "artifacts/security" + +security-scan: + stage: security + image: python:3.11 + timeout: 15 minutes + + artifacts: + when: always + paths: + - artifacts/security/ + expire_in: 30 days + + before_script: + - apt-get update -qq && apt-get install -y -qq curl + - pip install pyyaml --quiet + - mkdir -p $ARTIFACTS_DIR + + script: + - | + python3 << 'EOF' + import yaml, json, os + exclude_paths = [] + try: + if os.path.exists('security-config.yml'): + with open('security-config.yml') as f: + config = yaml.safe_load(f) + if config and 'exclude_paths' in config: + exclude_paths = config['exclude_paths'] or [] + except Exception as e: + print(f"Error reading config: {e}") + with open('/tmp/semgrep_excludes', 'w') as f: + f.write(' '.join([f'--exclude {p}' for p in exclude_paths])) + with open('/tmp/gitleaks_excludes', 'w') as f: + f.write(','.join(exclude_paths)) + EOF + + - pip install -r requirements.txt --quiet 2>/dev/null || true + - '[ -f Pipfile ] && pip install pipenv && pipenv install --quiet 2>/dev/null || true' + - '[ -f pyproject.toml ] && pip install . --quiet 2>/dev/null || true' + + {{- if .Tools.Semgrep }} + - pip install semgrep --quiet + - SEMGREP_EXCLUDES=$(cat /tmp/semgrep_excludes) + - semgrep --config p/ci $SEMGREP_EXCLUDES --json > $ARTIFACTS_DIR/semgrep-report.json || true + {{- end }} + + {{- if .Tools.Gitleaks }} + - | + GITLEAKS_EXCLUDES=$(cat /tmp/gitleaks_excludes) + curl -sSfL https://github.com/gitleaks/gitleaks/releases/download/v8.18.1/gitleaks_8.18.1_linux_x64.tar.gz | tar -xz + mv gitleaks /usr/local/bin/ + if [ -n "$GITLEAKS_EXCLUDES" ]; then + cat > .gitleaks.toml << TOMLEOF + [allowlist] + paths = ["$GITLEAKS_EXCLUDES"] + TOMLEOF + gitleaks detect --report-format json --report-path $ARTIFACTS_DIR/gitleaks-report.json --config .gitleaks.toml --exit-code 0 || true + else + gitleaks detect --report-format json --report-path $ARTIFACTS_DIR/gitleaks-report.json --exit-code 0 || true + fi + {{- end }} + + {{- if .Tools.Trivy }} + - | + curl -sSfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin + SKIP_DIRS=$(cat /tmp/gitleaks_excludes) + trivy fs . --format json --output $ARTIFACTS_DIR/trivy-fs.json --severity HIGH,CRITICAL ${SKIP_DIRS:+--skip-dirs $SKIP_DIRS} || true + {{- end }} + + - | + python3 << 'EOF' + import yaml, json, os + fail_on = {'gitleaks': 0, 'semgrep': 10, 'trivy_critical': 0, 'trivy_high': 5, 'trivy_medium': -1, 'trivy_low': -1} + try: + if os.path.exists('security-config.yml'): + with open('security-config.yml') as f: + config = yaml.safe_load(f) + if config and 'fail_on' in config: + fail_on.update(config['fail_on']) + except Exception as e: + print(f"Error reading config: {e}") + os.makedirs('artifacts/security', exist_ok=True) + with open('artifacts/security/fail-on.json', 'w') as out: + json.dump(fail_on, out) + EOF + + - | + python3 << 'EOF' + import json, os + dir = 'artifacts/security' + def read_json(f): + if not os.path.exists(f): return None + try: + return json.load(open(f)) + except: return None + + fail_on = read_json(f'{dir}/fail-on.json') or {'gitleaks': 0, 'semgrep': 10, 'trivy_critical': 0, 'trivy_high': 5} + result = {'version': '0.6.0', 'status': 'PASS', 'blocking_count': 0, 'summary': {}} + + gitleaks = read_json(f'{dir}/gitleaks-report.json') + if gitleaks is not None: + count = len(gitleaks) if isinstance(gitleaks, list) else len(gitleaks.get('findings', [])) + result['summary']['gitleaks'] = {'total': count} + if fail_on.get('gitleaks', 0) >= 0 and count > fail_on.get('gitleaks', 0): + result['blocking_count'] += count - fail_on['gitleaks'] + + trivy = read_json(f'{dir}/trivy-fs.json') + if trivy and trivy.get('Results'): + counts = {'critical': 0, 'high': 0, 'medium': 0, 'low': 0} + for r in trivy['Results']: + for v in (r.get('Vulnerabilities') or []): + s = v.get('Severity', '').lower() + if s in counts: counts[s] += 1 + result['summary']['trivy_fs'] = counts + for s, k in [('critical','trivy_critical'),('high','trivy_high'),('medium','trivy_medium'),('low','trivy_low')]: + if fail_on.get(k, -1) >= 0 and counts[s] > fail_on[k]: + result['blocking_count'] += counts[s] - fail_on[k] + + semgrep = read_json(f'{dir}/semgrep-report.json') + if semgrep is not None: + items = semgrep if isinstance(semgrep, list) else semgrep.get('results', []) + count = len(items) + result['summary']['semgrep'] = {'total': count} + if fail_on.get('semgrep', 10) >= 0 and count > fail_on.get('semgrep', 10): + result['blocking_count'] += count - fail_on['semgrep'] + + result['status'] = 'FAIL' if result['blocking_count'] > 0 else 'PASS' + with open(f'{dir}/summary.json', 'w') as out: + json.dump(result, out, indent=2) + print(f"Status: {result['status']} | Blocking: {result['blocking_count']}") + if result['status'] == 'FAIL': + raise SystemExit(1) + EOF diff --git a/cli/templates/templates.go b/cli/templates/templates.go index 7497163..05017e5 100644 --- a/cli/templates/templates.go +++ b/cli/templates/templates.go @@ -3,5 +3,7 @@ package templates import "embed" //go:embed workflows/*.tmpl +//go:embed ci/gitlab/*.tmpl +//go:embed ci/bitbucket/*.tmpl //go:embed security-config.yml.tmpl var TemplateFS embed.FS