name: Semgrep Security Scan on: workflow_call: # Called by release-gate.yml workflow_dispatch: permissions: {} # Minimal top-level for OSSF Scorecard Token-Permissions jobs: semgrep-scan: runs-on: ubuntu-latest timeout-minutes: 30 permissions: contents: read security-events: write actions: read steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit - name: Checkout code uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Set up Python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: '3.12' - name: Install Semgrep run: | # setuptools required: semgrep 1.87.0's opentelemetry dep imports pkg_resources, # which is not bundled with Python 3.12 by default on GitHub runners. # Pin setuptools<82 because 82.0 removed the pkg_resources module. pip install "setuptools<82" semgrep==1.87.0 - name: Run Semgrep security scan run: | semgrep \ --config=p/security-audit \ --config=p/secrets \ --severity=INFO \ --json \ --output=semgrep-results.json \ src/ || true # Crash detection: if semgrep crashed, no output file was produced if [ ! -f semgrep-results.json ]; then echo "::error::Semgrep security scan crashed — no output produced" exit 1 fi - name: Run Semgrep custom rules for LDR run: | semgrep \ --config=.semgrep/rules/ \ --severity=INFO \ --json \ --output=semgrep-custom-results.json \ src/ || true # Crash detection: if semgrep crashed, no output file was produced if [ ! -f semgrep-custom-results.json ]; then echo "::error::Semgrep custom rules scan crashed — no output produced" exit 1 fi - name: Merge Semgrep results run: | python3 -c " import json import glob results = [] for file in glob.glob('semgrep-*.json'): try: with open(file) as f: data = json.load(f) if isinstance(data, dict) and 'results' in data: results.extend(data['results']) elif isinstance(data, list): results.extend(data) except Exception as e: print(f'Error reading {file}: {e}') output = {'results': results, 'version': '1.0.0'} with open('semgrep-combined-results.json', 'w') as f: json.dump(output, f, indent=2) print(f'Combined {len(results)} findings from Semgrep scans') " - name: Convert to SARIF format run: | python3 -c " import json import uuid from datetime import datetime # Load combined results with open('semgrep-combined-results.json') as f: semgrep_data = json.load(f) # Convert to SARIF sarif = { '\$schema': 'https://json.schemastore.org/sarif-2.1.0', 'version': '2.1.0', 'runs': [{ 'tool': { 'driver': { 'name': 'Semgrep', 'version': '1.87.0', 'informationUri': 'https://semgrep.dev' } }, 'results': [] }] } for result in semgrep_data.get('results', []): sarif_result = { 'ruleId': result.get('check_id', 'unknown'), 'message': { 'text': result.get('message', 'Security issue detected') }, 'level': 'warning' if result.get('metadata', {}).get('severity', 'INFO') in ['ERROR', 'WARNING'] else 'note', 'locations': [{ 'physicalLocation': { 'artifactLocation': { 'uri': result.get('path', 'unknown') }, 'region': { 'startLine': result.get('start', {}).get('line', 1), 'startColumn': result.get('start', {}).get('col', 1), 'endLine': result.get('end', {}).get('line', result.get('start', {}).get('line', 1)), 'endColumn': result.get('end', {}).get('col', result.get('start', {}).get('col', 1) + 1) } } }] } # Add rule information metadata = result.get('metadata', {}) sarif_result['rule'] = { 'id': result.get('check_id', 'unknown'), 'name': metadata.get('name', 'Security Issue'), 'shortDescription': { 'text': metadata.get('name', 'Security Issue') }, 'fullDescription': { 'text': metadata.get('description', 'Security vulnerability detected') }, 'help': { 'text': metadata.get('remediation', 'Review and fix the security issue') }, 'properties': { 'precision': 'medium', 'tags': ['security', 'semgrep'] } } if 'security-severity' in metadata: sarif_result['rule']['properties']['security-severity'] = metadata['security-severity'] sarif['runs'][0]['results'].append(sarif_result) # Write SARIF file with open('semgrep-results.sarif', 'w') as f: json.dump(sarif, f, indent=2) print(f'Converted {len(sarif[\"runs\"][0][\"results\"])} findings to SARIF format') " # Fail loudly if the SARIF conversion produced no file — never fabricate an # empty one. An empty-results SARIF uploaded under the semgrep-security # category would make GitHub mark every previously-open Semgrep alert as # fixed, silently clearing real findings. The "Convert to SARIF" step above # always writes the file (results:[] on a clean scan), so a missing file # means a real conversion failure. Mirror the Grype/Trivy jobs. - name: Ensure SARIF file exists id: check-sarif if: always() run: | if [ -f semgrep-results.sarif ]; then echo "exists=true" >> "$GITHUB_OUTPUT" else echo "::error::Semgrep SARIF conversion did not produce a file — scan needs to be rerun" exit 1 fi - name: Upload Semgrep results to GitHub Security tab uses: github/codeql-action/upload-sarif@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3 if: always() && steps.check-sarif.outputs.exists == 'true' with: sarif_file: 'semgrep-results.sarif' category: semgrep-security - name: Upload Semgrep results as artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: always() with: name: semgrep-scan-results path: | semgrep-results.json semgrep-custom-results.json semgrep-combined-results.json semgrep-results.sarif retention-days: 7 # Reduced for security - name: Display Semgrep summary if: always() run: | { echo "## Semgrep Security Scan Summary" echo "" } >> "$GITHUB_STEP_SUMMARY" if [ -f semgrep-combined-results.json ]; then # Count results by severity CRITICAL=$(python3 -c "import json; data=json.load(open('semgrep-combined-results.json')); print(len([r for r in data['results'] if r.get('metadata', {}).get('severity') == 'ERROR']))" 2>/dev/null || echo "0") HIGH=$(python3 -c "import json; data=json.load(open('semgrep-combined-results.json')); print(len([r for r in data['results'] if r.get('metadata', {}).get('severity') == 'WARNING']))" 2>/dev/null || echo "0") MEDIUM=$(python3 -c "import json; data=json.load(open('semgrep-combined-results.json')); print(len([r for r in data['results'] if r.get('metadata', {}).get('severity') == 'INFO']))" 2>/dev/null || echo "0") TOTAL=$(python3 -c "import json; data=json.load(open('semgrep-combined-results.json')); print(len(data['results']))" 2>/dev/null || echo "0") { echo "📊 **Scan Results:**" echo "- **Critical:** $CRITICAL" echo "- **High:** $HIGH" echo "- **Medium:** $MEDIUM" echo "- **Total:** $TOTAL" echo "" } >> "$GITHUB_STEP_SUMMARY" if [ "$CRITICAL" -gt 0 ] || [ "$HIGH" -gt 0 ]; then echo "⚠️ **Action Required:** Critical or High severity issues found" >> "$GITHUB_STEP_SUMMARY" else echo "✅ **No Critical or High severity issues found**" >> "$GITHUB_STEP_SUMMARY" fi { echo "" echo "📋 **Detailed Results:**" echo "- Security tab: Results uploaded to GitHub Security tab" echo "- Artifacts: Full JSON and SARIF reports available" } >> "$GITHUB_STEP_SUMMARY" else echo "❌ Semgrep scan failed or no results generated" >> "$GITHUB_STEP_SUMMARY" fi