mirror of
https://github.com/ivuorinen/actions.git
synced 2026-03-09 17:57:10 +00:00
Set `permissions: {}` at the top level of all workflow files to deny all
permissions by default, then grant only the minimum required permissions at
the job level. This fixes the Docker push failure caused by missing
`packages: write` permission being scoped incorrectly.
Changes per workflow:
- build-testing-image.yml: add contents: read + packages: write to job
- action-security.yml: consolidate contents: read, actions: read,
pull-requests: read into the analyze job
- codeql-new.yml: add actions: read to the analyze job
- dependency-review.yml: add contents: read to the dependency-review job
- issue-stats.yml: top-level only (no checkout, existing job perms sufficient)
- new-release.yml: was read-all; job already has contents: write
- pr-lint.yml: was contents: read + packages: read; job already has full perms
- release.yml: job already has contents: write
- security-suite.yml: move all perms to job level
- stale.yml: top-level only (no checkout, existing job perms sufficient)
- sync-labels.yml: was read-all; add contents: read to job for checkout
- version-maintenance.yml: move all perms to job level
Co-authored-by: ivuorinen <11024+ivuorinen@users.noreply.github.com>
401 lines
16 KiB
YAML
401 lines
16 KiB
YAML
---
|
|
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
|
|
name: PR Security Analysis
|
|
|
|
on:
|
|
pull_request:
|
|
paths:
|
|
- '**/package.json'
|
|
- '**/package-lock.json'
|
|
- '**/yarn.lock'
|
|
- '**/pnpm-lock.yaml'
|
|
- '**/requirements.txt'
|
|
- '**/Dockerfile'
|
|
- '**/*.py'
|
|
- '**/*.js'
|
|
- '**/*.ts'
|
|
- '**/*.yml'
|
|
- '**/*.yaml'
|
|
- '.github/workflows/**'
|
|
|
|
permissions: {}
|
|
|
|
concurrency:
|
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
|
cancel-in-progress: true
|
|
|
|
jobs:
|
|
security-analysis:
|
|
name: Security Analysis
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
contents: read
|
|
pull-requests: write
|
|
issues: write
|
|
actions: read
|
|
|
|
steps:
|
|
- name: Checkout PR
|
|
uses: actions/checkout@71cf2267d89c5cb81562390fa70a37fa40b1305e # v6-beta
|
|
with:
|
|
fetch-depth: 0
|
|
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
|
ref: ${{ github.event.pull_request.head.sha }}
|
|
|
|
- name: Fetch PR Base
|
|
run: |
|
|
set -eu
|
|
# Fetch the base ref from base repository with authentication (works for private repos and forked PRs)
|
|
# Using ref instead of SHA because git fetch requires ref names, not raw commit IDs
|
|
# Use authenticated URL to avoid 403/404 on private repositories
|
|
git fetch --no-tags --depth=1 \
|
|
"https://x-access-token:${{ github.token }}@github.com/${{ github.event.pull_request.base.repo.full_name }}" \
|
|
${{ github.event.pull_request.base.ref }}:refs/remotes/origin-base/${{ github.event.pull_request.base.ref }}
|
|
# Record the base commit for diffing without checking it out
|
|
# Keep PR head checked out so scanners analyze the new changes
|
|
BASE_REF="refs/remotes/origin-base/${{ github.event.pull_request.base.ref }}"
|
|
echo "BASE_REF=${BASE_REF}" >> "$GITHUB_ENV"
|
|
echo "Base ref: ${BASE_REF}"
|
|
git log -1 --oneline "${BASE_REF}"
|
|
|
|
- name: OWASP Dependency Check
|
|
# Only run on pull_request, not pull_request_target to prevent executing
|
|
# untrusted third-party actions against PR head from forks
|
|
if: github.event_name == 'pull_request'
|
|
uses: dependency-check/Dependency-Check_Action@3102a65fd5f36d0000297576acc56a475b0de98d # main
|
|
with:
|
|
project: 'PR Security Analysis'
|
|
path: '.'
|
|
format: 'JSON'
|
|
out: 'reports'
|
|
args: >
|
|
--enableRetired --enableExperimental --failOnCVSS 0
|
|
continue-on-error: true
|
|
|
|
- name: Semgrep Static Analysis
|
|
uses: semgrep/semgrep-action@713efdd345f3035192eaa63f56867b88e63e4e5d # v1
|
|
with:
|
|
config: 'auto'
|
|
generateSarif: 'true'
|
|
env:
|
|
SEMGREP_APP_TOKEN: ${{ github.event_name != 'pull_request_target' && secrets.SEMGREP_APP_TOKEN || '' }}
|
|
continue-on-error: true
|
|
|
|
- name: TruffleHog Secret Scan
|
|
uses: trufflesecurity/trufflehog@0f58ae7c5036094a1e3e750d18772af92821b503
|
|
with:
|
|
path: ./
|
|
base: ${{ env.BASE_REF }}
|
|
head: HEAD
|
|
extra_args: --debug --only-verified --json --output /tmp/trufflehog_output.json
|
|
continue-on-error: true
|
|
|
|
- name: Analyze Security Results
|
|
id: analyze
|
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
|
with:
|
|
script: |-
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
// Unique marker to identify our bot comment
|
|
const SECURITY_COMMENT_MARKER = '<!-- security-analysis-bot-comment -->';
|
|
|
|
const findings = {
|
|
permissions: [],
|
|
actions: [],
|
|
secrets: [],
|
|
vulnerabilities: [],
|
|
dependencies: []
|
|
};
|
|
|
|
// Analyze GitHub Actions permission changes
|
|
const { execSync } = require('child_process');
|
|
const baseRef = process.env.BASE_REF;
|
|
try {
|
|
const changedWorkflows = execSync(
|
|
`git diff --name-only ${baseRef}...HEAD | grep -E "\.github/workflows/.*\.ya?ml$" || true`,
|
|
{ encoding: 'utf8' }
|
|
).trim().split('\n').filter(Boolean);
|
|
|
|
for (const workflow of changedWorkflows) {
|
|
if (!workflow) continue;
|
|
|
|
try {
|
|
const oldContent = execSync(`git show ${baseRef}:${workflow}`, { encoding: 'utf8' });
|
|
const newContent = fs.readFileSync(workflow, 'utf8');
|
|
|
|
// Simple permission extraction (could be enhanced with YAML parsing)
|
|
const oldPerms = oldContent.match(/permissions:\s*\n([\s\S]*?)(?=\n\w|\n$|$)/);
|
|
const newPerms = newContent.match(/permissions:\s*\n([\s\S]*?)(?=\n\w|\n$|$)/);
|
|
|
|
if (oldPerms?.[1] !== newPerms?.[1]) {
|
|
findings.permissions.push({
|
|
file: workflow,
|
|
old: oldPerms?.[1]?.trim() || 'None',
|
|
new: newPerms?.[1]?.trim() || 'None'
|
|
});
|
|
}
|
|
|
|
// Check for new actions
|
|
const oldActions = [...oldContent.matchAll(/uses:\s*([^\s\n]+)/g)].map(m => m[1]);
|
|
const newActions = [...newContent.matchAll(/uses:\s*([^\s\n]+)/g)].map(m => m[1]);
|
|
const addedActions = newActions.filter(action => !oldActions.includes(action));
|
|
|
|
if (addedActions.length > 0) {
|
|
findings.actions.push({
|
|
file: workflow,
|
|
added: addedActions
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.log(`Could not analyze ${workflow}: ${error.message}`);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.log('No workflow changes detected');
|
|
}
|
|
|
|
// Parse OWASP Dependency Check results
|
|
try {
|
|
const owaspResults = JSON.parse(fs.readFileSync('reports/dependency-check-report.json', 'utf8'));
|
|
if (owaspResults.dependencies) {
|
|
owaspResults.dependencies.forEach(dep => {
|
|
if (dep.vulnerabilities && dep.vulnerabilities.length > 0) {
|
|
dep.vulnerabilities.forEach(vuln => {
|
|
findings.dependencies.push({
|
|
file: dep.fileName || 'Unknown',
|
|
cve: vuln.name,
|
|
severity: vuln.severity || 'Unknown',
|
|
description: vuln.description || 'No description'
|
|
});
|
|
});
|
|
}
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.log('No OWASP results found');
|
|
}
|
|
|
|
// Parse Semgrep SARIF results
|
|
try {
|
|
if (fs.existsSync('semgrep.sarif')) {
|
|
const sarifContent = JSON.parse(fs.readFileSync('semgrep.sarif', 'utf8'));
|
|
if (sarifContent.runs && sarifContent.runs[0] && sarifContent.runs[0].results) {
|
|
const run = sarifContent.runs[0];
|
|
const rules = run.tool?.driver?.rules || [];
|
|
run.results.forEach(result => {
|
|
const rule = rules.find(r => r.id === result.ruleId);
|
|
findings.vulnerabilities.push({
|
|
file: result.locations?.[0]?.physicalLocation?.artifactLocation?.uri || 'Unknown',
|
|
line: result.locations?.[0]?.physicalLocation?.region?.startLine || 0,
|
|
rule: result.ruleId,
|
|
severity: result.level?.toUpperCase() || 'INFO',
|
|
message: result.message?.text || rule?.shortDescription?.text || 'No description'
|
|
});
|
|
});
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.log('Semgrep SARIF parsing completed');
|
|
}
|
|
|
|
// Parse TruffleHog results (NDJSON format - one JSON object per line)
|
|
try {
|
|
const truffleOutput = execSync('cat /tmp/trufflehog_output.json || echo ""', { encoding: 'utf8' });
|
|
const truffleLines = truffleOutput.trim().split('\n').filter(line => line.length > 0);
|
|
|
|
truffleLines.forEach((line, index) => {
|
|
try {
|
|
const result = JSON.parse(line);
|
|
findings.secrets.push({
|
|
file: result.SourceMetadata?.Data?.Filesystem?.file || 'Unknown',
|
|
line: result.SourceMetadata?.Data?.Filesystem?.line || 0,
|
|
detector: result.DetectorName,
|
|
verified: result.Verified || false
|
|
});
|
|
} catch (parseError) {
|
|
// Log only safe metadata to avoid leaking secrets
|
|
console.log('Failed to parse TruffleHog line at index', index, '- Error:', parseError.message, '(line length:', line.length, 'chars)');
|
|
}
|
|
});
|
|
|
|
if (truffleLines.length === 0) {
|
|
console.log('No secrets detected');
|
|
}
|
|
} catch (error) {
|
|
console.log('No TruffleHog output file found');
|
|
}
|
|
|
|
// Generate clean comment sections
|
|
const sections = [];
|
|
|
|
// GitHub Actions Permissions Changes
|
|
if (findings.permissions.length > 0) {
|
|
const permSection = ['## 🔐 GitHub Actions Permissions Changes'];
|
|
findings.permissions.forEach(change => {
|
|
permSection.push(`\n**${change.file}**:`);
|
|
|
|
// Parse permissions into lines
|
|
const oldLines = (change.old === 'None' ? [] : change.old.split('\n').map(l => l.trim()).filter(Boolean));
|
|
const newLines = (change.new === 'None' ? [] : change.new.split('\n').map(l => l.trim()).filter(Boolean));
|
|
|
|
// Create sets for comparison
|
|
const oldSet = new Set(oldLines);
|
|
const newSet = new Set(newLines);
|
|
|
|
// Find added, removed, and unchanged
|
|
const removed = oldLines.filter(l => !newSet.has(l));
|
|
const added = newLines.filter(l => !oldSet.has(l));
|
|
const unchanged = oldLines.filter(l => newSet.has(l));
|
|
|
|
// Only show diff if there are actual changes
|
|
if (removed.length > 0 || added.length > 0) {
|
|
permSection.push('```diff');
|
|
|
|
// Show removed permissions
|
|
removed.forEach(line => permSection.push(`- ${line}`));
|
|
|
|
// Show added permissions
|
|
added.forEach(line => permSection.push(`+ ${line}`));
|
|
|
|
permSection.push('```');
|
|
|
|
// Summary for context
|
|
if (unchanged.length > 0 && unchanged.length <= 3) {
|
|
permSection.push(`<details><summary>Unchanged (${unchanged.length})</summary>\n\n${unchanged.map(l => `- ${l}`).join('\n')}\n</details>`);
|
|
} else if (unchanged.length > 3) {
|
|
permSection.push(`<sub>*${unchanged.length} permissions unchanged*</sub>`);
|
|
}
|
|
}
|
|
});
|
|
sections.push(permSection.join('\n'));
|
|
}
|
|
|
|
// New/Changed Actions
|
|
if (findings.actions.length > 0) {
|
|
const actionSection = ['## 🎯 New GitHub Actions'];
|
|
findings.actions.forEach(change => {
|
|
actionSection.push(`**${change.file}**:`);
|
|
change.added.forEach(action => {
|
|
actionSection.push(`- \`${action}\``);
|
|
});
|
|
});
|
|
sections.push(actionSection.join('\n'));
|
|
}
|
|
|
|
// Secrets Detected
|
|
if (findings.secrets.length > 0) {
|
|
const secretSection = ['## 🔑 Secrets Detected'];
|
|
findings.secrets.forEach(secret => {
|
|
const verified = secret.verified ? '🚨 **VERIFIED**' : '⚠️ Potential';
|
|
secretSection.push(`- ${verified} ${secret.detector} in \`${secret.file}:${secret.line}\``);
|
|
});
|
|
sections.push(secretSection.join('\n'));
|
|
}
|
|
|
|
// Security Vulnerabilities
|
|
if (findings.vulnerabilities.length > 0) {
|
|
const vulnSection = ['## ⚠️ Security Vulnerabilities'];
|
|
const groupedBySeverity = findings.vulnerabilities.reduce((acc, vuln) => {
|
|
const sev = vuln.severity.toUpperCase();
|
|
if (!acc[sev]) acc[sev] = [];
|
|
acc[sev].push(vuln);
|
|
return acc;
|
|
}, {});
|
|
|
|
['ERROR', 'WARNING', 'INFO'].forEach(severity => {
|
|
if (groupedBySeverity[severity]) {
|
|
vulnSection.push(`\n**${severity} Severity:**`);
|
|
groupedBySeverity[severity].forEach(vuln => {
|
|
vulnSection.push(`- \`${vuln.file}:${vuln.line}\` - ${vuln.message}`);
|
|
vulnSection.push(` - Rule: \`${vuln.rule}\``);
|
|
});
|
|
}
|
|
});
|
|
sections.push(vulnSection.join('\n'));
|
|
}
|
|
|
|
// Dependency Issues
|
|
if (findings.dependencies.length > 0) {
|
|
const depSection = ['## 📦 Dependency Vulnerabilities'];
|
|
const groupedBySeverity = findings.dependencies.reduce((acc, dep) => {
|
|
const sev = dep.severity.toUpperCase();
|
|
if (!acc[sev]) acc[sev] = [];
|
|
acc[sev].push(dep);
|
|
return acc;
|
|
}, {});
|
|
|
|
['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'].forEach(severity => {
|
|
if (groupedBySeverity[severity]) {
|
|
depSection.push(`\n**${severity} Severity:**`);
|
|
groupedBySeverity[severity].forEach(dep => {
|
|
depSection.push(`- **${dep.cve}** in \`${dep.file}\``);
|
|
depSection.push(` - ${dep.description.substring(0, 100)}...`);
|
|
});
|
|
}
|
|
});
|
|
sections.push(depSection.join('\n'));
|
|
}
|
|
|
|
// Count critical issues for output
|
|
const criticalCount =
|
|
findings.secrets.filter(s => s.verified).length +
|
|
(findings.vulnerabilities.filter(v => v.severity.toUpperCase() === 'ERROR').length || 0) +
|
|
(findings.dependencies.filter(d => d.severity.toUpperCase() === 'CRITICAL').length || 0);
|
|
|
|
// Export critical count as output
|
|
core.setOutput('critical_issues', criticalCount.toString());
|
|
|
|
// Generate final comment with unique marker
|
|
let comment = `${SECURITY_COMMENT_MARKER}\n## ✅ Security Analysis\n\n`;
|
|
if (sections.length === 0) {
|
|
comment += 'No security issues detected in this PR.';
|
|
} else {
|
|
comment += sections.join('\n\n');
|
|
}
|
|
|
|
// Find existing security comment using unique marker
|
|
const { data: comments } = await github.rest.issues.listComments({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
issue_number: context.issue.number
|
|
});
|
|
|
|
const existingComment = comments.find(comment =>
|
|
comment.body && comment.body.includes(SECURITY_COMMENT_MARKER)
|
|
);
|
|
|
|
if (existingComment) {
|
|
// Update existing comment
|
|
await github.rest.issues.updateComment({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
comment_id: existingComment.id,
|
|
body: comment
|
|
});
|
|
} else {
|
|
// Create new comment
|
|
await github.rest.issues.createComment({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
issue_number: context.issue.number,
|
|
body: comment
|
|
});
|
|
}
|
|
|
|
- name: Check Critical Issues
|
|
if: always()
|
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
|
env:
|
|
CRITICAL_COUNT: ${{ steps.analyze.outputs.critical_issues || '0' }}
|
|
with:
|
|
script: |-
|
|
const criticalCount = parseInt(process.env.CRITICAL_COUNT || '0', 10);
|
|
|
|
if (criticalCount > 0) {
|
|
core.setFailed(`Found ${criticalCount} critical security issue(s). Please review and address them before merging.`);
|
|
} else {
|
|
console.log('No critical security issues found.');
|
|
}
|