diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 53e67c8..5a951da 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -23,6 +23,40 @@ jobs: - 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}") + + # Build exclusion flags for different tools + semgrep_excludes = ' '.join([f'--exclude {p}' for p in exclude_paths]) + gitleaks_excludes = ','.join(exclude_paths) if exclude_paths else '' + + # Write to GitHub Actions output + 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 Go uses: actions/setup-go@v5 with: @@ -30,14 +64,41 @@ jobs: - name: Install dependencies run: go mod download - - name: Run Semgrep - uses: returntocorp/semgrep-action@v1 + - name: Set up Python for Semgrep + uses: actions/setup-python@v5 with: - config: "p/ci" + python-version: "3.11" + + - name: Run Semgrep (CLI JSON) + run: | + pip install semgrep + semgrep --config p/ci ${{ steps.exclude-paths.outputs.semgrep_excludes }} --json > artifacts/security/semgrep-report.json || true + - 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 + echo "Generated .gitleaks.toml:" + cat .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 - uses: gitleaks/gitleaks-action@v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + 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 - name: Run Trivy FS Scan uses: aquasecurity/trivy-action@master with: @@ -45,121 +106,460 @@ jobs: format: json output: artifacts/security/trivy-fs.json severity: HIGH,CRITICAL + skip-dirs: ${{ steps.exclude-paths.outputs.gitleaks_excludes }} - - name: Generate security summary (JSON) - if: always() + - name: Check for Dockerfile + id: docker-check run: | - echo "Generating security summary..." - mkdir -p artifacts/security - - # Gitleaks: count total findings (supports array or {findings:[...]}) - if [ -f artifacts/security/gitleaks-report.json ]; then - GITLEAKS_COUNT=$(jq 'if type=="array" then length else (.findings | length // 0) end' artifacts/security/gitleaks-report.json) + if [ -f "Dockerfile" ]; then + echo "has_docker=true" >> $GITHUB_OUTPUT + echo "✓ Dockerfile detected, will scan Docker image" else - GITLEAKS_COUNT=0 + echo "has_docker=false" >> $GITHUB_OUTPUT + echo "No Dockerfile found, skipping image scan" fi - # Trivy: group vulnerabilities by severity (CRITICAL/HIGH/etc.) - if [ -f artifacts/security/trivy-fs.json ]; then - TRIVY_SUMMARY=$(jq ' - ( [ .Results[].Vulnerabilities[]? | .Severity ] - | group_by(.) - | map({ (.[0]): length }) - | add - ) // {}' artifacts/security/trivy-fs.json) - else - TRIVY_SUMMARY='{}' - fi + - name: Build Docker image for scanning + if: steps.docker-check.outputs.has_docker == 'true' + run: | + # Build image with a temporary tag for scanning + docker build -t devsecops-scan-temp:latest . + echo "Built Docker image: 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 + + - name: Extract fail_on thresholds from config + if: always() + run: | + pip install pyyaml + python3 << 'EOF' + import yaml + import json + import os + + # Default thresholds + fail_on = { + 'gitleaks': 0, + 'semgrep': 10, + 'trivy_critical': 0, + 'trivy_high': 5, + 'trivy_medium': -1, + 'trivy_low': -1 + } + + # Try to read from security-config.yml + 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']) + print(f"Loaded fail_on config: {fail_on}") + else: + print("No security-config.yml found, using defaults") + except Exception as e: + print(f"Error reading config, using defaults: {e}") + + # Write to JSON for GitHub script to read + 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'; + const summaryPath = path.join(dir, 'summary.json'); + + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + function readJson(file) { + if (!fs.existsSync(file)) return null; + const raw = fs.readFileSync(file, 'utf8'); + try { return JSON.parse(raw); } + catch { return null; } + } + + // Read fail_on thresholds from extracted config + const failOnPath = path.join(dir, 'fail-on.json'); + const failOn = readJson(failOnPath) || { + gitleaks: 0, + semgrep: 10, + trivy_critical: 0, + trivy_high: 5, + trivy_medium: -1, + trivy_low: -1 + }; + core.info(`Using fail_on thresholds: ${JSON.stringify(failOn)}`); + + const result = { + version: "0.3.0", + status: "PASS", + blocking_count: 0, + summary: {}, + findings: [] + }; + + // + // GITLEAKS + // + const gitleaksPath = path.join(dir, 'gitleaks-report.json'); + const gitleaks = readJson(gitleaksPath); + if (gitleaks) { + let count = 0; + if (Array.isArray(gitleaks)) count = gitleaks.length; + else if (Array.isArray(gitleaks.findings)) count = gitleaks.findings.length; + result.summary.gitleaks = { total: count }; + + // Check fail gate + if (failOn.gitleaks >= 0 && count > failOn.gitleaks) { + result.blocking_count += count - failOn.gitleaks; + core.warning(`Gitleaks: ${count} secrets found (threshold: ${failOn.gitleaks})`); + } + } + + // + // TRIVY FS + // + const trivyFsPath = path.join(dir, 'trivy-fs.json'); + const trivy = readJson(trivyFsPath); + 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 sev = (v.Severity || "").toLowerCase(); + if (counts[sev] !== undefined) counts[sev]++; + } + } + result.summary.trivy_fs = counts; + + // Check fail gates for each severity + const severities = { + critical: 'trivy_critical', + high: 'trivy_high', + medium: 'trivy_medium', + low: 'trivy_low' + }; + + for (const [sev, configKey] of Object.entries(severities)) { + const threshold = failOn[configKey]; + const count = counts[sev] || 0; + if (threshold >= 0 && count > threshold) { + result.blocking_count += count - threshold; + core.warning(`Trivy ${sev}: ${count} vulnerabilities (threshold: ${threshold})`); + } + } + } + + // + // TRIVY IMAGE + // + const trivyImagePath = path.join(dir, 'trivy-image.json'); + const trivyImage = readJson(trivyImagePath); + if (trivyImage?.Results) { + const counts = { critical: 0, high: 0, medium: 0, low: 0 }; + for (const r of trivyImage.Results) { + for (const v of (r.Vulnerabilities || [])) { + const sev = (v.Severity || "").toLowerCase(); + if (counts[sev] !== undefined) counts[sev]++; + } + } + result.summary.trivy_image = counts; + core.info(`Trivy Image scan: ${JSON.stringify(counts)}`); + + // Check fail gates for image scan (use same thresholds as FS) + const severities = { + critical: 'trivy_critical', + high: 'trivy_high', + medium: 'trivy_medium', + low: 'trivy_low' + }; + + for (const [sev, configKey] of Object.entries(severities)) { + const threshold = failOn[configKey]; + const count = counts[sev] || 0; + if (threshold >= 0 && count > threshold) { + result.blocking_count += count - threshold; + core.warning(`Trivy Image ${sev}: ${count} vulnerabilities (threshold: ${threshold})`); + } + } + } + + // + // SEMGREP + // + const semgrepPath = path.join(dir, 'semgrep-report.json'); + const semgrep = readJson(semgrepPath); + if (semgrep) { + let results = []; - jq -n \ - --argjson gitleaks_count "$GITLEAKS_COUNT" \ - --argjson trivy "$TRIVY_SUMMARY" \ - '{ gitleaks: { total: $gitleaks_count }, trivy: $trivy }' \ - > artifacts/security/summary.json + if (Array.isArray(semgrep)) { + results = semgrep; + } else if (Array.isArray(semgrep.results)) { + results = semgrep.results; + } - echo "Summary written to artifacts/security/summary.json" + const count = results.length; + result.summary.semgrep = { total: count }; + core.info(`Semgrep findings: ${count}`); - - name: Post security summary as PR comment + // Check fail gate + if (failOn.semgrep >= 0 && count > failOn.semgrep) { + result.blocking_count += count - failOn.semgrep; + core.warning(`Semgrep: ${count} findings (threshold: ${failOn.semgrep})`); + } + } else { + core.info("No semgrep-report.json found, skipping Semgrep summary."); + } + + // Set final status + result.status = result.blocking_count > 0 ? "FAIL" : "PASS"; + + if (result.blocking_count > 0) { + core.warning(`Security scan FAILED: ${result.blocking_count} issue(s) exceed thresholds`); + } else { + core.info("Security scan PASSED: All checks within thresholds"); + } + + fs.writeFileSync(summaryPath, JSON.stringify(result, null, 2)); + core.info("Wrote summary.json"); + + - 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 path = 'artifacts/security/summary.json'; const marker = ''; + const summaryPath = 'artifacts/security/summary.json'; - let summary; - try { - const raw = fs.readFileSync(path, 'utf8'); - summary = JSON.parse(raw); - } catch (err) { - core.warning(`Could not read ${path}: ${err}`); - summary = null; - } + let summary = null; + try { summary = JSON.parse(fs.readFileSync(summaryPath, 'utf8')); } + catch { core.warning("No summary.json available."); } - let body = `${marker}\n`; - body += '### 🔐 DevSecOps Kit Security Summary\n\n'; + let body = `${marker}\n### 🔐 DevSecOps Kit Security Summary\n\n`; if (!summary) { - body += '_No summary.json available. Check workflow logs._\n'; + body += "_No summary available._\n"; } else { - const gitleaksTotal = summary?.gitleaks?.total ?? 0; - const trivy = summary?.trivy || {}; + const leaks = summary.summary?.gitleaks?.total ?? 0; + const trivyFs = summary.summary?.trivy_fs ?? {}; + const trivyImage = summary.summary?.trivy_image ?? {}; + const semgrep = summary.summary?.semgrep ?? null; - body += `- **Gitleaks:** ${gitleaksTotal} leak(s)\n`; + body += `- **Gitleaks:** ${leaks} leak(s)\n`; - const severities = Object.keys(trivy); - if (severities.length > 0) { - body += '- **Trivy vulnerabilities:**\n'; - for (const sev of severities.sort()) { - body += ` - ${sev}: ${trivy[sev]}\n`; + if (Object.keys(trivyFs).length > 0) { + body += `- **Trivy FS:**\n`; + for (const sev of Object.keys(trivyFs)) { + body += ` - ${sev.toUpperCase()}: ${trivyFs[sev]}\n`; } - } else { - body += '- **Trivy vulnerabilities:** none counted in summary\n'; } - const hasBlocking = - gitleaksTotal > 0 || - (trivy.CRITICAL ?? 0) > 0 || - (trivy.HIGH ?? 0) > 0; + if (Object.keys(trivyImage).length > 0) { + body += `- **Trivy Image:**\n`; + for (const sev of Object.keys(trivyImage)) { + body += ` - ${sev.toUpperCase()}: ${trivyImage[sev]}\n`; + } + } + + if (semgrep) { + body += `- **Semgrep:** ${semgrep.total} finding(s)\n`; + } - body += '\n'; - body += hasBlocking - ? '🚨 _Status: Potential blocking issues detected._\n' - : '✅ _Status: No blocking issues detected (HIGH/CRITICAL)._ \n'; + // Use status from summary.json + const status = summary.status || "UNKNOWN"; + const blockingCount = summary.blocking_count || 0; + + body += `\n**Status:** ${status === "FAIL" ? '🚨 **FAIL**' : '✅ **PASS**'}\n`; + if (blockingCount > 0) { + body += `_${blockingCount} issue(s) exceed configured thresholds_\n`; + } } const { owner, repo } = context.repo; - const issue_number = context.issue.number; + const pr = context.issue.number; - const comments = await github.rest.issues.listComments({ - owner, - repo, - issue_number, - }); - - const existing = comments.data.find(c => c.body && c.body.includes(marker)); + 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, + owner, repo, comment_id: existing.id, - body, + body }); } else { await github.rest.issues.createComment({ - owner, - repo, - issue_number, - body, + owner, repo, + issue_number: pr, + body }); } + - name: Post detailed fix-it comments + if: always() && github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const fs = require('fs'); + const path = require('path'); + + const dir = 'artifacts/security'; + const { owner, repo } = context.repo; + const pr = context.issue.number; + + function readJson(file) { + if (!fs.existsSync(file)) return null; + try { return JSON.parse(fs.readFileSync(file, 'utf8')); } + catch { return null; } + } + + // Get PR files to validate comments are on changed files + const { data: prFiles } = await github.rest.pulls.listFiles({ owner, repo, pull_number: pr }); + const changedFiles = new Set(prFiles.map(f => f.filename)); + + // Get the commit SHA for review comments + const { data: prData } = await github.rest.pulls.get({ owner, repo, pull_number: pr }); + const commitId = prData.head.sha; + + const comments = []; + + // + // SEMGREP FINDINGS + // + const semgrepPath = path.join(dir, 'semgrep-report.json'); + const semgrep = readJson(semgrepPath); + if (semgrep) { + let results = []; + if (Array.isArray(semgrep)) results = semgrep; + else if (Array.isArray(semgrep.results)) results = semgrep.results; + + for (const finding of results.slice(0, 10)) { // Limit to 10 comments + const filePath = finding.path; + const line = finding.start?.line || finding.line || 1; + const message = finding.extra?.message || finding.check_id || 'Security issue detected'; + const severity = finding.extra?.severity || 'WARNING'; + const fixRegex = finding.extra?.fix_regex; + + if (!changedFiles.has(filePath)) continue; // Only comment on changed files + + let body = `**🔍 Semgrep [${severity}]**\n\n`; + body += `${message}\n\n`; + body += `**Rule:** \`${finding.check_id}\`\n`; + + if (fixRegex) { + body += `\n**Suggested fix:** Apply the regex replacement suggested by Semgrep.\n`; + } + + if (finding.extra?.metadata?.references) { + body += `\n**References:**\n`; + for (const ref of finding.extra.metadata.references.slice(0, 3)) { + body += `- ${ref}\n`; + } + } + + comments.push({ path: filePath, line, body }); + } + } + + // + // GITLEAKS FINDINGS + // + const gitleaksPath = path.join(dir, 'gitleaks-report.json'); + const gitleaks = readJson(gitleaksPath); + if (gitleaks) { + let leaks = []; + if (Array.isArray(gitleaks)) leaks = gitleaks; + else if (Array.isArray(gitleaks.findings)) leaks = gitleaks.findings; + + for (const leak of leaks.slice(0, 5)) { // Limit to 5 secrets + const filePath = leak.File || leak.file; + const line = leak.StartLine || leak.line || 1; + const secret = leak.Secret || leak.match || ''; + const rule = leak.RuleID || leak.rule || 'Secret detected'; + + if (!changedFiles.has(filePath)) continue; + + let body = `**🚨 Secret Detected**\n\n`; + body += `**Rule:** \`${rule}\`\n`; + body += `**Match:** \`${secret.substring(0, 20)}...\`\n\n`; + body += `⚠️ **Action Required:** Remove this secret immediately and:\n`; + body += `1. Rotate the compromised credential\n`; + body += `2. Use environment variables or secret management\n`; + body += `3. Never commit secrets to version control\n`; + + comments.push({ path: filePath, line, body }); + } + } + + // Post review comments (batch API) + if (comments.length > 0) { + try { + await github.rest.pulls.createReview({ + owner, + repo, + pull_number: pr, + commit_id: commitId, + event: 'COMMENT', + comments: comments.map(c => ({ + path: c.path, + line: c.line, + body: c.body + })) + }); + core.info(`Posted ${comments.length} fix-it comment(s)`); + } catch (error) { + core.warning(`Failed to post review comments: ${error.message}`); + } + } else { + core.info("No findings in changed files, skipping fix-it comments"); + } + - 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: | + if [ ! -f artifacts/security/summary.json ]; then + echo "No summary.json found, skipping fail gate check" + exit 0 + fi + + STATUS=$(cat artifacts/security/summary.json | python3 -c "import sys, json; print(json.load(sys.stdin).get('status', 'UNKNOWN'))") + BLOCKING_COUNT=$(cat artifacts/security/summary.json | python3 -c "import sys, json; print(json.load(sys.stdin).get('blocking_count', 0))") + + echo "Security scan status: $STATUS" + echo "Blocking issues: $BLOCKING_COUNT" + + if [ "$STATUS" = "FAIL" ]; then + echo "❌ Security scan FAILED: $BLOCKING_COUNT issue(s) exceed configured thresholds" + echo "Review the security summary above and artifacts for details" + exit 1 + else + echo "✅ Security scan PASSED: All findings within acceptable thresholds" + exit 0 + fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..be7a20d --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# Binaries +devsecops +devsecops-* + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store + +# Claude Code +CLAUDE.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 2763a61..cfd3c25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,96 @@ and this project adheres (loosely) to [Semantic Versioning](https://semver.org/s --- +## [0.3.0] - 2025-01-XX + +### Added + +- **Config-driven fail gates** 🎯: + - New `fail_on` configuration in `security-config.yml` + - Define per-tool thresholds that fail CI builds: + - `gitleaks`: Fail on secret count threshold (default: 0) + - `semgrep`: Fail on finding count threshold (default: 10) + - `trivy_critical`, `trivy_high`, `trivy_medium`, `trivy_low`: Fail on vulnerability counts + - Set threshold to `-1` to disable specific gate + - Workflow now exits with error code 1 when thresholds exceeded + - Summary status shows `PASS` or `FAIL` based on thresholds + +- **Exclude paths support** 🚫: + - New `exclude_paths` configuration to reduce scanning noise + - Applies to all enabled scanners: + - Semgrep: Uses `--exclude` flags + - Gitleaks: Generates `.gitleaks.toml` with path allowlist + - Trivy: Uses `skip-dirs` parameter + - Common exclusions: `vendor/`, `node_modules/`, `test/`, etc. + +- **Dockerfile detection** 🐳: + - Automatic detection of Dockerfile and docker-compose.yml + - Added `HasDocker` and `DockerImages` fields to `ProjectInfo` + - `devsecops detect` now shows Docker status + - Parses Dockerfile to extract base images + +- **Trivy image scanning** 📦: + - Automatic Docker image scanning when Dockerfile detected + - Builds temporary image (`devsecops-scan-temp:latest`) for scanning + - Generates `trivy-image.json` artifact + - Image vulnerabilities included in summary and PR comments + - Same fail gates apply to both FS and image scans + +- **Inline "Fix-it" PR comments** 💬: + - Detailed, file/line-specific security comments on PRs + - Semgrep findings: + - Shows severity, rule ID, and message + - Includes fix suggestions when available + - Links to security references + - Gitleaks findings: + - Highlights secret location + - Provides remediation steps + - Warns about credential rotation + - Only comments on changed files in the PR + - Limited to 10 Semgrep + 5 Gitleaks comments per run (prevents spam) + +- **Enhanced PR summary comments**: + - Now shows clear **PASS/FAIL status** based on fail gates + - Displays blocking issue count + - Separate sections for Trivy FS and Trivy Image results + - Idempotent updates (no duplicate comments) + +- **Structured summary.json v0.3.0**: + - New fields: + - `status`: "PASS" or "FAIL" + - `blocking_count`: Number of issues exceeding thresholds + - `trivy_image`: Image scan results (when Dockerfile present) + - Ready for dashboard integrations and trend analysis + +### Changed + +- **Updated `security-config.yml` schema to v0.3.0**: + - Added comprehensive `fail_on` configuration with defaults + - Added `exclude_paths` with commented examples + - Updated version to `"0.3.0"` + +- **Workflow templates enhanced**: + - Added Python step to extract config (requires PyYAML) + - Config extraction happens early in workflow + - Fail gate check runs at end (after artifacts upload) + - Both Go and Node.js templates updated identically + +- **README updated**: + - Highlighted v0.3.0 features with 🆕 badges + - Added fail gates and exclude paths examples + - Updated configuration section with full v0.3.0 schema + - Added customization instructions + - Updated roadmap with release status + +### Fixed + +- **PyYAML installation** added to config extraction step (fixes `ModuleNotFoundError`) +- **Dockerfile image extraction** now uses proper string parsing (not filepath.SplitList) +- **Build stage detection** in Dockerfiles (skips `FROM ... AS stage` lines) +- **Gitleaks JSON report generation** switched from `gitleaks-action` to direct CLI execution (enables fix-it comments to read findings) + +--- + ## [0.2.0] - 2025-11-21 ### Added diff --git a/README.md b/README.md index b8c39ad..3ad51de 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,14 @@ DevSecOps Kit detects your project (Node.js or Go), generates a hardened GitHub Designed for small teams, freelancers, and agencies who need practical DevSecOps without complexity. -## 🚀 Key Features (v0.2.0) +## 🚀 Key Features (v0.3.0) ### 🔍 Automatic Project Detection Works out-of-the-box with: - Node.js (`package.json`) - Go (`go.mod`) +- **Docker** (Dockerfile detection) 🆕 ### ⚙️ Auto-Generated Security Pipeline Generates a ready-to-run GitHub Actions workflow including: @@ -19,10 +20,41 @@ Generates a ready-to-run GitHub Actions workflow including: - Semgrep (SAST) - Gitleaks (Secrets detection) - Trivy (FS + dependency scanning) -- Hardenered permissions +- **Trivy Image Scanning** (when Dockerfile present) 🆕 +- Hardened permissions - Artifact uploads - Timeout protections +### 🎯 Config-Driven Fail Gates 🆕 +Define thresholds that automatically fail CI builds: + +```yaml +fail_on: + gitleaks: 0 # Fail if ANY secrets detected + semgrep: 10 # Fail if 10+ findings + trivy_critical: 0 # Fail if ANY critical vulnerabilities + trivy_high: 5 # Fail if 5+ high severity vulnerabilities +``` + +### 🚫 Exclude Paths 🆕 +Reduce noise by excluding directories from scans: + +```yaml +exclude_paths: + - "vendor/" + - "node_modules/" + - "test/" + - "*.test.js" +``` + +### 💬 Inline "Fix-it" PR Comments 🆕 +Get detailed, actionable feedback directly on your code: + +- File/line-specific comments for security issues +- Remediation guidance for each finding +- References to security best practices +- Automatic comment placement on changed files only + ### 🧙 Interactive Wizard ```bash devsecops init --wizard @@ -52,29 +84,39 @@ Each workflow produces: ``` artifacts/security/ gitleaks-report.json + semgrep-report.json trivy-fs.json - summary.json + trivy-image.json # When Dockerfile present + summary.json # v0.3.0 schema ``` The `summary.json` contains: - Total secrets leaks - Vulnerability counts by severity -- Ready for dashboards or fail-gates in future releases +- **PASS/FAIL status based on thresholds** 🆕 +- **Blocking issue count** 🆕 + +### 💬 Enhanced PR Security Comments 🆕 +Every pull request receives: -### 💬 Automated PR Security Comment -Every pull request receives a concise, updated comment summarizing: +1. **Summary Comment** (updated, not duplicated): + - Secrets found + - FS & Image vulnerabilities + - **Clear PASS/FAIL status** + - **Blocking issue count** -- Secrets found -- Vulnerabilities -- PASS/FAIL recommendation +2. **Inline Fix-it Comments**: + - Specific file/line comments + - Remediation guidance + - Security references -### 📄 Expanded Configuration (v0.2.0) +### 📄 Configuration (v0.3.0) Generated automatically as: ```yaml -version: "0.2.0" +version: "0.3.0" language: "golang" framework: "" @@ -86,8 +128,20 @@ tools: trivy: true gitleaks: true -exclude_paths: [] -fail_on: {} +# Exclude paths from scanning (reduces noise) +exclude_paths: + - "vendor/" + - "node_modules/" + - "test/" + +# Fail gates - CI fails if thresholds exceeded +fail_on: + gitleaks: 0 # Fail if ANY secrets detected + semgrep: 10 # Fail if 10+ Semgrep findings + trivy_critical: 0 # Fail if ANY critical vulnerabilities + trivy_high: 5 # Fail if 5+ high severity vulnerabilities + trivy_medium: -1 # Disabled (set to number to enable) + trivy_low: -1 # Disabled notifications: pr_comment: true @@ -95,6 +149,11 @@ notifications: email: false ``` +**How to customize:** +1. Run `devsecops init` to generate the config +2. Edit `security-config.yml` to adjust thresholds and exclusions +3. Commit changes - they take effect on next CI run + ## 🛠️ Installation ### Option A — Install via Go @@ -176,12 +235,12 @@ security-reports/ ## 🧭 Roadmap -| Version | Features | -|---------|----------| -| **0.3.0** | Fail-on logic, exclude-paths integration, Semgrep JSON support | -| **0.4.0** | Local CLI scans (`devsecops scan`) | -| **0.5.0** | Expanded detection: Python, Java, Dockerfiles | -| **1.0.0** | Full onboarding experience + multi-CI support | +| Version | Features | Status | +|---------|----------|--------| +| **0.3.0** | Config-driven fail gates, exclude paths, Docker detection, image scanning, inline PR comments | ✅ **Released** | +| **0.4.0** | Local CLI scans (`devsecops scan`), local report generation | 🚧 In Progress | +| **0.5.0** | Python/Java detection, expanded framework support | 📋 Planned | +| **1.0.0** | Full onboarding UX, multi-CI support (GitLab, Jenkins) | 📋 Planned | ## 🤝 Contributing diff --git a/cli/cmd/detect.go b/cli/cmd/detect.go index 1b2ad53..ef676d6 100644 --- a/cli/cmd/detect.go +++ b/cli/cmd/detect.go @@ -32,6 +32,18 @@ var detectCmd = &cobra.Command{ fmt.Printf(" RootDir: %s\n", info.RootDir) fmt.Printf(" Dependencies detected: %d\n", len(info.Dependencies)) + if info.HasDocker { + fmt.Println(" Docker: ✓ Detected") + if len(info.DockerImages) > 0 { + fmt.Println(" Docker Images:") + for _, img := range info.DockerImages { + fmt.Printf(" - %s\n", img) + } + } + } else { + fmt.Println(" Docker: Not detected") + } + return nil }, } diff --git a/cli/detectors/detector.go b/cli/detectors/detector.go index 058daed..c67e962 100644 --- a/cli/detectors/detector.go +++ b/cli/detectors/detector.go @@ -5,6 +5,7 @@ import ( "errors" "os" "path/filepath" + "strings" ) // ProjectInfo contains detected project information @@ -14,6 +15,8 @@ type ProjectInfo struct { PackageFile string // e.g. "package.json", "go.mod" RootDir string // project root directory Dependencies []string // coarse list of deps (for future heuristics) + HasDocker bool // true if Dockerfile detected + DockerImages []string // list of Docker image names found } // Detector interface for language/framework detection @@ -51,9 +54,71 @@ func DetectProject(dir string) (*ProjectInfo, error) { return nil, errors.New("no supported project type detected") } + // Check for Docker (can coexist with any language) + detectDocker(dir, bestMatch) + return bestMatch, nil } +// detectDocker checks for Dockerfile and docker-compose.yml, updates ProjectInfo in-place +func detectDocker(dir string, info *ProjectInfo) { + // Check for Dockerfile + dockerfilePath := filepath.Join(dir, "Dockerfile") + if fileExists(dockerfilePath) { + info.HasDocker = true + + // Try to extract image names from Dockerfile + images := extractDockerImages(dockerfilePath) + info.DockerImages = images + } + + // Also check for docker-compose.yml (indicates Docker usage) + composePaths := []string{ + filepath.Join(dir, "docker-compose.yml"), + filepath.Join(dir, "docker-compose.yaml"), + } + + for _, composePath := range composePaths { + if fileExists(composePath) { + info.HasDocker = true + break + } + } +} + +// extractDockerImages parses Dockerfile to find image references +// Returns a simple heuristic: finds lines like "FROM image:tag" +func extractDockerImages(dockerfilePath string) []string { + data, err := os.ReadFile(dockerfilePath) + if err != nil { + return nil + } + + var images []string + content := string(data) + lines := strings.Split(content, "\n") + + for _, line := range lines { + line = strings.TrimSpace(line) + + // Look for "FROM " + if strings.HasPrefix(strings.ToUpper(line), "FROM ") { + parts := strings.Fields(line) // Split by whitespace + if len(parts) >= 2 { + image := parts[1] + // Skip build stages (FROM ... AS stage_name) + // Check if next word is "AS" + if len(parts) >= 3 && strings.ToUpper(parts[2]) == "AS" { + continue // This is a build stage, skip it + } + images = append(images, image) + } + } + } + + return images +} + // fileExists checks if a file exists func fileExists(path string) bool { _, err := os.Stat(path) diff --git a/cli/templates/security-config.yml.tmpl b/cli/templates/security-config.yml.tmpl index fbe2ff2..486118a 100644 --- a/cli/templates/security-config.yml.tmpl +++ b/cli/templates/security-config.yml.tmpl @@ -1,6 +1,6 @@ # Security config generated by DevSecOps Kit -version: "0.2.0" +version: "0.3.0" language: "{{ .Project.Language }}" framework: "{{ .Project.Framework }}" @@ -12,11 +12,23 @@ tools: trivy: {{ .Tools.Trivy }} gitleaks: {{ .Tools.Gitleaks }} -# Reserved for future versions — currently unused in execution -exclude_paths: [] +# Path exclusions for scanners (applies to all enabled tools) +# Common paths to exclude: vendor/, node_modules/, test/, dist/, build/ +exclude_paths: + # - "vendor/" + # - "node_modules/" + # - "test/" + # - "*.test.js" -# Reserved for future: per-tool fail-on behavior -fail_on: {} +# Fail gates: Define thresholds that will fail the CI build +# Set to -1 to disable a specific gate +fail_on: + gitleaks: 0 # Fail if ANY secrets detected (recommended: 0) + semgrep: 10 # Fail if 10+ Semgrep findings + trivy_critical: 0 # Fail if ANY critical vulnerabilities + trivy_high: 5 # Fail if 5+ high severity vulnerabilities + trivy_medium: -1 # Disabled by default (set to number to enable) + trivy_low: -1 # Disabled by default # Notification settings (PR comment enabled by default) notifications: diff --git a/cli/templates/workflows/go_security.yml.tmpl b/cli/templates/workflows/go_security.yml.tmpl index 0635236..c2a0e1f 100644 --- a/cli/templates/workflows/go_security.yml.tmpl +++ b/cli/templates/workflows/go_security.yml.tmpl @@ -23,6 +23,40 @@ jobs: - 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}") + + # Build exclusion flags for different tools + semgrep_excludes = ' '.join([f'--exclude {p}' for p in exclude_paths]) + gitleaks_excludes = ','.join(exclude_paths) if exclude_paths else '' + + # Write to GitHub Actions output + 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 Go uses: actions/setup-go@v5 with: @@ -32,17 +66,44 @@ jobs: run: go mod download {{- if .Tools.Semgrep }} - - name: Run Semgrep - uses: returntocorp/semgrep-action@v1 + - name: Set up Python for Semgrep + uses: actions/setup-python@v5 with: - config: "p/ci" + python-version: "3.11" + + - name: Run Semgrep (CLI JSON) + 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 + echo "Generated .gitleaks.toml:" + cat .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 - uses: gitleaks/gitleaks-action@v2 - env: - GITHUB_TOKEN: ${{"{{"}} secrets.GITHUB_TOKEN {{ "}}" }} + 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 }} @@ -53,122 +114,461 @@ jobs: 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 + echo "✓ Dockerfile detected, will scan Docker image" + else + echo "has_docker=false" >> $GITHUB_OUTPUT + echo "No Dockerfile found, skipping image scan" + fi + + - name: Build Docker image for scanning + if: steps.docker-check.outputs.has_docker == 'true' + run: | + # Build image with a temporary tag for scanning + docker build -t devsecops-scan-temp:latest . + echo "Built Docker image: 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: Generate security summary (JSON) + - name: Extract fail_on thresholds from config if: always() run: | - echo "Generating security summary..." - mkdir -p artifacts/security + pip install pyyaml + python3 << 'EOF' + import yaml + import json + import os - # Gitleaks: count total findings (supports array or {findings:[...]}) - if [ -f artifacts/security/gitleaks-report.json ]; then - GITLEAKS_COUNT=$(jq 'if type=="array" then length else (.findings | length // 0) end' artifacts/security/gitleaks-report.json) - else - GITLEAKS_COUNT=0 - fi + # Default thresholds + fail_on = { + 'gitleaks': 0, + 'semgrep': 10, + 'trivy_critical': 0, + 'trivy_high': 5, + 'trivy_medium': -1, + 'trivy_low': -1 + } - # Trivy: group vulnerabilities by severity (CRITICAL/HIGH/etc.) - if [ -f artifacts/security/trivy-fs.json ]; then - TRIVY_SUMMARY=$(jq ' - ( [ .Results[].Vulnerabilities[]? | .Severity ] - | group_by(.) - | map({ (.[0]): length }) - | add - ) // {}' artifacts/security/trivy-fs.json) - else - TRIVY_SUMMARY='{}' - fi + # Try to read from security-config.yml + 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']) + print(f"Loaded fail_on config: {fail_on}") + else: + print("No security-config.yml found, using defaults") + except Exception as e: + print(f"Error reading config, using defaults: {e}") + + # Write to JSON for GitHub script to read + 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'; + const summaryPath = path.join(dir, 'summary.json'); + + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + function readJson(file) { + if (!fs.existsSync(file)) return null; + const raw = fs.readFileSync(file, 'utf8'); + try { return JSON.parse(raw); } + catch { return null; } + } + + // Read fail_on thresholds from extracted config + const failOnPath = path.join(dir, 'fail-on.json'); + const failOn = readJson(failOnPath) || { + gitleaks: 0, + semgrep: 10, + trivy_critical: 0, + trivy_high: 5, + trivy_medium: -1, + trivy_low: -1 + }; + core.info(`Using fail_on thresholds: ${JSON.stringify(failOn)}`); + + const result = { + version: "0.3.0", + status: "PASS", + blocking_count: 0, + summary: {}, + findings: [] + }; + + // + // GITLEAKS + // + const gitleaksPath = path.join(dir, 'gitleaks-report.json'); + const gitleaks = readJson(gitleaksPath); + if (gitleaks) { + let count = 0; + if (Array.isArray(gitleaks)) count = gitleaks.length; + else if (Array.isArray(gitleaks.findings)) count = gitleaks.findings.length; + result.summary.gitleaks = { total: count }; + + // Check fail gate + if (failOn.gitleaks >= 0 && count > failOn.gitleaks) { + result.blocking_count += count - failOn.gitleaks; + core.warning(`Gitleaks: ${count} secrets found (threshold: ${failOn.gitleaks})`); + } + } + + // + // TRIVY FS + // + const trivyFsPath = path.join(dir, 'trivy-fs.json'); + const trivy = readJson(trivyFsPath); + 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 sev = (v.Severity || "").toLowerCase(); + if (counts[sev] !== undefined) counts[sev]++; + } + } + result.summary.trivy_fs = counts; + + // Check fail gates for each severity + const severities = { + critical: 'trivy_critical', + high: 'trivy_high', + medium: 'trivy_medium', + low: 'trivy_low' + }; + + for (const [sev, configKey] of Object.entries(severities)) { + const threshold = failOn[configKey]; + const count = counts[sev] || 0; + if (threshold >= 0 && count > threshold) { + result.blocking_count += count - threshold; + core.warning(`Trivy ${sev}: ${count} vulnerabilities (threshold: ${threshold})`); + } + } + } + + // + // TRIVY IMAGE + // + const trivyImagePath = path.join(dir, 'trivy-image.json'); + const trivyImage = readJson(trivyImagePath); + if (trivyImage?.Results) { + const counts = { critical: 0, high: 0, medium: 0, low: 0 }; + for (const r of trivyImage.Results) { + for (const v of (r.Vulnerabilities || [])) { + const sev = (v.Severity || "").toLowerCase(); + if (counts[sev] !== undefined) counts[sev]++; + } + } + result.summary.trivy_image = counts; + core.info(`Trivy Image scan: ${JSON.stringify(counts)}`); + + // Check fail gates for image scan (use same thresholds as FS) + const severities = { + critical: 'trivy_critical', + high: 'trivy_high', + medium: 'trivy_medium', + low: 'trivy_low' + }; + + for (const [sev, configKey] of Object.entries(severities)) { + const threshold = failOn[configKey]; + const count = counts[sev] || 0; + if (threshold >= 0 && count > threshold) { + result.blocking_count += count - threshold; + core.warning(`Trivy Image ${sev}: ${count} vulnerabilities (threshold: ${threshold})`); + } + } + } + + // + // SEMGREP + // + const semgrepPath = path.join(dir, 'semgrep-report.json'); + const semgrep = readJson(semgrepPath); + if (semgrep) { + let results = []; - jq -n \ - --argjson gitleaks_count "$GITLEAKS_COUNT" \ - --argjson trivy "$TRIVY_SUMMARY" \ - '{ gitleaks: { total: $gitleaks_count }, trivy: $trivy }' \ - > artifacts/security/summary.json + if (Array.isArray(semgrep)) { + results = semgrep; + } else if (Array.isArray(semgrep.results)) { + results = semgrep.results; + } - echo "Summary written to artifacts/security/summary.json" + const count = results.length; + result.summary.semgrep = { total: count }; + core.info(`Semgrep findings: ${count}`); - - name: Post security summary as PR comment + // Check fail gate + if (failOn.semgrep >= 0 && count > failOn.semgrep) { + result.blocking_count += count - failOn.semgrep; + core.warning(`Semgrep: ${count} findings (threshold: ${failOn.semgrep})`); + } + } else { + core.info("No semgrep-report.json found, skipping Semgrep summary."); + } + + // Set final status + result.status = result.blocking_count > 0 ? "FAIL" : "PASS"; + + if (result.blocking_count > 0) { + core.warning(`Security scan FAILED: ${result.blocking_count} issue(s) exceed thresholds`); + } else { + core.info("Security scan PASSED: All checks within thresholds"); + } + + fs.writeFileSync(summaryPath, JSON.stringify(result, null, 2)); + core.info("Wrote summary.json"); + + - 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 path = 'artifacts/security/summary.json'; const marker = ''; + const summaryPath = 'artifacts/security/summary.json'; - let summary; - try { - const raw = fs.readFileSync(path, 'utf8'); - summary = JSON.parse(raw); - } catch (err) { - core.warning(`Could not read ${path}: ${err}`); - summary = null; - } + let summary = null; + try { summary = JSON.parse(fs.readFileSync(summaryPath, 'utf8')); } + catch { core.warning("No summary.json available."); } - let body = `${marker}\n`; - body += '### 🔐 DevSecOps Kit Security Summary\n\n'; + let body = `${marker}\n### 🔐 DevSecOps Kit Security Summary\n\n`; if (!summary) { - body += '_No summary.json available. Check workflow logs._\n'; + body += "_No summary available._\n"; } else { - const gitleaksTotal = summary?.gitleaks?.total ?? 0; - const trivy = summary?.trivy || {}; + const leaks = summary.summary?.gitleaks?.total ?? 0; + const trivyFs = summary.summary?.trivy_fs ?? {}; + const trivyImage = summary.summary?.trivy_image ?? {}; + const semgrep = summary.summary?.semgrep ?? null; - body += `- **Gitleaks:** ${gitleaksTotal} leak(s)\n`; + body += `- **Gitleaks:** ${leaks} leak(s)\n`; - const severities = Object.keys(trivy); - if (severities.length > 0) { - body += '- **Trivy vulnerabilities:**\n'; - for (const sev of severities.sort()) { - body += ` - ${sev}: ${trivy[sev]}\n`; + if (Object.keys(trivyFs).length > 0) { + body += `- **Trivy FS:**\n`; + for (const sev of Object.keys(trivyFs)) { + body += ` - ${sev.toUpperCase()}: ${trivyFs[sev]}\n`; } - } else { - body += '- **Trivy vulnerabilities:** none counted in summary\n'; } - const hasBlocking = - gitleaksTotal > 0 || - (trivy.CRITICAL ?? 0) > 0 || - (trivy.HIGH ?? 0) > 0; + if (Object.keys(trivyImage).length > 0) { + body += `- **Trivy Image:**\n`; + for (const sev of Object.keys(trivyImage)) { + body += ` - ${sev.toUpperCase()}: ${trivyImage[sev]}\n`; + } + } + + if (semgrep) { + body += `- **Semgrep:** ${semgrep.total} finding(s)\n`; + } - body += '\n'; - body += hasBlocking - ? '🚨 _Status: Potential blocking issues detected._\n' - : '✅ _Status: No blocking issues detected (HIGH/CRITICAL)._ \n'; + // Use status from summary.json + const status = summary.status || "UNKNOWN"; + const blockingCount = summary.blocking_count || 0; + + body += `\n**Status:** ${status === "FAIL" ? '🚨 **FAIL**' : '✅ **PASS**'}\n`; + if (blockingCount > 0) { + body += `_${blockingCount} issue(s) exceed configured thresholds_\n`; + } } const { owner, repo } = context.repo; - const issue_number = context.issue.number; + const pr = context.issue.number; - const comments = await github.rest.issues.listComments({ - owner, - repo, - issue_number, - }); - - const existing = comments.data.find(c => c.body && c.body.includes(marker)); + 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, + owner, repo, comment_id: existing.id, - body, + body }); } else { await github.rest.issues.createComment({ - owner, - repo, - issue_number, - body, + owner, repo, + issue_number: pr, + body }); } + - name: Post detailed fix-it comments + if: always() && github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + github-token: ${{"{{"}} secrets.GITHUB_TOKEN {{ "}}" }} + script: | + const fs = require('fs'); + const path = require('path'); + + const dir = 'artifacts/security'; + const { owner, repo } = context.repo; + const pr = context.issue.number; + + function readJson(file) { + if (!fs.existsSync(file)) return null; + try { return JSON.parse(fs.readFileSync(file, 'utf8')); } + catch { return null; } + } + + // Get PR files to validate comments are on changed files + const { data: prFiles } = await github.rest.pulls.listFiles({ owner, repo, pull_number: pr }); + const changedFiles = new Set(prFiles.map(f => f.filename)); + + // Get the commit SHA for review comments + const { data: prData } = await github.rest.pulls.get({ owner, repo, pull_number: pr }); + const commitId = prData.head.sha; + + const comments = []; + + // + // SEMGREP FINDINGS + // + const semgrepPath = path.join(dir, 'semgrep-report.json'); + const semgrep = readJson(semgrepPath); + if (semgrep) { + let results = []; + if (Array.isArray(semgrep)) results = semgrep; + else if (Array.isArray(semgrep.results)) results = semgrep.results; + + for (const finding of results.slice(0, 10)) { // Limit to 10 comments + const filePath = finding.path; + const line = finding.start?.line || finding.line || 1; + const message = finding.extra?.message || finding.check_id || 'Security issue detected'; + const severity = finding.extra?.severity || 'WARNING'; + const fixRegex = finding.extra?.fix_regex; + + if (!changedFiles.has(filePath)) continue; // Only comment on changed files + + let body = `**🔍 Semgrep [${severity}]**\n\n`; + body += `${message}\n\n`; + body += `**Rule:** \`${finding.check_id}\`\n`; + + if (fixRegex) { + body += `\n**Suggested fix:** Apply the regex replacement suggested by Semgrep.\n`; + } + + if (finding.extra?.metadata?.references) { + body += `\n**References:**\n`; + for (const ref of finding.extra.metadata.references.slice(0, 3)) { + body += `- ${ref}\n`; + } + } + + comments.push({ path: filePath, line, body }); + } + } + + // + // GITLEAKS FINDINGS + // + const gitleaksPath = path.join(dir, 'gitleaks-report.json'); + const gitleaks = readJson(gitleaksPath); + if (gitleaks) { + let leaks = []; + if (Array.isArray(gitleaks)) leaks = gitleaks; + else if (Array.isArray(gitleaks.findings)) leaks = gitleaks.findings; + + for (const leak of leaks.slice(0, 5)) { // Limit to 5 secrets + const filePath = leak.File || leak.file; + const line = leak.StartLine || leak.line || 1; + const secret = leak.Secret || leak.match || ''; + const rule = leak.RuleID || leak.rule || 'Secret detected'; + + if (!changedFiles.has(filePath)) continue; + + let body = `**🚨 Secret Detected**\n\n`; + body += `**Rule:** \`${rule}\`\n`; + body += `**Match:** \`${secret.substring(0, 20)}...\`\n\n`; + body += `⚠️ **Action Required:** Remove this secret immediately and:\n`; + body += `1. Rotate the compromised credential\n`; + body += `2. Use environment variables or secret management\n`; + body += `3. Never commit secrets to version control\n`; + + comments.push({ path: filePath, line, body }); + } + } + + // Post review comments (batch API) + if (comments.length > 0) { + try { + await github.rest.pulls.createReview({ + owner, + repo, + pull_number: pr, + commit_id: commitId, + event: 'COMMENT', + comments: comments.map(c => ({ + path: c.path, + line: c.line, + body: c.body + })) + }); + core.info(`Posted ${comments.length} fix-it comment(s)`); + } catch (error) { + core.warning(`Failed to post review comments: ${error.message}`); + } + } else { + core.info("No findings in changed files, skipping fix-it comments"); + } + - 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: | + if [ ! -f artifacts/security/summary.json ]; then + echo "No summary.json found, skipping fail gate check" + exit 0 + fi + + STATUS=$(cat artifacts/security/summary.json | python3 -c "import sys, json; print(json.load(sys.stdin).get('status', 'UNKNOWN'))") + BLOCKING_COUNT=$(cat artifacts/security/summary.json | python3 -c "import sys, json; print(json.load(sys.stdin).get('blocking_count', 0))") + + echo "Security scan status: $STATUS" + echo "Blocking issues: $BLOCKING_COUNT" + + if [ "$STATUS" = "FAIL" ]; then + echo "❌ Security scan FAILED: $BLOCKING_COUNT issue(s) exceed configured thresholds" + echo "Review the security summary above and artifacts for details" + exit 1 + else + echo "✅ Security scan PASSED: All findings within acceptable thresholds" + exit 0 + fi diff --git a/cli/templates/workflows/node_security.yml.tmpl b/cli/templates/workflows/node_security.yml.tmpl index 835d727..6658dd9 100644 --- a/cli/templates/workflows/node_security.yml.tmpl +++ b/cli/templates/workflows/node_security.yml.tmpl @@ -23,6 +23,40 @@ jobs: - 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}") + + # Build exclusion flags for different tools + semgrep_excludes = ' '.join([f'--exclude {p}' for p in exclude_paths]) + gitleaks_excludes = ','.join(exclude_paths) if exclude_paths else '' + + # Write to GitHub Actions output + 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 Node uses: actions/setup-node@v4 with: @@ -32,143 +66,509 @@ jobs: run: npm install --prefer-offline {{- if .Tools.Semgrep }} - - name: Run Semgrep - uses: returntocorp/semgrep-action@v1 + - name: Set up Python for Semgrep + uses: actions/setup-python@v5 with: - config: "p/ci" + python-version: "3.11" + + - name: Run Semgrep (CLI JSON) + 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 + echo "Generated .gitleaks.toml:" + cat .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 - uses: gitleaks/gitleaks-action@v2 - env: - GITHUB_TOKEN: ${{"{{"}} secrets.GITHUB_TOKEN {{ "}}" }} + 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 + - 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 + echo "✓ Dockerfile detected, will scan Docker image" + else + echo "has_docker=false" >> $GITHUB_OUTPUT + echo "No Dockerfile found, skipping image scan" + fi + + - name: Build Docker image for scanning + if: steps.docker-check.outputs.has_docker == 'true' + run: | + # Build image with a temporary tag for scanning + docker build -t devsecops-scan-temp:latest . + echo "Built Docker image: 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: Generate security summary (JSON) + - name: Extract fail_on thresholds from config if: always() run: | - echo "Generating security summary..." - mkdir -p artifacts/security + pip install pyyaml + python3 << 'EOF' + import yaml + import json + import os - # Gitleaks: count total findings (supports array or {findings:[...]}) - if [ -f artifacts/security/gitleaks-report.json ]; then - GITLEAKS_COUNT=$(jq 'if type=="array" then length else (.findings | length // 0) end' artifacts/security/gitleaks-report.json) - else - GITLEAKS_COUNT=0 - fi + # Default thresholds + fail_on = { + 'gitleaks': 0, + 'semgrep': 10, + 'trivy_critical': 0, + 'trivy_high': 5, + 'trivy_medium': -1, + 'trivy_low': -1 + } - # Trivy: group vulnerabilities by severity (CRITICAL/HIGH/etc.) - if [ -f artifacts/security/trivy-fs.json ]; then - TRIVY_SUMMARY=$(jq ' - ( [ .Results[].Vulnerabilities[]? | .Severity ] - | group_by(.) - | map({ (.[0]): length }) - | add - ) // {}' artifacts/security/trivy-fs.json) - else - TRIVY_SUMMARY='{}' - fi + # Try to read from security-config.yml + 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']) + print(f"Loaded fail_on config: {fail_on}") + else: + print("No security-config.yml found, using defaults") + except Exception as e: + print(f"Error reading config, using defaults: {e}") + + # Write to JSON for GitHub script to read + 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'; + const summaryPath = path.join(dir, 'summary.json'); + + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + function readJson(file) { + if (!fs.existsSync(file)) return null; + const raw = fs.readFileSync(file, 'utf8'); + try { return JSON.parse(raw); } + catch { return null; } + } + + // Read fail_on thresholds from extracted config + const failOnPath = path.join(dir, 'fail-on.json'); + const failOn = readJson(failOnPath) || { + gitleaks: 0, + semgrep: 10, + trivy_critical: 0, + trivy_high: 5, + trivy_medium: -1, + trivy_low: -1 + }; + core.info(`Using fail_on thresholds: ${JSON.stringify(failOn)}`); + + const result = { + version: "0.3.0", + status: "PASS", + blocking_count: 0, + summary: {}, + findings: [] + }; + + // + // GITLEAKS + // + const gitleaksPath = path.join(dir, 'gitleaks-report.json'); + const gitleaks = readJson(gitleaksPath); + if (gitleaks) { + let count = 0; + if (Array.isArray(gitleaks)) count = gitleaks.length; + else if (Array.isArray(gitleaks.findings)) count = gitleaks.findings.length; + result.summary.gitleaks = { total: count }; + + // Check fail gate + if (failOn.gitleaks >= 0 && count > failOn.gitleaks) { + result.blocking_count += count - failOn.gitleaks; + core.warning(`Gitleaks: ${count} secrets found (threshold: ${failOn.gitleaks})`); + } + } + + // + // TRIVY FS + // + const trivyFsPath = path.join(dir, 'trivy-fs.json'); + const trivy = readJson(trivyFsPath); + 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 sev = (v.Severity || "").toLowerCase(); + if (counts[sev] !== undefined) counts[sev]++; + } + } + result.summary.trivy_fs = counts; + + // Check fail gates for each severity + const severities = { + critical: 'trivy_critical', + high: 'trivy_high', + medium: 'trivy_medium', + low: 'trivy_low' + }; + + for (const [sev, configKey] of Object.entries(severities)) { + const threshold = failOn[configKey]; + const count = counts[sev] || 0; + if (threshold >= 0 && count > threshold) { + result.blocking_count += count - threshold; + core.warning(`Trivy ${sev}: ${count} vulnerabilities (threshold: ${threshold})`); + } + } + } + + // + // TRIVY IMAGE + // + const trivyImagePath = path.join(dir, 'trivy-image.json'); + const trivyImage = readJson(trivyImagePath); + if (trivyImage?.Results) { + const counts = { critical: 0, high: 0, medium: 0, low: 0 }; + for (const r of trivyImage.Results) { + for (const v of (r.Vulnerabilities || [])) { + const sev = (v.Severity || "").toLowerCase(); + if (counts[sev] !== undefined) counts[sev]++; + } + } + result.summary.trivy_image = counts; + core.info(`Trivy Image scan: ${JSON.stringify(counts)}`); + + // Check fail gates for image scan (use same thresholds as FS) + const severities = { + critical: 'trivy_critical', + high: 'trivy_high', + medium: 'trivy_medium', + low: 'trivy_low' + }; + + for (const [sev, configKey] of Object.entries(severities)) { + const threshold = failOn[configKey]; + const count = counts[sev] || 0; + if (threshold >= 0 && count > threshold) { + result.blocking_count += count - threshold; + core.warning(`Trivy Image ${sev}: ${count} vulnerabilities (threshold: ${threshold})`); + } + } + } + + // + // SEMGREP + // + const semgrepPath = path.join(dir, 'semgrep-report.json'); + const semgrep = readJson(semgrepPath); + if (semgrep) { + let results = []; - jq -n \ - --argjson gitleaks_count "$GITLEAKS_COUNT" \ - --argjson trivy "$TRIVY_SUMMARY" \ - '{ gitleaks: { total: $gitleaks_count }, trivy: $trivy }' \ - > artifacts/security/summary.json + if (Array.isArray(semgrep)) { + results = semgrep; + } else if (Array.isArray(semgrep.results)) { + results = semgrep.results; + } - echo "Summary written to artifacts/security/summary.json" + const count = results.length; + result.summary.semgrep = { total: count }; + core.info(`Semgrep findings: ${count}`); - - name: Post security summary as PR comment + // Check fail gate + if (failOn.semgrep >= 0 && count > failOn.semgrep) { + result.blocking_count += count - failOn.semgrep; + core.warning(`Semgrep: ${count} findings (threshold: ${failOn.semgrep})`); + } + } else { + core.info("No semgrep-report.json found, skipping Semgrep summary."); + } + + // Set final status + result.status = result.blocking_count > 0 ? "FAIL" : "PASS"; + + if (result.blocking_count > 0) { + core.warning(`Security scan FAILED: ${result.blocking_count} issue(s) exceed thresholds`); + } else { + core.info("Security scan PASSED: All checks within thresholds"); + } + + fs.writeFileSync(summaryPath, JSON.stringify(result, null, 2)); + core.info("Wrote summary.json"); + + - 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 path = 'artifacts/security/summary.json'; const marker = ''; + const summaryPath = 'artifacts/security/summary.json'; - let summary; - try { - const raw = fs.readFileSync(path, 'utf8'); - summary = JSON.parse(raw); - } catch (err) { - core.warning(`Could not read ${path}: ${err}`); - summary = null; - } + let summary = null; + try { summary = JSON.parse(fs.readFileSync(summaryPath, 'utf8')); } + catch { core.warning("No summary.json available."); } - let body = `${marker}\n`; - body += '### 🔐 DevSecOps Kit Security Summary\n\n'; + let body = `${marker}\n### 🔐 DevSecOps Kit Security Summary\n\n`; if (!summary) { - body += '_No summary.json available. Check workflow logs._\n'; + body += "_No summary available._\n"; } else { - const gitleaksTotal = summary?.gitleaks?.total ?? 0; - const trivy = summary?.trivy || {}; + const leaks = summary.summary?.gitleaks?.total ?? 0; + const trivyFs = summary.summary?.trivy_fs ?? {}; + const trivyImage = summary.summary?.trivy_image ?? {}; + const semgrep = summary.summary?.semgrep ?? null; - body += `- **Gitleaks:** ${gitleaksTotal} leak(s)\n`; + body += `- **Gitleaks:** ${leaks} leak(s)\n`; - const severities = Object.keys(trivy); - if (severities.length > 0) { - body += '- **Trivy vulnerabilities:**\n'; - for (const sev of severities.sort()) { - body += ` - ${sev}: ${trivy[sev]}\n`; + if (Object.keys(trivyFs).length > 0) { + body += `- **Trivy FS:**\n`; + for (const sev of Object.keys(trivyFs)) { + body += ` - ${sev.toUpperCase()}: ${trivyFs[sev]}\n`; } - } else { - body += '- **Trivy vulnerabilities:** none counted in summary\n'; } - const hasBlocking = - gitleaksTotal > 0 || - (trivy.CRITICAL ?? 0) > 0 || - (trivy.HIGH ?? 0) > 0; + if (Object.keys(trivyImage).length > 0) { + body += `- **Trivy Image:**\n`; + for (const sev of Object.keys(trivyImage)) { + body += ` - ${sev.toUpperCase()}: ${trivyImage[sev]}\n`; + } + } + + if (semgrep) { + body += `- **Semgrep:** ${semgrep.total} finding(s)\n`; + } - body += '\n'; - body += hasBlocking - ? '🚨 _Status: Potential blocking issues detected._\n' - : '✅ _Status: No blocking issues detected (HIGH/CRITICAL)._ \n'; + // Use status from summary.json + const status = summary.status || "UNKNOWN"; + const blockingCount = summary.blocking_count || 0; + + body += `\n**Status:** ${status === "FAIL" ? '🚨 **FAIL**' : '✅ **PASS**'}\n`; + if (blockingCount > 0) { + body += `_${blockingCount} issue(s) exceed configured thresholds_\n`; + } } const { owner, repo } = context.repo; - const issue_number = context.issue.number; + const pr = context.issue.number; - const comments = await github.rest.issues.listComments({ - owner, - repo, - issue_number, - }); - - const existing = comments.data.find(c => c.body && c.body.includes(marker)); + 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, + owner, repo, comment_id: existing.id, - body, + body }); } else { await github.rest.issues.createComment({ - owner, - repo, - issue_number, - body, + owner, repo, + issue_number: pr, + body }); } + - name: Post detailed fix-it comments + if: always() && github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + github-token: ${{"{{"}} secrets.GITHUB_TOKEN {{ "}}" }} + script: | + const fs = require('fs'); + const path = require('path'); + + const dir = 'artifacts/security'; + const { owner, repo } = context.repo; + const pr = context.issue.number; + + function readJson(file) { + if (!fs.existsSync(file)) return null; + try { return JSON.parse(fs.readFileSync(file, 'utf8')); } + catch { return null; } + } + + // Get PR files to validate comments are on changed files + const { data: prFiles } = await github.rest.pulls.listFiles({ owner, repo, pull_number: pr }); + const changedFiles = new Set(prFiles.map(f => f.filename)); + + // Get the commit SHA for review comments + const { data: prData } = await github.rest.pulls.get({ owner, repo, pull_number: pr }); + const commitId = prData.head.sha; + + const comments = []; + + // + // SEMGREP FINDINGS + // + const semgrepPath = path.join(dir, 'semgrep-report.json'); + const semgrep = readJson(semgrepPath); + if (semgrep) { + let results = []; + if (Array.isArray(semgrep)) results = semgrep; + else if (Array.isArray(semgrep.results)) results = semgrep.results; + + for (const finding of results.slice(0, 10)) { // Limit to 10 comments + const filePath = finding.path; + const line = finding.start?.line || finding.line || 1; + const message = finding.extra?.message || finding.check_id || 'Security issue detected'; + const severity = finding.extra?.severity || 'WARNING'; + const fixRegex = finding.extra?.fix_regex; + + if (!changedFiles.has(filePath)) continue; // Only comment on changed files + + let body = `**🔍 Semgrep [${severity}]**\n\n`; + body += `${message}\n\n`; + body += `**Rule:** \`${finding.check_id}\`\n`; + + if (fixRegex) { + body += `\n**Suggested fix:** Apply the regex replacement suggested by Semgrep.\n`; + } + + if (finding.extra?.metadata?.references) { + body += `\n**References:**\n`; + for (const ref of finding.extra.metadata.references.slice(0, 3)) { + body += `- ${ref}\n`; + } + } + + comments.push({ path: filePath, line, body }); + } + } + + // + // GITLEAKS FINDINGS + // + const gitleaksPath = path.join(dir, 'gitleaks-report.json'); + const gitleaks = readJson(gitleaksPath); + if (gitleaks) { + let leaks = []; + if (Array.isArray(gitleaks)) leaks = gitleaks; + else if (Array.isArray(gitleaks.findings)) leaks = gitleaks.findings; + + for (const leak of leaks.slice(0, 5)) { // Limit to 5 secrets + const filePath = leak.File || leak.file; + const line = leak.StartLine || leak.line || 1; + const secret = leak.Secret || leak.match || ''; + const rule = leak.RuleID || leak.rule || 'Secret detected'; + + if (!changedFiles.has(filePath)) continue; + + let body = `**🚨 Secret Detected**\n\n`; + body += `**Rule:** \`${rule}\`\n`; + body += `**Match:** \`${secret.substring(0, 20)}...\`\n\n`; + body += `⚠️ **Action Required:** Remove this secret immediately and:\n`; + body += `1. Rotate the compromised credential\n`; + body += `2. Use environment variables or secret management\n`; + body += `3. Never commit secrets to version control\n`; + + comments.push({ path: filePath, line, body }); + } + } + + // Post review comments (batch API) + if (comments.length > 0) { + try { + await github.rest.pulls.createReview({ + owner, + repo, + pull_number: pr, + commit_id: commitId, + event: 'COMMENT', + comments: comments.map(c => ({ + path: c.path, + line: c.line, + body: c.body + })) + }); + core.info(`Posted ${comments.length} fix-it comment(s)`); + } catch (error) { + core.warning(`Failed to post review comments: ${error.message}`); + } + } else { + core.info("No findings in changed files, skipping fix-it comments"); + } + - 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: | + if [ ! -f artifacts/security/summary.json ]; then + echo "No summary.json found, skipping fail gate check" + exit 0 + fi + + STATUS=$(cat artifacts/security/summary.json | python3 -c "import sys, json; print(json.load(sys.stdin).get('status', 'UNKNOWN'))") + BLOCKING_COUNT=$(cat artifacts/security/summary.json | python3 -c "import sys, json; print(json.load(sys.stdin).get('blocking_count', 0))") + + echo "Security scan status: $STATUS" + echo "Blocking issues: $BLOCKING_COUNT" + + if [ "$STATUS" = "FAIL" ]; then + echo "❌ Security scan FAILED: $BLOCKING_COUNT issue(s) exceed configured thresholds" + echo "Review the security summary above and artifacts for details" + exit 1 + else + echo "✅ Security scan PASSED: All findings within acceptable thresholds" + exit 0 + fi diff --git a/devsecops b/devsecops index e64688f..a0a3dc3 100755 Binary files a/devsecops and b/devsecops differ diff --git a/security-config.yml b/security-config.yml index 76a1c00..ddda537 100644 --- a/security-config.yml +++ b/security-config.yml @@ -1,6 +1,6 @@ # Security config generated by DevSecOps Kit -version: "0.2.0" +version: "0.3.0" language: "golang" framework: "" @@ -12,11 +12,23 @@ tools: trivy: true gitleaks: true -# Reserved for future versions — currently unused in execution -exclude_paths: [] +# Path exclusions for scanners (applies to all enabled tools) +# Common paths to exclude: vendor/, node_modules/, test/, dist/, build/ +exclude_paths: + # - "vendor/" + # - "node_modules/" + # - "test/" + # - "*.test.js" -# Reserved for future: per-tool fail-on behavior -fail_on: {} +# Fail gates: Define thresholds that will fail the CI build +# Set to -1 to disable a specific gate +fail_on: + gitleaks: 0 # Fail if ANY secrets detected (recommended: 0) + semgrep: 10 # Fail if 10+ Semgrep findings + trivy_critical: 0 # Fail if ANY critical vulnerabilities + trivy_high: 5 # Fail if 5+ high severity vulnerabilities + trivy_medium: -1 # Disabled by default (set to number to enable) + trivy_low: -1 # Disabled by default # Notification settings (PR comment enabled by default) notifications: