diff --git a/.github/actions/setup-python/action.yml b/.github/actions/setup-python/action.yml new file mode 100644 index 0000000..ec1a995 --- /dev/null +++ b/.github/actions/setup-python/action.yml @@ -0,0 +1,27 @@ +--- +name: Setup Python +description: | + Setup all dependencies for running Python +inputs: + working-directory: + description: | + The working directory to install dependencies in + required: true + extensions-package-registry-password: + description: | + The password for the extensions package registry + required: true +runs: + using: composite + steps: + - name: Install poetry + run: pipx install poetry==1.8.4 + shell: bash + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: "poetry" + cache-dependency-path: "${{ inputs.working-directory }}/poetry.lock" + - run: poetry install + shell: bash + working-directory: ${{ inputs.working-directory }} diff --git a/.github/actions/setup-trivy/action.yml b/.github/actions/setup-trivy/action.yml new file mode 100644 index 0000000..2867d61 --- /dev/null +++ b/.github/actions/setup-trivy/action.yml @@ -0,0 +1,21 @@ +name: Setup Trivy +description: | + Setup Trivy for Docker and configuration scanning +inputs: + working-directory: + description: | + The working directory to use Trivy in + required: false + default: . +runs: + using: composite + steps: + - name: Install Trivy + run: | + sudo apt-get install wget apt-transport-https gnupg + wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor | sudo tee /usr/share/keyrings/trivy.gpg > /dev/null + echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" | sudo tee -a /etc/apt/sources.list.d/trivy.list + sudo apt-get update + sudo apt-get install trivy + shell: bash + working-directory: ${{ inputs.working-directory }} diff --git a/.github/workflows/security-scan.yaml b/.github/workflows/security-scan.yaml new file mode 100644 index 0000000..6f7ef95 --- /dev/null +++ b/.github/workflows/security-scan.yaml @@ -0,0 +1,308 @@ +# .github/workflows/security-scan.yaml +name: Security Scan + +on: + workflow_call: + inputs: + scan-tool: + description: 'Tool das für den Scan verwendet werden soll, aktuell verfügbar: "checkov", "trivy"' + required: true + default: 'trivy' + type: string + scan-type: + description: 'Art des Scans, aktuell nur für Trivy verfügbar: "image", "filesystem" (default), "config"' + required: false + default: 'filesystem' + type: string + trivyignorefile: + description: 'Pfad zur Trivy Ignore-Datei' + default: '' + required: false + type: string + checkovbaseline: + description: 'Pfad zur Checkov Baseline-Datei' + default: '' + required: false + type: string + path: + description: 'Pfad in dem der Scan ausgeführt werden soll (bei image-scans muss der Ordner die Dockerfile enthalten)' + required: false + default: '.' + type: string + use-test-reporter: + description: 'Ob die Testergebnisse als Report angehängt werden sollen' + required: false + default: 'true' + type: boolean + issue-on-findings: + description: 'An welchen Github User bei gefailelten Scans ein Github Issue erstellt werden soll (Komma getrennte Liste: ''@user1'', ''@user2''). Wenn leer, wird kein Issue erstellt.' + required: false + default: 'false' + type: string + secrets: + GITHUB_TOKEN: + description: 'GitHub Token zum Download der Trivy-DB' + required: false + DOCKER_IMAGE_SECRETS: + description: 'Docker Image Build Secrets' + required: false + +jobs: + + trivy_configuration_scan: + if: ${{ inputs.scan-tool == 'trivy' && inputs.scan-type == 'config' }} + outputs: + NOTIFICATION: ${{ steps.scan.outcome == 'failure' && 'true' || 'false' }} + runs-on: ubuntu-latest + + steps: + - name: checkout repository + uses: actions/checkout@v4 + + - name: setup trivy + uses: ./.github/actions/setup-trivy + + - name: scan configuration for full report + if: always() + run: | + trivy config ${{ inputs.path }} --exit-code 0 + + - name: scan configuration for new medium, high, critical and unknown severities + if: always() + env: + REPORT: ${{ inputs.use-test-reporter == true && '-f json >> security-scanning/trivy.json' || '' }} + IGNOREFILE: ${{ inputs.trivyignorefile != '' && '--ignorefile ' + inputs.trivyignorefile || '' }} + id: scan + run: | + trivy config ${{ inputs.path }} \ + --exit-code 1 \ + --skip-db-update \ + --severity HIGH,CRITICAL,UNKNOWN \ + --scanners vuln \ + --timeout 10m \ + $IGNOREFILE \ + $REPORT + + - name: convert Trivy report to CTRF format + if: always() && ${{ inputs.use-test-reporter }} + run: | + python3 security-scanning/trivyconfig2ctrf.py ./security-scanning/trivy.json ./security-scanning/trivy.ctrf.json + + - name: Publish Test Report + if: always() && ${{ inputs.use-test-reporter }} + uses: ctrf-io/github-test-reporter@v1 + with: + report-path: './security-scanning/trivy.ctrf.json' + template-path: './security-scanning/config_scan_template.hbs' + custom-report: true + + checkov_scan: + if: ${{ inputs.scan-tool == 'checkov' }} + outputs: + NOTIFICATION: ${{ steps.scan.outcome == 'failure' && 'true' || 'false' }} + runs-on: ubuntu-latest + + steps: + - name: checkout repository + uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: setup Checkov + run: | + pip install checkov + + - name: run Checkov + env: + BASELINE: ${{ inputs.checkovbaseline != '' && '--baseline ' + inputs.checkovbaseline || '' }} + run: | + checkov \ + --directory ${{ inputs.path }} \ + --output json \ + $BASELINE \ + --soft-fail-on LOW > ./security-scanning/checkov.json + + - name: convert Checkov report to CTRF format + if: always() && ${{ inputs.use-test-reporter }} + run: | + echo "erstelle datei" > ./security-scanning/checkov.ctrf.json + python3 security-scanning/checkov2ctrf.py ./security-scanning/checkov.json ./security-scanning/checkov.ctrf.json + + - name: Publish Test Report + if: always() && ${{ inputs.use-test-reporter }} + uses: ctrf-io/github-test-reporter@v1 + with: + report-path: './security-scanning/checkov.ctrf.json' + template-path: './security-scanning/config_scan_template.hbs' + custom-report: true + + filesystem_scan: + if: ${{ inputs.scan-tool == 'trivy' && (inputs.scan-type == 'filesystem' || inputs.scan-type == '') }} + outputs: + NOTIFICATION: ${{ steps.scan.outcome == 'failure' && 'true' || 'false' }} + runs-on: ubuntu-latest + + steps: + - name: checkout repository + uses: actions/checkout@v4 + + - name: setup trivy + uses: ./.github/actions/setup-trivy + + - name: download vulnerabilities database from aws + uses: nick-fields/retry@v3 + with: + timeout_minutes: 5 + max_attempts: 3 + retry_wait_seconds: 60 + command: GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }} trivy fs --download-db-only --db-repository "${{ env.TRIVY_DB_REPOSITORY }}" + + - name: download vulnerabilities database if aws failed + if: failure() + uses: nick-fields/retry@v3 + with: + timeout_minutes: 5 + max_attempts: 3 + retry_wait_seconds: 60 + command: GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }} trivy fs --download-db-only + + - name: scan filesystem for full report + if: always() + run: | + trivy fs ${{ inputs.path }} --exit-code 0 --skip-db-update + + # ob mit report geht weiß ich noch nicht + - name: scan filesystem for new medium, high, critical and unknown severities with report and ignorefile + if: always() + id: scan + env: + REPORT: ${{ inputs.use-test-reporter == true && '-f json > ./security-scanning/trivy.json' || '' }} + IGNOREFILE: ${{ inputs.trivyignorefile != '' && '--ignorefile ' + inputs.trivyignorefile || '' }} + run: | + trivy fs ${{ inputs.path }} \ + --exit-code 1 \ + --skip-db-update \ + --severity MEDIUM,HIGH,CRITICAL,UNKNOWN \ + $IGNOREFILE \ + $REPORT + + - name: Publish Test Report + if: always() && ${{ inputs.use-test-reporter }} + uses: ctrf-io/github-test-reporter@v1 + with: + report-path: './security-scanning/trivy.ctrf.json' + template-path: './security-scanning/config_scan_template.hbs' + custom-report: true + + image_scan: + if: ${{ inputs.scan-tool == 'trivy' && inputs.scan-type == 'image' }} + outputs: + NOTIFICATION: ${{ steps.scan.outcome == 'failure' && 'true' || 'false' }} + runs-on: ubuntu-latest + + steps: + - name: checkout repository + uses: actions/checkout@v4 + + - name: setup trivy + uses: ./.github/actions/setup-trivy + + - name: download vulnerabilities database from aws + uses: nick-fields/retry@v3 + with: + timeout_minutes: 5 + max_attempts: 3 + retry_wait_seconds: 60 + command: GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }} trivy image --download-db-only + + - name: download vulnerabilities database if aws failed + if: failure() + uses: nick-fields/retry@v3 + with: + timeout_minutes: 5 + max_attempts: 3 + retry_wait_seconds: 60 + command: GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }} trivy image --download-db-only + + - name: build docker image + uses: docker/build-push-action@v4 + with: + context: ${{ inputs.path }} + push: false + tags: security-scan-image + secrets: ${{ secrets.DOCKER_IMAGE_SECRETS }} + + - name: scan image for full report + run: | + trivy image security-scan-image --exit-code 0 --skip-db-update --scanners vuln --timeout 10m --list-all-pkgs + + - name: scan docker image for high, critical and unknown severities + if: always() + env: + REPORT: ${{ inputs.use-test-reporter == true && '-f json >> security-scanning/trivy.json' || '' }} + IGNOREFILE: ${{ inputs.trivyignorefile != '' && '--ignorefile ' + inputs.trivyignorefile || '' }} + id: scan + run: | + trivy image security-scan-image \ + --exit-code 1 \ + --skip-db-update \ + --severity HIGH,CRITICAL,UNKNOWN \ + --scanners vuln \ + --timeout 10m \ + $IGNOREFILE \ + $REPORT + + - name: convert Trivy report to CTRF format + if: always() && ${{ inputs.use-test-reporter }} + run: | + python3 security-scanning/trivyimage2ctrf.py ./security-scanning/trivy.json ./security-scanning/trivy.ctrf.json + + - name: Publish Test Report + uses: ctrf-io/github-test-reporter@v1 + if: always() && ${{ inputs.use-test-reporter }} + with: + report-path: './security-scanning/trivy.ctrf.json' + template-path: './security-scanning/image_scan_template.hbs' + custom-report: true + + create_issues: + needs: [trivy_configuration_scan, checkov_scan, filesystem_scan, image_scan] + runs-on: ubuntu-latest + if: ${{ inputs.Issue-on-findings != 'false' }} + + steps: + - name: Create issue/Comment on issue + if: ${{ always() && (needs.image_scan.outputs.NOTIFICATION == 'true' || needs.trivy_configuration_scan.outputs.NOTIFICATION == 'true' || needs.filesystem_scan.outputs.NOTIFICATION == 'true') }} + uses: actions/github-script@v7 + with: + script: | + const repo = context.repo.repo; + const owner = context.repo.owner; + const issue_title = 'Security scan failed'; + const issue_body = 'One or more security scans failed. Please check the workflow run for more information: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}\nPlease check if the vulnerabilities are fixable. If there is a fix: Create a ticket for the fix or resolve it.\n' + const assignees = [${{ inputs.issue-on-findings }}]; + const existing_issue = await github.rest.issues.listForRepo({ + owner, + repo, + state: 'open', + labels: 'security-scan-failure' + }); + if (existing_issue.data.length === 0) { + await github.rest.issues.create({ + owner, + repo, + title: issue_title, + body: issue_body, + labels: ['security-scan-failure'] + }); + } else { + const issue_number = existing_issue.data[0].number; + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body: issue_body + }); + } diff --git a/security-scanning/checkov2ctrf.py b/security-scanning/checkov2ctrf.py new file mode 100644 index 0000000..b741019 --- /dev/null +++ b/security-scanning/checkov2ctrf.py @@ -0,0 +1,68 @@ +import json +import sys + + +def extract_checks(target, status): + # extracts checks of a check_type (e.g. terraform) with a status ('fail' oder 'pass'). + checks = [] + key = "failed_checks" if status == "fail" else "passed_checks" + for check in target.get("results", {}).get(key, []): + checks.append({ + "name": check.get("check_name"), + "status": "passed" if status == "pass" else "failed", + "duration": 1, + "file": check.get("file_path"), + "lines": check.get("file_line_range"), + "guideline": check.get("guideline") if check.get("guideline") != 'null' else "", + }) + return checks + + +def checkov_to_ctrf(checkov_json): + tests = [] + for target in checkov_json: + tests.extend(extract_checks(target, "fail")) + tests.extend(extract_checks(target, "pass")) + + total = len(tests) + passed = sum(1 for t in tests if t["status"] == "passed") + failed = sum(1 for t in tests if t["status"] == "failed") + pending = 0 + skipped = 0 + other = 0 + start = 0 + stop = 1 + return { + "results": { + "tool": { + "name": "Checkov " + }, + "summary": { + "tests": total, + "passed": passed, + "failed": failed, + "pending": pending, + "skipped": skipped, + "other": other, + "start": start, + "stop": stop + }, + "tests": tests, + "environment": { + "appName": "kamium-deployment", + "buildName": "kamium-deployment", + "buildNumber": "1" + } + } + } + + +if __name__ == "__main__": + if len(sys.argv) != 3: + print("Usage: python checkov2ctrf.py ") + sys.exit(1) + with open(sys.argv[1]) as old_f: + checkov_json = json.load(old_f) + ctrf_json = checkov_to_ctrf(checkov_json) + with open(sys.argv[2], "w") as new_f: + json.dump(ctrf_json, new_f, indent=2) diff --git a/security-scanning/config_scan_template.hbs b/security-scanning/config_scan_template.hbs new file mode 100644 index 0000000..6b5fe16 --- /dev/null +++ b/security-scanning/config_scan_template.hbs @@ -0,0 +1,67 @@ +## Summary of {{ctrf.tool.name}} Security Scan Results + +| **Tests 📝** | **Passed ✅** | **Failed ❌** | +| --- | --- | --- | +| {{ctrf.summary.tests}} | {{ctrf.summary.passed}} | {{ctrf.summary.failed}} | + +{{#if (eq ctrf.summary.passed ctrf.summary.tests)}} +### All Tests Passed! 🎉 +{{else}} +## Failed Tests: +{{#each ctrf.tests}} +{{#if (eq status "failed")}} +
+ ❌ {{name}} + + + + + + {{#if lines}} + + + + + {{/if}} + {{#if severity}} + + + + + {{/if}} + {{#if guideline}} + + + + + {{/if}} + {{#if description}} + + + + + {{/if}} + {{#if resolution}} + + + + + {{/if}} + {{#if references}} + + + + + {{/if}} +
file{{file}}
lines{{lines}}
severity{{severity}}
guideline + {{guideline}} +
description{{description}}
proposed solution{{resolution}}
references + {{#each references}} + {{this}}{{#unless @last}}, {{/unless}} + {{/each}} +
+
+ +{{/if}} +{{/each}} +{{/if}} diff --git a/security-scanning/image_scan_template.hbs b/security-scanning/image_scan_template.hbs new file mode 100644 index 0000000..a57e78e --- /dev/null +++ b/security-scanning/image_scan_template.hbs @@ -0,0 +1,66 @@ +## Summary of {{ctrf.tool.name}} Security Scan Results + +{{#if (eq ctrf.summary.passed ctrf.summary.tests)}} +### No vulnerabilities with set severity found! 🎉 +{{else}} +## Found Vulnerabilities: +{{#each ctrf.tests}} +
+ ❌ {{id}} {{pkgName}} + + + + + + {{#if installedVersion}} + + + + + {{/if}} + {{#if fixedVersion}} + + + + + {{/if}} + {{#if status}} + + + + + {{/if}} + {{#if severity}} + + + + + {{/if}} + {{#if description}} + + + + + {{/if}} + {{#if source}} + + + + + {{/if}} + {{#if references}} + + + + + {{/if}} +
image{{image}}
installed version{{installedVersion}}
fixed version{{fixedVersion}}
status{{status}}
severity{{severity}}
description{{description}}
source + {{source.URL}} +
references + {{#each references}} + {{this}}{{#unless @last}}, {{/unless}} + {{/each}} +
+
+{{/each}} +{{/if}} diff --git a/security-scanning/trivyconfig2crtf.py b/security-scanning/trivyconfig2crtf.py new file mode 100644 index 0000000..81998f9 --- /dev/null +++ b/security-scanning/trivyconfig2crtf.py @@ -0,0 +1,90 @@ +import json +import sys + + +def extract_checks_from_trivy_result(result): + checks = [] + misconfigs = result.get("Misconfigurations", []) + for misconf in misconfigs: + lines = None + cause = misconf.get("CauseMetadata", {}) + if "StartLine" in cause and "EndLine" in cause: + lines = [cause["StartLine"], cause["EndLine"]] + elif "Code" in cause and "Lines" in cause["Code"] and cause["Code"]["Lines"]: + # Fallback: nehme die ersten und letzten Zeilennummern aus Code.Lines + code_lines = cause["Code"]["Lines"] + if isinstance(code_lines, list) and code_lines: + lines = [code_lines[0].get( + "Number"), code_lines[-1].get("Number")] + + checks.append({ + "name": misconf.get("Title", misconf.get("ID", "")), + "status": "failed" if misconf.get("Status") == "FAIL" else "passed", + "duration": 1, + "file": result.get("Target", ""), + "lines": lines, + "guideline": misconf.get("PrimaryURL", ""), + "severity": misconf.get("Severity", ""), + "description": misconf.get("Description", ""), + "message": misconf.get("Message", ""), + "resolution": misconf.get("Resolution", ""), + "references": misconf.get("References", []), + "type": misconf.get("Type", ""), + "id": misconf.get("ID", ""), + }) + return checks + + +def trivy_to_ctrf(trivy_json): + tests = [] + successes_sum = 0 + results = trivy_json.get("Results", []) + for result in results: + tests.extend(extract_checks_from_trivy_result(result)) + + # Successful scans have no misconfigurations + misconf_summary = result.get("MisconfSummary", {}) + successes_sum += misconf_summary.get("Successes", 0) + + total = len(tests) + successes_sum + passed = successes_sum + failed = sum(1 for t in tests if t["status"] == "failed") + pending = 0 + skipped = 0 + other = 0 + start = 0 + stop = 1 + return { + "results": { + "tool": { + "name": "Trivy Configuration" + }, + "summary": { + "tests": total, + "passed": passed, + "failed": failed, + "pending": pending, + "skipped": skipped, + "other": other, + "start": start, + "stop": stop + }, + "tests": tests, + "environment": { + "appName": "kamium-elastic", + "buildName": "kamium-elastic", + "buildNumber": "1" + } + } + } + + +if __name__ == "__main__": + if len(sys.argv) != 3: + print("Usage: python trivy2ctrf.py ") + sys.exit(1) + with open(sys.argv[1]) as old_f: + trivy_json = json.load(old_f) + ctrf_json = trivy_to_ctrf(trivy_json) + with open(sys.argv[2], "w") as new_f: + json.dump(ctrf_json, new_f, indent=2) diff --git a/security-scanning/trivyimage2ctrf.py b/security-scanning/trivyimage2ctrf.py new file mode 100644 index 0000000..38f7699 --- /dev/null +++ b/security-scanning/trivyimage2ctrf.py @@ -0,0 +1,80 @@ +import json +import sys + + +def extract_checks_from_trivy_result(target): + checks = [] + + vulnerabilities = target.get("Vulnerabilities", []) + + for vuln in vulnerabilities: + checks.append({ + "name": vuln.get("PkgID", ""), + "status": vuln.get("Status", "unknown"), + "duration": 1, + "severity": vuln.get("Severity", ""), + "id": vuln.get("VulnerabilityID", ""), + "pkgName": vuln.get("PkgName", ""), + "installedVersion": vuln.get("InstalledVersion", ""), + "fixedVersion": vuln.get("FixedVersion", "no fix available"), + "image": target.get("Target", ""), + "source": vuln.get("DataSource", []), + "description": vuln.get("Description", ""), + "references": vuln.get("References", []) + }) + return checks + + +def trivy_to_ctrf(trivy_json): + tests = [] + successes_sum = 0 + results = trivy_json.get("Results", []) + for result in results: + tests.extend(extract_checks_from_trivy_result(result)) + + # Successful scans have no misconfigurations + misconf_summary = result.get("MisconfSummary", {}) + successes_sum += misconf_summary.get("Successes", 0) + + total = len(tests) + passed = 0 + failed = len(tests) + pending = 0 + skipped = 0 + other = 0 + start = 0 + stop = 1 + return { + "results": { + "tool": { + "name": "Trivy Image" + }, + "summary": { + "tests": total, + "passed": passed, + "failed": failed, + "pending": pending, + "skipped": skipped, + "other": other, + "start": start, + "stop": stop + }, + "tests": tests, + "environment": { + "appName": "kamium-elastic", + "buildName": "kamium-elastic", + "buildNumber": "1" + } + } + } + + +if __name__ == "__main__": + if len(sys.argv) != 3: + print("Usage: python trivy2ctrf.py ") + sys.exit(1) + with open(sys.argv[1]) as old_f: + trivy_json = json.load(old_f) + ctrf_json = trivy_to_ctrf(trivy_json) + with open(sys.argv[2], "w") as new_f: + json.dump(ctrf_json, new_f, indent=2)