From 36bedbd7385f65545b86adc1f9bb9dacfbda1562 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 01:38:28 +0000 Subject: [PATCH 1/2] feat(action): SARIF -> GitHub code scanning surface (dog-fooded in CI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Give the composite Action a fourth surface, `format: sarif`: it writes a SARIF 2.1.0 log to a file and exposes the path as the `sarif-file` output, so a consumer hands it straight to github/codeql-action/upload-sarif. Code scanning subsumes the `github` PR-annotation format (it renders inline PR annotations *and* the Security tab), so this stays a single own-check run. An optional `sarif-file` input overrides the default $RUNNER_TEMP path. With fail-on-finding the true exit code is captured *after* the file is written, so the upload can still run (code scanning is the gate). Validate the exporter on two levels — no "trust me bro": - structure (own-check-surface): a jq contract over a freshly-emitted log — version 2.1.0, the Own.NET driver, and every result carrying a catalogue ruleId + a located file (the shape GitHub's ingest enforces). Read-only. - end-to-end (own-check-codescan): the action `format: sarif` over the sample tree, then upload-sarif under a *scoped* security-events:write (the only non-contents:read job). upload-sarif waits for processing and fails the job if GitHub rejects the log — live proof the SARIF is accepted, and it lights up the Security tab on this public repo (the consumer payoff). Samples are intentional fixtures, namespaced under the own-net-samples category. docs/notes/sarif-export.md: flip the deferred code-scanning item to shipped (the public-repo reasoning that reverses the earlier deferral) + the consumer wiring snippet. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED --- .github/workflows/ci.yml | 66 ++++++++++++++++++++++++++++++++++++++ action.yml | 51 +++++++++++++++++++++++++++-- docs/notes/sarif-export.md | 50 ++++++++++++++++++++++++++--- 3 files changed, 161 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a44b1ed5..ba747dc3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -467,6 +467,33 @@ jobs: echo "FAIL: a tree with leaks should exit non-zero under --fail-on-finding"; exit 1 fi echo "OK: --fail-on-finding surfaced the leaks as a non-zero exit" + - name: SARIF surface is a valid 2.1.0 log (the structure code scanning enforces) + run: | + # The contract GitHub's code-scanning ingest enforces, checked locally so + # the upload (own-check-codescan job) is never the first place a drift is + # found: a single-run 2.1.0 log, the Own.NET driver, and every result + # carrying a catalogue ruleId + a located file. No upload, no permissions. + out="$RUNNER_TEMP/own.sarif" + scripts/own-check.sh --format sarif --severity warning -- frontend/roslyn/samples > "$out" + echo "wrote $(wc -c < "$out") bytes" + jq -e '.version == "2.1.0" and ((.runs | length) == 1)' "$out" >/dev/null \ + || { echo "FAIL: not a single-run SARIF 2.1.0 log"; exit 1; } + jq -e '.runs[0].tool.driver.name == "Own.NET"' "$out" >/dev/null \ + || { echo "FAIL: tool.driver.name is not Own.NET"; exit 1; } + # A dangling ruleId or an unlocated result is the #1 reason GitHub rejects + # a SARIF; startLine is optional (a file-level finding omits it -> // 1). + jq -e ' + (.runs[0].tool.driver.rules | map(.id)) as $ids + | .runs[0].results + | (length > 0) + and all(.[]; + (.ruleId | type == "string") + and ((([.ruleId] - $ids) | length) == 0) + and (.locations[0].physicalLocation.artifactLocation.uri | type == "string") + and ((.locations[0].physicalLocation.region.startLine // 1) | type == "number")) + ' "$out" >/dev/null \ + || { echo "FAIL: a result is unlocated or references an undeclared rule"; exit 1; } + echo "OK: SARIF 2.1.0 — Own.NET driver, every result rule-backed + located" - name: The composite action runs end-to-end (non-failing) uses: ./ with: @@ -474,3 +501,42 @@ jobs: format: github fail-on-finding: "false" + # Dog-food the code-scanning surface end-to-end (P-013): run the composite action + # with format: sarif over the sample tree, then upload the log to GitHub code + # scanning. The repo is public, so code scanning is free — this is the live proof + # that GitHub *accepts* our SARIF (upload-sarif waits for processing and fails the + # job if the log is rejected), not just that it is schema-valid (the surface job + # above). It also lights up the Security tab + inline PR annotations — the + # consumer-facing payoff the exporter was built for. The samples are intentional + # leak fixtures, so the alerts are real-if-intentional; a dedicated + # `own-net-samples` category keeps them from colliding with anything else. + own-check-codescan: + name: own-check SARIF -> GitHub code scanning (dog-food) + runs-on: ubuntu-latest + # The one job that writes: scoped to security-events so it can upload to code + # scanning. Every other job stays contents:read (the workflow-level default). + permissions: + contents: read + security-events: write + steps: + - uses: actions/checkout@v4 + - name: Own.NET leak check (SARIF surface) + id: own + uses: ./ + with: + path: frontend/roslyn/samples + format: sarif + severity: warning # include the injected-source (warning-tier) leaks + fail-on-finding: "false" # let code scanning be the gate, not the step + - name: The action exposes the SARIF path + run: | + f="${{ steps.own.outputs.sarif-file }}" + test -n "$f" || { echo "FAIL: action did not set the sarif-file output"; exit 1; } + test -s "$f" || { echo "FAIL: sarif-file '$f' is missing or empty"; exit 1; } + echo "OK: action wrote $(wc -c < "$f") bytes to $f" + - name: Upload to GitHub code scanning + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: ${{ steps.own.outputs.sarif-file }} + category: own-net-samples + diff --git a/action.yml b/action.yml index 119551c7..2f03b711 100644 --- a/action.yml +++ b/action.yml @@ -14,7 +14,10 @@ inputs: required: false default: "." format: - description: "Finding surface: github (PR annotations), msbuild, or human." + description: >- + Finding surface: github (PR annotations), msbuild, human, or sarif — a SARIF + 2.1.0 log for GitHub code scanning, written to a file and exposed as the + sarif-file output (feed it to github/codeql-action/upload-sarif). required: false default: "github" severity: @@ -33,6 +36,20 @@ inputs: description: "The .NET SDK version for the Roslyn extractor." required: false default: "8.0.x" + sarif-file: + description: >- + Where to write the SARIF log when format: sarif (default: + $RUNNER_TEMP/own-net.sarif). The chosen path is echoed back as the + sarif-file output regardless. + required: false + default: "" + +outputs: + sarif-file: + description: >- + Path to the SARIF 2.1.0 log — set only when format: sarif. Hand it to + github/codeql-action/upload-sarif to publish to code scanning. + value: ${{ steps.own.outputs.sarif-file }} runs: using: "composite" @@ -48,6 +65,7 @@ runs: dotnet-version: ${{ inputs.dotnet-version }} - name: Own.NET leak check + id: own shell: bash env: # Pass user-controlled inputs through the environment (data), not by @@ -58,9 +76,38 @@ runs: OWN_FORMAT: ${{ inputs.format }} OWN_SEVERITY: ${{ inputs.severity }} OWN_FAIL_ON_FINDING: ${{ inputs.fail-on-finding }} + OWN_SARIF_FILE: ${{ inputs.sarif-file }} run: | + check="${{ github.action_path }}/scripts/own-check.sh" + if [ "$OWN_FORMAT" = "sarif" ]; then + # The code-scanning surface: write a SARIF 2.1.0 log to a file and expose + # its path as an output, so the caller hands it to + # github/codeql-action/upload-sarif. Code scanning renders inline PR + # annotations *and* the Security tab — it subsumes the `github` format, + # so this stays a single own-check run, not two. --fail-on-finding is + # passed so the true exit code (0 clean / 1 findings / >=2 hard error) is + # captured *after* the file is written; whether a finding fails the step + # is the action's own fail-on-finding (default: let code scanning gate). + sarif="${OWN_SARIF_FILE:-$RUNNER_TEMP/own-net.sarif}" + set +e + "$check" --root "${{ github.action_path }}" --format sarif \ + --severity "$OWN_SEVERITY" --fail-on-finding -- "$OWN_PATH" > "$sarif" + rc=$? + set -e + echo "sarif-file=$sarif" >> "$GITHUB_OUTPUT" + echo "Own.NET wrote SARIF to $sarif ($(wc -c < "$sarif") bytes; own-check rc=$rc)" + if [ "$rc" -ge 2 ]; then + echo "::error::Own.NET hard error (bad facts / drifted contract)" + cat "$sarif" >&2 || true + exit "$rc" + fi + if [ "$OWN_FAIL_ON_FINDING" = "true" ] && [ "$rc" -eq 1 ]; then + exit 1 + fi + exit 0 + fi args=(--root "${{ github.action_path }}" --format "$OWN_FORMAT" --severity "$OWN_SEVERITY") if [ "$OWN_FAIL_ON_FINDING" = "true" ]; then args+=(--fail-on-finding) fi - "${{ github.action_path }}/scripts/own-check.sh" "${args[@]}" -- "$OWN_PATH" + "$check" "${args[@]}" -- "$OWN_PATH" diff --git a/docs/notes/sarif-export.md b/docs/notes/sarif-export.md index 36f7ac76..888c9ca2 100644 --- a/docs/notes/sarif-export.md +++ b/docs/notes/sarif-export.md @@ -71,11 +71,53 @@ The two internal follow-ups are done (a second slice): the **live** eval paths, not just in capability. (Filenames kept to keep the CI edit minimal; the content, not the extension, is what the consumers read.) +## Shipped — code-scanning upload (the consumer surface) + +The composite action now has a **fourth surface, `format: sarif`**: it writes a +SARIF 2.1.0 log to a file and exposes the path as the **`sarif-file` output**, so a +consumer hands it straight to code scanning. Code scanning *subsumes* the `github` +PR-annotation format — it renders inline PR annotations **and** the Security tab — +so this stays a single own-check run, not two. A consumer wires it in three steps: + +```yaml +permissions: + contents: read + security-events: write # to upload to code scanning +steps: + - uses: actions/checkout@v4 + - uses: PhysShell/Own.NET@main # pin to a tag or SHA in production + id: ownnet + with: + path: src + format: sarif + fail-on-finding: "false" # let code scanning be the gate, not the step + - uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: ${{ steps.ownnet.outputs.sarif-file }} + category: own-net +``` + +### Why it is dog-fooded here (the deferral reversed) + +The earlier draft deferred this — *"this repo's C# is test fixtures, so uploading +to its own Security tab is low-value."* That weighed the *findings* (intentional +fixture leaks — yes, low news value). It mis-weighed the *integration*: the repo is +**public**, so code scanning is free, and a live upload is the one thing a local +schema check cannot give — **proof that GitHub itself accepts the SARIF** +(`upload-sarif` waits for processing and fails the job if the log is rejected). So +CI validates the exporter on two levels: + +- **structure** (`own-check-surface`): a `jq` contract over a freshly-emitted log — + `version 2.1.0`, the `Own.NET` driver, and *every* result carrying a catalogue + ruleId and a located file (the shape GitHub's ingest enforces). No upload, no + permissions — runs everywhere. +- **end-to-end** (`own-check-codescan`): the composite action `format: sarif` over + the sample tree, then `upload-sarif` under a *scoped* `security-events: write` + (the only non-`contents:read` job in CI). The sample alerts ride a dedicated + `own-net-samples` category and are real-if-intentional — they double as the live + demo of the Security-tab surface this exporter was built for. + Still open: -- **GitHub code-scanning upload.** Deferred deliberately: this repo's C# is test - fixtures, so uploading own-check SARIF to *its own* Security tab is low-value. - Code scanning belongs in the consumer-facing distribution surface (P-013), run - on a real target — not here. - **Per-rule `helpUri`.** Once a stable per-code docs anchor exists, point each `rules[]` entry at it (intentionally omitted now rather than link a 404). From 3a49acbd4ebbb0d03185260e54ba0d017be8ed12 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 03:07:43 +0000 Subject: [PATCH 2/2] fix(action,ci): guard empty-SARIF write + skip codescan on fork PRs (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings: - Codex (action.yml): in the sarif branch, exit 1 was treated as "findings, gated off -> exit 0", but a redirection/setup failure (e.g. a custom sarif-file whose parent dir is absent fails the `> "$sarif"` redirection before own-check.sh runs) also exits 1 — so the action could "succeed" with no SARIF. Add a non-empty-file guard below the hard-error tier: a missing or empty log with rc<2 is surfaced as an error, never gated as a finding. (Also make the byte-count echo tolerate an absent file under set -e.) Verified the five paths locally: clean/findings(gate off|on)/hard-error/missing-parent-dir. - CodeRabbit (ci.yml): own-check-codescan requests security-events:write but ran on fork PRs too, where GitHub downgrades the token to read-only -> upload-sarif fails -> persistent red CI for external contributors. Guard the job to same-repo events (push or non-fork PR) with the canonical `head.repo.full_name == github.repository` if-condition. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED --- .github/workflows/ci.yml | 5 +++++ action.yml | 11 ++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba747dc3..82f061b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -513,6 +513,11 @@ jobs: own-check-codescan: name: own-check SARIF -> GitHub code scanning (dog-food) runs-on: ubuntu-latest + # Skip on fork PRs: GitHub downgrades GITHUB_TOKEN to read-only for a + # pull_request from a fork, so security-events:write is never granted and the + # upload would fail — red CI for an external contributor through no fault of + # their own. Same-repo pushes and PRs (where the token keeps write) still run. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository # The one job that writes: scoped to security-events so it can upload to code # scanning. Every other job stays contents:read (the workflow-level default). permissions: diff --git a/action.yml b/action.yml index 2f03b711..23d1113e 100644 --- a/action.yml +++ b/action.yml @@ -95,12 +95,21 @@ runs: rc=$? set -e echo "sarif-file=$sarif" >> "$GITHUB_OUTPUT" - echo "Own.NET wrote SARIF to $sarif ($(wc -c < "$sarif") bytes; own-check rc=$rc)" + echo "Own.NET wrote SARIF to $sarif ($(wc -c < "$sarif" 2>/dev/null || echo 0) bytes; own-check rc=$rc)" if [ "$rc" -ge 2 ]; then echo "::error::Own.NET hard error (bad facts / drifted contract)" cat "$sarif" >&2 || true exit "$rc" fi + # Below the hard-error tier the log must exist and be non-empty. A + # missing/empty file with rc<2 means the *write* failed, not that there + # were findings — e.g. a custom sarif-file whose parent dir is absent + # fails the redirection (exit 1, outside own-check's 0/1/>=2 contract). + # Surface it; never let it pass as a gated-off finding with no SARIF. + if [ ! -s "$sarif" ]; then + echo "::error::Own.NET produced no SARIF at $sarif (own-check rc=$rc) — the run or its redirection failed" + exit 1 + fi if [ "$OWN_FAIL_ON_FINDING" = "true" ] && [ "$rc" -eq 1 ]; then exit 1 fi