Skip to content

Report a scan's CI blocking-rule verdict from corgea list - #155

Closed
Ibrahimrahhal wants to merge 2 commits into
mainfrom
cursor/list-block-on-verdict-82d1
Closed

Report a scan's CI blocking-rule verdict from corgea list#155
Ibrahimrahhal wants to merge 2 commits into
mainfrom
cursor/list-block-on-verdict-82d1

Conversation

@Ibrahimrahhal

@Ibrahimrahhal Ibrahimrahhal commented Aug 13, 2026

Copy link
Copy Markdown
Member

What this adds

A scan does not have one verdict — it has a verdict per rule set, so the answer has to be scoped to the slugs the caller gates on, exactly as --block-on is. corgea list --block-on <slugs> asks that question about a scan that already ran, through the same endpoint corgea scan --block-on uses:

corgea ls --sha "$(git rev-parse HEAD)" --block-on criticals,malicious-deps --json
"blocking_verdict": {
  "block_on": ["criticals", "malicious-deps"],
  "status": "complete",
  "block": true,
  "blocked_issues": 3,
  "triggered_rules": ["criticals"]
}

No backend change. GET /api/v1/scan/<id>/check_blocking_rules?block_on=<slugs> already accepts any scan id and any slugs, and /api/v1/scans already accepts a sha filter — the CLI just stopped sending it when --skip-if-scanned was cut from COR-1639.

Not answering is a distinct answer

status is what says whether block can be trusted, and only complete is final:

status Meaning block
complete Final verdict true/false
pending Server still resolving the scan's license dependencies; retry false, not yet final
unavailable No verdict — scan never completed, or past the per-page cap; see reason null

The failure modes are deliberately loud, because every one of them is a chance to silently pass a pipeline that should have failed:

  • block is null, not false, whenever there is no verdict.
  • A scan that has not finished is never evaluated at all — blocking rules see only the findings recorded so far, so a "pass" there would be a pass on findings still arriving.
  • An evaluation error exits 1, including an unknown, inactive, or pull-request-scoped slug (the endpoint's 400).
  • Unlike corgea scan --block-on, the listing does not poll: it reports pending for the caller to retry rather than blocking for up to 15 minutes per scan.

--sha

Narrows the listing to one commit, so the duplicate-skip lookup is a single request instead of paging. It requires the full SHA — the server matches exactly, and a prefix would answer "no scans", which that path reads as "never scanned". Returned scans are re-checked against the SHA client-side so a backend that ignores the filter cannot attribute another commit's verdict to this one. An empty page under --sha now reports an unscanned commit instead of the "No Corgea project found" miss a full listing reports.

Cost

A verdict costs one request per scan, and the server re-evaluates every finding in that scan (up to 5000 issues plus a dependency walk for license rules). So the pass covers at most the first 10 scans of a page, --block-on shrinks the default page to 10, and anything beyond the cap reports unavailable with a reason pointing at --sha/--page-size.

Non-JSON output gets a Blocking column:

Scan ID        Project   Status     Repo          Branch   SHA        Blocking
scan-blocked   demo      complete   corgea/demo   main     01234567   BLOCKED: criticals
scan-clean     demo      complete   corgea/demo   main     aaaaaaaa   pass
scan-running   demo      scanning   corgea/demo   main     bbbbbbbb   N/A

Testing

  • 11 unit tests: verdict shape and status mapping, block null on unavailable, SHA normalization, the client-side SHA re-check, the Blocking column.
  • e2e: verdicts attached per scan with an unfinished scan never evaluated; --sha sent and re-checked; a short SHA rejected before dialing the API; the flags rejected on issue listings; an evaluation error exits 1; an empty --sha page is an empty result rather than a project miss.
  • ./harness ci passes in full locally (strict clippy, format, dep audit, 697 tests, coverage gate).

Still open

The second half of his thread — --out-format/--out-file being dropped when --block-on trips, because report generation runs after the gate's exit(1) — is a separate fix and is not in this PR.

Open in Web Open in Cursor 

cursoragent and others added 2 commits August 13, 2026 09:37
A pipeline that skips a duplicate scan for a commit it has already
scanned has no verdict to gate on: --block-on evaluates the CI rules
against the scan it is running, so the skip path was left re-deriving a
pass/fail from the previous scan's vulnerability counts, which is not the
question the rules answer.

corgea list --block-on <slugs> asks that question about a scan that
already ran, through the same endpoint corgea scan --block-on gates on.
Every listed scan gains a blocking_verdict: the rules asked for, whether
they blocked, how many issues did, and which rules tripped.

A verdict is only as good as its status, so only 'complete' is final. A
server still resolving license dependencies reports 'pending', and a scan
that never completed reports 'unavailable' with a reason -- both with
block null rather than a field a consumer would read as a pass. Scans
that have not finished are never evaluated, since blocking rules see only
the findings recorded so far. An evaluation error exits 1 for the same
reason: an unknown, inactive, or PR-scoped slug must not read as a pass.

--sha narrows the listing to one commit, which is how a duplicate-skip
path reaches its scan in a single request. It takes the full SHA because
the server matches exactly and a prefix would answer 'no scans', which
that path reads as 'never scanned'; the returned scans are re-checked
against it so a backend that ignores the filter cannot attribute another
commit's verdict to this one. An empty page under --sha now reports an
unscanned commit rather than the project miss a full listing would.

A verdict costs one request per scan and the server re-evaluates every
finding in that scan, so the pass covers at most ten scans and
--block-on shrinks the default page to what it evaluates.

Co-authored-by: ibrahim <ibrahim@corgea.com>
Both are new backward-compatible flags, so SemVer puts this at a minor
bump. Cargo.toml is the single source of truth: PyPI reads it via maturin
and npm takes its version from the release tag.

Co-authored-by: ibrahim <ibrahim@corgea.com>
@Ibrahimrahhal
Ibrahimrahhal marked this pull request as ready for review August 13, 2026 09:38
Comment thread src/list.rs
} else {
VERDICT_STATUS_PENDING
},
"block": response.block,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pending currently serializes the backend's block value (the existing response contract uses false while dependency resolution is pending), even though this PR's README says pending and unavailable both leave block null. That defeats the advertised fail-closed shape for any consumer that reads block without checking status: an unfinished evaluation looks like a pass. Serialize a boolean only for complete responses and add an e2e assertion for a pending blocking-rules response.

