From 3b89ac4a5458a863c0b11317c188f3114879477e Mon Sep 17 00:00:00 2001 From: EdgarPsda Date: Sun, 29 Mar 2026 16:00:11 -0700 Subject: [PATCH] Add GitHub Actions workflow templates for Python and Java Add python_security.yml.tmpl with Python setup, pip install, and Semgrep/Gitleaks/Trivy steps. Add java_security.yml.tmpl with Java 17 setup, Maven/Gradle dependency caching, and full security scanning steps. Update workflow generator to route python and java to their templates. --- cli/generators/workflow.go | 4 + .../workflows/java_security.yml.tmpl | 275 ++++++++++++++++++ .../workflows/python_security.yml.tmpl | 257 ++++++++++++++++ 3 files changed, 536 insertions(+) create mode 100644 cli/templates/workflows/java_security.yml.tmpl create mode 100644 cli/templates/workflows/python_security.yml.tmpl diff --git a/cli/generators/workflow.go b/cli/generators/workflow.go index 457a27f..975ee82 100644 --- a/cli/generators/workflow.go +++ b/cli/generators/workflow.go @@ -17,6 +17,10 @@ func GenerateGithubActions(cfg *InitConfig) error { tmplName = "workflows/node_security.yml.tmpl" case "golang": tmplName = "workflows/go_security.yml.tmpl" + case "python": + tmplName = "workflows/python_security.yml.tmpl" + case "java": + tmplName = "workflows/java_security.yml.tmpl" default: return fmt.Errorf("no workflow template for language: %s", cfg.Project.Language) } diff --git a/cli/templates/workflows/java_security.yml.tmpl b/cli/templates/workflows/java_security.yml.tmpl new file mode 100644 index 0000000..8faaafd --- /dev/null +++ b/cli/templates/workflows/java_security.yml.tmpl @@ -0,0 +1,275 @@ +name: Security Scan + +on: + push: + branches: ["main"] + pull_request: + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + security: + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Prepare artifacts directory + run: mkdir -p artifacts/security + + - name: Extract exclude paths from config + id: exclude-paths + run: | + python3 << 'EOF' + import yaml + import json + import 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}") + + semgrep_excludes = ' '.join([f'--exclude {p}' for p in exclude_paths]) + gitleaks_excludes = ','.join(exclude_paths) if exclude_paths else '' + + with open(os.environ['GITHUB_OUTPUT'], 'a') as f: + f.write(f"semgrep_excludes={semgrep_excludes}\n") + f.write(f"gitleaks_excludes={gitleaks_excludes}\n") + f.write(f"exclude_paths={json.dumps(exclude_paths)}\n") + EOF + pip install pyyaml + + - name: Set up Java + uses: actions/setup-java@v4 + with: + java-version: "17" + distribution: "temurin" + + - name: Cache Maven packages + if: hashFiles('pom.xml') != '' + uses: actions/cache@v4 + with: + path: ~/.m2 + key: ${{"{{"}} runner.os {{ "}}" }}-m2-${{"{{"}} hashFiles('**/pom.xml') {{ "}}" }} + + - name: Cache Gradle packages + if: hashFiles('build.gradle') != '' || hashFiles('build.gradle.kts') != '' + uses: actions/cache@v4 + with: + path: ~/.gradle/caches + key: ${{"{{"}} runner.os {{ "}}" }}-gradle-${{"{{"}} hashFiles('**/*.gradle*') {{ "}}" }} + + - name: Install dependencies (Maven) + if: hashFiles('pom.xml') != '' + run: mvn dependency:resolve -q + + - name: Install dependencies (Gradle) + if: hashFiles('build.gradle') != '' || hashFiles('build.gradle.kts') != '' + run: ./gradlew dependencies -q || gradle dependencies -q + + {{- if .Tools.Semgrep }} + - name: Set up Python for Semgrep + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Run Semgrep + run: | + pip install semgrep + semgrep --config p/ci ${{"{{"}} steps.exclude-paths.outputs.semgrep_excludes {{ "}}" }} --json > artifacts/security/semgrep-report.json || true + {{- end }} + + {{- if .Tools.Gitleaks }} + - name: Create Gitleaks config with exclusions + if: steps.exclude-paths.outputs.gitleaks_excludes != '' + run: | + cat > .gitleaks.toml << 'EOF' + [allowlist] + paths = [ + '''${{"{{"}} steps.exclude-paths.outputs.gitleaks_excludes {{ "}}" }}''' + ] + EOF + sed -i "s/'''/\"/g" .gitleaks.toml + sed -i 's/,/",\n "/g' .gitleaks.toml + + - name: Install Gitleaks + run: | + curl -sSfL https://github.com/gitleaks/gitleaks/releases/download/v8.18.1/gitleaks_8.18.1_linux_x64.tar.gz | tar -xz + sudo mv gitleaks /usr/local/bin/ + + - name: Run Gitleaks + run: | + if [ -f .gitleaks.toml ]; then + 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 }} + - name: Run Trivy FS Scan + uses: aquasecurity/trivy-action@master + with: + scan-type: fs + format: json + output: artifacts/security/trivy-fs.json + severity: HIGH,CRITICAL + skip-dirs: ${{"{{"}} steps.exclude-paths.outputs.gitleaks_excludes {{ "}}" }} + + - name: Check for Dockerfile + id: docker-check + run: | + if [ -f "Dockerfile" ]; then + echo "has_docker=true" >> $GITHUB_OUTPUT + else + echo "has_docker=false" >> $GITHUB_OUTPUT + fi + + - name: Build Docker image for scanning + if: steps.docker-check.outputs.has_docker == 'true' + run: docker build -t devsecops-scan-temp:latest . + + - name: Run Trivy Image Scan + if: steps.docker-check.outputs.has_docker == 'true' + uses: aquasecurity/trivy-action@master + with: + scan-type: image + image-ref: devsecops-scan-temp:latest + format: json + output: artifacts/security/trivy-image.json + severity: HIGH,CRITICAL + {{- end }} + + - name: Extract fail_on thresholds from config + if: always() + run: | + pip install pyyaml + python3 << 'EOF' + import yaml + import json + import 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, using defaults: {e}") + + os.makedirs('artifacts/security', exist_ok=True) + with open('artifacts/security/fail-on.json', 'w') as out: + json.dump(fail_on, out) + EOF + + - name: Generate security summary (JSON) + if: always() + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const path = require('path'); + const dir = 'artifacts/security'; + + function readJson(file) { + if (!fs.existsSync(file)) return null; + try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } + } + + const failOn = readJson(path.join(dir, 'fail-on.json')) || { gitleaks: 0, semgrep: 10, trivy_critical: 0, trivy_high: 5, trivy_medium: -1, trivy_low: -1 }; + const result = { version: "0.3.0", status: "PASS", blocking_count: 0, summary: {} }; + + const gitleaks = readJson(path.join(dir, 'gitleaks-report.json')); + if (gitleaks) { + const count = Array.isArray(gitleaks) ? gitleaks.length : (gitleaks.findings?.length || 0); + result.summary.gitleaks = { total: count }; + if (failOn.gitleaks >= 0 && count > failOn.gitleaks) result.blocking_count += count - failOn.gitleaks; + } + + const trivy = readJson(path.join(dir, 'trivy-fs.json')); + if (trivy?.Results) { + const counts = { critical: 0, high: 0, medium: 0, low: 0 }; + for (const r of trivy.Results) for (const v of (r.Vulnerabilities || [])) { const s = v.Severity?.toLowerCase(); if (s in counts) counts[s]++; } + result.summary.trivy_fs = counts; + for (const [s, k] of [['critical','trivy_critical'],['high','trivy_high'],['medium','trivy_medium'],['low','trivy_low']]) { + if (failOn[k] >= 0 && counts[s] > failOn[k]) result.blocking_count += counts[s] - failOn[k]; + } + } + + const semgrep = readJson(path.join(dir, 'semgrep-report.json')); + if (semgrep) { + const count = (Array.isArray(semgrep) ? semgrep : (semgrep.results || [])).length; + result.summary.semgrep = { total: count }; + if (failOn.semgrep >= 0 && count > failOn.semgrep) result.blocking_count += count - failOn.semgrep; + } + + result.status = result.blocking_count > 0 ? "FAIL" : "PASS"; + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'summary.json'), JSON.stringify(result, null, 2)); + + - name: Post PR Security Summary + if: always() && github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + github-token: ${{"{{"}} secrets.GITHUB_TOKEN {{ "}}" }} + script: | + const fs = require('fs'); + const marker = ''; + let summary = null; + try { summary = JSON.parse(fs.readFileSync('artifacts/security/summary.json', 'utf8')); } catch {} + + let body = `${marker}\n### 🔐 DevSecOps Kit Security Summary\n\n`; + if (!summary) { body += "_No summary available._\n"; } else { + body += `- **Gitleaks:** ${summary.summary?.gitleaks?.total ?? 0} leak(s)\n`; + const trivyFs = summary.summary?.trivy_fs ?? {}; + if (Object.keys(trivyFs).length > 0) { body += `- **Trivy FS:**\n`; for (const s of Object.keys(trivyFs)) body += ` - ${s.toUpperCase()}: ${trivyFs[s]}\n`; } + if (summary.summary?.semgrep) body += `- **Semgrep:** ${summary.summary.semgrep.total} finding(s)\n`; + body += `\n**Status:** ${summary.status === "FAIL" ? '🚨 **FAIL**' : '✅ **PASS**'}\n`; + if (summary.blocking_count > 0) body += `_${summary.blocking_count} issue(s) exceed configured thresholds_\n`; + } + + const { owner, repo } = context.repo; + const pr = context.issue.number; + const comments = await github.rest.issues.listComments({ owner, repo, issue_number: pr }); + const existing = comments.data.find(c => c.body?.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); + } else { + await github.rest.issues.createComment({ owner, repo, issue_number: pr, body }); + } + + - name: Upload security artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: security-reports + path: artifacts/security/ + + - name: Check fail gates + if: always() + run: | + STATUS=$(cat artifacts/security/summary.json | python3 -c "import sys, json; print(json.load(sys.stdin).get('status', 'UNKNOWN'))") + BLOCKING=$(cat artifacts/security/summary.json | python3 -c "import sys, json; print(json.load(sys.stdin).get('blocking_count', 0))") + echo "Status: $STATUS | Blocking: $BLOCKING" + if [ "$STATUS" = "FAIL" ]; then echo "❌ Security scan FAILED: $BLOCKING issue(s) exceed thresholds"; exit 1; fi + echo "✅ Security scan PASSED" diff --git a/cli/templates/workflows/python_security.yml.tmpl b/cli/templates/workflows/python_security.yml.tmpl new file mode 100644 index 0000000..d2d8209 --- /dev/null +++ b/cli/templates/workflows/python_security.yml.tmpl @@ -0,0 +1,257 @@ +name: Security Scan + +on: + push: + branches: ["main"] + pull_request: + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + security: + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Prepare artifacts directory + run: mkdir -p artifacts/security + + - name: Extract exclude paths from config + id: exclude-paths + run: | + python3 << 'EOF' + import yaml + import json + import 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 [] + print(f"Loaded exclude_paths: {exclude_paths}") + else: + print("No security-config.yml found, no exclusions") + except Exception as e: + print(f"Error reading config: {e}") + + semgrep_excludes = ' '.join([f'--exclude {p}' for p in exclude_paths]) + gitleaks_excludes = ','.join(exclude_paths) if exclude_paths else '' + + with open(os.environ['GITHUB_OUTPUT'], 'a') as f: + f.write(f"semgrep_excludes={semgrep_excludes}\n") + f.write(f"gitleaks_excludes={gitleaks_excludes}\n") + f.write(f"exclude_paths={json.dumps(exclude_paths)}\n") + EOF + pip install pyyaml + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + if [ -f Pipfile ]; then pip install pipenv && pipenv install; fi + if [ -f pyproject.toml ]; then pip install .; fi + + {{- if .Tools.Semgrep }} + - name: Run Semgrep + run: | + pip install semgrep + semgrep --config p/ci ${{"{{"}} steps.exclude-paths.outputs.semgrep_excludes {{ "}}" }} --json > artifacts/security/semgrep-report.json || true + {{- end }} + + {{- if .Tools.Gitleaks }} + - name: Create Gitleaks config with exclusions + if: steps.exclude-paths.outputs.gitleaks_excludes != '' + run: | + cat > .gitleaks.toml << 'EOF' + [allowlist] + paths = [ + '''${{"{{"}} steps.exclude-paths.outputs.gitleaks_excludes {{ "}}" }}''' + ] + EOF + sed -i "s/'''/\"/g" .gitleaks.toml + sed -i 's/,/",\n "/g' .gitleaks.toml + + - name: Install Gitleaks + run: | + curl -sSfL https://github.com/gitleaks/gitleaks/releases/download/v8.18.1/gitleaks_8.18.1_linux_x64.tar.gz | tar -xz + sudo mv gitleaks /usr/local/bin/ + + - name: Run Gitleaks + run: | + if [ -f .gitleaks.toml ]; then + 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 }} + - name: Run Trivy FS Scan + uses: aquasecurity/trivy-action@master + with: + scan-type: fs + format: json + output: artifacts/security/trivy-fs.json + severity: HIGH,CRITICAL + skip-dirs: ${{"{{"}} steps.exclude-paths.outputs.gitleaks_excludes {{ "}}" }} + + - name: Check for Dockerfile + id: docker-check + run: | + if [ -f "Dockerfile" ]; then + echo "has_docker=true" >> $GITHUB_OUTPUT + else + echo "has_docker=false" >> $GITHUB_OUTPUT + fi + + - name: Build Docker image for scanning + if: steps.docker-check.outputs.has_docker == 'true' + run: docker build -t devsecops-scan-temp:latest . + + - name: Run Trivy Image Scan + if: steps.docker-check.outputs.has_docker == 'true' + uses: aquasecurity/trivy-action@master + with: + scan-type: image + image-ref: devsecops-scan-temp:latest + format: json + output: artifacts/security/trivy-image.json + severity: HIGH,CRITICAL + {{- end }} + + - name: Extract fail_on thresholds from config + if: always() + run: | + pip install pyyaml + python3 << 'EOF' + import yaml + import json + import 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, using defaults: {e}") + + os.makedirs('artifacts/security', exist_ok=True) + with open('artifacts/security/fail-on.json', 'w') as out: + json.dump(fail_on, out) + EOF + + - name: Generate security summary (JSON) + if: always() + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const path = require('path'); + const dir = 'artifacts/security'; + + function readJson(file) { + if (!fs.existsSync(file)) return null; + try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } + } + + const failOn = readJson(path.join(dir, 'fail-on.json')) || { gitleaks: 0, semgrep: 10, trivy_critical: 0, trivy_high: 5, trivy_medium: -1, trivy_low: -1 }; + const result = { version: "0.3.0", status: "PASS", blocking_count: 0, summary: {} }; + + const gitleaks = readJson(path.join(dir, 'gitleaks-report.json')); + if (gitleaks) { + const count = Array.isArray(gitleaks) ? gitleaks.length : (gitleaks.findings?.length || 0); + result.summary.gitleaks = { total: count }; + if (failOn.gitleaks >= 0 && count > failOn.gitleaks) result.blocking_count += count - failOn.gitleaks; + } + + const trivy = readJson(path.join(dir, 'trivy-fs.json')); + if (trivy?.Results) { + const counts = { critical: 0, high: 0, medium: 0, low: 0 }; + for (const r of trivy.Results) for (const v of (r.Vulnerabilities || [])) { const s = v.Severity?.toLowerCase(); if (s in counts) counts[s]++; } + result.summary.trivy_fs = counts; + for (const [s, k] of [['critical','trivy_critical'],['high','trivy_high'],['medium','trivy_medium'],['low','trivy_low']]) { + if (failOn[k] >= 0 && counts[s] > failOn[k]) result.blocking_count += counts[s] - failOn[k]; + } + } + + const semgrep = readJson(path.join(dir, 'semgrep-report.json')); + if (semgrep) { + const count = (Array.isArray(semgrep) ? semgrep : (semgrep.results || [])).length; + result.summary.semgrep = { total: count }; + if (failOn.semgrep >= 0 && count > failOn.semgrep) result.blocking_count += count - failOn.semgrep; + } + + result.status = result.blocking_count > 0 ? "FAIL" : "PASS"; + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'summary.json'), JSON.stringify(result, null, 2)); + + - name: Post PR Security Summary + if: always() && github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + github-token: ${{"{{"}} secrets.GITHUB_TOKEN {{ "}}" }} + script: | + const fs = require('fs'); + const marker = ''; + let summary = null; + try { summary = JSON.parse(fs.readFileSync('artifacts/security/summary.json', 'utf8')); } catch {} + + let body = `${marker}\n### 🔐 DevSecOps Kit Security Summary\n\n`; + if (!summary) { body += "_No summary available._\n"; } else { + body += `- **Gitleaks:** ${summary.summary?.gitleaks?.total ?? 0} leak(s)\n`; + const trivyFs = summary.summary?.trivy_fs ?? {}; + if (Object.keys(trivyFs).length > 0) { body += `- **Trivy FS:**\n`; for (const s of Object.keys(trivyFs)) body += ` - ${s.toUpperCase()}: ${trivyFs[s]}\n`; } + if (summary.summary?.semgrep) body += `- **Semgrep:** ${summary.summary.semgrep.total} finding(s)\n`; + body += `\n**Status:** ${summary.status === "FAIL" ? '🚨 **FAIL**' : '✅ **PASS**'}\n`; + if (summary.blocking_count > 0) body += `_${summary.blocking_count} issue(s) exceed configured thresholds_\n`; + } + + const { owner, repo } = context.repo; + const pr = context.issue.number; + const comments = await github.rest.issues.listComments({ owner, repo, issue_number: pr }); + const existing = comments.data.find(c => c.body?.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); + } else { + await github.rest.issues.createComment({ owner, repo, issue_number: pr, body }); + } + + - name: Upload security artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: security-reports + path: artifacts/security/ + + - name: Check fail gates + if: always() + run: | + STATUS=$(cat artifacts/security/summary.json | python3 -c "import sys, json; print(json.load(sys.stdin).get('status', 'UNKNOWN'))") + BLOCKING=$(cat artifacts/security/summary.json | python3 -c "import sys, json; print(json.load(sys.stdin).get('blocking_count', 0))") + echo "Status: $STATUS | Blocking: $BLOCKING" + if [ "$STATUS" = "FAIL" ]; then echo "❌ Security scan FAILED: $BLOCKING issue(s) exceed thresholds"; exit 1; fi + echo "✅ Security scan PASSED"