From c23046007f6b2ab91d6c0071f1999ecf5ab81c88 Mon Sep 17 00:00:00 2001 From: Kelly Thomas Reardon Date: Sat, 16 May 2026 23:10:27 -0500 Subject: [PATCH 1/2] enabled enhanced sast scans with automatic issue creation on findings --- .gitea/workflows/docker-build.yaml | 35 +-- .gitea/workflows/security.yaml | 400 +++++++++++++++++++++++++++++ 2 files changed, 410 insertions(+), 25 deletions(-) create mode 100644 .gitea/workflows/security.yaml diff --git a/.gitea/workflows/docker-build.yaml b/.gitea/workflows/docker-build.yaml index 2bfc475..e8003bb 100644 --- a/.gitea/workflows/docker-build.yaml +++ b/.gitea/workflows/docker-build.yaml @@ -1,35 +1,19 @@ name: Build and Push Docker Image on: - push: - branches: - - "**" + workflow_dispatch: + +permissions: + contents: read jobs: build: runs-on: ubuntu-latest - # permissions: - # contents: read # Required to checkout and read repo files - # security-events: write # Required to upload SARIF files to Security tab steps: - name: Checkout uses: actions/checkout@v4 - # - name: Run Trivy vulnerability scanner in repo mode - # uses: aquasecurity/trivy-action@v0.36.0 - # with: - # scan-type: 'fs' - # ignore-unfixed: true - # format: 'sarif' - # output: 'trivy-results.sarif' - # severity: 'CRITICAL,HIGH' - - # - name: Upload Trivy scan results to GitHub Security tab - # uses: github/codeql-action/upload-sarif@v4 - # with: - # sarif_file: 'trivy-results.sarif' - - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -52,7 +36,8 @@ jobs: TAG="test" fi - echo "tag=$TAG" >> $GITHUB_OUTPUT + echo "tag=$TAG" >> "$GITEA_OUTPUT" + echo "branch=$BRANCH" >> "$GITEA_OUTPUT" - name: Build and push uses: docker/build-push-action@v6 @@ -69,9 +54,9 @@ jobs: -d "{ \"tags\": \"all\", \"title\": \"Gitea Build Succeeded\", - \"body\": \"Repo: ${{ gitea.repository }}\\nBranch: ${{ gitea.ref_name }}\\nImage tag built successfully\" + \"body\": \"Repo: ${{ gitea.repository }}\\nBranch: ${{ steps.tag.outputs.branch }}\\nImage tag built successfully\" }" \ - http://10.47.0.213:4444/notify/926263506803e21d72e382edd0caf3fb510a9629d860601dfb79506b5758c133 + ${{ secrets.APPRISE_URL }} - name: Notify Apprise (failure) if: failure() @@ -81,6 +66,6 @@ jobs: -d "{ \"tags\": \"all\", \"title\": \"Gitea Build Failed\", - \"body\": \"Repo: ${{ gitea.repository }}\\nBranch: ${{ gitea.ref_name }}\\nCheck logs in Gitea\" + \"body\": \"Repo: ${{ gitea.repository }}\\nBranch: ${{ steps.tag.outputs.branch }}\\nCheck logs in Gitea\" }" \ - http://10.47.0.213:4444/notify/926263506803e21d72e382edd0caf3fb510a9629d860601dfb79506b5758c133 \ No newline at end of file + ${{ secrets.APPRISE_URL }} diff --git a/.gitea/workflows/security.yaml b/.gitea/workflows/security.yaml new file mode 100644 index 0000000..f351633 --- /dev/null +++ b/.gitea/workflows/security.yaml @@ -0,0 +1,400 @@ +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 }} From cfcaafffd64e4e3cacba77265516f62ceb199d80 Mon Sep 17 00:00:00 2001 From: Kelly Thomas Reardon Date: Sat, 16 May 2026 23:23:22 -0500 Subject: [PATCH 2/2] resolving issues #4 and #5 - security findings --- Dockerfile | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 3b3d298..284b3f1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,12 @@ -FROM mcr.microsoft.com/powershell +FROM mcr.microsoft.com/powershell:7.5-ubuntu-24.04 + +USER root + +RUN apt-get update \ + && apt-get install -y --no-install-recommends tzdata \ + && apt-get dist-upgrade -y \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* USER 1000:1000