diff --git a/.github/actions/terrasafe-scan/action.yml b/.github/actions/terrasafe-scan/action.yml new file mode 100644 index 0000000..8d2a117 --- /dev/null +++ b/.github/actions/terrasafe-scan/action.yml @@ -0,0 +1,274 @@ +name: TerraSafe Security Scan +description: Scan Terraform files for security vulnerabilities and post results as PR comments + +inputs: + terraform_dir: + description: Directory to scan for Terraform files + required: false + default: "." + threshold: + description: Risk score threshold (0-100). Files scoring at or above this fail. + required: false + default: "70" + fail_on_high_risk: + description: Exit with error code if any file exceeds threshold + required: false + default: "true" + github_token: + description: GitHub token for posting PR comments + required: false + default: ${{ github.token }} + scan_changed_only: + description: Only scan Terraform files changed in the current PR + required: false + default: "true" + post_comment: + description: Post scan results as a PR comment + required: false + default: "true" + +outputs: + max_score: + description: Highest risk score across all scanned files + value: ${{ steps.parse-results.outputs.max_score }} + total_files: + description: Total number of Terraform files scanned + value: ${{ steps.parse-results.outputs.total_files }} + passed: + description: Number of files that passed the threshold + value: ${{ steps.parse-results.outputs.passed }} + failed: + description: Number of files that exceeded the threshold + value: ${{ steps.parse-results.outputs.failed }} + sarif_file: + description: Path to generated SARIF file + value: terrasafe-results.sarif + +runs: + using: composite + steps: + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install dependencies + shell: bash + run: pip install -r ${{ github.action_path }}/requirements.txt + + - name: Initialize ML model + shell: bash + run: | + PYTHONPATH=${{ github.action_path }} python -c \ + "from terrasafe.infrastructure.ml_model import MLPredictor; MLPredictor()" + + - name: Detect Terraform files + id: detect-files + shell: bash + env: + GH_TOKEN: ${{ inputs.github_token }} + TERRAFORM_DIR: ${{ inputs.terraform_dir }} + SCAN_CHANGED_ONLY: ${{ inputs.scan_changed_only }} + run: | + set -e + + TF_FILES="" + + if [[ "$SCAN_CHANGED_ONLY" == "true" ]] && [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then + echo "Detecting changed .tf files in PR..." + TF_FILES=$(gh pr diff --name-only 2>/dev/null | grep '\.tf$' || true) + fi + + if [[ -z "$TF_FILES" ]]; then + echo "Falling back to find all .tf files in $TERRAFORM_DIR" + TF_FILES=$(find "$TERRAFORM_DIR" -name '*.tf' 2>/dev/null || true) + fi + + if [[ -z "$TF_FILES" ]]; then + echo "no_tf_files=true" >> "$GITHUB_OUTPUT" + echo "tf_files=" >> "$GITHUB_OUTPUT" + echo "No Terraform files found." + exit 0 + fi + + # Cap at 50 files + FILE_COUNT=$(echo "$TF_FILES" | wc -l) + if [[ "$FILE_COUNT" -gt 50 ]]; then + echo "WARNING: Found $FILE_COUNT .tf files, capping at 50." + TF_FILES=$(echo "$TF_FILES" | head -50) + echo "files_capped=true" >> "$GITHUB_OUTPUT" + else + echo "files_capped=false" >> "$GITHUB_OUTPUT" + fi + + echo "Found $FILE_COUNT .tf file(s) to scan." + echo "no_tf_files=false" >> "$GITHUB_OUTPUT" + { + echo "tf_files<> "$GITHUB_OUTPUT" + + - name: Early exit — no Terraform files + if: steps.detect-files.outputs.no_tf_files == 'true' + shell: bash + run: | + echo "No Terraform files to scan." + exit 0 + + - name: Run scan (JSON) + if: steps.detect-files.outputs.no_tf_files != 'true' + shell: bash + env: + PYTHONPATH: ${{ github.action_path }} + THRESHOLD: ${{ inputs.threshold }} + run: | + # Build file list from multiline output + mapfile -t FILES <<< "${{ steps.detect-files.outputs.tf_files }}" + PYTHONPATH=${{ github.action_path }} python -m terrasafe.cli \ + "${FILES[@]}" \ + --output-format json \ + --threshold "$THRESHOLD" \ + --no-history \ + > scan_output.json 2>scan_errors.log || true + echo "Scan JSON output written." + + - name: Run scan (SARIF) + if: steps.detect-files.outputs.no_tf_files != 'true' + shell: bash + env: + THRESHOLD: ${{ inputs.threshold }} + run: | + mapfile -t FILES <<< "${{ steps.detect-files.outputs.tf_files }}" + PYTHONPATH=${{ github.action_path }} python -m terrasafe.cli \ + "${FILES[@]}" \ + --output-format sarif \ + --threshold "$THRESHOLD" \ + --no-history \ + > terrasafe-results.sarif 2>/dev/null || true + echo "SARIF output written." + + - name: Parse scan results + id: parse-results + if: steps.detect-files.outputs.no_tf_files != 'true' + shell: bash + run: | + if [[ -f scan_output.json ]]; then + MAX_SCORE=$(python3 -c "import json,sys; d=json.load(open('scan_output.json')); print(d['summary']['max_score'])" 2>/dev/null || echo "0") + TOTAL=$(python3 -c "import json; d=json.load(open('scan_output.json')); print(d['summary']['total_files'])" 2>/dev/null || echo "0") + PASSED=$(python3 -c "import json; d=json.load(open('scan_output.json')); print(d['summary']['passed'])" 2>/dev/null || echo "0") + FAILED=$(python3 -c "import json; d=json.load(open('scan_output.json')); print(d['summary']['failed'])" 2>/dev/null || echo "0") + else + MAX_SCORE=0; TOTAL=0; PASSED=0; FAILED=0 + fi + echo "max_score=$MAX_SCORE" >> "$GITHUB_OUTPUT" + echo "total_files=$TOTAL" >> "$GITHUB_OUTPUT" + echo "passed=$PASSED" >> "$GITHUB_OUTPUT" + echo "failed=$FAILED" >> "$GITHUB_OUTPUT" + + - name: Post PR comment + if: > + steps.detect-files.outputs.no_tf_files != 'true' && + inputs.post_comment == 'true' && + github.event_name == 'pull_request' + uses: actions/github-script@v7 + env: + THRESHOLD: ${{ inputs.threshold }} + FILES_CAPPED: ${{ steps.detect-files.outputs.files_capped }} + with: + github-token: ${{ inputs.github_token }} + script: | + const fs = require('fs'); + const threshold = parseInt(process.env.THRESHOLD, 10); + const filesCapped = process.env.FILES_CAPPED === 'true'; + + let data; + try { + data = JSON.parse(fs.readFileSync('scan_output.json', 'utf8')); + } catch (e) { + console.log('Could not read scan_output.json:', e.message); + return; + } + + const summary = data.summary; + const status = summary.failed > 0 ? 'FAILED' : 'PASSED'; + const statusEmoji = summary.failed > 0 ? '❌' : '✅'; + + let tableRows = ''; + for (const result of data.results) { + const score = result.score; + const filePath = result.file; + const vulnCount = (result.vulnerabilities || []).length; + let rowStatus; + if (score === -1) { + rowStatus = 'ERROR'; + } else if (score >= threshold) { + rowStatus = 'FAIL'; + } else { + rowStatus = 'PASS'; + } + tableRows += `| ${filePath} | ${score === -1 ? 'ERROR' : score} | ${vulnCount} | ${rowStatus} |\n`; + } + + let details = ''; + for (const result of data.results) { + if (!result.vulnerabilities || result.vulnerabilities.length === 0) continue; + const score = result.score === -1 ? 'ERROR' : result.score; + details += `\n
Details for ${result.file} (score: ${score})\n\n`; + for (const v of result.vulnerabilities) { + details += `- **${v.severity}**: ${v.message}\n`; + } + details += '\n
\n'; + } + + const capWarning = filesCapped + ? '\n> **Warning**: More than 50 .tf files found. Only the first 50 were scanned.\n' + : ''; + + const body = ` + ## TerraSafe Security Scan Results + + **Threshold**: ${threshold} | **Status**: ${statusEmoji} ${status} + ${capWarning} + | File | Score | Vulnerabilities | Status | + |------|-------|----------------|--------| + ${tableRows} + **Summary**: ${summary.failed}/${summary.total_files} files exceed threshold (max score: ${summary.max_score}) + ${details}`; + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const existing = comments.find(c => c.body && c.body.includes('')); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body: body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: body, + }); + } + + - name: Enforce threshold + if: > + steps.detect-files.outputs.no_tf_files != 'true' && + inputs.fail_on_high_risk == 'true' + shell: bash + run: | + FAILED="${{ steps.parse-results.outputs.failed }}" + if [[ "$FAILED" -gt 0 ]]; then + echo "ERROR: $FAILED file(s) exceeded the risk threshold." + exit 1 + fi + echo "All files passed the risk threshold." diff --git a/.github/actions/terrasafe-scan/requirements.txt b/.github/actions/terrasafe-scan/requirements.txt new file mode 100644 index 0000000..148781c --- /dev/null +++ b/.github/actions/terrasafe-scan/requirements.txt @@ -0,0 +1,7 @@ +# Core scanning dependencies for the TerraSafe composite action +python-hcl2==4.3.2 +scikit-learn==1.6.1 +numpy==1.26.4 +joblib==1.4.2 +pydantic==2.10.6 +pydantic-settings==2.7.1 diff --git a/.github/workflows/devsecops.yml b/.github/workflows/devsecops.yml index d760e7f..73442eb 100644 --- a/.github/workflows/devsecops.yml +++ b/.github/workflows/devsecops.yml @@ -8,6 +8,7 @@ on: permissions: contents: read + pull-requests: write security-events: write jobs: @@ -90,22 +91,27 @@ jobs: needs: test steps: - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 with: - python-version: '3.10' - - - name: Install TerraSafe - run: pip install -r requirements.txt - - - name: Scan vulnerable config (should fail) - run: | - python -m terrasafe.cli test_files/vulnerable.tf || echo "Expected failure" + fetch-depth: 0 - - name: Scan secure config (should pass) - run: | - python -m terrasafe.cli test_files/secure.tf + - name: Run TerraSafe composite action on test_files/ + id: terrasafe + uses: ./.github/actions/terrasafe-scan + with: + terraform_dir: test_files + threshold: "70" + fail_on_high_risk: "false" + github_token: ${{ secrets.GITHUB_TOKEN }} + scan_changed_only: "false" + post_comment: ${{ github.event_name == 'pull_request' && 'true' || 'false' }} + + - name: Upload SARIF to GitHub Security tab + uses: github/codeql-action/upload-sarif@v4 + if: always() && hashFiles('terrasafe-results.sarif') != '' + continue-on-error: true + with: + sarif_file: terrasafe-results.sarif + category: terrasafe-integration - name: Archive scan results uses: actions/upload-artifact@v4 @@ -113,8 +119,9 @@ jobs: with: name: scan-results path: | - scan_results_*.json - scan_history.json + scan_output.json + terrasafe-results.sarif + scan_errors.log # Docker security and build docker: diff --git a/.github/workflows/terrasafe-scan.yml b/.github/workflows/terrasafe-scan.yml new file mode 100644 index 0000000..9134892 --- /dev/null +++ b/.github/workflows/terrasafe-scan.yml @@ -0,0 +1,75 @@ +name: TerraSafe Terraform Security Scan + +on: + workflow_call: + inputs: + terraform_dir: + description: Directory containing Terraform files + type: string + required: false + default: "." + threshold: + description: Risk score threshold (0-100) + type: string + required: false + default: "70" + fail_on_high_risk: + description: Fail workflow if any file exceeds threshold + type: string + required: false + default: "true" + python_version: + description: Python version to use + type: string + required: false + default: "3.10" + +permissions: + contents: read + pull-requests: write + security-events: write + +jobs: + terrasafe: + runs-on: ubuntu-latest + + steps: + - name: Checkout calling repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Checkout TerraSafe action + uses: actions/checkout@v4 + with: + repository: ${{ github.repository }} + path: _terrasafe_action + + - name: Run TerraSafe scan + id: scan + uses: ./_terrasafe_action/.github/actions/terrasafe-scan + with: + terraform_dir: ${{ inputs.terraform_dir }} + threshold: ${{ inputs.threshold }} + fail_on_high_risk: ${{ inputs.fail_on_high_risk }} + github_token: ${{ secrets.GITHUB_TOKEN }} + scan_changed_only: "true" + post_comment: "true" + + - name: Upload SARIF to GitHub Security tab + uses: github/codeql-action/upload-sarif@v4 + if: always() && hashFiles('terrasafe-results.sarif') != '' + continue-on-error: true + with: + sarif_file: terrasafe-results.sarif + category: terrasafe + + - name: Upload scan artifacts + uses: actions/upload-artifact@v4 + if: always() + with: + name: terrasafe-scan-results + path: | + scan_output.json + terrasafe-results.sarif + scan_errors.log