diff --git a/.gitea/workflows/docker-build.yaml b/.gitea/workflows/docker-build.yaml new file mode 100644 index 0000000..1fb56a2 --- /dev/null +++ b/.gitea/workflows/docker-build.yaml @@ -0,0 +1,71 @@ +name: Build and Push Docker Image + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - 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" + echo "branch=$BRANCH" >> "$GITEA_OUTPUT" + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: blinkfink182/donetick-notifier:${{ steps.tag.outputs.tag }} + + - name: Notify Apprise (success) + if: success() + run: | + curl -X POST \ + -H "Content-Type: application/json" \ + -d "{ + \"tags\": \"all\", + \"title\": \"Gitea Build Succeeded\", + \"body\": \"Repo: ${{ gitea.repository }}\\nBranch: ${{ steps.tag.outputs.branch }}\\nImage tag built successfully\" + }" \ + ${{ secrets.APPRISE_URL }} + + - name: Notify Apprise (failure) + if: failure() + run: | + curl -X POST \ + -H "Content-Type: application/json" \ + -d "{ + \"tags\": \"all\", + \"title\": \"Gitea Build Failed\", + \"body\": \"Repo: ${{ gitea.repository }}\\nBranch: ${{ steps.tag.outputs.branch }}\\nCheck logs in Gitea\" + }" \ + ${{ secrets.APPRISE_URL }} diff --git a/.gitea/workflows/security.yaml b/.gitea/workflows/security.yaml new file mode 100644 index 0000000..c714cfd --- /dev/null +++ b/.gitea/workflows/security.yaml @@ -0,0 +1,444 @@ +name: Security + +on: + pull_request: + branches: + - main + - testing + - "v*" + push: + branches: + - main + - testing + - "v*" + 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/donetick-notifier:${{ 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", + } + vulnerability_label_name = "vulnerability" + vulnerability_label_id = None + + 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 ensure_vulnerability_label(): + global vulnerability_label_id + + if vulnerability_label_id is not None: + return vulnerability_label_id + + labels_url = f"{api_url}/repos/{urllib.parse.quote(owner)}/{urllib.parse.quote(name)}/labels" + + request = urllib.request.Request(labels_url, headers=headers) + with urllib.request.urlopen(request, timeout=30) as response: + labels = json.load(response) + + for label in labels: + if label.get("name") == vulnerability_label_name: + vulnerability_label_id = label.get("id") + return vulnerability_label_id + + payload = json.dumps({ + "name": vulnerability_label_name, + "color": "d73a4a", + "description": "Security vulnerability found by automated scans", + "exclusive": False, + "is_archived": False, + }).encode("utf-8") + request = urllib.request.Request(labels_url, data=payload, headers=headers, method="POST") + with urllib.request.urlopen(request, timeout=30) as response: + created = json.load(response) + + vulnerability_label_id = created.get("id") + return vulnerability_label_id + + def create_issue(title, body): + if find_existing(title): + print(f"Open issue already exists: {title}") + return + label_id = ensure_vulnerability_label() + payload = json.dumps({ + "title": title, + "body": body, + "labels": [label_id] if label_id is not None else [], + }).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/donetick-notifier:${{ 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 }} diff --git a/.postmate/collections/QBitTorrent API.json b/.postmate/collections/QBitTorrent API.json deleted file mode 100644 index b548883..0000000 --- a/.postmate/collections/QBitTorrent API.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "id": "4f1bc04d-f562-427b-99cf-b06504f6b81d", - "collectionName": "QBitTorrent API", - "creatredOn": "2026-05-05T22:01:07.843Z", - "folders": [], - "requests": [ - { - "url": "https://bt.ktr32.org/api/v2/auth/login", - "method": "POST", - "headers": { - "referer": "https://bt.ktr32.org/", - "accept": "*/*", - "user-agent": "https://postmateclient.com", - "content-type": "application/x-www-form-urlencoded" - }, - "body": { - "json": {}, - "xml": "", - "x-www-form-urlencoded": { - "username": "{{QBTUSERNAME}}", - "password": "{{QBTPASSWORD}}" - }, - "plainText": "" - }, - "tests": [], - "preRequestScript": "", - "testScript": "", - "id": "f2a3d592-3c47-4e2b-bbd1-1c403c0ce4da", - "name": "Login", - "collectionId": "4f1bc04d-f562-427b-99cf-b06504f6b81d", - "containerId": "4f1bc04d-f562-427b-99cf-b06504f6b81d", - "collectionName": "QBitTorrent API", - "bodyFormat": "x-www-form-urlencoded", - "preRequests": [], - "auth": { - "type": "none" - }, - "cookies": [] - }, - { - "id": "e5f30dbd-e3e9-467d-8496-7fbc3782efd0", - "collectionId": "4f1bc04d-f562-427b-99cf-b06504f6b81d", - "containerId": "4f1bc04d-f562-427b-99cf-b06504f6b81d", - "collectionName": "QBitTorrent API", - "name": "Get Torrents", - "url": "https://bt.ktr32.org/api/v2/torrents/info", - "method": "GET", - "headers": { - "referer": "https://bt.ktr32.org/", - "accept": "*/*", - "user-agent": "https://postmateclient.com" - }, - "body": { - "json": {} - }, - "tests": [], - "bodyFromat": "json", - "testScript": "", - "bodyFormat": "json", - "preRequestScript": "", - "preRequests": [ - { - "collectionId": "4f1bc04d-f562-427b-99cf-b06504f6b81d", - "collectionName": "QBitTorrent API", - "requestId": "f2a3d592-3c47-4e2b-bbd1-1c403c0ce4da" - } - ], - "auth": { - "type": "none" - }, - "cookies": [] - } - ] -} \ No newline at end of file diff --git a/.postmate/postmateHeader.json b/.postmate/postmateHeader.json deleted file mode 100644 index cd1cb84..0000000 --- a/.postmate/postmateHeader.json +++ /dev/null @@ -1,7 +0,0 @@ -[ - { - "key": "content-type", - "value": "application/json", - "default": false - } -] \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..5a11575 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "trivy.secretScanning": true, + "semgrep.scan.onlyGitDirty": false +} \ No newline at end of file