diff --git a/.github/workflows/binskim.yml b/.github/workflows/binskim.yml index e099ac0..850acbf 100644 --- a/.github/workflows/binskim.yml +++ b/.github/workflows/binskim.yml @@ -46,19 +46,24 @@ jobs: $bs = (Get-ChildItem "$env:RUNNER_TEMP\bs\Microsoft.CodeAnalysis.BinSkim\tools\net*\win-x64\BinSkim.exe" | Select-Object -First 1).FullName & $bs analyze publish-out/OmniVec.Worker.dll --output binskim-dotnet.sarif --recurse false + - name: Apply documented upstream-toolchain suppressions + run: python scripts/security/filter_binskim_sarif.py binskim-dotnet.sarif binskim-dotnet-final.sarif + - name: Upload BinSkim SARIF if: always() continue-on-error: true uses: github/codeql-action/upload-sarif@v3 with: - sarif_file: binskim-dotnet.sarif + sarif_file: binskim-dotnet-final.sarif category: binskim-dotnet - uses: actions/upload-artifact@v4 if: always() with: name: binskim-dotnet-sarif - path: binskim-dotnet.sarif + path: | + binskim-dotnet.sarif + binskim-dotnet-final.sarif binskim-go: name: BinSkim Go CLI @@ -84,19 +89,24 @@ jobs: $bs = (Get-ChildItem "$env:RUNNER_TEMP\bs\Microsoft.CodeAnalysis.BinSkim\tools\net*\win-x64\BinSkim.exe" | Select-Object -First 1).FullName & $bs analyze cli/omnivec.exe --output binskim-go.sarif --ignorePdbLoadError + - name: Apply documented upstream-toolchain suppressions + run: python scripts/security/filter_binskim_sarif.py binskim-go.sarif binskim-go-final.sarif + - name: Upload BinSkim SARIF if: always() continue-on-error: true uses: github/codeql-action/upload-sarif@v3 with: - sarif_file: binskim-go.sarif + sarif_file: binskim-go-final.sarif category: binskim-go - uses: actions/upload-artifact@v4 if: always() with: name: binskim-go-sarif - path: binskim-go.sarif + path: | + binskim-go.sarif + binskim-go-final.sarif binskim-rust: name: BinSkim Rust router (docgrok-router) diff --git a/scripts/security/binskim_suppressions.json b/scripts/security/binskim_suppressions.json index baf3cf1..c58dd1a 100644 --- a/scripts/security/binskim_suppressions.json +++ b/scripts/security/binskim_suppressions.json @@ -11,5 +11,12 @@ "binary": "docgrok-router.exe", "reason": "Spectre mitigations (/Qspectre) are an MSVC cl.exe codegen feature. rustc / LLVM does not currently emit the IDD_VC_FEATURE Spectre marker into the PE COFF debug record, so BinSkim's BA2024 detector cannot observe the mitigation. The C runtime modules linked from libcmt.lib/libucrt.lib would also require the optional 'C++ Spectre-mitigated libs' VS workload to be installed in the build environment. Threat surface is bounded: docgrok-router does not execute user-supplied indices on shared cores in a multi-tenant manner. Re-evaluate when rust-lang/rust adds Spectre marker emission (rust-lang/rust#103956)." } + ], + "invocation_notifications": [ + { + "descriptorId": "ERR997.ExceptionLoadingPdb", + "binary": "omnivec.exe", + "reason": "Go binaries built with `go build -trimpath -ldflags '-s -w'` do not emit Microsoft PDB files. BinSkim's PE/COFF analyzers cannot evaluate Go binaries — this is the documented limitation, not a tool failure. Go's security posture is enforced by the Go toolchain (govulncheck, the runtime's bounds-checking and W^X-by-default semantics) rather than the MSVC linker switches BinSkim inspects. Accepting this notification keeps the BinSkim configuration upload clean while we continue to scan Go for vulnerabilities via separate workflows." + } ] } diff --git a/scripts/security/filter_binskim_sarif.py b/scripts/security/filter_binskim_sarif.py index 0e87e72..dcc80ed 100644 --- a/scripts/security/filter_binskim_sarif.py +++ b/scripts/security/filter_binskim_sarif.py @@ -16,10 +16,10 @@ from pathlib import Path -def load_suppressions(path: Path) -> list[dict]: +def load_suppressions(path: Path) -> tuple[list[dict], list[dict]]: with path.open("r", encoding="utf-8") as fh: data = json.load(fh) - return data.get("suppressions", []) + return data.get("suppressions", []), data.get("invocation_notifications", []) def matches(result: dict, supp: dict) -> bool: @@ -89,6 +89,61 @@ def _ensure_message_text(sarif: dict) -> int: return patched +def _notification_matches(notif: dict, supp: dict) -> bool: + """Match a SARIF toolConfigurationNotification against a suppression entry.""" + desc_id = (notif.get("descriptor") or {}).get("id") or notif.get("ruleId") + if desc_id != supp.get("descriptorId"): + return False + binary = supp.get("binary", "") + if not binary: + return True + for loc in notif.get("locations", []) or []: + uri = ( + loc.get("physicalLocation", {}) + .get("artifactLocation", {}) + .get("uri", "") + ) + if uri.lower().endswith(binary.lower()): + return True + return False + + +def _normalize_invocations(sarif: dict, notif_suppressions: list[dict]) -> int: + """Demote accepted invocation notifications to ``note`` (with audit-trail + suppression annotation) and flip ``executionSuccessful`` back to True when + no error-level notifications remain. Returns the number of notifications + that were demoted.""" + demoted = 0 + for run in sarif.get("runs", []) or []: + for inv in run.get("invocations", []) or []: + for notif in inv.get("toolConfigurationNotifications", []) or []: + if notif.get("level") not in ("error", "warning"): + continue + matched = next( + (s for s in notif_suppressions if _notification_matches(notif, s)), + None, + ) + if not matched: + continue + notif["level"] = "note" + notif.setdefault("suppressions", []).append( + { + "kind": "external", + "status": "accepted", + "justification": matched.get("reason", ""), + } + ) + demoted += 1 + remaining_errors = sum( + 1 + for n in inv.get("toolConfigurationNotifications", []) or [] + if n.get("level") in ("error", "warning") + ) + if remaining_errors == 0: + inv["executionSuccessful"] = True + return demoted + + def main(argv: list[str]) -> int: if len(argv) < 3: print(__doc__, file=sys.stderr) @@ -101,7 +156,7 @@ def main(argv: list[str]) -> int: else Path(__file__).with_name("binskim_suppressions.json") ) - suppressions = load_suppressions(supp_path) + suppressions, notif_suppressions = load_suppressions(supp_path) with inp.open("r", encoding="utf-8") as fh: sarif = json.load(fh) @@ -135,6 +190,7 @@ def main(argv: list[str]) -> int: outp.parent.mkdir(parents=True, exist_ok=True) patched = _ensure_message_text(sarif) + notif_demoted = _normalize_invocations(sarif, notif_suppressions) with outp.open("w", encoding="utf-8") as fh: json.dump(sarif, fh, indent=2) @@ -146,7 +202,8 @@ def main(argv: list[str]) -> int: ) print( f"BinSkim filter: kept={kept} suppressed={dropped} " - f"unsuppressed_fails_or_warnings={fails} message_text_patched={patched} -> {outp}" + f"unsuppressed_fails_or_warnings={fails} message_text_patched={patched} " + f"notifications_demoted={notif_demoted} -> {outp}" ) return 0 if fails == 0 else 1