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 }}