diff --git a/.github/workflows/qodana.yml b/.github/workflows/qodana.yml index 6c7f148..40659c6 100644 --- a/.github/workflows/qodana.yml +++ b/.github/workflows/qodana.yml @@ -60,7 +60,7 @@ jobs: if: github.event_name != 'pull_request' uses: github/codeql-action/upload-sarif@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3 with: - sarif_file: artifacts/ci/qodana/qodana.sarif.json + sarif_file: artifacts/ci/qodana/qodana.upload.sarif.json - name: Upload Artifact if: always() diff --git a/tools/ci/bin/run.sh b/tools/ci/bin/run.sh index 4b5d57b..63bc448 100755 --- a/tools/ci/bin/run.sh +++ b/tools/ci/bin/run.sh @@ -451,7 +451,8 @@ run_pr_labeling() { run_qodana_contract() { build_validators local sarif_path="${OUT_DIR}/qodana.sarif.json" - if ! ci_run_capture "Qodana contract validator" dotnet "${ROOT_DIR}/tools/ci/checks/QodanaContractValidator/bin/Release/net10.0/QodanaContractValidator.dll" --sarif "$sarif_path"; then + local filtered_sarif_path="${OUT_DIR}/qodana.upload.sarif.json" + if ! ci_run_capture "Qodana contract validator" dotnet "${ROOT_DIR}/tools/ci/checks/QodanaContractValidator/bin/Release/net10.0/QodanaContractValidator.dll" --sarif "$sarif_path" --filtered-sarif-out "$filtered_sarif_path"; then if log_contains_code "CI-QODANA-001"; then ci_result_add_violation "CI-QODANA-001" "fail" "QODANA_TOKEN missing" "$CI_RAW_LOG" elif log_contains_code "CI-QODANA-002"; then @@ -467,6 +468,10 @@ run_qodana_contract() { fi return 1 fi + if [[ ! -f "${filtered_sarif_path}" ]]; then + ci_result_add_violation "CI-QODANA-003" "fail" "Filtered SARIF output missing" "$CI_RAW_LOG" "${filtered_sarif_path}" + return 1 + fi ci_result_append_summary "Qodana contract validation completed." } diff --git a/tools/ci/check-code-scanning-tools-zero.sh b/tools/ci/check-code-scanning-tools-zero.sh index ff635fd..032ddb7 100755 --- a/tools/ci/check-code-scanning-tools-zero.sh +++ b/tools/ci/check-code-scanning-tools-zero.sh @@ -62,19 +62,39 @@ fi # Gate strategy: # - Default: enforce 0 open alerts on main (prevents unrelated merges while main is red). -# - Exception: PRs labeled "area:qodana" are allowed to validate against the PR ref, so the cleanup PR itself can merge. +# - Exception: Qodana-cleanup PRs validate against the PR ref, so the cleanup PR itself can merge. +# Detection is fail-safe and deterministic: +# 1) explicit label "area:qodana", OR +# 2) changed files include qodana paths (independent of label-job timing). EVENT_NAME="${GITHUB_EVENT_NAME:-}" QUERY_REF="" if [[ "${EVENT_NAME}" == "pull_request" && -n "${GITHUB_EVENT_PATH:-}" && -f "${GITHUB_EVENT_PATH:-}" ]]; then + pr_number="$(jq -r '.pull_request.number // empty' "${GITHUB_EVENT_PATH}")" + has_qodana_label="false" if jq -r '.pull_request.labels[].name // empty' "${GITHUB_EVENT_PATH}" | grep -Fxq -- "area:qodana"; then + has_qodana_label="true" + fi + + has_qodana_changes="false" + if [[ -n "${pr_number}" ]]; then + files_json="${OUT_DIR}/pr-files.json" + if gh api "repos/${REPO}/pulls/${pr_number}/files?per_page=100" --paginate > "${files_json}" 2>> "${RAW_LOG}"; then + if jq -r '.[].filename' "${files_json}" | grep -Eiq '^(\.qodana/|qodana\.ya?ml$|\.github/workflows/qodana\.yml$)'; then + has_qodana_changes="true" + fi + else + log "WARN: PR files konnten nicht geladen werden; fallback auf Label-basierte Erkennung." + fi + fi + + if [[ "${has_qodana_label}" == "true" || "${has_qodana_changes}" == "true" ]]; then QUERY_REF="${GITHUB_REF:-}" if [[ -z "${QUERY_REF}" ]]; then - pr_number="$(jq -r '.pull_request.number // empty' "${GITHUB_EVENT_PATH}")" if [[ -n "${pr_number}" ]]; then QUERY_REF="refs/pull/${pr_number}/merge" fi fi - log "INFO: PR hat Label area:qodana -> pruefe Code-Scanning Alerts fuer ref=${QUERY_REF:-}" + log "INFO: Qodana-PR erkannt (label=${has_qodana_label}, files=${has_qodana_changes}) -> pruefe Code-Scanning Alerts fuer ref=${QUERY_REF:-}" else QUERY_REF="refs/heads/main" log "INFO: PR ohne Label area:qodana -> pruefe Code-Scanning Alerts fuer ref=${QUERY_REF}" diff --git a/tools/ci/checks/QodanaContractValidator/Program.cs b/tools/ci/checks/QodanaContractValidator/Program.cs index 05d2bf4..c88f5c8 100644 --- a/tools/ci/checks/QodanaContractValidator/Program.cs +++ b/tools/ci/checks/QodanaContractValidator/Program.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Nodes; var nonBlockingHighRuleIds = new HashSet(StringComparer.OrdinalIgnoreCase) { @@ -13,12 +14,17 @@ var argsList = args.ToList(); string? sarifPath = null; +string? filteredSarifOutPath = null; for (var i = 0; i < argsList.Count; i++) { if (argsList[i] == "--sarif" && i + 1 < argsList.Count) { sarifPath = argsList[++i]; } + else if (argsList[i] == "--filtered-sarif-out" && i + 1 < argsList.Count) + { + filteredSarifOutPath = argsList[++i]; + } } if (string.IsNullOrWhiteSpace(sarifPath)) @@ -42,7 +48,8 @@ try { - using var doc = JsonDocument.Parse(File.ReadAllText(sarifPath)); + var sarifText = File.ReadAllText(sarifPath); + using var doc = JsonDocument.Parse(sarifText); if (!doc.RootElement.TryGetProperty("runs", out var runs) || runs.ValueKind != JsonValueKind.Array) { Console.Error.WriteLine("CI-QODANA-003: SARIF missing runs[] array"); @@ -106,6 +113,15 @@ Console.Error.WriteLine($"QODANA_FINDING|severity={finding.Severity}|rule_id={finding.RuleId}|location={finding.Location}|message={finding.Message}"); } + if (!string.IsNullOrWhiteSpace(filteredSarifOutPath)) + { + if (!TryWriteFilteredSarif(sarifText, filteredSarifOutPath!, nonBlockingHighRuleIds)) + { + Console.Error.WriteLine("CI-QODANA-003: unable to write filtered SARIF output"); + return 1; + } + } + if (blockingFindings.Count > 0) { Console.Error.WriteLine($"CI-QODANA-004: blocking findings detected at severity High+ ({blockingFindings.Count})"); @@ -229,4 +245,116 @@ static string ExtractSeverity(JsonElement result) return value.GetString(); } +static bool TryWriteFilteredSarif(string sarifText, string outputPath, ISet nonBlockingHighRuleIds) +{ + JsonNode? rootNode; + try + { + rootNode = JsonNode.Parse(sarifText); + } + catch + { + return false; + } + + if (rootNode is not JsonObject rootObject) + { + return false; + } + + if (rootObject["runs"] is not JsonArray runsArray) + { + return false; + } + + foreach (var runNode in runsArray) + { + if (runNode is not JsonObject runObject) + { + continue; + } + + if (runObject["results"] is not JsonArray resultsArray) + { + continue; + } + + var filteredResults = new JsonArray(); + foreach (var resultNode in resultsArray) + { + if (resultNode is not JsonObject resultObject) + { + continue; + } + + var severity = ExtractSeverityFromNode(resultObject); + var ruleId = TryGetNodeString(resultObject["ruleId"]) ?? "UNKNOWN"; + + var isBlocking = SeverityRank(severity) >= SeverityRank("High") && + !nonBlockingHighRuleIds.Contains(ruleId); + if (isBlocking) + { + filteredResults.Add(resultObject.DeepClone()); + } + } + + runObject["results"] = filteredResults; + } + + var outputDirectory = Path.GetDirectoryName(outputPath); + if (!string.IsNullOrWhiteSpace(outputDirectory)) + { + Directory.CreateDirectory(outputDirectory); + } + + File.WriteAllText(outputPath, rootObject.ToJsonString(new JsonSerializerOptions + { + WriteIndented = false + })); + Console.WriteLine($"QODANA_FILTERED_SARIF|path={outputPath}"); + return true; +} + +static string ExtractSeverityFromNode(JsonObject resultObject) +{ + if (resultObject["properties"] is JsonObject propertiesObject) + { + var qodanaSeverity = TryGetNodeString(propertiesObject["qodanaSeverity"]); + if (!string.IsNullOrWhiteSpace(qodanaSeverity)) + { + return qodanaSeverity!; + } + } + + var level = TryGetNodeString(resultObject["level"]); + return level?.ToLowerInvariant() switch + { + "error" => "High", + "warning" => "Moderate", + "note" => "Low", + _ => "Unknown" + }; +} + +static string? TryGetNodeString(JsonNode? node) +{ + if (node is null) + { + return null; + } + + try + { + return node.GetValue(); + } + catch (InvalidOperationException) + { + return null; + } + catch (FormatException) + { + return null; + } +} + internal sealed record Finding(string Severity, string RuleId, string Message, string Location); diff --git a/tools/versioning/compute-pr-labels.js b/tools/versioning/compute-pr-labels.js index 243ded0..ac81ea6 100644 --- a/tools/versioning/compute-pr-labels.js +++ b/tools/versioning/compute-pr-labels.js @@ -20,6 +20,7 @@ const AREA_RULES = [ { prefix: '.github/workflows/', label: 'area:pipeline' }, { prefix: '.qodana/', label: 'area:qodana' }, { exact: 'qodana.yaml', label: 'area:qodana' }, + { exact: '.github/workflows/qodana.yml', label: 'area:qodana' }, { prefix: 'src/FileTypeDetection/Infrastructure/Archive', label: 'area:archive' }, { prefix: 'src/FileTypeDetection/ArchiveProcessing', label: 'area:archive' }, { exact: 'src/FileTypeDetection/EvidenceHashing.vb', label: 'area:hashing' },