Compare commits

..
17 Commits
Author SHA1 Message Date
DragonSlayer_14andClaude Sonnet 5 134d3048cf Fix: Security-Scan-Report robuster gemacht & TruffleHog läuft bei jedem Push
Security Scans / Trivy & OSV-Scanner (push) Failing after 5m27s
TruffleHog Secret Scan / TruffleHog (push) Successful in 7s
- OSV-Scanner-Funde erhalten jetzt eine echte Severity (aus CVSS-Score
  der Gruppen abgeleitet) statt hartcodiert "-", damit kritische Funde
  nicht unten in der Tabelle verschwinden
- Unerwartete Exit-Codes von osv-scanner/trufflehog gelten jetzt als
  Fehlschlag statt stillschweigend als "keine Funde"
- Timeout für Gitea-API-Requests ergänzt
- Label-Suche paginiert jetzt vollständig statt nur die ersten 50
- Markdown-Tabellenzellen werden escaped (Pipe/Zeilenumbruch)
- Doppeltes Sortieren der Findings entfernt, Dict-Aufbau der Loader
  über gemeinsamen Helper vereinheitlicht
- trufflehog-scan.yaml läuft nun bei jedem Push, nicht nur auf
  main/testing/dev

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKMjR9pBxshZYZdhKyT5Ky
2026-09-09 23:00:41 +02:00
DragonSlayer_14andClaude Sonnet 5 9445a82b53 Feat: TruffleHog-Secret-Scan als eigene Gitea-Action hinzugefügt
- Neuer Workflow trufflehog-scan.yaml scannt die komplette Git-Historie und
  meldet Funde in ein eigenes Issue (Label security-scan-trufflehog), getrennt
  vom Trivy/OSV-Issue, aber ebenfalls über SECURITY_TOKEN
- report-security-issue.py: Titel/Label per ISSUE_TITLE/ISSUE_LABEL
  konfigurierbar, TruffleHog-JSONL-Parsing (ohne Secret-Wert im Report),
  Workflow schlägt bei verifizierten Funden fehl
- Rotations-Anleitung wird automatisch an Issues mit TruffleHog-Funden angehängt

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKMjR9pBxshZYZdhKyT5Ky
2026-09-09 22:43:30 +02:00
DragonSlayer_14andClaude Sonnet 5 f56a5c71ab Feat: Gitea-Action für Security-Scans mit Trivy & OSV-Scanner hinzugefügt
- Trivy (fs-Scan: vuln, secret, misconfig) und OSV-Scanner (Cargo.lock)
  laufen als FOSS-Tools ohne Anmeldedaten
