Files
2026-05-16 23:10:27 -05:00

401 lines
15 KiB
YAML

name: Security
on:
pull_request:
push:
branches:
- "**"
workflow_dispatch:
permissions:
contents: read
issues: write
jobs:
security:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Prepare security reports
run: |
mkdir -p security-results
- name: Compute image tag
id: tag
run: |
BRANCH="${{ gitea.ref_name }}"
if [ "$BRANCH" = "main" ]; then
TAG="latest"
elif [[ "$BRANCH" == v* ]]; then
TAG="$BRANCH"
else
TAG="test"
fi
echo "tag=$TAG" >> "$GITEA_OUTPUT"
###########################################################
# GITLEAKS
###########################################################
- name: Gitleaks
run: |
set +e
cid="$(docker create \
ghcr.io/gitleaks/gitleaks:latest \
detect \
--source /repo \
--report-format json \
--report-path /repo/security-results/gitleaks.json \
--exit-code 1)"
docker cp . "$cid:/repo"
docker start -a "$cid"
status="$?"
docker cp "$cid:/repo/security-results/gitleaks.json" security-results/gitleaks.json || true
docker rm -f "$cid" >/dev/null 2>&1 || true
echo "$status" > security-results/gitleaks.exit
###########################################################
# SEMGREP
###########################################################
- name: Semgrep
run: |
set +e
cid="$(docker create \
--workdir /src \
semgrep/semgrep \
semgrep scan \
--config auto \
--json \
--output /src/security-results/semgrep.json \
--error \
/src)"
docker cp . "$cid:/src"
docker start -a "$cid"
status="$?"
docker cp "$cid:/src/security-results/semgrep.json" security-results/semgrep.json || true
docker rm -f "$cid" >/dev/null 2>&1 || true
echo "$status" > security-results/semgrep.exit
###########################################################
# TRIVY FS
###########################################################
- name: Trivy Filesystem
run: |
set +e
cid="$(docker create \
aquasec/trivy:latest \
fs \
--scanners vuln,secret,misconfig \
--severity HIGH,CRITICAL \
--format json \
--output /workspace/security-results/trivy-fs.json \
--exit-code 1 \
/workspace)"
docker cp . "$cid:/workspace"
docker start -a "$cid"
status="$?"
docker cp "$cid:/workspace/security-results/trivy-fs.json" security-results/trivy-fs.json || true
docker rm -f "$cid" >/dev/null 2>&1 || true
echo "$status" > security-results/trivy-fs.exit
###########################################################
# DOCKER IMAGE BUILD
###########################################################
- name: Build Image for scan
run: |
set +e
docker build \
-t app:${{ gitea.sha }} \
-t blinkfink182/qbt-gluetun-portmgr:${{ steps.tag.outputs.tag }} \
.
echo "$?" > security-results/docker-build.exit
###########################################################
# TRIVY IMAGE
###########################################################
- name: Trivy Image Scan
run: |
set +e
if [ "$(cat security-results/docker-build.exit)" != "0" ]; then
echo "Skipping image scan because the image build failed."
echo "1" > security-results/trivy-image.exit
exit 0
fi
cid="$(docker create \
-v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy:latest \
image \
--severity HIGH,CRITICAL \
--format json \
--output /tmp/trivy-image.json \
--exit-code 1 \
app:${{ gitea.sha }})"
docker start -a "$cid"
status="$?"
docker cp "$cid:/tmp/trivy-image.json" security-results/trivy-image.json || true
docker rm -f "$cid" >/dev/null 2>&1 || true
echo "$status" > security-results/trivy-image.exit
- name: Create Gitea issues for security findings
if: always()
env:
GITEA_API_URL: ${{ gitea.api_url }}
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_REF_NAME: ${{ gitea.ref_name }}
GITEA_RUN_ID: ${{ gitea.run_id }}
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_SHA: ${{ gitea.sha }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
python3 <<'PY'
import json
import os
import urllib.parse
import urllib.request
token = os.environ.get("GITEA_TOKEN", "")
api_url = os.environ["GITEA_API_URL"].rstrip("/")
repo = os.environ["GITEA_REPOSITORY"]
ref_name = os.environ.get("GITEA_REF_NAME", "")
sha = os.environ.get("GITEA_SHA", "")
run_id = os.environ.get("GITEA_RUN_ID", "")
server_url = os.environ.get("GITEA_SERVER_URL", "").rstrip("/")
if not token:
print("GITEA_TOKEN is unavailable; skipping issue creation.")
raise SystemExit(0)
owner, name = repo.split("/", 1)
issues_url = f"{api_url}/repos/{urllib.parse.quote(owner)}/{urllib.parse.quote(name)}/issues"
headers = {
"Authorization": f"token {token}",
"Content-Type": "application/json",
}
def load_json(path, fallback):
try:
with open(path, "r", encoding="utf-8") as handle:
return json.load(handle)
except FileNotFoundError:
return fallback
except json.JSONDecodeError as exc:
print(f"Could not parse {path}: {exc}")
return fallback
def trim(value, limit):
value = str(value or "unknown")
return value if len(value) <= limit else value[: limit - 3] + "..."
def find_existing(title):
query = urllib.parse.urlencode({"state": "open", "type": "issues", "q": title})
request = urllib.request.Request(f"{issues_url}?{query}", headers=headers)
with urllib.request.urlopen(request, timeout=30) as response:
issues = json.load(response)
return any(issue.get("title") == title for issue in issues)
def create_issue(title, body):
if find_existing(title):
print(f"Open issue already exists: {title}")
return
payload = json.dumps({"title": title, "body": body}).encode("utf-8")
request = urllib.request.Request(issues_url, data=payload, headers=headers, method="POST")
with urllib.request.urlopen(request, timeout=30) as response:
created = json.load(response)
print(f"Created issue #{created.get('number')}: {title}")
def body(scanner, summary, details):
run_url = f"{server_url}/{repo}/actions/runs/{run_id}" if server_url and run_id else "Unavailable"
lines = [
f"Security scanner: `{scanner}`",
f"Summary: {summary}",
f"Repository: `{repo}`",
f"Branch/ref: `{ref_name}`",
f"Commit: `{sha}`",
f"Action run: {run_url}",
"",
"Details:",
]
lines.extend(f"- {key}: {value}" for key, value in details if value not in (None, ""))
return "\n".join(lines)
findings = []
for finding in load_json("security-results/gitleaks.json", []):
rule = finding.get("RuleID") or finding.get("Description") or "secret"
file_path = finding.get("File", "unknown")
line = finding.get("StartLine", "unknown")
title = trim(f"[security][gitleaks] {rule} in {file_path}:{line}", 255)
findings.append((
title,
body("gitleaks", f"{rule} in `{file_path}:{line}`", [
("Rule", rule),
("Description", finding.get("Description")),
("File", file_path),
("Line", line),
("Fingerprint", finding.get("Fingerprint")),
]),
))
semgrep = load_json("security-results/semgrep.json", {})
for result in semgrep.get("results", []):
extra = result.get("extra", {})
start = result.get("start", {})
check_id = result.get("check_id", "semgrep finding")
file_path = result.get("path", "unknown")
line = start.get("line", "unknown")
title = trim(f"[security][semgrep] {check_id} in {file_path}:{line}", 255)
findings.append((
title,
body("semgrep", extra.get("message", check_id), [
("Check", check_id),
("Severity", extra.get("severity")),
("File", file_path),
("Line", line),
("Message", extra.get("message")),
]),
))
def add_trivy_findings(path, scanner):
report = load_json(path, {})
for result in report.get("Results", []):
target = result.get("Target", "unknown")
for vuln in result.get("Vulnerabilities", []) or []:
vuln_id = vuln.get("VulnerabilityID", "vulnerability")
package = vuln.get("PkgName", "unknown package")
severity = vuln.get("Severity", "UNKNOWN")
title = trim(f"[security][{scanner}] {severity} {vuln_id} in {package} on {target}", 255)
findings.append((
title,
body(scanner, vuln.get("Title") or vuln_id, [
("Type", "Vulnerability"),
("Severity", severity),
("Target", target),
("Package", package),
("Installed version", vuln.get("InstalledVersion")),
("Fixed version", vuln.get("FixedVersion")),
("Vulnerability ID", vuln_id),
("Primary URL", vuln.get("PrimaryURL")),
]),
))
for misconfig in result.get("Misconfigurations", []) or []:
misconfig_id = misconfig.get("ID", "misconfiguration")
severity = misconfig.get("Severity", "UNKNOWN")
title = trim(f"[security][{scanner}] {severity} {misconfig_id} in {target}", 255)
findings.append((
title,
body(scanner, misconfig.get("Title") or misconfig_id, [
("Type", "Misconfiguration"),
("Severity", severity),
("Target", target),
("ID", misconfig_id),
("Message", misconfig.get("Message")),
("Resolution", misconfig.get("Resolution")),
]),
))
for secret in result.get("Secrets", []) or []:
rule = secret.get("RuleID", "secret")
line = secret.get("StartLine", "unknown")
title = trim(f"[security][{scanner}] {rule} in {target}:{line}", 255)
findings.append((
title,
body(scanner, f"{rule} in `{target}:{line}`", [
("Type", "Secret"),
("Rule", rule),
("Category", secret.get("Category")),
("Severity", secret.get("Severity")),
("Target", target),
("Line", line),
]),
))
add_trivy_findings("security-results/trivy-fs.json", "trivy-fs")
add_trivy_findings("security-results/trivy-image.json", "trivy-image")
if not findings:
print("No security findings found in scanner reports.")
raise SystemExit(0)
for title, issue_body in findings:
try:
create_issue(title, issue_body)
except Exception as exc:
print(f"Failed to create issue for {title}: {exc}")
PY
- name: Stop on failed security scans
if: always()
run: |
failed=0
for result in security-results/*.exit; do
name="$(basename "$result" .exit)"
code="$(cat "$result")"
if [ "$code" != "0" ]; then
echo "$name failed with exit code $code"
failed=1
fi
done
if [ "$failed" != "0" ]; then
exit 1
fi
- name: Notify Apprise (failure)
if: failure()
run: |
curl -X POST \
-H "Content-Type: application/json" \
-d "{
\"tags\": \"all\",
\"title\": \"Gitea Security Scan Failed\",
\"body\": \"Repo: ${{ gitea.repository }}\\nBranch: ${{ gitea.ref_name }}\\nSecurity scan failed; check logs and generated issues in Gitea\"
}" \
${{ secrets.APPRISE_URL }}
- name: Login to Docker Hub
id: docker-login
if: ${{ gitea.event_name == 'push' }}
run: |
echo "${{ secrets.DOCKER_PASSWORD }}" | docker login \
--username "${{ secrets.DOCKER_USERNAME }}" \
--password-stdin
- name: Push scanned image
id: push
if: ${{ gitea.event_name == 'push' }}
run: |
docker push blinkfink182/qbt-gluetun-portmgr:${{ steps.tag.outputs.tag }}
- name: Notify Apprise (success)
if: ${{ success() && gitea.event_name == 'push' }}
run: |
curl -X POST \
-H "Content-Type: application/json" \
-d "{
\"tags\": \"all\",
\"title\": \"Gitea Build Succeeded\",
\"body\": \"Repo: ${{ gitea.repository }}\\nBranch: ${{ gitea.ref_name }}\\nImage tag ${{ steps.tag.outputs.tag }} pushed successfully\"
}" \
${{ secrets.APPRISE_URL }}
- name: Notify Apprise (failure)
if: ${{ failure() && (steps.docker-login.outcome == 'failure' || steps.push.outcome == 'failure') }}
run: |
curl -X POST \
-H "Content-Type: application/json" \
-d "{
\"tags\": \"all\",
\"title\": \"Gitea Build Failed\",
\"body\": \"Repo: ${{ gitea.repository }}\\nBranch: ${{ gitea.ref_name }}\\nCheck logs in Gitea\"
}" \
${{ secrets.APPRISE_URL }}