diff --git a/.github/workflows/bearer-diff.yml b/.github/workflows/bearer-diff.yml new file mode 100644 index 0000000..f59943a --- /dev/null +++ b/.github/workflows/bearer-diff.yml @@ -0,0 +1,195 @@ +name: Bearer-CLI differential scan + +on: + workflow_call: + inputs: + minimum_severity: + description: >- + Lowest severity to report. + required: false + type: string + default: high + enforce: + description: Fail this job when the scan reports new findings. + required: false + type: boolean + default: false + toolkit_repository: + description: Trusted repository containing the shared reporting script. + required: true + type: string + toolkit_ref: + description: Trusted ref containing the shared reporting script. + required: true + type: string + +permissions: + contents: read + +env: + BEARER_VERSION: "2.1.1" + BEARER_LINUX_AMD64_SHA256: "6b79d315577fea8305dfe08577bea6ad53852a929cd24de9211d39750a194bbb" + +jobs: + scan: + name: Scan + runs-on: ubuntu-24.04 + timeout-minutes: 30 + + steps: + - name: Validate trigger + shell: bash + run: | + set -euo pipefail + + if [[ "${GITHUB_EVENT_NAME}" != "pull_request" ]]; then + echo "::error::This workflow must be called from a pull_request workflow." + exit 2 + fi + + - name: Resolve severity threshold + id: policy + shell: bash + env: + MINIMUM_SEVERITY: ${{ inputs.minimum_severity }} + run: | + set -euo pipefail + + case "${MINIMUM_SEVERITY}" in + critical) severities="critical" ;; + high) severities="critical,high" ;; + medium) severities="critical,high,medium" ;; + low) severities="critical,high,medium,low" ;; + all) severities="critical,high,medium,low,warning" ;; + *) + echo "::error::minimum_severity must be critical, high, medium, low, or all." + exit 2 + ;; + esac + + echo "severities=${severities}" >> "${GITHUB_OUTPUT}" + + - name: Check out pull request head + uses: actions/checkout@v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ github.event.pull_request.head.sha }} + + - name: Flag Bearer-CLI configuration changes + shell: bash + env: + BASE_REF: ${{ github.base_ref }} + run: | + set -euo pipefail + + range="origin/${BASE_REF}...HEAD" + + if git diff --quiet "${range}" -- .bearer; then + echo "This pull request does not change the Bearer-CLI configuration." + exit 0 + fi + + { + echo "## Configuration changed" + echo + echo "**This pull request changes Bearer-CLI's own configuration.**" + echo + echo "These files control what gets scanned and ignored. Confirm the change is intended before approving." + echo + git diff --no-color "${range}" -- .bearer | sed 's/^/ /' + } >> "${GITHUB_STEP_SUMMARY}" + + while IFS= read -r -d '' path; do + path="${path//%/%25}" + path="${path//$'\r'/%0D}" + path="${path//$'\n'/%0A}" + path="${path//:/%3A}" + path="${path//,/%2C}" + + echo "::warning file=${path},line=1,title=Configuration changed::This file controls what Bearer-CLI scans and ignores. Confirm the change is intended before approving." + done < <(git diff --name-only -z "${range}" -- .bearer) + + - name: Install Bearer-CLI + shell: bash + run: | + set -euo pipefail + + bin_dir="${RUNNER_TEMP}/bearer-bin" + archive="${RUNNER_TEMP}/bearer.tar.gz" + mkdir -p "${bin_dir}" + + curl --fail --silent --show-error --location --retry 3 --retry-all-errors \ + "https://github.com/Bearer/bearer/releases/download/v${BEARER_VERSION}/bearer_${BEARER_VERSION}_linux_amd64.tar.gz" \ + --output "${archive}" + printf '%s %s\n' "${BEARER_LINUX_AMD64_SHA256}" "${archive}" | sha256sum --check --strict - + tar -xzf "${archive}" -C "${bin_dir}" bearer + + "${bin_dir}/bearer" version + echo "${bin_dir}" >> "${GITHUB_PATH}" + + - name: Run Bearer-CLI + shell: bash + env: + BEARER_DIFF_BASE_BRANCH: ${{ github.base_ref }} + SEVERITIES: ${{ steps.policy.outputs.severities }} + run: | + set -euo pipefail + + bearer scan . \ + --diff \ + --config-file=.bearer/bearer.yml \ + --ignore-file=.bearer/bearer.ignore \ + --scanner=sast,secrets \ + --severity="${SEVERITIES}" \ + --format=json \ + --output="${RUNNER_TEMP}/bearer-results.json" \ + --no-extract \ + --hide-progress-bar \ + --no-color \ + --disable-domain-resolution \ + --disable-version-check \ + --exit-code=0 + + test -s "${RUNNER_TEMP}/bearer-results.json" + + - name: Check out shared scripts + uses: actions/checkout@v7.0.1 + with: + repository: ${{ inputs.toolkit_repository }} + ref: ${{ inputs.toolkit_ref }} + sparse-checkout: bearer/scripts + path: .bearer-toolkit + persist-credentials: false + + - name: Publish pull request report + shell: bash + env: + ANNOTATION_LEVEL: ${{ inputs.enforce && 'error' || 'warning' }} + ENFORCE: ${{ inputs.enforce }} + MINIMUM_SEVERITY: ${{ inputs.minimum_severity }} + REPORT_FILE: ${{ runner.temp }}/bearer-results.json + SUMMARY_FILE: ${{ runner.temp }}/bearer-summary.md + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + + echo "::group::Bearer-CLI findings" + node "${GITHUB_WORKSPACE}/.bearer-toolkit/bearer/scripts/bearer-summary.mjs" \ + "${REPORT_FILE}" \ + "${SUMMARY_FILE}" \ + "${MINIMUM_SEVERITY}" \ + "${GITHUB_REPOSITORY}" \ + "${HEAD_SHA}" \ + "Bearer-CLI differential scan: findings introduced by this pull request" + echo "::endgroup::" + + cat "${SUMMARY_FILE}" >> "${GITHUB_STEP_SUMMARY}" + + finding_count="$(jq '[.critical, .high, .medium, .low, .warning] | map(length) | add' "${REPORT_FILE}")" + echo "Bearer-CLI reported ${finding_count} new findings at or above ${MINIMUM_SEVERITY}." + + if [[ "${ENFORCE}" == "true" ]] && (( finding_count > 0 )); then + echo "::error::Bearer-CLI reported ${finding_count} new findings at or above ${MINIMUM_SEVERITY}. See the job summary, then fix or suppress them." + exit 1 + fi diff --git a/.github/workflows/bearer-full.yml b/.github/workflows/bearer-full.yml new file mode 100644 index 0000000..b06796b --- /dev/null +++ b/.github/workflows/bearer-full.yml @@ -0,0 +1,130 @@ +name: Bearer-CLI Full Scan + +on: + workflow_call: + inputs: + minimum_severity: + description: Lowest severity to include in the scan. + required: false + type: string + default: high + toolkit_repository: + description: Trusted repository containing the shared reporting script. + required: true + type: string + toolkit_ref: + description: Trusted ref containing the shared reporting script. + required: true + type: string + +permissions: + contents: read + +env: + BEARER_VERSION: "2.1.1" + BEARER_LINUX_AMD64_SHA256: "6b79d315577fea8305dfe08577bea6ad53852a929cd24de9211d39750a194bbb" + +jobs: + scan: + name: Full scan + runs-on: ubuntu-24.04 + timeout-minutes: 30 + + steps: + - name: Resolve severity threshold + id: policy + shell: bash + env: + MINIMUM_SEVERITY: ${{ inputs.minimum_severity }} + run: | + set -euo pipefail + + case "${MINIMUM_SEVERITY}" in + critical) severities="critical" ;; + high) severities="critical,high" ;; + medium) severities="critical,high,medium" ;; + low) severities="critical,high,medium,low" ;; + all) severities="critical,high,medium,low,warning" ;; + *) + echo "::error::minimum_severity must be critical, high, medium, low, or all." + exit 2 + ;; + esac + + echo "severities=${severities}" >> "${GITHUB_OUTPUT}" + + - name: Check out selected branch + uses: actions/checkout@v7.0.1 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Install Bearer-CLI + shell: bash + run: | + set -euo pipefail + + bin_dir="${RUNNER_TEMP}/bearer-bin" + archive="${RUNNER_TEMP}/bearer.tar.gz" + mkdir -p "${bin_dir}" + + curl --fail --silent --show-error --location --retry 3 --retry-all-errors \ + "https://github.com/Bearer/bearer/releases/download/v${BEARER_VERSION}/bearer_${BEARER_VERSION}_linux_amd64.tar.gz" \ + --output "${archive}" + printf '%s %s\n' "${BEARER_LINUX_AMD64_SHA256}" "${archive}" | sha256sum --check --strict - + tar -xzf "${archive}" -C "${bin_dir}" bearer + + "${bin_dir}/bearer" version + echo "${bin_dir}" >> "${GITHUB_PATH}" + + - name: Run Bearer-CLI + shell: bash + env: + SEVERITIES: ${{ steps.policy.outputs.severities }} + run: | + set -euo pipefail + + bearer scan . \ + --config-file=.bearer/bearer.yml \ + --ignore-file=.bearer/bearer.ignore \ + --scanner=sast,secrets \ + --severity="${SEVERITIES}" \ + --format=json \ + --output="${RUNNER_TEMP}/bearer-results.json" \ + --no-extract \ + --hide-progress-bar \ + --no-color \ + --disable-domain-resolution \ + --disable-version-check \ + --exit-code=0 + + test -s "${RUNNER_TEMP}/bearer-results.json" + + - name: Check out shared scripts + uses: actions/checkout@v7.0.1 + with: + repository: ${{ inputs.toolkit_repository }} + ref: ${{ inputs.toolkit_ref }} + sparse-checkout: bearer/scripts + path: .bearer-toolkit + persist-credentials: false + + - name: Write full scan report + shell: bash + env: + MINIMUM_SEVERITY: ${{ inputs.minimum_severity }} + REPORT_FILE: ${{ runner.temp }}/bearer-results.json + SUMMARY_FILE: ${{ runner.temp }}/bearer-summary.md + run: | + set -euo pipefail + + echo "::group::Bearer-CLI findings" + node "${GITHUB_WORKSPACE}/.bearer-toolkit/bearer/scripts/bearer-summary.mjs" \ + "${REPORT_FILE}" \ + "${SUMMARY_FILE}" \ + "${MINIMUM_SEVERITY}" \ + "${GITHUB_REPOSITORY}" \ + "${GITHUB_REF_NAME}" + echo "::endgroup::" + + cat "${SUMMARY_FILE}" >> "${GITHUB_STEP_SUMMARY}" diff --git a/README.md b/README.md index c274f1f..0d37e54 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,8 @@ -# engineering-toolkit -Shared development tooling, configurations, reusable GitHub workflows, and other engineering artifacts for repositories across the organization. +# Dock Engineering Toolkit + +WIP. + +## Guides + +- [Bearer-CLI workflows](bearer/README.md) +- [Managing Semgrep findings](SEMGREP.md) diff --git a/SEMGREP.md b/SEMGREP.md new file mode 100644 index 0000000..92303a6 --- /dev/null +++ b/SEMGREP.md @@ -0,0 +1,35 @@ +# Managing Semgrep findings + +Handle Semgrep findings in the pull request where they appear. + +## Choose the right outcome + +- **Fix it** when the finding is valid. +- **False positive** only when Semgrep has misunderstood the code. +- **Acceptable risk** when the finding is valid but the risk is deliberately accepted. + +For GitHub PR comments, reply to the Semgrep bot with: + +```text +/fp +/ar +/open +``` + +Avoid `/other` as it leaves little useful audit context. + +Alternatively, in the Semgrep platform, filter **Findings** to your PR or branch, select the +finding, and use **Triage** to set the same status and explanation. + +## Guardrails + +- Always give a specific and sensible reason. For instance, a generic "Not exploitable" is not +enough without saying what prevents exploitation. +- Do not disable rules, add broad path exclusions, or change policies to resolve + one false positive. +- Try to avoid the use of `nosemgrep` for code-local suppression. + +## References + +- [Semgrep Platform Org Link](https://semgrep.dev/orgs/dock_labs) +- [Semgrep's finding triage documentation](https://semgrep.dev/docs/for-developers/resolve-findings-through-app) diff --git a/bearer/README.md b/bearer/README.md new file mode 100644 index 0000000..b01b11c --- /dev/null +++ b/bearer/README.md @@ -0,0 +1,75 @@ +# Bearer CLI + +Caller templates and support scripts for the reusable Bearer-CLI workflows in +[/.github/workflows](../.github/workflows). + +| Reusable workflow | Trigger | Scope | +| --- | --- | --- | +| `bearer-diff.yml` | Pull request | Findings introduced by the pull request | +| `bearer-full.yml` | Manual dispatch | Whole repository at the selected ref | + +Both run the `sast` and `secrets` scanners. + +## Contents + +| Path | Purpose | +| --- | --- | +| `templates/.github/workflows/bearer-pr.yml` | Pull-request caller | +| `templates/.github/workflows/bearer-full.yml` | Manual full-scan caller | +| `templates/.bearer/bearer.yml` | Required; rule and path exclusions | +| `templates/.bearer/bearer.ignore` | Required; fingerprint-based exclusions | +| `scripts/` | Internal report rendering used by the reusable workflows | + +## Setup + +1. Copy `templates/.github/workflows/` into the target repository's + `.github/workflows/`. +2. Copy `templates/.bearer/` to the repository root. Both files are required + even when empty; the scan fails without them. +3. Optionally set the [repository variables below](#repository-variables). + +## Repository variables + +Both apply to the pull-request caller only; the full scan takes its severity +from the dispatch form. + +| Variable | Default | Effect | +| --- | --- | --- | +| `BEARER_MIN_SEVERITY` | `high` | Lowest severity reported: `critical`, `high`, `medium`, `low`, or `all`. | +| `BEARER_ENFORCE` | unset | `true` reports findings as errors and fails the scan job. | + +`BEARER_ENFORCE` changes finding annotations from warnings to errors and fails +the scan job. + +## Results + +Pull requests get annotations on the scan job, each carrying the severity, rule +ID, fingerprint, and documentation link. Note that GitHub limits native +annotations to 10 warnings or errors per step. However, the complete findings table +is always written to the job summary. + +The full scan writes its findings table to the run summary and repeats the list +in the job log. + +## Configuration + +All Bearer-CLI configuration must live in `.bearer/` at the target repository root. + +A pull request that touches `.bearer/` gets a non-blocking warning annotation +on each changed file and the full diff in the job summary. Review those changes: +they decide what the scan sees. + +## Suppress a finding + +Take the fingerprint and run: + +```shell +bearer ignore add \ + --ignore-file=.bearer/bearer.ignore \ + --author="Developer Name" \ + --comment="Why this finding is accepted" \ + --false-positive +``` + +Omit `--false-positive` for accepted risk. The pull request will carry the +configuration-change warning(s). diff --git a/bearer/scripts/bearer-summary.mjs b/bearer/scripts/bearer-summary.mjs new file mode 100644 index 0000000..039bb43 --- /dev/null +++ b/bearer/scripts/bearer-summary.mjs @@ -0,0 +1,189 @@ +#!/usr/bin/env node +// Renders a Bearer-CLI JSON report into a Markdown summary and the process output. +// +// Usage: node bearer-summary.mjs +// [repository] [ref] [heading] +// +// Set ANNOTATION_LEVEL to "warning" or "error" to emit GitHub annotations. + +import { readFileSync, writeFileSync } from "node:fs"; + +const SEVERITY_ORDER = ["critical", "high", "medium", "low", "warning"]; +const ANNOTATION_LEVELS = new Set(["error", "warning"]); +const MAX_GITHUB_ANNOTATIONS_PER_STEP = 10; +const NO_FINDINGS = "No findings at the selected severity threshold."; +const DEFAULT_HEADING = "Bearer-CLI Full Scan"; + +function fail(message) { + console.error(message); + process.exit(1); +} + +function readArgs() { + const args = process.argv.slice(2); + + if (args.length < 3 || args.length > 6 || args.slice(0, 3).some((value) => !value)) { + fail( + "Usage: node bearer-summary.mjs " + + " [repository] [ref] [heading]", + ); + } + + return args; +} + +// Keep every rendered value on one line and inside its own table cell. +function clean(value) { + return String(value || "") + .replaceAll("\n", " ") + .replaceAll("\r", " ") + .replaceAll("|", "\\|") + .replaceAll("`", "'"); +} + +function escapeData(value) { + return String(value).replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A"); +} + +function escapeProperty(value) { + return escapeData(value).replaceAll(":", "%3A").replaceAll(",", "%2C"); +} + +const [reportFile, summaryFile, minimumSeverity, repository, ref, heading] = readArgs(); +const annotationLevel = process.env.ANNOTATION_LEVEL || ""; + +if (annotationLevel && !ANNOTATION_LEVELS.has(annotationLevel)) { + fail("ANNOTATION_LEVEL must be warning or error"); +} + +let report; +try { + report = JSON.parse(readFileSync(reportFile, "utf8")); +} catch (error) { + fail(`Could not read Bearer-CLI report ${reportFile}: ${error.message}`); +} + +if (report === null || typeof report !== "object" || Array.isArray(report)) { + fail("Bearer-CLI report must be a JSON object"); +} + +const findings = []; +const counts = {}; + +for (const severity of SEVERITY_ORDER) { + const items = report[severity] || []; + + if (!Array.isArray(items)) { + fail(`Bearer-CLI report field '${severity}' must be a list`); + } + + counts[severity] = items.length; + + for (const item of items) { + if (item === null || typeof item !== "object" || Array.isArray(item)) { + fail(`Bearer-CLI report field '${severity}' must contain only objects`); + } + + const annotationLocation = item.sink || item.source || {}; + const annotationPath = String(item.filename || item.full_filename || "").replace(/^\.\//, ""); + + findings.push({ + severity, + filename: clean(annotationPath || "unknown"), + line: item.line_number || (item.source || {}).start || 1, + annotationPath, + annotationLine: Number(annotationLocation.start || item.line_number) || 1, + sourceLine: clean((item.source || {}).start || "—"), + sinkLine: clean((item.sink || {}).start || "—"), + ruleId: clean(item.id || "unknown"), + title: clean(item.title || "Bearer-CLI finding"), + fingerprint: clean(item.fingerprint || "unavailable"), + documentation: clean(item.documentation_url), + }); + } +} + +if (findings.length === 0) { + console.log(NO_FINDINGS); +} + +const outputFindings = annotationLevel + ? findings.slice(0, MAX_GITHUB_ANNOTATIONS_PER_STEP) + : findings; + +for (const finding of outputFindings) { + if (annotationLevel) { + const properties = []; + if (finding.annotationPath) { + properties.push( + `file=${escapeProperty(finding.annotationPath)}`, + `line=${finding.annotationLine}`, + ); + } + properties.push( + `title=${escapeProperty(`${finding.severity.toUpperCase()}: ${finding.ruleId}`)}`, + ); + + const message = [ + finding.title, + `Severity: ${finding.severity.toUpperCase()}`, + `Rule: ${finding.ruleId}`, + `Fingerprint: ${finding.fingerprint}`, + finding.documentation, + ] + .filter(Boolean) + .join("\n"); + + console.log(`::${annotationLevel} ${properties.join(",")}::${escapeData(message)}`); + continue; + } + + console.log( + `- [${finding.severity.toUpperCase()}] ${finding.filename}:${finding.line}` + + ` | ${finding.ruleId} | ${finding.title} | fingerprint=${finding.fingerprint}`, + ); + if (finding.documentation) { + console.log(` ${finding.documentation}`); + } +} + +if (findings.length > outputFindings.length) { + console.log( + `${findings.length - outputFindings.length} additional findings are available in the job summary.`, + ); +} + +const summary = [`# ${clean(heading || DEFAULT_HEADING)}\n\n`]; + +if (repository) { + summary.push(`- Repository: \`${clean(repository)}\`\n`); +} +if (ref) { + summary.push(`- Ref: \`${clean(ref)}\`\n`); +} + +summary.push( + `- Minimum severity: \`${clean(minimumSeverity)}\`\n`, + `- Total findings: **${findings.length}**\n\n`, + "| Critical | High | Medium | Low | Warning |\n", + "| ---: | ---: | ---: | ---: | ---: |\n", + `| ${counts.critical} | ${counts.high} | ${counts.medium} | ` + + `${counts.low} | ${counts.warning} |\n\n`, +); + +if (findings.length === 0) { + summary.push(`${NO_FINDINGS}\n`); +} else { + summary.push("## Findings\n\n"); + summary.push("| Severity | Finding | Location | Source line | Sink line | Fingerprint |\n"); + summary.push("| --- | --- | --- | ---: | ---: | --- |\n"); + for (const finding of findings) { + summary.push( + `| ${finding.severity.toUpperCase()} | ${finding.ruleId}: ${finding.title} |` + + ` \`${finding.filename}:${finding.line}\` | ${finding.sourceLine} |` + + ` ${finding.sinkLine} | \`${finding.fingerprint}\` |\n`, + ); + } +} + +writeFileSync(summaryFile, summary.join(""), "utf8"); diff --git a/bearer/templates/.bearer/bearer.ignore b/bearer/templates/.bearer/bearer.ignore new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/bearer/templates/.bearer/bearer.ignore @@ -0,0 +1 @@ +{} diff --git a/bearer/templates/.bearer/bearer.yml b/bearer/templates/.bearer/bearer.yml new file mode 100644 index 0000000..9be1546 --- /dev/null +++ b/bearer/templates/.bearer/bearer.yml @@ -0,0 +1,7 @@ +# Repository-specific Bearer configuration. + +rule: + skip-rule: [] + +scan: + skip-path: [] diff --git a/bearer/templates/.github/workflows/bearer-full.yml b/bearer/templates/.github/workflows/bearer-full.yml new file mode 100644 index 0000000..fd7aec0 --- /dev/null +++ b/bearer/templates/.github/workflows/bearer-full.yml @@ -0,0 +1,27 @@ +name: Bearer-CLI Full Scan + +on: + workflow_dispatch: + inputs: + minimum_severity: + description: Lowest severity to include in the scan. + required: true + type: choice + default: high + options: + - critical + - high + - medium + - low + - all + +permissions: + contents: read + +jobs: + run: + uses: docknetwork/engineering-toolkit/.github/workflows/bearer-full.yml@main + with: + minimum_severity: ${{ inputs.minimum_severity }} + toolkit_repository: docknetwork/engineering-toolkit + toolkit_ref: main diff --git a/bearer/templates/.github/workflows/bearer-pr.yml b/bearer/templates/.github/workflows/bearer-pr.yml new file mode 100644 index 0000000..6d8cdb3 --- /dev/null +++ b/bearer/templates/.github/workflows/bearer-pr.yml @@ -0,0 +1,20 @@ +name: Bearer-CLI + +on: + pull_request: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + run: + uses: docknetwork/engineering-toolkit/.github/workflows/bearer-diff.yml@main + with: + minimum_severity: ${{ vars.BEARER_MIN_SEVERITY || 'high' }} + enforce: ${{ vars.BEARER_ENFORCE == 'true' }} + toolkit_repository: docknetwork/engineering-toolkit + toolkit_ref: main