Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
274 changes: 274 additions & 0 deletions .github/actions/terrasafe-scan/action.yml
Original file line number Diff line number Diff line change
@@ -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<<EOF"
echo "$TF_FILES"
echo "EOF"
} >> "$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><summary>Details for ${result.file} (score: ${score})</summary>\n\n`;
for (const v of result.vulnerabilities) {
details += `- **${v.severity}**: ${v.message}\n`;
}
details += '\n</details>\n';
}

const capWarning = filesCapped
? '\n> **Warning**: More than 50 .tf files found. Only the first 50 were scanned.\n'
: '';

const body = `<!-- terrasafe-scan -->
## 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('<!-- terrasafe-scan -->'));

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."
7 changes: 7 additions & 0 deletions .github/actions/terrasafe-scan/requirements.txt
Original file line number Diff line number Diff line change
@@ -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
39 changes: 23 additions & 16 deletions .github/workflows/devsecops.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ on:

permissions:
contents: read
pull-requests: write
security-events: write

jobs:
Expand Down Expand Up @@ -90,31 +91,37 @@ 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
if: always()
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:
Expand Down
Loading