Suggested change
"block": response.block,
"block": response.is_complete().then_some(response.block),

Comment thread src/list.rs
},
"block": response.block,
"blocked_issues": response.blocked_count(),
"triggered_rules": triggered_slugs(&response.blocking_issues),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This reports only the rules found on page 1. The call above passes page: None (page 1), BlockingRuleResponse exposes total_pages, and triggered_slugs only examines this response's paginated blocking_issues. If (for example) page 1 contains only criticals violations and page 2 contains a malicious-deps violation, block and blocked_issues describe the whole result while triggered_rules incorrectly omits malicious-deps. Since the new JSON contract promises which rules triggered, fetch pages 2 through total_pages and union their slugs (or obtain an aggregate from the API), with a multi-page test.

Comment thread src/list.rs
/// "never scanned".
pub fn normalize_sha(raw: &str) -> Result<String, String> {
let sha = raw.trim();
if !(40..=64).contains(&sha.len()) || !sha.chars().all(|c| c.is_ascii_hexdigit()) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This accepts every hex length from 40 through 64, but Git's full object IDs are exactly 40 characters (SHA-1) or 64 characters (SHA-256). A 41–63 character hex typo is therefore sent to the server's exact-match filter and returns an empty result—the same silent miss this validator is intended to prevent. Restrict the accepted lengths and cover an in-between length in the rejection test.

Suggested change
if !(40..=64).contains(&sha.len()) || !sha.chars().all(|c| c.is_ascii_hexdigit()) {
if !matches!(sha.len(), 40 | 64) || !sha.chars().all(|c| c.is_ascii_hexdigit()) {

Comment thread src/scanners/blast.rs
///
/// Falls back to rule ids against backends that do not send slugs yet.
pub fn triggered_slug_summary(issues: &[utils::api::BlockingIssue]) -> String {
pub fn triggered_slugs(issues: &[utils::api::BlockingIssue]) -> Vec<String> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Quality - The code uses "Vec::contains" inside nested loops to de-duplicate strings, causing O(n²) performance. This can slow down "triggered_slugs" when processing many issues or rules. View in Corgea ↗

More Details
🎟️Issue Explanation: The code uses "Vec::contains" inside nested loops to de-duplicate strings, causing O(n²) performance. This can slow down "triggered_slugs" when processing many issues or rules.

- The "triggered_slugs" function repeatedly calls "names.contains()" inside nested loops, leading to quadratic time complexity as "names" grows.
- Quadratic complexity makes "triggered_slugs" slower on large inputs, impacting scan speed when many blocking issues and rules exist.
- The inefficiency adds overhead when generating the list of unique triggered slugs, increasing runtime and reducing maintainability due to implicit intent.

🪄Fix Explanation: Replaces repeated linear searches in the result vector with a "HashSet" for efficient identifier deduplication. The change preserves insertion order while improving performance for large issue collections.
-"seen" provides average O(1) membership checks, replacing "names.contains", which scans the vector in O(n).
-"seen.insert(identifier.clone())" returns whether the identifier is new, simplifying the deduplication condition.
-Identifiers are still appended to "names" only once, preserving the existing output order and behavior.
-The separate set and ordered vector clearly express their distinct responsibilities: uniqueness tracking and result ordering.
diff --git a/src/scanners/blast.rs b/src/scanners/blast.rs
index 88ee833..2b7b2eb 100644
--- a/src/scanners/blast.rs
+++ b/src/scanners/blast.rs
@@ -648,13 +648,14 @@ pub fn normalize_block_on(block_on: Option<&str>) -> Result<Option<String>, Stri
 /// Falls back to rule ids against backends that do not send slugs yet.
 pub fn triggered_slugs(issues: &[utils::api::BlockingIssue]) -> Vec<String> {
     let mut names: Vec<String> = Vec::new();
+    let mut seen = std::collections::HashSet::new();
     for issue in issues {
         let identifiers = match &issue.triggered_by_slugs {
             Some(slugs) if !slugs.is_empty() => slugs.clone(),
             _ => issue.triggered_by_rules.clone(),
         };
         for identifier in identifiers {
-            if !names.contains(&identifier) {
+            if seen.insert(identifier.clone()) {
                 names.push(identifier);
             }
         }

To apply the fix, Download .patch.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants