From d9aeae38bf09429472d0d10b0be9b056b2e77bec Mon Sep 17 00:00:00 2001 From: Grigoriy Gogin Date: Tue, 28 Jul 2026 18:51:37 +0200 Subject: [PATCH 1/2] ci(security): TruffleHog secret gate and Trivy SCA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the scanning set semgrep.yml started (#1478): secret detection and software-composition analysis, over both the repository and the published images. Rollout state per gate follows the measured baseline rather than a uniform policy. TruffleHog splits in two, because a full-history scan returns 253 findings — all unverified, all triaged as detector false positives: synthetic e2e metric fixtures (178), the loose GitLab token regex firing on prose in docs/ and on identifiers in dbt models, zero-filled UUIDs in test data, CHANGE_ME templates in .env.local.example. - secrets-diff: pull requests only, BLOCKING. Scans just the commits the PR adds, so the baseline is out of range and the gate starts green. - secrets-history: nightly, report-only, every ref, no exclusions, so the 253 stay visible and any rise above them is noticeable. Both pass --results=verified,unknown,unverified,filtered_unverified. The default set hides findings whose live verification fails, so a revoked credential in history yields a green run — on a public repository that is still a leak. --filter-entropy was evaluated as a way to suppress the fixture noise and rejected: at the threshold that clears the baseline (4.0, cutting 253 to 3) a planted AWS access key was suppressed too, since a 20-character key ID sits at that entropy boundary. One matrix entry in trivy-images.yml carries a per-line trufflehog:ignore: the GitLab detector reads that image name as a token. The gate flagged it on this very branch, which is the intended escape hatch — waive the line, keep the detector. Trivy runs three passes. The repository gate blocks on CRITICAL (baseline 0); the repository report covers HIGH/MEDIUM/LOW (baseline 47 misconfigurations, 0 vulnerabilities) without blocking. trivy-images.yml is the one that matters most here: `trivy fs` resolves dependency versions from lock files, and this repository has only Cargo.lock — uv.lock is gitignored and no packages.lock.json exists — so .NET and Python dependencies are invisible to it. Inside the images they are installed and resolvable. First scans found what the repository pass cannot see: 11 CRITICAL and 88 HIGH fixable in one connector image (debian 12.6 base), 2 CRITICAL in the gateway image, 1 CRITICAL in toolbox. Report-only for that reason — blocking on CRITICAL would fail every build today. Raw secret values never reach a log, job summary or artifact: this repository is public, so those are world-readable. Summaries carry detector, commit and path. Scanner images are digest-pinned and actions are SHA-pinned, matching semgrep.yml, so these files add no findings to the rules they run. Signed-off-by: Grigoriy Gogin --- .github/workflows/trivy-images.yml | 168 +++++++++++++++++++++++ .github/workflows/trivy.yml | 170 +++++++++++++++++++++++ .github/workflows/trufflehog.yml | 212 +++++++++++++++++++++++++++++ .gitignore | 10 ++ 4 files changed, 560 insertions(+) create mode 100644 .github/workflows/trivy-images.yml create mode 100644 .github/workflows/trivy.yml create mode 100644 .github/workflows/trufflehog.yml diff --git a/.github/workflows/trivy-images.yml b/.github/workflows/trivy-images.yml new file mode 100644 index 000000000..9c894739f --- /dev/null +++ b/.github/workflows/trivy-images.yml @@ -0,0 +1,168 @@ +name: Trivy Images + +# CVE scan of the published images. This is the only place where three things are visible: +# +# 1. base-image and OS-package CVEs (nothing in the repository describes them); +# 2. .NET dependencies — the five .csproj projects ship no packages.lock.json, so +# `trivy fs` cannot resolve their versions and skips them entirely; +# 3. Python dependencies — same story for the eleven pyproject.toml projects (toolbox, +# jira-enrich and the connectors), which have no lock file either. +# +# Inside an image those dependencies are installed and therefore resolvable, so this workflow +# carries the SCA coverage that trivy.yml structurally cannot. +# +# Report-only: `--exit-code 0`, findings go to "Security -> Code scanning" under a per-image +# category plus a job-summary table. Nothing blocks. Barring a vulnerable image from +# promotion — the policy target — means adding the scan to build-images.yml between the merge +# and publish steps with `--exit-code 1`, which is a change to a much larger workflow and a +# separate step. +# +# Scans `:latest` rather than a build tag, so it reflects what is currently deployable. The +# legacy `insight-analytics-api` and `insight-api-gateway` packages are deliberately absent: +# they are pre-rename names that no current build job publishes. + +on: + schedule: + # Nightly, 04:07 UTC — after the repository-level scans (03:13 / 03:27 / 03:41). + - cron: "7 4 * * *" + workflow_dispatch: + inputs: + tag: + description: "Image tag to scan (default: latest)" + required: false + default: "latest" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + scan: + name: ${{ matrix.image }} + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read # checkout (.trivyignore waivers) + packages: read # pull the image under scan from GHCR + security-events: write # upload SARIF to Code Scanning + strategy: + # One image's CVEs must not hide another's. + fail-fast: false + matrix: + image: + # Rust / C# services + - insight-analytics + - insight-authenticator + - insight-gateway + - insight-identity + - insight-identity-resolution + # Python tooling + - insight-toolbox + - insight-jira-enrich + # Python connectors + - source-active-directory-insight + - source-bitbucket-cloud-insight + - source-github-copilot-insight + - source-github-v2-insight + - source-gitlab-insight + # The waiver below is per-line and must stay on the entry itself: TruffleHog's + # GitLab detector reads that 21-character image name as a token. Waiving the line + # keeps the detector enabled — it is one of the few that could catch a real + # connector credential. + - source-hubspot-insight # trufflehog:ignore + - source-salesforce-insight + steps: + - name: Checkout + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false + + - name: Trivy image scan (CRITICAL/HIGH) + # Trivy pulls straight from the registry (TRIVY_USERNAME/TRIVY_PASSWORD) instead of a + # mounted docker socket, so no local `docker pull` is needed. `--ignore-unfixed` keeps + # the output to what a base-image or dependency bump can actually fix. A multi-arch + # manifest resolves to the runner's platform (linux/amd64); the arm64 leg is built from + # the same base image and package set. + env: + TRIVY_IMAGE: aquasec/trivy:0.72.0@sha256:cffe3f5161a47a6823fbd23d985795b3ed72a4c806da4c4df16266c02accdd6f + IMAGE_REF: ghcr.io/${{ github.repository_owner }}/${{ matrix.image }}:${{ inputs.tag || 'latest' }} + TRIVY_USERNAME: ${{ github.actor }} + TRIVY_PASSWORD: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + echo "Scanning ${IMAGE_REF}" + docker run --rm \ + -v "${{ github.workspace }}:/work" \ + -v /tmp/trivy-cache:/root/.cache \ + -e TRIVY_USERNAME -e TRIVY_PASSWORD \ + "$TRIVY_IMAGE" image \ + --severity CRITICAL,HIGH \ + --pkg-types os,library \ + --ignore-unfixed \ + --exit-code 0 \ + --no-progress \ + --format json --output /work/trivy-image.json \ + "$IMAGE_REF" + docker run --rm \ + -v "${{ github.workspace }}:/work" \ + -v /tmp/trivy-cache:/root/.cache \ + -e TRIVY_USERNAME -e TRIVY_PASSWORD \ + "$TRIVY_IMAGE" image \ + --severity CRITICAL,HIGH \ + --pkg-types os,library \ + --ignore-unfixed \ + --exit-code 0 \ + --no-progress \ + --format sarif --output /work/trivy-image.sarif \ + "$IMAGE_REF" + + - name: Summarize findings in the job summary + if: always() + # Grouped by (package, version) — the unit of remediation. The ecosystem column + # separates base-image OS packages from application dependencies, which is the + # difference between "bump the base image" and "bump a dependency". + run: | + [ -f trivy-image.json ] || exit 0 + [ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0 + python3 - trivy-image.json "$GITHUB_STEP_SUMMARY" "${{ matrix.image }}" <<'PY' + import json, sys, collections + d = json.load(open(sys.argv[1], encoding="utf-8")) + out = open(sys.argv[2], "a", encoding="utf-8") + image = sys.argv[3] + w = out.write + rows = [] + for r in d.get("Results") or []: + for v in r.get("Vulnerabilities") or []: + rows.append((r.get("Type", "?"), v)) + w(f"## Trivy image — `{image}`\n\n") + w(f"Base image: `{d.get('Metadata', {}).get('OS', {}).get('Family', '?')} " + f"{d.get('Metadata', {}).get('OS', {}).get('Name', '?')}`\n\n") + if not rows: + w("No fixable CRITICAL/HIGH findings.\n") + sys.exit(0) + sev = collections.Counter(v["Severity"] for _, v in rows) + w(f"**{len(rows)}** fixable finding(s) — " + + ", ".join(f"{sev[s]} {s}" for s in ("CRITICAL", "HIGH") if sev.get(s)) + "\n\n") + bundles = collections.defaultdict(list) + for eco, v in rows: + bundles[(eco, v["PkgName"], v.get("InstalledVersion", "?"))].append(v) + rank = {"CRITICAL": 0, "HIGH": 1} + w("| Ecosystem | Package | Max | CVEs | Fixed in |\n|---|---|---|---:|---|\n") + for (eco, pkg, ver), items in sorted( + bundles.items(), key=lambda kv: (min(rank.get(i["Severity"], 2) for i in kv[1]), kv[0])): + worst = min(items, key=lambda i: rank.get(i["Severity"], 2))["Severity"] + fixes = sorted({i.get("FixedVersion") or "-" for i in items}) + w(f"| {eco} | `{pkg}@{ver}` | {worst} | {len(items)} | {', '.join(fixes)[:60]} |\n") + out.close() + PY + + - name: Upload SARIF to GitHub Code Scanning + if: always() + uses: github/codeql-action/upload-sarif@fb0994ef1c058010acf1efccff928b0a83b1ed54 # v4.32.6 + with: + sarif_file: trivy-image.sarif + # Per-image category: one analysis per image, so alerts never overwrite each other. + category: trivy-image:${{ matrix.image }} diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml new file mode 100644 index 000000000..44061c895 --- /dev/null +++ b/.github/workflows/trivy.yml @@ -0,0 +1,170 @@ +name: Trivy SCA + +# Software-composition analysis over the repository. Two passes: +# +# gate — CRITICAL only, blocks. Baseline today: 0 vulnerabilities, 0 secrets, +# 0 CRITICAL misconfigurations, so the gate starts green. +# report — HIGH/MEDIUM/LOW, never blocks. Baseline today: 9 HIGH, 8 MEDIUM, 30 LOW — +# all misconfigurations (missing `USER` in four Dockerfiles, and the frontend +# chart's absent securityContext under src/frontend/helm). Findings land in +# "Security -> Code scanning" plus a job-summary table. +# +# Coverage caveat, important when reading a green result: `trivy fs` resolves dependency +# versions from lock files, and this repository has exactly one — Cargo.lock. The five +# .csproj projects and eleven pyproject.toml projects ship no lock file, so .NET and Python +# dependencies are NOT scanned here at all. That coverage lives in trivy-images.yml, which +# scans the published images where those dependencies are actually installed. Committing +# lock files (`dotnet restore --use-lock-file`, `uv lock`) would move the coverage earlier. +# +# The report pass drops the secret scanner (`--scanners vuln,misconfig`): Code Scanning +# alerts on a public repository are world-readable and Trivy quotes the surrounding match. +# Secrets stay in the blocking gate, and TruffleHog owns the domain (trufflehog.yml). +# +# Some Helm charts are skipped by the misconfiguration scanner because they require values at +# render time (`existingSecret is required`, `keycloak.hostname is required`). Those templates +# are therefore unscanned; the umbrella chart in charts/insight is skipped for the same +# reason. Rendering them with test values would be the way to close that gap. + +on: + pull_request: + branches: [main] + push: + branches: [main] + schedule: + # Nightly, 03:41 UTC — after the TruffleHog sweep (03:13) and Semgrep baseline (03:27). + - cron: "41 3 * * *" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + TRIVY_IMAGE: aquasec/trivy:0.72.0@sha256:cffe3f5161a47a6823fbd23d985795b3ed72a4c806da4c4df16266c02accdd6f + +jobs: + gate: + name: gate (CRITICAL) + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false + + - name: Trivy fs scan — CRITICAL only (blocking) + # `--ignore-unfixed` keeps the gate actionable: a CRITICAL with no released fix cannot + # be resolved by the PR author and belongs in the report pass instead. Waivers go in + # `.trivyignore` at the repo root, date-scoped: + # CVE-2026-12345 exp:2026-12-31 # tracked in #NNNN, fix queued + run: | + docker run --rm \ + -v "${{ github.workspace }}:/src:ro" \ + "$TRIVY_IMAGE" fs \ + --scanners vuln,secret,misconfig \ + --severity CRITICAL \ + --ignore-unfixed \ + --exit-code 1 \ + --no-progress \ + /src + + report: + name: report (HIGH/MEDIUM/LOW) + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read # checkout + security-events: write # upload SARIF to Code Scanning + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false + + - name: Trivy fs scan — HIGH/MEDIUM/LOW (never blocks) + run: | + set -euo pipefail + docker run --rm \ + -v "${{ github.workspace }}:/src" \ + -v /tmp/trivy-cache:/root/.cache \ + "$TRIVY_IMAGE" fs \ + --scanners vuln,misconfig \ + --severity HIGH,MEDIUM,LOW \ + --exit-code 0 \ + --no-progress \ + --format sarif --output /src/trivy-fs.sarif \ + /src + docker run --rm \ + -v "${{ github.workspace }}:/src" \ + -v /tmp/trivy-cache:/root/.cache \ + "$TRIVY_IMAGE" fs \ + --scanners vuln,misconfig \ + --severity HIGH,MEDIUM,LOW \ + --exit-code 0 \ + --no-progress \ + --format json --output /src/trivy-fs.json \ + /src + + - name: Summarize findings in the job summary + if: always() + # Null-guarded append (repo convention, cf. semgrep.yml). Vulnerabilities are grouped + # by package because that is the unit of remediation; misconfigurations are grouped by + # rule because one rule usually means the same fix in several files. + run: | + [ -f trivy-fs.json ] || exit 0 + [ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0 + CS_BASE="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/security/code-scanning" + if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then + CS_URL="${CS_BASE}?query=is%3Aopen+pr%3A${GITHUB_REF_NAME%/merge}" + else + CS_URL="${CS_BASE}?query=is%3Aopen+branch%3A${GITHUB_REF_NAME}" + fi + python3 - trivy-fs.json "$GITHUB_STEP_SUMMARY" "$CS_URL" <<'PY' + import json, sys, collections + d = json.load(open(sys.argv[1], encoding="utf-8")) + out = open(sys.argv[2], "a", encoding="utf-8") + cs_url = sys.argv[3] + w = out.write + vulns, miscfg = [], [] + for r in d.get("Results") or []: + vulns += r.get("Vulnerabilities") or [] + miscfg += [(r.get("Target"), m) for m in (r.get("Misconfigurations") or [])] + w("## Trivy SCA — repository (report-only)\n\n") + w(f"**{len(vulns)}** vulnerabilit(ies) + **{len(miscfg)}** misconfiguration(s) at " + f"HIGH/MEDIUM/LOW. CRITICAL is handled by the blocking `gate` job; image layers and " + f"the .NET / Python dependencies that only exist inside them are covered by " + f"`Trivy Images`. Full details in [Security -> Code scanning]({cs_url}).\n\n") + rank = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3} + if vulns: + bundles = collections.defaultdict(list) + for v in vulns: + bundles[(v["PkgName"], v.get("InstalledVersion", "?"))].append(v) + w("| Package | Max | CVEs | Fixed in |\n|---|---|---:|---|\n") + for (pkg, ver), items in sorted( + bundles.items(), key=lambda kv: (min(rank.get(i["Severity"], 4) for i in kv[1]), kv[0])): + worst = min(items, key=lambda i: rank.get(i["Severity"], 4))["Severity"] + fixes = sorted({i.get("FixedVersion") or "-" for i in items}) + w(f"| `{pkg}@{ver}` | {worst} | {len(items)} | {', '.join(fixes)} |\n") + w("\n") + if miscfg: + by_rule = collections.defaultdict(list) + for target, m in miscfg: + by_rule[(m["Severity"], m["ID"], m.get("Title", ""))].append(target) + w("| Severity | Rule | Files | Title |\n|---|---|---:|---|\n") + for (sev, rid, title), targets in sorted(by_rule.items(), key=lambda kv: (rank.get(kv[0][0], 4), kv[0][1])): + w(f"| {sev} | `{rid}` | {len(targets)} | {title} |\n") + w("\n
Misconfigurations by file\n\n| Severity | Rule | Target |\n|---|---|---|\n") + for target, m in sorted(miscfg, key=lambda tm: (rank.get(tm[1]["Severity"], 4), tm[0])): + w(f"| {m['Severity']} | `{m['ID']}` | `{target}` |\n") + w("\n
\n") + PY + + - name: Upload SARIF to GitHub Code Scanning + # Skip on fork PRs: read-only token cannot upload, which would red-X this report-only job. + if: ${{ always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }} + uses: github/codeql-action/upload-sarif@fb0994ef1c058010acf1efccff928b0a83b1ed54 # v4.32.6 + with: + sarif_file: trivy-fs.sarif + category: trivy-fs diff --git a/.github/workflows/trufflehog.yml b/.github/workflows/trufflehog.yml new file mode 100644 index 000000000..a49047a22 --- /dev/null +++ b/.github/workflows/trufflehog.yml @@ -0,0 +1,212 @@ +name: TruffleHog Secrets + +# Secret detection, split into a blocking merge gate and a non-blocking history sweep. +# Companion to semgrep.yml, which excludes generic-secret rules precisely because this +# workflow owns the domain. +# +# Why the split: a full-history scan of this repository currently returns 253 findings — +# every one of them unverified, and every one triaged (2026-07-28) as a detector false +# positive: synthetic e2e metric fixtures (178, mostly JiraToken/Lob matching 24- and +# 40-character fixture strings), the loose GitLab token regex firing on prose in docs/ and +# on identifiers in dbt models, zero-filled UUIDs in test data, and `CHANGE_ME` templates in +# `.env.local.example`. Blocking on that baseline would be permanently red, so: +# +# secrets-diff — pull requests only, BLOCKING. Scans just the commits the PR adds, so +# the historical baseline is out of range by construction and the gate +# starts green. A new secret cannot merge. +# secrets-history — nightly, REPORT-ONLY. Full history over every ref, no exclusions, so +# the 253 stay visible and any change to that number is noticeable. +# +# `--results=verified,unknown,unverified,filtered_unverified` on both. The default set +# (`verified,unknown`) hides findings whose live verification fails, so an already-revoked or +# inactive credential produces a green run — on a public repository that is still a leak. +# +# `--filter-entropy` was evaluated as a way to suppress the fixture noise and REJECTED: at +# the threshold that clears the baseline (4.0, which cuts 253 findings to 3) a planted AWS +# access key was also suppressed. A 20-character AWS key ID sits right at that entropy +# boundary, so the flag trades a known false-negative class for cosmetic quiet. +# +# Raw secret values never reach a log, a job summary or an artifact: this repository is +# public, so Actions logs are world-readable and echoing a hit would publish the value the +# gate exists to protect. Summaries carry detector, commit and path only. +# +# Known gap: direct pushes to main are covered by the nightly report, not by the blocking +# gate, because a push has no reliable range to diff against on a new or force-pushed ref. +# Closing it requires the history baseline to be clean. + +on: + pull_request: + branches: [main] + schedule: + # Nightly sweep, 03:13 UTC — ahead of the Semgrep baseline at 03:27. + - cron: "13 3 * * *" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + secrets-diff: + name: secrets (diff) + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + # Full history so --since-commit can reach the merge base. + fetch-depth: 0 + persist-credentials: false + + - name: TruffleHog scan (commits added by this PR) + # Digest-pinned image via `docker run`, matching semgrep.yml. stdout carries the JSON + # findings — including raw values — and is redirected to a file that stays on the + # runner; only stderr (progress and the scan summary) reaches the log. `--fail` is + # not used: the summary step decides the exit code so a failing run still renders a + # readable report first. + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + docker run --rm \ + -v "${{ github.workspace }}:/src:ro" \ + -e BASE_SHA \ + trufflesecurity/trufflehog:3.96.0@sha256:aa821cf4ace8861c7d096d83818cdf7bb9719028a52d37a52eaad44086a52577 \ + git file:///src \ + --json \ + --no-update \ + --since-commit "$BASE_SHA" \ + --results=verified,unknown,unverified,filtered_unverified \ + > trufflehog-findings.jsonl + + - name: Summarize (redacted) and fail on any finding + if: always() + run: | + [ -f trufflehog-findings.jsonl ] || exit 0 + python3 - trufflehog-findings.jsonl "${GITHUB_STEP_SUMMARY:-/dev/null}" blocking <<'PY' + import json, sys, collections + rows = [] + with open(sys.argv[1], encoding="utf-8") as fh: + for line in fh: + line = line.strip() + if not line: + continue + d = json.loads(line) + git = (d.get("SourceMetadata") or {}).get("Data", {}).get("Git", {}) + rows.append({ + "detector": d.get("DetectorName", "?"), + "verified": bool(d.get("Verified")), + "commit": (git.get("commit") or "")[:8], + "file": git.get("file") or "?", + "line": git.get("line") or "", + }) + blocking = sys.argv[3] == "blocking" + out = open(sys.argv[2], "a", encoding="utf-8") + w = out.write + w("## TruffleHog — commits added by this PR\n\n") + if not rows: + w("No secrets detected in the added commits (all result kinds enabled).\n") + sys.exit(0) + ver = sum(1 for r in rows if r["verified"]) + w(f"**{len(rows)}** finding(s) — {ver} live-verified, {len(rows) - ver} unverified. " + "Values are redacted here on purpose; read them from the source commit.\n\n") + w("| Detector | Verified | Commit | Path |\n|---|---|---|---|\n") + for r in sorted(rows, key=lambda r: (not r["verified"], r["detector"])): + w(f"| `{r['detector']}` | {'yes' if r['verified'] else 'no'} | `{r['commit']}` | `{r['file']}`{':' + str(r['line']) if r['line'] else ''} |\n") + by_det = collections.Counter(r["detector"] for r in rows) + w(f"\nBy detector: {', '.join(f'{k} x{v}' for k, v in by_det.most_common())}\n") + w("\n**Revoke and rotate every credential listed above.** Removing the commit is not " + "remediation — assume the value is compromised the moment it was pushed. If a finding " + "is a detector false positive on synthetic data, say so in the PR and add a narrow, " + "reviewed exclusion rather than widening the filter.\n") + out.close() + sys.exit(1 if blocking else 0) + PY + + secrets-history: + name: secrets (full history) + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Fetch every branch + # The sweep covers all live branches, so a secret parked on a long-lived feature + # branch is visible before it reaches main. Explicit authenticated URL because + # `persist-credentials: false` leaves no credential on `origin`; Actions masks the + # token in logs. + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + git fetch --no-tags --prune \ + "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" \ + '+refs/heads/*:refs/remotes/origin/*' + + - name: TruffleHog scan (all refs, all result kinds) + run: | + set -euo pipefail + docker run --rm \ + -v "${{ github.workspace }}:/src:ro" \ + trufflesecurity/trufflehog:3.96.0@sha256:aa821cf4ace8861c7d096d83818cdf7bb9719028a52d37a52eaad44086a52577 \ + git file:///src \ + --json \ + --no-update \ + --results=verified,unknown,unverified,filtered_unverified \ + > trufflehog-findings.jsonl + + - name: Summarize (redacted) — report only + if: always() + # Never fails the run. A rise above the triaged baseline of 253, or any live-verified + # finding, is the signal to act; both are visible in the table. + run: | + [ -f trufflehog-findings.jsonl ] || exit 0 + python3 - trufflehog-findings.jsonl "${GITHUB_STEP_SUMMARY:-/dev/null}" <<'PY' + import json, sys, collections + BASELINE = 253 # triaged 2026-07-28: all unverified detector false positives + rows = [] + with open(sys.argv[1], encoding="utf-8") as fh: + for line in fh: + line = line.strip() + if not line: + continue + d = json.loads(line) + git = (d.get("SourceMetadata") or {}).get("Data", {}).get("Git", {}) + rows.append({ + "detector": d.get("DetectorName", "?"), + "verified": bool(d.get("Verified")), + "file": git.get("file") or "?", + }) + out = open(sys.argv[2], "a", encoding="utf-8") + w = out.write + ver = sum(1 for r in rows if r["verified"]) + w("## TruffleHog — full history (report-only)\n\n") + w(f"**{len(rows)}** finding(s) over every ref — {ver} live-verified, " + f"{len(rows) - ver} unverified. Triaged baseline: **{BASELINE}**, all unverified " + "detector false positives.\n\n") + if ver: + w("> **A live-verified secret is present.** Revoke and rotate it now; rewriting " + "history is not remediation.\n\n") + if len(rows) > BASELINE: + w(f"> Count is **{len(rows) - BASELINE} above** the triaged baseline — new findings " + "entered the history. Re-triage before adjusting the baseline.\n\n") + if rows: + by_det = collections.Counter(r["detector"] for r in rows) + w("| Detector | Count |\n|---|---:|\n") + for k, v in by_det.most_common(): + w(f"| `{k}` | {v} |\n") + by_file = collections.Counter(r["file"] for r in rows) + w("\n
Top paths\n\n| Count | Path |\n|---:|---|\n") + for k, v in by_file.most_common(20): + w(f"| {v} | `{k}` |\n") + w("\n
\n") + out.close() + PY diff --git a/.gitignore b/.gitignore index 790bec6b7..5825e7a11 100644 --- a/.gitignore +++ b/.gitignore @@ -410,3 +410,13 @@ coverage-raw/ # whole dirs, so a stray non-.pem key can't slip past a git add -A. deploy/compose/authenticator-dev-keys/ deploy/compose/authn-tls-certs/ + +# Security scanner output. CI writes these into the workspace, and the same +# commands are runnable locally; trufflehog's report holds raw secret values, so +# it must never be committed. +semgrep.sarif +trivy-fs.json +trivy-fs.sarif +trivy-image.json +trivy-image.sarif +trufflehog-findings.jsonl From 1e753bc7330468081ca2502ccc0a42815dd23088 Mon Sep 17 00:00:00 2001 From: Grigoriy Gogin Date: Wed, 29 Jul 2026 09:29:39 +0200 Subject: [PATCH 2/2] ci(security): honor repo-root .trivyignore, guard SARIF upload, scan once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from review on this PR. The repo-root `.trivyignore` documented on the gate job was never read. Trivy resolves the default ignore file relative to the working directory, and the container had no `-w`, so waivers were looked for in the image's own cwd. Adding `-w` to the mount fixes it. Verified: with a waiver present the gate now passes where it previously ignored the file, and with no `.trivyignore` at all — the current state of the repository — the gate is unaffected. `--ignorefile` is deliberately not used: Trivy exits FATAL when the named file is absent, which would break every run until someone adds a waiver. SARIF upload steps ran under `always()` against files a failed scan never wrote, producing a second error that pointed at Code Scanning instead of the scan that actually broke. A `hashFiles` guard drops that. It matters most in the image matrix, where one failed registry pull reported twice. Both report passes scanned the same target twice to produce two formats. Now the scan runs once to JSON and `trivy convert` derives the SARIF from it — identical output (47 results, unchanged), and for the 14-image matrix it removes 14 redundant pulls and layer analyses. Signed-off-by: Grigoriy Gogin --- .github/workflows/trivy-images.yml | 21 +++++++++++---------- .github/workflows/trivy.yml | 28 ++++++++++++++++++---------- 2 files changed, 29 insertions(+), 20 deletions(-) diff --git a/.github/workflows/trivy-images.yml b/.github/workflows/trivy-images.yml index 9c894739f..b6847806c 100644 --- a/.github/workflows/trivy-images.yml +++ b/.github/workflows/trivy-images.yml @@ -91,12 +91,17 @@ jobs: IMAGE_REF: ghcr.io/${{ github.repository_owner }}/${{ matrix.image }}:${{ inputs.tag || 'latest' }} TRIVY_USERNAME: ${{ github.actor }} TRIVY_PASSWORD: ${{ secrets.GITHUB_TOKEN }} + # One pull and one analysis per image: `trivy convert` derives the SARIF from the JSON + # rather than rescanning, which for a 14-image matrix saves 14 redundant registry pulls + # and layer analyses. `-w /work` so a repo-root `.trivyignore` is resolved — Trivy reads + # the default ignore file relative to the working directory. run: | set -euo pipefail echo "Scanning ${IMAGE_REF}" docker run --rm \ -v "${{ github.workspace }}:/work" \ -v /tmp/trivy-cache:/root/.cache \ + -w /work \ -e TRIVY_USERNAME -e TRIVY_PASSWORD \ "$TRIVY_IMAGE" image \ --severity CRITICAL,HIGH \ @@ -108,16 +113,9 @@ jobs: "$IMAGE_REF" docker run --rm \ -v "${{ github.workspace }}:/work" \ - -v /tmp/trivy-cache:/root/.cache \ - -e TRIVY_USERNAME -e TRIVY_PASSWORD \ - "$TRIVY_IMAGE" image \ - --severity CRITICAL,HIGH \ - --pkg-types os,library \ - --ignore-unfixed \ - --exit-code 0 \ - --no-progress \ + "$TRIVY_IMAGE" convert \ --format sarif --output /work/trivy-image.sarif \ - "$IMAGE_REF" + /work/trivy-image.json - name: Summarize findings in the job summary if: always() @@ -160,7 +158,10 @@ jobs: PY - name: Upload SARIF to GitHub Code Scanning - if: always() + # The hashFiles guard matters more here than elsewhere: with 14 matrix legs, one failed + # registry pull would otherwise report twice — once for the pull, once for a SARIF file + # that was never written. + if: ${{ always() && hashFiles('trivy-image.sarif') != '' }} uses: github/codeql-action/upload-sarif@fb0994ef1c058010acf1efccff928b0a83b1ed54 # v4.32.6 with: sarif_file: trivy-image.sarif diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index 44061c895..6483ed8d5 100644 --- a/.github/workflows/trivy.yml +++ b/.github/workflows/trivy.yml @@ -60,9 +60,16 @@ jobs: # be resolved by the PR author and belongs in the report pass instead. Waivers go in # `.trivyignore` at the repo root, date-scoped: # CVE-2026-12345 exp:2026-12-31 # tracked in #NNNN, fix queued + # + # `-w /src` is load-bearing for that: Trivy resolves the default ignore file relative + # to the working directory, so without it a repo-root `.trivyignore` is read from the + # image's own cwd and silently ignored. `--ignorefile` is not used instead because + # Trivy exits FATAL when the named file does not exist, which would break every run + # until someone adds a waiver. run: | docker run --rm \ -v "${{ github.workspace }}:/src:ro" \ + -w /src \ "$TRIVY_IMAGE" fs \ --scanners vuln,secret,misconfig \ --severity CRITICAL \ @@ -84,28 +91,27 @@ jobs: persist-credentials: false - name: Trivy fs scan — HIGH/MEDIUM/LOW (never blocks) + # One scan, two formats: JSON feeds the summary below, and `trivy convert` derives the + # SARIF from that same JSON instead of rescanning. `-w /src` for the ignore-file reason + # documented on the gate job. run: | set -euo pipefail docker run --rm \ -v "${{ github.workspace }}:/src" \ -v /tmp/trivy-cache:/root/.cache \ + -w /src \ "$TRIVY_IMAGE" fs \ --scanners vuln,misconfig \ --severity HIGH,MEDIUM,LOW \ --exit-code 0 \ --no-progress \ - --format sarif --output /src/trivy-fs.sarif \ + --format json --output /src/trivy-fs.json \ /src docker run --rm \ -v "${{ github.workspace }}:/src" \ - -v /tmp/trivy-cache:/root/.cache \ - "$TRIVY_IMAGE" fs \ - --scanners vuln,misconfig \ - --severity HIGH,MEDIUM,LOW \ - --exit-code 0 \ - --no-progress \ - --format json --output /src/trivy-fs.json \ - /src + "$TRIVY_IMAGE" convert \ + --format sarif --output /src/trivy-fs.sarif \ + /src/trivy-fs.json - name: Summarize findings in the job summary if: always() @@ -163,7 +169,9 @@ jobs: - name: Upload SARIF to GitHub Code Scanning # Skip on fork PRs: read-only token cannot upload, which would red-X this report-only job. - if: ${{ always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }} + # The hashFiles guard keeps a failed scan from producing a second, misleading error that + # points at Code Scanning rather than at the scan that actually broke. + if: ${{ always() && hashFiles('trivy-fs.sarif') != '' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }} uses: github/codeql-action/upload-sarif@fb0994ef1c058010acf1efccff928b0a83b1ed54 # v4.32.6 with: sarif_file: trivy-fs.sarif