- Bei Funden wird automatisch ein Gitea-Issue (Label security-scan)
  erstellt bzw. bei jedem weiteren Lauf per Kommentar aktualisiert,
  solange es offen ist; nach Funden wird nicht automatisch geschlossen

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKMjR9pBxshZYZdhKyT5Ky
2026-09-09 22:24:14 +02:00
DragonSlayer_14andClaude Sonnet 5 9a59fff032 Feat: Renovate für automatische Dependency-Updates eingerichtet
- renovate.json mit Zeitplan und Gruppierung für Gitea Actions und Docker-Images
- Gitea-Workflow zur wöchentlichen Ausführung von Renovate hinzugefügt
- Workflow-Datei in IDEA JSON-Schema-Zuordnungen registriert

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKMjR9pBxshZYZdhKyT5Ky
2026-09-09 21:54:28 +02:00
DragonSlayer_14 d9089afdc9 Feat: IDEA-Run-Konfiguration für Cargo hinzugefügt
- Standard-Konfiguration für `cargo run` mit Dev-Profil
- Terminal-Emulation und Projektverzeichnis als Arbeitsordner
2026-09-09 20:43:19 +02:00
DragonSlayer_14 59a81773cc Fix: Abhängigkeit serde_json entfernt (bereits über serde abgedeckt) 2026-09-09 20:43:03 +02:00
DragonSlayer_14 f7957ab9f5 Fix: .junie/plans-Ordner von IDEA-Indexierung ausgeschlossen 2026-09-09 20:42:46 +02:00
DragonSlayer_14 42829ce0a0 **Bump: Version auf 1.0.2 erhöht** 2026-09-03 19:52:47 +02:00
DragonSlayer_14 bc3f4c4ec6 Merge remote-tracking branch 'origin/dev' into dev 2026-09-03 18:56:34 +02:00
DragonSlayer_14 c3d5ed8b26 **Feat: Erweitert Repository-Input-Handling mit robusterer Normalisierung**
- Unterstützt Query-Parameter, Fragmente und verschiedene URL-Formate
- Neue `clean_repo_input`-Funktion für konsistente Verarbeitung
- Verbesserte Tests für Edge-Cases (Anführungszeichen, `.git`, Pfade)
2026-09-03 18:56:27 +02:00
DragonSlayer_14 a7c0c25b26 **Feat: Verbessert Token-Handling und String-Sanitization**
- Leere/Whitespace-Tokens werden konsistent als `None` behandelt
- Sanitization für Anführungszeichen (`"` und `'`) und Umgebungsvariablen
- Getter-Methode für GitHub-Token hinzugefügt
2026-09-03 18:56:27 +02:00
DragonSlayer_14 08cca0ca51 Test: GitHubClient-Token-Handling verbessert 2026-09-03 18:56:27 +02:00
DragonSlayer_14 2f61e15646 Feat: Fügt Utils-Modul hinzu und integriert Umgebungsvariablen in Config 2026-09-03 18:56:27 +02:00
DragonSlayer_14 5e5e3f5b17 **Feat: Erweitert Repository-Input-Handling mit robusterer Normalisierung**
- Unterstützt Query-Parameter, Fragmente und verschiedene URL-Formate
- Neue `clean_repo_input`-Funktion für konsistente Verarbeitung
- Verbesserte Tests für Edge-Cases (Anführungszeichen, `.git`, Pfade)
2026-09-03 18:52:55 +02:00
DragonSlayer_14 f04c9611b6 **Feat: Verbessert Token-Handling und String-Sanitization**
- Leere/Whitespace-Tokens werden konsistent als `None` behandelt
- Sanitization für Anführungszeichen (`"` und `'`) und Umgebungsvariablen
- Getter-Methode für GitHub-Token hinzugefügt
2026-09-03 17:21:04 +02:00
DragonSlayer_14 14a1c4944a Test: GitHubClient-Token-Handling verbessert 2026-09-02 23:40:23 +02:00
DragonSlayer_14 c743f51526 Feat: Fügt Utils-Modul hinzu und integriert Umgebungsvariablen in Config 2026-09-02 22:31:31 +02:00
17 changed files with 771 additions and 67 deletions
+25
View File
@@ -0,0 +1,25 @@
name: Renovate
on:
schedule:
- cron: "0 4 * * 1"
workflow_dispatch:
jobs:
renovate:
name: Dependency-Updates prüfen & Pull Requests erstellen
runs-on: ubuntu-latest
container: ghcr.io/renovatebot/renovate:43.279.0
steps:
- name: Renovate ausführen
run: renovate
env:
RENOVATE_PLATFORM: gitea
RENOVATE_ENDPOINT: ${{ gitea.server_url || github.server_url }}/api/v1/
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
RENOVATE_REPOSITORIES: ${{ gitea.repository || github.repository }}
RENOVATE_AUTODISCOVER: "false"
RENOVATE_GIT_AUTHOR: "Renovate Bot <renovate-bot@creative-dragonslayer.de>"
RENOVATE_HOST_RULES: >-
[{"hostType":"cargo","matchHost":"gitea.creative-dragonslayer.de","token":"${{ secrets.RENOVATE_TOKEN }}"}]
LOG_LEVEL: info
+52
View File
@@ -0,0 +1,52 @@
name: Security Scans
on:
push:
branches:
- main
- testing
- dev
pull_request:
schedule:
- cron: "0 5 * * 1"
workflow_dispatch:
jobs:
security-scan:
name: Trivy & OSV-Scanner
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v7
- name: Run Trivy Scanner
uses: aquasecurity/trivy-action@v0.36.0
with:
scan-type: fs
scan-ref: .
scanners: vuln,secret,misconfig
severity: CRITICAL,HIGH
format: json
output: trivy-results.json
exit-code: "0"
- name: Install OSV-Scanner
run: |
curl -fsSL -o /usr/local/bin/osv-scanner \
"https://github.com/google/osv-scanner/releases/download/v2.5.1/osv-scanner_linux_amd64"
chmod +x /usr/local/bin/osv-scanner
- name: Run OSV-Scanner
run: |
set +e
osv-scanner scan source --recursive --format json --output-file osv-results.json .
echo "OSV_EXIT=$?" >> "$GITHUB_ENV"
- name: Ergebnisse & Gitea-Issue erstellen/aktualisieren
env:
GITEA_URL: ${{ gitea.server_url || github.server_url }}
REPO: ${{ gitea.repository || github.repository }}
TOKEN: ${{ secrets.SECURITY_TOKEN }}
RUN_URL: ${{ gitea.server_url || github.server_url }}/${{ gitea.repository || github.repository }}/actions/runs/${{ gitea.run_id || github.run_id }}
run: |
python3 scripts/report-security-issue.py trivy-results.json osv-results.json
+44
View File
@@ -0,0 +1,44 @@
name: TruffleHog Secret Scan
on:
push:
pull_request:
schedule:
- cron: "0 5 * * 1"
workflow_dispatch:
jobs:
trufflehog-scan:
name: TruffleHog
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Install TruffleHog
run: |
curl -fsSL -o trufflehog.tar.gz \
"https://github.com/trufflesecurity/trufflehog/releases/download/v3.97.4/trufflehog_3.97.4_linux_amd64.tar.gz"
tar -xzf trufflehog.tar.gz trufflehog
chmod +x trufflehog
sudo mv trufflehog /usr/local/bin/trufflehog
rm trufflehog.tar.gz
- name: Run TruffleHog Scanner
run: |
set +e
trufflehog git file://. --results=verified,unknown --fail --json > trufflehog-results.json
echo "TRUFFLEHOG_EXIT=$?" >> "$GITHUB_ENV"
- name: Ergebnisse & Gitea-Issue erstellen/aktualisieren
env:
GITEA_URL: ${{ gitea.server_url || github.server_url }}
REPO: ${{ gitea.repository || github.repository }}
TOKEN: ${{ secrets.SECURITY_TOKEN }}
RUN_URL: ${{ gitea.server_url || github.server_url }}/${{ gitea.repository || github.repository }}/actions/runs/${{ gitea.run_id || github.run_id }}
ISSUE_TITLE: "Security-Scan: TruffleHog Secrets"
ISSUE_LABEL: "security-scan-trufflehog"
run: |
python3 scripts/report-security-issue.py "" "" trufflehog-results.json
+1
View File
@@ -109,3 +109,4 @@ fabric.properties
# Built Visual Studio Code Extensions # Built Visual Studio Code Extensions
*.vsix *.vsix
.junie/plans
+3
View File
@@ -17,6 +17,9 @@
<Item> <Item>
<option name="path" value=".gitea/workflows/testing.yaml" /> <option name="path" value=".gitea/workflows/testing.yaml" />
</Item> </Item>
<Item>
<option name="path" value=".gitea/workflows/renovate.yaml" />
</Item>
</list> </list>
</option> </option>
</SchemaInfo> </SchemaInfo>
+20
View File
@@ -0,0 +1,20 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run" type="CargoCommandRunConfiguration" factoryName="Cargo Command" nameIsGenerated="true">
<option name="buildProfileId" value="dev" />
<option name="command" value="run" />
<option name="workingDirectory" value="file://$PROJECT_DIR$" />
<envs />
<option name="emulateTerminal" value="true" />
<option name="channel" value="DEFAULT" />
<option name="requiredFeatures" value="true" />
<option name="allFeatures" value="false" />
<option name="withSudo" value="false" />
<option name="buildTarget" value="REMOTE" />
<option name="backtrace" value="SHORT" />
<option name="isRedirectInput" value="false" />
<option name="redirectInputPath" value="" />
<method v="2">
<option name="CARGO.BUILD_TASK_PROVIDER" enabled="true" />
</method>
</configuration>
</component>
+1
View File
@@ -4,6 +4,7 @@
<content url="file://$MODULE_DIR$"> <content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" /> <sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/tests" isTestSource="true" /> <sourceFolder url="file://$MODULE_DIR$/tests" isTestSource="true" />
<excludeFolder url="file://$MODULE_DIR$/.junie/plans" />
<excludeFolder url="file://$MODULE_DIR$/target" /> <excludeFolder url="file://$MODULE_DIR$/target" />
</content> </content>
<orderEntry type="inheritedJdk" /> <orderEntry type="inheritedJdk" />
Generated
+1 -2
View File
@@ -866,7 +866,7 @@ dependencies = [
[[package]] [[package]]
name = "mirror-package" name = "mirror-package"
version = "1.0.1" version = "1.0.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
@@ -874,7 +874,6 @@ dependencies = [
"logger-ctdra", "logger-ctdra",
"reqwest", "reqwest",
"serde", "serde",
"serde_json",
"tokio", "tokio",
] ]
+1 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "mirror-package" name = "mirror-package"
version = "1.0.1" version = "1.0.2"
edition = "2024" edition = "2024"
authors = ['DragonSlayer_14'] authors = ['DragonSlayer_14']
readme = "README.md" readme = "README.md"
@@ -15,7 +15,6 @@ clap = { version = "4.6.6", features = ["derive", "env"] }
reqwest = { version = "0.13.4", features = ["json", "stream"] } reqwest = { version = "0.13.4", features = ["json", "stream"] }
tokio = { version = "1.53.1", features = ["full"] } tokio = { version = "1.53.1", features = ["full"] }
serde = { version = "1.0.229", features = ["derive"] } serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"
anyhow = "1.0.104" anyhow = "1.0.104"
[profile.release] [profile.release]
+16
View File
@@ -0,0 +1,16 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"timezone": "Europe/Berlin",
"schedule": ["before 6am on monday"],
"packageRules": [
{
"matchManagers": ["github-actions"],
"groupName": "Gitea Actions"
},
{
"matchManagers": ["dockerfile", "docker-compose"],
"groupName": "Docker-Images"
}
]
}
+308
View File
@@ -0,0 +1,308 @@
#!/usr/bin/env python3
"""Erstellt oder kommentiert ein Gitea-Issue mit den Ergebnissen der Security-Scans.
Sucht ein offenes Issue mit dem Label ISSUE_LABEL (Standard: "security-scan").
Existiert eines, wird der aktuelle Scan-Stand als neuer Kommentar angehängt
(die Historie bleibt erhalten). Existiert keines (z.B. weil das letzte
geschlossen wurde), wird ein neues Issue erstellt. Gibt es keine Funde mehr,
wird ein offenes Issue nur kommentiert, nicht geschlossen.
Titel und Label lassen sich per Umgebungsvariable ISSUE_TITLE / ISSUE_LABEL
überschreiben, damit z.B. TruffleHog-Funde in ein eigenes Issue laufen statt
in das gemeinsame Trivy/OSV-Issue.
"""
import json
import os
import sys
import urllib.error
import urllib.request
LABEL_NAME = os.environ.get("ISSUE_LABEL", "security-scan")
LABEL_COLOR = "#b60205"
ISSUE_TITLE = os.environ.get("ISSUE_TITLE", "Security-Scan: Offene Schwachstellen")
SEVERITY_ORDER = {
"VERIFIED": -1,
"CRITICAL": 0,
"HIGH": 1,
"MEDIUM": 2,
"LOW": 3,
"UNKNOWN": 4,
"UNVERIFIED": 6,
}
def api(method, path, token, gitea_url, data=None):
url = f"{gitea_url}/api/v1{path}"
body = json.dumps(data).encode() if data is not None else None
req = urllib.request.Request(url, data=body, method=method)
req.add_header("Authorization", f"token {token}")
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req, timeout=10) as resp:
raw = resp.read()
return json.loads(raw) if raw else None
except urllib.error.HTTPError as e:
print(f"Gitea API Fehler ({method} {path}): {e.code} {e.read().decode()}", file=sys.stderr)
raise
def make_finding(source, id, severity, package, installed="-", fixed="-", target="-"):
return {
"source": source,
"id": id,
"severity": severity,
"package": package,
"installed": installed,
"fixed": fixed,
"target": target,
}
def cvss_score_to_severity(score):
try:
score = float(score)
except (TypeError, ValueError):
return "UNKNOWN"
if score >= 9.0:
return "CRITICAL"
if score >= 7.0:
return "HIGH"
if score >= 4.0:
return "MEDIUM"
if score > 0.0:
return "LOW"
return "UNKNOWN"
def load_trivy(path):
findings = []
if not path or not os.path.isfile(path):
return findings
with open(path) as f:
data = json.load(f)
for result in data.get("Results", []) or []:
target = result.get("Target", "?")
for vuln in result.get("Vulnerabilities", []) or []:
findings.append(make_finding(
"Trivy",
vuln.get("VulnerabilityID", "?"),
vuln.get("Severity", "UNKNOWN"),
vuln.get("PkgName", "?"),
installed=vuln.get("InstalledVersion", "?"),
fixed=vuln.get("FixedVersion") or "-",
target=target,
))
for misc in result.get("Misconfigurations", []) or []:
findings.append(make_finding(
"Trivy (Misconfig)",
misc.get("ID", "?"),
misc.get("Severity", "UNKNOWN"),
misc.get("Title", "?"),
target=target,
))
for secret in result.get("Secrets", []) or []:
findings.append(make_finding(
"Trivy (Secret)",
secret.get("RuleID", "?"),
secret.get("Severity", "UNKNOWN"),
secret.get("Title", "?"),
target=target,
))
return findings
def load_osv(path):
findings = []
if not path or not os.path.isfile(path):
return findings
with open(path) as f:
data = json.load(f)
for result in data.get("results", []) or []:
source = (result.get("source") or {}).get("path", "?")
for pkg in result.get("packages", []) or []:
info = pkg.get("package", {})
pkg_name = f"{info.get('name', '?')} ({info.get('ecosystem', '?')})"
severity_by_id = {}
for group in pkg.get("groups", []) or []:
label = cvss_score_to_severity(group.get("max_severity"))
for vuln_id in group.get("ids", []) or []:
severity_by_id[vuln_id] = label
for vuln in pkg.get("vulnerabilities", []) or []:
vuln_id = vuln.get("id", "?")
findings.append(make_finding(
"OSV-Scanner",
vuln_id,
severity_by_id.get(vuln_id, "UNKNOWN"),
pkg_name,
installed=info.get("version", "?"),
target=source,
))
return findings
def load_trufflehog(path):
findings = []
if not path or not os.path.isfile(path):
return findings
with open(path) as f:
for line in f:
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
except json.JSONDecodeError:
continue
git_meta = ((entry.get("SourceMetadata") or {}).get("Data") or {}).get("Git") or {}
findings.append(make_finding(
"TruffleHog",
entry.get("DetectorName", "?"),
"VERIFIED" if entry.get("Verified") else "UNVERIFIED",
git_meta.get("file", "?"),
target=git_meta.get("commit", "-"),
))
return findings
def sort_findings(findings):
return sorted(findings, key=lambda f: (SEVERITY_ORDER.get(f["severity"], 9), f["id"]))
def print_summary(findings):
if not findings:
print("Keine Funde.")
return
widths = {
key: max(len(key), *(len(str(f[key])) for f in findings))
for key in ("source", "id", "severity", "package", "installed", "fixed", "target")
}
header = ("source", "id", "severity", "package", "installed", "fixed", "target")
row_fmt = " ".join(f"{{:{widths[k]}}}" for k in header)
print(row_fmt.format(*header))
print(row_fmt.format(*("-" * widths[k] for k in header)))
for f in findings:
print(row_fmt.format(*(str(f[k]) for k in header)))
TRUFFLEHOG_GUIDANCE = """### Vorgehen bei gefundenen Secrets
1. **Sofort rotieren/widerrufen**: Das betroffene Secret (Token, Passwort, Schlüssel) beim jeweiligen Dienst ungültig machen und durch ein neues ersetzen. Ein einmal committetes Secret gilt als kompromittiert, auch wenn es später aus der Historie entfernt wird.
2. **Ursache beheben**: Neues Secret nur noch über Umgebungsvariablen/Secrets-Store einbinden, nicht erneut hart codieren.
3. **Historie bereinigen (optional, manuell, erst nach Schritt 1)**: Mit `git filter-repo` oder BFG Repo-Cleaner den Commit-Inhalt entfernen, danach `git push --force` in Absprache mit allen Mitwirkenden bestehende Clones/Forks werden dadurch ungültig.
4. **Issue schließen**, sobald rotiert wurde. TruffleHog findet das alte Secret ggf. weiterhin in der Historie nach der Rotation ist das unkritisch.
> Der Wert des Secrets selbst wird hier bewusst nicht ausgegeben, auch nicht gekürzt nur Detector, Datei und Commit. Fund lässt sich über "Ziel" (Commit-Hash) und "Paket" (Dateipfad) lokalisieren."""
def escape_md_cell(value):
return str(value).replace("|", "\\|").replace("\r", " ").replace("\n", " ")
def build_report(findings, run_url):
lines = [
"Automatisch erstellt vom Security-Scan-Workflow.",
f"Lauf: {run_url}" if run_url else "",
"",
"| Quelle | ID | Schweregrad | Paket | Installiert | Fix | Ziel |",
"|---|---|---|---|---|---|---|",
]
for f in findings:
cells = (f["source"], f["id"], f["severity"], f["package"], f["installed"], f["fixed"], f["target"])
lines.append("| " + " | ".join(escape_md_cell(c) for c in cells) + " |")
if any(f["source"] == "TruffleHog" for f in findings):
lines.append("")
lines.append(TRUFFLEHOG_GUIDANCE)
return "\n".join(lines)
def ensure_label(token, gitea_url, repo):
page = 1
while True:
labels = api("GET", f"/repos/{repo}/labels?limit=50&page={page}", token, gitea_url) or []
for label in labels:
if label.get("name") == LABEL_NAME:
return label["id"]
if len(labels) < 50:
break
page += 1
created = api("POST", f"/repos/{repo}/labels", token, gitea_url, {
"name": LABEL_NAME,
"color": LABEL_COLOR,
"description": "Automatisch verwaltet vom Security-Scan-Workflow",
})
return created["id"]
def find_open_issue(token, gitea_url, repo):
issues = api(
"GET",
f"/repos/{repo}/issues?state=open&type=issues&labels={LABEL_NAME}",
token,
gitea_url,
) or []
for issue in issues:
if issue.get("title") == ISSUE_TITLE:
return issue
return None
def main():
trivy_path = sys.argv[1] if len(sys.argv) > 1 else None
osv_path = sys.argv[2] if len(sys.argv) > 2 else None
trufflehog_path = sys.argv[3] if len(sys.argv) > 3 else None
gitea_url = os.environ["GITEA_URL"].rstrip("/")
repo = os.environ["REPO"]
token = os.environ["TOKEN"]
run_url = os.environ.get("RUN_URL", "")
osv_exit = int(os.environ.get("OSV_EXIT", "0"))
trufflehog_exit = int(os.environ.get("TRUFFLEHOG_EXIT", "0"))
findings = sort_findings(load_trivy(trivy_path) + load_osv(osv_path) + load_trufflehog(trufflehog_path))
print_summary(findings)
open_issue = find_open_issue(token, gitea_url, repo)
if findings:
report = build_report(findings, run_url)
if open_issue:
print(f"Kommentiere bestehendes Issue #{open_issue['number']} mit {len(findings)} Fund(en).")
api("POST", f"/repos/{repo}/issues/{open_issue['number']}/comments", token, gitea_url, {"body": report})
else:
label_id = ensure_label(token, gitea_url, repo)
print(f"Erstelle neues Issue mit {len(findings)} Fund(en).")
api("POST", f"/repos/{repo}/issues", token, gitea_url, {
"title": ISSUE_TITLE,
"body": report,
"labels": [label_id],
})
elif open_issue:
print(f"Keine aktuellen Funde mehr. Kommentiere Issue #{open_issue['number']}.")
api("POST", f"/repos/{repo}/issues/{open_issue['number']}/comments", token, gitea_url, {
"body": f"Aktueller Scan hat keine offenen Schwachstellen mehr gefunden.\n\n{run_url}".strip(),
})
else:
print("Keine Funde und kein offenes Issue vorhanden.")
osv_ok_exits = {0, 1}
trufflehog_ok_exits = {0, 183}
if osv_exit not in osv_ok_exits:
print(f"WARNUNG: osv-scanner beendete sich mit unerwartetem Exit-Code {osv_exit} - Scan evtl. unvollständig.", file=sys.stderr)
if trufflehog_exit not in trufflehog_ok_exits:
print(f"WARNUNG: trufflehog beendete sich mit unerwartetem Exit-Code {trufflehog_exit} - Scan evtl. unvollständig.", file=sys.stderr)
has_trivy_findings = any(f["source"].startswith("Trivy") for f in findings)
if (
has_trivy_findings
or osv_exit == 1
or trufflehog_exit == 183
or osv_exit not in osv_ok_exits
or trufflehog_exit not in trufflehog_ok_exits
):
sys.exit(1)
if __name__ == "__main__":
main()
+44 -16
View File
@@ -1,3 +1,4 @@
use crate::utils::{clean_repo_input, sanitize_string};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::PathBuf; use std::path::PathBuf;
@@ -21,7 +22,7 @@ pub struct RepoConfig {
impl RepoConfig { impl RepoConfig {
pub fn new(name: impl Into<String>, include_prereleases: bool) -> Self { pub fn new(name: impl Into<String>, include_prereleases: bool) -> Self {
Self { Self {
name: name.into(), name: AppConfig::normalize_repo_name(&name.into()),
include_prereleases, include_prereleases,
last_synced_tag: None, last_synced_tag: None,
} }
@@ -49,21 +50,17 @@ pub struct AppConfig {
} }
impl AppConfig { impl AppConfig {
/// Normalisiert die Repository-Eingabe, indem führende/nachgestellte Schrägstriche und GitHub-URL-Präfixe entfernt werden. /// Normalisiert die Repository-Eingabe, indem führende/nachgestellte Schrägstriche,
/// Query-Parameter, Fragmente und GitHub-URL-Präfixe entfernt werden.
pub fn normalize_repo_name(input: &str) -> String { pub fn normalize_repo_name(input: &str) -> String {
let trimmed = input.trim(); clean_repo_input(input).to_string()
let cleaned = trimmed
.trim_start_matches("https://github.com/")
.trim_start_matches("http://github.com/")
.trim_start_matches("github.com/")
.trim_end_matches(".git")
.trim_matches('/');
cleaned.to_string()
} }
/// Fügt ein Repository zur Konfiguration hinzu oder aktualisiert ein bestehendes. /// Fügt ein Repository zur Konfiguration hinzu oder aktualisiert ein bestehendes.
pub fn add_or_update_repo(&mut self, repo: RepoConfig) { pub fn add_or_update_repo(&mut self, mut repo: RepoConfig) {
if let Some(existing) = self.repositories.iter_mut().find(|r| r.name.eq_ignore_ascii_case(&repo.name)) { repo.name = Self::normalize_repo_name(&repo.name);
if let Some(existing) = self.repositories.iter_mut().find(|r| Self::normalize_repo_name(&r.name).eq_ignore_ascii_case(&repo.name)) {
existing.name = repo.name;
existing.include_prereleases = repo.include_prereleases; existing.include_prereleases = repo.include_prereleases;
} else { } else {
self.repositories.push(repo); self.repositories.push(repo);
@@ -74,20 +71,20 @@ impl AppConfig {
pub fn remove_repo(&mut self, repo_name: &str) -> bool { pub fn remove_repo(&mut self, repo_name: &str) -> bool {
let normalized = Self::normalize_repo_name(repo_name); let normalized = Self::normalize_repo_name(repo_name);
let before_len = self.repositories.len(); let before_len = self.repositories.len();
self.repositories.retain(|r| !r.name.eq_ignore_ascii_case(&normalized)); self.repositories.retain(|r| !Self::normalize_repo_name(&r.name).eq_ignore_ascii_case(&normalized));
self.repositories.len() < before_len self.repositories.len() < before_len
} }
/// Sucht ein Repository anhand des Namens. /// Sucht ein Repository anhand des Namens.
pub fn find_repo(&self, repo_name: &str) -> Option<&RepoConfig> { pub fn find_repo(&self, repo_name: &str) -> Option<&RepoConfig> {
let normalized = Self::normalize_repo_name(repo_name); let normalized = Self::normalize_repo_name(repo_name);
self.repositories.iter().find(|r| r.name.eq_ignore_ascii_case(&normalized)) self.repositories.iter().find(|r| Self::normalize_repo_name(&r.name).eq_ignore_ascii_case(&normalized))
} }
/// Aktualisiert den zuletzt synchronisierten Tag für ein bestimmtes Repository. /// Aktualisiert den zuletzt synchronisierten Tag für ein bestimmtes Repository.
pub fn update_last_synced_tag(&mut self, repo_name: &str, tag: String) { pub fn update_last_synced_tag(&mut self, repo_name: &str, tag: String) {
let normalized = Self::normalize_repo_name(repo_name); let normalized = Self::normalize_repo_name(repo_name);
if let Some(repo) = self.repositories.iter_mut().find(|r| r.name.eq_ignore_ascii_case(&normalized)) { if let Some(repo) = self.repositories.iter_mut().find(|r| Self::normalize_repo_name(&r.name).eq_ignore_ascii_case(&normalized)) {
repo.last_synced_tag = Some(tag); repo.last_synced_tag = Some(tag);
} }
} }
@@ -108,7 +105,38 @@ pub fn get_config_file_path() -> PathBuf {
/// Lädt die Anwendungskonfiguration. Verwendet Standardwerte, falls keine Datei vorhanden ist. /// Lädt die Anwendungskonfiguration. Verwendet Standardwerte, falls keine Datei vorhanden ist.
pub fn load_config() -> AppConfig { pub fn load_config() -> AppConfig {
config_ctdra::load_config::<AppConfig>() let mut config = config_ctdra::load_config::<AppConfig>();
for repo in &mut config.repositories {
repo.name = AppConfig::normalize_repo_name(&repo.name);
}
if let Ok(val) = std::env::var("GITEA_URL") {
let val = sanitize_string(&val);
if !val.is_empty() {
config.gitea_url = Some(val);
}
}
if let Ok(val) = std::env::var("GITEA_TOKEN") {
let val = sanitize_string(&val);
if !val.is_empty() {
config.gitea_token = Some(val);
}
}
if let Ok(val) = std::env::var("REGISTRY_OWNER") {
let val = sanitize_string(&val);
if !val.is_empty() {
config.registry_owner = Some(val);
}
}
if let Ok(val) = std::env::var("GITHUB_TOKEN") {
let val = sanitize_string(&val);
if !val.is_empty() {
config.github_token = Some(val);
}
}
config
} }
/// Speichert die Anwendungskonfiguration auf der Festplatte. /// Speichert die Anwendungskonfiguration auf der Festplatte.
+9 -8
View File
@@ -1,4 +1,4 @@
use anyhow::{bail, Context, Result}; use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// Unterstützte Linux-Paketverteilungstypen. /// Unterstützte Linux-Paketverteilungstypen.
@@ -87,6 +87,7 @@ pub struct GitHubClient {
impl GitHubClient { impl GitHubClient {
/// Erstellt einen neuen GitHub-API-Client mit optionalem Authentifizierungstoken. /// Erstellt einen neuen GitHub-API-Client mit optionalem Authentifizierungstoken.
pub fn new(token: Option<String>) -> Result<Self> { pub fn new(token: Option<String>) -> Result<Self> {
let token = token.filter(|t| !t.trim().is_empty());
let mut headers = reqwest::header::HeaderMap::new(); let mut headers = reqwest::header::HeaderMap::new();
headers.insert( headers.insert(
reqwest::header::USER_AGENT, reqwest::header::USER_AGENT,
@@ -105,6 +106,12 @@ impl GitHubClient {
Ok(Self { client, token }) Ok(Self { client, token })
} }
/// Gibt das optionale Authentifizierungstoken zurück (falls gesetzt).
pub fn token(&self) -> Option<&str> {
self.token.as_deref()
}
/// Ruft Releases für ein angegebenes Repository ab (Format "owner/repo"). /// Ruft Releases für ein angegebenes Repository ab (Format "owner/repo").
/// ///
/// Wenn `history` false ist, werden nur die neuesten Releases abgerufen. /// Wenn `history` false ist, werden nur die neuesten Releases abgerufen.
@@ -239,13 +246,7 @@ impl GitHubClient {
/// Hilfsfunktion zum Parsen von "owner/repo" aus einer Repository-Zeichenkette. /// Hilfsfunktion zum Parsen von "owner/repo" aus einer Repository-Zeichenkette.
pub fn parse_repo_owner_name(repo: &str) -> Result<(&str, &str)> { pub fn parse_repo_owner_name(repo: &str) -> Result<(&str, &str)> {
let cleaned = repo let cleaned = crate::utils::clean_repo_input(repo);
.trim()
.trim_start_matches("https://github.com/")
.trim_start_matches("http://github.com/")
.trim_start_matches("github.com/")
.trim_end_matches(".git")
.trim_matches('/');
let parts: Vec<&str> = cleaned.split('/').collect(); let parts: Vec<&str> = cleaned.split('/').collect();
if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() { if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
+1
View File
@@ -6,3 +6,4 @@ pub mod config;
pub mod gitea; pub mod gitea;
pub mod github; pub mod github;
pub mod pipeline; pub mod pipeline;
pub mod utils;
+64
View File
@@ -0,0 +1,64 @@
/// Sanitizes a string by removing surrounding quotes (single or double).
///
/// # Arguments
///
/// * `input` - The string to sanitize.
///
/// # Returns
///
/// The sanitized string with surrounding quotes removed.
pub fn sanitize_string(input: &str) -> String {
let trimmed = input.trim();
if let Some(stripped) = trimmed.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
stripped.to_string()
} else if let Some(stripped) = trimmed.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')) {
stripped.to_string()
} else {
trimmed.to_string()
}
}
/// Sanitizes environment variables by removing surrounding quotes.
///
/// # Arguments
///
/// * `env_vars` - A reference to a mutable map of environment variables.
pub fn sanitize_env_vars(env_vars: &mut std::collections::HashMap<String, String>) {
for (_key, value) in env_vars.iter_mut() {
*value = sanitize_string(value);
}
}
/// Bereinigt eine Repository-Eingabe (URL oder Kurzform), indem Query-Parameter, Fragmente,
/// URL-Schemata/Hosts (`github.com`, `www.github.com`, `git@github.com:`), `.git`-Endungen
/// und umgebende Schrägstriche entfernt werden.
pub fn clean_repo_input(input: &str) -> &str {
let mut trimmed = input.trim();
if (trimmed.starts_with('"') && trimmed.ends_with('"'))
|| (trimmed.starts_with('\'') && trimmed.ends_with('\''))
{
if trimmed.len() >= 2 {
trimmed = trimmed[1..trimmed.len() - 1].trim();
}
}
let without_query_or_fragment = match trimmed.find(|c| c == '?' || c == '#') {
Some(idx) => &trimmed[..idx],
None => trimmed,
};
let mut cleaned = without_query_or_fragment
.trim_start_matches("git@github.com:")
.trim_start_matches("https://")
.trim_start_matches("http://")
.trim_start_matches("www.github.com/")
.trim_start_matches("github.com/")
.trim_matches('/');
if let Some(stripped) = cleaned.strip_suffix(".git") {
cleaned = stripped.trim_matches('/');
}
cleaned
}
+148 -38
View File
@@ -1,53 +1,163 @@
use mirror_package::config::{AppConfig, RepoConfig}; use mirror_package::config::{AppConfig, RepoConfig, load_config};
use mirror_package::utils::{clean_repo_input, sanitize_env_vars, sanitize_string};
use std::collections::HashMap;
#[test]
fn test_sanitize_env_vars() {
let mut env_vars = HashMap::new();
env_vars.insert("GITEA_URL".to_string(), "\"https://gitea.example.com\"".to_string());
env_vars.insert("GITEA_TOKEN".to_string(), "\"token123\"".to_string());
env_vars.insert("REGISTRY_OWNER".to_string(), "\"owner\"".to_string());
env_vars.insert("GITHUB_TOKEN".to_string(), "\"github_token123\"".to_string());
sanitize_env_vars(&mut env_vars);
assert_eq!(env_vars.get("GITEA_URL").unwrap(), "https://gitea.example.com");
assert_eq!(env_vars.get("GITEA_TOKEN").unwrap(), "token123");
assert_eq!(env_vars.get("REGISTRY_OWNER").unwrap(), "owner");
assert_eq!(env_vars.get("GITHUB_TOKEN").unwrap(), "github_token123");
}
#[test]
fn test_load_config_with_env_vars() {
// This test relies on environment variables being set externally.
// To avoid `unsafe` blocks, we skip setting them programmatically.
// In a real test environment, set these variables before running the test:
// GITEA_URL="https://gitea.example.com"
// GITEA_TOKEN="token123"
// REGISTRY_OWNER="owner"
// GITHUB_TOKEN="github_token123"
let config = load_config();
// Only assert if the environment variables are set
if std::env::var("GITEA_URL").is_ok() {
assert_eq!(config.gitea_url, Some("https://gitea.example.com".to_string()));
assert_eq!(config.gitea_token, Some("token123".to_string()));
assert_eq!(config.registry_owner, Some("owner".to_string()));
assert_eq!(config.github_token, Some("github_token123".to_string()));
}
}
#[test]
fn test_sanitize_string() {
assert_eq!(sanitize_string("\"https://gitea.example.com\""), "https://gitea.example.com");
assert_eq!(sanitize_string("'https://gitea.example.com'"), "https://gitea.example.com");
assert_eq!(sanitize_string("\"token123\""), "token123");
assert_eq!(sanitize_string("'token123'"), "token123");
assert_eq!(sanitize_string("\"owner\""), "owner");
assert_eq!(sanitize_string("'owner'"), "owner");
assert_eq!(sanitize_string("\"github_token123\""), "github_token123");
assert_eq!(sanitize_string("'github_token123'"), "github_token123");
assert_eq!(sanitize_string("no_quotes"), "no_quotes");
assert_eq!(sanitize_string("\"single_quote\""), "single_quote");
assert_eq!(sanitize_string("'single_quote'"), "single_quote");
assert_eq!(sanitize_string("\"escaped_quote\""), "escaped_quote");
// Edge cases: single character inputs
assert_eq!(sanitize_string("\""), "\"");
assert_eq!(sanitize_string("'"), "'");
assert_eq!(sanitize_string("a"), "a");
// Edge cases: empty quotes and empty strings
assert_eq!(sanitize_string("\"\""), "");
assert_eq!(sanitize_string("''"), "");
assert_eq!(sanitize_string(""), "");
assert_eq!(sanitize_string(" "), "");
assert_eq!(sanitize_string(" 'hello' "), "hello");
assert_eq!(sanitize_string(" \"world\" "), "world");
}
#[test]
fn test_clean_repo_input() {
assert_eq!(clean_repo_input("owner/repo"), "owner/repo");
assert_eq!(clean_repo_input("https://github.com/owner/repo"), "owner/repo");
assert_eq!(clean_repo_input("http://github.com/owner/repo"), "owner/repo");
assert_eq!(clean_repo_input("github.com/owner/repo"), "owner/repo");
assert_eq!(clean_repo_input("https://github.com/owner/repo.git"), "owner/repo");
assert_eq!(clean_repo_input("https://github.com/owner/repo/"), "owner/repo");
assert_eq!(
clean_repo_input("https://github.com/raspberrypi/rpi-imager?tab=readme-ov-file"),
"raspberrypi/rpi-imager"
);
assert_eq!(
clean_repo_input("https://github.com/raspberrypi/rpi-imager/?tab=readme-ov-file"),
"raspberrypi/rpi-imager"
);
assert_eq!(
clean_repo_input("https://github.com/raspberrypi/rpi-imager.git?tab=readme-ov-file"),
"raspberrypi/rpi-imager"
);
assert_eq!(
clean_repo_input("https://github.com/raspberrypi/rpi-imager#readme"),
"raspberrypi/rpi-imager"
);
assert_eq!(
clean_repo_input("https://github.com/raspberrypi/rpi-imager?tab=readme-ov-file#install"),
"raspberrypi/rpi-imager"
);
assert_eq!(
clean_repo_input("raspberrypi/rpi-imager?tab=readme-ov-file"),
"raspberrypi/rpi-imager"
);
assert_eq!(
clean_repo_input("\"https://github.com/raspberrypi/rpi-imager?tab=readme-ov-file\""),
"raspberrypi/rpi-imager"
);
}
#[test] #[test]
fn test_normalize_repo_name() { fn test_normalize_repo_name() {
let input = "https://github.com/owner/repo.git";
let normalized = AppConfig::normalize_repo_name(input);
assert_eq!(normalized, "owner/repo");
let input_with_query = "https://github.com/raspberrypi/rpi-imager?tab=readme-ov-file";
assert_eq!( assert_eq!(
AppConfig::normalize_repo_name("raspberrypi/rpi-imager"), AppConfig::normalize_repo_name(input_with_query),
"raspberrypi/rpi-imager" "raspberrypi/rpi-imager"
); );
assert_eq!(
AppConfig::normalize_repo_name("https://github.com/raspberrypi/rpi-imager"),
"raspberrypi/rpi-imager"
);
assert_eq!(
AppConfig::normalize_repo_name("https://github.com/raspberrypi/rpi-imager.git"),
"raspberrypi/rpi-imager"
);
assert_eq!(
AppConfig::normalize_repo_name("Heroic-Games-Launcher/HeroicGamesLauncher/"),
"Heroic-Games-Launcher/HeroicGamesLauncher"
);
} }
#[test] #[test]
fn test_repo_management() { fn test_add_or_update_repo() {
let mut config = AppConfig::default(); let mut config = AppConfig::default();
config.add_or_update_repo(RepoConfig::new("owner/repo1", true)); let repo = RepoConfig::new("https://github.com/owner/repo?tab=readme-ov-file", true);
config.add_or_update_repo(RepoConfig::new("owner/repo2", false)); config.add_or_update_repo(repo.clone());
assert_eq!(config.repositories.len(), 2);
assert!(config.find_repo("owner/repo1").is_some());
assert!(config.find_repo("https://github.com/owner/repo1").is_some());
assert!(config.remove_repo("owner/repo1"));
assert_eq!(config.repositories.len(), 1); assert_eq!(config.repositories.len(), 1);
assert!(config.find_repo("owner/repo1").is_none()); assert_eq!(config.repositories[0].name, "owner/repo");
assert_eq!(config.repositories[0].include_prereleases, true);
// Updating existing repo with a URL with query param
let repo_updated = RepoConfig::new("https://github.com/owner/repo#readme", false);
config.add_or_update_repo(repo_updated);
assert_eq!(config.repositories.len(), 1);
assert_eq!(config.repositories[0].name, "owner/repo");
assert_eq!(config.repositories[0].include_prereleases, false);
} }
#[test] #[test]
fn test_serialization_roundtrip() { fn test_remove_repo() {
let mut config = AppConfig { let mut config = AppConfig::default();
gitea_url: Some("https://gitea.example.com".to_string()), let repo = RepoConfig::new("owner/repo", true);
gitea_token: Some("secret_token".to_string()), config.add_or_update_repo(repo.clone());
registry_owner: Some("my-org".to_string()), assert!(config.remove_repo("https://github.com/owner/repo?tab=readme-ov-file"));
github_token: Some("gh_pat".to_string()), assert!(config.repositories.is_empty());
repositories: Vec::new(),
};
config.add_or_update_repo(RepoConfig::new("raspberrypi/rpi-imager", true));
let json_str = serde_json::to_string(&config).unwrap();
let deserialized: AppConfig = serde_json::from_str(&json_str).unwrap();
assert_eq!(config, deserialized);
} }
#[test]
fn test_find_repo() {
let mut config = AppConfig::default();
let repo = RepoConfig::new("owner/repo", true);
config.add_or_update_repo(repo.clone());
assert_eq!(
config.find_repo("https://github.com/owner/repo?tab=readme-ov-file"),
Some(&RepoConfig::new("owner/repo", true))
);
}
#[test]
fn test_update_last_synced_tag() {
let mut config = AppConfig::default();
let repo = RepoConfig::new("owner/repo", true);
config.add_or_update_repo(repo.clone());
config.update_last_synced_tag("owner/repo", "v1.0.0".to_string());
assert_eq!(config.find_repo("owner/repo").unwrap().last_synced_tag, Some("v1.0.0".to_string()));
}
+33 -1
View File
@@ -1,4 +1,4 @@
use mirror_package::github::{parse_repo_owner_name, PackageType}; use mirror_package::github::{PackageType, parse_repo_owner_name};
#[test] #[test]
fn test_package_classification() { fn test_package_classification() {
@@ -64,7 +64,39 @@ fn test_parse_repo_owner_name() {
parse_repo_owner_name("https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher.git").unwrap(), parse_repo_owner_name("https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher.git").unwrap(),
("Heroic-Games-Launcher", "HeroicGamesLauncher") ("Heroic-Games-Launcher", "HeroicGamesLauncher")
); );
assert_eq!(
parse_repo_owner_name("https://github.com/raspberrypi/rpi-imager?tab=readme-ov-file").unwrap(),
("raspberrypi", "rpi-imager")
);
assert_eq!(
parse_repo_owner_name("https://github.com/raspberrypi/rpi-imager/?tab=readme-ov-file#install").unwrap(),
("raspberrypi", "rpi-imager")
);
assert_eq!(
parse_repo_owner_name("raspberrypi/rpi-imager?tab=readme-ov-file").unwrap(),
("raspberrypi", "rpi-imager")
);
assert!(parse_repo_owner_name("invalid_repo").is_err()); assert!(parse_repo_owner_name("invalid_repo").is_err());
assert!(parse_repo_owner_name("invalid/repo/extra").is_err()); assert!(parse_repo_owner_name("invalid/repo/extra").is_err());
} }
#[tokio::test]
async fn test_github_client_empty_token() {
use mirror_package::github::GitHubClient;
// Test that a client with an empty or whitespace token sanitizes it to None
let client = GitHubClient::new(Some("".to_string())).expect("Failed to create client with empty token");
assert_eq!(client.token(), None);
let client_whitespace = GitHubClient::new(Some(" ".to_string())).expect("Failed to create client with whitespace token");
assert_eq!(client_whitespace.token(), None);
// Test with a valid token
let client_with_token = GitHubClient::new(Some("valid_token".to_string())).expect("Failed to create client with valid token");
assert_eq!(client_with_token.token(), Some("valid_token"));
// Test with no token
let client_no_token = GitHubClient::new(None).expect("Failed to create client with no token");
assert_eq!(client_no_token.token(), None);
}